block.test.ts
The block pass on what an example's HTML can't show: exact source ranges, node data (heading
ids, code bodies, element nesting, list tightness, table cells) and the order of warnings.
What the block constructs write is in test/examples/markz/. Every parsed document is also
held to the tree invariants.
import { describe, expect, it } from "vite-plus/test";
import { html, parse, type Document, type NodeId, type NodeType } from "./index";
import { expectTree } from "../test/harness/tree";
/** The tree as `type "source"` lines, indented by depth. */
function outline(doc: Document, node: NodeId = doc.root, depth = 0): string[] {
const text = doc.source.slice(doc.start(node), doc.end(node));
const lines = [`${" ".repeat(depth)}${doc.type(node)} ${JSON.stringify(text)}`];
for (const child of doc.children(node)) lines.push(...outline(doc, child, depth + 1));
return lines;
}
function parsed(source: string): Document {
const doc = parse(source);
expectTree(doc);
return doc;
}
const first = (doc: Document, type: NodeType): NodeId => {
for (let n = 0; n < doc.size; n++) if (doc.type(n) === type) return n;
throw new Error(`no ${type}`);
};
const messages = (source: string) => parsed(source).warnings.map((d) => d.message);
describe("ranges", () => {
it("cover markers, and a block never its line ending", () => {
expect(outline(parsed("# Title #\n\n> quote\n> more\n\n- a\n\n b\n"))).toEqual([
'document "# Title #\\n\\n> quote\\n> more\\n\\n- a\\n\\n b\\n"',
' heading "# Title #"',
' text "Title"',
' blockquote "> quote\\n> more"',
' paragraph "quote\\n> more"',
// A soft break's text covers the line ending, and never the next line's `> `.
' text "quote\\n"',
' text "more"',
' list "- a\\n\\n b"',
' listItem "- a\\n\\n b"',
' paragraph "a"',
' text "a"',
' paragraph "b"',
' text "b"',
]);
});
it("handle CRLF and a lone CR", () => {
const doc = parsed("# A\r\n\r\npara\rline\r\n");
expect(outline(doc).slice(1)).toEqual([
' heading "# A"',
' text "A"',
' paragraph "para\\rline"',
// One text node: the lines touch in the source, and the lone CR reads as `\n`.
' text "para\\rline"',
]);
expect(doc.data(first(doc, "text") + 2, "text").value).toBe("para\nline");
});
it("give a code block its body range and a stripped value", () => {
const source = "> ```js meta\n> a\n>\tb\n> ```\n";
const doc = parsed(source);
const code = first(doc, "code");
const data = doc.data(code, "code");
expect(data).toMatchObject({ lang: "js", meta: "meta", value: "a\n b\n" });
expect(source.slice(data.body.start, data.body.end)).toBe("a\n>\tb");
expect(source.slice(doc.start(code), doc.end(code))).toBe("```js meta\n> a\n>\tb\n> ```");
});
// A code block whose lines are the source as it is takes one slice of it, so the value must
// still read every line ending as `\n` and keep nothing a prefix or indent took.
it.each([
["untouched lines", "```\na\n b\n```\n", "a\n b\n"],
["a lone CR", "```\na\rb\n```\n", "a\nb\n"],
["CRLF", "```\r\na\r\nb\r\n```\r\n", "a\nb\n"],
["an indented fence", " ```\n a\n b\n ```\n", "a\n b\n"],
["no closing line or final newline", "```\na\nb", "a\nb\n"],
])("give a code block with %s the value its lines read as", (_, source, value) => {
const doc = parsed(source);
expect(doc.data(first(doc, "code"), "code").value).toBe(value);
});
});
describe("attributes", () => {
it("decorate the next block, across blank lines", () => {
const doc = parsed("{#pricing .center}\n\n## Pricing\n");
const heading = first(doc, "heading");
expect(doc.data(heading, "heading")).toEqual({ depth: 2, id: "pricing", idExplicit: true });
expect(html(doc)).toBe('<h2 id="pricing" class="center">Pricing</h2>\n');
});
});
describe("elements", () => {
it("nest by name, the closing line taking the innermost open one", () => {
expect(outline(parsed("{@div}\n{@div .a}\nx\n{/div}\ny\n{/div}\n")).slice(1)).toEqual([
' element "{@div}\\n{@div .a}\\nx\\n{/div}\\ny\\n{/div}"',
' element "{@div .a}\\nx\\n{/div}"',
' paragraph "x"',
' text "x"',
' paragraph "y"',
' text "y"',
]);
});
it("close a list at their own level, before its items take the line", () => {
expect(outline(parsed("{@aside}\n- a\n {/aside}\nb\n")).slice(1)).toEqual([
' element "{@aside}\\n- a\\n {/aside}"',
' list "- a"',
' listItem "- a"',
' paragraph "a"',
' text "a"',
' paragraph "b"',
' text "b"',
]);
});
it("make a leaf of a labelled line, whose label is its children", () => {
const doc = parsed("{@dl}\n[Term _x_]{@dt /}\n[Def]{@dd .d /}\n{/dl}\n");
expect(doc.data(first(doc, "element"), "element")).toEqual({ kind: "container", name: "dl" });
expect(html(doc)).toBe('<dl><dt>Term <em>x</em></dt>\n<dd class="d">Def</dd>\n</dl>\n');
});
it("report a closing line with nothing to close, and an element left open", () => {
const doc = parsed("> {@aside}\n> x\n{/aside}\n");
expect(doc.warnings.map((w) => [w.code, doc.source.slice(w.start, w.end)])).toEqual([
["unclosed-element", "{@aside}"],
["element-close", "{/aside}"],
]);
});
it("point a closing line after a `{#name}` line to `{@name}`", () => {
const doc = parsed("{#aside}\nx\n{/aside}\n");
expect(doc.warnings.map((w) => [w.code, w.message])).toEqual([
["element-close", "`{#aside}` sets an id: open the element with `{@aside}`"],
]);
});
it("merge an attribute line above into their own attributes", () => {
expect(html(parsed("{#top}\n{@section .intro}\nx\n{/section}\n"))).toBe(
'<section class="intro" id="top"><p>x</p>\n</section>\n',
);
});
});
describe("fences", () => {
it("write ```=html verbatim and skip other formats", () => {
expect(html("```=html\n<b>hi</b>\n```\n\n```=latex\n\\x\n```\n")).toBe("<b>hi</b>\n");
const doc = parsed("```=latex\n\\x\n```\n");
expect(doc.data(first(doc, "raw"), "raw")).toMatchObject({ format: "latex", value: "\\x\n" });
});
});
describe("lists", () => {
it("mark task items and keep the marker out of the paragraph", () => {
const doc = parsed("- [x] done\n- [ ] todo\n- [ ]\n");
const items = [...doc.children(first(doc, "list"))];
expect(items.map((i) => doc.data(i, "listItem").checked)).toEqual([true, false, null]);
expect(
doc.source.slice(doc.start(first(doc, "paragraph")), doc.end(first(doc, "paragraph"))),
).toBe("done");
});
it("are loose with a blank line between items, and not after the last", () => {
const tight = (source: string) => parsed(source).data(1, "list").tight;
expect(tight("- a\n- b\n\n")).toBe(true);
expect(tight("- a\n\n- b\n")).toBe(false);
expect(tight("- a\n\n b\n")).toBe(false);
expect(tight("- a\n > b\n >\n- c\n")).toBe(true);
});
});
describe("tables", () => {
it("split on unescaped pipes only", () => {
const doc = parsed("| a \\| b | c |\n| - | - |\n");
const cells = [...doc.children(first(doc, "tableRow"))];
expect(cells.map((c) => doc.source.slice(doc.start(c), doc.end(c)))).toEqual(["a \\| b", "c"]);
});
});
describe("warnings", () => {
it("come in source order", () => {
// The paragraph's `*a*` is found when it closes, after the lazy line that closes it.
expect(messages("> *a* b\nlazy\n\n~~~")).toEqual([
"`*emphasis*`",
"lazy continuation line",
"`~~~` fence",
]);
});
});
describe("element names", () => {
it("suggests the writer's own name instead", () => {
const instead = (source: string) => parsed(source).warnings.map((w) => w.instead);
expect(instead("{@note}\n")).toEqual([
"a `div` with a class (`{@div .note}`), or a custom element (`{@my-note}`)",
]);
expect(instead("[x]{@note}")).toEqual([
"a span with a class (`[text]{.note}`), or a custom element (`[text]{@my-note}`)",
]);
// A name lowercasing would fix is suggested as it should be written.
expect(instead("{@Call-Out /}\n")[0]).toContain("`{@call-out}`");
});
});