diff --git a/config/scripts/mobile-web-app-session-terminal-closure.test.mjs b/config/scripts/mobile-web-app-session-terminal-closure.test.mjs index 1ef8b14b5b3..fa97e85fba9 100644 --- a/config/scripts/mobile-web-app-session-terminal-closure.test.mjs +++ b/config/scripts/mobile-web-app-session-terminal-closure.test.mjs @@ -229,8 +229,16 @@ const MERMAID_PACKAGE = 'node_modules/mermaid/' * diffed to name the difference: those two leave and nothing joins. The same measurement, taken * before #22067 landed, is how this branch read main's pin of 4,330 as three modules stale — the * three the paragraph above names. + * + * Then the two other table parsers gave up their own row splitters and read the editor's + * `src/components/rich-markdown/markdown-table-rows.ts` instead, which the session page reaches + * through the PR comment renderer. It is the one module that joins, and the only one it can be: it + * imports nothing, and no other file under `rich-markdown/` is in the closure beside it. + * + * modules 4331 -> 4332 (+1) + * local modules 989 -> 990 (+1) */ -const SESSION_ROUTE_MODULES = 4331 +const SESSION_ROUTE_MODULES = 4332 /** What the page enters this route through once the route is a switch with a `.web.tsx` sibling. */ const ROUTE_ENTRY = [ diff --git a/mobile/src/components/MobileMarkdown.test.ts b/mobile/src/components/MobileMarkdown.test.ts index 5a59e749b20..e85eb1ab197 100644 --- a/mobile/src/components/MobileMarkdown.test.ts +++ b/mobile/src/components/MobileMarkdown.test.ts @@ -45,6 +45,26 @@ describe('parseMobileMarkdown', () => { ]) }) + it('keeps an escaped pipe inside the cell that escaped it', () => { + expect(parseMobileMarkdown('| Cmd | Note |\n| --- | --- |\n| a \\| b | c |')).toEqual([ + { + type: 'table', + headers: ['Cmd', 'Note'], + rows: [['a | b', 'c']] + } + ]) + }) + + it('ends a cell at the pipe following an escaped backslash', () => { + expect(parseMobileMarkdown('| A | B |\n| --- | --- |\n| x\\\\|y |')).toEqual([ + { + type: 'table', + headers: ['A', 'B'], + rows: [['x\\', 'y']] + } + ]) + }) + it('parses standalone HTTPS images without folding them into paragraphs', () => { expect(parseMobileMarkdown('![Screenshot](https://example.com/screen.png)')).toEqual([ { diff --git a/mobile/src/components/mobile-markdown-parser.ts b/mobile/src/components/mobile-markdown-parser.ts index 2ee83236007..4227f2a7da6 100644 --- a/mobile/src/components/mobile-markdown-parser.ts +++ b/mobile/src/components/mobile-markdown-parser.ts @@ -1,3 +1,5 @@ +import { isTableSeparator, splitTableRow } from './rich-markdown/markdown-table-rows' + export type MobileMarkdownBlock = | { type: 'paragraph'; text: string } | { type: 'heading'; level: number; text: string } @@ -11,20 +13,6 @@ export type MobileMarkdownBlock = const HEADING = /^(#{1,6})\s+(.+)$/ const CODE_FENCE = /^```([A-Za-z0-9_-]+)?\s*$/ -function splitTableRow(line: string): string[] { - return line - .trim() - .replace(/^\|/, '') - .replace(/\|$/, '') - .split('|') - .map((cell) => cell.trim()) -} - -function isTableSeparator(line: string): boolean { - const cells = splitTableRow(line) - return cells.length > 0 && cells.every((cell) => /^:?-{3,}:?$/.test(cell)) -} - export function parseMobileMarkdown(content: string): MobileMarkdownBlock[] { const lines = content.replace(/\r\n?/g, '\n').split('\n') const blocks: MobileMarkdownBlock[] = [] diff --git a/mobile/src/components/pr-sidebar/markdown-blocks.test.ts b/mobile/src/components/pr-sidebar/markdown-blocks.test.ts index ab0026854b4..0501200e19e 100644 --- a/mobile/src/components/pr-sidebar/markdown-blocks.test.ts +++ b/mobile/src/components/pr-sidebar/markdown-blocks.test.ts @@ -135,6 +135,30 @@ describe('parseMarkdownBlocks tables', () => { ]) }) + it('keeps an escaped pipe that ends a row carrying no closing pipe', () => { + const md = ['A | B', '--- | ---', 'x | y \\|'].join('\n') + expect(parseMarkdownBlocks(md)).toEqual([ + { + kind: 'table', + headers: ['A', 'B'], + align: ['left', 'left'], + rows: [['x', 'y |']] + } + ]) + }) + + it('ends a cell at the pipe following an escaped backslash', () => { + const md = ['| A | B |', '| --- | --- |', '| x\\\\|y |'].join('\n') + expect(parseMarkdownBlocks(md)).toEqual([ + { + kind: 'table', + headers: ['A', 'B'], + align: ['left', 'left'], + rows: [['x\\', 'y']] + } + ]) + }) + it('does not treat prose containing a pipe as a table (no delimiter row)', () => { expect(parseMarkdownBlocks('this | that is just text')).toEqual([ { kind: 'paragraph', text: 'this | that is just text' } diff --git a/mobile/src/components/pr-sidebar/markdown-blocks.ts b/mobile/src/components/pr-sidebar/markdown-blocks.ts index ea317dd1223..c4abdf9d07b 100644 --- a/mobile/src/components/pr-sidebar/markdown-blocks.ts +++ b/mobile/src/components/pr-sidebar/markdown-blocks.ts @@ -1,4 +1,5 @@ import { createMarkdownInlineMatcher } from '../markdown-inline-matcher' +import { splitTableRow } from '../rich-markdown/markdown-table-rows' // Tiny, dependency-free markdown model for PR comment bodies. We render GitHub // markdown without a third-party RN markdown library (the previous dependency hung @@ -194,36 +195,7 @@ function parseLines(content: string): MarkdownBlock[] { return blocks } -// Splits a `| a | b |` table row into trimmed cells. Tolerates missing outer -// pipes and escaped `\|` inside cells. Total: never throws on odd input. -function splitTableRow(line: string): string[] { - const cells: string[] = [] - let cell = '' - let trimmed = line.trim() - if (trimmed.startsWith('|')) { - trimmed = trimmed.slice(1) - } - if (trimmed.endsWith('|')) { - trimmed = trimmed.slice(0, -1) - } - for (let j = 0; j < trimmed.length; j += 1) { - const ch = trimmed[j] - if (ch === '\\' && trimmed[j + 1] === '|') { - cell += '|' - j += 1 - continue - } - if (ch === '|') { - cells.push(cell.trim()) - cell = '' - continue - } - cell += ch - } - cells.push(cell.trim()) - return cells -} - +// A single dash is a delimiter cell here, unlike the editor's three-dash separator. function isTableDelimiter(line: string): boolean { return splitTableRow(line).every((cell) => /^:?-+:?$/.test(cell)) }