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