Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,33 @@ same stability guarantee.

## [Unreleased]

## [0.10.0] — 2026-07-18

### Changed

- **Unquoted YAML front-matter scalars are now typed like TOML.** `true`/`false`
become `.bool` and `null` becomes `.null` (in addition to the existing
int/float coercion), so `Frontmatter.get` returns the same `std.json.Value`
tag regardless of whether the source was YAML or TOML. Previously an unquoted
YAML boolean came back as `.string` (`"true"`) while the identical TOML value
came back as `.bool`, so a consumer could not switch on the value type
portably. Quoted scalars still stay `.string`, so `flag: "true"` is unchanged.
The typing now flows through the same `inferValue` helper the CLI
`--set key=value` path uses (#79).

### Fixed

- **Typst math equations now carry `alt` text.** The renderer emits
`#mi("…", alt: "…")` / `#mitex("…", alt: "…")` with the TeX source as the alt
argument (mitex forwards `..args` straight to `math.equation`), so a document
containing math compiles under Typst's PDF/UA-1 tagged-PDF mode
(`typst compile --pdf-standard ua-1`), which hard-fails on any equation
without alt text. Consumers no longer need a document-wide
`#set math.equation(alt: …)` stopgap, and each equation gets a meaningful
per-equation description instead of one generic string. The alt argument is
string-literal-escaped with the same guard as the content, so it cannot break
out of the literal (#78).

## [0.9.0] — 2026-07-16

### Fixed
Expand Down
2 changes: 1 addition & 1 deletion build.zig.zon
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
.name = .zigmark,
// This is a [Semantic Version](https://semver.org/).
// In a future version of Zig it will be used for package deduplication.
.version = "0.9.0",
.version = "0.10.0",
// Together with name, this represents a globally unique package
// identifier. This field is generated by the Zig toolchain when the
// package is first created, and then *never changes*. This allows
Expand Down
14 changes: 14 additions & 0 deletions examples/wasm/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@
.badge { display: inline-block; background: #238636; color: #fff; font-size: 11px; padding: 2px 8px;
border-radius: 12px; vertical-align: middle; }
.badge.js { background: #9e6a03; }
.badge.version { background: #30363d; color: #c9d1d9; }
/* Allocator benchmark overlay */
#bench-overlay { display:none; position:fixed; inset:0; background:rgba(0,0,0,.65); z-index:100; align-items:center; justify-content:center; }
#bench-overlay.open { display:flex; }
Expand Down Expand Up @@ -78,6 +79,7 @@
<header>
<h1>zigmark</h1>
<span class="badge">WASM</span>
<span class="badge version" id="version-badge" title="zigmark library version"></span>
<span class="stats">
zigmark <b id="zig-time">—</b> ms &nbsp;·&nbsp;
<span id="js-label">marked.js</span> <b id="js-time">—</b> ms &nbsp;·&nbsp;
Expand Down Expand Up @@ -241,6 +243,18 @@ <h3>Cold vs warm</h3>
});
wasm = instance.exports;
memory = wasm.memory;
showVersion();
}

// Read the zigmark library version exported by the WASM module and show it
// in the header badge.
function showVersion() {
const badge = document.getElementById('version-badge');
if (!badge || !wasm.version_ptr || !wasm.version_len) return;
const ptr = wasm.version_ptr();
const len = wasm.version_len();
const v = new TextDecoder().decode(new Uint8Array(wasm.memory.buffer, ptr, len));
badge.textContent = 'v' + v;
}

// ── Helpers ──────────────────────────────────────────────────────────────────
Expand Down
36 changes: 17 additions & 19 deletions flake.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions flake.nix
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@
description = "ZigMark - Simple markdown processing for zig";

inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
zig2nix.url = "https://flakehub.com/f/Cloudef/zig2nix/0.1.990.tar.gz";
nixpkgs.url = "https://flakehub.com/f/NixOS/nixpkgs/0.1.*.tar.gz";
zig2nix.url = "https://flakehub.com/f/Cloudef/zig2nix/0.1.*.tar.gz";
};

