@poodle64/librarian 2026.10.3 → 2026.10.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/README.md +10 -4
  2. package/dist/chat.svelte.d.ts +40 -0
  3. package/dist/chat.svelte.js +57 -3
  4. package/dist/client.d.ts +3 -0
  5. package/dist/client.js +4 -1
  6. package/dist/components/activity-group/activity-group.svelte +21 -108
  7. package/dist/components/activity-group/activity-group.svelte.d.ts +3 -1
  8. package/dist/components/agent-transcript/agent-transcript.svelte +164 -4
  9. package/dist/components/agent-transcript/agent-transcript.svelte.d.ts +18 -0
  10. package/dist/components/artefact-pane/artefact-pane.svelte +162 -18
  11. package/dist/components/artefact-pane/artefact-pane.svelte.d.ts +10 -1
  12. package/dist/components/conversation/conversation.svelte +74 -4
  13. package/dist/components/conversation/conversation.svelte.d.ts +15 -0
  14. package/dist/components/markdown/markdown.d.ts +11 -0
  15. package/dist/components/markdown/markdown.js +62 -2
  16. package/dist/components/markdown/markdown.svelte +22 -2
  17. package/dist/components/markdown/markdown.svelte.d.ts +4 -0
  18. package/dist/components/thinking-row/thinking-row.svelte +1 -0
  19. package/dist/components/thinking-row/thinking-row.svelte.d.ts +1 -0
  20. package/dist/components/tool-row/tool-row.svelte +40 -20
  21. package/dist/components/tool-row/tool-row.svelte.d.ts +2 -3
  22. package/dist/components/web-list/index.d.ts +2 -0
  23. package/dist/components/web-list/index.js +2 -0
  24. package/dist/components/web-list/web-list.svelte +84 -0
  25. package/dist/components/web-list/web-list.svelte.d.ts +9 -0
  26. package/dist/components/written-card/index.d.ts +2 -0
  27. package/dist/components/written-card/index.js +2 -0
  28. package/dist/components/written-card/written-card.svelte +130 -0
  29. package/dist/components/written-card/written-card.svelte.d.ts +15 -0
  30. package/dist/copy.d.ts +19 -0
  31. package/dist/copy.js +13 -0
  32. package/dist/transcript.svelte.d.ts +15 -34
  33. package/dist/transcript.svelte.js +52 -55
  34. package/dist/web-sources.d.ts +18 -0
  35. package/dist/web-sources.js +58 -0
  36. package/dist/written.d.ts +46 -0
  37. package/dist/written.js +41 -0
  38. package/package.json +5 -1
@@ -1,7 +1,9 @@
1
1
  import { type Artefact, type Block, type DescribeTool, type Outcome } from '../../transcript.svelte';
2
2
  import { type Citation } from '../../citations';
3
3
  import { type ChatCopy } from '../../copy';
4
+ import { type Written, type WrittenUrl } from '../../written';
4
5
  import type { Verdict } from '../../chat.svelte';
6
+ import type { Fences } from '../markdown/markdown';
5
7
  /** Everything one turn renders from: what `Conversation` hands a host's
6
8
  * own `turn` snippet, so it can render this component or its own. */
