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,37 +1,39 @@
1
- import { materializeNode } from "./mdast-materializer.js";
1
+ import { LEAF_TYPES, isCustomLeaf, materializeNode } from "./mdast-materializer.js";
2
2
  import { MdastReader } from "./mdast-reader.js";
3
- import { CommandBuffer, classifyReturn } from "../command-buffer.js";
3
+ import { acquireCommandBuffer, releaseCommandBuffer, classifyReturn, CommandBuffer, STRUCTURAL_CMD, } from "../command-buffer.js";
4
+ import { CMD_SET_CHILDREN } from "../generated/wire-constants.js";
4
5
  import { ru32, rstr, readPosition } from "../wire-read.js";
5
6
  import { decodeMdastTypeData } from "./generated/layout.js";
6
7
  import { TYPE_NAMES, NAME_TO_TYPE, VISITOR_KEYS, MDAST_OPSTREAM_TYPES, } from "./generated/node-types.js";
7
8
  import { OpWriter, OF_VALUE, OF_URL, OF_TITLE, OF_ALT, OF_LANG, OF_META, OF_IDENTIFIER, OF_LABEL, OF_NAME, OF_REFERENCE_TYPE, OF_DEPTH, OF_CHECKED, OF_START, OF_ORDERED, OF_SPREAD, OF_EXPLICIT, PROP_STRING, emitMdxAttr, } from "../op-stream.js";
8
9
  import { walkMdastHandle, mdastTextContentHandle } from "#binding";
9
- import { asArray, makeRequireNid, mergeAndReset, unencodableContentError, } from "../visitor-shared.js";
10
- import { LazyChildResolver } from "../lazy-child-resolver.js";
10
+ import { asArray, makeRequireNid, mergeAndReset, crossPipelineForeign, FOREIGN_REF, ROOT_NODE_ID, requireRootReplacement, rootReplacementError, unencodableContentError, } from "../visitor-shared.js";
11
+ import { LazyChildResolver, registerEpochCacheSlot, } from "../lazy-child-resolver.js";
11
12
  import { MdastChildStub } from "./child-stub.js";
