#!/usr/bin/env node
// Render HTML embeds in an isolated harness that mirrors the article runtime
// (design tokens, HtmlEmbed global styles, color-palettes.js) and report
// runtime errors, theme problems and layout issues. Screenshots are written
// so a human or an agent can look at the result.
//
// Usage:
// npm run check:embed -- d3-bar.html
// npm run check:embed -- path/to/file.html --data my.csv --config '{"xLabel":"Step"}'
// npm run check:embed -- --all
//
// Options:
// --all Check every embed under src/content/embeds
// --data Value passed as HtmlEmbed `data` (comma-separated for several)
// --config Value passed as HtmlEmbed `config`
// --frameless Render without the card frame
// --banner Render inside the hero banner box (auto for banner*.html)
// --widths Container widths in px (default 360,760)
// --out Screenshot directory (default .embed-check)
import { chromium } from 'playwright';
import { createServer } from 'http';
import { readFile, readdir, mkdir, stat } from 'fs/promises';
import { existsSync } from 'fs';
import { join, resolve, extname, basename, relative } from 'path';
import { fileURLToPath } from 'url';
const APP_DIR = resolve(fileURLToPath(import.meta.url), '../..');
const EMBEDS_DIR = join(APP_DIR, 'src/content/embeds');
const DATA_DIR = join(APP_DIR, 'src/content/assets/data');
const PUBLIC_SCRIPTS_DIR = join(APP_DIR, 'public/scripts');
const SETTLE_MS = 700;
const READY_SETTLE_MS = 150;
// Same D3 build the article pages preload synchronously.
const D3_URL = 'https://cdn.jsdelivr.net/npm/d3@7.9.0/dist/d3.min.js';
const LOAD_TIMEOUT_MS = 20_000;
// Globals an embed may legitimately rely on or create.
const ALLOWED_GLOBALS = new Set(['d3', 'ColorPalettes', 'tf', 'THREE', 'katex', 'SVG']);
const MIME = {
'.js': 'text/javascript',
'.mjs': 'text/javascript',
'.css': 'text/css',
'.csv': 'text/csv',
'.tsv': 'text/tab-separated-values',
'.json': 'application/json',
'.txt': 'text/plain',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.webp': 'image/webp',
'.svg': 'image/svg+xml',
};
function parseArgs(argv) {
const opts = { files: [], all: false, data: null, config: null, frameless: false, banner: false, widths: [360, 760], out: '.embed-check' };
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (a === '--all') opts.all = true;
else if (a === '--frameless') opts.frameless = true;
else if (a === '--banner') opts.banner = true;
else if (a === '--data') opts.data = argv[++i];
else if (a === '--config') opts.config = argv[++i];
else if (a === '--widths') opts.widths = argv[++i].split(',').map(Number).filter(Boolean);
else if (a === '--out') opts.out = argv[++i];
else if (a === '--help' || a === '-h') opts.help = true;
else opts.files.push(a);
}
return opts;
}
async function listEmbeds(dir) {
const out = [];
for (const entry of await readdir(dir, { withFileTypes: true })) {
const p = join(dir, entry.name);
if (entry.isDirectory()) out.push(...(await listEmbeds(p)));
else if (entry.name.endsWith('.html')) out.push(p);
}
return out.sort();
}
async function resolveEmbed(name) {
const direct = resolve(process.cwd(), name);
if (existsSync(direct) && (await stat(direct)).isFile()) return direct;
const wanted = name.endsWith('.html') ? name : `${name}.html`;
const match = (await listEmbeds(EMBEDS_DIR)).find((p) => p.endsWith(`/${wanted}`));
if (!match) throw new Error(`Embed not found: ${name}`);
return match;
}
const escapeAttr = (s) => String(s).replace(/&/g, '&').replace(/"/g, '"');
async function loadRuntimeCss() {
const variables = (await readFile(join(APP_DIR, 'src/styles/_variables.css'), 'utf8'))
.replace(/^\s*@custom-media[^;]*;\s*$/gm, '');
const htmlEmbed = await readFile(join(APP_DIR, 'src/components/HtmlEmbed.astro'), 'utf8');
const globalStyle = (htmlEmbed.match(/
${banner ? `
${figure}
` : figure}