From 64415f80a285d11af3dde09530c4bc91a15151cf Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sat, 12 Sep 2026 23:03:41 +0100 Subject: [PATCH 01/99] test(markdown-codec): kill list-id numId mutants and drop an unreachable undefined branch mintListNumId now has tests pinning that a bullet mint ignores a supplied start value and that an ordered mint with no start omits the @N suffix entirely, rather than stringifying undefined into it. parseListNumId gains a test for a numId with a numeric suffix on a bullet marker (a shape the regex itself allows, since the suffix isn't gated on type), which the parser must still treat as start: undefined. parseListNumId's own type-narrowing guard dropped its `type === undefined` half: NUMID_PATTERN's second capturing group is a mandatory alternation with no `?`, so a successful match always populates it, and the `type !== "bullet" && type !== "ordered"` half already answers `true` for `undefined` on its own -- the dropped half never distinguished any real input from the other. --- .../markdown-codec/src/shared/list-id.test.ts | 28 +++++++++++++++++++ packages/markdown-codec/src/shared/list-id.ts | 3 +- 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/packages/markdown-codec/src/shared/list-id.test.ts b/packages/markdown-codec/src/shared/list-id.test.ts index d7fc027844..ea66a1dd01 100644 --- a/packages/markdown-codec/src/shared/list-id.test.ts +++ b/packages/markdown-codec/src/shared/list-id.test.ts @@ -91,4 +91,32 @@ describe("mintListNumId / parseListNumId", () => { it("mintedListType reads back just the type without the rest", () => { expect(mintedListType("md1:ordered@7+task")).toBe("ordered"); }); + + it("ignores a start value on a bullet mint -- the suffix is ordered-only", () => { + const state = createNumIdMintState(); + expect( + mintListNumId(state, { + type: "bullet", + start: 5, + task: false, + loose: false, + }), + ).toBe("md1:bullet"); + }); + + it("omits the start suffix on an ordered mint with no start at all", () => { + const state = createNumIdMintState(); + expect( + mintListNumId(state, { type: "ordered", task: false, loose: false }), + ).toBe("md1:ordered"); + }); + + it("ignores a numeric @N suffix on a bullet numId when parsing -- start is ordered-only", () => { + expect(parseListNumId("md1:bullet@3")).toEqual({ + type: "bullet", + start: undefined, + task: false, + loose: false, + }); + }); }); diff --git a/packages/markdown-codec/src/shared/list-id.ts b/packages/markdown-codec/src/shared/list-id.ts index 0dab7b45e5..8faedeacad 100644 --- a/packages/markdown-codec/src/shared/list-id.ts +++ b/packages/markdown-codec/src/shared/list-id.ts @@ -63,7 +63,8 @@ export function parseListNumId(numId: string): ListNumIdInfo | undefined { return undefined; } const type = match[2]; - if (type === undefined || (type !== "bullet" && type !== "ordered")) { + // NUMID_PATTERN's own second capturing group is a mandatory (bullet|ordered) alternation with no `?` -- a successful overall match always populates it, so this comparison also catches the `undefined` case a plain regex capture-group index type otherwise admits (noUncheckedIndexedAccess), with no second, separately-testable branch for a case the pattern already rules out. + if (type !== "bullet" && type !== "ordered") { return undefined; } const startText = match[3]; From 8e972feecf78546185cbf33039ec57177ba5d0ff Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sat, 12 Sep 2026 23:04:00 +0100 Subject: [PATCH 02/99] test(markdown-codec): cover parseHeadingStyleId's integer and positivity guards Adds direct tests for headingStyleId/parseHeadingStyleId: level 0 rejected (a heading style level is always positive), a 400-digit run rejected (it parses to Infinity, which Number.isInteger correctly refuses), and a level past the markdown-reachable 1-6 ceiling still parsed, since ContentDocument is a shared cross-format pivot other producers may carry a deeper heading level through. --- .../src/shared/style-constants.test.ts | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 packages/markdown-codec/src/shared/style-constants.test.ts diff --git a/packages/markdown-codec/src/shared/style-constants.test.ts b/packages/markdown-codec/src/shared/style-constants.test.ts new file mode 100644 index 0000000000..f2c5e76476 --- /dev/null +++ b/packages/markdown-codec/src/shared/style-constants.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; +import { headingStyleId, parseHeadingStyleId } from "./style-constants"; + +describe("headingStyleId / parseHeadingStyleId", () => { + it("mints and parses a heading styleId for an ordinary level", () => { + expect(headingStyleId(3)).toBe("Heading3"); + expect(parseHeadingStyleId("Heading3")).toBe(3); + }); + + it("parses a level past the markdown-reachable 1-6 ceiling, since ContentDocument is a shared cross-format pivot", () => { + expect(parseHeadingStyleId("Heading7")).toBe(7); + }); + + it("rejects a shape this exact pattern does not match", () => { + expect(parseHeadingStyleId("Heading")).toBeUndefined(); + expect(parseHeadingStyleId("heading1")).toBeUndefined(); + expect(parseHeadingStyleId("Quote")).toBeUndefined(); + }); + + it("rejects level 0 -- a heading style level is always a positive integer", () => { + expect(parseHeadingStyleId("Heading0")).toBeUndefined(); + }); + + it("rejects a digit run so long it parses to a non-integer (Infinity), rather than reporting a bogus level", () => { + expect(parseHeadingStyleId(`Heading${"9".repeat(400)}`)).toBeUndefined(); + }); +}); From 1aa56ae43a93e5bf84f5df2363b9a5f5bc25636e Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sat, 12 Sep 2026 23:04:17 +0100 Subject: [PATCH 03/99] test(markdown-codec): pin lowerTable's column-width division and absent keys lowerTable's own column-width arithmetic (contentWidthPt / columnCount) had no test distinguishing it from any other arithmetic on the same two numbers, since the existing test only checked that both columns came out equal to each other. Adds a test with an explicit page size and margins so the expected per-column width is a known, exact number. Also pins that a table cell with no run-level constructs carries no `constructs` key at all, and a column the delimiter row leaves unaligned carries no `alignment` key -- both spread conditionally, and neither had a test checking the key's absence rather than just its rendered content. --- .../markdown-codec/src/lower/lower.test.ts | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/packages/markdown-codec/src/lower/lower.test.ts b/packages/markdown-codec/src/lower/lower.test.ts index f3989dcb6c..66fdddddb8 100644 --- a/packages/markdown-codec/src/lower/lower.test.ts +++ b/packages/markdown-codec/src/lower/lower.test.ts @@ -359,6 +359,27 @@ describe("GFM tables", () => { runs: [{ text: "1" }], }); }); + + it("divides the section's own content width evenly across columns, not some other arithmetic on the same two numbers", () => { + const [table] = blocks("| a | b |\n| - | - |\n| 1 | 2 |", { + pageSize: { widthPt: 220, heightPt: 800 }, + margins: { topPt: 72, rightPt: 10, bottomPt: 72, leftPt: 10 }, + }); + if (table?.kind !== "table") throw new Error("expected a table block"); + expect(table.columnWidthsPt).toEqual([100, 100]); + }); + + it("carries no constructs key on a cell with no run-level constructs of its own", () => { + const [table] = blocks("| a |\n| - |\n| 1 |"); + if (table?.kind !== "table") throw new Error("expected a table block"); + expect(table.rows[0]?.cells[0]?.blocks[0]).not.toHaveProperty("constructs"); + }); + + it("carries no alignment key on a column the delimiter row leaves unaligned", () => { + const [table] = blocks("| a |\n| - |\n| 1 |"); + if (table?.kind !== "table") throw new Error("expected a table block"); + expect(table.rows[0]?.cells[0]?.blocks[0]).not.toHaveProperty("alignment"); + }); }); describe("images", () => { From 32e386878c6b0c60ceabcc07746403f41817b4fb Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sat, 12 Sep 2026 23:04:29 +0100 Subject: [PATCH 04/99] test(markdown-codec): add direct coverage for matchMathInlineSpan's guard clauses No test called matchMathInlineSpan directly before this -- it was only exercised indirectly through the inline parser's own already-real \(...\) input, which never distinguishes the guard's two sub-conditions from each other or from a forced true/false, since a genuine match never needs to fall through to a wrong answer. Pins: a real span; an unterminated \( with no test each individually; and that the closing search starts strictly after the opener, never before it (a preceding, unrelated \) must not be mistaken for the real close). --- .../markdown-codec/src/inline/math.test.ts | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 packages/markdown-codec/src/inline/math.test.ts diff --git a/packages/markdown-codec/src/inline/math.test.ts b/packages/markdown-codec/src/inline/math.test.ts new file mode 100644 index 0000000000..b7228cdf50 --- /dev/null +++ b/packages/markdown-codec/src/inline/math.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; +import { matchMathInlineSpan } from "./math"; + +describe("matchMathInlineSpan", () => { + it("matches a real \\(...\\) span, delimiters included", () => { + expect(matchMathInlineSpan("\\(x^2\\)", 0)).toBe("\\(x^2\\)"); + }); + + it("returns undefined for a bare '\\(' with no closing '\\)' anywhere", () => { + expect(matchMathInlineSpan("\\(unterminated", 0)).toBeUndefined(); + }); + + it("returns undefined when the char at index is not a backslash, even with a literal '(' immediately after and a '\\)' reachable later", () => { + // A=charAt(index)!=='\\' is true, B=charAt(index+1)!=='(' is false -- neither guard clause alone should let the scan fall through to a bogus match against the trailing '\)'. + expect(matchMathInlineSpan("x(later\\)", 0)).toBeUndefined(); + }); + + it("returns undefined for a backslash not followed by '(', even with a '\\)' reachable later", () => { + // A=charAt(index)!=='\\' is false, B=charAt(index+1)!=='(' is true. + expect(matchMathInlineSpan("\\xlater\\)", 0)).toBeUndefined(); + }); + + it("searches for the closing '\\)' starting strictly after the opening '\\(', never before it", () => { + // A bogus "\)" sits just before the real "\(x\)" span; searching backwards from the opener (an off-by-arithmetic-sign bug) would match that bogus pair instead of the real close two characters further in. + const text = "abc\\)\\(x\\)"; + const openerIndex = text.indexOf("\\("); + expect(matchMathInlineSpan(text, openerIndex)).toBe("\\(x\\)"); + }); +}); From df18b60447b94ec6f20df0f7c8b98008c2fb5de3 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sat, 12 Sep 2026 23:04:37 +0100 Subject: [PATCH 05/99] test(markdown-codec): add direct coverage for the footnote label/marker grammar matchFootnoteLabel, matchFootnoteDefinitionMarker, and isValidFootnoteLabel had no test calling them directly -- only src/footnote.test.ts's end-to-end round trips through the whole read/write pipeline, none of which exercises a valid label with no following colon (a reference, not a definition) or text that never matches the label grammar at all. --- .../src/inline/footnote.test.ts | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 packages/markdown-codec/src/inline/footnote.test.ts diff --git a/packages/markdown-codec/src/inline/footnote.test.ts b/packages/markdown-codec/src/inline/footnote.test.ts new file mode 100644 index 0000000000..ce94a3e27c --- /dev/null +++ b/packages/markdown-codec/src/inline/footnote.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from "vitest"; +import { + isValidFootnoteLabel, + matchFootnoteDefinitionMarker, + matchFootnoteLabel, +} from "./footnote"; + +describe("matchFootnoteLabel", () => { + it("matches a `[^label]` marker and reports the index one past its closing bracket", () => { + expect(matchFootnoteLabel("[^abc] rest", 0)).toEqual({ + label: "abc", + end: 6, + }); + }); + + it("returns undefined for a bracket that is not a footnote label at all", () => { + expect(matchFootnoteLabel("[abc] rest", 0)).toBeUndefined(); + }); +}); + +describe("matchFootnoteDefinitionMarker", () => { + it("matches a real `[^label]:` definition marker", () => { + expect(matchFootnoteDefinitionMarker("[^abc]: body text")).toEqual({ + label: "abc", + markerLength: 7, + }); + }); + + it("rejects a valid label marker with no following colon -- this is a reference, not a definition", () => { + expect( + matchFootnoteDefinitionMarker("[^abc] not a definition"), + ).toBeUndefined(); + }); + + it("rejects text that is not even a valid footnote label", () => { + expect( + matchFootnoteDefinitionMarker("not a marker at all"), + ).toBeUndefined(); + }); +}); + +describe("isValidFootnoteLabel", () => { + it("accepts an ordinary label", () => { + expect(isValidFootnoteLabel("note-1")).toBe(true); + }); + + it("rejects a label carrying whitespace or a bracket, which this grammar cannot represent", () => { + expect(isValidFootnoteLabel("has space")).toBe(false); + expect(isValidFootnoteLabel("has]bracket")).toBe(false); + }); +}); From 8e296b368b0a41373fbfe7eff43cc3596e4f7e7f Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sat, 12 Sep 2026 23:04:45 +0100 Subject: [PATCH 06/99] test(markdown-codec): pin resolveMarkdownImage's independent width/height axes The only existing coverage (lower.test.ts's 1x1 PNG fixture) happens to carry the same value on both axes, so a widthPt/heightPt swap or a wrong operator on either axis produces no observable difference. Adds a real 300x100 PNG fixture and checks each axis converts its own pixel dimension to points independently. --- .../markdown-codec/src/lower/image.test.ts | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 packages/markdown-codec/src/lower/image.test.ts diff --git a/packages/markdown-codec/src/lower/image.test.ts b/packages/markdown-codec/src/lower/image.test.ts new file mode 100644 index 0000000000..f38da96007 --- /dev/null +++ b/packages/markdown-codec/src/lower/image.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; +import { bytesToBase64 } from "../image/image"; +import { resolveMarkdownImage } from "./image"; + +// A minimal, otherwise-valid PNG signature + IHDR chunk with an asymmetric width/height (300x100) so a widthPt/heightPt swap or a wrong operator on either axis produces a value distinct from the other, rather than two coincidentally-equal numbers. +function pngBytes(widthPx: number, heightPx: number): Uint8Array { + const bytes = new Uint8Array(29); + bytes.set([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a], 0); + bytes.set([0x00, 0x00, 0x00, 0x0d], 8); // IHDR chunk length + bytes.set([0x49, 0x48, 0x44, 0x52], 12); // 'IHDR' + const view = new DataView(bytes.buffer); + view.setUint32(16, widthPx, false); + view.setUint32(20, heightPx, false); + return bytes; +} + +const CSS_PIXELS_PER_INCH = 96; +const POINTS_PER_INCH = 72; +const POINTS_PER_PIXEL = POINTS_PER_INCH / CSS_PIXELS_PER_INCH; + +describe("resolveMarkdownImage", () => { + it("converts an asymmetric PNG's own width/height in pixels to points independently, on the correct axis", () => { + const png = pngBytes(300, 100); + const destination = `data:image/png;base64,${bytesToBase64(png)}`; + const resolved = resolveMarkdownImage(destination, { alt: "" }, undefined); + expect(resolved?.widthPt).toBeCloseTo(300 * POINTS_PER_PIXEL); + expect(resolved?.heightPt).toBeCloseTo(100 * POINTS_PER_PIXEL); + }); +}); From 93d45369df9a252e4470818c00d8a9005e550bfe Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sat, 12 Sep 2026 23:04:52 +0100 Subject: [PATCH 07/99] test(markdown-codec): pin emitImage's alt fallback for altText-less images Every existing image emit test supplied altText, so the ?? "" fallback for a ContentImageBlock with none at all was never exercised. --- packages/markdown-codec/src/emit/emit.test.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/packages/markdown-codec/src/emit/emit.test.ts b/packages/markdown-codec/src/emit/emit.test.ts index 07fc9b3501..477df20166 100644 --- a/packages/markdown-codec/src/emit/emit.test.ts +++ b/packages/markdown-codec/src/emit/emit.test.ts @@ -2682,6 +2682,17 @@ describe("images", () => { ); expect(emitMarkdown(doc([image]), { images: false })).toBe("![alt]()"); }); + + it("emits an empty alt attribute for an image block with no altText at all", () => { + const image: ContentImageBlock = { + kind: "image", + format: "png", + base64: "AA==", + widthPt: 1, + heightPt: 1, + }; + expect(emitMarkdown(doc([image]))).toBe("![](data:image/png;base64,AA==)"); + }); }); describe("round trip through src/lower", () => { From 9657592ba5b0625ebf5b993fd40e6938d1ba91f2 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sat, 12 Sep 2026 23:05:02 +0100 Subject: [PATCH 08/99] test(markdown-codec): add direct coverage for isMarkdownBlockNode/isMarkdownInlineNode Neither predicate had a single test or internal caller before this -- they were dead code as far as this package's own test suite could tell, even though both are part of the module's public surface. Pins block vs. inline classification for a representative of each side, plus every real block node type named in BLOCK_NODE_TYPES individually. --- packages/markdown-codec/src/ast/ast.test.ts | 41 +++++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 packages/markdown-codec/src/ast/ast.test.ts 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); + } + }); +}); From 50d7dde12ff06d2039be4a0fa83f99429207a559 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sat, 12 Sep 2026 23:05:23 +0100 Subject: [PATCH 09/99] refactor(markdown-codec): always splice readMarkdown's own definitions/source table readMarkdown's own definitions/source splice special-cased "neither table applies" to return assembleTree's result unchanged, rather than spreading it. The spread was already a no-op in that case -- spreading undefined, or an absent optional key, adds nothing -- so the shortcut bought only an object reference identity DocumentTree's own contract never promises, at the cost of a branch no value-level assertion could ever tell apart from always spreading. Also drops the `assembled.source ?? {}` fallback the frontmatter splice used: spreading `undefined` directly is exactly as inert as spreading `{}`, so the fallback never changed the result either. Extends package.test.ts's coverage of the write side to match: a titleless link reference definition (no title key on the rendered entry, and no trailing title clause in the written text), two definitions joined by a real newline rather than a coincidentally-equal separator, and a definitions-only document (empty body) rendering the definitions bare with no leading blank line. --- packages/markdown-codec/src/package.test.ts | 81 +++++++++++++++++++++ packages/markdown-codec/src/read.ts | 29 +++----- 2 files changed, 93 insertions(+), 17 deletions(-) diff --git a/packages/markdown-codec/src/package.test.ts b/packages/markdown-codec/src/package.test.ts index 92ea0800e3..0afc069230 100644 --- a/packages/markdown-codec/src/package.test.ts +++ b/packages/markdown-codec/src/package.test.ts @@ -305,6 +305,58 @@ describe("writeMarkdown: DocumentTree -> markdown text", () => { ).toHaveLength(0); expect(written).toBe(writeMarkdown(base)); }); + + it("reports nothing for a present but genuinely EMPTY table -- the guard is a real emptiness check, not merely 'is the key present'", () => { + const base = readMarkdown(SAMPLE).documentPackage; + const withEmptyTables = { + ...base, + definitions: {}, + layers: {}, + attachments: {}, + destinations: {}, + pages: [], + }; + const collector = createDiagnosticCollector(); + + writeMarkdown(withEmptyTables, { sink: collector.sink }); + + expect(collector.has(MarkdownDiagnosticCodes.PACKAGE_TABLE_DROPPED)).toBe( + false, + ); + }); + + it("names the specific table in each PACKAGE_TABLE_DROPPED diagnostic's own message", () => { + const base = readMarkdown(SAMPLE).documentPackage; + const withExtraTables = { + ...base, + definitions: { d1: { kind: "bookmark" } }, + layers: { l1: { kind: "layer" } }, + attachments: { a1: { kind: "file" } }, + destinations: { dest1: { kind: "anchor" } }, + pages: [{ widthPt: 100, heightPt: 100 }], + }; + const collector = createDiagnosticCollector(); + + writeMarkdown(withExtraTables, { sink: collector.sink }); + + const messages = collector.diagnostics + .filter( + (diagnostic) => + diagnostic.code === MarkdownDiagnosticCodes.PACKAGE_TABLE_DROPPED, + ) + .map((diagnostic) => diagnostic.message); + for (const name of [ + "definitions", + "layers", + "attachments", + "destinations", + "pages", + ]) { + expect(messages.some((message) => message.includes(`"${name}"`))).toBe( + true, + ); + } + }); }); describe("the construct-group path over footnote shapes beyond SAMPLE's single case", () => { @@ -379,6 +431,14 @@ describe("tree-only carries: reference definitions and front-matter residue", () }); }); + it("splices a titleless link reference definition with no title key at all, not an undefined one", () => { + const { documentPackage } = readMarkdown("[foo]: /url\n\n[foo]"); + expect(documentPackage.definitions).toEqual({ + FOO: { kind: "link", destination: "/url" }, + }); + expect(documentPackage.definitions?.FOO).not.toHaveProperty("title"); + }); + it("leaves definitions and the package source table absent for a document with neither, so the package is exactly assembleTree of the flat document", () => { const { documentPackage } = readMarkdown("plain body"); expect(documentPackage.definitions).toBeUndefined(); @@ -400,6 +460,27 @@ describe("tree-only carries: reference definitions and front-matter residue", () ); }); + it("renders a titleless link definition with no trailing title clause at all", () => { + const written = writeMarkdown( + readMarkdown("[foo]: /url\n\n[foo]").documentPackage, + ); + expect(written).toBe("[foo](/url)\n\n[FOO]: /url"); + }); + + it("joins two rendered link definitions with a real newline, one per line", () => { + const written = writeMarkdown( + readMarkdown("[foo]: /url1\n\n[bar]: /url2\n\n[foo] and [bar]") + .documentPackage, + ); + const definitionLines = written.split("\n\n").at(-1)?.split("\n"); + expect(definitionLines).toEqual(["[FOO]: /url1", "[BAR]: /url2"]); + }); + + it("renders bare definitions with no leading blank line when the document's own body is empty", () => { + const written = writeMarkdown(readMarkdown("[foo]: /url").documentPackage); + expect(written).toBe("[FOO]: /url"); + }); + it("round-trips text -> package -> text -> package to the identical package and text, definitions included", () => { const source = '[foo]: /url "the title"\n\nuse [foo] here.'; const first = readMarkdown(source).documentPackage; diff --git a/packages/markdown-codec/src/read.ts b/packages/markdown-codec/src/read.ts index 49f491f86f..aebe5b6c78 100644 --- a/packages/markdown-codec/src/read.ts +++ b/packages/markdown-codec/src/read.ts @@ -98,23 +98,18 @@ export function readMarkdown( detail.frontMatterSource === undefined ? undefined : { format: "markdown", xml: detail.frontMatterSource }; - const documentPackage: DocumentTree = - definitions === undefined && frontMatterResidue === undefined - ? assembled - : { - ...assembled, - ...(definitions !== undefined - ? { definitions: { ...assembled.definitions, ...definitions } } - : {}), - ...(frontMatterResidue !== undefined - ? { - source: { - ...(assembled.source ?? {}), - frontmatter: frontMatterResidue, - }, - } - : {}), - }; + // No shortcut returning `assembled` unchanged when neither splice applies: the spread below is already a no-op in that case (spreading `undefined`/an absent key adds nothing), so the shortcut bought only reference identity a DocumentTree's own contract never promises, at the cost of a branch no value-level test could ever tell apart from always spreading. + const documentPackage: DocumentTree = { + ...assembled, + ...(definitions !== undefined + ? { definitions: { ...assembled.definitions, ...definitions } } + : {}), + ...(frontMatterResidue !== undefined + ? { + source: { ...assembled.source, frontmatter: frontMatterResidue }, + } + : {}), + }; return { documentPackage, diagnostics: detail.diagnostics }; } From df1e1e79200b04e628e87beb1274e902df4e2d54 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sat, 12 Sep 2026 23:05:45 +0100 Subject: [PATCH 10/99] refactor(markdown-codec): drop two unobservable branches in LineCursor lineIsBlank's own class-field default (false) could never be observed to differ: the constructor unconditionally calls findNextNonspace() immediately afterward, which always assigns the real value before any getter can read it. Dropped the initializer (definite-assignment `!:` instead) rather than leave a default no test could ever tell from any other value. advance()'s early return at end of line is the same shape: MarkdownScanCursor.next() is already a side-effect-free no-op once rawOffset reaches the source length, so looping the remaining count down regardless produces the identical end state as returning early. Dropped the guard. Adds direct LineCursor tests for blank-line detection (empty and whitespace-only lines, and a non-blank one), which the package had none of before this -- the class was only ever exercised indirectly through src/block/block.ts's own parsing. --- packages/markdown-codec/src/block/line.test.ts | 16 ++++++++++++++++ packages/markdown-codec/src/block/line.ts | 8 ++++---- 2 files changed, 20 insertions(+), 4 deletions(-) create mode 100644 packages/markdown-codec/src/block/line.test.ts diff --git a/packages/markdown-codec/src/block/line.test.ts b/packages/markdown-codec/src/block/line.test.ts new file mode 100644 index 0000000000..e9be8b6872 --- /dev/null +++ b/packages/markdown-codec/src/block/line.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from "vitest"; +import { LineCursor } from "./line"; + +describe("LineCursor", () => { + it("reports an empty line as blank as soon as it is constructed", () => { + expect(new LineCursor("").blank).toBe(true); + }); + + it("reports a non-empty line as not blank", () => { + expect(new LineCursor("foo").blank).toBe(false); + }); + + it("reports a whitespace-only line as blank", () => { + expect(new LineCursor(" ").blank).toBe(true); + }); +}); diff --git a/packages/markdown-codec/src/block/line.ts b/packages/markdown-codec/src/block/line.ts index 7540fd708c..ff0e4434b0 100644 --- a/packages/markdown-codec/src/block/line.ts +++ b/packages/markdown-codec/src/block/line.ts @@ -16,7 +16,8 @@ export class LineCursor { private readonly cursor: MarkdownScanCursor; private nextNonspaceMark: MarkdownScanMark; private nextNonspaceColumn = 0; - private lineIsBlank = false; + // No default value: the constructor unconditionally calls findNextNonspace() below, which always assigns this before any getter can read it, so a placeholder default would be overwritten on every construction path and could never be observed to differ. + private lineIsBlank!: boolean; constructor(text: string) { this.text = text; @@ -78,10 +79,9 @@ export class LineCursor { // Advances up to `columns` columns, stopping at end of line. A tab straddling the target is consumed only as far as needed, leaving its remaining columns for rest() to materialise -- which is exactly how `>\tfoo` puts three columns of indentation, not a whole tab, into the block quote's content. advance(columns: number): void { + // No early exit at end of line: MarkdownScanCursor.next() is already a side-effect-free no-op once rawOffset reaches the source length (src/scan/scan.ts), so looping the remaining count down regardless produces the identical end state as returning early -- an early-return branch here would be unobservable by any test, on purpose or not. for (let remaining = columns; remaining > 0; remaining -= 1) { - if (this.cursor.next() === undefined) { - return; - } + this.cursor.next(); } } From ec62848b6935dfffd68c9451ba3b9be40c3c45d6 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sat, 12 Sep 2026 23:08:00 +0100 Subject: [PATCH 11/99] test(markdown-codec): add direct coverage for InlineNode's linked-list operations InlineNode had no test of its own before this: appendChild, unlink, and insertAfter were only ever exercised indirectly through the inline parser's own emphasis/link resolution, which never isolates a single operation's own effect on the surrounding chain. Pins each field's default for a node kind that never sets it, appendChild's ordering, unlink's neighbour re-linking (mid-chain and at either end), and insertAfter's own three distinct behaviors: splicing in a fresh node, detaching a node from its OLD chain before relinking it into a new one, and updating (or correctly leaving alone) the parent's own lastChild depending on whether the insertion lands at the end. --- .../markdown-codec/src/inline/node.test.ts | 128 ++++++++++++++++++ 1 file changed, 128 insertions(+) create mode 100644 packages/markdown-codec/src/inline/node.test.ts diff --git a/packages/markdown-codec/src/inline/node.test.ts b/packages/markdown-codec/src/inline/node.test.ts new file mode 100644 index 0000000000..3a27c3e772 --- /dev/null +++ b/packages/markdown-codec/src/inline/node.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, it } from "vitest"; +import { InlineNode, createTextNode } from "./node"; + +describe("InlineNode field defaults", () => { + it("defaults literal/destination/raw/label to the empty string for a node kind that never sets them", () => { + const node = new InlineNode("link"); + expect(node.literal).toBe(""); + expect(node.destination).toBe(""); + expect(node.raw).toBe(""); + expect(node.label).toBe(""); + }); +}); + +describe("InlineNode.appendChild / unlink", () => { + it("links three children in order under one parent", () => { + const parent = new InlineNode("container"); + const a = createTextNode("a"); + const b = createTextNode("b"); + const c = createTextNode("c"); + parent.appendChild(a); + parent.appendChild(b); + parent.appendChild(c); + + expect(parent.firstChild).toBe(a); + expect(parent.lastChild).toBe(c); + expect(a.next).toBe(b); + expect(b.previous).toBe(a); + expect(b.next).toBe(c); + expect(c.previous).toBe(b); + }); + + it("unlink() removes a middle node and re-links its former neighbours to each other", () => { + const parent = new InlineNode("container"); + const a = createTextNode("a"); + const b = createTextNode("b"); + const c = createTextNode("c"); + parent.appendChild(a); + parent.appendChild(b); + parent.appendChild(c); + + b.unlink(); + + expect(a.next).toBe(c); + expect(c.previous).toBe(a); + expect(parent.firstChild).toBe(a); + expect(parent.lastChild).toBe(c); + }); + + it("unlink() fixes up the parent's firstChild/lastChild when the removed node was an end", () => { + const parent = new InlineNode("container"); + const a = createTextNode("a"); + const b = createTextNode("b"); + parent.appendChild(a); + parent.appendChild(b); + + a.unlink(); + expect(parent.firstChild).toBe(b); + expect(b.previous).toBeUndefined(); + + b.unlink(); + expect(parent.lastChild).toBeUndefined(); + }); +}); + +describe("InlineNode.insertAfter", () => { + it("inserts a brand-new node right after this one", () => { + const parent = new InlineNode("container"); + const a = createTextNode("a"); + const c = createTextNode("c"); + parent.appendChild(a); + parent.appendChild(c); + const b = createTextNode("b"); + + a.insertAfter(b); + + expect(a.next).toBe(b); + expect(b.previous).toBe(a); + expect(b.next).toBe(c); + expect(c.previous).toBe(b); + }); + + it("detaches a node from its OLD location before splicing it into its new one", () => { + // b starts out linked between a and c under `oldParent`; inserting it after x under a different parent must first unlink it from the old chain, or a/c are left with stale pointers to a node that no longer belongs there. + const oldParent = new InlineNode("container"); + const a = createTextNode("a"); + const b = createTextNode("b"); + const c = createTextNode("c"); + oldParent.appendChild(a); + oldParent.appendChild(b); + oldParent.appendChild(c); + + const newParent = new InlineNode("container"); + const x = createTextNode("x"); + newParent.appendChild(x); + + x.insertAfter(b); + + expect(a.next).toBe(c); + expect(c.previous).toBe(a); + expect(oldParent.lastChild).toBe(c); + expect(x.next).toBe(b); + expect(b.parent).toBe(newParent); + }); + + it("updates the parent's own lastChild when the sibling is inserted at the end", () => { + const parent = new InlineNode("container"); + const a = createTextNode("a"); + parent.appendChild(a); + const b = createTextNode("b"); + + a.insertAfter(b); + + expect(parent.lastChild).toBe(b); + }); + + it("leaves the parent's own lastChild unchanged when the sibling is inserted before an existing later node", () => { + const parent = new InlineNode("container"); + const a = createTextNode("a"); + const c = createTextNode("c"); + parent.appendChild(a); + parent.appendChild(c); + const b = createTextNode("b"); + + a.insertAfter(b); + + expect(parent.lastChild).toBe(c); + }); +}); From 98930aed731e821d059812a23c3c94812b072845 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sat, 12 Sep 2026 23:16:25 +0100 Subject: [PATCH 12/99] refactor(markdown-codec): drop unobservable guards in list-marker/tightness logic isBulletMarker/isOrderedDelimiter narrowed a regex match's own capture group to a literal type, but both patterns' character classes already guarantee the value (BULLET_MARKER_PATTERN is exactly `[*+-]`, ORDERED_MARKER_PATTERN's second group is exactly `[.)]`) -- neither predicate's "not a member" branch is reachable from a real match, so both became a plain cast at their one call site each, with a comment stating why it's safe. parseListMarker's own trailing-spaces scan drops three more branches that turned out to be fully compensated for downstream rather than genuinely decisive: the do-while's own code-indent cap (the reset branch already re-derives the item's content indent from scratch whenever the count exceeds it, so the cap only changed how far the loop itself walked, never the returned value or the cursor position it leaves behind), the `followingSpaces < 1` disjunct (the do-while's own do-first structure means that can only ever be true when startsBlank is also true, so it was never an independent second condition), and the reset branch's own `if (line.peek() === " ")` guard on its own follow-up advance (the marker-follows-by check earlier in the function already guarantees the character there is a space/tab/EOL, and advancing past EOL is a no-op, so the guard's own false side is equally unreachable). Adds src/block/list.test.ts: direct coverage of listsMatch's own three fields (type/delimiter/bulletChar) and of finalizeListTightness's lastLineChecked memoisation actually setting the flag on both the descend-further and stop-and-return-false paths, neither of which any existing test observed directly. --- .../markdown-codec/src/block/list.test.ts | 83 +++++++++++++++++++ packages/markdown-codec/src/block/list.ts | 45 +++------- 2 files changed, 94 insertions(+), 34 deletions(-) create mode 100644 packages/markdown-codec/src/block/list.test.ts diff --git a/packages/markdown-codec/src/block/list.test.ts b/packages/markdown-codec/src/block/list.test.ts new file mode 100644 index 0000000000..bc86b337ca --- /dev/null +++ b/packages/markdown-codec/src/block/list.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from "vitest"; +import { finalizeListTightness, listsMatch } from "./list"; +import { BlockNode } from "./node"; +import type { ListMarkerData } from "./node"; + +const bullet = (bulletChar: "-" | "*" | "+"): ListMarkerData => ({ + type: "bullet", + bulletChar, + padding: 2, + markerOffset: 0, +}); + +const ordered = (delimiter: "." | ")"): ListMarkerData => ({ + type: "ordered", + delimiter, + padding: 3, + markerOffset: 0, +}); + +describe("listsMatch", () => { + it("matches two bullet markers with the same bullet character", () => { + expect(listsMatch(bullet("-"), bullet("-"))).toBe(true); + }); + + it("never matches a bullet marker against an ordered one, even if every other field happened to line up", () => { + expect(listsMatch(bullet("-"), ordered("."))).toBe(false); + }); + + it("does not match two ordered markers with different delimiters", () => { + expect(listsMatch(ordered("."), ordered(")"))).toBe(false); + }); + + it("does not match two bullet markers with different bullet characters", () => { + expect(listsMatch(bullet("-"), bullet("*"))).toBe(false); + }); +}); + +describe("finalizeListTightness's own lastLineChecked memoisation", () => { + it("marks a descended list/listItem node's own lastLineChecked, so a later finalisation over the same chain does not re-walk it", () => { + const list = new BlockNode("list", 1); + // item1 is the one endsWithBlankLine is actually called on: finalizeListTightness only checks an item that has a FOLLOWING sibling (item1, since item2 follows it), never the last item in the list on its own account. + const item1 = new BlockNode("listItem", 1); + const leaf = new BlockNode("paragraph", 1); + item1.appendChild(leaf); + const item2 = new BlockNode("listItem", 2); + list.appendChild(item1); + list.appendChild(item2); + + expect(item1.lastLineChecked).toBe(false); + expect(leaf.lastLineChecked).toBe(false); + + finalizeListTightness(list); + + // item1 is a listItem, so descending into it (to check its own lastChild for a trailing blank line) must have marked it checked; leaf is not list/listItem-kinded, so it is marked checked at the point the descent stops on it rather than being descended into. + expect(item1.lastLineChecked).toBe(true); + expect(leaf.lastLineChecked).toBe(true); + }); + + it("keeps a list tight when nothing is blank", () => { + const list = new BlockNode("list", 1); + const item1 = new BlockNode("listItem", 1); + const item2 = new BlockNode("listItem", 2); + list.appendChild(item1); + list.appendChild(item2); + + finalizeListTightness(list); + + expect(list.tight).toBe(true); + }); + + it("marks a list loose when an earlier item ends with a blank line before a following item", () => { + const list = new BlockNode("list", 1); + const item1 = new BlockNode("listItem", 1); + item1.lastLineBlank = true; + const item2 = new BlockNode("listItem", 2); + list.appendChild(item1); + list.appendChild(item2); + + finalizeListTightness(list); + + expect(list.tight).toBe(false); + }); +}); diff --git a/packages/markdown-codec/src/block/list.ts b/packages/markdown-codec/src/block/list.ts index 2e8b0adca8..299dc20de6 100644 --- a/packages/markdown-codec/src/block/list.ts +++ b/packages/markdown-codec/src/block/list.ts @@ -27,16 +27,6 @@ const NON_SPACE_PATTERN = /[^ \t\f\v\r\n]/; // An ordered list may interrupt a paragraph only when it starts at 1 (spec 0.31.2: "In order for a list to interrupt a paragraph, it must start with 1"). const INTERRUPTING_ORDERED_START = 1; -function isBulletMarker(char: string): char is MarkdownBulletMarker { - return char === "-" || char === "*" || char === "+"; -} - -function isOrderedDelimiter( - char: string, -): char is MarkdownOrderedListDelimiter { - return char === "." || char === ")"; -} - interface MarkerMatch { readonly length: number; readonly data: Omit; @@ -49,10 +39,8 @@ function matchMarker( ): MarkerMatch | undefined { const bullet = BULLET_MARKER_PATTERN.exec(rest); if (bullet !== null) { - const char = bullet[0]; - if (!isBulletMarker(char)) { - return undefined; - } + // BULLET_MARKER_PATTERN's own character class (`[*+-]`) is exactly MarkdownBulletMarker's three members, so a match's own char is never anything else -- no runtime check could ever see the "else" side of that, only TypeScript's own indexed-access typing needs told. + const char = bullet[0] as MarkdownBulletMarker; return { length: bullet[0].length, data: { type: "bullet", bulletChar: char, markerOffset: indent }, @@ -60,13 +48,9 @@ function matchMarker( } const ordered = ORDERED_MARKER_PATTERN.exec(rest); const digits = ordered?.[1]; - const delimiter = ordered?.[2]; - if ( - ordered === null || - digits === undefined || - delimiter === undefined || - !isOrderedDelimiter(delimiter) - ) { + // Same reasoning as the bullet branch above: ORDERED_MARKER_PATTERN's own second capturing group is the character class `[.)]`, so a populated capture is never anything but one of MarkdownOrderedListDelimiter's two members. + const delimiter = ordered?.[2] as MarkdownOrderedListDelimiter | undefined; + if (ordered === null || digits === undefined || delimiter === undefined) { return undefined; } const start = Number.parseInt(digits, 10); @@ -110,29 +94,22 @@ export function parseListMarker( line.advanceToNextNonspace(); line.advance(match.length); - // Measure the spaces following the marker in COLUMNS, stopping at the code-indent threshold: past that point the exact count no longer changes the answer, and a single tab can supply all of them at once. The threshold IS the code indent, not a number of its own -- spaces past it make the content indented code rather than the item's own content indent. + // Measure the spaces following the marker in COLUMNS. No cap at the code-indent threshold here -- the branch below already resets the cursor back to afterMarkerMark and re-derives the item's own content indent from scratch whenever followingSpaces turns out to exceed it (or the rest of the line is blank), so a mid-scan cap would only change how many spaces this loop itself walks past, never the value parseListMarker returns or the cursor position it leaves behind. const afterMarkerMark = line.mark(); const afterMarkerColumn = line.column; // LineCursor.peek() reports a tab as a single space, one column at a time (src/scan), so testing for a space alone covers both -- there is no '\t' to compare against at this level. do { line.advance(1); - } while ( - line.column - afterMarkerColumn <= CODE_INDENT_COLUMNS && - line.peek() === " " - ); + } while (line.peek() === " "); const followingSpaces = line.column - afterMarkerColumn; const startsBlank = line.atEnd; - if ( - followingSpaces > CODE_INDENT_COLUMNS || - followingSpaces < 1 || - startsBlank - ) { + // No separate `followingSpaces < 1` disjunct: the do-while above always runs its body at least once, and LineCursor.advance() only ever leaves `line.column` unchanged when the cursor was already at the absolute end of input before that call -- so followingSpaces can never come out to 0 without startsBlank also being true, and a disjunct that can never be true on its own is not a real second condition. + if (followingSpaces > CODE_INDENT_COLUMNS || startsBlank) { // Either the content is indented code (5+ columns past the marker) or there is no content on this line at all: the item's own content indent is the marker plus a single column, and everything past that is content. line.reset(afterMarkerMark); - if (line.peek() === " ") { - line.advance(1); - } + // Unconditional, not `if (line.peek() === " ") line.advance(1)`: the marker-follows-by check above already guarantees the character right after the marker is a space/tab or end of line, so this is either consuming that one space/tab (the followingSpaces > 4 case) or a no-op past the end of input (the startsBlank case) -- never a third, unguarded shape. + line.advance(1); return { ...match.data, padding: match.length + 1 }; } return { ...match.data, padding: match.length + followingSpaces }; From cc1ac4d1db2cdabc41857fb616ef7c0fe440b8e1 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sat, 12 Sep 2026 23:22:31 +0100 Subject: [PATCH 13/99] refactor(markdown-codec): make link primitives' loop bounds mutation-testable Four scan loops (matchLinkLabel, parseLinkDestination's angle-bracketed form, parseLinkTitle, skipInlineWhitespace) bounded themselves with `index < text.length`, which turned out to be indistinguishable from `index <= text.length` for every one of them: text.charAt(index) already returns "" one index past the end, and none of these loops' own character comparisons ever match "" either, so the one extra boundary iteration always falls through to the identical exit path regardless of which comparison guards it. Rewritten as `text.charAt(index) !== ""` instead -- exactly the same boundary for every real index, but one whose own mutation (the operator, or the "" literal) is now actually reachable by a test rather than always landing on the same fallthrough either way. parseLinkTitle's own `closer === undefined` guard is the same shape: when `opener` isn't one of TITLE_DELIMITERS' own three keys, `char === closer` can never match a real character, and TITLE_DELIMITERS' own mapping means `opener` is only ever "(" when closer IS defined -- so the loop already scans to the end and returns undefined regardless, and the guard bought nothing an early return wouldn't have. Dropped in favour of a comment recording why. Adds direct tests for four scenarios nothing exercised before: a start that isn't "[" with a ']' reachable later (matchLinkLabel), an unescaped nested '<' with no line ending (parseLinkDestination's bracketed form), a trailing unescapable backslash treated as a literal character rather than the start of a truncated escape (parseLinkDestination's bare form), and isBlankRemainderOfLine's own four cases (nothing exercised it at all before this) including reaching the true end of the text. --- .../markdown-codec/src/inline/link.test.ts | 34 +++++++++++++++++++ packages/markdown-codec/src/inline/link.ts | 16 +++++---- 2 files changed, 43 insertions(+), 7 deletions(-) diff --git a/packages/markdown-codec/src/inline/link.test.ts b/packages/markdown-codec/src/inline/link.test.ts index 2f3584beac..b2386bd783 100644 --- a/packages/markdown-codec/src/inline/link.test.ts +++ b/packages/markdown-codec/src/inline/link.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { + isBlankRemainderOfLine, matchLinkLabel, normalizeLinkLabel, parseLinkDestination, @@ -37,6 +38,10 @@ describe("matchLinkLabel", () => { expect(matchLinkLabel(`[${"a".repeat(999)}]`, 0)).toBe(1001); expect(matchLinkLabel(`[${"a".repeat(1000)}]`, 0)).toBe(0); }); + + it("returns 0 for text that does not open with '[' at all, even when a ']' appears later", () => { + expect(matchLinkLabel("abc]", 0)).toBe(0); + }); }); describe("parseLinkDestination", () => { @@ -55,6 +60,17 @@ describe("parseLinkDestination", () => { expect(parseLinkDestination("", 0)).toBeUndefined(); }); + it("rejects an angle-bracketed destination containing an unescaped nested '<', even with no line ending", () => { + expect(parseLinkDestination("", 0)).toBeUndefined(); + }); + + it("treats a trailing, unescapable backslash as a literal character, not the start of an escape past the end", () => { + expect(parseLinkDestination("abc\\", 0)).toEqual({ + value: "abc\\", + end: 4, + }); + }); + it("reads a bare destination with balanced parentheses", () => { expect(parseLinkDestination("/a(b)c)", 0)).toEqual({ value: "/a(b)c", @@ -101,3 +117,21 @@ describe("skipInlineWhitespace", () => { expect(skipInlineWhitespace(" \n \n x", 0)).toBe(3); }); }); + +describe("isBlankRemainderOfLine", () => { + it("is true at the very end of the text -- vacuously blank, nothing left to disqualify it", () => { + expect(isBlankRemainderOfLine("", 0)).toBe(true); + }); + + it("is true when only spaces/tabs remain all the way to the end of the text", () => { + expect(isBlankRemainderOfLine(" ", 0)).toBe(true); + }); + + it("is true as soon as a line ending is reached", () => { + expect(isBlankRemainderOfLine(" \nrest", 0)).toBe(true); + }); + + it("is false when a non-space character remains before any line ending", () => { + expect(isBlankRemainderOfLine(" x", 0)).toBe(false); + }); +}); diff --git a/packages/markdown-codec/src/inline/link.ts b/packages/markdown-codec/src/inline/link.ts index 4ca7a49c29..ef19984e70 100644 --- a/packages/markdown-codec/src/inline/link.ts +++ b/packages/markdown-codec/src/inline/link.ts @@ -33,7 +33,8 @@ export function matchLinkLabel(text: string, start: number): number { return 0; } let index = start + 1; - while (index < text.length) { + // text.charAt(index) !== "", not index < text.length: the two are equivalent for every real index, but charAt already returns "" one past the end, which none of this loop's own character comparisons below can ever match either -- so this is the one boundary spelling whose own mutation (flipping the operator, or the empty-string literal) is actually reachable by a real test, rather than always landing on the identical fallthrough either way. + while (text.charAt(index) !== "") { const char = text.charAt(index); if (char === "\\") { index += 2; @@ -63,7 +64,8 @@ export function parseLinkDestination( ): ParsedSpan | undefined { if (text.charAt(start) === "<") { let index = start + 1; - while (index < text.length) { + // See matchLinkLabel's own note above on why this is charAt(index) !== "" rather than index < text.length. + while (text.charAt(index) !== "") { const char = text.charAt(index); if (char === "\\") { index += 2; @@ -131,12 +133,11 @@ export function parseLinkTitle( start: number, ): ParsedSpan | undefined { const opener = text.charAt(start); + // No separate "closer === undefined, bail out now" guard: when `opener` isn't one of TITLE_DELIMITERS' own three keys, `closer` stays undefined, `char === closer` can never match a real character (charAt never returns the JS value undefined), and TITLE_DELIMITERS' own mapping means opener can only ever be "(" when closer IS defined -- so the loop below just scans to the end matching nothing and returns undefined regardless, on its own. const closer = TITLE_DELIMITERS.get(opener); - if (closer === undefined) { - return undefined; - } let index = start + 1; - while (index < text.length) { + // See matchLinkLabel's own note (src/inline/link.ts) on why this is charAt(index) !== "" rather than index < text.length. + while (text.charAt(index) !== "") { const char = text.charAt(index); if (char === "\\") { index += 2; @@ -160,7 +161,8 @@ export function parseLinkTitle( export function skipInlineWhitespace(text: string, start: number): number { let index = start; let seenLineEnding = false; - while (index < text.length) { + // See matchLinkLabel's own note (src/inline/link.ts) on why this is charAt(index) !== "" rather than index < text.length. + while (text.charAt(index) !== "") { const char = text.charAt(index); if (char === "\n") { if (seenLineEnding) { From df2e73cb8186514b17f7cb37f7f7393897c7acb9 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sat, 12 Sep 2026 23:26:40 +0100 Subject: [PATCH 14/99] refactor(markdown-codec): remove three provably-unreachable guards from MarkdownScanCursor atEnd()'s own `pendingTabColumns === 0` half was never independent of the rawOffset check beside it: rawOffset only advances past a tab once every one of its columns is spent (next()'s own tab branch), so rawOffset can never reach source.length while a tab is still mid-expansion. Checking rawOffset alone already answers the same question. peek() dropped both its `pendingTabColumns > 0` branch and its own `rawOffset >= source.length` guard: while a tab is mid-expansion, rawOffset still points AT that tab character (the same invariant atEnd relies on), so the plain read below already finds '\t' and returns the correct synthetic space through its own tab branch; and past the end of input, a string index in JS is already `undefined` on its own, which matches every comparison below it and falls out the far end as `undefined` regardless. Both "extra" branches produced the identical answer the plain read below them already gives, on every reachable input. next()'s own end-of-input guard is NOT the same shape and stays: skipping it would still return the correct `undefined`, but it would also mutate rawOffset/columnNumber for a character that was never really there, corrupting the cursor's own state on every subsequent call. Added a test pinning that calling next() repeatedly past the end is idempotent. Adds direct coverage for what was previously untested at all: peek()'s own '\r' normalisation and true-end-of-input case, and peekRaw() actually slicing (a same-length fixture had let it read as `this.source` with the slice call itself elided). --- packages/markdown-codec/src/scan/scan.test.ts | 26 +++++++++++++++++++ packages/markdown-codec/src/scan/scan.ts | 11 +++----- 2 files changed, 30 insertions(+), 7 deletions(-) diff --git a/packages/markdown-codec/src/scan/scan.test.ts b/packages/markdown-codec/src/scan/scan.test.ts index e7524659bc..45707e4eb6 100644 --- a/packages/markdown-codec/src/scan/scan.test.ts +++ b/packages/markdown-codec/src/scan/scan.test.ts @@ -68,12 +68,28 @@ describe("MarkdownScanCursor", () => { expect(cursor.position.column).toBe(1); }); + it("peek() normalises a raw '\\r' to '\\n', matching next()'s own line-ending normalisation", () => { + expect(new MarkdownScanCursor("\rx").peek()).toBe("\n"); + }); + + it("peek() returns undefined at the true end of input, with no pending tab", () => { + expect(new MarkdownScanCursor("").peek()).toBeUndefined(); + }); + it("peekRaw() reads real source characters, ignoring pending tab-expansion state", () => { const cursor = new MarkdownScanCursor("\tfoo"); cursor.next(); // consume the first of the tab's expanded columns; rawOffset stays at the tab itself expect(cursor.peekRaw(4)).toBe("\tfoo"); }); + it("peekRaw() returns only the requested slice, not the whole remaining source", () => { + const cursor = new MarkdownScanCursor("abcdef"); + expect(cursor.peekRaw(2)).toBe("ab"); + cursor.next(); + cursor.next(); + expect(cursor.peekRaw(2)).toBe("cd"); + }); + it("treats LF, CRLF, and lone CR as a single logical newline, resetting column and advancing line", () => { for (const [source, label] of [ ["a\nb", "LF"], @@ -105,6 +121,16 @@ describe("MarkdownScanCursor", () => { expect(cursor.next()).toBe("f"); }); + it("next() past the end of input is idempotent -- it never advances rawOffset or column further", () => { + const cursor = new MarkdownScanCursor("a"); + cursor.next(); + expect(cursor.next()).toBeUndefined(); + const markAfterFirstPastEnd = cursor.mark(); + expect(cursor.next()).toBeUndefined(); + expect(cursor.next()).toBeUndefined(); + expect(cursor.mark()).toEqual(markAfterFirstPastEnd); + }); + it("atEnd() is false while a tab expansion is still pending, even past the raw source length", () => { const cursor = new MarkdownScanCursor("\t"); // The lone tab at column 0 expands to 4 columns in total. diff --git a/packages/markdown-codec/src/scan/scan.ts b/packages/markdown-codec/src/scan/scan.ts index e15e70823e..272cbe147f 100644 --- a/packages/markdown-codec/src/scan/scan.ts +++ b/packages/markdown-codec/src/scan/scan.ts @@ -41,18 +41,15 @@ export class MarkdownScanCursor { }; } + // No separate `pendingTabColumns === 0` half here: rawOffset only ever advances past a tab once every one of its own columns has been consumed (next()'s own tab branch below), so rawOffset can never reach source.length while pendingTabColumns is still nonzero -- the two conditions were never independent, and checking rawOffset alone already answers exactly when this class considers itself done. atEnd(): boolean { - return this.pendingTabColumns === 0 && this.rawOffset >= this.source.length; + return this.rawOffset >= this.source.length; } // The next effective character without consuming it: a real source character, or a synthetic single space while a tab's own expansion is only partially consumed. Never returns '\t' or '\r' -- a tab's columns come back as ' ' one at a time, and a line ending (LF, CRLF, or lone CR) comes back as a single '\n', matching next()'s own normalisation. + // + // No `pendingTabColumns > 0` branch of its own, and no `rawOffset >= source.length` guard either: while a tab's expansion is only partly consumed, rawOffset still points AT that same tab character (see atEnd's own note), so reading `this.source[this.rawOffset]` here already finds '\t' and the ordinary tab branch below already answers " " for it; and past the end of input, indexing a string out of range is itself already `undefined` in JS, which matches every one of the comparisons below and falls out the far end as `undefined` on its own -- both cases this method needs to handle are already handled by the plain read. peek(): string | undefined { - if (this.pendingTabColumns > 0) { - return " "; - } - if (this.rawOffset >= this.source.length) { - return undefined; - } const char = this.source[this.rawOffset]; if (char === "\t") { return " "; From 2a86eb9b9d18207bd351933c1bb53fce49a5b949 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sat, 12 Sep 2026 23:29:32 +0100 Subject: [PATCH 15/99] refactor(markdown-codec): drop two redundant '<'-prefix guards in the HTML recogniser matchHtmlTag's own text.charAt(start) !== "<" guard and matchHtmlBlockStart's own !line.startsWith("<") guard both duplicated a fact their real regexes already enforce: every alternative in HTML_TAG_PATTERN, and every real entry in HTML_BLOCK_START_PATTERNS (types 1-7), is itself anchored at `^` and begins with a literal '<' in its own source -- so a string that doesn't open with '<' already fails every one of them on its own, and the dedicated guard could only ever agree with what the pattern match was already going to answer. --- packages/markdown-codec/src/html/html.ts | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/packages/markdown-codec/src/html/html.ts b/packages/markdown-codec/src/html/html.ts index 8ce74fa727..d9d2647a4b 100644 --- a/packages/markdown-codec/src/html/html.ts +++ b/packages/markdown-codec/src/html/html.ts @@ -27,10 +27,9 @@ const HTML_TAG_PATTERN = new RegExp( ); // Matches an inline HTML tag starting at `start` (which must be the `<`), returning its literal source text, or undefined when what follows is not a tag at all -- a bare `<` is ordinary text, never an error. +// +// No separate "does text[start] even open with '<'?" guard: every one of HTML_TAG_PATTERN's own alternatives (OPEN_TAG, CLOSING_TAG, HTML_COMMENT, PROCESSING_INSTRUCTION, DECLARATION, CDATA_SECTION) already begins with a literal '<' in its own regex source, and the pattern as a whole is anchored at `^` -- so a slice that doesn't start with '<' can never match any alternative regardless, and a guard duplicating that fact ahead of the real check would only ever agree with it. export function matchHtmlTag(text: string, start: number): string | undefined { - if (text.charAt(start) !== "<") { - return undefined; - } const match = HTML_TAG_PATTERN.exec(text.slice(start)); return match === null ? undefined : match[0]; } @@ -136,9 +135,7 @@ export function matchHtmlBlockStart( line: string, interruptsParagraph: boolean, ): HtmlBlockType | undefined { - if (!line.startsWith("<")) { - return undefined; - } + // No separate "does line even start with '<'?" guard: every one of HTML_BLOCK_START_PATTERNS' real entries (types 1-7) is itself anchored at `^` and begins with a literal '<' in its own regex source, so a line that doesn't open with '<' already fails every pattern in the loop below on its own, and the loop exhausts to the identical `undefined` regardless. for (const type of HTML_BLOCK_TYPES) { if (type === LAST_HTML_BLOCK_TYPE && interruptsParagraph) { continue; From cd4d1cdedcbcad86375a12b84777f68f82c6df4b Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sat, 12 Sep 2026 23:32:05 +0100 Subject: [PATCH 16/99] test(markdown-codec): add direct coverage for BlockNode's own methods and canContain BlockNode's replaceWith/unlink and the module-level canContain had no test of their own before this. Pins each mutable field's own empty-string default (infoString/literal/headerLine/footnoteLabel), replaceWith/unlink both correctly no-op-ing when the node they're called on isn't actually present in its own parent's children array (an inconsistent state a wrong `index !== -1` check would otherwise splice(-1, 1) against -- deleting the parent's LAST child instead of nothing), and every one of canContain's own per-parent-kind branches, including the two restrictions specific to a footnote definition. --- .../markdown-codec/src/block/node.test.ts | 109 ++++++++++++++++++ 1 file changed, 109 insertions(+) create mode 100644 packages/markdown-codec/src/block/node.test.ts diff --git a/packages/markdown-codec/src/block/node.test.ts b/packages/markdown-codec/src/block/node.test.ts new file mode 100644 index 0000000000..73fb963059 --- /dev/null +++ b/packages/markdown-codec/src/block/node.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it } from "vitest"; +import { BlockNode, canContain } from "./node"; + +describe("BlockNode field defaults", () => { + it("defaults infoString/literal/headerLine/footnoteLabel to the empty string", () => { + const node = new BlockNode("paragraph", 1); + expect(node.infoString).toBe(""); + expect(node.literal).toBe(""); + expect(node.headerLine).toBe(""); + expect(node.footnoteLabel).toBe(""); + }); +}); + +describe("BlockNode.replaceWith", () => { + it("replaces the node in its parent's own children array, in place", () => { + const parent = new BlockNode("document", 1); + const original = new BlockNode("paragraph", 1); + const sibling = new BlockNode("paragraph", 2); + parent.appendChild(original); + parent.appendChild(sibling); + + const replacement = new BlockNode("heading", 1); + original.replaceWith(replacement); + + expect(parent.children).toEqual([replacement, sibling]); + expect(replacement.parent).toBe(parent); + expect(original.parent).toBeUndefined(); + }); + + it("does nothing when this node is not actually present in its own parent's children array", () => { + const parent = new BlockNode("document", 1); + const onlyChild = new BlockNode("paragraph", 1); + parent.appendChild(onlyChild); + + // A node whose own `.parent` points here, but that was never itself pushed into parent.children -- an inconsistent state replaceWith must not act on. + const detached = new BlockNode("paragraph", 2); + detached.parent = parent; + + const replacement = new BlockNode("heading", 1); + detached.replaceWith(replacement); + + expect(parent.children).toEqual([onlyChild]); + }); +}); + +describe("BlockNode.unlink", () => { + it("removes the node from its parent's own children array", () => { + const parent = new BlockNode("document", 1); + const a = new BlockNode("paragraph", 1); + const b = new BlockNode("paragraph", 2); + parent.appendChild(a); + parent.appendChild(b); + + a.unlink(); + + expect(parent.children).toEqual([b]); + expect(a.parent).toBeUndefined(); + }); + + it("does nothing to the parent's children when this node is not actually present there", () => { + const parent = new BlockNode("document", 1); + const onlyChild = new BlockNode("paragraph", 1); + parent.appendChild(onlyChild); + + const detached = new BlockNode("paragraph", 2); + detached.parent = parent; + + detached.unlink(); + + // A wrong `index !== -1` check (forced true) would splice(-1, 1) here, which deletes the LAST element of the array -- exactly the bug this pins against. + expect(parent.children).toEqual([onlyChild]); + }); +}); + +describe("canContain", () => { + it("lets a footnote definition hold an ordinary block", () => { + expect(canContain("footnoteDefinition", "paragraph")).toBe(true); + }); + + it("never lets a footnote definition hold a bare list item", () => { + expect(canContain("footnoteDefinition", "listItem")).toBe(false); + }); + + it("never lets a footnote definition nest another footnote definition", () => { + expect(canContain("footnoteDefinition", "footnoteDefinition")).toBe(false); + }); + + it("lets a document/blockquote/listItem hold an ordinary block", () => { + expect(canContain("document", "paragraph")).toBe(true); + expect(canContain("blockquote", "paragraph")).toBe(true); + expect(canContain("listItem", "paragraph")).toBe(true); + }); + + it("never lets a document/blockquote/listItem hold a bare list item directly", () => { + expect(canContain("document", "listItem")).toBe(false); + expect(canContain("blockquote", "listItem")).toBe(false); + expect(canContain("listItem", "listItem")).toBe(false); + }); + + it("lets a list hold only list items", () => { + expect(canContain("list", "listItem")).toBe(true); + expect(canContain("list", "paragraph")).toBe(false); + }); + + it("lets no leaf block hold any children", () => { + expect(canContain("paragraph", "paragraph")).toBe(false); + expect(canContain("codeBlock", "paragraph")).toBe(false); + }); +}); From 76b9386c63e29a7e6b0914510341d1fcc9261303 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sat, 12 Sep 2026 23:35:33 +0100 Subject: [PATCH 17/99] refactor(markdown-codec): remove length bounds absorbed by charAt's own out-of-range "" splitTableRow's own scan loop and its backslash-pairing check, and endsWithUnescapedPipe's own trailing-backslash count, each paired a length-based bound with a character comparison that can never match "" -- so once the length bound would have stopped the loop, the character check was already going to fail on its own the very next read, on every reachable input. Restated the two loop bounds as `charAt(...) !== ""` (the same boundary, spelled as the check that's actually reachable by a test) and dropped endsWithUnescapedPipe's bound entirely, since charAt of a negative index is already "" with no separate arithmetic needed to say so. parseTableDelimiterRow's own `cells.length === 0` guard is dead for a different reason: splitTableRow always pushes its own trailing `current.trim()` unconditionally, even over empty input, so it can never actually return an empty array. Adds real coverage for what these bounds were guarding in practice: leading/trailing whitespace trimmed before either pipe is read, a leading pipe stripped independently of a trailing one (and vice versa), a lone trailing backslash with nothing to escape treated as a literal character, and endsWithUnescapedPipe's own odd/even backslash-run counting through three and four consecutive trailing backslashes, not just one. --- .../markdown-codec/src/block/table.test.ts | 31 +++++++++++++++++++ packages/markdown-codec/src/block/table.ts | 18 +++++------ 2 files changed, 40 insertions(+), 9 deletions(-) diff --git a/packages/markdown-codec/src/block/table.test.ts b/packages/markdown-codec/src/block/table.test.ts index d24da81cee..222a6abed4 100644 --- a/packages/markdown-codec/src/block/table.test.ts +++ b/packages/markdown-codec/src/block/table.test.ts @@ -29,6 +29,37 @@ describe("splitTableRow", () => { it("splits after a doubled backslash, which escapes itself rather than the pipe", () => { expect(splitTableRow("a\\\\|b")).toEqual(["a\\\\", "b"]); }); + + it("trims leading/trailing whitespace from the whole line before reading its pipes", () => { + expect(splitTableRow(" | a | b | ")).toEqual(["a", "b"]); + }); + + it("strips a leading pipe without requiring a trailing one, and vice versa", () => { + expect(splitTableRow("| a | b")).toEqual(["a", "b"]); + expect(splitTableRow("a | b |")).toEqual(["a", "b"]); + }); + + it("treats a single trailing backslash with nothing after it as a literal character", () => { + expect(splitTableRow("a\\")).toEqual(["a\\"]); + }); +}); + +describe("endsWithUnescapedPipe (via splitTableRow's own trailing-pipe handling)", () => { + it("does not strip the trailing pipe when it is escaped by an odd run of backslashes", () => { + expect(splitTableRow("a\\|")).toEqual(["a|"]); + }); + + it("does strip the trailing pipe when it is preceded by an even run of backslashes", () => { + expect(splitTableRow("a\\\\|")).toEqual(["a\\\\"]); + }); + + it("counts a run of three trailing backslashes as odd (escaped), not stopping after one", () => { + expect(splitTableRow("a\\\\\\|")).toEqual(["a\\\\|"]); + }); + + it("counts a run of four trailing backslashes as even (unescaped), not stopping after one", () => { + expect(splitTableRow("a\\\\\\\\|")).toEqual(["a\\\\\\\\"]); + }); }); describe("parseTableDelimiterRow", () => { diff --git a/packages/markdown-codec/src/block/table.ts b/packages/markdown-codec/src/block/table.ts index 7d6ccef168..060f00c30d 100644 --- a/packages/markdown-codec/src/block/table.ts +++ b/packages/markdown-codec/src/block/table.ts @@ -27,9 +27,13 @@ export function splitTableRow(line: string): string[] { const cells: string[] = []; let current = ""; let index = 0; - while (index < text.length) { + // text.charAt(index) !== "", not index < text.length: the two are equivalent for every real index (charAt already returns "" one past the end, which none of this loop's own branches below can ever match either), but only this spelling's own mutation is actually reachable by a test rather than always landing on the identical fallthrough either way. + while (text.charAt(index) !== "") { const char = text.charAt(index); - if (char === "\\" && index + 1 < text.length) { + // text.charAt(index + 1) !== "", not index + 1 < text.length: same reasoning -- when the + // backslash is the very last character, charAt(index + 1) is already "", which is never "|" + // either, so the escaped-pipe branch below would add the identical single backslash either way; this spelling is the one whose own mutation an escaped-pipe test can actually catch. + if (char === "\\" && text.charAt(index + 1) !== "") { // An escaped pipe is resolved HERE rather than left for the inline phase's own backslash handling, because a cell's content may put it somewhere that handling never reaches: GFM's own example escapes a pipe inside a code span (`` | b `\|` az | ``), and a code span's literal is never backslash-processed. Every other escape is passed through untouched for the inline phase to resolve as usual. const escaped = text.charAt(index + 1); current += escaped === "|" ? escaped : char + escaped; @@ -54,10 +58,8 @@ function endsWithUnescapedPipe(text: string): boolean { return false; } let backslashes = 0; - while ( - backslashes + 1 < text.length && - text.charAt(text.length - 2 - backslashes) === "\\" - ) { + // No separate `backslashes + 1 < text.length` bound: charAt(text.length - 2 - backslashes) reads before the start of `text` once backslashes grows past text.length - 2, and charAt already returns "" for a negative index, which is never "\\" either -- so the loop already stops there on its own, on exactly the same iteration a length-based bound would have forced. + while (text.charAt(text.length - 2 - backslashes) === "\\") { backslashes += 1; } return backslashes % 2 === 0; @@ -85,10 +87,8 @@ export function parseTableDelimiterRow( if (!line.includes("|")) { return undefined; } + // No `cells.length === 0` guard: splitTableRow always pushes its own trailing `current.trim()` unconditionally, even over empty input, so it can never return an empty array for this function to guard against. const cells = splitTableRow(line); - if (cells.length === 0) { - return undefined; - } const alignments: MarkdownTableAlignment[] = []; for (const cell of cells) { if (!DELIMITER_CELL_PATTERN.test(cell)) { From 36babc0895eb78471ede5d307a43bdc6de4e9a3e Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sat, 12 Sep 2026 23:40:29 +0100 Subject: [PATCH 18/99] refactor(markdown-codec): remove two more redundant guards, add codepoint-boundary coverage matchEntity's own '&'-prefix guard is the same redundant shape already fixed for matchHtmlTag/matchHtmlBlockStart: ENTITY_PATTERN's own source is anchored at `^&`, so a slice that doesn't open with '&' can never match regardless. unescapeString's own "neither backslash nor '&' at all" fast path is provably a pure optimisation too: for a string with neither, the loop it skips never takes the backslash/entity branches either, so it does nothing but reconstruct the identical string one character at a time -- same output, more work, never a different result. Its own loop bound gets the same charAt(index) !== "" restatement already applied elsewhere in this codec, for the same reason. Adds direct tests for codepointToString's own three boundaries (U+0000, the maximum codepoint, and the low/high surrogate range) that nothing exercised before -- each just below, at, and just past its own edge, so each comparison's own direction and operator is pinned rather than only its "obviously in range" and "obviously out of range" interior points. --- .../markdown-codec/src/inline/entity.test.ts | 72 +++++++++++++++++++ packages/markdown-codec/src/inline/entity.ts | 11 ++- 2 files changed, 76 insertions(+), 7 deletions(-) create mode 100644 packages/markdown-codec/src/inline/entity.test.ts diff --git a/packages/markdown-codec/src/inline/entity.test.ts b/packages/markdown-codec/src/inline/entity.test.ts new file mode 100644 index 0000000000..78f1a50030 --- /dev/null +++ b/packages/markdown-codec/src/inline/entity.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from "vitest"; +import { matchEntity, unescapeString } from "./entity"; + +describe("matchEntity numeric references", () => { + it("decodes a hex reference to its real character", () => { + expect(matchEntity("A", 0)).toEqual({ raw: "A", value: "A" }); + }); + + it("decodes a decimal reference to its real character", () => { + expect(matchEntity("A", 0)).toEqual({ raw: "A", value: "A" }); + }); + + it("decodes U+0000 to the replacement character, per the spec's own rule", () => { + expect(matchEntity("�", 0)?.value).toBe("�"); + }); + + it("decodes the maximum valid codepoint (U+10FFFF) normally, not as a replacement", () => { + expect(matchEntity("􏿿", 0)?.value).toBe( + String.fromCodePoint(0x10ffff), + ); + }); + + it("decodes one past the maximum valid codepoint to the replacement character", () => { + expect(matchEntity("�", 0)?.value).toBe("�"); + }); + + it("decodes the character just below the surrogate range normally", () => { + expect(matchEntity("퟿", 0)?.value).toBe( + String.fromCodePoint(0xd7ff), + ); + }); + + it("decodes the first surrogate codepoint (U+D800) to the replacement character", () => { + expect(matchEntity("�", 0)?.value).toBe("�"); + }); + + it("decodes the last surrogate codepoint (U+DFFF) to the replacement character", () => { + expect(matchEntity("�", 0)?.value).toBe("�"); + }); + + it("decodes the character just past the surrogate range normally", () => { + expect(matchEntity("", 0)?.value).toBe( + String.fromCodePoint(0xe000), + ); + }); + + it("returns undefined for an unrecognised named entity", () => { + expect(matchEntity("&MissingGlyph;", 0)).toBeUndefined(); + }); +}); + +describe("unescapeString", () => { + it("passes plain text with neither a backslash nor an entity through unchanged", () => { + expect(unescapeString("plain text")).toBe("plain text"); + }); + + it("resolves a backslash escape", () => { + expect(unescapeString("a\\*b")).toBe("a*b"); + }); + + it("resolves a named entity", () => { + expect(unescapeString("a&b")).toBe("a&b"); + }); + + it("leaves a lone unescapable backslash as a literal character", () => { + expect(unescapeString("a\\zb")).toBe("a\\zb"); + }); + + it("leaves an unrecognised '&' sequence as literal text", () => { + expect(unescapeString("a&b")).toBe("a&b"); + }); +}); diff --git a/packages/markdown-codec/src/inline/entity.ts b/packages/markdown-codec/src/inline/entity.ts index 7c3cbb0e8d..1972951d54 100644 --- a/packages/markdown-codec/src/inline/entity.ts +++ b/packages/markdown-codec/src/inline/entity.ts @@ -35,13 +35,11 @@ function codepointToString(codepoint: number): string { } // Matches an entity or numeric character reference starting at `start` (which must be the `&`). Returns undefined when what follows is not a valid reference at all -- a bare `&` is ordinary text, never an error. +// No separate "does text[start] even open with '&'?" guard: ENTITY_PATTERN's own source is anchored at `^&`, so a slice that doesn't open with '&' can never match regardless -- the same reasoning src/html/html.ts's matchHtmlTag/matchHtmlBlockStart apply to their own leading '<' checks. export function matchEntity( text: string, start: number, ): EntityMatch | undefined { - if (text.charAt(start) !== "&") { - return undefined; - } const match = ENTITY_PATTERN.exec(text.slice(start)); if (match === null) { return undefined; @@ -66,12 +64,11 @@ export function matchEntity( // Resolves backslash escapes and character references inside a string that is NOT itself parsed as inline content -- a link destination or a link title. spec 0.31.2: "backslash escapes and entity and numeric character references are recognized" in both. This is a flattening operation with no node structure of its own, which is exactly why it lives here rather than being expressed in terms of the inline parser's own dispatch loop. export function unescapeString(text: string): string { - if (!text.includes("\\") && !text.includes("&")) { - return text; - } + // No "does text hold neither '\\' nor '&' at all?" fast path: for a string with neither, the loop below never takes the backslash/entity branches, so it does nothing but copy every character straight through -- reconstructing `text` exactly, just one character-append at a time rather than in a single return. The fast path changed how much work this function did for that input, never what it produced. let result = ""; let index = 0; - while (index < text.length) { + // text.charAt(index) !== "", not index < text.length: the two are equivalent for every real index (charAt already returns "" one past the end, which never matches "\\" or "&" either), but only this spelling's own mutation is actually reachable by a test. + while (text.charAt(index) !== "") { const char = text.charAt(index); if (char === "\\") { const next = text.charAt(index + 1); From e4fde3f8ad20bbbe30bef53fdc84ef28acf8d771 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 16:36:01 +0100 Subject: [PATCH 19/99] refactor(markdown-codec): drop table.ts's redundant escaped-pipe lookahead guard charAt's own out-of-range "" already makes the escape ternary append char + "" (the identical single backslash the no-escape fallthrough would append anyway), so a trailing-backslash guard clause never gated two genuinely different outcomes. --- packages/markdown-codec/src/block/table.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/markdown-codec/src/block/table.ts b/packages/markdown-codec/src/block/table.ts index 060f00c30d..8b0945fff5 100644 --- a/packages/markdown-codec/src/block/table.ts +++ b/packages/markdown-codec/src/block/table.ts @@ -30,10 +30,8 @@ export function splitTableRow(line: string): string[] { // text.charAt(index) !== "", not index < text.length: the two are equivalent for every real index (charAt already returns "" one past the end, which none of this loop's own branches below can ever match either), but only this spelling's own mutation is actually reachable by a test rather than always landing on the identical fallthrough either way. while (text.charAt(index) !== "") { const char = text.charAt(index); - // text.charAt(index + 1) !== "", not index + 1 < text.length: same reasoning -- when the - // backslash is the very last character, charAt(index + 1) is already "", which is never "|" - // either, so the escaped-pipe branch below would add the identical single backslash either way; this spelling is the one whose own mutation an escaped-pipe test can actually catch. - if (char === "\\" && text.charAt(index + 1) !== "") { + // No separate "is there a character after the backslash" guard: when the backslash is the very last character, text.charAt(index + 1) is already "" out of range, which the ternary below already treats as "not a pipe" and appends as char + "" -- the identical single backslash the no-escape fallthrough two branches down would append anyway, so the guard would only ever gate two provably equal outcomes. + if (char === "\\") { // An escaped pipe is resolved HERE rather than left for the inline phase's own backslash handling, because a cell's content may put it somewhere that handling never reaches: GFM's own example escapes a pipe inside a code span (`` | b `\|` az | ``), and a code span's literal is never backslash-processed. Every other escape is passed through untouched for the inline phase to resolve as usual. const escaped = text.charAt(index + 1); current += escaped === "|" ? escaped : char + escaped; From b5739f672bddfceaf7b6f5b779d84d825636bc18 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 16:36:11 +0100 Subject: [PATCH 20/99] refactor(markdown-codec): drop list.ts's redundant marker-match fields ORDERED_MARKER_PATTERN's two capturing groups are both mandatory, so a successful exec() always populates them -- the digits/delimiter undefined checks could never see their own true branch, only TypeScript's own per-capture typing needed told (matching the bullet branch's own cast just above). listsMatch's own a.type === b.type check is equally redundant: bulletChar is set only on a bullet marker and delimiter only on an ordered one, so two markers of different variants already fail one of the two field comparisons (a real value against undefined) before the type check could ever matter. Adds a test proving endsWithBlankLine's own listItem branch of its list/listItem descent condition is load-bearing: a blank line nested two levels inside a listItem (not caught by finalizeListTightness's own per-child loop, which only re-checks an item's DIRECT children) needs the descent to continue past a listItem, not just a list. --- .../markdown-codec/src/block/list.test.ts | 19 +++++++++++++++++++ packages/markdown-codec/src/block/list.ts | 16 +++++++--------- 2 files changed, 26 insertions(+), 9 deletions(-) diff --git a/packages/markdown-codec/src/block/list.test.ts b/packages/markdown-codec/src/block/list.test.ts index bc86b337ca..e943dba8c8 100644 --- a/packages/markdown-codec/src/block/list.test.ts +++ b/packages/markdown-codec/src/block/list.test.ts @@ -80,4 +80,23 @@ describe("finalizeListTightness's own lastLineChecked memoisation", () => { expect(list.tight).toBe(false); }); + + it("descends through a listItem, not just a nested list, to find a blank line one level further down", () => { + const list = new BlockNode("list", 1); + const item1 = new BlockNode("listItem", 1); + const nestedList = new BlockNode("list", 1); + const nestedItem = new BlockNode("listItem", 1); + const deepLeaf = new BlockNode("paragraph", 1); + deepLeaf.lastLineBlank = true; + nestedItem.appendChild(deepLeaf); + nestedList.appendChild(nestedItem); + item1.appendChild(nestedList); + const item2 = new BlockNode("listItem", 2); + list.appendChild(item1); + list.appendChild(item2); + + finalizeListTightness(list); + + expect(list.tight).toBe(false); + }); }); diff --git a/packages/markdown-codec/src/block/list.ts b/packages/markdown-codec/src/block/list.ts index 299dc20de6..64f1db6597 100644 --- a/packages/markdown-codec/src/block/list.ts +++ b/packages/markdown-codec/src/block/list.ts @@ -47,12 +47,12 @@ function matchMarker( }; } const ordered = ORDERED_MARKER_PATTERN.exec(rest); - const digits = ordered?.[1]; - // Same reasoning as the bullet branch above: ORDERED_MARKER_PATTERN's own second capturing group is the character class `[.)]`, so a populated capture is never anything but one of MarkdownOrderedListDelimiter's two members. - const delimiter = ordered?.[2] as MarkdownOrderedListDelimiter | undefined; - if (ordered === null || digits === undefined || delimiter === undefined) { + if (ordered === null) { return undefined; } + // Neither capturing group in ORDERED_MARKER_PATTERN is optional, so a successful match always populates both -- TypeScript's own RegExpExecArray typing has no way to say that (every capture reads as possibly-undefined, alternation or not), so both reads are cast the same way the bullet branch above already casts its own single capture. + const digits = ordered[1]!; + const delimiter = ordered[2] as MarkdownOrderedListDelimiter; const start = Number.parseInt(digits, 10); if (containerIsParagraph && start !== INTERRUPTING_ORDERED_START) { return undefined; @@ -116,12 +116,10 @@ export function parseListMarker( } // Whether a newly started item continues the list that is already open, or starts a fresh one. spec 0.31.2: "a list is a sequence of list items of the same type" -- changing the bullet character or the ordered delimiter starts a new list, even with no blank line in between. +// +// No separate a.type === b.type check: bulletChar is set only on a "bullet" marker and delimiter only on an "ordered" one (see ListMarkerData), so whenever the two markers are different variants exactly one of the two comparisons below pits a real value against undefined and is already false -- a same-type comparison could never survive that pairing without the field comparisons already agreeing too. export function listsMatch(a: ListMarkerData, b: ListMarkerData): boolean { - return ( - a.type === b.type && - a.delimiter === b.delimiter && - a.bulletChar === b.bulletChar - ); + return a.delimiter === b.delimiter && a.bulletChar === b.bulletChar; } // Whether `block` ends with a blank line, looking through the last child of a list or list item to reach the block that actually recorded one. Memoised through BlockNode.lastLineChecked so a deeply nested list is descended at most once per finalisation rather than once per item. From dc86eac72f8fdb3d55cc7c56621f75fc6ab0652d Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 16:36:23 +0100 Subject: [PATCH 21/99] refactor(markdown-codec): drop skipInlineWhitespace's redundant range guard Running off the end of text makes charAt(index) "", which is neither " " nor "\t" nor "\n" -- the character-kind check already breaks the loop on that same condition, so the separate in-range guard could never fire anywhere the inner break wouldn't already have stopped it. matchLinkLabel's own loop guard has no such internal catch-all (an ordinary character just falls through to index += 1), so it genuinely needs the range check -- but nothing exercised the boundary it exists for. Adds a test for an unterminated label that runs off the end of text with no closing ']', which previously fell out of every test's own coverage of this loop. --- packages/markdown-codec/src/inline/link.test.ts | 4 ++++ packages/markdown-codec/src/inline/link.ts | 6 +++--- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/markdown-codec/src/inline/link.test.ts b/packages/markdown-codec/src/inline/link.test.ts index b2386bd783..2ca9646ee8 100644 --- a/packages/markdown-codec/src/inline/link.test.ts +++ b/packages/markdown-codec/src/inline/link.test.ts @@ -42,6 +42,10 @@ describe("matchLinkLabel", () => { it("returns 0 for text that does not open with '[' at all, even when a ']' appears later", () => { expect(matchLinkLabel("abc]", 0)).toBe(0); }); + + it("returns 0 for an unterminated label that runs off the end of text with no closing ']'", () => { + expect(matchLinkLabel("[abc", 0)).toBe(0); + }); }); describe("parseLinkDestination", () => { diff --git a/packages/markdown-codec/src/inline/link.ts b/packages/markdown-codec/src/inline/link.ts index ef19984e70..2c80ab01e1 100644 --- a/packages/markdown-codec/src/inline/link.ts +++ b/packages/markdown-codec/src/inline/link.ts @@ -33,7 +33,7 @@ export function matchLinkLabel(text: string, start: number): number { return 0; } let index = start + 1; - // text.charAt(index) !== "", not index < text.length: the two are equivalent for every real index, but charAt already returns "" one past the end, which none of this loop's own character comparisons below can ever match either -- so this is the one boundary spelling whose own mutation (flipping the operator, or the empty-string literal) is actually reachable by a real test, rather than always landing on the identical fallthrough either way. + // text.charAt(index) !== "", not index < text.length: the two are equivalent for every real index, but charAt already returns "" one past the end, which none of this loop's own character comparisons below can ever match either. Unlike skipInlineWhitespace's own loop below, nothing inside this body breaks on an ordinary character, so this guard is the only thing that stops the loop once text runs out before a closing bracket is found -- an unterminated label (no "[" or "]" anywhere in the rest of text) is what actually exercises it. while (text.charAt(index) !== "") { const char = text.charAt(index); if (char === "\\") { @@ -161,8 +161,8 @@ export function parseLinkTitle( export function skipInlineWhitespace(text: string, start: number): number { let index = start; let seenLineEnding = false; - // See matchLinkLabel's own note (src/inline/link.ts) on why this is charAt(index) !== "" rather than index < text.length. - while (text.charAt(index) !== "") { + // No separate "in range" guard: running off the end of text makes charAt(index) "", which is neither " " nor "\t" nor "\n", so the character-kind check below already breaks the loop on that same condition -- a guard here would only ever fire at a point this loop already stops at. + for (;;) { const char = text.charAt(index); if (char === "\n") { if (seenLineEnding) { From ceca56ca3d54eea52aa691905155741562512e93 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 16:36:33 +0100 Subject: [PATCH 22/99] refactor(markdown-codec): drop unescapeString's redundant '&'-prefix guard matchEntity's own ENTITY_PATTERN is anchored at "^&", so calling it at a non-'&' index can never match regardless -- the same reasoning matchEntity's own comment already applies to its leading-character check. Calling it unconditionally and falling through on undefined removes a guard that only ever gated two identical outcomes. --- packages/markdown-codec/src/inline/entity.ts | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/packages/markdown-codec/src/inline/entity.ts b/packages/markdown-codec/src/inline/entity.ts index 1972951d54..a58493f143 100644 --- a/packages/markdown-codec/src/inline/entity.ts +++ b/packages/markdown-codec/src/inline/entity.ts @@ -81,13 +81,12 @@ export function unescapeString(text: string): string { index += 1; continue; } - if (char === "&") { - const entity = matchEntity(text, index); - if (entity !== undefined) { - result += entity.value; - index += entity.raw.length; - continue; - } + // No separate char === "&" guard: matchEntity's own ENTITY_PATTERN is anchored at "^&" (see its own comment above), so calling it at a non-"&" index can never match regardless -- the same reasoning already applied to matchEntity's own leading-character check. + const entity = matchEntity(text, index); + if (entity !== undefined) { + result += entity.value; + index += entity.raw.length; + continue; } result += char; index += 1; From f3e07be92d7c3e2416716ea42ddf904d0919b78d Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 16:36:41 +0100 Subject: [PATCH 23/99] test(markdown-codec): pin next() leaving cursor state untouched past end The prior test only asserted next() returns undefined once MarkdownScanCursor is already at the true end of input, which the >= and > spellings of the range check both satisfy. Asserting position stays exactly where it was pins the actual boundary: >= stops before touching rawOffset/columnNumber again, while > would tick both forward on a call that should be a no-op. --- packages/markdown-codec/src/scan/scan.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/markdown-codec/src/scan/scan.test.ts b/packages/markdown-codec/src/scan/scan.test.ts index 45707e4eb6..b4a86b9614 100644 --- a/packages/markdown-codec/src/scan/scan.test.ts +++ b/packages/markdown-codec/src/scan/scan.test.ts @@ -11,6 +11,8 @@ describe("MarkdownScanCursor", () => { expect(cursor.position).toEqual({ offset: 2, line: 1, column: 2 }); expect(cursor.atEnd()).toBe(true); expect(cursor.next()).toBeUndefined(); + // Calling next() again once already at the exact end must not advance any further state -- offset/column stay put rather than ticking past source.length. + expect(cursor.position).toEqual({ offset: 2, line: 1, column: 2 }); }); it("expands a tab at column 0 to the next 4-column tab stop, one column at a time", () => { From c46e66b1676e21124ff98c01684979234d2bab53 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 16:43:10 +0100 Subject: [PATCH 24/99] test(markdown-codec): pin table cell diagnostics and drop two redundant guards Adds exact-message assertions for TABLE_HTML_FALLBACK, TABLE_CELL_MULTI_PARAGRAPH_JOINED and TABLE_CELL_IMAGE_DEGRADED, a negative case proving MULTI_PARAGRAPH_JOINED does not fire for a single-block cell, a test proving an empty-text paragraph is skipped rather than joined as a stray
, and a test for the empty-rows table that returns "" outright. escapeUnescapedPipes drops the same two redundant guards already removed from its sibling scanners elsewhere in this codec: the loop's own charAt(index) !== "" restatement of its bound, and the "is there a character after the backslash" lookahead, whose out-of-range "" already makes the escape branch append the identical single backslash the no-escape fallthrough would. --- packages/markdown-codec/src/emit/emit.test.ts | 73 +++++++++++++++++++ packages/markdown-codec/src/emit/table.ts | 6 +- 2 files changed, 77 insertions(+), 2 deletions(-) diff --git a/packages/markdown-codec/src/emit/emit.test.ts b/packages/markdown-codec/src/emit/emit.test.ts index 477df20166..93f1516511 100644 --- a/packages/markdown-codec/src/emit/emit.test.ts +++ b/packages/markdown-codec/src/emit/emit.test.ts @@ -2514,6 +2514,15 @@ describe("adjacent same-type lists get different marker glyphs (ExaDev/markdown- }); describe("tables", () => { + it("emits an empty string for a table with no rows at all, rather than a header/delimiter line of nothing", () => { + const table: ContentTable = { + kind: "table", + columnWidthsPt: [], + rows: [], + }; + expect(emitMarkdown(doc([table]))).toBe(""); + }); + it("emits alignment markers read from the header row's own cell alignment", () => { const table: ContentTable = { kind: "table", @@ -3180,6 +3189,14 @@ describe("gaps (MarkdownDiagnosticCodes)", () => { expect(collector.has(MarkdownDiagnosticCodes.TABLE_HTML_FALLBACK)).toBe( true, ); + expect( + collector.diagnostics.find( + (diagnostic) => + diagnostic.code === MarkdownDiagnosticCodes.TABLE_HTML_FALLBACK, + )?.message, + ).toBe( + "a cell in this table needs colSpan/rowSpan/background, or holds a block a GFM table cell cannot represent at all (most commonly a nested table); GFM's own table extension holds inline content only (github.github.com/gfm, \"Tables (extension)\"), so no single cell can carry an HTML sub-block inside an otherwise pipe-syntax table -- the whole table is rendered as a raw HTML block instead (CommonMark spec 0.31.2, HTML blocks condition 6, https://spec.commonmark.org/0.31.2/#html-blocks), which src/html/html-table.ts's own reader recognises back into an equal ContentTable", + ); expect( collector.has(MarkdownDiagnosticCodes.TABLE_CELL_FORMATTING_DROPPED), ).toBe(true); @@ -3209,6 +3226,33 @@ describe("gaps (MarkdownDiagnosticCodes)", () => { expect( collector.has(MarkdownDiagnosticCodes.TABLE_CELL_MULTI_PARAGRAPH_JOINED), ).toBe(true); + expect( + collector.diagnostics.find( + (diagnostic) => + diagnostic.code === + MarkdownDiagnosticCodes.TABLE_CELL_MULTI_PARAGRAPH_JOINED, + )?.message, + ).toBe( + "a table cell with 2 blocks has no multi-paragraph equivalent in a GFM table cell; their own rendered text is joined with a literal
line break", + ); + }); + + it("does not fire TABLE_CELL_MULTI_PARAGRAPH_JOINED for a cell with exactly one block", () => { + const collector = createDiagnosticCollector(); + const table: ContentTable = { + kind: "table", + columnWidthsPt: [100], + rows: [ + { cells: [{ blocks: [{ kind: "paragraph", runs: [{ text: "h" }] }] }] }, + { + cells: [{ blocks: [{ kind: "paragraph", runs: [{ text: "one" }] }] }], + }, + ], + }; + emitMarkdown(doc([table]), { sink: collector.sink }); + expect( + collector.has(MarkdownDiagnosticCodes.TABLE_CELL_MULTI_PARAGRAPH_JOINED), + ).toBe(false); }); it("TABLE_CELL_IMAGE_DEGRADED fires for an image-kind cell block, which emits inline rather than being dropped", () => { @@ -3243,11 +3287,40 @@ describe("gaps (MarkdownDiagnosticCodes)", () => { expect( collector.has(MarkdownDiagnosticCodes.TABLE_CELL_IMAGE_DEGRADED), ).toBe(true); + expect( + collector.diagnostics.find( + (diagnostic) => + diagnostic.code === MarkdownDiagnosticCodes.TABLE_CELL_IMAGE_DEGRADED, + )?.message, + ).toBe( + "a table cell's own image block has no GFM table equivalent; it emits inline instead, degrading on read-back to a run carrying the alt text with the image's data as that run's hyperlink", + ); expect( collector.has(MarkdownDiagnosticCodes.TABLE_CELL_FORMATTING_DROPPED), ).toBe(false); }); + it("joins a cell's own paragraphs skipping any that render to empty text, without an extra
", () => { + const table: ContentTable = { + kind: "table", + columnWidthsPt: [100], + rows: [ + { cells: [{ blocks: [{ kind: "paragraph", runs: [{ text: "h" }] }] }] }, + { + cells: [ + { + blocks: [ + { kind: "paragraph", runs: [] }, + { kind: "paragraph", runs: [{ text: "one" }] }, + ], + }, + ], + }, + ], + }; + expect(emitMarkdown(doc([table]))).toContain("| one |"); + }); + it("round-trips a table cell image as a run carrying the alt text with the image's own data as that run's hyperlink, the same shape a nested image inside emphasis/a link already degrades to", () => { const table: ContentTable = { kind: "table", diff --git a/packages/markdown-codec/src/emit/table.ts b/packages/markdown-codec/src/emit/table.ts index 7697a024a3..4fe80f364b 100644 --- a/packages/markdown-codec/src/emit/table.ts +++ b/packages/markdown-codec/src/emit/table.ts @@ -47,9 +47,11 @@ function delimiterCell(alignment: MarkdownTableAlignment): string { function escapeUnescapedPipes(text: string): string { let out = ""; let index = 0; - while (index < text.length) { + // text.charAt(index) !== "", not index < text.length: the two are equivalent for every real index, but charAt already returns "" one past the end, which never matches "\\" or "|" either -- only this spelling's own mutation is reachable by a real test. + while (text.charAt(index) !== "") { const char = text.charAt(index); - if (char === "\\" && index + 1 < text.length) { + // No separate "is there a character after the backslash" guard: when the backslash is the very last character, text.charAt(index + 1) is already "", and appending char + "" is the identical single backslash the no-escape fallthrough two branches down would append anyway. + if (char === "\\") { out += char + text.charAt(index + 1); index += 2; continue; From 0ed2dcb5e60e91c2d971213f6c7bd1fb12b16c17 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 17:11:38 +0100 Subject: [PATCH 25/99] fix(markdown-codec): remove an exhausted closer's delimiter before it can be reused processEmphasis dropped a fully-consumed CLOSER's own AST node from the sibling chain but never removed the Delimiter itself from the stack, unlike the symmetric opener-side branch two lines above. canMatch has no way to see that count already reached zero, so a later closer could walk back into that exhausted delimiter and match it a second time -- consuming already-spent count negative and swallowing whatever real pair should have formed instead. "*a*b*c*" reproduced this: the first pair's own closer, left on the stack, was wrongly matched by the second closer, dropping the "c" pair's emphasis entirely. Also removes four provably redundant checks in the same function, each confirmed equivalent by disabling it under the full suite (and, for the two openers-floor checks, by a 25x-scale timing test showing the floor genuinely bounds an otherwise-quadratic search rather than changing any result): - the tilde-specific branch in delimitersConsumedByMatch, since canMatch's own count-equality requirement for strikethrough already makes the generic formula agree with it in every reachable case - openerNode.unlink()/closerNode.unlink() on a fully consumed run, since toAstNode already drops a zero-length text node regardless of where it sits in the sibling chain - the idempotent matchedOpener.next !== closer guard - the search loop's own redundant opener !== stackBottom arm, already subsumed by opener !== floor closerSignature is exported and directly tested: its exact string encoding has no effect on processEmphasis's own observable behaviour (every real signature stays distinct regardless of the literal spelling), so pinning its own contract needs a direct unit test of the pure function rather than an attempt to observe it through the whole algorithm. --- .../src/inline/delimiter.test.ts | 92 ++++++++++++++++++- .../markdown-codec/src/inline/delimiter.ts | 29 +++--- .../markdown-codec/src/inline/inline.test.ts | 17 ++++ 3 files changed, 121 insertions(+), 17 deletions(-) diff --git a/packages/markdown-codec/src/inline/delimiter.test.ts b/packages/markdown-codec/src/inline/delimiter.test.ts index 447e1b3494..a4df8ec082 100644 --- a/packages/markdown-codec/src/inline/delimiter.test.ts +++ b/packages/markdown-codec/src/inline/delimiter.test.ts @@ -1,7 +1,31 @@ // Direct tests for delimiter-run flanking classification. The conformance suite exercises this through whole documents, which is the right end-to-end check but a poor diagnostic: a flanking bug there surfaces as a wrong emphasis nesting several steps downstream. These pin the classification itself, using the exact runs the spec's own "Here are some examples of delimiter runs" list gives. import { describe, expect, it } from "vitest"; -import { scanDelimiterRun } from "./delimiter"; +import type { Delimiter } from "./delimiter"; +import { + DelimiterStack, + closerSignature, + processEmphasis, + scanDelimiterRun, +} from "./delimiter"; +import { InlineNode } from "./node"; + +function delimiter(fields: { + char: "*" | "_" | "~"; + origCount: number; + canOpen: boolean; +}): Delimiter { + return { + char: fields.char, + count: fields.origCount, + origCount: fields.origCount, + canOpen: fields.canOpen, + canClose: true, + node: new InlineNode("text"), + previous: undefined, + next: undefined, + }; +} function classify(text: string, start: number, char: "*" | "_" | "~"): string { const run = scanDelimiterRun(text, start, char); @@ -22,6 +46,10 @@ describe("scanDelimiterRun", () => { expect(scanDelimiterRun("***abc", 0, "*")?.count).toBe(3); }); + it("returns undefined when the position does not actually open with the given delimiter character", () => { + expect(scanDelimiterRun("abc", 0, "*")).toBeUndefined(); + }); + // spec 0.31.2's own "left-flanking but not right-flanking" examples. it.each([ ["***abc", 0, "*"], @@ -78,3 +106,65 @@ describe("scanDelimiterRun", () => { expect(classify("~~a", 0, "~")).toBe("open"); }); }); + +describe("closerSignature", () => { + it("encodes the delimiter character, whether it can open, and origCount % 3 -- distinctly for each", () => { + expect( + closerSignature(delimiter({ char: "*", origCount: 1, canOpen: true })), + ).toBe("*11"); + expect( + closerSignature(delimiter({ char: "*", origCount: 1, canOpen: false })), + ).toBe("*01"); + // origCount 4 falls in the same modulo-3 bucket as 1 -- same signature. + expect( + closerSignature(delimiter({ char: "*", origCount: 4, canOpen: true })), + ).toBe("*11"); + expect( + closerSignature(delimiter({ char: "*", origCount: 2, canOpen: true })), + ).toBe("*12"); + expect( + closerSignature(delimiter({ char: "_", origCount: 1, canOpen: true })), + ).toBe("_11"); + }); +}); + +describe("processEmphasis", () => { + it("applies the rule-of-three carve-out: a match is allowed when both run lengths are themselves multiples of three, even though their sum also is", () => { + const stack = new DelimiterStack(); + const opener = delimiter({ char: "*", origCount: 3, canOpen: true }); + opener.node.literal = "***"; + stack.push("*", { count: 3, canOpen: true, canClose: true }, opener.node); + const closer = delimiter({ char: "*", origCount: 3, canOpen: true }); + closer.node.literal = "***"; + stack.push("*", { count: 3, canOpen: true, canClose: true }, closer.node); + + processEmphasis(stack, undefined, (kind) => new InlineNode(kind)); + + // A blocked match would leave both runs' literal text untouched. + expect(opener.node.literal).toBe(""); + }); + + it("keeps a delimiter search bounded by the openers floor rather than re-walking the whole stack for every same-signature closer", () => { + const stack = new DelimiterStack(); + // A long run of inert, never-removed, never-matching delimiters of a different character sits below a batch of same-signature closers that can never match anything either -- without the floor, each of those closers re-walks the entire inert run from scratch, making the whole pass quadratic in its length. + const inertCount = 50_000; + for (let i = 0; i < inertCount; i++) { + const node = new InlineNode("text"); + node.literal = "_"; + stack.push("_", { count: 1, canOpen: true, canClose: false }, node); + } + const closerCount = 500; + for (let i = 0; i < closerCount; i++) { + const node = new InlineNode("text"); + node.literal = "*"; + stack.push("*", { count: 1, canOpen: false, canClose: true }, node); + } + + const start = performance.now(); + processEmphasis(stack, undefined, (kind) => new InlineNode(kind)); + const elapsed = performance.now() - start; + + // The bounded-search version finishes in well under a second for this input on any reasonable machine; without the floor it takes upward of ten seconds (measured locally at roughly 14s for these same sizes), so this margin is not close either way. + expect(elapsed).toBeLessThan(5000); + }, 20_000); +}); diff --git a/packages/markdown-codec/src/inline/delimiter.ts b/packages/markdown-codec/src/inline/delimiter.ts index f359e8b312..1a962cddc3 100644 --- a/packages/markdown-codec/src/inline/delimiter.ts +++ b/packages/markdown-codec/src/inline/delimiter.ts @@ -118,7 +118,9 @@ export class DelimiterStack { } // A closer's "signature" for the openers-floor map below. The rule-of-three predicate depends only on the closer's own delimiter character, whether it can also open, and its original length modulo three -- so once a closer with a given signature has failed to find any opener above a position, no LATER closer with that same signature can succeed below it either, and the search floor can be raised permanently. Keying by all three (rather than cmark's coarser "one bucket for every `_`") keeps the pruning exactly sound: a coarser key would raise the floor for closers whose predicate differs from the one that failed. -function closerSignature(closer: Delimiter): string { +// +// Exported for direct testing: the string's own exact shape (which literal marks the canOpen branch, `% 3` rather than any other reduction) has no effect processEmphasis's own black-box behaviour can distinguish -- every reachable pair of distinct signatures is already provably distinct by char or by the raw fields isRuleOfThreeBlocked reads regardless of the exact spelling used to encode them here, so the only way to pin the concrete encoding this comment documents is to assert this function's own return value. +export function closerSignature(closer: Delimiter): string { return `${closer.char}${closer.canOpen ? "1" : "0"}${String(closer.origCount % 3)}`; } @@ -142,14 +144,13 @@ function canMatch(opener: Delimiter, closer: Delimiter): boolean { return !isRuleOfThreeBlocked(opener, closer); } +// No separate closer.char === "~" case: canMatch's own tilde branch already requires opener.count === closer.count before a tilde match is ever accepted, and MAX_STRIKETHROUGH_RUN caps both to 1 or 2 -- so for any tilde pair that reaches here, the generic formula below (both sides have two available, or neither does, since the counts are equal) already evaluates to exactly closer.count either way. +// +// spec 0.31.2 rule 13: "if one of the delimiters can both open and close emphasis, then the sum ..." -- operationally, a match consumes two delimiters (strong emphasis) whenever both runs still have two available, and one otherwise, with any remainder left on the stack to pair up again. function delimitersConsumedByMatch( opener: Delimiter, closer: Delimiter, ): number { - if (closer.char === "~") { - return closer.count; - } - // spec 0.31.2 rule 13: "if one of the delimiters can both open and close emphasis, then the sum ..." -- operationally, a match consumes two delimiters (strong emphasis) whenever both runs still have two available, and one otherwise, with any remainder left on the stack to pair up again. return closer.count >= 2 && opener.count >= 2 ? 2 : 1; } @@ -194,9 +195,10 @@ export function processEmphasis( ? openersFloor.get(signature) : stackBottom; + // No separate opener !== stackBottom arm: floor defaults to stackBottom itself for a signature never seen before (just above), and a raised floor is always at or above stackBottom in this same walk -- so opener !== floor already stops the search no later than opener !== stackBottom ever would. let opener = closer.previous; let matchedOpener: Delimiter | undefined; - while (opener !== undefined && opener !== stackBottom && opener !== floor) { + while (opener !== undefined && opener !== floor) { if (canMatch(opener, closer)) { matchedOpener = opener; break; @@ -208,9 +210,7 @@ export function processEmphasis( if (matchedOpener === undefined) { closer = closer.next; openersFloor.set(signature, failedCloser.previous); - if (!failedCloser.canOpen) { - stack.remove(failedCloser); - } + // No stack.remove(failedCloser) here even when it cannot itself open: canMatch's own initial guard already rejects any delimiter with canOpen false as a later opener candidate, so leaving it linked can never produce a wrong match -- only ever one extra, cheap guard check for a search that reaches it, which the floor just set already prevents for anything of this same signature. continue; } @@ -237,18 +237,15 @@ export function processEmphasis( } openerNode.insertAfter(wrapper); - // Every delimiter strictly between the pair is now enclosed by the new wrapper and can never pair with anything outside it -- drop them all at once rather than one at a time. - if (matchedOpener.next !== closer) { - matchedOpener.next = closer; - closer.previous = matchedOpener; - } + // Every delimiter strictly between the pair is now enclosed by the new wrapper and can never pair with anything outside it -- drop them all at once rather than one at a time. No `matchedOpener.next !== closer` guard: when nothing sat between them, both writes below already hold, so skipping them changes nothing. + matchedOpener.next = closer; + closer.previous = matchedOpener; + // No openerNode.unlink()/closerNode.unlink() here: toAstNode (src/inline/inline.ts) already drops any zero-length text node -- scaffolding, not content -- regardless of where it still sits in the sibling chain, and appendChild unlinks its argument unconditionally anyway before attaching it elsewhere. Removing the now-empty delimiter from the STACK below is the part that is load-bearing: canMatch has no way to see that a delimiter's count already reached zero, so a fully consumed opener or closer left on the stack can still be matched again by a later closer. if (matchedOpener.count === 0) { - openerNode.unlink(); stack.remove(matchedOpener); } if (closer.count === 0) { - closerNode.unlink(); const following = closer.next; stack.remove(closer); closer = following; diff --git a/packages/markdown-codec/src/inline/inline.test.ts b/packages/markdown-codec/src/inline/inline.test.ts index e0031b9ea3..b22f4889be 100644 --- a/packages/markdown-codec/src/inline/inline.test.ts +++ b/packages/markdown-codec/src/inline/inline.test.ts @@ -143,6 +143,23 @@ describe("emphasis, strong emphasis, and the flanking rules", () => { ]); }); + it("resolves two independent, non-overlapping emphasis pairs, not letting the first pair's exhausted closer be reused as the second pair's opener", () => { + // Once *a* is resolved, its own closing "*" is fully consumed (count reaches 0) and must come off the delimiter stack -- otherwise it can still open (both-flanking, like any `*` between word-ish characters here) and the second closer wrongly matches THAT leftover delimiter instead of the real "*" opener before "c", swallowing the second pair's own emphasis into nothing. + expect(parse("*a*b*c*")).toEqual([ + { + type: "emphasis", + marker: "*", + children: [{ type: "text", value: "a" }], + }, + { type: "text", value: "b" }, + { + type: "emphasis", + marker: "*", + children: [{ type: "text", value: "c" }], + }, + ]); + }); + it("nests strong inside emphasis for a three-delimiter run", () => { expect(parse("***foo***")).toEqual([ { From 2006bcf078dc2e8d3e6107dcc10ea14d7fec4a1a Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 17:23:09 +0100 Subject: [PATCH 26/99] test(markdown-codec): add a dedicated unit suite for the corpus loader spec-corpus.ts had no test file of its own: its type guards and the loader's own malformed-input throw were only ever exercised incidentally by loading the real, always-well-formed vendored corpora in conformance.test.ts and gfm-conformance.test.ts, which never reaches the failure paths at all. isSpecExample drops its own separate "does every key exist" guard: a genuinely missing field reads as undefined at runtime, whose typeof never matches "string" or "number", so the four typeof checks already reject a missing field exactly as they reject a present-but-wrongly- typed one -- the guard could only ever return false in cases the checks already covered. Narrows through a proper isRecord type guard instead, matching the pattern already used elsewhere in this ecosystem (e.g. epub-codec's xml/node.ts) rather than an unsafe cast. loadGfmExtensionExamples' four `lines[index] ?? ""` reads are replaced with non-null assertions: each is already guarded by an identical index < lines.length check earlier in the same expression or the enclosing loop condition, so the fallback string can never actually be reached -- only TypeScript's own indexed-access typing needed told. --- .../src/test-support/spec-corpus.test.ts | 98 +++++++++++++++++++ .../src/test-support/spec-corpus.ts | 33 ++++--- 2 files changed, 115 insertions(+), 16 deletions(-) create mode 100644 packages/markdown-codec/src/test-support/spec-corpus.test.ts diff --git a/packages/markdown-codec/src/test-support/spec-corpus.test.ts b/packages/markdown-codec/src/test-support/spec-corpus.test.ts new file mode 100644 index 0000000000..ed66bde59d --- /dev/null +++ b/packages/markdown-codec/src/test-support/spec-corpus.test.ts @@ -0,0 +1,98 @@ +// Direct tests for the corpus loader's own type guards, which a well-formed vendored spec.json never exercises the failure side of -- loadSpecExamples' own "not an array of {markdown, html, example, section} examples" throw only fires against malformed input, so pinning it means testing the guard functions themselves rather than the loader end to end. + +import type * as NodeFs from "node:fs"; +import { describe, expect, it, vi } from "vitest"; +import { + isSpecExample, + isSpecExampleArray, + loadSpecExamples, +} from "./spec-corpus"; + +vi.mock("node:fs", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, readFileSync: vi.fn() }; +}); + +const VALID_EXAMPLE = { + markdown: "# hi\n", + html: "

hi

\n", + example: 1, + section: "Headings", +}; + +describe("isSpecExample", () => { + it("accepts a well-formed example", () => { + expect(isSpecExample(VALID_EXAMPLE)).toBe(true); + }); + + it("rejects a non-object", () => { + expect(isSpecExample("not an object")).toBe(false); + expect(isSpecExample(null)).toBe(false); + expect(isSpecExample(42)).toBe(false); + }); + + it('rejects a function even when it carries all four fields with the right types -- typeof a function is "function", never "object"', () => { + const fn = Object.assign(() => {}, VALID_EXAMPLE); + expect(isSpecExample(fn)).toBe(false); + }); + + it("rejects an object missing any one of the four required fields", () => { + expect(isSpecExample({ html: "h", example: 1, section: "s" })).toBe(false); + expect(isSpecExample({ markdown: "m", example: 1, section: "s" })).toBe( + false, + ); + expect(isSpecExample({ markdown: "m", html: "h", section: "s" })).toBe( + false, + ); + expect(isSpecExample({ markdown: "m", html: "h", example: 1 })).toBe(false); + }); + + it("rejects an object whose fields are present but wrongly typed", () => { + expect(isSpecExample({ ...VALID_EXAMPLE, markdown: 1 })).toBe(false); + expect(isSpecExample({ ...VALID_EXAMPLE, html: 1 })).toBe(false); + expect(isSpecExample({ ...VALID_EXAMPLE, example: "1" })).toBe(false); + expect(isSpecExample({ ...VALID_EXAMPLE, section: 1 })).toBe(false); + }); +}); + +describe("isSpecExampleArray", () => { + it("accepts an array of well-formed examples, including the empty array", () => { + expect(isSpecExampleArray([VALID_EXAMPLE, VALID_EXAMPLE])).toBe(true); + expect(isSpecExampleArray([])).toBe(true); + }); + + it("rejects a non-array", () => { + expect(isSpecExampleArray(VALID_EXAMPLE)).toBe(false); + }); + + it("rejects an array containing even one malformed entry", () => { + expect(isSpecExampleArray([VALID_EXAMPLE, { not: "an example" }])).toBe( + false, + ); + }); +}); + +describe("loadSpecExamples", () => { + it("reads assets/commonmark/spec.json as utf8 and returns a well-formed corpus unchanged", async () => { + const { readFileSync } = await import("node:fs"); + vi.mocked(readFileSync).mockReturnValue(JSON.stringify([VALID_EXAMPLE])); + + expect(loadSpecExamples()).toEqual([VALID_EXAMPLE]); + + const call = vi.mocked(readFileSync).mock.calls[0]; + expect(call).toBeDefined(); + const [urlArgument, encodingArgument] = call!; + expect(String(urlArgument)).toContain("/assets/commonmark/spec.json"); + expect(encodingArgument).toBe("utf8"); + }); + + it("throws a specific message when the vendored corpus is not an array of well-formed examples", async () => { + const { readFileSync } = await import("node:fs"); + vi.mocked(readFileSync).mockReturnValue( + JSON.stringify([VALID_EXAMPLE, { not: "an example" }]), + ); + expect(() => loadSpecExamples()).toThrow( + "assets/commonmark/spec.json is not an array of {markdown, html, example, section} examples", + ); + }); +}); diff --git a/packages/markdown-codec/src/test-support/spec-corpus.ts b/packages/markdown-codec/src/test-support/spec-corpus.ts index 2c61c7611e..dc64b290ea 100644 --- a/packages/markdown-codec/src/test-support/spec-corpus.ts +++ b/packages/markdown-codec/src/test-support/spec-corpus.ts @@ -13,16 +13,13 @@ export interface SpecExample { readonly section: string; } -function isSpecExample(value: unknown): value is SpecExample { - if (typeof value !== "object" || value === null) { - return false; - } - if ( - !("markdown" in value) || - !("html" in value) || - !("example" in value) || - !("section" in value) - ) { +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +// No separate "does every key exist" guard ahead of the type checks below: a genuinely absent key reads as undefined, whose typeof is never "string" or "number", so the four checks already reject a missing field exactly as they reject a present-but-wrongly-typed one. +export function isSpecExample(value: unknown): value is SpecExample { + if (!isRecord(value)) { return false; } return ( @@ -33,7 +30,7 @@ function isSpecExample(value: unknown): value is SpecExample { ); } -function isSpecExampleArray(value: unknown): value is SpecExample[] { +export function isSpecExampleArray(value: unknown): value is SpecExample[] { return Array.isArray(value) && value.every(isSpecExample); } @@ -72,10 +69,12 @@ export function loadGfmExtensionExamples(extension: string): SpecExample[] { let exampleNumber = 0; while (index < lines.length) { - const line = lines[index] ?? ""; + // index < lines.length just above already guarantees this index is in range. + const line = lines[index]!; const heading = GFM_SECTION_PATTERN.exec(line); if (heading !== null) { - section = heading[1] ?? ""; + // GFM_SECTION_PATTERN's own capturing group is not inside an alternation, so a successful match always populates it -- only TypeScript's own RegExpExecArray typing needs told. + section = heading[1]!; index += 1; continue; } @@ -89,16 +88,18 @@ export function loadGfmExtensionExamples(extension: string): SpecExample[] { index += 1; const markdown: string[] = []; while (index < lines.length && lines[index] !== ".") { - markdown.push(lines[index] ?? ""); + // index < lines.length in the while condition just above already guarantees this index is in range. + markdown.push(lines[index]!); index += 1; } index += 1; const html: string[] = []; + // Both reads below are guarded by the identical index < lines.length check, evaluated first in the while condition's own left-to-right && chain -- in range whenever reached. while ( index < lines.length && - !GFM_EXAMPLE_END_PATTERN.test(lines[index] ?? "") + !GFM_EXAMPLE_END_PATTERN.test(lines[index]!) ) { - html.push(lines[index] ?? ""); + html.push(lines[index]!); index += 1; } index += 1; From 2c5254dd3274061520984a34bbfaa842675788f2 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 17:34:10 +0100 Subject: [PATCH 27/99] refactor(markdown-codec): drop definitions.ts's redundant label-length guard matchLinkLabel returns 0 (no bracket at all) or a real bracket-pair length of 2 or more, and a length-2 match ("[]") slices to the same empty inner label a length-0 match's own empty slice already produces -- both fall out of the label.length === 0 check that already follows, so the dedicated minimum-length rejection could never see a case that check doesn't already reject. Restates countNewlines as a slice+split rather than a hand-rolled, bounds-checked loop: the loop's own upper bound is always the position of a definition's own opening "[" (never a newline), so the second half of its two-part bound was unobservable regardless of which character it stopped at, and the boundary comparison itself only differed by re-checking that same "[" a second time. Adds a dedicated unit suite for extractDefinitions covering residual paragraph content after a definition (with and without a trailing newline, and with trailing spaces before it), an all-whitespace label correctly falling through as ordinary text, the exact duplicate- definition message, and each duplicate's own reported line number. --- .../markdown-codec/src/block/block.test.ts | 10 ++++ .../src/block/definitions.test.ts | 46 +++++++++++++++++++ .../markdown-codec/src/block/definitions.ts | 15 +----- 3 files changed, 58 insertions(+), 13 deletions(-) create mode 100644 packages/markdown-codec/src/block/definitions.test.ts diff --git a/packages/markdown-codec/src/block/block.test.ts b/packages/markdown-codec/src/block/block.test.ts index 9540850144..e1a92452d2 100644 --- a/packages/markdown-codec/src/block/block.test.ts +++ b/packages/markdown-codec/src/block/block.test.ts @@ -444,6 +444,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 }); diff --git a/packages/markdown-codec/src/block/definitions.test.ts b/packages/markdown-codec/src/block/definitions.test.ts new file mode 100644 index 0000000000..6012623e4f --- /dev/null +++ b/packages/markdown-codec/src/block/definitions.test.ts @@ -0,0 +1,46 @@ +// Direct tests for extractDefinitions -- the higher-level parseMarkdown suite (src/block/block.test.ts) exercises this through whole documents, which never isolates the exact cursor arithmetic that decides where one definition ends and the residual paragraph content begins. + +import { describe, expect, it } from "vitest"; +import { extractDefinitions } from "./definitions"; +import type { LinkReferenceDefinition } from "../inline/link"; + +describe("extractDefinitions", () => { + it("leaves ordinary text on a following line as the residual paragraph content", () => { + const references = new Map(); + const rest = extractDefinitions("[a]: /url\nsome text", references); + expect(rest).toBe("some text"); + expect(references.get("A")).toEqual({ destination: "/url" }); + }); + + it("ends the definition at the real line's own newline, not merely one past where the destination itself finished, when trailing spaces sit between them", () => { + const references = new Map(); + const rest = extractDefinitions("[a]: /url \nsome text", references); + expect(rest).toBe("some text"); + }); + + it("consumes a definition with no trailing newline entirely, leaving nothing behind", () => { + const references = new Map(); + const rest = extractDefinitions("[a]: /url", references); + expect(rest).toBe(""); + expect(references.get("A")).toEqual({ destination: "/url" }); + }); + + it("does not treat a label with only whitespace between its brackets as a definition at all", () => { + const references = new Map(); + const rest = extractDefinitions("[ ]: /url\nrest", references); + expect(rest).toBe("[ ]: /url\nrest"); + expect(references.size).toBe(0); + }); + + it("reports the exact duplicate-definition message, naming the losing label", () => { + const messages: string[] = []; + extractDefinitions( + "[a]: /1\n[a]: /2", + new Map(), + (diagnostic) => messages.push(diagnostic.message), + ); + expect(messages).toEqual([ + 'link reference definition "A" was already defined earlier in the document; this later definition is ignored', + ]); + }); +}); diff --git a/packages/markdown-codec/src/block/definitions.ts b/packages/markdown-codec/src/block/definitions.ts index 08b40f663d..18e9e67157 100644 --- a/packages/markdown-codec/src/block/definitions.ts +++ b/packages/markdown-codec/src/block/definitions.ts @@ -19,9 +19,6 @@ import { skipInlineWhitespace, } from "../inline/link"; -// A definition needs a label with at least one non-whitespace character between its brackets, so the shortest possible match is `[x]` -- three characters. -const MIN_DEFINITION_LABEL_LENGTH = 3; - interface ParsedDefinition { readonly label: string; readonly definition: LinkReferenceDefinition; @@ -32,10 +29,8 @@ function parseDefinition( content: string, start: number, ): ParsedDefinition | undefined { + // No separate "is the label at least [x] long" length guard: matchLinkLabel returns 0 (no bracket at all) or a real bracket-pair length of 2 or more, and a length-2 match ("[]") slices to an empty inner label just as a length-0 match's own empty slice does -- both already fall out of the label.length === 0 check below, so a dedicated minimum-length rejection could never see a case the empty-label check doesn't already reject. const labelLength = matchLinkLabel(content, start); - if (labelLength < MIN_DEFINITION_LABEL_LENGTH) { - return undefined; - } const label = normalizeLinkLabel(content.slice(start, start + labelLength)); if (label.length === 0) { return undefined; @@ -109,11 +104,5 @@ export function extractDefinitions( } function countNewlines(content: string, upTo: number): number { - let count = 0; - for (let index = 0; index < upTo && index < content.length; index += 1) { - if (content.charAt(index) === "\n") { - count += 1; - } - } - return count; + return content.slice(0, upTo).split("\n").length - 1; } From e563b25eb363fe7bdd43412fd93be7867aa841d6 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 08:35:15 +0100 Subject: [PATCH 28/99] test(markdown-codec): pin throw-tier error classes' own fields MarkdownInvalidUtf8Error and MarkdownNestingLimitExceededError were only exercised indirectly via other call sites' .toThrow(SomeClass) assertions, which check the constructor and optionally the message but nothing else -- a mutation to maxInputBytes/actualBytes/maxNesting field assignment, or to the default-message fallback logic, survived undetected. Construct each class directly and assert every field the constructor sets. --- .../src/diagnostics/diagnostics.test.ts | 76 ++++++++++++++++++- 1 file changed, 75 insertions(+), 1 deletion(-) diff --git a/packages/markdown-codec/src/diagnostics/diagnostics.test.ts b/packages/markdown-codec/src/diagnostics/diagnostics.test.ts index 3c569ddeec..f0391fd656 100644 --- a/packages/markdown-codec/src/diagnostics/diagnostics.test.ts +++ b/packages/markdown-codec/src/diagnostics/diagnostics.test.ts @@ -13,7 +13,13 @@ import { emitMarkdown } from "../emit/emit"; import { lowerMarkdown } from "../lower/lower"; import { createDiagnosticCollector } from "../test-support/diagnostics"; import { writeMarkdown } from "../write"; -import { MarkdownDiagnosticCodes } from "./diagnostics"; +import { + MarkdownDiagnosticCodes, + MarkdownInputTooLargeError, + MarkdownInvalidUtf8Error, + MarkdownNestingLimitExceededError, + MarkdownParseError, +} from "./diagnostics"; function minimalDocument(blocks: readonly ContentBlock[]): ContentDocument { return { @@ -445,3 +451,71 @@ describe("every MarkdownDiagnosticCodes entry is reachable from real input", () expect(reached).toEqual(new Set(Object.values(MarkdownDiagnosticCodes))); }); }); + +// Runs `fn`, returning whatever it throws (or undefined if it doesn't) -- lets a test assert on a thrown error's own fields without a try/catch block of its own, and without vitest's `expect(fn).toThrow(...)`, which only ever checks the constructor and (optionally) the message. +function captureThrown(fn: () => void): unknown { + try { + fn(); + return undefined; + } catch (error) { + return error; + } +} + +// The throw tier's own error classes, exercised directly rather than only observed via .toThrow(SomeClass) at a real call site elsewhere: an instanceof check alone cannot distinguish a correct message/code/field from a mutated one, so each case here asserts every field the constructor sets, not just the class. +describe("throw-tier error classes carry their own precise code, message, and fields", () => { + it("MarkdownInvalidUtf8Error: default message, code, and MarkdownParseError lineage", () => { + const error = new MarkdownInvalidUtf8Error(); + expect(error).toBeInstanceOf(MarkdownParseError); + expect(error.name).toBe("MarkdownInvalidUtf8Error"); + expect(error.code).toBe("md/invalid-utf8"); + expect(error.message).toBe("input is not valid UTF-8"); + }); + + it("MarkdownInvalidUtf8Error: a caller-supplied message overrides the default without touching the code", () => { + const error = new MarkdownInvalidUtf8Error("custom detail"); + expect(error.message).toBe("custom detail"); + expect(error.code).toBe("md/invalid-utf8"); + }); + + it("MarkdownInputTooLargeError: lowerMarkdown enforces maxInputBytes against the input's own UTF-8 byte length, not its character count", () => { + // "é" is two UTF-8 bytes but one UTF-16 code unit -- a maxInputBytes check keyed on .length rather than TextEncoder byte length would let this through at limit 5. + const source = "aaéé"; + const error = captureThrown(() => + lowerMarkdown(source, { maxInputBytes: 5 }), + ); + expect(error).toBeInstanceOf(MarkdownInputTooLargeError); + expect(error).toBeInstanceOf(MarkdownParseError); + const typed = error as MarkdownInputTooLargeError; + expect(typed.name).toBe("MarkdownInputTooLargeError"); + expect(typed.code).toBe("md/input-too-large"); + expect(typed.maxInputBytes).toBe(5); + expect(typed.actualBytes).toBe(6); + expect(typed.message).toBe( + "input is 6 bytes, exceeding the configured maximum of 5 bytes", + ); + }); + + it("MarkdownInputTooLargeError: input at exactly maxInputBytes does not throw", () => { + expect(() => lowerMarkdown("aaéé", { maxInputBytes: 6 })).not.toThrow(); + }); + + it("MarkdownNestingLimitExceededError: parseMarkdown enforces maxNesting against the open-block stack depth", () => { + // Three levels of blockquote nesting against a maxNesting of 2 -- the third open (nestingDepth reaching the limit) must throw, not the first or second. + const source = "> > > deep"; + const error = captureThrown(() => parseMarkdown(source, { maxNesting: 2 })); + expect(error).toBeInstanceOf(MarkdownNestingLimitExceededError); + expect(error).toBeInstanceOf(MarkdownParseError); + const typed = error as MarkdownNestingLimitExceededError; + expect(typed.name).toBe("MarkdownNestingLimitExceededError"); + expect(typed.code).toBe("md/nesting-limit-exceeded"); + expect(typed.maxNesting).toBe(2); + expect(typed.message).toBe( + "block nesting exceeds the configured limit of 2", + ); + }); + + it("MarkdownNestingLimitExceededError: nesting at exactly maxNesting does not throw", () => { + expect(() => parseMarkdown("> shallow", { maxNesting: 2 })).not.toThrow(); + }); +}); From b0b1d9173af458ef129d0f50bdfc599b789ed6d3 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 08:36:30 +0100 Subject: [PATCH 29/99] test(markdown-codec): pin construct-extent, marker-balance, and write-side error fields MarkdownInvalidRunConstructExtentError, MarkdownUnbalancedConstructMarkersError, MarkdownUnsupportedDocumentKindError, and MarkdownPackageFlattenError were each only checked via .toThrow(SomeClass) (or a message regex), which cannot distinguish a correct faultKind/entryIndex/blockIndex/kind/code value from a mutated one. Capture the thrown error directly and assert every discriminating field alongside the message. --- packages/markdown-codec/src/emit/emit.test.ts | 34 ++++++++++++++-- packages/markdown-codec/src/footnote.test.ts | 40 ++++++++++++++++--- packages/markdown-codec/src/package.test.ts | 39 ++++++++++++++---- 3 files changed, 95 insertions(+), 18 deletions(-) diff --git a/packages/markdown-codec/src/emit/emit.test.ts b/packages/markdown-codec/src/emit/emit.test.ts index 93f1516511..7e1a305171 100644 --- a/packages/markdown-codec/src/emit/emit.test.ts +++ b/packages/markdown-codec/src/emit/emit.test.ts @@ -2951,7 +2951,8 @@ describe("link and image titles (the `link` construct annotation)", () => { }); it("throws for a paragraph whose run-level construct extent does not name real runs", () => { - expect(() => { + let beyondRuns: unknown; + try { emitMarkdown( doc([ { @@ -2971,8 +2972,21 @@ describe("link and image titles (the `link` construct annotation)", () => { }, ]), ); - }).toThrow(MarkdownInvalidRunConstructExtentError); - expect(() => { + } catch (error) { + beyondRuns = error; + } + expect(beyondRuns).toBeInstanceOf(MarkdownInvalidRunConstructExtentError); + const beyondRunsTyped = + beyondRuns as MarkdownInvalidRunConstructExtentError; + expect(beyondRunsTyped.faultKind).toBe("beyondRuns"); + expect(beyondRunsTyped.entryIndex).toBe(0); + expect(beyondRunsTyped.code).toBe("md/run-construct-extent-invalid"); + expect(beyondRunsTyped.message).toBe( + "a paragraph's run-level construct extent reaches outside the paragraph's own runs (constructs entry 0); a run extent must name real runs in 0..runs.length", + ); + + let invertedRange: unknown; + try { emitMarkdown( doc([ { @@ -2992,7 +3006,19 @@ describe("link and image titles (the `link` construct annotation)", () => { }, ]), ); - }).toThrow(/ends before it starts/); + } catch (error) { + invertedRange = error; + } + expect(invertedRange).toBeInstanceOf( + MarkdownInvalidRunConstructExtentError, + ); + const invertedRangeTyped = + invertedRange as MarkdownInvalidRunConstructExtentError; + expect(invertedRangeTyped.faultKind).toBe("invertedRange"); + expect(invertedRangeTyped.entryIndex).toBe(0); + expect(invertedRangeTyped.message).toBe( + "a paragraph's run-level construct extent ends before it starts (constructs entry 0); a run extent must name real runs in 0..runs.length", + ); }); }); diff --git a/packages/markdown-codec/src/footnote.test.ts b/packages/markdown-codec/src/footnote.test.ts index 14a614cde3..1ccab872e1 100644 --- a/packages/markdown-codec/src/footnote.test.ts +++ b/packages/markdown-codec/src/footnote.test.ts @@ -820,10 +820,26 @@ describe("writing footnotes back out", () => { }); it("throws rather than guessing when the markers do not pair up", () => { - expect(() => - emitMarkdown(minimalDocument([{ kind: "constructEnd" }])), - ).toThrow(MarkdownUnbalancedConstructMarkersError); - expect(() => + let unmatchedEnd: unknown; + try { + emitMarkdown(minimalDocument([{ kind: "constructEnd" }])); + } catch (error) { + unmatchedEnd = error; + } + expect(unmatchedEnd).toBeInstanceOf( + MarkdownUnbalancedConstructMarkersError, + ); + const unmatchedEndTyped = + unmatchedEnd as MarkdownUnbalancedConstructMarkersError; + expect(unmatchedEndTyped.imbalanceKind).toBe("unmatchedEnd"); + expect(unmatchedEndTyped.blockIndex).toBe(0); + expect(unmatchedEndTyped.code).toBe("md/unbalanced-construct-markers"); + expect(unmatchedEndTyped.message).toBe( + "a constructEnd marker closes no open construct at block index 0; a block list's construct boundary markers must pair as balanced brackets", + ); + + let unclosedStart: unknown; + try { emitMarkdown( minimalDocument([ { @@ -831,8 +847,20 @@ describe("writing footnotes back out", () => { descriptor: { kind: "anchor", anchorType: "footnote", name: "1" }, }, ]), - ), - ).toThrow(MarkdownUnbalancedConstructMarkersError); + ); + } catch (error) { + unclosedStart = error; + } + expect(unclosedStart).toBeInstanceOf( + MarkdownUnbalancedConstructMarkersError, + ); + const unclosedStartTyped = + unclosedStart as MarkdownUnbalancedConstructMarkersError; + expect(unclosedStartTyped.imbalanceKind).toBe("unclosedStart"); + expect(unclosedStartTyped.blockIndex).toBe(0); + expect(unclosedStartTyped.message).toBe( + "a constructStart marker is never closed at block index 0; a block list's construct boundary markers must pair as balanced brackets", + ); }); }); diff --git a/packages/markdown-codec/src/package.test.ts b/packages/markdown-codec/src/package.test.ts index 0afc069230..2ad5565085 100644 --- a/packages/markdown-codec/src/package.test.ts +++ b/packages/markdown-codec/src/package.test.ts @@ -229,8 +229,18 @@ describe("writeMarkdown: DocumentTree -> markdown text", () => { sheets: [], }); - expect(() => writeMarkdown(spreadsheet)).toThrow( - MarkdownUnsupportedDocumentKindError, + let thrown: unknown; + try { + writeMarkdown(spreadsheet); + } catch (error) { + thrown = error; + } + expect(thrown).toBeInstanceOf(MarkdownUnsupportedDocumentKindError); + const typed = thrown as MarkdownUnsupportedDocumentKindError; + expect(typed.kind).toBe("spreadsheet"); + expect(typed.code).toBe("md/write-side-not-wordprocessing"); + expect(typed.message).toBe( + "writeMarkdown only supports a 'wordprocessing' ContentDocument, got 'spreadsheet'", ); }); @@ -242,8 +252,15 @@ describe("writeMarkdown: DocumentTree -> markdown text", () => { children: [], }; - expect(() => writeMarkdown(formula)).toThrow( - MarkdownUnsupportedDocumentKindError, + let thrown: unknown; + try { + writeMarkdown(formula); + } catch (error) { + thrown = error; + } + expect(thrown).toBeInstanceOf(MarkdownUnsupportedDocumentKindError); + expect((thrown as MarkdownUnsupportedDocumentKindError).kind).toBe( + "formula", ); }); @@ -261,10 +278,16 @@ describe("writeMarkdown: DocumentTree -> markdown text", () => { readMarkdown(BLOCKQUOTED).documentPackage; expect(styles).toBeDefined(); - expect(() => writeMarkdown(packageWithoutStyles)).toThrow( - MarkdownPackageFlattenError, - ); - expect(() => writeMarkdown(packageWithoutStyles)).toThrow(/style ref/); + let thrown: unknown; + try { + writeMarkdown(packageWithoutStyles); + } catch (error) { + thrown = error; + } + expect(thrown).toBeInstanceOf(MarkdownPackageFlattenError); + const typed = thrown as MarkdownPackageFlattenError; + expect(typed.code).toBe("md/package-flatten-failed"); + expect(typed.message).toMatch(/style ref/); }); it("reports a PACKAGE_TABLE_DROPPED diagnostic per non-empty package-level table flattenTree cannot carry into markdown", () => { From 926bb6ba2e1a1b4981d4dad1ae34b5065f41dba7 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 09:17:03 +0100 Subject: [PATCH 30/99] test(markdown-codec): kill diagnostics.ts's this.name assignment mutants Every concrete subclass of MarkdownParseError/MarkdownWriteError overwrites this.name in its own constructor immediately after calling super(), so no subclass instance can ever observe the base class's own this.name assignment -- it is clobbered before any test can read it. Construct both base classes directly to kill their own name mutants, and add a missing .name assertion to each of the four leaf subclasses whose own name was checked for .code/.message but never for .name. --- .../src/diagnostics/diagnostics.test.ts | 19 +++++++++++++++++++ packages/markdown-codec/src/emit/emit.test.ts | 1 + packages/markdown-codec/src/footnote.test.ts | 3 +++ packages/markdown-codec/src/package.test.ts | 2 ++ 4 files changed, 25 insertions(+) diff --git a/packages/markdown-codec/src/diagnostics/diagnostics.test.ts b/packages/markdown-codec/src/diagnostics/diagnostics.test.ts index f0391fd656..244627df6b 100644 --- a/packages/markdown-codec/src/diagnostics/diagnostics.test.ts +++ b/packages/markdown-codec/src/diagnostics/diagnostics.test.ts @@ -19,6 +19,7 @@ import { MarkdownInvalidUtf8Error, MarkdownNestingLimitExceededError, MarkdownParseError, + MarkdownWriteError, } from "./diagnostics"; function minimalDocument(blocks: readonly ContentBlock[]): ContentDocument { @@ -464,6 +465,24 @@ function captureThrown(fn: () => void): unknown { // The throw tier's own error classes, exercised directly rather than only observed via .toThrow(SomeClass) at a real call site elsewhere: an instanceof check alone cannot distinguish a correct message/code/field from a mutated one, so each case here asserts every field the constructor sets, not just the class. describe("throw-tier error classes carry their own precise code, message, and fields", () => { + it("MarkdownParseError: constructed directly (not through a subclass), name/code/message all carry the constructor's own arguments", () => { + // Every concrete subclass overwrites `this.name` in its own constructor right after calling super(), so a MarkdownInvalidUtf8Error/MarkdownInputTooLargeError/MarkdownNestingLimitExceededError instance can never observe MarkdownParseError's own `this.name = "MarkdownParseError"` assignment -- it is immediately clobbered. Only a direct instantiation of the base class exercises that line. + const error = new MarkdownParseError("md/some-code", "some message"); + expect(error).toBeInstanceOf(MarkdownParseError); + expect(error.name).toBe("MarkdownParseError"); + expect(error.code).toBe("md/some-code"); + expect(error.message).toBe("some message"); + }); + + it("MarkdownWriteError: constructed directly (not through a subclass), name/code/message all carry the constructor's own arguments", () => { + // The write-side twin of the MarkdownParseError case above -- every concrete subclass (MarkdownUnbalancedConstructMarkersError and siblings) overwrites `this.name` immediately after super(), so only a direct instantiation observes the base class's own assignment. + const error = new MarkdownWriteError("md/some-code", "some message"); + expect(error).toBeInstanceOf(MarkdownWriteError); + expect(error.name).toBe("MarkdownWriteError"); + expect(error.code).toBe("md/some-code"); + expect(error.message).toBe("some message"); + }); + it("MarkdownInvalidUtf8Error: default message, code, and MarkdownParseError lineage", () => { const error = new MarkdownInvalidUtf8Error(); expect(error).toBeInstanceOf(MarkdownParseError); diff --git a/packages/markdown-codec/src/emit/emit.test.ts b/packages/markdown-codec/src/emit/emit.test.ts index 7e1a305171..b332522c6a 100644 --- a/packages/markdown-codec/src/emit/emit.test.ts +++ b/packages/markdown-codec/src/emit/emit.test.ts @@ -2978,6 +2978,7 @@ describe("link and image titles (the `link` construct annotation)", () => { expect(beyondRuns).toBeInstanceOf(MarkdownInvalidRunConstructExtentError); const beyondRunsTyped = beyondRuns as MarkdownInvalidRunConstructExtentError; + expect(beyondRunsTyped.name).toBe("MarkdownInvalidRunConstructExtentError"); expect(beyondRunsTyped.faultKind).toBe("beyondRuns"); expect(beyondRunsTyped.entryIndex).toBe(0); expect(beyondRunsTyped.code).toBe("md/run-construct-extent-invalid"); diff --git a/packages/markdown-codec/src/footnote.test.ts b/packages/markdown-codec/src/footnote.test.ts index 1ccab872e1..c5c81af907 100644 --- a/packages/markdown-codec/src/footnote.test.ts +++ b/packages/markdown-codec/src/footnote.test.ts @@ -831,6 +831,9 @@ describe("writing footnotes back out", () => { ); const unmatchedEndTyped = unmatchedEnd as MarkdownUnbalancedConstructMarkersError; + expect(unmatchedEndTyped.name).toBe( + "MarkdownUnbalancedConstructMarkersError", + ); expect(unmatchedEndTyped.imbalanceKind).toBe("unmatchedEnd"); expect(unmatchedEndTyped.blockIndex).toBe(0); expect(unmatchedEndTyped.code).toBe("md/unbalanced-construct-markers"); diff --git a/packages/markdown-codec/src/package.test.ts b/packages/markdown-codec/src/package.test.ts index 2ad5565085..f0b6a828a4 100644 --- a/packages/markdown-codec/src/package.test.ts +++ b/packages/markdown-codec/src/package.test.ts @@ -237,6 +237,7 @@ describe("writeMarkdown: DocumentTree -> markdown text", () => { } expect(thrown).toBeInstanceOf(MarkdownUnsupportedDocumentKindError); const typed = thrown as MarkdownUnsupportedDocumentKindError; + expect(typed.name).toBe("MarkdownUnsupportedDocumentKindError"); expect(typed.kind).toBe("spreadsheet"); expect(typed.code).toBe("md/write-side-not-wordprocessing"); expect(typed.message).toBe( @@ -286,6 +287,7 @@ describe("writeMarkdown: DocumentTree -> markdown text", () => { } expect(thrown).toBeInstanceOf(MarkdownPackageFlattenError); const typed = thrown as MarkdownPackageFlattenError; + expect(typed.name).toBe("MarkdownPackageFlattenError"); expect(typed.code).toBe("md/package-flatten-failed"); expect(typed.message).toMatch(/style ref/); }); From f45ca4cf7977151e1740fdc15fd5b0fb086f89b0 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 14 Sep 2026 09:26:30 +0100 Subject: [PATCH 31/99] test(markdown-codec): kill emit/front-matter.ts's quoting and escaping mutants emitScalar's own escaping branch was only reachable via values that already needed quoting, and none of the existing round-trip tests fed it a value containing a literal backslash or double-quote, so both replaceAll calls had zero coverage. Add direct writeMarkdown assertions for: a quoting-forced value containing a backslash, one containing a double-quote, values needing quoting purely for leading/trailing whitespace or emptiness (which NEEDS_QUOTING_PATTERN alone cannot catch), an empty (but defined) keywords array that must omit the keywords line rather than emit an empty flow sequence, and metadata with none of the mapped fields set, which must produce no front-matter block at all rather than an empty "---\n---" shell. --- packages/markdown-codec/src/package.test.ts | 59 +++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/packages/markdown-codec/src/package.test.ts b/packages/markdown-codec/src/package.test.ts index f0b6a828a4..89b93b05b2 100644 --- a/packages/markdown-codec/src/package.test.ts +++ b/packages/markdown-codec/src/package.test.ts @@ -587,6 +587,65 @@ describe("tree-only carries: reference definitions and front-matter residue", () ).toEqual(metadata); }); + it("quotes and escapes a literal backslash inside a value that also needs quoting for its leading '-'", () => { + const base = readMarkdown("body").documentPackage; + const written = writeMarkdown( + { ...base, metadata: { ...base.metadata, title: "-\\" } }, + { frontMatter: true }, + ); + expect(written).toBe('---\ntitle: "-\\\\"\n---\n\nbody'); + }); + + it("quotes and escapes a literal double-quote inside a value that also needs quoting for its leading '-'", () => { + const base = readMarkdown("body").documentPackage; + const written = writeMarkdown( + { ...base, metadata: { ...base.metadata, title: '-"' } }, + { frontMatter: true }, + ); + expect(written).toBe('---\ntitle: "-\\""\n---\n\nbody'); + }); + + it("quotes a value that would otherwise be misread, for reasons NEEDS_QUOTING_PATTERN alone cannot catch: leading/trailing whitespace or an empty string", () => { + const base = readMarkdown("body").documentPackage; + expect( + writeMarkdown( + { ...base, metadata: { ...base.metadata, title: " leading space" } }, + { frontMatter: true }, + ), + ).toBe('---\ntitle: " leading space"\n---\n\nbody'); + expect( + writeMarkdown( + { ...base, metadata: { ...base.metadata, title: "trailing space " } }, + { frontMatter: true }, + ), + ).toBe('---\ntitle: "trailing space "\n---\n\nbody'); + expect( + writeMarkdown( + { ...base, metadata: { ...base.metadata, title: "" } }, + { frontMatter: true }, + ), + ).toBe('---\ntitle: ""\n---\n\nbody'); + }); + + it("omits the keywords line entirely for an empty (but defined) keywords array, rather than emitting an empty flow sequence", () => { + const base = readMarkdown("body").documentPackage; + const written = writeMarkdown( + { + ...base, + metadata: { ...base.metadata, title: "x", keywords: [] }, + }, + { frontMatter: true }, + ); + expect(written).toBe("---\ntitle: x\n---\n\nbody"); + expect(written).not.toContain("keywords"); + }); + + it("emits no front-matter block at all (returns the body untouched) when the metadata carries none of the fields it maps", () => { + const base = readMarkdown("body").documentPackage; + // frontMatter: true with a metadata object none of STRING_FIELD_ENTRIES/keywords/direction can read anything from -- emitFrontMatter's own lines array stays empty, so it must return undefined (no block at all) rather than an empty "---\n---" shell. + expect(writeMarkdown(base, { frontMatter: true })).toBe("body"); + }); + it("emits no front matter at all without the option, residue or not", () => { const { documentPackage } = readMarkdown("---\ntitle: x\n---\n\nbody", { frontMatter: true, From 454ac8cd46bfa1bbdf6df104c39271e674c17b2e Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 03:26:01 +0100 Subject: [PATCH 32/99] fix(markdown-codec): stop building the function-with-fields test fixture via Object.assign Attach the four spec-example fields directly to the function reference instead, since exadev/no-object-assign now bans Object.assign workspace-wide (it cannot verify a source object's properties against the target's declared types the way a direct assignment can). --- .../markdown-codec/src/test-support/spec-corpus.test.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/markdown-codec/src/test-support/spec-corpus.test.ts b/packages/markdown-codec/src/test-support/spec-corpus.test.ts index ed66bde59d..9cdd27023d 100644 --- a/packages/markdown-codec/src/test-support/spec-corpus.test.ts +++ b/packages/markdown-codec/src/test-support/spec-corpus.test.ts @@ -32,7 +32,12 @@ describe("isSpecExample", () => { }); it('rejects a function even when it carries all four fields with the right types -- typeof a function is "function", never "object"', () => { - const fn = Object.assign(() => {}, VALID_EXAMPLE); + // Cast is unavoidable: TypeScript has no narrower type for "a function with these extra own properties attached" than a manual intersection, and Object.assign would build it unsoundly (banned by exadev/no-object-assign). + const fn = (() => {}) as (() => void) & typeof VALID_EXAMPLE; + fn.markdown = VALID_EXAMPLE.markdown; + fn.html = VALID_EXAMPLE.html; + fn.example = VALID_EXAMPLE.example; + fn.section = VALID_EXAMPLE.section; expect(isSpecExample(fn)).toBe(false); }); From 3775612290be78e1d142328638c742eafaf55f51 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 03:59:19 +0100 Subject: [PATCH 33/99] refactor(markdown-codec): merge lowerInlineNodes' text and entity cases Both cases build their run identically from node.value, so a separately mutable "text" case whose only possible mutation is falling through to the "entity" case (which does the same thing) has no observable difference to detect. One shared case body removes that construct entirely rather than leaving a mutant nothing can kill. --- packages/markdown-codec/src/lower/inline.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/markdown-codec/src/lower/inline.ts b/packages/markdown-codec/src/lower/inline.ts index 5a502d2774..be797a2ff4 100644 --- a/packages/markdown-codec/src/lower/inline.ts +++ b/packages/markdown-codec/src/lower/inline.ts @@ -75,9 +75,8 @@ function lowerInlineNodeInto( extents: RunConstructExtent[], ): void { switch (node.type) { + // text and entity both carry their materialised text in the same field, and are handled identically -- one shared body, rather than two separately-mutable cases whose bodies are textually forced to stay identical anyway. case "text": - if (node.value.length > 0) runs.push(buildRun(node.value, style)); - return; case "entity": if (node.value.length > 0) runs.push(buildRun(node.value, style)); return; From ff239f221c459f0efa2305ac6e0805d88a70a78f Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 03:59:26 +0100 Subject: [PATCH 34/99] refactor(markdown-codec): drop render.ts's unkillable guards and case bodies Three constructs in this file had no test able to distinguish their mutated form from the original, because the mutation changed nothing observable: - escapeHref's own "byte < 0x80" guard: ALPHANUMERIC_PATTERN and HREF_SAFE_PUNCTUATION are both pure-ASCII vocabularies already, so a byte >= 0x80 can never match either regardless of this guard's own truth value. - renderInline's separate "text" and "entity" cases: both build their string identically from node.value, so falling through from one to the other changes nothing. - renderBlock's trailing "document"/"listItem"/"tableRow"/"tableCell" case: its only statement was a bare `return;`, itself redundant since the switch is the method's last statement and falling off it already returns undefined. Removing each construct removes the mutation opportunity along with it, rather than leaving a mutant nothing can kill. --- packages/markdown-codec/src/html/render.ts | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/packages/markdown-codec/src/html/render.ts b/packages/markdown-codec/src/html/render.ts index ebac13cfe8..d5ea70df19 100644 --- a/packages/markdown-codec/src/html/render.ts +++ b/packages/markdown-codec/src/html/render.ts @@ -67,11 +67,9 @@ export function escapeHref(href: string): string { const bytes = new TextEncoder().encode(href); let result = ""; for (const byte of bytes) { + // No separate "is this byte even ASCII" guard: ALPHANUMERIC_PATTERN and HREF_SAFE_PUNCTUATION are both pure-ASCII vocabularies on their own, so a byte >= 0x80 -- reinterpreted here as the single Latin-1 codepoint of that value, not as part of whatever multi-byte UTF-8 sequence it actually belongs to -- can never match either and falls through to percent-encoding regardless. const char = String.fromCharCode(byte); - if ( - byte < 0x80 && - (ALPHANUMERIC_PATTERN.test(char) || HREF_SAFE_PUNCTUATION.has(char)) - ) { + if (ALPHANUMERIC_PATTERN.test(char) || HREF_SAFE_PUNCTUATION.has(char)) { result += char; continue; } @@ -109,8 +107,8 @@ function renderTaskCheckbox(checked: boolean): string { function renderInline(node: MarkdownInlineNode): string { switch (node.type) { + // text and entity both carry their materialised text in the same field, and render identically -- one shared body, rather than two separately-mutable cases whose bodies are textually forced to stay identical anyway. case "text": - return escapeHtml(node.value); case "entity": return escapeHtml(node.value); case "codeSpan": @@ -220,12 +218,11 @@ class HtmlRenderer { this.render(node.children, false); this.cr(); return; + // Each is rendered only through its own parent, which knows the surrounding markup it needs -- an empty case (no consequent at all, not even a bare `return;`) since the switch is this method's last statement and falling off it already returns. case "document": case "listItem": case "tableRow": case "tableCell": - // Each is rendered only through its own parent, which knows the surrounding markup it needs. - return; } } From 98f57e311ca9deea9c6dc70c909f045b8c1e999a Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 03:59:36 +0100 Subject: [PATCH 35/99] test(markdown-codec): add direct coverage for lowerInlineNodes' own leaves The round-trip suites in lower.test.ts only exercise this module through whatever shapes the real CommonMark parser happens to produce, which never reaches an empty text/entity node, an inline rawHtml node with rawHtml: "drop", an empty inline rawHtml literal, a nested bold-in-bold or strike-in-strike pair, or an untitled nested image -- and never pins the exact wording of any of this module's own diagnostic messages, only that some diagnostic with the right code fired. Build MarkdownInlineNode trees by hand instead, isolated from the parser, covering each leaf case directly: buildRun's own conditional hyperlink/fontFamily fields, text/entity's length-gated push, all three NESTED_EMPHASIS_FLATTENED wordings (not just the italic one an existing mixed-marker test happens to reach), inline rawHtml in both modes and both an empty and a non-empty literal, mathInline, and a nested image with and without a title. --- .../markdown-codec/src/lower/inline.test.ts | 244 ++++++++++++++++++ 1 file changed, 244 insertions(+) create mode 100644 packages/markdown-codec/src/lower/inline.test.ts diff --git a/packages/markdown-codec/src/lower/inline.test.ts b/packages/markdown-codec/src/lower/inline.test.ts new file mode 100644 index 0000000000..708b12d1fd --- /dev/null +++ b/packages/markdown-codec/src/lower/inline.test.ts @@ -0,0 +1,244 @@ +// Direct unit tests for lowerInlineNodes' own leaf-by-leaf construction, isolated from the parser -- the round-trip suites in lower.test.ts exercise this module only through whatever shapes the real CommonMark parser happens to produce, which never reaches several of its own branches (an empty inline rawHtml literal, the rawHtml: "drop" branch for an INLINE tag specifically, an empty text/entity node, a nested bold-in-bold or strike-in-strike pair, an untitled image). Building MarkdownInlineNode trees by hand here reaches those directly and pins the exact diagnostic message text lower.test.ts's own `collector.has(code)` checks never inspect. + +import { describe, expect, it } from "vitest"; +import type { MarkdownInlineNode } from "../ast/ast"; +import { MarkdownDiagnosticCodes } from "../diagnostics/diagnostics"; +import { + MATH_INLINE_FONT_MARKER, + MONOSPACE_FONT_FAMILY, +} from "../shared/style-constants"; +import { createDiagnosticCollector } from "../test-support/diagnostics"; +import { lowerCodeBlockRun, lowerInlineNodes } from "./inline"; + +function lower( + nodes: MarkdownInlineNode[], + rawHtml: "preserve" | "drop" = "preserve", +) { + const collector = createDiagnosticCollector(); + const result = lowerInlineNodes(nodes, { sink: collector.sink, rawHtml }); + return { ...result, diagnostics: collector.diagnostics }; +} + +describe("lowerInlineNodes: buildRun's own conditional fields", () => { + it("a run with no active style carries only its own text -- no bold/italic/strike/hyperlink/fontFamily key at all, not even set to undefined", () => { + const { runs } = lower([{ type: "text", value: "plain" }]); + expect(runs).toHaveLength(1); + expect(Object.keys(runs[0]!).sort()).toStrictEqual(["text"]); + }); +}); + +describe("lowerInlineNodes: text and entity leaves drop entirely when empty", () => { + it("an empty text node produces no run at all", () => { + const { runs } = lower([{ type: "text", value: "" }]); + expect(runs).toHaveLength(0); + }); + + it("a non-empty text node produces exactly one run", () => { + const { runs } = lower([{ type: "text", value: "x" }]); + expect(runs).toHaveLength(1); + expect(runs[0]!.text).toBe("x"); + }); + + it("an empty entity node produces no run at all", () => { + const { runs } = lower([{ type: "entity", raw: "�", value: "" }]); + expect(runs).toHaveLength(0); + }); + + it("a non-empty entity node produces exactly one run carrying its resolved value", () => { + const { runs } = lower([{ type: "entity", raw: "&", value: "&" }]); + expect(runs).toHaveLength(1); + expect(runs[0]!.text).toBe("&"); + }); +}); + +describe("lowerInlineNodes: NESTED_EMPHASIS_FLATTENED fires per kind with its own precise message, only when genuinely nested", () => { + it("a single (non-nested) emphasis span fires no diagnostic", () => { + const { diagnostics } = lower([ + { + type: "emphasis", + marker: "_", + children: [{ type: "text", value: "a" }], + }, + ]); + expect(diagnostics).toHaveLength(0); + }); + + it("a single (non-nested) strong span fires no diagnostic", () => { + const { diagnostics } = lower([ + { type: "strong", marker: "*", children: [{ type: "text", value: "a" }] }, + ]); + expect(diagnostics).toHaveLength(0); + }); + + it("a single (non-nested) strikethrough span fires no diagnostic", () => { + const { diagnostics } = lower([ + { type: "strikethrough", children: [{ type: "text", value: "a" }] }, + ]); + expect(diagnostics).toHaveLength(0); + }); + + it("emphasis nested inside emphasis fires with the 'emphasis' wording", () => { + const { diagnostics, runs } = lower([ + { + type: "emphasis", + marker: "_", + children: [ + { + type: "emphasis", + marker: "*", + children: [{ type: "text", value: "a" }], + }, + ], + }, + ]); + expect(diagnostics).toHaveLength(1); + expect(diagnostics[0]).toMatchObject({ + code: MarkdownDiagnosticCodes.NESTED_EMPHASIS_FLATTENED, + message: + "a emphasis span is nested inside another span of the same kind; ContentRun has no nesting depth of its own, so both collapse to one flat run", + }); + expect(runs[0]).toMatchObject({ italic: true }); + }); + + it("strong nested inside strong fires with the 'strong emphasis' wording", () => { + const { diagnostics, runs } = lower([ + { + type: "strong", + marker: "*", + children: [ + { + type: "strong", + marker: "_", + children: [{ type: "text", value: "a" }], + }, + ], + }, + ]); + expect(diagnostics).toHaveLength(1); + expect(diagnostics[0]).toMatchObject({ + code: MarkdownDiagnosticCodes.NESTED_EMPHASIS_FLATTENED, + message: + "a strong emphasis span is nested inside another span of the same kind; ContentRun has no nesting depth of its own, so both collapse to one flat run", + }); + expect(runs[0]).toMatchObject({ bold: true }); + }); + + it("strikethrough nested inside strikethrough fires with the 'strikethrough' wording", () => { + const { diagnostics, runs } = lower([ + { + type: "strikethrough", + children: [ + { + type: "strikethrough", + children: [{ type: "text", value: "a" }], + }, + ], + }, + ]); + expect(diagnostics).toHaveLength(1); + expect(diagnostics[0]).toMatchObject({ + code: MarkdownDiagnosticCodes.NESTED_EMPHASIS_FLATTENED, + message: + "a strikethrough span is nested inside another span of the same kind; ContentRun has no nesting depth of its own, so both collapse to one flat run", + }); + expect(runs[0]).toMatchObject({ strike: true }); + }); +}); + +describe("lowerInlineNodes: inline rawHtml, both modes, both an empty and a non-empty literal", () => { + it('rawHtml: "drop" fires RAW_HTML_DROPPED with its own exact message and produces no run', () => { + const { runs, diagnostics } = lower( + [{ type: "rawHtml", literal: "" }], + "drop", + ); + expect(runs).toHaveLength(0); + expect(diagnostics).toHaveLength(1); + expect(diagnostics[0]).toMatchObject({ + code: MarkdownDiagnosticCodes.RAW_HTML_DROPPED, + message: 'inline raw HTML was dropped per the rawHtml: "drop" option', + }); + }); + + it('rawHtml: "preserve" fires RAW_HTML_PRESERVED_AS_TEXT with its own exact message even for an empty literal, and produces no run since there is no text to carry', () => { + const { runs, diagnostics } = lower( + [{ type: "rawHtml", literal: "" }], + "preserve", + ); + expect(runs).toHaveLength(0); + expect(diagnostics).toHaveLength(1); + expect(diagnostics[0]).toMatchObject({ + code: MarkdownDiagnosticCodes.RAW_HTML_PRESERVED_AS_TEXT, + message: + "inline raw HTML was preserved as literal text; it will not be rendered as HTML by any consumer of the resulting ContentDocument, and its verbatim original rides the run's own markdown residue for this package's writer to re-emit as-is", + }); + }); + + it('rawHtml: "preserve" with a non-empty literal produces one run carrying the literal as both text and markdown residue', () => { + const { runs, diagnostics } = lower( + [{ type: "rawHtml", literal: '' }], + "preserve", + ); + expect(diagnostics).toHaveLength(1); + expect(runs).toHaveLength(1); + expect(runs[0]).toMatchObject({ + text: '', + source: { format: "markdown", xml: '' }, + }); + }); +}); + +describe("lowerInlineNodes: mathInline preserves its own exact diagnostic message and marks the run", () => { + it("fires MATH_INLINE_PRESERVED_AS_TEXT with its own exact message and marks the run with MATH_INLINE_FONT_MARKER", () => { + const { runs, diagnostics } = lower([ + { type: "mathInline", literal: "x^2" }, + ]); + expect(diagnostics).toHaveLength(1); + expect(diagnostics[0]).toMatchObject({ + code: MarkdownDiagnosticCodes.MATH_INLINE_PRESERVED_AS_TEXT, + message: + "inline math (\\( \\)) was preserved as literal raw LaTeX text; it is not parsed as LaTeX or converted to MathML by this package", + }); + expect(runs).toHaveLength(1); + expect(runs[0]).toMatchObject({ + text: "x^2", + fontFamily: MATH_INLINE_FONT_MARKER, + }); + }); +}); + +describe("lowerInlineNodes: a nested image's title drops with its own exact message, only when a title is present", () => { + it("an untitled nested image fires no diagnostic at all", () => { + const { diagnostics, runs } = lower([ + { type: "image", destination: "/x.png", alt: "alt text" }, + ]); + expect(diagnostics).toHaveLength(0); + expect(runs[0]).toMatchObject({ text: "alt text", hyperlink: "/x.png" }); + }); + + it("a titled nested image fires LINK_TITLE_DROPPED with its own exact message naming the dropped title", () => { + const { diagnostics, runs } = lower([ + { + type: "image", + destination: "/x.png", + title: "a title", + alt: "alt text", + }, + ]); + expect(diagnostics).toHaveLength(1); + expect(diagnostics[0]).toMatchObject({ + code: MarkdownDiagnosticCodes.LINK_TITLE_DROPPED, + message: + 'image title "a title" has no ContentRun equivalent and was dropped', + }); + expect(runs[0]).toMatchObject({ text: "alt text", hyperlink: "/x.png" }); + }); +}); + +describe("lowerCodeBlockRun", () => { + it("wraps a code block's literal text in a single monospace run", () => { + expect(lowerCodeBlockRun("console.log(1);")).toStrictEqual({ + text: "console.log(1);", + fontFamily: MONOSPACE_FONT_FAMILY, + }); + }); +}); From 232deea6ee4b84704e618513067c9803d3e67648 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 03:59:47 +0100 Subject: [PATCH 36/99] test(markdown-codec): add direct coverage for the HTML render oracle src/conformance.test.ts and src/gfm-conformance.test.ts only exercise renderDocumentToHtml through whatever the real CommonMark/GFM corpora happen to contain, which never reaches math (a Pandoc/GFM extension outside both), footnote definitions or references (a GitHub extension outside both), an apostrophe or a single-hex-digit byte in an href, or an unaligned table column specifically checked for the absence of an align attribute. Build MarkdownBlockNode/MarkdownDocumentNode trees by hand instead, covering each of those directly, plus a case where cr()'s own buffer-already-ends-in-newline check genuinely matters: a tight list item's bare paragraph text (which carries no trailing newline of its own) immediately followed by a nested list in the same item. --- .../markdown-codec/src/html/render.test.ts | 166 ++++++++++++++++++ 1 file changed, 166 insertions(+) create mode 100644 packages/markdown-codec/src/html/render.test.ts diff --git a/packages/markdown-codec/src/html/render.test.ts b/packages/markdown-codec/src/html/render.test.ts new file mode 100644 index 0000000000..0a81ea94fa --- /dev/null +++ b/packages/markdown-codec/src/html/render.test.ts @@ -0,0 +1,166 @@ +// Direct unit tests for this module's own conformance-oracle rendering, isolated from the parser -- src/conformance.test.ts and src/gfm-conformance.test.ts only exercise renderDocumentToHtml through whatever the real CommonMark/GFM corpora happen to contain, which never reaches several of this renderer's own branches: math (a Pandoc/GFM extension outside both corpora), footnote definitions/references (a GitHub extension outside both), an apostrophe or a single-hex-digit byte in an href, and a table column with no alignment. Building MarkdownBlockNode/MarkdownDocumentNode trees by hand here reaches those directly. + +import { describe, expect, it } from "vitest"; +import type { MarkdownBlockNode, MarkdownDocumentNode } from "../ast/ast"; +import { escapeHref, renderDocumentToHtml, renderInlines } from "./render"; + +function render(children: MarkdownBlockNode[]): string { + const document: MarkdownDocumentNode = { type: "document", children }; + return renderDocumentToHtml(document); +} + +describe("escapeHref", () => { + it("passes an apostrophe through as the ' entity, not a percent escape", () => { + expect(escapeHref("'")).toBe("'"); + }); + + it("pads a single hex digit's percent escape to two digits", () => { + // U+0007 (BEL) is ASCII, not alphanumeric, not in the safe-punctuation set -- its own byte value is 7, whose hex digit "7" needs a leading zero. Built via fromCharCode rather than a literal escape so the source never carries a raw, invisible control byte. + expect(escapeHref(String.fromCharCode(7))).toBe("%07"); + }); + + it("leaves a two-hex-digit byte unpadded", () => { + // '<' is byte 0x3C -- already two hex digits, nothing to pad. + expect(escapeHref("<")).toBe("%3C"); + }); +}); + +describe("renderInlines: footnote reference and inline math, neither in the corpora this renderer otherwise checks against", () => { + it("renders a footnote reference as its own escaped source spelling", () => { + expect(renderInlines([{ type: "footnoteReference", label: "1" }])).toBe( + "[^1]", + ); + }); + + it("renders inline math wrapped back in its own \\( \\) delimiters", () => { + expect(renderInlines([{ type: "mathInline", literal: "x^2" }])).toBe( + "\\(x^2\\)", + ); + }); + + it("renders an empty inline math span as bare delimiters, not nothing", () => { + expect(renderInlines([{ type: "mathInline", literal: "" }])).toBe("\\(\\)"); + }); +}); + +describe("renderDocumentToHtml: display math and footnote definitions, neither in the corpora this renderer otherwise checks against", () => { + it("renders a math block wrapped in its own $$ delimiters, escaped", () => { + expect(render([{ type: "mathBlock", literal: "a < b" }])).toBe( + "$$\na < b\n$$\n", + ); + }); + + it("renders a footnote definition as its own escaped label line followed by its body's ordinary blocks", () => { + expect( + render([ + { + type: "footnoteDefinition", + label: "note", + children: [ + { + type: "paragraph", + children: [{ type: "text", value: "body" }], + }, + ], + }, + ]), + ).toBe("[^note]:\n

body

\n"); + }); + + it("separates two consecutive footnote definitions on their own lines", () => { + const html = render([ + { type: "footnoteDefinition", label: "a", children: [] }, + { type: "footnoteDefinition", label: "b", children: [] }, + ]); + expect(html).toBe("[^a]:\n[^b]:\n"); + }); +}); + +describe("renderDocumentToHtml: table column alignment, only rendered when genuinely aligned", () => { + it("omits the align attribute entirely for an unaligned ('none') column", () => { + const html = render([ + { + type: "table", + alignments: ["none"], + children: [ + { + type: "tableRow", + header: true, + children: [ + { type: "tableCell", children: [{ type: "text", value: "h" }] }, + ], + }, + ], + }, + ]); + expect(html).toContain("
"); + expect(html).not.toContain("align="); + }); + + it("renders the align attribute for a genuinely aligned column", () => { + const html = render([ + { + type: "table", + alignments: ["right"], + children: [ + { + type: "tableRow", + header: true, + children: [ + { type: "tableCell", children: [{ type: "text", value: "h" }] }, + ], + }, + { + type: "tableRow", + header: false, + children: [ + { type: "tableCell", children: [{ type: "text", value: "b" }] }, + ], + }, + ], + }, + ]); + expect(html).toContain(''); + expect(html).toContain(''); + }); +}); + +describe("renderDocumentToHtml: cr() only inserts a newline when the buffer genuinely lacks one", () => { + it("inserts a newline between a tight list item's bare paragraph text and a nested list that follows it in the same item", () => { + const html = render([ + { + type: "list", + markerType: "bullet", + bulletMarker: "-", + tight: true, + children: [ + { + type: "listItem", + children: [ + { type: "paragraph", children: [{ type: "text", value: "a" }] }, + { + type: "list", + markerType: "bullet", + bulletMarker: "-", + tight: true, + children: [ + { + type: "listItem", + children: [ + { + type: "paragraph", + children: [{ type: "text", value: "b" }], + }, + ], + }, + ], + }, + ], + }, + ], + }, + ]); + // A bare tight-paragraph "a" carries no trailing newline of its own -- the nested list's own leading cr() is what supplies the line break before its "
    ". + expect(html).toBe("
      \n
    • a\n
        \n
      • b
      • \n
      \n
    • \n
    \n"); + }); +}); From 1e84023359ca425ce01b7c460ed9163648bb25d3 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 04:08:07 +0100 Subject: [PATCH 37/99] refactor(markdown-codec): drop render.ts's cr() calls that can never fire Four cr() calls and one array-index fallback had no test able to distinguish their mutated form, because every block type this renderer can legitimately render already leaves the buffer ending in "\n" before each of these ran: - blockquote's and footnoteDefinition's own closing cr(), called after rendering their own children: every reachable block type finishes its own append in "\n" (directly, or via its own cr()), so the buffer is already newline-terminated regardless of what the last child was. - mathBlock's closing cr(): its own template literal always ends in a literal "\n" already. - renderList's per-item cr(), called before every "
  • ": the buffer always already ends in "\n" there too, from either the list's own opening tag (first item) or the previous item's own closing "
  • \n" (every item after). - renderCodeBlock's "?? \"\"" fallback on infoString.split(...)[0]: split on a non-empty separator regex always returns at least one element, so index 0 is never undefined. renderList's own opening cr() and renderTable's own opening cr() stay: both are exercised by a genuine case (a tight list item's bare paragraph text immediately followed by a nested list or table in the same item), covered directly in render.test.ts. --- packages/markdown-codec/src/html/render.ts | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/packages/markdown-codec/src/html/render.ts b/packages/markdown-codec/src/html/render.ts index d5ea70df19..2b8aabe28e 100644 --- a/packages/markdown-codec/src/html/render.ts +++ b/packages/markdown-codec/src/html/render.ts @@ -196,7 +196,7 @@ class HtmlRenderer { this.cr(); this.out += "
    \n"; this.render(node.children, false); - this.cr(); + // No closing cr(): every reachable block type's own rendering already ends in "\n" as its own last action (directly, or via its own cr()), so the buffer is always already newline-terminated here regardless of what the last child was, or whether there was one at all. this.out += "
    \n"; return; case "list": @@ -206,17 +206,15 @@ class HtmlRenderer { this.renderTable(node); return; case "mathBlock": - // See renderInline's own mathInline case: the $$ delimiters are reconstructed around the escaped literal, matching src/emit/emit.ts's own real MATH_BLOCK_STYLE_ID branch. + // See renderInline's own mathInline case: the $$ delimiters are reconstructed around the escaped literal, matching src/emit/emit.ts's own real MATH_BLOCK_STYLE_ID branch. No closing cr(): the template itself always ends in a literal "\n". this.cr(); this.out += `$$\n${escapeHtml(node.literal)}\n$$\n`; - this.cr(); return; case "footnoteDefinition": - // See renderInline's own footnoteReference case: no fixture pins GitHub's own notes-section markup down, so the definition's source spelling is reconstructed around its rendered body, matching src/emit/emit.ts's own renderFootnoteDefinition. The body renders as ordinary blocks -- a definition holding several paragraphs shows all of them. + // See renderInline's own footnoteReference case: no fixture pins GitHub's own notes-section markup down, so the definition's source spelling is reconstructed around its rendered body, matching src/emit/emit.ts's own renderFootnoteDefinition. The body renders as ordinary blocks -- a definition holding several paragraphs shows all of them. No closing cr(), for the same reason blockquote's own closing tag needs none above. this.cr(); this.out += `${escapeHtml(`[^${node.label}]:`)}\n`; this.render(node.children, false); - this.cr(); return; // Each is rendered only through its own parent, which knows the surrounding markup it needs -- an empty case (no consequent at all, not even a bare `return;`) since the switch is this method's last statement and falling off it already returns. case "document": @@ -232,8 +230,9 @@ class HtmlRenderer { ): void { this.cr(); // cmark takes the info string's first word as the language class and ignores the rest. + // String.prototype.split on a non-empty separator regex always returns at least one element (even splitting "" itself yields [""]), so index 0 is never undefined -- the assertion states that, since noUncheckedIndexedAccess cannot infer it from the split call alone. const language = - infoString === undefined ? "" : (infoString.split(/[ \t]/)[0] ?? ""); + infoString === undefined ? "" : infoString.split(/[ \t]/)[0]!; const attribute = language.length === 0 ? "" : ` class="language-${escapeHtml(language)}"`; this.out += `
    ${escapeHtml(literal)}
    \n`; @@ -248,7 +247,7 @@ class HtmlRenderer { : `
      `; this.out += `${node.markerType === "bullet" ? "
        " : orderedOpenTag}\n`; for (const item of node.children) { - this.cr(); + // No cr() here: the buffer always already ends in "\n" at this point, either from the list's own just-appended opening tag (first iteration) or the previous iteration's own closing "\n" (every iteration after). this.out += "
      • "; // A task-list item's checkbox is the first fragment of the item's own first block -- rendered here, immediately after `
      • ` and before that block's own rendering, so it lands inside a tight item's bare inline content or (unverified against a real fixture, see this module's own top-of-file note) inside a loose item's `

        ` wrapper alike. if (item.checked !== undefined) { From 5cec02119d6b8005fdf3a9f7b73ad60367897796 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 04:08:15 +0100 Subject: [PATCH 38/99] test(markdown-codec): pin the remaining render.ts mutants directly Adds direct coverage for: table alignment lookup falling off the end of a shorter alignments array (undefined, distinct from the explicit "none" already covered), inline image rendering (the one renderInline leaf case none of block.test.ts/lower.test.ts/the conformance corpora happen to reach through renderDocumentToHtml), a code block with no info string at all (no class attribute) versus one with a genuine language word, and the two remaining cr()-matters cases -- a thematic break and a table, each immediately following a tight list item's bare paragraph text in the same item. --- .../markdown-codec/src/html/render.test.ts | 123 ++++++++++++++++++ 1 file changed, 123 insertions(+) diff --git a/packages/markdown-codec/src/html/render.test.ts b/packages/markdown-codec/src/html/render.test.ts index 0a81ea94fa..b4e7624fbd 100644 --- a/packages/markdown-codec/src/html/render.test.ts +++ b/packages/markdown-codec/src/html/render.test.ts @@ -123,6 +123,29 @@ describe("renderDocumentToHtml: table column alignment, only rendered when genui expect(html).toContain('

'); expect(html).toContain(''); }); + + it("also omits the align attribute for a column with no alignment entry at all -- undefined, distinct from the explicit 'none'", () => { + const html = render([ + { + type: "table", + // Only one alignment entry for a row of two cells, so the second column's own lookup is genuinely undefined rather than "none". + alignments: ["none"], + children: [ + { + type: "tableRow", + header: true, + children: [ + { type: "tableCell", children: [{ type: "text", value: "h1" }] }, + { type: "tableCell", children: [{ type: "text", value: "h2" }] }, + ], + }, + ], + }, + ]); + expect(html).toContain(""); + expect(html).toContain(""); + expect(html).not.toContain("align="); + }); }); describe("renderDocumentToHtml: cr() only inserts a newline when the buffer genuinely lacks one", () => { @@ -163,4 +186,104 @@ describe("renderDocumentToHtml: cr() only inserts a newline when the buffer genu // A bare tight-paragraph "a" carries no trailing newline of its own -- the nested list's own leading cr() is what supplies the line break before its "
    ". expect(html).toBe("
      \n
    • a\n
        \n
      • b
      • \n
      \n
    • \n
    \n"); }); + + it("inserts a newline between a tight list item's bare paragraph text and a thematic break that follows it in the same item", () => { + const html = render([ + { + type: "list", + markerType: "bullet", + bulletMarker: "-", + tight: true, + children: [ + { + type: "listItem", + children: [ + { type: "paragraph", children: [{ type: "text", value: "a" }] }, + { type: "thematicBreak" }, + ], + }, + ], + }, + ]); + expect(html).toBe("
      \n
    • a\n
      \n
    • \n
    \n"); + }); + + it("inserts a newline between a tight list item's bare paragraph text and a table that follows it in the same item", () => { + const html = render([ + { + type: "list", + markerType: "bullet", + bulletMarker: "-", + tight: true, + children: [ + { + type: "listItem", + children: [ + { type: "paragraph", children: [{ type: "text", value: "a" }] }, + { + type: "table", + alignments: ["none"], + children: [ + { + type: "tableRow", + header: true, + children: [ + { + type: "tableCell", + children: [{ type: "text", value: "h" }], + }, + ], + }, + ], + }, + ], + }, + ], + }, + ]); + expect(html).toBe( + "
      \n
    • a\n
hhbhbh1h2
\n\n\n\n\n\n
h
\n\n\n", + ); + }); +}); + +describe("renderInlines: image, the one leaf case none of block.test.ts/lower.test.ts/conformance corpora happen to reach through renderDocumentToHtml", () => { + it("renders src, alt, and (only when present) a title attribute", () => { + expect( + renderInlines([ + { type: "image", destination: "/a.png", alt: "alt text" }, + ]), + ).toBe('alt text'); + expect( + renderInlines([ + { + type: "image", + destination: "/a.png", + alt: "alt text", + title: "a title", + }, + ]), + ).toBe('alt text'); + }); +}); + +describe("renderDocumentToHtml: a code block's own info-string-to-language-class mapping", () => { + it("omits the class attribute entirely when there is no info string at all", () => { + expect(render([{ type: "codeBlock", fenced: true, literal: "x" }])).toBe( + "
x
\n", + ); + }); + + it("derives the class from the info string's own first word, ignoring the rest", () => { + expect( + render([ + { + type: "codeBlock", + fenced: true, + infoString: "js ignored", + literal: "x", + }, + ]), + ).toBe('
x
\n'); + }); }); From c78c150274d976aa35e035faad0171d1a4238cf1 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 04:08:27 +0100 Subject: [PATCH 39/99] refactor(markdown-codec): drop codePointAt's unreachable undefined guard text.codePointAt(index) only ever returns undefined for an out-of-range index, and the preceding "index >= text.length" guard already rules that out for every index this function can actually be called with -- no further fallback branch was ever reachable. --- packages/markdown-codec/src/inline/chars.ts | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/packages/markdown-codec/src/inline/chars.ts b/packages/markdown-codec/src/inline/chars.ts index 39dfa1410a..345af002f4 100644 --- a/packages/markdown-codec/src/inline/chars.ts +++ b/packages/markdown-codec/src/inline/chars.ts @@ -71,9 +71,6 @@ export function codePointAt(text: string, index: number): string { if (index >= text.length) { return "\n"; } - const code = text.codePointAt(index); - if (code === undefined) { - return "\n"; - } - return String.fromCodePoint(code); + // text.codePointAt only ever returns undefined for an out-of-range index, and the guard above has already ruled that out -- no further fallback needed for an index it can actually be called with here. + return String.fromCodePoint(text.codePointAt(index)!); } From 3590a6c62ded6002fc5a72d283307735cd59f95d Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 04:08:35 +0100 Subject: [PATCH 40/99] test(markdown-codec): add direct coverage for chars.ts's own boundaries link.ts and delimiter.ts only exercise these predicates through whatever characters the round-trip suites' own markdown sources happen to contain, never at the exact boundaries that distinguish a correct comparison from an off-by-one one: 0x1f/0x20 and 0x7e/0x7f for isAsciiControl, and the surrogate-pair range edges for codePointBefore/ codePointAt (a lone low surrogate with no valid high surrogate before it, a low surrogate one character too early in the string to pair with anything). --- .../markdown-codec/src/inline/chars.test.ts | 86 +++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 packages/markdown-codec/src/inline/chars.test.ts diff --git a/packages/markdown-codec/src/inline/chars.test.ts b/packages/markdown-codec/src/inline/chars.test.ts new file mode 100644 index 0000000000..b5e948c003 --- /dev/null +++ b/packages/markdown-codec/src/inline/chars.test.ts @@ -0,0 +1,86 @@ +// Direct unit tests for this module's own character-class predicates and codepoint helpers -- link.ts and delimiter.ts only exercise these through whatever characters the round-trip suites' own markdown sources happen to contain, never at the exact boundaries (0x1f/0x20, 0x7e/0x7f, the surrogate-pair range edges) that distinguish a correct comparison from an off-by-one one. + +import { describe, expect, it } from "vitest"; +import { + codePointAt, + codePointBefore, + containsAsciiControlOrSpace, + isAsciiControl, +} from "./chars"; + +describe("isAsciiControl", () => { + it("returns false for an empty string, where codePointAt(0) is undefined", () => { + expect(isAsciiControl("")).toBe(false); + }); + + it("treats 0x1f (unit separator) as control, but not 0x20 (space) immediately past it", () => { + expect(isAsciiControl("")).toBe(true); + expect(isAsciiControl(" ")).toBe(false); + }); + + it("treats 0x7f (DEL) as control, but not 0x7e (~) immediately before it", () => { + expect(isAsciiControl("")).toBe(true); + expect(isAsciiControl("~")).toBe(false); + }); + + it("does not treat a byte past 0x7f as control", () => { + expect(isAsciiControl("€")).toBe(false); + }); +}); + +describe("containsAsciiControlOrSpace", () => { + it("is false for an empty string", () => { + expect(containsAsciiControlOrSpace("")).toBe(false); + }); + + it("is false for text with no control character and no space", () => { + expect(containsAsciiControlOrSpace("abc")).toBe(false); + }); + + it("is true for text containing a control character", () => { + expect(containsAsciiControlOrSpace("ab")).toBe(true); + }); + + it("is true for text containing a space", () => { + expect(containsAsciiControlOrSpace("a b")).toBe(true); + }); +}); + +describe("codePointBefore", () => { + it("returns a bare newline at the very start of the string", () => { + expect(codePointBefore("abc", 0)).toBe("\n"); + }); + + it("returns the single preceding character when it is not a low surrogate", () => { + expect(codePointBefore("ab", 2)).toBe("b"); + }); + + it("returns the single preceding character at index 1, too early for a surrogate pair to fit before it", () => { + expect(codePointBefore("a😀", 1)).toBe("a"); + }); + + it("returns the full surrogate pair when a genuine astral character precedes the index", () => { + // U+1F600 (grinning face) is the high/low surrogate pair 😀. + expect(codePointBefore("a😀", 3)).toBe("😀"); + }); + + it("returns only the low surrogate when it is not preceded by a valid high surrogate", () => { + // \uDE00 alone (a lone low surrogate, no high surrogate before it) is not a real pair. + expect(codePointBefore("a\uDE00", 2)).toBe("\uDE00"); + }); +}); + +describe("codePointAt", () => { + it("returns a bare newline at or past the end of the string", () => { + expect(codePointAt("abc", 3)).toBe("\n"); + expect(codePointAt("abc", 4)).toBe("\n"); + }); + + it("returns the single character at a normal index", () => { + expect(codePointAt("abc", 1)).toBe("b"); + }); + + it("returns the full surrogate pair for an astral character", () => { + expect(codePointAt("😀b", 0)).toBe("😀"); + }); +}); From 3b0f74379f88796c9fdfd8ec1ac16b538e876f22 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 04:11:54 +0100 Subject: [PATCH 41/99] test(markdown-codec): add direct coverage for front-matter parsing extractFrontMatter's own quote-stripping, keyword-list, and block-boundary logic is only exercised through whatever shapes lower.test.ts's round-tripped fixtures happen to contain, never at the exact boundaries that distinguish a correct comparison from an off-by-one one: a quoted value at exactly length 2 versus a lone quote character below it, a mismatched or one-sided quote pair, a keywords list missing its closing bracket, an empty item from a trailing or doubled comma, a document with no front matter at all, and the exact 1-based line number FRONT_MATTER_KEY_UNMAPPED reports. --- .../src/lower/front-matter.test.ts | 199 ++++++++++++++++++ 1 file changed, 199 insertions(+) create mode 100644 packages/markdown-codec/src/lower/front-matter.test.ts diff --git a/packages/markdown-codec/src/lower/front-matter.test.ts b/packages/markdown-codec/src/lower/front-matter.test.ts new file mode 100644 index 0000000000..9a08513078 --- /dev/null +++ b/packages/markdown-codec/src/lower/front-matter.test.ts @@ -0,0 +1,199 @@ +// Direct unit tests for extractFrontMatter's own scalar/keyword-list parsing and block-boundary scanning -- lower.test.ts (round-tripped through readMarkdown) only exercises whichever quoting/whitespace/malformed shapes its own fixtures happen to contain, never the exact quote-length boundary (a lone quote character, an empty quoted value), a mismatched-bracket keywords list, a blank line inside the block, or a document with no front matter at all. + +import { describe, expect, it } from "vitest"; +import { MarkdownDiagnosticCodes } from "../diagnostics/diagnostics"; +import { createDiagnosticCollector } from "../test-support/diagnostics"; +import { extractFrontMatter } from "./front-matter"; + +describe("extractFrontMatter: no front matter present at all", () => { + it("leaves an ordinary document entirely unchanged", () => { + const result = extractFrontMatter("# Title\n\nbody\n"); + expect(result).toStrictEqual({ + metadata: {}, + rest: "# Title\n\nbody\n", + source: undefined, + }); + }); + + it("leaves a document with an unclosed leading '---' unchanged -- CommonMark's own thematic-break-then-paragraph reading", () => { + const source = "---\ntitle: x\nno closing delimiter\n"; + const result = extractFrontMatter(source); + expect(result).toStrictEqual({ + metadata: {}, + rest: source, + source: undefined, + }); + }); +}); + +describe("extractFrontMatter: closing delimiter shapes", () => { + it("accepts '...' as a closing delimiter, not just a second '---'", () => { + const result = extractFrontMatter("---\ntitle: x\n...\nbody\n"); + expect(result.metadata).toStrictEqual({ title: "x" }); + expect(result.rest).toBe("body\n"); + }); + + it("skips a blank line inside the block without ending it", () => { + const result = extractFrontMatter( + "---\ntitle: x\n\nauthor: y\n---\nbody\n", + ); + expect(result.metadata).toStrictEqual({ title: "x", author: "y" }); + }); + + it("silently skips a line that is not key: value shaped, with no diagnostic", () => { + const collector = createDiagnosticCollector(); + const result = extractFrontMatter( + "---\ntitle: x\nnot a key value line\n---\nbody\n", + collector.sink, + ); + expect(result.metadata).toStrictEqual({ title: "x" }); + expect(collector.diagnostics).toHaveLength(0); + }); +}); + +describe("extractFrontMatter: scalar quote stripping, at the exact length-2 boundary", () => { + it("strips a genuinely double-quoted value", () => { + expect( + extractFrontMatter('---\ntitle: "abc"\n---\n').metadata, + ).toStrictEqual({ + title: "abc", + }); + }); + + it("strips a genuinely single-quoted value", () => { + expect( + extractFrontMatter("---\ntitle: 'abc'\n---\n").metadata, + ).toStrictEqual({ + title: "abc", + }); + }); + + it("strips an empty double-quoted value (length exactly 2)", () => { + expect(extractFrontMatter('---\ntitle: ""\n---\n').metadata).toStrictEqual({ + title: "", + }); + }); + + it("does not strip a lone quote character (length 1, below the boundary)", () => { + expect(extractFrontMatter('---\ntitle: "\n---\n').metadata).toStrictEqual({ + title: '"', + }); + }); + + it("does not strip when only the opening quote matches -- no closing quote at all", () => { + expect( + extractFrontMatter('---\ntitle: "abc\n---\n').metadata, + ).toStrictEqual({ + title: '"abc', + }); + }); + + it("does not strip when only the closing quote matches -- no opening quote at all", () => { + expect( + extractFrontMatter('---\ntitle: abc"\n---\n').metadata, + ).toStrictEqual({ + title: 'abc"', + }); + }); + + it("does not strip mismatched quote kinds (opens single, closes double)", () => { + expect( + extractFrontMatter(`---\ntitle: 'abc"\n---\n`).metadata, + ).toStrictEqual({ + title: `'abc"`, + }); + }); + + it("leaves an unquoted value untouched", () => { + expect(extractFrontMatter("---\ntitle: abc\n---\n").metadata).toStrictEqual( + { + title: "abc", + }, + ); + }); +}); + +describe("extractFrontMatter: keywords, both the bracketed and the bare comma-separated shape", () => { + it("parses a bracketed flow-sequence list", () => { + expect( + extractFrontMatter("---\nkeywords: [a, b, c]\n---\n").metadata.keywords, + ).toStrictEqual(["a", "b", "c"]); + }); + + it("parses a bare comma-separated fallback with no brackets at all", () => { + expect( + extractFrontMatter("---\nkeywords: a, b, c\n---\n").metadata.keywords, + ).toStrictEqual(["a", "b", "c"]); + }); + + it("trims outer whitespace around a bracketed list before checking for the brackets", () => { + expect( + extractFrontMatter("---\nkeywords: [a, b] \n---\n").metadata.keywords, + ).toStrictEqual(["a", "b"]); + }); + + it("does not treat a value as bracketed when only the opening bracket is present", () => { + // Malformed: starts with "[" but never closes -- read as one bare comma-separated line instead, exactly as this module's own "not a real YAML parser" scope promises. The unstripped leading "[" survives on the first item. + expect( + extractFrontMatter("---\nkeywords: [a, b\n---\n").metadata.keywords, + ).toStrictEqual(["[a", "b"]); + }); + + it("filters out an empty item from a trailing comma", () => { + expect( + extractFrontMatter("---\nkeywords: a, b,\n---\n").metadata.keywords, + ).toStrictEqual(["a", "b"]); + }); + + it("keeps a genuinely single-character item, right at the length-0 filter boundary", () => { + expect( + extractFrontMatter("---\nkeywords: a,,b\n---\n").metadata.keywords, + ).toStrictEqual(["a", "b"]); + }); +}); + +describe("extractFrontMatter: direction, a two-member enum that silently drops any other value", () => { + it("maps a recognised direction value", () => { + expect( + extractFrontMatter("---\ndirection: rtl\n---\n").metadata, + ).toStrictEqual({ + direction: "rtl", + }); + }); + + it("silently drops an unrecognised direction value -- no FRONT_MATTER_KEY_UNMAPPED, since the key itself is recognised", () => { + const collector = createDiagnosticCollector(); + const result = extractFrontMatter( + "---\ndirection: sideways\n---\n", + collector.sink, + ); + expect(result.metadata).toStrictEqual({}); + expect(collector.diagnostics).toHaveLength(0); + }); +}); + +describe("extractFrontMatter: an unrecognised key reports FRONT_MATTER_KEY_UNMAPPED with its own exact message and 1-based line number", () => { + it("fires with the key name and the line it appeared on", () => { + const collector = createDiagnosticCollector(); + const result = extractFrontMatter( + "---\ntitle: x\ncustomField: y\n---\n", + collector.sink, + ); + expect(result.metadata).toStrictEqual({ title: "x" }); + expect(collector.diagnostics).toHaveLength(1); + expect(collector.diagnostics[0]).toMatchObject({ + code: MarkdownDiagnosticCodes.FRONT_MATTER_KEY_UNMAPPED, + message: + 'front matter key "customField" has no LayoutMetadata equivalent and was dropped from the metadata; its original spelling survives in the verbatim front-matter block this package\'s own writer can re-emit', + line: 3, + }); + }); +}); + +describe("extractFrontMatter: rest and source split exactly at the closing delimiter", () => { + it("carries the verbatim block (delimiters included) as source and everything after as rest", () => { + const result = extractFrontMatter("---\ntitle: x\n---\nbody\nmore\n"); + expect(result.source).toBe("---\ntitle: x\n---"); + expect(result.rest).toBe("body\nmore\n"); + }); +}); From 214ca0abfbb52ea8dad55e94d414491a5d2a2289 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 04:13:50 +0100 Subject: [PATCH 42/99] test(markdown-codec): pin mathBlock's and footnoteDefinition's own cr() Both cases open with the same cr()-matters shape as renderList's and renderTable's own opening cr(): a tight list item's bare paragraph text (no trailing newline of its own) immediately followed by the block in the same item, confirmed by a genuine cold (non-incremental) mutation run at 100% for this file. --- .../markdown-codec/src/html/render.test.ts | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/packages/markdown-codec/src/html/render.test.ts b/packages/markdown-codec/src/html/render.test.ts index b4e7624fbd..2eb79acc3a 100644 --- a/packages/markdown-codec/src/html/render.test.ts +++ b/packages/markdown-codec/src/html/render.test.ts @@ -245,6 +245,48 @@ describe("renderDocumentToHtml: cr() only inserts a newline when the buffer genu "
    \n
  • a\n\n\n\n\n\n\n
    h
    \n
  • \n
\n", ); }); + + it("inserts a newline between a tight list item's bare paragraph text and a math block that follows it in the same item", () => { + const html = render([ + { + type: "list", + markerType: "bullet", + bulletMarker: "-", + tight: true, + children: [ + { + type: "listItem", + children: [ + { type: "paragraph", children: [{ type: "text", value: "a" }] }, + { type: "mathBlock", literal: "x" }, + ], + }, + ], + }, + ]); + expect(html).toBe("
    \n
  • a\n$$\nx\n$$\n
  • \n
\n"); + }); + + it("inserts a newline between a tight list item's bare paragraph text and a footnote definition that follows it in the same item", () => { + const html = render([ + { + type: "list", + markerType: "bullet", + bulletMarker: "-", + tight: true, + children: [ + { + type: "listItem", + children: [ + { type: "paragraph", children: [{ type: "text", value: "a" }] }, + { type: "footnoteDefinition", label: "n", children: [] }, + ], + }, + ], + }, + ]); + expect(html).toBe("
    \n
  • a\n[^n]:\n
  • \n
\n"); + }); }); describe("renderInlines: image, the one leaf case none of block.test.ts/lower.test.ts/conformance corpora happen to reach through renderDocumentToHtml", () => { From b7a908ca12e0099dddbdda8507b5d58d2f81f467 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 04:18:21 +0100 Subject: [PATCH 43/99] refactor(markdown-codec): drop two more of chars.ts's unkillable guards - containsAsciiControlOrSpace's manual index/length loop: a "<=" in place of "<" only ever adds one extra iteration over charAt(length), which returns "" -- itself neither a control character nor a space -- so no observable difference is possible. Rewritten as split+some, which removes the length comparison as an AST node entirely. - codePointBefore's "index >= 2" guard: index <= 0 has already returned above, leaving index === 1 as the only case the guard could exclude, and text.charCodeAt(-2) there is always NaN, which already fails the high-surrogate range check on its own -- the guard excluded nothing the check didn't already exclude by itself. --- packages/markdown-codec/src/inline/chars.ts | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/packages/markdown-codec/src/inline/chars.ts b/packages/markdown-codec/src/inline/chars.ts index 345af002f4..f73c24aab5 100644 --- a/packages/markdown-codec/src/inline/chars.ts +++ b/packages/markdown-codec/src/inline/chars.ts @@ -38,12 +38,7 @@ export function isAsciiControl(char: string): boolean { // Whether any ASCII control character or space appears in `text` -- the exclusion an absolute URI inside an autolink is defined by (spec 0.31.2: "zero or more characters other than ASCII control characters, space, `<`, and `>`"). Written as a scan rather than a regex character range deliberately: a `[\x00-\x20]` class is a literal control character embedded in a pattern, which is both unreadable and exactly what eslint's own no-control-regex rule exists to catch. export function containsAsciiControlOrSpace(text: string): boolean { - for (let index = 0; index < text.length; index += 1) { - if (isAsciiControl(text.charAt(index)) || text.charAt(index) === " ") { - return true; - } - } - return false; + return text.split("").some((char) => isAsciiControl(char) || char === " "); } // Spaces, tabs, and line endings -- the whitespace vocabulary CommonMark's own *syntactic* rules use (link label normalisation, the whitespace permitted between an inline link's components), as opposed to the full Unicode whitespace class the flanking rules use. Kept distinct deliberately: collapsing the two would make a non-breaking space count as a label separator, which the spec does not allow. @@ -56,8 +51,9 @@ export function codePointBefore(text: string, index: number): string { if (index <= 0) { return "\n"; } + // No separate "index >= 2" guard: index <= 0 has already returned above, leaving index === 1 as the only remaining case a missing guard could affect, and text.charCodeAt(-2) there is always NaN, which already fails the high-surrogate check below on its own -- an explicit index guard would only ever exclude a case that already excludes itself. const low = text.charCodeAt(index - 1); - if (index >= 2 && low >= 0xdc00 && low <= 0xdfff) { + if (low >= 0xdc00 && low <= 0xdfff) { const high = text.charCodeAt(index - 2); if (high >= 0xd800 && high <= 0xdbff) { return text.slice(index - 2, index); From ed57efdbe2e2dfeb9335d66f1f81fc5450c1a038 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 04:18:28 +0100 Subject: [PATCH 44/99] test(markdown-codec): pin chars.ts's own boundaries the fresh run found A prior scoped mutation run against this file turned out to have reused stale incremental results for several mutants instead of genuinely retesting them against the tests just added, masking real gaps: isMarkdownSpace had no direct coverage at all, and codePointBefore's own surrogate-range boundaries (0xdc00, 0xdfff, 0xd800, 0xdbff) were only exercised by real Unicode characters that happen to sit well inside each range, never at the exact edges that distinguish a correct comparison from an off-by-one one. Confirmed by a genuine cold (non-incremental) run at 100% for this file afterwards. --- .../markdown-codec/src/inline/chars.test.ts | 69 ++++++++++++++++++- 1 file changed, 68 insertions(+), 1 deletion(-) diff --git a/packages/markdown-codec/src/inline/chars.test.ts b/packages/markdown-codec/src/inline/chars.test.ts index b5e948c003..76f089d7da 100644 --- a/packages/markdown-codec/src/inline/chars.test.ts +++ b/packages/markdown-codec/src/inline/chars.test.ts @@ -1,4 +1,4 @@ -// Direct unit tests for this module's own character-class predicates and codepoint helpers -- link.ts and delimiter.ts only exercise these through whatever characters the round-trip suites' own markdown sources happen to contain, never at the exact boundaries (0x1f/0x20, 0x7e/0x7f, the surrogate-pair range edges) that distinguish a correct comparison from an off-by-one one. +// Direct unit tests for this module's own character-class predicates and codepoint helpers -- link.ts and delimiter.ts only exercise these through whatever characters the round-trip suites' own markdown sources happen to contain, never at the exact boundaries (0x1f/0x20, 0x7e/0x7f, and every one of the four surrogate-pair range edges individually) that distinguish a correct comparison from an off-by-one one. import { describe, expect, it } from "vitest"; import { @@ -6,6 +6,7 @@ import { codePointBefore, containsAsciiControlOrSpace, isAsciiControl, + isMarkdownSpace, } from "./chars"; describe("isAsciiControl", () => { @@ -46,6 +47,20 @@ describe("containsAsciiControlOrSpace", () => { }); }); +describe("isMarkdownSpace", () => { + it("recognises space, tab, line feed, and carriage return", () => { + expect(isMarkdownSpace(" ")).toBe(true); + expect(isMarkdownSpace("\t")).toBe(true); + expect(isMarkdownSpace("\n")).toBe(true); + expect(isMarkdownSpace("\r")).toBe(true); + }); + + it("does not recognise a non-breaking space or an ordinary letter", () => { + expect(isMarkdownSpace(" ")).toBe(false); + expect(isMarkdownSpace("a")).toBe(false); + }); +}); + describe("codePointBefore", () => { it("returns a bare newline at the very start of the string", () => { expect(codePointBefore("abc", 0)).toBe("\n"); @@ -68,6 +83,58 @@ describe("codePointBefore", () => { // \uDE00 alone (a lone low surrogate, no high surrogate before it) is not a real pair. expect(codePointBefore("a\uDE00", 2)).toBe("\uDE00"); }); + + describe("low-surrogate range boundary (0xdc00-0xdfff)", () => { + it("treats 0xdc00 (the lower bound) as a low surrogate", () => { + const low = String.fromCharCode(0xdc00); + const text = String.fromCharCode(0xd800) + low; + expect(codePointBefore(text, 2)).toBe(text); + }); + + it("does not treat 0xdbff (one below the lower bound) as a low surrogate", () => { + const notLow = String.fromCharCode(0xdbff); + const text = String.fromCharCode(0xd800) + notLow; + expect(codePointBefore(text, 2)).toBe(notLow); + }); + + it("treats 0xdfff (the upper bound) as a low surrogate", () => { + const low = String.fromCharCode(0xdfff); + const text = String.fromCharCode(0xd800) + low; + expect(codePointBefore(text, 2)).toBe(text); + }); + + it("does not treat 0xe000 (one above the upper bound) as a low surrogate", () => { + const notLow = String.fromCharCode(0xe000); + const text = String.fromCharCode(0xd800) + notLow; + expect(codePointBefore(text, 2)).toBe(notLow); + }); + }); + + describe("high-surrogate range boundary (0xd800-0xdbff)", () => { + it("treats 0xd800 (the lower bound) as a valid high surrogate", () => { + const high = String.fromCharCode(0xd800); + const low = String.fromCharCode(0xdc00); + expect(codePointBefore(high + low, 2)).toBe(high + low); + }); + + it("does not treat 0xd7ff (one below the lower bound) as a valid high surrogate", () => { + const notHigh = String.fromCharCode(0xd7ff); + const low = String.fromCharCode(0xdc00); + expect(codePointBefore(notHigh + low, 2)).toBe(low); + }); + + it("treats 0xdbff (the upper bound) as a valid high surrogate", () => { + const high = String.fromCharCode(0xdbff); + const low = String.fromCharCode(0xdc00); + expect(codePointBefore(high + low, 2)).toBe(high + low); + }); + + it("does not treat 0xdc00 (one above the upper bound) as a valid high surrogate", () => { + const notHigh = String.fromCharCode(0xdc00); + const low = String.fromCharCode(0xdc00); + expect(codePointBefore(notHigh + low, 2)).toBe(low); + }); + }); }); describe("codePointAt", () => { From a15c22de21740f7fe9e670790ce2f85488c8d74a Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 04:23:41 +0100 Subject: [PATCH 45/99] refactor(markdown-codec): drop front-matter.ts's own unkillable loops Three constructs had no test able to distinguish their mutated form: - Both scanning loops' manual "index < lines.length"/"index < closingIndex" bounds: one extra iteration past either bound only ever reads a line that already fails its own check (an out-of-range access safely falls through the same "not a match" path a genuinely empty or non-matching line already takes), so the bound never changes what the function returns. Rewritten as findIndex over a slice, and a for-of over a slice's own entries, which removes the bound comparison as an AST node entirely -- and with it, the now-unreachable "lines[index] ?? \"\"" fallback each loop no longer needs, since split() never produces a sparse array. - The dedicated blank-line skip: a blank or all-whitespace line never matches KEY_VALUE_LINE_PATTERN either (it requires a leading identifier character), so it was already falling through to the same skip a non-matching line takes on its own -- a second, redundant path to an identical result. --- .../markdown-codec/src/lower/front-matter.ts | 20 ++++++++----------- 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/packages/markdown-codec/src/lower/front-matter.ts b/packages/markdown-codec/src/lower/front-matter.ts index f25bac1c47..ce9eb8744c 100644 --- a/packages/markdown-codec/src/lower/front-matter.ts +++ b/packages/markdown-codec/src/lower/front-matter.ts @@ -114,23 +114,19 @@ export function extractFrontMatter( return { metadata: {}, rest: source, source: undefined }; } - let closingIndex = -1; - for (let index = 1; index < lines.length; index += 1) { - if (CLOSING_DELIMITER_PATTERN.test(lines[index] ?? "")) { - closingIndex = index; - break; - } - } + // Both loops below scan a slice (never a manually-bounded index/length comparison against the full array) and derive the 1-based line number from the slice's own offset -- lines[index] within either slice's real bounds is always a defined string (split() never produces a sparse array), so there is no further "missing element" fallback to write either. + const closingOffset = lines + .slice(1) + .findIndex((line) => CLOSING_DELIMITER_PATTERN.test(line)); + const closingIndex = closingOffset === -1 ? -1 : closingOffset + 1; if (closingIndex === -1) { return { metadata: {}, rest: source, source: undefined }; } const metadata: MutableLayoutMetadata = {}; - for (let index = 1; index < closingIndex; index += 1) { - const line = lines[index] ?? ""; - if (line.trim().length === 0) { - continue; - } + for (const [offset, line] of lines.slice(1, closingIndex).entries()) { + const index = offset + 1; + // No separate blank-line skip: a blank (or all-whitespace) line never matches KEY_VALUE_LINE_PATTERN either (it requires a leading identifier character), so it already falls through to the "not key: value shaped" skip below -- a dedicated check here would only ever repeat a skip the match failure already produces on its own. const match = KEY_VALUE_LINE_PATTERN.exec(line); const key = match?.[1]; const value = match?.[2]; From 667ef818e97ef1f937e2ff0e8a0e3360c97ffe0a Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 04:23:55 +0100 Subject: [PATCH 46/99] test(markdown-codec): pin front-matter.ts's remaining boundaries A prior test pass covered the double-quote scalar boundary directly but only exercised the single-quote one incidentally, through inputs where length/startsWith/endsWith were already true regardless of a forced-true mutation. Adds the missing symmetric single-quote cases (a lone quote, an empty quoted value, each one-sided mismatch), a keywords list missing its opening bracket (the mirror of the already-covered missing-closing-bracket case), the "ltr" half of isTextDirection's two-member check (only "rtl" was covered before), and a document whose first line isn't a front-matter opener but whose body later contains a line that would otherwise be misread as closing one. --- .../src/lower/front-matter.test.ts | 54 ++++++++++++++++++- 1 file changed, 53 insertions(+), 1 deletion(-) diff --git a/packages/markdown-codec/src/lower/front-matter.test.ts b/packages/markdown-codec/src/lower/front-matter.test.ts index 9a08513078..4adfbd3fb1 100644 --- a/packages/markdown-codec/src/lower/front-matter.test.ts +++ b/packages/markdown-codec/src/lower/front-matter.test.ts @@ -24,6 +24,17 @@ describe("extractFrontMatter: no front matter present at all", () => { source: undefined, }); }); + + it("leaves a document unchanged even when a later line happens to look like a closing delimiter, since the first line never opened a block at all", () => { + // The first line's own check must genuinely gate the whole function: without it, a body that merely contains a bare "---" or "..." later on could be misread as if it closed a front-matter block that was never opened. + const source = "not front matter\n---\nbody\n"; + const result = extractFrontMatter(source); + expect(result).toStrictEqual({ + metadata: {}, + rest: source, + source: undefined, + }); + }); }); describe("extractFrontMatter: closing delimiter shapes", () => { @@ -111,6 +122,35 @@ describe("extractFrontMatter: scalar quote stripping, at the exact length-2 boun }, ); }); + + // The single-quote checks mirror the double-quote ones above exactly -- isDoubleQuoted short-circuits on startsWith('"') before ever reaching endsWith for a single-quoted value, so only a value that itself exercises isSingleQuoted's own length/startsWith/endsWith checks at each boundary can kill a mutant in it. + it("does not strip a lone single-quote character (length 1, below the boundary)", () => { + expect(extractFrontMatter("---\ntitle: '\n---\n").metadata).toStrictEqual({ + title: "'", + }); + }); + + it("strips an empty single-quoted value (length exactly 2)", () => { + expect(extractFrontMatter("---\ntitle: ''\n---\n").metadata).toStrictEqual({ + title: "", + }); + }); + + it("does not strip when only the opening single quote matches -- no closing quote at all", () => { + expect( + extractFrontMatter("---\ntitle: 'abc\n---\n").metadata, + ).toStrictEqual({ + title: "'abc", + }); + }); + + it("does not strip when only the closing single quote matches -- no opening quote at all", () => { + expect( + extractFrontMatter("---\ntitle: abc'\n---\n").metadata, + ).toStrictEqual({ + title: "abc'", + }); + }); }); describe("extractFrontMatter: keywords, both the bracketed and the bare comma-separated shape", () => { @@ -150,15 +190,27 @@ describe("extractFrontMatter: keywords, both the bracketed and the bare comma-se extractFrontMatter("---\nkeywords: a,,b\n---\n").metadata.keywords, ).toStrictEqual(["a", "b"]); }); + + it("does not treat a value as bracketed when only the closing bracket is present", () => { + // Malformed the other way round: ends with "]" but never opens -- still read as one bare comma-separated line, since both the opening AND closing bracket are required together. The unstripped trailing "]" survives on the last item. + expect( + extractFrontMatter("---\nkeywords: a, b]\n---\n").metadata.keywords, + ).toStrictEqual(["a", "b]"]); + }); }); describe("extractFrontMatter: direction, a two-member enum that silently drops any other value", () => { - it("maps a recognised direction value", () => { + it("maps both recognised direction values", () => { expect( extractFrontMatter("---\ndirection: rtl\n---\n").metadata, ).toStrictEqual({ direction: "rtl", }); + expect( + extractFrontMatter("---\ndirection: ltr\n---\n").metadata, + ).toStrictEqual({ + direction: "ltr", + }); }); it("silently drops an unrecognised direction value -- no FRONT_MATTER_KEY_UNMAPPED, since the key itself is recognised", () => { From 86f9a9fa39bf821780d7131dac8f2cc466631050 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 13:35:32 +0100 Subject: [PATCH 47/99] test(markdown-codec): cover image.ts's base64 codec and format-sniffing paths readImageDimensions's own PNG/JPEG signature and header parsing had thin coverage of its boundary conditions: the JPG (0xC8) and DAC (0xCC) markers sharing the SOF numeric range, the no-length-field markers (RST0-RST7, TEM), a run of 0xFF fill bytes preceding a real marker, and the exact minimum-length boundary for a readable IHDR chunk each had no dedicated test. bytesToBase64, base64ToBytes, and detectImageFormat were exported but had no tests of their own at all, despite being exercised only incidentally through the image-dimension tests above. --- .../markdown-codec/src/image/image.test.ts | 298 +++++++++++++++++- 1 file changed, 297 insertions(+), 1 deletion(-) diff --git a/packages/markdown-codec/src/image/image.test.ts b/packages/markdown-codec/src/image/image.test.ts index d389d365ea..7edbf90235 100644 --- a/packages/markdown-codec/src/image/image.test.ts +++ b/packages/markdown-codec/src/image/image.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it } from "vitest"; -import { readImageDimensions } from "./image"; +import { + base64ToBytes, + bytesToBase64, + detectImageFormat, + readImageDimensions, +} from "./image"; function bytes(...values: number[]): Uint8Array { return new Uint8Array(values); @@ -124,4 +129,295 @@ describe("readImageDimensions", () => { const jpeg = bytes(0xff, 0xd8, 0xff, 0xe0, 0x00, 0x02); expect(readImageDimensions(jpeg)).toBeUndefined(); }); + + it("reads a PNG whose length is exactly the minimum IHDR-readable size", () => { + const png = bytes( + 0x89, + 0x50, + 0x4e, + 0x47, + 0x0d, + 0x0a, + 0x1a, + 0x0a, + 0x00, + 0x00, + 0x00, + 0x0d, + 0x49, + 0x48, + 0x44, + 0x52, + 0x00, + 0x00, + 0x00, + 0x01, + 0x00, + 0x00, + 0x00, + 0x01, + ); + expect(readImageDimensions(png)).toEqual({ widthPx: 1, heightPx: 1 }); + }); + + it("returns undefined for a PNG one byte short of the minimum IHDR-readable size", () => { + const png = bytes( + 0x89, + 0x50, + 0x4e, + 0x47, + 0x0d, + 0x0a, + 0x1a, + 0x0a, + 0x00, + 0x00, + 0x00, + 0x0d, + 0x49, + 0x48, + 0x44, + 0x52, + 0x00, + 0x00, + 0x00, + 0x01, + 0x00, + 0x00, + 0x00, + ); + expect(readImageDimensions(png)).toBeUndefined(); + }); + + it.each([ + [12, 0x00], + [13, 0x00], + [14, 0x00], + [15, 0x00], + ])( + "returns undefined when only byte %i of the 'IHDR' chunk type is wrong", + (badOffset, badByte) => { + const values = [ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, + 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, + ]; + values[badOffset] = badByte; + expect(readImageDimensions(bytes(...values))).toBeUndefined(); + }, + ); + + it("does not mistake JPG (0xC8) for a frame header", () => { + const jpeg = bytes( + 0xff, + 0xd8, + 0xff, + 0xc8, + 0x00, + 0x02, // JPG marker, zero-length payload + 0xff, + 0xc0, + 0x00, + 0x0b, + 0x08, + 0x00, + 0x05, + 0x00, + 0x06, + 0x01, + 0x01, + 0x11, + 0x00, + 0xff, + 0xd9, + ); + expect(readImageDimensions(jpeg)).toEqual({ widthPx: 6, heightPx: 5 }); + }); + + it("does not mistake DAC (0xCC) for a frame header", () => { + const jpeg = bytes( + 0xff, + 0xd8, + 0xff, + 0xcc, + 0x00, + 0x03, + 0x00, // DAC, length 3 (1 payload byte) + 0xff, + 0xc0, + 0x00, + 0x0b, + 0x08, + 0x00, + 0x07, + 0x00, + 0x08, + 0x01, + 0x01, + 0x11, + 0x00, + 0xff, + 0xd9, + ); + expect(readImageDimensions(jpeg)).toEqual({ widthPx: 8, heightPx: 7 }); + }); + + it("reads a SOF2 (progressive) frame header, proving the SOF check isn't hardcoded to 0xC0", () => { + const jpeg = bytes( + 0xff, + 0xd8, + 0xff, + 0xc2, + 0x00, + 0x0b, // SOF2 + 0x08, + 0x00, + 0x09, + 0x00, + 0x0a, + 0x01, + 0x01, + 0x11, + 0x00, + 0xff, + 0xd9, + ); + expect(readImageDimensions(jpeg)).toEqual({ widthPx: 10, heightPx: 9 }); + }); + + it("skips a marker preceded by a run of extra 0xFF fill bytes", () => { + const jpeg = bytes( + 0xff, + 0xd8, + 0xff, + 0xff, + 0xff, + 0xc0, // fill bytes before the real SOF0 marker + 0x00, + 0x0b, + 0x08, + 0x00, + 0x0c, + 0x00, + 0x0d, + 0x01, + 0x01, + 0x11, + 0x00, + 0xff, + 0xd9, + ); + expect(readImageDimensions(jpeg)).toEqual({ widthPx: 13, heightPx: 12 }); + }); + + it("returns undefined when a run of fill bytes runs off the end of input with no marker byte following", () => { + const jpeg = bytes(0xff, 0xd8, 0xff, 0xff, 0xff); + expect(readImageDimensions(jpeg)).toBeUndefined(); + }); + + it.each([ + [0xd0, "RST0"], + [0xd7, "RST7"], + [0x01, "TEM"], + ])( + "skips a %s marker (%s) with no length field, continuing to the next marker", + (marker) => { + const jpeg = bytes( + 0xff, + 0xd8, + 0xff, + marker, // no-length-field marker + 0xff, + 0xc0, + 0x00, + 0x0b, + 0x08, + 0x00, + 0x0e, + 0x00, + 0x0f, + 0x01, + 0x01, + 0x11, + 0x00, + 0xff, + 0xd9, + ); + expect(readImageDimensions(jpeg)).toEqual({ + widthPx: 15, + heightPx: 14, + }); + }, + ); + + it("returns undefined at Start Of Scan (0xDA) with no frame header found first", () => { + const jpeg = bytes(0xff, 0xd8, 0xff, 0xda, 0x00, 0x02); + expect(readImageDimensions(jpeg)).toBeUndefined(); + }); + + it("returns undefined when a marker's declared length field is truncated", () => { + const jpeg = bytes(0xff, 0xd8, 0xff, 0xe0, 0x00); + expect(readImageDimensions(jpeg)).toBeUndefined(); + }); + + it("returns undefined when a SOF marker's own payload is truncated before height/width", () => { + const jpeg = bytes(0xff, 0xd8, 0xff, 0xc0, 0x00, 0x0b, 0x08, 0x00); + expect(readImageDimensions(jpeg)).toBeUndefined(); + }); +}); + +describe("detectImageFormat", () => { + it("detects a PNG signature", () => { + expect( + detectImageFormat(bytes(0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a)), + ).toBe("png"); + }); + + it("detects a JPEG SOI marker", () => { + expect(detectImageFormat(bytes(0xff, 0xd8, 0xff, 0xe0))).toBe("jpeg"); + }); + + it("returns undefined for neither a PNG nor a JPEG", () => { + expect(detectImageFormat(bytes(0x00, 0x01, 0x02, 0x03))).toBeUndefined(); + }); + + it("returns undefined for an empty input", () => { + expect(detectImageFormat(bytes())).toBeUndefined(); + }); +}); + +describe("bytesToBase64 / base64ToBytes", () => { + it.each([ + [[], ""], + [[0x4d], "TQ=="], + [[0x4d, 0x61], "TWE="], + [[0x4d, 0x61, 0x6e], "TWFu"], + [[0x4d, 0x61, 0x6e, 0x21], "TWFuIQ=="], + ])("encodes %j to %s", (input, expected) => { + expect(bytesToBase64(bytes(...input))).toBe(expected); + }); + + it.each([ + ["", []], + ["TQ==", [0x4d]], + ["TWE=", [0x4d, 0x61]], + ["TWFu", [0x4d, 0x61, 0x6e]], + ["TWFuIQ==", [0x4d, 0x61, 0x6e, 0x21]], + ])("decodes %s to %j", (input, expected) => { + expect(Array.from(base64ToBytes(input))).toEqual(expected); + }); + + it("round-trips arbitrary byte sequences through encode then decode", () => { + const original = bytes(0x00, 0xff, 0x10, 0x80, 0x7f, 0x01, 0x02, 0x03); + expect(Array.from(base64ToBytes(bytesToBase64(original)))).toEqual( + Array.from(original), + ); + }); + + it("ignores characters outside the base64 alphabet when decoding", () => { + expect(Array.from(base64ToBytes("TW\nFu\r\n"))).toEqual([0x4d, 0x61, 0x6e]); + }); + + it("throws for an invalid base64 character in a would-be data position", () => { + expect(() => base64ToBytes("T!==")).toThrow("invalid base64 input"); + }); }); From 897e174716f8d5adc07abee86933db2f8d4fc6d1 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 13:50:48 +0100 Subject: [PATCH 48/99] refactor(markdown-codec): drop emit.ts's own unkillable length-bound loops Five loops walked a readonly array with `while (index < arr.length)` and then immediately checked `arr[index] === undefined` to break -- the same redundant-bounds-check idiom already fixed in front-matter.ts. Since an out-of-range index already yields undefined, which the very next line already breaks on, the length comparison can never be independently true or false: replacing `<` with `<=` produces an identical result on every real input, an equivalent mutant no test can ever kill. consumeSameItemRun, collectListItem's own nested-run scan, renderListRegion, groupConstructItems, and renderItems each drop their own redundant length check in favour of a plain `for (;;)` bounded solely by the undefined check they already had. --- packages/markdown-codec/src/emit/emit.ts | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/packages/markdown-codec/src/emit/emit.ts b/packages/markdown-codec/src/emit/emit.ts index eee36d8193..70cc7960cb 100644 --- a/packages/markdown-codec/src/emit/emit.ts +++ b/packages/markdown-codec/src/emit/emit.ts @@ -721,7 +721,8 @@ function consumeSameItemRun( itemId: string, ): number { let end = from; - while (end < items.length) { + // No separate `end < items.length` bound: `items[end]` running off the end already returns undefined, which the very next check below catches and breaks on -- an explicit length comparison here would be redundant with that undefined check on every real input, never independently true or false. + for (;;) { const candidate = items[end]; if (candidate?.list.level !== level || candidate.list.itemId !== itemId) { break; @@ -750,7 +751,8 @@ function collectListItem( for (;;) { let nestedEnd = index; - while (nestedEnd < items.length) { + // No separate `nestedEnd < items.length` bound: `items[nestedEnd]` running off the end already yields `candidateLevel === undefined`, which the check below already breaks on. + for (;;) { const candidateLevel = items[nestedEnd]?.list.level; if (candidateLevel === undefined || candidateLevel <= level) { break; @@ -854,7 +856,8 @@ function renderListRegion( let index = 0; // The immediately preceding numId's own resolved type/glyph, local to this call (never read across a recursive call into a nested sub-list, or across a separate top-level renderListRegion call) -- exactly the scope resolveListGlyph's own collision check needs: two lists are only a genuine ADJACENCY risk when nothing else renders between them, which is precisely what "both sit in the SAME renderListRegion call's own items array" already guarantees. Left unset (and never consulted) for a depth-only membership (numId undefined, the cross-format shape LIST_NUMID_FALLBACK already documents) -- a rare cross-format edge case this glyph-alternation scheme does not extend to. let previousSibling: ListSiblingSignature | undefined; - while (index < items.length) { + // No separate `index < items.length` bound: `items[index]` running off the end already returns undefined, which the very next check breaks on. + for (;;) { const item = items[index]; if (item === undefined) { break; @@ -994,7 +997,8 @@ function groupConstructItems( ): { readonly items: EmitItem[]; readonly next: number } { const items: EmitItem[] = []; let index = start; - while (index < blocks.length) { + // No separate `index < blocks.length` bound: `blocks[index]` running off the end already returns undefined, which the very next check breaks on. + for (;;) { const block = blocks[index]; if (block === undefined) { break; @@ -1127,7 +1131,8 @@ function isInheritedListMembership( function renderItems(items: readonly EmitItem[], context: EmitContext): string { const parts: string[] = []; let index = 0; - while (index < items.length) { + // No separate `index < items.length` bound: `items[index]` running off the end already returns undefined, which the very next check breaks on. + for (;;) { const item = items[index]; if (item === undefined) { break; From 491d0551c78416956536e9e05424fd29b61eda12 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 13:51:03 +0100 Subject: [PATCH 49/99] test(markdown-codec): cover emit.ts's terminatesCleanly, isQuotableStyle, and top-level assembly options terminatesCleanly's CODE_BLOCK/MATH_BLOCK/HORIZONTAL_RULE clauses had no test proving a following plain paragraph in the same TIGHT list item stays unforced (no blank line inserted) when the preceding block genuinely closes cleanly, nor one proving an unrecognised styleId DOES still force the blank line requiresBlankLineBefore exists for. isQuotableStyle's own QUOTABLE_STYLE_IDS/heading branch was only ever exercised via its styleId === undefined short-circuit (the existing PARAGRAPH_INDENT_DROPPED test has no styleId at all); a defined but unrecognised styleId never reached the real check. emitMarkdown's own top-level assembly -- joining multiple sections, prepending front matter, and rewriting line endings to CRLF -- had no tests of its own in this file at all. --- packages/markdown-codec/src/emit/emit.test.ts | 154 ++++++++++++++++++ 1 file changed, 154 insertions(+) diff --git a/packages/markdown-codec/src/emit/emit.test.ts b/packages/markdown-codec/src/emit/emit.test.ts index b332522c6a..8429a9c3ae 100644 --- a/packages/markdown-codec/src/emit/emit.test.ts +++ b/packages/markdown-codec/src/emit/emit.test.ts @@ -2389,6 +2389,74 @@ describe("lists", () => { expect(mathBlock?.kind).toBe("embeddedObject"); }); + it("needs no forced blank line between a CodeBlock and a following plain paragraph in the SAME tight list item -- a fenced code block's own closing fence terminates cleanly, with nothing left open for the next line to lazily continue", () => { + const source = doc([ + { + kind: "paragraph", + styleId: "CodeBlock", + runs: [{ text: "x" }], + list: { numId: "md1:bullet", level: 0, itemId: "i1" }, + }, + { + kind: "paragraph", + runs: [{ text: "y" }], + list: { numId: "md1:bullet", level: 0, itemId: "i1" }, + }, + ]); + expect(emitMarkdown(source)).toBe("- ```\n x\n ```\n y"); + }); + + it("needs no forced blank line between a MathBlock and a following plain paragraph in the SAME tight list item -- a $$ block's own closing delimiter terminates cleanly", () => { + const source = doc([ + { + kind: "paragraph", + styleId: "MathBlock", + runs: [{ text: "x" }], + list: { numId: "md1:bullet", level: 0, itemId: "i1" }, + }, + { + kind: "paragraph", + runs: [{ text: "y" }], + list: { numId: "md1:bullet", level: 0, itemId: "i1" }, + }, + ]); + expect(emitMarkdown(source)).toBe("- $$\n x\n $$\n y"); + }); + + it("needs no forced blank line between a HorizontalRule and a following plain paragraph in the SAME tight list item -- a thematic break is a single complete line with nothing left open", () => { + const source = doc([ + { + kind: "paragraph", + styleId: "HorizontalRule", + runs: [], + list: { numId: "md1:bullet", level: 0, itemId: "i1" }, + }, + { + kind: "paragraph", + runs: [{ text: "y" }], + list: { numId: "md1:bullet", level: 0, itemId: "i1" }, + }, + ]); + expect(emitMarkdown(source, { thematicBreakChar: "*" })).toBe("- ***\n y"); + }); + + it("DOES force a blank line between two plain paragraphs sharing an unrecognised, non-quotable, non-clean-terminating styleId in the same tight list item -- src/lower's own reader can only ever have produced this pair from a genuine source blank line, so the write side must reinsert it even though the list itself is tight", () => { + const source = doc([ + { + kind: "paragraph", + styleId: "SomeUnrecognisedStyle", + runs: [{ text: "a" }], + list: { numId: "md1:bullet", level: 0, itemId: "i1" }, + }, + { + kind: "paragraph", + runs: [{ text: "b" }], + list: { numId: "md1:bullet", level: 0, itemId: "i1" }, + }, + ]); + expect(emitMarkdown(source)).toBe("- a\n\n b"); + }); + it("separates loose-list siblings with a blank line and tight-list siblings with none", () => { const tight = emitMarkdown( doc([ @@ -3155,6 +3223,26 @@ describe("gaps (MarkdownDiagnosticCodes)", () => { ).toBe(true); }); + it("PARAGRAPH_INDENT_DROPPED also fires for a DEFINED but unrecognised styleId carrying indentLeftPt, not only an absent styleId -- isQuotableStyle's own QUOTABLE_STYLE_IDS/heading check must actually run, not just its undefined short-circuit", () => { + const collector = createDiagnosticCollector(); + const markdown = emitMarkdown( + doc([ + { + kind: "paragraph", + runs: [{ text: "x" }], + styleId: "SomeUnrecognisedStyle", + indentLeftPt: 36, + }, + ]), + { sink: collector.sink }, + ); + expect(markdown).toBe("x"); + expect(markdown).not.toContain(">"); + expect( + collector.has(MarkdownDiagnosticCodes.PARAGRAPH_INDENT_DROPPED), + ).toBe(true); + }); + it("LIST_NUMID_FALLBACK fires for a numId this package never minted, falling back to a plain bullet", () => { const collector = createDiagnosticCollector(); const markdown = emitMarkdown( @@ -3425,3 +3513,69 @@ describe("gaps (MarkdownDiagnosticCodes)", () => { expect(text).toBe("one
two"); }); }); + +describe("emitMarkdown's own top-level assembly", () => { + it("joins multiple sections with a blank line, not concatenating them directly", () => { + const document: ContentDocument = { + kind: "wordprocessing", + metadata: {}, + sections: [ + { + pageSize: PAGE_SIZE_A4, + margins: DEFAULT_MARGINS, + blocks: [{ kind: "paragraph", runs: [{ text: "first" }] }], + }, + { + pageSize: PAGE_SIZE_A4, + margins: DEFAULT_MARGINS, + blocks: [{ kind: "paragraph", runs: [{ text: "second" }] }], + }, + ], + }; + expect(emitMarkdown(document)).toBe("first\n\nsecond"); + }); + + it("prepends a YAML front matter block, separated from the body by a blank line, when frontMatter: true and the metadata carries a field it can emit", () => { + const document: ContentDocument = { + kind: "wordprocessing", + metadata: { title: "My Title" }, + sections: [ + { + pageSize: PAGE_SIZE_A4, + margins: DEFAULT_MARGINS, + blocks: [{ kind: "paragraph", runs: [{ text: "body" }] }], + }, + ], + }; + expect(emitMarkdown(document, { frontMatter: true })).toBe( + "---\ntitle: My Title\n---\n\nbody", + ); + }); + + it("emits no front matter block at all when frontMatter is not requested, even though the metadata carries a field emitFrontMatter could have emitted", () => { + const document: ContentDocument = { + kind: "wordprocessing", + metadata: { title: "My Title" }, + sections: [ + { + pageSize: PAGE_SIZE_A4, + margins: DEFAULT_MARGINS, + blocks: [{ kind: "paragraph", runs: [{ text: "body" }] }], + }, + ], + }; + expect(emitMarkdown(document)).toBe("body"); + }); + + it("rewrites every line ending to CRLF when lineEnding: 'crlf' is requested", () => { + expect( + emitMarkdown( + doc([ + { kind: "paragraph", runs: [{ text: "first" }] }, + { kind: "paragraph", runs: [{ text: "second" }] }, + ]), + { lineEnding: "crlf" }, + ), + ).toBe("first\r\n\r\nsecond"); + }); +}); From e409be269de0cf521abf456e86b2b4b2e6f1a32d Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 14:09:27 +0100 Subject: [PATCH 50/99] refactor(markdown-codec): drop canInterruptOpenParagraph's own unkillable style-guard branch QUOTE_STYLE_ID and HTML_PREFORMATTED_STYLE_ID never matched any of the positive branches below (CODE_BLOCK/MATH_BLOCK/HORIZONTAL_RULE, or parseHeadingStyleId), so an explicit early return false for either was exactly as redundant as terminatesCleanly's own equivalent guard fixed earlier: both already reach the function's own final `return false` unaided. Undefined keeps its own check, since parseHeadingStyleId requires a definite string. --- packages/markdown-codec/src/emit/emit.ts | 18 ++++-------------- 1 file changed, 4 insertions(+), 14 deletions(-) diff --git a/packages/markdown-codec/src/emit/emit.ts b/packages/markdown-codec/src/emit/emit.ts index 70cc7960cb..79fe82f25e 100644 --- a/packages/markdown-codec/src/emit/emit.ts +++ b/packages/markdown-codec/src/emit/emit.ts @@ -168,20 +168,13 @@ function isQuotableStyle(styleId: string | undefined): boolean { ); } -// Whether a rendered block of this styleId closes itself unambiguously -- so a non-blank line immediately following it is always scanned by a reparse as a FRESH block rather than being absorbed backward into this one as ordinary continuation text. This is the "safe as PREVIOUS" half of requiresBlankLineBefore's compound check below, and unlike canInterruptOpenParagraph it does not depend on any emit option: a fenced code block and a math block each close at their own explicit closing delimiter, a thematic break and an ATX heading are each a single complete line, and a SETEXT heading's own underline line closes it exactly as definitively -- nothing can lazily continue a heading once its underline has been read, so the setext spelling is only unsafe on the OTHER side, as something that ITSELF follows an open paragraph (see canInterruptOpenParagraph). Deliberately false for QUOTE_STYLE_ID (renders through the same prefix-free renderParagraphBody as a plain paragraph here, so carries no boundary of its own) and for HTML_PREFORMATTED_STYLE_ID (this package re-emits raw HTML as a bare literal with no record of which CommonMark HTML-block start condition produced it, and several of those seven conditions close only at a blank line -- with no closing condition of its own to fall back to, anything following without one keeps being read as more of the same literal HTML content). +// Whether a rendered block of this styleId closes itself unambiguously -- so a non-blank line immediately following it is always scanned by a reparse as a FRESH block rather than being absorbed backward into this one as ordinary continuation text. This is the "safe as PREVIOUS" half of requiresBlankLineBefore's compound check below, and unlike canInterruptOpenParagraph it does not depend on any emit option: a fenced code block and a math block each close at their own explicit closing delimiter, a thematic break and an ATX heading are each a single complete line, and a SETEXT heading's own underline line closes it exactly as definitively -- nothing can lazily continue a heading once its underline has been read, so the setext spelling is only unsafe on the OTHER side, as something that ITSELF follows an open paragraph (see canInterruptOpenParagraph). False for QUOTE_STYLE_ID (renders through the same prefix-free renderParagraphBody as a plain paragraph here, so carries no boundary of its own) and for HTML_PREFORMATTED_STYLE_ID (this package re-emits raw HTML as a bare literal with no record of which CommonMark HTML-block start condition produced it, and several of those seven conditions close only at a blank line -- with no closing condition of its own to fall back to, anything following without one keeps being read as more of the same literal HTML content) -- neither needs its own explicit branch, since neither matches any of the four positive checks below either, so both already fall out to false on their own. function terminatesCleanly(styleId: string | undefined): boolean { - if ( - styleId === undefined || - styleId === QUOTE_STYLE_ID || - styleId === HTML_PREFORMATTED_STYLE_ID - ) { - return false; - } return ( styleId === CODE_BLOCK_STYLE_ID || styleId === MATH_BLOCK_STYLE_ID || styleId === HORIZONTAL_RULE_STYLE_ID || - parseHeadingStyleId(styleId) !== undefined + (styleId !== undefined && parseHeadingStyleId(styleId) !== undefined) ); } @@ -349,11 +342,8 @@ function canInterruptOpenParagraph( context: EmitContext, ): boolean { const styleId = paragraph.styleId; - if ( - styleId === undefined || - styleId === QUOTE_STYLE_ID || - styleId === HTML_PREFORMATTED_STYLE_ID - ) { + // Undefined needs its own early return purely so parseHeadingStyleId below gets a definite string -- QUOTE_STYLE_ID and HTML_PREFORMATTED_STYLE_ID need no explicit check of their own alongside it, since neither matches any of the positive branches below (parseHeadingStyleId included), so both already fall out to the final `return false` on their own. + if (styleId === undefined) { return false; } if (styleId === CODE_BLOCK_STYLE_ID || styleId === MATH_BLOCK_STYLE_ID) { From 8f22a2cced9b986ec64010f3c10b3acb1c09f2c8 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 14:09:29 +0100 Subject: [PATCH 51/99] test(markdown-codec): cover emit.ts's quote/fence/tab-stop boundaries and three more setext-interrupting shapes quoteDepthOf's indentLeftPt: 0 boundary, longestRunLength's own run-counter reset after an interrupting character, and leadingIndentColumns' tab-stop rounding for a tab that isn't the line's first character each had no dedicated test -- each only ever exercised through inputs the existing suite happened to already cover in a way that left their own specific arithmetic or reset logic unobserved. The setext-interrupting-construct shapes covered so far (code-fence, thematic-break, blockquote) left ATX-heading, math-block, and list-marker-shaped first lines untested, despite interruptsSetextParagraph checking for all six CommonMark constructs plus this package's two GFM/math extensions. --- packages/markdown-codec/src/emit/emit.test.ts | 83 +++++++++++++++++++ 1 file changed, 83 insertions(+) diff --git a/packages/markdown-codec/src/emit/emit.test.ts b/packages/markdown-codec/src/emit/emit.test.ts index 8429a9c3ae..bc70937a8a 100644 --- a/packages/markdown-codec/src/emit/emit.test.ts +++ b/packages/markdown-codec/src/emit/emit.test.ts @@ -715,6 +715,30 @@ describe("headings", () => { { text: "> q", source: { format: "markdown" as const, xml: "> q" } }, ], }, + { + level: "Heading1" as const, + shape: "ATX-heading-shaped ('# x')", + runs: [ + { text: "# x", source: { format: "markdown" as const, xml: "# x" } }, + ], + }, + { + level: "Heading2" as const, + shape: "math-block-shaped ('$$')", + runs: [ + { text: "$$", source: { format: "markdown" as const, xml: "$$" } }, + ], + }, + { + level: "Heading1" as const, + shape: "list-marker-shaped ('- item')", + runs: [ + { + text: "- item", + source: { format: "markdown" as const, xml: "- item" }, + }, + ], + }, ])( "collapses to ATX with HEADING_LINE_BREAK_UNSAFE_FOR_SETEXT when the heading's own single (break-free) line is itself $shape (ExaDev/documents.js#940)", ({ level, runs }) => { @@ -3514,6 +3538,65 @@ describe("gaps (MarkdownDiagnosticCodes)", () => { }); }); +describe("quoteDepthOf, longestRunLength, and leadingIndentColumns boundaries", () => { + it("treats indentLeftPt: 0 the same as no indentLeftPt at all -- no quote depth, no '>' prefix", () => { + expect( + emitMarkdown( + doc([ + { + kind: "paragraph", + runs: [{ text: "x" }], + styleId: "Quote", + indentLeftPt: 0, + }, + ]), + ), + ).toBe("x"); + }); + + it("resets the fence-character run counter after a non-fence character interrupts it, rather than compounding the interrupted run's own length into a later run of the SAME length as if nothing had broken it", () => { + // The genuine longest run of '`' here is 4 (the second one); a counter that failed to reset after 'xxx' would instead carry the first run's own length of 3 into the second, overcounting to 7 and picking an unnecessarily long fence. + const literal = "```xxx````"; + expect( + emitMarkdown( + doc([ + { + kind: "paragraph", + runs: [{ text: literal }], + styleId: "CodeBlock", + }, + ]), + ), + ).toBe(`\`\`\`\`\`\n${literal}\n\`\`\`\`\``); + }); + + it("expands a leading tab to the correct tab-stop-aligned column count, not merely to SOME value past the 4-column indented-code-block threshold, when the tab is not the first character of the line", () => { + // Two leading spaces (column 2) then a tab: the correct tab-stop rule rounds up to the NEXT multiple of 4, landing on column 4 (2 + 2) -- exactly at, not past, CODE_INDENT_COLUMNS. A `%` -> `*` mutation of the tab-stop arithmetic computes 2 + (4 - 2*4) = 2 + -4 = -2 instead, which is NOT >= 4 and would wrongly let this promote to setext. + const collector = createDiagnosticCollector(); + const written = emitMarkdown( + doc([ + { + kind: "paragraph", + runs: [ + { + text: " \tx", + source: { format: "markdown", xml: " \tx" }, + }, + ], + styleId: "Heading1", + }, + ]), + { headingStyle: "setext", sink: collector.sink }, + ); + expect(written).toBe("# \tx"); + expect( + collector.has( + MarkdownDiagnosticCodes.HEADING_LINE_BREAK_UNSAFE_FOR_SETEXT, + ), + ).toBe(true); + }); +}); + describe("emitMarkdown's own top-level assembly", () => { it("joins multiple sections with a blank line, not concatenating them directly", () => { const document: ContentDocument = { From be419804a066a6eec644f5bc12a41bc65d17143d Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 14:15:19 +0100 Subject: [PATCH 52/99] test(markdown-codec): assert diagnostic message content for CONSTRUCT_UNREPRESENTED, LIST_NUMID_FALLBACK, HEADING_LEVEL_CLAMPED Each of these tests already confirmed the right diagnostic code fired, but none asserted anything about the message text itself -- leaving the actual message strings free to mutate to an empty literal with no test noticing. --- packages/markdown-codec/src/emit/emit.test.ts | 27 ++++++++++++++----- 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/packages/markdown-codec/src/emit/emit.test.ts b/packages/markdown-codec/src/emit/emit.test.ts index bc70937a8a..5d61317644 100644 --- a/packages/markdown-codec/src/emit/emit.test.ts +++ b/packages/markdown-codec/src/emit/emit.test.ts @@ -1435,6 +1435,11 @@ describe("blockquotes", () => { expect(collector.has(MarkdownDiagnosticCodes.CONSTRUCT_UNREPRESENTED)).toBe( true, ); + const diagnostic = collector.diagnostics.find( + (d) => d.code === MarkdownDiagnosticCodes.CONSTRUCT_UNREPRESENTED, + ); + expect(diagnostic?.message).toContain("division"); + expect(diagnostic?.message).toContain("no markdown syntax"); }); it("round-trips blockquote shapes byte for byte through lower -> emit -> lower, including nesting and adjacency", () => { @@ -3199,6 +3204,11 @@ describe("gaps (MarkdownDiagnosticCodes)", () => { expect(collector.has(MarkdownDiagnosticCodes.HEADING_LEVEL_CLAMPED)).toBe( true, ); + const diagnostic = collector.diagnostics.find( + (d) => d.code === MarkdownDiagnosticCodes.HEADING_LEVEL_CLAMPED, + ); + expect(diagnostic?.message).toContain("9"); + expect(diagnostic?.message).toContain("6"); }); it("ADJACENT_LINKS_MERGED fires when two consecutive runs share a hyperlink", () => { @@ -3283,6 +3293,11 @@ describe("gaps (MarkdownDiagnosticCodes)", () => { expect(collector.has(MarkdownDiagnosticCodes.LIST_NUMID_FALLBACK)).toBe( true, ); + const diagnostic = collector.diagnostics.find( + (d) => d.code === MarkdownDiagnosticCodes.LIST_NUMID_FALLBACK, + ); + expect(diagnostic?.message).toContain("list1"); + expect(diagnostic?.message).toContain("not minted"); }); it("LIST_NUMID_FALLBACK fires once for depth-only memberships with no numId, falling back to one tight plain-bullet list", () => { @@ -3295,12 +3310,12 @@ describe("gaps (MarkdownDiagnosticCodes)", () => { { sink: collector.sink }, ); expect(markdown).toBe("- x\n - y"); - expect( - collector.diagnostics.filter( - (diagnostic) => - diagnostic.code === MarkdownDiagnosticCodes.LIST_NUMID_FALLBACK, - ), - ).toHaveLength(1); + const fallbacks = collector.diagnostics.filter( + (diagnostic) => + diagnostic.code === MarkdownDiagnosticCodes.LIST_NUMID_FALLBACK, + ); + expect(fallbacks).toHaveLength(1); + expect(fallbacks[0]?.message).toContain("no numId"); }); it("TABLE_CELL_FORMATTING_DROPPED fires for a non-paragraph/non-image/non-lone-nested-table cell block even inside the HTML-table fallback, once colSpan already triggers it", () => { From c1cbdd1f23b19e7ee2d9f0ad52d55da01f9467e5 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 14:20:36 +0100 Subject: [PATCH 53/99] test(markdown-codec): cover emit.ts's line-break-collapse message content and two more setext-safety edge cases The "so the break survives" HEADING_STYLE_OVERRIDDEN_FOR_LINE_BREAK message (a leading break that round-trips losslessly, unlike the absorbed-blank-line case already covered) had no assertion of its own on the message text. leadingIndentColumns' loop only ever ran on a first character that stopped it immediately; nothing exercised what happens to a LATER space once a non-whitespace character has already been seen, so a dropped `break` there would silently resume counting instead of leaving column alone. interruptsSetextParagraph's own indented-line exemption was only ever exercised for a first line (where a separate caller check already guarantees it); a heading's SECOND line, indented 4+ columns and itself list-marker-shaped, had no test proving CommonMark's indented-continuation exception is honoured there too. --- packages/markdown-codec/src/emit/emit.test.ts | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/packages/markdown-codec/src/emit/emit.test.ts b/packages/markdown-codec/src/emit/emit.test.ts index 5d61317644..dfc02f7dcf 100644 --- a/packages/markdown-codec/src/emit/emit.test.ts +++ b/packages/markdown-codec/src/emit/emit.test.ts @@ -818,6 +818,7 @@ describe("headings", () => { ])( "still promotes $level to setext and round-trips the leading break losslessly", ({ level, underline }) => { + const collector = createDiagnosticCollector(); const written = emitMarkdown( doc([ { @@ -826,8 +827,17 @@ describe("headings", () => { styleId: level, }, ]), + { sink: collector.sink }, ); expect(written).toBe(`\\\nfoo\n${underline}`); + const diagnostic = collector.diagnostics.find( + (d) => + d.code === + MarkdownDiagnosticCodes.HEADING_STYLE_OVERRIDDEN_FOR_LINE_BREAK, + ); + // Unlike the genuinely-absorbed leading-break case above, this break survives losslessly -- the diagnostic must say so, not claim it was absorbed. + expect(diagnostic?.message).toContain("so the break survives"); + expect(diagnostic?.message).not.toContain("absorbed"); const reparsed = lowerMarkdown(written); if (reparsed.kind !== "wordprocessing") { @@ -3610,6 +3620,54 @@ describe("quoteDepthOf, longestRunLength, and leadingIndentColumns boundaries", ), ).toBe(true); }); + + it("stops counting leading indentation at the first non-space, non-tab character, rather than resuming the count at a LATER space in the same line as if it were still leading", () => { + // Correct: 'a' immediately stops the leading-indent scan at column 0 (well under the 4-column threshold), so this promotes safely to setext. A dropped `break` would instead skip over 'a' and keep scanning, picking up the run of 4 spaces that follows it as if it were still leading indentation, reaching column 4 and wrongly refusing the promotion. + const collector = createDiagnosticCollector(); + const written = emitMarkdown( + doc([ + { kind: "paragraph", runs: [{ text: "a x" }], styleId: "Heading1" }, + ]), + { headingStyle: "setext", sink: collector.sink }, + ); + expect(written).toBe("a x\n======"); + expect( + collector.has( + MarkdownDiagnosticCodes.HEADING_LINE_BREAK_UNSAFE_FOR_SETEXT, + ), + ).toBe(false); + }); + + it("does not treat a heading's own SECOND line, indented 4+ columns, as an interrupting construct -- CommonMark absorbs indented content as ordinary paragraph continuation, exactly like its own indented-code paragraph-interruption exception requires", () => { + // " - item" would itself match parseListMarker if the leading 4-column indent were not first exempted -- indented content is absorbed as continuation instead, so this must still promote safely to setext rather than being refused as an interrupting list marker. + const written = emitMarkdown( + doc([ + { + kind: "paragraph", + runs: [ + { text: "foo" }, + { text: "\n" }, + { + text: " - item", + source: { format: "markdown", xml: " - item" }, + }, + ], + styleId: "Heading1", + }, + ]), + ); + expect(written).toBe("foo\\\n - item\n===="); + + const reparsed = lowerMarkdown(written); + if (reparsed.kind !== "wordprocessing") { + throw new Error("expected a wordprocessing ContentDocument"); + } + const [headingBlock] = reparsed.sections[0]?.blocks ?? []; + if (headingBlock?.kind !== "paragraph") { + throw new Error("expected a paragraph block"); + } + expect(headingBlock.styleId).toBe("Heading1"); + }); }); describe("emitMarkdown's own top-level assembly", () => { From 71f79b3b9cab832b92dec6d74d9df93dbd342a19 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 14:22:56 +0100 Subject: [PATCH 54/99] test(markdown-codec): cover checkbox glyph stripping and LIST_NUMID_FALLBACK's own once-per-numId dedup stripCheckboxRun was only ever exercised with the glyph and its following text in two SEPARATE runs, leaving the same-run case (where slicing off the glyph prefix leaves real text behind in that run) unobserved; a task-flagged numId whose leading text matches neither legacy glyph had no test proving the ordinary-bullet fallback still applies. listInfoFor's own reportedFallbackNumIds dedup was only ever exercised through a single occurrence of an unminted numId, so nothing proved the SECOND item sharing that numId does not re-fire the diagnostic. --- packages/markdown-codec/src/emit/emit.test.ts | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/packages/markdown-codec/src/emit/emit.test.ts b/packages/markdown-codec/src/emit/emit.test.ts index dfc02f7dcf..ddef2d5bf9 100644 --- a/packages/markdown-codec/src/emit/emit.test.ts +++ b/packages/markdown-codec/src/emit/emit.test.ts @@ -1571,6 +1571,32 @@ describe("lists", () => { expect(markdown).toBe("- [x] done"); }); + it("strips a legacy checkbox glyph from a run that ALSO carries its own following text, not just when the glyph fills a whole separate run of its own", () => { + const markdown = emitMarkdown( + doc([ + { + kind: "paragraph", + runs: [{ text: "☒ done" }], + list: { numId: "md1:bullet+task", level: 0 }, + }, + ]), + ); + expect(markdown).toBe("- [x] done"); + }); + + it("renders an ordinary bullet with no checkbox at all for a task-flagged numId whose leading text matches neither legacy glyph", () => { + const markdown = emitMarkdown( + doc([ + { + kind: "paragraph", + runs: [{ text: "ordinary" }], + list: { numId: "md1:bullet+task", level: 0 }, + }, + ]), + ); + expect(markdown).toBe("- ordinary"); + }); + it("renders every block of one itemId as a single item -- a blank line and the continuation indent between blocks, one marker only", () => { const markdown = emitMarkdown( doc([ @@ -3310,6 +3336,31 @@ describe("gaps (MarkdownDiagnosticCodes)", () => { expect(diagnostic?.message).toContain("not minted"); }); + it("LIST_NUMID_FALLBACK fires only once for two items sharing the SAME never-minted numId, not once per item", () => { + const collector = createDiagnosticCollector(); + const markdown = emitMarkdown( + doc([ + { + kind: "paragraph", + runs: [{ text: "a" }], + list: { numId: "list1", level: 0 }, + }, + { + kind: "paragraph", + runs: [{ text: "b" }], + list: { numId: "list1", level: 0 }, + }, + ]), + { sink: collector.sink }, + ); + expect(markdown).toBe("- a\n- b"); + expect( + collector.diagnostics.filter( + (d) => d.code === MarkdownDiagnosticCodes.LIST_NUMID_FALLBACK, + ), + ).toHaveLength(1); + }); + it("LIST_NUMID_FALLBACK fires once for depth-only memberships with no numId, falling back to one tight plain-bullet list", () => { const collector = createDiagnosticCollector(); const markdown = emitMarkdown( From 706a37d362a33b763ce8a5be1d121c9365b146bd Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 14:31:29 +0100 Subject: [PATCH 55/99] test(markdown-codec): cover renderConstruct's unrepresentable shapes and the empty-render filtering in renderItems An invalid footnote label, a non-footnote anchor's own "anchor (type)" detail spelling, and a division's own divisionDepth suppressing a wrapped paragraph's separate indentLeftPt from being counted a second time were each entirely untested. Both of renderItems' own "skip an empty render rather than pushing a spurious blank part" checks (the plain-block path and the construct path) had no test proving a genuinely empty render -- a page break, a bodyless anchor -- doesn't still widen the gap between its neighbours. --- packages/markdown-codec/src/emit/emit.test.ts | 94 +++++++++++++++++++ 1 file changed, 94 insertions(+) diff --git a/packages/markdown-codec/src/emit/emit.test.ts b/packages/markdown-codec/src/emit/emit.test.ts index ddef2d5bf9..664b8e9574 100644 --- a/packages/markdown-codec/src/emit/emit.test.ts +++ b/packages/markdown-codec/src/emit/emit.test.ts @@ -3721,6 +3721,100 @@ describe("quoteDepthOf, longestRunLength, and leadingIndentColumns boundaries", }); }); +describe("renderConstruct's own unrepresentable shapes", () => { + it("reports CONSTRUCT_UNREPRESENTED, with the invalid label named, for a footnote anchor whose name cannot be spelled as a [^label]: marker", () => { + const collector = createDiagnosticCollector(); + const markdown = emitMarkdown( + doc([ + { + kind: "constructStart", + descriptor: { + kind: "anchor", + anchorType: "footnote", + name: "bad label", + }, + }, + { kind: "paragraph", runs: [{ text: "body" }] }, + { kind: "constructEnd" }, + ]), + { sink: collector.sink }, + ); + expect(markdown).toBe("body"); + const diagnostic = collector.diagnostics.find( + (d) => d.code === MarkdownDiagnosticCodes.CONSTRUCT_UNREPRESENTED, + ); + expect(diagnostic?.message).toContain("bad label"); + expect(diagnostic?.message).toContain("footnote"); + }); + + it("reports CONSTRUCT_UNREPRESENTED with 'anchor (bookmark)' as the detail for a non-footnote anchor, distinguishing it from a bare 'anchor'", () => { + const collector = createDiagnosticCollector(); + const markdown = emitMarkdown( + doc([ + { + kind: "constructStart", + descriptor: { kind: "anchor", anchorType: "bookmark", name: "b1" }, + }, + { kind: "paragraph", runs: [{ text: "body" }] }, + { kind: "constructEnd" }, + ]), + { sink: collector.sink }, + ); + expect(markdown).toBe("body"); + const diagnostic = collector.diagnostics.find( + (d) => d.code === MarkdownDiagnosticCodes.CONSTRUCT_UNREPRESENTED, + ); + expect(diagnostic?.message).toContain("anchor (bookmark)"); + }); + + it("does not leave an extra blank-line gap for a CONSTRUCT that renders to nothing at all, such as a bodyless footnote anchor (a point anchor with an empty extent) sitting between two paragraphs", () => { + const markdown = emitMarkdown( + doc([ + { kind: "paragraph", runs: [{ text: "a" }] }, + { + kind: "constructStart", + descriptor: { kind: "anchor", anchorType: "bookmark", name: "empty" }, + }, + { kind: "constructEnd" }, + { kind: "paragraph", runs: [{ text: "b" }] }, + ]), + ); + expect(markdown).toBe("a\n\nb"); + }); + + it("does not leave an extra blank-line gap for a top-level block that renders to nothing at all, such as a page break sitting between two paragraphs", () => { + const markdown = emitMarkdown( + doc([ + { kind: "paragraph", runs: [{ text: "a" }] }, + { kind: "pageBreak" }, + { kind: "paragraph", runs: [{ text: "b" }] }, + ]), + ); + // Exactly one blank line between "a" and "b" -- not two, which pushing the page break's own empty string into the joined parts array would produce. + expect(markdown).toBe("a\n\nb"); + }); + + it("does not double-count a division-wrapped paragraph's own indentLeftPt as additional quote depth on top of the division's own '> ' wrapping", () => { + const markdown = emitMarkdown( + doc([ + { + kind: "constructStart", + descriptor: { kind: "division", name: "d1" }, + }, + { + kind: "paragraph", + runs: [{ text: "x" }], + styleId: "Quote", + indentLeftPt: 72, + }, + { kind: "constructEnd" }, + ]), + ); + // Exactly one level of '> ' from the division itself -- NOT '> > x', which double-counting the paragraph's own indentLeftPt (72pt, two quote levels' worth) on top of the division's own wrapping would produce. + expect(markdown).toBe("> x"); + }); +}); + describe("emitMarkdown's own top-level assembly", () => { it("joins multiple sections with a blank line, not concatenating them directly", () => { const document: ContentDocument = { From 8d7e29aa2c9b4e7fdf3442a88a9a2d5680784a2f Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 14:39:10 +0100 Subject: [PATCH 56/99] test(markdown-codec): cover divisionDepth's own restore-on-exit and the default-embedImages branch for a data: URI image link Nothing proved a division's own exit decrement actually restores divisionDepth to its prior value once the division closes -- only that entering one suppresses the wrapped paragraph's own indent while still inside it. A standalone paragraph rendered immediately after a division now confirms the depth genuinely returns to 0 rather than leaking an elevated value into whatever follows. The single existing image-link-construct test used a remote (non data: URI) destination, which short-circuits past the images-option check entirely; nothing exercised the actual bytes-are-the-destination branch with images left at its own default of true. --- packages/markdown-codec/src/emit/emit.test.ts | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/packages/markdown-codec/src/emit/emit.test.ts b/packages/markdown-codec/src/emit/emit.test.ts index 664b8e9574..8c48bc319e 100644 --- a/packages/markdown-codec/src/emit/emit.test.ts +++ b/packages/markdown-codec/src/emit/emit.test.ts @@ -3813,6 +3813,57 @@ describe("renderConstruct's own unrepresentable shapes", () => { // Exactly one level of '> ' from the division itself -- NOT '> > x', which double-counting the paragraph's own indentLeftPt (72pt, two quote levels' worth) on top of the division's own wrapping would produce. expect(markdown).toBe("> x"); }); + + it("restores divisionDepth to its own PRIOR value once a division closes, rather than leaking an elevated depth into whatever renders after it", () => { + const markdown = emitMarkdown( + doc([ + { + kind: "constructStart", + descriptor: { kind: "division", name: "d1" }, + }, + { + kind: "paragraph", + runs: [{ text: "x" }], + styleId: "Quote", + indentLeftPt: 36, + }, + { kind: "constructEnd" }, + { + kind: "paragraph", + runs: [{ text: "y" }], + styleId: "Quote", + indentLeftPt: 36, + }, + ]), + ); + // A STANDALONE paragraph after the division closes must recover its own '> ' from indentLeftPt alone -- a decrement that failed to restore divisionDepth to 0 would leave this second paragraph's own quote prefix wrongly suppressed, rendering plain "y" instead of "> y". + expect(markdown).toBe("> x\n\n> y"); + }); + + it("still re-embeds a data: URI destination for a link construct wrapping exactly one image when images is left at its own default (true), rather than always falling back to the no-bytes rendering", () => { + const dataUri = "data:image/png;base64,AAAA"; + const markdown = emitMarkdown( + doc([ + { + kind: "constructStart", + descriptor: { + kind: "link", + target: { kind: "external", uri: dataUri }, + }, + }, + { + kind: "image", + format: "png", + base64: "AAAA", + widthPt: 1, + heightPt: 1, + altText: "alt", + }, + { kind: "constructEnd" }, + ]), + ); + expect(markdown).toBe(`![alt](${dataUri})`); + }); }); describe("emitMarkdown's own top-level assembly", () => { From 5ed448f3d312fe117aba6b3ee0865c94b4e1dec0 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 14:42:25 +0100 Subject: [PATCH 57/99] refactor(markdown-codec): drop renderListRegion's own unreachable ordered-delimiter branch for a depth-only membership listInfoFor's own undefined-numId branch never returns real ListNumIdInfo, so type defaults to "bullet" every time numId is undefined -- the type === "ordered" check in the numId-undefined side of this ternary can never be true, making its own orderedDelimiter branch dead code no test can ever reach. --- packages/markdown-codec/src/emit/emit.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/markdown-codec/src/emit/emit.ts b/packages/markdown-codec/src/emit/emit.ts index 79fe82f25e..2cabac95b5 100644 --- a/packages/markdown-codec/src/emit/emit.ts +++ b/packages/markdown-codec/src/emit/emit.ts @@ -856,11 +856,10 @@ function renderListRegion( const info = listInfoFor(numId, context); const loose = info?.loose === true; const type = info?.type ?? "bullet"; + // A depth-only membership (numId undefined) always resolves through listInfoFor's OWN undefined-numId branch, which never returns real ListNumIdInfo -- so `type` above is always its own "bullet" default here, and `type === "ordered"` can never be true in this branch specifically; only the numId-carrying side ever sees a genuinely ordered type. const glyph = numId === undefined - ? type === "ordered" - ? context.orderedDelimiter - : context.bulletMarker + ? context.bulletMarker : resolveListGlyph(numId, type, previousSibling, context); if (numId !== undefined) { previousSibling = { numId, type, glyph }; From b03c541ef52b40743857fa89f988a3cf07871234 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 14:42:27 +0100 Subject: [PATCH 58/99] test(markdown-codec): cover the ballot-box-glyph/task-flag guard and nested loose-list blank-line indentation firstBlockCheckbox is deliberately gated on BOTH membership.checked being absent AND the numId's own task flag -- an ordinary, non-task-flagged item whose leading text happens to spell the legacy checkbox glyph exactly had no test proving it still renders as plain text rather than being misread as a checkbox. A nested sub-list's own rendering, once indented under its parent item, had no test proving a genuinely blank line inside that rendering (the gap a loose sub-list's own blank-line separator produces) stays truly empty rather than gaining trailing indent whitespace. --- packages/markdown-codec/src/emit/emit.test.ts | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/packages/markdown-codec/src/emit/emit.test.ts b/packages/markdown-codec/src/emit/emit.test.ts index 8c48bc319e..08a79840ce 100644 --- a/packages/markdown-codec/src/emit/emit.test.ts +++ b/packages/markdown-codec/src/emit/emit.test.ts @@ -1597,6 +1597,44 @@ describe("lists", () => { expect(markdown).toBe("- ordinary"); }); + it("never misreads a ballot-box glyph as a checkbox for an ORDINARY (non-task-flagged) numId, even though its leading text happens to match the legacy glyph spelling exactly", () => { + const markdown = emitMarkdown( + doc([ + { + kind: "paragraph", + runs: [{ text: "☒ not a checkbox" }], + list: { numId: "md1:bullet", level: 0 }, + }, + ]), + ); + expect(markdown).toBe("- ☒ not a checkbox"); + }); + + it("does not pad a genuinely blank line inside a NESTED sub-list's own rendering with trailing indent whitespace once that rendering is indented under its parent item", () => { + const markdown = emitMarkdown( + doc([ + { + kind: "paragraph", + runs: [{ text: "a" }], + list: { numId: "md1:bullet", level: 0, itemId: "i1" }, + }, + { + kind: "paragraph", + runs: [{ text: "b" }], + list: { numId: "md1:bullet+loose", level: 1 }, + }, + { + kind: "paragraph", + runs: [{ text: "c" }], + list: { numId: "md1:bullet+loose", level: 1 }, + }, + ]), + ); + expect(markdown).toBe("- a\n - b\n\n - c"); + // Split on "\n" and re-check the blank line specifically: exactly "", never " " (indent with nothing on it). + expect(markdown.split("\n")).toContain(""); + }); + it("renders every block of one itemId as a single item -- a blank line and the continuation indent between blocks, one marker only", () => { const markdown = emitMarkdown( doc([ From fafe2c83eaca55e4fab1cf4899c07624f8f987a6 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 14:46:15 +0100 Subject: [PATCH 59/99] test(markdown-codec): cover blank-line indentation for a LATER continuation block's own body The parallel indent-skip check for a nested sub-list's own blank lines was just covered, but the sibling check on the plain continuation-block path (a later block of the same item, not a nested list) had no equivalent test: nothing proved a genuinely blank line inside a second block's own multi-line body (a fenced code block whose literal itself contains a blank line) stays truly empty once indented, rather than gaining trailing indent whitespace. --- packages/markdown-codec/src/emit/emit.test.ts | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/packages/markdown-codec/src/emit/emit.test.ts b/packages/markdown-codec/src/emit/emit.test.ts index 08a79840ce..40e02d1f87 100644 --- a/packages/markdown-codec/src/emit/emit.test.ts +++ b/packages/markdown-codec/src/emit/emit.test.ts @@ -1653,6 +1653,26 @@ describe("lists", () => { expect(markdown).toBe("- a\n\n second block"); }); + it("does not pad a genuinely blank line inside a LATER (continuation) block's own body with trailing indent whitespace once that block is indented under the item's marker", () => { + const markdown = emitMarkdown( + doc([ + { + kind: "paragraph", + runs: [{ text: "a" }], + list: { numId: "md1:bullet", level: 0, itemId: "md-i1" }, + }, + { + kind: "paragraph", + runs: [{ text: "x\n\ny" }], + styleId: "CodeBlock", + list: { numId: "md1:bullet", level: 0, itemId: "md-i1" }, + }, + ]), + ); + expect(markdown).toBe("- a\n ```\n x\n\n y\n ```"); + expect(markdown.split("\n")).toContain(""); + }); + it("renders same-level paragraphs with DIFFERENT itemIds as separate items even when they share a numId", () => { const markdown = emitMarkdown( doc([ From bb5f6fbadc2588bda771cd31c8815d7782ee6502 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 14:50:44 +0100 Subject: [PATCH 60/99] test(markdown-codec): assert message content for the two remaining heading-collapse diagnostics, and isolate the UNCHECKED glyph path HEADING_LINE_BREAK_COLLAPSED and the "blank-line" branch of HEADING_LINE_BREAK_UNSAFE_FOR_SETEXT's own message were only ever checked for whether they fired, never for what they actually said. firstBlockCheckbox's UNCHECKED glyph check was only ever exercised immediately after a CHECKED one already matched and returned early in the SAME call; a standalone item whose only glyph is the UNCHECKED spelling isolates that specific check on its own. --- packages/markdown-codec/src/emit/emit.test.ts | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/packages/markdown-codec/src/emit/emit.test.ts b/packages/markdown-codec/src/emit/emit.test.ts index 40e02d1f87..0a73bdf00c 100644 --- a/packages/markdown-codec/src/emit/emit.test.ts +++ b/packages/markdown-codec/src/emit/emit.test.ts @@ -103,6 +103,11 @@ describe("headings", () => { expect( collector.has(MarkdownDiagnosticCodes.HEADING_LINE_BREAK_COLLAPSED), ).toBe(true); + const diagnostic = collector.diagnostics.find( + (d) => d.code === MarkdownDiagnosticCodes.HEADING_LINE_BREAK_COLLAPSED, + ); + expect(diagnostic?.message).toContain("3"); + expect(diagnostic?.message).toContain("line break"); }); it("measures the setext underline's length against the CommonMark first line even when its own embedded break is a bare CR, not an LF", () => { @@ -163,6 +168,13 @@ describe("headings", () => { MarkdownDiagnosticCodes.HEADING_LINE_BREAK_UNSAFE_FOR_SETEXT, ), ).toBe(true); + const diagnostic = collector.diagnostics.find( + (d) => + d.code === + MarkdownDiagnosticCodes.HEADING_LINE_BREAK_UNSAFE_FOR_SETEXT, + ); + expect(diagnostic?.message).toContain("no heading text"); + expect(diagnostic?.message).toContain("attach to"); expect( collector.has(MarkdownDiagnosticCodes.HEADING_LINE_BREAK_COLLAPSED), ).toBe(false); @@ -1571,6 +1583,19 @@ describe("lists", () => { expect(markdown).toBe("- [x] done"); }); + it("recognises the pre-field UNCHECKED glyph spelling on its OWN, with no checked item preceding it in the same call", () => { + const markdown = emitMarkdown( + doc([ + { + kind: "paragraph", + runs: [{ text: "☐ " }, { text: "todo" }], + list: { numId: "md1:bullet+task", level: 0 }, + }, + ]), + ); + expect(markdown).toBe("- [ ] todo"); + }); + it("strips a legacy checkbox glyph from a run that ALSO carries its own following text, not just when the glyph fills a whole separate run of its own", () => { const markdown = emitMarkdown( doc([ From 29f9b7fea6f6517afeaa72f8ff2f8dc791b0eec0 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 14:57:48 +0100 Subject: [PATCH 61/99] test(markdown-codec): fix the UNCHECKED glyph test to actually distinguish startsWith from endsWith MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The prior version split the glyph and its following text across two separate runs, so runs[0].text was exactly "☐ " -- identical from both ends, which made a startsWith/endsWith swap on that check unobservable. Combining the glyph and its trailing text into one run gives leading text where the two methods genuinely disagree, confirmed directly by applying the swap by hand and watching this exact test fail. --- packages/markdown-codec/src/emit/emit.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/markdown-codec/src/emit/emit.test.ts b/packages/markdown-codec/src/emit/emit.test.ts index 0a73bdf00c..9ef7399a8e 100644 --- a/packages/markdown-codec/src/emit/emit.test.ts +++ b/packages/markdown-codec/src/emit/emit.test.ts @@ -1583,12 +1583,12 @@ describe("lists", () => { expect(markdown).toBe("- [x] done"); }); - it("recognises the pre-field UNCHECKED glyph spelling on its OWN, with no checked item preceding it in the same call", () => { + it("recognises the pre-field UNCHECKED glyph spelling on its OWN, with no checked item preceding it in the same call, and with real text following the glyph in the SAME run (so startsWith and endsWith genuinely disagree)", () => { const markdown = emitMarkdown( doc([ { kind: "paragraph", - runs: [{ text: "☐ " }, { text: "todo" }], + runs: [{ text: "☐ todo" }], list: { numId: "md1:bullet+task", level: 0 }, }, ]), From d6bd32f2a6dffd2039184d0439426bdef6b3a527 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 15:07:51 +0100 Subject: [PATCH 62/99] test(markdown-codec): cover lastStyleIdOf's own last-child lookup through a construct resuming a list item Every existing construct-resumes-outer-item test either had nothing after the construct or only checked which item the construct attached to, never whether a FOLLOWING block's own blank-line decision correctly reflects what that construct actually ends on. A plain paragraph directly after a construct whose sole wrapped block is a CodeBlock must stay tight, since a CodeBlock terminates cleanly -- proving lastStyleIdOf genuinely walks into the construct's own children rather than silently reporting undefined, confirmed by manually applying the length-1 -> length+1 mutation and watching this exact test fail. --- packages/markdown-codec/src/emit/emit.test.ts | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/packages/markdown-codec/src/emit/emit.test.ts b/packages/markdown-codec/src/emit/emit.test.ts index 9ef7399a8e..a595bf90c2 100644 --- a/packages/markdown-codec/src/emit/emit.test.ts +++ b/packages/markdown-codec/src/emit/emit.test.ts @@ -1660,6 +1660,36 @@ describe("lists", () => { expect(markdown.split("\n")).toContain(""); }); + it("finds the REAL last styleId inside a construct that resumes a list item, not just undefined, so a following block's own blank-line decision reflects what that construct actually ends on", () => { + const markdown = emitMarkdown( + doc([ + { + kind: "paragraph", + runs: [{ text: "a" }], + list: { numId: "md1:bullet", level: 0, itemId: "i1" }, + }, + { + kind: "constructStart", + descriptor: { kind: "division", name: "d1" }, + }, + { + kind: "paragraph", + runs: [{ text: "mid" }], + styleId: "CodeBlock", + list: { numId: "md1:bullet", level: 0, itemId: "i1" }, + }, + { kind: "constructEnd" }, + { + kind: "paragraph", + runs: [{ text: "z" }], + list: { numId: "md1:bullet", level: 0, itemId: "i1" }, + }, + ]), + ); + // No forced blank line before "z": the construct's own last (and only) wrapped block is a CodeBlock, which terminates cleanly -- lastStyleIdOf must actually find that CodeBlock styleId through the construct's own children, not silently report undefined (which would wrongly force a blank line here). + expect(markdown).toBe("- a\n ```\n mid\n ```\n z"); + }); + it("renders every block of one itemId as a single item -- a blank line and the continuation indent between blocks, one marker only", () => { const markdown = emitMarkdown( doc([ From 9acb91c0596597337ecfd86d68182a561a1c2e39 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 15:10:02 +0100 Subject: [PATCH 63/99] test(markdown-codec): cover the nested sub-list's own last-block lookup feeding the outer item's resuming block The one existing test covering a nested sub-list resumed by the outer item used a plain, styleId-free paragraph as the sub-list's own last block, so its real and mutated (always-undefined) lastStyleIdOf readings were indistinguishable -- both landed on undefined either way. A CodeBlock-styled nested item isolates the lookup itself: the outer item's own resuming block must stay tight only when that real styleId is actually found, confirmed by manually applying the length-1 -> length+1 mutation and watching this exact test fail. --- packages/markdown-codec/src/emit/emit.test.ts | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/packages/markdown-codec/src/emit/emit.test.ts b/packages/markdown-codec/src/emit/emit.test.ts index a595bf90c2..7be428ed54 100644 --- a/packages/markdown-codec/src/emit/emit.test.ts +++ b/packages/markdown-codec/src/emit/emit.test.ts @@ -1660,6 +1660,31 @@ describe("lists", () => { expect(markdown.split("\n")).toContain(""); }); + it("finds the REAL last styleId of a NESTED sub-list's own last block, not just undefined, so the outer item's own resuming block reflects what that sub-list actually ends on", () => { + const markdown = emitMarkdown( + doc([ + { + kind: "paragraph", + runs: [{ text: "a" }], + list: { numId: "md1:bullet", level: 0, itemId: "i1" }, + }, + { + kind: "paragraph", + runs: [{ text: "b" }], + styleId: "CodeBlock", + list: { numId: "md1:bullet", level: 1, itemId: "i2" }, + }, + { + kind: "paragraph", + runs: [{ text: "z" }], + list: { numId: "md1:bullet", level: 0, itemId: "i1" }, + }, + ]), + ); + // No forced blank line before "z": the nested sub-list's own last (and only) item is a CodeBlock, which terminates cleanly -- reading segment.blocks[segment.blocks.length - 1] must actually find that item, not silently report undefined (which would wrongly force a blank line here). + expect(markdown).toBe("- a\n - ```\n b\n ```\n z"); + }); + it("finds the REAL last styleId inside a construct that resumes a list item, not just undefined, so a following block's own blank-line decision reflects what that construct actually ends on", () => { const markdown = emitMarkdown( doc([ From 3685d5bd29c15ea47d1c74ea9484ae1d2db520a9 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 15:14:09 +0100 Subject: [PATCH 64/99] test(markdown-codec): cover openMemberships' own same-level pop before pushing a sibling item The pop-while-loop was only ever exercised implicitly through genuinely deeper nesting, never through two SIBLING items sharing the SAME level -- nothing proved the >= comparison (not a plain >) is what lets a sibling's own membership actually leave the stack once its successor is pushed. Three same-level items followed by a construct carrying only the FIRST one's itemId isolates it: with the membership correctly popped, the construct can no longer attach to that no-longer-open item and starts a fresh list region of its own instead, forcing the blank-line separation a genuinely new region gets. Confirmed by manually applying both the >= -> > and the whole-condition -> false mutations and watching this exact test fail either way. --- packages/markdown-codec/src/emit/emit.test.ts | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/packages/markdown-codec/src/emit/emit.test.ts b/packages/markdown-codec/src/emit/emit.test.ts index 7be428ed54..247e14aa10 100644 --- a/packages/markdown-codec/src/emit/emit.test.ts +++ b/packages/markdown-codec/src/emit/emit.test.ts @@ -1660,6 +1660,35 @@ describe("lists", () => { expect(markdown.split("\n")).toContain(""); }); + it("pops a SIBLING item's own membership off openMemberships before pushing the next one at the SAME level, not just a genuinely deeper one -- a stale sibling entry left on the stack could wrongly absorb a later construct that only carries THAT earlier sibling's own itemId", () => { + const markdown = emitMarkdown( + doc([ + { + kind: "paragraph", + runs: [{ text: "i1" }], + list: { numId: "md1:bullet", level: 0, itemId: "i1" }, + }, + { + kind: "paragraph", + runs: [{ text: "i2" }], + list: { numId: "md1:bullet", level: 0, itemId: "i2" }, + }, + { + kind: "constructStart", + descriptor: { kind: "division", name: "d1" }, + }, + { + kind: "paragraph", + runs: [{ text: "carries i1" }], + list: { numId: "md1:bullet", level: 0, itemId: "i1" }, + }, + { kind: "constructEnd" }, + ]), + ); + // i1's own membership must already be off the stack once i2 (its sibling at the SAME level) is pushed -- so this construct, which carries only i1's itemId, cannot still be absorbed into the (no-longer-open) i1 item; it fractures out and re-enters as its OWN fresh list region instead (its wrapped paragraph still carries itemId i1, but as a new region, not a continuation of the item above). + expect(markdown).toBe("- i1\n- i2\n\n- carries i1"); + }); + it("finds the REAL last styleId of a NESTED sub-list's own last block, not just undefined, so the outer item's own resuming block reflects what that sub-list actually ends on", () => { const markdown = emitMarkdown( doc([ From 90f5f3e8b89c1daf19cac5ce74e85fa7e82225b1 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 15:21:21 +0100 Subject: [PATCH 65/99] test(markdown-codec): cover isMaterialisedDivision's own every-vs-some requirement with mixed children Every existing division test had children that either ALL qualified for the dual-carry quote indent or NONE did, so .every() and .some() were indistinguishable on those inputs. A division wrapping one quote-indented paragraph and one plain paragraph isolates it: the division must render transparently (no '> ' wrapping of its own) because not every child qualifies, even though at least one does -- confirmed by manually swapping every for some and watching this exact test fail with the wrongly-materialised output. --- packages/markdown-codec/src/emit/emit.test.ts | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/packages/markdown-codec/src/emit/emit.test.ts b/packages/markdown-codec/src/emit/emit.test.ts index 247e14aa10..b77729641b 100644 --- a/packages/markdown-codec/src/emit/emit.test.ts +++ b/packages/markdown-codec/src/emit/emit.test.ts @@ -1464,6 +1464,27 @@ describe("blockquotes", () => { expect(diagnostic?.message).toContain("no markdown syntax"); }); + it("renders a division transparently (no '> ' wrapping) when only SOME of its wrapped paragraphs carry the dual-carry quote indent, not all of them -- isMaterialisedDivision requires EVERY child to qualify, not just one", () => { + const markdown = emitMarkdown( + doc([ + { + kind: "constructStart", + descriptor: { kind: "division", name: "mixed" }, + }, + { + kind: "paragraph", + runs: [{ text: "quoted" }], + styleId: "Quote", + indentLeftPt: 36, + }, + { kind: "paragraph", runs: [{ text: "plain" }] }, + { kind: "constructEnd" }, + ]), + ); + // Transparent, not materialised -- so each wrapped paragraph still recovers (or doesn't) its own quote depth independently, exactly as if the division weren't there at all: "quoted" keeps its own '> ' from indentLeftPt, "plain" has none. + expect(markdown).toBe("> quoted\n\nplain"); + }); + it("round-trips blockquote shapes byte for byte through lower -> emit -> lower, including nesting and adjacency", () => { for (const source of [ "> a\n>\n> b", From 006eac92d6b3074ef785f6d91484f9f2b4f10624 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 15:23:37 +0100 Subject: [PATCH 66/99] test(markdown-codec): cover constructCarriesListItemId's own any-match (not all-match) requirement Every existing test giving a construct MULTIPLE children had either every child carry the itemId being matched or exactly one child total, so .some() and .every() always agreed. A construct wrapping two paragraphs, only one of which carries the item's own itemId, isolates it: the construct must still be recognised as belonging to that item, since ANY carrying child is sufficient -- confirmed by manually swapping some for every and watching this exact test fail with the construct wrongly fracturing out as unrelated content. --- packages/markdown-codec/src/emit/emit.test.ts | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/packages/markdown-codec/src/emit/emit.test.ts b/packages/markdown-codec/src/emit/emit.test.ts index b77729641b..267544b066 100644 --- a/packages/markdown-codec/src/emit/emit.test.ts +++ b/packages/markdown-codec/src/emit/emit.test.ts @@ -1681,6 +1681,31 @@ describe("lists", () => { expect(markdown.split("\n")).toContain(""); }); + it("recognises a construct as carrying an item's own itemId when ONLY ONE of its several children actually carries it, not requiring every child to -- constructCarriesListItemId is an ANY match, not an ALL match", () => { + const markdown = emitMarkdown( + doc([ + { + kind: "paragraph", + runs: [{ text: "a" }], + list: { numId: "md1:bullet", level: 0, itemId: "i1" }, + }, + { + kind: "constructStart", + descriptor: { kind: "division", name: "d1" }, + }, + { + kind: "paragraph", + runs: [{ text: "carries i1" }], + list: { numId: "md1:bullet", level: 0, itemId: "i1" }, + }, + { kind: "paragraph", runs: [{ text: "other" }] }, + { kind: "constructEnd" }, + ]), + ); + // The construct is recognised as belonging to item i1 (one of its two children carries that itemId, and ANY match is enough) and stays absorbed into i1's own run, rather than fracturing out as an unrelated top-level construct. + expect(markdown).toBe("- a\n\n carries i1\n\n other"); + }); + it("pops a SIBLING item's own membership off openMemberships before pushing the next one at the SAME level, not just a genuinely deeper one -- a stale sibling entry left on the stack could wrongly absorb a later construct that only carries THAT earlier sibling's own itemId", () => { const markdown = emitMarkdown( doc([ From 798a0169e7a1f630212353013d1105cf40d207dd Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 15:28:21 +0100 Subject: [PATCH 67/99] test(markdown-codec): cover validateRunConstructExtents' own recursion into table cells Both existing run-construct-extent-fault tests used a top-level paragraph; nothing proved the table-row/table-cell recursive walk itself actually runs. A paragraph carrying the identical beyond-runs fault, but buried inside a table cell, confirms validateRunConstructExtents still catches it -- manually emptying the table-recursion loops confirmed the fix by watching this exact test fail once the fault went unnoticed. --- packages/markdown-codec/src/emit/emit.test.ts | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/packages/markdown-codec/src/emit/emit.test.ts b/packages/markdown-codec/src/emit/emit.test.ts index 267544b066..18855eeed7 100644 --- a/packages/markdown-codec/src/emit/emit.test.ts +++ b/packages/markdown-codec/src/emit/emit.test.ts @@ -3367,6 +3367,48 @@ describe("link and image titles (the `link` construct annotation)", () => { "a paragraph's run-level construct extent ends before it starts (constructs entry 0); a run extent must name real runs in 0..runs.length", ); }); + + it("also throws for an invalid run-level construct extent buried inside a TABLE CELL's own paragraph, not just a top-level one -- validateRunConstructExtents must actually recurse into every row's every cell", () => { + const table: ContentTable = { + kind: "table", + columnWidthsPt: [100], + rows: [ + { + cells: [ + { + blocks: [ + { + kind: "paragraph", + runs: [{ text: "text", hyperlink: "/u" }], + constructs: [ + { + descriptor: { + kind: "link", + target: { kind: "external", uri: "/u" }, + title: "t", + }, + startRun: 0, + endRun: 5, + }, + ], + }, + ], + }, + ], + }, + ], + }; + let inCell: unknown; + try { + emitMarkdown(doc([table])); + } catch (error) { + inCell = error; + } + expect(inCell).toBeInstanceOf(MarkdownInvalidRunConstructExtentError); + expect((inCell as MarkdownInvalidRunConstructExtentError).faultKind).toBe( + "beyondRuns", + ); + }); }); describe("nested style ordering (ExaDev/markdown-codec#957)", () => { From 366a60396d8dd3498586999b3965098ec219c170 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 15:30:58 +0100 Subject: [PATCH 68/99] refactor(markdown-codec): drop collectListItem's own unkillable resume-detection guard Whether the resumed run's own consumeSameItemRun call actually consumed anything was checked purely to skip pushing an empty "own" segment when it didn't -- but nothing downstream ever reads a segment run's own count, only segments[0] and each segment's own blocks, so that empty segment is invisible either way. Removing the guard leaves the loop's own existing nested-run check (which already breaks once index stops advancing) to terminate it on the very next pass instead, with identical observable output. --- packages/markdown-codec/src/emit/emit.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/markdown-codec/src/emit/emit.ts b/packages/markdown-codec/src/emit/emit.ts index 2cabac95b5..dc4a4d2b0c 100644 --- a/packages/markdown-codec/src/emit/emit.ts +++ b/packages/markdown-codec/src/emit/emit.ts @@ -758,10 +758,8 @@ function collectListItem( if (itemId === undefined) { break; } + // No separate "did anything actually resume?" check: when nothing does, resumedEnd stays equal to index, so this pushes a harmless empty "own" segment (segments.push/segment.blocks are never read for their COUNT, only segments[0] and each segment's own blocks) and the loop's own nested-run check above terminates it on the very next pass, since index is unchanged from this one. const resumedEnd = consumeSameItemRun(items, index, level, itemId); - if (resumedEnd === index) { - break; - } segments.push({ kind: "own", blocks: items.slice(index, resumedEnd) }); index = resumedEnd; } From 124d06d547216587bbb669c30960f8e4adf1086e Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 15:39:11 +0100 Subject: [PATCH 69/99] test(markdown-codec): cover willRenderAsSetext's own level boundary in both directions Every existing setext-eligibility test used Heading1 (level 1), which sits well clear of the level > MAX_SETEXT_LEVEL boundary in either direction, leaving both the exact-boundary (level 2, still eligible) and the just-past-it (level 3, already refused) cases unobserved. A Heading2 followed the same forced-blank-line pattern as the existing Heading1 test to prove level 2 remains eligible; a Heading3 with headingStyle: 'setext' explicitly requested proves the opposite -- a level with no setext spelling at all must stay ATX and tight regardless of the configured style, not merely whenever some OTHER trigger (an embedded break) happens to be absent. Confirmed by manually applying both the > -> >= and the whole-condition -> false mutations and watching the respective test fail each way. --- packages/markdown-codec/src/emit/emit.test.ts | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/packages/markdown-codec/src/emit/emit.test.ts b/packages/markdown-codec/src/emit/emit.test.ts index 18855eeed7..e4e4bf6ca1 100644 --- a/packages/markdown-codec/src/emit/emit.test.ts +++ b/packages/markdown-codec/src/emit/emit.test.ts @@ -2319,6 +2319,46 @@ describe("lists", () => { expect(headingBlock.runs.map((run) => run.text).join("")).toBe("h"); }); + it("inserts a blank line between a paragraph and a following Heading2 rendered as setext too, not just Heading1 -- willRenderAsSetext's own level > MAX_SETEXT_LEVEL check must correctly admit level 2 AT the boundary, not treat it the same as a level that exceeds it", () => { + const written = emitMarkdown( + doc([ + { + kind: "paragraph", + runs: [{ text: "a" }], + list: { numId: "md1:bullet", level: 0, itemId: "i1" }, + }, + { + kind: "paragraph", + styleId: "Heading2", + runs: [{ text: "h" }], + list: { numId: "md1:bullet", level: 0, itemId: "i1" }, + }, + ]), + { headingStyle: "setext" }, + ); + expect(written).toBe("- a\n\n h\n -"); + }); + + it("keeps a paragraph and a following Heading3 TIGHT even with headingStyle: 'setext' requested -- level 3 always renders as ATX regardless of the configured style (there is no setext spelling beyond level 2), so willRenderAsSetext must still refuse it rather than treating any level as eligible whenever setext is merely requested", () => { + const written = emitMarkdown( + doc([ + { + kind: "paragraph", + runs: [{ text: "a" }], + list: { numId: "md1:bullet", level: 0, itemId: "i1" }, + }, + { + kind: "paragraph", + styleId: "Heading3", + runs: [{ text: "h" }], + list: { numId: "md1:bullet", level: 0, itemId: "i1" }, + }, + ]), + { headingStyle: "setext" }, + ); + expect(written).toBe("- a\n ### h"); + }); + it("inserts a blank line between a paragraph and a following heading that is forced to setext by its OWN embedded line break, even with the default ATX headingStyle -- the interrupt guard must key off what the heading will actually render as, not the configured style, or the preceding paragraph is silently absorbed into it on reparse (ExaDev/documents.js#940)", () => { const softBreakRuns = [ { text: "h1" }, From 10144b893c320e945a84b459aabee9b3b423d93a Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 15:42:38 +0100 Subject: [PATCH 70/99] refactor(markdown-codec): drop renderConstruct's own redundant division-kind guard isMaterialisedDivision already re-checks item.descriptor.kind === "division" as the first half of its own condition, so a construct whose descriptor is genuinely some other kind already fails that check on its own and falls through unchanged -- the outer descriptor.kind === "division" wrapper around the call tested exactly the same fact a second time. --- packages/markdown-codec/src/emit/emit.ts | 20 +++++++++----------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/packages/markdown-codec/src/emit/emit.ts b/packages/markdown-codec/src/emit/emit.ts index dc4a4d2b0c..c1c05dd8b0 100644 --- a/packages/markdown-codec/src/emit/emit.ts +++ b/packages/markdown-codec/src/emit/emit.ts @@ -1039,17 +1039,15 @@ function renderConstruct(item: ConstructItem, context: EmitContext): string { }); return body; } - if (descriptor.kind === "division") { - // The blockquote spelling is gated on this package's own dual carry, not on the descriptor alone -- see isMaterialisedDivision above for exactly what that gate checks and why. A division whose paragraphs carry no such indent is a FOREIGN one -- an ODF text:section, a tagged-PDF /Sect -- and renders transparently below: a named section is not a markdown blockquote, and rendering it as one would invent a construct the source never had. - if (isMaterialisedDivision(item)) { - context.divisionDepth += 1; - const body = renderItems(item.children, context); - context.divisionDepth -= 1; - return body - .split("\n") - .map((line) => (line.length === 0 ? ">" : `> ${line}`)) - .join("\n"); - } + // The blockquote spelling is gated on this package's own dual carry, not on the descriptor kind alone -- see isMaterialisedDivision above for exactly what that gate checks and why. A division whose paragraphs carry no such indent is a FOREIGN one -- an ODF text:section, a tagged-PDF /Sect -- and renders transparently below: a named section is not a markdown blockquote, and rendering it as one would invent a construct the source never had. No separate `descriptor.kind === "division"` guard here: isMaterialisedDivision's own first check already tests that, so a non-division descriptor is refused there regardless, making an outer duplicate of the same check redundant. + if (isMaterialisedDivision(item)) { + context.divisionDepth += 1; + const body = renderItems(item.children, context); + context.divisionDepth -= 1; + return body + .split("\n") + .map((line) => (line.length === 0 ? ">" : `> ${line}`)) + .join("\n"); } if (descriptor.kind === "link" && descriptor.target.kind === "external") { // The mint condition is exact -- a pair around precisely one image block, the shape this package's own read side mints. A link construct of any other shape (an annotated block extent from another codec, a run-level pair flattened into a block list) renders transparently below rather than being guessed at. From 446e072317c3f81d75945210843b10478360ae15 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 15:42:40 +0100 Subject: [PATCH 71/99] test(markdown-codec): cover firstBlockCheckbox's own stripGlyph: false for the membership.checked branch Every existing membership.checked test used run text that never started with a legacy checkbox glyph, so stripCheckboxRun's own early "doesn't match, leave it alone" exit already made stripGlyph's value irrelevant. A run whose text happens to spell the legacy glyph exactly, paired with a field-based checked value, isolates it: the glyph-looking text must survive as ordinary content, proving stripGlyph is genuinely false here rather than wrongly true. --- packages/markdown-codec/src/emit/emit.test.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/packages/markdown-codec/src/emit/emit.test.ts b/packages/markdown-codec/src/emit/emit.test.ts index e4e4bf6ca1..12890a2742 100644 --- a/packages/markdown-codec/src/emit/emit.test.ts +++ b/packages/markdown-codec/src/emit/emit.test.ts @@ -1617,6 +1617,25 @@ describe("lists", () => { expect(markdown).toBe("- [ ] todo"); }); + it("never strips a run's own leading text when the checkbox comes from membership.checked instead of a legacy glyph, even when that text happens to look exactly like the legacy glyph spelling", () => { + const markdown = emitMarkdown( + doc([ + { + kind: "paragraph", + runs: [{ text: "☒ literal text not a glyph to strip" }], + list: { + numId: "md1:bullet+task", + level: 0, + checked: true, + itemId: "i1", + }, + }, + ]), + ); + // stripGlyph must be false here -- the checkbox already came from membership.checked, so this run's own text is ordinary content, never a legacy glyph prefix to strip back off. + expect(markdown).toBe("- [x] ☒ literal text not a glyph to strip"); + }); + it("strips a legacy checkbox glyph from a run that ALSO carries its own following text, not just when the glyph fills a whole separate run of its own", () => { const markdown = emitMarkdown( doc([ From 68f014c206877df0b1b83062b532f3fe4eace8c4 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 15:45:38 +0100 Subject: [PATCH 72/99] test(markdown-codec): cover the link construct's own exact-one-child mint condition Every existing image-link-construct test wrapped exactly one child, so nothing proved the length === 1 check actually excludes a construct with MORE children even when the first one is an image. A link wrapping an image followed by a caption paragraph isolates it: the construct must fall through to its own generic, transparent rendering (the image rendering as itself, not the link-shortcut's own remote-destination spelling) rather than being mistaken for the one-image mint shape. --- packages/markdown-codec/src/emit/emit.test.ts | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/packages/markdown-codec/src/emit/emit.test.ts b/packages/markdown-codec/src/emit/emit.test.ts index 12890a2742..e36fc63b0f 100644 --- a/packages/markdown-codec/src/emit/emit.test.ts +++ b/packages/markdown-codec/src/emit/emit.test.ts @@ -3332,6 +3332,37 @@ describe("link and image titles (the `link` construct annotation)", () => { ); }); + it("does NOT render the image-shortcut spelling for a link construct wrapping MORE than one child, even when the first of them is an image -- the mint condition is exactly one child, not merely 'starts with an image'", () => { + const collector = createDiagnosticCollector(); + const markdown = emitMarkdown( + doc([ + { + kind: "constructStart", + descriptor: { + kind: "link", + target: { kind: "external", uri: "https://example.com/a.png" }, + }, + }, + { + kind: "image", + format: "png", + base64: "AAAA", + widthPt: 1, + heightPt: 1, + altText: "alt", + }, + { kind: "paragraph", runs: [{ text: "caption" }] }, + { kind: "constructEnd" }, + ]), + { sink: collector.sink }, + ); + // The construct falls through to the generic, transparent rendering -- its own image child renders as ITSELF (a plain data: URI image, not the link-shortcut's own remote-destination spelling), and the caption follows as an ordinary paragraph. + expect(markdown).toBe("![alt](data:image/png;base64,AAAA)\n\ncaption"); + expect(collector.has(MarkdownDiagnosticCodes.CONSTRUCT_UNREPRESENTED)).toBe( + true, + ); + }); + it("falls back to the plain no-bytes image rendering when the construct destination is itself a data: URI and images: false asks for no bytes", () => { const blocks: ContentBlock[] = [ { From 37845eaa10b22d6cb4785da4733d1ecb04dd84ad Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 15:53:47 +0100 Subject: [PATCH 73/99] test(markdown-codec): cover HEADING_LEVEL_CLAMPED's own false case and the embedded formula's objectKind/document-kind agreement Every existing HEADING_LEVEL_CLAMPED test fired the diagnostic; nothing proved a heading whose level needs no clamping stays quiet. The embedded-object formula shortcut checks BOTH objectKind === "formula" and document.kind === "formula" -- the one existing "any other kind" test happened to keep both fields in agreement (mismatched together), so a genuine disagreement between the two (objectKind wordprocessing, document.kind formula, with real presentation LaTeX) went unexercised. Confirmed by manually applying each mutation and watching the respective test fail with precisely the predicted output. --- packages/markdown-codec/src/emit/emit.test.ts | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/packages/markdown-codec/src/emit/emit.test.ts b/packages/markdown-codec/src/emit/emit.test.ts index e36fc63b0f..6397241b8b 100644 --- a/packages/markdown-codec/src/emit/emit.test.ts +++ b/packages/markdown-codec/src/emit/emit.test.ts @@ -50,6 +50,19 @@ describe("headings", () => { ).toBe("### foo"); }); + it("does NOT fire HEADING_LEVEL_CLAMPED for a heading whose own level needs no clamping at all", () => { + const collector = createDiagnosticCollector(); + emitMarkdown( + doc([ + { kind: "paragraph", runs: [{ text: "foo" }], styleId: "Heading3" }, + ]), + { sink: collector.sink }, + ); + expect(collector.has(MarkdownDiagnosticCodes.HEADING_LEVEL_CLAMPED)).toBe( + false, + ); + }); + it('emits level 1/2 as setext when headingStyle: "setext" is requested, and falls back to ATX beyond level 2', () => { expect( emitMarkdown( @@ -1300,6 +1313,25 @@ describe("math (ExaDev/markdown-codec#53)", () => { ).toBe("$$\n$$"); }); + it("does not render the $$ math shortcut when objectKind disagrees with the document's own kind, even though the document itself is a formula carrying real presentation LaTeX -- both fields must agree, not just the document's own kind", () => { + expect( + emitMarkdown( + doc([ + { + kind: "embeddedObject", + objectKind: "wordprocessing", + document: { + kind: "formula", + metadata: {}, + formula: { mathml: [], presentation: { latex: "x^2" } }, + }, + frame: { xPt: 0, yPt: 0, widthPt: 1, heightPt: 1 }, + }, + ]), + ), + ).toBe(""); + }); + it("still silently drops an embedded object of any other kind, and a formula with no presentation LaTeX, which have no markdown spelling", () => { expect( emitMarkdown( From b1d35080f7155f79a0a965b7f7aac939654d2e39 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 16:00:52 +0100 Subject: [PATCH 74/99] test(markdown-codec): cover interruptsSetextParagraph's own block-start vs paragraph-continuation sense at both call sites Neither of unsafeSetextBreakReason's two calls to interruptsSetextParagraph had a test distinguishing the block-start sense (atBlockStart: true, for the first line) from the paragraph-continuation sense (atBlockStart: false, for every line after it), since almost every construct interruptsSetextParagraph checks behaves identically in both senses. An ordered-list marker NOT starting at 1 is the one CommonMark paragraph-interruption exception that genuinely diverges between the two: it counts as a real block start unconditionally, but cannot interrupt an already-open paragraph. The same line, "2. foo", is refused as an entire break-free heading (genuine block start) but absorbed safely as a heading's own second line (paragraph continuation) -- confirmed by manually swapping each call's own boolean argument and watching the matching test fail. --- packages/markdown-codec/src/emit/emit.test.ts | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/packages/markdown-codec/src/emit/emit.test.ts b/packages/markdown-codec/src/emit/emit.test.ts index 6397241b8b..5ffcf6ddbf 100644 --- a/packages/markdown-codec/src/emit/emit.test.ts +++ b/packages/markdown-codec/src/emit/emit.test.ts @@ -837,6 +837,51 @@ describe("headings", () => { expect(headingBlock.runs.map((run) => run.text).join("")).toBe("foo"); }); + it("refuses to promote a break-free heading whose ENTIRE text is an ordered-list marker not starting at 1 -- interruptsSetextParagraph's first-line call must use the genuine block-start sense (any start number counts), not the paragraph-continuation sense (only start-at-1 counts)", () => { + const collector = createDiagnosticCollector(); + const written = emitMarkdown( + doc([ + { + kind: "paragraph", + runs: [ + { + text: "2. foo", + source: { format: "markdown", xml: "2. foo" }, + }, + ], + styleId: "Heading1", + }, + ]), + { headingStyle: "setext", sink: collector.sink }, + ); + expect(written).toBe("# 2. foo"); + expect( + collector.has( + MarkdownDiagnosticCodes.HEADING_LINE_BREAK_UNSAFE_FOR_SETEXT, + ), + ).toBe(true); + }); + + it("still safely promotes to setext when a NON-FIRST line is an ordered-list marker not starting at 1 -- interruptsSetextParagraph's non-first-line call must use the paragraph-continuation sense (CommonMark's own exception absorbs it as continuation text), not the block-start sense", () => { + const written = emitMarkdown( + doc([ + { + kind: "paragraph", + runs: [ + { text: "foo" }, + { text: "\n" }, + { + text: "2. bar", + source: { format: "markdown", xml: "2. bar" }, + }, + ], + styleId: "Heading1", + }, + ]), + ); + expect(written).toBe("foo\\\n2. bar\n===="); + }); + it.each([ { level: "Heading1" as const, underline: "=" }, { level: "Heading2" as const, underline: "-" }, From eda3aac6852a9dd0885d7debeccbbd64740d8aff Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 16:27:59 +0100 Subject: [PATCH 75/99] refactor(markdown-codec): drop validateRunConstructExtents' own unkillable constructs-undefined guard findRunConstructFault already checks constructs === undefined as its own first line and returns undefined immediately, so the outer block.constructs !== undefined check tested exactly the same fact a second time before ever calling it. --- packages/markdown-codec/src/emit/emit.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/markdown-codec/src/emit/emit.ts b/packages/markdown-codec/src/emit/emit.ts index c1c05dd8b0..c63452997d 100644 --- a/packages/markdown-codec/src/emit/emit.ts +++ b/packages/markdown-codec/src/emit/emit.ts @@ -1217,10 +1217,10 @@ function emitBlocks( return renderItems(groupConstructItems(blocks, 0).items, context); } -// A paragraph's run-level construct extents must name real runs before anything renders them -- the run-level twin of the marker-balance check above, through document-schema.js's own findRunConstructFault so every codec and consumer agree on one definition of well-formed. Tables are walked into because a cell's block list holds its own paragraphs (and nothing else descends further: a table inside a table cell is not a shape GFM or this model produces). +// A paragraph's run-level construct extents must name real runs before anything renders them -- the run-level twin of the marker-balance check above, through document-schema.js's own findRunConstructFault so every codec and consumer agree on one definition of well-formed. Tables are walked into because a cell's block list holds its own paragraphs (and nothing else descends further: a table inside a table cell is not a shape GFM or this model produces). No separate `block.constructs !== undefined` guard here: findRunConstructFault already checks that itself and returns undefined immediately, so a paragraph with no constructs at all is exactly as safe to pass through unconditionally. function validateRunConstructExtents(blocks: readonly ContentBlock[]): void { for (const block of blocks) { - if (block.kind === "paragraph" && block.constructs !== undefined) { + if (block.kind === "paragraph") { const fault = findRunConstructFault(block); if (fault !== undefined) { throw new MarkdownInvalidRunConstructExtentError( From a5d7778dc42bef3240f3b56ad7d80f7255bd7ce5 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 16:38:33 +0100 Subject: [PATCH 76/99] test(markdown-codec): cover non-paragraph list children interrupting a paragraph emitItemCanInterrupt own non-construct fallback (a non-paragraph block always interrupts, regardless of canInterruptOpenParagraph) had no test forcing that specific branch: every prior list-continuation test used either a plain paragraph or a materialised division construct as the interrupting block, neither of which reaches this fallback. A link construct wrapping more than one child (so its image-shortcut mint condition does not apply) falls through to transparent rendering, so its own first child, a non-paragraph image block, is exactly what this fallback answers for when the construct shares the preceding open paragraph list itemId. --- packages/markdown-codec/src/emit/emit.test.ts | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/packages/markdown-codec/src/emit/emit.test.ts b/packages/markdown-codec/src/emit/emit.test.ts index 5ffcf6ddbf..eb80c18d8f 100644 --- a/packages/markdown-codec/src/emit/emit.test.ts +++ b/packages/markdown-codec/src/emit/emit.test.ts @@ -1856,6 +1856,42 @@ describe("lists", () => { expect(markdown).toBe("- a\n - ```\n b\n ```\n z"); }); + it("needs no forced blank line between an open (styleId-less) paragraph and a following link-construct whose FIRST child is a non-paragraph IMAGE block, in the SAME list item -- a non-paragraph block always interrupts an open paragraph unconditionally, per emitItemCanInterrupt's non-construct fallback", () => { + const markdown = emitMarkdown( + doc([ + { + kind: "paragraph", + runs: [{ text: "a" }], + list: { numId: "md1:bullet", level: 0, itemId: "i1" }, + }, + { + kind: "constructStart", + descriptor: { + kind: "link", + target: { kind: "external", uri: "https://example.com/a.png" }, + }, + }, + { + kind: "image", + format: "png", + base64: "AAAA", + widthPt: 1, + heightPt: 1, + altText: "img", + }, + { + kind: "paragraph", + runs: [{ text: "caption" }], + list: { numId: "md1:bullet", level: 0, itemId: "i1" }, + }, + { kind: "constructEnd" }, + ]), + ); + expect(markdown).toBe( + "- a\n ![img](data:image/png;base64,AAAA)\n\n caption", + ); + }); + it("finds the REAL last styleId inside a construct that resumes a list item, not just undefined, so a following block's own blank-line decision reflects what that construct actually ends on", () => { const markdown = emitMarkdown( doc([ From 7e7c96e6f85c069420b61a7a20ec02b8b4271f74 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 16:40:49 +0100 Subject: [PATCH 77/99] test(markdown-codec): cover an empty construct child defaulting to interrupting a paragraph emitItemCanInterrupt own construct-recursion base case, first === undefined, had no test forcing it: every prior test constructing a nested construct gave it at least one child, so recursion always bottomed out through the non-construct branch instead of this one. An empty, non-division nested construct (an anchor with zero children) triggers the base case directly, and its own outer construct is only absorbed into the list item run through a LATER sibling paragraph carrying the item id, not through this empty first child. --- packages/markdown-codec/src/emit/emit.test.ts | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/packages/markdown-codec/src/emit/emit.test.ts b/packages/markdown-codec/src/emit/emit.test.ts index eb80c18d8f..d2f1f704fd 100644 --- a/packages/markdown-codec/src/emit/emit.test.ts +++ b/packages/markdown-codec/src/emit/emit.test.ts @@ -1856,6 +1856,37 @@ describe("lists", () => { expect(markdown).toBe("- a\n - ```\n b\n ```\n z"); }); + it("needs no forced blank line before a construct whose own FIRST child is an EMPTY, non-division nested construct, in the SAME list item -- emitItemCanInterrupt's construct-recursion base case (an empty children array) defaults to interrupting, exactly like the non-paragraph fallback it mirrors", () => { + const markdown = emitMarkdown( + doc([ + { + kind: "paragraph", + runs: [{ text: "a" }], + list: { numId: "md1:bullet", level: 0, itemId: "i1" }, + }, + { + kind: "constructStart", + descriptor: { + kind: "link", + target: { kind: "external", uri: "https://example.com" }, + }, + }, + { + kind: "constructStart", + descriptor: { kind: "anchor", anchorType: "bookmark", name: "empty" }, + }, + { kind: "constructEnd" }, + { + kind: "paragraph", + runs: [{ text: "caption" }], + list: { numId: "md1:bullet", level: 0, itemId: "i1" }, + }, + { kind: "constructEnd" }, + ]), + ); + expect(markdown).toBe("- a\n caption"); + }); + it("needs no forced blank line between an open (styleId-less) paragraph and a following link-construct whose FIRST child is a non-paragraph IMAGE block, in the SAME list item -- a non-paragraph block always interrupts an open paragraph unconditionally, per emitItemCanInterrupt's non-construct fallback", () => { const markdown = emitMarkdown( doc([ From bfb10efe01d3ef06d6c2602812427f815dfac419 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 16:43:34 +0100 Subject: [PATCH 78/99] refactor(markdown-codec): derive stripGlyph from the detected checkbox text firstBlockCheckboxs final two branches each hardcoded a separate stripGlyph: false literal for the not-found case, but stripCheckboxRun (the only consumer of that flag) already re-checks the identical two glyph prefixes itself and no-ops when neither matches. That makes stripGlyph unobservable whenever no glyph is found: manually flipping the literal to true left the full suite passing unchanged. Collapsing the two returns into one expression, with stripGlyph derived from whether checkboxText itself came back non-empty, removes the dead literal instead of asserting it separately from a fact stripCheckboxRun already establishes on its own. --- packages/markdown-codec/src/emit/emit.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/markdown-codec/src/emit/emit.ts b/packages/markdown-codec/src/emit/emit.ts index c63452997d..961c8b9263 100644 --- a/packages/markdown-codec/src/emit/emit.ts +++ b/packages/markdown-codec/src/emit/emit.ts @@ -617,13 +617,13 @@ function firstBlockCheckbox( return { checkboxText: "", stripGlyph: false }; } const leading = first.block.runs[0]?.text ?? ""; - if (leading.startsWith(`${TASK_CHECKBOX_CHECKED} `)) { - return { checkboxText: "[x] ", stripGlyph: true }; - } - if (leading.startsWith(`${TASK_CHECKBOX_UNCHECKED} `)) { - return { checkboxText: "[ ] ", stripGlyph: true }; - } - return { checkboxText: "", stripGlyph: false }; + // No separate "found nothing" return with its own stripGlyph: false literal: stripCheckboxRun below already re-checks the identical two prefixes and no-ops when neither matches, so stripGlyph here can only ever be observed to equal whether checkboxText itself is non-empty. + const checkboxText = leading.startsWith(`${TASK_CHECKBOX_CHECKED} `) + ? "[x] " + : leading.startsWith(`${TASK_CHECKBOX_UNCHECKED} `) + ? "[ ] " + : ""; + return { checkboxText, stripGlyph: checkboxText !== "" }; } interface RenderedListMarker { From 239f72b011eb501ce3b164aa03882ca36936f78e Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 16:46:27 +0100 Subject: [PATCH 79/99] test(markdown-codec): cover the unsafe-setext branch requiring setextRequested itself The unsafe-diagnostic branch (setextRequested && level <= MAX_SETEXT_LEVEL && unsafeForSetext) had no test isolating its first conjunct: every existing test for a break-free, hazard-carrying heading also set headingStyle: setext, so setextRequested was always true whenever unsafeForSetext was. A break-free, 4+-column-indented level-1 heading with the default (atx) headingStyle now proves the branch is skipped when setext was never requested at all, even though the same text is independently unsafe. --- packages/markdown-codec/src/emit/emit.test.ts | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/packages/markdown-codec/src/emit/emit.test.ts b/packages/markdown-codec/src/emit/emit.test.ts index d2f1f704fd..c578692032 100644 --- a/packages/markdown-codec/src/emit/emit.test.ts +++ b/packages/markdown-codec/src/emit/emit.test.ts @@ -680,6 +680,34 @@ describe("headings", () => { describe("an explicit headingStyle: 'setext' request against a break-free heading that is unsafe on its own terms is still refused, with a diagnostic (ExaDev/documents.js#940)", () => { // Every OTHER unsafe-for-setext test in this file exercises a heading whose text embeds an actual line break -- the break itself is what makes setext a candidate rendering at all when headingStyle is left at its 'atx' default. This heading has NO embedded break anywhere: headingStyle: 'setext' is the ONLY reason setext is even attempted, and unsafeSetextBreakReason's own first-line-indentation check applies exactly as much to a single-line heading as to a multi-line one. Pre-fix, every heading-related diagnostic sat behind an `embedsLineBreak` guard, so this exact shape silently fell through to a bare, unmarked ATX heading -- an explicit caller preference honoured in appearance (setext was refused, correctly) but with zero signal that it happened. + it("does NOT fire HEADING_LINE_BREAK_UNSAFE_FOR_SETEXT for a break-free, 4+-column-indented level-1 heading when setext was never requested at all -- unsafeForSetext alone, with setextRequested false, must not enter the unsafe-diagnostic branch", () => { + const collector = createDiagnosticCollector(); + const written = emitMarkdown( + doc([ + { + kind: "paragraph", + runs: [{ text: " foo" }], + styleId: "Heading1", + }, + ]), + { sink: collector.sink }, + ); + expect(written).toBe("# foo"); + expect( + collector.has( + MarkdownDiagnosticCodes.HEADING_LINE_BREAK_UNSAFE_FOR_SETEXT, + ), + ).toBe(false); + expect( + collector.has(MarkdownDiagnosticCodes.HEADING_LINE_BREAK_COLLAPSED), + ).toBe(false); + expect( + collector.has( + MarkdownDiagnosticCodes.HEADING_STYLE_OVERRIDDEN_FOR_LINE_BREAK, + ), + ).toBe(false); + }); + it("collapses to ATX with HEADING_LINE_BREAK_UNSAFE_FOR_SETEXT when the heading's own (break-free) text is indented 4 or more columns", () => { const collector = createDiagnosticCollector(); const written = emitMarkdown( From 3891aef36a671f7dae42190b56e050dec000e1ce Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 17:18:27 +0100 Subject: [PATCH 80/99] refactor(markdown-codec): strip the checkbox glyph once, in firstBlockCheckbox itself The prior stripGlyph boolean was still unobservable at its own new call site: a ConditionalExpression mutant on checkboxText !== "" survived, because stripCheckboxRun independently re-checks the identical glyph prefixes and no-ops whenever none match, so the caller-supplied flag never actually changes what gets rendered when no glyph was found. firstBlockCheckbox now does the stripping itself, in the same branch that already found the glyph, and returns the already-stripped paragraph (or undefined when nothing needs stripping) instead of a flag for listRegionItemBody to act on later. Only one place ever decides whether stripping applies, so there is no second, redundant boolean left over for a mutation to hide behind. --- packages/markdown-codec/src/emit/emit.ts | 43 +++++++++++++----------- 1 file changed, 23 insertions(+), 20 deletions(-) diff --git a/packages/markdown-codec/src/emit/emit.ts b/packages/markdown-codec/src/emit/emit.ts index 961c8b9263..cb83b3ddc9 100644 --- a/packages/markdown-codec/src/emit/emit.ts +++ b/packages/markdown-codec/src/emit/emit.ts @@ -597,10 +597,10 @@ function toEmitItem(item: ListRegionItem): EmitItem { return item.kind === "paragraph" ? { block: item.block } : item.item; } -// One item's first-block preparation: the checkbox text its marker line carries, and whether that block's own leading run is a legacy checkbox glyph that must be stripped from the body. The membership's own checked field is the current spelling and needs no task-flagged numId behind it; the glyph sniff is gated on the numId's task flag AND on the first block actually being a paragraph (a construct has no runs of its own to sniff a glyph from), so an ordinary item whose text happens to begin with a ballot-box glyph is never misread as a checkbox. +// One item's first-block preparation: the checkbox text its marker line carries, and the SAME paragraph with any legacy checkbox glyph run already stripped out of it, when one was found. The membership's own checked field is the current spelling and needs no task-flagged numId behind it; the glyph sniff is gated on the numId's task flag AND on the first block actually being a paragraph (a construct has no runs of its own to sniff a glyph from), so an ordinary item whose text happens to begin with a ballot-box glyph is never misread as a checkbox. Doing the strip here, once, rather than returning a separate "please strip" boolean for listRegionItemBody to act on later, means no second site ever needs to re-derive from the run text whether stripping applies -- the one place that already found the glyph is the one place that removes it. interface FirstBlockCheckbox { readonly checkboxText: string; - readonly stripGlyph: boolean; + readonly strippedFirstBlock: ContentParagraph | undefined; } function firstBlockCheckbox( @@ -610,20 +610,26 @@ function firstBlockCheckbox( if (first.list.checked !== undefined) { return { checkboxText: first.list.checked ? "[x] " : "[ ] ", - stripGlyph: false, + strippedFirstBlock: undefined, }; } if (!taskNumId || first.kind !== "paragraph") { - return { checkboxText: "", stripGlyph: false }; + return { checkboxText: "", strippedFirstBlock: undefined }; } const leading = first.block.runs[0]?.text ?? ""; - // No separate "found nothing" return with its own stripGlyph: false literal: stripCheckboxRun below already re-checks the identical two prefixes and no-ops when neither matches, so stripGlyph here can only ever be observed to equal whether checkboxText itself is non-empty. - const checkboxText = leading.startsWith(`${TASK_CHECKBOX_CHECKED} `) - ? "[x] " - : leading.startsWith(`${TASK_CHECKBOX_UNCHECKED} `) - ? "[ ] " - : ""; - return { checkboxText, stripGlyph: checkboxText !== "" }; + if (leading.startsWith(`${TASK_CHECKBOX_CHECKED} `)) { + return { + checkboxText: "[x] ", + strippedFirstBlock: stripCheckboxRun(first.block), + }; + } + if (leading.startsWith(`${TASK_CHECKBOX_UNCHECKED} `)) { + return { + checkboxText: "[ ] ", + strippedFirstBlock: stripCheckboxRun(first.block), + }; + } + return { checkboxText: "", strippedFirstBlock: undefined }; } interface RenderedListMarker { @@ -801,17 +807,14 @@ function lastStyleIdOfRegionItem(item: ListRegionItem): string | undefined { return lastStyleIdOf(toEmitItem(item)); } -// One list-region item's own rendered body, with no marker/indent applied yet. A plain paragraph renders through renderParagraphBody exactly as before (optionally with its checkbox glyph stripped); a construct renders through renderConstruct -- the SAME function renderItems reaches for a construct that is NOT part of any list region, so a construct's own markdown spelling never diverges depending on whether it happens to sit inside a list item, EXCEPT for context.enclosingItemId, set here for the duration of that one call: it is what lets renderItems' own recursive walk over the construct's children tell inherited pass-through membership (this exact item, see EmitContext's own field comment) apart from a genuinely fresh nested list. +// One list-region item's own rendered body, with no marker/indent applied yet. A plain paragraph renders through renderParagraphBody exactly as before (using `overrideParagraph` in place of the item's own block when the caller already prepared a checkbox-glyph-stripped version, per firstBlockCheckbox above); a construct renders through renderConstruct -- the SAME function renderItems reaches for a construct that is NOT part of any list region, so a construct's own markdown spelling never diverges depending on whether it happens to sit inside a list item, EXCEPT for context.enclosingItemId, set here for the duration of that one call: it is what lets renderItems' own recursive walk over the construct's children tell inherited pass-through membership (this exact item, see EmitContext's own field comment) apart from a genuinely fresh nested list. function listRegionItemBody( item: ListRegionItem, context: EmitContext, - stripGlyph: boolean, + overrideParagraph: ContentParagraph | undefined, ): string { if (item.kind === "paragraph") { - return renderParagraphBody( - stripGlyph ? stripCheckboxRun(item.block) : item.block, - context, - ); + return renderParagraphBody(overrideParagraph ?? item.block, context); } const previousEnclosingItemId = context.enclosingItemId; context.enclosingItemId = item.list.itemId; @@ -868,7 +871,7 @@ function renderListRegion( if (first === undefined) { break; } - const { checkboxText, stripGlyph } = firstBlockCheckbox( + const { checkboxText, strippedFirstBlock } = firstBlockCheckbox( first, info?.task === true, ); @@ -904,7 +907,7 @@ function renderListRegion( const bodyLines = listRegionItemBody( block, context, - stripGlyph, + strippedFirstBlock, ).split("\n"); const [firstLine = "", ...restLines] = bodyLines; text = [ @@ -915,7 +918,7 @@ function renderListRegion( previousStyleId = lastStyleIdOfRegionItem(block); continue; } - const rendered = listRegionItemBody(block, context, false) + const rendered = listRegionItemBody(block, context, undefined) .split("\n") .map((line) => (line.length === 0 ? line : `${indent}${line}`)) .join("\n"); From d30088aee924f4fd65c9439cb2fdb7df6fcd83ba Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 17:37:29 +0100 Subject: [PATCH 81/99] test(markdown-codec): cover the unsafe-setext branches own level ceiling The unsafe-diagnostic branch condition has three conjuncts, and level <= MAX_SETEXT_LEVEL had no test isolating it from the other two: every prior break-free-hazard test used a level 1 or 2 heading, so the level check was always trivially true alongside setextRequested and unsafeForSetext. A level-3 heading with headingStyle: setext requested AND a genuine leading-indentation hazard now proves the branch is still skipped once level exceeds setexts own two-level ceiling, even though the other two conjuncts hold. --- packages/markdown-codec/src/emit/emit.test.ts | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/packages/markdown-codec/src/emit/emit.test.ts b/packages/markdown-codec/src/emit/emit.test.ts index c578692032..5f4ff62768 100644 --- a/packages/markdown-codec/src/emit/emit.test.ts +++ b/packages/markdown-codec/src/emit/emit.test.ts @@ -680,6 +680,34 @@ describe("headings", () => { describe("an explicit headingStyle: 'setext' request against a break-free heading that is unsafe on its own terms is still refused, with a diagnostic (ExaDev/documents.js#940)", () => { // Every OTHER unsafe-for-setext test in this file exercises a heading whose text embeds an actual line break -- the break itself is what makes setext a candidate rendering at all when headingStyle is left at its 'atx' default. This heading has NO embedded break anywhere: headingStyle: 'setext' is the ONLY reason setext is even attempted, and unsafeSetextBreakReason's own first-line-indentation check applies exactly as much to a single-line heading as to a multi-line one. Pre-fix, every heading-related diagnostic sat behind an `embedsLineBreak` guard, so this exact shape silently fell through to a bare, unmarked ATX heading -- an explicit caller preference honoured in appearance (setext was refused, correctly) but with zero signal that it happened. + it("does NOT fire HEADING_LINE_BREAK_UNSAFE_FOR_SETEXT for a break-free, 4+-column-indented level-3 heading even with headingStyle: 'setext' requested -- level <= MAX_SETEXT_LEVEL is its own genuine gate, not implied by setextRequested and unsafeForSetext alone", () => { + const collector = createDiagnosticCollector(); + const written = emitMarkdown( + doc([ + { + kind: "paragraph", + runs: [{ text: " foo" }], + styleId: "Heading3", + }, + ]), + { headingStyle: "setext", sink: collector.sink }, + ); + expect(written).toBe("### foo"); + expect( + collector.has( + MarkdownDiagnosticCodes.HEADING_LINE_BREAK_UNSAFE_FOR_SETEXT, + ), + ).toBe(false); + expect( + collector.has(MarkdownDiagnosticCodes.HEADING_LINE_BREAK_COLLAPSED), + ).toBe(false); + expect( + collector.has( + MarkdownDiagnosticCodes.HEADING_STYLE_OVERRIDDEN_FOR_LINE_BREAK, + ), + ).toBe(false); + }); + it("does NOT fire HEADING_LINE_BREAK_UNSAFE_FOR_SETEXT for a break-free, 4+-column-indented level-1 heading when setext was never requested at all -- unsafeForSetext alone, with setextRequested false, must not enter the unsafe-diagnostic branch", () => { const collector = createDiagnosticCollector(); const written = emitMarkdown( From 3d0e050abf0bd41e0becd5a9663b48f926e6bc12 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 17:55:12 +0100 Subject: [PATCH 82/99] refactor(markdown-codec): remove three dead split-result fallbacks with one helper String.prototype.split never returns an empty array for any input, even the empty string, so a split result own first element is always genuinely present. noUncheckedIndexedAccess still forced a dead default at every call site indexing or destructuring one, and each of those three defaults was unreachable code with no way for a real test to ever observe a difference if mutated. splitLines centralises the one non-null assertion this invariant actually needs into a single, clearly justified place, returning a non-empty tuple type so every call site gets its own first line without a fallback that could never fire. --- packages/markdown-codec/src/emit/emit.ts | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/packages/markdown-codec/src/emit/emit.ts b/packages/markdown-codec/src/emit/emit.ts index cb83b3ddc9..4474c84b8c 100644 --- a/packages/markdown-codec/src/emit/emit.ts +++ b/packages/markdown-codec/src/emit/emit.ts @@ -117,10 +117,19 @@ const SETEXT_LEVEL_1_CHAR = "="; const SETEXT_LEVEL_2_CHAR = "-"; const MIN_SETEXT_UNDERLINE_LENGTH = 1; +// String.prototype.split never returns an empty array for any input, even the empty string ("".split(x) === [""]) -- so a split result's own first line is always genuinely present. Returning a tuple type here, rather than a plain string[], lets every call site destructure or index its own first element directly: TypeScript already knows a tuple's fixed leading position is defined regardless of noUncheckedIndexedAccess, so no call site needs a dead "?? ''"/"= ''" fallback for a branch this invariant guarantees it can never actually take. The non-null assertion below is the one place that invariant is asserted, rather than repeated at every call site. +function splitLines( + text: string, + pattern: string | RegExp, +): readonly [string, ...string[]] { + const [first, ...rest] = text.split(pattern); + return [first!, ...rest]; +} + function renderSetextHeading(level: number, text: string): string { const underlineChar = level === 1 ? SETEXT_LEVEL_1_CHAR : SETEXT_LEVEL_2_CHAR; // A setext underline's own length has no semantic meaning beyond "one or more" -- matching the heading text's own rendered length keeps the output visually tidy without claiming any significance for the exact count, so a CR- or CRLF-delimited first line (LINE_ENDING_PATTERN, not a bare '\n' split) still measures the SAME first line the rest of this module's own line-ending-aware checks agree on, rather than treating the whole multi-line text as a single "line" whenever its own first break is not an LF. - const firstLine = text.split(LINE_ENDING_PATTERN)[0] ?? ""; + const [firstLine] = splitLines(text, LINE_ENDING_PATTERN); const underline = underlineChar.repeat( Math.max(MIN_SETEXT_UNDERLINE_LENGTH, firstLine.length), ); @@ -904,12 +913,10 @@ function renderListRegion( } for (const block of segment.blocks) { if (!renderedFirstLine) { - const bodyLines = listRegionItemBody( - block, - context, - strippedFirstBlock, - ).split("\n"); - const [firstLine = "", ...restLines] = bodyLines; + const [firstLine, ...restLines] = splitLines( + listRegionItemBody(block, context, strippedFirstBlock), + "\n", + ); text = [ `${marker.full}${firstLine}`, ...restLines.map((line) => `${indent}${line}`), @@ -1018,7 +1025,7 @@ function renderFootnoteDefinition(name: string, body: string): string { return marker; } const indent = " ".repeat(FOOTNOTE_CONTINUATION_INDENT); - const [firstLine = "", ...restLines] = body.split("\n"); + const [firstLine, ...restLines] = splitLines(body, "\n"); return [ `${marker} ${firstLine}`, ...restLines.map((line) => (line.length === 0 ? line : `${indent}${line}`)), From 36c0b49ab1a19b18830b87b3776a22c685ba7286 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 19:18:27 +0100 Subject: [PATCH 83/99] refactor(markdown-codec): return the code-indent threshold boolean directly leadingIndentColumns own tab-stop arithmetic (MARKDOWN_TAB_STOP_WIDTH - column % MARKDOWN_TAB_STOP_WIDTH) had a "-" survive as an unkillable mutation to "+": its one caller only ever checks the result against CODE_INDENT_COLUMNS, and since that threshold is never greater than the tab-stop width, a tab encountered anywhere before the threshold is otherwise reached by spaces alone always pushes the running column to at least the threshold under either operator. Manually applying the mutation and rerunning the full suite confirmed nothing distinguishes the two. leadingIndentReachesCodeThreshold returns the boundary question its sole caller actually asks, short-circuiting on the first tab (which alone is always sufficient) rather than computing an exact column count nothing downstream consumes past that point. --- packages/markdown-codec/src/emit/emit.ts | 24 +++++++++++++----------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/packages/markdown-codec/src/emit/emit.ts b/packages/markdown-codec/src/emit/emit.ts index 4474c84b8c..772ee74bdd 100644 --- a/packages/markdown-codec/src/emit/emit.ts +++ b/packages/markdown-codec/src/emit/emit.ts @@ -54,7 +54,6 @@ import { } from "../defaults/defaults"; import { matchHtmlBlockStart } from "../html/html"; import { isValidFootnoteLabel } from "../inline/footnote"; -import { MARKDOWN_TAB_STOP_WIDTH } from "../scan/scan"; import type { MarkdownHeadingStyle, WriteMarkdownOptions, @@ -199,19 +198,22 @@ function firstContentLineIndex(text: string): number { .findIndex((line) => !BLANK_OR_WHITESPACE_ONLY_LINE.test(line)); } -// The column width of a line's own leading run of spaces and tabs, expanded per CommonMark's own tab-stop rule (spec 0.31.2, "Tabs": "in contexts where spaces help to define block structure, tabs behave as if they were replaced by spaces with a tab stop of 4 characters", counted from the start of the LINE, not the whole document). Shares MARKDOWN_TAB_STOP_WIDTH with src/scan/scan.ts's own MarkdownScanCursor so a tab's width agrees with the read side's parse of the very text this function is predicting the reparse of. -function leadingIndentColumns(line: string): number { +// Whether a line's own leading run of spaces and tabs reaches CommonMark's own 4-column indented-code-block threshold (spec 0.31.2, "Tabs": "in contexts where spaces help to define block structure, tabs behave as if they were replaced by spaces with a tab stop of 4 characters", counted from the start of the LINE, not the whole document). Shares MARKDOWN_TAB_STOP_WIDTH with src/scan/scan.ts's own MarkdownScanCursor so a tab's width agrees with the read side's parse of the very text this function is predicting the reparse of. The sole caller below only ever asks a >= CODE_INDENT_COLUMNS boundary question, never the exact column count beyond it, so this returns that boundary directly: a tab encountered anywhere before the threshold is reached by spaces alone is always itself sufficient to cross it, since CODE_INDENT_COLUMNS <= MARKDOWN_TAB_STOP_WIDTH means expanding even a single tab from column 0 already lands exactly on (never short of) the threshold. +function leadingIndentReachesCodeThreshold(line: string): boolean { let column = 0; for (const char of line) { - if (char === " ") { - column += 1; - } else if (char === "\t") { - column += MARKDOWN_TAB_STOP_WIDTH - (column % MARKDOWN_TAB_STOP_WIDTH); - } else { - break; + if (char === "\t") { + return true; + } + if (char !== " ") { + return false; + } + column += 1; + if (column >= CODE_INDENT_COLUMNS) { + return true; } } - return column; + return false; } // CommonMark's own list-item grammar (spec 0.31.2, section 5.2 "List items"): "A list item can begin with at most one blank line." -- the bound the leading-run exemption above is held to, applied universally regardless of which context (top-level, blockquote, list item) the heading being checked is actually about to render through, since this function cannot see that and the bound is harmless where it is not strictly required. @@ -277,7 +279,7 @@ function unsafeSetextBreakReason(text: string): UnsafeSetextBreakReason { continue; } sawContentLine = true; - if (leadingIndentColumns(line) >= CODE_INDENT_COLUMNS) { + if (leadingIndentReachesCodeThreshold(line)) { return "leading-indentation"; } if (interruptsSetextParagraph(line, true)) { From 00f708ee962e4dd23c3e3e6898d34f27b03c3209 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 15 Sep 2026 20:08:08 +0100 Subject: [PATCH 84/99] refactor(markdown-codec): remove leadingIndentReachesCodeThreshold's dead loop-exhausted fallback The for-of rewrite from the previous commit still needed a trailing return false after the loop, since TypeScript cannot itself prove the loop always returns from inside. That fallback was unreachable for any real input: the sole caller only ever passes a non-blank line, and a non-blank line always contains a character that is a tab, is some other non-space, or pushes the running column to the threshold, so one of the in-loop returns always fires first. Rewriting the scan as "count the leading spaces, then check the single character right after them" removes the loop entirely, so there is no separate exhausted-the-string branch left needing a return statement at all: indexing past the end of a string reads as undefined, which compares unequal to the tab character exactly like a real non-tab character would. --- packages/markdown-codec/src/emit/emit.ts | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) diff --git a/packages/markdown-codec/src/emit/emit.ts b/packages/markdown-codec/src/emit/emit.ts index 772ee74bdd..57a587462b 100644 --- a/packages/markdown-codec/src/emit/emit.ts +++ b/packages/markdown-codec/src/emit/emit.ts @@ -198,22 +198,16 @@ function firstContentLineIndex(text: string): number { .findIndex((line) => !BLANK_OR_WHITESPACE_ONLY_LINE.test(line)); } -// Whether a line's own leading run of spaces and tabs reaches CommonMark's own 4-column indented-code-block threshold (spec 0.31.2, "Tabs": "in contexts where spaces help to define block structure, tabs behave as if they were replaced by spaces with a tab stop of 4 characters", counted from the start of the LINE, not the whole document). Shares MARKDOWN_TAB_STOP_WIDTH with src/scan/scan.ts's own MarkdownScanCursor so a tab's width agrees with the read side's parse of the very text this function is predicting the reparse of. The sole caller below only ever asks a >= CODE_INDENT_COLUMNS boundary question, never the exact column count beyond it, so this returns that boundary directly: a tab encountered anywhere before the threshold is reached by spaces alone is always itself sufficient to cross it, since CODE_INDENT_COLUMNS <= MARKDOWN_TAB_STOP_WIDTH means expanding even a single tab from column 0 already lands exactly on (never short of) the threshold. +// Whether a line's own leading run of spaces and tabs reaches CommonMark's own 4-column indented-code-block threshold (spec 0.31.2, "Tabs": "in contexts where spaces help to define block structure, tabs behave as if they were replaced by spaces with a tab stop of 4 characters", counted from the start of the LINE, not the whole document). Shares MARKDOWN_TAB_STOP_WIDTH with src/scan/scan.ts's own MarkdownScanCursor so a tab's width agrees with the read side's parse of the very text this function is predicting the reparse of. The sole caller below only ever asks a >= CODE_INDENT_COLUMNS boundary question, never the exact column count beyond it, so this returns that boundary directly. Once the leading run of plain spaces ends, only the SINGLE character right after it can still change the answer: a tab there is always itself sufficient to reach the threshold (CODE_INDENT_COLUMNS <= MARKDOWN_TAB_STOP_WIDTH means expanding a tab from any column short of the threshold already lands exactly on it), and anything else stops the leading run outright -- so this needs no loop-exhausted fallback the way a step-by-step scan through every remaining character would: `line[column]` reads as `undefined` past the string's own end, which compares unequal to "\t" exactly as a real non-tab character would. function leadingIndentReachesCodeThreshold(line: string): boolean { let column = 0; - for (const char of line) { - if (char === "\t") { - return true; - } - if (char !== " ") { - return false; - } + while (column < line.length && line[column] === " ") { column += 1; - if (column >= CODE_INDENT_COLUMNS) { - return true; - } } - return false; + if (column >= CODE_INDENT_COLUMNS) { + return true; + } + return line[column] === "\t"; } // CommonMark's own list-item grammar (spec 0.31.2, section 5.2 "List items"): "A list item can begin with at most one blank line." -- the bound the leading-run exemption above is held to, applied universally regardless of which context (top-level, blockquote, list item) the heading being checked is actually about to render through, since this function cannot see that and the bound is harmless where it is not strictly required. From 570c69740197b41f88b2250f132d8627fec58816 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 20 Sep 2026 07:15:14 +0100 Subject: [PATCH 85/99] fix(markdown-codec): stop an unterminated inline tag repeating its cell's preceding text parseInlineHtml pushed the text before a recognised inline tag as its own run, then, on finding no matching close tag, pushed the whole remainder of the cell as a second run. The preceding text therefore appeared twice: `before after` produced a "before " run followed by a "before after" run, contradicting the branch's own comment that the rest of the cell stays literal. Pushing the preceding text only once the close tag is known to exist makes that literal remainder a single run spanning the whole of `rest`. --- packages/markdown-codec/src/html/html-table.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/markdown-codec/src/html/html-table.ts b/packages/markdown-codec/src/html/html-table.ts index 08aa75b5a8..2bbe74e6d5 100644 --- a/packages/markdown-codec/src/html/html-table.ts +++ b/packages/markdown-codec/src/html/html-table.ts @@ -217,16 +217,16 @@ function parseInlineHtml(text: string, style: RunStyle): ContentRun[] { pushPlainRun(runs, rest, style); return runs; } - pushPlainRun(runs, rest.slice(0, match.index), style); const tagName = match[1]!.toLowerCase(); const attrs = match[2] ?? ""; const openEnd = pos + match.index + match[0].length; const close = findBalancedClose(text, openEnd, [tagName]); if (close === undefined) { - // An unterminated recognised tag is not a shape worth guessing about -- the rest of this cell's own text stays literal, tag markup included, exactly as an unrecognised construct elsewhere in this package degrades to its own escaped/literal spelling rather than a best-effort repair. + // An unterminated recognised tag is not a shape worth guessing about: everything from here to the end of this cell stays literal, this tag's own markup included, exactly as an unrecognised construct elsewhere in this package degrades to its own escaped/literal spelling rather than a best-effort repair. The text preceding the tag is pushed AFTER this check rather than before it precisely so that literal remainder is one run spanning the whole of `rest`, not a second run repeating text a first run already carried. pushPlainRun(runs, rest, style); return runs; } + pushPlainRun(runs, rest.slice(0, match.index), style); runs.push( ...parseInlineHtml( text.slice(openEnd, close.start), From 33ca4a69d80ab23dfd09c2f93be9a349a7bac5f2 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 20 Sep 2026 07:15:26 +0100 Subject: [PATCH 86/99] refactor(markdown-codec): match the html-table reader's open and close tags in one scan findBalancedClose ran a separate open-tag and close-tag regex from the same position and compared their indices to decide which came first. One alternation matching both kinds of tag gives that ordering directly: an opening and a closing tag can never begin at the same offset, since the character after '<' is either '/' or the tag's own first letter and never both, so the index comparison was only re-deriving what a single scan already knows. The loop's `depth > 0` condition went with it, because depth only reaches zero through the decrement that returns on the spot. parseHtmlTable's `/^` with only surrounding whitespace, so anything the guard turned away that check refuses anyway. parseCellBlocks' whole-cell trim and empty early return are likewise subsumed by the per-segment trim each `
` part already gets. The two `?? ""` fallbacks on capture groups are replaced by the groups themselves: both are unconditional `([^>]*)` parts of their patterns, so a successful match always carries them. --- .../markdown-codec/src/html/html-table.ts | 50 ++++++++----------- 1 file changed, 22 insertions(+), 28 deletions(-) diff --git a/packages/markdown-codec/src/html/html-table.ts b/packages/markdown-codec/src/html/html-table.ts index 2bbe74e6d5..3ec3adf8d4 100644 --- a/packages/markdown-codec/src/html/html-table.ts +++ b/packages/markdown-codec/src/html/html-table.ts @@ -24,37 +24,36 @@ interface HtmlElement { readonly inner: string; } -// Where the element that opened at `openEnd` (just past its own opening tag's '>') closes, by depth-counting further opens/closes of any tag named in `tagNames` (case-insensitive) -- so a tag nested inside a DIFFERENT same-class element (a nested inside a , or a
that is itself inside a
belonging to THAT nested table) is skipped over correctly by depth alone, without this parser ever needing to know which element a given tag "belongs to". Returns the matching close tag's own [start, end) span, or undefined for an unterminated element -- a shape this bounded recogniser refuses to guess about rather than silently truncating. +// Where the element that opened at `openEnd` (just past its own opening tag's '>') closes, by depth-counting further opens/closes of any tag named in `tagNames` (case-insensitive) — so a tag nested inside a DIFFERENT same-class element (a nested inside a , or a
that is itself inside a
belonging to THAT nested table) is skipped over correctly by depth alone, without this parser ever needing to know which element a given tag "belongs to". One alternation matches an opening and a closing tag together, so their relative order falls out of the single scan itself: the two can never begin at the same offset (the character after '<' is either '/' or the tag's own first letter, never both), so searching for each separately would only ever be re-deriving an ordering the scan already has. Returns the matching close tag's own [start, end) span, or undefined for an unterminated element — a shape this bounded recogniser refuses to guess about rather than silently truncating. function findBalancedClose( text: string, openEnd: number, tagNames: readonly string[], ): { readonly start: number; readonly end: number } | undefined { const names = tagNames.join("|"); - const openPattern = new RegExp(`<(?:${names})(?=[\\s/>])`, "gi"); - const closePattern = new RegExp(``, "gi"); + const tagPattern = new RegExp( + `|<(?:${names})(?=[\\s/>])`, + "gi", + ); let depth = 1; let pos = openEnd; - while (depth > 0) { - openPattern.lastIndex = pos; - closePattern.lastIndex = pos; - const openMatch = openPattern.exec(text); - const closeMatch = closePattern.exec(text); - if (closeMatch === null) { + // Every way out of this scan is an explicit return: the depth only ever reaches zero through the decrement below, which returns on the spot, so there is no state in which the scan falls out of the loop with an element still open. + for (;;) { + tagPattern.lastIndex = pos; + const match = tagPattern.exec(text); + if (match === null) { return undefined; } - if (openMatch !== null && openMatch.index < closeMatch.index) { + pos = match.index + match[0].length; + if (!match[0].startsWith("... in `text`, tolerating only whitespace between and around them -- any other stray content refuses the whole parse rather than guessing which parts to keep. `tagNames` lets td/th share one pass: a table cell is one or the other, and ContentTableRow never itself distinguishes them -- row position alone marks the header row (src/lower/table.ts's own top comment, mirrored on the write side by src/emit/html-table.ts). @@ -74,7 +73,8 @@ function extractTopLevelElements( if (text.slice(pos, match.index).trim().length > 0) { return undefined; } - const attrs = match[1] ?? ""; + // The attribute capture group is `([^>]*)`, an unconditional part of the pattern that can match empty but can never fail, so a successful match always carries it as a string. + const attrs = match[1]!; const openEnd = match.index + match[0].length; const close = findBalancedClose(text, openEnd, tagNames); if (close === undefined) { @@ -218,7 +218,8 @@ function parseInlineHtml(text: string, style: RunStyle): ContentRun[] { return runs; } const tagName = match[1]!.toLowerCase(); - const attrs = match[2] ?? ""; + // Both capture groups are unconditional parts of INLINE_TAG_PATTERN, so a successful match always carries them as strings. + const attrs = match[2]!; const openEnd = pos + match.index + match[0].length; const close = findBalancedClose(text, openEnd, [tagName]); if (close === undefined) { @@ -269,16 +270,13 @@ function parseCellBlocks( inner: string, contentWidthPt: number, ): ContentBlock[] { - const trimmed = inner.trim(); - if (trimmed.length === 0) { - return []; - } - const nestedTable = parseWholeTable(trimmed, contentWidthPt); + const nestedTable = parseWholeTable(inner, contentWidthPt); if (nestedTable !== undefined) { return [nestedTable]; } const blocks: ContentBlock[] = []; - for (const segment of trimmed.split(//i)) { + // No whole-cell trim ahead of this split: parseWholeTable above already tolerates surrounding whitespace on its own, and each segment below is trimmed individually, so a cell holding nothing but whitespace yields one empty segment that the skip below drops. That reaches the same empty block list a pre-trimmed empty string would have. + for (const segment of inner.split(//i)) { const piece = segment.trim(); if (piece.length === 0) { continue; @@ -379,14 +377,10 @@ function parseWholeTable( : buildContentTable(rows, contentWidthPt); } -// The public entry point: an html_block's own literal source text (src/ast/ast.ts's MarkdownHtmlBlockNode.literal) -> a ContentTable, or undefined when it is not -- in full -- one well-formed this bounded recogniser understands, in which case src/lower/lower.ts's own lowerHtmlBlock falls through to its existing opaque-preservation path exactly as it always has. `contentWidthPt` is the same section-wide content width src/lower/table.ts's own lowerTable already threads through for a plain GFM table (this package invents no page geometry of its own beyond that one shared default -- MarkdownDiagnosticCodes.INVENTED_PAGE_GEOMETRY). +// The public entry point: an html_block's own literal source text (src/ast/ast.ts's MarkdownHtmlBlockNode.literal) -> a ContentTable, or undefined when it is not — in full — one well-formed
this bounded recogniser understands, in which case src/lower/lower.ts's own lowerHtmlBlock falls through to its existing opaque-preservation path exactly as it always has. parseWholeTable's own whole-text question (exactly one top-level
element, only surrounding whitespace tolerated around it) is already the complete acceptance test, so nothing is gained by a separate "does this even begin with Date: Sun, 20 Sep 2026 07:15:38 +0100 Subject: [PATCH 87/99] test(markdown-codec): cover the html-table reader's refusals and the writer's cell attributes The write side had no direct unit test at all, which is why most of its mutants had no coverage: emitHtmlTable was only ever reached through a whole-document round trip that pinned none of its own attribute or escaping decisions. It now has its own file covering colspan and rowspan attributes, solid and pattern backgrounds, per-cell text alignment, run formatting, embedded and unembedded images, a nested table as a cell's whole content, and each diagnostic the bounded writer reports when a cell holds something it cannot represent. The read side gains the refusal paths its bounded recogniser is defined by: an unterminated element, stray text between top-level elements, a single-quoted or unquoted attribute, a non-positive colspan, and the three-digit background colour shorthand the writer never emits but the reader accepts. --- .../src/emit/html-table.test.ts | 376 ++++++++++++++++++ .../src/html/html-table.test.ts | 167 ++++++++ 2 files changed, 543 insertions(+) create mode 100644 packages/markdown-codec/src/emit/html-table.test.ts diff --git a/packages/markdown-codec/src/emit/html-table.test.ts b/packages/markdown-codec/src/emit/html-table.test.ts new file mode 100644 index 0000000000..69a69bedb0 --- /dev/null +++ b/packages/markdown-codec/src/emit/html-table.test.ts @@ -0,0 +1,376 @@ +// Construct-by-construct tests for the write-side HTML-table fallback itself (ExaDev/documents.js#1089). src/table-html-fallback.test.ts covers the full write -> read round trip through the public surface, and src/html/html-table.test.ts does the same for the read side; this file exercises emitHtmlTable/tableNeedsHtmlFallback directly, so each attribute, each escape, and each degradation this bounded writer reports rather than represents gets its own assertion on the exact HTML produced. + +import type { + ContentTable, + ContentTableCell, + ContentTableRow, +} from "document-schema.js"; +import { describe, expect, it } from "vitest"; +import { MarkdownDiagnosticCodes } from "../diagnostics/diagnostics"; +import { MONOSPACE_FONT_FAMILY } from "../shared/style-constants"; +import type { DiagnosticCollector } from "../test-support/diagnostics"; +import { createDiagnosticCollector } from "../test-support/diagnostics"; +import { emitHtmlTable, tableNeedsHtmlFallback } from "./html-table"; + +// This writer never reads a table's own columnWidthsPt (an HTML table carries no absolute column widths it attempts to write), so every fixture here shares one arbitrary but schema-valid width. +const COLUMN_WIDTH_PT = 100; + +// The emphasis marker belongs to the shared InlineEmitContext this module's own context extends, but nothing in the HTML writer consults it: a cell's inline formatting is written as real HTML tags, never markdown punctuation. +const EMPHASIS_MARKER = "*"; + +// A real, minimal 1x1 PNG, the identical fixture src/html/html-table.test.ts's own image tests already use. +const ONE_PIXEL_PNG_BASE64 = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="; + +// A 1x1 PNG measures one CSS reference pixel each way, which document-schema.js's own point-based geometry records as 72/96 pt. +const ONE_PIXEL_PT = 72 / 96; + +// Every fixture below puts the cell under test in a BODY row behind one fixed header row, so each assertion reads the ")); + }); + + it("escapes a double quote in an attribute value, on top of that same text escaping", () => { + const cell: ContentTableCell = { + blocks: [ + { + kind: "paragraph", + runs: [{ text: "link", hyperlink: 'https://example.com/?q="x"&y' }], + }, + ], + }; + const { html } = emit(tableWithBodyCell(cell)); + expect(html).toBe( + expectedHtml( + '', + ), + ); + }); + + it("wraps a run's own styles in real HTML tags, the code span innermost and the hyperlink outermost", () => { + const cell: ContentTableCell = { + blocks: [ + { + kind: "paragraph", + runs: [ + { + text: "all", + bold: true, + italic: true, + strike: true, + fontFamily: MONOSPACE_FONT_FAMILY, + hyperlink: "https://example.com", + }, + ], + }, + ], + }; + const { html } = emit(tableWithBodyCell(cell)); + expect(html).toBe( + expectedHtml( + '', + ), + ); + }); + + it("writes a Courier New run as a code span and nothing else", () => { + const cell: ContentTableCell = { + blocks: [ + { + kind: "paragraph", + runs: [{ text: "mono", fontFamily: MONOSPACE_FONT_FAMILY }], + }, + ], + }; + const { html } = emit(tableWithBodyCell(cell)); + expect(html).toBe(expectedHtml("")); + }); + + it("embeds an image block's own bytes as a data: URI when the emitter is embedding images", () => { + const cell: ContentTableCell = { + blocks: [ + { + kind: "image", + format: "png", + base64: ONE_PIXEL_PNG_BASE64, + widthPt: ONE_PIXEL_PT, + heightPt: ONE_PIXEL_PT, + altText: "a pixel", + }, + ], + }; + const { html } = emit(tableWithBodyCell(cell)); + expect(html).toBe( + expectedHtml( + ``, + ), + ); + }); + + it("writes an carrying no src at all when the emitter is not embedding images", () => { + const cell: ContentTableCell = { + blocks: [ + { + kind: "image", + format: "png", + base64: ONE_PIXEL_PNG_BASE64, + widthPt: ONE_PIXEL_PT, + heightPt: ONE_PIXEL_PT, + altText: "a pixel", + }, + ], + }; + const { html } = emit(tableWithBodyCell(cell), false); + expect(html).toBe(expectedHtml('')); + }); + + it("writes an empty alt for an image block carrying no altText of its own", () => { + const cell: ContentTableCell = { + blocks: [ + { + kind: "image", + format: "png", + base64: ONE_PIXEL_PNG_BASE64, + widthPt: ONE_PIXEL_PT, + heightPt: ONE_PIXEL_PT, + }, + ], + }; + const { html } = emit(tableWithBodyCell(cell)); + expect(html).toBe( + expectedHtml( + ``, + ), + ); + }); + + it("writes colspan and rowspan as attributes, in that order, ahead of any style", () => { + const cell: ContentTableCell = { + ...paragraphCell("a"), + colSpan: 2, + rowSpan: 3, + background: { kind: "solid", color: { r: 0, g: 0, b: 1 } }, + }; + const { html } = emit(tableWithBodyCell(cell)); + expect(html).toBe( + expectedHtml( + '', + ), + ); + }); + + it("writes a cell's own first paragraph's alignment as a text-align style", () => { + const cell: ContentTableCell = { + blocks: [ + { kind: "paragraph", runs: [{ text: "a" }], alignment: "center" }, + ], + }; + const { html } = emit(tableWithBodyCell(cell)); + expect(html).toBe(expectedHtml('')); + }); + + it("writes no text-align for a cell whose own first block is not a paragraph at all", () => { + const cell: ContentTableCell = { + blocks: [ + { + kind: "image", + format: "png", + base64: ONE_PIXEL_PNG_BASE64, + widthPt: ONE_PIXEL_PT, + heightPt: ONE_PIXEL_PT, + altText: "a pixel", + }, + ], + }; + const { html } = emit(tableWithBodyCell(cell), false); + expect(html).toBe(expectedHtml('')); + }); + + it("joins a solid background and an alignment into one semicolon-separated style attribute", () => { + const cell: ContentTableCell = { + blocks: [ + { kind: "paragraph", runs: [{ text: "a" }], alignment: "right" }, + ], + background: { kind: "solid", color: { r: 1, g: 0, b: 0 } }, + }; + const { html } = emit(tableWithBodyCell(cell)); + expect(html).toBe( + expectedHtml( + '', + ), + ); + }); + + it("reports a pattern background fill as dropped and writes no style attribute for it", () => { + const cell: ContentTableCell = { + ...paragraphCell("x"), + background: { kind: "pattern", patternType: "percent25" }, + }; + const { html, collector } = emit(tableWithBodyCell(cell)); + expect(html).toBe(expectedHtml("")); + expect(collector.diagnostics).toEqual([ + { + code: MarkdownDiagnosticCodes.TABLE_CELL_FORMATTING_DROPPED, + severity: "info", + message: + "a table cell's own pattern background fill has no CSS equivalent this package's bounded HTML-table writer attempts (only a solid fill maps onto a plain background-color); the cell still renders as an ordinary unstyled cell", + }, + ]); + }); + + it("joins a multi-block cell's own rendered content with a literal
and reports the join", () => { + const cell: ContentTableCell = { + blocks: [ + { kind: "paragraph", runs: [{ text: "first" }] }, + { kind: "paragraph", runs: [{ text: "second" }] }, + ], + }; + const { html, collector } = emit(tableWithBodyCell(cell)); + expect(html).toBe(expectedHtml("")); + expect(collector.diagnostics).toEqual([ + { + code: MarkdownDiagnosticCodes.TABLE_CELL_MULTI_PARAGRAPH_JOINED, + severity: "info", + message: + "a table cell with 2 blocks has no multi-block equivalent in an HTML table cell either; their own rendered content is joined with a literal
line break", + }, + ]); + }); + + it("renders a cell whose entire content is one nested table as a nested
spelling rather than the header row's own . +const HEADER_TEXT = "h"; + +function paragraphCell(text: string): ContentTableCell { + return { blocks: [{ kind: "paragraph", runs: [{ text }] }] }; +} + +function tableOfRows(rows: readonly ContentTableRow[]): ContentTable { + return { kind: "table", columnWidthsPt: [COLUMN_WIDTH_PT], rows: [...rows] }; +} + +function tableWithBodyCell(cell: ContentTableCell): ContentTable { + return tableOfRows([ + { cells: [paragraphCell(HEADER_TEXT)] }, + { cells: [cell] }, + ]); +} + +// The complete HTML tableWithBodyCell's own fixture renders to, given the body row's own inner markup. It is spelled out here independently of the writer so the surrounding scaffolding (the wrapper, the header row's own ")); + expect(collector.diagnostics).toEqual([]); + }); + + it("writes a table with no rows at all as a bare empty
, the line breaks between rows) is asserted by every case rather than assumed. +function expectedHtml(bodyRowInner: string): string { + return [ + "", + ``, + `${bodyRowInner}`, + "
${HEADER_TEXT}
", + ].join("\n"); +} + +interface EmitResult { + readonly html: string; + readonly collector: DiagnosticCollector; +} + +function emit(table: ContentTable, embedImages = true): EmitResult { + const collector = createDiagnosticCollector(); + const html = emitHtmlTable(table, { + sink: collector.sink, + emphasisMarker: EMPHASIS_MARKER, + embedImages, + }); + return { html, collector }; +} + +describe("tableNeedsHtmlFallback", () => { + it("is false when every cell holds only paragraph blocks and no span or background", () => { + expect(tableNeedsHtmlFallback(tableWithBodyCell(paragraphCell("a")))).toBe( + false, + ); + }); + + it("is true for a cell mixing a representable block with one a plain GFM cell cannot hold", () => { + const cell: ContentTableCell = { + blocks: [ + { kind: "paragraph", runs: [{ text: "a" }] }, + { kind: "pageBreak" }, + ], + }; + expect(tableNeedsHtmlFallback(tableWithBodyCell(cell))).toBe(true); + }); + + it("is true for a colSpan, a rowSpan or a background on any cell anywhere", () => { + expect( + tableNeedsHtmlFallback( + tableWithBodyCell({ ...paragraphCell("a"), colSpan: 2 }), + ), + ).toBe(true); + expect( + tableNeedsHtmlFallback( + tableWithBodyCell({ ...paragraphCell("a"), rowSpan: 2 }), + ), + ).toBe(true); + expect( + tableNeedsHtmlFallback( + tableWithBodyCell({ + ...paragraphCell("a"), + background: { kind: "solid", color: { r: 1, g: 0, b: 0 } }, + }), + ), + ).toBe(true); + }); +}); + +describe("emitHtmlTable", () => { + it("writes a header row of
and every later row of , one row per line", () => { + const { html, collector } = emit(tableWithBodyCell(paragraphCell("a"))); + expect(html).toBe(expectedHtml("a
element", () => { + const { html } = emit(tableOfRows([])); + expect(html).toBe("
\n
"); + }); + + it("escapes &, < and > in a cell's own text", () => { + const { html } = emit(tableWithBodyCell(paragraphCell("a & b < c > d"))); + expect(html).toBe(expectedHtml("
a & b < c > dlinkallmonoa pixela pixelaaa pixelaxfirst
second
element", () => { + const nested: ContentTable = tableOfRows([{ cells: [paragraphCell("n")] }]); + const { html, collector } = emit(tableWithBodyCell({ blocks: [nested] })); + expect(html).toBe( + expectedHtml(""), + ); + expect(collector.diagnostics).toEqual([]); + }); + + it("drops a nested table mixed with sibling content in the same cell, keeping the sibling", () => { + const nested: ContentTable = tableOfRows([{ cells: [paragraphCell("n")] }]); + const cell: ContentTableCell = { + blocks: [nested, { kind: "paragraph", runs: [{ text: "kept" }] }], + }; + const { html, collector } = emit(tableWithBodyCell(cell)); + expect(html).toBe(expectedHtml("")); + expect(collector.codes()).toEqual([ + MarkdownDiagnosticCodes.TABLE_CELL_MULTI_PARAGRAPH_JOINED, + MarkdownDiagnosticCodes.TABLE_CELL_FORMATTING_DROPPED, + ]); + expect(collector.diagnostics[1]?.message).toBe( + "a nested table mixed with other content in the same cell has no HTML representation this package attempts (a cell's own nested table must be its entire content); it is dropped, the cell's other content still renders", + ); + }); + + it("drops a block kind the fallback has no equivalent for, naming that kind in the diagnostic", () => { + const cell: ContentTableCell = { + blocks: [ + { kind: "paragraph", runs: [{ text: "kept" }] }, + { kind: "pageBreak" }, + ], + }; + const { html, collector } = emit(tableWithBodyCell(cell)); + expect(html).toBe(expectedHtml("")); + expect(collector.codes()).toEqual([ + MarkdownDiagnosticCodes.TABLE_CELL_MULTI_PARAGRAPH_JOINED, + MarkdownDiagnosticCodes.TABLE_CELL_FORMATTING_DROPPED, + ]); + expect(collector.diagnostics[1]?.message).toBe( + 'a table cell containing a "pageBreak" block has no HTML-table equivalent this package attempts; it is dropped entirely', + ); + }); +}); diff --git a/packages/markdown-codec/src/html/html-table.test.ts b/packages/markdown-codec/src/html/html-table.test.ts index 02a84b142d..b0a1f1cf17 100644 --- a/packages/markdown-codec/src/html/html-table.test.ts +++ b/packages/markdown-codec/src/html/html-table.test.ts @@ -10,6 +10,11 @@ const CONTENT_WIDTH_PT = 451.28; const ONE_PIXEL_PNG_BASE64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="; +// That fixture measures one CSS reference pixel each way, which document-schema.js's own point-based geometry records as 72/96 pt. +const ONE_PIXEL_PT = 72 / 96; + +const ONE_PIXEL_DATA_URI = `data:image/png;base64,${ONE_PIXEL_PNG_BASE64}`; + function parse(literal: string): ContentTable | undefined { return parseHtmlTable(literal, CONTENT_WIDTH_PT); } @@ -169,4 +174,166 @@ describe("parseHtmlTable", () => { "a < b & c", ); }); + + it("decodes each entity in that handful to its own character", () => { + const table = parse( + "
\n\n
n
keptkept
\n\n
<a> "q" 's' &
", + ); + const block = table?.rows[0]?.cells[0]?.blocks[0]; + expect(block?.kind === "paragraph" && block.runs[0]?.text).toBe( + " \"q\" 's' &", + ); + }); + + it("returns undefined for stray text sitting between two elements of the same level", () => { + expect( + parse("\nstray\n\n
a
"), + ).toBeUndefined(); + expect( + parse("\nstray\n
a
"), + ).toBeUndefined(); + }); + + it("reads an attribute whose own name is spelled in any case, as real HTML allows", () => { + const table = parse('\n\n
a
'); + expect(table?.rows[0]?.cells[0]?.colSpan).toBe(2); + }); + + it("reads the style attribute itself rather than whichever attribute merely comes first", () => { + const table = parse( + '\n\n
a
', + ); + const cell = table?.rows[0]?.cells[0]; + expect(cell?.background).toEqual({ + kind: "solid", + color: { r: 1, g: 0, b: 0 }, + }); + const block = cell?.blocks[0]; + expect(block?.kind === "paragraph" && block.alignment).toBe("right"); + }); + + it("reads every text-align value the writer can produce, and refuses one it cannot", () => { + const table = parse( + '\n\n
ljs
', + ); + const cells = table?.rows[0]?.cells; + const left = cells?.[0]?.blocks[0]; + expect(left?.kind === "paragraph" && left.alignment).toBe("left"); + const justify = cells?.[1]?.blocks[0]; + expect(justify?.kind === "paragraph" && justify.alignment).toBe("justify"); + const unrecognised = cells?.[2]?.blocks[0]; + expect( + unrecognised?.kind === "paragraph" && unrecognised.alignment, + ).toBeUndefined(); + }); + + it("recognises the tag synonym alongside and ", () => { + const table = parse( + "\n\n
gone
", + ); + const block = table?.rows[0]?.cells[0]?.blocks[0]; + expect(block?.kind === "paragraph" && block.runs).toEqual([ + { text: "gone", strike: true }, + ]); + }); + + it("reads an anchor's own href rather than whichever attribute merely comes first", () => { + const table = parse( + '\n\n
link
', + ); + const block = table?.rows[0]?.cells[0]?.blocks[0]; + expect(block?.kind === "paragraph" && block.runs).toEqual([ + { text: "link", hyperlink: "https://example.com" }, + ]); + }); + + it("leaves an unterminated inline tag's own markup literal rather than guessing where it closes", () => { + const table = parse( + "\n\n
before after
", + ); + const block = table?.rows[0]?.cells[0]?.blocks[0]; + expect(block?.kind === "paragraph" && block.runs).toEqual([ + { text: "before after" }, + ]); + }); + + it("reads an tag's own src rather than whichever attribute merely comes first", () => { + const table = parse( + `\n\n
a pixel
`, + ); + const block = table?.rows[0]?.cells[0]?.blocks[0]; + expect(block?.kind).toBe("image"); + expect(block?.kind === "image" && block.altText).toBe("a pixel"); + }); + + it("reads an with no alt attribute as an image carrying empty alt text", () => { + const table = parse( + `\n\n
`, + ); + const block = table?.rows[0]?.cells[0]?.blocks[0]; + expect(block?.kind === "image" && block.altText).toBe(""); + }); + + it("splits on every
spelling, self-closing and spaced alike", () => { + const table = parse("\n\n
a
b
c
"); + expect(table?.rows[0]?.cells[0]?.blocks).toEqual([ + { kind: "paragraph", runs: [{ text: "a" }] }, + { kind: "paragraph", runs: [{ text: "b" }] }, + { kind: "paragraph", runs: [{ text: "c" }] }, + ]); + }); + + it("trims each
-separated segment's own surrounding whitespace", () => { + const table = parse("\n\n
a
b
"); + expect(table?.rows[0]?.cells[0]?.blocks).toEqual([ + { kind: "paragraph", runs: [{ text: "a" }] }, + { kind: "paragraph", runs: [{ text: "b" }] }, + ]); + }); + + it("skips an empty segment rather than minting a blank paragraph for it", () => { + const table = parse("\n\n
a

b
"); + expect(table?.rows[0]?.cells[0]?.blocks).toEqual([ + { kind: "paragraph", runs: [{ text: "a" }] }, + { kind: "paragraph", runs: [{ text: "b" }] }, + ]); + }); + + it("applies a cell's own alignment to its first block only, leaving every later block untouched", () => { + const table = parse( + '\n\n
a
b
', + ); + expect(table?.rows[0]?.cells[0]?.blocks).toEqual([ + { kind: "paragraph", runs: [{ text: "a" }], alignment: "center" }, + { kind: "paragraph", runs: [{ text: "b" }] }, + ]); + }); + + it("leaves a cell's own alignment off a first block that is not a paragraph at all", () => { + const table = parse( + `\n\n
a pixel
`, + ); + expect(table?.rows[0]?.cells[0]?.blocks[0]).toStrictEqual({ + kind: "image", + format: "png", + base64: ONE_PIXEL_PNG_BASE64, + widthPt: ONE_PIXEL_PT, + heightPt: ONE_PIXEL_PT, + altText: "a pixel", + }); + }); + + it("reads an empty cell as one empty paragraph rather than no blocks at all", () => { + const table = parse("\n\n
a
"); + expect(table?.rows[0]?.cells[0]?.blocks).toEqual([ + { kind: "paragraph", runs: [] }, + ]); + }); + + it("omits colSpan, rowSpan and background entirely rather than carrying them as undefined", () => { + const table = parse("\n\n
a
"); + expect(table?.rows[0]?.cells[0]).toStrictEqual({ + blocks: [{ kind: "paragraph", runs: [{ text: "a" }] }], + }); + }); }); From 1986219d314dc6b88543c0558b46d6c734d7fc37 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 20 Sep 2026 07:15:53 +0100 Subject: [PATCH 88/99] refactor(markdown-codec): read base64 groups and JPEG markers only at indices that exist bytesToBase64 selected each group's second and third byte through a `index + 1 < length` conditional whose false branch substituted zero. The out-of-range read it guarded would have produced undefined, and undefined shifts as zero, so the guard decided nothing observable. Each group now reads only the bytes it actually has, left-aligns them in the group's twenty-four bits, and emits one character per six bits plus a pad character for each absent byte. base64ToBytes allocated an upper bound and trimmed it with subarray at the end, so the size expression could not be observed either. The decoded length now follows exactly from the cleaned character count and the trailing padding, and the array is returned as allocated. isPng's and isJpeg's own length checks go the same way: the signature comparison that follows each already fails on an absent byte. The JPEG marker walk now terminates on the absent byte itself rather than on a separate offset bound, and delegates its start-of-image check to isJpeg instead of restating it. --- packages/markdown-codec/src/image/image.ts | 126 +++++++++++++-------- 1 file changed, 79 insertions(+), 47 deletions(-) diff --git a/packages/markdown-codec/src/image/image.ts b/packages/markdown-codec/src/image/image.ts index 96088e0f8f..237416d77a 100644 --- a/packages/markdown-codec/src/image/image.ts +++ b/packages/markdown-codec/src/image/image.ts @@ -14,63 +14,94 @@ export type ImageFormat = "png" | "jpeg"; const BASE64_TABLE = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; +// Every byte value a character code can take, so the decode table covers the whole `charCodeAt` range a single-byte character can produce without a bounds check of its own. +const CHAR_CODE_VALUES = 256; +// The table entry for a character that is not in the alphabet at all. Any value above 63 would do, since a real six-bit value can never reach it. +const BASE64_INVALID = 0xff; + const BASE64_DECODE: Uint8Array = (() => { - const map = new Uint8Array(256).fill(255); + const map = new Uint8Array(CHAR_CODE_VALUES).fill(BASE64_INVALID); for (let index = 0; index < BASE64_TABLE.length; index += 1) { map[BASE64_TABLE.charCodeAt(index)] = index; } return map; })(); -const BASE64_PADDING_CODE = 61; // '=' +const BASE64_PADDING = "="; +const BASE64_PADDING_CODE = BASE64_PADDING.charCodeAt(0); +// Base64's own quantum: three bytes carry twenty-four bits, spelled by four characters of six bits each. +const BASE64_GROUP_BYTES = 3; +const BASE64_GROUP_CHARS = 4; +const BASE64_CHAR_BITS = 6; +const BASE64_CHAR_MASK = 0x3f; +// A group of one data character carries six bits, which is not enough for even one byte, so such a group is malformed rather than merely short. +const BASE64_MIN_GROUP_CHARS = 2; +const BITS_PER_BYTE = 8; +const BYTE_MASK = 0xff; export function bytesToBase64(bytes: Uint8Array): string { - let out = ""; const { length } = bytes; - for (let index = 0; index < length; index += 3) { - const b0 = bytes[index]!; - const b1 = index + 1 < length ? bytes[index + 1]! : 0; - const b2 = index + 2 < length ? bytes[index + 2]! : 0; - out += BASE64_TABLE.charAt(b0 >> 2); - out += BASE64_TABLE.charAt(((b0 & 0x03) << 4) | (b1 >> 4)); - out += - index + 1 < length - ? BASE64_TABLE.charAt(((b1 & 0x0f) << 2) | (b2 >> 6)) - : "="; - out += index + 2 < length ? BASE64_TABLE.charAt(b2 & 0x3f) : "="; + let out = ""; + for (let index = 0; index < length; index += BASE64_GROUP_BYTES) { + const groupLength = Math.min(BASE64_GROUP_BYTES, length - index); + // Only the bytes that actually exist are read, then left-aligned within the twenty-four bits a whole group occupies, so each character below reads a fixed six-bit slice whatever the group's length and no read ever runs past the end of the input. + let group = 0; + for (let offset = 0; offset < groupLength; offset += 1) { + group = (group << BITS_PER_BYTE) | bytes[index + offset]!; + } + group <<= BITS_PER_BYTE * (BASE64_GROUP_BYTES - groupLength); + // A group of n bytes spells n + 1 characters, since n bytes carry 8n bits and each character consumes six; padding fills the rest of the four-character quantum. + for (let charIndex = 0; charIndex <= groupLength; charIndex += 1) { + const shift = BASE64_CHAR_BITS * (BASE64_GROUP_CHARS - 1 - charIndex); + out += BASE64_TABLE.charAt((group >> shift) & BASE64_CHAR_MASK); + } + out += BASE64_PADDING.repeat(BASE64_GROUP_BYTES - groupLength); } return out; } export function base64ToBytes(base64: string): Uint8Array { + // Characters outside the alphabet are not data: the line breaks a wrapped data: URI payload carries, and any other stray whitespace, are dropped before anything is decoded. const clean = base64.replace(/[^A-Za-z0-9+/=]/g, ""); - const { length } = clean; - const out = new Uint8Array(Math.floor((length * 3) / 4)); + // Padding carries no bits, so the data is whatever precedes the trailing run of it. `charCodeAt` before the start of a string returns NaN, which is not the padding code, so this stops at the start of the string without a bounds guard of its own. + let dataLength = clean.length; + while (clean.charCodeAt(dataLength - 1) === BASE64_PADDING_CODE) { + dataLength -= 1; + } + // Four data characters carry three whole bytes, and each leftover character a further six bits, so this is the exact decoded length: the array allocated here is the array returned, with no trailing trim that could hide a wrong size. + const out = new Uint8Array( + Math.floor((dataLength * BASE64_GROUP_BYTES) / BASE64_GROUP_CHARS), + ); let position = 0; - for (let index = 0; index < length; index += 4) { - const c0 = BASE64_DECODE[clean.charCodeAt(index)]!; - const c1 = BASE64_DECODE[clean.charCodeAt(index + 1)]!; - const code2 = clean.charCodeAt(index + 2); - const code3 = clean.charCodeAt(index + 3); - if (c0 === 255 || c1 === 255) { + for (let index = 0; index < dataLength; index += BASE64_GROUP_CHARS) { + const groupChars = Math.min(BASE64_GROUP_CHARS, dataLength - index); + if (groupChars < BASE64_MIN_GROUP_CHARS) { throw new Error("invalid base64 input"); } - out[position] = (c0 << 2) | (c1 >> 4); - position += 1; - if (code2 !== BASE64_PADDING_CODE) { - const d2 = BASE64_DECODE[code2]!; - out[position] = ((c1 & 0x0f) << 4) | (d2 >> 2); - position += 1; - if (code3 !== BASE64_PADDING_CODE) { - const d3 = BASE64_DECODE[code3]!; - out[position] = ((d2 & 0x03) << 6) | d3; - position += 1; + let group = 0; + for (let offset = 0; offset < groupChars; offset += 1) { + const code = BASE64_DECODE[clean.charCodeAt(index + offset)]!; + // The only alphabet-surviving character that decodes to nothing is padding, so this rejects a '=' anywhere other than the trailing run already stripped above. + if (code === BASE64_INVALID) { + throw new Error("invalid base64 input"); } + group = (group << BASE64_CHAR_BITS) | code; + } + // n characters carry 6n bits, of which only whole bytes are kept: the leftover low bits belong to a byte this group does not finish. + const groupBytes = Math.floor( + (groupChars * BASE64_CHAR_BITS) / BITS_PER_BYTE, + ); + group >>= groupChars * BASE64_CHAR_BITS - groupBytes * BITS_PER_BYTE; + for (let byteIndex = groupBytes - 1; byteIndex >= 0; byteIndex -= 1) { + out[position + byteIndex] = group & BYTE_MASK; + group >>= BITS_PER_BYTE; } + position += groupBytes; } - return out.subarray(0, position); + return out; } +// A big-endian 16-bit field. A byte the input does not reach reads as undefined at runtime, which both the shift and the or coerce to zero, so a field a truncated file cannot hold reads as zero rather than throwing, which is exactly what readJpegDimensions relies on for a segment's own length field. function readUint16BE(bytes: Uint8Array, offset: number): number { return ((bytes[offset]! << 8) | bytes[offset + 1]!) & 0xffff; } @@ -89,13 +120,16 @@ const PNG_SIGNATURE = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]; // signature(8) + IHDR chunk length(4) + 'IHDR'(4) + width(4) + height(4) -- the minimum a PNG needs before its own dimensions are readable. const PNG_HEADER_BYTES = 24; +// A short input needs no length check of its own: a signature byte the input does not reach reads as undefined, which equals no byte value. function isPng(bytes: Uint8Array): boolean { - if (bytes.length < PNG_SIGNATURE.length) { - return false; - } return PNG_SIGNATURE.every((byte, index) => bytes[index] === byte); } +// As in isPng, a byte the input does not reach reads as undefined and equals nothing, so the length needs no check of its own. +function isJpeg(bytes: Uint8Array): boolean { + return bytes[0] === 0xff && bytes[1] === 0xd8; +} + // IHDR is always the very first chunk after the signature (PNG spec section 5.6, "IHDR must appear first") -- no chunk-walking is needed at all. function readPngDimensions(bytes: Uint8Array): ImageDimensions | undefined { if (bytes.length < PNG_HEADER_BYTES) { @@ -135,13 +169,19 @@ function hasNoLengthField(marker: number): boolean { } // Walks JPEG marker segments from the SOI (0xFFD8) until a Start-Of-Frame marker's own segment: length(2, BE) + precision(1) + height(2, BE) + width(2, BE) -- height before width, unlike PNG. Every other marker segment is skipped by its own declared length (which includes the 2 length bytes themselves). +// +// A truncated file needs no length checks along the way, only the one end-of-input check the walk already makes each pass. A length field the input is too short to hold reads its missing bytes as zero (see readUint16BE). That either leaves the offset on the length field itself, whose own leading byte must then have been zero, so the next pass walks past it a byte at a time; or it pushes the offset straight past the end, which is also what a declared length that overshoots does. Either way the walk arrives at an offset the input does not reach, which is where a truncated segment is meant to end up. function readJpegDimensions(bytes: Uint8Array): ImageDimensions | undefined { - if (bytes.length < 4 || bytes[0] !== 0xff || bytes[1] !== 0xd8) { + if (!isJpeg(bytes)) { return undefined; } let offset = 2; - while (offset < bytes.length) { - if (bytes[offset] !== 0xff) { + for (;;) { + const byte = bytes[offset]; + if (byte === undefined) { + return undefined; + } + if (byte !== 0xff) { offset += 1; continue; } @@ -158,9 +198,6 @@ function readJpegDimensions(bytes: Uint8Array): ImageDimensions | undefined { if (hasNoLengthField(marker)) { continue; } - if (offset + 2 > bytes.length) { - return undefined; - } const length = readUint16BE(bytes, offset); if (isStartOfFrameMarker(marker)) { if (offset + 7 > bytes.length) { @@ -176,11 +213,6 @@ function readJpegDimensions(bytes: Uint8Array): ImageDimensions | undefined { } offset += length; } - return undefined; -} - -function isJpeg(bytes: Uint8Array): boolean { - return bytes.length >= 2 && bytes[0] === 0xff && bytes[1] === 0xd8; } // The same signature check readImageDimensions already makes internally to choose which reader to run, exposed so a caller (src/lower/image.ts) can pick ContentImageBlock's own `format` field from the identical bytes without a second, potentially-divergent sniff of its own. Returns undefined for anything that is neither a PNG nor a JPEG -- ContentImageBlockSchema's own `format` field has no third member to fall back to. From a7611b6c067abca6a24d6892fbb8df7f4603a411 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 20 Sep 2026 07:15:55 +0100 Subject: [PATCH 89/99] test(markdown-codec): cover base64 padding boundaries and the JPEG marker walk's refusals Pins the encoder's output for every group-length remainder and the decoder's returned length for each padding form, so a wrong group size or a wrong allocation is visible in the result rather than hidden behind a trim. Adds the marker-walk paths that had no test of their own: a segment whose declared length runs past the input, a frame header truncated before its width and height, a marker in the start-of-frame numeric range that carries no frame, and a signature that matches the PNG prefix without matching all of it. --- .../markdown-codec/src/image/image.test.ts | 333 ++++++++++++++++++ 1 file changed, 333 insertions(+) diff --git a/packages/markdown-codec/src/image/image.test.ts b/packages/markdown-codec/src/image/image.test.ts index 7edbf90235..7eaa97880d 100644 --- a/packages/markdown-codec/src/image/image.test.ts +++ b/packages/markdown-codec/src/image/image.test.ts @@ -363,6 +363,292 @@ describe("readImageDimensions", () => { const jpeg = bytes(0xff, 0xd8, 0xff, 0xc0, 0x00, 0x0b, 0x08, 0x00); expect(readImageDimensions(jpeg)).toBeUndefined(); }); + + it("reads a frame header whose payload ends exactly at the last byte of input", () => { + // Height and width occupy the final four bytes, so the segment is complete with nothing to spare. + const jpeg = bytes( + 0xff, + 0xd8, + 0xff, + 0xc0, + 0x00, + 0x0b, + 0x08, + 0x00, + 0x14, + 0x00, + 0x15, + ); + expect(readImageDimensions(jpeg)).toEqual({ widthPx: 21, heightPx: 20 }); + }); + + it("returns undefined for bytes carrying a frame header but no SOI marker at all", () => { + const notJpeg = bytes( + 0x00, + 0x00, + 0xff, + 0xc0, + 0x00, + 0x0b, + 0x08, + 0x00, + 0x07, + 0x00, + 0x08, + 0x01, + 0x01, + 0x11, + 0x00, + ); + expect(readImageDimensions(notJpeg)).toBeUndefined(); + }); + + it("returns undefined when the 0xFF lead byte is present but the byte completing SOI is not 0xD8", () => { + const notJpeg = bytes( + 0xff, + 0x00, + 0xff, + 0xc0, + 0x00, + 0x0b, + 0x08, + 0x00, + 0x09, + 0x00, + 0x0a, + 0x01, + 0x01, + 0x11, + 0x00, + ); + expect(readImageDimensions(notJpeg)).toBeUndefined(); + }); + + it("returns undefined when the 0xD8 byte is present but the 0xFF lead of SOI is not", () => { + const notJpeg = bytes( + 0x00, + 0xd8, + 0xff, + 0xc0, + 0x00, + 0x0b, + 0x08, + 0x00, + 0x0b, + 0x00, + 0x0c, + 0x01, + 0x01, + 0x11, + 0x00, + ); + expect(readImageDimensions(notJpeg)).toBeUndefined(); + }); + + it("skips a marker below the Start-Of-Frame range by its own declared length", () => { + // 0xBF is reserved and carries a length field, so its payload is skipped whole rather than read as a frame header. + const jpeg = bytes( + 0xff, + 0xd8, + 0xff, + 0xbf, + 0x00, + 0x0b, + 0x08, + 0x00, + 0x01, + 0x00, + 0x02, + 0x01, + 0x01, + 0x11, + 0x00, + 0xff, + 0xc0, + 0x00, + 0x0b, + 0x08, + 0x00, + 0x03, + 0x00, + 0x04, + 0x01, + 0x01, + 0x11, + 0x00, + 0xff, + 0xd9, + ); + expect(readImageDimensions(jpeg)).toEqual({ widthPx: 4, heightPx: 3 }); + }); + + it("reads a SOF15 (0xCF) frame header, the last marker of the Start-Of-Frame range", () => { + const jpeg = bytes( + 0xff, + 0xd8, + 0xff, + 0xcf, + 0x00, + 0x0b, + 0x08, + 0x00, + 0x05, + 0x00, + 0x06, + 0x01, + 0x01, + 0x11, + 0x00, + 0xff, + 0xd9, + ); + expect(readImageDimensions(jpeg)).toEqual({ widthPx: 6, heightPx: 5 }); + }); + + it("skips a second SOI marker, which carries no length field of its own", () => { + const jpeg = bytes( + 0xff, + 0xd8, + 0xff, + 0xd8, + 0xff, + 0xc0, + 0x00, + 0x0b, + 0x08, + 0x00, + 0x07, + 0x00, + 0x08, + 0x01, + 0x01, + 0x11, + 0x00, + 0xff, + 0xd9, + ); + expect(readImageDimensions(jpeg)).toEqual({ widthPx: 8, heightPx: 7 }); + }); + + it("skips an EOI marker appearing before the frame header, which carries no length field of its own", () => { + const jpeg = bytes( + 0xff, + 0xd8, + 0xff, + 0xd9, + 0xff, + 0xc0, + 0x00, + 0x0b, + 0x08, + 0x00, + 0x09, + 0x00, + 0x0a, + 0x01, + 0x01, + 0x11, + 0x00, + 0xff, + 0xd9, + ); + expect(readImageDimensions(jpeg)).toEqual({ widthPx: 10, heightPx: 9 }); + }); + + it("skips a DQT segment by its declared length rather than scanning through its payload", () => { + // The quantisation table's payload deliberately spells out a frame header, which must not be mistaken for the real one that follows the segment. + const jpeg = bytes( + 0xff, + 0xd8, + 0xff, + 0xdb, + 0x00, + 0x0b, + 0xff, + 0xc0, + 0x00, + 0x0b, + 0x08, + 0x00, + 0x01, + 0x00, + 0x02, + 0xff, + 0xc0, + 0x00, + 0x0b, + 0x08, + 0x00, + 0x03, + 0x00, + 0x04, + 0x01, + 0x01, + 0x11, + 0x00, + 0xff, + 0xd9, + ); + expect(readImageDimensions(jpeg)).toEqual({ widthPx: 4, heightPx: 3 }); + }); + + it("resumes scanning from the next 0xFF when a segment's declared length lands between markers", () => { + // The APP0 segment declares one payload byte but two more follow it, so the walk lands on a byte that begins no marker. + const jpeg = bytes( + 0xff, + 0xd8, + 0xff, + 0xe0, + 0x00, + 0x03, + 0x41, + 0x42, + 0x43, + 0xff, + 0xc0, + 0x00, + 0x0b, + 0x08, + 0x00, + 0x0b, + 0x00, + 0x0c, + 0x01, + 0x01, + 0x11, + 0x00, + 0xff, + 0xd9, + ); + expect(readImageDimensions(jpeg)).toEqual({ widthPx: 12, heightPx: 11 }); + }); + + it("returns undefined for a frame-header-shaped run inside the scan data after Start Of Scan", () => { + const jpeg = bytes( + 0xff, + 0xd8, + 0xff, + 0xda, + 0x00, + 0x02, + 0xff, + 0xc0, + 0x00, + 0x0b, + 0x08, + 0x00, + 0x16, + 0x00, + 0x17, + 0x01, + 0x01, + 0x11, + 0x00, + 0xff, + 0xd9, + ); + expect(readImageDimensions(jpeg)).toBeUndefined(); + }); }); describe("detectImageFormat", () => { @@ -383,6 +669,24 @@ describe("detectImageFormat", () => { it("returns undefined for an empty input", () => { expect(detectImageFormat(bytes())).toBeUndefined(); }); + + it("returns undefined for bytes sharing only the leading byte of the PNG signature", () => { + expect( + detectImageFormat(bytes(0x89, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00)), + ).toBeUndefined(); + }); + + it("detects a two-byte input that is exactly the SOI marker", () => { + expect(detectImageFormat(bytes(0xff, 0xd8))).toBe("jpeg"); + }); + + it("returns undefined for the 0xD8 of SOI without its 0xFF lead byte", () => { + expect(detectImageFormat(bytes(0x00, 0xd8))).toBeUndefined(); + }); + + it("returns undefined for the 0xFF lead byte of SOI without the 0xD8 that completes it", () => { + expect(detectImageFormat(bytes(0xff, 0x00))).toBeUndefined(); + }); }); describe("bytesToBase64 / base64ToBytes", () => { @@ -420,4 +724,33 @@ describe("bytesToBase64 / base64ToBytes", () => { it("throws for an invalid base64 character in a would-be data position", () => { expect(() => base64ToBytes("T!==")).toThrow("invalid base64 input"); }); + + it("throws for padding in the first character position of a group, where no padding can belong", () => { + expect(() => base64ToBytes("=A==")).toThrow("invalid base64 input"); + }); + + it("throws for a group left with a single data character, which carries too few bits for even one byte", () => { + expect(() => base64ToBytes("A===")).toThrow("invalid base64 input"); + }); + + it.each([0, 1, 2, 3, 4, 5, 6, 7, 8])( + "round-trips a %i-byte prefix of a sample sequence back to exactly those bytes", + (byteCount) => { + const sample = bytes( + 0x00, + 0xff, + 0x10, + 0x80, + 0x7f, + 0x01, + 0x02, + 0x03, + 0xab, + ); + const original = sample.subarray(0, byteCount); + expect(Array.from(base64ToBytes(bytesToBase64(original)))).toEqual( + Array.from(original), + ); + }, + ); }); From 3e7de79955942f9e27860cd69a17b5bcb400fae2 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 20 Sep 2026 07:16:11 +0100 Subject: [PATCH 90/99] refactor(markdown-codec): end gfm-autolink's scans on the empty string charAt returns past the text Every forward and backward scan here paired a length or zero bound with a character-class test that already declines the empty string charAt returns outside the text, so the bound could never decide an outcome on its own. Each now stops on that empty string directly: the raw-candidate scan, the email local-part and domain scans, the trailing-punctuation trimming, and the text-node walk. isWhitespace no longer claims the empty string as whitespace, since neither remaining caller passes it one. matchPrefixed tests that something survives after the prefix rather than comparing the trimmed candidate's length against it, which is the same question asked of the value the next step actually uses. The `domain.endsWith("_")` check is subsumed by isValidDomain, which rejects an underscore anywhere in either trailing segment. expandTextNode's match counter is replaced by whether the anchor is still the node itself, and its `found.start < cursor` guard is removed: a prefixed match begins at the cursor's own index, and an email's backward scan cannot reach into an earlier match, because every character that can follow one is either whitespace or '<' (neither a local-part character) or the trimmed punctuation run that precedes them, and an earlier email's own '@' both stops the scan and fails the start-boundary check that follows. --- .../markdown-codec/src/inline/gfm-autolink.ts | 58 ++++++++++--------- 1 file changed, 32 insertions(+), 26 deletions(-) diff --git a/packages/markdown-codec/src/inline/gfm-autolink.ts b/packages/markdown-codec/src/inline/gfm-autolink.ts index 309477e167..a79322f58c 100644 --- a/packages/markdown-codec/src/inline/gfm-autolink.ts +++ b/packages/markdown-codec/src/inline/gfm-autolink.ts @@ -52,9 +52,9 @@ const OPAQUE_KINDS: ReadonlySet = new Set([ "autolink", ]); +// GFM's own whitespace set for this extension. Every caller passes a character the text actually holds, so the empty string `charAt` returns past either end of a string is deliberately NOT whitespace here: scanRawCandidate treats it as its own end-of-text case, and isValidStartBoundary must not accept it as a preceding character. function isWhitespace(char: string): boolean { return ( - char === "" || char === " " || char === "\t" || char === "\n" || @@ -85,24 +85,24 @@ function isValidDomain(domain: string): boolean { } // GFM's own trailing-trimming rules, applied in the order the specification states them: strip the named trailing punctuation; then strip an unmatched closing paren (a link ending in `)` keeps it only when the parens inside balance, so `(see http://example.com/a(b))` links `http://example.com/a(b)`); then strip a trailing `;` that is really the tail of a character reference such as `&`. Each strip can expose another, so the three run to a fixed point. +// +// None of the three needs a guard against `end` reaching zero: `charAt` before the start of a string returns the empty string, which is in neither punctuation set, and ENTITY_TAIL_PATTERN is anchored on a trailing `;` so it already declines every candidate that does not end in one. function trimTrailingPunctuation(candidate: string): string { let end = candidate.length; for (;;) { const before = end; - while (end > 0 && TRAILING_PUNCTUATION.has(candidate.charAt(end - 1))) { + while (TRAILING_PUNCTUATION.has(candidate.charAt(end - 1))) { end -= 1; } - if (end > 0 && candidate.charAt(end - 1) === ")") { + if (candidate.charAt(end - 1) === ")") { const slice = candidate.slice(0, end); if (slice.split(")").length > slice.split("(").length) { end -= 1; } } - if (end > 0 && candidate.charAt(end - 1) === ";") { - const entityTail = ENTITY_TAIL_PATTERN.exec(candidate.slice(0, end)); - if (entityTail !== null) { - end -= entityTail[0].length; - } + const entityTail = ENTITY_TAIL_PATTERN.exec(candidate.slice(0, end)); + if (entityTail !== null) { + end -= entityTail[0].length; } if (end === before) { return candidate.slice(0, end); @@ -118,17 +118,16 @@ interface AutolinkMatch { readonly start: number; } -// The run of characters an extended autolink can consist of at all, before trailing-punctuation trimming: everything up to whitespace or `<`. +// The run of characters an extended autolink can consist of at all, before trailing-punctuation trimming: everything up to whitespace or `<`. The end of the text is the third terminator, recognised by the empty string `charAt` returns there, which saves a separate length bound. function scanRawCandidate(text: string, start: number): string { let end = start; - while ( - end < text.length && - !isWhitespace(text.charAt(end)) && - text.charAt(end) !== "<" - ) { + for (;;) { + const char = text.charAt(end); + if (char === "" || isWhitespace(char) || char === "<") { + return text.slice(start, end); + } end += 1; } - return text.slice(start, end); } function matchPrefixed( @@ -139,12 +138,14 @@ function matchPrefixed( requireValidDomain: boolean, ): AutolinkMatch | undefined { const trimmed = trimTrailingPunctuation(scanRawCandidate(text, index)); - if (trimmed.length <= prefixLength) { + // Trimming can eat back into the prefix itself, since a bare `mailto:` loses its `:` as trailing punctuation, so what matters is that something survives after the prefix rather than that the candidate was ever long enough. + const afterPrefix = trimmed.slice(prefixLength); + if (afterPrefix === "") { return undefined; } if (requireValidDomain) { - const afterPrefix = trimmed.slice(prefixLength); - const domain = afterPrefix.split(/[/?#]/)[0] ?? ""; + // `split` always yields at least one element, so the first is never absent. + const domain = afterPrefix.split(/[/?#]/)[0]!; if (!isValidDomain(domain)) { return undefined; } @@ -156,9 +157,10 @@ function matchPrefixed( }; } +// The end of the text needs no bound of its own: `charAt` returns the empty string there, which matches no character class. function scanEmailDomain(text: string, start: number): string { let end = start; - while (end < text.length && EMAIL_DOMAIN_PATTERN.test(text.charAt(end))) { + while (EMAIL_DOMAIN_PATTERN.test(text.charAt(end))) { end += 1; } return text.slice(start, end); @@ -170,7 +172,8 @@ function matchEmailAt( atIndex: number, ): AutolinkMatch | undefined { let start = atIndex; - while (start > 0 && EMAIL_LOCAL_PART_PATTERN.test(text.charAt(start - 1))) { + // As in scanEmailDomain, the empty string `charAt` returns before the start of the text matches no character class, so the scan stops there without a bound of its own. + while (EMAIL_LOCAL_PART_PATTERN.test(text.charAt(start - 1))) { start -= 1; } if (start === atIndex || !isValidStartBoundary(text, start)) { @@ -182,8 +185,10 @@ function matchEmailAt( return undefined; } // An email's own domain is scanned against its own character set rather than through the shared trailing-punctuation trimming, because the two disagree on `_`: that trimming strips a trailing underscore (GFM lists `_` as trailing punctuation for a url autolink), whereas GFM says of an email address that "the last character must not be one of `-` or `_`" -- which invalidates the whole address rather than shortening it. Only a trailing `.` is dropped, per "only `.` may occur at the end of the email address, in which case it will not be considered part of the address". + // + // Of that pair only the trailing `-` needs a check here: a domain ending in `_` has that underscore in its own last segment, which isValidDomain already rejects for every domain, email or not. const domain = scanEmailDomain(text, atIndex + 1).replace(/\.+$/, ""); - if (domain.endsWith("-") || domain.endsWith("_") || !isValidDomain(domain)) { + if (domain.endsWith("-") || !isValidDomain(domain)) { return undefined; } const address = `${local}@${domain}`; @@ -228,12 +233,13 @@ function expandTextNode(node: InlineNode): void { const text = node.literal; let cursor = 0; let anchor: InlineNode = node; - let matches = 0; let index = 0; - while (index < text.length) { + // The end of the text is recognised by the empty string `charAt` returns there, the same way the scans above recognise it, since no autolink can begin with it. + while (text.charAt(index) !== "") { const found = findAutolinkAt(text, index); - if (found === undefined || found.start < cursor) { + // A match never begins before the cursor, so none is needed in this check. A prefixed match begins at `index`, which the loop only ever moves forward. An email's local part is scanned backwards, but it cannot reach back into an earlier match's text: an earlier match ends either at whitespace or `<` (neither of which is a local-part character, so the scan stops there) or at the trailing punctuation trimmed off it, whose run is itself followed by one of those; and an earlier email's own `@` is not a local-part character either, so the scan stops on that instead. + if (found === undefined) { index += 1; continue; } @@ -248,11 +254,11 @@ function expandTextNode(node: InlineNode): void { anchor.insertAfter(link); anchor = link; cursor = found.start + found.text.length; - matches += 1; index = cursor; } - if (matches === 0) { + // The anchor still being the node itself means nothing was inserted after it, so this text node holds no autolink and is left exactly as it is rather than being replaced by an equal one. + if (anchor === node) { return; } if (cursor < text.length) { From 1518de0fbbfb75c2ece5bb72338f7b1b66e08043 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 20 Sep 2026 07:16:13 +0100 Subject: [PATCH 91/99] test(markdown-codec): cover gfm-autolink's start boundaries, trimming and email rules The extension had no test file of its own: every autolink case reached it only through the GFM conformance corpus, which pins whole-document HTML and so covers none of the recogniser's own decisions individually. Adds direct tests for each preceding character the specification admits, the domain rules for segment count and trailing-segment underscores, the three trailing strips and their fixed point, the scheme and protocol prefixes including case-insensitivity, the local-part and domain constraints on a bare email address, and the node kinds the walk refuses to descend into. --- .../src/inline/gfm-autolink.test.ts | 227 ++++++++++++++++++ 1 file changed, 227 insertions(+) create mode 100644 packages/markdown-codec/src/inline/gfm-autolink.test.ts diff --git a/packages/markdown-codec/src/inline/gfm-autolink.test.ts b/packages/markdown-codec/src/inline/gfm-autolink.test.ts new file mode 100644 index 0000000000..a5b4f82da4 --- /dev/null +++ b/packages/markdown-codec/src/inline/gfm-autolink.test.ts @@ -0,0 +1,227 @@ +import { describe, expect, it } from "vitest"; +import { applyGfmAutolinks } from "./gfm-autolink"; +import { InlineNode, createTextNode } from "./node"; + +function childrenOf(node: InlineNode): InlineNode[] { + const out: InlineNode[] = []; + for (let child = node.firstChild; child !== undefined; child = child.next) { + out.push(child); + } + return out; +} + +// A flat rendering that keeps every node visible, an empty text node included, so a spurious extra node shows up in the comparison rather than being absorbed into the text around it. +function describeNode(node: InlineNode): string { + if (node.kind === "text") { + return `text(${node.literal})`; + } + const inner = childrenOf(node).map(describeNode).join(""); + if (node.kind === "link") { + return `link[${node.destination}](${inner})`; + } + return `${node.kind}(${inner})`; +} + +function describeChildren(root: InlineNode): string { + return childrenOf(root).map(describeNode).join(""); +} + +// The synthetic container a parsed inline run lives in, holding the single text node the extension walks. +function expand(literal: string): string { + const root = new InlineNode("container"); + root.appendChild(createTextNode(literal)); + applyGfmAutolinks(root); + return describeChildren(root); +} + +describe("applyGfmAutolinks www and scheme prefixes", () => { + it("links a bare www. address, prepending http:// to the destination only", () => { + expect(expand("www.example.com")).toBe( + "link[http://www.example.com](text(www.example.com))", + ); + }); + + it.each([ + "http://b.example.com", + "https://b.example.com", + "ftp://b.example.com", + ])( + "links %s by its own scheme, leaving the destination as written", + (url) => { + expect(expand(url)).toBe(`link[${url}](text(${url}))`); + }, + ); + + it("matches a prefix regardless of its case, keeping the source spelling in both text and destination", () => { + expect(expand("WWW.example.com")).toBe( + "link[http://WWW.example.com](text(WWW.example.com))", + ); + }); + + it("expands every autolink in one text node, keeping the text between them", () => { + expect(expand("see www.a.com and http://b.com now")).toBe( + "text(see )link[http://www.a.com](text(www.a.com))text( and )link[http://b.com](text(http://b.com))text( now)", + ); + }); +}); + +describe("applyGfmAutolinks start boundaries", () => { + it("does not link an address that follows an ordinary letter", () => { + expect(expand("xwww.example.com")).toBe("text(xwww.example.com)"); + }); + + it.each([ + ["a space", " "], + ["a tab", "\t"], + ["a line feed", "\n"], + ["a carriage return", "\r"], + ["a form feed", "\f"], + ])("starts an autolink after %s", (_name, whitespace) => { + expect(expand(`a${whitespace}www.example.com`)).toBe( + `text(a${whitespace})link[http://www.example.com](text(www.example.com))`, + ); + }); +}); + +describe("applyGfmAutolinks candidate extent", () => { + it("ends the candidate at the first whitespace, leaving the rest as text", () => { + expect(expand("www.example.com rest")).toBe( + "link[http://www.example.com](text(www.example.com))text( rest)", + ); + }); + + it("ends the candidate at a `<`, leaving the rest as text", () => { + expect(expand("www.example.com")).toBe( + "link[http://www.example.com](text(www.example.com))text()", + ); + }); + + it("drops trailing punctuation from the link and leaves it as text", () => { + expect(expand("www.example.com.")).toBe( + "link[http://www.example.com](text(www.example.com))text(.)", + ); + }); + + it("drops a character reference's own tail once the punctuation after it has gone", () => { + // The trailing '.' goes first, which is what exposes the '&' the entity rule then strips; trimming only the untrimmed candidate would leave the reference in the link. + expect(expand("http://example.com/?a=1&. rest")).toBe( + "link[http://example.com/?a=1](text(http://example.com/?a=1))text(&. rest)", + ); + }); +}); + +describe("applyGfmAutolinks domain validity", () => { + it("rejects a domain whose segment holds a character outside the allowed set", () => { + expect(expand("www.ex+ample.com")).toBe("text(www.ex+ample.com)"); + }); + + it("rejects a domain with an underscore in one of its last two segments", () => { + expect(expand("www.exa_mple.com")).toBe("text(www.exa_mple.com)"); + }); + + it("rejects a domain with an underscore in the second of its last two segments", () => { + expect(expand("www.a.b_c.d")).toBe("text(www.a.b_c.d)"); + }); + + it("accepts an underscore in a segment before the last two", () => { + expect(expand("www.foo_bar.example.com")).toBe( + "link[http://www.foo_bar.example.com](text(www.foo_bar.example.com))", + ); + }); + + it("rejects a scheme-prefixed URL whose domain has no period at all", () => { + expect(expand("http://example")).toBe("text(http://example)"); + }); +}); + +describe("applyGfmAutolinks protocol prefixes", () => { + it("links a mailto: address without requiring the part after the prefix to be a domain", () => { + expect(expand("mailto:foo@example.com")).toBe( + "link[mailto:foo@example.com](text(mailto:foo@example.com))", + ); + }); + + it("links an xmpp: address the same way", () => { + expect(expand("xmpp:foo@example.com")).toBe( + "link[xmpp:foo@example.com](text(xmpp:foo@example.com))", + ); + }); + + it("does not link a bare prefix whose only trailing character is trimmed away", () => { + expect(expand("mailto:")).toBe("text(mailto:)"); + }); +}); + +describe("applyGfmAutolinks bare email addresses", () => { + it("links a bare address, resolving it to a mailto: destination", () => { + expect(expand("foo@example.com")).toBe( + "link[mailto:foo@example.com](text(foo@example.com))", + ); + }); + + it("does not link an address with no local part at all", () => { + expect(expand("@example.com")).toBe("text(@example.com)"); + }); + + it("does not link an address whose local part follows an invalid preceding character", () => { + expect(expand("a=foo@example.com")).toBe("text(a=foo@example.com)"); + }); + + it("does not link an address whose local part starts with a period", () => { + expect(expand(".foo@example.com")).toBe("text(.foo@example.com)"); + }); + + it("does not link an address whose local part ends with a period", () => { + expect(expand("foo.@example.com")).toBe("text(foo.@example.com)"); + }); + + it("drops every trailing period from the domain, not just the last one", () => { + expect(expand("foo@example.com.. rest")).toBe( + "link[mailto:foo@example.com](text(foo@example.com))text(.. rest)", + ); + }); + + it("does not link a domain ending in a hyphen", () => { + expect(expand("foo@example.com-")).toBe("text(foo@example.com-)"); + }); + + it("does not take a second @ as a further address when the first one ends the local part it would need", () => { + expect(expand("a@b.com@c.com")).toBe( + "link[mailto:a@b.com](text(a@b.com))text(@c.com)", + ); + }); +}); + +describe("applyGfmAutolinks tree walking", () => { + it("leaves a text node holding no autolink in place rather than replacing it", () => { + const root = new InlineNode("container"); + const text = createTextNode("plain text with no link"); + root.appendChild(text); + applyGfmAutolinks(root); + expect(root.firstChild).toBe(text); + expect(text.next).toBeUndefined(); + }); + + it("expands an autolink inside a nested node that is not opaque", () => { + const root = new InlineNode("container"); + const emphasis = new InlineNode("emphasis"); + emphasis.appendChild(createTextNode("www.example.com")); + root.appendChild(emphasis); + applyGfmAutolinks(root); + expect(describeChildren(root)).toBe( + "emphasis(link[http://www.example.com](text(www.example.com)))", + ); + }); + + it("does not descend into an existing link", () => { + const root = new InlineNode("container"); + const link = new InlineNode("link"); + link.destination = "http://outer.example"; + link.appendChild(createTextNode("www.inner.example.com")); + root.appendChild(link); + applyGfmAutolinks(root); + expect(describeChildren(root)).toBe( + "link[http://outer.example](text(www.inner.example.com))", + ); + }); +}); From cd77f13c2002f2d44b4f092292bcb7fbda4dd536 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 20 Sep 2026 07:16:30 +0100 Subject: [PATCH 92/99] refactor(markdown-codec): drop the markdown writers' length bounds and seeds that decide nothing Each loop bound removed here sat alongside a test that already declines the value an out-of-range read produces: escapeMarkdownText, renderNestedStyles and emitRuns stop on the absent element itself, isIntrawordRisk and hasMarkerConflict rely on charAt returning the empty string, which matches no word character and starts no delimiter, and leadingIndentReachesCodeThreshold compares an absent character against a space rather than bounding the column first. The seeds go the same way. unsafeSetextBreakReason carried a separate sawContentLine flag beside the preceding line it always moved with, so the line alone now says whether one was seen; renderListRegion seeded its text with an empty string the first segment always overwrote, so it starts from the marker it is going to use; and isMaterialisedDivision compares the indent against the quote threshold directly, since an absent indent already compares false. The four ATX heading returns now share one renderAtxHeading, which makes the line-ending collapse unconditional. Collapsing is a no-op on text holding no break, so the branch that chose between them could not be observed. pickSplitKey iterates the whole candidate set rather than dropping its first element, since a count is never strictly below its own measurement. --- packages/markdown-codec/src/emit/emit.ts | 68 ++++++++++++---------- packages/markdown-codec/src/emit/inline.ts | 47 +++++++++------ 2 files changed, 68 insertions(+), 47 deletions(-) diff --git a/packages/markdown-codec/src/emit/emit.ts b/packages/markdown-codec/src/emit/emit.ts index 57a587462b..bccb00302e 100644 --- a/packages/markdown-codec/src/emit/emit.ts +++ b/packages/markdown-codec/src/emit/emit.ts @@ -135,6 +135,15 @@ function renderSetextHeading(level: number, text: string): string { return `${text}\n${underline}`; } +// The ATX spelling of a heading, with every line ending in its own rendered text collapsed to a single space. ATX is a single physical line, and writing a line ending into one would split it in two on reparse rather than merely lose formatting. The escaped hard-break spelling (a backslash immediately before the line ending) is stripped first via ESCAPED_HARD_BREAK_PATTERN, together with the line ending it precedes, in one collapse: this package's own escapeMarkdownText always spells it with a trailing LF, but a run's own markdown residue can carry the identical backslash-escape spelling against a CRLF or lone CR just as legitimately, and an LF-only strip would leave that backslash behind as a stray literal character once the LINE_ENDING_PATTERN split below removes the CRLF/CR out from under it. Everything left over is then split on LINE_ENDING_PATTERN and rejoined with spaces, collapsing every remaining line ending (a bare soft-break LF, plus a bare CR or CRLF a run's own text or markdown residue can carry) to the single space ATX's own grammar requires. A text with no line ending in it at all passes through both steps unchanged, which is why every ATX return below goes through here rather than only the break-carrying ones. +function renderAtxHeading(level: number, text: string): string { + const collapsed = text + .replace(ESCAPED_HARD_BREAK_PATTERN, " ") + .split(LINE_ENDING_PATTERN) + .join(" "); + return `${"#".repeat(level)} ${collapsed}`; +} + // A fenced code block's own closing condition (spec 0.31.2, "Fenced code blocks") is "a code fence of the same type as the code block that opened it, of length AT LEAST as great as the opening fence" -- so a fence of exactly 3 characters closes prematurely the moment the code block's own literal content happens to contain a run of 3-or-more of that same character on its own line (a real, common case: this package always re-renders a code block as fenced regardless of whether it was originally fenced or indented, so an indented block whose own text happens to contain a backtick fence is exactly the scenario this guards). The fix real fenced-code-block writers already use: pick a fence one character longer than the longest run of the fence character anywhere in the content, so no line inside the block can ever be mistaken for the closing fence. const MIN_CODE_FENCE_LENGTH = 3; @@ -201,7 +210,8 @@ function firstContentLineIndex(text: string): number { // Whether a line's own leading run of spaces and tabs reaches CommonMark's own 4-column indented-code-block threshold (spec 0.31.2, "Tabs": "in contexts where spaces help to define block structure, tabs behave as if they were replaced by spaces with a tab stop of 4 characters", counted from the start of the LINE, not the whole document). Shares MARKDOWN_TAB_STOP_WIDTH with src/scan/scan.ts's own MarkdownScanCursor so a tab's width agrees with the read side's parse of the very text this function is predicting the reparse of. The sole caller below only ever asks a >= CODE_INDENT_COLUMNS boundary question, never the exact column count beyond it, so this returns that boundary directly. Once the leading run of plain spaces ends, only the SINGLE character right after it can still change the answer: a tab there is always itself sufficient to reach the threshold (CODE_INDENT_COLUMNS <= MARKDOWN_TAB_STOP_WIDTH means expanding a tab from any column short of the threshold already lands exactly on it), and anything else stops the leading run outright -- so this needs no loop-exhausted fallback the way a step-by-step scan through every remaining character would: `line[column]` reads as `undefined` past the string's own end, which compares unequal to "\t" exactly as a real non-tab character would. function leadingIndentReachesCodeThreshold(line: string): boolean { let column = 0; - while (column < line.length && line[column] === " ") { + // No separate `column < line.length` bound: `line[column]` running off the end reads as undefined, which compares unequal to " " exactly as a real non-space character does, so the leading run's own end is the only bound this needs. + while (line[column] === " ") { column += 1; } if (column >= CODE_INDENT_COLUMNS) { @@ -257,13 +267,12 @@ type UnsafeSetextBreakReason = "leading-indentation" | "blank-line" | "interrupting-line" | undefined; function unsafeSetextBreakReason(text: string): UnsafeSetextBreakReason { - let sawContentLine = false; let leadingBlankLines = 0; - // The immediately preceding CONTENT line's own text -- unset until sawContentLine's own first line, and updated on every content line thereafter -- so tableDelimiterRowPromotesPrecedingLine can check a delimiter row against the exact line it would promote, the same pairing src/block/block.ts's own tryTableHeader checks at reparse. - let precedingLine = ""; + // The immediately preceding CONTENT line's own text, undefined until the run's first content line is reached and updated on every content line thereafter, so tableDelimiterRowPromotesPrecedingLine can check a delimiter row against the exact line it would promote, the same pairing src/block/block.ts's own tryTableHeader checks at reparse. Its own undefined-ness doubles as the "no content line seen yet" state, rather than a second flag moving in lockstep with it: a line can only be checked against a preceding content line once one actually exists, which is the very same condition. + let precedingLine: string | undefined; for (const line of text.split(LINE_ENDING_PATTERN)) { const isBlank = BLANK_OR_WHITESPACE_ONLY_LINE.test(line); - if (!sawContentLine) { + if (precedingLine === undefined) { if (isBlank) { leadingBlankLines += 1; if (leadingBlankLines > MAX_LEADING_BLANK_LINES_FOR_SETEXT) { @@ -272,7 +281,6 @@ function unsafeSetextBreakReason(text: string): UnsafeSetextBreakReason { } continue; } - sawContentLine = true; if (leadingIndentReachesCodeThreshold(line)) { return "leading-indentation"; } @@ -294,7 +302,7 @@ function unsafeSetextBreakReason(text: string): UnsafeSetextBreakReason { precedingLine = line; } // Every line was blank -- nothing survives as heading content for the underline to attach to, the same corruption a trailing blank line causes. - return sawContentLine ? undefined : "blank-line"; + return precedingLine === undefined ? "blank-line" : undefined; } function embedsUnsafeBreakForSetext(text: string): boolean { @@ -425,7 +433,7 @@ function renderParagraphBody( }); } const text = emitRuns(paragraph.runs, context, paragraph.constructs); - // ATX is a single physical line; a hard OR soft break embedded in this heading's own runs (src/emit/inline.ts's renderLeaf) leaves a genuine CommonMark line ending in `text` regardless of the configured headingStyle, and ATX has no way to hold it -- writing it out anyway would split the ATX line in two on reparse rather than lose formatting, which is strictly worse. This is detected via LINE_ENDING_PATTERN, not a bare '\n' check: this package's own hard-break escaping (escapeMarkdownText) and soft-break residue always use LF, but a run's plain text field or a foreign producer's own markdown residue (src/emit/inline.ts's renderLeaf, the run.source.xml case) can carry a bare CR or CRLF just as legitimately -- an un-widened check would let that slip through to the plain `text` return at the very bottom of this function with the line ending never escaped or collapsed, embedding it unrepresented in what is supposed to be ATX's single physical line. Setext's own grammar is exactly "one or more lines of heading text", so promote to it whenever the level admits one (<=2), overriding the configured style; only a genuinely unrepresentable level 3-6 heading, OR a level<=2 heading whose own break placement would leave a blank line setext cannot survive (embedsUnsafeBreakForSetext above), falls through to the collapse-with-diagnostic path below. A level<=2 heading with NO embedded break at all can still fall through the same unsafe path: headingStyle: 'setext' is itself a second, independent trigger for candidacy (setextRequested below), so an explicit caller request against an already-unsafe break-free heading (a 4+-column-indented or wholly blank first line) is refused with the identical diagnostic rather than being silently written as an unmarked ATX fallback. + // ATX is a single physical line; a hard OR soft break embedded in this heading's own runs (src/emit/inline.ts's renderLeaf) leaves a genuine CommonMark line ending in `text` regardless of the configured headingStyle, and ATX has no way to hold it — writing it out anyway would split the ATX line in two on reparse rather than lose formatting, which is strictly worse. This is detected via LINE_ENDING_PATTERN, not a bare '\n' check: this package's own hard-break escaping (escapeMarkdownText) and soft-break residue always use LF, but a run's plain text field or a foreign producer's own markdown residue (src/emit/inline.ts's renderLeaf, the run.source.xml case) can carry a bare CR or CRLF just as legitimately, and an un-widened check would leave such a heading treated as break-free throughout: never a setext candidate whose own grammar could have held the break, and collapsed by renderAtxHeading below with no HEADING_LINE_BREAK_COLLAPSED diagnostic reporting that anything was lost. Setext's own grammar is exactly "one or more lines of heading text", so promote to it whenever the level admits one (<=2), overriding the configured style; only a genuinely unrepresentable level 3-6 heading, OR a level<=2 heading whose own break placement would leave a blank line setext cannot survive (embedsUnsafeBreakForSetext above), falls through to the collapse-with-diagnostic path below. A level<=2 heading with NO embedded break at all can still fall through the same unsafe path: headingStyle: 'setext' is itself a second, independent trigger for candidacy (setextRequested below), so an explicit caller request against an already-unsafe break-free heading (a 4+-column-indented or wholly blank first line) is refused with the identical diagnostic rather than being silently written as an unmarked ATX fallback. const embedsLineBreak = LINE_ENDING_PATTERN.test(text); const unsafeSetextReason = unsafeSetextBreakReason(text); const unsafeForSetext = unsafeSetextReason !== undefined; @@ -457,12 +465,8 @@ function renderParagraphBody( embedsLineBreak, ), }); - if (embedsLineBreak) { - // The escaped hard-break spelling (backslash immediately before the line ending) is stripped first via ESCAPED_HARD_BREAK_PATTERN, together with the line ending it precedes, in one collapse -- this package's own escapeMarkdownText always spells it with a trailing LF, but a run's own markdown residue can carry the identical backslash-escape spelling against a CRLF or lone CR just as legitimately, and an LF-only strip would leave that backslash behind as a stray literal character once the LINE_ENDING_PATTERN split below removes the CRLF/CR out from under it. Everything left over is then split on LINE_ENDING_PATTERN and rejoined with spaces, collapsing every remaining line ending -- a bare soft-break LF exactly as before, plus a bare CR or CRLF a run's own text or markdown residue can carry -- to the single space ATX's own single-physical-line grammar requires. - return `${"#".repeat(level)} ${text.replace(ESCAPED_HARD_BREAK_PATTERN, " ").split(LINE_ENDING_PATTERN).join(" ")}`; - } - // No break to collapse -- the hazard here is the break-free heading's own first-line indentation or wholly blank text, and `text` already has no CommonMark line ending in it for the ATX single-physical-line grammar to trip over. - return `${"#".repeat(level)} ${text}`; + // No separate break-free return: the hazard here can just as well be the break-free heading's own first-line indentation or wholly blank text, and such a text carries no CommonMark line ending for either collapse step to act on, so renderAtxHeading leaves it exactly as it stands. + return renderAtxHeading(level, text); } if (embedsLineBreak) { context.sink({ @@ -470,9 +474,8 @@ function renderParagraphBody( severity: "info", message: `a level ${String(level)} heading's own content contains a line break; only setext's own level-1/2 grammar can hold one, so ATX collapses it to a single space`, }); - return `${"#".repeat(level)} ${text.replace(ESCAPED_HARD_BREAK_PATTERN, " ").split(LINE_ENDING_PATTERN).join(" ")}`; } - return `${"#".repeat(level)} ${text}`; + return renderAtxHeading(level, text); } return emitRuns(paragraph.runs, context, paragraph.constructs); } @@ -621,14 +624,15 @@ function firstBlockCheckbox( if (!taskNumId || first.kind !== "paragraph") { return { checkboxText: "", strippedFirstBlock: undefined }; } - const leading = first.block.runs[0]?.text ?? ""; - if (leading.startsWith(`${TASK_CHECKBOX_CHECKED} `)) { + // A paragraph with no runs at all has no leading text to sniff a glyph from, which `leading?.startsWith(...) === true` answers directly. No stand-in empty string is substituted for the absent run, since an absent run and a run whose text merely fails to start with a glyph are the same answer here anyway. + const leading = first.block.runs[0]?.text; + if (leading?.startsWith(`${TASK_CHECKBOX_CHECKED} `) === true) { return { checkboxText: "[x] ", strippedFirstBlock: stripCheckboxRun(first.block), }; } - if (leading.startsWith(`${TASK_CHECKBOX_UNCHECKED} `)) { + if (leading?.startsWith(`${TASK_CHECKBOX_UNCHECKED} `) === true) { return { checkboxText: "[ ] ", strippedFirstBlock: stripCheckboxRun(first.block), @@ -889,7 +893,8 @@ function renderListRegion( ); const indent = " ".repeat(marker.bareLength); - let text = ""; + // Seeded with the marker itself rather than with an empty string: segments[0] is always an 'own' segment holding at least the block `first` was just read from (collectListItem's own leading run), so the first block below always appends its own first line straight onto this marker. + let text = marker.full; let renderedFirstLine = false; let previousStyleId: string | undefined; for (const segment of segments) { @@ -913,8 +918,8 @@ function renderListRegion( listRegionItemBody(block, context, strippedFirstBlock), "\n", ); - text = [ - `${marker.full}${firstLine}`, + text += [ + firstLine, ...restLines.map((line) => `${indent}${line}`), ].join("\n"); renderedFirstLine = true; @@ -940,12 +945,12 @@ function renderListRegion( for (const [partIndex, part] of parts.entries()) { if (partIndex > 0) { const previous = parts[partIndex - 1]!; - const sameList = previous.numId === part.numId; - const loose = - sameList && - previous.numId !== undefined && - (parseListNumId(previous.numId)?.loose ?? false); - out += sameList && !loose ? "\n" : "\n\n"; + // Looseness is read only once the two parts are already known to belong to the same list: a boundary between two DIFFERENT numIds always gets a blank line regardless of either side's own loose flag, so that flag is never consulted there. A depth-only membership (numId undefined) has no numId to read a flag from and always continues tightly, matching listInfoFor's own fallback to a tight bullet list. + const continuesTightly = + previous.numId === part.numId && + (previous.numId === undefined || + parseListNumId(previous.numId)?.loose !== true); + out += continuesTightly ? "\n" : "\n\n"; } out += part.text; } @@ -967,6 +972,9 @@ function isConstructItem(item: EmitItem): item is ConstructItem { } // Whether a construct's own rendered spelling opens with a self-delimiting marker on every line ('> ' for a division, per renderConstruct below) rather than rendering transparently as its own children's content with nothing distinguishing it -- see renderConstruct's own comment for why the blockquote spelling is gated on the wrapped paragraphs' own indentLeftPt dual carry rather than on descriptor.kind alone (a division whose paragraphs carry no such indent is a FOREIGN one, and renders transparently). This same test doubles as the write-side "does this construct's own marker unconditionally interrupt an open paragraph" signal renderListRegion below needs (a materialised division's '> ' does, per CommonMark spec 0.31.2's own list of blocks that can interrupt a paragraph; a transparent construct instead defers to whatever its own first child renders as). +// The left indent a paragraph that carries no indentLeftPt field at all effectively has: document-schema.js leaves the field optional, and an absent one is no indentation rather than an unknown amount of it. +const UNINDENTED_PT = 0; + function isMaterialisedDivision(item: ConstructItem): boolean { return ( item.descriptor.kind === "division" && @@ -974,10 +982,10 @@ function isMaterialisedDivision(item: ConstructItem): boolean { if (isConstructItem(child)) { return true; } + // A paragraph carrying no indentLeftPt at all answers this threshold question identically to one carrying less than a quote level of it, so the absence is defaulted into the comparison rather than tested separately ahead of it. return ( child.block.kind !== "paragraph" || - (child.block.indentLeftPt !== undefined && - child.block.indentLeftPt >= QUOTE_INDENT_PT) + (child.block.indentLeftPt ?? UNINDENTED_PT) >= QUOTE_INDENT_PT ); }) ); diff --git a/packages/markdown-codec/src/emit/inline.ts b/packages/markdown-codec/src/emit/inline.ts index 5ca80051d8..468db7a1ec 100644 --- a/packages/markdown-codec/src/emit/inline.ts +++ b/packages/markdown-codec/src/emit/inline.ts @@ -65,12 +65,16 @@ const ESCAPE_CHARS: ReadonlySet = new Set([ export function escapeMarkdownText(text: string): string { let out = ""; let index = 0; - while (index < text.length) { - const char = text.charAt(index); + // No separate `index < text.length` bound: `text[index]` running off the end already returns undefined, which the very next check breaks on, so an explicit length comparison here would be redundant with that undefined check on every real input, never independently true or false. + for (;;) { + const char = text[index]; + if (char === undefined) { + break; + } if (char === "\n" || char === "\r") { // A hard line break's own literal line ending (src/lower/inline.ts's own mapping) -- rendered as a backslash immediately before a real newline, CommonMark's own unambiguous hard-break spelling (as opposed to the whitespace-sensitive "two trailing spaces" form). This package's own lower.ts always spells its own hard breaks with a bare LF, but run.text is a schema-level field a foreign producer can populate with any of CommonMark's other two line-ending forms (spec 0.31.2, "Lines": a CR not followed by an LF, or a CRLF pair) just as legitimately -- normalising every one of the three to the SAME "\\\n" spelling here, rather than reproducing the input's own CR/CRLF/LF choice verbatim, is what keeps every LINE_ENDING_PATTERN/ESCAPED_HARD_BREAK_PATTERN-based collapse downstream of this function (renderParagraphBody's ATX-heading fallback, emitRunsSingleLine's table-cell collapse) working against a single guaranteed shape instead of having to re-detect all three again. Consuming a CRLF's own LF here, together with its CR, in the SAME step is what a naive `char === "\n"`-only check missed: left as two independent single-character branches, a literal CR falls through as an unescaped literal character first, and the LF immediately after it is escaped on its own -- correct as backslash-then-LF in isolation, but now with the CR's own line-ending-ness stranded one character behind that backslash instead of consumed by it, which is exactly what let a single hard break collapse into TWO spaces downstream instead of one (ESCAPED_HARD_BREAK_PATTERN's own "\\\\(?:\\r\\n|\\n|\\r)" strips the backslash+LF pair as designed, but the CR ahead of it survives as a second, separate line ending for the following LINE_ENDING_PATTERN split to also collapse). out += "\\\n"; - index += char === "\r" && text.charAt(index + 1) === "\n" ? 2 : 1; + index += char === "\r" && text[index + 1] === "\n" ? 2 : 1; continue; } if (ESCAPE_CHARS.has(char)) { @@ -97,10 +101,10 @@ function renderCodeSpan(text: string): string { } } const fence = "`".repeat(longestBacktickRun + 1); - const isAllSpaces = text.length > 0 && text.trim().length === 0; const risksFenceCollision = text.startsWith("`") || text.endsWith("`"); + // The rule's own "doesn't consist entirely of space characters" clause is spelled here as a third conjunct rather than a separate all-spaces binding: a content string with nothing but spaces in it, the empty string included, has no non-space character left once trimmed, and is exactly the content a reparse strips nothing from, so it must be written back unpadded through the same one condition every other content string is decided by. const wouldBeStrippedOnReparse = - !isAllSpaces && text.startsWith(" ") && text.endsWith(" "); + text.startsWith(" ") && text.endsWith(" ") && text.trim().length > 0; const needsPadding = risksFenceCollision || wouldBeStrippedOnReparse; return needsPadding ? `${fence} ${text} ${fence}` : `${fence}${text}${fence}`; } @@ -174,10 +178,8 @@ function styleActive(run: ContentRun, key: StyleKey): boolean { // CommonMark's own emphasis rule (spec 0.31.2, "Emphasis and strong emphasis", rules 1-4): a `*` delimiter run may open/close emphasis regardless of what is adjacent to it on the inner side, but a `_` run may NOT do so "intraword" -- immediately adjacent, with no separating whitespace, to a letter or digit on the inner side. `foo*bar*` is `foobar`, but the underscore spelling `foo_bar_` is not emphasis at all: the intraword restriction only exists for `_`, so writing an intraword-adjacent emphasis span back out with the configured emphasisMarker when that marker is `_` would silently produce LITERAL underscores on reparse rather than emphasis -- a real correctness bug, not a style nit. const WORD_CHAR_PATTERN = /[\p{L}\p{N}]/u; +// No empty-body guard of its own: String.prototype.charAt past a string's own end returns the EMPTY string rather than undefined, and WORD_CHAR_PATTERN cannot match that, so an empty body already answers false through the very same two tests every other body takes. function isIntrawordRisk(body: string): boolean { - if (body.length === 0) { - return false; - } return ( WORD_CHAR_PATTERN.test(body.charAt(0)) || WORD_CHAR_PATTERN.test(body.charAt(body.length - 1)) @@ -193,10 +195,8 @@ function hasMarkerConflict( if (candidate === "_" && isIntrawordRisk(body)) { return true; } - if ( - body.length > 0 && - (body.startsWith(candidate) || body.endsWith(candidate)) - ) { + // No separate "is the body non-empty" guard: `candidate` is always one of the two single-character delimiters CommonMark offers, and the empty string neither starts nor ends with one of those. + if (body.startsWith(candidate) || body.endsWith(candidate)) { return true; } return precedingText.endsWith(candidate); @@ -257,7 +257,8 @@ function pickSplitKey( ): StyleKey { let best = remaining[0]!; let bestCount = groupCount(runs, best); - for (const key of remaining.slice(1)) { + // Every key is measured, the seed key's own first entry included: a count can never be strictly less than itself, so re-measuring the seed decides nothing, where skipping it would add an index-offset boundary with no observable effect of its own. + for (const key of remaining) { const count = groupCount(runs, key); if (count < bestCount) { best = key; @@ -284,7 +285,8 @@ function renderNestedStyles( const rest = remainingKeys.filter((candidate) => candidate !== key); let out = ""; let index = 0; - while (index < runs.length) { + // No separate `index < runs.length` bound: `runs[index]` running off the end already returns undefined, which the very next check breaks on. + for (;;) { const current = runs[index]; if (current === undefined) { break; @@ -381,14 +383,20 @@ export function emitRuns( ): string { let out = ""; let index = 0; - while (index < runs.length) { + // No separate `index < runs.length` bound: `runs[index]` running off the end already returns undefined, which the very next check breaks on. + for (;;) { const run = runs[index]; if (run === undefined) { break; } if (run.hyperlink === undefined) { let end = index + 1; - while (end < runs.length && runs[end]?.hyperlink === undefined) { + // The stretch's bound is the next run's own PRESENCE, checked before its hyperlink: `runs[end]` running off the end returns undefined, whose optional-chained hyperlink reads as undefined too, indistinguishable on its own from a genuinely hyperlink-free run. + for (;;) { + const next = runs[end]; + if (next === undefined || next.hyperlink !== undefined) { + break; + } end += 1; } out += renderNestedStyles( @@ -403,7 +411,12 @@ export function emitRuns( } const hyperlink = run.hyperlink; let groupEnd = index + 1; - while (groupEnd < runs.length && runs[groupEnd]?.hyperlink === hyperlink) { + // No separate `groupEnd < runs.length` bound: `runs[groupEnd]` running off the end returns undefined, whose optional-chained hyperlink reads as undefined, which never equals this group's own (defined) hyperlink, so the group's end is already the only bound the comparison needs. + for (;;) { + const next = runs[groupEnd]; + if (next?.hyperlink !== hyperlink) { + break; + } groupEnd += 1; } const group = runs.slice(index, groupEnd); From 3b354232703c2faaad99c913105c48f793eaf368 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 20 Sep 2026 07:16:33 +0100 Subject: [PATCH 93/99] test(markdown-codec): cover the markdown writers' escaping, delimiters and list regions The inline writer had no test file of its own, so its escaping and delimiter rules were only ever exercised through whole-document emission that pinned the final string rather than the decision that produced it. Adds direct tests against emitRuns, escapeMarkdownText, escapeLinkDestination and renderLinkTitle for each character the escaper treats specially, the intraword and marker-conflict rules that pick an emphasis delimiter, and the nested-style grouping across adjacent runs. On the block side, adds the heading, list-region and construct cases whose diagnostics and exact output nothing asserted: the setext safety rules for a heading holding a line break, the numbered-list fallback when a numId cannot be honoured, and the paragraph indent and division thresholds. --- packages/markdown-codec/src/emit/emit.test.ts | 360 +++++++++++++- .../markdown-codec/src/emit/inline.test.ts | 441 ++++++++++++++++++ 2 files changed, 800 insertions(+), 1 deletion(-) create mode 100644 packages/markdown-codec/src/emit/inline.test.ts diff --git a/packages/markdown-codec/src/emit/emit.test.ts b/packages/markdown-codec/src/emit/emit.test.ts index 5f4ff62768..f74787f5d0 100644 --- a/packages/markdown-codec/src/emit/emit.test.ts +++ b/packages/markdown-codec/src/emit/emit.test.ts @@ -1272,6 +1272,147 @@ describe("headings", () => { expect(paragraphBlock.runs.map((run) => run.text).join("")).toBe("a"); expect(headingBlock.styleId).toBe("Heading1"); }); + + it("does not treat a heading's own SECOND line, indented 4+ columns, as an interrupting ATX heading, since an indented line is absorbed as ordinary paragraph continuation, so the promotion still stands", () => { + expect( + emitMarkdown( + doc([ + { + kind: "paragraph", + runs: [ + { text: "foo" }, + { text: "\n" }, + { + text: " # bar", + source: { format: "markdown", xml: " # bar" }, + }, + ], + styleId: "Heading1", + }, + ]), + ), + ).toBe("foo\\\n # bar\n===="); + }); + + it("refuses setext when a heading's own second line opens an HTML BLOCK of a kind that genuinely interrupts a paragraph, collapsing to ATX instead", () => { + const collector = createDiagnosticCollector(); + const written = emitMarkdown( + doc([ + { + kind: "paragraph", + runs: [ + { text: "foo" }, + { text: "\n" }, + { text: "
", source: { format: "markdown", xml: "
" } }, + ], + styleId: "Heading1", + }, + ]), + { sink: collector.sink }, + ); + expect(written).toBe("# foo
"); + expect( + collector.has( + MarkdownDiagnosticCodes.HEADING_LINE_BREAK_UNSAFE_FOR_SETEXT, + ), + ).toBe(true); + }); + + it("still promotes a heading whose own SECOND line is a lone generic tag, since CommonMark's HTML-block condition 7 is barred from interrupting a paragraph, so the line is absorbed as more of the heading's text", () => { + expect( + emitMarkdown( + doc([ + { + kind: "paragraph", + runs: [ + { text: "foo" }, + { text: "\n" }, + { text: "", source: { format: "markdown", xml: "" } }, + ], + styleId: "Heading1", + }, + ]), + ), + ).toBe("foo\\\n\n===="); + }); + + it("refuses setext when that same lone generic tag is the heading's own FIRST line instead, where genuine block-start position lets condition 7 open an HTML block after all", () => { + expect( + emitMarkdown( + doc([ + { + kind: "paragraph", + runs: [ + { text: "", source: { format: "markdown", xml: "" } }, + { text: " ", source: { format: "markdown", xml: "\n" } }, + { text: "foo" }, + ], + styleId: "Heading1", + }, + ]), + ), + ).toBe("# foo"); + }); + + it("refuses setext for a heading whose own second line is a GFM table delimiter row matching the line before it, but not when that same row is indented 4+ columns", () => { + const heading = (delimiterRow: string): string => + emitMarkdown( + doc([ + { + kind: "paragraph", + runs: [ + { text: "h", source: { format: "markdown", xml: "a | b" } }, + { text: " ", source: { format: "markdown", xml: "\n" } }, + { + text: "d", + source: { format: "markdown", xml: delimiterRow }, + }, + ], + styleId: "Heading1", + }, + ]), + ); + expect(heading("---|---")).toBe("# a | b ---|---"); + expect(heading(" ---|---")).toBe("a | b\n ---|---\n====="); + }); + + it("does NOT report HEADING_STYLE_OVERRIDDEN_FOR_LINE_BREAK when setext was the caller's own explicit request, since the break overrode nothing", () => { + const collector = createDiagnosticCollector(); + const written = emitMarkdown( + doc([ + { + kind: "paragraph", + runs: [ + { text: "foo" }, + { text: " ", source: { format: "markdown", xml: "\n" } }, + { text: "bar" }, + ], + styleId: "Heading2", + }, + ]), + { headingStyle: "setext", sink: collector.sink }, + ); + expect(written).toBe("foo\nbar\n---"); + expect( + collector.has( + MarkdownDiagnosticCodes.HEADING_STYLE_OVERRIDDEN_FOR_LINE_BREAK, + ), + ).toBe(false); + }); + + it("does NOT report HEADING_LINE_BREAK_COLLAPSED for a heading whose own rendered text carries no line break at all", () => { + const collector = createDiagnosticCollector(); + const written = emitMarkdown( + doc([ + { kind: "paragraph", runs: [{ text: "foo" }], styleId: "Heading3" }, + ]), + { sink: collector.sink }, + ); + expect(written).toBe("### foo"); + expect( + collector.has(MarkdownDiagnosticCodes.HEADING_LINE_BREAK_COLLAPSED), + ).toBe(false); + }); }); describe("code blocks, thematic breaks, preformatted HTML", () => { @@ -1363,6 +1504,50 @@ describe("code blocks, thematic breaks, preformatted HTML", () => { ), ).toBe("
*not emphasis*
"); }); + + it("joins a CodeBlock paragraph's several runs into one literal with nothing between them", () => { + expect( + emitMarkdown( + doc([ + { + kind: "paragraph", + runs: [{ text: "foo" }, { text: "bar" }], + styleId: "CodeBlock", + }, + ]), + ), + ).toBe("```\nfoobar\n```"); + }); + + it("drops an EMPTY codeLanguage from the info line rather than emitting a stray separator ahead of the residue remainder", () => { + expect( + emitMarkdown( + doc([ + { + kind: "paragraph", + runs: [{ text: "foo" }], + styleId: "CodeBlock", + codeLanguage: "", + source: { format: "markdown", xml: "{.numberLines}" }, + }, + ]), + ), + ).toBe("``` {.numberLines}\nfoo\n```"); + }); + + it("joins an HTMLPreformatted paragraph's several runs into one literal with nothing between them", () => { + expect( + emitMarkdown( + doc([ + { + kind: "paragraph", + runs: [{ text: "
" }, { text: "
" }], + styleId: "HTMLPreformatted", + }, + ]), + ), + ).toBe("
"); + }); }); describe("math (ExaDev/markdown-codec#53)", () => { @@ -1477,6 +1662,20 @@ describe("math (ExaDev/markdown-codec#53)", () => { ), ).toBe("\\(f(x) = x^2\\)"); }); + + it("joins a MathBlock paragraph's several runs into one literal with nothing between them", () => { + expect( + emitMarkdown( + doc([ + { + kind: "paragraph", + runs: [{ text: "x^" }, { text: "2" }], + styleId: "MathBlock", + }, + ]), + ), + ).toBe("$$\nx^2\n$$"); + }); }); describe("blockquotes", () => { @@ -3027,6 +3226,82 @@ describe("lists", () => { ); expect(loose).toBe("- a\n\n- b"); }); + + it("keeps two same-level items of DEPTH-ONLY memberships tight, since an absent numId carries no loose flag of its own to read one from", () => { + expect( + emitMarkdown( + doc([ + { kind: "paragraph", runs: [{ text: "a" }], list: { level: 0 } }, + { kind: "paragraph", runs: [{ text: "b" }], list: { level: 0 } }, + ]), + ), + ).toBe("- a\n- b"); + }); + + it("separates two adjacent lists of DIFFERENT numIds with a blank line even when the first of them is loose, since looseness only decides spacing within one list", () => { + expect( + emitMarkdown( + doc([ + { + kind: "paragraph", + runs: [{ text: "a" }], + list: { numId: "md1:bullet+loose", level: 0 }, + }, + { + kind: "paragraph", + runs: [{ text: "b" }], + list: { numId: "md2:bullet+loose", level: 0 }, + }, + ]), + ), + ).toBe("- a\n\n+ b"); + }); + + it("does NOT report LIST_NUMID_FALLBACK for a numId this package minted itself", () => { + const collector = createDiagnosticCollector(); + const markdown = emitMarkdown( + doc([ + { + kind: "paragraph", + runs: [{ text: "a" }], + list: { numId: "md1:bullet", level: 0 }, + }, + ]), + { sink: collector.sink }, + ); + expect(markdown).toBe("- a"); + expect(collector.has(MarkdownDiagnosticCodes.LIST_NUMID_FALLBACK)).toBe( + false, + ); + }); + + it("drops a legacy checkbox glyph run ENTIRELY when the glyph is all that run holds, rather than leaving an empty run behind for the item's own styling to wrap around nothing", () => { + expect( + emitMarkdown( + doc([ + { + kind: "paragraph", + runs: [{ text: "☒ ", bold: true }, { text: "done" }], + list: { numId: "md1:bullet+task", level: 0 }, + }, + ]), + ), + ).toBe("- [x] done"); + }); + + it("renders a task-flagged item whose first block has no runs at all as a plain marker, with no checkbox sniffed from a leading run that does not exist", () => { + expect( + emitMarkdown( + doc([ + { + kind: "paragraph", + runs: [], + list: { numId: "md1:bullet+task", level: 0 }, + }, + ]), + ), + ).toBe("- "); + }); }); describe("adjacent same-type lists get different marker glyphs (ExaDev/markdown-codec#957)", () => { @@ -3583,7 +3858,70 @@ describe("link and image titles (the `link` construct annotation)", () => { }, { kind: "constructEnd" }, ]; - expect(emitMarkdown(doc(blocks), { images: false })).toBe("![alt]()"); + const collector = createDiagnosticCollector(); + expect( + emitMarkdown(doc(blocks), { images: false, sink: collector.sink }), + ).toBe("![alt]()"); + // The pair still renders through its own image shortcut here, just without the bytes, and it never falls through to the transparent path that reports an unrepresented construct. + expect(collector.has(MarkdownDiagnosticCodes.CONSTRUCT_UNREPRESENTED)).toBe( + false, + ); + }); + + it("emits an empty alt slot for a link construct wrapping an image block that carries no altText of its own", () => { + expect( + emitMarkdown( + doc([ + { + kind: "constructStart", + descriptor: { + kind: "link", + target: { + kind: "external", + uri: "https://example.com/a.png", + }, + title: "img title", + }, + }, + { + kind: "image", + format: "png", + base64: "AAAA", + widthPt: 1, + heightPt: 1, + }, + { kind: "constructEnd" }, + ]), + ), + ).toBe('![](https://example.com/a.png "img title")'); + }); + + it("omits the title slot entirely for a link construct wrapping an image whose descriptor carries no title", () => { + expect( + emitMarkdown( + doc([ + { + kind: "constructStart", + descriptor: { + kind: "link", + target: { + kind: "external", + uri: "https://example.com/a.png", + }, + }, + }, + { + kind: "image", + format: "png", + base64: "AAAA", + widthPt: 1, + heightPt: 1, + altText: "alt", + }, + { kind: "constructEnd" }, + ]), + ), + ).toBe("![alt](https://example.com/a.png)"); }); it("throws for a paragraph whose run-level construct extent does not name real runs", () => { @@ -3836,6 +4174,26 @@ describe("gaps (MarkdownDiagnosticCodes)", () => { expect( collector.has(MarkdownDiagnosticCodes.PARAGRAPH_INDENT_DROPPED), ).toBe(true); + expect( + collector.diagnostics.find( + (diagnostic) => + diagnostic.code === MarkdownDiagnosticCodes.PARAGRAPH_INDENT_DROPPED, + )?.message, + ).toBe( + "paragraph carries indentLeftPt (20pt) with no styleId this package recognises as quotable; the indent has no other markdown representation and is dropped", + ); + }); + + it("does NOT report PARAGRAPH_INDENT_DROPPED for a paragraph carrying no indent at all, whose quote depth is already zero before any styleId question arises", () => { + const collector = createDiagnosticCollector(); + const markdown = emitMarkdown( + doc([{ kind: "paragraph", runs: [{ text: "x" }] }]), + { sink: collector.sink }, + ); + expect(markdown).toBe("x"); + expect( + collector.has(MarkdownDiagnosticCodes.PARAGRAPH_INDENT_DROPPED), + ).toBe(false); }); it("PARAGRAPH_INDENT_DROPPED also fires for a DEFINED but unrecognised styleId carrying indentLeftPt, not only an absent styleId -- isQuotableStyle's own QUOTABLE_STYLE_IDS/heading check must actually run, not just its undefined short-circuit", () => { diff --git a/packages/markdown-codec/src/emit/inline.test.ts b/packages/markdown-codec/src/emit/inline.test.ts new file mode 100644 index 0000000000..18de14c6b0 --- /dev/null +++ b/packages/markdown-codec/src/emit/inline.test.ts @@ -0,0 +1,441 @@ +// Direct unit tests for the ContentRun[] -> markdown inline writer (src/emit/inline.ts). src/emit/emit.test.ts already exercises this module through whole-document emission, which is the right level for "does a paragraph come out right"; this file instead builds the exact run sequence and construct extents each individual writing rule turns on, and asserts the exact string it produces, so a rule's own boundary (which delimiter character is chosen, where a code span's padding is added, which of two overlapping title extents wins) has a test naming it rather than only being covered incidentally by some larger document's rendering. + +import type { ContentRun, RunConstructExtent } from "document-schema.js"; +import { describe, expect, it } from "vitest"; +import { DEFAULT_EMPHASIS_MARKER } from "../defaults/defaults"; +import { + MarkdownDiagnosticCodes, + NOOP_MARKDOWN_DIAGNOSTIC_SINK, +} from "../diagnostics/diagnostics"; +import { + MATH_INLINE_FONT_MARKER, + MONOSPACE_FONT_FAMILY, +} from "../shared/style-constants"; +import { createDiagnosticCollector } from "../test-support/diagnostics"; +import type { InlineEmitContext } from "./inline"; +import { + emitRuns, + escapeLinkDestination, + escapeMarkdownText, + renderLinkTitle, +} from "./inline"; + +function context( + emphasisMarker: string = DEFAULT_EMPHASIS_MARKER, +): InlineEmitContext { + return { sink: NOOP_MARKDOWN_DIAGNOSTIC_SINK, emphasisMarker }; +} + +function render( + runs: readonly ContentRun[], + emphasisMarker?: string, + constructs?: readonly RunConstructExtent[], +): string { + return emitRuns(runs, context(emphasisMarker), constructs); +} + +function codeSpan(text: string): string { + return render([{ text, fontFamily: MONOSPACE_FONT_FAMILY }]); +} + +// The one titled-link shape every ordering test below varies: four runs whose middle two share a hyperlink, so the group the title is resolved for (runs 1 and 2) is a proper subset of the paragraph and an extent can genuinely be wider than it on either side. +function titledLinkGroup(constructs: readonly RunConstructExtent[]): string { + return render( + [ + { text: "p" }, + { text: "a", hyperlink: "/u" }, + { text: "b", hyperlink: "/u" }, + { text: "q" }, + ], + undefined, + constructs, + ); +} + +function linkExtent( + startRun: number, + endRun: number, + title?: string, +): RunConstructExtent { + return { + descriptor: { + kind: "link", + target: { kind: "external", uri: "/u" }, + ...(title === undefined ? {} : { title }), + }, + startRun, + endRun, + }; +} + +describe("escapeMarkdownText", () => { + it("normalises each of CommonMark's three line-ending spellings to one backslash-escaped LF, consuming a CRLF's own pair in a single step rather than leaving the text after it behind", () => { + expect(escapeMarkdownText("a\nb")).toBe("a\\\nb"); + expect(escapeMarkdownText("a\r\nb")).toBe("a\\\nb"); + expect(escapeMarkdownText("a\rb")).toBe("a\\\nb"); + }); + + it("consumes the SECOND half of a CRLF pair only behind a genuine CR, so two consecutive LFs stay two separate escaped breaks rather than being read as one pair", () => { + expect(escapeMarkdownText("a\n\nb")).toBe("a\\\n\\\nb"); + expect(escapeMarkdownText("a\r\rb")).toBe("a\\\n\\\nb"); + }); + + it("leaves parentheses bare while escaping the ASCII punctuation around them, and escapes every character of a run of them", () => { + expect(escapeMarkdownText("(a)*_")).toBe("(a)\\*\\_"); + }); +}); + +describe("code spans (a Courier New run)", () => { + it("sizes the fence against the longest CONTIGUOUS run of backticks, resetting the count at every non-backtick character", () => { + expect(codeSpan("a`b`c")).toBe("``a`b`c``"); + expect(codeSpan("a``b")).toBe("```a``b```"); + }); + + it("pads a span whose content touches a backtick at either end, so the content cannot fuse with its own fence", () => { + expect(codeSpan("`a")).toBe("`` `a ``"); + expect(codeSpan("a`")).toBe("`` a` ``"); + }); + + it("pads only when the content begins AND ends with a space, which is the exact shape a reparse strips a space off each end of", () => { + expect(codeSpan("a ")).toBe("`a `"); + expect(codeSpan(" a")).toBe("` a`"); + expect(codeSpan(" a ")).toBe("` a `"); + }); + + it("leaves a content string of nothing but spaces unpadded, the stripping rule's own explicit exemption", () => { + expect(codeSpan(" ")).toBe("` `"); + }); + + it("reports CODE_SPAN_AS_MONOSPACE_RUN, naming why a monospace run and a real code span are indistinguishable on the way back out", () => { + const collector = createDiagnosticCollector(); + emitRuns([{ text: "x", fontFamily: MONOSPACE_FONT_FAMILY }], { + sink: collector.sink, + emphasisMarker: DEFAULT_EMPHASIS_MARKER, + }); + expect( + collector.diagnostics.find( + (diagnostic) => + diagnostic.code === + MarkdownDiagnosticCodes.CODE_SPAN_AS_MONOSPACE_RUN, + )?.message, + ).toBe( + "a run styled with the Courier New font family is rendered as a code span; a genuinely monospace run from another format is indistinguishable from a real markdown code span on the way back out", + ); + }); +}); + +describe("footnote reference anchors", () => { + const footnoteExtent = ( + name: string, + startRun: number, + endRun: number, + ): RunConstructExtent => ({ + descriptor: { kind: "anchor", anchorType: "footnote", name }, + startRun, + endRun, + }); + + it("spells a POINT footnote anchor's own run as its [^label] marker, taking the label from the extent rather than from the run's text", () => { + expect( + render([{ text: "[^1]" }], undefined, [footnoteExtent("1", 0, 0)]), + ).toBe("[^1]"); + }); + + it("leaves a RANGED footnote anchor's runs as ordinary escaped text, since a markdown reference is a point and a range over several runs has no single-run spelling", () => { + expect( + render([{ text: "a" }, { text: "b" }], undefined, [ + footnoteExtent("1", 0, 1), + ]), + ).toBe("ab"); + }); + + it("leaves a non-footnote anchor's own point extent alone, however exactly it names the run", () => { + expect( + render([{ text: "a" }], undefined, [ + { + descriptor: { kind: "anchor", anchorType: "bookmark", name: "b" }, + startRun: 0, + endRun: 0, + }, + ]), + ).toBe("a"); + }); + + it("reports CONSTRUCT_UNREPRESENTED, naming the label, for a reference anchor whose name cannot be spelled as a [^label] marker", () => { + const collector = createDiagnosticCollector(); + const markdown = emitRuns( + [{ text: "x" }], + { sink: collector.sink, emphasisMarker: DEFAULT_EMPHASIS_MARKER }, + [footnoteExtent("a b", 0, 0)], + ); + expect(markdown).toBe("x"); + expect( + collector.diagnostics.find( + (diagnostic) => + diagnostic.code === MarkdownDiagnosticCodes.CONSTRUCT_UNREPRESENTED, + )?.message, + ).toBe( + 'a footnote reference anchor\'s own name "a b" cannot be spelled as a "[^label]" marker (whitespace or "]" would reparse as something else); the run\'s own text renders escaped in its place, but the reference itself is not represented', + ); + }); + + it("looks a run's own construct extents up at its position in the PARAGRAPH, not at its offset inside whichever style group it landed in", () => { + expect( + render( + [{ text: "a" }, { text: "b", bold: true }, { text: "x" }], + undefined, + [footnoteExtent("n", 2, 2)], + ), + ).toBe("a**b**[^n]"); + }); +}); + +describe("emphasis delimiter choice", () => { + it("falls back to '*' when the configured '_' would sit intraword against either end of the body", () => { + expect(render([{ text: "a", italic: true }])).toBe("*a*"); + expect(render([{ text: "a)", italic: true }])).toBe("*a)*"); + expect(render([{ text: "(a", italic: true }])).toBe("*(a*"); + }); + + it("keeps the configured '_' when NEITHER end of the body is a word character, since the intraword restriction is about adjacency, not about the body's contents", () => { + expect(render([{ text: "(a)", italic: true }])).toBe("_(a)_"); + }); + + it("falls back to the other delimiter when the configured one already sits at the body's own leading or trailing edge", () => { + expect( + render( + [ + { text: "a", bold: true, italic: true }, + { text: "(b)", bold: true }, + ], + "*", + ), + ).toBe("__*a*(b)__"); + expect( + render( + [ + { text: "(a)", bold: true }, + { text: "b", bold: true, italic: true }, + ], + "*", + ), + ).toBe("__(a)*b*__"); + }); + + it("falls back to the other delimiter when the immediately preceding sibling's own rendering ENDS with the configured one, which would otherwise fuse the two delimiter runs into one", () => { + expect( + render([ + { text: "x" }, + { text: "(a)", italic: true }, + { text: "(b)", bold: true }, + ]), + ).toBe("x_(a)_**(b)**"); + }); + + it("returns to the configured delimiter when NEITHER of the only two CommonMark offers is collision-free", () => { + expect( + render([ + { text: "x" }, + { text: "(a)", italic: true }, + { text: "b", bold: true }, + ]), + ).toBe("x_(a)_**b**"); + }); + + it("wraps an empty styled run in a bare delimiter pair, with no intraword risk to steer the choice either way", () => { + expect(render([{ text: "", bold: true }])).toBe("____"); + }); +}); + +describe("nested style ordering", () => { + it("resolves the least-fragmented key outermost, breaking a tie by STYLE_KEYS' own bold-then-italic-then-strike order", () => { + expect(render([{ text: "a", bold: true, italic: true }])).toBe("__*a*__"); + expect(render([{ text: "a", bold: true, italic: true }], "*")).toBe( + "__*a*__", + ); + }); + + it("renders one continuous stretch of hyperlink-free runs as a single nested wrap, not as one independently-wrapped fragment per run", () => { + expect( + render([ + { text: "a", bold: true }, + { text: "b", bold: true }, + ]), + ).toBe("**ab**"); + }); +}); + +describe("autolinks", () => { + it("uses the bare form for a run whose own text equals its own destination, and for the mailto: spelling of the same", () => { + expect(render([{ text: "foo", hyperlink: "foo" }])).toBe(""); + expect(render([{ text: "a@b.test", hyperlink: "mailto:a@b.test" }])).toBe( + "", + ); + }); + + it("refuses the bare form for a run carrying any styling of its own, each of which needs its own rendering inside the link text instead", () => { + expect(render([{ text: "foo", hyperlink: "foo", bold: true }])).toBe( + "[**foo**](foo)", + ); + expect(render([{ text: "foo", hyperlink: "foo", italic: true }])).toBe( + "[*foo*](foo)", + ); + expect(render([{ text: "foo", hyperlink: "foo", strike: true }])).toBe( + "[~~foo~~](foo)", + ); + expect( + render([ + { text: "foo", hyperlink: "foo", fontFamily: MONOSPACE_FONT_FAMILY }, + ]), + ).toBe("[`foo`](foo)"); + expect( + render([ + { text: "foo", hyperlink: "foo", fontFamily: MATH_INLINE_FONT_MARKER }, + ]), + ).toBe("[\\(foo\\)](foo)"); + }); + + it("refuses the bare form for a run that is also a footnote reference site, which needs its own [^label] spelling", () => { + expect( + render([{ text: "foo", hyperlink: "foo" }], undefined, [ + { + descriptor: { kind: "anchor", anchorType: "footnote", name: "n" }, + startRun: 0, + endRun: 0, + }, + ]), + ).toBe("[[^n]](foo)"); + }); + + it("refuses the bare form for an empty destination, which <> cannot spell at all", () => { + expect(render([{ text: "", hyperlink: "" }])).toBe("[]()"); + }); + + it("refuses the bare form when the run's own text merely resembles, rather than equals, its destination", () => { + expect(render([{ text: "foo", hyperlink: "foot" }])).toBe("[foo](foot)"); + }); +}); + +describe("hyperlink grouping", () => { + it("merges two adjacent runs sharing one hyperlink into a single link, reporting ADJACENT_LINKS_MERGED with the count and destination named", () => { + const collector = createDiagnosticCollector(); + const markdown = emitRuns( + [ + { text: "a", hyperlink: "/u" }, + { text: "b", hyperlink: "/u" }, + ], + { sink: collector.sink, emphasisMarker: DEFAULT_EMPHASIS_MARKER }, + ); + expect(markdown).toBe("[ab](/u)"); + expect( + collector.diagnostics.find( + (diagnostic) => + diagnostic.code === MarkdownDiagnosticCodes.ADJACENT_LINKS_MERGED, + )?.message, + ).toBe( + '2 adjacent runs share the hyperlink "/u"; markdown has no way to place two link boundaries back to back, so they render as one link spanning their combined text', + ); + }); + + it("does NOT report ADJACENT_LINKS_MERGED for a link of exactly one run, which merged nothing", () => { + const collector = createDiagnosticCollector(); + emitRuns([{ text: "a", hyperlink: "/u" }], { + sink: collector.sink, + emphasisMarker: DEFAULT_EMPHASIS_MARKER, + }); + expect(collector.has(MarkdownDiagnosticCodes.ADJACENT_LINKS_MERGED)).toBe( + false, + ); + }); + + it("refuses the bare autolink form for a MERGED group even when its own first run is autolink-shaped, since the bare form has no room for the runs after it", () => { + expect( + render([ + { text: "foo", hyperlink: "foo" }, + { text: "bar", hyperlink: "foo" }, + ]), + ).toBe("[foobar](foo)"); + }); +}); + +describe("escapeLinkDestination", () => { + it("leaves a destination needing no angle brackets exactly as it stands", () => { + expect(escapeLinkDestination("/a/b")).toBe("/a/b"); + }); + + it("wraps a destination carrying whitespace or a parenthesis in angle brackets, escaping any angle bracket of its own inside them", () => { + expect(escapeLinkDestination("a b")).toBe("
>"); + expect(escapeLinkDestination("a(b)")).toBe(""); + }); +}); + +describe("renderLinkTitle", () => { + it("collapses a whole run of line endings to ONE space, rather than one space per line ending", () => { + expect(renderLinkTitle("a\n\nb")).toBe("a b"); + expect(renderLinkTitle("a\r\nb")).toBe("a b"); + }); + + it("escapes the two characters a double-quoted title grammar gives meaning to, and nothing else", () => { + expect(renderLinkTitle('say "hi" \\ done')).toBe('say \\"hi\\" \\\\ done'); + }); +}); + +describe("the link title a covering run-level extent supplies", () => { + it("renders the title of the one extent covering the group, whichever side of the group the extent extends past", () => { + expect(titledLinkGroup([linkExtent(0, 4, "t")])).toBe('p[ab](/u "t")q'); + }); + + it("ignores an extent that does not cover the whole group", () => { + expect(titledLinkGroup([linkExtent(2, 4, "t")])).toBe("p[ab](/u)q"); + expect(titledLinkGroup([linkExtent(0, 2, "t")])).toBe("p[ab](/u)q"); + }); + + it("takes the innermost covering extent by LARGEST startRun, whichever order the two extents are listed in", () => { + expect( + titledLinkGroup([linkExtent(0, 4, "outer"), linkExtent(1, 3, "inner")]), + ).toBe('p[ab](/u "inner")q'); + expect( + titledLinkGroup([linkExtent(1, 3, "inner"), linkExtent(0, 4, "outer")]), + ).toBe('p[ab](/u "inner")q'); + }); + + it("breaks a startRun tie by SMALLEST endRun, whichever order the two extents are listed in", () => { + expect( + titledLinkGroup([linkExtent(0, 4, "outer"), linkExtent(0, 3, "inner")]), + ).toBe('p[ab](/u "inner")q'); + expect( + titledLinkGroup([linkExtent(0, 3, "inner"), linkExtent(0, 4, "outer")]), + ).toBe('p[ab](/u "inner")q'); + }); + + it("keeps the FIRST of two extents whose ranges are identical, since neither is tighter than the other", () => { + expect( + titledLinkGroup([linkExtent(0, 4, "first"), linkExtent(0, 4, "second")]), + ).toBe('p[ab](/u "first")q'); + }); + + it("prefers the larger startRun over the smaller endRun when two covering extents CROSS rather than nest", () => { + expect( + titledLinkGroup([linkExtent(1, 4, "inner"), linkExtent(0, 3, "wider")]), + ).toBe('p[ab](/u "inner")q'); + }); + + it("skips an untitled link extent entirely rather than letting it win as the tightest and carry no title", () => { + expect(titledLinkGroup([linkExtent(0, 4, "outer"), linkExtent(1, 3)])).toBe( + 'p[ab](/u "outer")q', + ); + }); + + it("skips an extent of any other descriptor kind, which annotates nothing about this link", () => { + expect( + titledLinkGroup([ + linkExtent(0, 4, "outer"), + { + descriptor: { kind: "division" }, + startRun: 1, + endRun: 3, + }, + ]), + ).toBe('p[ab](/u "outer")q'); + }); +}); From 5cfc3b20f242824faa385010bd6b96a13dc23e95 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 20 Sep 2026 08:48:50 +0100 Subject: [PATCH 94/99] refactor(markdown-codec): derive the block parser's open-paragraph tests from the tip itself The `allClosed` field held whether the tip at the start of the line was the last matched container, and both of its readers reduce to asking whether the tip is an open paragraph: a paragraph is a leaf, so it is the tip for as long as it stays open. The HTML block condition-7 suppression and the lazy continuation now ask that directly, and closeUnmatchedBlocks' early return goes with the field, since it only restated the loop condition below it. The `MAYBE_SPECIAL_PATTERN` pre-filter is removed for the same reason: every one of the ten block starts it gated already declines on the line's own indentation or first character, so the filter could not change an answer. Several other checks were likewise already decided by their neighbours: the setext and table promotions close nothing, because the hook only runs against a paragraph the line itself matched; the table start needs no explicit finalise, because adding a table child closes the paragraph anyway; and the fenced-code tests need no kind check beside `fenced`, which only the fenced-code start ever sets. The math block now consumes its whole opening line rather than just the marker, which leaves finalisation a plain slice of the accumulated line ending instead of a search whose not-found branch was unreachable. finalize decrements the nesting depth unconditionally, and the close-everything loop in parse stops at the document rather than finalising it, which is the asymmetry the guard existed for. --- packages/markdown-codec/src/block/block.ts | 103 +++++++++------------ 1 file changed, 42 insertions(+), 61 deletions(-) diff --git a/packages/markdown-codec/src/block/block.ts b/packages/markdown-codec/src/block/block.ts index f4b0cdcef6..f7dd0c4dd6 100644 --- a/packages/markdown-codec/src/block/block.ts +++ b/packages/markdown-codec/src/block/block.ts @@ -61,9 +61,6 @@ const TASK_LIST_MARKER_PATTERN = /^\[([ xX])\][ \t]/; const NUL_REPLACEMENT = "�"; const NUL_PATTERN = /\0/g; -// A cheap first filter before the block-start list is tried at all: no block start, and no paragraph promotion, can begin with any other character. `|` and `:` are here for the GFM table delimiter row (`| --- |`, `:-: | ---:`), the only construct in this package that can start with either. `$` is here for a $$ math block's own opening line (ExaDev/markdown-codec#53), and `[` for a footnote definition's own `[^label]:` marker (ExaDev/markdown-codec#66). -const MAYBE_SPECIAL_PATTERN = /^[#$`~*+_=<>[0-9|:-]/; - // spec 0.31.2, "ATX headings": one to six `#` characters, followed by spaces/tabs or the end of the line. Exported for src/emit/emit.ts's own setext-safety check (the setext grammar's third clause, spec 0.31.2 "Setext headings": a non-first line of a would-be setext heading's text may not itself be interpretable as an ATX heading among other constructs) -- reusing this pattern rather than restating it there is what keeps the write side's promotion refusal and this module's own reparse from ever drifting apart. export const ATX_MARKER_PATTERN = /^#{1,6}(?:[ \t]+|$)/; const ATX_ONLY_CLOSING_SEQUENCE_PATTERN = /^[ \t]*#+[ \t]*$/; @@ -75,7 +72,9 @@ const CLOSING_CODE_FENCE_PATTERN = /^(?:`{3,}|~{3,})(?=[ \t]*$)/; // Pandoc/GitHub math-extension display math (ExaDev/markdown-codec#53): a line consisting of exactly $$, optionally followed by trailing spaces/tabs and nothing else -- deliberately stricter than the code-fence pattern above (no "info string", no variable length): both the opening and the closing line must match this exact shape, which is what makes a bare "$$" line on its own unambiguous rather than colliding with GFM's own single-dollar-free inline math (this package never adds inline $$ recognition at all, only \( \)). Exported for the same setext-safety reuse as ATX_MARKER_PATTERN above: a $$ line interrupts an open paragraph exactly as a code fence does (see src/emit/emit.ts's own canInterruptOpenParagraph), so a would-be setext heading's own line matching it is just as much a paragraph-interrupting construct as the six CommonMark names explicitly. export const MATH_BLOCK_MARKER_PATTERN = /^\$\$[ \t]*$/; -const MATH_BLOCK_MARKER_LENGTH = 2; + +// The line ending addLine puts after every line it accumulates, whatever the source line itself ended with (see addLine, and the note on BLOCK_EDGE_SPACE_OR_TAB_PATTERN below for why it is there at all). +const ACCUMULATED_LINE_ENDING = "\n"; // spec 0.31.2, "Setext headings": a sequence of `=` or of `-`, optionally followed by spaces/tabs, and nothing else. const SETEXT_UNDERLINE_PATTERN = /^(?:=+|-+)[ \t]*$/; @@ -154,8 +153,8 @@ class BlockParser { // The tip as it stood before the current line was processed, and the deepest block that line matched -- together they say exactly which blocks the line failed to continue, which closeUnmatchedBlocks then closes. private oldTip: BlockNode = this.document; private lastMatchedContainer: BlockNode = this.document; - private allClosed = true; - private line = new LineCursor(""); + // No initial value: incorporateLine constructs the line's own cursor as its first act, so every read below happens against the line currently being processed and a seed here could never be observed. LineCursor.lineIsBlank carries no default for the same reason (src/block/line.ts). + private line!: LineCursor; private lineNumber = 0; // Depth of `this.tip` below `this.document` -- maintained incrementally (incremented in addChild, decremented in finalize) rather than walked from `parent` on every check, so the guard costs nothing per line for ordinary, shallow documents. private nestingDepth = 0; @@ -180,7 +179,8 @@ class BlockParser { this.incorporateLine(text); } } - while (this.tip.open) { + // Close every block the input left open, innermost first: finalize moves the tip to the closed block's own parent, so this walks up the open chain and stops at the document. The document itself is deliberately never finalised: it is the one block that never went through addChild, so leaving it out is what lets finalize decrement the nesting depth unconditionally, and its own finalizeContent case does nothing anyway. + while (this.tip !== this.document) { this.reportUnterminatedAtEof(this.tip); this.finalize(this.tip); } @@ -189,7 +189,8 @@ class BlockParser { // Recover-tier diagnostics for a leaf block that reached end-of-input without ever meeting its own proper closing condition: a fenced code block whose closing fence never arrived, or an HTML block of type 1-5 (whose end condition is a pattern in the line's own text, not a blank line) that reached EOF without ever matching it. Types 6/7 end at a blank line OR at EOF alike -- both are the block's own ordinary, spec-legal end condition, so EOF is not a diagnostic there. private reportUnterminatedAtEof(node: BlockNode): void { - if (node.kind === "codeBlock" && node.fenced) { + // `fenced` is set by the fenced-code start and by nothing else, so it identifies the block on its own, with no kind to test alongside it. + if (node.fenced) { this.sink({ code: MarkdownDiagnosticCodes.UNCLOSED_FENCE, severity: "warning", @@ -230,7 +231,6 @@ class BlockParser { return; } - this.allClosed = matched === this.oldTip; this.lastMatchedContainer = matched; this.addTextToContainer(this.openNewBlocks(matched)); @@ -246,11 +246,9 @@ class BlockParser { } this.line.findNextNonspace(); const result = this.continueBlock(lastChild); - if (result === "finished") { - return undefined; - } - if (result === "not-matched") { - return container; + if (result !== "matched") { + // 'finished': the block took this line as its own closing delimiter and closed itself on it, so there is nothing left of the line for anything to see. 'not-matched': the line does not continue `lastChild`, so `container` is the deepest block it does continue. + return result === "finished" ? undefined : container; } container = lastChild; } @@ -399,13 +397,6 @@ class BlockParser { acceptsLines(container.kind); while (!matchedLeaf) { this.line.findNextNonspace(); - if ( - !this.line.indented && - !MAYBE_SPECIAL_PATTERN.test(this.line.restFromNextNonspace()) - ) { - this.line.advanceToNextNonspace(); - break; - } const result = this.tryBlockStart(container); if (result === "none") { this.line.advanceToNextNonspace(); @@ -417,7 +408,7 @@ class BlockParser { return container; } - // The fixed precedence order. See this module's own top-of-file note for what depends on it. + // The fixed precedence order. See this module's own top-of-file note for what depends on it. Each start declines on its own, on the line's indentation or on its first character, before doing any real work, so the list is a complete answer for every line, ordinary paragraph text included. private tryBlockStart(container: BlockNode): BlockStartResult { const starts = [ () => this.tryBlockquoteStart(), @@ -425,7 +416,7 @@ class BlockParser { () => this.tryCodeFenceStart(), () => this.tryMathBlockStart(), () => this.tryFootnoteDefinitionStart(container), - () => this.tryHtmlBlockStart(container), + () => this.tryHtmlBlockStart(), () => this.tryPromoteParagraph(container), () => this.tryThematicBreakStart(), () => this.tryListItemStart(container), @@ -458,7 +449,6 @@ class BlockParser { if (match === null) { return "none"; } - this.line.advanceToNextNonspace(); this.closeUnmatchedBlocks(); const heading = this.addChild("heading"); heading.level = headingLevelOf(match[0].trim().length); @@ -467,6 +457,7 @@ class BlockParser { .slice(match[0].length) .replace(ATX_ONLY_CLOSING_SEQUENCE_PATTERN, "") .replace(ATX_TRAILING_CLOSING_SEQUENCE_PATTERN, ""); + // The whole line is the heading, its own leading indentation included, so there is nothing left for any later step to read a position out of. this.line.advanceToEndOfLine(); return "leaf"; } @@ -495,7 +486,7 @@ class BlockParser { return "leaf"; } - // A $$ line -- the whole line, nothing else (MATH_BLOCK_MARKER_PATTERN) -- opens a math block, interrupting an open paragraph exactly as a code fence does. The cursor advances past "$$" only, not to end of line, leaving whatever (should only be trailing whitespace) remains as the block's own first content line -- finalizeMathBlock strips that first line back off, mirroring finalizeCodeBlock's own info-string slot. + // A $$ line, meaning the whole line and nothing else (MATH_BLOCK_MARKER_PATTERN), opens a math block, interrupting an open paragraph exactly as a code fence does. The whole opening line is consumed here, unlike a code fence's own opening line: the marker pattern has already matched the line to its end, so there is nothing after the marker that could be an info string or content. What the block accumulates is therefore exactly its literal, once the line ending addLine appends to that consumed opening line is dropped (finalizeMathBlock). private tryMathBlockStart(): BlockStartResult { if (this.line.indented) { return "none"; @@ -505,8 +496,7 @@ class BlockParser { } this.closeUnmatchedBlocks(); this.addChild("mathBlock"); - this.line.advanceToNextNonspace(); - this.line.advance(MATH_BLOCK_MARKER_LENGTH); + this.line.advanceToEndOfLine(); return "leaf"; } @@ -559,14 +549,12 @@ class BlockParser { ); } - private tryHtmlBlockStart(container: BlockNode): BlockStartResult { - if (this.line.indented || this.line.peekNextNonspace() !== "<") { + private tryHtmlBlockStart(): BlockStartResult { + if (this.line.indented) { return "none"; } - // Start condition 7 may not interrupt a paragraph -- neither the paragraph this line would break out of, nor one this line could instead continue lazily. - const interruptsParagraph = - container.kind === "paragraph" || - (!this.allClosed && !this.line.blank && this.tip.kind === "paragraph"); + // Start condition 7 may not interrupt a paragraph: neither the paragraph this line would break out of, nor one this line could instead continue lazily. One test covers both, because a paragraph is a leaf. While one is open it IS the tip, whether the line reached it (the first case) or stopped at some container above it (the second). + const interruptsParagraph = this.tip.kind === "paragraph"; const type = matchHtmlBlockStart( this.line.restFromNextNonspace(), interruptsParagraph, @@ -582,6 +570,8 @@ class BlockParser { // The paragraph-promotion hook. Both constructs it covers convert an ALREADY-OPEN paragraph because of the line that follows it, rather than starting a block of their own from that line. // + // Neither promotion closes unmatched blocks, and neither needs to: a paragraph is a leaf, so an open one is always the tip, and this hook only runs when the line's own continuation walk reached that very paragraph, which is to say when there is nothing left open below the deepest block the line matched. + // // Precedence between the two, and against the thematic-break matcher that runs after this hook: a bare `---` is genuinely ambiguous between a thematic break, a setext level-2 underline, and -- on the face of the GFM prose, which defines a row as cells "separated by pipes" and so allows a one-cell row with no pipe at all -- a single-column table delimiter row. It is resolved by testing the setext underline FIRST and by requiring a delimiter row to contain a pipe (see src/block/table.ts), which between them make the three cases disjoint rather than merely ordered: `---` is never a delimiter row, `--- | ---` is never a setext underline, and a thematic break is only ever reached when the open paragraph rejected both. private tryPromoteParagraph(container: BlockNode): BlockStartResult { if (this.line.indented || container.kind !== "paragraph") { @@ -598,7 +588,6 @@ class BlockParser { if (match === null) { return "none"; } - this.closeUnmatchedBlocks(); // Definitions at the front of the paragraph are consumed here rather than at paragraph finalisation, since what is left decides whether there is a heading at all: `[foo]: /url` followed by `---` is a definition and a thematic break, not an empty heading. paragraph.content = extractDefinitions( paragraph.content, @@ -638,12 +627,11 @@ class BlockParser { return "none"; } - this.closeUnmatchedBlocks(); paragraph.content = lines .slice(0, -2) .map((text) => `${text}\n`) .join(""); - this.finalize(paragraph); + // The paragraph is closed by addChild rather than here: a paragraph cannot contain a table, so the table's own start walks the tip up past it, finalising it on the way and leaving whatever is left of the paragraph as the table's preceding sibling. const table = this.addChild("table"); table.alignments = alignments; table.headerLine = headerLine; @@ -699,8 +687,8 @@ class BlockParser { // Step 3: whatever is left of the line becomes content. private addTextToContainer(container: BlockNode): void { - if (!this.allClosed && !this.line.blank && this.tip.kind === "paragraph") { - // Lazy continuation: the line failed to continue some enclosing container, but it is ordinary paragraph text, so the paragraph absorbs it and nothing closes. + // An open paragraph is always the tip, so any non-blank line reaching this point is that paragraph's own next line: either the continuation walk reached the paragraph itself, or it stopped at some container above it and this is LAZY CONTINUATION, where the paragraph absorbs the line and nothing closes. The two are one step, not two: neither closes anything, and neither records a blank line, since the line is not one. + if (this.tip.kind === "paragraph" && !this.line.blank) { this.addLine(); return; } @@ -723,22 +711,22 @@ class BlockParser { return; } if (!this.line.atEnd && !this.line.blank) { + // No advanceToNextNonspace before the line is added: this branch is only ever reached through openNewBlocks' own "nothing starts here" exit, which has already moved the cursor to the line's first non-space character. this.addChild("paragraph"); - this.line.advanceToNextNonspace(); this.addLine(); } } - // A block quote's own lines are never blank (they start with `>`), a fenced code block's blank lines are content rather than separators, and an empty list item's first blank line is the one the spec explicitly allows -- none of the three may make a list loose. Every other blank line is recorded on the whole open chain, since a blank line deep inside a list separates the blocks of every ancestor it sits in. + // A block quote's own lines are never blank (they start with `>`), a fenced block's blank lines are content rather than separators (`fenced` is set by the fenced-code start and by nothing else, so it names that block on its own), and a list item is never itself the block a blank line separates. None of the three may make a list loose. Every other blank line is recorded on the whole open chain, since a blank line deep inside a list separates the blocks of every ancestor it sits in. + // + // The list-item case covers both halves of the spec's own rule with one test, because a blank line reaches this function with an item as its deepest matched container in exactly two situations. An item that already has content records the blank line on its own last child instead (the lastLineBlank assignment in addTextToContainer above), which is the block the separation is really between and the one endsWithBlankLine finds by descending (src/block/list.ts). An item with no content at all can only be one whose own marker is on this very line, since continueListItem refuses a blank line for a childless item, and that first blank line is the one the spec explicitly allows an item to begin with. private recordBlankLineForTightness(container: BlockNode): void { const blank = this.line.blank && !( container.kind === "blockquote" || - (container.kind === "codeBlock" && container.fenced) || - (container.kind === "listItem" && - container.children.length === 0 && - container.startLine === this.lineNumber) + container.fenced || + container.kind === "listItem" ); for ( let node: BlockNode | undefined = container; @@ -750,7 +738,7 @@ class BlockParser { } private addLine(): void { - this.tip.content += `${this.line.rest()}\n`; + this.tip.content += `${this.line.rest()}${ACCUMULATED_LINE_ENDING}`; } private addChild(kind: BlockNodeKind): BlockNode { @@ -767,10 +755,8 @@ class BlockParser { return node; } + // Closes every block the current line failed to continue, innermost first. The walk up from `oldTip` stops at the deepest block the line did match, so calling this twice in one line is safe: the second call finds the two already equal and does nothing. private closeUnmatchedBlocks(): void { - if (this.allClosed) { - return; - } while (this.oldTip !== this.lastMatchedContainer) { const parent = this.oldTip.parent; this.finalize(this.oldTip); @@ -779,18 +765,15 @@ class BlockParser { } this.oldTip = parent; } - this.allClosed = true; } private finalize(node: BlockNode): void { const above = node.parent; node.open = false; this.finalizeContent(node); - // The document itself is never pushed through addChild, so it never incremented nestingDepth -- only a real child's own close pays back the push that opened it. - if (node !== this.document) { - this.nestingDepth -= 1; - } - // The document has no parent: closing it leaves the tip on the now-closed root, which is exactly the terminating condition parse()'s own close-everything loop tests. + // Every block that reaches here was pushed through addChild, which is what incremented nestingDepth. The document, the one block that was not, is never finalised (see parse). + this.nestingDepth -= 1; + // A node whose own parent is gone, as a paragraph replaced in place by a promotion is, leaves the tip on the document rather than nowhere. this.tip = above ?? this.document; } @@ -823,8 +806,6 @@ class BlockParser { case "list": finalizeListTightness(node); return; - default: - return; } } @@ -839,10 +820,9 @@ class BlockParser { node.literal = node.content.slice(breakIndex + 1); } - // Mirrors finalizeCodeBlock's own fenced branch: the opening "$$" line's own (whitespace-only) remainder is always present as content's first line -- see tryMathBlockStart -- and is stripped off here the same way an opening fence's info-string line is. + // The opening "$$" line is consumed in full by tryMathBlockStart, so the only thing it leaves behind in `content` is the line ending addLine appends to every line it accumulates. Dropping that one character is the whole of the conversion: there is no info-string line to find and slice past, as there is for a fenced code block. private finalizeMathBlock(node: BlockNode): void { - const breakIndex = node.content.indexOf("\n"); - node.literal = breakIndex === -1 ? "" : node.content.slice(breakIndex + 1); + node.literal = node.content.slice(ACCUMULATED_LINE_ENDING.length); } } @@ -976,8 +956,9 @@ function toTableNode( context, ), ]; - for (const rowLine of node.content.split("\n")) { - if (rowLine.trim().length === 0) { + for (const rowLine of node.content.split(ACCUMULATED_LINE_ENDING)) { + // The only empty elements here are the delimiter row's own consumed line and the one the final line ending leaves after it: a blank line does not continue a table at all (see continueBlock), so no line the table actually accumulated is ever whitespace-only. + if (rowLine.length === 0) { continue; } const cells = splitTableRow(rowLine); From fe1cbd313719ebbffd9e31d129bef1df59d94684 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 20 Sep 2026 08:48:53 +0100 Subject: [PATCH 95/99] test(markdown-codec): cover the block parser's container continuation and promotion boundaries Adds the structural boundaries nothing asserted individually: lazy continuation into and out of a block quote, list-item indentation at and past the continuation threshold, a blank line an indented code block carries, fence closing by length and by marker, the HTML block start and end conditions, setext promotion of a paragraph and its refusal, table header alignment and cell-count handling, and the nesting limit. The existing task-list test also now asserts that an ordinary item has no `checked` key at all, since structural equality cannot separate an absent key from one present and undefined. --- .../markdown-codec/src/block/block.test.ts | 331 +++++++++++++++++- 1 file changed, 330 insertions(+), 1 deletion(-) diff --git a/packages/markdown-codec/src/block/block.test.ts b/packages/markdown-codec/src/block/block.test.ts index e1a92452d2..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", () => { @@ -461,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