From 3bb9d815208b56543a82ccd54440d0b7b52be73e Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Thu, 5 Nov 2026 08:31:33 +0000 Subject: [PATCH] fix(markdown): preserve wrapped table breaks and separate file links Use the HTML-disabled Markdown pass to find tables exposed by wrapper normalization. Treat inline line separators as file-link boundaries so adjacent paths remain independently usable. --- .../components/markdown/html.browser.test.tsx | 61 ++++++++++--------- packages/app/src/components/markdown/html.ts | 17 +++--- .../src/components/markdown/renderer.test.ts | 37 +++++++++++ .../app/src/components/markdown/source.ts | 7 ++- 4 files changed, 82 insertions(+), 40 deletions(-) diff --git a/packages/app/src/components/markdown/html.browser.test.tsx b/packages/app/src/components/markdown/html.browser.test.tsx index 4f989d138d..4382e88728 100644 --- a/packages/app/src/components/markdown/html.browser.test.tsx +++ b/packages/app/src/components/markdown/html.browser.test.tsx @@ -7,33 +7,36 @@ import { normalizeMarkdownHtml } from "./html"; const FLAGS = { hardSoftBreaks: true, latexMath: false }; -it("renders HTML breaks on separate lines inside the same table cell", async () => { - const container = document.createElement("div"); - document.body.appendChild(container); - const root = createRoot(container); - try { - act(() => - root.render( - second |\n| 2 | first
second |\n| 3 | first
second |", - )} - md4cFlags={FLAGS} - />, - ), - ); - await waitFor(() => expect(within(container).getAllByRole("row")).toHaveLength(4)); - for (const row of within(container).getAllByRole("row").slice(1)) { - const cell = within(row).getAllByRole("cell")[1]; - expect(cell.innerText).toBe("first\nsecond"); - const first = document.createRange(); - first.selectNodeContents(cell.firstChild!); - const second = document.createRange(); - second.selectNodeContents(cell.lastChild!); - expect(second.getBoundingClientRect().top).toBeGreaterThan(first.getBoundingClientRect().top); +it.each([false, true])( + "renders table-cell breaks on separate lines (details: %s)", + async (details) => { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + const table = + "| A | B |\n|---|---|\n| 1 | first
second |\n| 2 | first
second |\n| 3 | first
second |"; + const source = details ? `
Results\n${table}\n
` : table; + try { + act(() => + root.render( + , + ), + ); + await waitFor(() => expect(within(container).getAllByRole("row")).toHaveLength(4)); + for (const row of within(container).getAllByRole("row").slice(1)) { + const cell = within(row).getAllByRole("cell")[1]; + expect(cell.innerText).toBe("first\nsecond"); + const first = document.createRange(); + first.selectNodeContents(cell.firstChild!); + const second = document.createRange(); + second.selectNodeContents(cell.lastChild!); + expect(second.getBoundingClientRect().top).toBeGreaterThan( + first.getBoundingClientRect().top, + ); + } + } finally { + act(() => root.unmount()); + container.remove(); } - } finally { - act(() => root.unmount()); - container.remove(); - } -}); + }, +); diff --git a/packages/app/src/components/markdown/html.ts b/packages/app/src/components/markdown/html.ts index e807234c77..bdc054e5bc 100644 --- a/packages/app/src/components/markdown/html.ts +++ b/packages/app/src/components/markdown/html.ts @@ -38,9 +38,6 @@ export function normalizeMarkdownHtml(source: string): string { const htmlRanges: { start: number; end: number }[] = []; const tableRanges: { start: number; end: number }[] = []; function findHtml(node: Nodes) { - if (node.type === "table" && node.position) { - tableRanges.push({ start: node.position.start.offset!, end: node.position.end.offset! }); - } if (node.type === "html" && node.position) { htmlRanges.push({ start: node.position.start.offset!, end: node.position.end.offset! }); } else if ("children" in node) node.children.forEach(findHtml); @@ -52,18 +49,22 @@ export function normalizeMarkdownHtml(source: string): string { }), ); const codeRanges: { start: number; end: number }[] = []; - // Disable HTML parsing so code inside
remains visible to the Markdown parser. + // Disable HTML parsing so code and tables inside
remain visible. const tree = fromMarkdown(source, { - extensions: [{ disable: { null: ["htmlFlow", "htmlText"] } }], + extensions: [{ disable: { null: ["htmlFlow", "htmlText"] } }, gfmTable()], + mdastExtensions: [gfmTableFromMarkdown()], }); - function protectCode(node: Nodes) { + function findMarkdownRanges(node: Nodes) { + if (node.type === "table" && node.position) { + tableRanges.push({ start: node.position.start.offset!, end: node.position.end.offset! }); + } if (node.type === "code" || node.type === "inlineCode") { const start = node.position?.start.offset; const end = node.position?.end.offset; if (start !== undefined && end !== undefined) codeRanges.push({ start, end }); - } else if ("children" in node) node.children.forEach(protectCode); + } else if ("children" in node) node.children.forEach(findMarkdownRanges); } - protectCode(tree); + findMarkdownRanges(tree); const links: (string | undefined)[] = []; let previousTag: { name: string; end: number } | null = null; const convert = (raw: string, offset: number): string => { diff --git a/packages/app/src/components/markdown/renderer.test.ts b/packages/app/src/components/markdown/renderer.test.ts index 3bf441f667..1594399c07 100644 --- a/packages/app/src/components/markdown/renderer.test.ts +++ b/packages/app/src/components/markdown/renderer.test.ts @@ -62,6 +62,43 @@ describe("shared Markdown source adaptation", () => { ["3", "first\u2028second"], ]); }); + it("keeps table breaks inside a details wrapper without a blank line", async () => { + const source = + "
Results\n| A | B |\n|---|---|\n| 1 | first
second |\n
"; + const tree = await parseMarkdown(prepareMarkdownSource(source).render(), { + hardSoftBreaks: true, + latexMath: false, + }); + const tables: ASTNode[] = []; + const collect = (node: ASTNode) => { + if (node.type === "Table") tables.push(node); + node.children?.forEach(collect); + }; + collect(tree); + expect(tables).toHaveLength(1); + expect(tables[0].attributes?.bodyRowCount).toBe("1"); + expect(tables[0].children?.[1]?.children?.[0]?.children?.[1]?.children).toEqual([ + { type: "Text", content: "first\u2028second" }, + ]); + }); + it("recognizes file links separately on either side of a table break", async () => { + const prepared = prepareMarkdownSource( + "| A | B |\n|---|---|\n| 1 | src/first.ts
src/second.ts |", + "/repo", + ); + const tree = recognizeTextLinks( + await parseMarkdown(prepared.render(), { hardSoftBreaks: true, latexMath: false }), + prepared.linkRecognition.text, + prepared.linkRecognition.inlineCode, + ); + const links: string[] = []; + const collect = (node: ASTNode) => { + if (node.type === "Link") links.push(String(node.attributes?.url)); + node.children?.forEach(collect); + }; + collect(tree); + expect(links).toEqual(["src/first.ts", "src/second.ts"]); + }); it.each([ ["| A
B | C |\n|---|---|\n| x | y |", "| A\u2028B | C |\n|---|---|\n| x | y |"], [ diff --git a/packages/app/src/components/markdown/source.ts b/packages/app/src/components/markdown/source.ts index 8b15b8ff98..ec0644998f 100644 --- a/packages/app/src/components/markdown/source.ts +++ b/packages/app/src/components/markdown/source.ts @@ -10,9 +10,10 @@ import type { Link, Nodes, Paragraph, PhrasingContent, Root } from "mdast"; // Keep this regex in the syntax shared by JavaScript, ICU and java.util.regex. // The Unicode flag makes Metro expand it to escapes rejected by native engines. -const PATH_CHARACTER = String.raw`[a-zA-Z0-9\u0080-\uFFFF_@.%+-]`; +// U+2028 is an inline line break, including between table-cell file links. +const PATH_CHARACTER = String.raw`[a-zA-Z0-9\u0080-\u2027\u2029-\uFFFF_@.%+-]`; const PATH_LETTER = String.raw`[a-zA-Z]`; -const PATH_SEGMENT = String.raw`${PATH_CHARACTER}*[a-zA-Z0-9\u0080-\uFFFF_@%+-]`; +const PATH_SEGMENT = String.raw`${PATH_CHARACTER}*[a-zA-Z0-9\u0080-\u2027\u2029-\uFFFF_@%+-]`; const LINE_SUFFIX = String.raw`(?::\d+(?::\d+)?(?:-\d+(?::\d+)?)?)?`; const FILE_EXTENSIONS = [...ASSISTANT_FILE_EXTENSIONS] .map((extension) => extension.replace(/[a-z]/g, (letter) => `[${letter}${letter.toUpperCase()}]`)) @@ -29,7 +30,7 @@ function createTextPattern(path: string, forgeReferencePattern?: string): RegExp const forgeReference = forgeReferencePattern ? `|${forgeReferencePattern}` : ""; // The native adapter mistakes a literal + in lookbehind for a quantifier; use its hex escape. return new RegExp( - String.raw`(?:${BARE_URL}${forgeReference}|(?