satteri 0.10.4 → 0.11.1

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 (89) 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 +99 -0
  16. package/dist/compile-types.d.ts +330 -0
  17. package/dist/compile-types.js +1 -0
  18. package/dist/compile.d.ts +22 -328
  19. package/dist/compile.js +129 -379
  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.js +0 -5
  27. package/dist/generated/wire-constants.d.ts +3 -0
  28. package/dist/generated/wire-constants.js +33 -42
  29. package/dist/handles.d.ts +0 -7
  30. package/dist/hast/child-stub.d.ts +1 -8
  31. package/dist/hast/child-stub.js +14 -13
  32. package/dist/hast/element-props.d.ts +13 -0
  33. package/dist/hast/element-props.js +52 -6
  34. package/dist/hast/generated/node-types.d.ts +1 -9
  35. package/dist/hast/generated/node-types.js +1 -9
  36. package/dist/hast/generated/walk-decode.d.ts +10 -0
  37. package/dist/hast/generated/walk-decode.js +61 -0
  38. package/dist/hast/hast-materializer.d.ts +3 -10
  39. package/dist/hast/hast-materializer.js +73 -47
  40. package/dist/hast/hast-reader.d.ts +9 -38
  41. package/dist/hast/hast-reader.js +82 -271
  42. package/dist/hast/hast-visitor.d.ts +29 -16
  43. package/dist/hast/hast-visitor.js +197 -508
  44. package/dist/hast/hast-walk.d.ts +18 -0
  45. package/dist/hast/hast-walk.js +264 -0
  46. package/dist/index.d.ts +5 -5
  47. package/dist/index.js +4 -12
  48. package/dist/lazy-child-resolver.d.ts +1 -21
  49. package/dist/lazy-child-resolver.js +11 -32
  50. package/dist/materializer-cache.d.ts +5 -29
  51. package/dist/materializer-cache.js +29 -101
  52. package/dist/mdast/child-stub.d.ts +1 -12
  53. package/dist/mdast/child-stub.js +18 -28
  54. package/dist/mdast/column-align.d.ts +1 -1
  55. package/dist/mdast/column-align.js +0 -2
  56. package/dist/mdast/generated/layout.d.ts +0 -16
  57. package/dist/mdast/generated/layout.js +6 -47
  58. package/dist/mdast/generated/node-types.d.ts +1 -9
  59. package/dist/mdast/generated/node-types.js +3 -9
  60. package/dist/mdast/mdast-materializer.d.ts +3 -11
  61. package/dist/mdast/mdast-materializer.js +91 -63
  62. package/dist/mdast/mdast-reader.d.ts +15 -63
  63. package/dist/mdast/mdast-reader.js +132 -269
  64. package/dist/mdast/mdast-visitor.d.ts +23 -15
  65. package/dist/mdast/mdast-visitor.js +188 -294
  66. package/dist/mdast/mdast-walk.d.ts +13 -0
  67. package/dist/mdast/mdast-walk.js +144 -0
  68. package/dist/mdx-attr.js +0 -3
  69. package/dist/mdx-types.js +1 -6
  70. package/dist/op-stream.d.ts +1 -15
  71. package/dist/op-stream.js +3 -19
  72. package/dist/phantom.d.ts +0 -1
  73. package/dist/phantom.js +1 -9
  74. package/dist/plugin-pipeline.d.ts +32 -0
  75. package/dist/plugin-pipeline.js +177 -0
  76. package/dist/plugin.d.ts +8 -7
  77. package/dist/plugin.js +3 -9
  78. package/dist/types.d.ts +0 -1
  79. package/dist/visitor-node.d.ts +12 -0
  80. package/dist/visitor-node.js +23 -0
  81. package/dist/visitor-shared.d.ts +26 -30
  82. package/dist/visitor-shared.js +34 -36
  83. package/dist/wire-read.d.ts +0 -22
  84. package/dist/wire-read.js +3 -24
  85. package/index.d.ts +19 -10
  86. package/index.js +56 -53
  87. package/package.json +3 -12
  88. package/dist/string-pool.d.ts +0 -13
  89. 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,20 +108,23 @@ 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);
