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.
- package/CHANGELOG.md +950 -0
- package/README.md +111 -82
- package/dist/block-props.d.ts +18 -0
- package/dist/block-props.js +75 -0
- package/dist/client.d.ts +311 -0
- package/{src/client.ts → dist/client.js} +143 -325
- package/dist/dom.d.ts +110 -0
- package/dist/dom.js +576 -0
- package/dist/element.d.ts +20 -0
- package/dist/element.js +287 -0
- package/dist/hi.d.ts +12 -0
- package/{src/hi.ts → dist/hi.js} +42 -74
- package/dist/html-to-react.d.ts +40 -0
- package/dist/html-to-react.js +344 -0
- package/{src/index.ts → dist/index.d.ts} +5 -25
- package/dist/index.js +16 -0
- package/dist/morph.d.ts +28 -0
- package/dist/morph.js +166 -0
- package/dist/react.d.ts +204 -0
- package/dist/react.js +490 -0
- package/dist/renderers/CodeBlock.d.ts +7 -0
- package/dist/renderers/CodeBlock.js +75 -0
- package/dist/renderers/Math.d.ts +14 -0
- package/dist/renderers/Math.js +15 -0
- package/dist/renderers/Mermaid.d.ts +13 -0
- package/dist/renderers/Mermaid.js +15 -0
- package/dist/server-react.d.ts +32 -0
- package/dist/server-react.js +48 -0
- package/dist/server.d.ts +31 -0
- package/dist/server.js +81 -0
- package/{src/solid.tsx → dist/solid.d.ts} +19 -95
- package/dist/solid.js +54 -0
- package/dist/svelte.d.ts +80 -0
- package/dist/svelte.js +59 -0
- package/dist/types-core.d.ts +382 -0
- package/dist/types-core.js +0 -0
- package/{src/types-react.ts → dist/types-react.d.ts} +0 -1
- package/dist/types-react.js +0 -0
- package/dist/types.d.ts +2 -0
- package/dist/types.js +2 -0
- package/dist/vue.d.ts +94 -0
- package/dist/vue.js +79 -0
- package/{src → dist}/wasm/flux_md_core.d.ts +18 -6
- package/{src → dist}/wasm/flux_md_core.js +50 -55
- package/dist/wasm/flux_md_core_bg.wasm +0 -0
- package/{src → dist}/wasm/flux_md_core_bg.wasm.d.ts +1 -0
- package/dist/worker-core.d.ts +65 -0
- package/dist/worker-core.js +154 -0
- package/dist/worker.d.ts +1 -0
- package/dist/worker.js +48 -0
- package/package.json +25 -19
- package/src/block-props.ts +0 -141
- package/src/dom.ts +0 -946
- package/src/element.ts +0 -400
- package/src/html-to-react.ts +0 -455
- package/src/morph.ts +0 -253
- package/src/react.tsx +0 -1020
- package/src/renderers/CodeBlock.tsx +0 -116
- package/src/renderers/Math.tsx +0 -28
- package/src/renderers/Mermaid.tsx +0 -27
- package/src/server.tsx +0 -221
- package/src/svelte.ts +0 -179
- package/src/types-core.ts +0 -398
- package/src/types.ts +0 -7
- package/src/vue.ts +0 -184
- package/src/wasm/flux_md_core_bg.wasm +0 -0
- package/src/worker-core.ts +0 -174
- package/src/worker.ts +0 -72
- /package/{src → dist}/styles.css +0 -0
package/src/html-to-react.ts
DELETED
|
@@ -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:` and double-encoded `javascript&#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
|
-
// <, 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
|
-
}
|