flux-md 0.5.5 → 0.7.0

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.
package/src/element.ts ADDED
@@ -0,0 +1,381 @@
1
+ import { FluxClient } from "./client";
2
+ import { mountFluxMarkdown, type DomComponents, type MountHandle } from "./dom";
3
+ import type { ParserConfig } from "./types-core";
4
+
5
+ /**
6
+ * `<flux-markdown>` custom element — thin lifecycle glue over
7
+ * {@link mountFluxMarkdown}. It owns no diffing: connect mounts the DOM
8
+ * renderer into the element itself (LIGHT DOM, so the host app's markdown CSS
9
+ * reaches the content), disconnect tears the mount down. It never reimplements
10
+ * subscribe/patch.
11
+ *
12
+ * Two usage modes:
13
+ * - **Caller-owned client** (`el.client = myClient`): the element subscribes
14
+ * and mounts but NEVER destroys the client — the caller owns the
15
+ * worker/stream lifecycle.
16
+ * - **Self-owned client** (`markdown`/`src`/`textContent` attrs, or
17
+ * `el.append()`): the element lazily creates an internal client from its
18
+ * config attributes and destroys it on disconnect.
19
+ *
20
+ * Not auto-registered (SSR-unsafe): call {@link defineFluxMarkdown} from
21
+ * browser code.
22
+ */
23
+
24
+ // Tri-state attribute parse: absent => undefined (omit, library default);
25
+ // ""/"true"/"1" => true; "false"/"0" => false. Tri-state is the only way to
26
+ // turn OFF a flag whose library default is on (autolinks, alerts). Exported so
27
+ // it is directly unit-testable.
28
+ export function parseTriBool(value: string | null): boolean | undefined {
29
+ if (value === null) return undefined;
30
+ if (value === "" || value === "true" || value === "1") return true;
31
+ if (value === "false" || value === "0") return false;
32
+ return undefined;
33
+ }
34
+
35
+ const CONFIG_ATTRS = [
36
+ "gfm-autolinks",
37
+ "gfm-alerts",
38
+ "gfm-footnotes",
39
+ "gfm-math",
40
+ "dir-auto",
41
+ "a11y",
42
+ "unsafe-html",
43
+ ];
44
+
45
+ export function defineFluxMarkdown(tag = "flux-markdown"): void {
46
+ // SSR-safe: no custom-element registry => nothing to define.
47
+ if (typeof customElements === "undefined") return;
48
+ // Idempotent: a tag may only be defined once.
49
+ if (customElements.get(tag)) return;
50
+
51
+ // The class is defined lazily INSIDE the function: at module-evaluation time
52
+ // `HTMLElement` may not exist (SSR / pre-DOM). Referencing it only after the
53
+ // guards above keeps the module import side-effect-free.
54
+ class FluxMarkdownElement extends HTMLElement {
55
+ static get observedAttributes(): string[] {
56
+ return ["markdown", "src", "component-tags", ...CONFIG_ATTRS];
57
+ }
58
+
59
+ #client: FluxClient | null = null;
60
+ #ownsClient = false;
61
+ #components: DomComponents | undefined = undefined;
62
+ #sanitize: ((html: string) => string) | undefined = undefined;
63
+ #handle: MountHandle | null = null;
64
+ #connected = false;
65
+ // In-flight `src` fetch supersession. A self-owned client is REUSED across
66
+ // src changes (not torn down), so two concurrent #streamFromSrc runs would
67
+ // capture the same client and reset() even reuses the worker streamId — an
68
+ // identity guard alone can't separate them. Each run captures the current
69
+ // #srcSeq; a newer src (or teardown) bumps it and aborts the fetch, so a
70
+ // stale run bails before interleaving its chunks into the parser.
71
+ #srcSeq = 0;
72
+ #srcAbort: AbortController | null = null;
73
+
74
+ // --- Accessor properties (objects/functions can't be attributes) ---------
75
+
76
+ get client(): FluxClient | null {
77
+ return this.#client;
78
+ }
79
+ set client(value: FluxClient | null) {
80
+ if (value === this.#client) return;
81
+ // Switching to a caller-owned client: tear down any internal client we own
82
+ // first, then adopt the new one without owning it.
83
+ this.#teardownClient();
84
+ this.#client = value;
85
+ this.#ownsClient = false;
86
+ if (this.#connected) this.#remount();
87
+ }
88
+
89
+ get components(): DomComponents | undefined {
90
+ return this.#components;
91
+ }
92
+ set components(value: DomComponents | undefined) {
93
+ this.#components = value;
94
+ if (this.#connected) this.#remount();
95
+ }
96
+
97
+ get sanitize(): ((html: string) => string) | undefined {
98
+ return this.#sanitize;
99
+ }
100
+ set sanitize(value: ((html: string) => string) | undefined) {
101
+ this.#sanitize = value;
102
+ if (this.#connected) this.#remount();
103
+ }
104
+
105
+ // --- Self-owned-client methods -------------------------------------------
106
+
107
+ append(chunk: string): void {
108
+ this.#ensureClient();
109
+ this.#client!.append(chunk);
110
+ }
111
+
112
+ finalize(): void {
113
+ // Only meaningful for a self-owned stream; a no-op if no client yet.
114
+ this.#client?.finalize();
115
+ }
116
+
117
+ reset(): void {
118
+ // Keep config; just clear the current stream's blocks. Also abandon any
119
+ // in-flight `src` fetch so it can't append into the freshly-reset stream.
120
+ this.#cancelSrcStream();
121
+ this.#client?.reset();
122
+ }
123
+
124
+ getClient(): FluxClient | null {
125
+ return this.#client;
126
+ }
127
+
128
+ // --- Lifecycle -----------------------------------------------------------
129
+
130
+ connectedCallback(): void {
131
+ // Guard double-connect; allow reconnect-after-move.
132
+ if (this.#connected) return;
133
+ this.#connected = true;
134
+
135
+ // Property-upgrade dance: a framework may set `el.client`/`components`/
136
+ // `sanitize` BEFORE the element is upgraded, leaving an own data property
137
+ // that shadows the accessor. Capture, delete, re-assign through the setter.
138
+ this.#upgradeProperty("client");
139
+ this.#upgradeProperty("components");
140
+ this.#upgradeProperty("sanitize");
141
+
142
+ // Mount synchronously if we already have a client (caller-owned, or one a
143
+ // pre-connect append() created). append/finalize are postMessage and the
144
+ // config rides the first message FIFO, so no whenReady await is needed.
145
+ this.#mountIfReady();
146
+
147
+ // Resolve initial content for self-owned mode only (no caller client).
148
+ if (!this.#client || this.#ownsClient) {
149
+ this.#resolveInitialContent();
150
+ }
151
+ }
152
+
153
+ attributeChangedCallback(name: string, _old: string | null, _new: string | null): void {
154
+ // attributeChangedCallback fires before connectedCallback for attributes
155
+ // present at upgrade; ignore until connected so config reads happen once.
156
+ if (!this.#connected) return;
157
+
158
+ if (name === "markdown" || name === "src") {
159
+ // One-shot content source change — only for a self-owned client. A
160
+ // caller-owned client is driven by its owner, not by our attributes.
161
+ if (!this.#client || this.#ownsClient) {
162
+ this.#resolveInitialContent();
163
+ }
164
+ return;
165
+ }
166
+
167
+ // A config / component-tags change. ParserConfig is immutable per stream.
168
+ if (this.#client && !this.#ownsClient) {
169
+ // eslint-disable-next-line no-console
170
+ console.warn(
171
+ "<flux-markdown>: config attributes are ignored while a caller-owned `client` is set (ParserConfig is immutable per stream).",
172
+ );
173
+ return;
174
+ }
175
+ // Self-owned: rebuild the client with fresh config, then re-render.
176
+ if (this.#ownsClient) {
177
+ this.#teardownClient();
178
+ this.#mountIfReady();
179
+ this.#resolveInitialContent();
180
+ }
181
+ }
182
+
183
+ disconnectedCallback(): void {
184
+ this.#connected = false;
185
+ // Stop any in-flight `src` fetch before we (maybe) destroy its client.
186
+ this.#cancelSrcStream();
187
+ // ALWAYS tear down the mount (the only teardown path for the renderer).
188
+ this.#handle?.destroy();
189
+ this.#handle = null;
190
+ // Destroy the client ONLY if we created it. A caller-owned client's
191
+ // worker/stream lifecycle belongs to the caller — never destroy it here.
192
+ if (this.#ownsClient) {
193
+ this.#client?.destroy();
194
+ this.#client = null;
195
+ this.#ownsClient = false;
196
+ }
197
+ }
198
+
199
+ // --- Internals -----------------------------------------------------------
200
+
201
+ #upgradeProperty(prop: "client" | "components" | "sanitize"): void {
202
+ if (Object.prototype.hasOwnProperty.call(this, prop)) {
203
+ const value = (this as unknown as Record<string, unknown>)[prop];
204
+ delete (this as unknown as Record<string, unknown>)[prop];
205
+ (this as unknown as Record<string, unknown>)[prop] = value;
206
+ }
207
+ }
208
+
209
+ // Build a ParserConfig from the current config attributes. Read ONCE, at
210
+ // client creation — config is immutable per stream.
211
+ #readConfig(): ParserConfig | undefined {
212
+ const cfg: ParserConfig = {};
213
+ let any = false;
214
+ const set = (attr: string, key: keyof ParserConfig): void => {
215
+ const v = parseTriBool(this.getAttribute(attr));
216
+ if (v !== undefined) {
217
+ (cfg as Record<string, unknown>)[key] = v;
218
+ any = true;
219
+ }
220
+ };
221
+ set("gfm-autolinks", "gfmAutolinks");
222
+ set("gfm-alerts", "gfmAlerts");
223
+ set("gfm-footnotes", "gfmFootnotes");
224
+ set("gfm-math", "gfmMath");
225
+ set("dir-auto", "dirAuto");
226
+ set("a11y", "a11y");
227
+ set("unsafe-html", "unsafeHtml");
228
+
229
+ const tags = this.getAttribute("component-tags");
230
+ if (tags !== null) {
231
+ const list = tags.split(/[\s,]+/).filter(Boolean);
232
+ if (list.length > 0) {
233
+ cfg.componentTags = list;
234
+ any = true;
235
+ }
236
+ }
237
+ return any ? cfg : undefined;
238
+ }
239
+
240
+ // Lazily create the internal client from config attributes (self-owned).
241
+ #ensureClient(): void {
242
+ if (this.#client) return;
243
+ this.#client = new FluxClient({ config: this.#readConfig() });
244
+ this.#ownsClient = true;
245
+ this.#mountIfReady();
246
+ }
247
+
248
+ // Mount once a client exists and we're connected. Idempotent.
249
+ #mountIfReady(): void {
250
+ if (!this.#connected || !this.#client || this.#handle) return;
251
+ this.#handle = mountFluxMarkdown(this.#client, this, {
252
+ components: this.#components,
253
+ sanitize: this.#sanitize,
254
+ });
255
+ }
256
+
257
+ // Destroy the current mount and remount against the current client+options.
258
+ // Used when a property changes while connected.
259
+ #remount(): void {
260
+ this.#handle?.destroy();
261
+ this.#handle = null;
262
+ this.#mountIfReady();
263
+ }
264
+
265
+ // Tear down only the client side (mount stays / is handled by the caller).
266
+ // Destroys the client only if self-owned, then clears it and the mount so
267
+ // the next mount targets a fresh client.
268
+ #teardownClient(): void {
269
+ // A swap/destroy abandons the current client; stop feeding it from src.
270
+ this.#cancelSrcStream();
271
+ this.#handle?.destroy();
272
+ this.#handle = null;
273
+ if (this.#ownsClient) this.#client?.destroy();
274
+ this.#client = null;
275
+ this.#ownsClient = false;
276
+ }
277
+
278
+ // Resolve the initial content of a self-owned stream from the attributes,
279
+ // in priority order: `src` (fetch+stream) > `markdown` (one-shot) >
280
+ // textContent (one-shot). A caller-owned client never reaches here.
281
+ #resolveInitialContent(): void {
282
+ // Single chokepoint: every content-source resolution supersedes any
283
+ // in-flight `src` fetch. This covers the src→markdown / src→textContent
284
+ // transitions too — #oneShot reuses (resets + finalizes) the same client,
285
+ // so without this a still-pending fetch would append into the finished
286
+ // stream. (#streamFromSrc bumps again; the extra bump is harmless.)
287
+ this.#cancelSrcStream();
288
+ const src = this.getAttribute("src");
289
+ if (src) {
290
+ void this.#streamFromSrc(src);
291
+ return;
292
+ }
293
+ const markdown = this.getAttribute("markdown");
294
+ if (markdown !== null) {
295
+ this.#oneShot(markdown);
296
+ return;
297
+ }
298
+ // textContent-as-initial-markdown: capture, clear, then feed. Capture
299
+ // BEFORE the mount appended its `.flux-md` root would pollute the text;
300
+ // mount happened in connectedCallback, so read only our own text nodes.
301
+ const text = this.#captureSourceText();
302
+ if (text.trim().length > 0) this.#oneShot(text);
303
+ }
304
+
305
+ // Read the raw markdown the host put between the tags, ignoring the
306
+ // renderer's `.flux-md` root (and any other element children).
307
+ #captureSourceText(): string {
308
+ let text = "";
309
+ for (const node of Array.from(this.childNodes)) {
310
+ if (node.nodeType === 3 /* Text */) {
311
+ text += node.textContent ?? "";
312
+ node.parentNode?.removeChild(node);
313
+ }
314
+ }
315
+ return text;
316
+ }
317
+
318
+ // One-shot: reset the stream (in case content changed), feed it, finalize.
319
+ #oneShot(markdown: string): void {
320
+ this.#ensureClient();
321
+ this.#client!.reset();
322
+ this.#client!.append(markdown);
323
+ this.#client!.finalize();
324
+ }
325
+
326
+ // Abort any in-flight `src` fetch and invalidate its read loop, so it can
327
+ // no longer append into a client we're about to reuse, swap, or destroy.
328
+ #cancelSrcStream(): void {
329
+ this.#srcSeq++;
330
+ this.#srcAbort?.abort();
331
+ this.#srcAbort = null;
332
+ }
333
+
334
+ // Fetch a URL and stream its body. TextDecoder with {stream:true} carries a
335
+ // multibyte sequence that straddles a chunk boundary into the next decode.
336
+ async #streamFromSrc(src: string): Promise<void> {
337
+ // Supersede any prior in-flight src, then tag this run with a fresh token.
338
+ this.#cancelSrcStream();
339
+ const token = this.#srcSeq;
340
+ const abort = new AbortController();
341
+ this.#srcAbort = abort;
342
+
343
+ this.#ensureClient();
344
+ this.#client!.reset();
345
+ const owned = this.#client!;
346
+ // True while THIS run is still the active stream: not superseded by a
347
+ // newer src, and the client wasn't swapped/destroyed out from under us.
348
+ const current = () => this.#srcSeq === token && this.#client === owned;
349
+
350
+ try {
351
+ const res = await fetch(src, { signal: abort.signal });
352
+ if (!current()) return;
353
+ const body = res.body;
354
+ if (!body) {
355
+ const text = await res.text();
356
+ if (!current()) return;
357
+ owned.append(text);
358
+ owned.finalize();
359
+ return;
360
+ }
361
+ const reader = body.getReader();
362
+ const decoder = new TextDecoder();
363
+ for (;;) {
364
+ const { done, value } = await reader.read();
365
+ if (!current()) return;
366
+ if (done) break;
367
+ if (value) owned.append(decoder.decode(value, { stream: true }));
368
+ }
369
+ owned.append(decoder.decode()); // flush any trailing partial sequence
370
+ owned.finalize();
371
+ } catch (err) {
372
+ // A supersede/disconnect aborts the fetch — intentional, not an error.
373
+ if (abort.signal.aborted || !current()) return;
374
+ // eslint-disable-next-line no-console
375
+ console.error("<flux-markdown>: failed to stream src", src, err);
376
+ }
377
+ }
378
+ }
379
+
380
+ customElements.define(tag, FluxMarkdownElement);
381
+ }
@@ -1,4 +1,4 @@
1
- import { memo, useMemo } from "react";
1
+ import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
2
2
  import { highlight } from "../hi";
