Jaspers Research: Research rooms where configurable analysts research companies in parallel over SEC filings data, answering with checked citations.
| 1 | import { parseCitationTags } from './citations.ts' |
| 2 | |
| 3 | // The Markdown an analyst writes, read into a small tree the view renders as React elements, never |
| 4 | // as HTML: headings, paragraphs, lists, fenced code, tables, quotes, rules, and inside them code, |
| 5 | // strong, emphasis, links, and citation tags around sourced sentences. Only https links survive, |
| 6 | // since they open in the browser through the bridge. Anything else reads as the text it is. Pure, |
| 7 | // so the reading can be checked in a test. |
| 8 | |
| 9 | export type Inline = |
| 10 | | { kind: 'text'; text: string } |
| 11 | | { kind: 'code'; text: string } |
| 12 | | { kind: 'strong'; children: Inline[] } |
| 13 | | { kind: 'em'; children: Inline[] } |
| 14 | | { kind: 'link'; href: string; children: Inline[] } |
| 15 | | { kind: 'cite'; attrs: Record<string, string>; children: Inline[] } |
| 16 | |
| 17 | export type Align = 'left' | 'center' | 'right' | null |
| 18 | |
| 19 | export type Block = |
| 20 | | { kind: 'heading'; level: number; children: Inline[] } |
| 21 | | { kind: 'paragraph'; children: Inline[] } |
| 22 | | { kind: 'list'; ordered: boolean; items: Inline[][] } |
| 23 | | { kind: 'code'; lang: string; text: string } |
| 24 | | { kind: 'table'; align: Align[]; header: Inline[][]; rows: Inline[][][] } |
| 25 | | { kind: 'quote'; children: Inline[] } |
| 26 | | { kind: 'rule' } |
| 27 | |
| 28 | const FENCE = /^```\s*([\w+-]*)\s*$/ |
| 29 | const HEADING = /^(#{1,6})\s+(.*?)\s*#*\s*$/ |
| 30 | const RULE = /^(?:-{3,}|\*{3,}|_{3,})\s*$/ |
| 31 | const BULLET = /^\s*[-*+]\s+(.*)$/ |
| 32 | const NUMBERED = /^\s*\d+[.)]\s+(.*)$/ |
| 33 | const SEPARATOR = /^\s*\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)*\|?\s*$/ |
| 34 | const INLINE = |
| 35 | /(`[^`\n]+`)|\[([^\]\n]+)\]\((https:\/\/[^\s)]+)\)|\*\*((?:[^*]|\*[^*\n]+\*)+?)\*\*|\*([^*\s](?:[^*\n]*[^*\s])?)\*|(https:\/\/[^\s<>()]+)/g |
| 36 | |
| 37 | export function parseMarkdown(source: string): Block[] { |
| 38 | const lines = source.replace(/\r\n?/g, '\n').split('\n') |
| 39 | const blocks: Block[] = [] |
| 40 | let i = 0 |
| 41 | while (i < lines.length) { |
| 42 | const line = lines[i]! |
| 43 | const fence = FENCE.exec(line) |
| 44 | if (fence) { |
| 45 | const body: string[] = [] |
| 46 | i++ |
| 47 | while (i < lines.length && !/^```\s*$/.test(lines[i]!)) body.push(lines[i++]!) |
| 48 | i++ |
| 49 | blocks.push({ kind: 'code', lang: fence[1] ?? '', text: body.join('\n') }) |
| 50 | continue |
| 51 | } |
| 52 | if (line.trim() === '') { |
| 53 | i++ |
| 54 | continue |
| 55 | } |
| 56 | const heading = HEADING.exec(line) |
| 57 | if (heading) { |
| 58 | blocks.push({ kind: 'heading', level: heading[1]!.length, children: parseInline(heading[2] ?? '') }) |
| 59 | i++ |
| 60 | continue |
| 61 | } |
| 62 | if (RULE.test(line)) { |
| 63 | blocks.push({ kind: 'rule' }) |
| 64 | i++ |
| 65 | continue |
| 66 | } |
| 67 | if (line.includes('|') && i + 1 < lines.length && SEPARATOR.test(lines[i + 1]!)) { |
| 68 | const align = cells(lines[i + 1]!).map(alignOf) |
| 69 | const header = cells(line).map(parseInline) |
| 70 | i += 2 |
| 71 | const rows: Inline[][][] = [] |
| 72 | while (i < lines.length && lines[i]!.includes('|') && lines[i]!.trim() !== '') rows.push(cells(lines[i++]!).map(parseInline)) |
| 73 | blocks.push({ kind: 'table', align, header, rows }) |
| 74 | continue |
| 75 | } |
| 76 | const bullet = BULLET.exec(line) |
| 77 | const numbered = bullet ? null : NUMBERED.exec(line) |
| 78 | if (bullet || numbered) { |
| 79 | const ordered = numbered !== null |
| 80 | const items: string[] = [] |
| 81 | while (i < lines.length) { |
| 82 | const item = (ordered ? NUMBERED : BULLET).exec(lines[i]!) |
| 83 | if (item) items.push(item[1] ?? '') |
| 84 | else if (items.length > 0 && /^\s{2,}\S/.test(lines[i]!)) items[items.length - 1] += ` ${lines[i]!.trim()}` |
| 85 | else break |
| 86 | i++ |
| 87 | } |
| 88 | blocks.push({ kind: 'list', ordered, items: items.map((item) => parseInline(item.trim())) }) |
| 89 | continue |
| 90 | } |
| 91 | if (line.startsWith('>')) { |
| 92 | const quoted: string[] = [] |
| 93 | while (i < lines.length && lines[i]!.startsWith('>')) quoted.push(lines[i++]!.replace(/^>\s?/, '').trim()) |
| 94 | blocks.push({ kind: 'quote', children: parseInline(quoted.join(' ')) }) |
| 95 | continue |
| 96 | } |
| 97 | const paragraph: string[] = [] |
| 98 | while (i < lines.length && lines[i]!.trim() !== '' && !startsBlock(lines, i)) paragraph.push(lines[i++]!.trim()) |
| 99 | blocks.push({ kind: 'paragraph', children: parseInline(paragraph.join(' ')) }) |
| 100 | } |
| 101 | return blocks |
| 102 | } |
| 103 | |
| 104 | /** Inline text: citation tags first, since their attributes may hold any character, then the marks between them. */ |
| 105 | export function parseInline(source: string): Inline[] { |
| 106 | const tags = parseCitationTags(source) |
| 107 | if (tags.length === 0) return parseMarks(source) |
| 108 | const out: Inline[] = [] |
| 109 | let at = 0 |
| 110 | for (const tag of tags) { |
| 111 | out.push(...parseMarks(source.slice(at, tag.start))) |
| 112 | out.push({ kind: 'cite', attrs: tag.attrs, children: parseInline(tag.inner) }) |
| 113 | at = tag.end |
| 114 | } |
| 115 | out.push(...parseMarks(source.slice(at))) |
| 116 | return mergeText(out) |
| 117 | } |
| 118 | |
| 119 | /** A stray closing tag is not text. */ |
| 120 | const STRAY_CLOSE = /<\/(?:cite|dbcitation)\s*>/gi |
| 121 | |
| 122 | function parseMarks(raw: string): Inline[] { |
| 123 | const source = raw.replace(STRAY_CLOSE, '') |
| 124 | const out: Inline[] = [] |
| 125 | const text = (value: string): void => { |
| 126 | if (!value) return |
| 127 | const last = out[out.length - 1] |
| 128 | if (last?.kind === 'text') last.text += value |
| 129 | else out.push({ kind: 'text', text: value }) |
| 130 | } |
| 131 | let at = 0 |
| 132 | for (const match of source.matchAll(INLINE)) { |
| 133 | const index = match.index ?? 0 |
| 134 | text(source.slice(at, index)) |
| 135 | at = index + match[0].length |
| 136 | const [, code, label, href, strong, em, bare] = match |
| 137 | if (code !== undefined) out.push({ kind: 'code', text: code.slice(1, -1) }) |
| 138 | else if (label !== undefined && href !== undefined) out.push({ kind: 'link', href, children: parseInline(label) }) |
| 139 | else if (strong !== undefined) out.push({ kind: 'strong', children: parseInline(strong) }) |
| 140 | else if (em !== undefined) out.push({ kind: 'em', children: parseInline(em) }) |
| 141 | else if (bare !== undefined) { |
| 142 | // Punctuation ending a sentence is not part of the link. |
| 143 | const url = bare.replace(/[.,;:!?]+$/, '') |
| 144 | out.push({ kind: 'link', href: url, children: [{ kind: 'text', text: url }] }) |
| 145 | at -= bare.length - url.length |
| 146 | } |
| 147 | } |
| 148 | text(source.slice(at)) |
| 149 | return out |
| 150 | } |
| 151 | |
| 152 | /** A paragraph ends where another block starts. */ |
| 153 | function startsBlock(lines: string[], i: number): boolean { |
| 154 | const line = lines[i]! |
| 155 | return ( |
| 156 | FENCE.test(line) || |
| 157 | HEADING.test(line) || |
| 158 | RULE.test(line) || |
| 159 | BULLET.test(line) || |
| 160 | NUMBERED.test(line) || |
| 161 | line.startsWith('>') || |
| 162 | (line.includes('|') && i + 1 < lines.length && SEPARATOR.test(lines[i + 1]!)) |
| 163 | ) |
| 164 | } |
| 165 | |
| 166 | /** Adjacent text runs joined, so a dropped tag leaves one node where a reader expects one. */ |
| 167 | function mergeText(nodes: Inline[]): Inline[] { |
| 168 | const out: Inline[] = [] |
| 169 | for (const node of nodes) { |
| 170 | const last = out[out.length - 1] |
| 171 | if (node.kind === 'text' && last?.kind === 'text') last.text += node.text |
| 172 | else if (node.kind !== 'text' || node.text) out.push(node) |
| 173 | } |
| 174 | return out |
| 175 | } |
| 176 | |
| 177 | /** The cells of a table row: split on pipes, except a pipe inside a tag, whose quote may hold one. */ |
| 178 | function cells(row: string): string[] { |
| 179 | const trimmed = row.trim().replace(/^\|/, '').replace(/\|$/, '') |
| 180 | const out: string[] = [] |
| 181 | let cell = '' |
| 182 | let inTag = false |
| 183 | for (let i = 0; i < trimmed.length; i++) { |
| 184 | const ch = trimmed[i]! |
| 185 | if (!inTag && ch === '<' && /^<(?:cite|dbcitation)\b/i.test(trimmed.slice(i))) inTag = true |
| 186 | else if (inTag && ch === '>') inTag = false |
| 187 | if (ch === '|' && !inTag) { |
| 188 | out.push(cell.trim()) |
| 189 | cell = '' |
| 190 | } else { |
| 191 | cell += ch |
| 192 | } |
| 193 | } |
| 194 | out.push(cell.trim()) |
| 195 | return out |
| 196 | } |
| 197 | |
| 198 | function alignOf(cell: string): Align { |
| 199 | const left = cell.startsWith(':') |
| 200 | const right = cell.endsWith(':') |
| 201 | return left && right ? 'center' : right ? 'right' : left ? 'left' : null |
| 202 | } |