all posts
The serv logo: the word «serv» in a monospace face with a red block cursor after it, on a dark background.

Serv 0.3: a local web server that reads Markdown

Hey everyone!

How often have you had a pile of markdown files sitting in one folder, a whole project’s worth of documentation, and needed to actually read it? Have you ever read raw markdown in your favorite editor? Then this post is for you.

serv just got its v0.3 release, and it can now serve that folder as a site. No config, no static site generator, no npm install. One binary, the -m flag, and your documentation is readable in a browser.

serv is a small server for local development: point it at a folder, open the URL. It has always served HTML, images and everything else, and it served .md honestly as text/markdown — which is to say the browser offered to download it. It doesn’t anymore.

Installing it, and the first run

Homebrew pours a prebuilt binary, nothing to compile:

brew install jwo1f/tap/serv

Or through cargo, if that’s more your habit:

cargo install --git https://github.com/JWo1F/serv

Sources are at https://github.com/JWo1F/serv. The repository has an example folder deliberately stuffed with one of every kind of block; every screenshot below is that folder.

serv -m example

  serv▌ 0.3.0

  ╭──────────────────────────╮
  │  http://127.0.0.1:8099/  │
  ╰──────────────────────────╯

  root         ~/work/jwo1f/serv/example
  contents     2 folders · 2 files · 4.2 kB
  index        no index.html · folders get a generated listing
  not found    built-in page
  urls         clean · /about serves about.html
  markdown     rendered · folders fall back to index.md, then README.md · code plain
  encoding     gzip · text between 1 KiB and 8 MiB
  logs         on

  ctrl-c to stop

Open the address and you get the README — set as a page, not as a wall of hashes and asterisks.

A serv page with a rendered README: the header «serv · local development server», the breadcrumb «example/», the heading «serv, reading markdown», a paragraph of prose and a code block.

The file is read from disk on every request — nothing is cached inside the server. Fix a paragraph, hit reload, see the fix. That is the same principle serv already applies to every other file; it just reaches your documentation now too.

-m doesn’t add a mode, it widens the rules

The decision that mattered here wasn’t the renderer, it was the routing. serv already did clean URLs: /about serves about.html, and /about.html redirects to /about so a page has one address rather than two. Markdown didn’t get rules of its own — it moved into those.

Request What you get
/about about.html, or the rendered about.md when there is no HTML
/about.md 301 to /about
/docs/ index.html, then index.md, then README.md, then the folder listing
/docs 301 to /docs/

HTML always wins. With about.html and about.md side by side you get the first one: the built artifact is the more specific thing.

301 GET  /guide/pages.md                        45 µs
200 GET  /guide/pages                           15.6 kB  296 µs

The third row of that table is the one this was all for. A folder holding a README.md opens as that README, the way a repository does on GitHub. Point serv -m at a project and you get its documentation instead of its file list.

There’s a price, and it’s a direct one: that folder no longer has a listing. At all. No ?listing, no fallback address — I wasn’t going to invent URL parameters serv has never had. A folder is either a document or a table of contents.

A folder with no document in it still draws its own listing:

A folder listing generated by serv: the breadcrumb «example/ guide/ nested/», rows with icons for notes.txt and style.css, size and date on the right.

Without -m nothing changes at all. A .md file stays a file, streamed off disk with an ETag and range requests.

Links

Documentation links point at files: [the guide](guide.md). Serve that link as written and the reader goes to /guide.md, gets a redirect to /guide, and does land in the right place — just one request later, with a flicker in the address bar on the way.

So links are rewritten while the page renders: guide.md becomes guide, docs/index.md folds into docs/, and an #anchor or a ?query rides along untouched.

The «Links» section of a serv page: a two-column table with the link as written in markdown on the left and what it becomes on the right — folds to the folder, loses the extension, keeps the fragment, opens in a new tab.

A link off the machine opens in its own tab, with rel="noopener". A mailto: or tel: gets neither: those hand off to a mail client or a phone, and the tab opened underneath them just sits there empty next to your document.

