Repository Public
1/**2 * Minimal, SAFE Markdown for model text. Parses to plain data (rendered by components/timeline/3 * markdown.tsx as React text nodes — never as HTML):4 * blocks: paragraphs, #-headings, bullet / numbered lists, block quotes, fenced code, rules5 * inline: `code`, **bold**, *italic*6 * Deliberately NOT supported: links (shown as written, never clickable), bare-URL auto-linking,7 * images (shown as written, never fetched), raw HTML (shown as text). Model and tool output is8 * untrusted: nothing in it can make the browser fetch or navigate.9 */10export type Inline = { t: 'text' | 'code' | 'bold' | 'italic'; v: string };11export type Block =12 | { t: 'p'; inl: Inline[] }13 | { t: 'h'; level: 1 | 2 | 3; inl: Inline[] }14 | { t: 'ul' | 'ol'; items: Inline[][]; start?: number }15 | { t: 'quote'; inl: Inline[] }16 | { t: 'code'; lang: string | null; text: string }17 | { t: 'hr' };1819export function parseInline(src: string): Inline[] {20 const out: Inline[] = [];21 let buf = '';22 const flush = () => {23 if (buf) out.push({ t: 'text', v: buf });24 buf = '';25 };26 let i = 0;27 while (i < src.length) {28 const c = src[i];29 if (c === '\\' && i + 1 < src.length && /[\\`*_#[\]()!-]/.test(src[i + 1] ?? '')) {30 buf += src[i + 1];31 i += 2;32 continue;33 }34 if (c === '`') {35 const end = src.indexOf('`', i + 1);36 if (end > i + 1) {37 flush();38 out.push({ t: 'code', v: src.slice(i + 1, end) });39 i = end + 1;40 continue;41 }42 }43 if (c === '*' && src[i + 1] === '*') {44 const end = src.indexOf('**', i + 2);45 if (end > i + 2 && !/\s/.test(src[i + 2] ?? ' ')) {46 flush();47 out.push({ t: 'bold', v: src.slice(i + 2, end) });48 i = end + 2;49 continue;50 }51 }52 if (c === '*' && src[i + 1] !== '*' && !/\s/.test(src[i + 1] ?? ' ')) {53 const end = src.indexOf('*', i + 1);54 if (end > i + 1 && src[end - 1] !== ' ' && !src.slice(i + 1, end).includes('\n')) {55 flush();56 out.push({ t: 'italic', v: src.slice(i + 1, end) });57 i = end + 1;58 continue;59 }60 }61 buf += c;62 i++;63 }64 flush();65 return out;66}6768const FENCE = /^\s{0,3}(`{3,}|~{3,})\s*([\w+#.-]*)\s*$/;69const BULLET = /^\s{0,6}[-*+]\s+(.*)$/;70const NUMBER = /^\s{0,6}(\d{1,9})[.)]\s+(.*)$/;71const HEADING = /^\s{0,3}(#{1,6})\s+(.*?)\s*#*\s*$/;7273export function parseMarkdown(src: string): Block[] {74 const lines = src.replace(/\r\n/g, '\n').split('\n');75 const blocks: Block[] = [];76 let para: string[] = [];77 const flushPara = () => {78 if (para.length) blocks.push({ t: 'p', inl: parseInline(para.join('\n')) });79 para = [];80 };81 let i = 0;82 while (i < lines.length) {83 const line = lines[i] ?? '';84 const fence = FENCE.exec(line);85 if (fence) {86 flushPara();87 const marker = fence[1] ?? '```';88 const body: string[] = [];89 i++;90 // an unterminated fence (text still streaming) runs to the end91 while (i < lines.length && !new RegExp(`^\\s{0,3}${marker[0]}{${marker.length},}\\s*$`).test(lines[i] ?? '')) body.push(lines[i++] ?? '');92 i++;93 blocks.push({ t: 'code', lang: fence[2] || null, text: body.join('\n') });94 continue;95 }96 if (line.trim() === '') {97 flushPara();98 i++;99 continue;100 }101 const h = HEADING.exec(line);102 if (h) {103 flushPara();104 blocks.push({ t: 'h', level: Math.min(3, (h[1] ?? '#').length) as 1 | 2 | 3, inl: parseInline(h[2] ?? '') });105 i++;106 continue;107 }108 if (/^\s{0,3}([-*_])(\s*\1){2,}\s*$/.test(line)) {109 flushPara();110 blocks.push({ t: 'hr' });111 i++;112 continue;113 }114 if (BULLET.test(line) || NUMBER.test(line)) {115 flushPara();116 const ordered = NUMBER.test(line) && !BULLET.test(line);117 const re = ordered ? NUMBER : BULLET;118 const items: string[] = [];119 const start = ordered ? Number(NUMBER.exec(line)?.[1] ?? 1) : undefined;120 while (i < lines.length) {121 const l = lines[i] ?? '';122 const m = re.exec(l);123 if (m) items.push((ordered ? m[2] : m[1]) ?? '');124 else if (l.trim() !== '' && /^\s{2,}/.test(l) && items.length) items[items.length - 1] += `\n${l.trim()}`; // continuation line125 else break;126 i++;127 }128 blocks.push({ t: ordered ? 'ol' : 'ul', items: items.map(parseInline), start });129 continue;130 }131 if (/^\s{0,3}>\s?/.test(line)) {132 flushPara();133 const body: string[] = [];134 while (i < lines.length && /^\s{0,3}>\s?/.test(lines[i] ?? '')) body.push((lines[i++] ?? '').replace(/^\s{0,3}>\s?/, ''));135 blocks.push({ t: 'quote', inl: parseInline(body.join('\n')) });136 continue;137 }138 para.push(line);139 i++;140 }141 flushPara();142 return blocks;143}144