chore(voyage): release v5.0.1 — drop standalone HTML render; print literal /playground document-critique invocation

The v5.0.0 stop-gap had /trekbrief, /trekplan, and /trekreview each render
a read-only {artifact}.html (via scripts/render-artifact.mjs) AND print a
vague "run the /playground plugin" instruction. In practice the read-only
HTML was redundant with what /playground produces and the instruction
wasn't copy-paste-ready — the operator had to guess the right invocation.

v5.0.1 deletes scripts/render-artifact.mjs + its test + npm run render,
and makes each producing command end with a single boxed, literal,
copy-paste-ready line:

    /playground build a document-critique playground for {artifact_path}

One paste from the operator launches the official playground skill's
document-critique template, which builds an interactive HTML — artifact
on the left, per-line Approve/Reject/Comment cards on the right, Copy
Prompt button at the bottom. Mark suggestions, click Copy Prompt, paste
back, Claude revises the .md. Doc-consistency test pins the literal
invocation so the prose cannot soften back into vagueness.

npm test green: 503 tests, 501 pass, 0 fail, 2 skipped.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-05-13 13:24:32 +02:00
commit 2e0892cdaf
15 changed files with 206 additions and 563 deletions

View file

@ -1,321 +0,0 @@
#!/usr/bin/env node
// scripts/render-artifact.mjs
//
// 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 <artifact.md> [--out <output.html>]
//
// Determinism: no timestamps, no random IDs — two runs on the same input
// produce byte-identical output.
//
// 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 { basename } from 'node:path';
import { splitFrontmatter } from '../lib/util/frontmatter.mjs';
// ---------------------------------------------------------------------------
function escapeHtml(s) {
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
// 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) {
if (!existsSync(inputPath)) {
process.stderr.write(`render-artifact: input not found: ${inputPath}\n`);
process.exit(2);
}
const text = readFileSync(inputPath, 'utf-8');
const html = buildHtml(inputPath, text);
const out = outputPath || inputPath.replace(/\.md$/, '.html');
writeFileSync(out, html);
return out;
}
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 <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);
}
const out = render(args.input, args.out);
process.stdout.write(out + '\n');
}
export { render, buildHtml, renderMarkdown, parseArgs };