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.
This commit is contained in:
paseo-bot[bot]
2026-11-05 08:31:33 +00:00
parent f98495c4b3
commit 3bb9d81520
4 changed files with 82 additions and 40 deletions
@@ -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(
<EnrichedMarkdownText
markdown={normalizeMarkdownHtml(
"| A | B |\n|---|---|\n| 1 | first<br/>second |\n| 2 | first<br />second |\n| 3 | first<br>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<br/>second |\n| 2 | first<br />second |\n| 3 | first<br>second |";
const source = details ? `<details><summary>Results</summary>\n${table}\n</details>` : table;
try {
act(() =>
root.render(
<EnrichedMarkdownText markdown={normalizeMarkdownHtml(source)} 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,
);
}
} finally {
act(() => root.unmount());
container.remove();
}
} finally {
act(() => root.unmount());
container.remove();
}
});
},
);
+9 -8
View File
@@ -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 <details> remains visible to the Markdown parser.
// Disable HTML parsing so code and tables inside <details> 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 => {
@@ -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 =
"<details><summary>Results</summary>\n| A | B |\n|---|---|\n| 1 | first<br>second |\n</details>";
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<br>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<br>B | C |\n|---|---|\n| x | y |", "| A\u2028B | C |\n|---|---|\n| x | y |"],
[
@@ -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}|(?<![a-zA-Z0-9\u0080-\uFFFF_@.%\x2b~:/\\-])(?:${path}|${FILE_NAME})${LINE_SUFFIX}(?![a-zA-Z0-9\u0080-\uFFFF_@%+:/\\-]|\.${PATH_CHARACTER}))`,
String.raw`(?:${BARE_URL}${forgeReference}|(?<![a-zA-Z0-9\u0080-\u2027\u2029-\uFFFF_@.%\x2b~:/\\-])(?:${path}|${FILE_NAME})${LINE_SUFFIX}(?![a-zA-Z0-9\u0080-\u2027\u2029-\uFFFF_@%+:/\\-]|\.${PATH_CHARACTER}))`,
"g",
);
}