No description
  • Zig 96.3%
  • Nix 3.7%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Jeffrey C. Ollie eb8f8e5c3a
Some checks failed
test / test (push) Failing after 7m28s
test / docs (push) Has been skipped
test / nix (push) Successful in 30m34s
Step to the next match on every n, not just the first
A jump leaves the match a third of the way down the screen, and `n` then
looked for the first match below the top line, which was the same one
again. Step from the match the view was taken to while the reader has not
scrolled away, and from the top line once they have.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01EqHo1eVSctsAWKup25exz5
2026-10-22 10:18:37 -05:00
.forgejo/workflows Move to git.jcollie.dev 2026-09-08 13:12:54 -05:00
LICENSES Add mdv, a terminal viewer for GitHub-Flavored Markdown 2026-09-05 15:14:39 -05:00
src Step to the next match on every n, not just the first 2026-10-22 10:18:37 -05:00
tests Build with Zig 0.17.0 2026-10-08 16:40:33 -05:00
.gitignore Add mdv, a terminal viewer for GitHub-Flavored Markdown 2026-09-05 15:14:39 -05:00
build.zig Build with Zig 0.17.0 2026-10-08 16:40:33 -05:00
build.zig.zon Build with Zig 0.17.0 2026-10-08 16:40:33 -05:00
build.zig.zon.nix Build with Zig 0.17.0 2026-10-08 16:40:33 -05:00
flake.lock Build with Zig 0.17.0 2026-10-08 16:40:33 -05:00
flake.nix Build with Zig 0.17.0 2026-10-08 16:40:33 -05:00
package.nix Build with Zig 0.17.0 2026-10-08 16:40:33 -05:00
README.md Draw Targa images, now that the compiler can build the decoder 2026-10-08 16:48:24 -05:00
REUSE.toml Move to git.jcollie.dev 2026-09-08 13:12:54 -05:00

mdv

A terminal viewer for GitHub-Flavored Markdown, written in Zig. It parses a document, lays it out to the width of your terminal, and lets you scroll and search through it.

$ mdv README.md
$ git show HEAD:CHANGELOG.md | mdv

What it renders

Everything GitHub-Flavored Markdown has:

Construct Shown as
Headings, ATX and setext Colored by level: a # centered and curly-underlined, the rest with their #s kept dimmed
Emphasis, strong, strikethrough The terminal's own italic, bold and strike
Code spans and fenced blocks A gutter down the left, never re-wrapped, and syntax highlighted when the fence names a language
Lists, ordered and nested Aligned markers, tight or loose as written
Task lists ☑ and ☐
Block quotes A bar down the left, italic within
Tables Box drawing, aligned, narrowed to fit
Links Underlined, and made clickable with OSC 8 when the destination names a scheme
Images Drawn inline where the terminal speaks the kitty graphics protocol; otherwise the alt text
Mermaid diagrams Drawn as the picture they describe where mermaid-cli is installed; otherwise the source they were written as
Footnotes Collected at the end, with the marker inline

Syntax highlighting

A fenced block that says what language it is in is highlighted with tree-sitter, through flow-syntax — the same library the flow editor and zat use, which packages tree-sitter itself, some ninety grammars, and the highlight queries that go with them, with the queries compiled to a binary form at build time so that nothing is parsed at startup.

The name on the fence is looked up as a language and then, failing that, as though it were a file extension, so js, rs, yml and the rest of the abbreviations people actually write come out of the same table as the full names rather than out of a list of aliases here that would forever be slightly out of date. Everything after the first word is ignored, since GitHub reads it as attributes.

A block that names no language is left plain. mdv will not guess from the contents, on purpose: a bare fence usually holds something that is not a program at all — a session transcript, a directory listing, a block of prose — and coloring those as though they were code is worse than leaving them alone. A named language nothing has a grammar for is left plain too.

What a grammar produces is a capture name out of a dotted hierarchy, and there are several hundred of them across ninety grammars. Those are collapsed onto the dozen distinctions a terminal can actually draw — keyword, string, escape, constant, comment, function, type, variable, operator, punctuation, tag, attribute — by longest matching prefix, so variable.parameter.builtin lands in variable and a grammar that invents a name nobody has seen before still lands somewhere sensible. As everywhere else in mdv the colors are ANSI palette indices rather than RGB, so highlighted code comes out in the reader's own terminal theme.