3
3
 
4
4
  /**
@@ -31,18 +31,75 @@ interface Props {
31
31
 
32
32
  function CodeBlockImpl({ html, open }: Props) {
33
33
  const lang = extractLang(html) || "text";
34
+ // Decode once: highlighter and copy handler share the same source.
35
+ const text = useMemo(() => (open ? "" : decodeText(html)), [html, open]);
34
36
  const highlighted = useMemo(() => {
35
- if (open) return null;
36
- const text = decodeText(html);
37
37
  if (!text) return null;
38
38
  return highlight(text, lang);
39
- }, [html, open, lang]);
39
+ }, [text, lang]);
40
+
41
+ const [copied, setCopied] = useState(false);
42
+ const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
43
+
44
+ // Reset "Copied" if the block re-opens or its content changes underneath us.
45
+ useEffect(() => {
46
+ if (open) setCopied(false);
47
+ }, [open, html]);
48
+
49
+ useEffect(() => {
50
+ return () => {
51
+ if (timerRef.current !== null) clearTimeout(timerRef.current);
52
+ };
53
+ }, []);
54
+
55
+ const onCopy = useCallback(() => {
56
+ const write = (typeof navigator !== "undefined" && navigator.clipboard && navigator.clipboard.writeText)
57
+ ? navigator.clipboard.writeText.bind(navigator.clipboard)
58
+ : null;
59
+ if (!write || !text) return;
60
+ write(text).then(
61
+ () => {
62
+ setCopied(true);
63
+ if (timerRef.current !== null) clearTimeout(timerRef.current);
64
+ timerRef.current = setTimeout(() => setCopied(false), 1500);
65
+ },
66
+ // Permission denied / blocked: stay silent, leave button usable.
67
+ () => {},
68
+ );
69
+ }, [text]);
40
70
 
41
71
  return (
42
72
  <div className={"flux-code-block" + (open ? " flux-streaming" : "")}>
43
73
  <div className="flux-code-header">
44
74
  <span className="flux-code-lang">{lang}</span>
45
- {open && <span className="flux-code-streaming-pill">streaming</span>}
75
+ {open ? (
76
+ <span className="flux-code-streaming-pill">streaming</span>
77
+ ) : (
78
+ <button
79
+ type="button"
80
+ className="flux-code-copy"
81
+ onClick={onCopy}
82
+ aria-label={copied ? "Copied" : "Copy code"}
83
+ aria-live="polite"
84
+ >
85
+ {copied ? (
86
+ <>
87
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
88
+ <path d="M20 6 9 17l-5-5" />
89
+ </svg>
90
+ <span>Copied</span>
91
+ </>
92
+ ) : (
93
+ <>
94
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
95
+ <rect x="9" y="9" width="11" height="11" rx="2" />
96
+ <path d="M5 15V5a2 2 0 0 1 2-2h10" />
97
+ </svg>
98
+ <span>Copy</span>
99
+ </>
100
+ )}
101
+ </button>
102
+ )}
46
103
  </div>
47
104
  <div className="flux-code-body">
48
105
  {highlighted ? (
package/src/solid.tsx ADDED
@@ -0,0 +1,70 @@
1
+ import { onCleanup, onMount, type JSX } from "solid-js";
2
+ import type { FluxClient } from "./client";
3
+ import { mountFluxMarkdown, type MountHandle, type MountOptions } from "./dom";
4
+
5
+ /**
6
+ * Solid binding for the framework-neutral DOM renderer ({@link mountFluxMarkdown}).
7
+ *
8
+ * Deliberately thin lifecycle glue: it mounts the renderer once on `onMount` and
9
+ * tears it down on `onCleanup`. There is **no** `createEffect` — the DOM renderer
10
+ * owns its own `client.subscribe` loop and patches the container directly, so
11
+ * re-running mount on signal changes would thrash (double-subscribe, rebuild the
12
+ * tree). Props are read once as a non-reactive snapshot at mount time.
13
+ *
14
+ * Ownership: unmount calls `handle.destroy()` (unsubscribe + remove the renderer
15
+ * root) and never `client.destroy()`. The caller owns the worker/stream.
16
+ */
17
+
18
+ export interface FluxMarkdownProps extends MountOptions {
19
+ client: FluxClient;
20
+ class?: string;
21
+ style?: JSX.CSSProperties | string;
22
+ }
23
+
24
+ /**
25
+ * Mount the DOM renderer and register its teardown — the testable core, free of
26
+ * JSX so it runs under any toolchain. `getProps` is read once (snapshot), the
27
+ * handle is returned so callers/tests can observe `destroy`, and the teardown is
28
+ * handed to `registerCleanup` (Solid's `onCleanup` at the call site).
29
+ */
30
+ export function mountSolid(
31
+ getProps: () => FluxMarkdownProps,
32
+ container: HTMLElement,
33
+ registerCleanup: (fn: () => void) => void,
34
+ ): MountHandle {
35
+ const p = getProps();
36
+ // Explicit field copy (not rest-spread): keeps `client`/`class`/`style` out of
37
+ // MountOptions and threads `batch`/`highlightCode` straight through.
38
+ const handle = mountFluxMarkdown(p.client, container, {
39
+ components: p.components,
40
+ sanitize: p.sanitize,
41
+ virtualize: p.virtualize,
42
+ stickToBottom: p.stickToBottom,
43
+ highlightCode: p.highlightCode,
44
+ batch: p.batch,
45
+ });
46
+ registerCleanup(() => handle.destroy());
47
+ return handle;
48
+ }
49
+
50
+ /**
51
+ * The container `<div>` the DOM renderer mounts into. We do not set
52
+ * `class="flux-md"`: the renderer appends its own `.flux-md` root inside it.
53
+ *
54
+ * Authored imperatively rather than with a JSX literal: a JSX literal makes
55
+ * bun's transform inject an automatic-runtime import (`jsxDEV` from
56
+ * `solid-js/jsx-dev-runtime`) that Solid does not provide (Solid compiles JSX
57
+ * via dom-expressions, not a runtime), which breaks importing this module under
58
+ * bun. A real DOM node is a valid Solid `JSX.Element`; under a Solid build this
59
+ * is equivalent to `<div ref={container} class={props.class} style={props.style} />`.
60
+ */
61
+ export function FluxMarkdown(props: FluxMarkdownProps): JSX.Element {
62
+ const container = document.createElement("div");
63
+ if (props.class) container.className = props.class;
64
+ if (typeof props.style === "string") container.setAttribute("style", props.style);
65
+ else if (props.style)
66
+ for (const [k, v] of Object.entries(props.style)) container.style.setProperty(k, String(v));
67
+ // Snapshot props once on mount; the renderer drives itself from here on.
68
+ onMount(() => mountSolid(() => props, container, onCleanup));
69
+ return container;
70
+ }
package/src/svelte.ts ADDED
@@ -0,0 +1,55 @@
1
+ import type { ActionReturn } from "svelte/action";
2
+ import type { FluxClient } from "./client";
3
+ import { mountFluxMarkdown, type DomComponents, type MountOptions } from "./dom";
4
+
5
+ /**
6
+ * Svelte action that mounts a streaming {@link FluxClient} into the host node.
7
+ * Plain `.ts` — no `.svelte` compile step — so `use:` works unchanged in
8
+ * Svelte 4 and 5. The action owns only lifecycle: it mounts on creation and
9
+ * tears the mount down on destroy. The caller keeps ownership of the client
10
+ * (the worker/stream); the action never calls `client.destroy()`.
11
+ *
12
+ * ```svelte
13
+ * <div use:fluxMarkdown={{ client, stickToBottom: true }} />
14
+ * ```
15
+ */
16
+ export interface FluxMarkdownParams {
17
+ client: FluxClient;
18
+ components?: DomComponents;
19
+ sanitize?: (h: string) => string;
20
+ virtualize?: boolean;
21
+ stickToBottom?: boolean;
22
+ }
23
+
24
+ export function fluxMarkdown(
25
+ node: HTMLElement,
26
+ params: FluxMarkdownParams,
27
+ ): ActionReturn<FluxMarkdownParams> {
28
+ let { client, ...options } = params;
29
+ let handle = mountFluxMarkdown(client, node, options as MountOptions);
30
+
31
+ return {
32
+ update(next: FluxMarkdownParams) {
33
+ // Svelte fires update on every params change, even when nothing the mount
34
+ // depends on moved (a fresh object literal with identical field values).
35
+ // Remount only when an input the renderer reads actually changed identity;
36
+ // otherwise the live mount keeps streaming untouched.
37
+ if (
38
+ next.client === client &&
39
+ next.components === options.components &&
40
+ next.sanitize === options.sanitize &&
41
+ next.virtualize === options.virtualize &&
42
+ next.stickToBottom === options.stickToBottom
43
+ ) {
44
+ return;
45
+ }
46
+ handle.destroy();
47
+ ({ client, ...options } = next);
48
+ handle = mountFluxMarkdown(client, node, options as MountOptions);
49
+ },
50
+ destroy() {
51
+ // Only the mount is torn down. The caller owns the client.
52
+ handle.destroy();
53
+ },
54
+ };
55
+ }