satteri 0.10.5 → 0.11.3

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 (90) hide show
  1. package/README.md +11 -0
  2. package/dist/arena-reader.d.ts +13 -0
  3. package/dist/arena-reader.js +104 -0
  4. package/dist/binding.browser.d.ts +1 -1
  5. package/dist/binding.browser.js +1 -1
  6. package/dist/binding.d.ts +1 -1
  7. package/dist/binding.js +1 -1
  8. package/dist/byte-writer.d.ts +0 -18
  9. package/dist/byte-writer.js +6 -30
  10. package/dist/child-stub.d.ts +0 -17
  11. package/dist/child-stub.js +6 -22
  12. package/dist/command-buffer.d.ts +5 -26
  13. package/dist/command-buffer.js +28 -39
  14. package/dist/compile-options.d.ts +11 -0
  15. package/dist/compile-options.js +101 -0
  16. package/dist/compile-types.d.ts +342 -0
  17. package/dist/compile-types.js +1 -0
  18. package/dist/compile.d.ts +22 -328
  19. package/dist/compile.js +133 -383
  20. package/dist/directive-types.d.ts +1 -0
  21. package/dist/directive-types.js +1 -6
  22. package/dist/generated/arena-layout.d.ts +6 -4
  23. package/dist/generated/arena-layout.js +6 -7
  24. package/dist/generated/fused-wire.d.ts +3 -0
  25. package/dist/generated/fused-wire.js +229 -0
  26. package/dist/generated/parse-options.d.ts +1 -0
  27. package/dist/generated/parse-options.js +1 -5
  28. package/dist/generated/wire-constants.d.ts +4 -0
  29. package/dist/generated/wire-constants.js +34 -42
  30. package/dist/handles.d.ts +0 -7
  31. package/dist/hast/child-stub.d.ts +1 -8
  32. package/dist/hast/child-stub.js +14 -13
  33. package/dist/hast/element-props.d.ts +13 -0
  34. package/dist/hast/element-props.js +52 -6
  35. package/dist/hast/generated/node-types.d.ts +1 -9
  36. package/dist/hast/generated/node-types.js +1 -9
  37. package/dist/hast/generated/walk-decode.d.ts +10 -0
  38. package/dist/hast/generated/walk-decode.js +61 -0
  39. package/dist/hast/hast-materializer.d.ts +3 -10
  40. package/dist/hast/hast-materializer.js +73 -47
  41. package/dist/hast/hast-reader.d.ts +9 -38
  42. package/dist/hast/hast-reader.js +82 -271
  43. package/dist/hast/hast-visitor.d.ts +29 -16
  44. package/dist/hast/hast-visitor.js +200 -508
  45. package/dist/hast/hast-walk.d.ts +18 -0
  46. package/dist/hast/hast-walk.js +264 -0
  47. package/dist/index.d.ts +5 -5
  48. package/dist/index.js +4 -12
  49. package/dist/lazy-child-resolver.d.ts +1 -21
  50. package/dist/lazy-child-resolver.js +11 -32
  51. package/dist/materializer-cache.d.ts +5 -29
  52. package/dist/materializer-cache.js +29 -101
  53. package/dist/mdast/child-stub.d.ts +1 -12
  54. package/dist/mdast/child-stub.js +18 -28
  55. package/dist/mdast/column-align.d.ts +1 -1
  56. package/dist/mdast/column-align.js +0 -2
  57. package/dist/mdast/generated/layout.d.ts +0 -16
  58. package/dist/mdast/generated/layout.js +6 -47
  59. package/dist/mdast/generated/node-types.d.ts +1 -9
  60. package/dist/mdast/generated/node-types.js +3 -9
  61. package/dist/mdast/mdast-materializer.d.ts +3 -11
  62. package/dist/mdast/mdast-materializer.js +91 -63
  63. package/dist/mdast/mdast-reader.d.ts +15 -63
  64. package/dist/mdast/mdast-reader.js +132 -269
  65. package/dist/mdast/mdast-visitor.d.ts +23 -15
  66. package/dist/mdast/mdast-visitor.js +189 -294
  67. package/dist/mdast/mdast-walk.d.ts +13 -0
  68. package/dist/mdast/mdast-walk.js +144 -0
  69. package/dist/mdx-attr.js +0 -3
  70. package/dist/mdx-types.js +1 -6
  71. package/dist/op-stream.d.ts +4 -15
  72. package/dist/op-stream.js +31 -20
  73. package/dist/phantom.d.ts +0 -1
  74. package/dist/phantom.js +1 -9
  75. package/dist/plugin-pipeline.d.ts +32 -0
  76. package/dist/plugin-pipeline.js +177 -0
  77. package/dist/plugin.d.ts +14 -8
  78. package/dist/plugin.js +5 -10
  79. package/dist/types.d.ts +0 -1
  80. package/dist/visitor-node.d.ts +12 -0
  81. package/dist/visitor-node.js +23 -0
  82. package/dist/visitor-shared.d.ts +26 -30
  83. package/dist/visitor-shared.js +34 -36
  84. package/dist/wire-read.d.ts +0 -22
  85. package/dist/wire-read.js +3 -24
  86. package/index.d.ts +19 -10
  87. package/index.js +56 -53
  88. package/package.json +6 -12
  89. package/dist/string-pool.d.ts +0 -13
  90. package/dist/string-pool.js +0 -91
