satteri 0.8.0 → 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 -24
  7. package/dist/command-buffer.js +65 -128
  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 +38 -12
  28. package/dist/hast/hast-visitor.js +417 -281
  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 +55 -13
  47. package/dist/mdast/mdast-visitor.js +375 -410
  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,41 +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;
55
+ }
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;
62
+ }
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
+ }
33
124
  }
34
- function markHastNode(node, isRoot) {
125
+ function emitHastOp(w, node, isRoot) {
126
+ if (node === null || typeof node !== "object")
127
+ return false;
35
128
  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;
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;
57
175
  }
58
- function nid(node) {
59
- return nodeIdMap.get(node) ?? node._nodeId;
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(" "));
60
189
  }
61
190
  class HastVisitorContextImpl {
62
191
  #commandBuffer = new CommandBuffer();
@@ -65,11 +194,18 @@ class HastVisitorContextImpl {
65
194
  #pendingNodes = new Map();
66
195
  #handle;
67
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;
68
201
  fileURL;
69
- constructor(handle, getSource, fileURL) {
202
+ data;
203
+ constructor(handle, getSource, fileURL, resolver, data) {
70
204
  this.#handle = handle;
71
205
  this.#getSource = getSource;
72
206
  this.fileURL = fileURL;
207
+ this.#resolver = resolver;
208
+ this.data = data;
73
209
  }
74
210
  get source() {
75
211
  const value = this.#getSource();
@@ -77,64 +213,128 @@ class HastVisitorContextImpl {
77
213
  return value;
78
214
  }
79
215
  removeNode(node) {
80
- this.#commandBuffer.removeNode(nid(node));
216
+ this.#commandBuffer.removeNode(requireNid(node, "removeNode"));
81
217
  }
82
218
  replaceNode(node, newNode) {
83
- const id = nid(node);
84
- 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.
85
222
  this.#pendingNodes.set(id, newNode);
86
223
  }
87
224
  insertBefore(node, newNode) {
88
- this.#commandBuffer.insertBeforeRawJson(nid(node), JSON.stringify(markHast(newNode)));
225
+ const id = requireNid(node, "insertBefore");
226
+ for (const n of asArray(newNode))
227
+ emitHastTree(this.#commandBuffer, "insertBefore", id, n);
89
228
  }
90
229
  insertAfter(node, newNode) {
91
- this.#commandBuffer.insertAfterRawJson(nid(node), JSON.stringify(markHast(newNode)));
230
+ const id = requireNid(node, "insertAfter");
231
+ for (const n of asArray(newNode))
232
+ emitHastTree(this.#commandBuffer, "insertAfter", id, n);
92
233
  }
93
234
  wrapNode(node, parentNode) {
94
- this.#commandBuffer.wrapNodeRawJson(nid(node), JSON.stringify(markHast(parentNode)));
235
+ const id = requireNid(node, "wrapNode");
236
+ emitHastTree(this.#commandBuffer, "wrapNode", id, parentNode);
95
237
  }
96
238
  prependChild(node, childNode) {
97
- this.#commandBuffer.prependChildRawJson(nid(node), JSON.stringify(markHast(childNode)));
239
+ const id = requireNid(node, "prependChild");
240
+ for (const n of asArray(childNode))
241
+ emitHastTree(this.#commandBuffer, "prependChild", id, n);
98
242
  }
99
243
  appendChild(node, childNode) {
100
- this.#commandBuffer.appendChildRawJson(nid(node), JSON.stringify(markHast(childNode)));
244
+ const id = requireNid(node, "appendChild");
245
+ for (const n of asArray(childNode))
246
+ emitHastTree(this.#commandBuffer, "appendChild", id, n);
247
+ }
248
+ insertChildAt(node, index, childNode) {
249
+ const children = "children" in node ? node.children : [];
250
+ if (index <= 0 || children.length === 0) {
251
+ this.prependChild(node, childNode);
252
+ }
253
+ else if (index >= children.length) {
254
+ this.appendChild(node, childNode);
255
+ }
256
+ else {
257
+ this.insertBefore(children[index], childNode);
258
+ }
259
+ }
260
+ removeChildAt(node, index) {
261
+ const child = "children" in node ? node.children[index] : undefined;
262
+ if (child)
263
+ this.removeNode(child);
101
264
  }
102
265
  setProperty(node, key, value) {
103
- const id = nid(node);
266
+ const id = requireNid(node, "setProperty");
267
+ if (key === "children") {
268
+ // children is structural: set-children keeps the node and swaps only its
269
+ // child list (reused children keep their id).
270
+ const ops = compileHastChildrenToOpstream(value);
271
+ if (!ops)
272
+ throw unencodableContentError(value);
273
+ this.#commandBuffer.setChildrenOpstream(id, ops);
274
+ return;
275
+ }
104
276
  if (key === "data") {
105
277
  this.#commandBuffer.setProperty(id, key, value != null ? JSON.stringify(value) : null);
106
278
  return;
107
279
  }
108
280
  if (node.type === "element") {
109
- // Fast binary path, no materialization, no JSON serialization
110
281
  this.#commandBuffer.setProperty(id, key, value);
111
282
  return;
112
283
  }
113
284
  if (node.type === "mdxJsxFlowElement" || node.type === "mdxJsxTextElement") {
114
- // 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.
115
290
  const pending = this.#pendingNodes.get(id);
116
- const current = (pending ?? node);
117
- const updated = { ...current };
118
- const attrs = [...(updated.attributes ?? [])];
119
- const idx = attrs.findIndex((a) => a.type === "mdxJsxAttribute" && a.name === key);
120
- if (idx !== -1)
121
- attrs.splice(idx, 1);
122
- const attrValue = value === true || value === null || value === undefined
123
- ? null
124
- : typeof value === "string"
125
- ? value
126
- : String(value);
127
- attrs.push({ type: "mdxJsxAttribute", name: key, value: attrValue });
128
- updated.attributes = attrs;
129
- 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);
130
315
  return;
131
316
  }
132
- // Text-like nodes (text, comment, raw, expressions, esm), fast binary path.
133
- // 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.
134
319
  this.#commandBuffer.setProperty(id, key, value);
135
320
  }
136
321
  textContent(node) {
137
- 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"));
138
338
  }
139
339
  report({ message, node, severity = "error", }) {
140
340
  this.#diagnostics.push({ message, nodeId: node ? nid(node) : undefined, severity });
@@ -146,12 +346,8 @@ class HastVisitorContextImpl {
146
346
  return this.#diagnostics;
147
347
  }
148
348
  }
149
- function isFilteredVisitor(v) {
150
- return typeof v === "object" && v !== null && "filter" in v && "visit" in v;
151
- }
152
349
  /** Node types that use filtered visitors (have tag/component names). */
153
350
  const FILTERED_METHODS = new Set(["element", "mdxJsxFlowElement", "mdxJsxTextElement"]);
154
- /** Resolve subscriptions from a plugin instance. */
155
351
  export function resolveSubscriptions(plugin) {
156
352
  const subs = [];
157
353
  for (const [methodName, nodeType] of Object.entries(METHOD_TO_TYPE)) {
@@ -175,24 +371,8 @@ export function resolveSubscriptions(plugin) {
175
371
  }
176
372
  return subs;
177
373
  }
178
- /** Reverse map: method name → node type number */
179
- const METHOD_TO_TYPE = {
180
- element: HAST_ELEMENT,
181
- text: HAST_TEXT,
182
- comment: HAST_COMMENT,
183
- raw: HAST_RAW,
184
- doctype: 4, // HAST_DOCTYPE
185
- mdxJsxFlowElement: HAST_MDX_JSX_ELEMENT,
186
- mdxJsxTextElement: HAST_MDX_JSX_TEXT_ELEMENT,
187
- mdxFlowExpression: HAST_MDX_FLOW_EXPRESSION,
188
- mdxTextExpression: HAST_MDX_TEXT_EXPRESSION,
189
- mdxjsEsm: HAST_MDX_ESM,
190
- };
191
- /**
192
- * Selective walk path: Rust walks the tree, only sends matched nodes to JS.
193
- */
194
- const textDecoder = new TextDecoder("utf-8");
195
- /** 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]]));
196
376
  function decodeProperties(view, buf, pos) {
197
377
  const propCount = view.getUint16(pos, true);
198
378
  pos += 2;
@@ -200,121 +380,139 @@ function decodeProperties(view, buf, pos) {
200
380
  for (let i = 0; i < propCount; i++) {
201
381
  const nameLen = view.getUint16(pos, true);
202
382
  pos += 2;
203
- const name = textDecoder.decode(buf.subarray(pos, pos + nameLen));
383
+ const name = rstr(buf, pos, nameLen);
204
384
  pos += nameLen;
205
385
  const kind = buf[pos];
206
386
  pos += 1;
207
387
  const valLen = view.getUint16(pos, true);
208
388
  pos += 2;
209
- const valStr = textDecoder.decode(buf.subarray(pos, pos + valLen));
389
+ const valStr = rstr(buf, pos, valLen);
210
390
  pos += valLen;
211
- switch (kind) {
212
- case 0: // PROP_STRING
213
- properties[name] = valStr;
214
- break;
215
- case 1: // PROP_BOOL_TRUE
216
- properties[name] = true;
217
- break;
218
- case 2: // PROP_BOOL_FALSE
219
- break;
220
- case 3: // PROP_SPACE_SEP
221
- properties[name] = valStr.split(" ").filter((s) => s.length > 0);
222
- break;
223
- case 5: // PROP_INT
224
- properties[name] = Number(valStr);
225
- break;
226
- }
391
+ properties[name] = decodeElementProp(kind, valStr);
227
392
  }
228
393
  return properties;
229
394
  }
230
- /** Decode the 24-byte position block written by `serialize_hast_node_inline`. */
231
- function readPositionPrefix(view, offset) {
232
- const startOffset = view.getUint32(offset, true);
233
- const endOffset = view.getUint32(offset + 4, true);
234
- const startLine = view.getUint32(offset + 8, true);
235
- const startColumn = view.getUint32(offset + 12, true);
236
- const endLine = view.getUint32(offset + 16, true);
237
- const endColumn = view.getUint32(offset + 20, true);
238
- if (startLine === 0 && startOffset === 0)
239
- return undefined;
240
- return {
241
- start: { offset: startOffset, line: startLine, column: startColumn },
242
- end: { offset: endOffset, line: endLine, column: endColumn },
243
- };
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;
244
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;
245
411
  /**
246
- * Walk-path element node: uses prototype getters instead of per-instance
247
- * Object.defineProperty. V8 optimises shared hidden classes far better,
248
- * this is ~16x faster for construction than the defineProperty approach.
249
- *
250
- * The buffer reference data is stored on private instance fields so the
251
- * 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.
252
419
  */
253
420
  class WalkElement {
254
421
  type = "element";
255
- /** @internal */ _view;
256
- /** @internal */ _buf;
257
- /** @internal */ _propsPos;
258
- /** @internal */ _childIds;
259
- /** @internal */ _resolver;
260
- get properties() {
261
- const val = decodeProperties(this._view, this._buf, this._propsPos);
262
- Object.defineProperty(this, "properties", {
263
- value: val,
264
- 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 = {
265
460
  enumerable: true,
266
461
  configurable: true,
267
- });
268
- return val;
269
- }
270
- get children() {
271
- const val = this._resolver.materializeChildren(this._childIds);
272
- Object.defineProperty(this, "children", {
273
- value: val,
274
- 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 = {
275
474
  enumerable: true,
276
475
  configurable: true,
277
- });
278
- 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
+ };
279
487
  }
280
488
  }
281
489
  /** Read the tail of a matched element node (tag + properties).
282
490
  * Common prelude (data/position/children) is already consumed by `readMatchedNode`. */
283
- function readElementFromBinary(view, buf, offset, nodeId, resolver, position, childIds, data) {
491
+ function readElementFromBinary(view, buf, offset, nodeId, resolver, position, childIdsPos, childTypesPos, childCount, data) {
284
492
  let pos = offset;
285
493
  // Eager: tagName (almost always accessed by visitors)
286
494
  const tagLen = view.getUint16(pos, true);
287
495
  pos += 2;
288
- const tagName = textDecoder.decode(buf.subarray(pos, pos + tagLen));
496
+ const tagName = rstr(buf, pos, tagLen);
289
497
  pos += tagLen;
290
- const propsPos = pos;
291
- // Build node using class (prototype getters, no per-instance defineProperty)
292
- const node = new WalkElement();
293
- 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);
294
500
  if (position !== undefined)
295
501
  node.position = position;
296
502
  if (data !== null)
297
503
  node.data = data;
298
- node._view = view;
299
- node._buf = buf;
300
- node._propsPos = propsPos;
301
- node._childIds = childIds;
302
- node._resolver = resolver;
303
- nodeIdMap.set(node, nodeId);
304
504
  return node;
305
505
  }
306
- /** Read a text/comment/raw/expression node from the binary data section. */
307
- const TEXT_NODE_TYPES = {
308
- 2: "text",
309
- 3: "comment",
310
- 5: "raw",
311
- [HAST_MDX_FLOW_EXPRESSION]: "mdxFlowExpression",
312
- [HAST_MDX_TEXT_EXPRESSION]: "mdxTextExpression",
313
- [HAST_MDX_ESM]: "mdxjsEsm",
314
- };
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]));
315
508
  function readTextFromBinary(view, buf, offset, nodeId, nodeType, position, data) {
316
509
  const valLen = view.getUint32(offset, true);
317
- 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;
318
516
  const base = { type: TEXT_NODE_TYPES[nodeType], value };
319
517
  if (position !== undefined)
320
518
  base.position = position;
@@ -330,13 +528,11 @@ function readTextFromBinary(view, buf, offset, nodeId, nodeType, position, data)
330
528
  }
331
529
  return node;
332
530
  }
333
- /** Read an MDX JSX element from the binary data section. */
334
- 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) {
335
532
  let pos = offset;
336
- // Name
337
533
  const nameLen = view.getUint16(pos, true);
338
534
  pos += 2;
339
- const name = nameLen > 0 ? textDecoder.decode(buf.subarray(pos, pos + nameLen)) : null;
535
+ const name = nameLen > 0 ? rstr(buf, pos, nameLen) : null;
340
536
  pos += nameLen;
341
537
  // Attributes: [kind: u8][nameLen: u16][name][valLen: u32][val]
342
538
  const attrCount = view.getUint16(pos, true);
@@ -347,30 +543,13 @@ function readMdxJsxFromBinary(view, buf, offset, nodeId, nodeType, resolver, pos
347
543
  pos += 1;
348
544
  const attrNameLen = view.getUint16(pos, true);
349
545
  pos += 2;
350
- const attrName = textDecoder.decode(buf.subarray(pos, pos + attrNameLen));
546
+ const attrName = rstr(buf, pos, attrNameLen);
351
547
  pos += attrNameLen;
352
548
  const attrValLen = view.getUint32(pos, true);
353
549
  pos += 4;
354
- const attrVal = textDecoder.decode(buf.subarray(pos, pos + attrValLen));
550
+ const attrVal = rstr(buf, pos, attrValLen);
355
551
  pos += attrValLen;
356
- switch (kind) {
357
- case 0: // BooleanProp
358
- attributes.push({ type: "mdxJsxAttribute", name: attrName, value: null });
359
- break;
360
- case 1: // LiteralProp
361
- attributes.push({ type: "mdxJsxAttribute", name: attrName, value: attrVal });
362
- break;
363
- case 2: // ExpressionProp
364
- attributes.push({
365
- type: "mdxJsxAttribute",
366
- name: attrName,
367
- value: { type: "mdxJsxAttributeValueExpression", value: attrVal },
368
- });
369
- break;
370
- case 3: // Spread
371
- attributes.push({ type: "mdxJsxExpressionAttribute", value: attrVal });
372
- break;
373
- }
552
+ attributes.push(decodeMdxJsxAttr(kind, attrName, attrVal));
374
553
  }
375
554
  const typeName = nodeType === HAST_MDX_JSX_ELEMENT ? "mdxJsxFlowElement" : "mdxJsxTextElement";
376
555
  const base = { type: typeName, name, attributes };
@@ -379,39 +558,40 @@ function readMdxJsxFromBinary(view, buf, offset, nodeId, nodeType, resolver, pos
379
558
  if (data !== null)
380
559
  base.data = data;
381
560
  nodeIdMap.set(base, nodeId);
382
- makeLazyChildren(base, childIds, resolver);
561
+ makeLazyChildren(base, view, buf, childIdsPos, childTypesPos, childCount, resolver);
383
562
  return base;
384
563
  }
385
564
  function readMatchedNode(view, buf, offset, nodeId, nodeType, resolver) {
386
565
  let pos = offset;
387
566
  // Shared prelude (matches serialize_hast_node_inline / serialize_mdast_node_inline):
388
- // [data_len: u32][data_bytes][position: 24B][child_count: u16][child_ids: N×u32]
389
- // Data (JSON), eagerly parsed
567
+ // [data_len: u32][data_bytes][position: 24B][child_count: u32][child_ids: N×u32][child_types: N×u8]
390
568
  const dataLen = view.getUint32(pos, true);
391
569
  pos += 4;
392
570
  let data = null;
393
571
  if (dataLen > 0) {
394
- const jsonStr = textDecoder.decode(buf.subarray(pos, pos + dataLen));
572
+ const jsonStr = rstr(buf, pos, dataLen);
395
573
  try {
396
574
  data = JSON.parse(jsonStr);
397
575
  }
398
- catch {
399
- /* 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
+ }
400
580
  }
401
581
  pos += dataLen;
402
582
  }
403
- const position = readPositionPrefix(view, pos);
583
+ const position = readPosition(view, pos);
404
584
  pos += 24;
405
- const childCount = view.getUint16(pos, true);
406
- pos += 2;
407
- const childIds = [];
408
- for (let i = 0; i < childCount; i++) {
409
- childIds.push(view.getUint32(pos, true));
410
- pos += 4;
411
- }
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;
412
592
  // Dispatch to type-specific tail (pos now sits at the type-specific section)
413
593
  if (nodeType === HAST_ELEMENT) {
414
- return readElementFromBinary(view, buf, pos, nodeId, resolver, position, childIds, data);
594
+ return readElementFromBinary(view, buf, pos, nodeId, resolver, position, childIdsPos, childTypesPos, childCount, data);
415
595
  }
416
596
  else if (nodeType === HAST_TEXT ||
417
597
  nodeType === HAST_COMMENT ||
@@ -422,10 +602,10 @@ function readMatchedNode(view, buf, offset, nodeId, nodeType, resolver) {
422
602
  return readTextFromBinary(view, buf, pos, nodeId, nodeType, position, data);
423
603
  }
424
604
  else if (nodeType === HAST_MDX_JSX_ELEMENT || nodeType === HAST_MDX_JSX_TEXT_ELEMENT) {
425
- 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);
426
606
  }
427
- // Fallback: minimal node carrying whatever prelude data we found
428
- 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})` };
429
609
  if (position !== undefined)
430
610
  base.position = position;
431
611
  if (data !== null)
@@ -434,71 +614,42 @@ function readMatchedNode(view, buf, offset, nodeId, nodeType, resolver) {
434
614
  nodeIdMap.set(node, nodeId);
435
615
  return node;
436
616
  }
437
- // Shared helpers
438
- /**
439
- * Lazy child materializer, serializes the handle's buffer once when first
440
- * child is accessed, then materializes children from it via HastReader.
441
- */
442
- class LazyChildResolver {
443
- #handle;
444
- #reader = null;
445
- constructor(handle) {
446
- this.#handle = handle;
617
+ class HastLazyChildResolver extends LazyChildResolver {
618
+ createReader(wire) {
619
+ return new HastReader(wire);
447
620
  }
448
- #ensure() {
449
- if (!this.#reader) {
450
- this.#reader = new HastReader(serializeHandle(this.#handle));
451
- }
452
- return this.#reader;
453
- }
454
- materializeChildren(childIds) {
455
- const reader = this.#ensure();
456
- return childIds.map((id) => {
457
- const node = materializeHastNode(reader, id);
458
- this.attachLazyData(node, id);
459
- return node;
460
- });
621
+ materializeNode(reader, nodeId) {
622
+ return materializeHastNode(reader, nodeId);
461
623
  }
462
- /** Attach a lazy `data` getter backed by the Rust arena's node_data. */
463
- attachLazyData(node, nodeId) {
464
- const handle = this.#handle;
465
- Object.defineProperty(node, "data", {
466
- get() {
467
- const json = napiGetNodeData(handle, nodeId);
468
- const val = json ? JSON.parse(json) : null;
469
- Object.defineProperty(this, "data", {
470
- value: val,
471
- writable: true,
472
- enumerable: true,
473
- configurable: true,
474
- });
475
- return val;
476
- },
477
- configurable: true,
478
- enumerable: true,
479
- });
624
+ readParentId(reader, nodeId) {
625
+ return reader.getParentId(nodeId);
626
+ }
627
+ readChildIds(reader, nodeId) {
628
+ return reader.getChildIds(nodeId);
480
629
  }
481
630
  }
482
- /** Create a lazy `children` property backed by the handle. */
483
- 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) {
484
636
  Object.defineProperty(node, "children", {
485
637
  get() {
486
- const children = resolver.materializeChildren(childIds);
638
+ const val = readChildStubs(view, buf, childIdsPos, childTypesPos, childCount, resolver);
487
639
  Object.defineProperty(this, "children", {
488
- value: children,
640
+ value: val,
489
641
  writable: true,
490
642
  enumerable: true,
491
643
  configurable: true,
492
644
  });
493
- return children;
645
+ return val;
494
646
  },
495
- configurable: true,
496
647
  enumerable: true,
648
+ configurable: true,
497
649
  });
498
650
  }
499
- /** Handle a visitor result (sync).
500
- * If the result is the same object as the input node, treat it as a no-op
501
- * 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. */
502
653
  function handleVisitResult(result, nodeId, returnBuffer, deferred, originalNode) {
503
654
  if (result == null)
504
655
  return deferred;
@@ -509,7 +660,7 @@ function handleVisitResult(result, nodeId, returnBuffer, deferred, originalNode)
509
660
  list.push({ nodeId, promise: result, originalNode });
510
661
  return list;
511
662
  }
512
- returnBuffer.replaceRawJson(nodeId, JSON.stringify(markHast(result)));
663
+ emitHastTree(returnBuffer, "replace", nodeId, result);
513
664
  return deferred;
514
665
  }
515
666
  /**
@@ -521,7 +672,7 @@ function dispatchMatches(matchBuf, subs, ctx, returnBuffer, resolver) {
521
672
  const matchCount = matchView.getUint32(0, true);
522
673
  let deferred = null;
523
674
  for (let i = 0; i < matchCount; i++) {
524
- const indexBase = 4 + i * 12;
675
+ const indexBase = 4 + i * 10;
525
676
  const nodeId = matchView.getUint32(indexBase, true);
526
677
  const subIndex = matchBuf[indexBase + 4];
527
678
  const dataOffset = matchView.getUint32(indexBase + 6, true);
@@ -532,26 +683,6 @@ function dispatchMatches(matchBuf, subs, ctx, returnBuffer, resolver) {
532
683
  }
533
684
  return deferred;
534
685
  }
535
- /** Merge return-value + context command buffers and release internals. */
536
- function mergeAndReset(returnBuffer, ctx) {
537
- const ctxCmdBuf = ctx.getCommandBuffer();
538
- const ctxBuf = ctxCmdBuf.getBuffer();
539
- const retBuf = returnBuffer.getBuffer();
540
- const totalLen = retBuf.length + ctxBuf.length;
541
- let merged;
542
- if (totalLen === 0) {
543
- merged = new Uint8Array(0);
544
- }
545
- else {
546
- merged = new Uint8Array(totalLen);
547
- merged.set(retBuf, 0);
548
- merged.set(ctxBuf, retBuf.length);
549
- }
550
- returnBuffer.reset();
551
- ctxCmdBuf.reset();
552
- return { merged, hasMutations: totalLen > 0 };
553
- }
554
- // Handle-based visitor
555
686
  /**
556
687
  * Walk a handle's arena in Rust, dispatch matched nodes to JS visitor functions,
557
688
  * and apply mutations back to the handle. No arena buffers cross NAPI.
@@ -560,29 +691,34 @@ function mergeAndReset(returnBuffer, ctx) {
560
691
  * replaced earlier in the same pass (the caller warns when non-zero), or a
561
692
  * Promise of that count if any visitor is async.
562
693
  */
563
- export function visitHastHandle(handle, plugin, subs, source, fileURL) {
694
+ export function visitHastHandle(handle, plugin, subs, source, fileURL, data = {}) {
564
695
  const getSource = typeof source === "function" ? source : () => source;
565
- const ctx = new HastVisitorContextImpl(handle, getSource, fileURL);
696
+ const resolver = new HastLazyChildResolver(handle);
697
+ const ctx = new HastVisitorContextImpl(handle, getSource, fileURL, resolver, data);
566
698
  const returnBuffer = new CommandBuffer();
567
- const resolver = new LazyChildResolver(handle);
568
699
  const rustSubs = subs.map((s) => ({ nodeType: s.nodeType, tagFilter: s.tagFilter }));
569
700
  const deferred = dispatchMatches(walkHandle(handle, rustSubs), subs, ctx, returnBuffer, resolver);
570
701
  if (deferred) {
571
702
  return Promise.all(deferred.map((d) => d.promise.then((result) => ({ nodeId: d.nodeId, result, originalNode: d.originalNode })))).then((results) => {
572
703
  for (const { nodeId, result, originalNode } of results) {
573
704
  if (result != null && result !== originalNode) {
574
- returnBuffer.replaceRawJson(nodeId, JSON.stringify(markHast(result)));
705
+ emitHastTree(returnBuffer, "replace", nodeId, result);
575
706
  }
576
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();
577
711
  return applyMutations(handle, returnBuffer, ctx);
578
712
  });
579
713
  }
714
+ resolver.seal();
580
715
  return applyMutations(handle, returnBuffer, ctx);
581
716
  }
582
717
  /** Returns the number of patches dropped as stranded (0 when none). */
583
718
  function applyMutations(handle, returnBuffer, ctx) {
584
719
  const { merged, hasMutations } = mergeAndReset(returnBuffer, ctx);
585
720
  if (hasMutations) {
721
+ markHandleMutated(handle);
586
722
  return applyCommandsToHandle(handle, merged);
587
723
  }
588
724
  return 0;