mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-06 00:14:54 +00:00
fix(markdown): keep HTML line breaks inside table cells
Normalize table breaks to Unicode line separators so parsing does not split the row. Render those separators as explicit web line breaks, preserving paragraph and literal-code behavior.
This commit is contained in:
@@ -0,0 +1,39 @@
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { within, waitFor } from "@testing-library/dom";
|
||||
import { EnrichedMarkdownText } from "react-native-enriched-markdown";
|
||||
import { expect, it } from "vitest";
|
||||
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);
|
||||
}
|
||||
} finally {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
}
|
||||
});
|
||||
@@ -1,5 +1,7 @@
|
||||
import { Parser } from "htmlparser2";
|
||||
import { fromMarkdown } from "mdast-util-from-markdown";
|
||||
import { gfmTable } from "micromark-extension-gfm-table";
|
||||
import { gfmTableFromMarkdown } from "mdast-util-gfm-table";
|
||||
import { decodeString } from "micromark-util-decode-string";
|
||||
import type { Nodes } from "mdast";
|
||||
|
||||
@@ -34,12 +36,21 @@ const EMPHASIS: Readonly<Record<string, string>> = { b: "**", strong: "**", i: "
|
||||
export function normalizeMarkdownHtml(source: string): string {
|
||||
if (!source.includes("<")) return source;
|
||||
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);
|
||||
}
|
||||
findHtml(fromMarkdown(source));
|
||||
findHtml(
|
||||
fromMarkdown(source, {
|
||||
extensions: [gfmTable()],
|
||||
mdastExtensions: [gfmTableFromMarkdown()],
|
||||
}),
|
||||
);
|
||||
const codeRanges: { start: number; end: number }[] = [];
|
||||
// Disable HTML parsing so code inside <details> remains visible to the Markdown parser.
|
||||
const tree = fromMarkdown(source, {
|
||||
@@ -84,6 +95,13 @@ export function normalizeMarkdownHtml(source: string): string {
|
||||
: /^\s*$/.test(source.slice(0, offset));
|
||||
previousTag = { name, end: offset + raw.length };
|
||||
if (EMPHASIS[name]) return EMPHASIS[name];
|
||||
if (name === "br") {
|
||||
if (closing) return "";
|
||||
// A Unicode line separator renders a break without ending the GFM row.
|
||||
return tableRanges.some(({ start, end }) => offset >= start && offset < end)
|
||||
? "\u2028"
|
||||
: " \n";
|
||||
}
|
||||
const boundary = layoutBoundary(name, source, raw, offset, closing, startsAfterBoundary);
|
||||
if (boundary !== null) return boundary;
|
||||
if (name === "a") return markdownAnchor(raw, closing, links);
|
||||
@@ -168,7 +186,6 @@ function layoutBoundary(
|
||||
closing: boolean,
|
||||
startsAfterBoundary: boolean,
|
||||
): string | null {
|
||||
if (name === "br") return closing ? "" : " \n";
|
||||
if (name === "summary") return closing ? "**\n\n" : "**";
|
||||
if (name === "details")
|
||||
return (closing ? offset + raw.length === source.length : offset === 0) ? "" : "\n\n";
|
||||
|
||||
@@ -39,6 +39,47 @@ it("resolves authored root-relative links only when the target is a known comman
|
||||
});
|
||||
|
||||
describe("shared Markdown source adaptation", () => {
|
||||
it("keeps HTML line breaks inside their Markdown table cells", async () => {
|
||||
const source =
|
||||
"| A | B |\n|---|---|\n| 1 | first<br/>second |\n| 2 | first<br />second |\n| 3 | first<br>second |";
|
||||
const tree = await parseMarkdown(prepareMarkdownSource(source).render(), {
|
||||
hardSoftBreaks: true,
|
||||
latexMath: false,
|
||||
});
|
||||
const rows: ASTNode[] = [];
|
||||
const collect = (node: ASTNode) => {
|
||||
if (node.type === "TableRow") rows.push(node);
|
||||
node.children?.forEach(collect);
|
||||
};
|
||||
collect(tree);
|
||||
expect(rows).toHaveLength(4);
|
||||
const text = (node: ASTNode): string =>
|
||||
(node.content ?? "") + (node.children?.map(text).join("") ?? "");
|
||||
expect(rows.map((row) => row.children?.map(text))).toEqual([
|
||||
["A", "B"],
|
||||
["1", "first\u2028second"],
|
||||
["2", "first\u2028second"],
|
||||
["3", "first\u2028second"],
|
||||
]);
|
||||
});
|
||||
it.each([
|
||||
["| A<br>B | C |\n|---|---|\n| x | y |", "| A\u2028B | C |\n|---|---|\n| x | y |"],
|
||||
[
|
||||
"> | A | B |\n> |---|---|\n> | x | first<br>second |",
|
||||
"> | A | B |\n> |---|---|\n> | x | first\u2028second |",
|
||||
],
|
||||
[
|
||||
"| A | B |\n|---|---|\n| `<br>` | **first<br><BR />second** |",
|
||||
"| A | B |\n|---|---|\n| `<br>` | **first\u2028\u2028second** |",
|
||||
],
|
||||
[
|
||||
"| A | B |\n|---|---|\n| x | \\<br> <br> |",
|
||||
"| A | B |\n|---|---|\n| x | \\<br> <br> |",
|
||||
],
|
||||
["Not a table | first<br>second", "Not a table | first \nsecond"],
|
||||
])("normalizes table breaks without changing literal tags: %s", (source, expected) => {
|
||||
expect(normalizeMarkdownHtml(source)).toBe(expected);
|
||||
});
|
||||
it("keeps paragraphs, fences and quotes together for the library", () => {
|
||||
const source = "First paragraph.\n\nSecond paragraph.\n\n```ts\nconst n = 1;\n```\n\n> Quote";
|
||||
expect(prepareMarkdownSource(source).render()).toBe(source);
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user