walk.test.ts
walk, textContent, headings and position: the order walk visits in and what skipping
does, that it survives nesting too deep to recurse, what text a node reads as, which headings
the outline lists and with what ids, and how offsets become lines and columns across every line
ending.
import { describe, expect, it } from "vite-plus/test";
import { headings, html, parse, position, textContent, walk, type NodeId } from "./index";
describe("walk", () => {
it("enters before the children and exits after them", () => {
const doc = parse("# A _b_\n\nc\n");
const events: string[] = [];
walk(doc, {
enter: (n) => void events.push(`+${doc.type(n)}`),
exit: (n) => void events.push(`-${doc.type(n)}`),
});
expect(events.join(" ")).toBe(
"+document +heading +text -text +emphasis +text -text -emphasis -heading +paragraph +text -text -paragraph -document",
);
});
it("skips the children when enter returns false, and still exits", () => {
const doc = parse("> a\n\nb\n");
const events: string[] = [];
walk(doc, {
enter: (n) => (
events.push(`+${doc.type(n)}`),
doc.type(n) === "blockquote" ? false : undefined
),
exit: (n) => void events.push(`-${doc.type(n)}`),
});
expect(events.join(" ")).toBe(
"+document +blockquote -blockquote +paragraph +text -text -paragraph -document",
);
});
it("walks only the subtree it starts from", () => {
const doc = parse("a _b_\n\nc\n");
const paragraph = doc.firstChild(doc.root);
const seen: string[] = [];
walk(doc, { enter: (n) => void seen.push(doc.type(n)) }, paragraph);
expect(seen).toEqual(["paragraph", "text", "emphasis", "text"]);
});
it("handles nesting too deep to recurse", () => {
const doc = parse(`${">".repeat(20000)} x\n`);
let depth = 0;
let deepest = 0;
walk(doc, {
enter: () => void (deepest = Math.max(deepest, ++depth)),
exit: () => void depth--,
});
expect(deepest).toBeGreaterThan(20000);
expect(depth).toBe(0);
});
});
describe("textContent", () => {
const first = (source: string): [ReturnType<typeof parse>, NodeId] => {
const doc = parse(source);
return [doc, doc.firstChild(doc.root)];
};
it("reads text as the rendered page does", () => {
const [doc, heading] = first('# "Hi" \\*there\\* `a<b` $x^2$ \n');
expect(textContent(doc, heading)).toBe('"Hi" *there* a<b x^2 ');
});
it("keeps line breaks and expressions", () => {
const [doc, paragraph] = first("a\\\nb ${n}\n");
expect(textContent(doc, paragraph)).toBe("a\nb ${n}");
});
it("includes an element's content, a leaf's label among it", () => {
const details = parse("{@details}\n[More]{@summary /}\n\nx\n{/details}\n");
expect(textContent(details)).toBe("Morex");
});
it("matches the text of the HTML", () => {
const source = "# T\n\n- a **b**\n- [c](d)\n\n```js\nx\n```\n";
const text = html(source)
.replace(/<[^>]*>/g, "")
.replace(/\n/g, "");
expect(textContent(parse(source)).replace(/\n/g, "")).toBe(text);
});
});
describe("headings", () => {
it("lists every heading in source order, with the depth, id and text html() gives it", () => {
const source = "# One _a_\n\ntext\n\n## Two\n\n{#custom}\n### Three `b`\n";
const doc = parse(source);
const list = headings(doc);
expect(list.map(({ depth, id, text }) => ({ depth, id, text }))).toEqual([
{ depth: 1, id: "one-a", text: "One a" },
{ depth: 2, id: "two", text: "Two" },
{ depth: 3, id: "custom", text: "Three b" },
]);
for (const { id } of list) expect(html(doc)).toContain(`id="${id}"`);
});
it("finds a heading inside a blockquote, a list item or an element", () => {
const doc = parse("> # Quoted\n\n- ## Listed\n\n{@section}\n### Inside\n{/section}\n");
expect(headings(doc).map((h) => h.text)).toEqual(["Quoted", "Listed", "Inside"]);
});
it("gives each heading's node, so its range is in the source", () => {
const source = "intro\n\n## Here\n";
const doc = parse(source);
const [h] = headings(doc);
expect(source.slice(doc.start(h!.node), doc.end(h!.node))).toBe("## Here");
});
it("is empty for a document with no headings, and a `#` in code is not one", () => {
expect(headings(parse("a\n\n```\n# no\n```\n"))).toEqual([]);
});
});
describe("position", () => {
it("gives 1-based lines and 0-based columns", () => {
const at = position("ab\ncd\n");
expect([0, 1, 2, 3, 5, 6].map(at)).toEqual([
{ line: 1, column: 0 },
{ line: 1, column: 1 },
{ line: 1, column: 2 },
{ line: 2, column: 0 },
{ line: 2, column: 2 },
{ line: 3, column: 0 },
]);
});
it("ends a line at CRLF, LF or a lone CR", () => {
const at = position("a\r\nb\rc\nd");
expect([3, 5, 7].map(at)).toEqual([
{ line: 2, column: 0 },
{ line: 3, column: 0 },
{ line: 4, column: 0 },
]);
expect(at(2)).toEqual({ line: 1, column: 2 });
});
it("counts UTF-16 code units and clamps past the end", () => {
const at = position("😀x");
expect(at(2)).toEqual({ line: 1, column: 2 });
expect(at(99)).toEqual({ line: 1, column: 3 });
expect(at(-1)).toEqual({ line: 1, column: 0 });
});
it("maps a warning to where it is", () => {
const source = "ok\n\n*a*\n";
const [w] = parse(source).warnings;
expect(position(source)(w!.start)).toEqual({ line: 3, column: 0 });
});
});