1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
|
<script lang="ts">
import { type MarkdownImageOptions, renderMarkdown } from "../logic/markdown";
import { advance, createStreamingMarkdown } from "../logic/streaming-markdown";
let {
text,
streaming = false,
image = undefined,
}: {
text: string;
/** Balance dangling delimiters while the message is still generating. */
streaming?: boolean;
/**
* Per-conversation local-image context: the backend HTTP base and the
* conversation cwd. Markdown image srcs that are local paths are rewritten
* through `${apiBase}/local-image?path=…` (relative paths resolve against
* `cwd`). Remote HTTP(S) images require a click; data, same-origin and
* backend images load inline. Omit it to skip local-path rewriting.
*/
image?: MarkdownImageOptions | undefined;
} = $props();
// Keep the optional `image` out of the options object when absent
// (exactOptionalPropertyTypes). Identity is irrelevant: the streaming cache
// keys on apiBase/cwd values.
const renderOpts = $derived(image === undefined ? undefined : { image });
// Checkpoint streaming state: one per component instance (per streaming
// message). It caches the settled blocks' HTML so each delta re-renders only
// the live tail — never the settled prefix (see logic/streaming-markdown).
const streamingState = createStreamingMarkdown();
// While streaming: the checkpoint split (settled prefix + live tail), which
// equals today's full render at every step modulo inter-block newlines and
// highlight-on-close. When the message completes (or for every non-streaming
// chunk): ONE full authoritative render — today's exact behavior, so the
// final/persisted output is byte-identical by construction.
const rendered = $derived.by(() => {
if (!streaming) return { prefixHtml: "", tailHtml: renderMarkdown(text, renderOpts) };
return advance(streamingState, text, renderOpts);
});
let container: HTMLElement;
// One delegated listener on the stable container handles image consent and
// every code block's copy button — including blocks re-created when the tail html changes
// (streaming), since the listener lives on the container, not the buttons.
// Clipboard is the edge effect; absent (insecure context) → no-op.
$effect(() => {
const el = container;
if (el === undefined) return;
const onClick = (event: Event): void => {
const target = event.target;
if (!(target instanceof Element)) return;
const remote = target.closest<HTMLButtonElement>("button.remote-image[data-src]");
if (remote !== null && el.contains(remote)) {
event.preventDefault(); // A Markdown image may be wrapped in a link.
event.stopPropagation();
// data-* is authorable in raw HTML: validate again at the DOM edge.
let url: URL;
try {
url = new URL(remote.dataset.src ?? "");
} catch {
return;
}
if (url.protocol !== "http:" && url.protocol !== "https:") return;
const img = el.ownerDocument.createElement("img");
img.alt = remote.dataset.alt ?? "";
if (remote.dataset.title !== undefined) img.title = remote.dataset.title;
img.setAttribute("referrerpolicy", "no-referrer");
img.src = url.href;
remote.replaceWith(img);
return;
}
const button = target.closest<HTMLButtonElement>("[data-copy]");
if (button === null) return;
const code = button.closest(".code-block")?.querySelector("code")?.textContent ?? "";
const clipboard = navigator.clipboard;
if (clipboard === undefined) return;
void clipboard
.writeText(code)
.then(() => {
const prev = button.textContent;
button.textContent = "Copied";
setTimeout(() => {
button.textContent = prev;
}, 1200);
})
.catch(() => {
// Clipboard denied — leave the button as-is.
});
};
el.addEventListener("click", onClick);
return () => el.removeEventListener("click", onClick);
});
</script>
<div class="markdown-body" bind:this={container}>
{#if streaming}
<!-- Two sibling wrappers (no padding/border/min-height, so block margins
collapse across them exactly as if the blocks were direct children).
Svelte 5 skips the {@html} DOM write when the string is unchanged, so
the settled prefix's subtree is never re-parsed/re-created/re-laid-out;
only the tail's subtree is replaced per delta. Both strings are
DOMPurify-sanitized in the logic modules. -->
<div class="markdown-stream-prefix">{@html rendered.prefixHtml}</div>
<div class="markdown-stream-tail">{@html rendered.tailHtml}</div>
{:else}
<!-- {@html} is safe here: `html` is DOMPurify-sanitized inside renderMarkdown. -->
{@html rendered.tailHtml}
{/if}
</div>
|