Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions src/components/Markdown/Markdown.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<Markdown text={text} />)
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(<Markdown text={text} />)
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(<Markdown text={text} />)
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(<Markdown text={text} />)
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(<Markdown text={bogus} />)
expect(queryByRole('table')).toBeNull() // no table
expect(getByText('not | a | table')).toBeDefined()
})
})
73 changes: 73 additions & 0 deletions src/components/Markdown/Markdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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[] = []
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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'
Expand Down Expand Up @@ -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
}
Expand Down