#!/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 [--out ] // // 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, '&') .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) => `${c}`); out = out.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (_, text, href) => { const safe = /^(https?:|mailto:|#|\.|\/)/i.test(href) ? href : '#'; return `${text}`; }); out = out.replace(/\*\*([^*]+)\*\*/g, (_, c) => `${c}`); out = out.replace(/(^|[^*])\*([^*\s][^*]*?)\*(?!\*)/g, (_, pre, c) => `${pre}${c}`); 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 = '\n'; for (const h of header) html += ``; html += '\n\n'; for (const r of body) { html += ''; for (let i = 0; i < header.length; i++) html += ``; html += '\n'; } return html + '\n
${renderInline(escapeHtml(h))}
${renderInline(escapeHtml(r[i] || ''))}
\n'; } // Build nested '; } if (!stack.length || indent > stack[stack.length - 1].indent) { html += ordered ? '
    ' : '
      '; stack.push({ indent, ordered }); } else { html += ''; } html += `
    • ${renderInline(escapeHtml(text))}`; } while (stack.length) { const top = stack.pop(); html += top.ordered ? '
' : ''; } 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 += `

${renderInline(escapeHtml(para.join(' ')))}

\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 += `
${escapeHtml(buf.join('\n'))}\n
\n`; continue; } // ATX heading const h = line.match(/^(#{1,6})\s+(.*?)\s*#*\s*$/); if (h) { flushPara(); const lvl = h[1].length; html += `${renderInline(escapeHtml(h[2]))}\n`; i++; continue; } // Horizontal rule if (/^\s*([-*_])(\s*\1){2,}\s*$/.test(line)) { flushPara(); html += '
\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 += `
${renderInline(escapeHtml(buf.join(' ')))}
\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 ? `
Frontmatter
${escapeHtml(fm)}\n
\n` : ''; const bodyHtml = renderMarkdown(body); return '\n' + '\n\n\n' + '\n' + `${escapeHtml(String(title))}\n` + `\n\n\n
\n` + `

voyage artifact — ${escapeHtml(String(kind))}

\n` + fmBlock + bodyHtml + '
\n\n\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 [--out ]\n\n' + 'Renders a voyage artifact to a self-contained HTML file (zero network).\n' + 'Default output: .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 };