diff --git a/packages/markdown-codec/src/ast/ast.test.ts b/packages/markdown-codec/src/ast/ast.test.ts
new file mode 100644
index 0000000000..ce0fb5a772
--- /dev/null
+++ b/packages/markdown-codec/src/ast/ast.test.ts
@@ -0,0 +1,41 @@
+import { describe, expect, it } from "vitest";
+import { isMarkdownBlockNode, isMarkdownInlineNode } from "./ast";
+import type { MarkdownNode } from "./ast";
+
+const blockNode: MarkdownNode = { type: "paragraph", children: [] };
+const inlineNode: MarkdownNode = { type: "text", value: "hi" };
+
+describe("isMarkdownBlockNode / isMarkdownInlineNode", () => {
+ it("classifies a block node as a block and not inline", () => {
+ expect(isMarkdownBlockNode(blockNode)).toBe(true);
+ expect(isMarkdownInlineNode(blockNode)).toBe(false);
+ });
+
+ it("classifies an inline node as inline and not a block", () => {
+ expect(isMarkdownBlockNode(inlineNode)).toBe(false);
+ expect(isMarkdownInlineNode(inlineNode)).toBe(true);
+ });
+
+ it("recognises every real block node type named in the table, not just one representative", () => {
+ const types: MarkdownNode["type"][] = [
+ "document",
+ "paragraph",
+ "heading",
+ "blockquote",
+ "list",
+ "listItem",
+ "codeBlock",
+ "thematicBreak",
+ "htmlBlock",
+ "table",
+ "tableRow",
+ "tableCell",
+ "mathBlock",
+ "footnoteDefinition",
+ ];
+ for (const type of types) {
+ // isMarkdownBlockNode reads only `.type`, so a bare-type fixture is a faithful runtime input; the cast is unavoidable since a real MarkdownNode variant also carries fields (children, value, ...) this loop has no reason to construct per type.
+ expect(isMarkdownBlockNode({ type } as MarkdownNode)).toBe(true);
+ }
+ });
+});
diff --git a/packages/markdown-codec/src/block/block.test.ts b/packages/markdown-codec/src/block/block.test.ts
index 9540850144..025a142303 100644
--- a/packages/markdown-codec/src/block/block.test.ts
+++ b/packages/markdown-codec/src/block/block.test.ts
@@ -4,7 +4,10 @@
import { describe, expect, it } from "vitest";
import type { MarkdownBlockNode } from "../ast/ast";
-import { MarkdownDiagnosticCodes } from "../diagnostics/diagnostics";
+import {
+ MarkdownDiagnosticCodes,
+ MarkdownNestingLimitExceededError,
+} from "../diagnostics/diagnostics";
import { createDiagnosticCollector } from "../test-support/diagnostics";
import { parseMarkdown } from "./block";
@@ -12,6 +15,20 @@ function parse(source: string): MarkdownBlockNode[] {
return parseMarkdown(source).document.children;
}
+describe("line endings", () => {
+ it("reads a carriage return ending the last line as that line's own ending, not as a blank line after it", () => {
+ expect(parse("```\na\r")).toEqual([
+ {
+ type: "codeBlock",
+ fenced: true,
+ fenceChar: "`",
+ infoString: "",
+ literal: "a\n",
+ },
+ ]);
+ });
+});
+
describe("headings", () => {
it("records an ATX heading's own style and level", () => {
expect(parse("### foo")).toEqual([
@@ -43,6 +60,11 @@ describe("headings", () => {
]);
});
+ it("takes a setext level from the underline's own leading character, not from where the line ends", () => {
+ // The match covers the trailing spaces the spec allows after the underline, so what the underline ENDS with is not the same question as which character it is made of.
+ expect(parse("foo\n=== ")).toMatchObject([{ level: 1, style: "setext" }]);
+ });
+
it("promotes only the paragraph it directly follows, never a lazily continued one", () => {
// The `---` cannot reach the paragraph inside the block quote, so it is a thematic break in the document itself.
expect(parse("> foo\n---")).toEqual([
@@ -75,6 +97,19 @@ describe("code blocks", () => {
{ type: "codeBlock", fenced: false, literal: "foo\n" },
]);
});
+
+ it("closes on a closing fence that carries trailing spaces, which the spec allows after it", () => {
+ expect(parse("```\nx\n``` \nafter")).toEqual([
+ {
+ type: "codeBlock",
+ fenced: true,
+ fenceChar: "`",
+ infoString: "",
+ literal: "x\n",
+ },
+ { type: "paragraph", children: [{ type: "text", value: "after" }] },
+ ]);
+ });
});
describe("math blocks (ExaDev/markdown-codec#53)", () => {
@@ -100,6 +135,99 @@ describe("math blocks (ExaDev/markdown-codec#53)", () => {
{ type: "mathBlock", literal: "x^2 $$ y\n" },
]);
});
+
+ it("keeps trailing whitespace on the opening $$ line out of the content", () => {
+ expect(parse("$$ \nx^2\n$$")).toEqual([
+ { type: "mathBlock", literal: "x^2\n" },
+ ]);
+ });
+
+ it("ends at its closing $$ rather than carrying on over what follows", () => {
+ expect(parse("$$\nx^2\n$$\nafter")).toEqual([
+ { type: "mathBlock", literal: "x^2\n" },
+ { type: "paragraph", children: [{ type: "text", value: "after" }] },
+ ]);
+ });
+
+ it("reads an indented $$ line as indented code, since a block may not open there", () => {
+ expect(parse(" $$")).toEqual([
+ { type: "codeBlock", fenced: false, literal: "$$\n" },
+ ]);
+ });
+
+ it("opens at the level of the deepest block the line matched, closing whatever it did not", () => {
+ expect(parse("> a\n$$\nx\n$$")).toEqual([
+ {
+ type: "blockquote",
+ children: [
+ { type: "paragraph", children: [{ type: "text", value: "a" }] },
+ ],
+ },
+ { type: "mathBlock", literal: "x\n" },
+ ]);
+ });
+});
+
+describe("footnote definitions (ExaDev/markdown-codec#66)", () => {
+ it("ends a definition with no content at a blank line rather than taking what follows as its body", () => {
+ expect(parse("[^1]:\n\n b")).toEqual([
+ { type: "footnoteDefinition", label: "1", children: [] },
+ { type: "codeBlock", fenced: false, literal: "b\n" },
+ ]);
+ });
+
+ it("continues a definition that already has content across a blank line", () => {
+ expect(parse("[^1]: a\n\n b")).toEqual([
+ {
+ type: "footnoteDefinition",
+ label: "1",
+ children: [
+ { type: "paragraph", children: [{ type: "text", value: "a" }] },
+ { type: "paragraph", children: [{ type: "text", value: "b" }] },
+ ],
+ },
+ ]);
+ });
+
+ it("consumes a whitespace-only line whole, so a code block in the body sees a genuinely blank line", () => {
+ expect(parse("[^1]: a\n\n code\n \n more")).toEqual([
+ {
+ type: "footnoteDefinition",
+ label: "1",
+ children: [
+ { type: "paragraph", children: [{ type: "text", value: "a" }] },
+ { type: "codeBlock", fenced: false, literal: "code\n\nmore\n" },
+ ],
+ },
+ ]);
+ });
+
+ it("takes the body from just after the marker when the marker itself is indented", () => {
+ expect(parse(" [^1]: body")).toEqual([
+ {
+ type: "footnoteDefinition",
+ label: "1",
+ children: [
+ { type: "paragraph", children: [{ type: "text", value: "body" }] },
+ ],
+ },
+ ]);
+ });
+});
+
+describe("the nesting limit", () => {
+ it("counts how deep the open chain goes, not how many blocks the document has in total", () => {
+ expect(() =>
+ parseMarkdown("a\n\nb\n\nc\n\nd", { maxNesting: 3 }),
+ ).not.toThrow();
+ });
+
+ it("refuses the block that would sit at the limit and allows the one just below it", () => {
+ expect(() => parseMarkdown("> a", { maxNesting: 2 })).not.toThrow();
+ expect(() => parseMarkdown("> > a", { maxNesting: 2 })).toThrow(
+ MarkdownNestingLimitExceededError,
+ );
+ });
});
describe("lists", () => {
@@ -215,6 +343,24 @@ describe("lists", () => {
expect(parse("- - -")).toEqual([{ type: "thematicBreak" }]);
expect(parse("* * *")).toEqual([{ type: "thematicBreak" }]);
});
+
+ it("marks a list loose when the blank line between its items is one an indented code block continues", () => {
+ // The code block, not the item, is the deepest block the blank line matched, so nothing below the item records it: the separation is only visible on the chain of blocks the line sat inside.
+ expect(parse("- b\n\n- a")[0]).toMatchObject({ tight: false });
+ });
+
+ it("keeps a list tight when the only blank line is a block quote's own marker-only line", () => {
+ expect(parse("- > a\n >\n- b")[0]).toMatchObject({ tight: true });
+ });
+
+ it("keeps a list tight when the only blank line is content inside a fenced code block", () => {
+ expect(parse("- ```\n\n ```\n- b")[0]).toMatchObject({ tight: true });
+ });
+
+ it("keeps a list tight when an item is nothing but its own marker", () => {
+ // spec 0.31.2: "A list item can begin with at most one blank line". That first blank line is the item itself, not a separator between items.
+ expect(parse("- foo\n-\n- bar")[0]).toMatchObject({ tight: true });
+ });
});
describe("containers and lazy continuation", () => {
@@ -338,6 +484,77 @@ describe("HTML blocks", () => {
},
]);
});
+
+ it("opens a type-7 block when there is no open paragraph for it to interrupt", () => {
+ expect(parse('')).toEqual([
+ { type: "htmlBlock", literal: '' },
+ ]);
+ });
+
+ it("does not let a type-7 block interrupt a paragraph it could instead continue lazily", () => {
+ expect(parse('> foo\n')).toEqual([
+ {
+ type: "blockquote",
+ children: [
+ {
+ type: "paragraph",
+ children: [
+ { type: "text", value: "foo" },
+ { type: "softBreak" },
+ { type: "rawHtml", literal: '' },
+ ],
+ },
+ ],
+ },
+ ]);
+ });
+
+ it("opens a type-7 block after a line that left a container unmatched but no paragraph open", () => {
+ expect(parse('> # h\n')).toEqual([
+ {
+ type: "blockquote",
+ children: [
+ {
+ type: "heading",
+ level: 1,
+ style: "atx",
+ children: [{ type: "text", value: "h" }],
+ },
+ ],
+ },
+ { type: "htmlBlock", literal: '' },
+ ]);
+ });
+
+ it("tests an end condition only against an open HTML block, never against a fenced code block", () => {
+ // A code block's content is literal, so a line that would end an HTML block of type 1 is just one more line of it.
+ expect(parse("```\n\nstill code\n```")).toEqual([
+ {
+ type: "codeBlock",
+ fenced: true,
+ fenceChar: "`",
+ infoString: "",
+ literal: "\nstill code\n",
+ },
+ ]);
+ });
+
+ it("tests an end condition only against an open HTML block, never against a paragraph", () => {
+ expect(parse("foo\nbar baz\nqux")).toEqual([
+ {
+ type: "paragraph",
+ children: [
+ { type: "text", value: "foo" },
+ { type: "softBreak" },
+ { type: "text", value: "bar " },
+ { type: "rawHtml", literal: "" },
+ { type: "text", value: " baz" },
+ { type: "softBreak" },
+ { type: "text", value: "qux" },
+ ],
+ },
+ ]);
+ });
});
describe("GFM extension toggles", () => {
@@ -348,6 +565,40 @@ describe("GFM extension toggles", () => {
});
});
+describe("GFM tables", () => {
+ it("leaves the paragraph's earlier lines behind as a paragraph, still separated as they were written", () => {
+ const blocks = parse("a\nb\n| h |\n| - |");
+ expect(blocks[0]).toEqual({
+ type: "paragraph",
+ children: [
+ { type: "text", value: "a" },
+ { type: "softBreak" },
+ { type: "text", value: "b" },
+ ],
+ });
+ expect(blocks[1]).toMatchObject({ type: "table" });
+ });
+
+ it("is not itself promoted by a following underline, which only an open paragraph answers to", () => {
+ // A table accepts lines and still lets block starts be tried, so the promotion hook is offered it as a container and has to decline, or the accumulated rows would be read as a setext heading's own text.
+ const blocks = parse("| a |\n| - |\n| 1 |\n---");
+ expect(blocks).toHaveLength(2);
+ expect(blocks[0]).toMatchObject({ type: "table" });
+ expect(blocks[1]).toEqual({ type: "thematicBreak" });
+ });
+
+ it("builds one row per body line, with the delimiter row's own consumed line contributing none", () => {
+ const [table] = parse("| a |\n| - |\n| 1 |");
+ expect(table).toMatchObject({
+ type: "table",
+ children: [
+ { type: "tableRow", header: true },
+ { type: "tableRow", header: false },
+ ],
+ });
+ });
+});
+
describe("GFM task list items", () => {
it("reads [ ] and [x] as an unchecked/checked task list item, stripping the marker from the item's own text", () => {
expect(parse("- [ ] todo\n- [x] done")).toEqual([
@@ -387,6 +638,10 @@ describe("GFM task list items", () => {
expect(list).toMatchObject({ children: [{ type: "listItem" }] });
if (list?.type !== "list") throw new Error("expected a list node");
expect(list.children[0]?.checked).toBeUndefined();
+ const [item] = list.children;
+ if (item === undefined) throw new Error("expected a list item");
+ // Absent, not present and undefined: a structural equality check reads the two the same way, so the key itself has to be asked for.
+ expect(Object.hasOwn(item, "checked")).toBe(false);
});
it("reads a leading [ ]/[x] as ordinary text when task lists are disabled", () => {
@@ -444,6 +699,16 @@ describe("recover-tier diagnostics", () => {
).toBe(true);
});
+ it("reports each duplicate definition's own line, counted from how many newlines precede it within the paragraph", () => {
+ const collector = createDiagnosticCollector();
+ parseMarkdown("[a]: /1\n[a]: /2\n[a]: /3", { sink: collector.sink });
+ const duplicates = collector.diagnostics.filter(
+ (diagnostic) =>
+ diagnostic.code === MarkdownDiagnosticCodes.DUPLICATE_LINK_REFERENCE,
+ );
+ expect(duplicates.map((diagnostic) => diagnostic.line)).toEqual([2, 3]);
+ });
+
it("reports a math block never closed by a matching $$ before end-of-input", () => {
const collector = createDiagnosticCollector();
parseMarkdown("$$\nx^2", { sink: collector.sink });
@@ -451,4 +716,78 @@ describe("recover-tier diagnostics", () => {
true,
);
});
+
+ it("reports nothing for an indented code block left open at end-of-input, which has no closing condition to miss", () => {
+ const collector = createDiagnosticCollector();
+ parseMarkdown(" code", { sink: collector.sink });
+ expect(collector.codes()).toEqual([]);
+ });
+
+ it("names the opening line of the fenced code block that was never closed", () => {
+ const collector = createDiagnosticCollector();
+ parseMarkdown("text\n\n```js\ncode", { sink: collector.sink });
+ const [diagnostic] = collector.diagnostics;
+ expect(diagnostic?.code).toBe(MarkdownDiagnosticCodes.UNCLOSED_FENCE);
+ expect(diagnostic?.line).toBe(3);
+ expect(diagnostic?.message).toContain("line 3");
+ expect(diagnostic?.message).toContain("never closed");
+ });
+
+ it("names the type and the opening line of the HTML block that never met its end condition", () => {
+ const collector = createDiagnosticCollector();
+ parseMarkdown("text\n\n