From 0be50b6c24f324fe08b3178a5f4e4f1fc33d669a Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Mon, 25 May 2026 13:12:33 +0100 Subject: [PATCH] Add static site QA checks --- .forgejo/workflows/deploy.yml | 9 +- scripts/check-links.mjs | 136 ++++++++++++ scripts/check-no-js.mjs | 80 +++++++ scripts/check-overflow.mjs | 309 ++++++++++++++++++++++++++++ scripts/install-playwright-deps.mjs | 32 +++ 5 files changed, 564 insertions(+), 2 deletions(-) create mode 100644 scripts/check-links.mjs create mode 100644 scripts/check-no-js.mjs create mode 100644 scripts/check-overflow.mjs create mode 100644 scripts/install-playwright-deps.mjs diff --git a/.forgejo/workflows/deploy.yml b/.forgejo/workflows/deploy.yml index 92ca412..07a7185 100644 --- a/.forgejo/workflows/deploy.yml +++ b/.forgejo/workflows/deploy.yml @@ -35,8 +35,13 @@ jobs: exit 1 fi - - name: Build - run: npm run build + - name: Typecheck + run: npm run typecheck + + - name: Build & QA + run: | + npx playwright install chromium + npm run qa - name: Copy build to host pages mount if: github.event_name == 'push' && github.ref == 'refs/heads/main' diff --git a/scripts/check-links.mjs b/scripts/check-links.mjs new file mode 100644 index 0000000..b6f4301 --- /dev/null +++ b/scripts/check-links.mjs @@ -0,0 +1,136 @@ +import { readdir, readFile, stat } from 'node:fs/promises'; +import path from 'node:path'; + +const dist = path.resolve('dist'); +const allowedPreservedRoutes = new Set(['/fleeting/', '/reconcile/']); +const failures = []; + +async function walk(dir) { + const entries = await readdir(dir, { withFileTypes: true }); + const files = []; + + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + files.push(...(await walk(fullPath))); + } else { + files.push(fullPath); + } + } + + return files; +} + +async function exists(file) { + try { + return (await stat(file)).isFile(); + } catch { + return false; + } +} + +async function targetExists(pathname) { + if (allowedPreservedRoutes.has(pathname)) return true; + + const safePath = path + .normalize(decodeURIComponent(pathname)) + .replace(/^\/+/, '') + .replace(/^(\.\.(\/|\\|$))+/, ''); + const candidate = path.join(dist, safePath); + const candidates = [ + candidate, + path.join(candidate, 'index.html'), + path.join(dist, `${safePath}.html`), + ]; + + for (const file of candidates) { + if (await exists(file)) return true; + } + return false; +} + +try { + await stat(dist); +} catch { + throw new Error('dist/ does not exist. Run npm run build first.'); +} + +const files = await walk(dist); +const checkedFiles = files.filter((file) => /\.(html|xml|css|webmanifest)$/.test(file)); + +function pagePathname(file) { + const rel = path.relative(dist, file).replaceAll(path.sep, '/'); + if (rel === 'index.html') return '/'; + if (rel.endsWith('/index.html')) return `/${rel.slice(0, -'index.html'.length)}`; + return `/${rel}`; +} + +function collectUrlReferences(body, rel) { + const urls = []; + + for (const match of body.matchAll(/\b(?:href|src|poster)=["']([^"']+)["']/g)) { + urls.push(match[1]); + } + + for (const match of body.matchAll(/\bsrcset=["']([^"']+)["']/g)) { + for (const candidate of match[1].split(',')) { + const url = candidate.trim().split(/\s+/)[0]; + if (url) urls.push(url); + } + } + + if (rel.endsWith('.css')) { + for (const match of body.matchAll(/url\(\s*['"]?([^'")]+)['"]?\s*\)/g)) { + urls.push(match[1]); + } + } + + if (rel.endsWith('.webmanifest')) { + try { + const manifest = JSON.parse(body); + for (const key of ['start_url', 'scope']) { + if (typeof manifest[key] === 'string') urls.push(manifest[key]); + } + for (const icon of manifest.icons ?? []) { + if (typeof icon?.src === 'string') urls.push(icon.src); + } + for (const screenshot of manifest.screenshots ?? []) { + if (typeof screenshot?.src === 'string') urls.push(screenshot.src); + } + } catch { + failures.push(`${rel}: invalid web manifest JSON`); + } + } + + return urls; +} + +for (const file of checkedFiles) { + const body = await readFile(file, 'utf8'); + const rel = path.relative(dist, file); + const baseUrl = new URL(pagePathname(file), 'https://schmelczer.dev'); + + for (const raw of collectUrlReferences(body, rel)) { + if (/^(mailto:|tel:|data:)/i.test(raw)) continue; + + let parsed; + try { + parsed = new URL(raw, baseUrl); + } catch { + failures.push(`${rel}: invalid URL ${raw}`); + continue; + } + + if (parsed.origin !== 'https://schmelczer.dev') continue; + if (!(await targetExists(parsed.pathname))) { + failures.push(`${rel}: missing local target ${parsed.pathname}`); + } + } +} + +if (failures.length > 0) { + console.error(failures.join('\n')); + process.exit(1); +} + +console.log('No missing local href/src targets found in dist/.'); diff --git a/scripts/check-no-js.mjs b/scripts/check-no-js.mjs new file mode 100644 index 0000000..00cd759 --- /dev/null +++ b/scripts/check-no-js.mjs @@ -0,0 +1,80 @@ +import { readdir, readFile, stat } from 'node:fs/promises'; +import path from 'node:path'; + +const dist = path.resolve('dist'); +const failures = []; + +async function walk(dir) { + const entries = await readdir(dir, { withFileTypes: true }); + const files = []; + + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + files.push(...(await walk(fullPath))); + } else { + files.push(fullPath); + } + } + + return files; +} + +try { + await stat(dist); +} catch { + throw new Error('dist/ does not exist. Run npm run build first.'); +} + +const files = await walk(dist); +const jsFiles = files.filter((file) => file.endsWith('.js')); + +if (jsFiles.length > 0) { + failures.push( + `Unexpected JavaScript assets:\n${jsFiles.map((file) => `- ${file}`).join('\n')}` + ); +} + +// Script tags are only allowed if they declare one of these safe `type` +// attributes (or are tagged with `data-theme-script`). All other scripts — +// including untyped ones, which default to executable JavaScript — are +// flagged. +const SAFE_SCRIPT_TYPES = new Set([ + 'application/ld+json', + 'importmap', + 'speculationrules', +]); + +function isSafeScriptTag(tag) { + if (tag.includes('data-theme-script')) return true; + const typeMatch = tag.match(/\btype=["']([^"']+)["']/i); + if (!typeMatch) return false; + return SAFE_SCRIPT_TYPES.has(typeMatch[1].trim().toLowerCase()); +} + +for (const file of files.filter((candidate) => candidate.endsWith('.html'))) { + const html = await readFile(file, 'utf8'); + const scripts = (html.match(/]*>/gi) ?? []).filter( + (tag) => !isSafeScriptTag(tag) + ); + if (scripts.length) { + failures.push(`Unexpected script tag in ${file}:\n${scripts.join('\n')}`); + } + + // Inline event handlers (onclick=, onload=, etc.) execute JavaScript even + // without a