Skip to content

Commit 1e5ac49

Browse files
authored
Markdown: fix table pipe edge cases (escapes, inlines) (#263)
1 parent a23ddcc commit 1e5ac49

File tree

4 files changed

+78
-18
lines changed

4 files changed

+78
-18
lines changed

package.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@
6262
},
6363
"devDependencies": {
6464
"@eslint/js": "9.29.0",
65-
"@storybook/react-vite": "9.0.11",
65+
"@storybook/react-vite": "9.0.12",
6666
"@testing-library/react": "16.3.0",
6767
"@types/node": "24.0.3",
6868
"@types/react": "19.1.8",
@@ -73,12 +73,12 @@
7373
"eslint-plugin-react": "7.37.5",
7474
"eslint-plugin-react-hooks": "5.2.0",
7575
"eslint-plugin-react-refresh": "0.4.20",
76-
"eslint-plugin-storybook": "9.0.11",
76+
"eslint-plugin-storybook": "9.0.12",
7777
"globals": "16.2.0",
7878
"jsdom": "26.1.0",
7979
"nodemon": "3.1.10",
8080
"npm-run-all": "4.1.5",
81-
"storybook": "9.0.11",
81+
"storybook": "9.0.12",
8282
"typescript": "5.8.3",
8383
"typescript-eslint": "8.34.1",
8484
"vite": "6.3.5",

src/components/Markdown/Markdown.test.tsx

Lines changed: 32 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -449,14 +449,14 @@ describe('Markdown with tables', () => {
449449
})
450450

451451
it('keeps surrounding paragraphs intact', () => {
452-
const text = 'Above\n\n| H1 | H2 |\n|----|----|\n| a | b |\n\nBelow'
453-
const { getByText, getByRole } = render(<Markdown text={text} />)
454-
expect(getByText('Above').tagName).toBe('P')
455-
expect(getByText('Below').tagName).toBe('P')
456-
expect(getByRole('table')).toBeDefined()
452+
const text = 'alpha | beta\n---\ngamma'
453+
const { getByText, queryByRole } = render(<Markdown text={text} />)
454+
expect(getByText('alpha | beta').tagName).toBe('P')
455+
expect(getByText('gamma').tagName).toBe('P')
456+
expect(queryByRole('table')).toBeNull()
457457
})
458458

459-
it('ignores pipeseparated text that lacks a separator line', () => {
459+
it('ignores pipe-separated text that lacks a separator line', () => {
460460
const bogus = 'not | a | table'
461461
const { queryByRole, getByText } = render(<Markdown text={bogus} />)
462462
expect(queryByRole('table')).toBeNull() // no table
@@ -478,4 +478,30 @@ describe('Markdown with tables', () => {
478478
expect(getByText('Header1')).toBeDefined()
479479
expect(getByText('Data2')).toBeDefined()
480480
})
481+
482+
it('keeps a pipe that is inside inline code in a table', () => {
483+
const text = 'Header1 | Header2\n------- | -------\n| Here is some `inline | code` with a pipe. |'
484+
const { getByText, getByRole } = render(<Markdown text={text} />)
485+
expect(getByRole('table')).toBeDefined()
486+
expect(getByText('inline | code')).toBeDefined()
487+
})
488+
489+
it('does not treat --- as a table separator', () => {
490+
const text = 'Column 1 | Column 2\n---\nData 1 | Data 2'
491+
const { queryByRole, getByRole, getByText } = render(<Markdown text={text} />)
492+
expect(queryByRole('table')).toBeNull() // no table
493+
expect(getByText('Column 1 | Column 2')).toBeDefined()
494+
expect(getByRole('separator')).toBeDefined() // horizontal rule
495+
expect(getByText('Data 1 | Data 2')).toBeDefined()
496+
})
497+
498+
it('handles escaped pipes in table cells', () => {
499+
const text = '| Header \\| 1 | Header 2 |\n|----------|----------|\n| Cell with \\| escaped pipe | Normal cell |'
500+
const { getByText, getByRole } = render(<Markdown text={text} />)
501+
expect(getByRole('table')).toBeDefined()
502+
expect(getByText('Header | 1')).toBeDefined()
503+
expect(getByText('Header 2')).toBeDefined()
504+
expect(getByText('Cell with | escaped pipe')).toBeDefined()
505+
expect(getByText('Normal cell')).toBeDefined()
506+
})
481507
})

src/components/Markdown/Markdown.tsx

Lines changed: 39 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,7 @@ function parseMarkdown(text: string): Token[] {
113113
// Check if the next line is a valid table separator
114114
// Extended markdown alignment syntax: |:--|, |:--:|, |--:|
115115
const tableSepRegex = /^\s*\|?\s*:?-+:?\s*(\|\s*:?-+:?\s*)*\|?\s*$/
116-
if (tableSepRegex.test(sepLine)) {
116+
if (sepLine.includes('|') && tableSepRegex.test(sepLine)) {
117117
// collect header cells
118118
const headerCells = splitTableRow(line)
119119
i += 2
@@ -446,12 +446,40 @@ function parseInlineRecursive(text: string, stop?: string): [Token[], number] {
446446

447447
/** Split a table row, trimming outer pipes and whitespace */
448448
function splitTableRow(row: string): string[] {
449-
return row
450-
.trim()
451-
.replace(/^\|/, '')
452-
.replace(/\|$/, '')
453-
.split('|')
454-
.map(cell => cell.trim())
449+
const cells: string[] = []
450+
let current = ''
451+
let inCode = false
452+
453+
// strip a single leading / trailing pipe so we don't create empty edge‑cells
454+
const trimmed = row.trim()
455+
const start = trimmed.startsWith('|') ? 1 : 0
456+
const end = trimmed.endsWith('|') ? trimmed.length - 1 : trimmed.length
457+
458+
for (let i = start; i < end; i++) {
459+
const ch = trimmed[i] ?? ''
460+
461+
if (ch === '`') {
462+
inCode = !inCode
463+
current += ch
464+
continue
465+
}
466+
467+
if (ch === '|' && !inCode) {
468+
// escaped pipe \| becomes |
469+
if (i > start && trimmed[i - 1] === '\\') {
470+
current = current.slice(0, -1) + '|'
471+
continue
472+
}
473+
cells.push(current.trim())
474+
current = ''
475+
continue
476+
}
477+
478+
current += ch
479+
}
480+
481+
cells.push(current.trim())
482+
return cells
455483
}
456484

457485
function isOpeningUnderscore(text: string, pos: number): boolean {
@@ -591,6 +619,10 @@ function renderTokens(tokens: Token[], keyPrefix = ''): ReactNode[] {
591619
})
592620
}
593621

622+
/**
623+
* Markdown component. Converts markdown text to React elements.
624+
* Designed to handle streaming markdown input gracefully.
625+
*/
594626
export default function Markdown({ text, className }: MarkdownProps) {
595627
const tokens = parseMarkdown(text)
596628
return createElement('div', { className }, renderTokens(tokens))

src/components/MarkdownView/MarkdownView.module.css

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,14 +37,16 @@
3737
}
3838
.markdownView th,
3939
.markdownView td {
40-
border-bottom: 1px solid #333;
4140
padding: 8px;
4241
text-align: left;
4342
}
4443
.markdownView th {
45-
border-bottom: 2px solid #333;
44+
border-bottom: 2px solid #888;
4645
font-weight: 500;
4746
}
47+
.markdownView td {
48+
border-bottom: 1px solid #666;
49+
}
4850
.markdownView tr:last-child td {
4951
border-bottom: none;
5052
}

0 commit comments

Comments
 (0)