295eb22826
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
125 lines
5.2 KiB
JavaScript
125 lines
5.2 KiB
JavaScript
#!/usr/bin/env node
|
|
// gate-parse.mjs — static JS parse gate for the panel front-ends.
|
|
//
|
|
// This codifies the 2026-05-08 blank-page deploy gate: a single syntax error
|
|
// anywhere in the giant inline <script> block of new.html/index.html renders
|
|
// the whole panel as a blank page, and `go build` cannot catch it because the
|
|
// HTML is embedded as opaque bytes (//go:embed). This script extracts every
|
|
// <script> block from the served HTML files and parses each one.
|
|
//
|
|
// Zero dependencies: each block is written to a temp file and checked with
|
|
// `node --check` (falls back to acorn if a local install of it exists).
|
|
//
|
|
// Usage: node gate-parse.mjs
|
|
// Exit 0 = every script block in every file parses. Non-zero = at least one
|
|
// syntax error (printed with file, block index, and node's error output).
|
|
|
|
import { readFileSync, writeFileSync, mkdtempSync, rmSync } from 'node:fs';
|
|
import { execFileSync } from 'node:child_process';
|
|
import { tmpdir } from 'node:os';
|
|
import { join, dirname, resolve } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
const staticDir = resolve(here, '../cmd/controller/static');
|
|
|
|
// new.html + index.html are the two panel UIs the root route can serve;
|
|
// login.html is the unauthenticated landing page. All three brick on a
|
|
// syntax error, so all three are gated.
|
|
const files = ['new.html', 'index.html', 'login.html'];
|
|
|
|
// Extract inline <script> blocks. Blocks with a src= attribute have no inline
|
|
// body worth parsing; blocks with a non-JS type (application/json templates
|
|
// etc.) are skipped.
|
|
function extractScripts(html) {
|
|
const out = [];
|
|
const re = /<script\b([^>]*)>([\s\S]*?)<\/script>/gi;
|
|
let m;
|
|
while ((m = re.exec(html)) !== null) {
|
|
const attrs = m[1];
|
|
const body = m[2];
|
|
// External local bundles (e.g. /rcc.js?v=hash) are part of the deploy:
|
|
// resolve them from static/ and gate their contents too.
|
|
const srcMatch = attrs.match(/\bsrc\s*=\s*["']?([^"'\s>]+)/i);
|
|
if (srcMatch) {
|
|
const src = srcMatch[1];
|
|
if (/^(https?:)?\/\//i.test(src)) continue; // never hotlink; skip if ever present
|
|
const local = src.replace(/^\//, '').split('?')[0];
|
|
try {
|
|
const extBody = readFileSync(join(staticDir, local), 'utf8');
|
|
const line = html.slice(0, m.index).split('\n').length;
|
|
out.push({ body: extBody, line, module: /\btype\s*=\s*["']?module/i.test(attrs), external: local });
|
|
} catch (e) {
|
|
out.push({ body: `throw new SyntaxError(${JSON.stringify(`missing external script ${local}: ${e.message}`)});(`, line: 0, module: false, external: local });
|
|
}
|
|
continue;
|
|
}
|
|
const typeMatch = attrs.match(/\btype\s*=\s*["']?([^"'\s>]+)/i);
|
|
const type = typeMatch ? typeMatch[1].toLowerCase() : 'text/javascript';
|
|
if (!['text/javascript', 'application/javascript', 'module'].includes(type)) continue;
|
|
// Line offset of the block start, for error reporting.
|
|
const line = html.slice(0, m.index).split('\n').length;
|
|
out.push({ body, line, module: type === 'module' });
|
|
}
|
|
return out;
|
|
}
|
|
|
|
// Prefer acorn if it happens to be installed next to this suite; otherwise
|
|
// use `node --check` on a temp file (always available, needs no npm install).
|
|
let acorn = null;
|
|
try { acorn = await import('acorn'); } catch { /* fine — node --check path */ }
|
|
|
|
const tmp = mkdtempSync(join(tmpdir(), 'panel-gate-'));
|
|
let failures = 0;
|
|
let checkedFiles = 0;
|
|
|
|
for (const name of files) {
|
|
let html;
|
|
try {
|
|
html = readFileSync(join(staticDir, name), 'utf8');
|
|
} catch (e) {
|
|
console.error(`FAIL ${name}: cannot read (${e.message})`);
|
|
failures++;
|
|
continue;
|
|
}
|
|
checkedFiles++;
|
|
const blocks = extractScripts(html);
|
|
let fileOk = true;
|
|
blocks.forEach((b, i) => {
|
|
const label = `${name} script-block ${i + 1}/${blocks.length}${b.external ? ` [external ${b.external}]` : ''} (starts html line ${b.line}, ${b.body.length} chars)`;
|
|
try {
|
|
if (acorn) {
|
|
acorn.parse(b.body, {
|
|
ecmaVersion: 'latest',
|
|
sourceType: b.module ? 'module' : 'script',
|
|
});
|
|
} else {
|
|
const tf = join(tmp, `${name.replace(/\W/g, '_')}_${i}${b.module ? '.mjs' : '.js'}`);
|
|
writeFileSync(tf, b.body);
|
|
execFileSync(process.execPath, ['--check', tf], { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
}
|
|
console.log(`PASS ${label}`);
|
|
} catch (e) {
|
|
fileOk = false;
|
|
failures++;
|
|
const detail = (e.stderr ? e.stderr.toString() : e.message).trim();
|
|
console.error(`FAIL ${label}\n${detail.split('\n').map(l => ' ' + l).join('\n')}`);
|
|
}
|
|
});
|
|
if (blocks.length === 0) {
|
|
// A panel HTML with zero script blocks is itself a broken deploy.
|
|
console.error(`FAIL ${name}: 0 inline script blocks found — extraction or file is broken`);
|
|
failures++;
|
|
fileOk = false;
|
|
}
|
|
console.log(`${fileOk ? 'OK ' : 'BAD '} ${name}: ${blocks.length} inline script block(s)`);
|
|
}
|
|
|
|
rmSync(tmp, { recursive: true, force: true });
|
|
|
|
if (failures > 0) {
|
|
console.error(`\ngate-parse: ${failures} failure(s) across ${checkedFiles} file(s) — DO NOT DEPLOY`);
|
|
process.exit(1);
|
|
}
|
|
console.log(`\ngate-parse: all script blocks parse (${checkedFiles} files, parser: ${acorn ? 'acorn' : 'node --check'})`);
|