flux-md 0.16.2 → 0.18.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.
Files changed (69) hide show
  1. package/CHANGELOG.md +950 -0
  2. package/README.md +111 -82
  3. package/dist/block-props.d.ts +18 -0
  4. package/dist/block-props.js +75 -0
  5. package/dist/client.d.ts +311 -0
  6. package/{src/client.ts → dist/client.js} +143 -325
  7. package/dist/dom.d.ts +110 -0
  8. package/dist/dom.js +576 -0
  9. package/dist/element.d.ts +20 -0
  10. package/dist/element.js +287 -0
  11. package/dist/hi.d.ts +12 -0
  12. package/{src/hi.ts → dist/hi.js} +42 -74
  13. package/dist/html-to-react.d.ts +40 -0
  14. package/dist/html-to-react.js +344 -0
  15. package/{src/index.ts → dist/index.d.ts} +5 -25
  16. package/dist/index.js +16 -0
  17. package/dist/morph.d.ts +28 -0
  18. package/dist/morph.js +166 -0
  19. package/dist/react.d.ts +204 -0
  20. package/dist/react.js +490 -0
  21. package/dist/renderers/CodeBlock.d.ts +7 -0
  22. package/dist/renderers/CodeBlock.js +75 -0
  23. package/dist/renderers/Math.d.ts +14 -0
  24. package/dist/renderers/Math.js +15 -0
  25. package/dist/renderers/Mermaid.d.ts +13 -0
  26. package/dist/renderers/Mermaid.js +15 -0
  27. package/dist/server-react.d.ts +32 -0
  28. package/dist/server-react.js +48 -0
  29. package/dist/server.d.ts +31 -0
  30. package/dist/server.js +81 -0
  31. package/{src/solid.tsx → dist/solid.d.ts} +19 -95
  32. package/dist/solid.js +54 -0
  33. package/dist/svelte.d.ts +80 -0
  34. package/dist/svelte.js +59 -0
  35. package/dist/types-core.d.ts +382 -0
  36. package/dist/types-core.js +0 -0
  37. package/{src/types-react.ts → dist/types-react.d.ts} +0 -1
  38. package/dist/types-react.js +0 -0
  39. package/dist/types.d.ts +2 -0
  40. package/dist/types.js +2 -0
  41. package/dist/vue.d.ts +94 -0
  42. package/dist/vue.js +79 -0
  43. package/{src → dist}/wasm/flux_md_core.d.ts +18 -6
  44. package/{src → dist}/wasm/flux_md_core.js +50 -55
  45. package/dist/wasm/flux_md_core_bg.wasm +0 -0
  46. package/{src → dist}/wasm/flux_md_core_bg.wasm.d.ts +1 -0
  47. package/dist/worker-core.d.ts +65 -0
  48. package/dist/worker-core.js +154 -0
  49. package/dist/worker.d.ts +1 -0
  50. package/dist/worker.js +48 -0
  51. package/package.json +25 -19
  52. package/src/block-props.ts +0 -141
  53. package/src/dom.ts +0 -946
  54. package/src/element.ts +0 -400
  55. package/src/html-to-react.ts +0 -455
  56. package/src/morph.ts +0 -253
  57. package/src/react.tsx +0 -1020
  58. package/src/renderers/CodeBlock.tsx +0 -116
  59. package/src/renderers/Math.tsx +0 -28
  60. package/src/renderers/Mermaid.tsx +0 -27
  61. package/src/server.tsx +0 -221
  62. package/src/svelte.ts +0 -179
  63. package/src/types-core.ts +0 -398
  64. package/src/types.ts +0 -7
  65. package/src/vue.ts +0 -184
  66. package/src/wasm/flux_md_core_bg.wasm +0 -0
  67. package/src/worker-core.ts +0 -174
  68. package/src/worker.ts +0 -72
  69. /package/{src → dist}/styles.css +0 -0
