SPB Git

Repository Public

markdown.ts 4.7 KB 144 lines typescript

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
Files main
Menu

Theme