390 lines
12 KiB
JavaScript
390 lines
12 KiB
JavaScript
#!/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 client‑side 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, "&")
|
||
.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 += "<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">☰</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}`);
|
||
}
|