@@ -1,455 +0,0 @@
1
- import { createElement, type ReactNode } from "react";
2
- import type { Components } from "./types";
3
-
4
- // HTML void elements: no closing tag, never have children.
5
- const VOID = new Set([
6
- "area", "base", "br", "col", "embed", "hr", "img", "input",
7
- "link", "meta", "param", "source", "track", "wbr",
8
- ]);
9
-
10
- // Attribute name → React prop name, for the handful that differ. Anything not
11
- // listed passes through verbatim (React forwards data-*/aria-* and lowercase
12
- // attributes unchanged).
13
- // Prototype-free map so an attribute named `constructor`/`hasOwnProperty`/etc.
14
- // returns undefined (and the `?? name` fallback fires) rather than resolving to
15
- // an inherited Object.prototype member.
16
- const ATTR_MAP: Record<string, string> = Object.assign(Object.create(null), {
17
- class: "className",
18
- for: "htmlFor",
19
- colspan: "colSpan",
20
- rowspan: "rowSpan",
21
- tabindex: "tabIndex",
22
- maxlength: "maxLength",
23
- minlength: "minLength",
24
- readonly: "readOnly",
25
- autocomplete: "autoComplete",
26
- autofocus: "autoFocus",
27
- spellcheck: "spellCheck",
28
- contenteditable: "contentEditable",
29
- crossorigin: "crossOrigin",
30
- enterkeyhint: "enterKeyHint",
31
- inputmode: "inputMode",
32
- });
33
-
34
- // URL-bearing attributes whose value must be scheme-checked. `htmlToReact` is
35
- // exported and may be handed untrusted HTML directly; React happily renders a
36
- // `javascript:` href (it only warns), so we neutralize it here as
37
- // defense-in-depth — the core's own output is already sanitized.
38
- const URL_ATTRS = new Set(["href", "src", "xlink:href", "formaction", "action", "poster", "data"]);
39
-
40
- // React-meaningful prop names that must never be forwarded from (possibly
41
- // untrusted) HTML attributes: `dangerouslySetInnerHTML` as a prop crashes the
42
- // whole render tree (DoS), and ref/key/defaultValue/etc. are injectable.
43
- const PROP_DENY = new Set([
44
- "dangerouslysetinnerhtml", "ref", "key", "defaultvalue", "defaultchecked",
45
- "suppresshydrationwarning", "suppresscontenteditablewarning",
46
- ]);
47
-
48
- // Only forward attribute names that are a plain HTML attribute identifier
49
- // (so camelCase / `__proto__` / `constructor` never reach React props). The
50
- // explicit ATTR_MAP renames and `xlink:href` are allowed past this gate.
51
- const SAFE_ATTR_NAME = /^[a-z][a-z0-9-]*$/i;
52
-
53
- /** Replace a dangerous-scheme URL with "#". Mirrors the Rust `is_dangerous_scheme`:
54
- * strip control chars (C0, DEL, C1 — matching Rust char::is_control),
55
- * lowercase, then match. The strip affects only the probe, never output. */
56
- function safeUrl(value: string): string {
57
- // Decode-STABLE probe: a value can be entity-decoded more than once before it
58
- // reaches the DOM, so peel layers to a fixpoint before the scheme check —
59
- // catches `javascript&#58;` and double-encoded `javascript&amp;#58;`. Only the
60
- // probe is decoded; the returned value is untouched (safe URLs stay verbatim).
61
- // Cap at 8 iterations: far beyond any legit URL (browsers entity-decode an
62
- // href once), and bounds the loop so a hostile value can't make it quadratic.
63
- let decoded = value;
64
- for (let i = 0, prev = ""; i < 8 && decoded !== prev; i++) {
65
- prev = decoded;
66
- decoded = decodeEntities(decoded);
67
- }
68
- // eslint-disable-next-line no-control-regex
69
- const probe = decoded.replace(/[\u0000-\u001f\u007f-\u009f]/g, "").replace(/^\s+/, "").toLowerCase();
70
- if (
71
- probe.startsWith("javascript:") ||
72
- probe.startsWith("vbscript:") ||
73
- probe.startsWith("data:text/html") ||
74
- probe.startsWith("data:text/javascript")
75
- ) {
76
- return "#";
77
- }
78
- return value;
79
- }
80
-
81
- type HNode =
82
- | { kind: "text"; text: string }
83
- | { kind: "el"; tag: string; attrs: Record<string, string | true>; children: HNode[] };
84
-
85
- const NAMED_ENTITIES: Record<string, string> = {
86
- amp: "&", lt: "<", gt: ">", quot: '"', apos: "'", nbsp: " ",
87
- copy: "©", reg: "®", hellip: "…", mdash: "—", ndash: "–",
88
- };
89
-
90
- /** Decode the (small, known) set of entities the core emits, plus numeric refs. */
91
- export function decodeEntities(s: string): string {
92
- if (s.indexOf("&") === -1) return s;
93
- return s.replace(/&(#x[0-9a-fA-F]+|#\d+|[a-zA-Z][a-zA-Z0-9]*);/g, (m, body: string) => {
94
- if (body[0] === "#") {
95
- const code = body[1] === "x" || body[1] === "X"
96
- ? parseInt(body.slice(2), 16)
97
- : parseInt(body.slice(1), 10);
98
- if (Number.isNaN(code) || code < 0 || code > 0x10ffff) return m;
99
- try {
100
- return String.fromCodePoint(code);
101
- } catch {
102
- return m;
103
- }
104
- }
105
- const named = NAMED_ENTITIES[body];
106
- return named === undefined ? m : named;
107
- });
108
- }
109
-
110
- /**
111
- * Parse an inline CSS string (`"text-align:left;color:red"`) into the object
112
- * React's `style` prop requires, camelCasing property names. Custom properties
113
- * (`--x`) keep their literal name.
114
- */
115
- export function parseStyle(css: string): Record<string, string> {
116
- const out: Record<string, string> = {};
117
- for (const decl of css.split(";")) {
118
- const c = decl.indexOf(":");
119
- if (c === -1) continue;
120
- const rawName = decl.slice(0, c).trim();
121
- const value = decl.slice(c + 1).trim();
122
- if (!rawName || !value) continue;
123
- const name = rawName.startsWith("--")
124
- ? rawName
125
- : rawName.toLowerCase().replace(/-([a-z])/g, (_, ch: string) => ch.toUpperCase());
126
- out[name] = value;
127
- }
128
- return out;
129
- }
130
-
131
- // CSS values that beacon/exfiltrate (`url(`), execute (legacy `expression(`,
132
- // `-moz-binding`, `behavior:`), or pull external resources (`@import`,
133
- // `image-set(`). Defense-in-depth: the core sanitizer already drops `style`, but
134
- // `htmlToReact` is exported and may be handed untrusted HTML directly.
135
- const DANGEROUS_CSS_VALUE = /url\(|expression\(|image-set\(|-moz-binding|@import|behavior\s*:/i;
136
-
137
- /** Strip CSS declarations that can beacon/exfiltrate, execute, or overlay the
138
- * viewport (`position: fixed/sticky` → clickjacking). Safe declarations
139
- * (`text-align`, `color`, …) — including flux's own table-alignment style —
140
- * pass through untouched. */
141
- function safeStyle(style: Record<string, string>): Record<string, string> {
142
- const out: Record<string, string> = {};
143
- for (const k in style) {
144
- const v = style[k];
145
- if (DANGEROUS_CSS_VALUE.test(v)) continue;
146
- if (k.toLowerCase() === "position" && /\b(?:fixed|sticky)\b/i.test(v)) continue;
147
- out[k] = v;
148
- }
149
- return out;
150
- }
151
-
152
- // Single-char classifiers for parseOpenTag, hoisted to module scope so the
153
- // scan loops don't re-evaluate a fresh regex literal per character on every
154
- // block render (parseTrustedHtml runs this for every open streaming block).
155
- const TAG_CHAR = /[a-zA-Z0-9-]/;
156
- const WS_CHAR = /\s/;
157
- const ATTR_NAME_END = /[\s=>/]/;
158
- const UNQUOTED_VALUE_END = /[\s>]/;
159
-
160
- /** Parse one opening tag starting at `start` (the `<`). */
161
- function parseOpenTag(html: string, start: number) {
162
- let i = start + 1;
163
- let j = i;
164
- while (j < html.length && TAG_CHAR.test(html[j])) j++;
165
- // Preserve the tag's ORIGINAL case so an inline custom-component element (e.g.
166
- // `<Cite>`) dispatches to `components.Cite`. Standard elements the core emits
167
- // are already lowercase; the semantic checks below (VOID, `input`, close-tag
168
- // matching) lowercase as needed, so HTML behavior is unchanged.
169
- const tag = html.slice(i, j);
170
- i = j;
171
- const attrs: Record<string, string | true> = {};
172
- while (i < html.length) {
173
- const loopStart = i;
174
- while (i < html.length && WS_CHAR.test(html[i])) i++;
175
- if (html[i] === ">") return { tag, attrs, selfClose: false, next: i + 1 };
176
- if (html[i] === "/" && html[i + 1] === ">") return { tag, attrs, selfClose: true, next: i + 2 };
177
- if (i >= html.length) break;
178
- let k = i;
179
- while (k < html.length && !ATTR_NAME_END.test(html[k])) k++;
180
- const name = html.slice(i, k);
181
- i = k;
182
- while (i < html.length && WS_CHAR.test(html[i])) i++;
183
- if (html[i] === "=") {
184
- i++;
185
- while (i < html.length && WS_CHAR.test(html[i])) i++;
186
- let value = "";
187
- const q = html[i];
188
- if (q === '"' || q === "'") {
189
- i++;
190
- const e = html.indexOf(q, i);
191
- value = html.slice(i, e === -1 ? html.length : e);
192
- i = e === -1 ? html.length : e + 1;
193
- } else {
194
- let v = i;
195
- while (v < html.length && !UNQUOTED_VALUE_END.test(html[v])) v++;
196
- value = html.slice(i, v);
197
- i = v;
198
- }
199
- if (name) attrs[name] = decodeEntities(value);
200
- } else if (name) {
201
- attrs[name] = true; // boolean attribute (e.g. checked, disabled)
202
- }
203
- // Guarantee forward progress on malformed input (e.g. a stray '/').
204
- if (i <= loopStart) i = loopStart + 1;
205
- }
206
- return { tag, attrs, selfClose: false, next: i };
207
- }
208
-
209
- /**
210
- * Tokenize **trusted** HTML (the kind flux-md's core emits: well-formed,
211
- * entity-escaped, allowlisted) into a small node tree. This is deliberately not
212
- * a defensive HTML5 parser — the threat model is "our own serializer output",
213
- * not hostile markup. Unrecognized constructs (comments, doctype) are skipped.
214
- */
215
- // Instrumentation: how many times the tokenizer has run. Used by tests to
216
- // prove open blocks and the no-override fast path never reach the parser, and
217
- // that memoized closed blocks parse exactly once. Negligible cost in prod.
218
- let parseCount = 0;
219
- export function getParseCount(): number {
220
- return parseCount;
221
- }
222
- export function resetParseCount(): void {
223
- parseCount = 0;
224
- }
225
-
226
- export function parseTrustedHtml(html: string): HNode[] {
227
- parseCount++;
228
- const root: HNode[] = [];
229
- const stack: Array<Extract<HNode, { kind: "el" }>> = [];
230
- let i = 0;
231
- const push = (n: HNode) => {
232
- if (stack.length) stack[stack.length - 1].children.push(n);
233
- else root.push(n);
234
- };
235
- while (i < html.length) {
236
- const lt = html.indexOf("<", i);
237
- if (lt === -1) {
238
- const t = html.slice(i);
239
- if (t) push({ kind: "text", text: decodeEntities(t) });
240
- break;
241
- }
242
- if (lt > i) push({ kind: "text", text: decodeEntities(html.slice(i, lt)) });
243
-
244
- if (html.startsWith("<!--", lt)) {
245
- const end = html.indexOf("-->", lt + 4);
246
- i = end === -1 ? html.length : end + 3;
247
- continue;
248
- }
249
- if (html[lt + 1] === "!") {
250
- const end = html.indexOf(">", lt);
251
- i = end === -1 ? html.length : end + 1;
252
- continue;
253
- }
254
- if (html[lt + 1] === "/") {
255
- const end = html.indexOf(">", lt);
256
- const closeLower = html.slice(lt + 2, end === -1 ? html.length : end).trim().toLowerCase();
257
- for (let s = stack.length - 1; s >= 0; s--) {
258
- if (stack[s].tag.toLowerCase() === closeLower) {
259
- stack.length = s;
260
- break;
261
- }
262
- }
263
- i = end === -1 ? html.length : end + 1;
264
- continue;
265
- }
266
- // An opening tag must start with an ASCII letter. Anything else (a stray
267
- // '<', as in "3 < 4") is literal text. (Real core output escapes '<' to
268
- // &lt;, so this only matters for hand-fed input — but it must not hang.)
269
- const c1 = html[lt + 1];
270
- const isName = (c1 >= "a" && c1 <= "z") || (c1 >= "A" && c1 <= "Z");
271
- if (!isName) {
272
- push({ kind: "text", text: "<" });
273
- i = lt + 1;
274
- continue;
275
- }
276
- const { tag, attrs, selfClose, next } = parseOpenTag(html, lt);
277
- const el: Extract<HNode, { kind: "el" }> = { kind: "el", tag, attrs, children: [] };
278
- push(el);
279
- if (!selfClose && !VOID.has(tag.toLowerCase())) stack.push(el);
280
- i = next;
281
- }
282
- return root;
283
- }
284
-
285
- function attrsToProps(tag: string, attrs: Record<string, string | true>, key: string): Record<string, unknown> {
286
- const props: Record<string, unknown> = { key };
287
- for (const name in attrs) {
288
- const value = attrs[name];
289
- const lower = name.toLowerCase();
290
- // Defense-in-depth: never forward inline event handlers, even though
291
- // React drops most lowercase `on*` attrs — this also covers casings and
292
- // future React behavior.
293
- if (lower.startsWith("on")) continue;
294
- // Reject React-meaningful names that would crash the render tree or inject
295
- // internals (dangerouslySetInnerHTML, ref, key, defaultValue, …).
296
- if (PROP_DENY.has(lower)) continue;
297
- if (lower === "style" && typeof value === "string") {
298
- props.style = safeStyle(parseStyle(value));
299
- continue;
300
- }
301
- // Neutralize dangerous-scheme URLs (javascript:, vbscript:, data:text/html).
302
- if (URL_ATTRS.has(lower) && typeof value === "string") {
303
- props[ATTR_MAP[lower] ?? name] = safeUrl(value);
304
- continue;
305
- }
306
- // A static checkbox carries `checked` with no handler; render it
307
- // uncontrolled so React doesn't warn about a missing onChange.
308
- if (tag.toLowerCase() === "input" && lower === "checked") {
309
- props.defaultChecked = value === true ? true : value;
310
- continue;
311
- }
312
- // Restrict forwarded ORIGINAL names to a plain HTML attribute identifier
313
- // (plus the ATTR_MAP renames and xlink:href handled above) so weird casings
314
- // / `__proto__` / `constructor` can never become a React prop.
315
- if (!(lower in ATTR_MAP) && !SAFE_ATTR_NAME.test(name)) continue;
316
- props[ATTR_MAP[lower] ?? name] = value;
317
- }
318
- return props;
319
- }
320
-
321
- function nodesToReact(nodes: HNode[], components: Components, keyPrefix: string): ReactNode {
322
- const out: ReactNode[] = [];
323
- for (let idx = 0; idx < nodes.length; idx++) {
324
- const n = nodes[idx];
325
- if (n.kind === "text") {
326
- out.push(n.text);
327
- continue;
328
- }
329
- const key = keyPrefix + idx;
330
- const type = components[n.tag] ?? n.tag;
331
- const props = attrsToProps(n.tag, n.attrs, key);
332
- if (VOID.has(n.tag.toLowerCase())) {
333
- out.push(createElement(type, props));
334
- } else {
335
- out.push(createElement(type, props, nodesToReact(n.children, components, key + ".")));
336
- }
337
- }
338
- // `null` (not an empty array) for no children, so a self-closing / empty inline
339
- // component's `children` is nullish and a `{children ?? fallback}` override fires.
340
- return out.length === 0 ? null : out.length === 1 ? out[0] : out;
341
- }
342
-
343
- /**
344
- * Split trusted HTML into its TOP-LEVEL node segments (each segment is the
345
- * exact substring spanning one root node — an element with its full subtree, or
346
- * a run of text between elements). Used only by the opt-in child-memo path so an
347
- * OPEN block whose leading children are unchanged can reuse their already-built
348
- * React nodes instead of re-parsing the whole string every tick. Mirrors
349
- * `parseTrustedHtml`'s tokenizer for boundary detection, but records spans only.
350
- */
351
- function topLevelSegments(html: string): string[] {
352
- const segs: string[] = [];
353
- let depth = 0; // open-element nesting depth
354
- let segStart = 0; // start of the current top-level segment
355
- let i = 0;
356
- while (i < html.length) {
357
- const lt = html.indexOf("<", i);
358
- if (lt === -1) break;
359
- if (html.startsWith("<!--", lt)) {
360
- const end = html.indexOf("-->", lt + 4);
361
- i = end === -1 ? html.length : end + 3;
362
- continue;
363
- }
364
- if (html[lt + 1] === "!") {
365
- const end = html.indexOf(">", lt);
366
- i = end === -1 ? html.length : end + 1;
367
- continue;
368
- }
369
- if (html[lt + 1] === "/") {
370
- const end = html.indexOf(">", lt);
371
- if (depth > 0) {
372
- depth--;
373
- if (depth === 0) {
374
- // Closing the outermost element ends one top-level segment.
375
- const stop = end === -1 ? html.length : end + 1;
376
- segs.push(html.slice(segStart, stop));
377
- segStart = stop;
378
- }
379
- }
380
- i = end === -1 ? html.length : end + 1;
381
- continue;
382
- }
383
- const c1 = html[lt + 1];
384
- const isName = (c1 >= "a" && c1 <= "z") || (c1 >= "A" && c1 <= "Z");
385
- if (!isName) {
386
- // A stray '<' is literal text — not a boundary.
387
- i = lt + 1;
388
- continue;
389
- }
390
- const { tag, selfClose, next } = parseOpenTag(html, lt);
391
- if (depth === 0) {
392
- // Any text before this element is its own top-level segment.
393
- if (lt > segStart) {
394
- segs.push(html.slice(segStart, lt));
395
- segStart = lt;
396
- }
397
- }
398
- const isVoid = selfClose || VOID.has(tag.toLowerCase());
399
- if (isVoid) {
400
- if (depth === 0) {
401
- segs.push(html.slice(segStart, next));
402
- segStart = next;
403
- }
404
- } else {
405
- depth++;
406
- }
407
- i = next;
408
- }
409
- // Trailing text (or an unterminated subtree) after the last boundary.
410
- if (segStart < html.length) segs.push(html.slice(segStart));
411
- return segs;
412
- }
413
-
414
- /**
415
- * Convert a block's trusted HTML string into a React node tree, replacing any
416
- * element whose tag name appears in `components`.
417
- *
418
- * With no `childMemoMap`, behavior is byte-identical to a single
419
- * `parseTrustedHtml` + convert pass — the closed-block call site memoizes on
420
- * `(html, components)`.
421
- *
422
- * Passing a `childMemoMap` opts into OPEN-block child reuse: the html is split
423
- * into top-level node segments and each is keyed by its exact substring. On a
424
- * hit the cached React node is reused (no re-parse, no re-serialize); only new /
425
- * changed trailing segments are parsed. The caller owns the map's lifetime and
426
- * must scope it per block.id and invalidate it when `components` changes (a hit
427
- * carries the React node built under the previous components map). Segment keys
428
- * carry their original document order via `keyOffset` so React keys stay stable.
429
- */
430
- export function htmlToReact(
431
- html: string,
432
- components: Components,
433
- childMemoMap?: Map<string, ReactNode>,
434
- ): ReactNode {
435
- if (!childMemoMap) return nodesToReact(parseTrustedHtml(html), components, "");
436
- const segs = topLevelSegments(html);
437
- const out: ReactNode[] = [];
438
- for (let idx = 0; idx < segs.length; idx++) {
439
- const seg = segs[idx];
440
- // Key the cache by index + segment text so identical segments at different
441
- // positions get distinct React keys (no collision) and a leading segment
442
- // stays a hit only while it keeps the same document index (append-only
443
- // growth — the common open-block case — preserves both).
444
- const cacheKey = idx + "" + seg;
445
- const hit = childMemoMap.get(cacheKey);
446
- if (hit !== undefined) {
447
- out.push(hit);
448
- continue;
449
- }
450
- const node = nodesToReact(parseTrustedHtml(seg), components, idx + ".");
451
- childMemoMap.set(cacheKey, node);
452
- out.push(node);
453
- }
454
- return out.length === 0 ? null : out.length === 1 ? out[0] : out;
455
- }