|
#!/usr/bin/env tsx |
|
/** |
|
* Renders a Markdown spec file to a self-contained HTML page. |
|
* |
|
* Conversation blocks use fenced code with the `msgs` language tag: |
|
* |
|
* ```msgs |
|
* User: Hello! |
|
* Bot: Hi there, how can I help? |
|
* ``` |
|
* |
|
* Participants are inferred from the "Name:" prefix on each line. |
|
* The first participant found is rendered on the right (outgoing), |
|
* everyone else on the left (incoming). Lines without a prefix are |
|
* continuation of the previous message. |
|
* |
|
* Usage: |
|
* tsx scripts/render-spec.ts <input.md> [output.html] |
|
* |
|
* If output is omitted, writes to <input>.html alongside the source. |
|
*/ |
|
|
|
import { readFileSync, writeFileSync } from "node:fs"; |
|
import { basename, dirname, join, extname } from "node:path"; |
|
import { Marked } from "marked"; |
|
|
|
// --------------------------------------------------------------------------- |
|
// Chat bubble renderer |
|
// --------------------------------------------------------------------------- |
|
|
|
interface ChatMessage { |
|
sender: string; |
|
lines: string[]; |
|
} |
|
|
|
function parseMsgsBlock(raw: string): ChatMessage[] { |
|
const messages: ChatMessage[] = []; |
|
for (const line of raw.split("\n")) { |
|
const match = line.match(/^([A-Za-z0-9_ ]+?):\s(.+)/); |
|
if (match) { |
|
messages.push({ sender: match[1].trim(), lines: [match[2]] }); |
|
} else if (line.trim() && messages.length > 0) { |
|
messages[messages.length - 1].lines.push(line); |
|
} |
|
} |
|
return messages; |
|
} |
|
|
|
function escapeHtml(s: string): string { |
|
return s |
|
.replace(/&/g, "&") |
|
.replace(/</g, "<") |
|
.replace(/>/g, ">") |
|
.replace(/"/g, """); |
|
} |
|
|
|
function renderMsgsHtml(code: string): string { |
|
const messages = parseMsgsBlock(code); |
|
if (messages.length === 0) return ""; |
|
|
|
// First sender = "outgoing" (right side, like the user) |
|
const outgoingSender = messages[0].sender; |
|
|
|
const bubbles = messages |
|
.map((msg) => { |
|
const isOutgoing = msg.sender === outgoingSender; |
|
const side = isOutgoing ? "outgoing" : "incoming"; |
|
const body = msg.lines.map(escapeHtml).join("<br>"); |
|
return `<div class="msgs-row msgs-${side}"><div class="msgs-bubble msgs-${side}"><div class="msgs-sender">${escapeHtml(msg.sender)}</div><div class="msgs-text">${body}</div></div></div>`; |
|
}) |
|
.join("\n"); |
|
|
|
return `<div class="msgs-chat">\n${bubbles}\n</div>`; |
|
} |
|
|
|
// --------------------------------------------------------------------------- |
|
// Marked setup with msgs extension |
|
// --------------------------------------------------------------------------- |
|
|
|
const marked = new Marked(); |
|
|
|
marked.use({ |
|
renderer: { |
|
code({ text, lang }: { text: string; lang?: string | null }) { |
|
if (lang === "msgs") { |
|
return renderMsgsHtml(text); |
|
} |
|
// Fall through to default |
|
return `<pre><code class="language-${escapeHtml(lang || "")}">${escapeHtml(text)}</code></pre>`; |
|
}, |
|
}, |
|
}); |
|
|
|
// --------------------------------------------------------------------------- |
|
// HTML template |
|
// --------------------------------------------------------------------------- |
|
|
|
function wrapHtml(title: string, body: string): string { |
|
return `<!DOCTYPE html> |
|
<html lang="en"> |
|
<head> |
|
<meta charset="utf-8"> |
|
<meta name="viewport" content="width=device-width, initial-scale=1"> |
|
<title>${escapeHtml(title)}</title> |
|
<style> |
|
/* --- Base --- */ |
|
:root { |
|
--bg: #ffffff; |
|
--fg: #1a1a1a; |
|
--muted: #6b7280; |
|
--border: #e5e7eb; |
|
--code-bg: #f3f4f6; |
|
--msgs-bg: #e8ecf0; |
|
--msgs-out-bg: #dcf8c6; |
|
--msgs-in-bg: #ffffff; |
|
--msgs-sender: #1a73e8; |
|
} |
|
@media (prefers-color-scheme: dark) { |
|
:root { |
|
--bg: #111111; |
|
--fg: #e5e5e5; |
|
--muted: #9ca3af; |
|
--border: #2a2a2a; |
|
--code-bg: #1e1e1e; |
|
--msgs-bg: #0e1621; |
|
--msgs-out-bg: #2b5278; |
|
--msgs-in-bg: #182533; |
|
--msgs-sender: #6ab2f2; |
|
} |
|
} |
|
* { box-sizing: border-box; margin: 0; padding: 0; } |
|
body { |
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; |
|
color: var(--fg); |
|
background: var(--bg); |
|
line-height: 1.65; |
|
max-width: 780px; |
|
margin: 0 auto; |
|
padding: 2rem 1.5rem 4rem; |
|
} |
|
h1 { font-size: 1.8rem; margin: 2rem 0 0.75rem; } |
|
h2 { font-size: 1.4rem; margin: 1.8rem 0 0.6rem; } |
|
h3 { font-size: 1.15rem; margin: 1.5rem 0 0.5rem; } |
|
p, ul, ol { margin: 0.6rem 0; } |
|
ul, ol { padding-left: 1.5rem; } |
|
a { color: var(--msgs-sender); } |
|
hr { border: none; border-top: 1px solid var(--border); margin: 1.5rem 0; } |
|
pre { |
|
background: var(--code-bg); |
|
border-radius: 6px; |
|
padding: 0.8rem 1rem; |
|
overflow-x: auto; |
|
font-size: 0.85rem; |
|
margin: 0.6rem 0; |
|
} |
|
code { |
|
font-family: "SF Mono", Menlo, Consolas, monospace; |
|
font-size: 0.88em; |
|
} |
|
p code { |
|
background: var(--code-bg); |
|
padding: 0.15em 0.35em; |
|
border-radius: 3px; |
|
} |
|
table { |
|
border-collapse: collapse; |
|
width: 100%; |
|
margin: 0.6rem 0; |
|
font-size: 0.92rem; |
|
} |
|
th, td { |
|
border: 1px solid var(--border); |
|
padding: 0.45rem 0.65rem; |
|
text-align: left; |
|
} |
|
th { background: var(--code-bg); font-weight: 600; } |
|
blockquote { |
|
border-left: 3px solid var(--border); |
|
padding-left: 1rem; |
|
color: var(--muted); |
|
margin: 0.6rem 0; |
|
} |
|
img { max-width: 100%; border-radius: 6px; } |
|
|
|
/* --- Chat bubbles --- */ |
|
.msgs-chat { |
|
background: var(--msgs-bg); |
|
border-radius: 12px; |
|
padding: 16px 12px; |
|
margin: 1rem 0; |
|
display: flex; |
|
flex-direction: column; |
|
gap: 6px; |
|
} |
|
.msgs-row { |
|
display: flex; |
|
} |
|
.msgs-row.msgs-outgoing { |
|
justify-content: flex-end; |
|
} |
|
.msgs-row.msgs-incoming { |
|
justify-content: flex-start; |
|
} |
|
.msgs-bubble { |
|
max-width: 75%; |
|
padding: 7px 12px 8px; |
|
border-radius: 12px; |
|
font-size: 0.9rem; |
|
line-height: 1.45; |
|
position: relative; |
|
} |
|
.msgs-bubble.msgs-outgoing { |
|
background: var(--msgs-out-bg); |
|
border-bottom-right-radius: 4px; |
|
} |
|
.msgs-bubble.msgs-incoming { |
|
background: var(--msgs-in-bg); |
|
border-bottom-left-radius: 4px; |
|
} |
|
.msgs-sender { |
|
font-size: 0.78rem; |
|
font-weight: 600; |
|
color: var(--msgs-sender); |
|
margin-bottom: 2px; |
|
} |
|
.msgs-text { |
|
word-break: break-word; |
|
} |
|
</style> |
|
</head> |
|
<body> |
|
${body} |
|
</body> |
|
</html>`; |
|
} |
|
|
|
// --------------------------------------------------------------------------- |
|
// Main |
|
// --------------------------------------------------------------------------- |
|
|
|
const inputPath = process.argv[2]; |
|
if (!inputPath) { |
|
console.error("Usage: tsx scripts/render-spec.ts <input.md> [output.html]"); |
|
process.exit(1); |
|
} |
|
|
|
const outputPath = |
|
process.argv[3] ?? |
|
join(dirname(inputPath), basename(inputPath, extname(inputPath)) + ".html"); |
|
|
|
const md = readFileSync(inputPath, "utf-8"); |
|
const body = marked.parse(md) as string; |
|
const title = md.match(/^#\s+(.+)/m)?.[1] ?? basename(inputPath, ".md"); |
|
|
|
writeFileSync(outputPath, wrapHtml(title, body)); |
|
console.log(`Written to ${outputPath}`); |