Skip to content

Commit 03e8263

Browse files
authored
Markdown: render markdown tables (#259)
1 parent 8158ab0 commit 03e8263

File tree

2 files changed

+117
-0
lines changed

2 files changed

+117
-0
lines changed

src/components/Markdown/Markdown.test.tsx

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -400,3 +400,47 @@ describe('Markdown with nested elements', () => {
400400
expect(getByText('Second').tagName).toBe('LI')
401401
})
402402
})
403+
404+
describe('Markdown with tables', () => {
405+
it('renders a simple table', () => {
406+
const text = '| Header 1 | Header 2 |\n|----------|----------|\n| Row 1 | Data 1 |\n| Row 2 | Data 2 |'
407+
const { getByText } = render(<Markdown text={text} />)
408+
expect(getByText('Header 1')).toBeDefined()
409+
expect(getByText('Header 2')).toBeDefined()
410+
expect(getByText('Row 1')).toBeDefined()
411+
expect(getByText('Data 1')).toBeDefined()
412+
expect(getByText('Row 2')).toBeDefined()
413+
expect(getByText('Data 2')).toBeDefined()
414+
})
415+
416+
it('renders a table that omits the outer pipes', () => {
417+
const text = 'Header A | Header B\n---|---\nCell 1 | Cell 2'
418+
const { getByText, getByRole } = render(<Markdown text={text} />)
419+
expect(getByRole('table')).toBeDefined()
420+
expect(getByText('Header A')).toBeDefined()
421+
expect(getByText('Cell 2')).toBeDefined()
422+
})
423+
424+
it('renders inline formatting inside cells', () => {
425+
const text = '| **Bold** | Link |\n|-----------|------|\n| `code` | [x](#) |'
426+
const { getByText, getByRole } = render(<Markdown text={text} />)
427+
expect(getByRole('table')).toBeDefined()
428+
expect(getByText('Bold').tagName).toBe('STRONG')
429+
expect(getByRole('link', { name: 'x' })).toBeDefined()
430+
})
431+
432+
it('keeps surrounding paragraphs intact', () => {
433+
const text = 'Above\n\n| H1 | H2 |\n|----|----|\n| a | b |\n\nBelow'
434+
const { getByText, getByRole } = render(<Markdown text={text} />)
435+
expect(getByText('Above').tagName).toBe('P')
436+
expect(getByText('Below').tagName).toBe('P')
437+
expect(getByRole('table')).toBeDefined()
438+
})
439+
440+
it('ignores pipe‑separated text that lacks a separator line', () => {
441+
const bogus = 'not | a | table'
442+
const { queryByRole, getByText } = render(<Markdown text={bogus} />)
443+
expect(queryByRole('table')).toBeNull() // no table
444+
expect(getByText('not | a | table')).toBeDefined()
445+
})
446+
})

src/components/Markdown/Markdown.tsx

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ type Token =
1818
| { type: 'blockquote', children: Token[] }
1919
| { type: 'codeblock', language?: string, content: string }
2020
| { type: 'hr' }
21+
| { type: 'table', header: Token[][], rows: Token[][][] }
2122

2223
function parseMarkdown(text: string): Token[] {
2324
const tokens: Token[] = []
@@ -106,6 +107,31 @@ function parseMarkdown(text: string): Token[] {
106107
continue
107108
}
108109

110+
// Table (requires header line | separator line)
111+
if (line.includes('|') && i + 1 < lines.length) {
112+
const sepLine = lines[i + 1] ?? ''
113+
// Check if the next line is a valid table separator
114+
// Extended markdown alignment syntax: |:--|, |:--:|, |--:|
115+
if (/^\s*\|?(\s*:?-+:?\s*\|)+\s*:?-+:?\s*\|?\s*$/.test(sepLine)) {
116+
// collect header cells
117+
const headerCells = splitTableRow(line)
118+
i += 2
119+
120+
const rows: Token[][][] = []
121+
while (i < lines.length && lines[i]?.includes('|')) {
122+
rows.push(splitTableRow(lines[i] ?? '').map(c => parseInline(c)))
123+
i++
124+
}
125+
126+
tokens.push({
127+
type: 'table',
128+
header: headerCells.map(c => parseInline(c)),
129+
rows,
130+
})
131+
continue
132+
}
133+
}
134+
109135
// Paragraph
110136
const paraLines: string[] = []
111137
while (i < lines.length) {
@@ -416,6 +442,16 @@ function parseInlineRecursive(text: string, stop?: string): [Token[], number] {
416442
return [tokens, i]
417443
}
418444

445+
/** Split a table row, trimming outer pipes and whitespace */
446+
function splitTableRow(row: string): string[] {
447+
return row
448+
.trim()
449+
.replace(/^\|/, '')
450+
.replace(/\|$/, '')
451+
.split('|')
452+
.map(cell => cell.trim())
453+
}
454+
419455
function isOpeningUnderscore(text: string, pos: number): boolean {
420456
const prev = text[pos - 1] ?? '\n'
421457
const next = text[pos + 1] ?? '\n'
@@ -510,6 +546,43 @@ function renderTokens(tokens: Token[], keyPrefix = ''): ReactNode[] {
510546
)
511547
case 'hr':
512548
return createElement('hr', { key })
549+
case 'table': {
550+
const thead = createElement(
551+
'thead',
552+
null,
553+
createElement(
554+
'tr',
555+
null,
556+
token.header.map((cell, c) =>
557+
createElement(
558+
'th',
559+
{ key: `${key}-h${c}` },
560+
renderTokens(cell, `${key}-h${c}-`)
561+
)
562+
)
563+
)
564+
)
565+
566+
const tbody = createElement(
567+
'tbody',
568+
null,
569+
token.rows.map((row, r) =>
570+
createElement(
571+
'tr',
572+
{ key: `${key}-r${r}` },
573+
row.map((cell, c) =>
574+
createElement(
575+
'td',
576+
{ key: `${key}-r${r}c${c}` },
577+
renderTokens(cell, `${key}-r${r}c${c}-`)
578+
)
579+
)
580+
)
581+
)
582+
)
583+
584+
return createElement('table', { key }, [thead, tbody])
585+
}
513586
default:
514587
return null
515588
}

0 commit comments

Comments
 (0)