satteri 0.9.4 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/README.md +11 -1
  2. package/dist/binding.browser.d.ts +1 -1
  3. package/dist/binding.browser.js +1 -1
  4. package/dist/binding.d.ts +1 -1
  5. package/dist/binding.js +1 -1
  6. package/dist/byte-writer.d.ts +4 -0
  7. package/dist/byte-writer.js +13 -4
  8. package/dist/child-stub.d.ts +7 -3
  9. package/dist/child-stub.js +18 -7
  10. package/dist/command-buffer.d.ts +36 -50
  11. package/dist/command-buffer.js +102 -49
  12. package/dist/compile.d.ts +80 -32
  13. package/dist/compile.js +295 -70
  14. package/dist/freeze.d.ts +2 -0
  15. package/dist/freeze.js +10 -0
  16. package/dist/generated/wire-constants.d.ts +3 -2
  17. package/dist/generated/wire-constants.js +4 -2
  18. package/dist/hast/child-stub.d.ts +2 -3
  19. package/dist/hast/child-stub.js +18 -11
  20. package/dist/hast/element-props.d.ts +1 -1
  21. package/dist/hast/element-props.js +14 -6
  22. package/dist/hast/hast-materializer.d.ts +6 -2
  23. package/dist/hast/hast-materializer.js +48 -134
  24. package/dist/hast/hast-reader.d.ts +7 -13
  25. package/dist/hast/hast-reader.js +76 -21
  26. package/dist/hast/hast-visitor.d.ts +55 -5
  27. package/dist/hast/hast-visitor.js +320 -154
  28. package/dist/index.d.ts +6 -6
  29. package/dist/index.js +1 -1
  30. package/dist/lazy-child-resolver.d.ts +20 -13
  31. package/dist/lazy-child-resolver.js +66 -38
  32. package/dist/materializer-cache.d.ts +42 -0
  33. package/dist/materializer-cache.js +192 -0
  34. package/dist/mdast/child-stub.d.ts +6 -3
  35. package/dist/mdast/child-stub.js +71 -8
  36. package/dist/mdast/generated/layout.d.ts +2 -3
  37. package/dist/mdast/generated/layout.js +24 -28
  38. package/dist/mdast/generated/node-types.d.ts +4 -0
  39. package/dist/mdast/generated/node-types.js +25 -0
  40. package/dist/mdast/mdast-materializer.d.ts +12 -3
  41. package/dist/mdast/mdast-materializer.js +83 -78
  42. package/dist/mdast/mdast-reader.d.ts +15 -0
  43. package/dist/mdast/mdast-reader.js +55 -10
  44. package/dist/mdast/mdast-visitor.d.ts +79 -32
  45. package/dist/mdast/mdast-visitor.js +305 -122
  46. package/dist/op-stream.d.ts +5 -12
  47. package/dist/op-stream.js +6 -21
  48. package/dist/plugin.d.ts +36 -10
  49. package/dist/plugin.js +50 -2
  50. package/dist/string-pool.d.ts +13 -0
  51. package/dist/string-pool.js +91 -0
  52. package/dist/types.d.ts +44 -1
  53. package/dist/visitor-shared.d.ts +27 -4
  54. package/dist/visitor-shared.js +36 -10
  55. package/dist/wire-read.js +3 -1
  56. package/index.d.ts +130 -7
  57. package/index.js +89 -68
  58. package/package.json +24 -19
  59. package/dist/lazy-props.d.ts +0 -10
  60. package/dist/lazy-props.js +0 -50
  61. package/satteri_napi.wasi-browser.js +0 -83
  62. package/satteri_napi.wasi.cjs +0 -134
  63. package/wasi-worker-browser.mjs +0 -36
  64. package/wasi-worker.mjs +0 -63
@@ -1,18 +1,17 @@
1
1
  import { materializeHastNode } from "./hast-materializer.js";
2
- import { HastReader, HAST_ELEMENT, HAST_TEXT, HAST_COMMENT, HAST_RAW, HAST_MDX_JSX_ELEMENT, HAST_MDX_JSX_TEXT_ELEMENT, HAST_MDX_FLOW_EXPRESSION, HAST_MDX_TEXT_EXPRESSION, HAST_MDX_ESM, } from "./hast-reader.js";
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
3
  import { TYPE_NAMES, NAME_TO_TYPE, VISITOR_KEYS, HAST_OPSTREAM_TYPES, } from "./generated/node-types.js";
4
- import { CommandBuffer } from "../command-buffer.js";
4
+ import { acquireCommandBuffer, releaseCommandBuffer, CommandBuffer, STRUCTURAL_CMD, } from "../command-buffer.js";
5
+ import { CMD_SET_CHILDREN } from "../generated/wire-constants.js";
5
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";
6
7
  import { restorePhantomSpaces } from "../phantom.js";
7
8
  import { decodeMdxJsxAttr } from "../mdx-attr.js";
8
9
  import { decodeElementProp } from "./element-props.js";
9
10
  import { readPosition, rstr } from "../wire-read.js";
10
11
  import { walkHandle, applyCommandsToHandle, textContentHandle, parseExpression as napiParseExpression, parseEsm as napiParseEsm, } from "#binding";
11
- import { asArray, makeRequireNid, mergeAndReset, unencodableContentError, } from "../visitor-shared.js";
12
- import { LazyChildResolver, markHandleMutated } from "../lazy-child-resolver.js";
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";
13
14
  import { HastChildStub } from "./child-stub.js";
