Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(node): add duplicated ids and bad anchor links detection #3151

Draft
wants to merge 4 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
1 change: 1 addition & 0 deletions package.json
Expand Up @@ -192,6 +192,7 @@
"sitemap": "^7.1.1",
"supports-color": "^9.4.0",
"typescript": "^5.2.2",
"ultrahtml": "^1.5.2",
"vitest": "^0.34.6",
"vue-tsc": "^1.8.19",
"wait-on": "^7.0.1"
Expand Down
7 changes: 7 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions src/node/build/build.ts
Expand Up @@ -13,6 +13,7 @@ import { task } from '../utils/task'
import { bundle } from './bundle'
import { generateSitemap } from './generateSitemap'
import { renderPage } from './render'
import { checkIdsAndAnchorHrefs } from './checkIdsAndAnchorHrefs'

export async function build(
root?: string,
Expand Down Expand Up @@ -137,6 +138,7 @@ export async function build(
if (!process.env.DEBUG) await rimraf(siteConfig.tempDir)
}

await checkIdsAndAnchorHrefs(siteConfig)
await generateSitemap(siteConfig)
await siteConfig.buildEnd?.(siteConfig)

Expand Down
111 changes: 111 additions & 0 deletions src/node/build/checkIdsAndAnchorHrefs.ts
@@ -0,0 +1,111 @@
import type { SiteConfig } from '../config'
import fg from 'fast-glob'
import { task } from '../utils/task'
import fs from 'fs-extra'
import { parse, walkSync, ELEMENT_NODE } from 'ultrahtml'
import { dirname, join, resolve } from 'path'

export async function checkIdsAndAnchorHrefs(siteConfig: SiteConfig) {
await task('checking for duplicate ids and bad anchor hrefs', async () => {
for await (const error of collectErrors(siteConfig)) {
// TODO: use picocolors here
console.error(error)
}
})
}

// TODO: export this function for testing purposes?
async function* collectErrors(siteConfig: SiteConfig) {
const cwd = siteConfig.outDir
const files = new Set(
siteConfig.pages.map((page) =>
`${siteConfig.rewrites.map[page] || page}`
.replace(/\\/g, '/')
.replace(/\.md$/, '.html')
)
)
// add public html files to the list: i.e. VP docs has public/pure.html
for await (const file of fg.stream('*.html', {
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Apply this to the public folder without deep.

cwd,
deep: 1
})) {
files.add(file.toString().replace(/\\/g, '/'))
}
const checkHtmlExt = siteConfig.site.cleanUrls === false
for await (const file of fg.stream('**/*.html', {
cwd
})) {
const links = new Set<string>()
const ids = new Set<string>()
const errors: string[] = []
// collect ids and href anchors
walkSync(
parse(await fs.promises.readFile(resolve(cwd, file.toString()), 'utf8')),
(node) => {
if (node.type === ELEMENT_NODE) {
const id = node.attributes.id
if (id) {
if (ids.has(id)) errors.push(`duplicate id "${id}"`)
else ids.add(id)
}
if (node.name.toLowerCase() === 'a') {
const href = node.attributes.href
if (
!href ||
href.startsWith('http://') ||
href.startsWith('https://')
)
return

links.add(href)
}
}
}
)
// check for local hrefs and external links
for (const href of links) {
// 1) check for local ids
if (href[0] === '#') {
if (!ids.has(href.slice(1)))
errors.push(`missing local id for "${href}"`)
continue
}
// 2) check for external links
// Remove parameters and hash
let localLink = href.split(/[#?]/).shift()
if (!localLink) continue

// Append .html
if (checkHtmlExt) {
if (localLink[localLink.length - 1] !== '/') {
localLink += 'index.html'
}
if (!localLink.endsWith('.html')) {
errors.push(`bad href link "${href}"`)
continue
}
} else {
if (localLink === '/') localLink = '/index.html'
if (!localLink.endsWith('.html')) localLink += '.html'
}
// Get absolute link
if (localLink[0] === '.') {
localLink =
'/' + join(dirname(file.toString()), localLink).replace(/\\/g, '/')
}
if (localLink[0] !== '/') {
errors.push(`bad href link "${href}"`)
continue
}
localLink = localLink.slice(1)
if (!localLink) localLink = 'index.html'

// Check if target html page exists
if (!files.has(localLink)) {
errors.push(`bad href link "${href}" (missing file)`)
}
}
if (errors.length)
yield `\n${file}\n${errors.map((e) => ` - ${e}`).join('\n')}`
}
}