mirror of
https://github.com/stablyai/orca.git
synced 2026-10-06 16:02:25 +00:00
* test(mobile): pin escaped pipes in mobile markdown table cells The mobile preview parser splits a table row on every pipe, so a cell that escaped one becomes two cells and keeps the backslash. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): read table rows through the shared row splitter The editor's markdown-table-rows already splits on unescaped pipes only and unescapes the cell; it has no imports of its own, so owning the rule once costs the preview parser nothing. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): pin escaped-pipe rows in PR comment tables Its splitter strips the trailing pipe before walking escapes and reads `\\|` as an escaped pipe, so a row ending in `\|` loses the pipe and a cell holding a backslash swallows the separator after it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): split PR comment table rows on unescaped pipes only Its own delimiter grammar stays local: a single dash still opens a table here, which the editor's three-dash separator would reject. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(config): repin the session route closure at 4,332 modules markdown-table-rows.ts joins through the PR comment renderer. Measured on this head: 4,332 modules / 990 local, and it is the only file under rich-markdown/ in the closure, so nothing came with it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
129 lines
4.1 KiB
TypeScript
129 lines
4.1 KiB
TypeScript
import { isTableSeparator, splitTableRow } from './rich-markdown/markdown-table-rows'
|
|
|
|
export type MobileMarkdownBlock =
|
|
| { type: 'paragraph'; text: string }
|
|
| { type: 'heading'; level: number; text: string }
|
|
| { type: 'quote'; text: string }
|
|
| { type: 'code'; text: string; language?: string; closed: boolean }
|
|
| { type: 'list'; ordered: boolean; items: Array<{ text: string; checked?: boolean }> }
|
|
| { type: 'image'; alt: string; url: string }
|
|
| { type: 'table'; headers: string[]; rows: string[][] }
|
|
| { type: 'rule' }
|
|
|
|
const HEADING = /^(#{1,6})\s+(.+)$/
|
|
const CODE_FENCE = /^```([A-Za-z0-9_-]+)?\s*$/
|
|
|
|
export function parseMobileMarkdown(content: string): MobileMarkdownBlock[] {
|
|
const lines = content.replace(/\r\n?/g, '\n').split('\n')
|
|
const blocks: MobileMarkdownBlock[] = []
|
|
let index = 0
|
|
|
|
while (index < lines.length) {
|
|
const line = lines[index] ?? ''
|
|
if (!line.trim()) {
|
|
index += 1
|
|
continue
|
|
}
|
|
|
|
const fence = line.match(CODE_FENCE)
|
|
if (fence) {
|
|
index += 1
|
|
const code: string[] = []
|
|
while (index < lines.length && !/^```\s*$/.test(lines[index] ?? '')) {
|
|
code.push(lines[index] ?? '')
|
|
index += 1
|
|
}
|
|
// closed=false means the fence is still streaming in (no terminator yet).
|
|
const closed = index < lines.length
|
|
if (closed) {
|
|
index += 1
|
|
}
|
|
blocks.push({ type: 'code', text: code.join('\n'), language: fence[1], closed })
|
|
continue
|
|
}
|
|
|
|
if (/^\s*(-{3,}|\*{3,}|_{3,})\s*$/.test(line)) {
|
|
blocks.push({ type: 'rule' })
|
|
index += 1
|
|
continue
|
|
}
|
|
|
|
const standaloneImage = line.match(/^!\[([^\]]*)\]\((https?:\/\/[^)\s]+)(?:\s+"[^"]*")?\)\s*$/i)
|
|
if (standaloneImage) {
|
|
blocks.push({ type: 'image', alt: standaloneImage[1] ?? '', url: standaloneImage[2]! })
|
|
index += 1
|
|
continue
|
|
}
|
|
|
|
if (
|
|
line.includes('|') &&
|
|
index + 1 < lines.length &&
|
|
isTableSeparator(lines[index + 1] ?? '')
|
|
) {
|
|
const headers = splitTableRow(line)
|
|
index += 2
|
|
const rows: string[][] = []
|
|
while (index < lines.length && (lines[index] ?? '').includes('|') && lines[index]?.trim()) {
|
|
rows.push(splitTableRow(lines[index] ?? ''))
|
|
index += 1
|
|
}
|
|
blocks.push({ type: 'table', headers, rows })
|
|
continue
|
|
}
|
|
|
|
const heading = line.match(HEADING)
|
|
if (heading) {
|
|
blocks.push({ type: 'heading', level: heading[1]!.length, text: heading[2]!.trim() })
|
|
index += 1
|
|
continue
|
|
}
|
|
|
|
if (/^>\s?/.test(line)) {
|
|
const quote: string[] = []
|
|
while (index < lines.length && /^>\s?/.test(lines[index] ?? '')) {
|
|
quote.push((lines[index] ?? '').replace(/^>\s?/, ''))
|
|
index += 1
|
|
}
|
|
blocks.push({ type: 'quote', text: quote.join('\n').trim() })
|
|
continue
|
|
}
|
|
|
|
if (/^\s*(?:[-*+]|\d+[.)])\s+/.test(line)) {
|
|
const items: Array<{ text: string; checked?: boolean }> = []
|
|
let ordered = false
|
|
while (index < lines.length && /^\s*(?:[-*+]|\d+[.)])\s+/.test(lines[index] ?? '')) {
|
|
const current = lines[index] ?? ''
|
|
const orderedMatch = current.match(/^\s*\d+[.)]\s+(.+)$/)
|
|
const unorderedMatch = current.match(/^\s*[-*+]\s+(.+)$/)
|
|
ordered ||= Boolean(orderedMatch)
|
|
const rawText = (orderedMatch?.[1] ?? unorderedMatch?.[1] ?? '').trim()
|
|
const task = rawText.match(/^\[([ xX])\]\s+(.+)$/)
|
|
items.push({
|
|
text: task?.[2] ?? rawText,
|
|
checked: task ? task[1]?.toLowerCase() === 'x' : undefined
|
|
})
|
|
index += 1
|
|
}
|
|
blocks.push({ type: 'list', ordered, items })
|
|
continue
|
|
}
|
|
|
|
const paragraph: string[] = []
|
|
while (
|
|
index < lines.length &&
|
|
lines[index]?.trim() &&
|
|
!CODE_FENCE.test(lines[index] ?? '') &&
|
|
!HEADING.test(lines[index] ?? '') &&
|
|
!/^>\s?/.test(lines[index] ?? '') &&
|
|
!/^\s*(?:[-*+]|\d+[.)])\s+/.test(lines[index] ?? '') &&
|
|
!/^\s*(-{3,}|\*{3,}|_{3,})\s*$/.test(lines[index] ?? '')
|
|
) {
|
|
paragraph.push(lines[index] ?? '')
|
|
index += 1
|
|
}
|
|
blocks.push({ type: 'paragraph', text: paragraph.join('\n').trim() })
|
|
}
|
|
|
|
return blocks
|
|
}
|