outputs = {
Expand Down
20 changes: 12 additions & 8 deletions src/markdown/frontmatter.zig
Original file line number Diff line number Diff line change
Expand Up @@ -613,8 +613,8 @@ fn mergeJsonValue(alloc: Allocator, base: *std.json.Value, overlay: std.json.Val

/// Recursively convert a `zig-yaml` Tree node into `std.json.Value`,
/// preserving quoting information: `.string_value` (quoted) nodes always
/// produce `.string`; `.value` (unquoted) nodes are coerced to int/float
/// where possible.
/// produce `.string`; `.value` (unquoted) nodes are typed via `inferValue`
/// (bool/null/int/float, else string), matching TOML's scalar typing.
fn treeNodeToJson(allocator: Allocator, tree: Tree, node_index: Tree.Node.Index) !JsonValue {
switch (tree.nodeTag(node_index)) {
.doc => {
Expand Down Expand Up @@ -687,14 +687,18 @@ fn treeNodeToJson(allocator: Allocator, tree: Tree, node_index: Tree.Node.Index)
return JsonValue{ .string = raw };
},
.value => {
// Unquoted scalar — coerce to int, float, or keep as string.
// Unquoted scalar — typed via `inferValue` (bool/null/int/float/
// string), the same helper the CLI `--set key=value` path uses, so
// `fm.get()` returns the same tag regardless of front-matter format
// (matching TOML: `true`/`false` → `.bool`, `null` → `.null`).
// `inferValue` aliases its input for strings, and `raw` may alias the
// yaml/source buffers, so the `.string` case is duped into `allocator`.
const raw = tree.nodeScope(node_index).rawString(tree);
if (raw.len == 0) return JsonValue{ .null = {} };
return JsonValue{ .integer = std.fmt.parseInt(i64, raw, 10) catch {
return JsonValue{ .float = std.fmt.parseFloat(f64, raw) catch {
return JsonValue{ .string = try allocator.dupe(u8, raw) };
} };
} };
return switch (inferValue(raw)) {
.string => JsonValue{ .string = try allocator.dupe(u8, raw) },
else => |v| v,
};
},
}
}
Expand Down
42 changes: 38 additions & 4 deletions src/markdown/frontmatter_test.zig
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ test "frontmatter: YAML quoted numeric strings stay as strings" {
\\version: "1"
\\weight: "42"
\\tag: "007"
\\flag: "true"
;
var fm = try FrontMatter.init(alloc, source, .yaml);
defer fm.deinit();
Expand All @@ -95,6 +96,12 @@ test "frontmatter: YAML quoted numeric strings stay as strings" {
try tst.expect(version.? == .string);
try tst.expectEqualStrings("1", version.?.string);

// A quoted `"true"` stays a string — quoting overrides bool coercion.
const flag = fm.get("flag");
try tst.expect(flag != null);
try tst.expect(flag.? == .string);
try tst.expectEqualStrings("true", flag.?.string);

const weight = fm.get("weight");
try tst.expect(weight != null);
try tst.expect(weight.? == .string);
Expand All @@ -107,6 +114,8 @@ test "frontmatter: YAML quoted numeric strings stay as strings" {
}

test "frontmatter: YAML boolean values" {
// Unquoted YAML booleans are typed `.bool`, matching TOML — so a consumer
// can switch on the value type portably regardless of front-matter format.
const alloc = tst.allocator;
const source =
\\draft: true
Expand All @@ -117,13 +126,38 @@ test "frontmatter: YAML boolean values" {

const draft = fm.get("draft");
try tst.expect(draft != null);
try tst.expect(draft.? == .string);
try tst.expectEqualStrings("true", draft.?.string);
try tst.expectEqualDeep(std.json.Value{ .bool = true }, draft.?);

const published = fm.get("published");
try tst.expect(published != null);
try tst.expect(published.? == .string);
try tst.expectEqualStrings("false", published.?.string);
try tst.expectEqualDeep(std.json.Value{ .bool = false }, published.?);
}

test "frontmatter: YAML null value" {
// Unquoted YAML `null` is typed `.null` (YAML 1.2 core schema).
const alloc = tst.allocator;
const source =
\\reviewer: null
;
var fm = try FrontMatter.init(alloc, source, .yaml);
defer fm.deinit();

const reviewer = fm.get("reviewer");
try tst.expect(reviewer != null);
try tst.expect(reviewer.? == .null);
}

test "frontmatter: YAML and TOML type unquoted booleans identically" {
// Same key/value in both formats must yield the same `.bool` tag.
const alloc = tst.allocator;

var y = try FrontMatter.init(alloc, "enabled: true\n", .yaml);
defer y.deinit();
var t = try FrontMatter.init(alloc, "enabled = true\n", .toml);
defer t.deinit();

try tst.expectEqualDeep(y.get("enabled").?, t.get("enabled").?);
try tst.expectEqualDeep(std.json.Value{ .bool = true }, y.get("enabled").?);
}

test "frontmatter: TOML basic parsing" {
Expand Down
30 changes: 21 additions & 9 deletions src/markdown/renderers/typst.zig
Original file line number Diff line number Diff line change
Expand Up @@ -233,8 +233,17 @@ fn renderInline(writer: *std.Io.Writer, item: AST.Inline, ctx: *const Ctx) anyer
// zigmark does not emit the `#import "@preview/mitex..."` line;
// the consumer's preamble must provide `mi`/`mitex` (use
// `docHasMath` to decide whether the import is needed).
//
// An `alt:` argument carrying the TeX source is emitted on every
// equation: mitex forwards `..args` straight to `math.equation`, so
// this gives each equation per-equation alt text and lets tagged
// PDFs pass Typst's PDF/UA-1 mode (which hard-fails on any
// `math.equation` without alt text). It goes through
// `writeStringLiteral` too, so `"`/`\` cannot escape the literal.
try writer.writeAll(if (m.display) "#mitex(\"" else "#mi(\"");
try writeStringLiteral(writer, m.content);
try writer.writeAll("\", alt: \"");
try writeStringLiteral(writer, m.content);
try writer.writeAll("\")");
},

Expand Down Expand Up @@ -1195,19 +1204,21 @@ fn okMath(src: []const u8, expected: []const u8) !void {
}

test "inline math emits #mi" {
try okMath("$E=mc^2$", "#mi(\"E=mc^2\")\n\n");
// Each equation carries an `alt:` (the TeX source) so tagged PDFs pass UA-1.
try okMath("$E=mc^2$", "#mi(\"E=mc^2\", alt: \"E=mc^2\")\n\n");
}

test "display math emits #mitex" {
// LaTeX backslashes become `\\` inside the Typst string literal;
// Typst unescapes them back to `\` before mitex sees the TeX.
try okMath("$$\\sum_{i=0}^n i$$", "#mitex(\"\\\\sum_{i=0}^n i\")\n\n");
// Typst unescapes them back to `\` before mitex sees the TeX. The same
// escaping applies to the `alt:` argument.
try okMath("$$\\sum_{i=0}^n i$$", "#mitex(\"\\\\sum_{i=0}^n i\", alt: \"\\\\sum_{i=0}^n i\")\n\n");
}

test "inline and display math mixed with text" {
try okMath(
"Inline $a+b$ and display: $$\\frac{a}{b}$$",
"Inline #mi(\"a+b\") and display: #mitex(\"\\\\frac{a}{b}\")\n\n",
"Inline #mi(\"a+b\", alt: \"a+b\") and display: #mitex(\"\\\\frac{a}{b}\", alt: \"\\\\frac{a}{b}\")\n\n",
);
}

Expand All @@ -1232,7 +1243,7 @@ test "unterminated math stays literal" {
}

test "math in heading" {
try okMath("# Euler $e^{i\\pi}$", "= Euler #mi(\"e^{i\\\\pi}\")\n");
try okMath("# Euler $e^{i\\pi}$", "= Euler #mi(\"e^{i\\\\pi}\", alt: \"e^{i\\\\pi}\")\n");
}

test "math in table cell" {
Expand All @@ -1243,19 +1254,20 @@ test "math in table cell" {
" align: (auto),\n" ++
" stroke: rgb(\"#999999\"),\n" ++
" table.header(\n" ++
" [*#mi(\"x^2\")*],\n" ++
" [*#mi(\"x^2\", alt: \"x^2\")*],\n" ++
" ),\n" ++
" [#mi(\"y\")],\n" ++
" [#mi(\"y\", alt: \"y\")],\n" ++
")\n\n",
);
}

test "math content cannot break out of the Typst string literal" {
// A quote in the TeX source must stay inside the string literal — the
// same injection guard as the code-span/raw() tests above.
// same injection guard as the code-span/raw() tests above. This holds for
// the `alt:` argument too, which is escaped identically.
try okMath(
"$\") #read(\"/etc/passwd\") #(\"$",
"#mi(\"\\\") #read(\\\"/etc/passwd\\\") #(\\\"\")\n\n",
"#mi(\"\\\") #read(\\\"/etc/passwd\\\") #(\\\"\", alt: \"\\\") #read(\\\"/etc/passwd\\\") #(\\\"\")\n\n",
);
}

Expand Down
Loading