From 9c8a99caaa613b44886ca5ff04fb8ab841b4d484 Mon Sep 17 00:00:00 2001 From: TsunamiNoAi Date: Fri, 3 Jul 2026 10:39:02 -0400 Subject: [PATCH 1/5] fix(typst): emit compilable mermaid embeds and add body-only mermaid API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Typst renderer emitted #image.decode(bytes.fromBase64("…")), but bytes.fromBase64 does not exist in Typst and image.decode is deprecated — every document with a rendered mermaid block failed typst compile. The tests only asserted the emitted string, so this never met a real typst. - Embed diagrams as image(bytes(""), format: "svg") with the SVG source in a string literal (quotes/backslashes escaped, raw newlines legal; verified against typst 0.14.2). - Render diagrams at natural size, scaling down only when wider than the line width (layout/measure wrapper) — matches how LaTeX pipelines size images instead of blowing small flowcharts up to full text width. - Add renderTypstWithMermaid / renderToWriterWithMermaid: body-only Typst rendering with the mermaid hook, for callers that supply their own preamble (e.g. PolicyPress). - Bump version to 0.7.2. Signed-off-by: TsunamiNoAi --- CHANGELOG.md | 22 ++++++++ README.md | 2 +- build.zig.zon | 2 +- src/markdown/renderers/typst.zig | 94 ++++++++++++++++++++++++++++---- src/root.zig | 21 +++++-- 5 files changed, 123 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fd41f37..3881661 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,28 @@ Versions track the library API, not Zig itself. The major version will remain 0.x until Zig reaches 1.0, at which point zigmark will follow the same stability guarantee. +## [0.7.2] — 2026-07-03 + +### Fixed + +- **Typst mermaid embeds now actually compile.** The Typst renderer emitted + `#image.decode(bytes.fromBase64("…"))`, but `bytes.fromBase64` does not + exist in Typst (and `image.decode` is deprecated) — every document with a + rendered mermaid block failed `typst compile`. Diagrams are now embedded as + `image(bytes(""), format: "svg")` with the SVG source in a string + literal (verified against typst 0.14.2). The existing tests only asserted + the emitted string, which is how this slipped through. +- Mermaid diagrams are rendered at their natural size and only scaled down + when wider than the line width (via a `layout`/`measure` wrapper), instead + of being blown up to full text width. + +### Added + +- `renderTypstWithMermaid` / `TypstRenderer`'s `renderToWriterWithMermaid` — + body-only Typst rendering with the mermaid hook, for callers that supply + their own preamble (previously the hook was only reachable through the + full-document `renderTypstDocWithMermaid`). + ## [0.7.0] — 2026-06-04 ### Breaking diff --git a/README.md b/README.md index aa58be1..b0a49e1 100644 --- a/README.md +++ b/README.md @@ -755,7 +755,7 @@ Requires **Zig 0.16.0** or later. - **`Parser`** — Block-level + inline two-pass parser built on the [mecha](https://github.com/Hejsil/mecha) parser combinator library; accepts a `[]const u8` via `parseMarkdown` or any `*std.Io.Reader` via `parseFromReader` - **`AST`** — Typed union-based Abstract Syntax Tree (`Document` → `Block` → `Inline`) - **`HTMLRenderer`** — CommonMark-compliant HTML serialiser; `renderHtmlWithMermaid` accepts an optional `MermaidRendererFn` (`*const fn(Allocator, []const u8) anyerror![]const u8`) to convert `mermaid`/`mermaidjs` fenced blocks to inline SVG `
` elements (falls back to a plain code block on error or when `null`) -- **`TypstRenderer`** — Typst markup renderer; `typst.renderDocument` adds an eisvogel-inspired preamble (title page, TOC, headers/footers, styled code blocks and blockquotes) driven by `DocumentOptions`; `renderTypstDocWithMermaid` accepts the same optional `MermaidRendererFn` (`*const fn(Allocator, []const u8) anyerror![]const u8`) and converts `mermaid`/`mermaidjs` blocks to `#image.decode` calls +- **`TypstRenderer`** — Typst markup renderer; `typst.renderDocument` adds an eisvogel-inspired preamble (title page, TOC, headers/footers, styled code blocks and blockquotes) driven by `DocumentOptions`; `renderTypstDocWithMermaid` (full document) and `renderTypstWithMermaid` (body only) accept the same optional `MermaidRendererFn` (`*const fn(Allocator, []const u8) anyerror![]const u8`) and convert `mermaid`/`mermaidjs` blocks to inline `#image(bytes("…"), format: "svg")` calls, rendered at natural size and capped at the line width - **`ASTRenderer`** — Human-readable tree diagram with box-drawing characters - **`AIRenderer`** — Token-efficient AST representation for LLM consumption - **`MarkdownRenderer`** — AST→Markdown normaliser; converts headings to ATX, links to inline, code blocks to fenced diff --git a/build.zig.zon b/build.zig.zon index 6e83497..0b7404b 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.7.0", + .version = "0.7.2", // 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/src/markdown/renderers/typst.zig b/src/markdown/renderers/typst.zig index dc109d3..1f40772 100644 --- a/src/markdown/renderers/typst.zig +++ b/src/markdown/renderers/typst.zig @@ -295,13 +295,23 @@ fn renderBlock(writer: *std.Io.Writer, block: AST.Block, ctx: *const Ctx) anyerr if (!is_mermaid) break :mermaid; const svg = mfn(ctx.allocator, fcb.content) catch break :mermaid; defer ctx.allocator.free(svg); - const encoded_len = std.base64.standard.Encoder.calcSize(svg.len); - const encoded = try ctx.allocator.alloc(u8, encoded_len); - defer ctx.allocator.free(encoded); - _ = std.base64.standard.Encoder.encode(encoded, svg); - try writer.writeAll("#image.decode(bytes.fromBase64(\""); - try writer.writeAll(encoded); - try writer.writeAll("\"), format: \"svg\", width: 100%)\n\n"); + // Embed the SVG source directly as a string literal: + // `bytes.fromBase64` does not exist in Typst and + // `image.decode` is deprecated; `image(bytes("…"))` is the + // supported construct (raw newlines are legal in Typst + // string literals). The layout/measure wrapper renders the + // diagram at its natural size but caps it at the line + // width, matching how LaTeX pipelines size images. + try writer.writeAll("#{\n let d = bytes(\""); + try writeStringLiteral(writer, svg); + try writer.writeAll( + "\")\n" ++ + " layout(size => {\n" ++ + " let img = image(d, format: \"svg\")\n" ++ + " if measure(img).width > size.width { image(d, format: \"svg\", width: 100%) } else { img }\n" ++ + " })\n" ++ + "}\n\n", + ); return; } } @@ -735,6 +745,26 @@ pub fn render(allocator: Allocator, doc: AST.Document) ![]u8 { return aw.toOwnedSlice(); } +/// Render `doc` to a writer as Typst markup (body only, no preamble), +/// converting mermaid fenced blocks to inline `#image` calls using the +/// provided renderer. Pass `null` to render mermaid blocks as plain code +/// blocks. +pub fn renderToWriterWithMermaid( + allocator: Allocator, + writer: *std.Io.Writer, + doc: AST.Document, + mermaid: ?*const fn (Allocator, []const u8) anyerror![]const u8, +) !void { + var ctx = Ctx.init(allocator); + ctx.mermaid = mermaid; + defer ctx.deinit(); + try collectFootnotes(doc, &ctx); + for (doc.children.items) |child| { + if (child == .footnote_definition) continue; + try renderBlock(writer, child, &ctx); + } +} + /// Render `doc` to a writer as a complete Typst document with an /// Eisvogel-inspired preamble derived from `opts`. pub fn renderDocumentToWriter(allocator: Allocator, writer: *std.Io.Writer, doc: AST.Document, opts: DocumentOptions) !void { @@ -749,7 +779,7 @@ pub fn renderDocumentToWriter(allocator: Allocator, writer: *std.Io.Writer, doc: } /// Render `doc` to a writer as a complete Typst document, converting mermaid -/// fenced blocks to `#image.decode` calls using the provided renderer. +/// fenced blocks to inline `#image` calls using the provided renderer. pub fn renderDocumentToWriterWithMermaid( allocator: Allocator, writer: *std.Io.Writer, @@ -885,22 +915,62 @@ fn stubSvgError(_: std.mem.Allocator, _: []const u8) anyerror![]const u8 { return error.RenderFailed; } -test "mermaid block renders as image.decode" { +/// The Typst emitted for a mermaid block whose renderer produced `svg_lit` +/// (already escaped for a Typst string literal). +fn expectedMermaidTypst(comptime svg_lit: []const u8) []const u8 { + return "#{\n let d = bytes(\"" ++ svg_lit ++ "\")\n" ++ + " layout(size => {\n" ++ + " let img = image(d, format: \"svg\")\n" ++ + " if measure(img).width > size.width { image(d, format: \"svg\", width: 100%) } else { img }\n" ++ + " })\n" ++ + "}\n\n"; +} + +test "mermaid block renders as inline svg image" { try okMermaid( "```mermaid\ngraph LR\nA-->B\n```", stubSvg, - "#image.decode(bytes.fromBase64(\"PHN2Zz5tb2NrPC9zdmc+\"), format: \"svg\", width: 100%)\n\n", + expectedMermaidTypst("mock"), ); } -test "mermaidjs block renders as image.decode" { +test "mermaidjs block renders as inline svg image" { try okMermaid( "```mermaidjs\ngraph LR\nA-->B\n```", stubSvg, - "#image.decode(bytes.fromBase64(\"PHN2Zz5tb2NrPC9zdmc+\"), format: \"svg\", width: 100%)\n\n", + expectedMermaidTypst("mock"), ); } +fn stubSvgSpecials(alloc: std.mem.Allocator, _: []const u8) anyerror![]const u8 { + // Quotes and backslashes must be escaped in the Typst string literal; + // raw newlines pass through unchanged (legal in Typst strings). + return alloc.dupe(u8, "\nline2\n"); +} + +test "mermaid svg quotes, backslashes, and newlines survive string-literal embedding" { + try okMermaid( + "```mermaid\ngraph LR\nA-->B\n```", + stubSvgSpecials, + expectedMermaidTypst("\nline2\n"), + ); +} + +test "renderToWriterWithMermaid public API" { + const allocator = tst.allocator; + var parser = Parser.init(); + defer parser.deinit(allocator); + var res = try parser.parseMarkdown(allocator, "# T\n\n```mermaid\ngraph LR\nA-->B\n```"); + defer res.deinit(allocator); + var aw: std.Io.Writer.Allocating = .init(allocator); + defer aw.deinit(); + try renderToWriterWithMermaid(allocator, &aw.writer, res, stubSvg); + const out = try aw.toOwnedSlice(); + defer allocator.free(out); + try tst.expect(std.mem.indexOf(u8, out, "let d = bytes(\"mock\")") != null); + try tst.expect(std.mem.indexOf(u8, out, "```mermaid") == null); +} + test "mermaid renderer error falls back to code block" { try okMermaid( "```mermaid\ngraph LR\nA-->B\n```", diff --git a/src/root.zig b/src/root.zig index 59f3a0f..8cfaf76 100644 --- a/src/root.zig +++ b/src/root.zig @@ -68,9 +68,10 @@ pub const TypstRenderer = Renderer.create(typst_mod); pub const typst = typst_mod; /// Function pointer type for an optional mermaid diagram renderer. -/// Pass a function matching this signature to `renderHtmlWithMermaid` or -/// `renderTypstDocWithMermaid` to have mermaid fenced code blocks converted -/// to inline diagrams instead of emitting raw code blocks. +/// Pass a function matching this signature to `renderHtmlWithMermaid`, +/// `renderTypstWithMermaid`, or `renderTypstDocWithMermaid` to have mermaid +/// fenced code blocks converted to inline diagrams instead of emitting raw +/// code blocks. pub const MermaidRendererFn = *const fn (std.mem.Allocator, []const u8) anyerror![]const u8; /// Render `doc` to HTML, converting mermaid fenced blocks to inline SVG @@ -84,8 +85,20 @@ pub fn renderHtmlWithMermaid( return html.renderToWriterWithMermaid(allocator, writer, doc, mermaid); } +/// Render `doc` to Typst markup (body only, no preamble), converting mermaid +/// fenced blocks to inline `#image` calls using `mermaid` (pass `null` to +/// skip conversion). Use this when you supply your own Typst preamble. +pub fn renderTypstWithMermaid( + allocator: std.mem.Allocator, + writer: *std.Io.Writer, + doc: AST.Document, + mermaid: ?MermaidRendererFn, +) !void { + return typst_mod.renderToWriterWithMermaid(allocator, writer, doc, mermaid); +} + /// Render `doc` to a full Typst document, converting mermaid fenced blocks -/// to `#image.decode` calls using `mermaid` (pass `null` to skip conversion). +/// to inline `#image` calls using `mermaid` (pass `null` to skip conversion). pub fn renderTypstDocWithMermaid( allocator: std.mem.Allocator, writer: *std.Io.Writer, From e68c0ee716038c66897ec66a57180abaab8a7d56 Mon Sep 17 00:00:00 2001 From: TsunamiNoAi Date: Fri, 3 Jul 2026 13:26:30 -0400 Subject: [PATCH 2/5] chore: add release bump app and CHANGELOG tag guard - nix run .#bump -- bumps build.zig.zon and rolls the CHANGELOG [Unreleased] section into a dated release section (ported from PolicyPress). - .githooks/pre-push rejects release-tag pushes without a CHANGELOG change since the previous tag; installed by the devshell shellHook. - The release workflow enforces the same rule as an un-bypassable backstop before building artifacts. Signed-off-by: TsunamiNoAi --- .githooks/pre-push | 32 ++++++++++ .github/workflows/release.yml | 22 +++++++ flake.nix | 117 ++++++++++++++++++++++++++++++++++ 3 files changed, 171 insertions(+) create mode 100755 .githooks/pre-push diff --git a/.githooks/pre-push b/.githooks/pre-push new file mode 100755 index 0000000..8b35e23 --- /dev/null +++ b/.githooks/pre-push @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +# Reject release-tag pushes that don't include a CHANGELOG.md update since the +# previous release tag. Installed into .git/hooks/pre-push by the Nix devshell +# (see flake.nix shellHook). The pre-commit framework only manages the +# pre-commit hook, so pre-push is ours. +# +# Bypass (discouraged): git push --no-verify +set -euo pipefail + +tag_re='^refs/tags/v?[0-9]+\.[0-9]+\.[0-9]+$' +status=0 + +# git feeds one line per ref being pushed: +while read -r local_ref local_sha _remote_ref _remote_sha; do + [[ "$local_ref" =~ $tag_re ]] || continue + # Tag deletion (all-zero local sha) — nothing to verify. + [[ "$local_sha" =~ ^0+$ ]] && continue + + tag="${local_ref#refs/tags/}" + # Nearest release tag before this commit. None => first release, allow it. + prev="$(git describe --tags --abbrev=0 "${local_sha}^" 2>/dev/null || true)" + [ -n "$prev" ] || continue + + if git diff --quiet "$prev" "$local_sha" -- CHANGELOG.md; then + echo "✗ $tag: CHANGELOG.md has no changes since $prev." >&2 + echo " Update the changelog before tagging — e.g. 'nix run .#bump -- '." >&2 + echo " (Override with 'git push --no-verify' if you really mean to.)" >&2 + status=1 + fi +done + +exit "$status" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index cdc917e..4c85473 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -29,6 +29,28 @@ jobs: steps: - uses: actions/checkout@v6 + with: + # Full history + tags so the release-tag CHANGELOG guard can diff + # against the previous tag. + fetch-depth: 0 + + # Authoritative backstop for the local pre-push hook (.githooks/pre-push): + # a release tag must include a CHANGELOG.md change since the previous tag. + # Cannot be bypassed with `git push --no-verify`. + - name: Require CHANGELOG update for release tag + if: ${{ github.ref_type == 'tag' }} + run: | + tag="${{ github.ref_name }}" + prev="$(git describe --tags --abbrev=0 "${tag}^" 2>/dev/null || true)" + if [ -z "$prev" ]; then + echo "No previous tag; skipping CHANGELOG check." + exit 0 + fi + if git diff --quiet "$prev" "$tag" -- CHANGELOG.md; then + echo "::error::CHANGELOG.md has no changes between $prev and $tag. Update the changelog (e.g. 'nix run .#bump') before releasing." + exit 1 + fi + echo "CHANGELOG.md updated since $prev ✓" - name: Install Nix uses: DeterminateSystems/determinate-nix-action@v3 diff --git a/flake.nix b/flake.nix index ea08de9..dc99a5f 100644 --- a/flake.nix +++ b/flake.nix @@ -157,6 +157,17 @@ shellHook = '' export ZIG_GLOBAL_CACHE_DIR=.zig-cache + + # Install the release-tag CHANGELOG guard. Kept in .githooks/ so + # it is version-controlled; the release workflow enforces the same + # rule as an un-bypassable backstop. + hooks_dir="$(git rev-parse --git-path hooks 2>/dev/null || true)" + if [ -n "$hooks_dir" ] && [ -f .githooks/pre-push ]; then + mkdir -p "$hooks_dir" + cp -f .githooks/pre-push "$hooks_dir/pre-push" + chmod +x "$hooks_dir/pre-push" + fi + echo "To update Zig dependencies, run: update-zon" echo "To run the fuzzer, run: fuzz [port] (default port: 8080)" # Auto-generate/update zon2json-lock when entering the dev shell. @@ -414,6 +425,112 @@ program = "${bench-app}/bin/zigmark-bench"; meta.description = "Benchmark zigmark against cmark, cmark-gfm, pandoc, discount, and lowdown; updates README.md with results"; }; + + # `nix run .#bump -- ` performs the manual + # pre-release steps in one shot: bumps the version in build.zig.zon, + # rolls the CHANGELOG `[Unreleased]` section into a dated release + # section, and commits. Pushing the tag is left to you; the release + # workflow then builds artifacts and publishes. + bump = let + bump-app = pkgs.writeShellApplication { + name = "zigmark-bump"; + runtimeInputs = with pkgs; [coreutils gnused gawk git]; + meta.description = "Bump version in build.zig.zon and roll the CHANGELOG"; + text = '' + dry=0 + commit=1 + arg="" + for a in "$@"; do + case "$a" in + --dry-run) dry=1 ;; + --no-commit) commit=0 ;; + -h | --help) + echo "usage: nix run .#bump -- [--dry-run] [--no-commit]" + exit 0 + ;; + *) arg="$a" ;; + esac + done + + if [ -z "$arg" ]; then + echo "usage: nix run .#bump -- [--dry-run] [--no-commit]" >&2 + exit 1 + fi + + cur=$(sed -nE 's/^[[:space:]]*\.version = "([^"]+)",/\1/p' build.zig.zon | head -n1) + if [ -z "$cur" ]; then + echo "error: could not read current version from build.zig.zon" >&2 + exit 1 + fi + + case "$arg" in + major | minor | patch) + IFS=. read -r ma mi pa <<< "$cur" + case "$arg" in + major) + ma=$((ma + 1)) + mi=0 + pa=0 + ;; + minor) + mi=$((mi + 1)) + pa=0 + ;; + patch) pa=$((pa + 1)) ;; + esac + new="$ma.$mi.$pa" + ;; + *) new="$arg" ;; + esac + + if ! [[ "$new" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "error: invalid version '$new' (expected X.Y.Z or patch|minor|major)" >&2 + exit 1 + fi + + today=$(date +%F) + echo "Release: $cur -> $new ($today)" + + if [ "$dry" = 1 ]; then + echo "[dry-run] would update:" + echo " build.zig.zon .version = \"$new\"" + echo " CHANGELOG.md roll [Unreleased] into [$new] - $today" + exit 0 + fi + + sed -i -E "s/^([[:space:]]*\.version = )\"[^\"]+\",/\1\"$new\",/" build.zig.zon + awk -v ver="$new" -v dt="$today" ' + !done && /^## \[Unreleased\]/ { + print "## [Unreleased]"; + print ""; + print "## [" ver "] - " dt; + done = 1; + next + } + { print } + ' CHANGELOG.md > CHANGELOG.md.tmp && mv CHANGELOG.md.tmp CHANGELOG.md + + echo "Updated build.zig.zon, CHANGELOG.md" + + if [ "$commit" = 1 ]; then + git add build.zig.zon CHANGELOG.md + git commit -m "chore: release $new" + echo "" + echo "Committed 'chore: release $new'. Publish with:" + echo " git tag v$new && git push origin HEAD v$new" + else + echo "" + echo "Files edited (not committed). Then:" + echo " git add build.zig.zon CHANGELOG.md && git commit -m 'chore: release $new'" + echo " git tag v$new && git push origin HEAD v$new" + fi + ''; + }; + in { + type = "app"; + program = "${bump-app}/bin/zigmark-bump"; + meta.description = "Bump version in build.zig.zon and roll the CHANGELOG for a release"; + }; } ); }; From ced0e10053f67021f98974c38ad291ea248b9267 Mon Sep 17 00:00:00 2001 From: TsunamiNoAi Date: Fri, 3 Jul 2026 15:12:17 -0400 Subject: [PATCH 3/5] =?UTF-8?q?docs(typst):=20match=20mermaid=20docs=20to?= =?UTF-8?q?=20emitted=20#{=E2=80=A6}=20embed;=20note=20callback=20ownershi?= =?UTF-8?q?p?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot review on #70 flagged that the public docs describe the Typst mermaid output as "inline #image calls" while the renderer actually emits a #{ … } block that binds the SVG via bytes("…") and calls image(…, format: "svg") inside a layout/measure wrapper. Align README + all four Typst mermaid docstrings with the real output. Also document MermaidRendererFn's ownership contract: the returned slice must be allocated with the allocator passed to the callback, since both the HTML and Typst renderers free it with that allocator. Returning static memory would trigger an invalid free. Docs-only; no behavior change. zig build test passes. --- README.md | 2 +- src/markdown/renderers/typst.zig | 13 ++++++++----- src/root.zig | 19 ++++++++++++++----- 3 files changed, 23 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index b0a49e1..8cb5b65 100644 --- a/README.md +++ b/README.md @@ -755,7 +755,7 @@ Requires **Zig 0.16.0** or later. - **`Parser`** — Block-level + inline two-pass parser built on the [mecha](https://github.com/Hejsil/mecha) parser combinator library; accepts a `[]const u8` via `parseMarkdown` or any `*std.Io.Reader` via `parseFromReader` - **`AST`** — Typed union-based Abstract Syntax Tree (`Document` → `Block` → `Inline`) - **`HTMLRenderer`** — CommonMark-compliant HTML serialiser; `renderHtmlWithMermaid` accepts an optional `MermaidRendererFn` (`*const fn(Allocator, []const u8) anyerror![]const u8`) to convert `mermaid`/`mermaidjs` fenced blocks to inline SVG `
` elements (falls back to a plain code block on error or when `null`) -- **`TypstRenderer`** — Typst markup renderer; `typst.renderDocument` adds an eisvogel-inspired preamble (title page, TOC, headers/footers, styled code blocks and blockquotes) driven by `DocumentOptions`; `renderTypstDocWithMermaid` (full document) and `renderTypstWithMermaid` (body only) accept the same optional `MermaidRendererFn` (`*const fn(Allocator, []const u8) anyerror![]const u8`) and convert `mermaid`/`mermaidjs` blocks to inline `#image(bytes("…"), format: "svg")` calls, rendered at natural size and capped at the line width +- **`TypstRenderer`** — Typst markup renderer; `typst.renderDocument` adds an eisvogel-inspired preamble (title page, TOC, headers/footers, styled code blocks and blockquotes) driven by `DocumentOptions`; `renderTypstDocWithMermaid` (full document) and `renderTypstWithMermaid` (body only) accept the same optional `MermaidRendererFn` (`*const fn(Allocator, []const u8) anyerror![]const u8`) and convert each `mermaid`/`mermaidjs` block to an inline Typst `#{ … }` block that embeds the SVG via `image(bytes("…"), format: "svg")`, rendered at natural size and capped at the line width via a `layout`/`measure` wrapper - **`ASTRenderer`** — Human-readable tree diagram with box-drawing characters - **`AIRenderer`** — Token-efficient AST representation for LLM consumption - **`MarkdownRenderer`** — AST→Markdown normaliser; converts headings to ATX, links to inline, code blocks to fenced diff --git a/src/markdown/renderers/typst.zig b/src/markdown/renderers/typst.zig index 1f40772..d0e9291 100644 --- a/src/markdown/renderers/typst.zig +++ b/src/markdown/renderers/typst.zig @@ -746,9 +746,10 @@ pub fn render(allocator: Allocator, doc: AST.Document) ![]u8 { } /// Render `doc` to a writer as Typst markup (body only, no preamble), -/// converting mermaid fenced blocks to inline `#image` calls using the -/// provided renderer. Pass `null` to render mermaid blocks as plain code -/// blocks. +/// converting each mermaid fenced block to an inline Typst `#{ … }` block that +/// embeds the SVG via `image(bytes("…"), format: "svg")` — sized to its +/// natural width but capped at the line width — using the provided renderer. +/// Pass `null` to render mermaid blocks as plain code blocks. pub fn renderToWriterWithMermaid( allocator: Allocator, writer: *std.Io.Writer, @@ -778,8 +779,10 @@ pub fn renderDocumentToWriter(allocator: Allocator, writer: *std.Io.Writer, doc: } } -/// Render `doc` to a writer as a complete Typst document, converting mermaid -/// fenced blocks to inline `#image` calls using the provided renderer. +/// Render `doc` to a writer as a complete Typst document, converting each +/// mermaid fenced block to an inline Typst `#{ … }` block that embeds the SVG +/// via `image(bytes("…"), format: "svg")` — sized to its natural width but +/// capped at the line width — using the provided renderer. pub fn renderDocumentToWriterWithMermaid( allocator: Allocator, writer: *std.Io.Writer, diff --git a/src/root.zig b/src/root.zig index 8cfaf76..84ab4c1 100644 --- a/src/root.zig +++ b/src/root.zig @@ -72,6 +72,11 @@ pub const typst = typst_mod; /// `renderTypstWithMermaid`, or `renderTypstDocWithMermaid` to have mermaid /// fenced code blocks converted to inline diagrams instead of emitting raw /// code blocks. +/// +/// The returned slice must be allocated with the allocator passed into the +/// callback; zigmark frees it with that same allocator after emitting the +/// diagram. Returning static or externally-owned memory will trigger an +/// invalid free. pub const MermaidRendererFn = *const fn (std.mem.Allocator, []const u8) anyerror![]const u8; /// Render `doc` to HTML, converting mermaid fenced blocks to inline SVG @@ -85,9 +90,11 @@ pub fn renderHtmlWithMermaid( return html.renderToWriterWithMermaid(allocator, writer, doc, mermaid); } -/// Render `doc` to Typst markup (body only, no preamble), converting mermaid -/// fenced blocks to inline `#image` calls using `mermaid` (pass `null` to -/// skip conversion). Use this when you supply your own Typst preamble. +/// Render `doc` to Typst markup (body only, no preamble), converting each +/// mermaid fenced block to an inline Typst `#{ … }` block that embeds the SVG +/// via `image(bytes("…"), format: "svg")` — sized to its natural width but +/// capped at the line width — using `mermaid` (pass `null` to skip +/// conversion). Use this when you supply your own Typst preamble. pub fn renderTypstWithMermaid( allocator: std.mem.Allocator, writer: *std.Io.Writer, @@ -97,8 +104,10 @@ pub fn renderTypstWithMermaid( return typst_mod.renderToWriterWithMermaid(allocator, writer, doc, mermaid); } -/// Render `doc` to a full Typst document, converting mermaid fenced blocks -/// to inline `#image` calls using `mermaid` (pass `null` to skip conversion). +/// Render `doc` to a full Typst document, converting each mermaid fenced block +/// to an inline Typst `#{ … }` block that embeds the SVG via +/// `image(bytes("…"), format: "svg")` — sized to its natural width but capped +/// at the line width — using `mermaid` (pass `null` to skip conversion). pub fn renderTypstDocWithMermaid( allocator: std.mem.Allocator, writer: *std.Io.Writer, From 6d6025188bed5adc065481b871621c5c13ea4ff2 Mon Sep 17 00:00:00 2001 From: TsunamiNoAi Date: Fri, 3 Jul 2026 15:27:48 -0400 Subject: [PATCH 4/5] fix(release): guard bump against missing [Unreleased]; doc mermaid callback ownership Second round of Copilot review on #70: - CHANGELOG.md: add the `## [Unreleased]` header the `nix run .#bump` helper rolls into a dated release. Without it, bump would update build.zig.zon but leave the changelog untouched, then trip the pre-push/CI release guard. - flake.nix bump app: fail fast (with a clear message, before touching any files) when `## [Unreleased]` is missing, instead of silently succeeding and committing only build.zig.zon. Rolled entries now use the em-dash separator to match existing changelog headers. - html/typst renderers: document that the mermaid callback must return memory allocated with the passed allocator (it is freed after emit) on the raw fn-pointer signatures, matching the note already on `MermaidRendererFn`. Docs + release-tooling only; no library behavior change. zig build test passes. --- CHANGELOG.md | 2 ++ flake.nix | 17 ++++++++++++++--- src/markdown/renderers/html.zig | 5 ++++- src/markdown/renderers/typst.zig | 10 ++++++++-- 4 files changed, 28 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3881661..7af8e10 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ Versions track the library API, not Zig itself. The major version will remain 0.x until Zig reaches 1.0, at which point zigmark will follow the same stability guarantee. +## [Unreleased] + ## [0.7.2] — 2026-07-03 ### Fixed diff --git a/flake.nix b/flake.nix index dc99a5f..5c5d956 100644 --- a/flake.nix +++ b/flake.nix @@ -498,17 +498,28 @@ exit 0 fi + if ! grep -q '^## \[Unreleased\]' CHANGELOG.md; then + echo "error: no '## [Unreleased]' section in CHANGELOG.md; add one (with notes) before bumping" >&2 + exit 1 + fi + sed -i -E "s/^([[:space:]]*\.version = )\"[^\"]+\",/\1\"$new\",/" build.zig.zon - awk -v ver="$new" -v dt="$today" ' + if ! awk -v ver="$new" -v dt="$today" ' !done && /^## \[Unreleased\]/ { print "## [Unreleased]"; print ""; - print "## [" ver "] - " dt; + print "## [" ver "] \xe2\x80\x94 " dt; done = 1; next } { print } - ' CHANGELOG.md > CHANGELOG.md.tmp && mv CHANGELOG.md.tmp CHANGELOG.md + END { if (!done) exit 3 } + ' CHANGELOG.md > CHANGELOG.md.tmp; then + rm -f CHANGELOG.md.tmp + echo "error: failed to roll '## [Unreleased]' in CHANGELOG.md" >&2 + exit 1 + fi + mv CHANGELOG.md.tmp CHANGELOG.md echo "Updated build.zig.zon, CHANGELOG.md" diff --git a/src/markdown/renderers/html.zig b/src/markdown/renderers/html.zig index b35d1e8..6f0d28e 100644 --- a/src/markdown/renderers/html.zig +++ b/src/markdown/renderers/html.zig @@ -743,7 +743,10 @@ pub fn renderToWriter(allocator: Allocator, writer: *std.Io.Writer, doc: AST.Doc } /// Render `doc` to a writer, converting mermaid fenced blocks to inline SVG -/// using the provided renderer function. +/// using the provided renderer function. The renderer must return memory +/// allocated with `allocator`; it is freed with that allocator after the +/// diagram is emitted, so returning static or externally-owned memory will +/// trigger an invalid free. pub fn renderToWriterWithMermaid( allocator: Allocator, writer: *std.Io.Writer, diff --git a/src/markdown/renderers/typst.zig b/src/markdown/renderers/typst.zig index d0e9291..2185698 100644 --- a/src/markdown/renderers/typst.zig +++ b/src/markdown/renderers/typst.zig @@ -749,7 +749,10 @@ pub fn render(allocator: Allocator, doc: AST.Document) ![]u8 { /// converting each mermaid fenced block to an inline Typst `#{ … }` block that /// embeds the SVG via `image(bytes("…"), format: "svg")` — sized to its /// natural width but capped at the line width — using the provided renderer. -/// Pass `null` to render mermaid blocks as plain code blocks. +/// Pass `null` to render mermaid blocks as plain code blocks. The renderer +/// must return memory allocated with `allocator`; it is freed with that +/// allocator after the diagram is emitted, so returning static or +/// externally-owned memory will trigger an invalid free. pub fn renderToWriterWithMermaid( allocator: Allocator, writer: *std.Io.Writer, @@ -782,7 +785,10 @@ pub fn renderDocumentToWriter(allocator: Allocator, writer: *std.Io.Writer, doc: /// Render `doc` to a writer as a complete Typst document, converting each /// mermaid fenced block to an inline Typst `#{ … }` block that embeds the SVG /// via `image(bytes("…"), format: "svg")` — sized to its natural width but -/// capped at the line width — using the provided renderer. +/// capped at the line width — using the provided renderer. The renderer must +/// return memory allocated with `allocator`; it is freed with that allocator +/// after the diagram is emitted, so returning static or externally-owned +/// memory will trigger an invalid free. pub fn renderDocumentToWriterWithMermaid( allocator: Allocator, writer: *std.Io.Writer, From 8657cddaf35b613407aab1141d9eedaac80e7bb7 Mon Sep 17 00:00:00 2001 From: TsunamiNoAi Date: Fri, 3 Jul 2026 15:36:14 -0400 Subject: [PATCH 5/5] fix(hooks): resolve release-tag guard through the tag ref, not the pushed sha Copilot review on #70: for annotated tags the pushed local_sha is the tag object rather than a commit. Resolve `prev` and the CHANGELOG diff through the tag ref (`$tag`) instead so the guard peels to the tagged commit for both lightweight and annotated tags. Verified against both tag types; shellcheck clean. --- .githooks/pre-push | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/.githooks/pre-push b/.githooks/pre-push index 8b35e23..f67af02 100755 --- a/.githooks/pre-push +++ b/.githooks/pre-push @@ -17,11 +17,14 @@ while read -r local_ref local_sha _remote_ref _remote_sha; do [[ "$local_sha" =~ ^0+$ ]] && continue tag="${local_ref#refs/tags/}" + # Use the tag ref, not $local_sha: for annotated tags the pushed sha is the + # tag object, so resolve through $tag so both lightweight and annotated tags + # peel to the tagged commit. # Nearest release tag before this commit. None => first release, allow it. - prev="$(git describe --tags --abbrev=0 "${local_sha}^" 2>/dev/null || true)" + prev="$(git describe --tags --abbrev=0 "$tag^" 2>/dev/null || true)" [ -n "$prev" ] || continue - if git diff --quiet "$prev" "$local_sha" -- CHANGELOG.md; then + if git diff --quiet "$prev" "$tag" -- CHANGELOG.md; then echo "✗ $tag: CHANGELOG.md has no changes since $prev." >&2 echo " Update the changelog before tagging — e.g. 'nix run .#bump -- '." >&2 echo " (Override with 'git push --no-verify' if you really mean to.)" >&2