From 6d1f7e61c9137e644ee7e0fe39670582de7d4f7a Mon Sep 17 00:00:00 2001 From: TsunamiNoAi Date: Sat, 18 Jul 2026 11:03:55 -0400 Subject: [PATCH 1/2] feat: Typst math alt text + typed YAML front-matter scalars (0.10.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Typst renderer now emits `alt:` (the TeX source) on every `#mi(…)` / `#mitex(…)` equation, so documents containing math compile under Typst's PDF/UA-1 tagged-PDF mode without a document-wide `#set math.equation(alt: …)` stopgap. The alt argument is escaped with the same guard as the content. Closes #78. Unquoted YAML front-matter scalars are now typed like TOML: `true`/`false` → `.bool`, `null` → `.null` (in addition to the existing int/float coercion), routed through the same `inferValue` helper the CLI `--set key=value` path uses. `Frontmatter.get` now returns the same `std.json.Value` tag regardless of source format; quoted scalars still stay `.string`. Closes #79. Also surface the zigmark library version in the WASM demo header via a new badge, read from the module's exported `version_ptr`/`version_len`. --- CHANGELOG.md | 27 ++++++++++++++++++++ build.zig.zon | 2 +- examples/wasm/index.html | 14 +++++++++++ src/markdown/frontmatter.zig | 20 +++++++++------ src/markdown/frontmatter_test.zig | 42 ++++++++++++++++++++++++++++--- src/markdown/renderers/typst.zig | 30 +++++++++++++++------- 6 files changed, 113 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d888df6..cde7e0e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/build.zig.zon b/build.zig.zon index 2a97f6b..93e45cf 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -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 diff --git a/examples/wasm/index.html b/examples/wasm/index.html index a6e18dd..1f12e17 100644 --- a/examples/wasm/index.html +++ b/examples/wasm/index.html @@ -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; } @@ -78,6 +79,7 @@

zigmark

WASM + zigmark ms  ·  marked.js ms  ·  @@ -241,6 +243,18 @@

Cold vs warm

}); 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 ────────────────────────────────────────────────────────────────── diff --git a/src/markdown/frontmatter.zig b/src/markdown/frontmatter.zig index 4b300f0..39a514e 100644 --- a/src/markdown/frontmatter.zig +++ b/src/markdown/frontmatter.zig @@ -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 => { @@ -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, + }; }, } } diff --git a/src/markdown/frontmatter_test.zig b/src/markdown/frontmatter_test.zig index 8877c9d..e0602a3 100644 --- a/src/markdown/frontmatter_test.zig +++ b/src/markdown/frontmatter_test.zig @@ -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(); @@ -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); @@ -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 @@ -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" { diff --git a/src/markdown/renderers/typst.zig b/src/markdown/renderers/typst.zig index eb64391..a964e02 100644 --- a/src/markdown/renderers/typst.zig +++ b/src/markdown/renderers/typst.zig @@ -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("\")"); }, @@ -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", ); } @@ -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" { @@ -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", ); } From b7ba938bbeb5c495967a15283e173731dbd01cbc Mon Sep 17 00:00:00 2001 From: TsunamiNoAi Date: Sat, 18 Jul 2026 11:13:24 -0400 Subject: [PATCH 2/2] fix: update nixpkgs and zig2nix URLs to use tarball sources --- flake.lock | 36 +++++++++++++++++------------------- flake.nix | 4 ++-- 2 files changed, 19 insertions(+), 21 deletions(-) diff --git a/flake.lock b/flake.lock index 96ba37a..5cdad4e 100644 --- a/flake.lock +++ b/flake.lock @@ -20,27 +20,25 @@ }, "nixpkgs": { "locked": { - "lastModified": 1780365719, - "narHash": "sha256-QfWfccTN+70ZQ4m2qlU9PiKfz2Yppq94058iJyARNwc=", - "owner": "NixOS", - "repo": "nixpkgs", - "rev": "ffa10e26ae11d676b2db836259889f1f571cb14f", - "type": "github" + "lastModified": 1784356753, + "narHash": "sha256-12KrbMiWLcf8m7pCvAtZh1ZrgF85ZXDXvfR/fWTKy84=", + "rev": "61b7c44c4073f0b827768aff0049561b5110ea5a", + "revCount": 1036777, + "type": "tarball", + "url": "https://api.flakehub.com/f/pinned/NixOS/nixpkgs/0.1.1036777%2Brev-61b7c44c4073f0b827768aff0049561b5110ea5a/019f755f-4937-7ed1-93d6-e2d5a2d3cca7/source.tar.gz" }, "original": { - "owner": "NixOS", - "ref": "nixpkgs-unstable", - "repo": "nixpkgs", - "type": "github" + "type": "tarball", + "url": "https://flakehub.com/f/NixOS/nixpkgs/0.1.%2A.tar.gz" } }, "nixpkgs_2": { "locked": { - "lastModified": 1780540212, - "narHash": "sha256-hqlJBt1MTYZ7LSm20DPb5ZVqYp6PGaXDmZWPBOXRF6A=", + "lastModified": 1783476344, + "narHash": "sha256-+gX8sBedQ3JSvFQ7CIV67+FyB/OlHb9HSoQDl/kM064=", "owner": "nixos", "repo": "nixpkgs", - "rev": "a03c17048bf84b1faaef309607168810bb9acb07", + "rev": "e920408605a19fda9b4e96e308b4d3f6592b32f6", "type": "github" }, "original": { @@ -76,16 +74,16 @@ "nixpkgs": "nixpkgs_2" }, "locked": { - "lastModified": 1780460820, - "narHash": "sha256-1VrSHtqG+Mu9i7nerwfNlOSgHT1sCOx/Kx9Y1xeMHQw=", - "rev": "aff77c36c42dc84f81c2b48d6ac4a0457099e426", - "revCount": 992, + "lastModified": 1783395912, + "narHash": "sha256-khY1LUzeLm7QETVGsG+hRPkgjrHWSuXTMjKMJ1LcbPg=", + "rev": "db4696c32577d9d5213112d10a5762124f4a7fed", + "revCount": 1026, "type": "tarball", - "url": "https://api.flakehub.com/f/pinned/Cloudef/zig2nix/0.1.992%2Brev-aff77c36c42dc84f81c2b48d6ac4a0457099e426/019e90dd-0e0a-7463-97b3-eaa1b86f8c0c/source.tar.gz" + "url": "https://api.flakehub.com/f/pinned/Cloudef/zig2nix/0.1.1026%2Brev-db4696c32577d9d5213112d10a5762124f4a7fed/019f3fab-6e97-7521-8429-90a92b6107f3/source.tar.gz" }, "original": { "type": "tarball", - "url": "https://flakehub.com/f/Cloudef/zig2nix/0.1.990.tar.gz" + "url": "https://flakehub.com/f/Cloudef/zig2nix/0.1.%2A.tar.gz" } } }, diff --git a/flake.nix b/flake.nix index b99f393..6d5a9a1 100644 --- a/flake.nix +++ b/flake.nix @@ -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 = {