diff --git a/client/src/components/conversation/MarkdownContent.tsx b/client/src/components/conversation/MarkdownContent.tsx index f9986633..9cf8df0c 100644 --- a/client/src/components/conversation/MarkdownContent.tsx +++ b/client/src/components/conversation/MarkdownContent.tsx @@ -84,7 +84,12 @@ const HR_RE = /^\s*(-{3,}|\*{3,}|_{3,})\s*$/; const UL_RE = /^(\s*)([-*+])\s+(.*)$/; const OL_RE = /^(\s*)(\d+)\.\s+(.*)$/; const QUOTE_RE = /^\s*>\s?(.*)$/; -const TABLE_DIVIDER_RE = /^\s*\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)+\|?\s*$/; +const TABLE_DIVIDER_RE = /^\s*\|?\s*:?-+:?\s*(\|\s*:?-+:?\s*)+\|?\s*$/; + +/** A GFM table starts at a line holding a pipe that is followed by a divider. */ +function isTableStart(lines: string[], i: number): boolean { + return lines[i]!.includes("|") && i + 1 < lines.length && TABLE_DIVIDER_RE.test(lines[i + 1]!); +} function splitTableRow(line: string): string[] { // Trim leading/trailing pipes, then split, respecting escaped pipes. @@ -171,7 +176,7 @@ function parseBlocks(src: string): Block[] { } // Table: header line followed by an alignment divider - if (line.includes("|") && i + 1 < lines.length && TABLE_DIVIDER_RE.test(lines[i + 1]!)) { + if (isTableStart(lines, i)) { const header = splitTableRow(line); const aligns = parseAlignments(lines[i + 1]!); i += 2; @@ -227,7 +232,8 @@ function parseBlocks(src: string): Block[] { continue; } - // Paragraph: collect until a blank line or the start of another block + // Paragraph: collect until a blank line or the start of another block — + // a table included, which (as in GFM) may follow prose with no blank line. const paraLines: string[] = [line]; i++; while (i < lines.length) { @@ -239,7 +245,8 @@ function parseBlocks(src: string): Block[] { HR_RE.test(nl) || UL_RE.test(nl) || OL_RE.test(nl) || - QUOTE_RE.test(nl) + QUOTE_RE.test(nl) || + isTableStart(lines, i) ) { break; } @@ -300,7 +307,10 @@ function UrlLink({ url }: { url: string }) { /** A markdown link's file target as a path: `%20` decoded, `#L12` → `:12`. */ function decodeHref(href: string): string { - let p = href.replace(/^file:\/\//i, "").replace(/#L(\d+)(?:-L?\d+)?$/, ":$1").replace(/#.*$/, ""); + let p = href + .replace(/^file:\/\//i, "") + .replace(/#L(\d+)(?:-L?\d+)?$/, ":$1") + .replace(/#.*$/, ""); try { p = decodeURIComponent(p); } catch { @@ -383,13 +393,7 @@ function renderInline(text: string, baseKey = ""): React.ReactNode[] { {codeM[1]} ); - push( - matchPath(codeM[1]!) === codeM[1] ? ( - {code} - ) : ( - code - ) - ); + push(matchPath(codeM[1]!) === codeM[1] ? {code} : code); i += codeM[0].length; continue; } @@ -440,9 +444,7 @@ function renderInline(text: string, baseKey = ""): React.ReactNode[] { (!SCHEME_RE.test(href) && !href.startsWith("#") && !href.startsWith("//")) ) { push( - - {renderInline(linkM[1]!, `${baseKey}-l${n}`)} - + {renderInline(linkM[1]!, `${baseKey}-l${n}`)} ); i += linkM[0].length; continue; diff --git a/client/src/components/conversation/__tests__/MarkdownContent.test.tsx b/client/src/components/conversation/__tests__/MarkdownContent.test.tsx index 78c95dc7..50bb2114 100644 --- a/client/src/components/conversation/__tests__/MarkdownContent.test.tsx +++ b/client/src/components/conversation/__tests__/MarkdownContent.test.tsx @@ -153,4 +153,18 @@ describe("", () => { expect(container.firstChild).not.toBeNull(); expect(container.querySelectorAll("p, ul, ol, blockquote, pre, hr").length).toBe(0); }); + + it("renders a table that follows prose with no blank line (issue #83)", () => { + const text = 'Cách kiểm tra:\n| Token xoá | Số chỗ |\n|---|---|\n| `width="N"` | 29 |'; + const { container } = render(); + expect(container.querySelector("p")?.textContent).toBe("Cách kiểm tra:"); + expect(container.querySelectorAll("th").length).toBe(2); + expect(container.querySelectorAll("tbody tr").length).toBe(1); + expect(container.textContent).not.toContain("|---|"); + }); + + it("accepts a GFM divider with single dashes", () => { + const { container } = render(); + expect(container.querySelectorAll("th").length).toBe(2); + }); });