import { Box, Link, Text } from '@hermes/ink' import { memo, type ReactNode, useMemo } from 'react' import { ensureEmojiPresentation } from '../lib/emoji.js' import { highlightLine, isHighlightable } from '../lib/syntax.js' import type { Theme } from '../theme.js' const FENCE_RE = /^\s*(`{3,}|~{3,})(.*)$/ const FENCE_CLOSE_RE = /^\s*(`{3,}|~{3,})\s*$/ const HR_RE = /^ {0,3}([-*_])(?:\s*\1){2,}\s*$/ const HEADING_RE = /^\s{0,3}(#{1,6})\s+(.*?)(?:\s+#+\s*)?$/ const SETEXT_RE = /^\s{0,3}(=+|-+)\s*$/ const FOOTNOTE_RE = /^\[\^([^\]]+)\]:\s*(.*)$/ const DEF_RE = /^\s*:\s+(.+)$/ const BULLET_RE = /^(\s*)[-+*]\s+(.*)$/ const TASK_RE = /^\[( |x|X)\]\s+(.*)$/ const NUMBERED_RE = /^(\s*)(\d+)[.)]\s+(.*)$/ const QUOTE_RE = /^\s*(?:>\s*)+/ const TABLE_DIVIDER_CELL_RE = /^:?-{3,}:?$/ const MD_URL_RE = '((?:[^\\s()]|\\([^\\s()]*\\))+?)' export const MEDIA_LINE_RE = /^\s*[`"']?MEDIA:\s*(\S+?)[`"']?\s*$/ export const AUDIO_DIRECTIVE_RE = /^\s*\[\[audio_as_voice\]\]\s*$/ // Inline markdown tokens, in priority order. The outer regex picks the // leftmost match at each position, preferring earlier alternatives on tie — // so `**` must come before `*`, `__` before `_`, etc. Each pattern owns its // own capture groups; MdInline dispatches on which group matched. // // Subscript (`~x~`) is restricted to short alphanumeric runs so prose like // `thing ~! more ~?` from Kimi / Qwen / GLM (kaomoji-style decorators) // doesn't pair up the first `~` with the next one on the line and swallow // the text between them as a dim `_`-prefixed span. export const INLINE_RE = new RegExp( [ `!\\[(.*?)\\]\\(${MD_URL_RE}\\)`, // 1,2 image `\\[(.+?)\\]\\(${MD_URL_RE}\\)`, // 3,4 link `<((?:https?:\\/\\/|mailto:)[^>\\s]+|[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,})>`, // 5 autolink `~~(.+?)~~`, // 6 strike `\`([^\\\`]+)\``, // 7 code `\\*\\*(.+?)\\*\\*`, // 8 bold * `(? Math.floor(s.replace(/\t/g, ' ').length / 2) const splitRow = (row: string) => row .trim() .replace(/^\|/, '') .replace(/\|$/, '') .split('|') .map(c => c.trim()) const isTableDivider = (row: string) => { const cells = splitRow(row) return cells.length > 1 && cells.every(c => TABLE_DIVIDER_CELL_RE.test(c)) } const autolinkUrl = (raw: string) => raw.startsWith('mailto:') || raw.startsWith('http') || !raw.includes('@') ? raw : `mailto:${raw}` const renderAutolink = (k: number, t: Theme, raw: string) => ( {raw.replace(/^mailto:/, '')} ) export const stripInlineMarkup = (v: string) => v .replace(/!\[(.*?)\]\(((?:[^\s()]|\([^\s()]*\))+?)\)/g, '[image: $1] $2') .replace(/\[(.+?)\]\(((?:[^\s()]|\([^\s()]*\))+?)\)/g, '$1') .replace(/<((?:https?:\/\/|mailto:)[^>\s]+|[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,})>/g, '$1') .replace(/~~(.+?)~~/g, '$1') .replace(/`([^`]+)`/g, '$1') .replace(/\*\*(.+?)\*\*/g, '$1') .replace(/(? { const widths = rows[0]!.map((_, ci) => Math.max(...rows.map(r => stripInlineMarkup(r[ci] ?? '').length))) return ( {rows.map((row, ri) => ( {widths.map((w, ci) => ( {' '.repeat(Math.max(0, w - stripInlineMarkup(row[ci] ?? '').length))} {ci < widths.length - 1 ? ' ' : ''} ))} ))} ) } function MdInline({ t, text }: { t: Theme; text: string }) { const parts: ReactNode[] = [] let last = 0 for (const m of text.matchAll(INLINE_RE)) { const i = m.index ?? 0 const k = parts.length if (i > last) { parts.push({text.slice(last, i)}) } if (m[1] && m[2]) { parts.push( [image: {m[1]}] {m[2]} ) } else if (m[3] && m[4]) { parts.push( {m[3]} ) } else if (m[5]) { parts.push(renderAutolink(parts.length, t, m[5])) } else if (m[6]) { parts.push( {m[6]} ) } else if (m[7]) { parts.push( {m[7]} ) } else if (m[8] ?? m[9]) { parts.push( {m[8] ?? m[9]} ) } else if (m[10] ?? m[11]) { parts.push( {m[10] ?? m[11]} ) } else if (m[12]) { parts.push( {m[12]} ) } else if (m[13]) { parts.push( [{m[13]}] ) } else if (m[14]) { parts.push( ^{m[14]} ) } else if (m[15]) { parts.push( _{m[15]} ) } else if (m[16]) { // Bare URL — trim trailing prose punctuation into a sibling text node // so `see https://x.com/, which…` keeps the comma outside the link. const url = m[16].replace(/[),.;:!?]+$/g, '') parts.push(renderAutolink(parts.length, t, url)) if (url.length < m[16].length) { parts.push({m[16].slice(url.length)}) } } last = i + m[0].length } if (last < text.length) { parts.push({text.slice(last)}) } return {parts.length ? parts : {text}} } // Cross-instance parsed-children cache: useMemo's per-instance cache dies // on remount, so virtualization re-parses every row that scrolls back into // view. Theme-keyed WeakMap drops stale palettes; inner Map is LRU-bounded. const MD_CACHE_LIMIT = 512 const mdCache = new WeakMap>() const cacheBucket = (t: Theme) => { const b = mdCache.get(t) if (b) { return b } const fresh = new Map() mdCache.set(t, fresh) return fresh } const cacheGet = (b: Map, key: string) => { const v = b.get(key) if (v) { b.delete(key) b.set(key, v) } return v } const cacheSet = (b: Map, key: string, v: ReactNode[]) => { b.set(key, v) if (b.size > MD_CACHE_LIMIT) { b.delete(b.keys().next().value!) } } function MdImpl({ compact, t, text }: MdProps) { const nodes = useMemo(() => { const bucket = cacheBucket(t) const cacheKey = `${compact ? '1' : '0'}|${text}` const cached = cacheGet(bucket, cacheKey) if (cached) { return cached } const lines = ensureEmojiPresentation(text).split('\n') const nodes: ReactNode[] = [] let prevKind: Kind = null let i = 0 const gap = () => { if (nodes.length && prevKind !== 'blank') { nodes.push( ) prevKind = 'blank' } } const start = (kind: Exclude) => { if (prevKind && prevKind !== 'blank' && prevKind !== kind) { gap() } prevKind = kind } while (i < lines.length) { const line = lines[i]! const key = nodes.length if (!line.trim()) { if (!compact) { gap() } i++ continue } if (AUDIO_DIRECTIVE_RE.test(line)) { i++ continue } const media = line.match(MEDIA_LINE_RE)?.[1] if (media) { start('paragraph') nodes.push( {'▸ '} {media} ) i++ continue } const fence = line.match(FENCE_RE) if (fence) { const char = fence[1]![0] as '`' | '~' const len = fence[1]!.length const lang = fence[2]!.trim().toLowerCase() const block: string[] = [] for (i++; i < lines.length; i++) { const close = lines[i]!.match(FENCE_CLOSE_RE)?.[1] if (close && close[0] === char && close.length >= len) { break } block.push(lines[i]!) } if (i < lines.length) { i++ } if (['md', 'markdown'].includes(lang)) { start('paragraph') nodes.push() continue } start('code') const isDiff = lang === 'diff' const highlighted = !isDiff && isHighlightable(lang) nodes.push( {lang && !isDiff && {'─ ' + lang}} {block.map((l, j) => { if (highlighted) { return ( {highlightLine(l, lang, t).map(([color, text], kk) => color ? ( {text} ) : ( {text} ) )} ) } const add = isDiff && l.startsWith('+') const del = isDiff && l.startsWith('-') const hunk = isDiff && l.startsWith('@@') return ( {l} ) })} ) continue } if (line.trim().startsWith('$$')) { start('code') const block: string[] = [] for (i++; i < lines.length; i++) { if (lines[i]!.trim().startsWith('$$')) { i++ break } block.push(lines[i]!) } nodes.push( ─ math {block.map((l, j) => ( {l} ))} ) continue } const heading = line.match(HEADING_RE)?.[2] if (heading) { start('heading') nodes.push( {heading} ) i++ continue } if (i + 1 < lines.length && SETEXT_RE.test(lines[i + 1]!)) { start('heading') nodes.push( {line.trim()} ) i += 2 continue } if (HR_RE.test(line)) { start('rule') nodes.push( {'─'.repeat(36)} ) i++ continue } const footnote = line.match(FOOTNOTE_RE) if (footnote) { start('list') nodes.push( [{footnote[1]}] ) i++ while (i < lines.length && /^\s{2,}\S/.test(lines[i]!)) { nodes.push( ) i++ } continue } if (i + 1 < lines.length && DEF_RE.test(lines[i + 1]!)) { start('list') nodes.push( {line.trim()} ) i++ while (i < lines.length) { const def = lines[i]!.match(DEF_RE)?.[1] if (!def) { break } nodes.push( · ) i++ } continue } const bullet = line.match(BULLET_RE) if (bullet) { start('list') const task = bullet[2]!.match(TASK_RE) const marker = task ? (task[1]!.toLowerCase() === 'x' ? '☑' : '☐') : '•' nodes.push( {' '.repeat(indentDepth(bullet[1]!) * 2)} {marker}{' '} ) i++ continue } const numbered = line.match(NUMBERED_RE) if (numbered) { start('list') nodes.push( {' '.repeat(indentDepth(numbered[1]!) * 2)} {numbered[2]}.{' '} ) i++ continue } if (QUOTE_RE.test(line)) { start('quote') const quoteLines: Array<{ depth: number; text: string }> = [] while (i < lines.length && QUOTE_RE.test(lines[i]!)) { const prefix = lines[i]!.match(QUOTE_RE)?.[0] ?? '' quoteLines.push({ depth: (prefix.match(/>/g) ?? []).length, text: lines[i]!.slice(prefix.length) }) i++ } nodes.push( {quoteLines.map((ql, qi) => ( {' '.repeat(Math.max(0, ql.depth - 1) * 2)} {'│ '} ))} ) continue } if (line.includes('|') && i + 1 < lines.length && isTableDivider(lines[i + 1]!)) { start('table') const rows: string[][] = [splitRow(line)] for (i += 2; i < lines.length && lines[i]!.includes('|') && lines[i]!.trim(); i++) { rows.push(splitRow(lines[i]!)) } nodes.push(renderTable(key, rows, t)) continue } if (/^<\/?details\b/i.test(line)) { i++ continue } const summary = line.match(/^(.*?)<\/summary>$/i)?.[1] if (summary) { start('paragraph') nodes.push( ▶ {summary} ) i++ continue } if (/^<\/?[^>]+>$/.test(line.trim())) { start('paragraph') nodes.push( {line.trim()} ) i++ continue } if (line.includes('|') && line.trim().startsWith('|')) { start('table') const rows: string[][] = [] while (i < lines.length && lines[i]!.trim().startsWith('|')) { const row = lines[i]!.trim() if (!/^[|\s:-]+$/.test(row)) { rows.push(splitRow(row)) } i++ } if (rows.length) { nodes.push(renderTable(key, rows, t)) } continue } start('paragraph') nodes.push() i++ } cacheSet(bucket, cacheKey, nodes) return nodes }, [compact, t, text]) return {nodes} } export const Md = memo(MdImpl) type Kind = 'blank' | 'code' | 'heading' | 'list' | 'paragraph' | 'quote' | 'rule' | 'table' | null interface MdProps { compact?: boolean t: Theme text: string }