flux-md 0.5.5 → 0.6.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 +103 -0
- package/README.md +230 -19
- package/package.json +20 -5
- package/src/block-props.ts +96 -0
- package/src/client.ts +1 -1
- package/src/dom.ts +430 -0
- package/src/element.ts +339 -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 +138 -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_bg.wasm +0 -0
package/src/element.ts
ADDED
|
@@ -0,0 +1,339 @@
|
|
|
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
|
+
"unsafe-html",
|
|
42
|
+
];
|
|
43
|
+
|
|
44
|
+
export function defineFluxMarkdown(tag = "flux-markdown"): void {
|
|
45
|
+
// SSR-safe: no custom-element registry => nothing to define.
|
|
46
|
+
if (typeof customElements === "undefined") return;
|
|
47
|
+
// Idempotent: a tag may only be defined once.
|
|
48
|
+
if (customElements.get(tag)) return;
|
|
49
|
+
|
|
50
|
+
// The class is defined lazily INSIDE the function: at module-evaluation time
|
|
51
|
+
// `HTMLElement` may not exist (SSR / pre-DOM). Referencing it only after the
|
|
52
|
+
// guards above keeps the module import side-effect-free.
|
|
53
|
+
class FluxMarkdownElement extends HTMLElement {
|
|
54
|
+
static get observedAttributes(): string[] {
|
|
55
|
+
return ["markdown", "src", "component-tags", ...CONFIG_ATTRS];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
#client: FluxClient | null = null;
|
|
59
|
+
#ownsClient = false;
|
|
60
|
+
#components: DomComponents | undefined = undefined;
|
|
61
|
+
#sanitize: ((html: string) => string) | undefined = undefined;
|
|
62
|
+
#handle: MountHandle | null = null;
|
|
63
|
+
#connected = false;
|
|
64
|
+
|
|
65
|
+
// --- Accessor properties (objects/functions can't be attributes) ---------
|
|
66
|
+
|
|
67
|
+
get client(): FluxClient | null {
|
|
68
|
+
return this.#client;
|
|
69
|
+
}
|
|
70
|
+
set client(value: FluxClient | null) {
|
|
71
|
+
if (value === this.#client) return;
|
|
72
|
+
// Switching to a caller-owned client: tear down any internal client we own
|
|
73
|
+
// first, then adopt the new one without owning it.
|
|
74
|
+
this.#teardownClient();
|
|
75
|
+
this.#client = value;
|
|
76
|
+
this.#ownsClient = false;
|
|
77
|
+
if (this.#connected) this.#remount();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
get components(): DomComponents | undefined {
|
|
81
|
+
return this.#components;
|
|
82
|
+
}
|
|
83
|
+
set components(value: DomComponents | undefined) {
|
|
84
|
+
this.#components = value;
|
|
85
|
+
if (this.#connected) this.#remount();
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
get sanitize(): ((html: string) => string) | undefined {
|
|
89
|
+
return this.#sanitize;
|
|
90
|
+
}
|
|
91
|
+
set sanitize(value: ((html: string) => string) | undefined) {
|
|
92
|
+
this.#sanitize = value;
|
|
93
|
+
if (this.#connected) this.#remount();
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// --- Self-owned-client methods -------------------------------------------
|
|
97
|
+
|
|
98
|
+
append(chunk: string): void {
|
|
99
|
+
this.#ensureClient();
|
|
100
|
+
this.#client!.append(chunk);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
finalize(): void {
|
|
104
|
+
// Only meaningful for a self-owned stream; a no-op if no client yet.
|
|
105
|
+
this.#client?.finalize();
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
reset(): void {
|
|
109
|
+
// Keep config; just clear the current stream's blocks.
|
|
110
|
+
this.#client?.reset();
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
getClient(): FluxClient | null {
|
|
114
|
+
return this.#client;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// --- Lifecycle -----------------------------------------------------------
|
|
118
|
+
|
|
119
|
+
connectedCallback(): void {
|
|
120
|
+
// Guard double-connect; allow reconnect-after-move.
|
|
121
|
+
if (this.#connected) return;
|
|
122
|
+
this.#connected = true;
|
|
123
|
+
|
|
124
|
+
// Property-upgrade dance: a framework may set `el.client`/`components`/
|
|
125
|
+
// `sanitize` BEFORE the element is upgraded, leaving an own data property
|
|
126
|
+
// that shadows the accessor. Capture, delete, re-assign through the setter.
|
|
127
|
+
this.#upgradeProperty("client");
|
|
128
|
+
this.#upgradeProperty("components");
|
|
129
|
+
this.#upgradeProperty("sanitize");
|
|
130
|
+
|
|
131
|
+
// Mount synchronously if we already have a client (caller-owned, or one a
|
|
132
|
+
// pre-connect append() created). append/finalize are postMessage and the
|
|
133
|
+
// config rides the first message FIFO, so no whenReady await is needed.
|
|
134
|
+
this.#mountIfReady();
|
|
135
|
+
|
|
136
|
+
// Resolve initial content for self-owned mode only (no caller client).
|
|
137
|
+
if (!this.#client || this.#ownsClient) {
|
|
138
|
+
this.#resolveInitialContent();
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
attributeChangedCallback(name: string, _old: string | null, _new: string | null): void {
|
|
143
|
+
// attributeChangedCallback fires before connectedCallback for attributes
|
|
144
|
+
// present at upgrade; ignore until connected so config reads happen once.
|
|
145
|
+
if (!this.#connected) return;
|
|
146
|
+
|
|
147
|
+
if (name === "markdown" || name === "src") {
|
|
148
|
+
// One-shot content source change — only for a self-owned client. A
|
|
149
|
+
// caller-owned client is driven by its owner, not by our attributes.
|
|
150
|
+
if (!this.#client || this.#ownsClient) {
|
|
151
|
+
this.#resolveInitialContent();
|
|
152
|
+
}
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// A config / component-tags change. ParserConfig is immutable per stream.
|
|
157
|
+
if (this.#client && !this.#ownsClient) {
|
|
158
|
+
// eslint-disable-next-line no-console
|
|
159
|
+
console.warn(
|
|
160
|
+
"<flux-markdown>: config attributes are ignored while a caller-owned `client` is set (ParserConfig is immutable per stream).",
|
|
161
|
+
);
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
// Self-owned: rebuild the client with fresh config, then re-render.
|
|
165
|
+
if (this.#ownsClient) {
|
|
166
|
+
this.#teardownClient();
|
|
167
|
+
this.#mountIfReady();
|
|
168
|
+
this.#resolveInitialContent();
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
disconnectedCallback(): void {
|
|
173
|
+
this.#connected = false;
|
|
174
|
+
// ALWAYS tear down the mount (the only teardown path for the renderer).
|
|
175
|
+
this.#handle?.destroy();
|
|
176
|
+
this.#handle = null;
|
|
177
|
+
// Destroy the client ONLY if we created it. A caller-owned client's
|
|
178
|
+
// worker/stream lifecycle belongs to the caller — never destroy it here.
|
|
179
|
+
if (this.#ownsClient) {
|
|
180
|
+
this.#client?.destroy();
|
|
181
|
+
this.#client = null;
|
|
182
|
+
this.#ownsClient = false;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// --- Internals -----------------------------------------------------------
|
|
187
|
+
|
|
188
|
+
#upgradeProperty(prop: "client" | "components" | "sanitize"): void {
|
|
189
|
+
if (Object.prototype.hasOwnProperty.call(this, prop)) {
|
|
190
|
+
const value = (this as unknown as Record<string, unknown>)[prop];
|
|
191
|
+
delete (this as unknown as Record<string, unknown>)[prop];
|
|
192
|
+
(this as unknown as Record<string, unknown>)[prop] = value;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Build a ParserConfig from the current config attributes. Read ONCE, at
|
|
197
|
+
// client creation — config is immutable per stream.
|
|
198
|
+
#readConfig(): ParserConfig | undefined {
|
|
199
|
+
const cfg: ParserConfig = {};
|
|
200
|
+
let any = false;
|
|
201
|
+
const set = (attr: string, key: keyof ParserConfig): void => {
|
|
202
|
+
const v = parseTriBool(this.getAttribute(attr));
|
|
203
|
+
if (v !== undefined) {
|
|
204
|
+
(cfg as Record<string, unknown>)[key] = v;
|
|
205
|
+
any = true;
|
|
206
|
+
}
|
|
207
|
+
};
|
|
208
|
+
set("gfm-autolinks", "gfmAutolinks");
|
|
209
|
+
set("gfm-alerts", "gfmAlerts");
|
|
210
|
+
set("gfm-footnotes", "gfmFootnotes");
|
|
211
|
+
set("gfm-math", "gfmMath");
|
|
212
|
+
set("dir-auto", "dirAuto");
|
|
213
|
+
set("unsafe-html", "unsafeHtml");
|
|
214
|
+
|
|
215
|
+
const tags = this.getAttribute("component-tags");
|
|
216
|
+
if (tags !== null) {
|
|
217
|
+
const list = tags.split(/[\s,]+/).filter(Boolean);
|
|
218
|
+
if (list.length > 0) {
|
|
219
|
+
cfg.componentTags = list;
|
|
220
|
+
any = true;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
return any ? cfg : undefined;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Lazily create the internal client from config attributes (self-owned).
|
|
227
|
+
#ensureClient(): void {
|
|
228
|
+
if (this.#client) return;
|
|
229
|
+
this.#client = new FluxClient({ config: this.#readConfig() });
|
|
230
|
+
this.#ownsClient = true;
|
|
231
|
+
this.#mountIfReady();
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Mount once a client exists and we're connected. Idempotent.
|
|
235
|
+
#mountIfReady(): void {
|
|
236
|
+
if (!this.#connected || !this.#client || this.#handle) return;
|
|
237
|
+
this.#handle = mountFluxMarkdown(this.#client, this, {
|
|
238
|
+
components: this.#components,
|
|
239
|
+
sanitize: this.#sanitize,
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Destroy the current mount and remount against the current client+options.
|
|
244
|
+
// Used when a property changes while connected.
|
|
245
|
+
#remount(): void {
|
|
246
|
+
this.#handle?.destroy();
|
|
247
|
+
this.#handle = null;
|
|
248
|
+
this.#mountIfReady();
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Tear down only the client side (mount stays / is handled by the caller).
|
|
252
|
+
// Destroys the client only if self-owned, then clears it and the mount so
|
|
253
|
+
// the next mount targets a fresh client.
|
|
254
|
+
#teardownClient(): void {
|
|
255
|
+
this.#handle?.destroy();
|
|
256
|
+
this.#handle = null;
|
|
257
|
+
if (this.#ownsClient) this.#client?.destroy();
|
|
258
|
+
this.#client = null;
|
|
259
|
+
this.#ownsClient = false;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Resolve the initial content of a self-owned stream from the attributes,
|
|
263
|
+
// in priority order: `src` (fetch+stream) > `markdown` (one-shot) >
|
|
264
|
+
// textContent (one-shot). A caller-owned client never reaches here.
|
|
265
|
+
#resolveInitialContent(): void {
|
|
266
|
+
const src = this.getAttribute("src");
|
|
267
|
+
if (src) {
|
|
268
|
+
void this.#streamFromSrc(src);
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
const markdown = this.getAttribute("markdown");
|
|
272
|
+
if (markdown !== null) {
|
|
273
|
+
this.#oneShot(markdown);
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
// textContent-as-initial-markdown: capture, clear, then feed. Capture
|
|
277
|
+
// BEFORE the mount appended its `.flux-md` root would pollute the text;
|
|
278
|
+
// mount happened in connectedCallback, so read only our own text nodes.
|
|
279
|
+
const text = this.#captureSourceText();
|
|
280
|
+
if (text.trim().length > 0) this.#oneShot(text);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// Read the raw markdown the host put between the tags, ignoring the
|
|
284
|
+
// renderer's `.flux-md` root (and any other element children).
|
|
285
|
+
#captureSourceText(): string {
|
|
286
|
+
let text = "";
|
|
287
|
+
for (const node of Array.from(this.childNodes)) {
|
|
288
|
+
if (node.nodeType === 3 /* Text */) {
|
|
289
|
+
text += node.textContent ?? "";
|
|
290
|
+
node.parentNode?.removeChild(node);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
return text;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// One-shot: reset the stream (in case content changed), feed it, finalize.
|
|
297
|
+
#oneShot(markdown: string): void {
|
|
298
|
+
this.#ensureClient();
|
|
299
|
+
this.#client!.reset();
|
|
300
|
+
this.#client!.append(markdown);
|
|
301
|
+
this.#client!.finalize();
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// Fetch a URL and stream its body. TextDecoder with {stream:true} carries a
|
|
305
|
+
// multibyte sequence that straddles a chunk boundary into the next decode.
|
|
306
|
+
async #streamFromSrc(src: string): Promise<void> {
|
|
307
|
+
this.#ensureClient();
|
|
308
|
+
this.#client!.reset();
|
|
309
|
+
const owned = this.#client!;
|
|
310
|
+
try {
|
|
311
|
+
const res = await fetch(src);
|
|
312
|
+
const body = res.body;
|
|
313
|
+
if (!body) {
|
|
314
|
+
owned.append(await res.text());
|
|
315
|
+
owned.finalize();
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
const reader = body.getReader();
|
|
319
|
+
const decoder = new TextDecoder();
|
|
320
|
+
for (;;) {
|
|
321
|
+
const { done, value } = await reader.read();
|
|
322
|
+
// The element may have been disconnected (and `owned` destroyed) or
|
|
323
|
+
// the client swapped mid-stream; stop feeding a stale client.
|
|
324
|
+
if (this.#client !== owned) return;
|
|
325
|
+
if (done) break;
|
|
326
|
+
if (value) owned.append(decoder.decode(value, { stream: true }));
|
|
327
|
+
}
|
|
328
|
+
if (this.#client !== owned) return;
|
|
329
|
+
owned.append(decoder.decode()); // flush any trailing partial sequence
|
|
330
|
+
owned.finalize();
|
|
331
|
+
} catch (err) {
|
|
332
|
+
// eslint-disable-next-line no-console
|
|
333
|
+
console.error("<flux-markdown>: failed to stream src", src, err);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
customElements.define(tag, FluxMarkdownElement);
|
|
339
|
+
}
|
|
@@ -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
|
-
}, [
|
|
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
|
|
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
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
export type BlockKindTag =
|
|
2
|
+
| "Paragraph"
|
|
3
|
+
| "Heading"
|
|
4
|
+
| "CodeBlock"
|
|
5
|
+
| "MathBlock"
|
|
6
|
+
| "Mermaid"
|
|
7
|
+
| "List"
|
|
8
|
+
| "Blockquote"
|
|
9
|
+
| "Alert"
|
|
10
|
+
| "Table"
|
|
11
|
+
| "Rule"
|
|
12
|
+
| "Html"
|
|
13
|
+
| "Component";
|
|
14
|
+
|
|
15
|
+
export interface BlockKind {
|
|
16
|
+
type: BlockKindTag;
|
|
17
|
+
data?: unknown;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface Block {
|
|
21
|
+
id: number;
|
|
22
|
+
kind: BlockKind;
|
|
23
|
+
start: number;
|
|
24
|
+
end: number;
|
|
25
|
+
html: string;
|
|
26
|
+
open: boolean;
|
|
27
|
+
speculative: boolean;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface Patch {
|
|
31
|
+
newly_committed: Block[];
|
|
32
|
+
active: Block[];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Props passed to a block-kind override (e.g. `components.CodeBlock`). */
|
|
36
|
+
export interface BlockComponentProps {
|
|
37
|
+
/** The full parsed block, including `kind` (with `kind.data`) and offsets. */
|
|
38
|
+
block: Block;
|
|
39
|
+
/** Rendered, XSS-safe HTML for this block. */
|
|
40
|
+
html: string;
|
|
41
|
+
/** True while the block is still streaming (its HTML may still change). */
|
|
42
|
+
open: boolean;
|
|
43
|
+
/** True if the block was closed speculatively and may yet be revised. */
|
|
44
|
+
speculative: boolean;
|
|
45
|
+
/** Decoded source text — present for `CodeBlock` / `MathBlock`. */
|
|
46
|
+
text?: string;
|
|
47
|
+
/** Info-string language — present for `CodeBlock` (from `kind.data.lang`). */
|
|
48
|
+
language?: string;
|
|
49
|
+
/** Component tag name — present for `Component` blocks (from `kind.data.tag`). */
|
|
50
|
+
tag?: string;
|
|
51
|
+
/**
|
|
52
|
+
* Sanitized attributes — present for `Component` blocks. The name-form depends
|
|
53
|
+
* on the consumer: the JSX renderer maps `class`→`className`/`for`→`htmlFor`
|
|
54
|
+
* so `{...attrs}` spreads cleanly onto an element; the DOM renderer keeps the
|
|
55
|
+
* literal HTML names (`class`/`for`) because it applies them via
|
|
56
|
+
* `setAttribute`. For `Component` blocks, `html` is the **inner**
|
|
57
|
+
* rendered-markdown HTML (not the `<tag>…</tag>` wrapper), so an override can
|
|
58
|
+
* wrap it itself.
|
|
59
|
+
*/
|
|
60
|
+
attrs?: Record<string, string>;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Per-stream parser configuration. Omitted fields use the library defaults
|
|
65
|
+
* (autolinks + alerts on, raw HTML escaped, footnotes off) — so the default
|
|
66
|
+
* `new FluxClient()` behaves exactly as before. Config is applied when the
|
|
67
|
+
* stream's parser is created and is **immutable** for that stream's lifetime
|
|
68
|
+
* (a `reset()` keeps it; use a new client for different flags).
|
|
69
|
+
*/
|
|
70
|
+
export interface ParserConfig {
|
|
71
|
+
/** GFM extended autolinks (bare www./http(s)://ftp:// + emails). Default true. */
|
|
72
|
+
gfmAutolinks?: boolean;
|
|
73
|
+
/** GitHub alerts (`> [!NOTE]` → callouts). Default true. */
|
|
74
|
+
gfmAlerts?: boolean;
|
|
75
|
+
/** GFM footnotes (`[^1]` + `[^1]:` → footnote section). Default false. */
|
|
76
|
+
gfmFootnotes?: boolean;
|
|
77
|
+
/**
|
|
78
|
+
* Math: `$…$` / `\(…\)` inline and `$$…$$` / `\[…\]` display. Default false
|
|
79
|
+
* (so `$` in prose / currency stays literal). Emits KaTeX-ready markup
|
|
80
|
+
* (`<span class="math math-inline">` / `<div class="math math-display">`)
|
|
81
|
+
* carrying the LaTeX — bring your own KaTeX pass (flux-md stays zero-dep).
|
|
82
|
+
*/
|
|
83
|
+
gfmMath?: boolean;
|
|
84
|
+
/**
|
|
85
|
+
* Emit `dir="auto"` on block-level text elements (`p`, `h1`–`h6`,
|
|
86
|
+
* `blockquote`, `ul`/`ol`/`li`, `table`) so the browser detects each block's
|
|
87
|
+
* direction independently — correct for documents mixing English with
|
|
88
|
+
* Arabic/Hebrew. Default false; code blocks always stay LTR. Recommended for
|
|
89
|
+
* apps that render RTL or mixed-direction content.
|
|
90
|
+
*/
|
|
91
|
+
dirAuto?: boolean;
|
|
92
|
+
/** Pass raw HTML through unescaped. Default false. **Never enable for untrusted input.** */
|
|
93
|
+
unsafeHtml?: boolean;
|
|
94
|
+
/**
|
|
95
|
+
* Opt-in allowlist of custom component tag names (e.g. `["Thinking",
|
|
96
|
+
* "Callout"]`). A `<Tag>…</Tag>` whose name is listed renders as a component
|
|
97
|
+
* whose inner content is parsed as **markdown** — safely, without `unsafeHtml`
|
|
98
|
+
* (the tag is allowlisted and its attributes are sanitized: event handlers
|
|
99
|
+
* dropped, dangerous URL schemes neutralized). The block is dispatched by the
|
|
100
|
+
* renderer via `components[tag]` (or `components.Component`). Empty/omitted =
|
|
101
|
+
* off. Names match case-sensitively.
|
|
102
|
+
*/
|
|
103
|
+
componentTags?: string[];
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Each message carries a `streamId` so one worker can multiplex many parsers
|
|
107
|
+
// (the worker pool). `ready` is the exception — it's worker-level (WASM loaded),
|
|
108
|
+
// not stream-level. The first message for a stream may carry `config`, applied
|
|
109
|
+
// when that stream's parser is created.
|
|
110
|
+
export type ToWorker =
|
|
111
|
+
| { type: "append"; streamId: number; chunk: string; config?: ParserConfig }
|
|
112
|
+
| { type: "finalize"; streamId: number; config?: ParserConfig }
|
|
113
|
+
| { type: "reset"; streamId: number }
|
|
114
|
+
| { type: "dispose"; streamId: number };
|
|
115
|
+
|
|
116
|
+
export type FromWorker =
|
|
117
|
+
| { type: "ready" }
|
|
118
|
+
| {
|
|
119
|
+
type: "patch";
|
|
120
|
+
streamId: number;
|
|
121
|
+
patch: Patch;
|
|
122
|
+
appendedBytes: number;
|
|
123
|
+
parseMicros: number;
|
|
124
|
+
retainedBytes: number;
|
|
125
|
+
wasmMemoryBytes: number;
|
|
126
|
+
}
|
|
127
|
+
| { type: "error"; streamId: number; message: string };
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Minimal structural interface satisfied by the DOM `Worker`. Injectable so the
|
|
131
|
+
* pool's routing/lifecycle logic can be unit-tested with a fake worker — no
|
|
132
|
+
* real Worker or WASM required.
|
|
133
|
+
*/
|
|
134
|
+
export interface WorkerLike {
|
|
135
|
+
postMessage(msg: ToWorker): void;
|
|
136
|
+
addEventListener(type: "message", listener: (ev: { data: FromWorker }) => void): void;
|
|
137
|
+
terminate(): void;
|
|
138
|
+
}
|