A tiny, client-only reader that turns a folder of markdown files into a beautifully typeset website. No build step for your writing, no database, no server logic — an HTTP server hands over .md files and doogie typesets them.
- Typography first — Literata body text on the Everforest palette, fluid 17→20 px sizing, ~70 ch measure, stepped line-height, real small caps, oldstyle figures in prose with tabular figures in tables, smart punctuation.
- Yours to change — eight color schemes and fourteen typefaces from a panel in the sidebar, plus light/dark/auto. Deployers set site defaults (and may supply their own palette and fonts) with a single
doogie.json; no rebuild. See Customizing doogie. - Live outline — a sidebar table of contents scanned from
h2/h3(the loneh1is the title, not a section), highlighting the section you're reading; collapsible with [. - VS Code-quality highlighting — Shiki v4 with the Everforest Dark/Light themes, grammars lazy-loaded per language; a document without code downloads zero highlighter bytes.
- Anchors that work — GitHub-compatible heading slugs, hover permalinks, deep links that survive late-loading images and code colorization.
- Bookmarks & reading position — stored per document in
localStorage; doogie takes you back to where you left off (a URL#hashalways wins). - Every font reads the same size —
font-size-adjustpins x-height, so switching faces changes the character of the page and not how large it reads. - Reading controls — sliders for text size (document only — the interface never moves), text weight (a live 300–700 range on the variable faces) and, on faces with a width axis, text width — for the body and, separately, for headings, which can also take a face of their own; plus font smoothing and a full-measure toggle for wide tables, from the same drawer.
- Remote mode —
?src=<url>reads any CORS-accessible markdown file (raw GitHub files, gists…).
Deploy the built app and your .md files on the same host:
| You visit | doogie fetches |
|---|---|
md.example.com/notes |
/notes.md |
md.example.com/guides/setup |
/guides/setup.md |
md.example.com/ or /guides/ |
index.md there (root falls back to a built-in welcome page) |
md.example.com/?src=https://…/README.md |
that remote file |
#hash fragments are heading anchors, exactly like GitHub's.
Build once, then copy dist/ and your markdown to any static host:
pnpm install
pnpm build
rsync -av dist/ server:/srv/doogie/
rsync -av ~/writing/*.md server:/srv/doogie/One server rule makes clean URLs work: serve real files as-is; every other path returns index.html with status 200.
Netlify — nothing to do: a _redirects file with /* /index.html 200 ships in the build. Non-forced rewrites only fire when no real file matches.
Cloudflare Pages — nothing to do; the same _redirects file (or Pages' default SPA behavior) covers it.
nginx
server {
root /srv/doogie;
index index.html;
charset utf-8;
location /assets/ {
add_header Cache-Control "public, max-age=31536000, immutable";
}
# optional but nice: direct .md visits display instead of downloading
location ~* \.md$ {
default_type "text/markdown; charset=utf-8";
try_files $uri =404;
add_header Cache-Control "no-cache";
}
location / {
try_files $uri /index.html;
}
}Caddy
md.example.com {
root * /srv/doogie
encode zstd gzip
try_files {path} /index.html
file_server
}GitHub Pages — no rewrites exist; copy index.html to 404.html in the deployed folder. Misses then return HTTP 404 status but the reader works. Remote ?src= mode is unaffected.
Notes:
- doogie never trusts the
.mdcontent type (hosts serve markdown as anything fromtext/markdowntoapplication/octet-stream); it sniffs HTML responses to detect misses behind the SPA fallback instead. - Deploying under a subpath? Set Vite's
basein vite.config.ts and rebuild — routing followsimport.meta.env.BASE_URL. - Browser floor: current evergreen (Chrome 139+, Firefox 142+, Safari 26+) — set in vite.config.ts (
css.lightningcss.targets), so the modern CSS (light-dark(), range media queries,allow-discrete) ships untranspiled.
pnpm dev # dev server; sample docs live in public/
pnpm test # vitest unit suite
pnpm check # svelte-check (strict TypeScript)
pnpm build # production build to dist/
pnpm preview # serve the production build locallySample torture documents: public/sample.md (languages, duplicate headings, wide tables, late images) and public/guides/setup.md (nested paths, relative links). The bundled welcome page (src/welcome.md) doubles as the typography test sheet.
Svelte 5 (runes) + Vite + strict TypeScript. Four components; framework-free logic in src/lib/.
- Pipeline: markdown-it 15 (
typographeron) + anchor/footnote/task-list plugins → one DOMPurify pass (the security boundary — documents can come from arbitrary URLs) →{@html}. Code blocks render instantly as plain<pre>, then a lazily-imported Shiki core swaps in dual-theme Everforest highlighting, yielding to the main thread between blocks. - Scroll-spy: rAF over cached heading offsets with a click-lock; its activation line equals the CSS
scroll-margin-toptoken, so the spy and anchor scrolling can never disagree. A ResizeObserver plusdocument.fonts.readyhandle late layout shifts. - Appearance:
<html>carries the whole state as attributes (data-mode,data-scheme,data-font-*), CSS does all the switching, and an inline script inindex.htmlapplies saved settings before first paint so returning readers never see a flash. Colors live in one semantic token layer, so a scheme is just a block that redefines it — the default scheme being the:rootblock itself. A test fails the build if a scheme forgets a token, or if any file consumes one that is never defined. - Untrusted by default:
doogie.jsonis validated field by field — colors are shape-checked beforeCSS.supports, font URLs are protocol-limited, and anything unusable falls back to a built-in rather than breaking the page.