apps/web/src/lib/markdownDelivery.ts
1import { sanitizeHref } from '@local/ai/components/items/chatMarkdown'
2
3/** Normalize a single Markdown link, never invent a destination. */
4function canonicalLink(link: string): string {
5 const match = link.match(/^\[([^\]]+)\]\(([\s\S]*)\)$/)
6 if (!match) return link
7 const raw = match[2].trim().replace(/^["'<]+|["'>]+$/g, '')
8 const href = /^mailto:[^\s@]+@[^\s@]+\.[^\s@]+$/i.test(raw)
9 ? raw
10 : sanitizeHref(raw)
11 return href ? `[${match[1]}](${href})` : match[1]
12}
13
14/** Hold only the current link, capped at 4 KiB; ordinary words stream immediately. */
15export function markdownDeliveryStream() {
16 let pending = ''
17 const decoder = new TextDecoder()
18 const encoder = new TextEncoder()
19 const drain = (flush: boolean) => {
20 let output = ''
21 while (pending) {
22 const start = pending.indexOf('[')
23 if (start < 0) {
24 output += pending
25 pending = ''
26 break
27 }
28 if (start > 0) {
29 output += pending.slice(0, start)
30 pending = pending.slice(start)
31 }
32 const labelEnd = pending.indexOf(']')
33 if (labelEnd < 0 || labelEnd === pending.length - 1) {
34 if (!flush && pending.length < 4096) break
35 output += pending
36 pending = ''
37 break
38 }
39 if (pending[labelEnd + 1] !== '(') {
40 output += pending.slice(0, labelEnd + 1)
41 pending = pending.slice(labelEnd + 1)
42 continue
43 }
44 let depth = 1,
45 end = labelEnd + 2
46 for (; end < pending.length; end++) {
47 if (pending[end] === '(') depth++
48 if (pending[end] === ')' && --depth === 0) break
49 }
50 if (end === pending.length) {
51 if (!flush && pending.length < 4096) break
52 output += pending
53 pending = ''
54 break
55 }
56 output += canonicalLink(pending.slice(0, end + 1))
57 pending = pending.slice(end + 1)
58 }
59 return output
60 }
61 return new TransformStream<Uint8Array, Uint8Array>({
62 transform(chunk, controller) {
63 pending += decoder.decode(chunk, { stream: true })
64 const text = drain(false)
65 if (text) controller.enqueue(encoder.encode(text))
66 },
67 flush(controller) {
68 pending += decoder.decode()
69 const text = drain(true)
70 if (text) controller.enqueue(encoder.encode(text))
71 },
72 })
73}
74