kheops-pyramid / scripts /check-html.mjs
Vroum
Add interactive 3D model of the Great Pyramid of Khufu
682b01a
Raw History Blame Contribute Delete
11 kB
#!/usr/bin/env node
/**
* Static sanity check for the single-file app.
*
* node scripts/check-html.mjs [index.html] [--net]
*
* Verifies that the document is well formed, that the ES-module import map is
* present and valid, that the three.js + OrbitControls module scripts are there
* and that the inline module body actually parses as an ES module (delegated to
* `node --check`). Also cross-checks that every element id used by the script
* exists in the markup, and — with --net — that the CDN entry points resolve.
*/
import { readFileSync, writeFileSync, mkdtempSync, rmSync, existsSync } from 'node:fs';
import { execFileSync } from 'node:child_process';
import { tmpdir } from 'node:os';
import { join, resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const argv = process.argv.slice(2).filter((a) => !a.startsWith('--'));
const flags = process.argv.slice(2).filter((a) => a.startsWith('--'));
const file = resolve(root, argv[0] || 'index.html');
const results = [];
const ok = (name, detail = '') => results.push([true, name, detail]);
const bad = (name, detail = '') => results.push([false, name, detail]);
const warn = (name, detail = '') => results.push([null, name, detail]);
const assert = (cond, msg) => { if (!cond) throw new Error(msg); };
const html = readFileSync(file, 'utf8');
/* ---------------------------------------------------------------- doctype */
try {
assert(/^\s*<!doctype html>/i.test(html), 'missing <!DOCTYPE html>');
assert(/<html\b[^>]*lang="en"/i.test(html), '<html> has no lang attribute');
assert(/<meta[^>]+charset="utf-8"/i.test(html), 'missing <meta charset="utf-8">');
assert(/<title>[^<]{4,}<\/title>/i.test(html), 'missing <title>');
ok('doctype / <html lang> / charset / title');
} catch (e) { bad('document preamble', e.message); }
/* ------------------------------- split out <script> and <style> contents */
const scripts = [];
const withoutScripts = html
.replace(/<!--[\s\S]*?-->/g, '')
.replace(/<script\b([^>]*)>([\s\S]*?)<\/script>/gi, (_m, attrs, body) => {
const attr = (name) => { const r = new RegExp(`${name}="([^"]*)"`, 'i').exec(attrs); return r ? r[1] : null; };
scripts.push({ type: (attr('type') || '').toLowerCase(), src: attr('src'), body });
return '\n';
})
.replace(/<style\b[^>]*>[\s\S]*?<\/style>/gi, '\n');
/* ------------------------------------------------------------- tag balance */
try {
const VOID = new Set(['area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link', 'meta', 'param', 'source', 'track', 'wbr']);
const stack = [];
const re = /<(\/)?([a-zA-Z][a-zA-Z0-9-]*)\b([^>]*)>/g;
let m, count = 0;
while ((m = re.exec(withoutScripts))) {
const [, closing, tag, rest] = m;
count++;
const name = tag.toLowerCase();
if (VOID.has(name)) continue;
if (rest.trimEnd().endsWith('/')) continue; // XML-style self-closing
if (closing) {
assert(stack.length > 0, `closing </${name}> with an empty stack`);
const open = stack.pop();
assert(open === name, `</${name}> closes <${open}> opened earlier`);
} else stack.push(name);
}
assert(stack.length === 0, `unclosed element(s): ${stack.join(', ')}`);
assert(count > 20, `only ${count} markup tags found — file looks truncated`);
ok(`markup is balanced (${count} tags)`);
} catch (e) { bad('markup balance', e.message); }
/* -------------------------------------------------------------- import map */
let importMap = null;
try {
const maps = scripts.filter((s) => s.type === 'importmap');
assert(maps.length === 1, `expected exactly 1 import map, found ${maps.length}`);
importMap = JSON.parse(maps[0].body);
assert(importMap.imports && typeof importMap.imports === 'object', 'import map has no "imports" object');
const three = importMap.imports['three'];
const addons = importMap.imports['three/addons/'];
assert(typeof three === 'string' && /^https:\/\//.test(three), `"three" is not mapped to an https URL (got ${three})`);
assert(typeof addons === 'string' && /^https:\/\/.*\/$/.test(addons), `"three/addons/" is not mapped to an https folder (got ${addons})`);
assert(/\/three@\d[\d.]*\//.test(three) && /\/three\/addons\/?$|\/$/.test(addons), 'the three.js version must be pinned in the CDN URL');
for (const [k, v] of Object.entries(importMap.imports)) assert(/^https:\/\//.test(v), `import "${k}" is not an https URL`);
ok('import map is valid JSON', `${Object.keys(importMap.imports).join(', ')} → three ${/three@([\d.]+)/.exec(three)?.[1]}`);
} catch (e) { bad('import map', e.message); }
/* ---------------------------------------------------- module scripts & specs */
let moduleBodies = [];
try {
moduleBodies = scripts.filter((s) => s.type === 'module');
assert(moduleBodies.length === 1, `expected exactly 1 inline module script, found ${moduleBodies.length}`);
const body = moduleBodies.map((s) => s.body).join('\n');
assert(/import\s+\*\s+as\s+THREE\s+from\s+'three'/.test(body), "no `import * as THREE from 'three'`");
assert(/import\s*\{[^}]*OrbitControls[^}]*\}\s*from\s+'three\/addons\/controls\/OrbitControls\.js'/.test(body), 'no OrbitControls import from three/addons/');
const bare = [...body.matchAll(/from\s+'([^']+)'/g)].map((m) => m[1]);
for (const spec of bare) {
assert(spec === 'three' || spec.startsWith('three/addons/'), `bare-module import "${spec}" is not covered by the import map`);
}
assert(!/from\s+'\.\.?\//.test(body), 'relative module import found — the page must be self-contained');
assert(!/<script\b[^>]*\bsrc\s*=\s*["'](?!https:)/i.test(html), 'a <script src> points at a local file — no build step allowed');
ok('module scripts (three + three/addons/OrbitControls)', `${bare.length} specifiers, all via import map`);
} catch (e) { bad('module scripts', e.message); }
/* -------------------------------------- inline module body parses as an ESM */
try {
assert(moduleBodies.length > 0, 'no module body to parse (earlier check failed)');
const dir = mkdtempSync(join(tmpdir(), 'khufu-parse-'));
try {
const tmp = join(dir, 'module.mjs');
writeFileSync(tmp, moduleBodies.map((s) => s.body).join('\n'));
execFileSync(process.execPath, ['--check', tmp], { stdio: ['ignore', 'pipe', 'pipe'] });
const bytes = Buffer.byteLength(moduleBodies.map((s) => s.body).join('\n'));
ok('inline module parses as an ES module (node --check)', `${(bytes / 1024).toFixed(1)} kB`);
} finally { rmSync(dir, { recursive: true, force: true }); }
} catch (e) {
bad('inline module parse', (e.stderr ? e.stderr.toString() : e.message).split('\n').slice(0, 6).join(' | '));
}
/* -------------------------------------------- classic (non-module) scripts */
try {
const classic = scripts.filter((s) => !s.type);
assert(classic.length === 0, `unexpected classic <script> block(s): ${classic.map((s) => s.src || 'inline').join(', ')}`);
ok('no stray classic scripts');
} catch (e) { bad('classic scripts', e.message); }
/* ---------------------------------- ids referenced by the script all exist */
try {
const present = new Set([...html.matchAll(/\sid="([^"]+)"/g)].map((m) => m[1]));
const used = new Set([...(moduleBodies[0]?.body || '').matchAll(/\$\('#([a-zA-Z0-9_-]+)'\)|getElementById\('([a-zA-Z0-9_-]+)'\)/g)].map((m) => m[1] || m[2]));
const missing = [...used].filter((id) => !present.has(id));
assert(missing.length === 0, `missing id(s): ${missing.join(', ')}`);
ok(`all ${used.size} scripted element ids exist in the markup`);
} catch (e) { bad('element ids', e.message); }
/* ------------------------------------------- data-group panels are wired up */
try {
const groups = [...html.matchAll(/<h2[^>]+data-group="([^"]+)"/g)].map((m) => m[1]);
const missing = groups.filter((g) => !html.includes(`id="g-${g}"`));
assert(missing.length === 0, `collapsible section(s) without a container: ${missing.join(', ')}`);
ok(`collapsible panels wired (${groups.length})`);
} catch (e) { bad('panel wiring', e.message); }
/* --------------------------------------------------- the facts on show page */
try {
const must = [
[/\bbase:\s*230\.33\b/, 'base side 230.33 m (displays as 230.3 m)'],
[/\bheight:\s*146\.57\b/, 'original height 146.57 m (displays as 146.6 m)'],
[/courses:\s*203\b/, '203 core courses'],
[/slopeDeg\.toFixed\(2\)/, 'face slope rendered to 2 dp (51.84°)'],
[/buildHuman\(2\)/, 'a 2 m human figure'],
[/shadowMap\.enabled\s*=\s*true/, 'shadow mapping enabled'],
[/PCFSoftShadowMap/, 'soft shadow filtering'],
];
const body = moduleBodies[0]?.body || '';
const missing = must.filter(([re]) => !re.test(body)).map(([, label]) => label);
assert(missing.length === 0, `absent: ${missing.join('; ')}`);
ok('key model facts present', 'base · height · courses · slope · human · shadows');
} catch (e) { bad('model facts', e.message); }
/* --------------------------------------------------------- README companion */
try {
const readme = join(root, 'README.md');
assert(existsSync(readme), 'README.md missing');
const md = readFileSync(readme, 'utf8');
assert(/python3 -m http\.server/.test(md), 'README does not mention python3 -m http.server');
assert(md.length > 400, 'README looks too short');
ok('README.md present and documents http.server', `${md.split('\n').length} lines`);
} catch (e) { bad('README.md', e.message); }
/* ---------------------------------------- optional: CDN entry points reply */
if (importMap && flags.includes('--net')) {
const urls = [...Object.values(importMap.imports).map((v) => (v.endsWith('/') ? v + 'controls/OrbitControls.js' : v))];
for (const url of urls) {
try {
const res = await fetch(url, { method: 'GET', redirect: 'follow', signal: AbortSignal.timeout(20000) });
assert(res.ok && res.headers.get('content-type')?.includes('javascript'), `HTTP ${res.status} / ${res.headers.get('content-type')}`);
ok('CDN reachable', `${new URL(url).pathname.split('/').slice(-2).join('/')} · ${(await res.arrayBuffer()).byteLength} bytes`);
} catch (e) { warn('CDN reachable', `${url} — ${e.message} (offline? the page needs this at run time)`); }
}
}
/* ------------------------------------------------------------------ report */
let failures = 0;
console.log(`\ncheck-html · ${file.replace(process.cwd() + '/', '')}`);
for (const [pass, name, detail] of results) {
if (pass === null) console.log(` \x1b[33mWARN\x1b[0m ${name}${detail ? ` — ${detail}` : ''}`);
else { if (!pass) failures++; console.log(` ${pass ? '\x1b[32m PASS\x1b[0m' : '\x1b[31m FAIL\x1b[0m'} ${name}${detail ? ` — ${detail}` : ''}`); }
}
console.log(`\n${results.filter((r) => r[0]).length} passed · ${failures} failed · ${results.filter((r) => r[0] === null).length} warnings\n`);
process.exit(failures ? 1 : 0);