Add web service, PDF export, and Docker support
- Refactor conversion logic into reusable convert.js module - Add HTTP server with upload UI, HTML/PDF download, and live preview - Dockerfile with Chromium for server-side PDF rendering - Local mermaid.min.js injection for offline diagram rendering Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
3
.dockerignore
Normal file
3
.dockerignore
Normal file
@@ -0,0 +1,3 @@
|
||||
node_modules
|
||||
*.html
|
||||
*.pdf
|
||||
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
node_modules/
|
||||
*.html
|
||||
*.pdf
|
||||
!server.js
|
||||
!convert.js
|
||||
!md2html.js
|
||||
11
Dockerfile
11
Dockerfile
@@ -1,6 +1,5 @@
|
||||
FROM node:22-slim
|
||||
|
||||
# Install Chrome dependencies
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
chromium \
|
||||
fonts-freefont-ttf \
|
||||
@@ -12,8 +11,14 @@ WORKDIR /app
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci --omit=dev
|
||||
|
||||
COPY md2html.js mermaid.min.js ./
|
||||
COPY convert.js server.js md2html.js mermaid.min.js ./
|
||||
|
||||
ENV CHROME_PATH=/usr/bin/chromium
|
||||
ENV PORT=3000
|
||||
|
||||
ENTRYPOINT ["node", "md2html.js"]
|
||||
EXPOSE 3000
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
|
||||
CMD node -e "require('http').get('http://localhost:3000/health', r => process.exit(r.statusCode === 200 ? 0 : 1))"
|
||||
|
||||
CMD ["node", "server.js"]
|
||||
|
||||
277
convert.js
Normal file
277
convert.js
Normal file
@@ -0,0 +1,277 @@
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const { marked } = require("marked");
|
||||
|
||||
function escapeHtml(s) {
|
||||
return s
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
function stripHtml(s) {
|
||||
return s.replace(/<[^>]*>/g, "");
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
function markdownToHtml(md) {
|
||||
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 });
|
||||
};
|
||||
|
||||
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`;
|
||||
};
|
||||
|
||||
marked.setOptions({ renderer, gfm: true, breaks: false });
|
||||
const bodyHtml = marked.parse(md);
|
||||
const tocHtml = buildToc(tocItems);
|
||||
const docTitle = tocItems.length > 0 ? tocItems[0].text : "Document";
|
||||
|
||||
return `<!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>
|
||||
*, *::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;
|
||||
}
|
||||
#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;
|
||||
}
|
||||
#content {
|
||||
margin-left: 280px;
|
||||
max-width: 920px;
|
||||
padding: 40px 48px 80px;
|
||||
width: 100%;
|
||||
}
|
||||
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; }
|
||||
ul, ol { margin: 0 0 1em 2em; }
|
||||
li + li { margin-top: 0.25em; }
|
||||
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 {
|
||||
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; }
|
||||
pre.mermaid {
|
||||
background: #fff;
|
||||
border: 1px solid #d0d7de;
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
}
|
||||
@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; }
|
||||
}
|
||||
@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>
|
||||
<button id="toc-toggle" onclick="document.getElementById('toc-sidebar').classList.toggle('open')" aria-label="Toggle table of contents">☰</button>
|
||||
<nav id="toc-sidebar">
|
||||
${tocHtml}
|
||||
</nav>
|
||||
<main id="content">
|
||||
${bodyHtml}
|
||||
</main>
|
||||
<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>
|
||||
<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>`;
|
||||
}
|
||||
|
||||
async function htmlToPdf(html) {
|
||||
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();
|
||||
|
||||
const htmlForPdf = html.replace(
|
||||
/<script src="https:\/\/cdn\.jsdelivr\.net\/npm\/mermaid@[^"]*"><\/script>/,
|
||||
""
|
||||
);
|
||||
await page.setContent(htmlForPdf, { waitUntil: "domcontentloaded" });
|
||||
|
||||
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" });
|
||||
});
|
||||
}
|
||||
|
||||
const pdfBuffer = await page.pdf({
|
||||
format: "A4",
|
||||
printBackground: true,
|
||||
margin: { top: "20mm", bottom: "20mm", left: "15mm", right: "15mm" },
|
||||
});
|
||||
await browser.close();
|
||||
return pdfBuffer;
|
||||
}
|
||||
|
||||
module.exports = { markdownToHtml, htmlToPdf };
|
||||
252
server.js
Normal file
252
server.js
Normal file
@@ -0,0 +1,252 @@
|
||||
const http = require("http");
|
||||
const { markdownToHtml, htmlToPdf } = require("./convert");
|
||||
|
||||
const PORT = process.env.PORT || 3000;
|
||||
|
||||
const UPLOAD_PAGE = `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Markdown Converter</title>
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
|
||||
background: #f6f8fa;
|
||||
color: #24292f;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 40px 20px;
|
||||
min-height: 100vh;
|
||||
}
|
||||
.container { max-width: 800px; width: 100%; }
|
||||
h1 { font-size: 1.8em; margin-bottom: 8px; }
|
||||
p.sub { color: #656d76; margin-bottom: 24px; }
|
||||
.card {
|
||||
background: #fff;
|
||||
border: 1px solid #d0d7de;
|
||||
border-radius: 8px;
|
||||
padding: 24px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
label { display: block; font-weight: 600; margin-bottom: 8px; }
|
||||
textarea {
|
||||
width: 100%;
|
||||
height: 300px;
|
||||
font-family: "SFMono-Regular", Consolas, monospace;
|
||||
font-size: 0.9rem;
|
||||
padding: 12px;
|
||||
border: 1px solid #d0d7de;
|
||||
border-radius: 6px;
|
||||
resize: vertical;
|
||||
}
|
||||
.actions { display: flex; gap: 12px; margin-top: 16px; flex-wrap: wrap; }
|
||||
.btn {
|
||||
padding: 10px 20px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.btn-primary { background: #0969da; color: #fff; }
|
||||
.btn-primary:hover { background: #0860ca; }
|
||||
.btn-secondary { background: #24292f; color: #fff; }
|
||||
.btn-secondary:hover { background: #32383f; }
|
||||
.btn-outline { background: #fff; color: #24292f; border: 1px solid #d0d7de; }
|
||||
.btn-outline:hover { background: #f6f8fa; }
|
||||
.or { color: #656d76; padding: 0 4px; font-size: 0.85rem; }
|
||||
.drop-zone {
|
||||
border: 2px dashed #d0d7de;
|
||||
border-radius: 6px;
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
color: #656d76;
|
||||
margin-bottom: 16px;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s, background 0.2s;
|
||||
}
|
||||
.drop-zone.over { border-color: #0969da; background: #ddf4ff; }
|
||||
.drop-zone input { display: none; }
|
||||
.status { margin-top: 12px; font-size: 0.85rem; color: #656d76; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>Markdown Converter</h1>
|
||||
<p class="sub">Convert Markdown to styled HTML or PDF with TOC and Mermaid diagrams</p>
|
||||
|
||||
<div class="card">
|
||||
<label>Upload a .md file</label>
|
||||
<div class="drop-zone" id="dropZone">
|
||||
<p>Drag & drop a .md file here, or click to browse</p>
|
||||
<input type="file" id="fileInput" accept=".md,.markdown,.txt">
|
||||
</div>
|
||||
|
||||
<label>Or paste Markdown</label>
|
||||
<textarea id="mdInput" placeholder="# Hello World Your markdown here..."></textarea>
|
||||
|
||||
<div class="actions">
|
||||
<button class="btn btn-primary" onclick="convert('html')">Convert to HTML</button>
|
||||
<button class="btn btn-secondary" onclick="convert('pdf')">Convert to PDF</button>
|
||||
<button class="btn btn-outline" onclick="preview()">Preview</button>
|
||||
</div>
|
||||
<div class="status" id="status"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const dropZone = document.getElementById('dropZone');
|
||||
const fileInput = document.getElementById('fileInput');
|
||||
const mdInput = document.getElementById('mdInput');
|
||||
const status = document.getElementById('status');
|
||||
|
||||
dropZone.addEventListener('click', () => fileInput.click());
|
||||
dropZone.addEventListener('dragover', (e) => { e.preventDefault(); dropZone.classList.add('over'); });
|
||||
dropZone.addEventListener('dragleave', () => dropZone.classList.remove('over'));
|
||||
dropZone.addEventListener('drop', (e) => {
|
||||
e.preventDefault();
|
||||
dropZone.classList.remove('over');
|
||||
const file = e.dataTransfer.files[0];
|
||||
if (file) readFile(file);
|
||||
});
|
||||
fileInput.addEventListener('change', () => {
|
||||
if (fileInput.files[0]) readFile(fileInput.files[0]);
|
||||
});
|
||||
|
||||
function readFile(file) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
mdInput.value = e.target.result;
|
||||
status.textContent = 'Loaded: ' + file.name;
|
||||
};
|
||||
reader.readAsText(file);
|
||||
}
|
||||
|
||||
async function convert(format) {
|
||||
const md = mdInput.value.trim();
|
||||
if (!md) { status.textContent = 'Please enter some markdown first.'; return; }
|
||||
|
||||
status.textContent = 'Converting to ' + format.toUpperCase() + '...';
|
||||
try {
|
||||
const res = await fetch('/convert', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ markdown: md, format })
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
|
||||
const blob = await res.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'output.' + (format === 'pdf' ? 'pdf' : 'html');
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
status.textContent = 'Done!';
|
||||
} catch (err) {
|
||||
status.textContent = 'Error: ' + err.message;
|
||||
}
|
||||
}
|
||||
|
||||
async function preview() {
|
||||
const md = mdInput.value.trim();
|
||||
if (!md) { status.textContent = 'Please enter some markdown first.'; return; }
|
||||
|
||||
status.textContent = 'Generating preview...';
|
||||
try {
|
||||
const res = await fetch('/convert', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ markdown: md, format: 'html' })
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
const html = await res.text();
|
||||
const w = window.open('', '_blank');
|
||||
w.document.write(html);
|
||||
w.document.close();
|
||||
status.textContent = 'Preview opened in new tab.';
|
||||
} catch (err) {
|
||||
status.textContent = 'Error: ' + err.message;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
function parseBody(req) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const chunks = [];
|
||||
let size = 0;
|
||||
const limit = 10 * 1024 * 1024; // 10MB
|
||||
req.on("data", (chunk) => {
|
||||
size += chunk.length;
|
||||
if (size > limit) {
|
||||
reject(new Error("Request body too large"));
|
||||
req.destroy();
|
||||
return;
|
||||
}
|
||||
chunks.push(chunk);
|
||||
});
|
||||
req.on("end", () => resolve(Buffer.concat(chunks).toString()));
|
||||
req.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
const server = http.createServer(async (req, res) => {
|
||||
if (req.method === "GET" && (req.url === "/" || req.url === "")) {
|
||||
res.writeHead(200, { "Content-Type": "text/html" });
|
||||
res.end(UPLOAD_PAGE);
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === "POST" && req.url === "/convert") {
|
||||
try {
|
||||
const body = JSON.parse(await parseBody(req));
|
||||
const { markdown, format } = body;
|
||||
|
||||
if (!markdown) {
|
||||
res.writeHead(400, { "Content-Type": "text/plain" });
|
||||
res.end("Missing 'markdown' field");
|
||||
return;
|
||||
}
|
||||
|
||||
const html = markdownToHtml(markdown);
|
||||
|
||||
if (format === "pdf") {
|
||||
const pdfBuffer = await htmlToPdf(html);
|
||||
res.writeHead(200, {
|
||||
"Content-Type": "application/pdf",
|
||||
"Content-Disposition": "attachment; filename=output.pdf",
|
||||
});
|
||||
res.end(pdfBuffer);
|
||||
} else {
|
||||
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
||||
res.end(html);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Convert error:", err);
|
||||
res.writeHead(500, { "Content-Type": "text/plain" });
|
||||
res.end("Conversion failed: " + err.message);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === "GET" && req.url === "/health") {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ status: "ok" }));
|
||||
return;
|
||||
}
|
||||
|
||||
res.writeHead(404, { "Content-Type": "text/plain" });
|
||||
res.end("Not found");
|
||||
});
|
||||
|
||||
server.listen(PORT, () => {
|
||||
console.log(`md2html server listening on http://0.0.0.0:${PORT}`);
|
||||
});
|
||||
Reference in New Issue
Block a user