This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { readdir, readFile, stat } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
|
||||
@@ -24,6 +25,7 @@ const exists = async (path) => {
|
||||
|
||||
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',
|
||||
@@ -48,6 +50,10 @@ for (const file of htmlFiles) {
|
||||
if (duplicateIds.length > 0) errors.push(`${file}: duplicate IDs: ${[...new Set(duplicateIds)].join(', ')}`)
|
||||
if (!html.includes('<meta name="viewport"')) errors.push(`${file}: missing viewport metadata`)
|
||||
if (html.includes('<heading')) errors.push(`${file}: contains a nonstandard <heading> 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}`)
|
||||
@@ -56,10 +62,11 @@ for (const file of htmlFiles) {
|
||||
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, pathname, 'index.html') : join(generatedRoot, pathname)
|
||||
else target = pathname.endsWith('/') ? join(generatedRoot, relative, 'index.html') : join(generatedRoot, relative)
|
||||
if (!(await exists(target))) errors.push(`${file}: broken internal link ${href}`)
|
||||
}
|
||||
|
||||
@@ -71,6 +78,58 @@ for (const file of htmlFiles) {
|
||||
}
|
||||
}
|
||||
|
||||
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('<rss version="2.0"')) errors.push('feed.xml: missing RSS 2.0 channel')
|
||||
if (feed.includes('noindex')) errors.push('feed.xml: unexpected noindex marker')
|
||||
for (const [, loc] of feed.matchAll(/<guid isPermaLink="true">([^<]+)<\/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>([^<]+)<\/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
|
||||
|
||||
Reference in New Issue
Block a user