Jaspers Hub

jaspers / research

pluginOfficialReviewed★ 0↓ 28

Jaspers Research: Research rooms where configurable analysts research companies in parallel over SEC filings data, answering with checked citations.

7.4 KB
1import { 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
9export 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
17export type Align = 'left' | 'center' | 'right' | null
18
19export 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
28const FENCE = /^```\s*([\w+-]*)\s*$/
29const HEADING = /^(#{1,6})\s+(.*?)\s*#*\s*$/
30const RULE = /^(?:-{3,}|\*{3,}|_{3,})\s*$/
31const BULLET = /^\s*[-*+]\s+(.*)$/
32const NUMBERED = /^\s*\d+[.)]\s+(.*)$/
33const SEPARATOR = /^\s*\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)*\|?\s*$/
34const INLINE =
35 /(`[^`\n]+`)|\[([^\]\n]+)\]\((https:\/\/[^\s)]+)\)|\*\*((?:[^*]|\*[^*\n]+\*)+?)\*\*|\*([^*\s](?:[^*\n]*[^*\s])?)\*|(https:\/\/[^\s<>()]+)/g
36
37export 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. */
105export 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. */
120const STRAY_CLOSE = /<\/(?:cite|dbcitation)\s*>/gi
121
122function 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. */
153function 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. */
167function 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. */
178function 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
198function 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}