- New scripts/render-report.mjs CLI: stdin/file/stdout modes, ESM import
from ./lib/report-renderers.mjs, kebab→camel renderer-name lookup so
any of the 18 PARSERS works
- Standalone HTML wrap: inlines 6 DS stylesheets (tokens, base, components,
tier2, tier3, tier3-supplement) + local .report-table CSS. Skips fonts.css
→ system-ui fallback via tokens.css (~137 KB self-contained vs ~1 MB
with woff2 bundled)
- 4 skill files wired: commands/{scan,audit,posture,deep-scan}.md — new
step instructs Claude to Write the markdown report to a temp file,
invoke the CLI, and print a markdown-formatted file:// link
- Absolute file:// paths in stdout for Ghostty cmd-click compatibility
- Default output: reports/<command>-<YYYYMMDD-HHmmss>.html relative to CWD
- Smoke-tested: stdin→stdout, file→file roundtrip, all 4 commands produce
valid HTML with DS-aligned page-shell (page__title, verdict-pill-lg,
risk-meter, key-stats, findings__item, recommendation-card)
- Tests 1820/1820 green (same baseline; pre-compact-scan perf-flake from
NEXT-SESSION-PROMPT did not fire on retry)
- Playground untouched (2 scripts, 0 parse failures), report-renderers.mjs
untouched (74 exports, 18 PARSERS, 18 RENDERERS)
Sesjon 4 av 5. v7.7.0 release + 9 remaining skill wirings = sesjon 5.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
192 lines
7.1 KiB
JavaScript
192 lines
7.1 KiB
JavaScript
#!/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 <commandId> --in <md-file> (file → reports/<command>-<ts>.html)
|
|
* node render-report.mjs <commandId> --in <md-file> --out <html>
|
|
* node render-report.mjs <commandId> --in - (stdin → reports/<command>-<ts>.html)
|
|
* node render-report.mjs <commandId> --in <md> --out - (file → stdout)
|
|
* node render-report.mjs <commandId> --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 <main class="page"> 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 <style> block. Keep in sync with playground
|
|
// llm-security-playground.html (search for "v7.6.1 fix: .report-table").
|
|
const REPORT_TABLE_CSS = `
|
|
.report-table { width: 100%; border-collapse: collapse; margin: var(--space-3) 0; font-size: var(--font-size-sm); }
|
|
.report-table th { text-align: left; padding: 8px 12px; border-bottom: 2px solid var(--color-border-moderate); background: var(--color-bg-soft); font-weight: var(--font-weight-semibold); color: var(--color-text-secondary); text-transform: uppercase; font-size: 11px; letter-spacing: 0.04em; }
|
|
.report-table td { padding: 8px 12px; border-bottom: 1px solid var(--color-border-subtle); vertical-align: top; color: var(--color-text-primary); }
|
|
.report-table tr:last-child td { border-bottom: none; }
|
|
.report-table tbody tr:hover { background: var(--color-bg-soft); }
|
|
.report-table code { font-family: var(--font-family-mono); font-size: 12px; background: var(--color-surface-sunken); padding: 1px 6px; border-radius: var(--radius-sm); }
|
|
.recommendation-card__body { overflow-wrap: anywhere; word-break: break-word; }
|
|
main.page { max-width: 1100px; margin: 0 auto; padding: var(--space-6) var(--space-5); }
|
|
@media (max-width: 720px) { main.page { padding: var(--space-4) var(--space-3); } }
|
|
`;
|
|
|
|
function parseArgs(argv) {
|
|
const args = { commandId: null, in: null, out: null };
|
|
for (let i = 0; i < argv.length; i++) {
|
|
const a = argv[i];
|
|
if (a === '--in') args.in = argv[++i];
|
|
else if (a === '--out') args.out = argv[++i];
|
|
else if (!a.startsWith('--') && !args.commandId) args.commandId = a;
|
|
}
|
|
return args;
|
|
}
|
|
|
|
function commandToRendererName(commandId) {
|
|
return 'render' + commandId.split('-').map(s => s[0].toUpperCase() + s.slice(1)).join('');
|
|
}
|
|
|
|
function readStdinSync() {
|
|
try {
|
|
return readFileSync(0, 'utf8');
|
|
} catch {
|
|
return '';
|
|
}
|
|
}
|
|
|
|
function timestamp() {
|
|
const d = new Date();
|
|
const pad = n => String(n).padStart(2, '0');
|
|
return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`;
|
|
}
|
|
|
|
function loadDsCss() {
|
|
return CSS_FILES.map(name => {
|
|
const path = join(VENDOR_CSS_DIR, name);
|
|
if (!existsSync(path)) return `/* missing: ${name} */`;
|
|
return `/* === ${name} === */\n` + readFileSync(path, 'utf8');
|
|
}).join('\n');
|
|
}
|
|
|
|
function wrapHtml(commandId, bodyHtml, dsCss) {
|
|
return `<!DOCTYPE html>
|
|
<html lang="nb" data-theme="dark">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>llm-security · ${commandId}</title>
|
|
<script>
|
|
(function(){var t=null;try{var s=localStorage.getItem('llm-security-theme');if(s==='light'||s==='dark')t=s;}catch(e){}if(!t&&window.matchMedia)t=window.matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light';if(!t)t=document.documentElement.getAttribute('data-theme')||'dark';document.documentElement.setAttribute('data-theme',t);document.documentElement.style.colorScheme=t;})();
|
|
</script>
|
|
<style>
|
|
${dsCss}
|
|
${REPORT_TABLE_CSS}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<main class="page">
|
|
${bodyHtml}
|
|
</main>
|
|
</body>
|
|
</html>
|
|
`;
|
|
}
|
|
|
|
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 <commandId> (e.g. scan, audit, posture, deep-scan)');
|
|
if (!args.in) fail(1, 'missing --in <path|->');
|
|
|
|
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();
|