Spaces:
Running
Running
File size: 10,952 Bytes
682b01a | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 | #!/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);
|