#!/usr/bin/env node /* * render-report.mjs — convert llm-security markdown reports into self-contained * HTML files. Zero npm deps. Reads markdown from stdin or a file, parses via * PARSERS, renders via RENDERERS, wraps the output in a standalone HTML doc * with inlined Playground Design System CSS. * * Usage: * node render-report.mjs --in (file → reports/-.html) * node render-report.mjs --in --out * node render-report.mjs --in - (stdin → reports/-.html) * node render-report.mjs --in --out - (file → stdout) * node render-report.mjs --in - --out - (stdin → stdout) * * Exit codes: * 0 success · 1 parser error or empty input · 2 renderer threw · 3 file I/O failed * * Standalone HTML wraps in
so DS Tier 3 page-shell styling * matches the playground render exactly. Output uses absolute file:// URLs to * stdout (Ghostty cmd-click requires absolute paths). */ import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs'; import { resolve, dirname, isAbsolute, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { PARSERS, RENDERERS } from './lib/report-renderers.mjs'; const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url)); const PLUGIN_ROOT = resolve(SCRIPT_DIR, '..'); const VENDOR_CSS_DIR = join(PLUGIN_ROOT, 'playground', 'vendor', 'playground-design-system'); // DS stylesheets to inline. Skip fonts.css — tokens.css has system-ui // fallbacks (-apple-system, BlinkMacSystemFont, system-ui, sans-serif), // so reports stay self-contained without bundling ~940 KB of woff2. const CSS_FILES = [ 'tokens.css', 'base.css', 'components.css', 'components-tier2.css', 'components-tier3.css', 'components-tier3-supplement.css' ]; // .report-table is a playground-local DS supplement (DS doesn't ship it). Same // styling lives in playground inline
${bodyHtml}
`; } function fail(code, msg) { process.stderr.write(`render-report: ${msg}\n`); process.exit(code); } function main() { const args = parseArgs(process.argv.slice(2)); if (!args.commandId) fail(1, 'missing (e.g. scan, audit, posture, deep-scan)'); if (!args.in) fail(1, 'missing --in '); const parser = PARSERS[args.commandId]; if (!parser) fail(1, `unknown commandId "${args.commandId}". Valid: ${Object.keys(PARSERS).join(', ')}`); const rendererName = commandToRendererName(args.commandId); const renderer = RENDERERS[rendererName]; if (typeof renderer !== 'function') fail(1, `no renderer found for "${args.commandId}" (looked up ${rendererName})`); let md; try { md = args.in === '-' ? readStdinSync() : readFileSync(resolve(args.in), 'utf8'); } catch (e) { fail(1, `cannot read input: ${e.message}`); } if (!md || !md.trim()) fail(1, 'input markdown is empty'); let data; try { data = parser(md); } catch (e) { fail(1, `parser threw: ${e.message}`); } if (!data || typeof data !== 'object') fail(1, 'parser returned non-object'); const slot = { innerHTML: '' }; try { renderer(data, slot); } catch (e) { fail(2, `renderer threw: ${e.message}`); } if (!slot.innerHTML) fail(2, 'renderer produced empty HTML'); const dsCss = loadDsCss(); const html = wrapHtml(args.commandId, slot.innerHTML, dsCss); if (args.out === '-') { process.stdout.write(html); return; } let outPath; if (args.out) { outPath = isAbsolute(args.out) ? args.out : resolve(args.out); } else { const reportsDir = resolve('reports'); try { mkdirSync(reportsDir, { recursive: true }); } catch (e) { fail(3, `cannot create reports dir: ${e.message}`); } outPath = join(reportsDir, `${args.commandId}-${timestamp()}.html`); } try { mkdirSync(dirname(outPath), { recursive: true }); writeFileSync(outPath, html, 'utf8'); } catch (e) { fail(3, `cannot write ${outPath}: ${e.message}`); } process.stdout.write(`file://${outPath}\n`); } main();