@@ -1,62 +1,15 @@
1
- import { materializeHastNode } from "./hast-materializer.js";
2
- import { HastReader, HAST_ROOT, 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";
4
- import { acquireCommandBuffer, releaseCommandBuffer, CommandBuffer, STRUCTURAL_CMD, } from "../command-buffer.js";
1
+ import { applyCommandsToHandle, textContentHandle, walkHandle } from "#binding";
2
+ import { acquireCommandBuffer, CommandBuffer, releaseCommandBuffer, STRUCTURAL_CMD, } from "../command-buffer.js";
5
3
  import { CMD_SET_CHILDREN } from "../generated/wire-constants.js";
6
- 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";
7
- import { restorePhantomSpaces } from "../phantom.js";
8
- import { decodeMdxJsxAttr } from "../mdx-attr.js";
9
- import { decodeElementProp } from "./element-props.js";
10
- import { readPosition, rstr } from "../wire-read.js";
11
- import { walkHandle, applyCommandsToHandle, textContentHandle, parseExpression as napiParseExpression, parseEsm as napiParseEsm, } from "#binding";
12
- import { asArray, makeRequireNid, mergeAndReset, ROOT_NODE_ID, requireRootReplacement, rootReplacementError, crossPipelineForeign, FOREIGN_REF, unencodableContentError, } from "../visitor-shared.js";
13
- import { LazyChildResolver, markHandleMutated, registerEpochCacheSlot, } from "../lazy-child-resolver.js";
14
- import { HastChildStub } from "./child-stub.js";
15
- /** Attach `parseExpression()` to an MDX expression or ESM node. */
16
- function attachParseExpression(node, parseFn) {
17
- Object.defineProperty(node, "parseExpression", {
18
- value() {
19
- const value = this.value;
20
- if (typeof value !== "string")
21
- return null;
22
- const json = parseFn(value);
23
- if (json == null)
24
- return null;
25
- return JSON.parse(json);
26
- },
27
- writable: false,
28
- enumerable: false,
29
- configurable: true,
30
- });
31
- }
32
- /**
33
- * Arena identity of a node, rejecting impostors — the one place the
34
- * spread/identity invariant is enforced. A spread copy of a matched node or
35
- * stub must read as NEW content: trusting a copied id would splice the
36
- * original in as a ref and drop the copy's edits. Walk elements carry their
37
- * id in a private field behind `instanceof` (spread copies fail the check);
38
- * other walk-built nodes are keyed in the WeakMap (invisible to spread);
39
- * `HastChildStub`s (enumerable `_id`, but that key is ignored on plain
40
- * objects) are recognized by `instanceof`. Plain objects are trusted only via
41
- * the WeakMap or a NON-enumerable `_nodeId` (the materializers' convention,
42
- * which spread cannot copy).
43
- */
44
- function nid(node, refs) {
45
- if (node instanceof WalkElement)
46
- return node._refs === refs ? node._nid : FOREIGN_REF;
47
- if (node instanceof HastChildStub)
48
- return node._refs === refs ? node._id : FOREIGN_REF;
49
- const id = refs.get(node);
50
- if (id !== undefined)
51
- return id;
52
- const d = Object.getOwnPropertyDescriptor(node, "_nodeId");
53
- if (d !== undefined && !d.enumerable)
54
- return FOREIGN_REF;
55
- return crossPipelineForeign(node);
56
- }
57
- const requireNid = makeRequireNid(nid);
58
- /** `wrapNode` allowlist: an unlisted type fails loud instead of silently
59
- * mis-wrapping. */
4
+ import { emitMdxAttr, OF_EXPLICIT, OF_NAME, OF_TAGNAME, OF_VALUE, OpWriter, PROP_BOOL_FALSE, PROP_BOOL_TRUE, PROP_INT, PROP_TOKEN_LIST, PROP_STRING, } from "../op-stream.js";
5
+ import { HAST_OPSTREAM_TYPES, NAME_TO_TYPE, VISITOR_KEYS } from "./generated/node-types.js";
6
+ import {} from "./hast-materializer.js";
7
+ import { encodeTokenList } from "./element-props.js";
8
+ import { HAST_ELEMENT, HAST_MDX_JSX_ELEMENT, HAST_MDX_JSX_TEXT_ELEMENT, HAST_ROOT, HastReader, } from "./hast-reader.js";
9
+ import { getNodeId, HastLazyChildResolver, readMatchedNode } from "./hast-walk.js";
10
+ import { LazyChildResolver, markHandleMutated } from "../lazy-child-resolver.js";
11
+ import { asArray, collectCommands, FOREIGN_REF, foreignContentError, makeRequireNid, requireRootReplacement, ROOT_NODE_ID, rootReplacementError, unencodableContentError, withMdxJsxAttribute, } from "../visitor-shared.js";
12
+ const requireNid = makeRequireNid(getNodeId);
60
13
  const HAST_PARENT_TYPES = ["element", "mdxJsxFlowElement", "mdxJsxTextElement"];
61
14
  const HAST_PARENT_TYPE_SET = new Set(HAST_PARENT_TYPES);
62
15
  /** A leaf wrapper would make the patch engine drop or displace the wrapped node. */
@@ -71,11 +24,9 @@ function assertHastWrapParent(parentNode) {
71
24
  function hastReusedId(node, refs) {
72
25
  if (node === null || typeof node !== "object")
73
26
  return undefined;
74
- const id = nid(node, refs);
27
+ const id = getNodeId(node, refs);
75
28
  return id !== undefined && id !== FOREIGN_REF ? id : undefined;
76
29
  }
77
- /** Emit a set-children command in place: a root-wrapped child list, the shape
78
- * `Patch::SetChildren` splices in. Reused children become refs. */
79
30
  function emitHastChildrenCommand(buffer, id, children, refs) {
80
31
  if (!Array.isArray(children))
81
32
  return false;
@@ -89,21 +40,61 @@ function emitHastChildrenCommand(buffer, id, children, refs) {
89
40
  return true;
90
41
  });
91
42
  }
92
- /** Encode `node` as the `op` structural command, emitting the op-stream
93
- * payload directly into the command buffer (no intermediate copy). HAST
94
- * content is always a declarative node (no raw escape hatch), so it
95
- * compiles or it's a hard error. */
96
- function emitHastTree(buffer, op, id, node, refs) {
97
- const ok = buffer.emitOpstreamCommand(STRUCTURAL_CMD[op], id, () => emitHastOp(buffer, node, true, refs));
43
+ function emitHastTree(buffer, op, id, node, refs, allowRootRef = false) {
44
+ const ok = buffer.emitOpstreamCommand(STRUCTURAL_CMD[op], id, () => emitHastOp(buffer, node, true, refs, false, allowRootRef));
98
45
  if (!ok)
99
46
  throw unencodableContentError(node);
100
47
  }
101
- /** Separate from the per-node encoder, which rejects a `root` payload. */
48
+ /** Replace `id` with several nodes in one command, root-wrapped so the engine
49
+ * splices the children in place of the node. */
50
+ function emitHastMultiReplace(buffer, id, nodes, refs) {
51
+ const ok = buffer.emitOpstreamCommand(STRUCTURAL_CMD.replace, id, () => {
52
+ buffer.open(HAST_ROOT);
53
+ for (const n of nodes)
54
+ if (!emitHastOp(buffer, n, false, refs))
55
+ return false;
56
+ buffer.close();
57
+ return true;
58
+ });
59
+ if (!ok)
60
+ throw unencodableContentError(nodes);
61
+ }
62
+ // Root replacement needs a separate encoder because per-node encoding rejects root payloads.
102
63
  function emitHastRootReplace(buffer, root, refs) {
103
64
  const ok = buffer.emitOpstreamCommand(STRUCTURAL_CMD.replace, ROOT_NODE_ID, () => emitHastRootOp(buffer, root, refs));
104
65
  if (!ok)
105
66
  throw unencodableContentError(root);
106
67
  }
68
+ /** A document is encoded standalone, so no node can be a ref into a tree. */
69
+ const NO_REFS = new WeakMap();
70
+ const HAST_DOCTYPE = NAME_TO_TYPE.doctype;
71
+ /**
72
+ * Encode a standalone HAST tree as an op-stream document: one `root` wrapping
73
+ * `nodes`, which Rust replays into an arena of its own and serializes. The
74
+ * bytes are handed to `use` because they are a view into a pooled buffer,
75
+ * valid only until it is released.
76
+ */
77
+ export function encodeHastDocument(nodes, use) {
78
+ const w = acquireCommandBuffer();
79
+ try {
80
+ w.open(HAST_ROOT);
81
+ for (const node of nodes) {
82
+ // Carries no fields, and patch content has nothing to attach it to.
83
+ if (node.type === "doctype" && HAST_DOCTYPE !== undefined) {
84
+ w.open(HAST_DOCTYPE);
85
+ w.close();
86
+ continue;
87
+ }
88
+ if (!emitHastOp(w, node, false, NO_REFS, true))
89
+ throw unencodableContentError(node);
90
+ }
91
+ w.close();
92
+ return use(w.getBuffer());
93
+ }
94
+ finally {
95
+ releaseCommandBuffer(w);
96
+ }
97
+ }
107
98
  function emitHastRootOp(w, n, refs) {
108
99
  w.open(HAST_ROOT);
109
100
  if (n.data != null)
@@ -117,21 +108,27 @@ function emitHastRootOp(w, n, refs) {
117
108
  w.close();
118
109
  return true;
119
110
  }
120
- function emitHastOp(w, node, isRoot, refs) {
111
+ function emitHastOp(w, node, isRoot, refs, document = false, allowRootRef = false) {
121
112
  if (node === null || typeof node !== "object")
122
113
  return false;
123
- if (!isRoot) {
124
- const id = hastReusedId(node, refs);
125
- if (id !== undefined) {
126
- w.ref(id);
127
- return true;
128
- }
114
+ const id = getNodeId(node, refs);
115
+ if (id === FOREIGN_REF && !document)
116
+ throw foreignContentError();
117
+ if ((!isRoot || allowRootRef) && id !== undefined && id !== FOREIGN_REF) {
118
+ w.ref(id);
119
+ return true;
129
120
  }
130
121
  const n = node;
131
122
  const type = HAST_OPSTREAM_TYPES[n.type];
132
123
  if (type === undefined)
133
124
  return false;
125
+ // Standalone HTML serialization does not need MDX support or JSON-serializable metadata.
126
+ if (document && n.type.startsWith("mdx"))
127
+ return true;
134
128
  w.open(type);
129
+ // A standalone document is only serialized, and has no source for a position to point into.
130
+ if (!document)
131
+ w.position(n.position);
135
132
  if (type === HAST_ELEMENT) {
136
133
  w.str(OF_TAGNAME, typeof n.tagName === "string" ? n.tagName : "div");
137
134
  const props = n.properties;
@@ -142,7 +139,6 @@ function emitHastOp(w, node, isRoot, refs) {
142
139
  }
143
140
  }
144
141
  else if (type === HAST_MDX_JSX_ELEMENT || type === HAST_MDX_JSX_TEXT_ELEMENT) {
145
- // Name falls back to tagName, matching `encode_hast_js_node_data`.
146
142
  const name = typeof n.name === "string" ? n.name : typeof n.tagName === "string" ? n.tagName : "";
147
143
  if (name !== "")
148
144
  w.str(OF_NAME, name);
@@ -157,19 +153,17 @@ function emitHastOp(w, node, isRoot, refs) {
157
153
  else {
158
154
  w.str(OF_VALUE, typeof n.value === "string" ? n.value : "");
159
155
  }
160
- if (n.data != null)
156
+ if (!document && n.data != null)
161
157
  w.data(n.data);
162
158
  const children = n.children;
163
159
  if (Array.isArray(children)) {
164
160
  for (const c of children)
165
- if (!emitHastOp(w, c, false, refs))
161
+ if (!emitHastOp(w, c, false, refs, document))
166
162
  return false;
167
163
  }
168
164
  w.close();
169
165
  return true;
170
166
  }
171
- /** Emit one element property, mirroring `encode_hast_js_node_data` exactly:
172
- * bool/string/number/array → kind; null/object → skip. */
173
167
  function emitHastProp(w, name, value) {
174
168
  if (value === true)
175
169
  w.prop(name, PROP_BOOL_TRUE, "");
@@ -177,23 +171,22 @@ function emitHastProp(w, name, value) {
177
171
  w.prop(name, PROP_BOOL_FALSE, "");
178
172
  else if (typeof value === "string")
179
173
  w.prop(name, PROP_STRING, value);
180
- else if (typeof value === "number")
181
- w.prop(name, PROP_INT, String(value));
174
+ // A NaN property is dropped, not rendered as `"NaN"`, matching hast.
175
+ else if (typeof value === "number") {
176
+ if (!Number.isNaN(value))
177
+ w.prop(name, PROP_INT, String(value));
178
+ }
182
179
  else if (Array.isArray(value))
183
- w.prop(name, PROP_SPACE_SEP, value.filter((v) => typeof v === "string").join(" "));
180
+ w.prop(name, PROP_TOKEN_LIST, encodeTokenList(value));
184
181
  }
185
182
  class HastVisitorContextImpl {
186
183
  #commandBuffer = acquireCommandBuffer();
187
184
  #diagnostics;
188
- /** Track accumulated node state for multiple setProperty calls on the same node. */
189
185
  #pendingNodes = new Map();
190
186
  #handle;
191
187
  #getSource;
192
188
  #resolver;
193
189
  #refs;
194
- /** One canonical object per parent id, so visitors can dedupe by identity.
195
- * Null until the first `parent()` call; most passes never make one. */
196
- #parentsById = null;
197
190
  fileURL;
198
191
  data;
199
192
  sourceFormat;
@@ -213,14 +206,32 @@ class HastVisitorContextImpl {
213
206
  return value;
214
207
  }
215
208
  removeNode(node) {
216
- this.#commandBuffer.removeNode(requireNid(node, "removeNode", this.#refs));
209
+ const id = requireNid(node, "removeNode", this.#refs);
210
+ this.#commandBuffer.removeNode(id);
211
+ this.#pendingNodes.delete(id);
217
212
  }
218
213
  replaceNode(node, newNode) {
219
214
  const id = requireNid(node, "replaceNode", this.#refs);
215
+ const onlyReplacement = Array.isArray(newNode)
216
+ ? newNode.length === 1
217
+ ? newNode[0]
218
+ : undefined
219
+ : newNode;
220
+ if (id === ROOT_NODE_ID &&
221
+ onlyReplacement !== undefined &&
222
+ hastReusedId(onlyReplacement, this.#refs) === id) {
223
+ return;
224
+ }
220
225
  if (Array.isArray(newNode)) {
221
226
  if (id === ROOT_NODE_ID && newNode.length > 1)
222
227
  throw rootReplacementError(newNode);
223
- // The last node carries the `replace` so refs back to the target still splice.
228
+ // One command, so the node's replacement is its whole slot and a ref back
229
+ // to it resolves to all of it rather than to the last element.
230
+ if (id !== ROOT_NODE_ID && newNode.length > 1) {
231
+ emitHastMultiReplace(this.#commandBuffer, id, newNode, this.#refs);
232
+ this.#pendingNodes.delete(id);
233
+ return;
234
+ }
224
235
  let previous;
225
236
  for (const n of newNode) {
226
237
  if (previous !== undefined)
@@ -228,36 +239,37 @@ class HastVisitorContextImpl {
228
239
  previous = n;
229
240
  }
230
241
  if (previous === undefined) {
231
- // Replacing with nothing drops the node, like removeNode.
232
242
  this.removeNode(node);
233
243
  }
234
244
  else if (id === ROOT_NODE_ID) {
235
245
  emitHastRootReplace(this.#commandBuffer, requireRootReplacement(previous), this.#refs);
236
246
  }
237
247
  else {
238
- emitHastTree(this.#commandBuffer, "replace", id, previous, this.#refs);
248
+ emitHastTree(this.#commandBuffer, "replace", id, previous, this.#refs, true);
239
249
  }
240
- // A stale queued replacement would win: setProperty folds into it, landing last.
241
- this.#pendingNodes.delete(id);
250
+ if (previous === undefined)
251
+ this.#pendingNodes.delete(id);
252
+ else
253
+ this.#pendingNodes.set(id, previous);
242
254
  return;
243
255
  }
244
256
  if (id === ROOT_NODE_ID) {
245
257
  emitHastRootReplace(this.#commandBuffer, requireRootReplacement(newNode), this.#refs);
246
258
  return;
247
259
  }
248
- emitHastTree(this.#commandBuffer, "replace", id, newNode, this.#refs);
249
- // Track the replacement so a later mdxJsx setProperty can fold into it.
260
+ emitHastTree(this.#commandBuffer, "replace", id, newNode, this.#refs, true);
250
261
  this.#pendingNodes.set(id, newNode);
251
262
  }
263
+ #splice(anchorId, content, op) {
264
+ for (const n of asArray(content)) {
265
+ emitHastTree(this.#commandBuffer, op, anchorId, n, this.#refs, true);
266
+ }
267
+ }
252
268
  insertBefore(node, newNode) {
253
- const id = requireNid(node, "insertBefore", this.#refs);
254
- for (const n of asArray(newNode))
255
- emitHastTree(this.#commandBuffer, "insertBefore", id, n, this.#refs);
269
+ this.#splice(requireNid(node, "insertBefore", this.#refs), newNode, "insertBefore");
256
270
  }
257
271
  insertAfter(node, newNode) {
258
- const id = requireNid(node, "insertAfter", this.#refs);
259
- for (const n of asArray(newNode))
260
- emitHastTree(this.#commandBuffer, "insertAfter", id, n, this.#refs);
272
+ this.#splice(requireNid(node, "insertAfter", this.#refs), newNode, "insertAfter");
261
273
  }
262
274
  wrapNode(node, parentNode) {
263
275
  const id = requireNid(node, "wrapNode", this.#refs);
@@ -270,26 +282,19 @@ class HastVisitorContextImpl {
270
282
  emitHastTree(this.#commandBuffer, "wrapNode", id, parentNode, this.#refs);
271
283
  }
272
284
  prependChild(node, childNode) {
273
- const id = requireNid(node, "prependChild", this.#refs);
274
- for (const n of asArray(childNode))
275
- emitHastTree(this.#commandBuffer, "prependChild", id, n, this.#refs);
285
+ this.#splice(requireNid(node, "prependChild", this.#refs), childNode, "prependChild");
276
286
  }
277
287
  appendChild(node, childNode) {
278
- const id = requireNid(node, "appendChild", this.#refs);
279
- for (const n of asArray(childNode))
280
- emitHastTree(this.#commandBuffer, "appendChild", id, n, this.#refs);
288
+ this.#splice(requireNid(node, "appendChild", this.#refs), childNode, "appendChild");
281
289
  }
282
290
  insertChildAt(node, index, childNode) {
283
291
  const children = "children" in node ? node.children : [];
284
- if (index <= 0 || children.length === 0) {
285
- this.prependChild(node, childNode);
286
- }
287
- else if (index >= children.length) {
288
- this.appendChild(node, childNode);
289
- }
290
- else {
291
- this.insertBefore(children[index], childNode);
292
- }
292
+ const [anchor, op] = index <= 0 || children.length === 0
293
+ ? [node, "prependChild"]
294
+ : index >= children.length
295
+ ? [node, "appendChild"]
296
+ : [children[index], "insertBefore"];
297
+ this.#splice(requireNid(anchor, "insertChildAt", this.#refs), childNode, op);
293
298
  }
294
299
  removeChildAt(node, index) {
295
300
  const child = "children" in node ? node.children[index] : undefined;
@@ -299,8 +304,6 @@ class HastVisitorContextImpl {
299
304
  setProperty(node, key, value) {
300
305
  const id = requireNid(node, "setProperty", this.#refs);
301
306
  if (key === "children") {
302
- // children is structural: set-children keeps the node and swaps only its
303
- // child list (reused children keep their id).
304
307
  if (!emitHastChildrenCommand(this.#commandBuffer, id, value, this.#refs)) {
305
308
  throw unencodableContentError(value);
306
309
  }
@@ -311,45 +314,67 @@ class HastVisitorContextImpl {
311
314
  return;
312
315
  }
313
316
  if (node.type === "element") {
314
- this.#commandBuffer.setProperty(id, key, value);
317
+ if (Array.isArray(value)) {
318
+ this.#commandBuffer.setTokenListProperty(id, key, encodeTokenList(value));
319
+ }
320
+ else {
321
+ // NaN drops the attribute, as it does on a built element.
322
+ const dropped = typeof value === "number" && Number.isNaN(value);
323
+ this.#commandBuffer.setProperty(id, key, dropped ? null : value);
324
+ }
315
325
  return;
316
326
  }
317
327
  if (node.type === "mdxJsxFlowElement" || node.type === "mdxJsxTextElement") {
318
- // MDX JSX nodes carry `attributes`, not `properties`. If a replacement is
319
- // already queued for this node, fold the attribute into it so the change
320
- // survives the rebuild. This spreads the queued replacement object, not
321
- // the matched node, so it never forces the matched node's children to
322
- // materialize.
323
- const pending = this.#pendingNodes.get(id);
324
- if (pending !== undefined) {
325
- const updated = { ...pending };
326
- const attrs = [...(updated.attributes ?? [])];
327
- const idx = attrs.findIndex((a) => a.type === "mdxJsxAttribute" && a.name === key);
328
- if (idx !== -1)
329
- attrs.splice(idx, 1);
330
- // Arrays space-join, matching the binary path's PROP_SPACE_SEP encoding
331
- // (hast convention for list-valued properties like className).
332
- const attrValue = value === true || value === null || value === undefined
333
- ? null
334
- : typeof value === "string"
335
- ? value
336
- : Array.isArray(value)
337
- ? value.join(" ")
338
- : String(value);
339
- attrs.push({ type: "mdxJsxAttribute", name: key, value: attrValue });
340
- updated.attributes = attrs;
341
- this.replaceNode(node, updated);
328
+ if (this.#foldPendingJsxAttribute(node, id, key, value))
342
329
  return;
330
+ }
331
+ this.#commandBuffer.setProperty(id, key, value);
332
+ }
333
+ setField(node, key, value) {
334
+ const id = requireNid(node, "setField", this.#refs);
335
+ if (this.#foldPendingField(node, id, key, value))
336
+ return;
337
+ if (key === "children") {
338
+ if (!emitHastChildrenCommand(this.#commandBuffer, id, value, this.#refs)) {
339
+ throw unencodableContentError(value);
343
340
  }
344
- // Binary attribute upsert in the arena's type_data — no child
345
- // materialization. Rust maps the value-type to a boolean (true/null) or
346
- // literal (string/number/false) attribute, mirroring the fold path above.
347
- this.#commandBuffer.setProperty(id, key, value);
348
341
  return;
349
342
  }
350
- // Text-like nodes (text, comment, raw, expressions, esm): Rust handles
351
- // `value` directly on these types.
352
- this.#commandBuffer.setProperty(id, key, value);
343
+ if ((key === "attributes" &&
344
+ (node.type === "mdxJsxFlowElement" || node.type === "mdxJsxTextElement")) ||
345
+ (key === "properties" && node.type === "element")) {
346
+ // These are structured fields, so re-encode the node rather than a scalar command.
347
+ this.replaceNode(node, { ...node, [key]: value });
348
+ return;
349
+ }
350
+ if (key === "data") {
351
+ this.#commandBuffer.setField(id, key, value != null ? JSON.stringify(value) : null);
352
+ return;
353
+ }
354
+ this.#commandBuffer.setField(id, key, value);
355
+ }
356
+ setAttribute(node, name, value) {
357
+ const id = requireNid(node, "setAttribute", this.#refs);
358
+ if (this.#foldPendingJsxAttribute(node, id, name, value))
359
+ return;
360
+ this.#commandBuffer.setAttribute(id, name, value);
361
+ }
362
+ /** A queued replacement would discard the field, so fold it into a fresh replacement. */
363
+ #foldPendingField(node, id, key, value) {
364
+ const pending = this.#pendingNodes.get(id);
365
+ if (pending === undefined)
366
+ return false;
367
+ this.replaceNode(node, { ...pending, [key]: value });
368
+ return true;
369
+ }
370
+ /** A queued replacement would discard the attribute, so fold it in instead. */
371
+ #foldPendingJsxAttribute(node, id, name, value) {
372
+ const pending = this.#pendingNodes.get(id);
373
+ if (pending?.type !== "mdxJsxFlowElement" && pending?.type !== "mdxJsxTextElement") {
374
+ return false;
375
+ }
376
+ this.replaceNode(node, withMdxJsxAttribute(pending, name, value));
377
+ return true;
353
378
  }
354
379
  textContent(node) {
355
380
  return textContentHandle(this.#handle, requireNid(node, "textContent", this.#refs));
@@ -358,22 +383,17 @@ class HastVisitorContextImpl {
358
383
  const parentId = this.#resolver.parentIdOf(requireNid(node, "parent", this.#refs));
359
384
  if (parentId === undefined)
360
385
  return undefined;
361
- const byId = (this.#parentsById ??= new Map());
362
- let parent = byId.get(parentId);
363
- if (parent === undefined) {
364
- parent = this.#resolver.materializeOne(parentId);
365
- byId.set(parentId, parent);
366
- }
367
- return parent;
386
+ return this.#resolver.materializeOne(parentId);
368
387
  }
369
388
  indexOf(node) {
370
389
  return this.#resolver.indexInParent(requireNid(node, "indexOf", this.#refs));
371
390
  }
372
391
  report({ message, node, severity = "error", }) {
373
- const id = node ? nid(node, this.#refs) : undefined;
392
+ const id = node ? getNodeId(node, this.#refs) : undefined;
374
393
  this.#diagnostics.push({
375
394
  message,
376
395
  nodeId: id === FOREIGN_REF ? undefined : id,
396
+ position: node?.position,
377
397
  severity,
378
398
  });
379
399
  }
@@ -384,7 +404,6 @@ class HastVisitorContextImpl {
384
404
  return this.#diagnostics;
385
405
  }
386
406
  }
387
- /** Node types that use filtered visitors (have tag/component names). */
388
407
  const FILTERED_METHODS = new Set(["element", "mdxJsxFlowElement", "mdxJsxTextElement"]);
389
408
  const subscriptionCache = new WeakMap();
390
409
  export function resolveSubscriptions(plugin) {
@@ -395,8 +414,6 @@ export function resolveSubscriptions(plugin) {
395
414
  subscriptionCache.set(plugin, built);
396
415
  return built.subs;
397
416
  }
398
- /** Get the (cached) Rust-side projection of `subs` that strips visitFn so it
399
- * can cross NAPI. Computed once per plugin object alongside `subs`. */
400
417
  function getRustSubs(plugin) {
401
418
  const cached = subscriptionCache.get(plugin);
402
419
  if (cached !== undefined)
@@ -411,7 +428,7 @@ function isFilteredVisitor(value) {
411
428
  Array.isArray(value.filter) &&
412
429
  typeof value.visit === "function");
413
430
  }
414
- /** Caught pre-wire so the failure names the API shape, not the internal `tagFilter` field. */
431
+ // Validate before NAPI so errors name the public visitor contract.
415
432
  function malformedFilteredVisitorError(plugin, methodName) {
416
433
  const name = plugin.name;
417
434
  const pluginName = typeof name === "string" && name !== "" ? name : "(unnamed)";
@@ -438,319 +455,14 @@ function buildSubscriptions(plugin) {
438
455
  }
439
456
  }
440
457
  else {
441
- // Bare function, empty filter matches all nodes of this type
442
458
  subs.push({ nodeType, tagFilter: [], visitFn: value });
443
459
  }
444
460
  }
445
461
  const rustSubs = subs.map((s) => ({ nodeType: s.nodeType, tagFilter: s.tagFilter }));
446
462
  return { subs, rustSubs };
447
463
  }
448
- /** Visitor method name → node-type tag (method names are the subscribable AST names). */
449
464
  const METHOD_TO_TYPE = Object.fromEntries([...VISITOR_KEYS].map((name) => [name, NAME_TO_TYPE[name]]));
450
- function decodeProperties(view, buf, pos) {
451
- const propCount = view.getUint16(pos, true);
452
- pos += 2;
453
- const properties = {};
454
- for (let i = 0; i < propCount; i++) {
455
- const nameLen = view.getUint16(pos, true);
456
- pos += 2;
457
- const name = rstr(buf, pos, nameLen);
458
- pos += nameLen;
459
- const kind = buf[pos];
460
- pos += 1;
461
- const valLen = view.getUint16(pos, true);
462
- pos += 2;
463
- const valStr = rstr(buf, pos, valLen);
464
- pos += valLen;
465
- properties[name] = decodeElementProp(kind, valStr);
466
- }
467
- return properties;
468
- }
469
- /** Build the child-stub list for a matched node from the wire's `[child_ids]
470
- * [child_types]` blocks, no arena snapshot. Stale ids are caught at
471
- * materialization: the resolver's epoch check refuses a snapshot once the
472
- * arena has mutated or been dropped. */
473
- function readChildStubs(view, buf, idsPos, typesPos, count, resolver) {
474
- // With a hot snapshot a stub's deferral buys nothing; real nodes skip its per-field getters.
475
- if (resolver.hasHotSnapshot()) {
476
- const nodes = new Array(count);
477
- for (let i = 0; i < count; i++) {
478
- nodes[i] = resolver.materializeOne(view.getUint32(idsPos + i * 4, true));
479
- }
480
- return nodes;
481
- }
482
- const stubs = new Array(count);
483
- for (let i = 0; i < count; i++) {
484
- stubs[i] = new HastChildStub(resolver, view.getUint32(idsPos + i * 4, true), buf[typesPos + i]);
485
- }
486
- return stubs;
487
- }
488
- // Shared own-getter descriptors for WalkElement's lazy fields, populated in
489
- // its static block so the getters can read the private wire fields.
490
- let WALK_PROPS_DESC;
491
- let WALK_CHILDREN_DESC;
492
- /**
493
- * Walk-path element. Spread-correctness requires `properties`/`children` as
494
- * own enumerable keys (`{ ...node }` copies nothing else), but construction
495
- * runs per matched element, so everything stays off the expensive paths:
496
- * wire state in private fields (plain stores, invisible to spread — a WeakMap
497
- * entry per element caused major-GC ephemeron stalls at this volume), shared
498
- * getter functions instead of per-node closures, at most one define per lazy
499
- * field, and `instanceof` gating identity so copies read as new content.
500
- */
501
- class WalkElement {
502
- type = "element";
503
- tagName;
504
- #nodeId;
505
- #wire;
506
- #propsPos;
507
- #childIdsPos;
508
- #childTypesPos;
509
- #childCount;
510
- constructor(tagName, nodeId, wire, propsPos, propCount, childIdsPos, childTypesPos, childCount) {
511
- this.tagName = tagName;
512
- this.#nodeId = nodeId;
513
- this.#wire = wire;
514
- this.#propsPos = propsPos;
515
- this.#childIdsPos = childIdsPos;
516
- this.#childTypesPos = childTypesPos;
517
- this.#childCount = childCount;
518
- if (propCount === 0) {
519
- this.properties = {};
520
- }
521
- else {
522
- Object.defineProperty(this, "properties", WALK_PROPS_DESC);
523
- }
524
- if (childCount === 0) {
525
- this.children = [];
526
- }
527
- else {
528
- Object.defineProperty(this, "children", WALK_CHILDREN_DESC);
529
- }
530
- }
531
- /** @internal */
532
- get _nid() {
533
- return this.#nodeId;
534
- }
535
- /** @internal */
536
- get _refs() {
537
- return this.#wire.resolver.refs;
538
- }
539
- static {
540
- WALK_PROPS_DESC = {
541
- enumerable: true,
542
- configurable: true,
543
- get() {
544
- const w = this.#wire;
545
- const val = decodeProperties(w.view, w.buf, this.#propsPos);
546
- Object.defineProperty(this, "properties", {
547
- value: val,
548
- writable: true,
549
- enumerable: true,
550
- configurable: true,
551
- });
552
- return val;
553
- },
554
- };
555
- WALK_CHILDREN_DESC = {
556
- enumerable: true,
557
- configurable: true,
558
- get() {
559
- const w = this.#wire;
560
- const val = readChildStubs(w.view, w.buf, this.#childIdsPos, this.#childTypesPos, this.#childCount, w.resolver);
561
- Object.defineProperty(this, "children", {
562
- value: val,
563
- writable: true,
564
- enumerable: true,
565
- configurable: true,
566
- });
567
- return val;
568
- },
569
- };
570
- }
571
- }
572
- /** Read the tail of a matched element node (tag + properties).
573
- * Common prelude (data/position/children) is already consumed by `readMatchedNode`. */
574
- function readElementFromBinary(wire, offset, nodeId, position, childIdsPos, childTypesPos, childCount, data) {
575
- let pos = offset;
576
- // Eager: tagName (almost always accessed by visitors)
577
- const tagLen = wire.view.getUint16(pos, true);
578
- pos += 2;
579
- const tagName = rstr(wire.buf, pos, tagLen);
580
- pos += tagLen;
581
- const propCount = wire.view.getUint16(pos, true);
582
- const node = new WalkElement(tagName, nodeId, wire, pos, propCount, childIdsPos, childTypesPos, childCount);
583
- if (position !== undefined)
584
- node.position = position;
585
- if (data !== null)
586
- node.data = data;
587
- return node;
588
- }
589
- /** Value-carrying types read by `readTextFromBinary` (tag → AST name). */
590
- const TEXT_NODE_TYPES = Object.fromEntries(["text", "comment", "raw", "mdxFlowExpression", "mdxTextExpression", "mdxjsEsm"].map((name) => [NAME_TO_TYPE[name], name]));
591
- function readTextFromBinary(view, buf, offset, nodeId, nodeType, position, data, refs) {
592
- const valLen = view.getUint32(offset, true);
593
- const rawValue = rstr(buf, offset + 4, valLen);
594
- // MDX flow/text expressions store phantom-space sentinels; restore them so
595
- // the value matches the reader path. ESM and plain text keep their value.
596
- const value = nodeType === HAST_MDX_FLOW_EXPRESSION || nodeType === HAST_MDX_TEXT_EXPRESSION
597
- ? restorePhantomSpaces(rawValue)
598
- : rawValue;
599
- const base = { type: TEXT_NODE_TYPES[nodeType], value };
600
- if (position !== undefined)
601
- base.position = position;
602
- if (data !== null)
603
- base.data = data;
604
- const node = base;
605
- refs.set(node, nodeId);
606
- if (nodeType === HAST_MDX_FLOW_EXPRESSION || nodeType === HAST_MDX_TEXT_EXPRESSION) {
607
- attachParseExpression(node, napiParseExpression);
608
- }
609
- else if (nodeType === HAST_MDX_ESM) {
610
- attachParseExpression(node, napiParseEsm);
611
- }
612
- return node;
613
- }
614
- function readMdxJsxFromBinary(view, buf, offset, nodeId, nodeType, resolver, position, childIdsPos, childTypesPos, childCount, data) {
615
- let pos = offset;
616
- const nameLen = view.getUint16(pos, true);
617
- pos += 2;
618
- const name = nameLen > 0 ? rstr(buf, pos, nameLen) : null;
619
- pos += nameLen;
620
- // Attributes: [kind: u8][nameLen: u16][name][valLen: u32][val]
621
- const attrCount = view.getUint16(pos, true);
622
- pos += 2;
623
- const attributes = [];
624
- for (let i = 0; i < attrCount; i++) {
625
- const kind = buf[pos];
626
- pos += 1;
627
- const attrNameLen = view.getUint16(pos, true);
628
- pos += 2;
629
- const attrName = rstr(buf, pos, attrNameLen);
630
- pos += attrNameLen;
631
- const attrValLen = view.getUint32(pos, true);
632
- pos += 4;
633
- const attrVal = rstr(buf, pos, attrValLen);
634
- pos += attrValLen;
635
- attributes.push(decodeMdxJsxAttr(kind, attrName, attrVal));
636
- }
637
- const typeName = nodeType === HAST_MDX_JSX_ELEMENT ? "mdxJsxFlowElement" : "mdxJsxTextElement";
638
- const base = { type: typeName, name, attributes };
639
- if (position !== undefined)
640
- base.position = position;
641
- if (data !== null)
642
- base.data = data;
643
- resolver.refs.set(base, nodeId);
644
- makeLazyChildren(base, view, buf, childIdsPos, childTypesPos, childCount, resolver);
645
- return base;
646
- }
647
- function readMatchedNode(wire, offset, nodeId, nodeType) {
648
- const { view, buf, resolver } = wire;
649
- let pos = offset;
650
- // Shared prelude (matches serialize_hast_node_inline / serialize_mdast_node_inline):
651
- // [data_len: u32][data_bytes][position: 24B][child_count: u32][child_ids: N×u32][child_types: N×u8]
652
- const dataLen = view.getUint32(pos, true);
653
- pos += 4;
654
- let data = null;
655
- if (dataLen > 0) {
656
- const jsonStr = rstr(buf, pos, dataLen);
657
- try {
658
- data = JSON.parse(jsonStr);
659
- }
660
- catch (err) {
661
- if (process.env.NODE_ENV !== "production") {
662
- console.warn(`readMatchedNode: malformed node_data for nodeId=${nodeId}`, err);
663
- }
664
- }
665
- pos += dataLen;
666
- }
667
- const position = readPosition(view, pos);
668
- pos += 24;
669
- const childCount = view.getUint32(pos, true);
670
- pos += 4;
671
- // Ids/types decode lazily with `.children` — most matched nodes never read them.
672
- const childIdsPos = pos;
673
- pos += childCount * 4;
674
- const childTypesPos = pos;
675
- pos += childCount;
676
- // Dispatch to type-specific tail (pos now sits at the type-specific section)
677
- if (nodeType === HAST_ELEMENT) {
678
- return readElementFromBinary(wire, pos, nodeId, position, childIdsPos, childTypesPos, childCount, data);
679
- }
680
- else if (nodeType === HAST_TEXT ||
681
- nodeType === HAST_COMMENT ||
682
- nodeType === HAST_RAW ||
683
- nodeType === HAST_MDX_FLOW_EXPRESSION ||
684
- nodeType === HAST_MDX_TEXT_EXPRESSION ||
685
- nodeType === HAST_MDX_ESM) {
686
- return readTextFromBinary(view, buf, pos, nodeId, nodeType, position, data, resolver.refs);
687
- }
688
- else if (nodeType === HAST_MDX_JSX_ELEMENT || nodeType === HAST_MDX_JSX_TEXT_ELEMENT) {
689
- return readMdxJsxFromBinary(view, buf, pos, nodeId, nodeType, resolver, position, childIdsPos, childTypesPos, childCount, data);
690
- }
691
- // Fallback: root and doctype.
692
- const base = { type: TYPE_NAMES[nodeType] ?? `unknown(${nodeType})` };
693
- if (position !== undefined)
694
- base.position = position;
695
- if (data !== null)
696
- base.data = data;
697
- if (nodeType === HAST_ROOT) {
698
- // `...root.children` has to work in a hook.
699
- if (childCount > 0) {
700
- makeLazyChildren(base, view, buf, childIdsPos, childTypesPos, childCount, resolver);
701
- }
702
- else {
703
- base.children = [];
704
- }
705
- }
706
- const node = base;
707
- resolver.refs.set(node, nodeId);
708
- return node;
709
- }
710
- const HAST_EPOCH_CACHE = registerEpochCacheSlot(new WeakMap());
711
- class HastLazyChildResolver extends LazyChildResolver {
712
- cacheSlot() {
713
- return HAST_EPOCH_CACHE;
714
- }
715
- createReader(wire) {
716
- return new HastReader(wire);
717
- }
718
- materializeNode(reader, nodeId, refs) {
719
- return materializeHastNode(reader, nodeId, true, refs);
720
- }
721
- readParentId(reader, nodeId) {
722
- return reader.getParentId(nodeId);
723
- }
724
- readChildIds(reader, nodeId) {
725
- return reader.getChildIds(nodeId);
726
- }
727
- }
728
- /** Install `children` as an own enumerable getter (spread must carry it),
729
- * self-replacing with the one stable stub array on first read. One closure
730
- * and one define per node — installing the wire locals as hidden slots
731
- * instead measurably regressed every matching pipeline. */
732
- function makeLazyChildren(node, view, buf, childIdsPos, childTypesPos, childCount, resolver) {
733
- Object.defineProperty(node, "children", {
734
- get() {
735
- const val = readChildStubs(view, buf, childIdsPos, childTypesPos, childCount, resolver);
736
- Object.defineProperty(this, "children", {
737
- value: val,
738
- writable: true,
739
- enumerable: true,
740
- configurable: true,
741
- });
742
- return val;
743
- },
744
- enumerable: true,
745
- configurable: true,
746
- });
747
- }
748
- /** A result that is the same object as the input node is a no-op, so context
749
- * mutations (e.g. setProperty) are not clobbered.
750
- *
751
- * A same-type text-like node carrying only a new `value` becomes a
752
- * setProperty("value") rather than a structural replace, which would force
753
- * the arena into a full rebuild for a shape that didn't change. */
465
+ // Returning the input node must not overwrite mutations queued through the context.
754
466
  function applyHastVisitResult(result, nodeId, returnBuffer, originalNode, refs) {
755
467
  if (result == null)
756
468
  return;
@@ -760,20 +472,9 @@ function applyHastVisitResult(result, nodeId, returnBuffer, originalNode, refs)
760
472
  returnBuffer.setProperty(nodeId, "value", result.value);
761
473
  return;
762
474
  }
763
- emitHastTree(returnBuffer, "replace", nodeId, result, refs);
475
+ emitHastTree(returnBuffer, "replace", nodeId, result, refs, true);
764
476
  }
765
- function handleVisitResult(result, nodeId, returnBuffer, deferred, originalNode, refs) {
766
- if (result instanceof Promise) {
767
- const list = deferred ?? [];
768
- list.push({ nodeId, promise: result, originalNode });
769
- return list;
770
- }
771
- applyHastVisitResult(result, nodeId, returnBuffer, originalNode, refs);
772
- return deferred;
773
- }
774
- /** True when `result` is a same-type text-like node carrying only `type` +
775
- * `value`. The explicit `=== undefined` checks avoid the array alloc of
776
- * `Object.keys().length` on this per-text-node hot path. */
477
+ // Explicit field checks avoid allocating Object.keys on the per-text-node hot path.
777
478
  function isTextValueSwap(result, original) {
778
479
  if (result.type !== original.type)
779
480
  return false;
@@ -803,7 +504,13 @@ function dispatchMatches(matchBuf, subs, ctx, returnBuffer, resolver) {
803
504
  const sub = subs[subIndex];
804
505
  const node = readMatchedNode(wire, dataOffset, nodeId, sub.nodeType);
805
506
  const result = sub.visitFn(node, ctx);
806
- deferred = handleVisitResult(result, nodeId, returnBuffer, deferred, node, resolver.refs);
507
+ if (result instanceof Promise) {
508
+ deferred ??= [];
509
+ deferred.push({ nodeId, promise: result, originalNode: node });
510
+ }
511
+ else {
512
+ applyHastVisitResult(result, nodeId, returnBuffer, node, resolver.refs);
513
+ }
807
514
  }
808
515
  return deferred;
809
516
  }
@@ -822,20 +529,13 @@ export function visitHastHandle(handle, plugin, subs, source, fileURL, data = {}
822
529
  }
823
530
  return applyCollectedCommands(handle, result);
824
531
  }
825
- /** Apply commands collected by `visitHastHandleCollect`; returns the number of
826
- * patches dropped as stranded (0 when none). */
827
532
  function applyCollectedCommands(handle, commands) {
828
533
  if (commands.length === 0)
829
534
  return 0;
830
535
  markHandleMutated(handle);
831
536
  return applyCommandsToHandle(handle, commands);
832
537
  }
833
- /** Run a HAST visitor, build the command buffer, but do NOT apply it. Returns
834
- * the merged commands so the caller can choose how to dispatch: either via
835
- * `applyCommandsToHandle` (intermediate plugins in a chain) or via a fused
836
- * NAPI call like `applyCommandsAndRenderHandle` (final plugin, saves one
837
- * apply + one render + one drop crossing). Empty result means no mutations.
838
- */
538
+ // Collect the last plugin’s commands so the caller can fuse their apply with rendering or compilation.
839
539
  export function visitHastHandleCollect(handle, plugin, subs, source, fileURL, data = {}, sourceFormat = "markdown", diagnostics = []) {
840
540
  const getSource = typeof source === "function" ? source : () => source;
841
541
  const resolver = new HastLazyChildResolver(handle);
@@ -844,20 +544,20 @@ export function visitHastHandleCollect(handle, plugin, subs, source, fileURL, da
844
544
  const rustSubs = getRustSubs(plugin);
845
545
  const deferred = dispatchMatches(walkHandle(handle, rustSubs), subs, ctx, returnBuffer, resolver);
846
546
  if (deferred) {
847
- return Promise.all(deferred.map((d) => d.promise.then((result) => ({ nodeId: d.nodeId, result, originalNode: d.originalNode })))).then((results) => {
848
- for (const { nodeId, result, originalNode } of results) {
849
- applyHastVisitResult(result, nodeId, returnBuffer, originalNode, resolver.refs);
547
+ return Promise.all(deferred.map((visit) => visit.promise)).then((results) => {
548
+ for (let i = 0; i < deferred.length; i++) {
549
+ const { nodeId, originalNode } = deferred[i];
550
+ applyHastVisitResult(results[i], nodeId, returnBuffer, originalNode, resolver.refs);
850
551
  }
851
- return collectCommands(returnBuffer, ctx);
552
+ return collectCommands(returnBuffer, ctx.getCommandBuffer());
852
553
  });
853
554
  }
854
- return collectCommands(returnBuffer, ctx);
555
+ return collectCommands(returnBuffer, ctx.getCommandBuffer());
855
556
  }
856
557
  const HAST_ROOT_SUBS = [
857
558
  { nodeType: HAST_ROOT, tagFilter: [] },
858
559
  ];
859
- /** Its own pass so the caller can apply what `before` queued before the
860
- * visitors walk, and the visitors' mutations before `after` reads the tree. */
560
+ /** Hooks run in separate passes so each sees mutations from the preceding pass. */
861
561
  export function visitHastHookCollect(handle, plugin, hook, source, fileURL, data = {}, sourceFormat = "markdown", diagnostics = []) {
862
562
  const getSource = typeof source === "function" ? source : () => source;
863
563
  const resolver = new HastLazyChildResolver(handle);
@@ -866,13 +566,13 @@ export function visitHastHookCollect(handle, plugin, hook, source, fileURL, data
866
566
  const matchBuf = walkHandle(handle, HAST_ROOT_SUBS);
867
567
  const matchView = new DataView(matchBuf.buffer, matchBuf.byteOffset, matchBuf.byteLength);
868
568
  if (matchView.getUint32(0, true) === 0)
869
- return collectCommands(returnBuffer, ctx);
569
+ return collectCommands(returnBuffer, ctx.getCommandBuffer());
870
570
  const wire = { view: matchView, buf: matchBuf, resolver };
871
571
  const root = readMatchedNode(wire, matchView.getUint32(10, true), matchView.getUint32(4, true), HAST_ROOT);
872
572
  const result = hook.call(plugin, root, ctx);
873
573
  if (result instanceof Promise)
874
- return result.then(() => collectCommands(returnBuffer, ctx));
875
- return collectCommands(returnBuffer, ctx);
574
+ return result.then(() => collectCommands(returnBuffer, ctx.getCommandBuffer()));
575
+ return collectCommands(returnBuffer, ctx.getCommandBuffer());
876
576
  }
877
577
  export function visitHastHook(handle, plugin, hook, source, fileURL, data = {}, sourceFormat = "markdown", diagnostics = []) {
878
578
  const result = visitHastHookCollect(handle, plugin, hook, source, fileURL, data, sourceFormat, diagnostics);
@@ -881,11 +581,3 @@ export function visitHastHook(handle, plugin, hook, source, fileURL, data = {},
881
581
  }
882
582
  return applyCollectedCommands(handle, result);
883
583
  }
884
- function collectCommands(returnBuffer, ctx) {
885
- const { merged } = mergeAndReset(returnBuffer, ctx);
886
- // Return the buffers to the pool. The merged Uint8Array above already
887
- // copied the bytes out, so the underlying ArrayBuffers can be reused.
888
- releaseCommandBuffer(returnBuffer);
889
- releaseCommandBuffer(ctx.getCommandBuffer());
890
- return merged;
891
- }