14
- /** Maps HastNode objects to their arena node IDs without Object.defineProperty overhead. */
15
- const nodeIdMap = new WeakMap();
16
15
  /** Attach `parseExpression()` to an MDX expression or ESM node. */
17
16
  function attachParseExpression(node, parseFn) {
18
17
  Object.defineProperty(node, "parseExpression", {
@@ -42,91 +41,87 @@ function attachParseExpression(node, parseFn) {
42
41
  * the WeakMap or a NON-enumerable `_nodeId` (the materializers' convention,
43
42
  * which spread cannot copy).
44
43
  */
45
- function nid(node) {
44
+ function nid(node, refs) {
46
45
  if (node instanceof WalkElement)
47
- return node._nid;
46
+ return node._refs === refs ? node._nid : FOREIGN_REF;
48
47
  if (node instanceof HastChildStub)
49
- return node._id;
50
- const id = nodeIdMap.get(node);
48
+ return node._refs === refs ? node._id : FOREIGN_REF;
49
+ const id = refs.get(node);
51
50
  if (id !== undefined)
52
51
  return id;
53
52
  const d = Object.getOwnPropertyDescriptor(node, "_nodeId");
54
- return d !== undefined && !d.enumerable ? d.value : undefined;
53
+ if (d !== undefined && !d.enumerable)
54
+ return FOREIGN_REF;
55
+ return crossPipelineForeign(node);
55
56
  }
56
57
  const requireNid = makeRequireNid(nid);
57
- function hastReusedId(node) {
58
+ /** `wrapNode` allowlist: an unlisted type fails loud instead of silently
59
+ * mis-wrapping. */
60
+ const HAST_PARENT_TYPES = ["element", "mdxJsxFlowElement", "mdxJsxTextElement"];
61
+ const HAST_PARENT_TYPE_SET = new Set(HAST_PARENT_TYPES);
62
+ /** A leaf wrapper would make the patch engine drop or displace the wrapped node. */
63
+ function assertHastWrapParent(parentNode) {
64
+ const type = parentNode.type;
65
+ if (typeof type === "string" && HAST_PARENT_TYPE_SET.has(type))
66
+ return;
67
+ throw new Error(`wrapNode: "${String(type)}" nodes cannot hold children, so they cannot wrap a node. ` +
68
+ 'Wrap in an element instead, e.g. { type: "element", tagName: "div", properties: {}, children: [] } ' +
69
+ 'or { raw: "<div></div>" }.');
70
+ }
71
+ function hastReusedId(node, refs) {
58
72
  if (node === null || typeof node !== "object")
59
73
  return undefined;
60
- const id = nid(node);
61
- return typeof id === "number" ? id : undefined;
74
+ const id = nid(node, refs);
75
+ return id !== undefined && id !== FOREIGN_REF ? id : undefined;
62
76
  }
63
- // Reused across replacements in a pass — see the note on `mdastWriter`.
64
- const hastWriter = new OpWriter();
65
- /** Compile a set-children payload: a root-wrapped child list, the shape
77
+ /** Emit a set-children command in place: a root-wrapped child list, the shape
66
78
  * `Patch::SetChildren` splices in. Reused children become refs. */
67
- function compileHastChildrenToOpstream(children) {
79
+ function emitHastChildrenCommand(buffer, id, children, refs) {
68
80
  if (!Array.isArray(children))
69
- return null;
70
- hastWriter.begin();
71
- try {
72
- hastWriter.open(NAME_TO_TYPE.root);
81
+ return false;
82
+ return buffer.emitOpstreamCommand(CMD_SET_CHILDREN, id, () => {
83
+ buffer.open(HAST_ROOT);
73
84
  for (const c of children) {
74
- if (!emitHastOp(hastWriter, c, false))
75
- return null;
85
+ if (!emitHastOp(buffer, c, false, refs))
86
+ return false;
76
87
  }
77
- hastWriter.close();
78
- return hastWriter.take();
79
- }
80
- finally {
81
- hastWriter.end();
82
- }
88
+ buffer.close();
89
+ return true;
90
+ });
83
91
  }
84
- /** Encode `node` as the `op` structural command. HAST content is always a
85
- * declarative node (no raw escape hatch), so it compiles to the op-stream or
86
- * it's a hard error — the op-stream is the only structural encoding. The
87
- * switch stays inline so the buffer calls are monomorphic (computed method
88
- * names defeat inline caches on this warm path). */
89
- function emitHastTree(buffer, op, id, node) {
90
- const ops = compileHastToOpstream(node);
91
- if (ops === null)
92
+ /** 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));
98
+ if (!ok)
92
99
  throw unencodableContentError(node);
93
- switch (op) {
94
- case "replace":
95
- return buffer.replaceOpstream(id, ops);
96
- case "insertBefore":
97
- return buffer.insertBeforeOpstream(id, ops);
98
- case "insertAfter":
99
- return buffer.insertAfterOpstream(id, ops);
100
- case "prependChild":
101
- return buffer.prependChildOpstream(id, ops);
102
- case "appendChild":
103
- return buffer.appendChildOpstream(id, ops);
104
- case "wrapNode":
105
- return buffer.wrapNodeOpstream(id, ops);
106
- }
107
100
  }
108
- /**
109
- * Compile a declarative HAST replacement tree to the op-stream — the only
110
- * structural encoding. Reused nodes become `ref`s (transparent passthrough).
111
- * Returns null when the tree holds a type the replay can't reproduce
112
- * identically; the caller throws.
113
- */
114
- function compileHastToOpstream(root) {
115
- hastWriter.begin();
116
- try {
117
- if (!emitHastOp(hastWriter, root, true))
118
- return null;
119
- return hastWriter.take();
120
- }
121
- finally {
122
- hastWriter.end();
101
+ /** Separate from the per-node encoder, which rejects a `root` payload. */
102
+ function emitHastRootReplace(buffer, root, refs) {
103
+ const ok = buffer.emitOpstreamCommand(STRUCTURAL_CMD.replace, ROOT_NODE_ID, () => emitHastRootOp(buffer, root, refs));
104
+ if (!ok)
105
+ throw unencodableContentError(root);
106
+ }
107
+ function emitHastRootOp(w, n, refs) {
108
+ w.open(HAST_ROOT);
109
+ if (n.data != null)
110
+ w.data(n.data);
111
+ const children = n.children;
112
+ if (Array.isArray(children)) {
113
+ for (const c of children)
114
+ if (!emitHastOp(w, c, false, refs))
115
+ return false;
123
116
  }
117
+ w.close();
118
+ return true;
124
119
  }
125
- function emitHastOp(w, node, isRoot) {
120
+ function emitHastOp(w, node, isRoot, refs) {
126
121
  if (node === null || typeof node !== "object")
127
122
  return false;
128
123
  if (!isRoot) {
129
- const id = hastReusedId(node);
124
+ const id = hastReusedId(node, refs);
130
125
  if (id !== undefined) {
131
126
  w.ref(id);
132
127
  return true;
@@ -167,7 +162,7 @@ function emitHastOp(w, node, isRoot) {
167
162
  const children = n.children;
168
163
  if (Array.isArray(children)) {
169
164
  for (const c of children)
170
- if (!emitHastOp(w, c, false))
165
+ if (!emitHastOp(w, c, false, refs))
171
166
  return false;
172
167
  }
173
168
  w.close();
@@ -188,24 +183,29 @@ function emitHastProp(w, name, value) {
188
183
  w.prop(name, PROP_SPACE_SEP, value.filter((v) => typeof v === "string").join(" "));
189
184
  }
190
185
  class HastVisitorContextImpl {
191
- #commandBuffer = new CommandBuffer();
192
- #diagnostics = [];
186
+ #commandBuffer = acquireCommandBuffer();
187
+ #diagnostics;
193
188
  /** Track accumulated node state for multiple setProperty calls on the same node. */
194
189
  #pendingNodes = new Map();
195
190
  #handle;
196
191
  #getSource;
197
192
  #resolver;
193
+ #refs;
198
194
  /** One canonical object per parent id, so visitors can dedupe by identity.
199
195
  * Null until the first `parent()` call; most passes never make one. */
200
196
  #parentsById = null;
201
197
  fileURL;
202
198
  data;
203
- constructor(handle, getSource, fileURL, resolver, data) {
199
+ sourceFormat;
200
+ constructor(handle, getSource, fileURL, resolver, data, sourceFormat, diagnostics) {
204
201
  this.#handle = handle;
205
202
  this.#getSource = getSource;
206
203
  this.fileURL = fileURL;
207
204
  this.#resolver = resolver;
205
+ this.#refs = resolver.refs;
208
206
  this.data = data;
207
+ this.sourceFormat = sourceFormat;
208
+ this.#diagnostics = diagnostics;
209
209
  }
210
210
  get source() {
211
211
  const value = this.#getSource();
@@ -213,37 +213,71 @@ class HastVisitorContextImpl {
213
213
  return value;
214
214
  }
215
215
  removeNode(node) {
216
- this.#commandBuffer.removeNode(requireNid(node, "removeNode"));
216
+ this.#commandBuffer.removeNode(requireNid(node, "removeNode", this.#refs));
217
217
  }
218
218
  replaceNode(node, newNode) {
219
- const id = requireNid(node, "replaceNode");
220
- emitHastTree(this.#commandBuffer, "replace", id, newNode);
219
+ const id = requireNid(node, "replaceNode", this.#refs);
220
+ if (Array.isArray(newNode)) {
221
+ if (id === ROOT_NODE_ID && newNode.length > 1)
222
+ throw rootReplacementError(newNode);
223
+ // The last node carries the `replace` so refs back to the target still splice.
224
+ let previous;
225
+ for (const n of newNode) {
226
+ if (previous !== undefined)
227
+ emitHastTree(this.#commandBuffer, "insertBefore", id, previous, this.#refs);
228
+ previous = n;
229
+ }
230
+ if (previous === undefined) {
231
+ // Replacing with nothing drops the node, like removeNode.
232
+ this.removeNode(node);
233
+ }
234
+ else if (id === ROOT_NODE_ID) {
235
+ emitHastRootReplace(this.#commandBuffer, requireRootReplacement(previous), this.#refs);
236
+ }
237
+ else {
238
+ emitHastTree(this.#commandBuffer, "replace", id, previous, this.#refs);
239
+ }
240
+ // A stale queued replacement would win: setProperty folds into it, landing last.
241
+ this.#pendingNodes.delete(id);
242
+ return;
243
+ }
244
+ if (id === ROOT_NODE_ID) {
245
+ emitHastRootReplace(this.#commandBuffer, requireRootReplacement(newNode), this.#refs);
246
+ return;
247
+ }
248
+ emitHastTree(this.#commandBuffer, "replace", id, newNode, this.#refs);
221
249
  // Track the replacement so a later mdxJsx setProperty can fold into it.
222
250
  this.#pendingNodes.set(id, newNode);
223
251
  }
224
252
  insertBefore(node, newNode) {
225
- const id = requireNid(node, "insertBefore");
253
+ const id = requireNid(node, "insertBefore", this.#refs);
226
254
  for (const n of asArray(newNode))
227
- emitHastTree(this.#commandBuffer, "insertBefore", id, n);
255
+ emitHastTree(this.#commandBuffer, "insertBefore", id, n, this.#refs);
228
256
  }
229
257
  insertAfter(node, newNode) {
230
- const id = requireNid(node, "insertAfter");
258
+ const id = requireNid(node, "insertAfter", this.#refs);
231
259
  for (const n of asArray(newNode))
232
- emitHastTree(this.#commandBuffer, "insertAfter", id, n);
260
+ emitHastTree(this.#commandBuffer, "insertAfter", id, n, this.#refs);
233
261
  }
234
262
  wrapNode(node, parentNode) {
235
- const id = requireNid(node, "wrapNode");
236
- emitHastTree(this.#commandBuffer, "wrapNode", id, parentNode);
263
+ const id = requireNid(node, "wrapNode", this.#refs);
264
+ if (typeof parentNode.raw === "string" ||
265
+ typeof parentNode.rawHtml === "string") {
266
+ this.#commandBuffer.wrapNode(id, parentNode);
267
+ return;
268
+ }
269
+ assertHastWrapParent(parentNode);
270
+ emitHastTree(this.#commandBuffer, "wrapNode", id, parentNode, this.#refs);
237
271
  }
238
272
  prependChild(node, childNode) {
239
- const id = requireNid(node, "prependChild");
273
+ const id = requireNid(node, "prependChild", this.#refs);
240
274
  for (const n of asArray(childNode))
241
- emitHastTree(this.#commandBuffer, "prependChild", id, n);
275
+ emitHastTree(this.#commandBuffer, "prependChild", id, n, this.#refs);
242
276
  }
243
277
  appendChild(node, childNode) {
244
- const id = requireNid(node, "appendChild");
278
+ const id = requireNid(node, "appendChild", this.#refs);
245
279
  for (const n of asArray(childNode))
246
- emitHastTree(this.#commandBuffer, "appendChild", id, n);
280
+ emitHastTree(this.#commandBuffer, "appendChild", id, n, this.#refs);
247
281
  }
248
282
  insertChildAt(node, index, childNode) {
249
283
  const children = "children" in node ? node.children : [];
@@ -263,14 +297,13 @@ class HastVisitorContextImpl {
263
297
  this.removeNode(child);
264
298
  }
265
299
  setProperty(node, key, value) {
266
- const id = requireNid(node, "setProperty");
300
+ const id = requireNid(node, "setProperty", this.#refs);
267
301
  if (key === "children") {
268
302
  // children is structural: set-children keeps the node and swaps only its
269
303
  // child list (reused children keep their id).
270
- const ops = compileHastChildrenToOpstream(value);
271
- if (!ops)
304
+ if (!emitHastChildrenCommand(this.#commandBuffer, id, value, this.#refs)) {
272
305
  throw unencodableContentError(value);
273
- this.#commandBuffer.setChildrenOpstream(id, ops);
306
+ }
274
307
  return;
275
308
  }
276
309
  if (key === "data") {
@@ -319,10 +352,10 @@ class HastVisitorContextImpl {
319
352
  this.#commandBuffer.setProperty(id, key, value);
320
353
  }
321
354
  textContent(node) {
322
- return textContentHandle(this.#handle, requireNid(node, "textContent"));
355
+ return textContentHandle(this.#handle, requireNid(node, "textContent", this.#refs));
323
356
  }
324
357
  parent(node) {
325
- const parentId = this.#resolver.parentIdOf(requireNid(node, "parent"));
358
+ const parentId = this.#resolver.parentIdOf(requireNid(node, "parent", this.#refs));
326
359
  if (parentId === undefined)
327
360
  return undefined;
328
361
  const byId = (this.#parentsById ??= new Map());
@@ -334,10 +367,15 @@ class HastVisitorContextImpl {
334
367
  return parent;
335
368
  }
336
369
  indexOf(node) {
337
- return this.#resolver.indexInParent(requireNid(node, "indexOf"));
370
+ return this.#resolver.indexInParent(requireNid(node, "indexOf", this.#refs));
338
371
  }
339
372
  report({ message, node, severity = "error", }) {
340
- this.#diagnostics.push({ message, nodeId: node ? nid(node) : undefined, severity });
373
+ const id = node ? nid(node, this.#refs) : undefined;
374
+ this.#diagnostics.push({
375
+ message,
376
+ nodeId: id === FOREIGN_REF ? undefined : id,
377
+ severity,
378
+ });
341
379
  }
342
380
  getCommandBuffer() {
343
381
  return this.#commandBuffer;
@@ -348,7 +386,40 @@ class HastVisitorContextImpl {
348
386
  }
349
387
  /** Node types that use filtered visitors (have tag/component names). */
350
388
  const FILTERED_METHODS = new Set(["element", "mdxJsxFlowElement", "mdxJsxTextElement"]);
389
+ const subscriptionCache = new WeakMap();
351
390
  export function resolveSubscriptions(plugin) {
391
+ const cached = subscriptionCache.get(plugin);
392
+ if (cached !== undefined)
393
+ return cached.subs;
394
+ const built = buildSubscriptions(plugin);
395
+ subscriptionCache.set(plugin, built);
396
+ return built.subs;
397
+ }
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
+ function getRustSubs(plugin) {
401
+ const cached = subscriptionCache.get(plugin);
402
+ if (cached !== undefined)
403
+ return cached.rustSubs;
404
+ const built = buildSubscriptions(plugin);
405
+ subscriptionCache.set(plugin, built);
406
+ return built.rustSubs;
407
+ }
408
+ function isFilteredVisitor(value) {
409
+ return (value !== null &&
410
+ typeof value === "object" &&
411
+ Array.isArray(value.filter) &&
412
+ typeof value.visit === "function");
413
+ }
414
+ /** Caught pre-wire so the failure names the API shape, not the internal `tagFilter` field. */
415
+ function malformedFilteredVisitorError(plugin, methodName) {
416
+ const name = plugin.name;
417
+ const pluginName = typeof name === "string" && name !== "" ? name : "(unnamed)";
418
+ return new Error(`hast plugin "${pluginName}": "${methodName}" visitors filter by tag/component name, ` +
419
+ `so each must be an object { filter: string[], visit: function } (or an array of those). ` +
420
+ `Use filter: [] to visit every "${methodName}" node.`);
421
+ }
422
+ function buildSubscriptions(plugin) {
352
423
  const subs = [];
353
424
  for (const [methodName, nodeType] of Object.entries(METHOD_TO_TYPE)) {
354
425
  const value = plugin[methodName];
@@ -357,6 +428,8 @@ export function resolveSubscriptions(plugin) {
357
428
  if (FILTERED_METHODS.has(methodName)) {
358
429
  const items = Array.isArray(value) ? value : [value];
359
430
  for (const fv of items) {
431
+ if (!isFilteredVisitor(fv))
432
+ throw malformedFilteredVisitorError(plugin, methodName);
360
433
  subs.push({
361
434
  nodeType,
362
435
  tagFilter: fv.filter,
@@ -369,7 +442,8 @@ export function resolveSubscriptions(plugin) {
369
442
  subs.push({ nodeType, tagFilter: [], visitFn: value });
370
443
  }
371
444
  }
372
- return subs;
445
+ const rustSubs = subs.map((s) => ({ nodeType: s.nodeType, tagFilter: s.tagFilter }));
446
+ return { subs, rustSubs };
373
447
  }
374
448
  /** Visitor method name → node-type tag (method names are the subscribable AST names). */
375
449
  const METHOD_TO_TYPE = Object.fromEntries([...VISITOR_KEYS].map((name) => [name, NAME_TO_TYPE[name]]));
@@ -393,11 +467,18 @@ function decodeProperties(view, buf, pos) {
393
467
  return properties;
394
468
  }
395
469
  /** Build the child-stub list for a matched node from the wire's `[child_ids]
396
- * [child_types]` blocks — no arena snapshot. The seal check still applies:
397
- * post-pass ids are stale, and a stub built from them could later splice the
398
- * wrong node as a ref. */
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. */
399
473
  function readChildStubs(view, buf, idsPos, typesPos, count, resolver) {
400
- resolver.assertUnsealed();
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
+ }
401
482
  const stubs = new Array(count);
402
483
  for (let i = 0; i < count; i++) {
403
484
  stubs[i] = new HastChildStub(resolver, view.getUint32(idsPos + i * 4, true), buf[typesPos + i]);
@@ -421,23 +502,19 @@ class WalkElement {
421
502
  type = "element";
422
503
  tagName;
423
504
  #nodeId;
424
- #view;
425
- #buf;
505
+ #wire;
426
506
  #propsPos;
427
507
  #childIdsPos;
428
508
  #childTypesPos;
429
509
  #childCount;
430
- #resolver;
431
- constructor(tagName, nodeId, view, buf, propsPos, propCount, childIdsPos, childTypesPos, childCount, resolver) {
510
+ constructor(tagName, nodeId, wire, propsPos, propCount, childIdsPos, childTypesPos, childCount) {
432
511
  this.tagName = tagName;
433
512
  this.#nodeId = nodeId;
434
- this.#view = view;
435
- this.#buf = buf;
513
+ this.#wire = wire;
436
514
  this.#propsPos = propsPos;
437
515
  this.#childIdsPos = childIdsPos;
438
516
  this.#childTypesPos = childTypesPos;
439
517
  this.#childCount = childCount;
440
- this.#resolver = resolver;
441
518
  if (propCount === 0) {
442
519
  this.properties = {};
443
520
  }
@@ -455,12 +532,17 @@ class WalkElement {
455
532
  get _nid() {
456
533
  return this.#nodeId;
457
534
  }
535
+ /** @internal */
536
+ get _refs() {
537
+ return this.#wire.resolver.refs;
538
+ }
458
539
  static {
459
540
  WALK_PROPS_DESC = {
460
541
  enumerable: true,
461
542
  configurable: true,
462
543
  get() {
463
- const val = decodeProperties(this.#view, this.#buf, this.#propsPos);
544
+ const w = this.#wire;
545
+ const val = decodeProperties(w.view, w.buf, this.#propsPos);
464
546
  Object.defineProperty(this, "properties", {
465
547
  value: val,
466
548
  writable: true,
@@ -474,7 +556,8 @@ class WalkElement {
474
556
  enumerable: true,
475
557
  configurable: true,
476
558
  get() {
477
- const val = readChildStubs(this.#view, this.#buf, this.#childIdsPos, this.#childTypesPos, this.#childCount, this.#resolver);
559
+ const w = this.#wire;
560
+ const val = readChildStubs(w.view, w.buf, this.#childIdsPos, this.#childTypesPos, this.#childCount, w.resolver);
478
561
  Object.defineProperty(this, "children", {
479
562
  value: val,
480
563
  writable: true,
@@ -488,15 +571,15 @@ class WalkElement {
488
571
  }
489
572
  /** Read the tail of a matched element node (tag + properties).
490
573
  * Common prelude (data/position/children) is already consumed by `readMatchedNode`. */
491
- function readElementFromBinary(view, buf, offset, nodeId, resolver, position, childIdsPos, childTypesPos, childCount, data) {
574
+ function readElementFromBinary(wire, offset, nodeId, position, childIdsPos, childTypesPos, childCount, data) {
492
575
  let pos = offset;
493
576
  // Eager: tagName (almost always accessed by visitors)
494
- const tagLen = view.getUint16(pos, true);
577
+ const tagLen = wire.view.getUint16(pos, true);
495
578
  pos += 2;
496
- const tagName = rstr(buf, pos, tagLen);
579
+ const tagName = rstr(wire.buf, pos, tagLen);
497
580
  pos += tagLen;
498
- const propCount = view.getUint16(pos, true);
499
- const node = new WalkElement(tagName, nodeId, view, buf, pos, propCount, childIdsPos, childTypesPos, childCount, resolver);
581
+ const propCount = wire.view.getUint16(pos, true);
582
+ const node = new WalkElement(tagName, nodeId, wire, pos, propCount, childIdsPos, childTypesPos, childCount);
500
583
  if (position !== undefined)
501
584
  node.position = position;
502
585
  if (data !== null)
@@ -505,7 +588,7 @@ function readElementFromBinary(view, buf, offset, nodeId, resolver, position, ch
505
588
  }
506
589
  /** Value-carrying types read by `readTextFromBinary` (tag → AST name). */
507
590
  const TEXT_NODE_TYPES = Object.fromEntries(["text", "comment", "raw", "mdxFlowExpression", "mdxTextExpression", "mdxjsEsm"].map((name) => [NAME_TO_TYPE[name], name]));
508
- function readTextFromBinary(view, buf, offset, nodeId, nodeType, position, data) {
591
+ function readTextFromBinary(view, buf, offset, nodeId, nodeType, position, data, refs) {
509
592
  const valLen = view.getUint32(offset, true);
510
593
  const rawValue = rstr(buf, offset + 4, valLen);
511
594
  // MDX flow/text expressions store phantom-space sentinels; restore them so
@@ -519,7 +602,7 @@ function readTextFromBinary(view, buf, offset, nodeId, nodeType, position, data)
519
602
  if (data !== null)
520
603
  base.data = data;
521
604
  const node = base;
522
- nodeIdMap.set(node, nodeId);
605
+ refs.set(node, nodeId);
523
606
  if (nodeType === HAST_MDX_FLOW_EXPRESSION || nodeType === HAST_MDX_TEXT_EXPRESSION) {
524
607
  attachParseExpression(node, napiParseExpression);
525
608
  }
@@ -557,11 +640,12 @@ function readMdxJsxFromBinary(view, buf, offset, nodeId, nodeType, resolver, pos
557
640
  base.position = position;
558
641
  if (data !== null)
559
642
  base.data = data;
560
- nodeIdMap.set(base, nodeId);
643
+ resolver.refs.set(base, nodeId);
561
644
  makeLazyChildren(base, view, buf, childIdsPos, childTypesPos, childCount, resolver);
562
645
  return base;
563
646
  }
564
- function readMatchedNode(view, buf, offset, nodeId, nodeType, resolver) {
647
+ function readMatchedNode(wire, offset, nodeId, nodeType) {
648
+ const { view, buf, resolver } = wire;
565
649
  let pos = offset;
566
650
  // Shared prelude (matches serialize_hast_node_inline / serialize_mdast_node_inline):
567
651
  // [data_len: u32][data_bytes][position: 24B][child_count: u32][child_ids: N×u32][child_types: N×u8]
@@ -591,7 +675,7 @@ function readMatchedNode(view, buf, offset, nodeId, nodeType, resolver) {
591
675
  pos += childCount;
592
676
  // Dispatch to type-specific tail (pos now sits at the type-specific section)
593
677
  if (nodeType === HAST_ELEMENT) {
594
- return readElementFromBinary(view, buf, pos, nodeId, resolver, position, childIdsPos, childTypesPos, childCount, data);
678
+ return readElementFromBinary(wire, pos, nodeId, position, childIdsPos, childTypesPos, childCount, data);
595
679
  }
596
680
  else if (nodeType === HAST_TEXT ||
597
681
  nodeType === HAST_COMMENT ||
@@ -599,27 +683,40 @@ function readMatchedNode(view, buf, offset, nodeId, nodeType, resolver) {
599
683
  nodeType === HAST_MDX_FLOW_EXPRESSION ||
600
684
  nodeType === HAST_MDX_TEXT_EXPRESSION ||
601
685
  nodeType === HAST_MDX_ESM) {
602
- return readTextFromBinary(view, buf, pos, nodeId, nodeType, position, data);
686
+ return readTextFromBinary(view, buf, pos, nodeId, nodeType, position, data, resolver.refs);
603
687
  }
604
688
  else if (nodeType === HAST_MDX_JSX_ELEMENT || nodeType === HAST_MDX_JSX_TEXT_ELEMENT) {
605
689
  return readMdxJsxFromBinary(view, buf, pos, nodeId, nodeType, resolver, position, childIdsPos, childTypesPos, childCount, data);
606
690
  }
607
- // Fallback (e.g. doctype): minimal node carrying whatever prelude data we found
691
+ // Fallback: root and doctype.
608
692
  const base = { type: TYPE_NAMES[nodeType] ?? `unknown(${nodeType})` };
609
693
  if (position !== undefined)
610
694
  base.position = position;
611
695
  if (data !== null)
612
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
+ }
613
706
  const node = base;
614
- nodeIdMap.set(node, nodeId);
707
+ resolver.refs.set(node, nodeId);
615
708
  return node;
616
709
  }
710
+ const HAST_EPOCH_CACHE = registerEpochCacheSlot(new WeakMap());
617
711
  class HastLazyChildResolver extends LazyChildResolver {
712
+ cacheSlot() {
713
+ return HAST_EPOCH_CACHE;
714
+ }
618
715
  createReader(wire) {
619
716
  return new HastReader(wire);
620
717
  }
621
- materializeNode(reader, nodeId) {
622
- return materializeHastNode(reader, nodeId);
718
+ materializeNode(reader, nodeId, refs) {
719
+ return materializeHastNode(reader, nodeId, true, refs);
623
720
  }
624
721
  readParentId(reader, nodeId) {
625
722
  return reader.getParentId(nodeId);
@@ -649,27 +746,54 @@ function makeLazyChildren(node, view, buf, childIdsPos, childTypesPos, childCoun
649
746
  });
650
747
  }
651
748
  /** A result that is the same object as the input node is a no-op, so context
652
- * mutations (e.g. setProperty) are not clobbered. */
653
- function handleVisitResult(result, nodeId, returnBuffer, deferred, originalNode) {
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. */
754
+ function applyHastVisitResult(result, nodeId, returnBuffer, originalNode, refs) {
654
755
  if (result == null)
655
- return deferred;
756
+ return;
656
757
  if (result === originalNode)
657
- return deferred;
758
+ return;
759
+ if (isTextValueSwap(result, originalNode)) {
760
+ returnBuffer.setProperty(nodeId, "value", result.value);
761
+ return;
762
+ }
763
+ emitHastTree(returnBuffer, "replace", nodeId, result, refs);
764
+ }
765
+ function handleVisitResult(result, nodeId, returnBuffer, deferred, originalNode, refs) {
658
766
  if (result instanceof Promise) {
659
767
  const list = deferred ?? [];
660
768
  list.push({ nodeId, promise: result, originalNode });
661
769
  return list;
662
770
  }
663
- emitHastTree(returnBuffer, "replace", nodeId, result);
771
+ applyHastVisitResult(result, nodeId, returnBuffer, originalNode, refs);
664
772
  return deferred;
665
773
  }
666
- /**
667
- * Dispatch matched nodes from a binary match buffer to visitor functions.
668
- * Returns null if all sync, or an array of deferred promises if any visitor was async.
669
- */
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. */
777
+ function isTextValueSwap(result, original) {
778
+ if (result.type !== original.type)
779
+ return false;
780
+ if (result.type !== "text" && result.type !== "comment" && result.type !== "raw")
781
+ return false;
782
+ const r = result;
783
+ if (typeof r.value !== "string")
784
+ return false;
785
+ return (r.children === undefined &&
786
+ r.position === undefined &&
787
+ r.data === undefined &&
788
+ r.tagName === undefined &&
789
+ r.properties === undefined &&
790
+ r.name === undefined &&
791
+ r.attributes === undefined);
792
+ }
670
793
  function dispatchMatches(matchBuf, subs, ctx, returnBuffer, resolver) {
671
794
  const matchView = new DataView(matchBuf.buffer, matchBuf.byteOffset, matchBuf.byteLength);
672
795
  const matchCount = matchView.getUint32(0, true);
796
+ const wire = { view: matchView, buf: matchBuf, resolver };
673
797
  let deferred = null;
674
798
  for (let i = 0; i < matchCount; i++) {
675
799
  const indexBase = 4 + i * 10;
@@ -677,9 +801,9 @@ function dispatchMatches(matchBuf, subs, ctx, returnBuffer, resolver) {
677
801
  const subIndex = matchBuf[indexBase + 4];
678
802
  const dataOffset = matchView.getUint32(indexBase + 6, true);
679
803
  const sub = subs[subIndex];
680
- const node = readMatchedNode(matchView, matchBuf, dataOffset, nodeId, sub.nodeType, resolver);
804
+ const node = readMatchedNode(wire, dataOffset, nodeId, sub.nodeType);
681
805
  const result = sub.visitFn(node, ctx);
682
- deferred = handleVisitResult(result, nodeId, returnBuffer, deferred, node);
806
+ deferred = handleVisitResult(result, nodeId, returnBuffer, deferred, node, resolver.refs);
683
807
  }
684
808
  return deferred;
685
809
  }
@@ -691,35 +815,77 @@ function dispatchMatches(matchBuf, subs, ctx, returnBuffer, resolver) {
691
815
  * replaced earlier in the same pass (the caller warns when non-zero), or a
692
816
  * Promise of that count if any visitor is async.
693
817
  */
694
- export function visitHastHandle(handle, plugin, subs, source, fileURL, data = {}) {
818
+ export function visitHastHandle(handle, plugin, subs, source, fileURL, data = {}, sourceFormat = "markdown", diagnostics = []) {
819
+ const result = visitHastHandleCollect(handle, plugin, subs, source, fileURL, data, sourceFormat, diagnostics);
820
+ if (result instanceof Promise) {
821
+ return result.then((commands) => applyCollectedCommands(handle, commands));
822
+ }
823
+ return applyCollectedCommands(handle, result);
824
+ }
825
+ /** Apply commands collected by `visitHastHandleCollect`; returns the number of
826
+ * patches dropped as stranded (0 when none). */
827
+ function applyCollectedCommands(handle, commands) {
828
+ if (commands.length === 0)
829
+ return 0;
830
+ markHandleMutated(handle);
831
+ return applyCommandsToHandle(handle, commands);
832
+ }
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
+ */
839
+ export function visitHastHandleCollect(handle, plugin, subs, source, fileURL, data = {}, sourceFormat = "markdown", diagnostics = []) {
695
840
  const getSource = typeof source === "function" ? source : () => source;
696
841
  const resolver = new HastLazyChildResolver(handle);
697
- const ctx = new HastVisitorContextImpl(handle, getSource, fileURL, resolver, data);
698
- const returnBuffer = new CommandBuffer();
699
- const rustSubs = subs.map((s) => ({ nodeType: s.nodeType, tagFilter: s.tagFilter }));
842
+ const ctx = new HastVisitorContextImpl(handle, getSource, fileURL, resolver, data, sourceFormat, diagnostics);
843
+ const returnBuffer = acquireCommandBuffer();
844
+ const rustSubs = getRustSubs(plugin);
700
845
  const deferred = dispatchMatches(walkHandle(handle, rustSubs), subs, ctx, returnBuffer, resolver);
701
846
  if (deferred) {
702
847
  return Promise.all(deferred.map((d) => d.promise.then((result) => ({ nodeId: d.nodeId, result, originalNode: d.originalNode })))).then((results) => {
703
848
  for (const { nodeId, result, originalNode } of results) {
704
- if (result != null && result !== originalNode) {
705
- emitHastTree(returnBuffer, "replace", nodeId, result);
706
- }
849
+ applyHastVisitResult(result, nodeId, returnBuffer, originalNode, resolver.refs);
707
850
  }
708
- // Mutations land next, renumbering the arena: snapshots taken after
709
- // this point would resolve match-time child ids against wrong nodes.
710
- resolver.seal();
711
- return applyMutations(handle, returnBuffer, ctx);
851
+ return collectCommands(returnBuffer, ctx);
712
852
  });
713
853
  }
714
- resolver.seal();
715
- return applyMutations(handle, returnBuffer, ctx);
854
+ return collectCommands(returnBuffer, ctx);
855
+ }
856
+ const HAST_ROOT_SUBS = [
857
+ { nodeType: HAST_ROOT, tagFilter: [] },
858
+ ];
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. */
861
+ export function visitHastHookCollect(handle, plugin, hook, source, fileURL, data = {}, sourceFormat = "markdown", diagnostics = []) {
862
+ const getSource = typeof source === "function" ? source : () => source;
863
+ const resolver = new HastLazyChildResolver(handle);
864
+ const ctx = new HastVisitorContextImpl(handle, getSource, fileURL, resolver, data, sourceFormat, diagnostics);
865
+ const returnBuffer = acquireCommandBuffer();
866
+ const matchBuf = walkHandle(handle, HAST_ROOT_SUBS);
867
+ const matchView = new DataView(matchBuf.buffer, matchBuf.byteOffset, matchBuf.byteLength);
868
+ if (matchView.getUint32(0, true) === 0)
869
+ return collectCommands(returnBuffer, ctx);
870
+ const wire = { view: matchView, buf: matchBuf, resolver };
871
+ const root = readMatchedNode(wire, matchView.getUint32(10, true), matchView.getUint32(4, true), HAST_ROOT);
872
+ const result = hook.call(plugin, root, ctx);
873
+ if (result instanceof Promise)
874
+ return result.then(() => collectCommands(returnBuffer, ctx));
875
+ return collectCommands(returnBuffer, ctx);
716
876
  }
717
- /** Returns the number of patches dropped as stranded (0 when none). */
718
- function applyMutations(handle, returnBuffer, ctx) {
719
- const { merged, hasMutations } = mergeAndReset(returnBuffer, ctx);
720
- if (hasMutations) {
721
- markHandleMutated(handle);
722
- return applyCommandsToHandle(handle, merged);
877
+ export function visitHastHook(handle, plugin, hook, source, fileURL, data = {}, sourceFormat = "markdown", diagnostics = []) {
878
+ const result = visitHastHookCollect(handle, plugin, hook, source, fileURL, data, sourceFormat, diagnostics);
879
+ if (result instanceof Promise) {
880
+ return result.then((commands) => applyCollectedCommands(handle, commands));
723
881
  }
724
- return 0;
882
+ return applyCollectedCommands(handle, result);
883
+ }
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;
725
891
  }