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