#!/usr/bin/env node /** * md2html — Convert Markdown files to styled HTML (and optionally PDF) * with sidebar TOC + Mermaid diagrams. * * Usage: * node md2html.js [output.html] * node md2html.js --pdf [output.pdf] * * If output is omitted, writes to .html (or .pdf) alongside the source file. */ const fs = require("fs"); const path = require("path"); const { marked } = require("marked"); // ── CLI args ────────────────────────────────────────────────────────────────── const args = process.argv.slice(2); if (args.length === 0 || args.includes("--help") || args.includes("-h")) { console.log("Usage: node md2html.js [output.html]"); console.log(" node md2html.js --pdf [output.pdf]"); process.exit(0); } const pdfMode = args.includes("--pdf"); const filteredArgs = args.filter((a) => a !== "--pdf"); const inputPath = path.resolve(filteredArgs[0]); const defaultExt = pdfMode ? ".pdf" : ".html"; const outputPath = filteredArgs[1] ? path.resolve(filteredArgs[1]) : inputPath.replace(/\.md$/i, defaultExt); if (!fs.existsSync(inputPath)) { console.error(`Error: file not found — ${inputPath}`); process.exit(1); } const md = fs.readFileSync(inputPath, "utf-8"); // ── Marked setup ────────────────────────────────────────────────────────────── // Custom renderer: leave mermaid code blocks as
 for client‑side rendering
const renderer = new marked.Renderer();
const defaultCode = renderer.code.bind(renderer);

renderer.code = function ({ text, lang }) {
  if (lang === "mermaid") {
    return `
${escapeHtml(text)}
\n`; } return defaultCode({ text, lang }); }; // Collect headings for TOC while rendering const tocItems = []; renderer.heading = function ({ text, depth }) { const slug = text .toLowerCase() .replace(/<[^>]*>/g, "") .replace(/[^\w\s-]/g, "") .replace(/\s+/g, "-") .replace(/-+/g, "-") .trim(); tocItems.push({ text: stripHtml(text), depth, slug }); return `${text}\n`; }; function escapeHtml(s) { return s .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """); } function stripHtml(s) { return s.replace(/<[^>]*>/g, ""); } marked.setOptions({ renderer, gfm: true, breaks: false }); const bodyHtml = marked.parse(md); // ── Build TOC HTML ──────────────────────────────────────────────────────────── function buildToc(items) { if (items.length === 0) return ""; let html = ""; let prevDepth = 0; for (const item of items) { if (item.depth > prevDepth) { for (let i = prevDepth; i < item.depth; i++) html += "
    "; } else if (item.depth < prevDepth) { for (let i = item.depth; i < prevDepth; i++) html += "
"; } html += `
  • ${escapeHtml(item.text)}
  • \n`; prevDepth = item.depth; } for (let i = 0; i < prevDepth; i++) html += ""; return html; } const tocHtml = buildToc(tocItems); const docTitle = tocItems.length > 0 ? tocItems[0].text : "Document"; // ── Full HTML template ──────────────────────────────────────────────────────── const html = ` ${escapeHtml(docTitle)}
    ${bodyHtml}
    `; if (pdfMode) { (async () => { const puppeteer = require("puppeteer-core"); const browser = await puppeteer.launch({ executablePath: process.env.CHROME_PATH || "/usr/bin/google-chrome-stable", args: ["--no-sandbox", "--disable-setuid-sandbox"], }); const page = await browser.newPage(); // Capture console for debugging page.on("console", (msg) => { if (msg.type() === "error") console.error(" [browser]", msg.text()); }); // Load the HTML content directly (no CDN scripts — we inject mermaid below) // Strip the CDN mermaid script so we can inject it ourselves via puppeteer const htmlForPdf = html.replace( /