diff --git a/CHANGELOG.md b/CHANGELOG.md
index cde7e0e..4827c40 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,53 @@ same stability guarantee.
## [Unreleased]
+## [0.11.0] — 2026-07-22
+
+### Added
+
+- **Programmatic footnote synthesis** (`zigmark.footnotes`). A new module lets a
+ caller supply footnote definitions on demand through a `Resolver` callback:
+ `resolve(alloc, &doc, resolver, .{})` finds every `[^label]` reference that
+ has no matching definition (walking paragraphs, headings, blockquotes, list
+ items, table cells, and footnote-definition bodies, plus emphasis/strong/
+ strikethrough/link inlines), parses the resolver's Markdown, and appends real
+ `footnote_definition` blocks in first-reference order. Because synthesis
+ happens at the AST level, every renderer benefits with **zero renderer
+ changes** — in particular the Typst back-end expands the now-defined
+ references to native `#footnote[…]`. `resolve` is single-pass;
+ `dangling(alloc, &doc)` returns the deduplicated labels that are still
+ undefined (in first-reference order) so consumers can hard-fail a build. The
+ resolver-returned slice is owned and freed by zigmark (the `MermaidRendererFn`
+ ownership contract). See #82.
+- **`Library.footnoteResolver()`** — a `footnotes.Resolver` that sources
+ definition bodies from the footnote definitions found across a library's
+ documents (first match wins). The intended pattern is to build a glossary
+ document of `[^ID]: …` lines from external data, `add()` it to the library,
+ and pass `lib.footnoteResolver()` to `footnotes.resolve`. Nothing
+ domain-specific lands in zigmark.
+- `renderers/markdown.zig`'s `renderBlock` is now `pub`, so callers can
+ serialise a node's child blocks back to Markdown without wrapping a whole
+ document (used by `footnoteResolver`).
+
+### Changed
+
+- **Footnote-definition labels now accept a much wider charset.** A label may
+ contain any byte except ASCII whitespace (space, tab, CR, LF) and the square
+ brackets `[` / `]`, rather than only `[a-zA-Z0-9]`. This is the intersection
+ of pulldown-cmark (Zola) and cmark-gfm (GitHub), so control-ID-shaped labels
+ such as `IAC-21.5` or `SCF:GOV-01` now parse to the same definition in
+ zigmark, on GitHub, and in Zola. Both parser call sites (footnote definition
+ parsing and paragraph interruption) route through the one combinator, and the
+ reference parser is unchanged (it already accepts a permissive superset).
+ - **Behaviour change:** a whitespace-free line shaped like `[^word]: …` — with
+ a label the old charset rejected (e.g. `[^SCF:GOV-01]: text`) — now parses
+ as a footnote **definition** where previous releases treated it as an
+ ordinary paragraph containing a footnote reference. Lines whose label
+ contains a space (e.g. `[^see note]: x`) still stay paragraphs. The 0.8.0
+ HTML-escaping guarantee for footnote labels extends to (and is tested on)
+ the definition and synthesis paths. CommonMark/GFM spec conformance is
+ unchanged (652/652 + 24/24).
+
## [0.10.0] — 2026-07-18
### Changed
diff --git a/README.md b/README.md
index 10e855e..eef6f27 100644
--- a/README.md
+++ b/README.md
@@ -720,13 +720,71 @@ Run the GFM suite with `zig build gfm`.
### Extensions
- **Frontmatter** — YAML (`---`), TOML (`+++`), JSON (`{`), and ZON (`.{`) extraction, all normalised to `std.json.Value`
-- **Footnotes** — `[^label]` references and definitions
+- **Footnotes** — `[^label]` references and definitions, plus programmatic synthesis (see [Footnotes](#footnotes))
- **GFM Tables** — pipe-delimited tables with optional column alignment
- **GFM Task lists** — `- [x]` / `- [ ]` items rendered as disabled checkboxes
- **GFM Strikethrough** — `~~text~~` rendered as `text`
- **GFM Extended autolinks** — bare `www.`, `http(s)://`, `ftp://`, and email autolinks
- **GFM Disallowed raw HTML** — dangerous tags escaped at render time
+### Footnotes
+
+`[^label]` marks a reference; `[^label]: …` on its own line defines it. A label
+may contain any byte **except** ASCII whitespace and the brackets `[` / `]` —
+the intersection of pulldown-cmark (used by Zola) and cmark-gfm (GitHub) — so
+control-ID-shaped labels such as `IAC-21.5` or `SCF:GOV-01` parse to the same
+definition in zigmark, on GitHub, and in Zola:
+
+```markdown
+Access is authenticated per policy.[^IAC-01]
+
+[^IAC-01]: Identification & Authentication — see the access-control policy.
+```
+
+> **Behaviour note:** because the label charset now permits `:`, `.`, `-`, and
+> other punctuation (rather than only `[a-zA-Z0-9]`), a whitespace-free line
+> shaped like `[^word]: …` now parses as a footnote *definition* where a
+> previous release treated it as a paragraph.
+
+**Programmatic synthesis.** References whose definition text lives in external
+data (a control catalog, a glossary, a database) can be filled in at the AST
+level via the `zigmark.footnotes` module. Supply a `Resolver` callback that
+returns Markdown for a given label; `resolve` parses it and appends real
+`footnote_definition` blocks, so every renderer works unchanged — including the
+Typst back-end, which expands the now-defined references to native
+`#footnote[…]`:
+
+```zig
+const zigmark = @import("zigmark");
+
+var doc = try parser.parseMarkdown(alloc, source);
+defer doc.deinit(alloc);
+
+const resolver = zigmark.footnotes.Resolver{
+ .resolveFn = struct {
+ fn f(_: ?*anyopaque, a: std.mem.Allocator, label: []const u8) anyerror!?[]const u8 {
+ if (std.mem.eql(u8, label, "IAC-01"))
+ return try a.dupe(u8, "Identification & Authentication control.");
+ return null; // unknown label → left dangling
+ }
+ }.f,
+};
+
+const report = try zigmark.footnotes.resolve(alloc, &doc, resolver, .{});
+// report.synthesized — definitions added; report.unresolved — nulls returned
+
+// Labels still without a definition (e.g. to hard-fail a build):
+const missing = try zigmark.footnotes.dangling(alloc, &doc);
+defer {
+ for (missing) |m| alloc.free(m);
+ alloc.free(missing);
+}
+```
+
+`Library.footnoteResolver()` builds such a resolver from footnote definitions
+found across a library's documents (first match wins) — for example a generated
+glossary document of `[^ID]: …` lines that you `add()` to the library.
+
## Building \& Testing
```bash
diff --git a/build.zig.zon b/build.zig.zon
index 93e45cf..998e088 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.10.0",
+ .version = "0.11.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/src/markdown/ast.zig b/src/markdown/ast.zig
index 86d5893..2be285a 100644
--- a/src/markdown/ast.zig
+++ b/src/markdown/ast.zig
@@ -968,4 +968,5 @@ test {
_ = @import("query_test.zig");
_ = @import("library_test.zig");
_ = @import("mutation_test.zig");
+ _ = @import("footnotes_test.zig");
}
diff --git a/src/markdown/combinators.zig b/src/markdown/combinators.zig
index 4428a61..5f18449 100644
--- a/src/markdown/combinators.zig
+++ b/src/markdown/combinators.zig
@@ -46,6 +46,33 @@ pub const letter = mecha.oneOf(.{ mecha.ascii.range('a', 'z'), mecha.ascii.range
pub const alphanumeric = mecha.oneOf(.{ letter, digit });
pub const whitespace = mecha.oneOf(.{ space, tab }).many(.{ .collect = false, .min = 1 });
+/// A single byte permitted inside a footnote-definition label.
+///
+/// Accepts any byte **except** ASCII whitespace (space, tab, CR, LF) and the
+/// square brackets `[` / `]`. This charset is deliberately the *intersection*
+/// of the two footnote dialects zigmark must interoperate with:
+///
+/// * pulldown-cmark (the parser Zola uses) treats a footnote label like a
+/// link label — effectively any run of non-bracket characters; while
+/// * cmark-gfm (GitHub) additionally forbids internal whitespace.
+///
+/// Taking the intersection means control-ID-shaped labels such as `IAC-21.5`
+/// or `SCF:GOV-01` parse to the *same* definition in zigmark, on GitHub, and
+/// in Zola. The bracket exclusion keeps the label unambiguous (the closing
+/// `]` terminates it). The whitespace exclusion is what keeps ordinary prose
+/// such as `[^see note]: x` a paragraph rather than a footnote definition —
+/// this matters because `tryFootnoteDef` also drives paragraph interruption
+/// (see `isParaBreak` in `parser.zig`), so a looser charset would silently
+/// reclassify authored prose.
+///
+/// The footnote *reference* scanner (`inline.zig`) is a permissive superset of
+/// this charset; tightening it to match is noted as future work.
+pub const footnote_label_char = mecha.ascii.not(mecha.oneOf(.{
+ space, tab,
+ mecha.ascii.char('\n'), mecha.ascii.char('\r'),
+ lbracket, rbracket,
+}));
+
pub const url_char = mecha.oneOf(.{
alphanumeric, mecha.ascii.char('.'), mecha.ascii.char('/'),
mecha.ascii.char(':'), mecha.ascii.char('?'), mecha.ascii.char('='),
@@ -126,9 +153,9 @@ pub const blockquote_line = mecha.combine(.{
}.f);
pub const footnote_definition = mecha.combine(.{
- lbracket, caret,
- mecha.many(mecha.oneOf(.{ letter, digit }), .{ .collect = false, .min = 1 }).asStr(), rbracket,
- colon, space,
+ lbracket, caret,
+ footnote_label_char.many(.{ .collect = false, .min = 1 }).asStr(), rbracket,
+ colon, space,
mecha.rest.asStr(),
}).map(struct {
fn f(r: anytype) FootnoteDefResult {
diff --git a/src/markdown/footnotes.zig b/src/markdown/footnotes.zig
new file mode 100644
index 0000000..2098218
--- /dev/null
+++ b/src/markdown/footnotes.zig
@@ -0,0 +1,231 @@
+//! Programmatic footnote synthesis.
+//!
+//! A document may *reference* footnotes (`[^label]`) that have no matching
+//! `[^label]: …` definition — for example when the definition text lives in an
+//! external data source (a control catalog, a glossary, a database). This
+//! module lets a caller supply those definitions on demand through a
+//! `Resolver` callback and have them synthesised into the AST as real
+//! `footnote_definition` blocks.
+//!
+//! Because synthesis happens at the **AST level** (not in a renderer), every
+//! back-end benefits with zero renderer changes:
+//!
+//! * the Typst renderer's existing footnote pre-pass turns the now-defined
+//! references into native `#footnote[…]` (PDF/UA-1-friendly);
+//! * the HTML renderer links references to the appended definition divs; and
+//! * the Markdown renderer round-trips the synthesised `[^label]: …` lines.
+//!
+//! ## Usage
+//!
+//! ```zig
+//! const report = try footnotes.resolve(allocator, &doc, resolver, .{});
+//! // report.synthesized — definitions added; report.unresolved — refs the
+//! // resolver returned null for. Use footnotes.dangling() to list the latter.
+//! ```
+//!
+//! `resolve` is **single-pass**: footnote references that appear *inside*
+//! resolver-returned content are not themselves resolved. Call `dangling`
+//! afterwards to discover any such (or otherwise unknown) labels — consumers
+//! typically hard-fail a build on a non-empty result.
+const std = @import("std");
+const Allocator = std.mem.Allocator;
+
+const AST = @import("ast.zig");
+const Parser = @import("parser.zig");
+
+/// Supplies Markdown source for footnote definitions on demand.
+pub const Resolver = struct {
+ /// Opaque context threaded to `resolveFn` (e.g. a `*const Library`).
+ ctx: ?*anyopaque = null,
+ /// Return Markdown source for `label`'s definition body, or `null` when the
+ /// label is unknown. A non-null result must be allocated with the passed
+ /// `allocator`; zigmark frees it (the `MermaidRendererFn` ownership
+ /// contract). The returned Markdown may contain multiple blocks.
+ resolveFn: *const fn (ctx: ?*anyopaque, allocator: Allocator, label: []const u8) anyerror!?[]const u8,
+
+ fn call(self: Resolver, allocator: Allocator, label: []const u8) anyerror!?[]const u8 {
+ return self.resolveFn(self.ctx, allocator, label);
+ }
+};
+
+/// Options controlling `resolve`.
+pub const ResolveOptions = struct {
+ /// Parser used to turn resolver-returned Markdown into AST blocks. Defaults
+ /// to a plain parser; pass one with matching flags (e.g. `.math = true`) so
+ /// synthesised definitions parse the same way as the host document.
+ parser: Parser = .{},
+};
+
+/// Outcome of a `resolve` call.
+pub const ResolveReport = struct {
+ /// Number of footnote definitions synthesised and appended to the document.
+ synthesized: usize = 0,
+ /// Number of undefined references the resolver returned `null` for.
+ unresolved: usize = 0,
+};
+
+/// Synthesise definitions for every undefined footnote reference in `doc`.
+///
+/// For each *distinct* referenced label (in first-reference order) that lacks a
+/// top-level `footnote_definition`, the `resolver` is invoked. A non-null
+/// result is parsed and appended to `doc` as a new `footnote_definition` block;
+/// a `null` result increments `unresolved`.
+///
+/// Definitions are only ever top-level in `doc.children`, so only that level is
+/// scanned for existing definitions and that is where synthesised blocks land.
+pub fn resolve(
+ allocator: Allocator,
+ doc: *AST.Document,
+ resolver: Resolver,
+ opts: ResolveOptions,
+) !ResolveReport {
+ var report = ResolveReport{};
+
+ // Existing top-level definitions — never re-synthesise these.
+ var defined = std.StringHashMap(void).init(allocator);
+ defer defined.deinit();
+ for (doc.children.items) |*block| {
+ if (block.* == .footnote_definition)
+ try defined.put(block.footnote_definition.label, {});
+ }
+
+ // Referenced labels in first-reference order, deduplicated. The label
+ // slices borrow from the documents' inline-source buffers, which are not
+ // moved by appending blocks below, so they stay valid across the mutation.
+ var seen = std.StringHashMap(void).init(allocator);
+ defer seen.deinit();
+ var ordered = std.ArrayList([]const u8).empty;
+ defer ordered.deinit(allocator);
+ for (doc.children.items) |*block| try walkBlockRefs(block, &seen, &ordered, allocator);
+
+ for (ordered.items) |label| {
+ if (defined.contains(label)) continue;
+ const md = try resolver.call(allocator, label) orelse {
+ report.unresolved += 1;
+ continue;
+ };
+ defer allocator.free(md);
+
+ var fn_def = try synthesizeDefinition(allocator, opts.parser, label, md);
+ doc.edit().appendBlock(allocator, .{ .footnote_definition = fn_def }) catch |err| {
+ fn_def.deinit(allocator);
+ return err;
+ };
+ report.synthesized += 1;
+ }
+
+ return report;
+}
+
+/// Return the deduplicated labels of every footnote reference in `doc` that has
+/// no matching top-level definition, in first-reference order.
+///
+/// The returned slice and each label are owned by the caller: free every entry
+/// and then the slice with the same allocator.
+pub fn dangling(allocator: Allocator, doc: *const AST.Document) ![][]const u8 {
+ var defined = std.StringHashMap(void).init(allocator);
+ defer defined.deinit();
+ for (doc.children.items) |*block| {
+ if (block.* == .footnote_definition)
+ try defined.put(block.footnote_definition.label, {});
+ }
+
+ var seen = std.StringHashMap(void).init(allocator);
+ defer seen.deinit();
+ var ordered = std.ArrayList([]const u8).empty;
+ defer ordered.deinit(allocator);
+ for (doc.children.items) |*block| try walkBlockRefs(block, &seen, &ordered, allocator);
+
+ var out = std.ArrayList([]const u8).empty;
+ errdefer {
+ for (out.items) |s| allocator.free(s);
+ out.deinit(allocator);
+ }
+ for (ordered.items) |label| {
+ if (defined.contains(label)) continue;
+ const owned = try allocator.dupe(u8, label);
+ errdefer allocator.free(owned);
+ try out.append(allocator, owned);
+ }
+ return out.toOwnedSlice(allocator);
+}
+
+// ── Internal ──────────────────────────────────────────────────────────────────
+
+/// Parse `markdown` (a resolver's definition body) and build a
+/// `FootnoteDefinition` for `label` that OWNS the parsed blocks.
+///
+/// The parsed blocks are **moved** out of the temporary document; only the
+/// document's list shell is freed here. Calling `tmp.deinit` would double-free
+/// the moved blocks, so it is never called. The label is duped — definitions
+/// own their labels (see `AST.FootnoteDefinition.deinit`).
+fn synthesizeDefinition(
+ allocator: Allocator,
+ parser: Parser,
+ label: []const u8,
+ markdown: []const u8,
+) !AST.FootnoteDefinition {
+ var tmp = try parser.parseMarkdown(allocator, markdown);
+ // While `tmp` still owns the blocks, any failure must free it in full.
+ var moved = false;
+ errdefer if (!moved) tmp.deinit(allocator);
+
+ const owned_label = try allocator.dupe(u8, label);
+ var fn_def = AST.FootnoteDefinition.init(allocator, owned_label);
+ // Past here `fn_def` owns the label (and, once moved, the blocks); a failure
+ // frees it and only `tmp`'s list shell.
+ errdefer fn_def.deinit(allocator);
+
+ // Reserve up-front so the moves below cannot fail (all-or-nothing transfer).
+ try fn_def.children.ensureTotalCapacity(allocator, tmp.children.items.len);
+ for (tmp.children.items) |block| fn_def.children.appendAssumeCapacity(block);
+
+ // Ownership transferred; free only the now-logically-empty list shell.
+ moved = true;
+ tmp.children.deinit(allocator);
+
+ return fn_def;
+}
+
+fn walkBlockRefs(
+ block: *const AST.Block,
+ seen: *std.StringHashMap(void),
+ ordered: *std.ArrayList([]const u8),
+ allocator: Allocator,
+) Allocator.Error!void {
+ switch (block.*) {
+ .paragraph => |*p| try walkInlineRefs(p.children.items, seen, ordered, allocator),
+ .heading => |*h| try walkInlineRefs(h.children.items, seen, ordered, allocator),
+ .blockquote => |*bq| for (bq.children.items) |*b| try walkBlockRefs(b, seen, ordered, allocator),
+ .list => |*l| for (l.items.items) |*item| {
+ for (item.children.items) |*b| try walkBlockRefs(b, seen, ordered, allocator);
+ },
+ .footnote_definition => |*fd| for (fd.children.items) |*b| try walkBlockRefs(b, seen, ordered, allocator),
+ .table => |*t| {
+ for (t.header.cells.items) |*c| try walkInlineRefs(c.children.items, seen, ordered, allocator);
+ for (t.body.items) |*row| {
+ for (row.cells.items) |*c| try walkInlineRefs(c.children.items, seen, ordered, allocator);
+ }
+ },
+ else => {},
+ }
+}
+
+fn walkInlineRefs(
+ inlines: []const AST.Inline,
+ seen: *std.StringHashMap(void),
+ ordered: *std.ArrayList([]const u8),
+ allocator: Allocator,
+) Allocator.Error!void {
+ for (inlines) |*inl| switch (inl.*) {
+ .footnote_reference => |fr| {
+ const gop = try seen.getOrPut(fr.label);
+ if (!gop.found_existing) try ordered.append(allocator, fr.label);
+ },
+ .emphasis => |*e| try walkInlineRefs(e.children.items, seen, ordered, allocator),
+ .strong => |*s| try walkInlineRefs(s.children.items, seen, ordered, allocator),
+ .strikethrough => |*s| try walkInlineRefs(s.children.items, seen, ordered, allocator),
+ .link => |*l| try walkInlineRefs(l.children.items, seen, ordered, allocator),
+ else => {},
+ };
+}
diff --git a/src/markdown/footnotes_test.zig b/src/markdown/footnotes_test.zig
new file mode 100644
index 0000000..3f9a086
--- /dev/null
+++ b/src/markdown/footnotes_test.zig
@@ -0,0 +1,274 @@
+//! Tests for programmatic footnote synthesis (`footnotes.zig`).
+//!
+//! All tests run under `std.testing.allocator`, so any leak — including on the
+//! resolver-error path — fails the test.
+const std = @import("std");
+const tst = std.testing;
+const mem = std.mem;
+const Allocator = std.mem.Allocator;
+
+const AST = @import("ast.zig");
+const Parser = @import("parser.zig");
+const footnotes = @import("footnotes.zig");
+const html = @import("renderers/html.zig");
+const typst = @import("renderers/typst.zig");
+const md_renderer = @import("renderers/markdown.zig");
+
+// ── Test resolvers ────────────────────────────────────────────────────────────
+
+/// Records how many times a resolver was invoked.
+const Recorder = struct {
+ calls: usize = 0,
+};
+
+/// Resolves a small fixed catalog; unknown labels return `null`.
+fn resolveCatalog(ctx: ?*anyopaque, allocator: Allocator, label: []const u8) anyerror!?[]const u8 {
+ if (ctx) |p| {
+ const rec: *Recorder = @ptrCast(@alignCast(p));
+ rec.calls += 1;
+ }
+ if (mem.eql(u8, label, "IAC-01"))
+ return try allocator.dupe(u8, "IAC-01 — Identification. Covered by Access Control Policy.");
+ if (mem.eql(u8, label, "GOV-01"))
+ return try allocator.dupe(u8, "Governance definition body.");
+ if (mem.eql(u8, label, "MULTI"))
+ return try allocator.dupe(u8, "First paragraph.\n\nSecond paragraph.");
+ return null;
+}
+
+/// Resolves every label to the same benign body.
+fn resolveAny(ctx: ?*anyopaque, allocator: Allocator, label: []const u8) anyerror!?[]const u8 {
+ _ = ctx;
+ _ = label;
+ return try allocator.dupe(u8, "definition body");
+}
+
+/// Always fails — exercises the error path.
+fn resolveErr(ctx: ?*anyopaque, allocator: Allocator, label: []const u8) anyerror!?[]const u8 {
+ _ = ctx;
+ _ = allocator;
+ _ = label;
+ return error.ResolverFailed;
+}
+
+fn parse(alloc: Allocator, src: []const u8) !AST.Document {
+ var p = Parser.init();
+ return p.parseMarkdown(alloc, src);
+}
+
+// ── resolve() ─────────────────────────────────────────────────────────────────
+
+test "resolve: synthesizes a definition for a missing reference" {
+ const alloc = tst.allocator;
+ var doc = try parse(alloc, "See [^IAC-01].\n");
+ defer doc.deinit(alloc);
+
+ const report = try footnotes.resolve(alloc, &doc, .{ .resolveFn = resolveCatalog }, .{});
+ try tst.expectEqual(@as(usize, 1), report.synthesized);
+ try tst.expectEqual(@as(usize, 0), report.unresolved);
+
+ // A footnote_definition for IAC-01 now exists at the top level.
+ var found = false;
+ for (doc.children.items) |b| {
+ if (b == .footnote_definition and mem.eql(u8, b.footnote_definition.label, "IAC-01")) found = true;
+ }
+ try tst.expect(found);
+}
+
+test "resolve: existing definition is untouched and resolver is not called" {
+ const alloc = tst.allocator;
+ var doc = try parse(alloc, "See [^IAC-01].\n\n[^IAC-01]: already defined\n");
+ defer doc.deinit(alloc);
+
+ var rec = Recorder{};
+ const report = try footnotes.resolve(alloc, &doc, .{ .ctx = &rec, .resolveFn = resolveCatalog }, .{});
+ try tst.expectEqual(@as(usize, 0), report.synthesized);
+ try tst.expectEqual(@as(usize, 0), report.unresolved);
+ try tst.expectEqual(@as(usize, 0), rec.calls);
+}
+
+test "resolve: repeated references are deduplicated (one synthesis)" {
+ const alloc = tst.allocator;
+ var doc = try parse(alloc, "[^IAC-01] and again [^IAC-01].\n");
+ defer doc.deinit(alloc);
+
+ var rec = Recorder{};
+ const report = try footnotes.resolve(alloc, &doc, .{ .ctx = &rec, .resolveFn = resolveCatalog }, .{});
+ try tst.expectEqual(@as(usize, 1), report.synthesized);
+ try tst.expectEqual(@as(usize, 1), rec.calls);
+}
+
+test "resolve: definitions are appended in first-reference order" {
+ const alloc = tst.allocator;
+ var doc = try parse(alloc, "[^GOV-01] then [^IAC-01].\n");
+ defer doc.deinit(alloc);
+
+ const report = try footnotes.resolve(alloc, &doc, .{ .resolveFn = resolveCatalog }, .{});
+ try tst.expectEqual(@as(usize, 2), report.synthesized);
+
+ const n = doc.children.items.len;
+ try tst.expect(n >= 3);
+ // The paragraph is first; the two synthesised defs follow in ref order.
+ try tst.expect(doc.children.items[n - 2] == .footnote_definition);
+ try tst.expect(doc.children.items[n - 1] == .footnote_definition);
+ try tst.expectEqualStrings("GOV-01", doc.children.items[n - 2].footnote_definition.label);
+ try tst.expectEqualStrings("IAC-01", doc.children.items[n - 1].footnote_definition.label);
+}
+
+test "resolve: null result counts as unresolved" {
+ const alloc = tst.allocator;
+ var doc = try parse(alloc, "[^UNKNOWN] and [^IAC-01].\n");
+ defer doc.deinit(alloc);
+
+ const report = try footnotes.resolve(alloc, &doc, .{ .resolveFn = resolveCatalog }, .{});
+ try tst.expectEqual(@as(usize, 1), report.synthesized);
+ try tst.expectEqual(@as(usize, 1), report.unresolved);
+}
+
+test "resolve: multi-block resolver content becomes multiple child blocks" {
+ const alloc = tst.allocator;
+ var doc = try parse(alloc, "[^MULTI]\n");
+ defer doc.deinit(alloc);
+
+ const report = try footnotes.resolve(alloc, &doc, .{ .resolveFn = resolveCatalog }, .{});
+ try tst.expectEqual(@as(usize, 1), report.synthesized);
+
+ const n = doc.children.items.len;
+ const def = doc.children.items[n - 1].footnote_definition;
+ try tst.expectEqual(@as(usize, 2), def.children.items.len);
+ try tst.expect(def.children.items[0] == .paragraph);
+ try tst.expect(def.children.items[1] == .paragraph);
+}
+
+test "resolve: references inside blockquotes and table cells are found" {
+ const alloc = tst.allocator;
+ const src =
+ \\> A quote referencing [^GOV-01].
+ \\
+ \\| Column [^IAC-01] |
+ \\|---|
+ \\| cell |
+ ;
+ var doc = try parse(alloc, src);
+ defer doc.deinit(alloc);
+
+ const report = try footnotes.resolve(alloc, &doc, .{ .resolveFn = resolveCatalog }, .{});
+ try tst.expectEqual(@as(usize, 2), report.synthesized);
+}
+
+test "resolve: no leak when the resolver errors" {
+ const alloc = tst.allocator;
+ var doc = try parse(alloc, "[^IAC-01]\n");
+ defer doc.deinit(alloc);
+
+ try tst.expectError(
+ error.ResolverFailed,
+ footnotes.resolve(alloc, &doc, .{ .resolveFn = resolveErr }, .{}),
+ );
+}
+
+// ── End-to-end rendering ────────────────────────────────────────────────────────
+
+test "resolve then HTML: reference links to synthesised definition div" {
+ const alloc = tst.allocator;
+ var doc = try parse(alloc, "See [^IAC-01].\n");
+ defer doc.deinit(alloc);
+
+ _ = try footnotes.resolve(alloc, &doc, .{ .resolveFn = resolveCatalog }, .{});
+
+ const out = try html.render(alloc, doc);
+ defer alloc.free(out);
+ try tst.expect(mem.indexOf(u8, out, "href=\"#fn:IAC-01\"") != null);
+ try tst.expect(mem.indexOf(u8, out, "
a footnoteSCF:GOV-01\n" ++ "a footnote2
\n" ++ "SCF:GOV-01: Footnote 1
\n" ++ + // The `[^SCF:GOV-01]:` line now parses as a real footnote definition + // (the relaxed label charset accepts `:` and `-`), matching Zola/GitHub. + "SCF:GOV-01: Footnote 1
\n2: Footnote 2
\n