twig-sys 3.7.0

FFI bindings and native library for Twig (the Djot/Markdown/HTML/XML document engine). Used by the `twig-doc` crate.
Documentation
//! Document -> HTML renderer, mirroring `languages/djot/html.zig` exactly
//! (see that file's module doc comment for the full rationale — this is the
//! same "thin adapter over the shared, language-neutral printer at
//! `languages/html/serializer.zig`" split, for the same reason).
//!
//! Markdown resolves `link`/`image` references at PARSE time (Phase 2), so
//! — unlike djot — the shared printer never looks a reference up here; the
//! `labels.references` it is handed is simply never consulted. Footnotes are
//! the one Markdown construct that (like djot's) resolves at RENDER time
//! instead (see `markdown.zig`'s module doc comment), so `labels.footnotes`
//! is the table that matters.
//!
//! This is the module `format.zig`'s markdown registry entry renders
//! through (`renderHtmlMarkdown`) instead of the bare generic
//! `Html.serialize`, precisely so footnotes resolve when converting via the
//! CLI — using the generic printer directly (`ctx = null`) would silently
//! drop every footnote reference/definition (an unresolved
//! `footnote_reference` renders as if its definition were simply missing —
//! see `languages/html/serializer.zig`'s module doc comment).

const std = @import("std");
const Allocator = std.mem.Allocator;
const Writer = std.Io.Writer;
const markdown = @import("markdown.zig");
const Document = @import("../../document.zig");
const Html = @import("../html/html.zig");

pub const RenderOptions = struct {
    warn: ?*const fn (message: []const u8) void = null,
    /// Which Markdown dialect's HTML conventions to print with — GFM spells a
    /// table cell's alignment `align="center"` where twig's default flavour
    /// writes a `style`. This is `ParseOptions.dialect`, which the parser
    /// never reads and the `Document` does not record (see that field's doc
    /// comment for why it is not recoverable from the tree); the caller that
    /// held the parse config supplies it. `format.zig`'s registry does so
    /// from the `ParseConfig` it carries on every `ParsedDoc`, which is what
    /// keeps a document from being parsed as GFM and printed as CommonMark by
    /// a caller forgetting to say so twice.
    dialect: markdown.ParseOptions.Dialect = .commonmark,
};

/// Same error set `Html`'s printer returns: write failures from `writer`
/// merged with allocation failures (footnote index/id tracking needs to
/// allocate).
pub const RenderError = Html.RenderError;

/// Render `doc` (rooted at `doc.ast.root`, normally a `doc` node) to HTML,
/// writing to `writer`. Delegates to `Html.Renderer` directly (rather than
/// `Html.serialize`) so `options.warn` can be threaded through to the
/// printer's own `RenderOptions` -- mirrors `Djot.html.render` exactly.
pub fn render(allocator: Allocator, doc: *const Document, writer: *Writer, options: RenderOptions) RenderError!void {
    // Route through the shared printer with the dialect's conventions plus
    // this call's `warn` hook. See `RenderOptions.dialect`, and
    // `Html.commonmark_render_options`/`Html.gfm_render_options`.
    var render_opts = switch (options.dialect) {
        .commonmark => Html.commonmark_render_options,
        .gfm => Html.gfm_render_options,
    };
    render_opts.warn = options.warn;
    var r = Html.Renderer.init(allocator, &doc.ast, writer, &doc.labels, render_opts);
    defer r.deinit();
    try r.renderNode(doc.ast.root);
}

/// Convenience wrapper: render to an owned string.
pub fn renderAlloc(allocator: Allocator, doc: *const Document, options: RenderOptions) Html.RenderAllocError![]u8 {
    var out: Writer.Allocating = .init(allocator);
    defer out.deinit();
    // `Writer.Allocating` only ever fails (`error.WriteFailed`) when its own
    // backing allocation fails, so it collapses to `error.OutOfMemory`;
    // `error.UnsafeMetadata` propagates as a real content refusal (a `metadata`
    // node whose body contains `</script`).
    render(allocator, doc, &out.writer, options) catch |err| switch (err) {
        error.WriteFailed, error.OutOfMemory => return error.OutOfMemory,
        error.UnsafeMetadata => return error.UnsafeMetadata,
    };
    return out.toOwnedSlice();
}

const testing = std.testing;