A grammar that hands part of its document to another one is followed: the JavaScript in a <script> element and the CSS in a <style> are highlighted as what they are, and so is the code in a fence inside a ```markdown block.

Highlighting is the viewer's, not the library's: Layout takes the runs through a callback and the mdv module stays free of tree-sitter, so anything embedding the parser and the layout engine pays for a grammar only if it asks for one.

Every destination — in a [text](...), in a <...> autolink, and in a link reference definition — is put through an RFC 3986 parser. What comes back decides two things.

An autolink is only an autolink if what is between the brackets really is a URI. <http://example.com:99999/> looks like one and is not, because there is no such port; it stays as the text that was typed. This is stricter than pattern matching, and catches malformed hosts and ports that would otherwise be handed to the terminal as though they were addresses.

Whether a hyperlink is offered to the terminal depends on the same answer. A destination that names a scheme — https:, mailto:, file: — is something the terminal knows how to open, and is handed over as an OSC 8 hyperlink: explicitly, rather than left for the terminal to notice a URL in the text and guess. A relative one is not, because the terminal has no document to resolve it against. mdv follows those itself — see below.

Click a link with the mouse and mdv goes there:

  • [other](./other.md) opens that file, resolved relative to the document the link is in rather than to wherever mdv was run from.
  • [section](#far-section) scrolls to the heading whose anchor that is, computed the way GitHub computes them — folded to lower case, spaces hyphenated, punctuation dropped, and a counter appended when two headings would otherwise collide.
  • [part](./other.md#part) does both.

Backspace, or o, goes back to where the link was followed from, at the line it was left on. A link that leads nowhere leaves the view alone and says so in the status bar.

Links with a scheme are not followed by mdv, because they were handed to the terminal as OSC 8 hyperlinks and it is the terminal's business what clicking one means. Images are not followed either: the destination is there, but opening a PNG as a Markdown document helps nobody.

The outline

t puts the document's headings on screen, indented by level, in a box over the top of it. j and k move through the list, d, u, g and G move through it faster, Enter goes to the heading selected, and Esc — or t again — puts it away. Clicking a heading goes to it, and the wheel moves the selection.

It opens on the heading you are already under rather than at the top of the document, since asking for the outline halfway through a long file is usually a way of getting somewhere near where you are. The list is the one anchors are resolved against, so a heading reached this way lands exactly where a link to it would.

Reloading

The document is re-read whenever it changes on disk, so that a file being edited in one window can be read in another without touching either. Save in the editor and the view catches up, as far through the document as it already was, with the search and the matches it had. The position kept is the fraction of the way down rather than the line number, since a document being written grows above where it is being read as well as below: somebody at the top stays at the top, somebody at the bottom watching it being written stays at the bottom, and somebody in the middle stays in the middle. r still re-reads on demand, and is what there is when the document came in on standard input and has no file to watch.

That place is remembered rather than taken from the screen each time, because a save that truncates the file and writes it again is seen twice: the watcher wakes the viewer for the truncation, and what it re-reads then is an empty document. An empty document has no place in it, so the last real one is kept and handed to the document that arrives a moment later. Without that the reader is thrown to the top by every editor that saves in place.

nightwatch does the watching, which means inotify on Linux, kqueue on the BSDs and macOS, and ReadDirectoryChangesW on Windows, on a thread of its own that does nothing but wake the viewer.

What is watched is the directory, not the file. Watching the file is the obvious thing and the wrong thing: an editor that saves by writing a temporary file and renaming it over the original leaves the watch attached to an inode nothing points at any more, so the first save is seen and no save after it is — and that is how vim, Emacs and everything else promising an atomic save behaves. A watch on the containing directory sees the rename, and every one after it. The watch is one directory deep, so opening a document at the top of a large repository does not register a watch for every directory beneath it.

One save is several events — a write, a rename, a close — and they collapse into one re-read rather than three.

Images

An image that is the only thing in its paragraph is drawn inline, at its own size, scaled down only if it is wider than the text. Anywhere else — an image in the middle of a sentence — it stays as its alt text, because there is nowhere in a line of text to put a picture several rows tall.

Drawing one needs a terminal that speaks the kitty graphics protocol and reports its cell size in pixels; kitty, Ghostty and WezTerm all do. Everywhere else the alt text stands in, which is also what happens if the file is missing or is in a format mdv cannot read.

Only local files are drawn, resolved relative to the document. A remote URL is left as its alt text and is not fetched: opening a document should not make network requests, since an image in someone else's README is as easily a tracking pixel as a diagram.

Mermaid diagrams

A fenced block that says mermaid is a picture written as text, and mdv shows the picture: the fence is replaced by the diagram, drawn the same way as an image that has a paragraph to itself.

Mermaid is a language rather than a picture format — turning graph TD into something with boxes in it means laying out a graph, shaping text and measuring fonts, which is why every implementation of it is the real one running in a browser. mdv does not carry that around. It hands the source to mermaid-cli — mmdc, the Mermaid project's own command, which drives a headless Chromium — and draws the PNG that comes back.

mmdc is not part of mdv and is not installed with it:

$ nix shell nixpkgs#mermaid-cli     # or
$ npm install --global @mermaid-js/mermaid-cli

None of it is required. With no mmdc on the PATH, on a terminal that cannot draw images, or on a diagram with a syntax error in it, the block is shown as the source it was written as — which is what it was before any of this and what every other Markdown viewer in a terminal does with it.

Starting a browser costs a second or two, so a diagram is drawn on a task of its own rather than while the reader waits: the document appears immediately with its diagrams as source, and each picture replaces its source as it arrives. They are drawn one at a time, since each is a Chromium and a document with a dozen diagrams in it should not start a dozen of them.

What comes out is kept in $XDG_CACHE_HOME/mdv/diagrams, or under ~/.cache for a reader who has no XDG_CACHE_HOME, in a file named by a hash of the source and of how it was drawn. Opening the same document again therefore costs nothing at all: the PNG is already there and nothing is started. They are ordinary cache files, and deleting them loses nothing but the time to draw them again.

A diagram arrives with its colors already decided, unlike everything else mdv draws, which is painted in the sixteen ANSI palette indices and so comes out in the reader's own terminal theme. The nearest thing to that is to ask the terminal what color its background is — the OSC 11 query — and pick the Mermaid theme that can be read on it: dark on a dark terminal and neutral on a light one, both on a transparent background, so that a diagram sits on the page rather than on a card. A terminal that will not answer gets neutral on an explicit white background, which is the only choice that is legible whatever the background turns out to be.

--mermaid-theme=NAME settles it by hand instead, with one of Mermaid's own themes: default, forest, dark or neutral. --no-mermaid turns the whole thing off, and every diagram stays the source it was written as.

Keys

Key What it does
j k ↓ ↑ A line at a time
d u Half a screen
f b Space A whole screen
g G Home End The start or the end
wheel Three lines
/ Search; Enter to run it, Esc to give up
n N The next or previous match
t The headings, to pick one and go to it
click Follow a link: open a file, or jump to a heading
Backspace o Back to the document you came from
r Re-read the file from disk, which also happens on its own
? The list of keys, on screen
q Esc Quit

Building

The build needs Zig 0.17 and nothing else; libvaxis, which draws the terminal, flow-syntax, which highlights the code blocks, and nightwatch, which notices when the file changes, are fetched by the Zig package manager. The first build is a long one — flow-syntax brings ninety-odd tree-sitter grammars, each a generated C parser, and they are all compiled — but they are compiled once and cached. A Nix flake is provided for everything around it:

$ nix develop
$ zig build
$ zig build test
$ zig build run -- README.md

The flake also packages it, fetching the Zig dependencies with Nix rather than with Zig — a second way of building that goes wrong in its own ways, which is why CI does both:

$ nix build .#mdv

Nothing in the build depends on mermaid-cli: it is looked for on the PATH at run time, by name, and a machine without it simply shows diagrams as source.

build.zig.zon.nix is what makes that possible, and it is generated rather than maintained: after changing a dependency in build.zig.zon, regenerate it with the zon2nix in the devshell.

$ zon2nix --16 --nix=build.zig.zon.nix build.zig.zon

zig build docs writes the API reference for the mdv module — the parser and the layout engine, usable on their own by anything wanting a Markdown tree or a set of styled lines — into zig-out/docs. CI publishes it from main to https://jeff.jcollie.page/mdv/. The generated viewer fetches sources.tar at run time, which a browser refuses to do from a file:// page, so reading it locally means serving that directory over HTTP.

zig build test includes a soak that pushes a few thousand generated documents through the parser and the layout engine under the testing allocator, which is where the wrapping loop that could not make progress on a double-width character was caught. zig build fuzz --fuzz drives the same generator and the same checks from Zig's fuzzer, which steers toward documents that reach code nothing has reached yet; it runs until interrupted, and --fuzz=1M makes it a bounded run that ends with a report.

How it is put together

  • src/md/ — the parser. block.zig finds the structure of the document by the algorithm the CommonMark specification sets out, one line at a time against a stack of open blocks; inline.zig resolves emphasis, links and code spans within it; Document.zig is the tree they produce.
  • src/render/ — Layout.zig turns that tree into a flat array of styled lines for a given width, and Theme.zig holds the styles it paints with.
  • src/tui/App.zig — the viewport onto that array: scrolling, search, and drawing through libvaxis. Highlighter.zig beside it runs the tree-sitter grammars and hands the layout engine the runs it found, and Images.zig decodes the pictures; both are reached through a callback, so the layout engine depends on neither. Mermaid.zig runs the diagram renderer and keeps what it drew, handing the result to Images.zig as one more picture. Watcher.zig is the file watch, and it touches nothing: it wakes the event loop, and the viewer does the re-reading on its own thread when it gets there.

Laying the whole document out ahead of time is what makes the viewer simple. The screen is a window onto an array, so scrolling is an integer, the scrollbar knows the true length of the document, and a search can see all of it. Only a resize costs anything, and a resize rebuilds the layout from the tree without re-parsing.

Where it departs from the specification

  • Tabs are expanded to four-column stops before parsing rather than being consumed a column at a time, which differs only for documents that split a tab between a container marker and its content.
  • Of the two thousand named character references HTML5 defines, the hundred or so that appear in practice are decoded; the rest are left as the text that was typed.
  • A bare URL in running text is not recognized if its scheme or an email address's local part contains an underscore, because by then the underscore has already been taken for emphasis. Wrapping it in <> always works.

Repository

The repository is published on Radicle and mirrored to a Forgejo instance and to Tangled. Any of them will do; the Radicle copy needs no account and no forge.

# Radicle
rad clone rad:z4FbDCyBofJRocvs1gTA8poTjjp2f

# Forgejo
git clone https://git.jcollie.dev/jeff/mdv.git

# Tangled
git clone https://tangled.org/jcollie.dev/mdv

rad clone finds seeds through your local node's routing table, so the node has to be running first:

rad node start

If you already have the repository and only want to follow it, seeding it makes your node help host it for others:

rad seed rad:z4FbDCyBofJRocvs1gTA8poTjjp2f

The Radicle repository is public, its default branch is main, and it is named mdv — the same name and default branch as the Forgejo and Tangled mirrors, so any of the remotes gives the same history.

References cited

The documents mdv was written against, and the outside work it is built on. Every entry is also filed in the project's Zotero collection, mdv, so a citation can be taken from there rather than composed.

  • [COMMONMARK] MacFarlane, J., "CommonMark Spec", version 0.31.2, https://spec.commonmark.org/0.31.2/. The block and inline algorithms src/md/ implements, including the one-line-at-a-time walk over a stack of open blocks; Where it departs from the specification says what is not followed.
  • [GFM] GitHub, "GitHub Flavored Markdown Spec", https://github.github.com/gfm/. The extensions on top of CommonMark that mdv also renders: tables, task lists, strikethrough, autolinks and footnotes.
  • [RFC3986] Berners-Lee, T., Fielding, R., and L. Masinter, "Uniform Resource Identifier (URI): Generic Syntax", STD 66, RFC 3986, January 2005, https://www.rfc-editor.org/info/rfc3986. What every link destination is put through, which is what decides whether an autolink is one and whether a destination is handed to the terminal as a hyperlink.
  • [KITTY-GFX] Goyal, K., "Terminal graphics protocol", kitty documentation, https://sw.kovidgoyal.net/kitty/graphics-protocol/. How an image — a picture the document named, or a diagram mdv drew — is given to the terminal once and placed on every frame after that.
  • [XTERM-CTL] Dickey, T., "XTerm Control Sequences", https://invisible-island.net/xterm/ctlseqs/ctlseqs.html. The OSC 11 query mdv asks the terminal its background color with, which is what picks the Mermaid theme a diagram is legible in.
  • [MERMAID] "About Mermaid", https://mermaid.js.org/intro/. The diagram language a ```mermaid fence is written in.
  • [MERMAID-CLI] "mermaid-cli: a command-line interface for mermaid", https://github.com/mermaid-js/mermaid-cli. mmdc, which mdv shells out to for the picture, and whose --theme and --backgroundColor are the two things it is told about the terminal.
  • [XDG-BASEDIR] "XDG Base Directory Specification", https://specifications.freedesktop.org/basedir-spec/latest/. Where the rendered diagrams are kept: $XDG_CACHE_HOME, falling back to ~/.cache.
  • [LIBVAXIS] Rockorager, T., "libvaxis", https://github.com/rockorager/libvaxis. Draws the terminal, parses its input, and carries zigimg for decoding the images.
  • [FLOW-SYNTAX] Neurocyte, "flow-syntax", https://github.com/neurocyte/flow-syntax. The tree-sitter grammars and highlight queries behind the syntax highlighting, compiled to a binary form at build time.
  • [TREE-SITTER] Brunsfeld, M., et al., "Tree-sitter: an incremental parsing system for programming tools", https://tree-sitter.github.io/tree-sitter/. What those grammars are, and the capture names the theme collapses onto the dozen distinctions a terminal can draw.
  • [NIGHTWATCH] Neurocyte, "nightwatch", https://github.com/neurocyte/nightwatch. The file watch: inotify, kqueue and ReadDirectoryChangesW behind one interface.
  • [ZIGIMG] "zigimg", https://github.com/zigimg/zigimg. Decodes the image formats mdv draws and re-encodes them as the PNG the terminal is sent.

License

MIT. The project follows the REUSE specification; reuse lint passes.