Files
orca/mobile/src/components/mobile-markdown-parser.ts
T

136 lines
4.2 KiB
TypeScript

export type MobileMarkdownBlock =
| { type: 'paragraph'; text: string }
| { type: 'heading'; level: number; text: string }
| { type: 'quote'; text: string }
| { type: 'code'; text: string; language?: string }
| { type: 'list'; ordered: boolean; items: Array<{ text: string; checked?: boolean }> }
| { type: 'image'; alt: string; url: string }
| { type: 'table'; headers: string[]; rows: string[][] }
| { type: 'rule' }
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[] = []
let index = 0
while (index < lines.length) {
const line = lines[index] ?? ''
if (!line.trim()) {
index += 1
continue
}
const fence = line.match(/^```([A-Za-z0-9_-]+)?\s*$/)
if (fence) {
index += 1
const code: string[] = []
while (index < lines.length && !/^```\s*$/.test(lines[index] ?? '')) {
code.push(lines[index] ?? '')
index += 1
}
if (index < lines.length) {
index += 1
}
blocks.push({ type: 'code', text: code.join('\n'), language: fence[1] })
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(/^(#{1,6})\s+(.+)$/)
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() &&
!(lines[index] ?? '').startsWith('```') &&
!/^(#{1,6})\s+/.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
}