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);
+ });
});