135
129
  if (type === HAST_ELEMENT) {
136
130
  w.str(OF_TAGNAME, typeof n.tagName === "string" ? n.tagName : "div");
@@ -142,7 +136,6 @@ function emitHastOp(w, node, isRoot, refs) {
142
136
  }
143
137
  }
144
138
  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
139
  const name = typeof n.name === "string" ? n.name : typeof n.tagName === "string" ? n.tagName : "";
147
140
  if (name !== "")
148
141
  w.str(OF_NAME, name);
@@ -157,19 +150,17 @@ function emitHastOp(w, node, isRoot, refs) {
157
150
  else {
158
151
  w.str(OF_VALUE, typeof n.value === "string" ? n.value : "");
159
152
  }
160
- if (n.data != null)
153
+ if (!document && n.data != null)
161
154
  w.data(n.data);
162
155
  const children = n.children;
163
156
  if (Array.isArray(children)) {
164
157
  for (const c of children)
165
- if (!emitHastOp(w, c, false, refs))
158
+ if (!emitHastOp(w, c, false, refs, document))
166
159
  return false;
167
160
  }
168
161
  w.close();
169
162
  return true;
170
163
  }
171
- /** Emit one element property, mirroring `encode_hast_js_node_data` exactly:
172
- * bool/string/number/array → kind; null/object → skip. */
173
164
  function emitHastProp(w, name, value) {
174
165
  if (value === true)
175
166
  w.prop(name, PROP_BOOL_TRUE, "");
@@ -177,23 +168,22 @@ function emitHastProp(w, name, value) {
177
168
  w.prop(name, PROP_BOOL_FALSE, "");
178
169
  else if (typeof value === "string")
179
170
  w.prop(name, PROP_STRING, value);
180
- else if (typeof value === "number")
181
- w.prop(name, PROP_INT, String(value));
171
+ // A NaN property is dropped, not rendered as `"NaN"`, matching hast.
172
+ else if (typeof value === "number") {
173
+ if (!Number.isNaN(value))
174
+ w.prop(name, PROP_INT, String(value));
175
+ }
182
176
  else if (Array.isArray(value))
183
- w.prop(name, PROP_SPACE_SEP, value.filter((v) => typeof v === "string").join(" "));
177
+ w.prop(name, PROP_TOKEN_LIST, encodeTokenList(value));
184
178
  }
185
179
  class HastVisitorContextImpl {
186
180
  #commandBuffer = acquireCommandBuffer();
187
181
  #diagnostics;
188
- /** Track accumulated node state for multiple setProperty calls on the same node. */
189
182
  #pendingNodes = new Map();
190
183
  #handle;
191
184
  #getSource;
192
185
  #resolver;
193
186
  #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
187
  fileURL;
198
188
  data;
199
189
  sourceFormat;
@@ -213,14 +203,32 @@ class HastVisitorContextImpl {
213
203
  return value;
214
204
  }
215
205
  removeNode(node) {
216
- this.#commandBuffer.removeNode(requireNid(node, "removeNode", this.#refs));
206
+ const id = requireNid(node, "removeNode", this.#refs);
207
+ this.#commandBuffer.removeNode(id);
208
+ this.#pendingNodes.delete(id);
217
209
  }
218
210
  replaceNode(node, newNode) {
219
211
  const id = requireNid(node, "replaceNode", this.#refs);
212
+ const onlyReplacement = Array.isArray(newNode)
213
+ ? newNode.length === 1
214
+ ? newNode[0]
215
+ : undefined
216
+ : newNode;
217
+ if (id === ROOT_NODE_ID &&
218
+ onlyReplacement !== undefined &&
219
+ hastReusedId(onlyReplacement, this.#refs) === id) {
220
+ return;
221
+ }
220
222
  if (Array.isArray(newNode)) {
221
223
  if (id === ROOT_NODE_ID && newNode.length > 1)
222
224
  throw rootReplacementError(newNode);
223
- // The last node carries the `replace` so refs back to the target still splice.
225
+ // One command, so the node's replacement is its whole slot and a ref back
226
+ // to it resolves to all of it rather than to the last element.
227
+ if (id !== ROOT_NODE_ID && newNode.length > 1) {
228
+ emitHastMultiReplace(this.#commandBuffer, id, newNode, this.#refs);
229
+ this.#pendingNodes.delete(id);
230
+ return;
231
+ }
224
232
  let previous;
225
233
  for (const n of newNode) {
226
234
  if (previous !== undefined)
@@ -228,36 +236,37 @@ class HastVisitorContextImpl {
228
236
  previous = n;
229
237
  }
230
238
  if (previous === undefined) {
231
- // Replacing with nothing drops the node, like removeNode.
232
239
  this.removeNode(node);
233
240
  }
234
241
  else if (id === ROOT_NODE_ID) {
235
242
  emitHastRootReplace(this.#commandBuffer, requireRootReplacement(previous), this.#refs);
236
243
  }
237
244
  else {
238
- emitHastTree(this.#commandBuffer, "replace", id, previous, this.#refs);
245
+ emitHastTree(this.#commandBuffer, "replace", id, previous, this.#refs, true);
239
246
  }
240
- // A stale queued replacement would win: setProperty folds into it, landing last.
241
- this.#pendingNodes.delete(id);
247
+ if (previous === undefined)
248
+ this.#pendingNodes.delete(id);
249
+ else
250
+ this.#pendingNodes.set(id, previous);
242
251
  return;
243
252
  }
244
253
  if (id === ROOT_NODE_ID) {
245
254
  emitHastRootReplace(this.#commandBuffer, requireRootReplacement(newNode), this.#refs);
246
255
  return;
247
256
  }
248
- emitHastTree(this.#commandBuffer, "replace", id, newNode, this.#refs);
249
- // Track the replacement so a later mdxJsx setProperty can fold into it.
257
+ emitHastTree(this.#commandBuffer, "replace", id, newNode, this.#refs, true);
250
258
  this.#pendingNodes.set(id, newNode);
251
259
  }
260
+ #splice(anchorId, content, op) {
261
+ for (const n of asArray(content)) {
262
+ emitHastTree(this.#commandBuffer, op, anchorId, n, this.#refs, true);
263
+ }
264
+ }
252
265
  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);
266
+ this.#splice(requireNid(node, "insertBefore", this.#refs), newNode, "insertBefore");
256
267
  }
257
268
  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);
269
+ this.#splice(requireNid(node, "insertAfter", this.#refs), newNode, "insertAfter");
261
270
  }
262
271
  wrapNode(node, parentNode) {
263
272
  const id = requireNid(node, "wrapNode", this.#refs);
@@ -270,26 +279,19 @@ class HastVisitorContextImpl {
270
279
  emitHastTree(this.#commandBuffer, "wrapNode", id, parentNode, this.#refs);
271
280
  }
272
281
  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);
282
+ this.#splice(requireNid(node, "prependChild", this.#refs), childNode, "prependChild");
276
283
  }
277
284
  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);
285
+ this.#splice(requireNid(node, "appendChild", this.#refs), childNode, "appendChild");
281
286
  }
282
287
  insertChildAt(node, index, childNode) {
283
288
  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
- }
289
+ const [anchor, op] = index <= 0 || children.length === 0
290
+ ? [node, "prependChild"]
291
+ : index >= children.length
292
+ ? [node, "appendChild"]
293
+ : [children[index], "insertBefore"];
294
+ this.#splice(requireNid(anchor, "insertChildAt", this.#refs), childNode, op);
293
295
  }
294
296
  removeChildAt(node, index) {
295
297
  const child = "children" in node ? node.children[index] : undefined;
@@ -299,8 +301,6 @@ class HastVisitorContextImpl {
299
301
  setProperty(node, key, value) {
300
302
  const id = requireNid(node, "setProperty", this.#refs);
301
303
  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
304
  if (!emitHastChildrenCommand(this.#commandBuffer, id, value, this.#refs)) {
305
305
  throw unencodableContentError(value);
306
306
  }
@@ -311,45 +311,67 @@ class HastVisitorContextImpl {
311
311
  return;
312
312
  }
313
313
  if (node.type === "element") {
314
- this.#commandBuffer.setProperty(id, key, value);
314
+ if (Array.isArray(value)) {
315
+ this.#commandBuffer.setTokenListProperty(id, key, encodeTokenList(value));
316
+ }
317
+ else {
318
+ // NaN drops the attribute, as it does on a built element.
319
+ const dropped = typeof value === "number" && Number.isNaN(value);
320
+ this.#commandBuffer.setProperty(id, key, dropped ? null : value);
321
+ }
315
322
  return;
316
323
  }
317
324
  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);
325
+ if (this.#foldPendingJsxAttribute(node, id, key, value))
342
326
  return;
327
+ }
328
+ this.#commandBuffer.setProperty(id, key, value);
329
+ }
330
+ setField(node, key, value) {
331
+ const id = requireNid(node, "setField", this.#refs);
332
+ if (this.#foldPendingField(node, id, key, value))
333
+ return;
334
+ if (key === "children") {
335
+ if (!emitHastChildrenCommand(this.#commandBuffer, id, value, this.#refs)) {
336
+ throw unencodableContentError(value);
343
337
  }
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
338
  return;
349
339
  }
350
- // Text-like nodes (text, comment, raw, expressions, esm): Rust handles
351
- // `value` directly on these types.
352
- this.#commandBuffer.setProperty(id, key, value);
340
+ if ((key === "attributes" &&
341
+ (node.type === "mdxJsxFlowElement" || node.type === "mdxJsxTextElement")) ||
342
+ (key === "properties" && node.type === "element")) {
343
+ // These are structured fields, so re-encode the node rather than a scalar command.
344
+ this.replaceNode(node, { ...node, [key]: value });
345
+ return;
346
+ }
347
+ if (key === "data") {
348
+ this.#commandBuffer.setField(id, key, value != null ? JSON.stringify(value) : null);
349
+ return;
350
+ }
351
+ this.#commandBuffer.setField(id, key, value);
352
+ }
353
+ setAttribute(node, name, value) {
354
+ const id = requireNid(node, "setAttribute", this.#refs);
355
+ if (this.#foldPendingJsxAttribute(node, id, name, value))
356
+ return;
357
+ this.#commandBuffer.setAttribute(id, name, value);
358
+ }
359
+ /** A queued replacement would discard the field, so fold it into a fresh replacement. */
360
+ #foldPendingField(node, id, key, value) {
361
+ const pending = this.#pendingNodes.get(id);
362
+ if (pending === undefined)
363
+ return false;
364
+ this.replaceNode(node, { ...pending, [key]: value });
365
+ return true;
366
+ }
367
+ /** A queued replacement would discard the attribute, so fold it in instead. */
368
+ #foldPendingJsxAttribute(node, id, name, value) {
369
+ const pending = this.#pendingNodes.get(id);
370
+ if (pending?.type !== "mdxJsxFlowElement" && pending?.type !== "mdxJsxTextElement") {
371
+ return false;
372
+ }
373
+ this.replaceNode(node, withMdxJsxAttribute(pending, name, value));
374
+ return true;
353
375
  }
354
376
  textContent(node) {
355
377
  return textContentHandle(this.#handle, requireNid(node, "textContent", this.#refs));
@@ -358,22 +380,17 @@ class HastVisitorContextImpl {
358
380
  const parentId = this.#resolver.parentIdOf(requireNid(node, "parent", this.#refs));
359
381
  if (parentId === undefined)
360
382
  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;
383
+ return this.#resolver.materializeOne(parentId);
368
384
  }
369
385
  indexOf(node) {
370
386
  return this.#resolver.indexInParent(requireNid(node, "indexOf", this.#refs));
371
387
  }
372
388
  report({ message, node, severity = "error", }) {
373
- const id = node ? nid(node, this.#refs) : undefined;
389
+ const id = node ? getNodeId(node, this.#refs) : undefined;
374
390
  this.#diagnostics.push({
375
391
  message,
376
392
  nodeId: id === FOREIGN_REF ? undefined : id,
393
+ position: node?.position,
377
394
  severity,
378
395
  });
379
396
  }
@@ -384,7 +401,6 @@ class HastVisitorContextImpl {
384
401
  return this.#diagnostics;
385
402
  }
386
403
  }
387
- /** Node types that use filtered visitors (have tag/component names). */
388
404
  const FILTERED_METHODS = new Set(["element", "mdxJsxFlowElement", "mdxJsxTextElement"]);
389
405
  const subscriptionCache = new WeakMap();
390
406
  export function resolveSubscriptions(plugin) {
@@ -395,8 +411,6 @@ export function resolveSubscriptions(plugin) {
395
411
  subscriptionCache.set(plugin, built);
396
412
  return built.subs;
397
413
  }
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
414
  function getRustSubs(plugin) {
401
415
  const cached = subscriptionCache.get(plugin);
402
416
  if (cached !== undefined)
@@ -411,7 +425,7 @@ function isFilteredVisitor(value) {
411
425
  Array.isArray(value.filter) &&
412
426
  typeof value.visit === "function");
413
427
  }
414
- /** Caught pre-wire so the failure names the API shape, not the internal `tagFilter` field. */
428
+ // Validate before NAPI so errors name the public visitor contract.
415
429
  function malformedFilteredVisitorError(plugin, methodName) {
416
430
  const name = plugin.name;
417
431
  const pluginName = typeof name === "string" && name !== "" ? name : "(unnamed)";
@@ -438,319 +452,14 @@ function buildSubscriptions(plugin) {
438
452
  }
439
453
  }
440
454
  else {
441
- // Bare function, empty filter matches all nodes of this type
442
455
  subs.push({ nodeType, tagFilter: [], visitFn: value });
443
456
  }
444
457
  }
445
458
  const rustSubs = subs.map((s) => ({ nodeType: s.nodeType, tagFilter: s.tagFilter }));
446
459
  return { subs, rustSubs };
447
460
  }
448
- /** Visitor method name → node-type tag (method names are the subscribable AST names). */
449
461
  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. */
462
+ // Returning the input node must not overwrite mutations queued through the context.
754
463
  function applyHastVisitResult(result, nodeId, returnBuffer, originalNode, refs) {
755
464
  if (result == null)
756
465
  return;
@@ -760,20 +469,9 @@ function applyHastVisitResult(result, nodeId, returnBuffer, originalNode, refs)
760
469
  returnBuffer.setProperty(nodeId, "value", result.value);
761
470
  return;
762
471
  }
763
- emitHastTree(returnBuffer, "replace", nodeId, result, refs);
472
+ emitHastTree(returnBuffer, "replace", nodeId, result, refs, true);
764
473
  }
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. */
474
+ // Explicit field checks avoid allocating Object.keys on the per-text-node hot path.
777
475
  function isTextValueSwap(result, original) {
778
476
  if (result.type !== original.type)
779
477
  return false;
@@ -803,7 +501,13 @@ function dispatchMatches(matchBuf, subs, ctx, returnBuffer, resolver) {
803
501
  const sub = subs[subIndex];
804
502
  const node = readMatchedNode(wire, dataOffset, nodeId, sub.nodeType);
805
503
  const result = sub.visitFn(node, ctx);
806
- deferred = handleVisitResult(result, nodeId, returnBuffer, deferred, node, resolver.refs);
504
+ if (result instanceof Promise) {
505
+ deferred ??= [];
506
+ deferred.push({ nodeId, promise: result, originalNode: node });
507
+ }
508
+ else {
509
+ applyHastVisitResult(result, nodeId, returnBuffer, node, resolver.refs);
510
+ }
807
511
  }
808
512
  return deferred;
809
513
  }
@@ -822,20 +526,13 @@ export function visitHastHandle(handle, plugin, subs, source, fileURL, data = {}
822
526
  }
823
527
  return applyCollectedCommands(handle, result);
824
528
  }
825
- /** Apply commands collected by `visitHastHandleCollect`; returns the number of
826
- * patches dropped as stranded (0 when none). */
827
529
  function applyCollectedCommands(handle, commands) {
828
530
  if (commands.length === 0)
829
531
  return 0;
830
532
  markHandleMutated(handle);
831
533
  return applyCommandsToHandle(handle, commands);
832
534
  }
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
- */
535
+ // Collect the last plugin’s commands so the caller can fuse their apply with rendering or compilation.
839
536
  export function visitHastHandleCollect(handle, plugin, subs, source, fileURL, data = {}, sourceFormat = "markdown", diagnostics = []) {
840
537
  const getSource = typeof source === "function" ? source : () => source;
841
538
  const resolver = new HastLazyChildResolver(handle);
@@ -844,20 +541,20 @@ export function visitHastHandleCollect(handle, plugin, subs, source, fileURL, da
844
541
  const rustSubs = getRustSubs(plugin);
845
542
  const deferred = dispatchMatches(walkHandle(handle, rustSubs), subs, ctx, returnBuffer, resolver);
846
543
  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);
544
+ return Promise.all(deferred.map((visit) => visit.promise)).then((results) => {
545
+ for (let i = 0; i < deferred.length; i++) {
546
+ const { nodeId, originalNode } = deferred[i];
547
+ applyHastVisitResult(results[i], nodeId, returnBuffer, originalNode, resolver.refs);
850
548
  }
851
- return collectCommands(returnBuffer, ctx);
549
+ return collectCommands(returnBuffer, ctx.getCommandBuffer());
852
550
  });
853
551
  }
854
- return collectCommands(returnBuffer, ctx);
552
+ return collectCommands(returnBuffer, ctx.getCommandBuffer());
855
553
  }
856
554
  const HAST_ROOT_SUBS = [
857
555
  { nodeType: HAST_ROOT, tagFilter: [] },
858
556
  ];
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. */
557
+ /** Hooks run in separate passes so each sees mutations from the preceding pass. */
861
558
  export function visitHastHookCollect(handle, plugin, hook, source, fileURL, data = {}, sourceFormat = "markdown", diagnostics = []) {
862
559
  const getSource = typeof source === "function" ? source : () => source;
863
560
  const resolver = new HastLazyChildResolver(handle);
@@ -866,13 +563,13 @@ export function visitHastHookCollect(handle, plugin, hook, source, fileURL, data
866
563
  const matchBuf = walkHandle(handle, HAST_ROOT_SUBS);
867
564
  const matchView = new DataView(matchBuf.buffer, matchBuf.byteOffset, matchBuf.byteLength);
868
565
  if (matchView.getUint32(0, true) === 0)
869
- return collectCommands(returnBuffer, ctx);
566
+ return collectCommands(returnBuffer, ctx.getCommandBuffer());
870
567
  const wire = { view: matchView, buf: matchBuf, resolver };
871
568
  const root = readMatchedNode(wire, matchView.getUint32(10, true), matchView.getUint32(4, true), HAST_ROOT);
872
569
  const result = hook.call(plugin, root, ctx);
873
570
  if (result instanceof Promise)
874
- return result.then(() => collectCommands(returnBuffer, ctx));
875
- return collectCommands(returnBuffer, ctx);
571
+ return result.then(() => collectCommands(returnBuffer, ctx.getCommandBuffer()));
572
+ return collectCommands(returnBuffer, ctx.getCommandBuffer());
876
573
  }
877
574
  export function visitHastHook(handle, plugin, hook, source, fileURL, data = {}, sourceFormat = "markdown", diagnostics = []) {
878
575
  const result = visitHastHookCollect(handle, plugin, hook, source, fileURL, data, sourceFormat, diagnostics);
@@ -881,11 +578,3 @@ export function visitHastHook(handle, plugin, hook, source, fileURL, data = {},
881
578
  }
882
579
  return applyCollectedCommands(handle, result);
883
580
  }
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
- }