Under -e, where serv serves paths literally, links aren’t touched at all — nothing is being stripped there, so there’s nothing to point somewhere else.

Diagrams

A ```mermaid block is drawn as a diagram rather than set as code.

The «A diagram» section of a serv page: a mermaid flowchart with decision diamonds «On disk?», «index.html?», «index.md or README.md?», «–spa?» and boxes «Send it», «Render the document», «Draw the listing», «404».

That picture, incidentally, is exactly the logic from the table above — it’s the diagram out of example/README.md, drawn by serv itself.

Now for what it cost. Mermaid exists only as a browser library: there is no Rust port that turns graph TD into SVG, so the only thing that can draw a diagram is JavaScript in the page. A minified mermaid.min.js is 2.62 MB against a serv binary of 1.38 MB, and bundling that for the sake of one block type wasn’t something I was willing to do.

So the page pulls mermaid from jsdelivr, and that is the single place in all of serv that reaches the network. Only a page that actually draws a diagram reaches for it; a document without one asks for nothing. Until mermaid arrives, and if it never does, the block shows the diagram’s own source rather than an empty gap.

Before 0.3 the README said serv never goes to the network. That sentence had to be rewritten.

Syntax highlighting

In the released binaries a code block is plain monospace. Real highlighting lives behind a build flag:

cargo install --git https://github.com/JWo1F/serv --features highlight

Inside it is syntect, with actual TextMate grammars. The price:

Build Size
0.1.1, before markdown 1.11 MB
0.3.0, default 1.38 MB
0.3.0, --features highlight 2.97 MB

Markdown itself cost 258 KiB — that’s pulldown-cmark. Highlighting adds another megabyte and a half on top: a grammar dump plus a regex engine. For a tool you start in order to read some documentation that’s a lot to carry by default, and perfectly reasonable on request — hence a flag rather than a dependency.

syntect’s output is class-based rather than the inline colors from a theme, so code is painted in the page’s own palette and follows it into dark mode. A language syntect doesn’t know stays an ordinary block.

Where it breaks

A few places I know about and am not fixing.

Raw HTML inside markdown isn’t rewritten: if you wrote <a href="guide.md"> by hand, that link still points at the .md. The renderer never reaches it — it never becomes a link event in the first place. The HTML itself passes through and works, because it’s your file on your machine.

README.md doesn’t fold into its folder. A link to it goes to /README, which is a working address and opens the page. But folding it to ./ the way index.md folds would mean starting to guess, and the guess breaks the day an index.md shows up beside it.

Generated pages — the listing, the 404 and the document — are served without gzip and without an ETag, which means they’re rendered fresh on every request. On localhost that’s a fraction of a millisecond, and a cache would contradict the main promise: what you saved is what goes to the browser.

Why bother, when your editor has a preview

Fair objection: every editor has a preview pane, and for a single file it’s more convenient — nothing to start.

The difference shows up on the second page. A preview shows a file, not a folder: links between documents either don’t work or open the source, relative images resolve only sometimes, anchors are hit and miss. A set of documents is a small site, and it’s easier to read as a site, with navigation and an address bar. On top of that, what the browser shows you can be shown to a colleague by handing them the address.

Under the hood

pulldown-cmark with the GitHub extensions: tables, task lists, footnotes, strikethrough. Plus smart punctuation — serv’s pages are set as a printed sheet, and straight quotes looked wrong on it. Front matter is dropped rather than printed as a heading. Every heading gets an anchor, so the #links any README is full of have somewhere to land.

There are 311 tests in the default build and 316 with --features highlight, and CI runs both configurations: a flag nobody compiles breaks quietly.

Try it

Install it, point it at your own documentation folder, and try to read it.

serv -m ./docs

If something renders wrongly, an issue or a pull request at https://github.com/JWo1F/serv would be welcome. The interesting cases are markdown files that render without an error but incorrectly — those I only find by eye.

Comments 0

No comments yet.