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);