7
9
  export interface AgentTranscriptProps {
@@ -35,6 +37,22 @@ export interface AgentTranscriptProps {
35
37
  onopenartefact?: () => void;
36
38
  /** The persona's own words for its tools, and what they read. */
37
39
  describeTool?: DescribeTool;
40
+ /** The names of the files that went with the question, shown as chips. */
41
+ files?: string[];
42
+ /** Replaces this question with `text` and asks it again from here
43
+ * (`Chat.edit`), resolving false when that did not go through. Omit
44
+ * and no edit control renders: not while an answer is being written. */
45
+ onedit?: (text: string) => Promise<boolean>;
46
+ /** The documents the persona wrote answering it, carded under the answer. */
47
+ written?: Written[];
48
+ /** The version the reading column shows now, by `writtenKey`. */
49
+ showingWritten?: string;
50
+ /** Opens a version in the reading column. Omit and the cards only link. */
51
+ onopenwritten?: (written: Written) => void;
52
+ /** Where a version downloads. Omit and no download links render. */
53
+ writtenUrl?: WrittenUrl;
54
+ /** Languages of fenced code the host draws with its own component. */
55
+ fences?: Fences;
38
56
  /** When an answer this page did not start began, epoch ms: it is still
39
57
  * being written, and the clock counts from then. */
40
58
  answering?: number;
@@ -1,5 +1,10 @@
1
1
  <!--
2
- An artefact that is its answer's own prose, open in the reading column.
2
+ An artefact that is its answer's own prose, or one version of a document the
3
+ persona wrote, open in the reading column.
4
+
5
+ A written document is shown from its source: Markdown through the package's
6
+ sanitising renderer, HTML only inside a sandboxed frame with no permissions
7
+ (no scripts, no same origin), never injected into the page.
3
8
 
4
9
  The same shell shape `DocumentPane` uses (a column at 64rem, a bottom sheet
5
10
  below it) because decision 2 of the agent surface is that a colleague never
@@ -8,14 +13,33 @@
8
13
  -->
9
14
  <script lang="ts">
10
15
  import XIcon from '@lucide/svelte/icons/x';
16
+ import { untrack } from 'svelte';
11
17
  import { segment, type TextBlock, type Turn } from '../../transcript.svelte';
12
18
  import { resolveCitations, splitSources, type Citation } from '../../citations';
13
- import { resolveCopy, type ChatCopy } from '../../copy';
19
+ import { fill, resolveCopy, type ChatCopy } from '../../copy';
20
+ import {
21
+ downloads,
22
+ framed,
23
+ isHtml,
24
+ writtenKey,
25
+ type LoadWritten,
26
+ type Written,
27
+ type WrittenUrl
28
+ } from '../../written';
14
29
  import Markdown from '../markdown/markdown.svelte';
15
30
  import SourceList from '../source-list/source-list.svelte';
16
31
 
32
+
17
33
  interface Props {
18
- turn: Turn;
34
+ /** The turn whose answer is the artefact. Absent when `versions` is given. */
35
+ turn?: Turn;
36
+ /** Every version of one written document, oldest first: the pane shows
37
+ * `version` of them, with a switcher. */
38
+ versions?: Written[];
39
+ version?: number;
40
+ onversion?: (version: number) => void;
41
+ loadWritten?: LoadWritten;
42
+ writtenUrl?: WrittenUrl;
19
43
  onclose: () => void;
20
44
  /** Opens the cited document instead — swapping the column back, never
21
45
  * stacking it beside the artefact. Omit and the sources still list, but
@@ -24,14 +48,49 @@
24
48
  copy?: Partial<ChatCopy>;
25
49
  }
26
50
 
27
- let { turn, onclose, oncite, copy }: Props = $props();
51
+ let {
52
+ turn,
53
+ versions = [],
54
+ version,
55
+ onversion,
56
+ loadWritten,
57
+ writtenUrl,
58
+ onclose,
59
+ oncite,
60
+ copy
61
+ }: Props = $props();
28
62
 
29
63
  const words = $derived(resolveCopy(copy));
30
64
 
31
- const texts = $derived(segment(turn.blocks).filter((s): s is TextBlock => s.kind === 'text'));
65
+ // The newest, until the reader picks another.
66
+ const current = $derived(versions.find((v) => v.version === version) ?? versions.at(-1));
67
+ const currentKey = $derived(current ? writtenKey(current) : '');
68
+ let source = $state<{ text: string; mediaType: string } | null>(null);
69
+ let failed = $state(false);
70
+
71
+ // Keyed on the version alone: a new version of the same name arriving in
72
+ // the stream must not read this one again.
73
+ $effect(() => {
74
+ if (!currentKey) return;
75
+ const shown = untrack(() => current);
76
+ const load = untrack(() => loadWritten);
77
+ if (!shown || !load) return;
78
+ let live = true;
79
+ source = null;
80
+ failed = false;
81
+ load(shown.name, shown.version).then(
82
+ (read) => live && (source = read),
83
+ () => live && (failed = true)
84
+ );
85
+ return () => (live = false);
86
+ });
87
+
88
+ const texts = $derived(
89
+ segment(turn?.blocks ?? []).filter((s): s is TextBlock => s.kind === 'text')
90
+ );
32
91
  const rawAnswer = $derived(texts.map((t) => t.text).join('\n\n'));
33
92
  const split = $derived(splitSources(rawAnswer));
34
- const sources = $derived(resolveCitations(turn.citations ?? [], split.citations));
93
+ const sources = $derived(resolveCitations(turn?.citations ?? [], split.citations));
35
94
 
36
95
  let closeButton = $state<HTMLButtonElement | null>(null);
37
96
 
@@ -53,7 +112,7 @@
53
112
 
54
113
  <svelte:window onkeydown={keydown} />
55
114
 
56
- <aside aria-label="Study artefact" class="ds-lib-pane">
115
+ <aside aria-label={current?.name ?? 'Study artefact'} class="ds-lib-pane">
57
116
  <!-- The sheet's grabber. On a phone this is an overlay a reader has to be
58
117
  able to see the top edge of; on a desktop it is a column, so there is
59
118
  nothing here to grab. -->
@@ -61,9 +120,34 @@
61
120
 
62
121
  <header class="ds-lib-pane-header">
63
122
  <div class="ds-lib-pane-heading">
64
- <h2 class="ds-lib-pane-title">{turn.artefact?.title}</h2>
65
- {#if turn.artefact?.summary}
66
- <p class="ds-lib-pane-subtitle">{turn.artefact.summary}</p>
123
+ {#if current}
124
+ <h2 class="ds-lib-pane-title">{current.name}</h2>
125
+ <div class="ds-lib-pane-tools">
126
+ <select
127
+ class="ds-lib-pane-versions"
128
+ aria-label={words.writtenVersions}
129
+ value={current.version}
130
+ onchange={(event) => onversion?.(Number(event.currentTarget.value))}
131
+ >
132
+ {#each versions as option (option.version)}
133
+ <option value={option.version}>{fill(words.writtenVersion, { n: option.version })}</option>
134
+ {/each}
135
+ </select>
136
+ {#if writtenUrl}
137
+ <ul class="ds-lib-pane-links" aria-label={words.download}>
138
+ {#each downloads(current, words) as link (link.format)}
139
+ <li>
140
+ <a href={writtenUrl(current.name, current.version, link.format)}>{link.label}</a>
141
+ </li>
142
+ {/each}
143
+ </ul>
144
+ {/if}
145
+ </div>
146
+ {:else}
147
+ <h2 class="ds-lib-pane-title">{turn?.artefact?.title}</h2>
148
+ {#if turn?.artefact?.summary}
149
+ <p class="ds-lib-pane-subtitle">{turn.artefact.summary}</p>
150
+ {/if}
67
151
  {/if}
68
152
  </div>
69
153
  <button
@@ -77,15 +161,29 @@
77
161
  </button>
78
162
  </header>
79
163
 
80
- <div class="ds-lib-pane-body">
81
- <Markdown content={split.citations.length > 0 ? split.body : rawAnswer} />
164
+ {#if current}
165
+ {@const html = source !== null && isHtml(source.mediaType || current.media_type)}
166
+ <div class="ds-lib-pane-body" class:is-frame={html}>
167
+ {#if failed}
168
+ <p class="ds-lib-pane-subtitle">{words.documentUnavailable}</p>
169
+ {:else if source && html}
170
+ <iframe class="ds-lib-pane-frame" sandbox="" srcdoc={framed(source.text)} title={current.name}
171
+ ></iframe>
172
+ {:else if source}
173
+ <Markdown content={source.text} />
174
+ {/if}
175
+ </div>
176
+ {:else}
177
+ <div class="ds-lib-pane-body">
178
+ <Markdown content={split.citations.length > 0 ? split.body : rawAnswer} />
82
179
 
83
- {#if sources.length > 0}
84
- <div class="ds-lib-pane-sources">
85
- <SourceList {sources} {words} {oncite} level={3} />
86
- </div>
87
- {/if}
88
- </div>
180
+ {#if sources.length > 0}
181
+ <div class="ds-lib-pane-sources">
182
+ <SourceList {sources} {words} {oncite} level={3} />
183
+ </div>
184
+ {/if}
185
+ </div>
186
+ {/if}
89
187
  </aside>
90
188
 
91
189
  <style>
@@ -187,6 +285,52 @@
187
285
  padding: 0.75rem 1rem;
188
286
  }
189
287
 
288
+ .ds-lib-pane-tools {
289
+ display: flex;
290
+ flex-wrap: wrap;
291
+ align-items: center;
292
+ gap: 0.25rem 0.75rem;
293
+ margin-top: 0.25rem;
294
+ }
295
+
296
+ .ds-lib-pane-versions {
297
+ border: 1px solid var(--ds-color-border);
298
+ border-radius: var(--ds-radius-md);
299
+ background: var(--ds-color-surface-1);
300
+ padding: 0.125rem 0.25rem;
301
+ color: var(--ds-color-foreground);
302
+ font: inherit;
303
+ font-size: var(--ds-text-2xs);
304
+ }
305
+
306
+ .ds-lib-pane-links {
307
+ display: flex;
308
+ flex-wrap: wrap;
309
+ gap: 0.75rem;
310
+ margin: 0;
311
+ padding: 0;
312
+ font-size: var(--ds-text-2xs);
313
+ list-style: none;
314
+ }
315
+
316
+ .ds-lib-pane-links a {
317
+ color: var(--ds-color-muted-foreground);
318
+ text-decoration: underline;
319
+ }
320
+
321
+ .ds-lib-pane-body.is-frame {
322
+ overflow: hidden;
323
+ padding: 0;
324
+ }
325
+
326
+ .ds-lib-pane-frame {
327
+ display: block;
328
+ width: 100%;
329
+ height: 100%;
330
+ border: 0;
331
+ background: #fff;
332
+ }
333
+
190
334
  .ds-lib-pane-sources {
191
335
  margin-top: 1.5rem;
192
336
  }
@@ -1,8 +1,17 @@
1
1
  import { type Turn } from '../../transcript.svelte';
2
2
  import { type Citation } from '../../citations';
3
3
  import { type ChatCopy } from '../../copy';
4
+ import { type LoadWritten, type Written, type WrittenUrl } from '../../written';
4
5
  interface Props {
5
- turn: Turn;
6
+ /** The turn whose answer is the artefact. Absent when `versions` is given. */
7
+ turn?: Turn;
8
+ /** Every version of one written document, oldest first: the pane shows
9
+ * `version` of them, with a switcher. */
10
+ versions?: Written[];
11
+ version?: number;
12
+ onversion?: (version: number) => void;
13
+ loadWritten?: LoadWritten;
14
+ writtenUrl?: WrittenUrl;
6
15
  onclose: () => void;
7
16
  /** Opens the cited document instead — swapping the column back, never
8
17
  * stacking it beside the artefact. Omit and the sources still list, but
@@ -20,19 +20,32 @@
20
20
  import type { DescribeTool, Turn } from '../../transcript.svelte';
21
21
  import type { Verdict } from '../../chat.svelte';
22
22
  import type { Citation, LoadDocument } from '../../citations';
23
+ import {
24
+ writtenKey,
25
+ writtenVersions,
26
+ type LoadWritten,
27
+ type Written,
28
+ type WrittenUrl
29
+ } from '../../written';
23
30
  import { DEFAULT_PERSONA, personaName, resolveCopy, type ChatCopy } from '../../copy';
24
31
  import { FollowScroll } from '../../follow-scroll.svelte';
25
32
  import AgentTranscript, {
26
33
  type AgentTranscriptProps
27
34
  } from '../agent-transcript/agent-transcript.svelte';
28
35
  import ArtefactPane from '../artefact-pane/artefact-pane.svelte';
36
+ import type { Fences } from '../markdown/markdown';
29
37
  import DocumentPane from '../document-pane/document-pane.svelte';
30
38
  import ScopeStatement from '../scope-statement/scope-statement.svelte';
31
39
 
32
- /** What the reading column holds — a cited document or a study artefact,
33
- * never both and never two panes: whichever the reader last opened
34
- * replaces whatever was there. */
35
- type Column = { kind: 'document'; citation: Citation } | { kind: 'artefact'; turn: Turn } | null;
40
+ /** What the reading column holds — a cited document, a study artefact or a
41
+ * document the persona wrote, never two at once and never two panes:
42
+ * whichever the reader last opened replaces whatever was there. A written
43
+ * document's `version` is null while the reader follows the newest. */
44
+ type Column =
45
+ | { kind: 'document'; citation: Citation }
46
+ | { kind: 'artefact'; turn: Turn }
47
+ | { kind: 'written'; name: string; version: number | null }
48
+ | null;
36
49
 
37
50
  interface Props {
38
51
  turns: Turn[];
@@ -86,6 +99,19 @@
86
99
  showing?: string;
87
100
  /** The persona's own words for its tools, and what they read. */
88
101
  describeTool?: DescribeTool;
102
+ /** Replaces the question at `position` (its place among `turns`) with
103
+ * `text` and asks it again from there (`Chat.edit`). Offered on every
104
+ * plain question while no answer is being written; without it no turn
105
+ * can be edited. */
106
+ onedit?: (position: number, text: string) => Promise<boolean>;
107
+ /** Reads one version of a document the persona wrote, to show it in the
108
+ * column. Without it its cards link but do not open. */
109
+ loadWritten?: LoadWritten;
110
+ /** Where a version downloads. Without it no download links render. */
111
+ writtenUrl?: WrittenUrl;
112
+ /** Languages of fenced code the host draws with its own component
113
+ * rather than as code. */
114
+ fences?: Fences;
89
115
  /** Prices a turn's `outcome.costUsd` in the host's own currency and
90
116
  * words ("about A$0.34"), for the footer's cost line beside a turn's
91
117
  * duration. The package never converts currency itself; omitted, the
@@ -133,6 +159,10 @@
133
159
  onopenartefact,
134
160
  showing,
135
161
  describeTool,
162
+ onedit,
163
+ loadWritten,
164
+ writtenUrl,
165
+ fences,
136
166
  collectionNames = new Set(),
137
167
  composer,
138
168
  turn: presentTurn,
@@ -252,6 +282,24 @@
252
282
  return undefined;
253
283
  }
254
284
 
285
+ /** Opens a written version, following the newest if it is the newest. */
286
+ function openWritten(written: Written) {
287
+ const newest = writtenVersions(turns, written.name).at(-1)?.version;
288
+ column = {
289
+ kind: 'written',
290
+ name: written.name,
291
+ version: written.version === newest ? null : written.version
292
+ };
293
+ }
294
+
295
+ const shownWritten = $derived.by(() => {
296
+ const open = column;
297
+ if (open?.kind !== 'written') return undefined;
298
+ const versions = writtenVersions(turns, open.name);
299
+ const shown = versions.find((v) => v.version === open.version) ?? versions.at(-1);
300
+ return { versions, version: shown?.version, key: shown ? writtenKey(shown) : undefined };
301
+ });
302
+
255
303
  function isShowing(turn: Turn): boolean {
256
304
  if (onopenartefact) return showing === turn.id;
257
305
  return column?.kind === 'artefact' && column.turn.id === turn.id;
@@ -333,6 +381,16 @@
333
381
  artefactOpen: isShowing(turn),
334
382
  onopenartefact: opener(turn),
335
383
  describeTool,
384
+ files: turn.files,
385
+ written: turn.written,
386
+ showingWritten: shownWritten?.key,
387
+ onopenwritten: loadWritten ? openWritten : undefined,
388
+ writtenUrl,
389
+ fences,
390
+ onedit:
391
+ onedit && !running && !turn.kind && turn.question
392
+ ? (text: string) => onedit(index, text)
393
+ : undefined,
336
394
  formatCost,
337
395
  oncite: oncite || loadDocument ? cite : undefined,
338
396
  onregenerate: last && !running ? onregenerate : undefined,
@@ -387,6 +445,18 @@
387
445
  copy={words}
388
446
  onclose={() => (column = null)}
389
447
  />
448
+ {:else if column?.kind === 'written' && shownWritten && shownWritten.versions.length > 0}
449
+ <ArtefactPane
450
+ versions={shownWritten.versions}
451
+ version={shownWritten.version}
452
+ onversion={(version) => {
453
+ if (column?.kind === 'written') column = { ...column, version };
454
+ }}
455
+ {loadWritten}
456
+ {writtenUrl}
457
+ copy={words}
458
+ onclose={() => (column = null)}
459
+ />
390
460
  {:else if column?.kind === 'artefact'}
391
461
  <ArtefactPane
392
462
  turn={column.turn}
@@ -2,8 +2,10 @@ import type { Snippet } from 'svelte';
2
2
  import type { DescribeTool, Turn } from '../../transcript.svelte';
3
3
  import type { Verdict } from '../../chat.svelte';
4
4
  import type { Citation, LoadDocument } from '../../citations';
5
+ import { type LoadWritten, type WrittenUrl } from '../../written';
5
6
  import { type ChatCopy } from '../../copy';
6
7
  import { type AgentTranscriptProps } from '../agent-transcript/agent-transcript.svelte';
8
+ import type { Fences } from '../markdown/markdown';
7
9
  interface Props {
8
10
  turns: Turn[];
9
11
  running: boolean;
@@ -56,6 +58,19 @@ interface Props {
56
58
  showing?: string;
57
59
  /** The persona's own words for its tools, and what they read. */
58
60
  describeTool?: DescribeTool;
61
+ /** Replaces the question at `position` (its place among `turns`) with
62
+ * `text` and asks it again from there (`Chat.edit`). Offered on every
63
+ * plain question while no answer is being written; without it no turn
64
+ * can be edited. */
65
+ onedit?: (position: number, text: string) => Promise<boolean>;
66
+ /** Reads one version of a document the persona wrote, to show it in the
67
+ * column. Without it its cards link but do not open. */
68
+ loadWritten?: LoadWritten;
69
+ /** Where a version downloads. Without it no download links render. */
70
+ writtenUrl?: WrittenUrl;
71
+ /** Languages of fenced code the host draws with its own component
72
+ * rather than as code. */
73
+ fences?: Fences;
59
74
  /** Prices a turn's `outcome.costUsd` in the host's own currency and
60
75
  * words ("about A$0.34"), for the footer's cost line beside a turn's
61
76
  * duration. The package never converts currency itself; omitted, the
@@ -15,6 +15,7 @@
15
15
  * Sanitised on the way out, always. The content is model output rendered as
16
16
  * HTML, which is exactly the case DOMPurify exists for.
17
17
  */
18
+ import { type Component } from 'svelte';
18
19
  /** Close anything the stream has opened but not yet finished. */
19
20
  export declare function balance(markdown: string): string;
20
21
  export declare function render(markdown: string, { streaming }?: {
@@ -42,6 +43,16 @@ export declare function markCollections(root: HTMLElement, collections: Set<stri
42
43
  * button ROLE and a tab stop, and the component delegates the events.
43
44
  */
44
45
  export declare function markCitations(root: HTMLElement, valid: Set<number>): void;
46
+ /** Fenced code blocks of a language a host names, drawn by its own component. */
47
+ export type Fences = Record<string, Component<{
48
+ source: string;
49
+ }>>;
50
+ /**
51
+ * Draw every fenced block whose language the host registered with the host's
52
+ * component, in place of the code block. The source is the block's text, never
53
+ * HTML. Returns what unmounts them.
54
+ */
55
+ export declare function mountFences(root: HTMLElement, fences: Fences): () => void;
45
56
  /**
46
57
  * Replace every `<pre><code>` in a rendered fragment with a highlighted one.
47
58
  * Runs against the DOM node rather than the HTML string so it can be applied
@@ -17,6 +17,7 @@
17
17
  */
18
18
  import DOMPurify from 'isomorphic-dompurify';
19
19
  import { marked } from 'marked';
20
+ import { mount, unmount } from 'svelte';
20
21
  marked.setOptions({ gfm: true, breaks: false });
21
22
  /** Close anything the stream has opened but not yet finished. */
22
23
  export function balance(markdown) {
@@ -35,14 +36,52 @@ export function balance(markdown) {
35
36
  }
36
37
  return text;
37
38
  }
39
+ const FETCHERS = 'img, picture, source, video, audio, track, iframe, object, embed, link, style, image, use';
40
+ const URL_ATTRS = ['src', 'href', 'xlink:href', 'poster', 'data', 'srcset'];
41
+ /** Only what cannot leave the origin: a data URL, a same-origin path, a fragment. */
42
+ function stays(url) {
43
+ const u = url.trim();
44
+ return /^data:/i.test(u) || (u.startsWith('/') && !u.startsWith('//')) || u.startsWith('#');
45
+ }
46
+ /**
47
+ * A rendered answer must not fetch from anywhere: a remote image or url() in
48
+ * agent output is a beacon that carries the conversation out in its query.
49
+ * A remote image degrades to its alt text (or its address as a link).
50
+ */
51
+ function dropFetchers(root) {
52
+ for (const el of root.querySelectorAll(FETCHERS)) {
53
+ const urls = URL_ATTRS.map((a) => el.getAttribute(a)).filter((v) => v !== null);
54
+ if (urls.every(stays))
55
+ continue;
56
+ if (el.tagName === 'IMG') {
57
+ const alt = el.getAttribute('alt')?.trim();
58
+ const src = el.getAttribute('src')?.trim() ?? '';
59
+ if (alt)
60
+ el.replaceWith(root.ownerDocument.createTextNode(alt));
61
+ else if (/^https?:\/\//i.test(src)) {
62
+ const a = root.ownerDocument.createElement('a');
63
+ a.setAttribute('href', src);
64
+ a.textContent = src;
65
+ el.replaceWith(a);
66
+ }
67
+ else
68
+ el.remove();
69
+ }
70
+ else
71
+ el.remove();
72
+ }
73
+ }
38
74
  export function render(markdown, { streaming = false } = {}) {
39
75
  const source = streaming ? balance(markdown) : markdown;
40
76
  const html = marked.parse(source, { async: false });
41
- return DOMPurify.sanitize(html, {
77
+ const body = DOMPurify.sanitize(html, {
42
78
  ADD_ATTR: ['target', 'rel', 'data-cite', 'tabindex', 'role'],
43
79
  FORBID_TAGS: ['style', 'form', 'input', 'button'],
44
- FORBID_ATTR: ['style', 'onerror', 'onload']
80
+ FORBID_ATTR: ['style', 'srcset', 'onerror', 'onload'],
81
+ RETURN_DOM: true
45
82
  });
83
+ dropFetchers(body);
84
+ return body.innerHTML;
46
85
  }
47
86
  let highlighterPromise = null;
48
87
  /** Loaded once, lazily — shiki's engine and grammars are not small. */
@@ -137,6 +176,27 @@ export function markCitations(root, valid) {
137
176
  text.replaceWith(fragment);
138
177
  }
139
178
  }
179
+ /**
180
+ * Draw every fenced block whose language the host registered with the host's
181
+ * component, in place of the code block. The source is the block's text, never
182
+ * HTML. Returns what unmounts them.
183
+ */
184
+ export function mountFences(root, fences) {
185
+ const drawn = [];
186
+ for (const code of root.querySelectorAll('pre > code')) {
187
+ const lang = [...code.classList].find((c) => c.startsWith('language-'))?.slice(9);
188
+ const pre = code.parentElement;
189
+ if (!lang || !pre || !Object.hasOwn(fences, lang))
190
+ continue;
191
+ const target = document.createElement('div');
192
+ pre.replaceWith(target);
193
+ drawn.push(mount(fences[lang], { target, props: { source: code.textContent ?? '' } }));
194
+ }
195
+ return () => {
196
+ for (const one of drawn)
197
+ void unmount(one);
198
+ };
199
+ }
140
200
  /**
141
201
  * Replace every `<pre><code>` in a rendered fragment with a highlighted one.
142
202
  * Runs against the DOM node rather than the HTML string so it can be applied
@@ -7,7 +7,14 @@
7
7
  consuming app.
8
8
  -->
9
9
  <script lang="ts">
10
- import { highlight, markCitations, markCollections, render } from './markdown';
10
+ import {
11
+ highlight,
12
+ markCitations,
13
+ markCollections,
14
+ mountFences,
15
+ render,
16
+ type Fences
17
+ } from './markdown';
11
18
 
12
19
  interface Props {
13
20
  content: string;
@@ -20,6 +27,9 @@
20
27
  /** A step down, for prose that is reference material beside an answer
21
28
  * rather than the answer itself — the source pane's sections. */
22
29
  dense?: boolean;
30
+ /** A language to draw with the host's own component instead of as code.
31
+ * Absent, every fence renders as code. */
32
+ fences?: Fences;
23
33
  }
24
34
 
25
35
  let {
@@ -28,12 +38,22 @@
28
38
  collectionNames = new Set(),
29
39
  citationNumbers = new Set(),
30
40
  oncite,
31
- dense = false
41
+ dense = false,
42
+ fences
32
43
  }: Props = $props();
33
44
  let host = $state<HTMLElement | null>(null);
34
45
 
35
46
  const html = $derived(render(content, { streaming }));
36
47
 
48
+ // Once settled (a half-written fence is not a chart), and before the
49
+ // highlight below, which would otherwise take the block first. Its own
50
+ // effect, so a change to the chips never unmounts what it drew.
51
+ $effect(() => {
52
+ void html;
53
+ if (!host || streaming || !fences) return;
54
+ return mountFences(host, fences);
55
+ });
56
+
37
57
  // Highlight only once the text has settled. Re-running per token would
38
58
  // re-parse grammars for a block that changes again milliseconds later.
39
59
  $effect(() => {
@@ -1,3 +1,4 @@
1
+ import { type Fences } from './markdown';
1
2
  interface Props {
2
3
  content: string;
3
4
  streaming?: boolean;
@@ -9,6 +10,9 @@ interface Props {
9
10
  /** A step down, for prose that is reference material beside an answer
10
11
  * rather than the answer itself — the source pane's sections. */
11
12
  dense?: boolean;
13
+ /** A language to draw with the host's own component instead of as code.
14
+ * Absent, every fence renders as code. */
15
+ fences?: Fences;
12
16
  }
13
17
  declare const Markdown: import("svelte").Component<Props, {}, "">;
14
18
  type Markdown = ReturnType<typeof Markdown>;
@@ -15,6 +15,7 @@
15
15
 
16
16
  interface Props {
17
17
  block: ThinkingBlock | TextBlock;
18
+ /** Still being written: "Thinking…" rather than "Thought". */
18
19
  active: boolean;
19
20
  }
20
21
 
@@ -1,6 +1,7 @@
1
1
  import type { TextBlock, ThinkingBlock } from '../../transcript.svelte';
2
2
  interface Props {
3
3
  block: ThinkingBlock | TextBlock;
4
+ /** Still being written: "Thinking…" rather than "Thought". */
4
5
  active: boolean;
5
6
  }
6
7
  declare const ThinkingRow: import("svelte").Component<Props, {}, "">;