From 9c5046de7bfcd85b2615ef8cb7ca3e1455bd5c45 Mon Sep 17 00:00:00 2001 From: Alex Zaw Date: Fri, 10 Apr 2026 11:15:08 -0700 Subject: [PATCH] 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) --- .dockerignore | 3 + .gitignore | 6 ++ Dockerfile | 11 +- convert.js | 277 ++++++++++++++++++++++++++++++++++++++++++++++++++ server.js | 252 +++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 546 insertions(+), 3 deletions(-) create mode 100644 .dockerignore create mode 100644 .gitignore create mode 100644 convert.js create mode 100644 server.js diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..558b358 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,3 @@ +node_modules +*.html +*.pdf diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..46a5783 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +node_modules/ +*.html +*.pdf +!server.js +!convert.js +!md2html.js diff --git a/Dockerfile b/Dockerfile index 291cb46..b62f3b3 100644 --- a/Dockerfile +++ b/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"] diff --git a/convert.js b/convert.js new file mode 100644 index 0000000..0ceabfd --- /dev/null +++ b/convert.js @@ -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, """); +} + +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 += ""; + } + html += `
  • ${escapeHtml(item.text)}
  • \n`; + prevDepth = item.depth; + } + for (let i = 0; i < prevDepth; i++) html += ""; + 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 `
    ${escapeHtml(text)}
    \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 `${text}\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 ` + + + + +${escapeHtml(docTitle)} + + + + + +
    +${bodyHtml} +
    + + + + +`; +} + +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( + / + +`; + +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}`); +});