test "dialect: the same table prints twig-markdown-shaped by default and GFM-shaped under .gfm" {
    // The contract this file exists to enforce: twig prints djot, markdown,
    // and GFM DISTINCTLY. Both dialects parse this to the same
    // `table`/`row`/`cell` nodes; only the printing differs, and the dialect
    // is the caller's to name at render time. (Djot's third spelling — bare
    // `<tr>`, no sections — is pinned by `languages/djot/conformance.zig`.)
    const src = "| a |\n| :-: |\n| 1 |\n";

    var md_doc = try markdown.parse(testing.allocator, src, .{ .tables = true });
    defer md_doc.deinit();
    const md_out = try renderAlloc(testing.allocator, &md_doc, .{});
    defer testing.allocator.free(md_out);

    var gfm_doc = try markdown.parse(testing.allocator, src, markdown.ParseOptions.gfm);
    defer gfm_doc.deinit();
    const gfm_out = try renderAlloc(testing.allocator, &gfm_doc, .{ .dialect = .gfm });
    defer testing.allocator.free(gfm_out);

    // Both section their rows — that's well-formed HTML, not a GFM quirk.
    try testing.expect(std.mem.indexOf(u8, md_out, "<thead>") != null);
    try testing.expect(std.mem.indexOf(u8, gfm_out, "<thead>") != null);
    // They disagree only on how a cell's alignment is spelled.
    try testing.expect(std.mem.indexOf(u8, md_out, "<th style=\"text-align: center;\">a</th>") != null);
    try testing.expect(std.mem.indexOf(u8, gfm_out, "<th align=\"center\">a</th>") != null);
    try testing.expect(std.mem.indexOf(u8, md_out, "align=\"center\"") == null);
    try testing.expect(std.mem.indexOf(u8, gfm_out, "text-align") == null);
}

test "dialect: tagfilter is GFM-only, so default markdown passes raw <title> through" {
    // Unlike the table spellings above, the tagfilter changes what the HTML
    // DOES rather than how it's spelled, so it stays scoped to the dialect
    // that actually specifies it.
    const src = "<strong> <title>\n";

    var md_doc = try markdown.parse(testing.allocator, src, .{});
    defer md_doc.deinit();
    const md_out = try renderAlloc(testing.allocator, &md_doc, .{});
    defer testing.allocator.free(md_out);
    try testing.expect(std.mem.indexOf(u8, md_out, "<title>") != null);

    var gfm_doc = try markdown.parse(testing.allocator, src, markdown.ParseOptions.gfm);
    defer gfm_doc.deinit();
    const gfm_out = try renderAlloc(testing.allocator, &gfm_doc, .{ .dialect = .gfm });
    defer testing.allocator.free(gfm_out);
    try testing.expect(std.mem.indexOf(u8, gfm_out, "&lt;title>") != null);
    // `<strong>` isn't blacklisted, so it stays live in both.
    try testing.expect(std.mem.indexOf(u8, gfm_out, "<strong>") != null);
}

test "highlight colors: ==🔴 text== renders as <mark data-color=\"red\"> with the emoji gone" {
    var doc = try markdown.parse(testing.allocator, "a ==\u{1F534} b== c\n", .{ .highlight = true, .highlight_colors = true });
    defer doc.deinit();
    const html = try renderAlloc(testing.allocator, &doc, .{});
    defer testing.allocator.free(html);
    try testing.expectEqualStrings("<p>a <mark data-color=\"red\">b</mark> c</p>\n", html);
}

test "highlight: ==text== renders as <mark> with the extension on, literal text off" {
    var on = try markdown.parse(testing.allocator, "a ==b== c\n", .{ .highlight = true });
    defer on.deinit();
    const html_on = try renderAlloc(testing.allocator, &on, .{});
    defer testing.allocator.free(html_on);
    try testing.expectEqualStrings("<p>a <mark>b</mark> c</p>\n", html_on);

    var off = try markdown.parse(testing.allocator, "a ==b== c\n", .{});
    defer off.deinit();
    const html_off = try renderAlloc(testing.allocator, &off, .{});
    defer testing.allocator.free(html_off);
    try testing.expectEqualStrings("<p>a ==b== c</p>\n", html_off);
}

test "renders a simple paragraph with emphasis" {
    var doc = try markdown.parse(testing.allocator, "hello *world*\n", .{});
    defer doc.deinit();
    const html = try renderAlloc(testing.allocator, &doc, .{});
    defer testing.allocator.free(html);
    try testing.expectEqualStrings("<p>hello <em>world</em></p>\n", html);
}

test "a top-level block quote after a list is a sibling of the list, not nested in it" {
    // `- item\n\n> quote`: the unindented `>` opens a new block quote beside the
    // list, closing it. Not covered by the CommonMark spec suite; regression for
    // a `>` block start that forgot to close the open list first.
    var doc = try markdown.parse(testing.allocator, "- item\n\n> quote\n", .{});
    defer doc.deinit();
    const html = try renderAlloc(testing.allocator, &doc, .{});
    defer testing.allocator.free(html);
    try testing.expectEqualStrings(
        "<ul>\n<li>item</li>\n</ul>\n<blockquote>\n<p>quote</p>\n</blockquote>\n",
        html,
    );
}

