- Zig 98.7%
- Shell 1.3%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
The referenced repos have migrated; their codeberg copies are deleted. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
| bench | ||
| src | ||
| test | ||
| .gitignore | ||
| BENCHMARKS.md | ||
| build.zig | ||
| build.zig.zon | ||
| CLAUDE.md | ||
| context.md | ||
| DESIGN.md | ||
| LICENSE | ||
| README.md | ||
markz
A fast CommonMark markdown parser for Zig. Builds a full AST for programmatic access, with optional extensions for GFM tables/task lists, CriticMarkup, and Obsidian-flavored markdown.
- 100% CommonMark spec conformance (652/652 examples)
- 1.5x faster than cmark (the C reference implementation)
- Zero-copy text nodes reference original source bytes
- Extensions for GFM (tables, task lists), CriticMarkup, and Obsidian, toggled at parse time
- Zig 0.16+, no dependencies
Performance
Measured on Apple Silicon (M-series), parse + render to HTML:
| Corpus | markz | cmark | md4c |
|---|---|---|---|
| Pro Git (10.8 MB) | 249 MB/s | 162 MB/s | 330 MB/s |
| Synthetic (976 KB) | 76 MB/s | 42 MB/s | 91 MB/s |
| Spec suite (15 KB) | 5.8 MB/s | 4.8 MB/s | 5.3 MB/s |
markz builds a full AST then renders in a separate pass. md4c uses streaming callbacks with no intermediate tree, which accounts for most of the gap. The AST is the point — it's what consumers like editors and analysis tools need.
Getting started
Add markz to your build.zig.zon:
.markz = .{ .url = "git+https://git.urverk.org/urverk/markz#<commit>" },
Then in build.zig:
const markz_dep = b.dependency("markz", .{ .target = target, .optimize = optimize });
exe.root_module.addImport("markz", markz_dep.module("markz"));
Usage
Parse and render to HTML
const markz = @import("markz");
const html = blk: {
var doc = try markz.parse(allocator, markdown_source);
defer doc.deinit();
break :blk try markz.renderHtml(allocator, &doc);
};
defer allocator.free(html);
Walk the AST
var doc = try markz.parse(allocator, source);
defer doc.deinit();
var iter = doc.iterator();
while (iter.next()) |event| {
switch (event) {
.enter => |node| {
// Container opened (block_quote, list, emphasis, link, ...)
switch (node.tag) {
.heading => std.debug.print("<h{}>", .{node.data.heading.level}),
.emphasis => std.debug.print("<em>", .{}),
.link => {
const ld = doc.getLinkData(node.data.link);
std.debug.print("<a href=\"{s}\">", .{ld.destination});
},
else => {},
}
},
.exit => |node| {
// Container closed
switch (node.tag) {
.heading => std.debug.print("</h{}>", .{node.data.heading.level}),
.emphasis => std.debug.print("</em>", .{}),
.link => std.debug.print("</a>", .{}),
else => {},
}
},
.leaf => |node| {
// Leaf node (text, code_span, soft_break, ...)
switch (node.tag) {
.text => std.debug.print("{s}", .{doc.nodeText(node)}),
.code_span => std.debug.print("<code>{s}</code>", .{doc.nodeText(node)}),
.soft_break => std.debug.print("\n", .{}),
else => {},
}
},
}
}
Parse with extensions
var doc = try markz.parseWith(allocator, source, .{
.gfm = true,
.critic_markup = true,
.obsidian = true,
});
defer doc.deinit();
Extensions are off by default. Standard markdown produces identical output regardless of extension flags.
Extensions
GFM (GitHub Flavored Markdown)
Tables with optional column alignment:
| Left | Center | Right |
|:-----|:------:|------:|
| a | b | c |
Renders to <table> with <thead>/<tbody>, and align attributes on cells.
Task lists in list items:
- [ ] todo
- [x] done
Renders checkboxes as <input type="checkbox" disabled="" /> (checked when [x]).
CriticMarkup
Editorial markup for prose review:
| Syntax | Renders as |
|---|---|
{++inserted text++} |
<ins>inserted text</ins> |
{--deleted text--} |
<del>deleted text</del> |
{~~old~>new~~} |
<del>old</del><ins>new</ins> |
{>>comment<<} |
<span class="critic comment">comment</span> |
{==highlighted==} |
<mark>highlighted</mark> |
Obsidian
Wiki-style markup for note-taking apps:
| Syntax | Renders as |
|---|---|
[[page]] |
<a href="page" class="wikilink">page</a> |
[[page|display text]] |
<a href="page" class="wikilink">display text</a> |
#tag |
<span class="obsidian-tag">#tag</span> |
YAML frontmatter (--- fenced at document start) is parsed as a frontmatter
node but not included in HTML output.
AST node types
Block-level
document, block_quote, list, list_item, heading, code_block,
html_block, paragraph, thematic_break, frontmatter
Inline
text, emphasis, strong, code_span, link, image, autolink,
html_inline, soft_break, hard_break
GFM
table, table_head, table_body, table_row, table_cell
list_item gains a task_state (none, unchecked, checked) when GFM is enabled.
CriticMarkup
critic_insertion, critic_deletion, critic_substitution,
critic_comment, critic_highlight
Obsidian
wiki_link, obsidian_tag
Building
zig build test # unit tests
zig build test-spec # CommonMark spec conformance (652 examples)
zig build bench # performance benchmarks