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}|(?