test "a block quote indented into a list item stays inside the item" {
    // The companion case: indented to the item's content column, the `>` is the
    // item's own second block, so it must NOT close the list.
    var doc = try markdown.parse(testing.allocator, "- item\n\n  > quote\n", .{});
    defer doc.deinit();
    const html = try renderAlloc(testing.allocator, &doc, .{});
    defer testing.allocator.free(html);
    try testing.expectEqualStrings(
        "<ul>\n<li>\n<p>item</p>\n<blockquote>\n<p>quote</p>\n</blockquote>\n</li>\n</ul>\n",
        html,
    );
}

test "footnote: a reference + definition render the noteref, sup, and endnotes section" {
    var doc = try markdown.parse(testing.allocator,
        \\text[^a] more
        \\
        \\[^a]: the note
        \\
    , .{ .footnotes = true });
    defer doc.deinit();
    const html = try renderAlloc(testing.allocator, &doc, .{});
    defer testing.allocator.free(html);

    try testing.expect(std.mem.indexOf(u8, html, "id=\"fnref1\"") != null);
    try testing.expect(std.mem.indexOf(u8, html, "role=\"doc-noteref\"") != null);
    try testing.expect(std.mem.indexOf(u8, html, "<sup>") != null);
    try testing.expect(std.mem.indexOf(u8, html, "role=\"doc-endnotes\"") != null);
    try testing.expect(std.mem.indexOf(u8, html, "id=\"fn1\"") != null);
    try testing.expect(std.mem.indexOf(u8, html, "the note") != null);
    // A backlink from the note back to its reference.
    try testing.expect(std.mem.indexOf(u8, html, "href=\"#fnref1\"") != null);
}

test "footnote: a forward reference (used before its definition) still resolves" {
    var doc = try markdown.parse(testing.allocator,
        \\see[^a]
        \\
        \\[^a]: later
        \\
    , .{ .footnotes = true });
    defer doc.deinit();
    const html = try renderAlloc(testing.allocator, &doc, .{});
    defer testing.allocator.free(html);
    try testing.expect(std.mem.indexOf(u8, html, "id=\"fnref1\"") != null);
    try testing.expect(std.mem.indexOf(u8, html, "later") != null);
}

test "footnote: multiple footnotes are numbered in reference order" {
    var doc = try markdown.parse(testing.allocator,
        \\one[^b] two[^a]
        \\
        \\[^a]: A
        \\[^b]: B
        \\
    , .{ .footnotes = true });
    defer doc.deinit();
    const html = try renderAlloc(testing.allocator, &doc, .{});
    defer testing.allocator.free(html);
    // `[^b]` is referenced first in the text, so it claims footnote 1.
    try testing.expect(std.mem.indexOf(u8, html, "id=\"fnref1\"") != null);
    try testing.expect(std.mem.indexOf(u8, html, "id=\"fnref2\"") != null);
    const fn1 = std.mem.indexOf(u8, html, "id=\"fn1\"").?;
    const fn2 = std.mem.indexOf(u8, html, "id=\"fn2\"").?;
    try testing.expect(fn1 < fn2);
    try testing.expect(std.mem.indexOf(u8, html[fn1..fn2], "B") != null);
}

test "footnotes OFF: '[^a]' with no definition falls back to ordinary CommonMark link parsing" {
    // With `footnotes = false`, `'['` never special-cases `^` at all -- this
    // is ordinary shortcut-reference-link syntax with an unresolved label
    // ("^a", no matching `labels.references` entry), which CommonMark falls
    // back to literal bracket text for (same as any other undefined
    // `[label]` -- see `block.zig`'s own "unresolved reference falls back
    // to literal brackets" coverage).
    var doc = try markdown.parse(testing.allocator, "see [^a]\n", .{ .footnotes = false });
    defer doc.deinit();
    const html = try renderAlloc(testing.allocator, &doc, .{});
    defer testing.allocator.free(html);
    try testing.expect(std.mem.indexOf(u8, html, "doc-endnotes") == null);
    try testing.expect(std.mem.indexOf(u8, html, "doc-noteref") == null);
    try testing.expect(std.mem.indexOf(u8, html, "[^a]") != null);
}

test "footnotes OFF: '[^a]: note' is an ordinary link reference definition (the '^' has no special meaning)" {
    // Confirms the flag genuinely gates the `^`-prefixed grammar rather than
    // just suppressing rendering: with it off, `[^a]: note` is valid,
    // ordinary CommonMark link reference definition syntax (labels may
    // contain `^`), so a shortcut reference to it resolves as a normal
    // link, NOT a footnote noteref.
    var doc = try markdown.parse(testing.allocator, "see[^a]\n\n[^a]: /note\n", .{ .footnotes = false });
    defer doc.deinit();
    const html = try renderAlloc(testing.allocator, &doc, .{});
    defer testing.allocator.free(html);
    try testing.expect(std.mem.indexOf(u8, html, "doc-endnotes") == null);
    try testing.expect(std.mem.indexOf(u8, html, "doc-noteref") == null);
    try testing.expect(std.mem.indexOf(u8, html, "href=\"/note\"") != null);
}