Compare commits

...
Author SHA1 Message Date
dbd0370459 chore(markdown): update the table fix to current main
Keep the contributor's table regression tests and source adaptation while
bringing in current browser tooling for supported CI runners.

Co-authored-by: Okan YILDIRIM <63511519+ragokan@users.noreply.github.com>
Co-authored-by: thsrite <thsrite@gmail.com>
Co-authored-by: Woosuk Nam <me@suk.kr>
2026-11-01 17:59:55 +00:00
Okan YILDIRIM 61f668f394 fix(app): preserve table headers during source adaptation
Replace the preceding line ending when inserting a table separator.
This keeps the separator before the header's source range, so entity
and local image edits cannot consume the paragraph break.
2026-10-23 11:54:56 +00:00
Okan YILDIRIM d73e998c94 fix(app): render tables immediately after paragraphs
Insert a paragraph break before GFM tables adjacent to prose so MD4C
recognizes them, preserving quote/list containers and line endings.
2026-10-23 11:18:38 +00:00
2 changed files with 204 additions and 5 deletions
@@ -130,6 +130,186 @@ it("preserves empty table cells while escaping spoilers in cell text", () => {
);
});
it("renders a table directly after a paragraph through the library parser", async () => {
const source = "**Notes**\n| Item | Detail |\n| --- | --- |\n| A | one |\n| B | two |";
const separated = "**Notes**\n\n| Item | Detail |\n| --- | --- |\n| A | one |\n| B | two |";
const flags = { hardSoftBreaks: true, latexMath: false };
const expected = await parseMarkdown(separated, flags);
expect(expected.children?.map((node) => node.type)).toEqual(["Paragraph", "Table"]);
expect(await parseMarkdown(prepareMarkdownSource(source).render(), flags)).toEqual(expected);
});
it.each([
[
"a quote",
"> Notes\n> | Item |\n> | --- |\n> | A |",
"> Notes\n>\n> | Item |\n> | --- |\n> | A |",
["Document", ["Blockquote", "Paragraph", "Table"]],
],
[
"a list item",
"- Notes\n | Item |\n | --- |\n | A |\n- Next",
"- Notes\n\n | Item |\n | --- |\n | A |\n- Next",
["Document", ["UnorderedList", ["ListItem", "Paragraph", "Table"], ["ListItem", "Paragraph"]]],
],
[
"a quoted list item",
"> - Notes\n> | Item |\n> | --- |\n> | A |",
"> - Notes\n>\n> | Item |\n> | --- |\n> | A |",
["Document", ["Blockquote", ["UnorderedList", ["ListItem", "Paragraph", "Table"]]]],
],
[
"a quote inside a list item",
"- > Notes\n > | Item |\n > | --- |\n > | A |",
"- > Notes\n >\n > | Item |\n > | --- |\n > | A |",
["Document", ["UnorderedList", ["ListItem", ["Blockquote", "Paragraph", "Table"]]]],
],
[
"aligned columns without outer pipes",
"Notes\nItem | Detail\n:--- | ---:\nA | one",
"Notes\n\nItem | Detail\n:--- | ---:\nA | one",
["Document", "Paragraph", "Table"],
],
[
"an entity at the start of a header without outer pipes",
"Notes\nAT&amp;T | Detail\n--- | ---\nA | one",
"Notes\n\nAT\\&T | Detail\n--- | ---\nA | one",
["Document", "Paragraph", "Table"],
],
[
"a local image at the start of a header without outer pipes",
"Notes\n![Local](assets/a.png) | Detail\n--- | ---\nA | one",
"Notes\n\nLocal | Detail\n--- | ---\nA | one",
["Document", "Paragraph", "Table"],
],
[
"CRLF lines",
"Notes\r\n| Item |\r\n| --- |\r\n| A |",
"Notes\r\n\r\n| Item |\r\n| --- |\r\n| A |",
["Document", "Paragraph", "Table"],
],
[
"CR lines",
"Notes\r| Item |\r| --- |\r| A |",
"Notes\r\r| Item |\r| --- |\r| A |",
["Document", "Paragraph", "Table"],
],
[
"a quote with CRLF lines",
"> Notes\r\n> | Item |\r\n> | --- |\r\n> | A |",
"> Notes\r\n>\r\n> | Item |\r\n> | --- |\r\n> | A |",
["Document", ["Blockquote", "Paragraph", "Table"]],
],
[
"a list item with CRLF lines",
"- Notes\r\n | Item |\r\n | --- |\r\n | A |\r\n- Next",
"- Notes\r\n\r\n | Item |\r\n | --- |\r\n | A |\r\n- Next",
["Document", ["UnorderedList", ["ListItem", "Paragraph", "Table"], ["ListItem", "Paragraph"]]],
],
[
"a quote with CR lines",
"> Notes\r> | Item |\r> | --- |\r> | A |",
"> Notes\r>\r> | Item |\r> | --- |\r> | A |",
["Document", ["Blockquote", "Paragraph", "Table"]],
],
[
"a list item with CR lines",
"- Notes\r | Item |\r | --- |\r | A |\r- Next",
"- Notes\r\r | Item |\r | --- |\r | A |\r- Next",
["Document", ["UnorderedList", ["ListItem", "Paragraph", "Table"], ["ListItem", "Paragraph"]]],
],
[
"a non-interrupting ordered marker with tildes",
"Intro\n2. ~~~\n Notes\n | A |\n | --- |\n | x |",
"Intro\n2. ~~~\n Notes\n\n | A |\n | --- |\n | x |",
["Document", "Paragraph", "Table"],
],
[
"a non-interrupting ordered marker with backticks",
"Notes\n2. ```\n | Item |\n | --- |\n | A |",
"Notes\n2. ```\n\n | Item |\n | --- |\n | A |",
["Document", "Paragraph", "Table"],
],
])("preserves tables after paragraphs in %s", async (_name, source, separated, expected) => {
const rendered = prepareMarkdownSource(source).render();
expect(rendered).toEqual(separated);
const flags = { hardSoftBreaks: true, latexMath: false };
function blocks(node: ASTNode): unknown {
if (node.type === "Paragraph" || node.type === "Table") return node.type;
return [node.type, ...(node.children ?? []).map(blocks)];
}
expect(blocks(await parseMarkdown(rendered, flags))).toEqual(expected);
});
it.each([
[
"a literal backslash",
"Notes\\\n| Item |\n| --- |\n| A |",
"Notes\\\n\n| Item |\n| --- |\n| A |",
"Notes\\",
],
[
"an entity and literal backslash with LF lines",
"AT&amp;T\\\n| Item |\n| --- |\n| A |",
"AT\\&T\\\n\n| Item |\n| --- |\n| A |",
"AT&T\\",
],
[
"an entity and literal backslash with CRLF lines",
"AT&amp;T\\\r\n| Item |\r\n| --- |\r\n| A |",
"AT\\&T\\\r\n\r\n| Item |\r\n| --- |\r\n| A |",
"AT&T\\",
],
[
"an entity and literal backslash with CR lines",
"AT&amp;T\\\r| Item |\r| --- |\r| A |",
"AT\\&T\\\r\r| Item |\r| --- |\r| A |",
"AT&T\\",
],
])(
"preserves paragraph text before an interrupting table in %s",
async (_name, source, separated, text) => {
const rendered = prepareMarkdownSource(source).render();
expect(rendered).toEqual(separated);
const tree = await parseMarkdown(rendered, { hardSoftBreaks: true, latexMath: false });
expect(tree.children?.map((node) => node.type)).toEqual(["Paragraph", "Table"]);
expect(tree.children?.[0]?.children?.map((node) => node.content).join("")).toEqual(text);
},
);
it.each([
"```yaml\n---\nkey: value\n---\n```\n",
"~~~md\n| Item |\n| --- |\n| A |\n~~~\n",
"````md\n```\n| Item |\n| --- |\n```\n`````\n",
"> ```md\n> | Item |\n> | --- |\n> ```\n",
"> ```md\n> Code\n",
"- ```md\n Code\n",
" ```\n\n",
"```not`a-fence\n",
])("renders a table after a fence without changing its code: %s", async (prefix) => {
const table = "| Item |\n| --- |\n| A |";
const rendered = prepareMarkdownSource(`${prefix}Notes\n${table}`).render();
expect(rendered).toEqual(`${prefix}Notes\n\n${table}`);
const tree = await parseMarkdown(rendered, { hardSoftBreaks: true, latexMath: false });
expect(tree.children?.slice(-2).map((node) => node.type)).toEqual(["Paragraph", "Table"]);
});
it.each([
"| Item | Detail |\n| --- | --- |\n" + "| A | one |\n".repeat(100),
"Notes\n\n| Item | Detail |\n| --- | --- |\n" + "| A | one |\n".repeat(100),
"Notes\n\n| Item |\n| --- |\n| A |",
"| Item |\n| --- |\n| A |",
"```md\nNotes\n| Item |\n| --- |\n| A |\n```",
"```yaml\n---\nkey: value\n---\n```",
"```yaml\n---\nkey: value",
"~~~md\n| Item |\n| --- |\n| A |\n~~~",
"````md\n```\n| Item |\n| --- |\n```\n````",
" Notes\n | Item |\n | --- |\n | A |",
"Notes\n| Item | Detail |\n| --- |\n| A |",
])("leaves separated tables and table-like text unchanged: %s", (source) => {
expect(prepareMarkdownSource(source).render()).toEqual(source);
});
it.each([
"src/components/message.tsx",
"C:\\repo\\file.ts",
+24 -5
View File
@@ -138,6 +138,10 @@ export function prepareMarkdownSource(
!source.includes("![") &&
!source.includes("||") &&
!source.includes("&") &&
// Match content, header, and delimiter lines; quote-only lines count as blank.
!/^[\t >]*[^\t >\r\n][^\r\n]*(?:\r\n|\r|\n)[^\r\n]+(?:\r\n|\r|\n)(?=[\t >:|-]*\|)[\t >|]*:?-[-\t :|]*$/m.test(
source,
) &&
!(workspaceRoot && source.includes("]("))
) {
return { images, ...metadata, render: () => source };
@@ -212,11 +216,25 @@ export function prepareMarkdownSource(
if (lineEnd < end) split(lineEnd);
}
}
function visit(node: Nodes) {
const start = node.position?.start.offset;
const end = node.position?.end.offset;
if (start !== undefined && end !== undefined) {
function visit(node: Nodes, previous?: Nodes) {
const position = node.position;
if (position?.start.offset !== undefined && position.end.offset !== undefined) {
const start = position.start.offset;
const end = position.end.offset;
if (node.type === "paragraph") splitStandaloneImages(node, start, end);
if (
node.type === "table" &&
previous?.type === "paragraph" &&
previous.position?.end.line === position.start.line - 1
) {
// MD4C only recognizes a table header on the first line of a paragraph.
const lineStart =
Math.max(source.lastIndexOf("\n", start - 1), source.lastIndexOf("\r", start - 1)) + 1;
const newline = source.slice(lineStart - 2, lineStart).replace(/[^\r\n]/g, "");
const blank = source.slice(lineStart, start).replace(/[^>]/g, " ").trimEnd();
const boundary = lineStart - newline.length;
edits.push({ start: boundary, end: lineStart, text: `${newline}${blank}${newline}` });
}
if (node.type === "link") {
const title = workspaceHoverTitle(node, source.slice(start, end), workspaceRoot);
if (title !== null)
@@ -244,7 +262,8 @@ export function prepareMarkdownSource(
if (text !== source.slice(start, end)) edits.push({ start, end, text });
}
}
if ("children" in node) node.children.forEach(visit);
if ("children" in node)
node.children.forEach((child, index) => visit(child, node.children[index - 1]));
}
visit(getTree());
return {