Files
md2html/md2html.js
2026-04-10 11:09:26 -07:00

390 lines
12 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
/**
* md2html — Convert Markdown files to styled HTML (and optionally PDF)
* with sidebar TOC + Mermaid diagrams.
*
* Usage:
* node md2html.js <input.md> [output.html]
* node md2html.js <input.md> --pdf [output.pdf]
*
* If output is omitted, writes to <input>.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 <input.md> [output.html]");
console.log(" node md2html.js <input.md> --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 <pre class="mermaid"> for clientside rendering
const renderer = new marked.Renderer();
const defaultCode = renderer.code.bind(renderer);
renderer.code = function ({ text, lang }) {
if (lang === "mermaid") {
return `<pre class="mermaid">${escapeHtml(text)}</pre>\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 `<h${depth} id="${slug}">${text}</h${depth}>\n`;
};
function escapeHtml(s) {
return s
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
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 += "<ul>";
} else if (item.depth < prevDepth) {
for (let i = item.depth; i < prevDepth; i++) html += "</ul>";
}
html += `<li><a href="#${item.slug}">${escapeHtml(item.text)}</a></li>\n`;
prevDepth = item.depth;
}
for (let i = 0; i < prevDepth; i++) html += "</ul>";
return html;
}
const tocHtml = buildToc(tocItems);
const docTitle = tocItems.length > 0 ? tocItems[0].text : "Document";
// ── Full HTML template ────────────────────────────────────────────────────────
const html = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${escapeHtml(docTitle)}</title>
<style>
/* ── Reset & base ──────────────────────────────────────────────────── */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html { font-size: 16px; scroll-behavior: smooth; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
color: #24292f;
background: #ffffff;
line-height: 1.6;
display: flex;
min-height: 100vh;
}
/* ── Sidebar TOC ───────────────────────────────────────────────────── */
#toc-sidebar {
position: fixed;
top: 0; left: 0;
width: 280px;
height: 100vh;
overflow-y: auto;
background: #f6f8fa;
border-right: 1px solid #d0d7de;
padding: 20px 16px;
font-size: 0.85rem;
z-index: 10;
}
#toc-sidebar ul {
list-style: none;
padding-left: 0;
}
#toc-sidebar ul ul {
padding-left: 16px;
}
#toc-sidebar li {
margin: 3px 0;
}
#toc-sidebar a {
color: #0969da;
text-decoration: none;
display: block;
padding: 2px 6px;
border-radius: 4px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
#toc-sidebar a:hover {
background: #ddf4ff;
}
#toc-sidebar a.active {
background: #0969da;
color: #fff;
}
#toc-toggle {
display: none;
position: fixed;
top: 12px; left: 12px;
z-index: 20;
background: #f6f8fa;
border: 1px solid #d0d7de;
border-radius: 6px;
padding: 6px 10px;
cursor: pointer;
font-size: 1.1rem;
}
/* ── Main content ──────────────────────────────────────────────────── */
#content {
margin-left: 280px;
max-width: 920px;
padding: 40px 48px 80px;
width: 100%;
}
/* ── Typography ────────────────────────────────────────────────────── */
h1, h2, h3, h4, h5, h6 {
margin-top: 1.8em;
margin-bottom: 0.6em;
font-weight: 600;
line-height: 1.25;
color: #1f2328;
}
h1 { font-size: 2em; border-bottom: 1px solid #d0d7de; padding-bottom: 0.3em; }
h2 { font-size: 1.5em; border-bottom: 1px solid #d0d7de; padding-bottom: 0.3em; }
h3 { font-size: 1.25em; }
h1:first-child { margin-top: 0; }
p { margin: 0 0 1em; }
a { color: #0969da; text-decoration: none; }
a:hover { text-decoration: underline; }
strong { font-weight: 600; }
hr { border: none; border-top: 1px solid #d0d7de; margin: 2em 0; }
/* ── Lists ─────────────────────────────────────────────────────────── */
ul, ol { margin: 0 0 1em 2em; }
li + li { margin-top: 0.25em; }
/* ── Tables ────────────────────────────────────────────────────────── */
table {
border-collapse: collapse;
width: 100%;
margin: 0 0 1em;
font-size: 0.9rem;
}
th, td {
border: 1px solid #d0d7de;
padding: 8px 12px;
text-align: left;
}
th {
background: #f6f8fa;
font-weight: 600;
}
tr:nth-child(even) { background: #f6f8fa; }
/* ── Code blocks ───────────────────────────────────────────────────── */
code {
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
font-size: 0.875em;
background: #f6f8fa;
padding: 0.2em 0.4em;
border-radius: 4px;
}
pre {
background: #f6f8fa;
border: 1px solid #d0d7de;
border-radius: 6px;
padding: 16px;
overflow-x: auto;
margin: 0 0 1em;
line-height: 1.45;
}
pre code {
background: none;
padding: 0;
font-size: 0.85rem;
}
/* ── Mermaid ───────────────────────────────────────────────────────── */
pre.mermaid {
background: #fff;
border: 1px solid #d0d7de;
text-align: center;
padding: 20px;
}
/* ── Responsive ────────────────────────────────────────────────────── */
@media (max-width: 900px) {
#toc-sidebar { transform: translateX(-100%); transition: transform 0.25s; }
#toc-sidebar.open { transform: translateX(0); }
#toc-toggle { display: block; }
#content { margin-left: 0; padding: 40px 24px 80px; }
}
/* ── Print ─────────────────────────────────────────────────────────── */
@media print {
#toc-sidebar, #toc-toggle { display: none; }
#content { margin-left: 0; max-width: 100%; padding: 0; }
pre.mermaid { border: none; }
h1, h2 { break-after: avoid; }
table { break-inside: avoid; }
}
</style>
</head>
<body>
<!-- TOC toggle button (mobile) -->
<button id="toc-toggle" onclick="document.getElementById('toc-sidebar').classList.toggle('open')" aria-label="Toggle table of contents">&#9776;</button>
<!-- Sidebar -->
<nav id="toc-sidebar">
${tocHtml}
</nav>
<!-- Main content -->
<main id="content">
${bodyHtml}
</main>
<!-- Mermaid.js (loaded from CDN) -->
<script src="https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.min.js"></script>
<script>
mermaid.initialize({
startOnLoad: true,
theme: "default",
flowchart: { useMaxWidth: true, htmlLabels: true, curve: "basis" },
securityLevel: "loose"
});
</script>
<!-- Active TOC highlight on scroll -->
<script>
(function () {
const links = document.querySelectorAll("#toc-sidebar a");
const headings = [];
links.forEach(function (a) {
const id = a.getAttribute("href").slice(1);
const el = document.getElementById(id);
if (el) headings.push({ el: el, a: a });
});
if (headings.length === 0) return;
function onScroll() {
let current = headings[0];
for (const h of headings) {
if (h.el.getBoundingClientRect().top <= 80) current = h;
}
links.forEach(function (a) { a.classList.remove("active"); });
current.a.classList.add("active");
}
window.addEventListener("scroll", onScroll, { passive: true });
onScroll();
})();
</script>
</body>
</html>`;
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(
/<script src="https:\/\/cdn\.jsdelivr\.net\/npm\/mermaid@[^"]*"><\/script>/,
""
);
await page.setContent(htmlForPdf, {
waitUntil: "domcontentloaded",
});
// Check if there are mermaid diagrams, and if so inject local mermaid + render
const hasMermaid = await page.evaluate(
() => document.querySelectorAll("pre.mermaid").length > 0
);
if (hasMermaid) {
const mermaidJs = fs.readFileSync(
path.join(__dirname, "mermaid.min.js"),
"utf-8"
);
await page.addScriptTag({ content: mermaidJs });
await page.evaluate(() => {
mermaid.initialize({
startOnLoad: false,
theme: "default",
flowchart: { useMaxWidth: true, htmlLabels: true, curve: "basis" },
securityLevel: "loose",
});
return mermaid.run({ querySelector: "pre.mermaid" });
});
}
await page.pdf({
path: outputPath,
format: "A4",
printBackground: true,
margin: { top: "20mm", bottom: "20mm", left: "15mm", right: "15mm" },
});
await browser.close();
console.log(`${outputPath}`);
})();
} else {
fs.writeFileSync(outputPath, html, "utf-8");
console.log(`${outputPath}`);
}