mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-06 08:24:46 +00:00
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:
@@ -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();
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user