//! Markdown's surface spelling — the table `Editor`'s authoring gestures
//! consult. See `src/syntax.zig` for the model.
//!
//! Markdown spells strictly LESS than djot: three inline marks against djot's
//! eight. That gap is the whole reason `Syntax.inline_delims` is a table of
//! optionals — `Editor.toggleInline(.mark)` has to be a clean
//! `error.UnsupportedFormat` here while it works one file over.
const std = @import("std");
const syntax = @import("../../syntax.zig");
const markdown = @import("markdown.zig");
/// Defers to the parser's own autolink scanner: Markdown wants an absolute URI
/// or a CommonMark email and silently reads anything else as RAW HTML, so a
/// re-derived rule here could turn `<foo>` into a tag.
fn spellsAutolink(angled: []const u8) bool {
return markdown.spellsAutolink(angled);
}
pub const table: syntax.Syntax = .{
// Only `strong`/`emph` are AUTHORABLE. The rest carry the spelling the
// serializer uses when converting a djot document down to Markdown —
// `==mark==` and friends are extension syntax that CommonMark won't parse
// back, so writing them is acceptable (better than dropping the node) while
// an editor gesture minting them is not. See `Delims.authorable`.
.inline_delims = .init(.{
.strong = .{ .open = "**", .close = "**" },
.emph = .{ .open = "*", .close = "*" },
.mark = .{ .open = "==", .close = "==", .authorable = false },
.superscript = .{ .open = "^", .close = "^", .authorable = false },
.subscript = .{ .open = "~", .close = "~", .authorable = false },
.insert = .{ .open = "{+", .close = "+}", .authorable = false },
// GFM strikethrough: parsed back, but only with the extension on, so
// still not something a toggle may assume.
.delete = .{ .open = "~~", .close = "~~", .authorable = false },
.double_quoted = .{ .open = "\"", .close = "\"", .authorable = false },
.single_quoted = .{ .open = "'", .close = "'", .authorable = false },
}),
.text_leaf_delims = .init(.{
.verbatim = .{ .open = "`", .close = "`" },
.inline_math = .{ .open = "$", .close = "$", .authorable = false },
.display_math = .{ .open = "$$", .close = "$$", .authorable = false },
.symb = .{ .open = ":", .close = ":", .authorable = false },
.url = .{ .open = "<", .close = ">", .authorable = false },
.email = .{ .open = "<", .close = ">", .authorable = false },
.footnote_reference = .{ .open = "[^", .close = "]", .authorable = false },
// No citation registry and no substitutions in Markdown either; see the
// matching entries in `djot/syntax.zig`.
.citation_reference = null,
.substitution_reference = null,
}),
.container_spelling = .init(.{
.block_quote = .{ .marker = "> ", .cont = "> ", .blank = ">" },
.bullet_list = .{ .marker = "- ", .cont = " ", .blank = "" },
.ordered_list = .{ .marker = "", .cont = "", .blank = "", .numbered = true },
}),
.heading_marker = '#',
// A generic directive's `{#id .class key=val}` shorthand. Unlike djot, a
// value is left bare when `attributes.zig`'s `isNameChar` grammar can read
// it back, and quoted (escaping `"`/`\`) otherwise.
.attr_spelling = .{
.open = "{",
.close = "}",
.quoting = .when_needed,
.quote_escapes = "\"\\",
.id_sigil = "#",
.class_sigil = ".",
},
// `<` and `&` where djot has `{`/`}` and smart punctuation: Markdown reads
// `<…>` as raw HTML and `&…;` as an entity.
.link_text_escapes = "\\[]*_^`~<>&",
.link_dest_escapes = .{
.plain = "\\()<&",
// Markdown's `<dest>` form carries a destination containing whitespace.
// Inside it the brackets are what must be escaped, not the parens — and
// `&` still is, because Markdown DECODES entity references in a
// destination in both forms (an `a&b` handed in would come back out
// as `a&b`, corrupting the URL rather than breaking the link).
.angle = .{ .escapes = "\\<>&" },
},
// Body-text literals. Narrower than `link_text_escapes` in reasoning but
// reaching the same specials: `\` (escape), the emphasis/strike/code runs,
// the link brackets, and — unlike link TEXT, which is already bounded by its
// `[…]` — the two that mint markup out in the open, `<` (autolink / raw HTML)
// and `&` (entity). Block openers that only bite at column zero are in
// `block_start_escapes`, not here.
.text_escapes = "\\*_`[]~<&",
// `#` heading, `>` quote, `-`/`+` bullets (and `-` a thematic break or setext
// underline), `=` a setext underline. `*`/`_` also open bullets/breaks but are
// already escaped everywhere by `text_escapes`, so they need no entry here.
.block_start_escapes = "#>-+=",
.spellsAutolink = spellsAutolink,
// GFM's only in-cell break: a table row is one source line, and raw HTML is
// valid inside a GFM cell, so `<br>` is the one spelling that fits. The
// inline parser promotes it back to a `hard_break` in cell context, so the
// token round-trips (`<br/>`/`<br />` normalize to this on the way out).
.cell_line_break = "<br>",
};
test "markdown SPELLS every mark but AUTHORS only three" {
// The distinction `Delims.authorable` exists for. The serializer needs a
// spelling for every mark so a djot document converts without losing
// nodes; the editor must refuse all but the CommonMark three, because the
// rest would not reparse as the mark they were meant to be.
const AST = @import("../../ast/ast.zig");
for (std.enums.values(AST.InlineMark)) |m| {
try std.testing.expect(table.inline_delims.get(m) != null);
}
const authorable_marks = [_]AST.InlineMark{ .strong, .emph };
for (std.enums.values(AST.InlineMark)) |m| {
const want = std.mem.indexOfScalar(AST.InlineMark, &authorable_marks, m) != null;
try std.testing.expectEqual(want, table.inline_delims.get(m).?.authorable);
}
// `verbatim` moved to the text-leaf table with the rest of its family, and
// is the one leaf an editor may toggle.
try std.testing.expect(table.text_leaf_delims.get(.verbatim).?.authorable);
try std.testing.expect(!table.text_leaf_delims.get(.url).?.authorable);
table.assertCoherent();
try std.testing.expect(table.authorable());
}
test "markdown spells the in-cell break as <br>" {
try std.testing.expectEqualStrings("<br>", table.cell_line_break.?);
}
test "markdown spells body-text and line-start literals" {
const te = table.text_escapes.?;
// The always-on inline specials.
for ("\\*_`[]~<&") |c| try std.testing.expect(std.mem.indexOfScalar(u8, te, c) != null);
const bse = table.block_start_escapes.?;
for ("#>-+=") |c| try std.testing.expect(std.mem.indexOfScalar(u8, bse, c) != null);
// The two sets are disjoint: a byte escaped everywhere needs no line-start
// entry, and `assertCoherent` pairs their nullness.
for (te) |c| try std.testing.expect(std.mem.indexOfScalar(u8, bse, c) == null);
table.assertCoherent();
}
test "markdown autolinks by scheme, so a bare word would be raw HTML" {
try std.testing.expect(spellsAutolink("<https://x.dev>"));
try std.testing.expect(spellsAutolink("<a@b.dev>"));
// `<foo>` is a TAG, not an autolink — the reason this asks the parser.
try std.testing.expect(!spellsAutolink("<foo>"));
try std.testing.expect(!spellsAutolink("<foo/bar>"));
}