diff --git a/src/components/Markdown/Markdown.test.tsx b/src/components/Markdown/Markdown.test.tsx index 10d5e5bf..6950aad3 100644 --- a/src/components/Markdown/Markdown.test.tsx +++ b/src/components/Markdown/Markdown.test.tsx @@ -400,3 +400,47 @@ describe('Markdown with nested elements', () => { expect(getByText('Second').tagName).toBe('LI') }) }) + +describe('Markdown with tables', () => { + it('renders a simple table', () => { + const text = '| Header 1 | Header 2 |\n|----------|----------|\n| Row 1 | Data 1 |\n| Row 2 | Data 2 |' + const { getByText } = render() + expect(getByText('Header 1')).toBeDefined() + expect(getByText('Header 2')).toBeDefined() + expect(getByText('Row 1')).toBeDefined() + expect(getByText('Data 1')).toBeDefined() + expect(getByText('Row 2')).toBeDefined() + expect(getByText('Data 2')).toBeDefined() + }) + + it('renders a table that omits the outer pipes', () => { + const text = 'Header A | Header B\n---|---\nCell 1 | Cell 2' + const { getByText, getByRole } = render() + expect(getByRole('table')).toBeDefined() + expect(getByText('Header A')).toBeDefined() + expect(getByText('Cell 2')).toBeDefined() + }) + + it('renders inline formatting inside cells', () => { + const text = '| **Bold** | Link |\n|-----------|------|\n| `code` | [x](#) |' + const { getByText, getByRole } = render() + expect(getByRole('table')).toBeDefined() + expect(getByText('Bold').tagName).toBe('STRONG') + expect(getByRole('link', { name: 'x' })).toBeDefined() + }) + + it('keeps surrounding paragraphs intact', () => { + const text = 'Above\n\n| H1 | H2 |\n|----|----|\n| a | b |\n\nBelow' + const { getByText, getByRole } = render() + expect(getByText('Above').tagName).toBe('P') + expect(getByText('Below').tagName).toBe('P') + expect(getByRole('table')).toBeDefined() + }) + + it('ignores pipe‑separated text that lacks a separator line', () => { + const bogus = 'not | a | table' + const { queryByRole, getByText } = render() + expect(queryByRole('table')).toBeNull() // no table + expect(getByText('not | a | table')).toBeDefined() + }) +}) diff --git a/src/components/Markdown/Markdown.tsx b/src/components/Markdown/Markdown.tsx index 81a27d91..4cd5b0d8 100644 --- a/src/components/Markdown/Markdown.tsx +++ b/src/components/Markdown/Markdown.tsx @@ -18,6 +18,7 @@ type Token = | { type: 'blockquote', children: Token[] } | { type: 'codeblock', language?: string, content: string } | { type: 'hr' } + | { type: 'table', header: Token[][], rows: Token[][][] } function parseMarkdown(text: string): Token[] { const tokens: Token[] = [] @@ -106,6 +107,31 @@ function parseMarkdown(text: string): Token[] { continue } + // Table (requires header line | separator line) + if (line.includes('|') && i + 1 < lines.length) { + const sepLine = lines[i + 1] ?? '' + // Check if the next line is a valid table separator + // Extended markdown alignment syntax: |:--|, |:--:|, |--:| + if (/^\s*\|?(\s*:?-+:?\s*\|)+\s*:?-+:?\s*\|?\s*$/.test(sepLine)) { + // collect header cells + const headerCells = splitTableRow(line) + i += 2 + + const rows: Token[][][] = [] + while (i < lines.length && lines[i]?.includes('|')) { + rows.push(splitTableRow(lines[i] ?? '').map(c => parseInline(c))) + i++ + } + + tokens.push({ + type: 'table', + header: headerCells.map(c => parseInline(c)), + rows, + }) + continue + } + } + // Paragraph const paraLines: string[] = [] while (i < lines.length) { @@ -416,6 +442,16 @@ function parseInlineRecursive(text: string, stop?: string): [Token[], number] { return [tokens, i] } +/** Split a table row, trimming outer pipes and whitespace */ +function splitTableRow(row: string): string[] { + return row + .trim() + .replace(/^\|/, '') + .replace(/\|$/, '') + .split('|') + .map(cell => cell.trim()) +} + function isOpeningUnderscore(text: string, pos: number): boolean { const prev = text[pos - 1] ?? '\n' const next = text[pos + 1] ?? '\n' @@ -510,6 +546,43 @@ function renderTokens(tokens: Token[], keyPrefix = ''): ReactNode[] { ) case 'hr': return createElement('hr', { key }) + case 'table': { + const thead = createElement( + 'thead', + null, + createElement( + 'tr', + null, + token.header.map((cell, c) => + createElement( + 'th', + { key: `${key}-h${c}` }, + renderTokens(cell, `${key}-h${c}-`) + ) + ) + ) + ) + + const tbody = createElement( + 'tbody', + null, + token.rows.map((row, r) => + createElement( + 'tr', + { key: `${key}-r${r}` }, + row.map((cell, c) => + createElement( + 'td', + { key: `${key}-r${r}c${c}` }, + renderTokens(cell, `${key}-r${r}c${c}-`) + ) + ) + ) + ) + ) + + return createElement('table', { key }, [thead, tbody]) + } default: return null }