satteri 0.8.1 → 0.9.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 (60) hide show
  1. package/README.md +35 -0
  2. package/dist/byte-writer.d.ts +31 -0
  3. package/dist/byte-writer.js +106 -0
  4. package/dist/child-stub.d.ts +17 -0
  5. package/dist/child-stub.js +57 -0
  6. package/dist/command-buffer.d.ts +23 -26
  7. package/dist/command-buffer.js +64 -132
  8. package/dist/compile.d.ts +5 -1
  9. package/dist/compile.js +40 -26
  10. package/dist/directive-types.d.ts +4 -2
  11. package/dist/generated/arena-layout.d.ts +37 -0
  12. package/dist/generated/arena-layout.js +41 -0
  13. package/dist/generated/wire-constants.d.ts +51 -0
  14. package/dist/generated/wire-constants.js +63 -0
  15. package/dist/handles.d.ts +21 -0
  16. package/dist/handles.js +1 -0
  17. package/dist/hast/child-stub.d.ts +18 -0
  18. package/dist/hast/child-stub.js +43 -0
  19. package/dist/hast/element-props.d.ts +2 -0
  20. package/dist/hast/element-props.js +23 -0
  21. package/dist/hast/generated/node-types.d.ts +14 -0
  22. package/dist/hast/generated/node-types.js +64 -0
  23. package/dist/hast/hast-materializer.d.ts +2 -1
  24. package/dist/hast/hast-materializer.js +9 -45
  25. package/dist/hast/hast-reader.d.ts +18 -23
  26. package/dist/hast/hast-reader.js +48 -115
  27. package/dist/hast/hast-visitor.d.ts +35 -13
  28. package/dist/hast/hast-visitor.js +395 -302
  29. package/dist/index.d.ts +10 -4
  30. package/dist/index.js +23 -1
  31. package/dist/lazy-child-resolver.d.ts +36 -0
  32. package/dist/lazy-child-resolver.js +95 -0
  33. package/dist/lazy-props.d.ts +1 -1
  34. package/dist/mdast/child-stub.d.ts +18 -0
  35. package/dist/mdast/child-stub.js +46 -0
  36. package/dist/mdast/column-align.d.ts +1 -0
  37. package/dist/mdast/column-align.js +6 -0
  38. package/dist/mdast/generated/layout.d.ts +19 -0
  39. package/dist/mdast/generated/layout.js +272 -0
  40. package/dist/mdast/generated/node-types.d.ts +58 -0
  41. package/dist/mdast/generated/node-types.js +266 -0
  42. package/dist/mdast/mdast-materializer.d.ts +3 -2
  43. package/dist/mdast/mdast-materializer.js +9 -93
  44. package/dist/mdast/mdast-reader.d.ts +4 -120
  45. package/dist/mdast/mdast-reader.js +45 -272
  46. package/dist/mdast/mdast-visitor.d.ts +52 -14
  47. package/dist/mdast/mdast-visitor.js +348 -427
  48. package/dist/mdx-attr.d.ts +2 -0
  49. package/dist/mdx-attr.js +20 -0
  50. package/dist/op-stream.d.ts +38 -0
  51. package/dist/op-stream.js +125 -0
  52. package/dist/phantom.d.ts +2 -0
  53. package/dist/phantom.js +13 -0
  54. package/dist/types.d.ts +34 -4
  55. package/dist/visitor-shared.d.ts +28 -0
  56. package/dist/visitor-shared.js +52 -0
  57. package/dist/wire-read.d.ts +27 -0
  58. package/dist/wire-read.js +59 -0
  59. package/index.js +52 -52
  60. package/package.json +6 -6
@@ -1,7 +1,16 @@
1
1
  import { materializeHastNode } from "./hast-materializer.js";
2
2
  import { HastReader, HAST_ELEMENT, HAST_TEXT, HAST_COMMENT, HAST_RAW, HAST_MDX_JSX_ELEMENT, HAST_MDX_JSX_TEXT_ELEMENT, HAST_MDX_FLOW_EXPRESSION, HAST_MDX_TEXT_EXPRESSION, HAST_MDX_ESM, } from "./hast-reader.js";
3
+ import { TYPE_NAMES, NAME_TO_TYPE, VISITOR_KEYS, HAST_OPSTREAM_TYPES, } from "./generated/node-types.js";
3
4
  import { CommandBuffer } from "../command-buffer.js";
4
- import { walkHandle, applyCommandsToHandle, serializeHandle, textContentHandle, getNodeData as napiGetNodeData, parseExpression as napiParseExpression, parseEsm as napiParseEsm, } from "#binding";
5
+ import { OpWriter, OF_VALUE, OF_TAGNAME, OF_NAME, OF_EXPLICIT, PROP_STRING, PROP_BOOL_TRUE, PROP_BOOL_FALSE, PROP_SPACE_SEP, PROP_INT, emitMdxAttr, } from "../op-stream.js";
6
+ import { restorePhantomSpaces } from "../phantom.js";
7
+ import { decodeMdxJsxAttr } from "../mdx-attr.js";
8
+ import { decodeElementProp } from "./element-props.js";
9
+ import { readPosition, rstr } from "../wire-read.js";
10
+ import { walkHandle, applyCommandsToHandle, textContentHandle, parseExpression as napiParseExpression, parseEsm as napiParseEsm, } from "#binding";
11
+ import { asArray, makeRequireNid, mergeAndReset, unencodableContentError, } from "../visitor-shared.js";
12
+ import { LazyChildResolver, markHandleMutated } from "../lazy-child-resolver.js";
13
+ import { HastChildStub } from "./child-stub.js";
5
14
  /** Maps HastNode objects to their arena node IDs without Object.defineProperty overhead. */
6
15
  const nodeIdMap = new WeakMap();
7
16
  /** Attach `parseExpression()` to an MDX expression or ESM node. */
@@ -22,44 +31,161 @@ function attachParseExpression(node, parseFn) {
22
31
  });
23
32
  }
