Skip to content

Latest commit

 

History

5 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

localpages

Preview Markdown like GitHub renders it — locally, with one command.

license Node.js >= 20

cd path/to/your/markdown
npx github:mrtnzlml/localpages

Opens at http://localhost:8000. Live-reloads on save. Renders entirely in your terminal — no GitHub API, no headless Chrome, no third-party calls.


Features

Faithful GitHub rendering

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.

GitHub-flavored alerts

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.

Mermaid diagrams, server-side

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.

Section state labels

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.

Click-to-anchor headings

Every heading gets a GitHub-compatible slug id. Hovering reveals a # permalink at the left margin. Deep-link via fragments: …/index.html#installation.

Sidebar table of contents

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 preview for in-doc references

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.

Copy-to-clipboard on every code block

Hover a fenced code block and a "Copy" button appears top-right. Falls back to document.execCommand('copy') outside secure contexts.

Print-quality PDF

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.

Standalone images become figures

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.

Wide-content breakout

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.

Hot reload that doesn't break print preview

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.


Static HTML export

npx github:mrtnzlml/localpages --export             # writes ./docs.zip
npx github:mrtnzlml/localpages --export bundle.zip  # custom output path

Or while the server is running, visit http://localhost:8000/__export.zip.

The ZIP contains:

  • One <name>.html per top-level .md file 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.


Source-file viewer

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.


CLI reference

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.

Authoring notes

localpages is opinionated about a few details. Documenting them because the output is what you'll be reading.

Markdown dialect

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

JSON5 fences

Code fences tagged ```json5 are highlighted with the JavaScript grammar so // comments render cleanly:

```json5
{
  "scope": "stock",     // S, D, C
  "limit": 1000         // dollar amount
}
```

TODO / TBD highlights

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.

Section state label syntax

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 state that isn't one of the five, or no state at 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.

Standalone images get auto-captioned

![Architecture overview of the order flow](assets/architecture.png)

The image becomes a <figure> and the alt text becomes its <figcaption>. Inline images mid-paragraph are untouched.

Wide tables and diagrams

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.


How it works

localpages is a small Node script. The dependency tree is short on purpose:

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.


Customization

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.


FAQ

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.


Contributing

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 test

Tests 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.


License

MIT — see LICENSE.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages