flux-md 0.15.0 → 0.16.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/hi.ts CHANGED
@@ -55,7 +55,7 @@ const jsPats: Pat[] = [
55
55
  ["str", /"(?:\\.|[^"\\\n])*"/y],
56
56
  ["str", /'(?:\\.|[^'\\\n])*'/y],
57
57
  ["str", /`(?:\\.|[^`\\])*`/y],
58
- ["rx", /\/(?:\\.|\[(?:\\.|[^\]\\])*\]|[^/\\\n])+\/[gimsuy]*/y],
58
+ ["rx", /\/(?![*/])(?:\\.|[^/\\\n])+\/[gimsuy]*/y],
59
59
  ["num", /\b(?:0x[\da-fA-F_]+|0b[01_]+|0o[0-7_]+|\d[\d_]*(?:\.\d[\d_]*)?(?:[eE][+-]?\d+)?)\b/y],
60
60
  ["ident", /[A-Za-z_$][\w$]*/y],
61
61
  ["pun", /[+\-*/=<>!&|^~?:;,.[\](){}]/y],
@@ -103,7 +103,7 @@ const goPats: Pat[] = [
103
103
 
104
104
  const bashPats: Pat[] = [
105
105
  ["com", /#[^\n]*/y],
106
- ["str", /"(?:\\.|\$\([^)]*\)|[^"\\])*"/y],
106
+ ["str", /"(?:\\.|[^"\\])*"/y],
107
107
  ["str", /'[^']*'/y],
108
108
  ["var", /\$\{[^}]+\}|\$\w+|\$[*@#?!$0-9]/y],
109
109
  ["num", /\b\d+\b/y],
@@ -187,6 +187,9 @@ function escapeHtml(s: string): string {
187
187
  }
188
188
 
189
189
  export function highlight(code: string, lang: string): string {
190
+ // Defense-in-depth: never tokenize a pathologically huge block on the main
191
+ // thread — fall back to plain escaped text.
192
+ if (code.length > 50_000) return escapeHtml(code);
190
193
  const conf = LANGS[lang.toLowerCase()];
191
194
  if (!conf) return escapeHtml(code);
192
195
 
@@ -10,7 +10,10 @@ const VOID = new Set([
10
10
  // Attribute name → React prop name, for the handful that differ. Anything not
11
11
  // listed passes through verbatim (React forwards data-*/aria-* and lowercase
12
12
  // attributes unchanged).
13
- const ATTR_MAP: Record<string, string> = {
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), {
14
17
  class: "className",
15
18
  for: "htmlFor",
16
19
  colspan: "colSpan",
@@ -26,7 +29,7 @@ const ATTR_MAP: Record<string, string> = {
26
29
  crossorigin: "crossOrigin",
27
30
  enterkeyhint: "enterKeyHint",
28
31
  inputmode: "inputMode",
29
- };
32
+ });
30
33
 
31
34
  // URL-bearing attributes whose value must be scheme-checked. `htmlToReact` is
32
35
  // exported and may be handed untrusted HTML directly; React happily renders a
@@ -34,6 +37,19 @@ const ATTR_MAP: Record<string, string> = {
34
37
  // defense-in-depth — the core's own output is already sanitized.
35
38
  const URL_ATTRS = new Set(["href", "src", "xlink:href", "formaction", "action", "poster", "data"]);
36
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
+
37
53
  /** Replace a dangerous-scheme URL with "#". Mirrors the Rust `is_dangerous_scheme`:
38
54
  * strip control chars (C0, DEL, C1 — matching Rust char::is_control),
39
55
  * lowercase, then match. The strip affects only the probe, never output. */
@@ -42,8 +58,10 @@ function safeUrl(value: string): string {
42
58
  // reaches the DOM, so peel layers to a fixpoint before the scheme check —
43
59
  // catches `javascript&#58;` and double-encoded `javascript&amp;#58;`. Only the
44
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.
45
63
  let decoded = value;
46
- for (let prev = ""; decoded !== prev; ) {
64
+ for (let i = 0, prev = ""; i < 8 && decoded !== prev; i++) {
47
65
  prev = decoded;
48
66
  decoded = decodeEntities(decoded);
49
67
  }
@@ -265,6 +283,9 @@ function attrsToProps(tag: string, attrs: Record<string, string | true>, key: st
265
283
  // React drops most lowercase `on*` attrs — this also covers casings and
266
284
  // future React behavior.
267
285
  if (lower.startsWith("on")) continue;
286
+ // Reject React-meaningful names that would crash the render tree or inject
287
+ // internals (dangerouslySetInnerHTML, ref, key, defaultValue, …).
288
+ if (PROP_DENY.has(lower)) continue;
268
289
  if (lower === "style" && typeof value === "string") {
269
290
  props.style = safeStyle(parseStyle(value));
270
291
  continue;
@@ -280,6 +301,10 @@ function attrsToProps(tag: string, attrs: Record<string, string | true>, key: st
280
301
  props.defaultChecked = value === true ? true : value;
281
302
  continue;
282
303
  }
304
+ // Restrict forwarded ORIGINAL names to a plain HTML attribute identifier
305
+ // (plus the ATTR_MAP renames and xlink:href handled above) so weird casings
306
+ // / `__proto__` / `constructor` can never become a React prop.
307
+ if (!(lower in ATTR_MAP) && !SAFE_ATTR_NAME.test(name)) continue;
283
308
  props[ATTR_MAP[lower] ?? name] = value;
284
309
  }
285
310
  return props;
@@ -307,12 +332,116 @@ function nodesToReact(nodes: HNode[], components: Components, keyPrefix: string)
307
332
  return out.length === 0 ? null : out.length === 1 ? out[0] : out;
308
333
  }
309
334
 
335
+ /**
336
+ * Split trusted HTML into its TOP-LEVEL node segments (each segment is the
337
+ * exact substring spanning one root node — an element with its full subtree, or
338
+ * a run of text between elements). Used only by the opt-in child-memo path so an
339
+ * OPEN block whose leading children are unchanged can reuse their already-built
340
+ * React nodes instead of re-parsing the whole string every tick. Mirrors
341
+ * `parseTrustedHtml`'s tokenizer for boundary detection, but records spans only.
342
+ */
343
+ function topLevelSegments(html: string): string[] {
344
+ const segs: string[] = [];
345
+ let depth = 0; // open-element nesting depth
346
+ let segStart = 0; // start of the current top-level segment
347
+ let i = 0;
348
+ while (i < html.length) {
349
+ const lt = html.indexOf("<", i);
350
+ if (lt === -1) break;
351
+ if (html.startsWith("<!--", lt)) {
352
+ const end = html.indexOf("-->", lt + 4);
353
+ i = end === -1 ? html.length : end + 3;
354
+ continue;
355
+ }
356
+ if (html[lt + 1] === "!") {
357
+ const end = html.indexOf(">", lt);
358
+ i = end === -1 ? html.length : end + 1;
359
+ continue;
360
+ }
361
+ if (html[lt + 1] === "/") {
362
+ const end = html.indexOf(">", lt);
363
+ if (depth > 0) {
364
+ depth--;
365
+ if (depth === 0) {
366
+ // Closing the outermost element ends one top-level segment.
367
+ const stop = end === -1 ? html.length : end + 1;
368
+ segs.push(html.slice(segStart, stop));
369
+ segStart = stop;
370
+ }
371
+ }
372
+ i = end === -1 ? html.length : end + 1;
373
+ continue;
374
+ }
375
+ const c1 = html[lt + 1];
376
+ const isName = (c1 >= "a" && c1 <= "z") || (c1 >= "A" && c1 <= "Z");
377
+ if (!isName) {
378
+ // A stray '<' is literal text — not a boundary.
379
+ i = lt + 1;
380
+ continue;
381
+ }
382
+ const { tag, selfClose, next } = parseOpenTag(html, lt);
383
+ if (depth === 0) {
384
+ // Any text before this element is its own top-level segment.
385
+ if (lt > segStart) {
386
+ segs.push(html.slice(segStart, lt));
387
+ segStart = lt;
388
+ }
389
+ }
390
+ const isVoid = selfClose || VOID.has(tag.toLowerCase());
391
+ if (isVoid) {
392
+ if (depth === 0) {
393
+ segs.push(html.slice(segStart, next));
394
+ segStart = next;
395
+ }
396
+ } else {
397
+ depth++;
398
+ }
399
+ i = next;
400
+ }
401
+ // Trailing text (or an unterminated subtree) after the last boundary.
402
+ if (segStart < html.length) segs.push(html.slice(segStart));
403
+ return segs;
404
+ }
405
+
310
406
  /**
311
407
  * Convert a block's trusted HTML string into a React node tree, replacing any
312
- * element whose tag name appears in `components`. Call this only for **closed**
313
- * blocks (open/streaming blocks have partial HTML); memoize on `(html,
314
- * components)` at the call site.
408
+ * element whose tag name appears in `components`.
409
+ *
410
+ * With no `childMemoMap`, behavior is byte-identical to a single
411
+ * `parseTrustedHtml` + convert pass — the closed-block call site memoizes on
412
+ * `(html, components)`.
413
+ *
414
+ * Passing a `childMemoMap` opts into OPEN-block child reuse: the html is split
415
+ * into top-level node segments and each is keyed by its exact substring. On a
416
+ * hit the cached React node is reused (no re-parse, no re-serialize); only new /
417
+ * changed trailing segments are parsed. The caller owns the map's lifetime and
418
+ * must scope it per block.id and invalidate it when `components` changes (a hit
419
+ * carries the React node built under the previous components map). Segment keys
420
+ * carry their original document order via `keyOffset` so React keys stay stable.
315
421
  */
316
- export function htmlToReact(html: string, components: Components): ReactNode {
317
- return nodesToReact(parseTrustedHtml(html), components, "");
422
+ export function htmlToReact(
423
+ html: string,
424
+ components: Components,
425
+ childMemoMap?: Map<string, ReactNode>,
426
+ ): ReactNode {
427
+ if (!childMemoMap) return nodesToReact(parseTrustedHtml(html), components, "");
428
+ const segs = topLevelSegments(html);
429
+ const out: ReactNode[] = [];
430
+ for (let idx = 0; idx < segs.length; idx++) {
431
+ const seg = segs[idx];
432
+ // Key the cache by index + segment text so identical segments at different
433
+ // positions get distinct React keys (no collision) and a leading segment
434
+ // stays a hit only while it keeps the same document index (append-only
435
+ // growth — the common open-block case — preserves both).
436
+ const cacheKey = idx + "" + seg;
437
+ const hit = childMemoMap.get(cacheKey);
438
+ if (hit !== undefined) {
439
+ out.push(hit);
440
+ continue;
441
+ }
442
+ const node = nodesToReact(parseTrustedHtml(seg), components, idx + ".");
443
+ childMemoMap.set(cacheKey, node);
444
+ out.push(node);
445
+ }
446
+ return out.length === 0 ? null : out.length === 1 ? out[0] : out;
318
447
  }
package/src/index.ts CHANGED
@@ -37,4 +37,6 @@ export type {
37
37
  CodeBlockData,
38
38
  MathBlockData,
39
39
  ListData,
40
+ NestedBlock,
41
+ ContainerData,
40
42
  } from "./types";
package/src/morph.ts ADDED
@@ -0,0 +1,253 @@
1
+ /**
2
+ * Tiny, self-contained idiomorph-style DOM morph (zero npm dependency, to keep
3
+ * the security/supply-chain posture). It mutates a live element's children to
4
+ * match a desired tree **in place** instead of replacing them wholesale, so the
5
+ * browser only repaints/relayouts the parts that actually changed and focus +
6
+ * text-selection survive a streaming update.
7
+ *
8
+ * Scope: this is intentionally minimal — it handles the cases the streaming
9
+ * open-block tail produces (text growth, appended trailing nodes, attribute
10
+ * tweaks). It is OPT-IN (see `morphOpenBlocks` in dom.ts) and never runs on the
11
+ * default path.
12
+ *
13
+ * Matching strategy (per child level):
14
+ * - Build an id-set over the *new* subtree so a node carrying an `id` that the
15
+ * incoming tree also has can be matched across reorders/insertions.
16
+ * - Walk old/new children with two cursors. When the heads are "soft-equal"
17
+ * (same nodeType, same tagName for elements) we morph them in place and
18
+ * recurse; otherwise we id-match, insert, or remove the minimum.
19
+ * - Mutate matched element attributes/text in place; append trailing new nodes;
20
+ * remove old nodes with no match.
21
+ */
22
+
23
+ /** Parse an HTML string into a detached `<template>`'s content (a DocumentFragment). */
24
+ export function htmlToFragment(html: string): DocumentFragment {
25
+ const tpl = document.createElement("template");
26
+ tpl.innerHTML = html;
27
+ return tpl.content;
28
+ }
29
+
30
+ interface SavedFocus {
31
+ node: Element;
32
+ start: number | null;
33
+ end: number | null;
34
+ }
35
+
36
+ // Snapshot the active element + (for text inputs/areas) its selection range, so
37
+ // a morph that touches the focused node — or its ancestors — does not blur it.
38
+ function saveFocus(root: Node): SavedFocus | null {
39
+ if (typeof document === "undefined") return null;
40
+ const active = document.activeElement;
41
+ if (!active || active === document.body) return null;
42
+ if (!root.contains(active)) return null;
43
+ let start: number | null = null;
44
+ let end: number | null = null;
45
+ const el = active as HTMLInputElement & HTMLTextAreaElement;
46
+ try {
47
+ if (typeof el.selectionStart === "number") {
48
+ start = el.selectionStart;
49
+ end = el.selectionEnd;
50
+ }
51
+ } catch {
52
+ // Some input types throw on selectionStart access; ignore.
53
+ }
54
+ return { node: active, start, end };
55
+ }
56
+
57
+ function restoreFocus(saved: SavedFocus | null): void {
58
+ if (!saved) return;
59
+ const node = saved.node as HTMLElement & HTMLInputElement & HTMLTextAreaElement;
60
+ // Still attached? (a morph could have removed it — then there's nothing to do)
61
+ if (typeof document !== "undefined" && !document.contains(node)) return;
62
+ if (document.activeElement === node) {
63
+ // Selection may still need restoring even if focus was kept.
64
+ } else if (typeof node.focus === "function") {
65
+ try {
66
+ node.focus({ preventScroll: true });
67
+ } catch {
68
+ try {
69
+ node.focus();
70
+ } catch {
71
+ /* not focusable anymore */
72
+ }
73
+ }
74
+ }
75
+ if (saved.start !== null && typeof node.setSelectionRange === "function") {
76
+ try {
77
+ node.setSelectionRange(saved.start, saved.end ?? saved.start);
78
+ } catch {
79
+ // setSelectionRange throws for input types that don't support it.
80
+ }
81
+ }
82
+ }
83
+
84
+ /**
85
+ * Morph `from`'s children to match `to` (a fragment, element, or HTML string).
86
+ * `from` itself is never replaced — only its subtree is reconciled in place.
87
+ */
88
+ export function morph(from: Element, to: string | DocumentFragment | Element): void {
89
+ const fragment: DocumentFragment | Element =
90
+ typeof to === "string" ? htmlToFragment(to) : to;
91
+ const saved = saveFocus(from);
92
+ morphChildren(from, fragment);
93
+ restoreFocus(saved);
94
+ }
95
+
96
+ // Two element nodes can be morphed into one another (vs. replaced) when they are
97
+ // the same element type. Text/comment nodes match on nodeType alone.
98
+ function isSoftMatch(a: Node, b: Node): boolean {
99
+ if (a.nodeType !== b.nodeType) return false;
100
+ if (a.nodeType === 1) {
101
+ return (a as Element).tagName === (b as Element).tagName;
102
+ }
103
+ return true;
104
+ }
105
+
106
+ function nodeId(n: Node): string | null {
107
+ if (n.nodeType !== 1) return null;
108
+ const id = (n as Element).getAttribute("id");
109
+ return id && id.length > 0 ? id : null;
110
+ }
111
+
112
+ // Collect the set of element ids present anywhere in `parent`'s direct children
113
+ // (one level — recursion handles deeper levels). Used so a keyed node can be
114
+ // matched across insertions before we resort to soft-matching.
115
+ function childIdSet(parent: Node): Set<string> {
116
+ const ids = new Set<string>();
117
+ for (let c = parent.firstChild; c; c = c.nextSibling) {
118
+ const id = nodeId(c);
119
+ if (id) ids.add(id);
120
+ }
121
+ return ids;
122
+ }
123
+
124
+ function morphChildren(oldParent: Node, newParent: Node): void {
125
+ const newIds = childIdSet(newParent);
126
+
127
+ let oldChild = oldParent.firstChild;
128
+ let newChild = newParent.firstChild;
129
+
130
+ while (newChild) {
131
+ const nextNew = newChild.nextSibling;
132
+
133
+ // No more old nodes → append the rest of the new ones (the common streaming
134
+ // case: trailing tokens/elements grew onto the tail). We must import nodes
135
+ // out of the source fragment into the live document.
136
+ if (!oldChild) {
137
+ oldParent.appendChild(importNode(oldParent, newChild));
138
+ newChild = nextNew;
139
+ continue;
140
+ }
141
+
142
+ const nextOld = oldChild.nextSibling;
143
+
144
+ // Exact id match at the head → morph in place.
145
+ const newKey = nodeId(newChild);
146
+ const oldKey = nodeId(oldChild);
147
+
148
+ if (isSoftMatch(oldChild, newChild) && newKey === oldKey) {
149
+ morphNode(oldChild, newChild);
150
+ oldChild = nextOld;
151
+ newChild = nextNew;
152
+ continue;
153
+ }
154
+
155
+ // The old head carries an id the new tree no longer has → it's gone; remove
156
+ // it and retry this new child against the next old node.
157
+ if (oldKey && !newIds.has(oldKey)) {
158
+ const toRemove = oldChild;
159
+ oldChild = nextOld;
160
+ oldParent.removeChild(toRemove);
161
+ continue;
162
+ }
163
+
164
+ // The new head has an id that exists further down the old list → pull that
165
+ // old node up to the head, morph, and continue.
166
+ if (newKey) {
167
+ const match = findChildById(oldParent, newKey);
168
+ if (match) {
169
+ if (match !== oldChild) oldParent.insertBefore(match, oldChild);
170
+ morphNode(match, newChild);
171
+ oldChild = match.nextSibling;
172
+ newChild = nextNew;
173
+ continue;
174
+ }
175
+ // New keyed node not present in the old tree → insert a fresh import.
176
+ oldParent.insertBefore(importNode(oldParent, newChild), oldChild);
177
+ newChild = nextNew;
178
+ continue;
179
+ }
180
+
181
+ // Soft (unkeyed) match → morph in place.
182
+ if (isSoftMatch(oldChild, newChild)) {
183
+ morphNode(oldChild, newChild);
184
+ oldChild = nextOld;
185
+ newChild = nextNew;
186
+ continue;
187
+ }
188
+
189
+ // No match at all: replace the old head with an import of the new head.
190
+ oldParent.insertBefore(importNode(oldParent, newChild), oldChild);
191
+ oldParent.removeChild(oldChild);
192
+ oldChild = nextOld;
193
+ newChild = nextNew;
194
+ }
195
+
196
+ // Any leftover old children have no counterpart → remove them.
197
+ while (oldChild) {
198
+ const next = oldChild.nextSibling;
199
+ oldParent.removeChild(oldChild);
200
+ oldChild = next;
201
+ }
202
+ }
203
+
204
+ // Find a direct child of `parent` whose element id equals `id`.
205
+ function findChildById(parent: Node, id: string): Node | null {
206
+ for (let c = parent.firstChild; c; c = c.nextSibling) {
207
+ if (nodeId(c) === id) return c;
208
+ }
209
+ return null;
210
+ }
211
+
212
+ // Bring a node from a source fragment into the live document so it can be
213
+ // inserted. `importNode(deep)` clones; the source fragment is discarded after.
214
+ function importNode(target: Node, source: Node): Node {
215
+ const doc = target.ownerDocument ?? document;
216
+ return doc.importNode(source, true);
217
+ }
218
+
219
+ // Morph a single matched node `oldNode` toward `newNode`. Elements get their
220
+ // attributes synced and children recursed; text/comment nodes get their data
221
+ // updated in place (so the live text node identity — and any caret in it —
222
+ // survives a token append).
223
+ function morphNode(oldNode: Node, newNode: Node): void {
224
+ if (oldNode.nodeType === 1) {
225
+ morphAttributes(oldNode as Element, newNode as Element);
226
+ morphChildren(oldNode, newNode);
227
+ return;
228
+ }
229
+ // Text (3) / comment (8) / CDATA: update character data in place.
230
+ if (oldNode.nodeValue !== newNode.nodeValue) {
231
+ oldNode.nodeValue = newNode.nodeValue;
232
+ }
233
+ }
234
+
235
+ function morphAttributes(oldEl: Element, newEl: Element): void {
236
+ // Set/update attributes present on the new element.
237
+ const newAttrs = newEl.attributes;
238
+ for (let i = 0; i < newAttrs.length; i++) {
239
+ const attr = newAttrs[i];
240
+ if (oldEl.getAttribute(attr.name) !== attr.value) {
241
+ oldEl.setAttribute(attr.name, attr.value);
242
+ }
243
+ }
244
+ // Remove attributes the new element no longer has. Iterate over a snapshot of
245
+ // names because removeAttribute mutates the live NamedNodeMap.
246
+ const oldAttrs = oldEl.attributes;
247
+ const stale: string[] = [];
248
+ for (let i = 0; i < oldAttrs.length; i++) {
249
+ const name = oldAttrs[i].name;
250
+ if (!newEl.hasAttribute(name)) stale.push(name);
251
+ }
252
+ for (const name of stale) oldEl.removeAttribute(name);
253
+ }