12
- /** Maps MdastNode objects to their arena node IDs without Object.defineProperty overhead. */
13
- const mdastNodeIdMap = new WeakMap();
14
- function nid(node) {
13
+ function nid(node, refs) {
15
14
  // Genuine stubs carry their id as a plain field; a spread copy is not
16
15
  // `instanceof` and has no `_nodeId`, so it correctly reads as new content.
17
16
  if (node instanceof MdastChildStub)
18
- return node._id;
19
- const id = mdastNodeIdMap.get(node);
17
+ return node._refs === refs ? node._id : FOREIGN_REF;
18
+ const id = refs.get(node);
20
19
  if (id !== undefined)
21
20
  return id;
22
- // Plain objects are trusted only via the WeakMap or a NON-enumerable
21
+ // Plain objects are trusted only via this tree's refs or a NON-enumerable
23
22
  // `_nodeId` (the materializer's convention, which spread cannot copy) — an
24
23
  // enumerable one rode in on a copy and must read as new content.
25
24
  const d = Object.getOwnPropertyDescriptor(node, "_nodeId");
26
- return d !== undefined && !d.enumerable ? d.value : undefined;
25
+ if (d !== undefined && !d.enumerable)
26
+ return FOREIGN_REF;
27
+ return crossPipelineForeign(node);
27
28
  }
28
29
  const requireNid = makeRequireNid(nid);
29
30
  export class MdastVisitorContext {
30
- #commandBuffer = new CommandBuffer();
31
- #diagnostics = [];
31
+ #commandBuffer = acquireCommandBuffer();
32
+ #diagnostics;
32
33
  #handle;
33
34
  #getSource;
34
35
  #resolver;
36
+ #refs;
35
37
  /** One canonical object per parent id, so visitors can dedupe by identity.
36
38
  * Null until the first `parent()` call; most passes never make one. */
37
39
  #parentsById = null;
@@ -49,12 +51,21 @@ export class MdastVisitorContext {
49
51
  * instances. Returned to the caller as `result.data`.
50
52
  */
51
53
  data;
52
- constructor(handle, getSource, fileURL, resolver, data) {
54
+ /**
55
+ * The source format this compile is processing: `"markdown"` for a plain
56
+ * Markdown compile, `"mdx"` for an MDX one. Lets a plugin shared between both
57
+ * pipelines branch on which it is handling.
58
+ */
59
+ sourceFormat;
60
+ constructor(handle, getSource, fileURL, resolver, data, sourceFormat, diagnostics) {
53
61
  this.#handle = handle;
54
62
  this.#getSource = getSource;
55
63
  this.fileURL = fileURL;
56
64
  this.#resolver = resolver;
65
+ this.#refs = resolver.refs;
57
66
  this.data = data;
67
+ this.sourceFormat = sourceFormat;
68
+ this.#diagnostics = diagnostics;
58
69
  }
59
70
  get source() {
60
71
  const value = this.#getSource();
@@ -62,39 +73,44 @@ export class MdastVisitorContext {
62
73
  return value;
63
74
  }
64
75
  removeNode(node) {
65
- this.#commandBuffer.removeNode(requireNid(node, "removeNode"));
76
+ this.#commandBuffer.removeNode(requireNid(node, "removeNode", this.#refs));
66
77
  }
67
78
  insertBefore(node, newNode) {
68
- const id = requireNid(node, "insertBefore");
79
+ const id = requireNid(node, "insertBefore", this.#refs);
69
80
  for (const n of asArray(newNode))
70
- emitMdastTree(this.#commandBuffer, "insertBefore", id, n);
81
+ emitMdastTree(this.#commandBuffer, "insertBefore", id, n, false, this.#refs);
71
82
  }
72
83
  insertAfter(node, newNode) {
73
- const id = requireNid(node, "insertAfter");
84
+ const id = requireNid(node, "insertAfter", this.#refs);
74
85
  for (const n of asArray(newNode))
75
- emitMdastTree(this.#commandBuffer, "insertAfter", id, n);
86
+ emitMdastTree(this.#commandBuffer, "insertAfter", id, n, false, this.#refs);
76
87
  }
77
88
  /**
78
89
  * Wrap `node` in `parentNode`, making it `parentNode`'s first child. Any
79
- * children `parentNode` declares are kept after it.
90
+ * children `parentNode` declares are kept after it. `parentNode` must be a
91
+ * node type that can hold children, or a raw string parsing to exactly one
92
+ * such block (`{ raw: "> " }` wraps in a blockquote); to surround a node
93
+ * with raw HTML tags, use `replaceNode(node, [openTag, node, closeTag])`
94
+ * instead.
80
95
  */
81
96
  wrapNode(node, parentNode) {
82
- const id = requireNid(node, "wrapNode");
83
- emitMdastTree(this.#commandBuffer, "wrapNode", id, parentNode);
97
+ const id = requireNid(node, "wrapNode", this.#refs);
98
+ assertMdastWrapParent(parentNode);
99
+ emitMdastTree(this.#commandBuffer, "wrapNode", id, parentNode, false, this.#refs);
84
100
  }
85
101
  prependChild(node, childNode) {
86
- const id = requireNid(node, "prependChild");
102
+ const id = requireNid(node, "prependChild", this.#refs);
87
103
  for (const n of asArray(childNode))
88
- emitMdastTree(this.#commandBuffer, "prependChild", id, n);
104
+ emitMdastTree(this.#commandBuffer, "prependChild", id, n, false, this.#refs);
89
105
  }
90
106
  appendChild(node, childNode) {
91
- const id = requireNid(node, "appendChild");
107
+ const id = requireNid(node, "appendChild", this.#refs);
92
108
  for (const n of asArray(childNode))
93
- emitMdastTree(this.#commandBuffer, "appendChild", id, n);
109
+ emitMdastTree(this.#commandBuffer, "appendChild", id, n, false, this.#refs);
94
110
  }
95
111
  /** Insert one node or an array at `index`; clamps (`0` or less prepends, past the end appends). */
96
112
  insertChildAt(node, index, childNode) {
97
- const children = "children" in node ? node.children : [];
113
+ const children = ("children" in node ? node.children : undefined) ?? [];
98
114
  if (index <= 0 || children.length === 0) {
99
115
  this.prependChild(node, childNode);
100
116
  }
@@ -107,38 +123,70 @@ export class MdastVisitorContext {
107
123
  }
108
124
  /** Remove the `index`-th child of `node`; a no-op when there is no such child. */
109
125
  removeChildAt(node, index) {
110
- const child = "children" in node ? node.children[index] : undefined;
126
+ const child = "children" in node ? node.children?.[index] : undefined;
111
127
  if (child)
112
128
  this.removeNode(child);
113
129
  }
130
+ /**
131
+ * Swap `node` for one node, or for an array of nodes placed in order at its
132
+ * position. An empty array drops the node, the same as `removeNode`.
133
+ * The document root takes a `root`, the one place a `root` is accepted as
134
+ * content, or a raw string, which parses to a root of its own.
135
+ */
114
136
  replaceNode(node, newNode) {
115
- const id = requireNid(node, "replaceNode");
116
- emitMdastTree(this.#commandBuffer, "replace", id, newNode, true);
137
+ const id = requireNid(node, "replaceNode", this.#refs);
138
+ if (Array.isArray(newNode)) {
139
+ if (id === ROOT_NODE_ID && newNode.length > 1)
140
+ throw rootReplacementError(newNode);
141
+ // The last node carries the `replace` so refs back to the target still splice.
142
+ let previous;
143
+ for (const n of newNode) {
144
+ if (previous !== undefined) {
145
+ emitMdastTree(this.#commandBuffer, "insertBefore", id, previous, false, this.#refs);
146
+ }
147
+ previous = n;
148
+ }
149
+ if (previous === undefined) {
150
+ // Replacing with nothing drops the node, like removeNode.
151
+ this.removeNode(node);
152
+ }
153
+ else if (id === ROOT_NODE_ID && !isRawMdastContent(previous)) {
154
+ emitMdastRootReplace(this.#commandBuffer, requireRootReplacement(previous), this.#refs);
155
+ }
156
+ else {
157
+ emitMdastTree(this.#commandBuffer, "replace", id, previous, true, this.#refs);
158
+ }
159
+ return;
160
+ }
161
+ if (id === ROOT_NODE_ID && !isRawMdastContent(newNode)) {
162
+ emitMdastRootReplace(this.#commandBuffer, requireRootReplacement(newNode), this.#refs);
163
+ return;
164
+ }
165
+ emitMdastTree(this.#commandBuffer, "replace", id, newNode, true, this.#refs);
117
166
  }
118
167
  setProperty(node, key, value) {
119
168
  if (key === "children") {
120
169
  // children is structural: set-children keeps the node and swaps only its
121
170
  // child list (reused children keep their id).
122
- const id = requireNid(node, "setProperty");
123
- const ops = compileMdastChildrenToOpstream(value);
124
- if (!ops)
171
+ const id = requireNid(node, "setProperty", this.#refs);
172
+ if (!emitMdastChildrenCommand(this.#commandBuffer, id, value, this.#refs)) {
125
173
  throw unencodableContentError(value);
126
- this.#commandBuffer.setChildrenOpstream(id, ops);
174
+ }
127
175
  return;
128
176
  }
129
177
  if (key === "data") {
130
178
  // data is stored as JSON in the arena, serialize it for the command buffer
131
- this.#commandBuffer.setProperty(requireNid(node, "setProperty"), key, value != null ? JSON.stringify(value) : null);
179
+ this.#commandBuffer.setProperty(requireNid(node, "setProperty", this.#refs), key, value != null ? JSON.stringify(value) : null);
132
180
  return;
133
181
  }
134
- this.#commandBuffer.setProperty(requireNid(node, "setProperty"), key, value);
182
+ this.#commandBuffer.setProperty(requireNid(node, "setProperty", this.#refs), key, value);
135
183
  }
136
184
  /** Collect the concatenated text of all descendant text nodes (like mdast-util-to-string). */
137
185
  textContent(node, options) {
138
- return mdastTextContentHandle(this.#handle, requireNid(node, "textContent"), options);
186
+ return mdastTextContentHandle(this.#handle, requireNid(node, "textContent", this.#refs), options);
139
187
  }
140
188
  parent(node) {
141
- const parentId = this.#resolver.parentIdOf(requireNid(node, "parent"));
189
+ const parentId = this.#resolver.parentIdOf(requireNid(node, "parent", this.#refs));
142
190
  if (parentId === undefined)
143
191
  return undefined;
144
192
  const byId = (this.#parentsById ??= new Map());
@@ -154,12 +202,13 @@ export class MdastVisitorContext {
154
202
  * Use this rather than `parent.children.indexOf(node)`, which won't find it.
155
203
  */
156
204
  indexOf(node) {
157
- return this.#resolver.indexInParent(requireNid(node, "indexOf"));
205
+ return this.#resolver.indexInParent(requireNid(node, "indexOf", this.#refs));
158
206
  }
159
207
  report({ message, node, severity = "error", }) {
208
+ const id = node ? nid(node, this.#refs) : undefined;
160
209
  this.#diagnostics.push({
161
210
  message,
162
- nodeId: node ? nid(node) : undefined,
211
+ nodeId: id === FOREIGN_REF ? undefined : id,
163
212
  position: node?.position,
164
213
  severity,
165
214
  });
@@ -172,7 +221,24 @@ export class MdastVisitorContext {
172
221
  return this.#diagnostics;
173
222
  }
174
223
  }
224
+ const mdastSubscriptionCache = new WeakMap();
175
225
  export function resolveMdastSubscriptions(plugin) {
226
+ const cached = mdastSubscriptionCache.get(plugin);
227
+ if (cached !== undefined)
228
+ return cached.subs;
229
+ const built = buildMdastSubscriptions(plugin);
230
+ mdastSubscriptionCache.set(plugin, built);
231
+ return built.subs;
232
+ }
233
+ function getMdastRustSubs(plugin) {
234
+ const cached = mdastSubscriptionCache.get(plugin);
235
+ if (cached !== undefined)
236
+ return cached.rustSubs;
237
+ const built = buildMdastSubscriptions(plugin);
238
+ mdastSubscriptionCache.set(plugin, built);
239
+ return built.rustSubs;
240
+ }
241
+ function buildMdastSubscriptions(plugin) {
176
242
  const subs = [];
177
243
  for (const [name, fn] of Object.entries(plugin)) {
178
244
  if (VISITOR_KEYS.has(name) && typeof fn === "function") {
@@ -185,14 +251,19 @@ export function resolveMdastSubscriptions(plugin) {
185
251
  }
186
252
  }
187
253
  }
188
- return subs;
254
+ const rustSubs = subs.map((s) => ({ nodeType: s.nodeType, tagFilter: [] }));
255
+ return { subs, rustSubs };
189
256
  }
257
+ const MDAST_EPOCH_CACHE = registerEpochCacheSlot(new WeakMap());
190
258
  class MdastLazyChildResolver extends LazyChildResolver {
259
+ cacheSlot() {
260
+ return MDAST_EPOCH_CACHE;
261
+ }
191
262
  createReader(wire) {
192
263
  return new MdastReader(wire);
193
264
  }
194
- materializeNode(reader, nodeId) {
195
- return materializeNode(reader, nodeId);
265
+ materializeNode(reader, nodeId, refs) {
266
+ return materializeNode(reader, nodeId, true, refs);
196
267
  }
197
268
  readParentId(reader, nodeId) {
198
269
  return reader.getParentId(nodeId);
@@ -202,11 +273,18 @@ class MdastLazyChildResolver extends LazyChildResolver {
202
273
  }
203
274
  }
204
275
  /** Build the child-stub list for a matched node from the wire's `[child_ids]
205
- * [child_types]` blocks — no arena snapshot. The seal check still applies:
206
- * post-pass ids are stale, and a stub built from them could later splice the
207
- * wrong node as a ref. */
276
+ * [child_types]` blocks, no arena snapshot. Stale ids are caught at
277
+ * materialization: the resolver's epoch check refuses a snapshot once the
278
+ * arena has mutated or been dropped. */
208
279
  function readMdastChildStubs(view, buf, idsPos, typesPos, count, resolver) {
209
- resolver.assertUnsealed();
280
+ // With a hot snapshot a stub's deferral buys nothing; real nodes skip its per-field getters.
281
+ if (resolver.hasHotSnapshot()) {
282
+ const nodes = new Array(count);
283
+ for (let i = 0; i < count; i++) {
284
+ nodes[i] = resolver.materializeOne(ru32(view, idsPos + i * 4));
285
+ }
286
+ return nodes;
287
+ }
210
288
  const stubs = new Array(count);
211
289
  for (let i = 0; i < count; i++) {
212
290
  stubs[i] = new MdastChildStub(resolver, ru32(view, idsPos + i * 4), buf[typesPos + i]);
@@ -293,82 +371,124 @@ function readMdastMatchedNode(view, buf, dataOffset, nodeId, nodeType, resolver)
293
371
  node.spread = buf[pos + 1] !== 0;
294
372
  break;
295
373
  }
374
+ case 37: {
375
+ // descriptionDetails
376
+ node.spread = buf[pos] !== 0;
377
+ break;
378
+ }
296
379
  // table (21), directives (30/31/32) and mdxJsx elements (100/101) are
297
380
  // decoded by the generated `decodeMdastTypeData` from their tails.
298
381
  // root(0), paragraph(1), thematicBreak(3), blockquote(4), emphasis(11),
299
382
  // strong(12), break(14), tableRow(22), tableCell(23), delete(24): no extra data
300
383
  }
301
384
  }
302
- mdastNodeIdMap.set(node, nodeId);
385
+ // User-defined node: the stored `name` field holds the author's public type
386
+ // string. Surface it as `node.type` (open type string) instead of the
387
+ // internal `"custom"`, drop the redundant `name`, and drop an empty `value`
388
+ // so a parent node isn't given a spurious leaf field.
389
+ if (nodeType === MDAST_CUSTOM) {
390
+ node.type = node.name;
391
+ delete node.name;
392
+ if (node.value === "")
393
+ delete node.value;
394
+ }
395
+ const leafCustom = nodeType === MDAST_CUSTOM && isCustomLeaf({ value: node.value, data: initialData }, childCount);
396
+ if (childCount === 0 && !LEAF_TYPES.has(nodeType) && !leafCustom) {
397
+ node.children = [];
398
+ }
399
+ resolver.refs.set(node, nodeId);
303
400
  if (initialData) {
304
401
  node.data = initialData;
305
402
  }
306
403
  return node;
307
404
  }
405
+ const MDAST_ROOT = NAME_TO_TYPE.root;
406
+ /** Internal tag for user-defined nodes. The stored `name` field holds the
407
+ * author's public `type` string; the read paths surface it as `node.type`
408
+ * and the emit path routes any unrecognized `type` here. */
409
+ const MDAST_CUSTOM = NAME_TO_TYPE.custom;
308
410
  /** The arena id of a node if it is an existing (materialized) node, else
309
411
  * undefined for a freshly-built one. */
310
- function reusedId(node) {
412
+ function reusedId(node, refs) {
311
413
  if (node === null || typeof node !== "object")
312
414
  return undefined;
313
- const id = nid(node);
314
- return typeof id === "number" ? id : undefined;
315
- }
316
- // Reused across every replacement in a pass: compile is synchronous and its
317
- // result is copied into the command buffer before the next call, so a single
318
- // writer is safe and avoids a 512-byte allocation per built node.
319
- const mdastWriter = new OpWriter();
320
- /**
321
- * Compile a declarative MDAST replacement tree to the op-stream — the only
322
- * structural encoding. Reused nodes (those still carrying an arena id) become
323
- * `ref`s so the rebuild splices the original back in place. Returns null when
324
- * the replay can't reproduce the tree identically (unsupported node type or
325
- * out-of-range numeric field); the caller turns that into a hard error.
326
- */
327
- function compileMdastToOpstream(root, forReplace = false) {
328
- mdastWriter.begin();
329
- try {
330
- if (!emitMdastOp(mdastWriter, root, true, forReplace))
331
- return null;
332
- return mdastWriter.take();
333
- }
334
- finally {
335
- mdastWriter.end();
336
- }
415
+ const id = nid(node, refs);
416
+ return id !== undefined && id !== FOREIGN_REF ? id : undefined;
337
417
  }
338
- /** Compile a set-children payload: a root-wrapped child list, the shape
418
+ /** Emit a set-children command in place: a root-wrapped child list, the shape
339
419
  * `Patch::SetChildren` splices in. Reused children become refs. */
340
- function compileMdastChildrenToOpstream(children) {
420
+ function emitMdastChildrenCommand(buffer, id, children, refs) {
341
421
  if (!Array.isArray(children))
342
- return null;
343
- mdastWriter.begin();
344
- try {
345
- mdastWriter.open(NAME_TO_TYPE.root);
422
+ return false;
423
+ return buffer.emitOpstreamCommand(CMD_SET_CHILDREN, id, () => {
424
+ buffer.open(MDAST_ROOT);
346
425
  for (const c of children) {
347
- if (!emitMdastOp(mdastWriter, c, false, false))
348
- return null;
426
+ if (!emitMdastOp(buffer, c, false, false, refs))
427
+ return false;
349
428
  }
350
- mdastWriter.close();
351
- return mdastWriter.take();
429
+ buffer.close();
430
+ return true;
431
+ });
432
+ }
433
+ /** Separate from the per-node encoder, which rejects a `root` payload. */
434
+ function emitMdastRootReplace(buffer, root, refs) {
435
+ const ok = buffer.emitOpstreamCommand(STRUCTURAL_CMD.replace, ROOT_NODE_ID, () => emitMdastRootOp(buffer, root, refs));
436
+ if (!ok)
437
+ throw unencodableContentError(root);
438
+ }
439
+ function emitMdastRootOp(w, n, refs) {
440
+ w.open(MDAST_ROOT);
441
+ if (n.data != null)
442
+ w.data(n.data);
443
+ if (n._keepChildren === true) {
444
+ w.keepChildren();
352
445
  }
353
- finally {
354
- mdastWriter.end();
446
+ else {
447
+ const children = n.children;
448
+ if (Array.isArray(children)) {
449
+ for (const c of children)
450
+ if (!emitMdastOp(w, c, false, true, refs))
451
+ return false;
452
+ }
355
453
  }
454
+ w.close();
455
+ return true;
356
456
  }
357
- function emitMdastOp(w, node, isRoot, forReplace) {
457
+ function emitMdastOp(w, node, isRoot, forReplace, refs) {
358
458
  if (node === null || typeof node !== "object")
359
459
  return false;
360
460
  if (!isRoot) {
361
- const id = reusedId(node);
461
+ const id = reusedId(node, refs);
362
462
  if (id !== undefined) {
363
463
  w.ref(id);
364
464
  return true;
365
465
  }
366
466
  }
367
467
  const n = node;
368
- const type = MDAST_OPSTREAM_TYPES[n.type];
369
- if (type === undefined)
370
- return false;
468
+ // Any `type` outside the built-in set is a user-defined node: route it to the
469
+ // internal `custom` tag and carry the author's `type` string as the name.
470
+ let type = MDAST_OPSTREAM_TYPES[n.type];
471
+ let isCustom = false;
472
+ if (type === undefined) {
473
+ if (typeof n.type !== "string" || n.type.length === 0)
474
+ return false;
475
+ // A known built-in that just isn't op-stream-encodable (e.g. `root`) is a
476
+ // real type used wrong, so fail loudly rather than reinterpreting it as a
477
+ // user-defined node. Only genuinely-unknown type strings become custom.
478
+ if (NAME_TO_TYPE[n.type] !== undefined)
479
+ return false;
480
+ type = MDAST_CUSTOM;
481
+ isCustom = true;
482
+ }
483
+ else if (type === MDAST_CUSTOM) {
484
+ // `"custom"` is the internal tag's own public name, so it resolves here
485
+ // instead of falling through as unknown. Still a user-defined node, so carry
486
+ // the `type` string as the name so it round-trips rather than vanishing.
487
+ isCustom = true;
488
+ }
371
489
  w.open(type);
490
+ if (isCustom)
491
+ w.str(OF_NAME, n.type);
372
492
  if (typeof n.value === "string")
373
493
  w.str(OF_VALUE, n.value);
374
494
  if (typeof n.url === "string")
@@ -405,7 +525,7 @@ function emitMdastOp(w, node, isRoot, forReplace) {
405
525
  w.bool(OF_ORDERED, n.ordered);
406
526
  if (typeof n.spread === "boolean")
407
527
  w.bool(OF_SPREAD, n.spread);
408
- if (typeof n.name === "string")
528
+ if (!isCustom && typeof n.name === "string")
409
529
  w.str(OF_NAME, n.name);
410
530
  const attrs = n.attributes;
411
531
  if (Array.isArray(attrs)) {
@@ -439,7 +559,7 @@ function emitMdastOp(w, node, isRoot, forReplace) {
439
559
  const children = n.children;
440
560
  if (Array.isArray(children)) {
441
561
  for (const c of children)
442
- if (!emitMdastOp(w, c, false, forReplace))
562
+ if (!emitMdastOp(w, c, false, forReplace, refs))
443
563
  return false;
444
564
  }
445
565
  }
@@ -463,7 +583,7 @@ function isRawMdastContent(content) {
463
583
  * op-stream is the only declarative encoding. The switches stay inline so the
464
584
  * buffer calls are monomorphic (computed method names defeat inline caches on
465
585
  * this warm path). */
466
- function emitMdastTree(buffer, op, id, content, forReplace = false) {
586
+ function emitMdastTree(buffer, op, id, content, forReplace, refs) {
467
587
  if (isRawMdastContent(content)) {
468
588
  switch (op) {
469
589
  case "replace":
@@ -480,27 +600,65 @@ function emitMdastTree(buffer, op, id, content, forReplace = false) {
480
600
  return buffer.wrapNode(id, content);
481
601
  }
482
602
  }
483
- const ops = compileMdastToOpstream(content, forReplace);
484
- if (ops === null)
603
+ const ok = buffer.emitOpstreamCommand(STRUCTURAL_CMD[op], id, () => emitMdastOp(buffer, content, true, forReplace, refs));
604
+ if (!ok)
485
605
  throw unencodableContentError(content);
486
- switch (op) {
487
- case "replace":
488
- return buffer.replaceOpstream(id, ops);
489
- case "insertBefore":
490
- return buffer.insertBeforeOpstream(id, ops);
491
- case "insertAfter":
492
- return buffer.insertAfterOpstream(id, ops);
493
- case "prependChild":
494
- return buffer.prependChildOpstream(id, ops);
495
- case "appendChild":
496
- return buffer.appendChildOpstream(id, ops);
497
- case "wrapNode":
498
- return buffer.wrapNodeOpstream(id, ops);
606
+ }
607
+ /** A leaf wrapper would make the patch engine drop or displace the wrapped node. */
608
+ function assertMdastWrapParent(parentNode) {
609
+ const sandwich = 'replaceNode(node, [{ type: "html", value: "<div>" }, node, { type: "html", value: "</div>" }])';
610
+ // A raw wrapper is only a tree once Rust has parsed it, so it is checked there.
611
+ if (isRawMdastContent(parentNode))
612
+ return;
613
+ const type = parentNode.type;
614
+ const tag = typeof type === "string" ? NAME_TO_TYPE[type] : undefined;
615
+ if (tag === undefined) {
616
+ // User-defined type: only the declared shape distinguishes a parent from
617
+ // a text leaf.
618
+ if (Array.isArray(parentNode.children))
619
+ return;
620
+ throw new Error(`wrapNode: a user-defined "${String(type)}" wrapper must declare a children array. ` +
621
+ "A leaf-shaped custom node renders as text and cannot hold the wrapped node.");
499
622
  }
623
+ if (!LEAF_TYPES.has(tag))
624
+ return;
625
+ throw new Error(`wrapNode: "${String(type)}" nodes cannot hold children, so they cannot wrap a node. ` +
626
+ 'Pass a parent node such as { type: "blockquote", children: [] }, a raw wrapper ' +
627
+ `such as { raw: "> " }, or surround the node with tags: ${sandwich}.`);
628
+ }
629
+ /** MDAST node types whose `value` Rust can set in place via setProperty. A
630
+ * visitor returning one of these as `{type, value}` with no other fields
631
+ * skips a full arena rebuild. */
632
+ const MDAST_VALUE_ONLY_TYPES = new Set([
633
+ "text",
634
+ "html",
635
+ "inlineCode",
636
+ "yaml",
637
+ "toml",
638
+ "inlineMath",
639
+ ]);
640
+ /** True when the visitor returned a same-type MDAST node carrying only `type`
641
+ * + `value`. Any other field present (children, position, data, lang, meta)
642
+ * falls back to the full replace path so nothing is silently dropped. */
643
+ function isMdastTextValueSwap(result, original) {
644
+ if (original === undefined)
645
+ return false;
646
+ if (result.type !== original.type)
647
+ return false;
648
+ if (!MDAST_VALUE_ONLY_TYPES.has(result.type))
649
+ return false;
650
+ const r = result;
651
+ if (typeof r.value !== "string")
652
+ return false;
653
+ return (r.children === undefined &&
654
+ r.position === undefined &&
655
+ r.data === undefined &&
656
+ r.lang === undefined &&
657
+ r.meta === undefined);
500
658
  }
501
659
  /** A result that is the same object as the input node is a no-op, so context
502
660
  * mutations (e.g. setProperty) are not clobbered. */
503
- function applyMdastVisitResult(result, nodeId, returnBuffer, originalNode) {
661
+ function applyMdastVisitResult(result, nodeId, returnBuffer, refs, originalNode) {
504
662
  if (result === undefined || result === null)
505
663
  return;
506
664
  if (result === originalNode)
@@ -513,9 +671,15 @@ function applyMdastVisitResult(result, nodeId, returnBuffer, originalNode) {
513
671
  case "raw_html":
514
672
  returnBuffer.replace(nodeId, result);
515
673
  break;
516
- case "structured_node":
517
- emitMdastTree(returnBuffer, "replace", nodeId, result, true);
674
+ case "structured_node": {
675
+ const node = result;
676
+ if (isMdastTextValueSwap(node, originalNode)) {
677
+ returnBuffer.setProperty(nodeId, "value", node.value);
678
+ break;
679
+ }
680
+ emitMdastTree(returnBuffer, "replace", nodeId, node, true, refs);
518
681
  break;
682
+ }
519
683
  }
520
684
  }
521
685
  /**
@@ -525,12 +689,12 @@ function applyMdastVisitResult(result, nodeId, returnBuffer, originalNode) {
525
689
  * Returns MdastVisitResult synchronously if all visitors are sync,
526
690
  * or Promise<MdastVisitResult> if any visitor is async.
527
691
  */
528
- export function visitMdastHandle(handle, plugin, subs, source, fileURL, data = {}) {
692
+ export function visitMdastHandle(handle, plugin, subs, source, fileURL, data = {}, sourceFormat = "markdown", diagnostics = []) {
529
693
  const getSource = typeof source === "function" ? source : () => source;
530
694
  const resolver = new MdastLazyChildResolver(handle);
531
- const context = new MdastVisitorContext(handle, getSource, fileURL, resolver, data);
532
- const returnBuffer = new CommandBuffer();
533
- const rustSubs = subs.map((s) => ({ nodeType: s.nodeType, tagFilter: [] }));
695
+ const context = new MdastVisitorContext(handle, getSource, fileURL, resolver, data, sourceFormat, diagnostics);
696
+ const returnBuffer = acquireCommandBuffer();
697
+ const rustSubs = getMdastRustSubs(plugin);
534
698
  const matchBuf = walkMdastHandle(handle, rustSubs);
535
699
  const matchView = new DataView(matchBuf.buffer, matchBuf.byteOffset, matchBuf.byteLength);
536
700
  const matchCount = ru32(matchView, 0);
@@ -548,25 +712,44 @@ export function visitMdastHandle(handle, plugin, subs, source, fileURL, data = {
548
712
  deferred.push({ nodeId, promise: result, originalNode: node });
549
713
  }
550
714
  else {
551
- applyMdastVisitResult(result, nodeId, returnBuffer, node);
715
+ applyMdastVisitResult(result, nodeId, returnBuffer, resolver.refs, node);
552
716
  }
553
717
  }
554
718
  if (deferred) {
555
719
  return Promise.all(deferred.map((d) => d.promise.then((r) => ({ nodeId: d.nodeId, result: r, originalNode: d.originalNode })))).then((results) => {
556
720
  for (const { nodeId, result, originalNode } of results) {
557
- applyMdastVisitResult(result, nodeId, returnBuffer, originalNode);
721
+ applyMdastVisitResult(result, nodeId, returnBuffer, resolver.refs, originalNode);
558
722
  }
559
- // End of the pass — the caller applies the returned command buffer next,
560
- // renumbering the arena, so later snapshots would resolve match-time
561
- // child ids against wrong nodes. This is the last point we control.
562
- resolver.seal();
563
723
  return finalizeMdastVisit(handle, context, returnBuffer);
564
724
  });
565
725
  }
566
- resolver.seal();
726
+ return finalizeMdastVisit(handle, context, returnBuffer);
727
+ }
728
+ const MDAST_ROOT_SUBS = [
729
+ { nodeType: MDAST_ROOT, tagFilter: [] },
730
+ ];
731
+ /** Its own pass so the caller can apply what `before` queued before the
732
+ * visitors walk, and the visitors' mutations before `after` reads the tree. */
733
+ export function visitMdastHook(handle, plugin, hook, source, fileURL, data = {}, sourceFormat = "markdown", diagnostics = []) {
734
+ const getSource = typeof source === "function" ? source : () => source;
735
+ const resolver = new MdastLazyChildResolver(handle);
736
+ const context = new MdastVisitorContext(handle, getSource, fileURL, resolver, data, sourceFormat, diagnostics);
737
+ const returnBuffer = acquireCommandBuffer();
738
+ const matchBuf = walkMdastHandle(handle, MDAST_ROOT_SUBS);
739
+ const matchView = new DataView(matchBuf.buffer, matchBuf.byteOffset, matchBuf.byteLength);
740
+ if (ru32(matchView, 0) === 0)
741
+ return finalizeMdastVisit(handle, context, returnBuffer);
742
+ const root = readMdastMatchedNode(matchView, matchBuf, ru32(matchView, 10), ru32(matchView, 4), MDAST_ROOT, resolver);
743
+ const result = hook.call(plugin, root, context);
744
+ if (result instanceof Promise) {
745
+ return result.then(() => finalizeMdastVisit(handle, context, returnBuffer));
746
+ }
567
747
  return finalizeMdastVisit(handle, context, returnBuffer);
568
748
  }
569
749
  function finalizeMdastVisit(handle, context, returnBuffer) {
570
750
  const { merged, hasMutations } = mergeAndReset(returnBuffer, context);
751
+ // Return both buffers to the pool. Bytes were copied into `merged` above.
752
+ releaseCommandBuffer(returnBuffer);
753
+ releaseCommandBuffer(context.getCommandBuffer());
571
754
  return { commandBuffer: merged, diagnostics: context.getDiagnostics(), hasMutations };
572
755
  }