Accepts raw markdown body, format via query param: curl --data-binary @file.md host/api/convert > out.html curl --data-binary @file.md host/api/convert?format=pdf -o out.pdf Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
290 lines
8.8 KiB
JavaScript
290 lines
8.8 KiB
JavaScript
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;
|
|
}
|
|
|
|
// curl-friendly API: accepts raw markdown body, format via query param or Accept header
|
|
// Usage:
|
|
// curl -X POST --data-binary @file.md https://host/api/convert > output.html
|
|
// curl -X POST --data-binary @file.md https://host/api/convert?format=pdf -o output.pdf
|
|
if (req.method === "POST" && req.url.startsWith("/api/convert")) {
|
|
try {
|
|
const url = new URL(req.url, `http://${req.headers.host}`);
|
|
const formatParam = url.searchParams.get("format") || "html";
|
|
const markdown = await parseBody(req);
|
|
|
|
if (!markdown.trim()) {
|
|
res.writeHead(400, { "Content-Type": "text/plain" });
|
|
res.end("Empty body — POST raw markdown text");
|
|
return;
|
|
}
|
|
|
|
const html = markdownToHtml(markdown);
|
|
|
|
if (formatParam === "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("API 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}`);
|
|
});
|