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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
60 changes: 59 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<del>text</del>`
- **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
Expand Down
2 changes: 1 addition & 1 deletion build.zig.zon
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
.name = .zigmark,
// This is a [Semantic Version](https://semver.org/).
// In a future version of Zig it will be used for package deduplication.
.version = "0.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
Expand Down
1 change: 1 addition & 0 deletions src/markdown/ast.zig
Original file line number Diff line number Diff line change
Expand Up @@ -968,4 +968,5 @@ test {
_ = @import("query_test.zig");
_ = @import("library_test.zig");
_ = @import("mutation_test.zig");
_ = @import("footnotes_test.zig");
}
33 changes: 30 additions & 3 deletions src/markdown/combinators.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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('='),
Expand Down Expand Up @@ -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 {
Expand Down
Loading
Loading