diff --git a/.githooks/pre-push b/.githooks/pre-push new file mode 100755 index 0000000..f67af02 --- /dev/null +++ b/.githooks/pre-push @@ -0,0 +1,35 @@ +#!/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/}" + # 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 "$tag^" 2>/dev/null || true)" + [ -n "$prev" ] || continue + + 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 + 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/CHANGELOG.md b/CHANGELOG.md index fd41f37..7af8e10 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,30 @@ 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 + +- **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..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` 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 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/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/flake.nix b/flake.nix index ea08de9..5c5d956 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,123 @@ 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 + + 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 + if ! awk -v ver="$new" -v dt="$today" ' + !done && /^## \[Unreleased\]/ { + print "## [Unreleased]"; + print ""; + print "## [" ver "] \xe2\x80\x94 " dt; + done = 1; + next + } + { print } + 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" + + 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"; + }; } ); }; 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 dc109d3..2185698 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,30 @@ 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 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. 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, + 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 { @@ -748,8 +782,13 @@ 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. +/// 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. 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, @@ -885,22 +924,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..84ab4c1 100644 --- a/src/root.zig +++ b/src/root.zig @@ -68,9 +68,15 @@ 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. +/// +/// 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 @@ -84,8 +90,24 @@ pub fn renderHtmlWithMermaid( return html.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). +/// 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, + doc: AST.Document, + mermaid: ?MermaidRendererFn, +) !void { + return typst_mod.renderToWriterWithMermaid(allocator, writer, doc, mermaid); +} + +/// 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,