#!/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*/i.test(html), 'missing '); assert(/]*lang="en"/i.test(html), ' has no lang attribute'); assert(/]+charset="utf-8"/i.test(html), 'missing '); assert(/[^<]{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);