diff --git a/package.json b/package.json index cb8b3051..7dddce96 100644 --- a/package.json +++ b/package.json @@ -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", @@ -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", diff --git a/src/components/Markdown/Markdown.test.tsx b/src/components/Markdown/Markdown.test.tsx index 67c8689d..fae194c2 100644 --- a/src/components/Markdown/Markdown.test.tsx +++ b/src/components/Markdown/Markdown.test.tsx @@ -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() - 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() + expect(getByText('alpha | beta').tagName).toBe('P') + expect(getByText('gamma').tagName).toBe('P') + expect(queryByRole('table')).toBeNull() }) - it('ignores pipe‑separated 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() expect(queryByRole('table')).toBeNull() // no table @@ -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() + 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() + 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() + 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() + }) }) diff --git a/src/components/Markdown/Markdown.tsx b/src/components/Markdown/Markdown.tsx index ac12311b..ffc7ea9b 100644 --- a/src/components/Markdown/Markdown.tsx +++ b/src/components/Markdown/Markdown.tsx @@ -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 @@ -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 { @@ -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)) diff --git a/src/components/MarkdownView/MarkdownView.module.css b/src/components/MarkdownView/MarkdownView.module.css index be129583..a1284e2e 100644 --- a/src/components/MarkdownView/MarkdownView.module.css +++ b/src/components/MarkdownView/MarkdownView.module.css @@ -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; }