chore(voyage): release v5.0.0 — remove bespoke playground + /trekrevise + Handover 8; render produced artifacts to HTML + link, annotate via /playground
The v4.2/v4.3 bespoke playground SPA (~388 KB), the /trekrevise command, Handover 8 (annotation → revision), the supporting lib/ modules (anchor-parser, annotation-digest, markdown-write, revision-guard), the Playwright e2e suite, and the @playwright/test / @axe-core/playwright devDeps are removed. A browser walkthrough found the playground borderline unusable, and it duplicated the official /playground plugin's document-critique / diff-review templates. In their place: scripts/render-artifact.mjs — a small, zero-dependency renderer that turns a brief/plan/review .md into a self-contained, design-system-styled, zero-network .html (frontmatter folded into a <details> block). /trekbrief, /trekplan, and /trekreview call it on their last step and print the file:// link; to annotate, run /playground (document-critique) on the .md and paste the generated prompt back. Resolves the v4.3.1-deferred findings as moot (their target files are deleted). npm test green: 509 tests, 507 pass, 0 fail, 2 skipped. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
0f197f6ff6
commit
916d30f63e
96 changed files with 620 additions and 14716 deletions
|
|
@ -1,101 +1,284 @@
|
|||
#!/usr/bin/env node
|
||||
// scripts/render-artifact.mjs
|
||||
// CLI renderer for v4.2 — satisfies brief SC1 + SC11 (zero-network, self-eat).
|
||||
//
|
||||
// Renders a voyage artifact (brief.md / plan.md / review.md) to a
|
||||
// self-contained HTML file in the same directory, with inlined CSS and
|
||||
// zero external network references. The producing commands (/trekbrief,
|
||||
// /trekplan, /trekreview) call this at the end and print the file:// link
|
||||
// so the operator can read the artifact in a browser — and, when they want
|
||||
// to annotate it, run the official `/playground` plugin (document-critique
|
||||
// template) on it and paste the generated prompt back into Claude Code.
|
||||
//
|
||||
// Usage:
|
||||
// node scripts/render-artifact.mjs <input.md> [--out <output.html>]
|
||||
// node scripts/render-artifact.mjs <artifact.md> [--out <output.html>]
|
||||
//
|
||||
// Reads input.md, renders it via the same vendored markdown-it +
|
||||
// markdown-it-front-matter + highlight.js bundle that the browser
|
||||
// playground uses (playground/lib/*.min.js), and emits a self-contained
|
||||
// HTML file with inlined CSS + inlined highlight.js so the output renders
|
||||
// correctly with zero network requests.
|
||||
// Determinism: no timestamps, no random IDs — two runs on the same input
|
||||
// produce byte-identical output.
|
||||
//
|
||||
// Determinism contract (SC11): two invocations on the same input produce
|
||||
// byte-identical output. No timestamps, no random IDs.
|
||||
// Zero npm deps (marketplace convention). The markdown→HTML conversion is a
|
||||
// small hand-rolled subset that covers what the artifact templates emit:
|
||||
// ATX headings, ordered/unordered/nested lists, fenced code blocks, inline
|
||||
// code, bold, links, blockquotes, GitHub-style tables, and horizontal rules.
|
||||
|
||||
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
|
||||
import { dirname, basename, resolve, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { basename } from 'node:path';
|
||||
import { splitFrontmatter } from '../lib/util/frontmatter.mjs';
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = resolve(HERE, '..');
|
||||
const PLAYGROUND_LIB = join(ROOT, 'playground', 'lib');
|
||||
const DS_DIR = join(ROOT, 'playground', 'vendor', 'playground-design-system');
|
||||
|
||||
// --- argument parsing -------------------------------------------------------
|
||||
|
||||
function parseArgs(argv) {
|
||||
const args = { input: null, out: null };
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const a = argv[i];
|
||||
if (a === '--out') {
|
||||
args.out = argv[++i];
|
||||
} else if (a === '--help' || a === '-h') {
|
||||
args.help = true;
|
||||
} else if (!args.input) {
|
||||
args.input = a;
|
||||
}
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
// --- vendored-lib loader (CommonJS shim) ------------------------------------
|
||||
|
||||
function loadVendoredScript(name, globalName) {
|
||||
const src = readFileSync(join(PLAYGROUND_LIB, name), 'utf-8');
|
||||
const sandbox = {};
|
||||
// Minimal browser-shim: provide window/globalThis aliases the IIFE bundles
|
||||
// expect when running outside the browser.
|
||||
const fn = new Function('window', 'globalThis', 'self', src);
|
||||
fn(sandbox, sandbox, sandbox);
|
||||
return sandbox[globalName];
|
||||
}
|
||||
|
||||
// --- inline-asset loaders ---------------------------------------------------
|
||||
|
||||
function readDsCss() {
|
||||
const order = [
|
||||
'tokens.css',
|
||||
'base.css',
|
||||
'fonts.css',
|
||||
'components.css',
|
||||
'components-tier2.css',
|
||||
'components-tier3.css',
|
||||
'components-tier3-supplement.css',
|
||||
'print.css',
|
||||
];
|
||||
const parts = [];
|
||||
for (const f of order) {
|
||||
const p = join(DS_DIR, f);
|
||||
if (existsSync(p)) parts.push('/* === ' + f + ' === */\n' + readFileSync(p, 'utf-8'));
|
||||
}
|
||||
return parts.join('\n');
|
||||
}
|
||||
|
||||
function readHighlightInline() {
|
||||
// Inline the assembled highlight.min.js so the output HTML can re-highlight
|
||||
// pre/code blocks on view (purely defensive — they're already pre-highlighted
|
||||
// server-side at render time, but inlining keeps the static HTML resilient).
|
||||
//
|
||||
// Zero-network constraint (SC1): the highlight.js source contains URL
|
||||
// strings inside language-comment metadata (e.g. references to MDN). These
|
||||
// are inert string-literals (not network refs) but a literal grep for
|
||||
// "http://" would still match. Strip URL strings to preserve SC1's
|
||||
// grep-based check while keeping the runtime functional.
|
||||
const raw = readFileSync(join(PLAYGROUND_LIB, 'highlight.min.js'), 'utf-8');
|
||||
return raw.replace(/https?:\/\/[^\s"'\\)]+/g, 'about:blank');
|
||||
}
|
||||
|
||||
// --- renderer ---------------------------------------------------------------
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
// Inline spans, applied to already-HTML-escaped text. Order matters: code
|
||||
// spans first (so their contents aren't re-processed), then links, bold, em.
|
||||
function renderInline(escaped) {
|
||||
let out = escaped.replace(/`([^`]+)`/g, (_, c) => `<code>${c}</code>`);
|
||||
out = out.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (_, text, href) => {
|
||||
const safe = /^(https?:|mailto:|#|\.|\/)/i.test(href) ? href : '#';
|
||||
return `<a href="${safe}">${text}</a>`;
|
||||
});
|
||||
out = out.replace(/\*\*([^*]+)\*\*/g, (_, c) => `<strong>${c}</strong>`);
|
||||
out = out.replace(/(^|[^*])\*([^*\s][^*]*?)\*(?!\*)/g, (_, pre, c) => `${pre}<em>${c}</em>`);
|
||||
return out;
|
||||
}
|
||||
|
||||
function renderTable(rows) {
|
||||
const cells = (line) => line.replace(/^\s*\|/, '').replace(/\|\s*$/, '').split('|').map((c) => c.trim());
|
||||
const header = cells(rows[0]);
|
||||
const body = rows.slice(2).map(cells);
|
||||
let html = '<table>\n<thead><tr>';
|
||||
for (const h of header) html += `<th>${renderInline(escapeHtml(h))}</th>`;
|
||||
html += '</tr></thead>\n<tbody>\n';
|
||||
for (const r of body) {
|
||||
html += '<tr>';
|
||||
for (let i = 0; i < header.length; i++) html += `<td>${renderInline(escapeHtml(r[i] || ''))}</td>`;
|
||||
html += '</tr>\n';
|
||||
}
|
||||
return html + '</tbody>\n</table>\n';
|
||||
}
|
||||
|
||||
// Build nested <ul>/<ol> from a run of list lines (2-space indent = 1 level).
|
||||
function renderList(items) {
|
||||
let html = '';
|
||||
const stack = []; // { indent, ordered }
|
||||
for (const { indent, ordered, text } of items) {
|
||||
while (
|
||||
stack.length &&
|
||||
(indent < stack[stack.length - 1].indent ||
|
||||
(indent === stack[stack.length - 1].indent && ordered !== stack[stack.length - 1].ordered))
|
||||
) {
|
||||
const top = stack.pop();
|
||||
html += top.ordered ? '</li></ol>' : '</li></ul>';
|
||||
}
|
||||
if (!stack.length || indent > stack[stack.length - 1].indent) {
|
||||
html += ordered ? '<ol>' : '<ul>';
|
||||
stack.push({ indent, ordered });
|
||||
} else {
|
||||
html += '</li>';
|
||||
}
|
||||
html += `<li>${renderInline(escapeHtml(text))}`;
|
||||
}
|
||||
while (stack.length) {
|
||||
const top = stack.pop();
|
||||
html += top.ordered ? '</li></ol>' : '</li></ul>';
|
||||
}
|
||||
return html + '\n';
|
||||
}
|
||||
|
||||
function renderMarkdown(md) {
|
||||
const lines = md.replace(/\r\n/g, '\n').split('\n');
|
||||
let html = '';
|
||||
let i = 0;
|
||||
let para = [];
|
||||
|
||||
const flushPara = () => {
|
||||
if (para.length) {
|
||||
html += `<p>${renderInline(escapeHtml(para.join(' ')))}</p>\n`;
|
||||
para = [];
|
||||
}
|
||||
};
|
||||
|
||||
while (i < lines.length) {
|
||||
const line = lines[i];
|
||||
|
||||
// Fenced code block
|
||||
const fence = line.match(/^(\s*)(`{3,}|~{3,})(.*)$/);
|
||||
if (fence) {
|
||||
flushPara();
|
||||
const marker = fence[2];
|
||||
const lang = (fence[3] || '').trim().split(/\s+/)[0];
|
||||
const buf = [];
|
||||
i++;
|
||||
while (i < lines.length && !lines[i].match(new RegExp('^\\s*' + marker[0] + '{3,}\\s*$'))) {
|
||||
buf.push(lines[i]);
|
||||
i++;
|
||||
}
|
||||
i++; // consume closing fence
|
||||
const cls = lang ? ` class="language-${escapeHtml(lang)}"` : '';
|
||||
html += `<pre><code${cls}>${escapeHtml(buf.join('\n'))}\n</code></pre>\n`;
|
||||
continue;
|
||||
}
|
||||
|
||||
// ATX heading
|
||||
const h = line.match(/^(#{1,6})\s+(.*?)\s*#*\s*$/);
|
||||
if (h) {
|
||||
flushPara();
|
||||
const lvl = h[1].length;
|
||||
html += `<h${lvl}>${renderInline(escapeHtml(h[2]))}</h${lvl}>\n`;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Horizontal rule
|
||||
if (/^\s*([-*_])(\s*\1){2,}\s*$/.test(line)) {
|
||||
flushPara();
|
||||
html += '<hr>\n';
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Table (header row + separator row)
|
||||
if (/^\s*\|.*\|\s*$/.test(line) && i + 1 < lines.length && /^\s*\|?[\s:|-]+\|?\s*$/.test(lines[i + 1]) && lines[i + 1].includes('-')) {
|
||||
flushPara();
|
||||
const rows = [];
|
||||
while (i < lines.length && /^\s*\|.*\|\s*$/.test(lines[i])) { rows.push(lines[i]); i++; }
|
||||
// include the separator that was matched as part of rows already
|
||||
html += renderTable(rows);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Blockquote
|
||||
if (/^\s*>\s?/.test(line)) {
|
||||
flushPara();
|
||||
const buf = [];
|
||||
while (i < lines.length && /^\s*>\s?/.test(lines[i])) { buf.push(lines[i].replace(/^\s*>\s?/, '')); i++; }
|
||||
html += `<blockquote>${renderInline(escapeHtml(buf.join(' ')))}</blockquote>\n`;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Lists (consume a contiguous block, allowing blank lines between items)
|
||||
const listMatch = line.match(/^(\s*)([-*+]|\d+[.)])\s+(.*)$/);
|
||||
if (listMatch) {
|
||||
flushPara();
|
||||
const items = [];
|
||||
while (i < lines.length) {
|
||||
const m = lines[i].match(/^(\s*)([-*+]|\d+[.)])\s+(.*)$/);
|
||||
if (m) {
|
||||
items.push({ indent: m[1].length, ordered: /\d/.test(m[2]), text: m[3] });
|
||||
i++;
|
||||
} else if (lines[i].trim() === '' && i + 1 < lines.length && lines[i + 1].match(/^(\s*)([-*+]|\d+[.)])\s+/)) {
|
||||
i++; // blank line inside the list
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
html += renderList(items);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Blank line — paragraph break
|
||||
if (line.trim() === '') {
|
||||
flushPara();
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Default — accumulate into paragraph
|
||||
para.push(line.trim());
|
||||
i++;
|
||||
}
|
||||
flushPara();
|
||||
return html;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const STYLE = `
|
||||
:root { color-scheme: light; }
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0; padding: 2.5rem 1.25rem 4rem;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
font-size: 16px; line-height: 1.6; color: #1a1a1a; background: #f7f7f8;
|
||||
}
|
||||
main { max-width: 56rem; margin: 0 auto; background: #fff; border: 1px solid #e2e2e6;
|
||||
border-radius: 12px; padding: 2.5rem 3rem; box-shadow: 0 1px 3px rgba(0,0,0,.06); }
|
||||
h1, h2, h3, h4, h5, h6 { line-height: 1.25; margin: 1.8em 0 .6em; font-weight: 650; }
|
||||
h1 { font-size: 2rem; margin-top: 0; }
|
||||
h2 { font-size: 1.5rem; border-bottom: 1px solid #ececef; padding-bottom: .3em; }
|
||||
h3 { font-size: 1.2rem; }
|
||||
h4 { font-size: 1.05rem; }
|
||||
p { margin: .8em 0; }
|
||||
a { color: #0855a8; text-decoration: underline; text-underline-offset: 2px; }
|
||||
a:hover { color: #06408a; }
|
||||
code { font-family: ui-monospace, "SF Mono", "JetBrains Mono", Menlo, Consolas, monospace;
|
||||
font-size: .9em; background: #f0f0f3; padding: .12em .35em; border-radius: 4px; }
|
||||
pre { background: #1e1e24; color: #e6e6eb; padding: 1rem 1.25rem; border-radius: 8px;
|
||||
overflow-x: auto; font-size: .85rem; line-height: 1.5; }
|
||||
pre code { background: none; padding: 0; color: inherit; font-size: inherit; }
|
||||
blockquote { margin: 1em 0; padding: .4em 1.2em; border-left: 4px solid #0855a8;
|
||||
background: #f0f5fb; color: #34495e; border-radius: 0 6px 6px 0; }
|
||||
ul, ol { padding-left: 1.6em; margin: .8em 0; }
|
||||
li { margin: .25em 0; }
|
||||
table { border-collapse: collapse; width: 100%; margin: 1.2em 0; font-size: .92rem; }
|
||||
th, td { border: 1px solid #e2e2e6; padding: .5em .75em; text-align: left; vertical-align: top; }
|
||||
th { background: #f0f0f3; font-weight: 600; }
|
||||
tr:nth-child(even) td { background: #fafafb; }
|
||||
hr { border: none; border-top: 1px solid #e2e2e6; margin: 2em 0; }
|
||||
details.frontmatter { margin: 0 0 2rem; border: 1px solid #e2e2e6; border-radius: 8px;
|
||||
background: #fafafb; padding: .6em 1em; }
|
||||
details.frontmatter > summary { cursor: pointer; font-weight: 600; font-size: .9rem; color: #555; }
|
||||
details.frontmatter pre { margin: .8em 0 .2em; background: #f4f4f6; color: #333; }
|
||||
.artifact-meta { color: #888; font-size: .82rem; margin: 0 0 1.5rem; }
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root { color-scheme: dark; }
|
||||
body { color: #e6e6eb; background: #18181b; }
|
||||
main { background: #1f1f23; border-color: #2e2e34; box-shadow: none; }
|
||||
h2 { border-bottom-color: #2e2e34; }
|
||||
a { color: #6db0ee; } a:hover { color: #93c5fd; }
|
||||
code { background: #2a2a30; }
|
||||
blockquote { background: #1a242f; color: #b6c5d4; border-left-color: #6db0ee; }
|
||||
th, td { border-color: #2e2e34; } th { background: #26262c; }
|
||||
tr:nth-child(even) td { background: #222226; }
|
||||
hr { border-top-color: #2e2e34; }
|
||||
details.frontmatter { background: #222226; border-color: #2e2e34; }
|
||||
details.frontmatter > summary { color: #aaa; }
|
||||
details.frontmatter pre { background: #1a1a1d; color: #ccc; }
|
||||
.artifact-meta { color: #777; }
|
||||
}
|
||||
@media print { body { background: #fff; padding: 0; } main { border: none; box-shadow: none; max-width: none; } }
|
||||
`.trim();
|
||||
|
||||
function buildHtml(mdPath, mdText) {
|
||||
const { hasFrontmatter, frontmatter, body } = splitFrontmatter(mdText);
|
||||
const fm = hasFrontmatter ? frontmatter : '';
|
||||
const fmLine = (key) => {
|
||||
const m = fm.match(new RegExp('^' + key + ':\\s*(.+)$', 'm'));
|
||||
return m ? m[1].trim().replace(/^["']|["']$/g, '') : null;
|
||||
};
|
||||
const title = fmLine('task') || fmLine('slug') || (body.match(/^#\s+(.+)$/m) || [])[1] || basename(mdPath);
|
||||
const kind = fmLine('type') || basename(mdPath).replace(/\.md$/, '');
|
||||
|
||||
const fmBlock = hasFrontmatter
|
||||
? `<details class="frontmatter"><summary>Frontmatter</summary><pre><code>${escapeHtml(fm)}\n</code></pre></details>\n`
|
||||
: '';
|
||||
|
||||
const bodyHtml = renderMarkdown(body);
|
||||
|
||||
return '<!DOCTYPE html>\n'
|
||||
+ '<html lang="en">\n<head>\n<meta charset="utf-8">\n'
|
||||
+ '<meta name="viewport" content="width=device-width, initial-scale=1">\n'
|
||||
+ `<title>${escapeHtml(String(title))}</title>\n`
|
||||
+ `<style>\n${STYLE}\n</style>\n</head>\n<body>\n<main>\n`
|
||||
+ `<p class="artifact-meta">voyage artifact — ${escapeHtml(String(kind))}</p>\n`
|
||||
+ fmBlock
|
||||
+ bodyHtml
|
||||
+ '</main>\n</body>\n</html>\n';
|
||||
}
|
||||
|
||||
function render(inputPath, outputPath) {
|
||||
|
|
@ -104,93 +287,35 @@ function render(inputPath, outputPath) {
|
|||
process.exit(2);
|
||||
}
|
||||
const text = readFileSync(inputPath, 'utf-8');
|
||||
|
||||
// Load vendored libs (deterministic — no network, no timestamps in output)
|
||||
const markdownit = loadVendoredScript('markdown-it.min.js', 'markdownit');
|
||||
const markdownitFrontMatter = loadVendoredScript('markdown-it-front-matter.min.js', 'markdownitFrontMatter');
|
||||
const hljs = loadVendoredScript('highlight.min.js', 'hljs');
|
||||
|
||||
let capturedFrontmatter = '';
|
||||
const md = markdownit({
|
||||
html: true,
|
||||
linkify: false,
|
||||
typographer: false,
|
||||
highlight: function (code, lang) {
|
||||
if (hljs && lang && hljs.getLanguage(lang)) {
|
||||
try {
|
||||
return hljs.highlight(code, { language: lang, ignoreIllegals: true }).value;
|
||||
} catch (e) {
|
||||
/* fall through */
|
||||
}
|
||||
}
|
||||
return '';
|
||||
},
|
||||
});
|
||||
try {
|
||||
md.use(markdownitFrontMatter, function (fm) {
|
||||
capturedFrontmatter = fm || '';
|
||||
});
|
||||
} catch (e) {
|
||||
process.stderr.write(`render-artifact: front-matter plugin error: ${e.message}\n`);
|
||||
}
|
||||
|
||||
const bodyHtml = md.render(text);
|
||||
const fmHtml = capturedFrontmatter
|
||||
? '<details><summary>Frontmatter</summary><pre><code>' +
|
||||
escapeHtml(capturedFrontmatter) + '</code></pre></details>'
|
||||
: '';
|
||||
|
||||
// Determine title from frontmatter slug or first H1 fallback
|
||||
let title = basename(inputPath);
|
||||
const slugMatch = capturedFrontmatter.match(/^slug:\s*(.+)$/m);
|
||||
if (slugMatch) title = slugMatch[1].replace(/^["']|["']$/g, '');
|
||||
const taskMatch = capturedFrontmatter.match(/^task:\s*(.+)$/m);
|
||||
if (taskMatch) title = taskMatch[1].replace(/^["']|["']$/g, '');
|
||||
|
||||
const css = readDsCss();
|
||||
const hljsInline = readHighlightInline();
|
||||
|
||||
// Self-contained HTML — zero network references. Determinism:
|
||||
// no Date.now(), no Math.random(), no timestamps.
|
||||
const html =
|
||||
'<!DOCTYPE html>\n' +
|
||||
'<html lang="nb">\n' +
|
||||
'<head>\n' +
|
||||
' <meta charset="utf-8">\n' +
|
||||
' <meta name="viewport" content="width=device-width, initial-scale=1">\n' +
|
||||
' <title>' + escapeHtml(title) + '</title>\n' +
|
||||
' <style>\n' + css + '\n </style>\n' +
|
||||
' <script>\n' + hljsInline + '\n </script>\n' +
|
||||
'</head>\n' +
|
||||
'<body>\n' +
|
||||
' <main class="rendered-artifact">\n' +
|
||||
' <h1 class="rendered-artifact__title">' + escapeHtml(title) + '</h1>\n' +
|
||||
fmHtml + '\n' +
|
||||
bodyHtml + '\n' +
|
||||
' </main>\n' +
|
||||
'</body>\n' +
|
||||
'</html>\n';
|
||||
|
||||
const html = buildHtml(inputPath, text);
|
||||
const out = outputPath || inputPath.replace(/\.md$/, '.html');
|
||||
writeFileSync(out, html);
|
||||
process.stdout.write('render-artifact: wrote ' + out + ' (' + Buffer.byteLength(html, 'utf-8') + ' bytes)\n');
|
||||
return out;
|
||||
}
|
||||
|
||||
// --- CLI entry point --------------------------------------------------------
|
||||
function parseArgs(argv) {
|
||||
const args = { input: null, out: null, help: false };
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const a = argv[i];
|
||||
if (a === '--out') args.out = argv[++i];
|
||||
else if (a === '--help' || a === '-h') args.help = true;
|
||||
else if (!args.input) args.input = a;
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
if (args.help || !args.input) {
|
||||
process.stdout.write(
|
||||
'Usage: render-artifact <input.md> [--out <output.html>]\n' +
|
||||
'\n' +
|
||||
'Reads input.md and emits a self-contained HTML file with inlined\n' +
|
||||
'CSS + highlight.js. Default output: <input-basename>.html next to input.\n',
|
||||
'Usage: render-artifact <artifact.md> [--out <output.html>]\n\n'
|
||||
+ 'Renders a voyage artifact to a self-contained HTML file (zero network).\n'
|
||||
+ 'Default output: <input-basename>.html next to the input.\n',
|
||||
);
|
||||
process.exit(args.help ? 0 : 2);
|
||||
}
|
||||
render(args.input, args.out);
|
||||
const out = render(args.input, args.out);
|
||||
process.stdout.write(out + '\n');
|
||||
}
|
||||
|
||||
export { render, parseArgs };
|
||||
export { render, buildHtml, renderMarkdown, parseArgs };
|
||||
|
|
|
|||
|
|
@ -1,174 +0,0 @@
|
|||
#!/usr/bin/env node
|
||||
// scripts/vendor-playground-libs.mjs
|
||||
// Reproducible vendor script for v4.2 playground render-pipeline.
|
||||
//
|
||||
// Usage: node scripts/vendor-playground-libs.mjs
|
||||
//
|
||||
// Pins (locked per plan-critic B3 — never use highlightjs.org website builder
|
||||
// or any other interactive UI; this script is fully headless):
|
||||
// - markdown-it@14.1.0 (UMD bundle copied verbatim)
|
||||
// - markdown-it-front-matter@0.2.4 (CommonJS module wrapped in IIFE)
|
||||
// - highlight.js@11.11.1 (5-lang bundle assembled from CommonJS sources)
|
||||
// - dompurify@3.2.6 (UMD bundle copied verbatim) — v4.3 Step 24
|
||||
//
|
||||
// Output: playground/lib/{markdown-it.min.js, markdown-it-front-matter.min.js,
|
||||
// highlight.min.js, dompurify.min.js}
|
||||
//
|
||||
// All three output files are zero-network browser-loadable scripts that
|
||||
// expose globals (`window.markdownit`, `window.markdownitFrontMatter`,
|
||||
// `window.hljs`). They also work under Node.js dynamic-import via the
|
||||
// pattern in scripts/render-artifact.mjs (UMD + global-eval).
|
||||
|
||||
import { execSync } from 'node:child_process';
|
||||
import { copyFileSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync, rmSync } from 'node:fs';
|
||||
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 ROOT = resolve(HERE, '..');
|
||||
const OUT = join(ROOT, 'playground', 'lib');
|
||||
|
||||
const PINS = {
|
||||
'markdown-it': '14.1.0',
|
||||
'markdown-it-front-matter': '0.2.4',
|
||||
'highlight.js': '11.11.1',
|
||||
// v4.3 Step 24 — pinned ≥ 3.1.1 (PortSwigger HTML-comment mutation-XSS bypass
|
||||
// was fixed in 3.1.x; 3.2.6 is the current stable line as of 2026-05-10).
|
||||
'dompurify': '3.2.6',
|
||||
};
|
||||
|
||||
const HL_LANGS = ['yaml', 'json', 'javascript', 'bash', 'markdown', 'diff'];
|
||||
|
||||
function vendor() {
|
||||
mkdirSync(OUT, { recursive: true });
|
||||
|
||||
const tmp = mkdtempSync(join(tmpdir(), 'voyage-vendor-'));
|
||||
const log = (msg) => process.stdout.write(`[vendor] ${msg}\n`);
|
||||
|
||||
try {
|
||||
// 1. markdown-it — copy UMD min bundle directly
|
||||
log('packing markdown-it@' + PINS['markdown-it']);
|
||||
execSync(`npm pack markdown-it@${PINS['markdown-it']} --silent`, { cwd: tmp });
|
||||
execSync(`tar xzf markdown-it-${PINS['markdown-it']}.tgz`, { cwd: tmp });
|
||||
copyFileSync(
|
||||
join(tmp, 'package', 'dist', 'markdown-it.min.js'),
|
||||
join(OUT, 'markdown-it.min.js'),
|
||||
);
|
||||
log(`wrote ${join(OUT, 'markdown-it.min.js')}`);
|
||||
|
||||
// 2. markdown-it-front-matter — wrap CommonJS in IIFE that exposes a global
|
||||
log('packing markdown-it-front-matter@' + PINS['markdown-it-front-matter']);
|
||||
execSync(`npm pack markdown-it-front-matter@${PINS['markdown-it-front-matter']} --silent`, { cwd: tmp });
|
||||
execSync(`tar xzf markdown-it-front-matter-${PINS['markdown-it-front-matter']}.tgz`, { cwd: tmp });
|
||||
const fmSrc = readFileSync(join(tmp, 'package', 'index.js'), 'utf-8');
|
||||
const fmBundle = wrapCommonJS('markdownitFrontMatter', fmSrc);
|
||||
writeFileSync(join(OUT, 'markdown-it-front-matter.min.js'), fmBundle);
|
||||
log(`wrote ${join(OUT, 'markdown-it-front-matter.min.js')}`);
|
||||
|
||||
// 3. highlight.js — assemble core + 5 languages from CommonJS sources
|
||||
log('packing highlight.js@' + PINS['highlight.js']);
|
||||
execSync(`npm pack highlight.js@${PINS['highlight.js']} --silent`, { cwd: tmp });
|
||||
execSync(`tar xzf highlight.js-${PINS['highlight.js']}.tgz`, { cwd: tmp });
|
||||
|
||||
const coreSrc = readFileSync(join(tmp, 'package', 'lib', 'core.js'), 'utf-8');
|
||||
const langSrcs = HL_LANGS.map((lang) => ({
|
||||
lang,
|
||||
src: readFileSync(join(tmp, 'package', 'lib', 'languages', `${lang}.js`), 'utf-8'),
|
||||
}));
|
||||
|
||||
const hlBundle = assembleHighlight(coreSrc, langSrcs);
|
||||
writeFileSync(join(OUT, 'highlight.min.js'), hlBundle);
|
||||
log(`wrote ${join(OUT, 'highlight.min.js')} (${HL_LANGS.length} langs)`);
|
||||
|
||||
// 4. dompurify — copy UMD min bundle directly (v4.3 Step 24).
|
||||
// Mirrors markdown-it-vendoring: npm pack → tar xzf → copy
|
||||
// dist/purify.min.js → playground/lib/dompurify.min.js. The UMD bundle
|
||||
// exposes `window.DOMPurify` for browser-loadable use.
|
||||
log('packing dompurify@' + PINS['dompurify']);
|
||||
execSync(`npm pack dompurify@${PINS['dompurify']} --silent`, { cwd: tmp });
|
||||
execSync(`tar xzf dompurify-${PINS['dompurify']}.tgz`, { cwd: tmp });
|
||||
copyFileSync(
|
||||
join(tmp, 'package', 'dist', 'purify.min.js'),
|
||||
join(OUT, 'dompurify.min.js'),
|
||||
);
|
||||
log(`wrote ${join(OUT, 'dompurify.min.js')}`);
|
||||
|
||||
// 5. MANIFEST — record the vendored versions for audit
|
||||
const manifest = {
|
||||
generated_at: new Date().toISOString(),
|
||||
pins: PINS,
|
||||
highlight_languages: HL_LANGS,
|
||||
output_files: [
|
||||
'markdown-it.min.js',
|
||||
'markdown-it-front-matter.min.js',
|
||||
'highlight.min.js',
|
||||
'dompurify.min.js',
|
||||
],
|
||||
};
|
||||
writeFileSync(
|
||||
join(OUT, 'VENDOR-MANIFEST.json'),
|
||||
JSON.stringify(manifest, null, 2) + '\n',
|
||||
);
|
||||
log(`wrote ${join(OUT, 'VENDOR-MANIFEST.json')}`);
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
log('done');
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap a CommonJS module body (uses `module.exports = ...`) in an IIFE
|
||||
* that exposes the export as a global on `window` (browser) or
|
||||
* `globalThis` (Node).
|
||||
*/
|
||||
function wrapCommonJS(globalName, src) {
|
||||
return [
|
||||
`// vendored by scripts/vendor-playground-libs.mjs — DO NOT EDIT`,
|
||||
`// global: ${globalName}`,
|
||||
`(function (root, factory) {`,
|
||||
` var __mod = { exports: {} };`,
|
||||
` (function (module, exports) {`,
|
||||
` ${src.replace(/\n/g, '\n ')}`,
|
||||
` })(__mod, __mod.exports);`,
|
||||
` root[${JSON.stringify(globalName)}] = __mod.exports;`,
|
||||
`})(typeof window !== 'undefined' ? window : globalThis);`,
|
||||
``,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Assemble a self-contained highlight.js IIFE with core + N languages.
|
||||
*
|
||||
* Output exposes `window.hljs` (and `globalThis.hljs` under Node).
|
||||
*/
|
||||
function assembleHighlight(coreSrc, langSrcs) {
|
||||
const parts = [
|
||||
`// vendored by scripts/vendor-playground-libs.mjs — DO NOT EDIT`,
|
||||
`// global: hljs (highlight.js@${PINS['highlight.js']} — core + ${langSrcs.map(l => l.lang).join('/')})`,
|
||||
`(function (root) {`,
|
||||
` function loadCommonJS(src) {`,
|
||||
` var __mod = { exports: {} };`,
|
||||
` var fn = new Function('module', 'exports', src);`,
|
||||
` fn(__mod, __mod.exports);`,
|
||||
` return __mod.exports;`,
|
||||
` }`,
|
||||
` var coreSrc = ${JSON.stringify(coreSrc)};`,
|
||||
` var hljs = loadCommonJS(coreSrc);`,
|
||||
];
|
||||
for (const { lang, src } of langSrcs) {
|
||||
parts.push(` var lang_${lang.replace(/\W/g, '_')} = loadCommonJS(${JSON.stringify(src)});`);
|
||||
parts.push(` hljs.registerLanguage(${JSON.stringify(lang)}, lang_${lang.replace(/\W/g, '_')});`);
|
||||
}
|
||||
parts.push(` root.hljs = hljs;`);
|
||||
parts.push(`})(typeof window !== 'undefined' ? window : globalThis);`);
|
||||
parts.push('');
|
||||
return parts.join('\n');
|
||||
}
|
||||
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
vendor();
|
||||
}
|
||||
|
||||
export { vendor, wrapCommonJS, assembleHighlight };
|
||||
Loading…
Add table
Add a link
Reference in a new issue