- Zig 96.3%
- Nix 3.7%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
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 |
||
| .forgejo/workflows | ||
| LICENSES | ||
| src | ||
| tests | ||
| .gitignore | ||
| build.zig | ||
| build.zig.zon | ||
| build.zig.zon.nix | ||
| flake.lock | ||
| flake.nix | ||
| package.nix | ||
| README.md | ||
| REUSE.toml | ||
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, |
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.
Links
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.
Following links
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.zigfinds 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.zigresolves emphasis, links and code spans within it;Document.zigis the tree they produce.src/render/—Layout.zigturns that tree into a flat array of styled lines for a given width, andTheme.zigholds the styles it paints with.src/tui/App.zig— the viewport onto that array: scrolling, search, and drawing through libvaxis.Highlighter.zigbeside it runs the tree-sitter grammars and hands the layout engine the runs it found, andImages.zigdecodes the pictures; both are reached through a callback, so the layout engine depends on neither.Mermaid.zigruns the diagram renderer and keeps what it drew, handing the result toImages.zigas one more picture.Watcher.zigis 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
```mermaidfence 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--themeand--backgroundColorare 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
ReadDirectoryChangesWbehind 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.