//! HTML's surface spelling — the table `Editor`'s authoring gestures consult.
//! See `src/syntax.zig` for the model.
//!
//! ── Why HTML has a table at all ────────────────────────────────────────────
//! It used to carry `Syntax.none` on the reasoning that authoring gestures
//! spell lightweight markup and HTML has none. Half of that is right. HTML has
//! no `**`, no `> ` prefix, no fence — but it spells seven of the nine inline
//! marks with a plain element pair, and `html/parser.zig` reads every one of
//! them back (`semanticKind`'s `em`/`strong`/`mark`/`ins`/`del`/`sup`/`sub`
//! arms). A `Delims{open, close}` is exactly a tag pair. So Cmd-B over HTML
//! needs no new code — only the bytes, which is what this file is.
//!
//! ── Where a tag pair is not enough: the renderers ──────────────────────────
//! Two of HTML's spellings have no table shape at all, and they are why
//! `Syntax` grew its renderer family (see its module doc comment):
//!
//! * A literal is spelled with ENTITIES. Every alphabet field in `Syntax`
//! feeds a routine that writes a backslash before a byte, and filling
//! `text_escapes` with `&<>` would make `insertLiteral` write `\&` — two
//! literal characters here, not an escape. So the alphabets stay `null`
//! and `renderText` below writes `&`/`<`/`>` instead, in every
//! position alike: `<pre>` reads `<` the same way `<p>` does.
//! * A heading is a WRAPPING PAIR carrying the level in both ends, so there
//! is no `heading_marker` to rewrite. `renderBlock` is the serializer over
//! a fragment, and `Editor.setBlock` builds the heading node and prints
//! it through this — the tag pair falls out of the tree.
//!
//! ── What still stops, and why ──────────────────────────────────────────────
//! Everything left `null` below is null because HTML's spelling has a different
//! SHAPE from the gesture that would read it, not because nobody filled it in:
//!
//! * `ContainerSpelling` prefixes every LINE. `<blockquote>` wraps a range,
//! and a list needs a per-item `<li>` — a different algorithm, not a
//! different alphabet, which is the premise `syntax.zig` is built on.
//! * `CodeFence` measures the longest run of its fence byte. `<pre><code>`
//! doesn't measure anything; it entity-escapes a body instead.
//! * `link_text_escapes`/`link_dest_escapes` feed `[text](dest)`, and a
//! link's destination here lives in a quoted `href` attribute.
//!
//! Each of those is a gesture that could be taught to build a node and print
//! it the way `setBlock` now does; which of them should be is
//! `docs/proposals/editable-html-block-elements.md`'s question, and lifting
//! one is a change to `editor.zig`, not to this file.
const std = @import("std");
const syntax = @import("../../syntax.zig");
const Writer = std.Io.Writer;
const AST = @import("../../ast/ast.zig");
const serializer = @import("serializer.zig");
/// HTML's literal: the three bytes that open markup become entities, in every
/// position — there is no line-start alphabet because no HTML byte opens a
/// block at column zero, and no verbatim exemption because a `<pre>` body
/// decodes entities exactly as a `<p>` does. Mirrors `serializer.zig`'s
/// `writeEscaped` for text content, and stays byte-for-byte what its
/// `parser.zig` decodes back, so an inserted `str` reparses as itself.
fn renderText(_: *const syntax.Syntax, text: []const u8, _: syntax.TextPosition, out: *Writer) Writer.Error!void {
for (text) |c| {
switch (c) {
'&' => try out.writeAll("&"),
'<' => try out.writeAll("<"),
'>' => try out.writeAll(">"),
else => try out.writeByte(c),
}
}
}
/// The serializer over one node — HTML's printer takes a node id directly, so
/// no re-rooting adapter is needed. Label-free (`ctx = null`): a fragment the
/// editor builds resolves nothing by label.
fn renderBlock(allocator: std.mem.Allocator, ast: *const AST, root: AST.Node.Id, out: *Writer) anyerror!void {
try serializer.serializeNode(allocator, ast, root, out, null);
}
pub const table: syntax.Syntax = .{
// Seven of nine. Each is the tag `html/serializer.zig` emits AND the tag
// `html/parser.zig` maps back to this very mark, so a toggle reverses.
//
// The aliases are deliberately absent: `<b>`, `<i>` and `<s>` parse back as
// strong/emph/delete too, so a document can hold them, but a gesture has to
// pick ONE spelling to author and the semantic tag is the one the serializer
// already emits. Toggling an existing `<b>` OFF still works — `Splicer`
// strips via the parser's `content_span` rather than by matching these bytes
// (see `splicer.zig`'s `toggleInline`), so the alias needs no entry here.
// The one visible consequence: toggling `<b>x</b>` off and on again yields
// `<strong>x</strong>`. Normalizing, not byte-identical.
.inline_delims = .init(.{
.emph = .{ .open = "<em>", .close = "</em>" },
.strong = .{ .open = "<strong>", .close = "</strong>" },
.mark = .{ .open = "<mark>", .close = "</mark>" },
.superscript = .{ .open = "<sup>", .close = "</sup>" },
.subscript = .{ .open = "<sub>", .close = "</sub>" },
.insert = .{ .open = "<ins>", .close = "</ins>" },
.delete = .{ .open = "<del>", .close = "</del>" },
// The two the serializer renders as CHARACTERS — curly quotes, not a
// tag pair (see its `double_quoted`/`single_quoted` arms). The parser
// has no rule turning a curly quote back into a quoted container, so
// there is no spelling here to author OR to emit: `null`, not a `<q>`
// the round trip would lose. This is where HTML differs from djot and
// Markdown, which both spell all nine.
.double_quoted = null,
.single_quoted = null,
}),
// `<code>` is the one text leaf HTML spells as a pair the parser reads back
// — and only when the element holds exactly one text child, which is what a
// wrap over a plain selection produces (`html/parser.zig`'s `code` arm).
//
// The rest are `null` for the reason `verbatim` is not: HTML spells them,
// but not as a SYMMETRIC PAIR. A url or email is `<a href="…">text</a>`,
// where the payload sits in an attribute; a footnote is a `<sup><a>` pair
// plus a matching `<li>` elsewhere in the document. `Delims` cannot describe
// either, and inventing one that drops the destination would make a toggle
// lossy in a way `error.UnsupportedFormat` isn't.
.text_leaf_delims = .init(.{
.verbatim = .{ .open = "<code>", .close = "</code>" },
.symb = null,
.inline_math = null,
.display_math = null,
.url = null,
.email = null,
.footnote_reference = null,
.citation_reference = null,
.substitution_reference = null,
}),
// What the serializer emits (its `thematic_break` arm renders an `hr` tag).
// The void spelling, not the XHTML `<hr />` the `xhtml_void` option can
// produce: both parse back to `.thematic_break`, and a gesture that writes
// one form must be the form a round trip reproduces by default.
.thematic_break = "<hr>",
// `<br>` — `html/parser.zig` maps it to `.hard_break` and the serializer
// emits it, so the token round-trips the way `Syntax.cell_line_break`
// requires. Unlike Markdown, where `<br>` is borrowed raw HTML admitted only
// because a GFM row is one source line, here it is simply how HTML spells a
// break. The gesture is still cell-only (`Editor.insertLineBreak` checks for
// an enclosing `.cell`), so this understates what HTML can do — a general
// hard break is the same future work it is for every other format.
.cell_line_break = "<br>",
// ── Renderers ──────────────────────────────────────────────────────────
// The two spellings no table can hold — see this file's doc comment.
.renderText = renderText,
.renderBlock = renderBlock,
// ── Deliberately absent ────────────────────────────────────────────────
// `heading_marker`: a heading is spelled through `renderBlock` instead.
// `container_spelling`, `code_fence`, `task_marker`, `footnote`,
// `link_*_escapes`: the shape mismatches in this file's doc comment.
// `text_escapes`/`block_start_escapes`: `renderText` is not the alphabet
// renderer, so it carries no alphabet — `assertCoherent` pins that.
//
// `spellsAutolink`: HTML has no autolink form at all — a bare `<https://x>`
// is a tag with a nonsense name, never a link.
//
// `attr_spelling`: HTML is deliberately not a client of it. Its serializer's
// `renderAttributes` merges a synthesized `extra` list, dedups against it and
// escapes for a tag's interior — output machinery, not surface spelling. See
// `syntax.zig`'s `AttrSpelling` doc.
};
test "html authors the seven marks it can read back, and neither quote" {
const paired = [_]AST.InlineMark{ .emph, .strong, .mark, .superscript, .subscript, .insert, .delete };
for (std.enums.values(AST.InlineMark)) |m| {
const want = std.mem.indexOfScalar(AST.InlineMark, &paired, m) != null;
const d = table.inline_delims.get(m);
try std.testing.expectEqual(want, d != null);
if (d) |dd| try std.testing.expect(dd.authorable);
}
// Every opener is a start tag and every closer its matching end tag — the
// property that makes a wrap reparse as the mark it was meant to be.
for (std.enums.values(AST.InlineMark)) |m| {
const d = table.inline_delims.get(m) orelse continue;
try std.testing.expect(std.mem.startsWith(u8, d.open, "<"));
try std.testing.expect(std.mem.startsWith(u8, d.close, "</"));
try std.testing.expectEqualStrings(d.open[1..], d.close[2..]);
}
table.assertCoherent();
try std.testing.expect(table.authorable());
}
test "html spells `code` and no other text leaf" {
for (std.enums.values(AST.TextLeafKind)) |l| {
try std.testing.expectEqual(l == .verbatim, table.text_leaf_delims.get(l) != null);
}
try std.testing.expect(table.text_leaf_delims.get(.verbatim).?.authorable);
}
test "html spells no line-prefixed block structure and no backslash alphabet" {
// The shape mismatches, pinned so lifting one is a deliberate edit here
// rather than a silent drift in `syntax.zig`.
try std.testing.expect(table.heading_marker == null);
try std.testing.expect(table.container_spelling.get(.block_quote) == null);
try std.testing.expect(table.container_spelling.get(.bullet_list) == null);
try std.testing.expect(table.container_spelling.get(.ordered_list) == null);
try std.testing.expect(table.code_fence == null);
// Backslash escaping is the mechanism HTML does not have: the alphabets
// are null rather than half-filled with `&<>`, and the literal is spelled
// by a renderer of HTML's own instead of the shared alphabet one.
try std.testing.expect(table.text_escapes == null);
try std.testing.expect(table.block_start_escapes == null);
try std.testing.expect(table.renderText != null);
try std.testing.expect(table.renderText != &syntax.renderTextByAlphabet);
try std.testing.expect(table.link_text_escapes == null);
try std.testing.expect(table.link_dest_escapes == null);
// No footnotes, no task boxes, no autolink form, no attribute spelling.
try std.testing.expect(table.footnote == null);
try std.testing.expect(table.task_marker == null);
try std.testing.expect(table.spellsAutolink == null);
try std.testing.expect(table.attr_spelling == null);
table.assertCoherent();
}
test "html spells a literal with entities, in every position" {
for (std.enums.values(syntax.TextPosition)) |pos| {
var out: Writer.Allocating = .init(std.testing.allocator);
defer out.deinit();
try table.renderText.?(&table, "a <b> & \\c", pos, &out.writer);
// The backslash is content: it is not HTML's escape and is written as is.
try std.testing.expectEqualStrings("a <b> & \\c", out.written());
}
}
test "html prints a fragment as the tag pair its parser reads back" {
var b = AST.Builder.init(std.testing.allocator);
defer b.deinit();
const text = try b.addLeaf(.{ .str = "hi" });
const em = try b.addContainer(.{ .inline_mark = .emph }, &.{text});
const h = try b.addContainer(.{ .heading = .{ .level = 2 } }, &.{em});
const view = b.view(h);
var out: Writer.Allocating = .init(std.testing.allocator);
defer out.deinit();
try table.renderBlock.?(std.testing.allocator, &view, h, &out.writer);
try std.testing.expectEqualStrings("<h2><em>hi</em></h2>\n", out.written());
}
test "html spells the rule and the break as void tags" {
try std.testing.expectEqualStrings("<hr>", table.thematic_break.?);
try std.testing.expectEqualStrings("<br>", table.cell_line_break.?);
}