Spaces:
Running
Running
Download scripts/check-html.mjs from Mike0021/kheops-pyramid: direct link, hf CLI and curl.
- Browser
- Download file 11 kB
-
https://huggingface.co/spaces/Mike0021/kheops-pyramid/resolve/main/scripts/check-html.mjs
- Command line
-
hf download hf://spaces/Mike0021/kheops-pyramid/scripts/check-html.mjs
-
curl -L -o check-html.mjs https://huggingface.co/spaces/Mike0021/kheops-pyramid/resolve/main/scripts/check-html.mjs
11 kB
| /** | |
| * 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); | |