24
33
  /**
25
- * Serialize a HastNode for the command buffer. Marks each node `_hast: true`,
26
- * and — except at the root, which is the new replacement content — emits a
27
- * reused (materialized) node as a `{ _ref: id }` placeholder so the rebuild
28
- * splices the original in place (preserving its id, applying any pending patch
29
- * on it) instead of rebuilding it fresh.
34
+ * Arena identity of a node, rejecting impostors — the one place the
35
+ * spread/identity invariant is enforced. A spread copy of a matched node or
36
+ * stub must read as NEW content: trusting a copied id would splice the
37
+ * original in as a ref and drop the copy's edits. Walk elements carry their
38
+ * id in a private field behind `instanceof` (spread copies fail the check);
39
+ * other walk-built nodes are keyed in the WeakMap (invisible to spread);
40
+ * `HastChildStub`s (enumerable `_id`, but that key is ignored on plain
41
+ * objects) are recognized by `instanceof`. Plain objects are trusted only via
42
+ * the WeakMap or a NON-enumerable `_nodeId` (the materializers' convention,
43
+ * which spread cannot copy).
30
44
  */
31
- function markHast(node) {
32
- return markHastNode(node, true);
45
+ function nid(node) {
46
+ if (node instanceof WalkElement)
47
+ return node._nid;
48
+ if (node instanceof HastChildStub)
49
+ return node._id;
50
+ const id = nodeIdMap.get(node);
51
+ if (id !== undefined)
52
+ return id;
53
+ const d = Object.getOwnPropertyDescriptor(node, "_nodeId");
54
+ return d !== undefined && !d.enumerable ? d.value : undefined;
33
55
  }
34
- function markHastNode(node, isRoot) {
35
- if (!isRoot) {
36
- const id = nodeIdMap.get(node) ?? node._nodeId;
37
- if (typeof id === "number")
38
- return { _ref: id };
39
- }
40
- const obj = { _hast: true, type: node.type };
41
- if ("tagName" in node)
42
- obj.tagName = node.tagName;
43
- if ("properties" in node)
44
- obj.properties = node.properties;
45
- if ("value" in node)
46
- obj.value = node.value;
47
- if ("name" in node)
48
- obj.name = node.name;
49
- if ("attributes" in node)
50
- obj.attributes = node.attributes;
51
- if ("data" in node && node.data != null)
52
- obj.data = node.data;
53
- if ("children" in node) {
54
- obj.children = node.children.map((c) => markHastNode(c, false));
55
- }
56
- return obj;
56
+ const requireNid = makeRequireNid(nid);
57
+ function hastReusedId(node) {
58
+ if (node === null || typeof node !== "object")
59
+ return undefined;
60
+ const id = nid(node);
61
+ return typeof id === "number" ? id : undefined;
57
62
  }
58
- function nid(node) {
59
- return nodeIdMap.get(node) ?? node._nodeId;
63
+ // Reused across replacements in a pass — see the note on `mdastWriter`.
64
+ const hastWriter = new OpWriter();
65
+ /** Compile a set-children payload: a root-wrapped child list, the shape
66
+ * `Patch::SetChildren` splices in. Reused children become refs. */
67
+ function compileHastChildrenToOpstream(children) {
68
+ if (!Array.isArray(children))
69
+ return null;
70
+ hastWriter.begin();
71
+ try {
72
+ hastWriter.open(NAME_TO_TYPE.root);
73
+ for (const c of children) {
74
+ if (!emitHastOp(hastWriter, c, false))
75
+ return null;
76
+ }
77
+ hastWriter.close();
78
+ return hastWriter.take();
79
+ }
80
+ finally {
81
+ hastWriter.end();
82
+ }
83
+ }
84
+ /** Encode `node` as the `op` structural command. HAST content is always a
85
+ * declarative node (no raw escape hatch), so it compiles to the op-stream or
86
+ * it's a hard error — the op-stream is the only structural encoding. The
87
+ * switch stays inline so the buffer calls are monomorphic (computed method
88
+ * names defeat inline caches on this warm path). */
89
+ function emitHastTree(buffer, op, id, node) {
90
+ const ops = compileHastToOpstream(node);
91
+ if (ops === null)
92
+ throw unencodableContentError(node);
93
+ switch (op) {
94
+ case "replace":
95
+ return buffer.replaceOpstream(id, ops);
96
+ case "insertBefore":
97
+ return buffer.insertBeforeOpstream(id, ops);
98
+ case "insertAfter":
99
+ return buffer.insertAfterOpstream(id, ops);
100
+ case "prependChild":
101
+ return buffer.prependChildOpstream(id, ops);
102
+ case "appendChild":
103
+ return buffer.appendChildOpstream(id, ops);
104
+ case "wrapNode":
105
+ return buffer.wrapNodeOpstream(id, ops);
106
+ }
107
+ }
108
+ /**
109
+ * Compile a declarative HAST replacement tree to the op-stream — the only
110
+ * structural encoding. Reused nodes become `ref`s (transparent passthrough).
111
+ * Returns null when the tree holds a type the replay can't reproduce
112
+ * identically; the caller throws.
113
+ */
114
+ function compileHastToOpstream(root) {
115
+ hastWriter.begin();
116
+ try {
117
+ if (!emitHastOp(hastWriter, root, true))
118
+ return null;
119
+ return hastWriter.take();
120
+ }
121
+ finally {
122
+ hastWriter.end();
123
+ }
124
+ }
125
+ function emitHastOp(w, node, isRoot) {
126
+ if (node === null || typeof node !== "object")
127
+ return false;
128
+ if (!isRoot) {
129
+ const id = hastReusedId(node);
130
+ if (id !== undefined) {
131
+ w.ref(id);
132
+ return true;
133
+ }
134
+ }
135
+ const n = node;
136
+ const type = HAST_OPSTREAM_TYPES[n.type];
137
+ if (type === undefined)
138
+ return false;
139
+ w.open(type);
140
+ if (type === HAST_ELEMENT) {
141
+ w.str(OF_TAGNAME, typeof n.tagName === "string" ? n.tagName : "div");
142
+ const props = n.properties;
143
+ if (props !== null && typeof props === "object") {
144
+ for (const key in props) {
145
+ emitHastProp(w, key, props[key]);
146
+ }
147
+ }
148
+ }
149
+ else if (type === HAST_MDX_JSX_ELEMENT || type === HAST_MDX_JSX_TEXT_ELEMENT) {
150
+ // Name falls back to tagName, matching `encode_hast_js_node_data`.
151
+ const name = typeof n.name === "string" ? n.name : typeof n.tagName === "string" ? n.tagName : "";
152
+ if (name !== "")
153
+ w.str(OF_NAME, name);
154
+ if (Array.isArray(n.attributes)) {
155
+ for (const a of n.attributes)
156
+ emitMdxAttr(w, a);
157
+ }
158
+ if (n.data?._mdxExplicitJsx === true) {
159
+ w.bool(OF_EXPLICIT, true);
160
+ }
161
+ }
162
+ else {
163
+ w.str(OF_VALUE, typeof n.value === "string" ? n.value : "");
164
+ }
165
+ if (n.data != null)
166
+ w.data(n.data);
167
+ const children = n.children;
168
+ if (Array.isArray(children)) {
169
+ for (const c of children)
170
+ if (!emitHastOp(w, c, false))
171
+ return false;
172
+ }
173
+ w.close();
174
+ return true;
60
175
  }
61
- function asArray(value) {
62
- return Array.isArray(value) ? value : [value];
176
+ /** Emit one element property, mirroring `encode_hast_js_node_data` exactly:
177
+ * bool/string/number/array → kind; null/object → skip. */
178
+ function emitHastProp(w, name, value) {
179
+ if (value === true)
180
+ w.prop(name, PROP_BOOL_TRUE, "");
181
+ else if (value === false)
182
+ w.prop(name, PROP_BOOL_FALSE, "");
183
+ else if (typeof value === "string")
184
+ w.prop(name, PROP_STRING, value);
185
+ else if (typeof value === "number")
186
+ w.prop(name, PROP_INT, String(value));
187
+ else if (Array.isArray(value))
188
+ w.prop(name, PROP_SPACE_SEP, value.filter((v) => typeof v === "string").join(" "));
63
189
  }
64
190
  class HastVisitorContextImpl {
65
191
  #commandBuffer = new CommandBuffer();
@@ -68,11 +194,18 @@ class HastVisitorContextImpl {
68
194
  #pendingNodes = new Map();
69
195
  #handle;
70
196
  #getSource;
197
+ #resolver;
198
+ /** One canonical object per parent id, so visitors can dedupe by identity.
199
+ * Null until the first `parent()` call; most passes never make one. */
200
+ #parentsById = null;
71
201
  fileURL;
72
- constructor(handle, getSource, fileURL) {
202
+ data;
203
+ constructor(handle, getSource, fileURL, resolver, data) {
73
204
  this.#handle = handle;
74
205
  this.#getSource = getSource;
75
206
  this.fileURL = fileURL;
207
+ this.#resolver = resolver;
208
+ this.data = data;
76
209
  }
77
210
  get source() {
78
211
  const value = this.#getSource();
@@ -80,39 +213,37 @@ class HastVisitorContextImpl {
80
213
  return value;
81
214
  }
82
215
  removeNode(node) {
83
- this.#commandBuffer.removeNode(nid(node));
216
+ this.#commandBuffer.removeNode(requireNid(node, "removeNode"));
84
217
  }
85
218
  replaceNode(node, newNode) {
86
- const id = nid(node);
87
- this.#commandBuffer.replaceRawJson(id, JSON.stringify(markHast(newNode)));
219
+ const id = requireNid(node, "replaceNode");
220
+ emitHastTree(this.#commandBuffer, "replace", id, newNode);
221
+ // Track the replacement so a later mdxJsx setProperty can fold into it.
88
222
  this.#pendingNodes.set(id, newNode);
89
223
  }
90
224
  insertBefore(node, newNode) {
91
- const id = nid(node);
92
- for (const n of asArray(newNode)) {
93
- this.#commandBuffer.insertBeforeRawJson(id, JSON.stringify(markHast(n)));
94
- }
225
+ const id = requireNid(node, "insertBefore");
226
+ for (const n of asArray(newNode))
227
+ emitHastTree(this.#commandBuffer, "insertBefore", id, n);
95
228
  }
96
229
  insertAfter(node, newNode) {
97
- const id = nid(node);
98
- for (const n of asArray(newNode)) {
99
- this.#commandBuffer.insertAfterRawJson(id, JSON.stringify(markHast(n)));
100
- }
230
+ const id = requireNid(node, "insertAfter");
231
+ for (const n of asArray(newNode))
232
+ emitHastTree(this.#commandBuffer, "insertAfter", id, n);
101
233
  }
102
234
  wrapNode(node, parentNode) {
103
- this.#commandBuffer.wrapNodeRawJson(nid(node), JSON.stringify(markHast(parentNode)));
235
+ const id = requireNid(node, "wrapNode");
236
+ emitHastTree(this.#commandBuffer, "wrapNode", id, parentNode);
104
237
  }
105
238
  prependChild(node, childNode) {
106
- const id = nid(node);
107
- for (const n of asArray(childNode)) {
108
- this.#commandBuffer.prependChildRawJson(id, JSON.stringify(markHast(n)));
109
- }
239
+ const id = requireNid(node, "prependChild");
240
+ for (const n of asArray(childNode))
241
+ emitHastTree(this.#commandBuffer, "prependChild", id, n);
110
242
  }
111
243
  appendChild(node, childNode) {
112
- const id = nid(node);
113
- for (const n of asArray(childNode)) {
114
- this.#commandBuffer.appendChildRawJson(id, JSON.stringify(markHast(n)));
115
- }
244
+ const id = requireNid(node, "appendChild");
245
+ for (const n of asArray(childNode))
246
+ emitHastTree(this.#commandBuffer, "appendChild", id, n);
116
247
  }
117
248
  insertChildAt(node, index, childNode) {
118
249
  const children = "children" in node ? node.children : [];
@@ -132,16 +263,14 @@ class HastVisitorContextImpl {
132
263
  this.removeNode(child);
133
264
  }
134
265
  setProperty(node, key, value) {
135
- const id = nid(node);
266
+ const id = requireNid(node, "setProperty");
136
267
  if (key === "children") {
137
268
  // children is structural: set-children keeps the node and swaps only its
138
269
  // child list (reused children keep their id).
139
- const wrapper = {
140
- _hast: true,
141
- type: "root",
142
- children: value.map((child) => markHastNode(child, false)),
143
- };
144
- this.#commandBuffer.setChildren(id, JSON.stringify(wrapper));
270
+ const ops = compileHastChildrenToOpstream(value);
271
+ if (!ops)
272
+ throw unencodableContentError(value);
273
+ this.#commandBuffer.setChildrenOpstream(id, ops);
145
274
  return;
146
275
  }
147
276
  if (key === "data") {
@@ -149,35 +278,63 @@ class HastVisitorContextImpl {
149
278
  return;
150
279
  }
151
280
  if (node.type === "element") {
152
- // Fast binary path, no materialization, no JSON serialization
153
281
  this.#commandBuffer.setProperty(id, key, value);
154
282
  return;
155
283
  }
156
284
  if (node.type === "mdxJsxFlowElement" || node.type === "mdxJsxTextElement") {
157
- // MDX JSX nodes use `attributes`, not `properties`, keep replaceNode path
285
+ // MDX JSX nodes carry `attributes`, not `properties`. If a replacement is
286
+ // already queued for this node, fold the attribute into it so the change
287
+ // survives the rebuild. This spreads the queued replacement object, not
288
+ // the matched node, so it never forces the matched node's children to
289
+ // materialize.
158
290
  const pending = this.#pendingNodes.get(id);
159
- const current = (pending ?? node);
160
- const updated = { ...current };
161
- const attrs = [...(updated.attributes ?? [])];
162
- const idx = attrs.findIndex((a) => a.type === "mdxJsxAttribute" && a.name === key);
163
- if (idx !== -1)
164
- attrs.splice(idx, 1);
165
- const attrValue = value === true || value === null || value === undefined
166
- ? null
167
- : typeof value === "string"
168
- ? value
169
- : String(value);
170
- attrs.push({ type: "mdxJsxAttribute", name: key, value: attrValue });
171
- updated.attributes = attrs;
172
- this.replaceNode(node, updated);
291
+ if (pending !== undefined) {
292
+ const updated = { ...pending };
293
+ const attrs = [...(updated.attributes ?? [])];
294
+ const idx = attrs.findIndex((a) => a.type === "mdxJsxAttribute" && a.name === key);
295
+ if (idx !== -1)
296
+ attrs.splice(idx, 1);
297
+ // Arrays space-join, matching the binary path's PROP_SPACE_SEP encoding
298
+ // (hast convention for list-valued properties like className).
299
+ const attrValue = value === true || value === null || value === undefined
300
+ ? null
301
+ : typeof value === "string"
302
+ ? value
303
+ : Array.isArray(value)
304
+ ? value.join(" ")
305
+ : String(value);
306
+ attrs.push({ type: "mdxJsxAttribute", name: key, value: attrValue });
307
+ updated.attributes = attrs;
308
+ this.replaceNode(node, updated);
309
+ return;
310
+ }
311
+ // Binary attribute upsert in the arena's type_data — no child
312
+ // materialization. Rust maps the value-type to a boolean (true/null) or
313
+ // literal (string/number/false) attribute, mirroring the fold path above.
314
+ this.#commandBuffer.setProperty(id, key, value);
173
315
  return;
174
316
  }
175
- // Text-like nodes (text, comment, raw, expressions, esm), fast binary path.
176
- // Rust handles "value" setProperty directly on these types.
317
+ // Text-like nodes (text, comment, raw, expressions, esm): Rust handles
318
+ // `value` directly on these types.
177
319
  this.#commandBuffer.setProperty(id, key, value);
178
320
  }
179
321
  textContent(node) {
180
- return textContentHandle(this.#handle, nid(node));
322
+ return textContentHandle(this.#handle, requireNid(node, "textContent"));
323
+ }
324
+ parent(node) {
325
+ const parentId = this.#resolver.parentIdOf(requireNid(node, "parent"));
326
+ if (parentId === undefined)
327
+ return undefined;
328
+ const byId = (this.#parentsById ??= new Map());
329
+ let parent = byId.get(parentId);
330
+ if (parent === undefined) {
331
+ parent = this.#resolver.materializeOne(parentId);
332
+ byId.set(parentId, parent);
333
+ }
334
+ return parent;
335
+ }
336
+ indexOf(node) {
337
+ return this.#resolver.indexInParent(requireNid(node, "indexOf"));
181
338
  }
182
339
  report({ message, node, severity = "error", }) {
183
340
  this.#diagnostics.push({ message, nodeId: node ? nid(node) : undefined, severity });
@@ -189,12 +346,8 @@ class HastVisitorContextImpl {
189
346
  return this.#diagnostics;
190
347
  }
191
348
  }
192
- function isFilteredVisitor(v) {
193
- return typeof v === "object" && v !== null && "filter" in v && "visit" in v;
194
- }
195
349
  /** Node types that use filtered visitors (have tag/component names). */
196
350
  const FILTERED_METHODS = new Set(["element", "mdxJsxFlowElement", "mdxJsxTextElement"]);
197
- /** Resolve subscriptions from a plugin instance. */
198
351
  export function resolveSubscriptions(plugin) {
199
352
  const subs = [];
200
353
  for (const [methodName, nodeType] of Object.entries(METHOD_TO_TYPE)) {
@@ -218,24 +371,8 @@ export function resolveSubscriptions(plugin) {
218
371
  }
219
372
  return subs;
220
373
  }
221
- /** Reverse map: method name → node type number */
222
- const METHOD_TO_TYPE = {
223
- element: HAST_ELEMENT,
224
- text: HAST_TEXT,
225
- comment: HAST_COMMENT,
226
- raw: HAST_RAW,
227
- doctype: 4, // HAST_DOCTYPE
228
- mdxJsxFlowElement: HAST_MDX_JSX_ELEMENT,
229
- mdxJsxTextElement: HAST_MDX_JSX_TEXT_ELEMENT,
230
- mdxFlowExpression: HAST_MDX_FLOW_EXPRESSION,
231
- mdxTextExpression: HAST_MDX_TEXT_EXPRESSION,
232
- mdxjsEsm: HAST_MDX_ESM,
233
- };
234
- /**
235
- * Selective walk path: Rust walks the tree, only sends matched nodes to JS.
236
- */
237
- const textDecoder = new TextDecoder("utf-8");
238
- /** Decode properties from the walk buffer at the given position. */
374
+ /** Visitor method name → node-type tag (method names are the subscribable AST names). */
375
+ const METHOD_TO_TYPE = Object.fromEntries([...VISITOR_KEYS].map((name) => [name, NAME_TO_TYPE[name]]));
239
376
  function decodeProperties(view, buf, pos) {
240
377
  const propCount = view.getUint16(pos, true);
241
378
  pos += 2;
@@ -243,121 +380,139 @@ function decodeProperties(view, buf, pos) {
243
380
  for (let i = 0; i < propCount; i++) {
244
381
  const nameLen = view.getUint16(pos, true);
245
382
  pos += 2;
246
- const name = textDecoder.decode(buf.subarray(pos, pos + nameLen));
383
+ const name = rstr(buf, pos, nameLen);
247
384
  pos += nameLen;
248
385
  const kind = buf[pos];
249
386
  pos += 1;
250
387
  const valLen = view.getUint16(pos, true);
251
388
  pos += 2;
252
- const valStr = textDecoder.decode(buf.subarray(pos, pos + valLen));
389
+ const valStr = rstr(buf, pos, valLen);
253
390
  pos += valLen;
254
- switch (kind) {
255
- case 0: // PROP_STRING
256
- properties[name] = valStr;
257
- break;
258
- case 1: // PROP_BOOL_TRUE
259
- properties[name] = true;
260
- break;
261
- case 2: // PROP_BOOL_FALSE
262
- break;
263
- case 3: // PROP_SPACE_SEP
264
- properties[name] = valStr.split(" ").filter((s) => s.length > 0);
265
- break;
266
- case 5: // PROP_INT
267
- properties[name] = Number(valStr);
268
- break;
269
- }
391
+ properties[name] = decodeElementProp(kind, valStr);
270
392
  }
271
393
  return properties;
272
394
  }
273
- /** Decode the 24-byte position block written by `serialize_hast_node_inline`. */
274
- function readPositionPrefix(view, offset) {
275
- const startOffset = view.getUint32(offset, true);
276
- const endOffset = view.getUint32(offset + 4, true);
277
- const startLine = view.getUint32(offset + 8, true);
278
- const startColumn = view.getUint32(offset + 12, true);
279
- const endLine = view.getUint32(offset + 16, true);
280
- const endColumn = view.getUint32(offset + 20, true);
281
- if (startLine === 0 && startOffset === 0)
282
- return undefined;
283
- return {
284
- start: { offset: startOffset, line: startLine, column: startColumn },
285
- end: { offset: endOffset, line: endLine, column: endColumn },
286
- };
395
+ /** Build the child-stub list for a matched node from the wire's `[child_ids]
396
+ * [child_types]` blocks — no arena snapshot. The seal check still applies:
397
+ * post-pass ids are stale, and a stub built from them could later splice the
398
+ * wrong node as a ref. */
399
+ function readChildStubs(view, buf, idsPos, typesPos, count, resolver) {
400
+ resolver.assertUnsealed();
401
+ const stubs = new Array(count);
402
+ for (let i = 0; i < count; i++) {
403
+ stubs[i] = new HastChildStub(resolver, view.getUint32(idsPos + i * 4, true), buf[typesPos + i]);
404
+ }
405
+ return stubs;
287
406
  }
407
+ // Shared own-getter descriptors for WalkElement's lazy fields, populated in
408
+ // its static block so the getters can read the private wire fields.
409
+ let WALK_PROPS_DESC;
410
+ let WALK_CHILDREN_DESC;
288
411
  /**
289
- * Walk-path element node: uses prototype getters instead of per-instance
290
- * Object.defineProperty. V8 optimises shared hidden classes far better,
291
- * this is ~16x faster for construction than the defineProperty approach.
292
- *
293
- * The buffer reference data is stored on private instance fields so the
294
- * prototype getter can decode lazily on first access.
412
+ * Walk-path element. Spread-correctness requires `properties`/`children` as
413
+ * own enumerable keys (`{ ...node }` copies nothing else), but construction
414
+ * runs per matched element, so everything stays off the expensive paths:
415
+ * wire state in private fields (plain stores, invisible to spread — a WeakMap
416
+ * entry per element caused major-GC ephemeron stalls at this volume), shared
417
+ * getter functions instead of per-node closures, at most one define per lazy
418
+ * field, and `instanceof` gating identity so copies read as new content.
295
419
  */
296
420
  class WalkElement {
297
421
  type = "element";
298
- /** @internal */ _view;
299
- /** @internal */ _buf;
300
- /** @internal */ _propsPos;
301
- /** @internal */ _childIds;
302
- /** @internal */ _resolver;
303
- get properties() {
304
- const val = decodeProperties(this._view, this._buf, this._propsPos);
305
- Object.defineProperty(this, "properties", {
306
- value: val,
307
- writable: true,
422
+ tagName;
423
+ #nodeId;
424
+ #view;
425
+ #buf;
426
+ #propsPos;
427
+ #childIdsPos;
428
+ #childTypesPos;
429
+ #childCount;
430
+ #resolver;
431
+ constructor(tagName, nodeId, view, buf, propsPos, propCount, childIdsPos, childTypesPos, childCount, resolver) {
432
+ this.tagName = tagName;
433
+ this.#nodeId = nodeId;
434
+ this.#view = view;
435
+ this.#buf = buf;
436
+ this.#propsPos = propsPos;
437
+ this.#childIdsPos = childIdsPos;
438
+ this.#childTypesPos = childTypesPos;
439
+ this.#childCount = childCount;
440
+ this.#resolver = resolver;
441
+ if (propCount === 0) {
442
+ this.properties = {};
443
+ }
444
+ else {
445
+ Object.defineProperty(this, "properties", WALK_PROPS_DESC);
446
+ }
447
+ if (childCount === 0) {
448
+ this.children = [];
449
+ }
450
+ else {
451
+ Object.defineProperty(this, "children", WALK_CHILDREN_DESC);
452
+ }
453
+ }
454
+ /** @internal */
455
+ get _nid() {
456
+ return this.#nodeId;
457
+ }
458
+ static {
459
+ WALK_PROPS_DESC = {
308
460
  enumerable: true,
309
461
  configurable: true,
310
- });
311
- return val;
312
- }
313
- get children() {
314
- const val = this._resolver.materializeChildren(this._childIds);
315
- Object.defineProperty(this, "children", {
316
- value: val,
317
- writable: true,
462
+ get() {
463
+ const val = decodeProperties(this.#view, this.#buf, this.#propsPos);
464
+ Object.defineProperty(this, "properties", {
465
+ value: val,
466
+ writable: true,
467
+ enumerable: true,
468
+ configurable: true,
469
+ });
470
+ return val;
471
+ },
472
+ };
473
+ WALK_CHILDREN_DESC = {
318
474
  enumerable: true,
319
475
  configurable: true,
320
- });
321
- return val;
476
+ get() {
477
+ const val = readChildStubs(this.#view, this.#buf, this.#childIdsPos, this.#childTypesPos, this.#childCount, this.#resolver);
478
+ Object.defineProperty(this, "children", {
479
+ value: val,
480
+ writable: true,
481
+ enumerable: true,
482
+ configurable: true,
483
+ });
484
+ return val;
485
+ },
486
+ };
322
487
  }
323
488
  }
324
489
  /** Read the tail of a matched element node (tag + properties).
325
490
  * Common prelude (data/position/children) is already consumed by `readMatchedNode`. */
326
- function readElementFromBinary(view, buf, offset, nodeId, resolver, position, childIds, data) {
491
+ function readElementFromBinary(view, buf, offset, nodeId, resolver, position, childIdsPos, childTypesPos, childCount, data) {
327
492
  let pos = offset;
328
493
  // Eager: tagName (almost always accessed by visitors)
329
494
  const tagLen = view.getUint16(pos, true);
330
495
  pos += 2;
331
- const tagName = textDecoder.decode(buf.subarray(pos, pos + tagLen));
496
+ const tagName = rstr(buf, pos, tagLen);
332
497
  pos += tagLen;
333
- const propsPos = pos;
334
- // Build node using class (prototype getters, no per-instance defineProperty)
335
- const node = new WalkElement();
336
- node.tagName = tagName;
498
+ const propCount = view.getUint16(pos, true);
499
+ const node = new WalkElement(tagName, nodeId, view, buf, pos, propCount, childIdsPos, childTypesPos, childCount, resolver);
337
500
  if (position !== undefined)
338
501
  node.position = position;
339
502
  if (data !== null)
340
503
  node.data = data;
341
- node._view = view;
342
- node._buf = buf;
343
- node._propsPos = propsPos;
344
- node._childIds = childIds;
345
- node._resolver = resolver;
346
- nodeIdMap.set(node, nodeId);
347
504
  return node;
348
505
  }
349
- /** Read a text/comment/raw/expression node from the binary data section. */
350
- const TEXT_NODE_TYPES = {
351
- 2: "text",
352
- 3: "comment",
353
- 5: "raw",
354
- [HAST_MDX_FLOW_EXPRESSION]: "mdxFlowExpression",
355
- [HAST_MDX_TEXT_EXPRESSION]: "mdxTextExpression",
356
- [HAST_MDX_ESM]: "mdxjsEsm",
357
- };
506
+ /** Value-carrying types read by `readTextFromBinary` (tag → AST name). */
507
+ const TEXT_NODE_TYPES = Object.fromEntries(["text", "comment", "raw", "mdxFlowExpression", "mdxTextExpression", "mdxjsEsm"].map((name) => [NAME_TO_TYPE[name], name]));
358
508
  function readTextFromBinary(view, buf, offset, nodeId, nodeType, position, data) {
359
509
  const valLen = view.getUint32(offset, true);
360
- const value = textDecoder.decode(buf.subarray(offset + 4, offset + 4 + valLen));
510
+ const rawValue = rstr(buf, offset + 4, valLen);
511
+ // MDX flow/text expressions store phantom-space sentinels; restore them so
512
+ // the value matches the reader path. ESM and plain text keep their value.
513
+ const value = nodeType === HAST_MDX_FLOW_EXPRESSION || nodeType === HAST_MDX_TEXT_EXPRESSION
514
+ ? restorePhantomSpaces(rawValue)
515
+ : rawValue;
361
516
  const base = { type: TEXT_NODE_TYPES[nodeType], value };
362
517
  if (position !== undefined)
363
518
  base.position = position;
@@ -373,13 +528,11 @@ function readTextFromBinary(view, buf, offset, nodeId, nodeType, position, data)
373
528
  }
374
529
  return node;
375
530
  }
376
- /** Read an MDX JSX element from the binary data section. */
377
- function readMdxJsxFromBinary(view, buf, offset, nodeId, nodeType, resolver, position, childIds, data) {
531
+ function readMdxJsxFromBinary(view, buf, offset, nodeId, nodeType, resolver, position, childIdsPos, childTypesPos, childCount, data) {
378
532
  let pos = offset;
379
- // Name
380
533
  const nameLen = view.getUint16(pos, true);
381
534
  pos += 2;
382
- const name = nameLen > 0 ? textDecoder.decode(buf.subarray(pos, pos + nameLen)) : null;
535
+ const name = nameLen > 0 ? rstr(buf, pos, nameLen) : null;
383
536
  pos += nameLen;
384
537
  // Attributes: [kind: u8][nameLen: u16][name][valLen: u32][val]
385
538
  const attrCount = view.getUint16(pos, true);
@@ -390,30 +543,13 @@ function readMdxJsxFromBinary(view, buf, offset, nodeId, nodeType, resolver, pos
390
543
  pos += 1;
391
544
  const attrNameLen = view.getUint16(pos, true);
392
545
  pos += 2;
393
- const attrName = textDecoder.decode(buf.subarray(pos, pos + attrNameLen));
546
+ const attrName = rstr(buf, pos, attrNameLen);
394
547
  pos += attrNameLen;
395
548
  const attrValLen = view.getUint32(pos, true);
396
549
  pos += 4;
397
- const attrVal = textDecoder.decode(buf.subarray(pos, pos + attrValLen));
550
+ const attrVal = rstr(buf, pos, attrValLen);
398
551
  pos += attrValLen;
399
- switch (kind) {
400
- case 0: // BooleanProp
401
- attributes.push({ type: "mdxJsxAttribute", name: attrName, value: null });
402
- break;
403
- case 1: // LiteralProp
404
- attributes.push({ type: "mdxJsxAttribute", name: attrName, value: attrVal });
405
- break;
406
- case 2: // ExpressionProp
407
- attributes.push({
408
- type: "mdxJsxAttribute",
409
- name: attrName,
410
- value: { type: "mdxJsxAttributeValueExpression", value: attrVal },
411
- });
412
- break;
413
- case 3: // Spread
414
- attributes.push({ type: "mdxJsxExpressionAttribute", value: attrVal });
415
- break;
416
- }
552
+ attributes.push(decodeMdxJsxAttr(kind, attrName, attrVal));
417
553
  }
418
554
  const typeName = nodeType === HAST_MDX_JSX_ELEMENT ? "mdxJsxFlowElement" : "mdxJsxTextElement";
419
555
  const base = { type: typeName, name, attributes };
@@ -422,39 +558,40 @@ function readMdxJsxFromBinary(view, buf, offset, nodeId, nodeType, resolver, pos
422
558
  if (data !== null)
423
559
  base.data = data;
424
560
  nodeIdMap.set(base, nodeId);
425
- makeLazyChildren(base, childIds, resolver);
561
+ makeLazyChildren(base, view, buf, childIdsPos, childTypesPos, childCount, resolver);
426
562
  return base;
427
563
  }
428
564
  function readMatchedNode(view, buf, offset, nodeId, nodeType, resolver) {
429
565
  let pos = offset;
430
566
  // Shared prelude (matches serialize_hast_node_inline / serialize_mdast_node_inline):
431
- // [data_len: u32][data_bytes][position: 24B][child_count: u16][child_ids: N×u32]
432
- // Data (JSON), eagerly parsed
567
+ // [data_len: u32][data_bytes][position: 24B][child_count: u32][child_ids: N×u32][child_types: N×u8]
433
568
  const dataLen = view.getUint32(pos, true);
434
569
  pos += 4;
435
570
  let data = null;
436
571
  if (dataLen > 0) {
437
- const jsonStr = textDecoder.decode(buf.subarray(pos, pos + dataLen));
572
+ const jsonStr = rstr(buf, pos, dataLen);
438
573
  try {
439
574
  data = JSON.parse(jsonStr);
440
575
  }
441
- catch {
442
- /* ignore malformed JSON */
576
+ catch (err) {
577
+ if (process.env.NODE_ENV !== "production") {
578
+ console.warn(`readMatchedNode: malformed node_data for nodeId=${nodeId}`, err);
579
+ }
443
580
  }
444
581
  pos += dataLen;
445
582
  }
446
- const position = readPositionPrefix(view, pos);
583
+ const position = readPosition(view, pos);
447
584
  pos += 24;
448
- const childCount = view.getUint16(pos, true);
449
- pos += 2;
450
- const childIds = [];
451
- for (let i = 0; i < childCount; i++) {
452
- childIds.push(view.getUint32(pos, true));
453
- pos += 4;
454
- }
585
+ const childCount = view.getUint32(pos, true);
586
+ pos += 4;
587
+ // Ids/types decode lazily with `.children` — most matched nodes never read them.
588
+ const childIdsPos = pos;
589
+ pos += childCount * 4;
590
+ const childTypesPos = pos;
591
+ pos += childCount;
455
592
  // Dispatch to type-specific tail (pos now sits at the type-specific section)
456
593
  if (nodeType === HAST_ELEMENT) {
457
- return readElementFromBinary(view, buf, pos, nodeId, resolver, position, childIds, data);
594
+ return readElementFromBinary(view, buf, pos, nodeId, resolver, position, childIdsPos, childTypesPos, childCount, data);
458
595
  }
459
596
  else if (nodeType === HAST_TEXT ||
460
597
  nodeType === HAST_COMMENT ||
@@ -465,10 +602,10 @@ function readMatchedNode(view, buf, offset, nodeId, nodeType, resolver) {
465
602
  return readTextFromBinary(view, buf, pos, nodeId, nodeType, position, data);
466
603
  }
467
604
  else if (nodeType === HAST_MDX_JSX_ELEMENT || nodeType === HAST_MDX_JSX_TEXT_ELEMENT) {
468
- return readMdxJsxFromBinary(view, buf, pos, nodeId, nodeType, resolver, position, childIds, data);
605
+ return readMdxJsxFromBinary(view, buf, pos, nodeId, nodeType, resolver, position, childIdsPos, childTypesPos, childCount, data);
469
606
  }
470
- // Fallback: minimal node carrying whatever prelude data we found
471
- const base = { type: `unknown(${nodeType})` };
607
+ // Fallback (e.g. doctype): minimal node carrying whatever prelude data we found
608
+ const base = { type: TYPE_NAMES[nodeType] ?? `unknown(${nodeType})` };
472
609
  if (position !== undefined)
473
610
  base.position = position;
474
611
  if (data !== null)
@@ -477,71 +614,42 @@ function readMatchedNode(view, buf, offset, nodeId, nodeType, resolver) {
477
614
  nodeIdMap.set(node, nodeId);
478
615
  return node;
479
616
  }
480
- // Shared helpers
481
- /**
482
- * Lazy child materializer, serializes the handle's buffer once when first
483
- * child is accessed, then materializes children from it via HastReader.
484
- */
485
- class LazyChildResolver {
486
- #handle;
487
- #reader = null;
488
- constructor(handle) {
489
- this.#handle = handle;
617
+ class HastLazyChildResolver extends LazyChildResolver {
618
+ createReader(wire) {
619
+ return new HastReader(wire);
490
620
  }
491
- #ensure() {
492
- if (!this.#reader) {
493
- this.#reader = new HastReader(serializeHandle(this.#handle));
494
- }
495
- return this.#reader;
496
- }
497
- materializeChildren(childIds) {
498
- const reader = this.#ensure();
499
- return childIds.map((id) => {
500
- const node = materializeHastNode(reader, id);
501
- this.attachLazyData(node, id);
502
- return node;
503
- });
621
+ materializeNode(reader, nodeId) {
622
+ return materializeHastNode(reader, nodeId);
504
623
  }
505
- /** Attach a lazy `data` getter backed by the Rust arena's node_data. */
506
- attachLazyData(node, nodeId) {
507
- const handle = this.#handle;
508
- Object.defineProperty(node, "data", {
509
- get() {
510
- const json = napiGetNodeData(handle, nodeId);
511
- const val = json ? JSON.parse(json) : null;
512
- Object.defineProperty(this, "data", {
513
- value: val,
514
- writable: true,
515
- enumerable: true,
516
- configurable: true,
517
- });
518
- return val;
519
- },
520
- configurable: true,
521
- enumerable: true,
522
- });
624
+ readParentId(reader, nodeId) {
625
+ return reader.getParentId(nodeId);
626
+ }
627
+ readChildIds(reader, nodeId) {
628
+ return reader.getChildIds(nodeId);
523
629
  }
524
630
  }
525
- /** Create a lazy `children` property backed by the handle. */
526
- function makeLazyChildren(node, childIds, resolver) {
631
+ /** Install `children` as an own enumerable getter (spread must carry it),
632
+ * self-replacing with the one stable stub array on first read. One closure
633
+ * and one define per node — installing the wire locals as hidden slots
634
+ * instead measurably regressed every matching pipeline. */
635
+ function makeLazyChildren(node, view, buf, childIdsPos, childTypesPos, childCount, resolver) {
527
636
  Object.defineProperty(node, "children", {
528
637
  get() {
529
- const children = resolver.materializeChildren(childIds);
638
+ const val = readChildStubs(view, buf, childIdsPos, childTypesPos, childCount, resolver);
530
639
  Object.defineProperty(this, "children", {
531
- value: children,
640
+ value: val,
532
641
  writable: true,
533
642
  enumerable: true,
534
643
  configurable: true,
535
644
  });
536
- return children;
645
+ return val;
537
646
  },
538
- configurable: true,
539
647
  enumerable: true,
648
+ configurable: true,
540
649
  });
541
650
  }
542
- /** Handle a visitor result (sync).
543
- * If the result is the same object as the input node, treat it as a no-op
544
- * so that context mutations (e.g. setProperty) are not clobbered. */
651
+ /** A result that is the same object as the input node is a no-op, so context
652
+ * mutations (e.g. setProperty) are not clobbered. */
545
653
  function handleVisitResult(result, nodeId, returnBuffer, deferred, originalNode) {
546
654
  if (result == null)
547
655
  return deferred;
@@ -552,7 +660,7 @@ function handleVisitResult(result, nodeId, returnBuffer, deferred, originalNode)
552
660
  list.push({ nodeId, promise: result, originalNode });
553
661
  return list;
554
662
  }
555
- returnBuffer.replaceRawJson(nodeId, JSON.stringify(markHast(result)));
663
+ emitHastTree(returnBuffer, "replace", nodeId, result);
556
664
  return deferred;
557
665
  }
558
666
  /**
@@ -564,7 +672,7 @@ function dispatchMatches(matchBuf, subs, ctx, returnBuffer, resolver) {
564
672
  const matchCount = matchView.getUint32(0, true);
565
673
  let deferred = null;
566
674
  for (let i = 0; i < matchCount; i++) {
567
- const indexBase = 4 + i * 12;
675
+ const indexBase = 4 + i * 10;
568
676
  const nodeId = matchView.getUint32(indexBase, true);
569
677
  const subIndex = matchBuf[indexBase + 4];
570
678
  const dataOffset = matchView.getUint32(indexBase + 6, true);
@@ -575,26 +683,6 @@ function dispatchMatches(matchBuf, subs, ctx, returnBuffer, resolver) {
575
683
  }
576
684
  return deferred;
577
685
  }
578
- /** Merge return-value + context command buffers and release internals. */
579
- function mergeAndReset(returnBuffer, ctx) {
580
- const ctxCmdBuf = ctx.getCommandBuffer();
581
- const ctxBuf = ctxCmdBuf.getBuffer();
582
- const retBuf = returnBuffer.getBuffer();
583
- const totalLen = retBuf.length + ctxBuf.length;
584
- let merged;
585
- if (totalLen === 0) {
586
- merged = new Uint8Array(0);
587
- }
588
- else {
589
- merged = new Uint8Array(totalLen);
590
- merged.set(retBuf, 0);
591
- merged.set(ctxBuf, retBuf.length);
592
- }
593
- returnBuffer.reset();
594
- ctxCmdBuf.reset();
595
- return { merged, hasMutations: totalLen > 0 };
596
- }
597
- // Handle-based visitor
598
686
  /**
599
687
  * Walk a handle's arena in Rust, dispatch matched nodes to JS visitor functions,
600
688
  * and apply mutations back to the handle. No arena buffers cross NAPI.
@@ -603,29 +691,34 @@ function mergeAndReset(returnBuffer, ctx) {
603
691
  * replaced earlier in the same pass (the caller warns when non-zero), or a
604
692
  * Promise of that count if any visitor is async.
605
693
  */
606
- export function visitHastHandle(handle, plugin, subs, source, fileURL) {
694
+ export function visitHastHandle(handle, plugin, subs, source, fileURL, data = {}) {
607
695
  const getSource = typeof source === "function" ? source : () => source;
608
- const ctx = new HastVisitorContextImpl(handle, getSource, fileURL);
696
+ const resolver = new HastLazyChildResolver(handle);
697
+ const ctx = new HastVisitorContextImpl(handle, getSource, fileURL, resolver, data);
609
698
  const returnBuffer = new CommandBuffer();
610
- const resolver = new LazyChildResolver(handle);
611
699
  const rustSubs = subs.map((s) => ({ nodeType: s.nodeType, tagFilter: s.tagFilter }));
612
700
  const deferred = dispatchMatches(walkHandle(handle, rustSubs), subs, ctx, returnBuffer, resolver);
613
701
  if (deferred) {
614
702
  return Promise.all(deferred.map((d) => d.promise.then((result) => ({ nodeId: d.nodeId, result, originalNode: d.originalNode })))).then((results) => {
615
703
  for (const { nodeId, result, originalNode } of results) {
616
704
  if (result != null && result !== originalNode) {
617
- returnBuffer.replaceRawJson(nodeId, JSON.stringify(markHast(result)));
705
+ emitHastTree(returnBuffer, "replace", nodeId, result);
618
706
  }
619
707
  }
708
+ // Mutations land next, renumbering the arena: snapshots taken after
709
+ // this point would resolve match-time child ids against wrong nodes.
710
+ resolver.seal();
620
711
  return applyMutations(handle, returnBuffer, ctx);
621
712
  });
622
713
  }
714
+ resolver.seal();
623
715
  return applyMutations(handle, returnBuffer, ctx);
624
716
  }
625
717
  /** Returns the number of patches dropped as stranded (0 when none). */
626
718
  function applyMutations(handle, returnBuffer, ctx) {
627
719
  const { merged, hasMutations } = mergeAndReset(returnBuffer, ctx);
628
720
  if (hasMutations) {
721
+ markHandleMutated(handle);
629
722
  return applyCommandsToHandle(handle, merged);
630
723
  }
631
724
  return 0;