@jdubray/mdsite
v0.1.0
Published
Turn a folder of Markdown into a browsable static site that works over file://. No dependencies, no CDN, no build step. Code fences and ASCII diagrams survive byte-for-byte.
Maintainers
Readme
mdsite
Turn a folder of Markdown into a browsable static site that works over
file://. No dependencies, no CDN, no build step, no server. Code fences and
ASCII diagrams survive byte-for-byte.
npx @jdubray/mdsite ./docs --out ./site
open ./site/index.htmlThat's it. Markdown becomes HTML; PDFs and everything else are copied and linked, so they open in the browser's own viewer.
Why this exists
Most documentation tools assume a server, a bundler, or a network. That is fine until you want to hand somebody a folder — on a USB stick, inside an air-gapped network, as a build artifact, in a ZIP attached to an email — and have it just open.
file:// blocks XMLHttpRequest. So a single-page viewer that loads Markdown at
runtime cannot work there, and every tool built that way fails silently in
exactly the situation where you most needed it to work.
mdsite pre-renders every .md to its own .html, next to where it was.
Relative links between documents are rewritten to point at the generated pages.
The result is a folder of ordinary HTML files with no runtime anything.
What you get
- A sidebar tree of the whole folder, on every page, with a filter box.
- Per-page table of contents and hover anchors on headings.
- Light and dark, from
prefers-color-scheme. - Wide things scroll in their own container — tables, long code lines, ASCII diagrams — so the page body never scrolls sideways.
- PDFs, images, HTML, Office files copied through and linked.
- One inlined stylesheet. Nothing is fetched at runtime, ever.
Install
npm i -D @jdubray/mdsite # or use npx, there is nothing to installNode ≥ 20. Zero runtime dependencies — the dependencies block is empty and
stays that way.
CLI
mdsite <src> [--out <dir>] [options]
--out <dir> output directory (default: ./mdsite-out). REPLACED on build.
--title <name> site name in the sidebar (default: the source folder name)
--subtitle <text> small text under the title (default: "documentation")
--exclude <re> skip paths matching this regular expression
--flag-dir <dir> directory whose pages carry a warning banner
--flag-note <text> the banner text
--css <file> replace the stylesheet entirely--flag-dir exists for the common case of a folder you want to browse but not
distribute:
mdsite ./docs --out ./site \
--flag-dir internal --flag-note "Internal draft — not for distribution."
--outis deleted before the build. Pointing it inside--srcwould destroy your documents, so mdsite refuses that outright rather than doing it.
API
import { renderMarkdown, buildSite } from '@jdubray/mdsite';
// just the HTML
const { html, toc } = renderMarkdown('# Hello\n\nSome `code`.');
// the whole tree
buildSite({
src: './docs',
out: './site',
title: 'My project',
include: (rel) => !rel.startsWith('drafts/'),
indexIntro: '> Start with **the manual**.',
});| export | what |
|---|---|
| renderMarkdown(src, opts) | → { html, toc }. opts.linkFor rewrites hrefs and image srcs |
| renderInline(text, linkFor) | inline spans only; input must already be escaped |
| safeUrl(url, opts) | the URL scheme allowlist — returns the URL, or null |
| buildSite(opts) | the generator |
| collect(root, opts) | walk a directory, returning what would be rendered or copied |
| escapeHtml · slugify · splitRow | the small pieces, exported because they are useful |
| DEFAULT_CSS · FILTER_JS | the theme, if you want to extend rather than replace it |
Security
The renderer is designed to be pointed at Markdown its operator did not write — a docs folder from a pull request, a wiki export, user-submitted content.
- Input is escaped before any formatting is applied, so markup in the source cannot become markup in the output. There is no raw-HTML passthrough, and that is deliberate.
- Every emitted URL passes a scheme allowlist.
[x](javascript:alert(1))does not produce a live link. Refused arejavascript:,vbscript:,data:in an href, and any unrecognised scheme — unknown means refused, not assumed harmless. Allowed are all relative forms plushttp(s),mailto,tel,ftp,file. - Detection ignores control characters, so
\tjavascript:andjavascript:cannot slip past a naive prefix check. - The allowlist runs after your
linkFor, not before, becauselinkForis yours and can return anything. data:image/*is kept for images — inline diagrams are a real pattern and a data URL in an<img>cannot execute — but never for a link, where it can navigate to attacker-authored HTML.- A refused link renders its label without an anchor, with a title saying
why. It is not rewritten to
#: a link that silently points somewhere else is worse than a visible refusal.
Escaping alone would not have covered the URL cases. The quotes are safe; the scheme is not.
What it does not do
Stated plainly, because a docs tool that overpromises wastes your afternoon:
- It is not CommonMark-complete. It covers ATX headings, fenced code, GFM pipe tables, blockquotes, nested lists, rules, images, links, and the usual inline spans. Setext headings, reference links, footnotes, definition lists and inline HTML are not supported.
- No syntax highlighting. Fences get a
lang-*class and are left alone. Add a highlighter in your own CSS/JS if you want one. - No search index, beyond the sidebar filter, which is filename-only.
- No incremental build. It is fast because it does almost nothing; just run it again.
- No plugin system. Swap the CSS, pass
linkFor, or importrenderMarkdownand build your own pages.
If you need any of those, use Eleventy, MkDocs, Docusaurus or VitePress. This is for the case where you want a folder you can open.
Tests
npm test33 tests, node:test, no runner. They cover the URL allowlist, escaping,
verbatim fences, table edge cases (including escaped pipes), duplicate heading
ids, link rewriting, and the refusal to write into the source directory.
License
MIT
