//! 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, "<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);
}