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/CHANGELOG.md +143 -0
- package/README.md +261 -21
- package/package.json +21 -5
- package/src/block-props.ts +96 -0
- package/src/client.ts +111 -11
- package/src/dom.ts +430 -0
- package/src/element.ts +381 -0
- package/src/renderers/CodeBlock.tsx +62 -5
- package/src/solid.tsx +70 -0
- package/src/svelte.ts +55 -0
- package/src/types-core.ts +147 -0
- package/src/types-react.ts +14 -0
- package/src/types.ts +7 -150
- package/src/vue.ts +100 -0
- package/src/wasm/flux_md_core.d.ts +7 -0
- package/src/wasm/flux_md_core.js +9 -0
- package/src/wasm/flux_md_core_bg.wasm +0 -0
- package/src/wasm/flux_md_core_bg.wasm.d.ts +1 -0
- package/src/wasm/package.json +1 -1
- package/src/worker.ts +11 -2
package/src/dom.ts
ADDED
|
@@ -0,0 +1,430 @@
|
|
|
1
|
+
import type { FluxClient } from "./client";
|
|
2
|
+
import { highlight } from "./hi";
|
|
3
|
+
import type { Block, BlockComponentProps, BlockKindTag } from "./types-core";
|
|
4
|
+
import { blockProps, extractLang } from "./block-props";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Framework-neutral DOM renderer for a {@link FluxClient}. Mounts the streaming
|
|
8
|
+
* document into a container and keeps it in sync via direct DOM mutation,
|
|
9
|
+
* mirroring the JSX renderer's block model: each block is keyed by its stable
|
|
10
|
+
* parser-assigned id, and a committed block's node is reused untouched on every
|
|
11
|
+
* later patch (the parity analogue of the JSX renderer's block memo). Only the
|
|
12
|
+
* streaming tail is rebuilt.
|
|
13
|
+
*
|
|
14
|
+
* This is the foundation the Web Component / Vue / Svelte / Solid bindings
|
|
15
|
+
* build on; it imports only neutral modules and carries no framework dependency.
|
|
16
|
+
*
|
|
17
|
+
* ## Custom components
|
|
18
|
+
*
|
|
19
|
+
* Pass `components` to override a whole block kind (or a component tag). Keys
|
|
20
|
+
* are capitalized block-kind names (`CodeBlock`, `Table`, `Mermaid`…) or, for
|
|
21
|
+
* `Component` blocks, the tag name (e.g. `Thinking`) with `Component` as the
|
|
22
|
+
* generic fallback. A component receives {@link BlockComponentProps} and returns
|
|
23
|
+
* an `HTMLElement` or an HTML string. There is no tag-level override path (no
|
|
24
|
+
* `table`/`a`/`code` keys) — that requires an HTML→tree pass the DOM renderer
|
|
25
|
+
* doesn't carry.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
export interface MountHandle {
|
|
29
|
+
destroy(): void;
|
|
30
|
+
refresh(): void;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export type DomBlockComponent = (props: BlockComponentProps) => HTMLElement | string;
|
|
34
|
+
|
|
35
|
+
/** Override map: capitalized block-kind / component-tag keys only. */
|
|
36
|
+
export type DomComponents = Record<string, DomBlockComponent>;
|
|
37
|
+
|
|
38
|
+
export interface MountOptions {
|
|
39
|
+
components?: DomComponents;
|
|
40
|
+
/**
|
|
41
|
+
* Optional HTML sanitizer applied to every generic block's HTML before it is
|
|
42
|
+
* injected via `innerHTML` — **including the streaming (open/speculative)
|
|
43
|
+
* tail**. The built-in code/math/mermaid renderers operate on already-escaped
|
|
44
|
+
* content and are not run through it (same as the JSX renderer). When omitted,
|
|
45
|
+
* rendering is byte-identical and zero-cost.
|
|
46
|
+
*/
|
|
47
|
+
sanitize?: (html: string) => string;
|
|
48
|
+
/**
|
|
49
|
+
* Skip layout/paint for off-screen *closed* blocks via CSS
|
|
50
|
+
* `content-visibility: auto` (for very long documents). Off by default.
|
|
51
|
+
*/
|
|
52
|
+
virtualize?: boolean;
|
|
53
|
+
/**
|
|
54
|
+
* Keep a bottom snap target so the view follows the streaming tail. CSS-only:
|
|
55
|
+
* emits a sentinel with `scroll-snap-align: end`; you add
|
|
56
|
+
* `scroll-snap-type: y proximity` to your scroll container. Off by default.
|
|
57
|
+
*/
|
|
58
|
+
stickToBottom?: boolean;
|
|
59
|
+
/** Use the built-in code highlighter. Default true; suppressed when a
|
|
60
|
+
* `components.CodeBlock` override is supplied. */
|
|
61
|
+
highlightCode?: boolean;
|
|
62
|
+
/** Coalesce patches into one DOM write per animation frame. Default true. */
|
|
63
|
+
batch?: boolean;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Per-kind off-screen size estimate for `contain-intrinsic-size`. Duplicated
|
|
67
|
+
// verbatim from the JSX renderer so per-kind virtualization sizes match.
|
|
68
|
+
const INTRINSIC_PX: Record<string, number> = {
|
|
69
|
+
Paragraph: 80, Heading: 44, CodeBlock: 300, MathBlock: 140, Mermaid: 220,
|
|
70
|
+
List: 120, Blockquote: 100, Alert: 120, Table: 200, Rule: 24, Html: 80,
|
|
71
|
+
Component: 120,
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
// The fingerprint that decides whether a block's node may be reused: exactly
|
|
75
|
+
// what the JSX renderer's block memo checks, minus `id` (the map key).
|
|
76
|
+
interface MountedBlock {
|
|
77
|
+
id: number;
|
|
78
|
+
node: HTMLElement;
|
|
79
|
+
html: string;
|
|
80
|
+
open: boolean;
|
|
81
|
+
speculative: boolean;
|
|
82
|
+
kind: BlockKindTag;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function mountFluxMarkdown(
|
|
86
|
+
client: FluxClient,
|
|
87
|
+
container: HTMLElement,
|
|
88
|
+
options: MountOptions = {},
|
|
89
|
+
): MountHandle {
|
|
90
|
+
if (typeof document === "undefined") {
|
|
91
|
+
throw new Error("mountFluxMarkdown is browser-only; call it after the DOM exists.");
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Normalize "no overrides" to undefined so the fast path doesn't churn.
|
|
95
|
+
const components =
|
|
96
|
+
options.components && Object.keys(options.components).length > 0 ? options.components : undefined;
|
|
97
|
+
const { sanitize, virtualize, stickToBottom } = options;
|
|
98
|
+
const highlightCode = options.highlightCode !== false && !components?.CodeBlock;
|
|
99
|
+
const batch = options.batch !== false && typeof requestAnimationFrame === "function";
|
|
100
|
+
|
|
101
|
+
const root = document.createElement("div");
|
|
102
|
+
root.className = "flux-md";
|
|
103
|
+
container.appendChild(root);
|
|
104
|
+
|
|
105
|
+
// CSS-only stick-to-bottom: a permanent sentinel pinned as the last child.
|
|
106
|
+
let anchor: HTMLElement | null = null;
|
|
107
|
+
if (stickToBottom) {
|
|
108
|
+
anchor = document.createElement("div");
|
|
109
|
+
anchor.className = "flux-bottom-anchor";
|
|
110
|
+
anchor.setAttribute("aria-hidden", "true");
|
|
111
|
+
anchor.style.scrollSnapAlign = "end";
|
|
112
|
+
root.appendChild(anchor);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const mounted = new Map<number, MountedBlock>();
|
|
116
|
+
let order: number[] = [];
|
|
117
|
+
let dead = false;
|
|
118
|
+
let frame = 0;
|
|
119
|
+
|
|
120
|
+
function sync(): void {
|
|
121
|
+
if (dead) return;
|
|
122
|
+
const snapshot = client.getSnapshot();
|
|
123
|
+
const nextOrder: number[] = new Array(snapshot.length);
|
|
124
|
+
const seen = new Set<number>();
|
|
125
|
+
|
|
126
|
+
for (let i = 0; i < snapshot.length; i++) {
|
|
127
|
+
const b = snapshot[i];
|
|
128
|
+
nextOrder[i] = b.id;
|
|
129
|
+
seen.add(b.id);
|
|
130
|
+
const existing = mounted.get(b.id);
|
|
131
|
+
if (!existing) {
|
|
132
|
+
const node = renderBlock(b);
|
|
133
|
+
mounted.set(b.id, {
|
|
134
|
+
id: b.id, node, html: b.html, open: b.open, speculative: b.speculative, kind: b.kind.type,
|
|
135
|
+
});
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
// Unchanged fingerprint → reuse the node untouched. Committed blocks land
|
|
139
|
+
// here forever: their node is never recreated, so any one-shot work
|
|
140
|
+
// (highlight, copy listener) runs exactly once. This is the whole point.
|
|
141
|
+
if (existing.html === b.html && existing.open === b.open && existing.speculative === b.speculative) {
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
// Changed → rebuild and swap in place.
|
|
145
|
+
const node = renderBlock(b);
|
|
146
|
+
existing.node.replaceWith(node);
|
|
147
|
+
existing.node = node;
|
|
148
|
+
existing.html = b.html;
|
|
149
|
+
existing.open = b.open;
|
|
150
|
+
existing.speculative = b.speculative;
|
|
151
|
+
existing.kind = b.kind.type;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Drop ids no longer present (reset() empties the snapshot; a speculative
|
|
155
|
+
// revision can drop a tail block).
|
|
156
|
+
if (mounted.size > seen.size) {
|
|
157
|
+
for (const [id, mb] of mounted) {
|
|
158
|
+
if (!seen.has(id)) {
|
|
159
|
+
mb.node.remove();
|
|
160
|
+
mounted.delete(id);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
order = nextOrder;
|
|
166
|
+
reconcileChildren();
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Keyed reconcile with a single forward cursor (O(n), not O(n²)): walk the
|
|
170
|
+
// desired order and the live children in lockstep, inserting/moving only a
|
|
171
|
+
// node whose live position differs. The `.flux-bottom-anchor` is never part of
|
|
172
|
+
// `order`, so it acts as the end-of-list marker — blocks always land before
|
|
173
|
+
// it, keeping it pinned last. The common streaming case touches 1–2 tail nodes.
|
|
174
|
+
function reconcileChildren(): void {
|
|
175
|
+
let cursor = root.firstChild;
|
|
176
|
+
for (let i = 0; i < order.length; i++) {
|
|
177
|
+
const mb = mounted.get(order[i]);
|
|
178
|
+
if (!mb) continue;
|
|
179
|
+
const want = mb.node;
|
|
180
|
+
if (cursor === want) {
|
|
181
|
+
cursor = want.nextSibling; // already in place; advance
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
// Out of place: move `want` before the cursor. When an anchor exists the
|
|
185
|
+
// cursor never advances past it (the anchor is never a `want`), so blocks
|
|
186
|
+
// always land before it; without one, a tail cursor of `null` appends.
|
|
187
|
+
root.insertBefore(want, cursor);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function renderBlock(b: Block): HTMLElement {
|
|
192
|
+
const content = renderBlockContent(b);
|
|
193
|
+
// Virtualize only *closed* blocks. Unlike the JSX renderer (which wraps in
|
|
194
|
+
// an extra div) the DOM renderer sets the properties on the block node
|
|
195
|
+
// directly — one of the documented byte-faithfulness divergences.
|
|
196
|
+
if (virtualize && !b.open && !b.speculative) {
|
|
197
|
+
const px = INTRINSIC_PX[b.kind.type] ?? 120;
|
|
198
|
+
content.style.contentVisibility = "auto";
|
|
199
|
+
content.style.containIntrinsicSize = `auto ${px}px`;
|
|
200
|
+
}
|
|
201
|
+
return content;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function renderBlockContent(b: Block): HTMLElement {
|
|
205
|
+
const kind = b.kind.type;
|
|
206
|
+
|
|
207
|
+
// 1. Block-kind override (a Component block dispatches on its tag first).
|
|
208
|
+
if (components) {
|
|
209
|
+
if (kind === "Component") {
|
|
210
|
+
const tag = (b.kind.data as { tag?: string } | undefined)?.tag;
|
|
211
|
+
const override = (tag && components[tag]) || components.Component;
|
|
212
|
+
if (override) return wrapOverrideResult(override(blockProps(b)));
|
|
213
|
+
}
|
|
214
|
+
const blockOverride = components[kind];
|
|
215
|
+
if (blockOverride) return wrapOverrideResult(blockOverride(blockProps(b)));
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// 2. Dedicated default renderers.
|
|
219
|
+
switch (kind) {
|
|
220
|
+
case "CodeBlock":
|
|
221
|
+
if (highlightCode) return renderCodeBlock(b);
|
|
222
|
+
break; // fall through to the generic path
|
|
223
|
+
case "MathBlock":
|
|
224
|
+
return renderMathBlock(b);
|
|
225
|
+
case "Mermaid":
|
|
226
|
+
return renderMermaid(b);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// 3. Generic fast path.
|
|
230
|
+
const node = document.createElement("div");
|
|
231
|
+
node.className =
|
|
232
|
+
"flux-block flux-block-" +
|
|
233
|
+
kind.toLowerCase() +
|
|
234
|
+
(b.open ? " flux-open" : "") +
|
|
235
|
+
(b.speculative ? " flux-speculative" : "");
|
|
236
|
+
node.innerHTML = sanitize ? sanitize(b.html) : b.html;
|
|
237
|
+
return node;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// An override may return an element (used directly) or an HTML string (wrapped
|
|
241
|
+
// in a div so the renderer always owns a single block node to track/swap).
|
|
242
|
+
function wrapOverrideResult(result: HTMLElement | string): HTMLElement {
|
|
243
|
+
if (typeof result === "string") {
|
|
244
|
+
const node = document.createElement("div");
|
|
245
|
+
node.innerHTML = result;
|
|
246
|
+
return node;
|
|
247
|
+
}
|
|
248
|
+
return result;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function renderCodeBlock(b: Block): HTMLElement {
|
|
252
|
+
const lang = extractLang(b.html) || "text";
|
|
253
|
+
// Mirror CodeBlock.tsx: text is "" while open, so the body falls to the raw
|
|
254
|
+
// `<div>` path; a closed block decodes once and highlights once. The node is
|
|
255
|
+
// frozen once closed, so highlight runs exactly once (no re-tokenize).
|
|
256
|
+
const text = b.open ? "" : decodeCodeText(b.html);
|
|
257
|
+
const highlighted = text ? highlight(text, lang) : null;
|
|
258
|
+
|
|
259
|
+
const block = document.createElement("div");
|
|
260
|
+
block.className = "flux-code-block" + (b.open ? " flux-streaming" : "");
|
|
261
|
+
|
|
262
|
+
const header = document.createElement("div");
|
|
263
|
+
header.className = "flux-code-header";
|
|
264
|
+
const langSpan = document.createElement("span");
|
|
265
|
+
langSpan.className = "flux-code-lang";
|
|
266
|
+
langSpan.textContent = lang;
|
|
267
|
+
header.appendChild(langSpan);
|
|
268
|
+
|
|
269
|
+
if (b.open) {
|
|
270
|
+
const pill = document.createElement("span");
|
|
271
|
+
pill.className = "flux-code-streaming-pill";
|
|
272
|
+
pill.textContent = "streaming";
|
|
273
|
+
header.appendChild(pill);
|
|
274
|
+
} else {
|
|
275
|
+
header.appendChild(makeCopyButton(text));
|
|
276
|
+
}
|
|
277
|
+
block.appendChild(header);
|
|
278
|
+
|
|
279
|
+
const body = document.createElement("div");
|
|
280
|
+
body.className = "flux-code-body";
|
|
281
|
+
if (highlighted) {
|
|
282
|
+
const pre = document.createElement("pre");
|
|
283
|
+
pre.tabIndex = 0;
|
|
284
|
+
pre.setAttribute("role", "region");
|
|
285
|
+
pre.setAttribute("aria-label", `${lang} code`);
|
|
286
|
+
const code = document.createElement("code");
|
|
287
|
+
code.innerHTML = highlighted;
|
|
288
|
+
pre.appendChild(code);
|
|
289
|
+
body.appendChild(pre);
|
|
290
|
+
} else {
|
|
291
|
+
const div = document.createElement("div");
|
|
292
|
+
div.tabIndex = 0;
|
|
293
|
+
div.setAttribute("role", "region");
|
|
294
|
+
div.setAttribute("aria-label", `${lang} code`);
|
|
295
|
+
div.innerHTML = b.html;
|
|
296
|
+
body.appendChild(div);
|
|
297
|
+
}
|
|
298
|
+
block.appendChild(body);
|
|
299
|
+
return block;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function renderMathBlock(b: Block): HTMLElement {
|
|
303
|
+
const block = document.createElement("div");
|
|
304
|
+
block.className = "flux-math-block" + (b.open ? " flux-streaming" : "");
|
|
305
|
+
const header = document.createElement("div");
|
|
306
|
+
header.className = "flux-math-header";
|
|
307
|
+
const lang = document.createElement("span");
|
|
308
|
+
lang.className = "flux-math-lang";
|
|
309
|
+
lang.textContent = "math";
|
|
310
|
+
header.appendChild(lang);
|
|
311
|
+
if (b.open) header.appendChild(streamingPill());
|
|
312
|
+
block.appendChild(header);
|
|
313
|
+
const body = document.createElement("div");
|
|
314
|
+
body.className = "flux-math-body";
|
|
315
|
+
body.innerHTML = b.html;
|
|
316
|
+
block.appendChild(body);
|
|
317
|
+
return block;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function renderMermaid(b: Block): HTMLElement {
|
|
321
|
+
const block = document.createElement("div");
|
|
322
|
+
block.className = "flux-mermaid-block" + (b.open ? " flux-streaming" : "");
|
|
323
|
+
const header = document.createElement("div");
|
|
324
|
+
header.className = "flux-mermaid-header";
|
|
325
|
+
const lang = document.createElement("span");
|
|
326
|
+
lang.className = "flux-mermaid-lang";
|
|
327
|
+
lang.textContent = "mermaid";
|
|
328
|
+
header.appendChild(lang);
|
|
329
|
+
if (b.open) header.appendChild(streamingPill());
|
|
330
|
+
block.appendChild(header);
|
|
331
|
+
const body = document.createElement("div");
|
|
332
|
+
body.className = "flux-mermaid-body";
|
|
333
|
+
body.innerHTML = b.html;
|
|
334
|
+
block.appendChild(body);
|
|
335
|
+
return block;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function streamingPill(): HTMLElement {
|
|
339
|
+
const pill = document.createElement("span");
|
|
340
|
+
pill.className = "flux-code-streaming-pill";
|
|
341
|
+
pill.textContent = "streaming";
|
|
342
|
+
return pill;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// SVG markup uses the live-DOM attribute form (hyphenated, e.g. stroke-width).
|
|
346
|
+
const COPY_ICON =
|
|
347
|
+
'<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="9" y="9" width="11" height="11" rx="2"></rect><path d="M5 15V5a2 2 0 0 1 2-2h10"></path></svg><span>Copy</span>';
|
|
348
|
+
const COPIED_ICON =
|
|
349
|
+
'<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 6 9 17l-5-5"></path></svg><span>Copied</span>';
|
|
350
|
+
|
|
351
|
+
function makeCopyButton(text: string): HTMLElement {
|
|
352
|
+
const btn = document.createElement("button");
|
|
353
|
+
btn.type = "button";
|
|
354
|
+
btn.className = "flux-code-copy";
|
|
355
|
+
btn.setAttribute("aria-label", "Copy code");
|
|
356
|
+
btn.setAttribute("aria-live", "polite");
|
|
357
|
+
btn.innerHTML = COPY_ICON;
|
|
358
|
+
// The listener lives as long as the node. A closed block's node is never
|
|
359
|
+
// recreated (frozen fingerprint), so there is no per-patch rebind; it is
|
|
360
|
+
// GC'd when `root` is removed.
|
|
361
|
+
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
362
|
+
btn.addEventListener("click", () => {
|
|
363
|
+
const clip = typeof navigator !== "undefined" ? navigator.clipboard : undefined;
|
|
364
|
+
if (!clip || !clip.writeText || !text) return;
|
|
365
|
+
clip.writeText(text).then(
|
|
366
|
+
() => {
|
|
367
|
+
btn.setAttribute("aria-label", "Copied");
|
|
368
|
+
btn.innerHTML = COPIED_ICON;
|
|
369
|
+
if (timer !== null) clearTimeout(timer);
|
|
370
|
+
timer = setTimeout(() => {
|
|
371
|
+
btn.setAttribute("aria-label", "Copy code");
|
|
372
|
+
btn.innerHTML = COPY_ICON;
|
|
373
|
+
}, 1500);
|
|
374
|
+
},
|
|
375
|
+
// Permission denied / blocked: stay silent, leave button usable.
|
|
376
|
+
() => {},
|
|
377
|
+
);
|
|
378
|
+
});
|
|
379
|
+
return btn;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
const unsubscribe = client.subscribe(() => {
|
|
383
|
+
if (dead) return;
|
|
384
|
+
if (batch) {
|
|
385
|
+
if (frame === 0) frame = requestAnimationFrame(flush);
|
|
386
|
+
} else {
|
|
387
|
+
sync();
|
|
388
|
+
}
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
function flush(): void {
|
|
392
|
+
frame = 0;
|
|
393
|
+
sync();
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// Initial render from whatever is already in the snapshot.
|
|
397
|
+
sync();
|
|
398
|
+
|
|
399
|
+
return {
|
|
400
|
+
destroy() {
|
|
401
|
+
if (dead) return;
|
|
402
|
+
dead = true;
|
|
403
|
+
if (frame !== 0) {
|
|
404
|
+
cancelAnimationFrame(frame);
|
|
405
|
+
frame = 0;
|
|
406
|
+
}
|
|
407
|
+
unsubscribe();
|
|
408
|
+
// The caller owns the worker/stream — never call client.destroy() here
|
|
409
|
+
// (same contract as the JSX renderer: unmounting never destroys the client).
|
|
410
|
+
root.remove();
|
|
411
|
+
},
|
|
412
|
+
refresh() {
|
|
413
|
+
if (dead) return;
|
|
414
|
+
sync();
|
|
415
|
+
},
|
|
416
|
+
};
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// Local copy of the canonical code-text decoder (kept here so dom.ts depends
|
|
420
|
+
// only on neutral modules; block-props.ts keeps its own private copy too).
|
|
421
|
+
function decodeCodeText(html: string): string {
|
|
422
|
+
const m = html.match(/<pre><code[^>]*>([\s\S]*?)<\/code><\/pre>/);
|
|
423
|
+
if (!m) return "";
|
|
424
|
+
return m[1]
|
|
425
|
+
.replace(/</g, "<")
|
|
426
|
+
.replace(/>/g, ">")
|
|
427
|
+
.replace(/"/g, '"')
|
|
428
|
+
.replace(/'/g, "'")
|
|
429
|
+
.replace(/&/g, "&");
|
|
430
|
+
}
|