No description
  • Zig 95%
  • Nix 5%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Jeffrey C. Ollie ef91bbf57e
All checks were successful
test / test (push) Successful in 6m9s
test / nix (push) Successful in 7m25s
test / docs (push) Successful in 4m58s
Check what the layout engine draws, not just that it survives
The soak asserted nothing beyond "it came back", and a parser that returned an
empty document for everything would have passed it too. It now checks the
contracts that matter -- no line wider than the terminal it was laid out for,
no empty spans or trailing whitespace, tables rectangular, footnote references
resolving, headings in range, image placements on lines that exist, and the
same document laying out the same way twice. The generator gained the
constructs the new code cares about: link destinations for the URI classifier
to judge, and combining marks and double-width glyphs for the width
arithmetic. One generator now feeds both the fuzzer and the seeded soak, so
the two cannot drift apart.

The width check found four ways to draw past the edge of the screen, which is
the one layout bug that cannot be shrugged off: the terminal wraps the
overflow onto the next row and every line below it is a row out for the rest
of the document, so the scroll position stops meaning anything.

A prefix was never measured against the terminal. Nesting costs columns -- two
per block quote, eleven for a list numbered in the hundreds of millions -- and
`available` then guaranteed a content column on top of whatever the prefix had
taken. `999999999. x` came to twelve columns in an eight-column terminal. The
prefix is now cut to fit, keeping the outermost structure, and two columns are
always held back for content since a grapheme cluster can be two wide.

A list marker replaced the last element of the prefix rather than the
placeholder pushed for it. Those are the same element only when the item's
first child writes a line itself; when it is a block quote, the bar sits on
top of the placeholder, so `1. > quoted` put the bullet a level too deep, lost
the bar from that line, and ran eight columns over. The marker now records
which element it stands in for.

A fenced block's info string was emitted unwrapped. A tilde fence puts no
restriction on it, so it is an arbitrary line of text, and it now gets the
same hard wrap as the code it labels.

A table cannot be made narrow enough. Every column costs three columns of
frame on top of its content, so two columns want nine columns of terminal
however little is in them, and the shrink loop cannot buy the frame back.
Table lines are now cut to fit.

The fourth is not a live bug: the image sizer is asked for a width and told
what there is to spare, and the one mdv ships respects it. It is clamped
anyway, because that number is the only one in a layout that does not come
from the layout engine, and everything else here already refuses to overrun.

175,000 generated documents across seven seeds are clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XYdxmcJRqTTDiUATCSHwU5
2026-09-05 21:18:02 -05:00
.forgejo/workflows Bring CI up to the current shape, and package with Nix 2026-09-05 15:39:41 -05:00
LICENSES Add mdv, a terminal viewer for GitHub-Flavored Markdown 2026-09-05 15:14:39 -05:00
src Check what the layout engine draws, not just that it survives 2026-09-05 21:18:02 -05:00
tests Check what the layout engine draws, not just that it survives 2026-09-05 21:18:02 -05:00
.gitignore Add mdv, a terminal viewer for GitHub-Flavored Markdown 2026-09-05 15:14:39 -05:00
build.zig Check what the layout engine draws, not just that it survives 2026-09-05 21:18:02 -05:00
build.zig.zon Draw images inline with the kitty graphics protocol 2026-09-05 16:26:48 -05:00
build.zig.zon.nix Draw images inline with the kitty graphics protocol 2026-09-05 16:26:48 -05:00
flake.lock Bring CI up to the current shape, and package with Nix 2026-09-05 15:39:41 -05:00
flake.nix Bring CI up to the current shape, and package with Nix 2026-09-05 15:39:41 -05:00
package.nix Bring CI up to the current shape, and package with Nix 2026-09-05 15:39:41 -05:00
README.md Record why code blocks are not highlighted yet 2026-09-05 18:12:17 -05:00
REUSE.toml Bring CI up to the current shape, and package with Nix 2026-09-05 15:39:41 -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, short of syntax highlighting inside code blocks:

Construct Shown as
Headings, ATX and setext Colored by level, with the #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
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
Footnotes Collected at the end, with the marker inline

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.

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.

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
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
? The list of keys, on screen
q Esc Quit

Building

The build needs Zig 0.16 and nothing else; libvaxis, which draws the terminal, is fetched by the Zig package manager. 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

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.ocj.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. There is a zig build fuzz --fuzz target too, but Zig 0.16 leaves its coverage table empty and dies in the build runner before reaching it; the soak stands in until that is fixed.

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.

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.
  • Code blocks are not syntax highlighted. The intended route is neurocyte/tree-sitter, which packages tree-sitter and some ninety grammars for Zig, and it cannot currently be reached through the Zig package manager: every grammar, the tree-sitter core and the treez binding are git submodules, and zig fetch does not recurse them, so what arrives is eighty-nine empty directories. Fetching the repository with its submodules from Nix would work — 70dbb6f is the last commit whose build.zig compiles under Zig 0.16, the one after it being the switch to b.root — at the cost of building all ninety-odd grammars, or of wiring up a chosen few by hand.
  • Targa images are not drawn, though zigimg can read them. Referencing its Targa decoder segfaults the Zig 0.16 compiler, and the entry point that dispatches over every format reaches that decoder along with the rest, so mdv names the formats it wants instead. Every other format zigimg supports is included.

Repository

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

# Radicle
rad clone rad:z4FbDCyBofJRocvs1gTA8poTjjp2f

# Forgejo
git clone https://git.ocjtech.us/jeff/mdv.git

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 mirror, so either remote gives the same history.

License

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