Multi-Tab GitHub-Flavored Markdown (GFM) Live Previewer & PDF Exporter
Upload a local folder of .md files and preview them instantly with 1:1 GitHub visual parity — then export any tab to a high-fidelity PDF.
- Folder Upload — drag-and-drop or browse to upload any folder; all
.mdfiles are opened as tabs - Multi-Tab SPA — navigate between files with a browser-style tab bar; close individual tabs
- GitHub-Style Rendering — full GFM support via
react-markdown+remark-gfm:- Tables, task-list checkboxes, strikethrough, autolinks
- Fenced code blocks with syntax highlighting (
highlight.js) - Jekyll/front-matter stripping (
remark-frontmatter) - Raw HTML passthrough (e.g. badges,
<details>, custom fonts) - Sanitised with
rehype-sanitizeto prevent XSS
- shields.io badges render correctly via HTML passthrough
- Export to PDF — headless-browser PDF via Playwright preserving all CSS styles
- API Cross-platform Support unlimited api calls to converting .md files
- Ephemeral Storage — server-side uploads stored in
/tmp/; a cron job purges sessions older than 24 hours - Privacy — client-side-only mode: files are read directly in the browser and never uploaded unless you call the upload API
| Layer | Choice |
|---|---|
| Framework | Next.js 16 (App Router, TypeScript) |
| Styling | Tailwind CSS v4 + GitHub Primer-inspired markdown CSS |
| Markdown | react-markdown, remark-gfm, remark-frontmatter, rehype-raw, rehype-sanitize, rehype-highlight |
| PDF/PNG Rendering | puppeteer-core + @sparticuz/chromium |
| Cleanup | node-cron — hourly sweep of sessions older than 24 h |
# 1. Install dependencies
npm install
# 2. (Optional) Install Playwright browser for PDF export
npx playwright install chromium
# 3. Start development server
npm run devOpen http://localhost:3000 in your browser.
npm run build
npm start| Method | Path | Description |
|---|---|---|
POST |
/api/upload |
Upload folder files (multipart/form-data: files[] + paths[]) |
GET |
/api/list-files?sessionId=<id> |
List .md files in a session |
GET |
/api/file-content?sessionId=<id>&path=<rel> |
Fetch file contents |
POST |
/api/export-pdf |
Convert rendered HTML to PDF |
POST |
/api/export-png-pages |
Convert rendered HTML to a ZIP of page-sliced PNGs |
POST |
/api/convert-md |
Upload one .md file and export directly to PDF or PNG |
Upload a single markdown file and export in one request. This route is designed for browser clients, curl, Python scripts, CI jobs, and other HTTP clients.
Request (multipart/form-data):
file(required): one.mdfile, max 5 MBformat(required):pdforpngpngMode(optional):pages(default, returns.zip) orsingle(returns one.png)
Response content types:
format=pdf->application/pdfformat=png&pngMode=pages->application/zipformat=png&pngMode=single->image/png
You should replace 'your-domain.com' to your deployment server or a vercel server (me-reader-gray.vercel.app)
# PDF
curl -L -X POST "https://your-domain.com/api/convert-md" \
-F "file=@README.md" \
-F "format=pdf" \
-o README.pdf
# PNG pages (zip)
curl -L -X POST "https://your-domain.com/api/convert-md" \
-F "file=@README.md" \
-F "format=png" \
-F "pngMode=pages" \
-o README_png_pages.zip
# Single PNG
curl -L -X POST "https://your-domain.com/api/convert-md" \
-F "file=@README.md" \
-F "format=png" \
-F "pngMode=single" \
-o README.pngimport requests
url = "https://your-domain.com/api/convert-md"
with open("README.md", "rb") as f:
files = {"file": ("README.md", f, "text/markdown")}
data = {"format": "pdf"}
response = requests.post(url, files=files, data=data, timeout=120)
response.raise_for_status()
with open("README.pdf", "wb") as out:
out.write(response.content)There is a renderer component in MarkdownPreview.tsx. Below is an example.
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Live MD Renderer</title>
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<style>
body {
margin: 0;
background: #f6f8fa;
font-family: Georgia, "Times New Roman", serif;
}
#preview {
max-width: 900px;
margin: 32px auto;
padding: 24px 28px;
background: #ffffff;
border: 1px solid #d0d7de;
border-radius: 10px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.06);
line-height: 1.6;
}
</style>
</head>
<body>
<div id="preview">Loading rendered.md...</div>
<script>
const preview = document.getElementById("preview");
let lastText = "";
async function renderMarkdown() {
try {
const response = await fetch("/rendered.md?t=" + Date.now(), { cache: "no-store" });
if (!response.ok) throw new Error("Cannot load rendered.md");
const markdown = await response.text();
if (markdown !== lastText) {
preview.innerHTML = marked.parse(markdown);
lastText = markdown;
}
} catch (error) {
preview.textContent = "Error: " + error.message;
}
}
renderMarkdown();
setInterval(renderMarkdown, 1000);
</script>
</body>
</html>
The route is standard HTTP and works over HTTPS automatically when deployed behind TLS (for example Vercel, Nginx, Cloudflare, or other reverse proxies). Use https:// URLs in Python/curl clients in production.
Uploaded files are stored in /tmp/<sessionId>/. The server runs a cron job every hour that deletes any session directory whose .meta timestamp is older than 24 hours.
See the project_tree.txt for update versions of the project structure.