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
6 changes: 3 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@
},
"devDependencies": {
"@eslint/js": "9.29.0",
"@storybook/react-vite": "9.0.11",
"@storybook/react-vite": "9.0.12",
"@testing-library/react": "16.3.0",
"@types/node": "24.0.3",
"@types/react": "19.1.8",
Expand All @@ -73,12 +73,12 @@
"eslint-plugin-react": "7.37.5",
"eslint-plugin-react-hooks": "5.2.0",
"eslint-plugin-react-refresh": "0.4.20",
"eslint-plugin-storybook": "9.0.11",
"eslint-plugin-storybook": "9.0.12",
"globals": "16.2.0",
"jsdom": "26.1.0",
"nodemon": "3.1.10",
"npm-run-all": "4.1.5",
"storybook": "9.0.11",
"storybook": "9.0.12",
"typescript": "5.8.3",
"typescript-eslint": "8.34.1",
"vite": "6.3.5",
Expand Down
38 changes: 32 additions & 6 deletions src/components/Markdown/Markdown.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -449,14 +449,14 @@ describe('Markdown with tables', () => {
})

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()
const text = 'alpha | beta\n---\ngamma'
const { getByText, queryByRole } = render(<Markdown text={text} />)
expect(getByText('alpha | beta').tagName).toBe('P')
expect(getByText('gamma').tagName).toBe('P')
expect(queryByRole('table')).toBeNull()
})

it('ignores pipeseparated text that lacks a separator line', () => {
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
Expand All @@ -478,4 +478,30 @@ describe('Markdown with tables', () => {
expect(getByText('Header1')).toBeDefined()
expect(getByText('Data2')).toBeDefined()
})

it('keeps a pipe that is inside inline code in a table', () => {
const text = 'Header1 | Header2\n------- | -------\n| Here is some `inline | code` with a pipe. |'
const { getByText, getByRole } = render(<Markdown text={text} />)
expect(getByRole('table')).toBeDefined()
expect(getByText('inline | code')).toBeDefined()
})

it('does not treat --- as a table separator', () => {
const text = 'Column 1 | Column 2\n---\nData 1 | Data 2'
const { queryByRole, getByRole, getByText } = render(<Markdown text={text} />)
expect(queryByRole('table')).toBeNull() // no table
expect(getByText('Column 1 | Column 2')).toBeDefined()
expect(getByRole('separator')).toBeDefined() // horizontal rule
expect(getByText('Data 1 | Data 2')).toBeDefined()
})

it('handles escaped pipes in table cells', () => {
const text = '| Header \\| 1 | Header 2 |\n|----------|----------|\n| Cell with \\| escaped pipe | Normal cell |'
const { getByText, getByRole } = render(<Markdown text={text} />)
expect(getByRole('table')).toBeDefined()
expect(getByText('Header | 1')).toBeDefined()
expect(getByText('Header 2')).toBeDefined()
expect(getByText('Cell with | escaped pipe')).toBeDefined()
expect(getByText('Normal cell')).toBeDefined()
})
})
46 changes: 39 additions & 7 deletions src/components/Markdown/Markdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ function parseMarkdown(text: string): Token[] {
// Check if the next line is a valid table separator
// Extended markdown alignment syntax: |:--|, |:--:|, |--:|
const tableSepRegex = /^\s*\|?\s*:?-+:?\s*(\|\s*:?-+:?\s*)*\|?\s*$/
if (tableSepRegex.test(sepLine)) {
if (sepLine.includes('|') && tableSepRegex.test(sepLine)) {
// collect header cells
const headerCells = splitTableRow(line)
i += 2
Expand Down Expand Up @@ -446,12 +446,40 @@ function parseInlineRecursive(text: string, stop?: string): [Token[], number] {

/** Split a table row, trimming outer pipes and whitespace */
function splitTableRow(row: string): string[] {
return row
.trim()
.replace(/^\|/, '')
.replace(/\|$/, '')
.split('|')
.map(cell => cell.trim())
const cells: string[] = []
let current = ''
let inCode = false

// strip a single leading / trailing pipe so we don't create empty edge‑cells
const trimmed = row.trim()
const start = trimmed.startsWith('|') ? 1 : 0
const end = trimmed.endsWith('|') ? trimmed.length - 1 : trimmed.length

for (let i = start; i < end; i++) {
const ch = trimmed[i] ?? ''

if (ch === '`') {
inCode = !inCode
current += ch
continue
}

if (ch === '|' && !inCode) {
// escaped pipe \| becomes |
if (i > start && trimmed[i - 1] === '\\') {
current = current.slice(0, -1) + '|'
continue
}
cells.push(current.trim())
current = ''
continue
}

current += ch
}

cells.push(current.trim())
return cells
}

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

/**
* Markdown component. Converts markdown text to React elements.
* Designed to handle streaming markdown input gracefully.
*/
export default function Markdown({ text, className }: MarkdownProps) {
const tokens = parseMarkdown(text)
return createElement('div', { className }, renderTokens(tokens))
Expand Down
6 changes: 4 additions & 2 deletions src/components/MarkdownView/MarkdownView.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -37,14 +37,16 @@
}
.markdownView th,
.markdownView td {
border-bottom: 1px solid #333;
padding: 8px;
text-align: left;
}
.markdownView th {
border-bottom: 2px solid #333;
border-bottom: 2px solid #888;
font-weight: 500;
}
.markdownView td {
border-bottom: 1px solid #666;
}
.markdownView tr:last-child td {
border-bottom: none;
}