//! The format registry: the single place a new Twig language plugs in.
//!
//! Each `Entry` bundles everything that varies by language behind one uniform
//! shape — a parser adapter (`parse`), the `Document` reparse adapter the
//! `Splicer` needs (`parseToAst`), an HTML renderer (`renderHtml`), optional
//! serializers, and an optional `Syntax` table — so no consumer needs a
//! per-language `switch` of its own. Adding a language is "write a few small
//! adapters, add one `registry` entry".
//!
//! ── Why this isn't in `cli/` ───────────────────────────────────────────────
//! It used to be. The C ABI can't import the CLI, so it grew its own parallel
//! copy: the same four `parseToAst` adapters (its own comment admitted
//! "Mirrors `cli/format.zig`'s"), its own `ParseConfig`, and a hand-written
//! `switch (format)` per operation where this table has a field. Two copies of
//! one table is a drift bug waiting to happen — and the second copy sat behind
//! an `extern` boundary, so only a C caller could reach or test it. Living
//! here, both the CLI and the C ABI read the same row.
//!
//! ── Optional fields are the raggedness ─────────────────────────────────────
//! Twig's languages are not interchangeable. Every one parses and renders, but
//! AsciiDoc has no serializer at all, nothing can be written INTO XML from
//! another format, and only djot and Markdown have a `syntax` — a `null` says so
//! once, in whichever of the two tables owns the question, and every caller
//! turns it into the same "unsupported" error instead of rediscovering the fact
//! in an `else =>` arm. See `syntax.zig` for that argument in full.
//!
//! ── Two axes: what Twig READS and what Twig WRITES ─────────────────────────
//! There are two tables here, not one. `registry` is keyed by `Format` — the
//! languages Twig can PARSE — and `targets` is keyed by `Target` — the formats
//! Twig can WRITE. Every `Format` is also a `Target`, so the two lists coincide
//! today, and they are still separate types.
//!
//! The reason is that the enums answer different questions and only one of them
//! can grow freely. `Format` is what `ParsedDoc.format` records: a variant
//! there must have a parser and a reparse adapter for the `Splicer`. A
//! `Target` needs none of that — it needs somewhere for bytes to go. An
//! EXPORT-ONLY target (one Twig can write and no parser can read back; PDF is
//! the motivating case) is expressible as a `Target` and is NOT expressible as a
//! `Format`, and before the split there was nowhere to put it that did not also
//! claim Twig could parse it.
//!
//! The split was already latent rather than hypothetical: `diagnostics.zig`'s
//! `fidelity(target, kind)` has always indexed its capability table on an output
//! axis while spelling the parameter `Format`, and `cli/format.zig` had already
//! named its `-i` re-export `InputFormat` to distinguish it from what `-o`
//! accepts. `serializeFromAst` moved with it, from `Entry` to `TargetEntry`: it
//! is keyed by where the bytes are going, not by what parsed them.
//! `serializeCanonical` stayed on `Entry`: it serializes a parsed `Document`
//! with the label tables its own parser filled, and so is a fact about the
//! input row — see its doc comment for what still separates it from
//! `serializeFromAst` now that both take the shared types.
const std = @import("std");
const Allocator = std.mem.Allocator;
const Writer = std.Io.Writer;
const AST = @import("ast/ast.zig");
const Document = @import("document.zig");
const Djot = @import("languages/djot/djot.zig");
const Markdown = @import("languages/markdown/markdown.zig");
const Xml = @import("languages/xml/xml.zig");
const Html = @import("languages/html/html.zig");
const Asciidoc = @import("languages/asciidoc/asciidoc.zig");
const Splicer = @import("ast/splicer.zig").Splicer;
const syntax_mod = @import("syntax.zig");
const Syntax = syntax_mod.Syntax;
/// Markdown's spelling tables — plural, because Markdown's authorable subset
/// depends on its parse config. See `Entry.syntaxFor`.
const markdown_syntax = @import("languages/markdown/syntax.zig");
const djot_serializer = Djot.serializer;
const markdown_serializer = Markdown.serializer;
const asciidoc_serializer = Asciidoc.serializer;
/// Every language Twig can PARSE — the `-i`/`--input` vocabulary, what
/// `ParsedDoc.format` records, and what the C ABI's `TwigFormat` wire codes
/// decode to on the parse path. Deliberately has NO explicit values: the integers are
/// the C ABI's contract, so they live there (`c_abi.zig`'s `intToFormat`), not
/// here.
///
/// This is the INPUT axis only. What Twig can write is `Target` — see the
/// two-axes note at the top of this file.
pub const Format = enum {
djot,
markdown,
xml,
html,
asciidoc,
/// Strict CommonMark 0.31.2: Markdown with every extension off. A DIALECT
/// of `markdown` — one parser, one serializer, one `Target` — that the
/// registry carries as a row of its own so `-i commonmark` and
/// `TWIG_FORMAT_COMMONMARK` name it, the way fig's `json`/`jsonc`/`json5`
/// are three rows over one language. See `Entry.dialect_of`.
commonmark,
/// GitHub-Flavored Markdown: the spec's four extensions and GFM's HTML
/// conventions (`align=` on a cell, not `style=`). A dialect of
/// `markdown`, as `commonmark` is.
gfm,
};
/// Every format Twig can WRITE — what `-o`/`--output` names beyond its three
/// `OutputMode` words, the axis `diagnostics.fidelity` is indexed by, and the
/// key of the `targets` table.
///
/// Spelled out rather than derived from `Format`, even though the two lists
/// coincide today. A generated enum would make the interesting case — a variant
/// that is a target and NOT a format — invisible at the point a reader looks for
/// it, and would put the subset relation in a comptime expression instead of in
/// a test that says what it is checking. The relation is enforced by `test
/// "every Format is also a Target"`: the same hand-maintained-table-plus-test
/// trust boundary `registry` itself relies on.
///
/// An export-only target appends HERE and nowhere else. It gets a `targets` row
/// with `reads_back_as = null` and no `Format` variant, no `registry` row, and
/// no `Syntax` — none of which it could honestly fill in. That is the whole
/// reason this enum exists apart from `Format`.
pub const Target = enum {
djot,
markdown,
xml,
html,
asciidoc,
/// The `Format` whose parser reads this target's own output back, or `null`
/// for an export-only target. `null` is what makes a round-trip
/// unstateable, which is why `diagnostics.zig`'s probe skips such a target
/// rather than guessing at an answer for it.
pub fn asFormat(self: Target) ?Format {
return targetEntryFor(self).reads_back_as;
}
};
/// The output target that writes `fmt`'s own syntax. TOTAL — every input format
/// is also a target, including the ones with no serializer yet, because
/// `-o asciidoc` has to reach "not supported yet" rather than "unknown target".
///
/// A dialect writes as its LANGUAGE: Markdown has one serializer however it was
/// parsed, so `commonmark` and `gfm` land on `.markdown` and `Target` does not
/// grow a row per dialect. Those are the only arms spelled out; for every
/// language row totality is a compile error rather than a test, since `@field`
/// fails to resolve if a `Format` name is missing from `Target`. The dialect
/// arms agree with `Entry.dialect_of` by a test below.
pub fn targetFor(fmt: Format) Target {
return switch (fmt) {
.commonmark, .gfm => .markdown,
inline else => |f| @field(Target, @tagName(f)),
};
}
/// Whether `target` writes the syntax `fmt` was parsed from — the question
/// `-o canonical` and `twig_document_serialize` ask before choosing between
/// the input row's `serializeCanonical` (the parsed `Document` whole, spelling
/// and labels intact) and the target's bare-`AST` `serializeFromAst`. Asked
/// through `targetFor` rather than `==` so a GFM document serialized `-o
/// markdown` takes the faithful path: it IS Markdown. An export-only target is
/// never any format's own, and takes the cross-format path, the only one it has.
pub fn writesOwnSyntax(fmt: Format, target: Target) bool {
return targetFor(fmt) == target;
}
/// Per-invocation parse configuration, threaded from a consumer's feature flags
/// into the `parse`/`parseToAst` adapters. Passed as an opaque `*const anyopaque`
/// (so `ast/splicer.zig` can carry it across reparses without depending on this
/// type — see `Splicer.ParseFn`); every adapter that reads it `@ptrCast`s it
/// back. Only Markdown's rows consult it today; other formats' adapters ignore it.
///
/// This is what a caller adds ON TOP of the row it chose, never the row itself.
/// Which Markdown dialect a document is — strict, GFM, or the default — is the
/// `Format`, and the row carries that preset; the config is the opt-in
/// `Extensions` laid over it. It used to hold the whole `ParseOptions`, and a
/// dialect was then a value a caller wrote into the config while the `Format`
/// still said plain `markdown` — the same fact stated in two places, which
/// `ParsedDoc`'s doc comment below argues against.
pub const ParseConfig = struct {
markdown: Markdown.ParseOptions.Extensions = .{},
/// Recover a `*const ParseConfig` from the opaque pointer the registry
/// adapters / the splicer pass around.
pub fn from(ctx: *const anyopaque) *const ParseConfig {
return @ptrCast(@alignCast(ctx));
}
};
/// A parsed document: the shared `Document` plus the two facts about HOW it
/// was parsed that the tree does not record — which `Format`'s parser
/// produced it, and the `ParseConfig` that parser was given.
///
/// This used to be a `union(Format)`, because djot and Markdown each returned
/// a wrapper of their own carrying label -> definition maps the shared
/// `Document` had no column for. Those maps are `Document.labels` now, so
/// every parser returns the one type and the union had nothing left to
/// distinguish. What remains is a thin wrapper, and both its fields earn
/// their place: `format` picks the `registry` row for every later operation
/// — and for Markdown that row IS the dialect, so `renderHtml` spells a
/// table's alignment the way the format the document was parsed as does,
/// and a caller cannot parse a document as GFM and print it as CommonMark
/// by forgetting to say so twice. `config` is the extensions laid over that
/// row, which the editor's reparse and `syntaxForConfig` need to agree on.
pub const ParsedDoc = struct {
format: Format,
config: ParseConfig,
doc: Document,
/// The shared `AST` underneath — MEANING only.
pub fn ast(self: *const ParsedDoc) *const AST {
return &self.doc.ast;
}
/// A BORROWED `Document` view — the tree plus the positions addressing
/// `source`, by value. What the edit layer and
/// `languages/xml/serializer.zig` take; must NOT be `deinit`ed (this
/// struct owns the storage).
pub fn document(self: *const ParsedDoc) Document {
return self.doc;
}
pub fn deinit(self: *ParsedDoc) void {
self.doc.deinit();
}
};
fn parseDjot(ctx: *const anyopaque, allocator: Allocator, source: []const u8) anyerror!ParsedDoc {
const cfg = ParseConfig.from(ctx);
return .{ .format = .djot, .config = cfg.*, .doc = try Djot.parse(allocator, source) };
}
/// The adapters for one Markdown DIALECT: the parser under `preset`, with the
/// caller's `ParseConfig.markdown` extensions laid over it. One instantiation
/// per Markdown row in `registry`, so the preset is stated on the row and
/// nowhere else — the parse, the splicer's reparse, the HTML render's
/// conventions and the editor's spelling table all read it from here.
fn MarkdownDialect(comptime id: Format, comptime preset: Markdown.ParseOptions) type {
return struct {
/// The spelling table under a default config — what the row's
/// `syntax` points at, by address, so the serializer and the editor
/// cannot read two different answers for one document.
const syntax: *const Syntax = markdown_syntax.forOptions(preset);
fn options(ctx: *const anyopaque) Markdown.ParseOptions {
return Markdown.ParseOptions.withExtensions(preset, ParseConfig.from(ctx).markdown);
}
fn parse(ctx: *const anyopaque, allocator: Allocator, source: []const u8) anyerror!ParsedDoc {
const cfg = ParseConfig.from(ctx);
return .{ .format = id, .config = cfg.*, .doc = try Markdown.parse(allocator, source, options(ctx)) };
}
fn parseToAst(ctx: *const anyopaque, allocator: Allocator, source: []const u8) anyerror!Document {
return Markdown.parse(allocator, source, options(ctx));
}
/// Markdown needs its own HTML rendering path (`Markdown.html.render`)
/// rather than the generic printer for the same reason djot does
/// (`renderHtmlDjot`'s doc comment): footnotes resolve/number/backlink
/// entirely at RENDER time, against `Document.labels.footnotes` — see
/// `markdown/html.zig`'s module doc comment. Using the generic printer
/// here would silently drop footnotes (every `link`/`image`, by
/// contrast, is already fully resolved at PARSE time, so those are
/// unaffected either way). The render dialect is the row's, which is
/// the one place it is stated.
fn renderHtml(allocator: Allocator, doc: *const ParsedDoc, writer: *Writer) anyerror!void {
try Markdown.html.render(allocator, &doc.doc, writer, .{ .dialect = preset.dialect });
}
/// The `Entry.syntaxFor` for this row: the extensions decide whether
/// `==x==` reads back as a `mark`, and so whether an editor may write
/// one, over whatever the preset already reads.
fn syntaxFor(cfg: *const ParseConfig) *const Syntax {
return markdown_syntax.forOptions(Markdown.ParseOptions.withExtensions(preset, cfg.markdown));
}
};
}
/// Twig's default Markdown: CommonMark plus the default-on extensions.
const MarkdownDefault = MarkdownDialect(.markdown, .{});
const Commonmark = MarkdownDialect(.commonmark, Markdown.ParseOptions.commonmark);
const Gfm = MarkdownDialect(.gfm, Markdown.ParseOptions.gfm);
fn parseXml(ctx: *const anyopaque, allocator: Allocator, source: []const u8) anyerror!ParsedDoc {
const cfg = ParseConfig.from(ctx);
return .{ .format = .xml, .config = cfg.*, .doc = try Xml.parse(allocator, source) };
}
fn parseHtml(ctx: *const anyopaque, allocator: Allocator, source: []const u8) anyerror!ParsedDoc {
const cfg = ParseConfig.from(ctx);
return .{ .format = .html, .config = cfg.*, .doc = try Html.parse(allocator, source) };
}
fn parseAsciidoc(ctx: *const anyopaque, allocator: Allocator, source: []const u8) anyerror!ParsedDoc {
const cfg = ParseConfig.from(ctx);
return .{ .format = .asciidoc, .config = cfg.*, .doc = try Asciidoc.parser.parse(allocator, source) };
}
// ── splicer reparse adapters ───────────────────────────────────────────────
// The span-splice engine (`Splicer`) reparses after every edit and holds the
// shared `Document` — spans, structure, and now the label tables too, since
// they are a column of it. Each adapter is the language's own `parse`, minus
// the `ParsedDoc` wrapper the splicer has no use for.
fn parseToAstDjot(ctx: *const anyopaque, allocator: Allocator, source: []const u8) anyerror!Document {
_ = ctx;
return Djot.parse(allocator, source);
}
fn parseToAstXml(ctx: *const anyopaque, allocator: Allocator, source: []const u8) anyerror!Document {
_ = ctx;
return Xml.parse(allocator, source);
}
fn parseToAstHtml(ctx: *const anyopaque, allocator: Allocator, source: []const u8) anyerror!Document {
_ = ctx;
return Html.parse(allocator, source);
}
fn parseToAstAsciidoc(ctx: *const anyopaque, allocator: Allocator, source: []const u8) anyerror!Document {
_ = ctx;
return Asciidoc.parser.parse(allocator, source);
}
/// Djot needs its own HTML rendering path (`Djot.html.render`) rather than the
/// generic printer: it resolves reference/footnote labels against
/// `Document.labels` at render time (see `djot/html.zig`'s module doc
/// comment), and the generic `Html.serialize` with `ctx = null` is handed no
/// tables. Using the generic printer here would silently drop footnotes and
/// reference-style links.
fn renderHtmlDjot(allocator: Allocator, doc: *const ParsedDoc, writer: *Writer) anyerror!void {
try Djot.html.render(allocator, &doc.doc, writer, .{});
}
/// Every other language (XML and HTML) has no labels to resolve, so the
/// shared, language-neutral printer (`languages/html/serializer.zig`) is the
/// whole story — `ctx = null`.
fn renderHtmlGeneric(allocator: Allocator, doc: *const ParsedDoc, writer: *Writer) anyerror!void {
try Html.serialize(allocator, doc.ast(), writer, null);
}
fn serializeCanonicalXml(allocator: Allocator, doc: *const ParsedDoc) anyerror![]u8 {
return Xml.serializeAlloc(allocator, &doc.doc);
}
fn serializeCanonicalDjot(allocator: Allocator, doc: *const ParsedDoc) anyerror![]u8 {
return djot_serializer.serializeAlloc(allocator, &doc.doc);
}
fn serializeCanonicalMarkdown(allocator: Allocator, doc: *const ParsedDoc) anyerror![]u8 {
return markdown_serializer.serializeAlloc(allocator, &doc.doc);
}
/// HTML's printer renders the full shared vocabulary from a bare AST, so it
/// serves as both the round-trip and the cross-format path (`ctx = null`: this
/// is the label-free printer; `renderHtmlDjot`/`renderHtmlMarkdown` are the
/// richer, label-resolving renders).
///
/// These two are NEW to the registry and not new to Twig: the C ABI's
/// `serializeDocument` has always served HTML on both paths, while this table —
/// its other copy — claimed HTML had no serializer at all and made
/// `twig convert -i html -o canonical` fail. Neither copy was consulted by the
/// other, so nothing caught the disagreement. One table, one answer.
fn serializeCanonicalHtml(allocator: Allocator, doc: *const ParsedDoc) anyerror![]u8 {
return Html.serializeAlloc(allocator, doc.ast(), null);
}
fn serializeFromAstHtml(allocator: Allocator, ast: *const AST) anyerror![]u8 {
return Html.serializeAlloc(allocator, ast, null);
}
fn serializeFromAstDjot(allocator: Allocator, ast: *const AST) anyerror![]u8 {
return djot_serializer.serializeAstAlloc(allocator, ast);
}
fn serializeFromAstMarkdown(allocator: Allocator, ast: *const AST) anyerror![]u8 {
return markdown_serializer.serializeAstAlloc(allocator, ast);
}
fn serializeCanonicalAsciidoc(allocator: Allocator, doc: *const ParsedDoc) anyerror![]u8 {
return asciidoc_serializer.serializeAlloc(allocator, &doc.doc);
}
fn serializeFromAstAsciidoc(allocator: Allocator, ast: *const AST) anyerror![]u8 {
return asciidoc_serializer.serializeAstAlloc(allocator, ast);
}
/// One entry per `Format`. This IS the extensibility point Twig is built
/// around: consumers are written entirely against this table, never against a
/// per-language switch of their own.
pub const Entry = struct {
id: Format,
/// The language this row is a DIALECT of, or `null` for a language's own
/// row. A dialect is a name for one configuration of another row's parser
/// — strict CommonMark, GFM — carried as a row so that `-i`, `ParsedDoc`
/// and the C ABI can say it in one word. It shares its language's `Target`
/// (`targetFor`), declares no `extensions` of its own (a `.md` file is the
/// default flavor until a caller says otherwise), and is listed under its
/// language by `printSupportedInputFormats`.
dialect_of: ?Format = null,
/// Small source documents checked by the shared language harness.
/// An empty declaration fails the harness; corpora belong to conformance tests.
samples: []const []const u8 = &.{},
/// Lowercase, dot-less extensions that infer this format (checked
/// case-insensitively against a path's last `.`-separated segment).
extensions: []const []const u8,
/// Extra input names accepted besides `@tagName(id)` itself (which
/// `parseFormatName` always accepts via `std.meta.stringToEnum`).
aliases: []const []const u8 = &.{},
parse: *const fn (*const anyopaque, Allocator, []const u8) anyerror!ParsedDoc,
/// Source -> the shared `Document`, the reparse callback the span-splice
/// engine (`Splicer`) needs: `parse` without the `ParsedDoc` wrapper,
/// because the splicer already knows its format and config. Every format
/// has one. Its shape matches `Splicer.ParseFn` (leading opaque
/// `ParseConfig` context) so it can be handed straight to `Splicer.init`.
parseToAst: Splicer.ParseFn,
renderHtml: *const fn (Allocator, *const ParsedDoc, *Writer) anyerror!void,
/// Round-trip serializer back to this format's own source syntax —
/// `convert -o canonical`'s implementation. `null` means the language has no
/// serializer yet; callers turn that into a clear "not supported yet" error
/// rather than a crash.
///
/// The one serializer that stays on the INPUT row. It takes the parsed
/// `Document` whole — the `labels` its own parser filled (which say which
/// of two same-labelled definitions wins), the `node_spelling` that puts
/// an author's `*` bullets back, and for XML the interior spans that mark
/// a self-closing tag — where `TargetEntry.serializeFromAst` takes a bare
/// `AST` and rebuilds what it can (`Document.Labels.index`, canonical
/// spelling). The two are the same printer under two amounts of
/// knowledge; a caller with a `ParsedDoc` in hand should use this one.
serializeCanonical: ?*const fn (Allocator, *const ParsedDoc) anyerror![]u8 = null,
/// This format's surface spelling — the table the authoring gestures in
/// `ast/editor.zig` consult. Defaults to `Syntax.none`, the table that
/// spells nothing: a language that can be parsed and rendered but not
/// AUTHORED into (XML, HTML) simply omits this field, and every gesture over
/// it reports unsupported by finding the same `null` in the same table any
/// other unspellable kind would. See `syntax.zig`.
syntax: *const Syntax = &syntax_mod.none,
/// This format's surface spelling for a PARSE CONFIG, where the two are not
/// the same question. `null` — every row but Markdown's — means the
/// spelling does not move with the extensions, so `syntax` is the whole
/// answer.
///
/// Markdown's does move, and in BOTH directions from its defaults: `==x==`
/// is literal text until `ParseOptions.highlight` is turned on, `~~x~~` is
/// a `delete` until `strikethrough` is turned off. Whether an editor may
/// WRITE either depends on what the editor's own reparse will read back,
/// and the alternative — one table stating a default as though it were a
/// property of the format — mints bytes that come back as a `str`, giving
/// a Cmd-B that cannot be undone by pressing it again. See
/// `languages/markdown/syntax.zig`'s `forOptions`, which owns the choice;
/// this field only says the row has one to make.
///
/// `syntax` stays the DEFAULT-config table and is what a serializer reads,
/// so a row carrying both must agree with itself under a default config —
/// pinned by a test below rather than by convention. That agreement is by
/// ADDRESS: Markdown's row points into the same table set `forOptions`
/// indexes, so the two cannot become two answers.
syntaxFor: ?*const fn (*const ParseConfig) *const Syntax = null,
};
pub const registry = [_]Entry{
.{
.id = .djot,
.samples = Djot.samples,
.extensions = &.{ "dj", "djot" },
.aliases = &.{"dj"},
.parse = parseDjot,
.parseToAst = parseToAstDjot,
.renderHtml = renderHtmlDjot,
.serializeCanonical = serializeCanonicalDjot,
.syntax = &@import("languages/djot/syntax.zig").table,
},
.{
.id = .markdown,
.samples = Markdown.samples,
.extensions = &.{ "md", "markdown" },
.aliases = &.{"md"},
.parse = MarkdownDefault.parse,
.parseToAst = MarkdownDefault.parseToAst,
.renderHtml = MarkdownDefault.renderHtml,
.serializeCanonical = serializeCanonicalMarkdown,
.syntax = MarkdownDefault.syntax,
// The rows whose authorable subset moves with the parse config: this
// one and its two dialects below.
.syntaxFor = MarkdownDefault.syntaxFor,
},
.{
// Strict CommonMark — `markdown` with every extension off. Same
// parser, same serializer, same `Target`; a different preset, and so
// a different default `syntax`: `~~x~~` is two literal tildes here
// and no gesture may mint it. No `extensions`: nothing about a file
// name says it is strict.
.id = .commonmark,
.dialect_of = .markdown,
.samples = Markdown.commonmark_samples,
.extensions = &.{},
.parse = Commonmark.parse,
.parseToAst = Commonmark.parseToAst,
.renderHtml = Commonmark.renderHtml,
.serializeCanonical = serializeCanonicalMarkdown,
.syntax = Commonmark.syntax,
.syntaxFor = Commonmark.syntaxFor,
},
.{
// GitHub-Flavored Markdown — the spec's four extensions and GFM's
// HTML conventions, which is the one thing about this row a bare
// flag set could not say (`ParseOptions.dialect`'s doc comment).
.id = .gfm,
.dialect_of = .markdown,
.samples = Markdown.gfm_samples,
.extensions = &.{},
.parse = Gfm.parse,
.parseToAst = Gfm.parseToAst,
.renderHtml = Gfm.renderHtml,
.serializeCanonical = serializeCanonicalMarkdown,
.syntax = Gfm.syntax,
.syntaxFor = Gfm.syntaxFor,
},
.{
.id = .xml,
.samples = Xml.samples,
.extensions = &.{"xml"},
.parse = parseXml,
.parseToAst = parseToAstXml,
.renderHtml = renderHtmlGeneric,
.serializeCanonical = serializeCanonicalXml,
// Why converting INTO xml from another format isn't meaningful yet is
// now a fact about the xml TARGET row (`targets`, below), not this one.
//
// No `syntax`: XML has no lightweight inline markup to toggle and no
// line-prefix containers, so it is parse-and-render only.
},
.{
.id = .html,
.samples = Html.samples,
.extensions = &.{ "html", "htm" },
.parse = parseHtml,
.parseToAst = parseToAstHtml,
.renderHtml = renderHtmlGeneric,
.serializeCanonical = serializeCanonicalHtml,
// A PARTIAL `syntax`, and the row that proves `Syntax`'s raggedness is
// real rather than a two-formats-and-the-rest split. HTML has none of
// the lightweight markup this table was built for, but a tag pair IS a
// `Delims`: it spells seven of the nine inline marks, `<code>`, `<hr>`
// and `<br>`, and its parser reads every one of them back. A heading
// and a literal are spelled through its RENDERERS — a printed fragment
// and entities — since neither is a marker or a backslash alphabet.
// Its quote, list and fence spellings still have the wrong SHAPE for
// the fields that hold them, so those stay `null` and their gestures
// stay unsupported. See `languages/html/syntax.zig` for which and why.
.syntax = &@import("languages/html/syntax.zig").table,
},
.{
.id = .asciidoc,
.samples = Asciidoc.samples,
.extensions = &.{ "adoc", "asciidoc" },
.aliases = &.{"adoc"},
.parse = parseAsciidoc,
.parseToAst = parseToAstAsciidoc,
.renderHtml = renderHtmlGeneric,
.serializeCanonical = serializeCanonicalAsciidoc,
// AsciiDoc's parser covers the language as the ASG schema enumerates
// it (`languages/asciidoc/parser.zig`'s doc comment lists the
// constructs, and the handful it leaves unmodelled). What it does not
// recognize survives as LITERAL SOURCE TEXT, so an unhandled
// `menu:File[Save]` renders as those very characters — visibly
// unhandled — rather than as a mangled tree. That property is what
// let this row exist before the parser was complete, and it still
// holds at the parser's edges.
//
// A PARTIAL `syntax`, ragged the way HTML's is and for the same
// reason: the inline marks, the heading marker, the three container
// prefixes, the fence, the task box and the escape alphabets all fit
// the gesture algorithms' shapes; a link (`dest[text]`, the halves in
// the other order from `[text](dest)`), a footnote (one macro, no
// definition line) and a table (`|===`-fenced, no delimiter row) do
// not, and those gestures stay unsupported. See
// `languages/asciidoc/syntax.zig`.
.syntax = &Asciidoc.syntax.table,
},
};
/// One entry per `Target` — the WRITE half of the registry. Split from `Entry`
/// (the READ half) for the reason the two-axes note at the top of this file
/// gives: what a target needs is somewhere for bytes to go, which is strictly
/// less than what a language needs to be parsed.
pub const TargetEntry = struct {
id: Target,
/// The `Format` whose parser reads this target's own output back — set for
/// every target that is also an input language, `null` for an export-only
/// one. This single field is what distinguishes the two kinds of target, and
/// it is what `diagnostics.zig`'s round-trip probe reparses with.
reads_back_as: ?Format,
/// Serialize a BARE shared `AST` (regardless of which format parsed it) as
/// this target's own syntax — `convert -o <target>`'s cross-format
/// implementation (e.g. `-i markdown -o djot`), and the C ABI's builder
/// output path. Unlike `Entry.serializeCanonical` it needs no parse to
/// have happened: it is handed whatever `ParsedDoc.ast()` returns and
/// builds any label tables it needs from that bare tree.
///
/// `null` means Twig cannot write this target yet, and every caller turns it
/// into the same `error.UnsupportedFormat`. For an export-only target this
/// is the ONLY function in either table that would be non-null.
serializeFromAst: ?*const fn (Allocator, *const AST) anyerror![]u8 = null,
};
pub const targets = [_]TargetEntry{
.{ .id = .djot, .reads_back_as = .djot, .serializeFromAst = serializeFromAstDjot },
.{ .id = .markdown, .reads_back_as = .markdown, .serializeFromAst = serializeFromAstMarkdown },
.{
.id = .xml,
.reads_back_as = .xml,
// No `serializeFromAst`: XML's serializer only understands the
// generic-markup kinds (`element`/`comment`/`doctype`/...) its own
// parser produces (see `xml/serializer.zig`'s `else => unreachable`); it
// has no mapping for djot/Markdown's semantic kinds
// (`heading`/`emph`/`link`/...), so cross-format conversion INTO xml
// from another format isn't meaningful yet. Same-format `-o canonical` /
// `-o xml` still works, through the input row's `serializeCanonical`.
},
.{ .id = .html, .reads_back_as = .html, .serializeFromAst = serializeFromAstHtml },
.{ .id = .asciidoc, .reads_back_as = .asciidoc, .serializeFromAst = serializeFromAstAsciidoc },
};
/// Look up `fmt`'s entry. Every `Format` variant has exactly one `registry`
/// entry (enforced by the test below rather than the type system — same trust
/// boundary fig's own hand-maintained tables rely on), so this never
/// legitimately misses.
pub fn entryFor(fmt: Format) *const Entry {
for (®istry) |*e| {
if (e.id == fmt) return e;
}
unreachable;
}
/// Look up `t`'s write-half entry. Every `Target` variant has exactly one
/// `targets` entry (enforced by the test below, same as `entryFor`), so this
/// never legitimately misses.
pub fn targetEntryFor(t: Target) *const TargetEntry {
for (&targets) |*e| {
if (e.id == t) return e;
}
unreachable;
}
/// `fmt`'s surface spelling — `Syntax.none` for a parse-only language, never
/// `null`. Ask `.authorable()` if you need to know which.
pub fn syntaxFor(fmt: Format) *const Syntax {
return entryFor(fmt).syntax;
}
/// `fmt`'s surface spelling for a document parsed with `cfg` — what an EDITOR
/// must consult, because a gesture may only write bytes the reparse behind it
/// reads back the same way.
///
/// `syntaxFor` above is this under a default config, and stays the serializer's
/// question: converting into Markdown spells `==x==` for a `mark` whatever the
/// config says, while a toggle that minted the same bytes without
/// `ParseOptions.highlight` would produce text no reparse turns back into a
/// mark. The other direction — `~~x~~`, which the default flavor reads back
/// and strict CommonMark does not — is no longer this function's to answer:
/// that is a difference between two ROWS, and `syntaxFor(.commonmark)` says
/// so under any config. The two questions differ only for Markdown's rows
/// today, which is why every other row leaves `Entry.syntaxFor` null and gets
/// the same answer from both.
pub fn syntaxForConfig(fmt: Format, cfg: *const ParseConfig) *const Syntax {
const e = entryFor(fmt);
const pick = e.syntaxFor orelse return e.syntax;
return pick(cfg);
}
/// The entry for whichever language produced `doc` — `ParsedDoc.format`
/// records it, so the document knows its own row.
pub fn entryForDoc(doc: *const ParsedDoc) *const Entry {
return entryFor(doc.format);
}
/// Errors `renderHtmlAlloc` can produce beyond a language's own. Named because
/// the `unsafe_metadata` refusal is a real, reportable outcome and not an
/// internal failure — a `metadata` node whose body contains `</script` can't be
/// emitted into a raw-text `<script>` data island without breaking out of the
/// element.
pub const RenderError = error{ OutOfMemory, UnsafeMetadata };
/// Render `doc` to HTML as an owned buffer — the registry's writer-shaped
/// `renderHtml`, collected. `Writer.Allocating` only ever fails
/// (`error.WriteFailed`) when its own backing allocation does, so it collapses
/// to `error.OutOfMemory`.
pub fn renderHtmlAlloc(allocator: Allocator, doc: *const ParsedDoc) RenderError![]u8 {
var out: Writer.Allocating = .init(allocator);
defer out.deinit();
entryForDoc(doc).renderHtml(allocator, doc, &out.writer) catch |err| switch (err) {
error.WriteFailed, error.OutOfMemory => return error.OutOfMemory,
error.UnsafeMetadata => return error.UnsafeMetadata,
// The registry's adapters are `anyerror`-shaped only because they're
// function pointers; rendering a already-parsed tree has no other
// failure mode.
else => return error.OutOfMemory,
};
return out.toOwnedSlice();
}
/// Errors the serialize helpers report on top of a language's own.
pub const SerializeError = error{ OutOfMemory, UnsupportedFormat };
/// Serialize `doc` back to its OWN source syntax (`convert -o canonical`).
/// `error.UnsupportedFormat` when the language has no serializer yet.
pub fn serializeCanonicalAlloc(allocator: Allocator, doc: *const ParsedDoc) anyerror![]u8 {
const f = entryForDoc(doc).serializeCanonical orelse return error.UnsupportedFormat;
return f(allocator, doc);
}
/// Serialize a bare `AST` as `target`'s syntax, regardless of which language
/// parsed it (`convert -o <target>`, and the C ABI's builder output).
/// `error.UnsupportedFormat` when `target` has no AST serializer.
pub fn serializeFromAstAlloc(allocator: Allocator, ast: *const AST, target: Target) anyerror![]u8 {
const f = targetEntryFor(target).serializeFromAst orelse return error.UnsupportedFormat;
return f(allocator, ast);
}
/// Map an input name to a `Format`: the enum's own tag name first
/// (`std.meta.stringToEnum`, so `"djot"`/`"markdown"`/`"gfm"` always work), then
/// each entry's `aliases` (`"dj"`, `"md"`). Returns `null` for an unrecognized
/// name so the caller can print a tailored error.
pub fn parseFormatName(name: []const u8) ?Format {
if (std.meta.stringToEnum(Format, name)) |f| return f;
for (®istry) |*e| {
for (e.aliases) |alias| {
if (std.mem.eql(u8, alias, name)) return e.id;
}
}
return null;
}
/// Map an output name to a `Target`: the enum's own tag names first, then every
/// input format's aliases via `parseFormatName`, so `-o dj` / `-o md` keep
/// meaning what they always did. An export-only target has no input row to
/// inherit aliases from and so is spelled by its full name only — which is why
/// the aliases are not duplicated into a second list here.
pub fn parseTargetName(name: []const u8) ?Target {
if (std.meta.stringToEnum(Target, name)) |t| return t;
if (parseFormatName(name)) |f| return targetFor(f);
return null;
}
/// Infer a `Format` from a file path's extension (the part after its last `.`),
/// matched case-insensitively against every `registry` entry's `extensions`.
/// Returns `null` when the path has no extension or it matches no known format.
pub fn detectFromExtension(file_path: []const u8) ?Format {
const dot = std.mem.lastIndexOfScalar(u8, file_path, '.') orelse return null;
const ext = file_path[dot + 1 ..];
if (ext.len == 0) return null;
for (®istry) |*e| {
for (e.extensions) |known| {
if (std.ascii.eqlIgnoreCase(known, ext)) return e.id;
}
}
return null;
}
test "every Format has exactly one registry entry" {
inline for (std.meta.fields(Format)) |f| {
const fmt: Format = @enumFromInt(f.value);
var seen: usize = 0;
for (®istry) |*e| {
if (e.id == fmt) seen += 1;
}
try std.testing.expectEqual(@as(usize, 1), seen);
}
}
test "every Target has exactly one targets entry" {
inline for (std.meta.fields(Target)) |f| {
const t: Target = @enumFromInt(f.value);
var seen: usize = 0;
for (&targets) |*e| {
if (e.id == t) seen += 1;
}
try std.testing.expectEqual(@as(usize, 1), seen);
}
}
test "every Format is also a Target, and the two agree in both directions" {
// The subset invariant the `Target` doc comment promises, checked rather
// than generated. `targetFor` is total by construction (a missing name is a
// compile error); what needs asserting is that the target it lands on says
// the SAME format reads it back, so nothing can be wired to a row that
// spells a different language. A dialect lands on its LANGUAGE's target,
// and the language is what reads that target back — so the two arms
// `targetFor` spells by hand are pinned to `Entry.dialect_of` here.
inline for (std.meta.fields(Format)) |f| {
const fmt: Format = @enumFromInt(f.value);
const t = targetFor(fmt);
const lang = entryFor(fmt).dialect_of orelse fmt;
try std.testing.expectEqualStrings(@tagName(lang), @tagName(t));
try std.testing.expectEqual(lang, t.asFormat().?);
try std.testing.expect(writesOwnSyntax(fmt, t));
}
}
test "a dialect is a row over its language's parser, not a language" {
// What `dialect_of` promises: a language row is nobody's dialect, a
// dialect's language is a language row, and the dialect declares no
// extensions — a `.md` file is the default flavor until `-i` says
// otherwise. And the name resolves as a format in its own right.
for (®istry) |*e| {
const lang = e.dialect_of orelse continue;
try std.testing.expect(entryFor(lang).dialect_of == null);
try std.testing.expectEqual(@as(usize, 0), e.extensions.len);
try std.testing.expectEqual(targetFor(lang), targetFor(e.id));
try std.testing.expectEqual(e.id, parseFormatName(@tagName(e.id)).?);
}
try std.testing.expectEqual(Format.markdown, entryFor(.gfm).dialect_of.?);
try std.testing.expectEqual(Format.markdown, entryFor(.commonmark).dialect_of.?);
try std.testing.expect(entryFor(.markdown).dialect_of == null);
try std.testing.expectEqual(Target.markdown, parseTargetName("gfm").?);
try std.testing.expect(detectFromExtension("a.md") == .markdown);
}
test "the dialect rows parse what their names say, and record it" {
// Strict CommonMark reads `~~x~~` as text and a pipe table as a paragraph;
// GFM reads both. The same bytes, three rows, and each `ParsedDoc` says
// which it went through.
const src = "a ~~b~~ c\n\n| x |\n| - |\n| 1 |\n";
const cfg: ParseConfig = .{};
inline for (.{
.{ Format.commonmark, false },
.{ Format.markdown, true },
.{ Format.gfm, true },
}) |case| {
var doc = try entryFor(case[0]).parse(&cfg, std.testing.allocator, src);
defer doc.deinit();
try std.testing.expectEqual(case[0], doc.format);
var has_delete = false;
var has_table = false;
for (doc.ast().nodes) |n| switch (n.kind) {
.inline_mark => |m| has_delete = has_delete or m == .delete,
.table => has_table = true,
else => {},
};
try std.testing.expectEqual(case[1], has_delete);
try std.testing.expectEqual(case[1], has_table);
}
}
test "an extension lays over a dialect rather than replacing it" {
// `-i gfm --math` is GFM plus math: the table still parses, and so does
// the `$x$` that GFM proper does not read. And the editor's spelling table
// for that pairing is the one the reparse agrees with.
const src = "| a |\n| - |\n| $x$ |\n";
const math: ParseConfig = .{ .markdown = .{ .math = true } };
var doc = try entryFor(.gfm).parse(&math, std.testing.allocator, src);
defer doc.deinit();
var has_math = false;
var has_table = false;
for (doc.ast().nodes) |n| switch (n.kind) {
.text_leaf => |t| has_math = has_math or t.kind == .inline_math,
.table => has_table = true,
else => {},
};
try std.testing.expect(has_math and has_table);
const hi: ParseConfig = .{ .markdown = .{ .highlight = true } };
const strict_hi = syntaxForConfig(.commonmark, &hi);
strict_hi.assertCoherent();
try std.testing.expect(strict_hi.inline_delims.get(.mark).?.authorable);
try std.testing.expect(!strict_hi.inline_delims.get(.delete).?.authorable);
}
test "the write half is keyed on the target, not on what parsed it" {
// `-i markdown -o djot` reaches djot's serializer without markdown's row
// being consulted for it at all — the property that lets an export-only
// target exist with no input row of its own. The `null` here is the same
// "not supported yet" every caller reports, sourced from the TARGET table.
try std.testing.expect(targetEntryFor(.djot).serializeFromAst != null);
try std.testing.expect(targetEntryFor(.xml).serializeFromAst == null);
try std.testing.expectEqual(Target.djot, parseTargetName("dj").?);
try std.testing.expectEqual(Target.markdown, parseTargetName("markdown").?);
try std.testing.expect(parseTargetName("nope") == null);
}
test "a config-varying row agrees with its own default table" {
// `Entry.syntax` and `Entry.syntaxFor` are two spellings of one fact, so
// the pair has to meet under a default config or the serializer and the
// editor would be reading different tables for the same document.
const default_cfg: ParseConfig = .{};
for (®istry) |*e| {
const pick = e.syntaxFor orelse continue;
try std.testing.expectEqual(e.syntax, pick(&default_cfg));
}
// The answer moves in both directions from the defaults: the strict
// CommonMark ROW takes strikethrough AWAY, where the `highlight`
// extension below adds a mark to any row.
const strict_syntax = syntaxFor(.commonmark);
strict_syntax.assertCoherent();
try std.testing.expect(syntaxFor(.markdown).inline_delims.get(.delete).?.authorable);
try std.testing.expect(!strict_syntax.inline_delims.get(.delete).?.authorable);
try std.testing.expect(syntaxFor(.gfm).inline_delims.get(.delete).?.authorable);
// And the variant tables are tables like any other.
var hi: ParseConfig = .{};
hi.markdown.highlight = true;
const hi_syntax = syntaxForConfig(.markdown, &hi);
hi_syntax.assertCoherent();
try std.testing.expect(hi_syntax != syntaxFor(.markdown));
try std.testing.expect(hi_syntax.inline_delims.get(.mark).?.authorable);
try std.testing.expect(hi_syntax.mark_colors == null);
var colors = hi;
colors.markdown.highlight_colors = true;
const color_syntax = syntaxForConfig(.markdown, &colors);
color_syntax.assertCoherent();
try std.testing.expect(color_syntax.mark_colors != null);
// A row with no `syntaxFor` answers the same either way — the raggedness
// stated as a `null`, exactly like every other optional in these tables.
try std.testing.expectEqual(syntaxFor(.djot), syntaxForConfig(.djot, &colors));
try std.testing.expectEqual(syntaxFor(.xml), syntaxForConfig(.xml, &colors));
}
test "a ParsedDoc renders with the dialect it was parsed under" {
// The `format` field's reason to exist: `-i gfm` is said once, at parse,
// and the render reads it back from the document rather than being told
// again. Both rows parse this table to the same nodes; only the
// alignment spelling tells them apart.
const src = "| a |\n| :-: |\n| 1 |\n";
const gfm: ParseConfig = .{};
var g = try entryFor(.gfm).parse(&gfm, std.testing.allocator, src);
defer g.deinit();
try std.testing.expectEqual(Format.gfm, g.format);
const g_html = try renderHtmlAlloc(std.testing.allocator, &g);
defer std.testing.allocator.free(g_html);
try std.testing.expect(std.mem.indexOf(u8, g_html, "<th align=\"center\">") != null);
const plain: ParseConfig = .{};
var d = try entryFor(.markdown).parse(&plain, std.testing.allocator, src);
defer d.deinit();
const d_html = try renderHtmlAlloc(std.testing.allocator, &d);
defer std.testing.allocator.free(d_html);
try std.testing.expect(std.mem.indexOf(u8, d_html, "text-align: center") != null);
try std.testing.expectEqual(Format.markdown, d.format);
}
test "every syntax table in the registry is coherent" {
for (®istry) |*e| e.syntax.assertCoherent();
}
test "which formats can be authored into at all" {
try std.testing.expect(syntaxFor(.djot).authorable());
try std.testing.expect(syntaxFor(.markdown).authorable());
// HTML joins them PARTIALLY: it spells inline marks, `<code>`, `<hr>`,
// `<br>`, and — through its renderers rather than a marker or an alphabet
// — a heading and a literal, so `authorable()` is true while
// `toggleBlockContainer`, `toggleCodeBlock`, the task, link and footnote
// gestures all still report unsupported. The predicate is "can ANY gesture
// be spelled", not "can every one" — see `diagnostics.zig` for the per-kind
// fidelity question, which is a different one again.
try std.testing.expect(syntaxFor(.html).authorable());
try std.testing.expect(syntaxFor(.html).heading_marker == null);
try std.testing.expect(syntaxFor(.html).renderBlock != null);
try std.testing.expect(syntaxFor(.html).text_escapes == null);
try std.testing.expect(syntaxFor(.html).renderText != null);
// XML alone still carries the table that spells nothing, so every
// gesture over it is refused; AsciiDoc's table is ragged like HTML's.
try std.testing.expect(!syntaxFor(.xml).authorable());
try std.testing.expect(syntaxFor(.asciidoc).authorable());
try std.testing.expect(syntaxFor(.asciidoc).link_text_escapes == null);
}
test "AsciiDoc parses, renders and serializes in both directions" {
const entry = entryFor(.asciidoc);
try std.testing.expect(entry.serializeCanonical != null);
try std.testing.expect(targetEntryFor(.asciidoc).serializeFromAst != null);
try std.testing.expectEqual(Format.asciidoc, parseFormatName("adoc").?);
try std.testing.expectEqual(Format.asciidoc, detectFromExtension("guide.ADOC").?);
// Canonical: the input row's own serializer, over the parsed document.
var doc = try parseAsciidoc(&ParseConfig{}, std.testing.allocator, "= Title\n\nsome *bold* text\n");
defer doc.deinit();
const canonical = try serializeCanonicalAlloc(std.testing.allocator, &doc);
defer std.testing.allocator.free(canonical);
try std.testing.expectEqualStrings("= Title\n\nsome *bold* text\n", canonical);
// Cross-format: a Markdown tree written as AsciiDoc.
var md = try MarkdownDefault.parse(&ParseConfig{}, std.testing.allocator, "# Title\n\nsome **bold** text\n");
defer md.deinit();
const converted = try serializeFromAstAlloc(std.testing.allocator, md.ast(), .asciidoc);
defer std.testing.allocator.free(converted);
try std.testing.expectEqualStrings("= Title\n\nsome *bold* text\n", converted);
}
test "an unimplemented AsciiDoc construct renders as literal source, not as a mangled tree" {
// The property the registry entry rested on before the parser was
// complete, and still holds at its edges — see the `.asciidoc` row. The
// `menu:` macro is one of the few things `asciidoc/parser.zig` leaves
// unmodelled; what matters is that its source SURVIVES to the output
// instead of being half-consumed into some other node.
var doc = try parseAsciidoc(&ParseConfig{}, std.testing.allocator, "menu:File[Save]\n");
defer doc.deinit();
const html = try renderHtmlAlloc(std.testing.allocator, &doc);
defer std.testing.allocator.free(html);
try std.testing.expectEqualStrings("<p>menu:File[Save]</p>\n", html);
}
// ── cross-format round-trips ───────────────────────────────────────────────
//
// Markdown -> Djot -> HTML is where a serializer gap shows up as silent data
// loss rather than an error: a construct the Djot serializer can't spell is
// written as something that reparses into a DIFFERENT node, and only rendering
// the reparse catches it. Asserting on the Djot text alone would not — the
// output looks like a plausible document either way.
/// Markdown source -> Djot text -> reparsed as Djot -> HTML.
fn markdownThroughDjotToHtml(allocator: Allocator, source: []const u8) ![]u8 {
var md = try Markdown.parse(allocator, source, .{});
defer md.deinit();
const djot_src = try djot_serializer.serializeAstAlloc(allocator, &md.ast);
defer allocator.free(djot_src);
var dj = try Djot.parse(allocator, djot_src);
defer dj.deinit();
return Djot.html.renderAlloc(allocator, &dj, .{});
}
test "round-trip: a Markdown table's header row survives the Djot leg" {
const out = try markdownThroughDjotToHtml(std.testing.allocator, "| a | b |\n| --- | --- |\n| 1 | 2 |\n");
defer std.testing.allocator.free(out);
// Djot marks a header by the separator line under it, so dropping the
// separator on the way out turns every `<th>` back into a `<td>`.
try std.testing.expect(std.mem.indexOf(u8, out, "<th>a</th>") != null);
try std.testing.expect(std.mem.indexOf(u8, out, "<td>1</td>") != null);
}
test "round-trip: column alignment survives the Djot leg" {
const out = try markdownThroughDjotToHtml(std.testing.allocator, "| a | b |\n| :-- | --: |\n| 1 | 2 |\n");
defer std.testing.allocator.free(out);
try std.testing.expect(std.mem.indexOf(u8, out, "<th style=\"text-align: left;\">a</th>") != null);
try std.testing.expect(std.mem.indexOf(u8, out, "<td style=\"text-align: right;\">2</td>") != null);
}
test "round-trip: a table survives Djot -> HTML -> Djot" {
// The other direction: HTML is a real input format, so a table has to
// survive being read back out of the printer's own output — row groups,
// header, alignment, caption and all.
const source = "| a | b |\n|:--|--:|\n| 1 | 2 |\n^ Cap\n";
var dj = try Djot.parse(std.testing.allocator, source);
defer dj.deinit();
const rendered = try Djot.html.renderAlloc(std.testing.allocator, &dj, .{});
defer std.testing.allocator.free(rendered);
var page = try Html.parse(std.testing.allocator, rendered);
defer page.deinit();
const back = try djot_serializer.serializeAstAlloc(std.testing.allocator, &page.ast);
defer std.testing.allocator.free(back);
try std.testing.expectEqualStrings(source, back);
}
test "round-trip: raw inline HTML survives the Djot leg" {
const out = try markdownThroughDjotToHtml(std.testing.allocator, "x <sub>y</sub> z\n");
defer std.testing.allocator.free(out);
// Written as bare text, the tags reparse as ordinary characters and come
// back escaped (`<sub>`) with no error raised anywhere.
try std.testing.expect(std.mem.indexOf(u8, out, "<p>x <sub>y</sub> z</p>") != null);
}
test "round-trip: a raw HTML block survives the Djot leg" {
const out = try markdownThroughDjotToHtml(std.testing.allocator, "<div>\nhi\n</div>\n");
defer std.testing.allocator.free(out);
// Spelled as a language (` ```html `) rather than djot's raw form
// (` ```=html `), this reparses as a code block and renders as escaped
// text inside `<pre>`.
try std.testing.expect(std.mem.indexOf(u8, out, "<div>") != null);
try std.testing.expect(std.mem.indexOf(u8, out, "<pre>") == null);
}
test "format names and extensions resolve" {
try std.testing.expectEqual(Format.djot, parseFormatName("dj").?);
try std.testing.expectEqual(Format.markdown, parseFormatName("markdown").?);
try std.testing.expectEqual(Format.gfm, parseFormatName("gfm").?);
try std.testing.expectEqual(Format.commonmark, parseFormatName("commonmark").?);
try std.testing.expect(parseFormatName("nope") == null);
try std.testing.expectEqual(Format.markdown, detectFromExtension("a/b.MD").?);
try std.testing.expect(detectFromExtension("noext") == null);
}