runlocally

runlocally engineering notes

Markdown Viewer

How Markdown Viewer is built

By Geppetto · · Open Markdown Viewer →

Markdown Viewer opens a local .md file and renders it as sanitized HTML, entirely in the browser. This post is about the two engineering decisions that mattered most: layering two independent defenses against a hostile document instead of trusting one, and reusing an encoding routine that already existed elsewhere in this catalog rather than reaching for a heavier library.

Tech used

Two independent defenses, not one

A Markdown file is untrusted input the moment it’s dropped from disk — it could contain an <img onerror=...> or a javascript: link just as easily as a heading. The tool doesn’t rely on a single filter to catch that. markdown-it (the parser) is configured with html: false, so raw HTML tags in the source are never passed through as HTML at all — they’re escaped to plain text. A validateLink override on top of that restricts href schemes to http:, https:, and mailto:, rejecting anything else (javascript:, data:, vbscript:) before a link is even emitted. That closes the two most common injection paths at the parser level.

The output still goes through DOMPurify afterward, as a second, independent pass over the actual rendered HTML — regardless of how it was produced. The reasoning: html: false is a parser setting, and settings can be misconfigured, overridden by a future change, or bypassed by an edge case nobody thought of. DOMPurify sanitizes the DOM tree itself, which doesn’t care what produced it. The e2e suite includes a dedicated test that feeds the tool a genuine XSS payload and asserts nothing executes — proving the combination works, not just that each piece is configured correctly in isolation.

Encoding detection, borrowed rather than rebuilt

A .md file exported from a Japanese-locale tool is often Shift_JIS, not UTF-8, and decoding it as UTF-8 turns every non-ASCII character into mojibake. This problem already had a solution in this catalog: CSV Viewer’s detectEncoding/decodeBytes pair, which tries a strict, fatal: true UTF-8 decode first and falls back to Shift_JIS only if that throws. Markdown Viewer ports that exact two-encoding, strict-then-fallback approach rather than pulling in a general-purpose encoding-detection library — there’s no user-facing “choose an encoding” control, just a decode that works for the two encodings that actually show up in practice. A UTF-8 byte-order mark, when present, is stripped rather than rendered as a stray character at the top of the document.

The feature that was deliberately left out: ZIP

The tool this one traces its concept back to (an earlier, unrelated Cloudflare-era project, referenced only for the idea — no code or dependencies were carried over) could also open a .zip of Markdown files. That capability didn’t come along. Bundling “view a Markdown file” and “extract a ZIP archive” into one tool means the tool no longer has a single job, and the ZIP/encoding-detection code this catalog needed for archive handling was already spun out on its own in Fix ZIP Filenames. Markdown Viewer’s MVP is scoped to exactly one thing: take one local .md file, show it safely. Front-matter display, multi-file tabs, a table of contents, and theme switching are the same kind of scope-creep candidate, and none of them shipped either.

Implementation & operational notes

GFM tables and fenced code blocks need no extra code. markdown-it’s table plugin handles GitHub-Flavored table syntax and fenced code blocks out of the box — there was nothing tool-specific to write here, only to verify it renders correctly, which the e2e suite does with a fixture containing both.

Only one input method needed real design work. Drag-and-drop and a file picker both hand the tool a File, so fileValidation.ts checks the extension (.md/.markdown) before anything is read — an unsupported file gets a clear, localized rejection message rather than an attempt to decode and render garbage.

Try it / source