- Zig 95%
- Nix 5%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
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 |
||
| .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, short of syntax highlighting inside code blocks:
| Construct | Shown as |
|---|---|
| Headings, ATX and setext | Colored by level, with the #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 |
| 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 |
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.
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.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.
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
treezbinding are git submodules, andzig fetchdoes not recurse them, so what arrives is eighty-nine empty directories. Fetching the repository with its submodules from Nix would work —70dbb6fis the last commit whosebuild.zigcompiles under Zig 0.16, the one after it being the switch tob.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.