import { Box, Link, stringWidth, Text } from '@hermes/ink' import { Fragment, memo, type ReactNode, useMemo } from 'react' import { ensureEmojiPresentation } from '../lib/emoji.js' import { normalizeExternalUrl, urlSlugTitleLabel, useLinkTitle } from '../lib/externalLink.js' import { BOX_CLOSE, BOX_OPEN, texToUnicode } from '../lib/mathUnicode.js' import { highlightLine, isHighlightable } from '../lib/syntax.js' import type { Theme } from '../theme.js' // `\boxed{X}` regions in `texToUnicode` output are marked with the // non-printable U+0001 / U+0002 sentinels. Split on them and render the // boxed segment with `inverse + bold` so it reads as a highlighter-pen // emphasis on top of whatever color the parent `` is using (the // theme accent for math). The leading / trailing space inside the // highlight gives a one-cell visual margin so the highlight reads as a // block, not a hug. const renderMath = (text: string): ReactNode => { if (!text.includes(BOX_OPEN)) { return text } const out: ReactNode[] = [] let i = 0 let key = 0 while (i < text.length) { const start = text.indexOf(BOX_OPEN, i) if (start < 0) { out.push(text.slice(i)) break } if (start > i) { out.push(text.slice(i, start)) } const end = text.indexOf(BOX_CLOSE, start + 1) if (end < 0) { out.push(text.slice(start)) break } out.push( {' '} {text.slice(start + 1, end)}{' '} ) i = end + 1 } return out } 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()]*\\))+?)' const MD_IDENTIFIER_RE = '[A-Za-z_][A-Za-z0-9_]*' const MD_DUNDER_IDENTIFIER_RE = `(?:${MD_IDENTIFIER_RE}__(?!\\w))` const MD_UNDERSCORE_BOLD_RE = `(?\\s]+|[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,})>`, // 5 autolink `~~(.+?)~~`, // 6 strike `\`([^\\\`]+)\``, // 7 code `\\*\\*(.+?)\\*\\*`, // 8 bold * MD_UNDERSCORE_BOLD_RE, // 9 bold _ `\\*(.+?)\\*`, // 10 italic * MD_UNDERSCORE_ITALIC_RE, // 11 italic _ `==(.+?)==`, // 12 highlight `\\[\\^([^\\]]+)\\]`, // 13 footnote ref `\\^([^^\\s][^^]*?)\\^`, // 14 superscript `~([A-Za-z0-9]{1,8})~`, // 15 subscript `(https?:\\/\\/[^\\s<]+)`, // 16 bare URL — wrapped so it owns its own // capture group; without this, the math // spans below would land in m[16] and the // MdInline dispatcher would treat them as // bare URLs and render them as autolinks. `(? 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 defaultLinkLabel = (url: string) => url.startsWith('mailto:') ? url.replace(/^mailto:/, '') : /^https?:\/\//i.test(url) ? urlSlugTitleLabel(url) : url const pickFallbackLabel = (label: string | undefined, target: string): string | undefined => { const trimmed = label?.trim() if (!trimmed) { return undefined } return normalizeExternalUrl(trimmed) === target ? undefined : trimmed } interface ResolvedLinkProps { fallbackLabel?: string t: Theme url: string } function ResolvedLink({ fallbackLabel, t, url }: ResolvedLinkProps) { const fetched = useLinkTitle(url) const display = fetched || fallbackLabel || defaultLinkLabel(url) return ( {display} ) } const renderResolvedLink = (k: number, t: Theme, rawUrl: string, label?: string) => { const target = normalizeExternalUrl(rawUrl) return } 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(STRIP_UNDERSCORE_BOLD_RE, '$1') .replace(/\*(.+?)\*/g, '$1') .replace(STRIP_UNDERSCORE_ITALIC_RE, '$1') .replace(/==(.+?)==/g, '$1') .replace(/\[\^([^\]]+)\]/g, '[$1]') .replace(/\^([^^\s][^^]*?)\^/g, '^$1') .replace(/~([A-Za-z0-9]{1,8})~/g, '_$1') .replace(/(? const renderTable = (k: number, rows: string[][], t: Theme, cols?: number) => { // Guard: empty table if (rows.length === 0 || rows[0]!.length === 0) return null const cellDisplayWidth = (raw: string) => stringWidth(stripInlineMarkup(raw)) // Minimum width: longest word in a cell (to avoid breaking words) const minCellWidth = (raw: string) => { const text = stripInlineMarkup(raw) const words = text.split(/\s+/).filter(w => w.length > 0) if (words.length === 0) return MIN_COL_WIDTH return Math.max(...words.map(w => stringWidth(w)), MIN_COL_WIDTH) } const numCols = rows[0]!.length // Normalize ragged rows: ensure every row has exactly numCols cells const normalizedRows = rows.map(row => { if (row.length >= numCols) return row.slice(0, numCols) return [...row, ...Array(numCols - row.length).fill('')] }) // Ideal widths: max cell content per column const idealWidths = normalizedRows[0]!.map((_, ci) => Math.max(...normalizedRows.map(r => cellDisplayWidth(r[ci] ?? '')), MIN_COL_WIDTH) ) // Min widths: longest word per column const minWidths = normalizedRows[0]!.map((_, ci) => Math.max(...normalizedRows.map(r => minCellWidth(r[ci] ?? '')), MIN_COL_WIDTH) ) // Available width: cols minus table padding minus column gaps minus safety. // transcriptBodyWidth (source of cols) subtracts message gutter + scrollbar, // but NOT this table's paddingLeft — we subtract it here. const gapOverhead = (numCols - 1) * COL_GAP const availableWidth = cols ? Math.max(cols - TABLE_PADDING_LEFT - gapOverhead - SAFETY_MARGIN, numCols * MIN_COL_WIDTH) : Infinity const totalIdeal = idealWidths.reduce((a, b) => a + b, 0) const totalMin = minWidths.reduce((a, b) => a + b, 0) let columnWidths: number[] let needsWrap = false if (totalIdeal <= availableWidth) { // Tier 1: everything fits at ideal widths columnWidths = idealWidths } else if (totalMin <= availableWidth) { // Tier 2: proportional shrink — distribute extra space beyond minimums needsWrap = true const extraSpace = availableWidth - totalMin const overflows = idealWidths.map((ideal, i) => ideal - minWidths[i]!) const totalOverflow = overflows.reduce((a, b) => a + b, 0) if (totalOverflow === 0) { columnWidths = [...minWidths] } else { const rawAlloc = minWidths.map((min, i) => min + (overflows[i]! / totalOverflow) * extraSpace ) columnWidths = rawAlloc.map(v => Math.floor(v)) // Distribute rounding remainders to columns with largest fractional part let remainder = availableWidth - columnWidths.reduce((a, b) => a + b, 0) const fracs = rawAlloc.map((v, i) => ({ i, frac: v - Math.floor(v) })) .sort((a, b) => b.frac - a.frac) for (const { i } of fracs) { if (remainder <= 0) break columnWidths[i]!++ remainder-- } } } else { // Tier 3: even min-widths don't fit — scale proportionally, allow hard breaks. // NOTE: Math.max(..., MIN_COL_WIDTH) can push total above availableWidth when // many columns are scaled below 3. This is caught by safetyOverflow → vertical fallback. needsWrap = true const scaleFactor = availableWidth / totalMin const rawAlloc = minWidths.map(w => w * scaleFactor) columnWidths = rawAlloc.map(v => Math.max(Math.floor(v), MIN_COL_WIDTH)) let remainder = availableWidth - columnWidths.reduce((a, b) => a + b, 0) const fracs = rawAlloc.map((v, i) => ({ i, frac: v - Math.floor(v) })) .sort((a, b) => b.frac - a.frac) for (const { i } of fracs) { if (remainder <= 0) break columnWidths[i]!++ remainder-- } } // Grapheme-safe hard-break: prefer Intl.Segmenter, fall back to code-point split const segmenter = typeof Intl !== 'undefined' && 'Segmenter' in Intl ? new (Intl as any).Segmenter(undefined, { granularity: 'grapheme' }) : null const graphemes = (s: string): string[] => segmenter ? [...segmenter.segment(s)].map((seg: { segment: string }) => seg.segment) : [...s] // Word-wrap plain text to fit within `width` display columns. // Operates on stripped text for correct width measurement. const wrapCell = (raw: string, width: number, hard: boolean): string[] => { const text = stripInlineMarkup(raw) if (width <= 0) return [text] if (stringWidth(text) <= width) return [text] const words = text.split(/\s+/).filter(w => w.length > 0) const lines: string[] = [] let current = '' let currentWidth = 0 for (const word of words) { const w = stringWidth(word) if (currentWidth === 0) { if (hard && w > width) { for (const ch of graphemes(word)) { const cw = stringWidth(ch) if (currentWidth + cw > width && current) { lines.push(current) current = '' currentWidth = 0 } current += ch currentWidth += cw } } else { current = word currentWidth = w } } else if (currentWidth + 1 + w <= width) { current += ' ' + word currentWidth += 1 + w } else { lines.push(current) current = word currentWidth = w } } if (current) lines.push(current) return lines.length > 0 ? lines : [''] } const isHard = totalMin > availableWidth // tier 3 needs hard word breaks const sep = columnWidths.map(w => '─'.repeat(Math.max(1, w))).join(' ') // When wrapping isn't needed, build single-line strings per row. // All cells render as plain text via stripInlineMarkup. // TODO: follow-up — format to ANSI then wrap with wrapAnsi for inline markdown preservation. // See free-code/src/components/MarkdownTable.tsx L44-L62 for approach. if (!needsWrap) { const buildRowString = (row: string[]): string => row.map((cell, ci) => { const text = stripInlineMarkup(cell) const pad = ' '.repeat(Math.max(0, columnWidths[ci]! - stringWidth(text))) const gap = ci < numCols - 1 ? ' ' : '' return text + pad + gap }).join('') return ( {normalizedRows.map((row, ri) => ( {buildRowString(row)} {ri === 0 && normalizedRows.length > 1 ? ( {sep} ) : null} ))} ) } // Wrapping path: build multi-line rows as complete strings. type LineEntry = { text: string; kind: 'header' | 'separator' | 'body' } const buildRowLines = (row: string[]): string[] => { const cellLines = row.map((cell, ci) => wrapCell(cell, columnWidths[ci]!, isHard) ) const maxLines = Math.max(...cellLines.map(l => l.length), 1) const result: string[] = [] for (let li = 0; li < maxLines; li++) { let line = '' for (let ci = 0; ci < numCols; ci++) { const cl = cellLines[ci] ?? [''] const cellText = li < cl.length ? cl[li]! : '' const pad = ' '.repeat(Math.max(0, columnWidths[ci]! - stringWidth(cellText))) line += cellText + pad if (ci < numCols - 1) line += ' ' } result.push(line) } return result } // Build all lines with metadata for styling, tracking tallest body row const allEntries: LineEntry[] = [] let tallestBodyRow = 0 normalizedRows.forEach((row, ri) => { const kind = ri === 0 ? 'header' as const : 'body' as const const rowLines = buildRowLines(row) rowLines.forEach(text => allEntries.push({ text, kind })) if (ri > 0) tallestBodyRow = Math.max(tallestBodyRow, rowLines.length) if (ri === 0 && normalizedRows.length > 1) { allEntries.push({ text: sep, kind: 'separator' }) } }) // Post-render safety condition: compute max line width. const maxLineWidth = Math.max(...allEntries.map(e => stringWidth(e.text))) const safetyOverflow = cols != null && maxLineWidth > cols - TABLE_PADDING_LEFT - SAFETY_MARGIN // Scaled vertical threshold — 2-3 col tables stay tabular even with tall cells const maxRowLinesThreshold = numCols <= 3 ? 8 : numCols <= 6 ? 5 : 4 const useVertical = tallestBodyRow > maxRowLinesThreshold || safetyOverflow if (useVertical) { // Edge case: header-only table if (normalizedRows.length <= 1) { return ( {normalizedRows[0]!.map(h => stripInlineMarkup(h)).join(' · ')} ) } const headers = normalizedRows[0]! const dataRows = normalizedRows.slice(1) const sepWidth = Math.max(1, cols ? Math.min(cols - TABLE_PADDING_LEFT - 1, 40) : 40) return ( {dataRows.map((row, ri) => ( {ri > 0 ? ( {'─'.repeat(sepWidth)} ) : null} {headers.map((header, ci) => { const cell = row[ci] ?? '' const label = stripInlineMarkup(header) || `Col ${ci + 1}` return ( {label}: {' '}{stripInlineMarkup(cell)} ) })} ))} ) } // Render wrapped horizontal rows — one per visual line. return ( {allEntries.map((entry, i) => ( {entry.text} ))} ) } 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(renderResolvedLink(parts.length, t, m[4], m[3])) } else if (m[5]) { parts.push(renderResolvedLink(parts.length, t, autolinkUrl(m[5]), m[5].replace(/^mailto:/, ''))) } else if (m[6]) { parts.push( ) } else if (m[7]) { // Code is the one wrap that does NOT recurse — inline `code` spans // are verbatim by definition. Letting MdInline reprocess them // would corrupt regex examples and shell snippets. parts.push( {m[7]} ) } else if (m[8] ?? m[9]) { // Recurse into bold / italic / strike / highlight so nested // `$...$` math (and other inline tokens) inside a `**bolded // statement with $\mathbb{Z}$ math**` actually render. Without // this the inner content is dropped into a single `` // verbatim and the math renderer never sees it. parts.push( ) } else if (m[10] ?? m[11]) { parts.push( ) } else if (m[12]) { parts.push( ) } 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(renderResolvedLink(parts.length, t, url)) if (url.length < m[16].length) { parts.push({m[16].slice(url.length)}) } } else if (m[17] ?? m[18]) { // Inline math is run through `texToUnicode` (Greek letters, ℕℤℚℝ, // operators, sub/superscripts, fractions) and rendered in italic // accent. Italic is the disambiguator — links use accent+underline, // so without italic readers can't tell `\mathbb{R}` (math) from a // hyperlinked word. Anything `texToUnicode` doesn't recognise is // preserved verbatim, so unfamiliar commands just look like their // raw LaTeX rather than vanishing. parts.push( {renderMath(texToUnicode(m[17] ?? m[18]!))} ) } 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({ cols, compact, t, text }: MdProps) { const nodes = useMemo(() => { const bucket = cacheBucket(t) const cacheKey = `${compact ? '1' : '0'}|${cols ?? ''}|${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 } const mathOpen = line.match(MATH_BLOCK_OPEN_RE) if (mathOpen) { const opener = mathOpen[1]! const closeRe = opener === '$$' ? MATH_BLOCK_CLOSE_DOLLAR_RE : MATH_BLOCK_CLOSE_BRACKET_RE const headRest = mathOpen[2] ?? '' const block: string[] = [] // Single-line block: `$$x + y = z$$` or `\[x\]`. Capture inner content // and emit the block immediately. Without this, the close-scan loop // skips line `i` and treats the next opener as our closer, swallowing // every paragraph in between. const sameLineClose = headRest.match(closeRe) if (sameLineClose) { const inner = sameLineClose[1]!.trim() start('code') nodes.push( {inner ? {renderMath(texToUnicode(inner))} : null} ) i++ continue } // Multi-line block: scan ahead for a real closer before committing. // If none exists in the rest of the document, render this line as a // paragraph instead of consuming everything that follows. let closeIdx = -1 for (let j = i + 1; j < lines.length; j++) { if (closeRe.test(lines[j]!)) { closeIdx = j break } } if (closeIdx < 0) { start('paragraph') nodes.push() i++ continue } if (headRest.trim()) { block.push(headRest) } for (let j = i + 1; j < closeIdx; j++) { block.push(lines[j]!) } const tail = lines[closeIdx]!.match(closeRe)![1]!.trimEnd() if (tail.trim()) { block.push(tail) } start('code') nodes.push( {block.map((l, j) => ( {renderMath(texToUnicode(l))} ))} ) i = closeIdx + 1 continue } const heading = line.match(HEADING_RE)?.[2] if (heading) { start('heading') nodes.push( ) i++ continue } if (i + 1 < lines.length && SETEXT_RE.test(lines[i + 1]!)) { start('heading') nodes.push( ) 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( {marker} ) i++ continue } const numbered = line.match(NUMBERED_RE) if (numbered) { start('list') nodes.push( {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) => ( ))} ) 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, cols)) 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, cols)) } continue } start('paragraph') nodes.push() i++ } cacheSet(bucket, cacheKey, nodes) return nodes }, [cols, compact, t, text]) return {nodes} } export const Md = memo(MdImpl) type Kind = 'blank' | 'code' | 'heading' | 'list' | 'paragraph' | 'quote' | 'rule' | 'table' | null interface MdProps { cols?: number compact?: boolean t: Theme text: string }