Every Markdown previewer I tried wanted something from me first. An upload. A signup. A desktop app install for what is, at its core, a text transformation.
So I built one that asks for nothing: paste Markdown, get a preview, upload zero bytes. It's one of the 30 free browser tools in my ToolkitLoop collection — full disclosure, it's my own project.
The interesting part wasn't the UI. It was the three problems hiding inside "just render some Markdown."
Problem 1: GFM tables will humble you
CommonMark is the easy part — headings, emphasis, lists. The pain starts with GitHub Flavored Markdown, specifically tables and task lists.
A table looks simple until you hit the edge cases: rows with mismatched cell counts, escaped pipes inside cells (\|), alignment rows with colons. A naive regex-based parser falls apart here. I checked — writing a correct table parser by hand is a weekend of suffering I didn't need.
The pragmatic answer is marked. It's a real, maintained parser with proper GFM support:
import { marked } from 'marked';
const input = document.getElementById('source');
const output = document.getElementById('preview');
function render() {
const html = marked.parse(input.value, {
gfm: true,
breaks: false
});
output.innerHTML = html;
}
Forty lines was aspirational; the honest count is a bit more once you add the rest. But the parsing itself is genuinely a few lines, because marked does the heavy lifting — tokenizing, handling the table edge cases, nested lists, all of it.
The lesson: don't hand-roll a Markdown parser in 2026. The spec edge cases are a tar pit. Use a real parser and spend your energy on the problems around it.
Problem 2: Markdown becomes HTML, and HTML bites
Here's the one people skip: marked.parse() returns HTML, and you inject it with innerHTML. That means your previewer is an XSS delivery mechanism unless you sanitize.
Markdown has plenty of HTML smuggling routes. A link like [click](javascript:alert(1)). An image with an onerror attribute in inline HTML. Markdown explicitly allows raw HTML passthrough, which is great for docs and terrible for a preview pane.
The fix is DOMPurify, applied to every render with no exceptions:
import DOMPurify from 'dompurify';
function render() {
const dirty = marked.parse(input.value, { gfm: true });
output.innerHTML = DOMPurify.sanitize(dirty);
}
This isn't optional hardening — it's the difference between a tool and a vulnerability. Any time you render user-supplied Markdown as HTML, sanitize the output. Every time. There is no "it's just a preview" exemption; the preview runs in the user's browser with the user's session.
One subtlety worth knowing: sanitizing is about the HTML output, not the Markdown input. Trying to blacklist "dangerous Markdown" is a losing game — the attack surface is the DOM, so that's where you defend.
Problem 3: Big documents turn keystrokes into slideshows
Re-rendering on every keystroke feels instant on a 50-line README. Paste a 10,000-line changelog and the same approach turns typing into a slideshow — parse, sanitize, and re-layout on each keypress is genuinely expensive.
The fix is a debounce, and it's the highest value-to-effort ratio in the whole project:
let timer;
input.addEventListener('input', () => {
clearTimeout(timer);
timer = setTimeout(render, 150);
});
150 milliseconds is the sweet spot I landed on: short enough that the preview still feels live, long enough that fast typing coalesces into a single render. For truly huge documents you could get fancier — incremental rendering, virtualized output — but debounce plus a fast parser covers everything I've thrown at it.
Bonus: warn before you load remote images
One more real-world gotcha: Markdown documents love remote images — . Rendering them means the reader's browser phones home to that server. For a preview tool, silently triggering those requests is rude at best.
I added a lightweight pre-render scan that collects remote image URLs and surfaces them as a warning instead of loading them blindly:
function findRemoteImages(markdown) {
const urls = [];
const re = /!\[[^\]]*\]\((https?:[^)\s]+)\)/g;
let m;
while ((m = re.exec(markdown))) urls.push(m[1]);
return [...new Set(urls)];
}
It's deliberately simple — a regex over the source, not the parsed AST — because it only needs to answer "are there external references?" before anything touches the network. The full tool pairs this with a Remote Asset Guard UI, but even the ten-line version changes the trust story: the preview never makes a network request the user didn't approve.
Small feature, outsized trust payoff. The kind of thing you only add after someone pastes a doc full of tracking pixels.
What shipped
The result is Markdown Viewer: paste or open a .md file, get an instant GFM preview, nothing leaves your browser. Along the way I added a few things the core loop needed — reading stats (words, headings, links, code blocks) as a structural sanity check, a guard that warns when your document references remote images, and a "passport" check that flags app-specific syntax like wiki links which won't survive a move to GitHub.
None of those were in the original forty-line plan. They came from actually using the thing on real documents and hitting real paper cuts.
Your turn
Here's my question for you: what's the weirdest Markdown edge case you've ever hit in the wild? Nested task lists inside blockquotes? A table that renders on GitHub but explodes in your static site generator? I'm collecting them — partly out of curiosity, partly because every one is a potential test case. Drop it in the comments.
Top comments (0)