import { execFileSync } from 'node:child_process' import { readdir, readFile, stat } from 'node:fs/promises' import { join } from 'node:path' const generatedRoot = '.generated' const walk = async (directory) => { const files = [] for (const entry of await readdir(directory, { withFileTypes: true })) { const path = join(directory, entry.name) if (entry.isDirectory()) files.push(...(await walk(path))) else files.push(path) } return files } const exists = async (path) => { try { await stat(path) return true } catch { return false } } const errors = [] const htmlFiles = (await walk(generatedRoot)).filter((file) => file.endsWith('.html')) const noindexFiles = new Set() const clientSource = await readFile('src/main.js', 'utf8') const builtInPrismLanguages = new Set([ 'markup', 'html', 'xml', 'svg', 'css', 'clike', 'javascript', 'js', 'plain', 'plaintext', 'text', 'txt', ]) const prismLanguageAliases = { sh: 'bash', shell: 'bash' } for (const file of htmlFiles) { const html = await readFile(file, 'utf8') const ids = [...html.matchAll(/\sid="([^"]+)"/g)].map((match) => match[1]) const duplicateIds = ids.filter((id, index) => ids.indexOf(id) !== index) if (duplicateIds.length > 0) errors.push(`${file}: duplicate IDs: ${[...new Set(duplicateIds)].join(', ')}`) if (!html.includes(' element`) if (html.includes('name="robots"') && html.includes('noindex')) { noindexFiles.add(file) errors.push(`${file}: draft page must not be in production output`) } for (const [, fragment] of html.matchAll(/href="#([^"]+)"/g)) { if (!ids.includes(fragment)) errors.push(`${file}: missing fragment target #${fragment}`) } for (const [, href] of html.matchAll(/href="(\/[^"]*)"/g)) { if (href === '/') continue const pathname = href.split(/[?#]/, 1)[0] const relative = pathname.replace(/^\//, '') let target if (pathname.startsWith('/src/')) target = `.${pathname}` else if (pathname.startsWith('/assets/') || pathname === '/favicon.ico') target = `public${pathname}` else target = pathname.endsWith('/') ? join(generatedRoot, relative, 'index.html') : join(generatedRoot, relative) if (!(await exists(target))) errors.push(`${file}: broken internal link ${href}`) } for (const [, language] of html.matchAll(/class="language-([\w-]+)"/g)) { if (builtInPrismLanguages.has(language)) continue const component = prismLanguageAliases[language] ?? language if (!clientSource.includes(`prism-${component}`)) errors.push(`${file}: Prism grammar is not imported for ${language}`) } } const siteUrl = 'https://paulw.xyz' const generatedFile = (pathname) => { const relative = pathname.replace(/^\//, '') return pathname.endsWith('/') ? join(generatedRoot, relative, 'index.html') : join(generatedRoot, relative) } for (const name of ['feed.xml', 'sitemap.xml']) { if (!(await exists(join(generatedRoot, name)))) errors.push(`missing ${name}`) } if (await exists(join(generatedRoot, 'feed.xml'))) { const feed = await readFile(join(generatedRoot, 'feed.xml'), 'utf8') if (!feed.includes('([^<]+)<\/guid>/g)) { const { pathname } = new URL(loc) if (!(await exists(generatedFile(pathname)))) errors.push(`feed.xml: missing page for ${loc}`) } } if (await exists(join(generatedRoot, 'sitemap.xml'))) { const sitemap = await readFile(join(generatedRoot, 'sitemap.xml'), 'utf8') if (!sitemap.includes('http://www.sitemaps.org/schemas/sitemap/0.9')) errors.push('sitemap.xml: missing urlset namespace') const locs = [...sitemap.matchAll(/([^<]+)<\/loc>/g)].map((match) => match[1]) for (const loc of locs) { const { origin, pathname } = new URL(loc) if (origin !== siteUrl) errors.push(`sitemap.xml: unexpected origin ${loc}`) if (!(await exists(generatedFile(pathname))) && pathname !== '/sitemap.xml') errors.push(`sitemap.xml: missing file for ${loc}`) } const locSet = new Set(locs) for (const file of htmlFiles) { if (file.endsWith('404.html') || noindexFiles.has(file)) continue const relative = file.slice(generatedRoot.length + 1).replaceAll('\\', '/') const pathname = relative === 'index.html' ? '/' : `/${relative.replace(/\/index\.html$/, '/')}` const loc = `${siteUrl}${pathname}` if (!locSet.has(loc)) errors.push(`sitemap.xml: missing ${loc}`) } } if (!(await exists('public/robots.txt'))) errors.push('missing public/robots.txt') try { const tracked = execFileSync('git', ['ls-files', '-z'], { encoding: 'utf8' }) for (const file of tracked.split('\0').filter(Boolean)) { if (/(?:^|\/)\.[^/]*\.md$/.test(file)) errors.push(`${file}: hidden markdown must not be tracked`) } } catch {} if (errors.length > 0) { console.error(errors.join('\n')) process.exitCode = 1 } else { console.log(`Checked ${htmlFiles.length} generated pages.`) }