Preview Markdown like GitHub renders it — locally, with one command.
cd path/to/your/markdown
npx github:mrtnzlml/localpagesOpens at http://localhost:8000. Live-reloads on save. Renders entirely in your terminal — no GitHub API, no headless Chrome, no third-party calls.
The output uses github-markdown-css (the official GitHub stylesheet) and matches what github.com displays for README.md files. Tables, blockquotes, task lists, fenced code blocks, footnotes, and image figures all render the same way.
The > [!NOTE] syntax renders identically to github.com:
> [!NOTE]
> Highlights useful information.
> [!WARNING]
> Critical action that the user should take.Five callout types: NOTE, TIP, IMPORTANT, WARNING, CAUTION. Each gets a coloured rule, icon, and a subtle hue-matched background.
Fenced ```mermaid blocks render to inline SVG in Node, via beautiful-mermaid. No headless Chrome, no client-side JavaScript, no flash of unstyled content. The same fenced blocks render natively when the Markdown is later viewed on github.com or gitlab.com.
TODO and TBD text inside diagrams gets a yellow stroke automatically — useful for marking unfinished work that should be hard to miss.
A long-lived document rots unevenly. Mark how far each section can be trusted by putting one element directly below its heading:
## 3. Architecture
<state-label state="stale" date="2026-08-17" />It is lifted onto the heading row as a badge, counted in a tally under the document title, and mirrored as a dot in the sidebar.
state |
Meaning |
|---|---|
rough-draft |
Written from notes, or still a stub. Checked against nothing. |
in-progress |
Being written or reworked right now. |
ready |
Complete and self-consistent, but not yet checked against the code. |
verified |
Every claim traced to a file, command output, or API response, on the date shown. |
stale |
Was true; the implementation has since moved. Warns the reader off without deleting the content. |
ready and verified are both green; they're told apart by icon shape and fill weight, not hue alone, so they survive greyscale printing and colour-blindness.
Labels are optional — an unlabelled section means "state not assessed", so a document can adopt the convention one section at a time.
Note
This is the one place localpages extends beyond GFM. <state-label> is a custom element, so github.com and gitlab.com strip it — a labelled document read through their web UI shows the prose but not the badges. Everything else in localpages renders the same in both places.
See examples/basic/states.md for the full demo.
Every heading gets a GitHub-compatible slug id. Hovering reveals a # permalink at the left margin. Deep-link via fragments: …/index.html#installation.
A 220 px sidebar lists every <h2> and <h3>. The active entry tracks the scroll position. Below 1280 px viewport width the sidebar hides automatically and the content column takes over.
Hover any [link](#anchor) whose target is a heading and a floating card appears with a peek of that section. Click to navigate, hover-out to dismiss. Works in the static export too — all data lives in the DOM.
Hover a fenced code block and a "Copy" button appears top-right. Falls back to document.execCommand('copy') outside secure contexts.
Cmd-P (macOS) or Ctrl-P (Windows/Linux) produces a clean PDF: no nav bar, no sidebar, no modal, no copy buttons. Page-break rules keep headings with their content; tables, code blocks, figures, and Mermaid diagrams avoid mid-element splits. <details> is force-expanded so collapsed content makes it into the export.
A paragraph that contains nothing but a single image is auto-wrapped in <figure>, with the image's alt text as a <figcaption>. Mixed paragraphs (text + inline image) are left alone.
Wrap a wide table or diagram in <div class="wide"> and it extends past the 980 px content column, up to the available viewport width. The breakout reserves space for the TOC sidebar so wide tables don't slide underneath. Mermaid blocks get wrapped automatically.
Saving a .md while a print dialog is open used to slam the dialog shut. localpages defers the reload until the dialog closes. EventSource reconnects don't trigger reloads either — only genuine server restarts (tracked by per-process ID) do.
npx github:mrtnzlml/localpages --export # writes ./docs.zip
npx github:mrtnzlml/localpages --export bundle.zip # custom output pathOr while the server is running, visit http://localhost:8000/__export.zip.
The ZIP contains:
- One
<name>.htmlper top-level.mdfile in your docs directory - Every non-Markdown asset (images, PDFs, raw source files referenced via the modal) copied verbatim
- All CSS, fonts, and JavaScript inlined per page — no external requests on open
Unzip and double-click index.html. No server needed. Works under file://, behind any web host, anywhere — even mailed as an attachment.
The ZIP is a vanilla DEFLATE-compressed archive written with node:zlib — no archiver or jszip dependency.
Click a link to a .py, .json, .yaml, or other text file from inside your Markdown — instead of navigating away, a modal opens with the file's contents, syntax-highlighted. Press Esc or click outside to close; press the Copy button to put the contents on the clipboard.
This works in two modes:
- Live preview — the modal fetches
/__source?path=…from the running server. - Static export — referenced source files are embedded as hidden
<template>elements in the rendered HTML, so the modal works offline too.
By default, the source root is your git repository toplevel (git rev-parse --show-toplevel). Override with --source-root <path> to widen or narrow it.
Sensitive files are blocked. The default blocklist is:
credentials.* .env* .envrc *.pem *.key id_rsa* secrets.*
Add to it with --block <pattern> (repeatable). Path-traversal protection always applies on top of the blocklist: source-viewer requests that resolve outside --source-root return 403.
Supported source extensions: .py, .json, .yaml, .yml, .txt, .csv, .xml, .html, .css, .js, .mjs, .toml, .sh, .bash.
localpages [file] [options]
| Flag | Default | Description |
|---|---|---|
[file] |
./index.md (or first .md in cwd) |
Markdown file served at /. Other .md files in the same directory are still reachable at /<name>.md. |
--port N |
8000 |
TCP port to listen on. |
--open |
off | Open the browser when the server is ready. |
--no-watch |
watch on | Disable file watching and live reload. Useful in CI. |
--export [path] |
— | Write a static-HTML ZIP and exit. Default output ./docs.zip. |
--source-root <path> |
git toplevel | Restrict the source-file viewer to files under this directory. |
--block <pattern> |
(see above) | Glob to never expose via the source viewer. Repeatable. |
--version |
— | Print version and exit. |
--help |
— | Print help and exit. |
localpages is opinionated about a few details. Documenting them because the output is what you'll be reading.
GitHub-Flavored Markdown only. Don't use:
!!! note ...(MkDocs Material)??? details ...(collapsibles, MkDocs)=== "Tab"(tabbed content, MkDocs)==highlight==(renders as literal==text==on GitHub/GitLab)
Do use:
> [!NOTE]for callouts<details><summary>Click</summary>...</details>for collapsibles<mark>highlighted</mark>for inline highlights
Code fences tagged ```json5 are highlighted with the JavaScript grammar so // comments render cleanly:
```json5
{
"scope": "stock", // S, D, C
"limit": 1000 // dollar amount
}
```Wrap unfinished prose in <mark>this is TBD</mark> for a yellow inline highlight. Inside Mermaid diagrams, the literal words TODO and TBD get a yellow stroke automatically — no markup needed.
| Attribute | Required | Notes |
|---|---|---|
state |
yes | One of rough-draft, in-progress, ready, verified, stale. |
date |
no | Rendered verbatim — localpages never reformats it. Omit it and no date shows. |
label |
no | Overrides the display text, for custom wording or another language. |
The date is the day the state last changed, not the day the text was last edited. Write date="—" when that day cannot be established.
Write it as one self-closing tag on its own line, directly after a heading. That's a real constraint, not a style preference: <state-label …></state-label> and attributes wrapped across several lines both parse as a paragraph rather than an HTML block.
Because the syntax is explicit, mistakes are reported rather than guessed at. A browser renders an unknown element as nothing at all, so each of these prints a warning naming the file and line, and marks the spot in the page:
- a
statethat isn't one of the five, or nostateat all - the paired or multi-line form
- an element that doesn't directly follow a heading
solution.md:75 <state-label> has an unknown state "staale" (expected one of: rough-draft, in-progress, ready, verified, stale)
A <state-label> shown inside a fenced code block stays code — detection runs on the parsed document, where a fence is never an HTML block.
The image becomes a <figure> and the alt text becomes its <figcaption>. Inline images mid-paragraph are untouched.
Wrap content in <div class="wide"> to break out past the 980 px content column:
<div class="wide">
| ID | Description | Long column 1 | Long column 2 | More |
|----|-------------|---------------|---------------|------|
</div>Mermaid blocks are wrapped automatically.
localpages is a small Node script. The dependency tree is short on purpose:
markdown-it— Markdown parsermarkdown-it-github-alerts—> [!NOTE]pluginmarkdown-it-anchor— heading anchorsgithub-markdown-css— the official GitHub stylesheethighlight.js— code-block syntax highlightingbeautiful-mermaid— Mermaid → SVG in Node
The HTTP server uses node:http. File watching uses fs.watch({ recursive: true }) with a flat-watch fallback for older Linux. Live reload is plain Server-Sent Events. The ZIP export hand-writes the DEFLATE archive with node:zlib and Buffer — no archiver, no jszip.
That's the whole stack. There is no client-side framework — the in-page scripts (TOC scroll-spy, modal, hover card, copy buttons) are vanilla JS, inlined.
localpages is intentionally zero-config. Theme colours, content width, fonts, and the Markdown plugin set are not exposed in 0.1.0.
If you need to extend it, fork the repo — every feature lives in a single readable codebase. Open an issue with your use case before forking, ideally.
Is this related to GitHub Pages?
No. The name is a nod to the spirit ("a personal page, but local"). GitHub Pages is GitHub's static-site hosting service — localpages is unaffiliated software for previewing Markdown on your own machine.
Why not just use grip?
grip calls the GitHub API for every render. It's rate-limited, needs a personal access token for any real use, and won't work offline or behind corporate firewalls. localpages does the rendering in your terminal.
Why a ZIP instead of a directory for --export?
A single file is easier to attach to an email, drop into Slack, or hand someone over USB. Unzipping is one step on every OS. The format is a vanilla DEFLATE-compressed ZIP — unzip, double-click, or upload it anywhere.
Does it support TypeScript or JSX in code fences?
Yes — highlight.js ships grammars for both. Use ```ts, ```tsx, or ```jsx.
Does it work on Windows?
It should. The watcher uses fs.watch({ recursive: true }), supported on Windows since Node 12. Open an issue if a path translation breaks.
Can I serve a directory with hundreds of .md files?
You can, but the top nav lists every file and there's no search. Reach for an SSG like VitePress or MkDocs instead.
Does it generate a sitemap, RSS feed, or canonical URLs? No. It's a preview tool, not a publishing pipeline.
My Mermaid diagram doesn't render — why?
Run with node … in a terminal you can watch; rendering errors are logged to stdout. Common causes: a diagram type unsupported by beautiful-mermaid, a syntax error in the diagram itself, or a malformed Mermaid front-matter line.
PRs welcome on bug fixes and accessibility improvements. For new features, open an issue first to align on scope — localpages aims to stay small.
git clone https://github.com/mrtnzlml/localpages.git
cd localpages
npm install
node bin/localpages.mjs examples/basic
npm testTests are node:test snapshots over fixtures in examples/basic. The whole codebase is around 2 800 lines (≈ 1 240 ESM, 425 client JS, 575 CSS, 560 tests).
The client-side tests in test/toc.test.mjs drive a real headless Chrome (via --dump-dom) rather than a DOM shim, so the assertions run against a browser instead of a stub. Chrome is not a dependency — those tests skip when it isn't found. Override the path with CHROME_BIN=/path/to/chrome npm test.
MIT — see LICENSE.