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
package/dist/index.d.ts CHANGED
@@ -1,11 +1,11 @@
1
- export { markdownToHtml, mdxToJs, evaluate, markdownToMdast, mdxToMdast, markdownToHast, mdxToHast, } from "./compile.js";
2
- export type { CompileOptions, MdxCompileOptions, MdxOnlyOptions, EvaluateOptions, OptimizeStaticConfig, Features, SmartPunctuationOptions, Frontmatter, MarkdownToHtmlResult, MdxToJsResult, } from "./compile.js";
1
+ export { markdownToHtml, markdownToJs, mdxToJs, evaluate, markdownToMdast, mdxToMdast, markdownToHast, mdxToHast, htmlToHast, } from "./compile.js";
2
+ export type { CompileOptions, MdxCompileOptions, MdxOnlyOptions, MarkdownToJsOptions, EvaluateOptions, OptimizeStaticConfig, Features, TreeOptions, SmartPunctuationOptions, Frontmatter, MarkdownToHtmlResult, MarkdownToJsResult, MdxToJsResult, } from "./compile.js";
3
3
  export { defineMdastPlugin, defineHastPlugin } from "./plugin.js";
4
- export type { MdastPluginDefinition, HastPluginDefinition, MdastPluginInput, HastPluginInput, } from "./plugin.js";
5
- export type { HastVisitorInstance, HastVisitorContext, HastFilteredVisitor, HastContent, EstreeProgram, } from "./hast/hast-visitor.js";
6
- export type { MdastNode, HastNode, DataMap, Data, Position, Point, MdxJsxAttributeNode, MdxJsxExpressionAttributeNode, MdxJsxAttributeValueExpressionNode, MdxJsxAttributeUnion, MdxJsxFlowElement, MdxJsxTextElement, MdxFlowExpression, MdxTextExpression, MdxjsEsm, MdxJsxFlowElementHast, MdxJsxTextElementHast, MdxFlowExpressionHast, MdxTextExpressionHast, MdxjsEsmHast, } from "./types.js";
4
+ export type { MdastPluginDefinition, HastPluginDefinition, MdastPluginInput, HastPluginInput, MdastPluginEntry, HastPluginEntry, MdastPluginList, HastPluginList, PluginFactoryContext, } from "./plugin.js";
5
+ export type { HastVisitorInstance, HastVisitorContext, HastFilteredVisitor, HastContent, HastParentContent, RawHastContent, RawHtmlHastContent, EstreeProgram, } from "./hast/hast-visitor.js";
6
+ export type { MdastNode, HastNode, Custom, DataMap, Data, SourceFormat, Position, Point, MdxJsxAttributeNode, MdxJsxExpressionAttributeNode, MdxJsxAttributeValueExpressionNode, MdxJsxAttributeUnion, MdxJsxFlowElement, MdxJsxTextElement, MdxFlowExpression, MdxTextExpression, MdxjsEsm, MdxJsxFlowElementHast, MdxJsxTextElementHast, MdxFlowExpressionHast, MdxTextExpressionHast, MdxjsEsmHast, } from "./types.js";
7
7
  export { visitMdastHandle, resolveMdastSubscriptions } from "./mdast/mdast-visitor.js";
8
- export type { MdastPluginInstance, MdastVisitorContext, MdastContent, } from "./mdast/mdast-visitor.js";
8
+ export type { MdastPluginInstance, MdastVisitorContext, MdastContent, MdastTarget, MdastParentContent, RawMdastContent, RawHtmlMdastContent, } from "./mdast/mdast-visitor.js";
9
9
  export { visitHastHandle, resolveSubscriptions as resolveHastSubscriptions, } from "./hast/hast-visitor.js";
10
10
  export { MdastReader } from "./mdast/mdast-reader.js";
11
11
  export { materializeMdastTree } from "./mdast/mdast-materializer.js";
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // Public API: compile functions
2
- export { markdownToHtml, mdxToJs, evaluate, markdownToMdast, mdxToMdast, markdownToHast, mdxToHast, } from "./compile.js";
2
+ export { markdownToHtml, markdownToJs, mdxToJs, evaluate, markdownToMdast, mdxToMdast, markdownToHast, mdxToHast, htmlToHast, } from "./compile.js";
3
3
  // Plugin definitions
4
4
  export { defineMdastPlugin, defineHastPlugin } from "./plugin.js";
5
5
  // Visitor pipeline (for manual plugin execution)
@@ -1,7 +1,17 @@
1
+ import type { NodeRefs } from "./visitor-shared.js";
1
2
  import type { AnyHandle } from "./handles.js";
3
+ export declare function registerEpochCacheSlot<T extends object>(slot: WeakMap<AnyHandle, T>): WeakMap<AnyHandle, T>;
2
4
  /** Record that `handle`'s arena was rebuilt. Resolvers created before the bump
3
5
  * refuse to take a fresh snapshot afterwards (their ids are stale). */
4
6
  export declare function markHandleMutated(handle: AnyHandle): void;
7
+ /** Snapshot for one `(handle, epoch)`. Immutable once built, so it is shared
8
+ * by every resolver of the same pass chain: nested matched containers dedup
9
+ * by id, and later read-only passes reuse the whole cache instead of
10
+ * re-serializing and re-materializing. */
11
+ export interface EpochCache<TReader> {
12
+ epoch: number;
13
+ reader: TReader;
14
+ }
5
15
  /**
6
16
  * Lazy node materializer for the walk paths: serializes the handle's arena
7
17
  * once, on the first stub materialization, then materializes nodes from that
@@ -10,23 +20,20 @@ export declare function markHandleMutated(handle: AnyHandle): void;
10
20
  */
11
21
  export declare abstract class LazyChildResolver<TReader, TNode> {
12
22
  #private;
23
+ readonly refs: NodeRefs;
13
24
  constructor(handle: AnyHandle);
14
25
  protected abstract createReader(wire: Uint8Array): TReader;
15
- protected abstract materializeNode(reader: TReader, nodeId: number): TNode;
26
+ protected abstract materializeNode(reader: TReader, nodeId: number, refs: NodeRefs): TNode;
16
27
  protected abstract readParentId(reader: TReader, nodeId: number): number;
17
28
  protected abstract readChildIds(reader: TReader, nodeId: number): number[];
18
- /**
19
- * Mark the visitor pass over. Child ids were captured at match time; once
20
- * the pass's mutations land the arena is rebuilt and ids renumbered, so a
21
- * later snapshot would map those stale ids onto the wrong nodes (or out of
22
- * range). Failing loudly here beats silently wrong children.
23
- */
24
- seal(): void;
25
- /** Guards the `.children` getters: a first read after the pass is refused
26
- * outright, even when no mutation landed — match-time ids cannot be trusted
27
- * once the plugin no longer controls the tree. */
28
- assertUnsealed(): void;
29
- /** Materialize one node for a child stub's first real-field read. */
29
+ /** Kind-specific `(handle → cache)` slot, supplied as a module-level WeakMap
30
+ * by each subclass so MDAST and HAST snapshots never collide. */
31
+ protected abstract cacheSlot(): WeakMap<AnyHandle, EpochCache<TReader>>;
32
+ /** Whether this pass's snapshot already exists; never takes one itself. */
33
+ hasHotSnapshot(): boolean;
34
+ /** Materialize one node for a child stub's first real-field read. The
35
+ * materializers memoize per `(reader, id)`, so overlapping subtrees and
36
+ * later passes share the same materialized objects. */
30
37
  materializeOne(nodeId: number): TNode;
31
38
  /** Arena id of `nodeId`'s parent in the pass snapshot, or undefined at the root. */
32
39
  parentIdOf(nodeId: number): number | undefined;
@@ -2,10 +2,30 @@ import { serializeHandle } from "#binding";
2
2
  /** Rebuild count per handle: bumped whenever a command buffer lands and
3
3
  * renumbers the arena, invalidating ids captured before it. */
4
4
  const HANDLE_EPOCHS = new WeakMap();
5
+ /** Per-flavor `(handle → cache)` slots, registered so a mutation can evict a snapshot the handle would otherwise pin. */
6
+ const EPOCH_CACHE_SLOTS = [];
7
+ export function registerEpochCacheSlot(slot) {
8
+ EPOCH_CACHE_SLOTS.push(slot);
9
+ return slot;
10
+ }
11
+ /** Per handle, not per epoch: an id stale within its own handle is the epoch machinery's to drop with a warning. */
12
+ const HANDLE_NODE_REFS = new WeakMap();
13
+ function nodeRefsOfHandle(handle) {
14
+ let refs = HANDLE_NODE_REFS.get(handle);
15
+ if (refs === undefined) {
16
+ refs = new WeakMap();
17
+ HANDLE_NODE_REFS.set(handle, refs);
18
+ }
19
+ return refs;
20
+ }
5
21
  /** Record that `handle`'s arena was rebuilt. Resolvers created before the bump
6
22
  * refuse to take a fresh snapshot afterwards (their ids are stale). */
7
23
  export function markHandleMutated(handle) {
8
24
  HANDLE_EPOCHS.set(handle, (HANDLE_EPOCHS.get(handle) ?? 0) + 1);
25
+ // Safe to evict: a stale-epoch entry can never be adopted, only pinned by existing resolvers
26
+ for (const slot of EPOCH_CACHE_SLOTS) {
27
+ slot.delete(handle);
28
+ }
9
29
  }
10
30
  /** Arena sentinel in the node struct's parent field: the node has no parent. */
11
31
  const NO_PARENT = 0xffffffff;
@@ -17,57 +37,66 @@ const NO_PARENT = 0xffffffff;
17
37
  */
18
38
  export class LazyChildResolver {
19
39
  #handle;
20
- #reader = null;
21
- #sealed = false;
22
40
  #epoch;
41
+ refs;
42
+ /** Strong pin: retained nodes keep their pass snapshot alive after later epochs evict the slot. */
43
+ #cache;
23
44
  constructor(handle) {
24
45
  this.#handle = handle;
25
46
  this.#epoch = HANDLE_EPOCHS.get(handle) ?? 0;
47
+ this.refs = nodeRefsOfHandle(handle);
26
48
  }
27
- /**
28
- * Mark the visitor pass over. Child ids were captured at match time; once
29
- * the pass's mutations land the arena is rebuilt and ids renumbered, so a
30
- * later snapshot would map those stale ids onto the wrong nodes (or out of
31
- * range). Failing loudly here beats silently wrong children.
32
- */
33
- seal() {
34
- this.#sealed = true;
35
- }
36
- /** Guards the `.children` getters: a first read after the pass is refused
37
- * outright, even when no mutation landed — match-time ids cannot be trusted
38
- * once the plugin no longer controls the tree. */
39
- assertUnsealed() {
40
- if (this.#sealed) {
49
+ #ensureCache() {
50
+ let cache = this.#cache;
51
+ if (cache !== undefined)
52
+ return cache;
53
+ const slot = this.cacheSlot();
54
+ cache = slot.get(this.#handle);
55
+ if (cache !== undefined && cache.epoch === this.#epoch) {
56
+ this.#cache = cache;
57
+ return cache;
58
+ }
59
+ // A node id proves the tree was read in-pass, so a deferred snapshot is
60
+ // still faithful as long as no command buffer mutated the arena since
61
+ // match time. An existing same-epoch cache is always safe: the snapshot
62
+ // is an immutable copy.
63
+ if ((HANDLE_EPOCHS.get(this.#handle) ?? 0) !== this.#epoch) {
41
64
  throw new Error("Cannot read node content: this node was retained past its visitor pass " +
42
- "and the tree may have changed since; read it inside the visitor.");
65
+ "and the tree has changed since. Reading a child node's field (or calling " +
66
+ "ctx.parent()) during the pass pins the pass snapshot; eager fields like " +
67
+ "tagName or properties do not. Or copy the data you need before the pass ends.");
43
68
  }
69
+ // The serialized buffer already carries each node's `data` blob (read
70
+ // eagerly by the materializer), and the arena isn't mutated mid-visit,
71
+ // so no separate lazy NAPI fetch is needed. This also keeps walk-path
72
+ // children consistent with the fully materialized tree (no `data` key
73
+ // when a node has none).
74
+ cache = {
75
+ epoch: this.#epoch,
76
+ reader: this.createReader(serializeHandle(this.#handle)),
77
+ };
78
+ this.#cache = cache;
79
+ slot.set(this.#handle, cache);
80
+ return cache;
44
81
  }
45
82
  #ensureReader() {
46
- if (this.#reader === null) {
47
- // A node id proves the tree was read in-pass, so a deferred snapshot is
48
- // still faithful as long as no command buffer rebuilt the arena since
49
- // match time (ids renumber on rebuild). An existing reader is always
50
- // safe: the snapshot is an immutable pre-mutation copy.
51
- if ((HANDLE_EPOCHS.get(this.#handle) ?? 0) !== this.#epoch) {
52
- throw new Error("Cannot read node content: this node was retained past its visitor pass " +
53
- "and the tree has changed since; read it inside the visitor.");
54
- }
55
- // The serialized buffer already carries each node's `data` blob (read
56
- // eagerly by the materializer), and the arena isn't mutated mid-visit —
57
- // so no separate lazy NAPI fetch is needed. This also keeps walk-path
58
- // children consistent with the fully materialized tree (no `data` key
59
- // when a node has none).
60
- this.#reader = this.createReader(serializeHandle(this.#handle));
61
- }
62
- return this.#reader;
83
+ return this.#ensureCache().reader;
84
+ }
85
+ /** Whether this pass's snapshot already exists; never takes one itself. */
86
+ hasHotSnapshot() {
87
+ if (this.#cache !== undefined)
88
+ return true;
89
+ const cache = this.cacheSlot().get(this.#handle);
90
+ return cache !== undefined && cache.epoch === this.#epoch;
63
91
  }
64
- /** Materialize one node for a child stub's first real-field read. */
92
+ /** Materialize one node for a child stub's first real-field read. The
93
+ * materializers memoize per `(reader, id)`, so overlapping subtrees and
94
+ * later passes share the same materialized objects. */
65
95
  materializeOne(nodeId) {
66
- return this.materializeNode(this.#ensureReader(), nodeId);
96
+ return this.materializeNode(this.#ensureCache().reader, nodeId, this.refs);
67
97
  }
68
98
  /** Arena id of `nodeId`'s parent in the pass snapshot, or undefined at the root. */
69
99
  parentIdOf(nodeId) {
70
- this.assertUnsealed();
71
100
  const parentId = this.readParentId(this.#ensureReader(), nodeId);
72
101
  return parentId === NO_PARENT ? undefined : parentId;
73
102
  }
@@ -77,7 +106,6 @@ export class LazyChildResolver {
77
106
  /** Index of `nodeId` within its parent's children in the pass snapshot,
78
107
  * or undefined at the root. */
79
108
  indexInParent(nodeId) {
80
- this.assertUnsealed();
81
109
  const reader = this.#ensureReader();
82
110
  const parentId = this.readParentId(reader, nodeId);
83
111
  if (parentId === NO_PARENT)
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Shared materializer machinery for the HAST and MDAST flavors: per-reader
3
+ * node memo, lazy `children` descriptors, and the frozen-mode (plugin walk
4
+ * path) freeze rules.
5
+ */
6
+ import type { Position } from "unist";
7
+ import type { NodeRefs } from "./visitor-shared.js";
8
+ /** The reader surface the shared machinery needs; both `HastReader` and `MdastReader` satisfy it. */
9
+ export interface MaterializerReader {
10
+ getNodeType(nodeId: number): number;
11
+ getChildIds(nodeId: number): number[];
12
+ getPosition(nodeId: number): Position | undefined;
13
+ getNodeData(nodeId: number): string | null;
14
+ }
15
+ export interface MaterializerSpec<TReader extends MaterializerReader, TNode extends object> {
16
+ /** Function name used in error/warning messages (e.g. "materializeHastNode"). */
17
+ label: string;
18
+ /** Node-type tag -> canonical AST name (the generated `TYPE_NAMES`). */
19
+ typeNames: Readonly<Record<number, string>>;
20
+ /** Whether `node` carries `children`. Takes the built node because mdast
21
+ * `custom` decides leafness per node rather than per type. */
22
+ hasChildren(nodeType: number, node: TNode, reader: TReader, nodeId: number): boolean;
23
+ /**
24
+ * Install the type-specific eager fields on `node`. Must not install
25
+ * `children`, `position`, `data`, or `_nodeId`, and must not freeze:
26
+ * the shared machinery owns all of those.
27
+ */
28
+ populate(node: TNode, reader: TReader, nodeId: number, nodeType: number): void;
29
+ }
30
+ /**
31
+ * Build a memoizing materializer, memoized per `(reader, id)`.
32
+ *
33
+ * `node` materializes one node with lazy `children`; `frozen` (the plugin walk
34
+ * path) deep-freezes every node at construction so plugins cannot corrupt the
35
+ * shared cache. `tree` materializes a whole tree eagerly, which is what the
36
+ * step-by-step API wants: it asked for the tree, so laziness would only add
37
+ * per-node accessor overhead.
38
+ */
39
+ export declare function createMaterializer<TReader extends MaterializerReader, TNode extends object>(spec: MaterializerSpec<TReader, TNode>): {
40
+ node: (reader: TReader, nodeId: number, frozen?: boolean, refs?: NodeRefs) => TNode;
41
+ tree: (reader: TReader, rootId: number) => TNode;
42
+ };
@@ -0,0 +1,192 @@
1
+ /**
2
+ * Shared materializer machinery for the HAST and MDAST flavors: per-reader
3
+ * node memo, lazy `children` descriptors, and the frozen-mode (plugin walk
4
+ * path) freeze rules.
5
+ */
6
+ import { deepFreeze } from "./freeze.js";
7
+ /**
8
+ * Build a memoizing materializer, memoized per `(reader, id)`.
9
+ *
10
+ * `node` materializes one node with lazy `children`; `frozen` (the plugin walk
11
+ * path) deep-freezes every node at construction so plugins cannot corrupt the
12
+ * shared cache. `tree` materializes a whole tree eagerly, which is what the
13
+ * step-by-step API wants: it asked for the tree, so laziness would only add
14
+ * per-node accessor overhead.
15
+ */
16
+ export function createMaterializer(spec) {
17
+ const readerCaches = new WeakMap();
18
+ function materialize(reader, nodeId, frozen = false, refs) {
19
+ const cache = readerCache(reader, frozen, refs);
20
+ let node = cache.nodes.get(nodeId);
21
+ if (node === undefined) {
22
+ node = buildNode(reader, cache, nodeId, reader.getNodeType(nodeId));
23
+ cache.nodes.set(nodeId, node);
24
+ }
25
+ return node;
26
+ }
27
+ /** Frozen-mode `children`: memoized in `cache.childLists` because the node
28
+ * is frozen, so the accessor cannot self-replace with a data property. */
29
+ function frozenChildrenDescriptor(reader, cache) {
30
+ return {
31
+ get() {
32
+ const nodeId = this._nodeId;
33
+ let value = cache.childLists.get(nodeId);
34
+ if (value === undefined) {
35
+ const ids = reader.getChildIds(nodeId);
36
+ const built = new Array(ids.length);
37
+ let i = 0;
38
+ for (const childId of ids)
39
+ built[i++] = materialize(reader, childId, true);
40
+ value = Object.freeze(built);
41
+ cache.childLists.set(nodeId, value);
42
+ }
43
+ return value;
44
+ },
45
+ configurable: true,
46
+ enumerable: true,
47
+ };
48
+ }
49
+ /** Mutable-mode `children`: self-replacing with a plain writable array on
50
+ * first read. The id is captured rather than stored on the node, so a
51
+ * materialized tree carries no marker for `toEqual` or a spread to find. */
52
+ function mutableChildrenDescriptor(reader, nodeId) {
53
+ return {
54
+ get() {
55
+ const ids = reader.getChildIds(nodeId);
56
+ const value = new Array(ids.length);
57
+ let i = 0;
58
+ for (const childId of ids)
59
+ value[i++] = materialize(reader, childId);
60
+ Object.defineProperty(this, "children", {
61
+ value,
62
+ writable: true,
63
+ configurable: true,
64
+ enumerable: true,
65
+ });
66
+ return value;
67
+ },
68
+ configurable: true,
69
+ enumerable: true,
70
+ };
71
+ }
72
+ function readerCache(reader, frozen, refs) {
73
+ let cache = readerCaches.get(reader);
74
+ if (cache === undefined) {
75
+ cache = {
76
+ nodes: new Map(),
77
+ childLists: new Map(),
78
+ children: undefined,
79
+ frozen,
80
+ refs,
81
+ };
82
+ if (frozen)
83
+ cache.children = frozenChildrenDescriptor(reader, cache);
84
+ readerCaches.set(reader, cache);
85
+ }
86
+ if (cache.frozen !== frozen) {
87
+ throw new Error(`${spec.label}: a reader cannot mix frozen and mutable materialization`);
88
+ }
89
+ return cache;
90
+ }
91
+ function buildNode(reader, cache, nodeId, nodeType, eager = false) {
92
+ const typeName = spec.typeNames[nodeType] ?? `unknown(${nodeType})`;
93
+ // Plain object, not a class: unified's `assertNode` rejects any other prototype.
94
+ const node = { type: typeName };
95
+ const position = reader.getPosition(nodeId);
96
+ if (position !== undefined) {
97
+ node.position = position;
98
+ }
99
+ if (cache.frozen) {
100
+ // Non-enumerable so `nid()` never trusts an id that a spread copied.
101
+ cache.refs?.set(node, nodeId);
102
+ Object.defineProperty(node, "_nodeId", {
103
+ value: nodeId,
104
+ writable: false,
105
+ configurable: true,
106
+ enumerable: false,
107
+ });
108
+ }
109
+ spec.populate(node, reader, nodeId, nodeType);
110
+ // Plugins can set `data` on any node type, so rehydrate generically
111
+ // (see website/content/docs/divergences.md for the code-block case).
112
+ const rawData = reader.getNodeData(nodeId);
113
+ if (rawData !== null) {
114
+ try {
115
+ const parsed = JSON.parse(rawData);
116
+ if (parsed && typeof parsed === "object" && Object.keys(parsed).length > 0) {
117
+ Object.defineProperty(node, "data", {
118
+ value: parsed,
119
+ writable: true,
120
+ configurable: true,
121
+ enumerable: true,
122
+ });
123
+ }
124
+ }
125
+ catch (err) {
126
+ if (process.env.NODE_ENV !== "production") {
127
+ console.warn(`${spec.label}: malformed node_data for nodeId=${nodeId}`, err);
128
+ }
129
+ }
130
+ }
131
+ if (!eager && spec.hasChildren(nodeType, node, reader, nodeId)) {
132
+ Object.defineProperty(node, "children", cache.children ?? mutableChildrenDescriptor(reader, nodeId));
133
+ }
134
+ if (cache.frozen) {
135
+ // Deep-freeze the eager own values but not the lazy `children` accessor;
136
+ // freeze eagerly even for containers so nothing is writable while cached.
137
+ const descriptors = Object.getOwnPropertyDescriptors(node);
138
+ for (const key of Object.keys(descriptors)) {
139
+ const desc = descriptors[key];
140
+ if (desc !== undefined && "value" in desc) {
141
+ deepFreeze(desc.value);
142
+ }
143
+ }
144
+ Object.freeze(node);
145
+ }
146
+ return node;
147
+ }
148
+ /**
149
+ * Iterative on purpose: recursing to full document depth overflows on deeply
150
+ * nested input, and nothing else here descends more than one level.
151
+ *
152
+ * Breadth-first over the parents still to fill, so every node is built and
153
+ * every child list decoded exactly once, and a child is wired into its
154
+ * parent's array as it is built rather than in a second pass.
155
+ */
156
+ function fillTree(reader, cache, rootId) {
157
+ const rootType = reader.getNodeType(rootId);
158
+ const root = buildNode(reader, cache, rootId, rootType, true);
159
+ if (!spec.hasChildren(rootType, root, reader, rootId))
160
+ return root;
161
+ const parents = [root];
162
+ const parentIds = [rootId];
163
+ for (let p = 0; p < parentIds.length; p++) {
164
+ const parent = parents[p];
165
+ const parentId = parentIds[p];
166
+ if (parent === undefined || parentId === undefined)
167
+ continue;
168
+ const ids = reader.getChildIds(parentId);
169
+ const kids = new Array(ids.length);
170
+ for (let i = 0; i < ids.length; i++) {
171
+ const childId = ids[i];
172
+ if (childId === undefined)
173
+ continue;
174
+ const childType = reader.getNodeType(childId);
175
+ const child = buildNode(reader, cache, childId, childType, true);
176
+ kids[i] = child;
177
+ if (spec.hasChildren(childType, child, reader, childId)) {
178
+ parents.push(child);
179
+ parentIds.push(childId);
180
+ }
181
+ }
182
+ // Assignment beats defineProperty here; `eager` left `children` uninstalled.
183
+ parent.children = kids;
184
+ }
185
+ return root;
186
+ }
187
+ /** Whole tree at once: the caller will walk it, so lazy accessors cost more than they defer. */
188
+ function materializeTree(reader, rootId) {
189
+ return fillTree(reader, readerCache(reader, false, undefined), rootId);
190
+ }
191
+ return { node: materialize, tree: materializeTree };
192
+ }
@@ -5,9 +5,12 @@ type MdastResolver = LazyChildResolver<MdastReader, MdastNode>;
5
5
  /**
6
6
  * Walk-path child stub: arena id + `type` eagerly, every other field a lazy
7
7
  * forward to the materialized node (first read snapshots the arena via
8
- * `materializeOne`, which enforces the handle epoch — the pass seal is checked
9
- * where the stubs are built). Spread/identity rules are enforced by `nid()`
10
- * (authoritative doc in hast-visitor.ts).
8
+ * `materializeOne`, which enforces the handle epoch). Spread/identity rules
9
+ * are enforced by `nid()` (authoritative doc in hast-visitor.ts).
10
+ *
11
+ * A user-defined node's `type` is the one exception: it lives in the arena,
12
+ * not the tag, so it joins the lazy fields rather than making every sibling
13
+ * list snapshot the arena up front.
11
14
  */
12
15
  export declare class MdastChildStub {
13
16
  _resolver: MdastResolver;
@@ -1,4 +1,4 @@
1
- import { flatByTag, stubDescriptors } from "../child-stub.js";
1
+ import { flatByTag, installStubDescriptors, stubDescriptors, } from "../child-stub.js";
2
2
  import { MDAST_LAYOUT_KEYS } from "./generated/layout.js";
3
3
  import { NAME_TO_TYPE, TYPE_NAMES } from "./generated/node-types.js";
4
4
  import { LEAF_TYPES } from "./mdast-materializer.js";
@@ -8,6 +8,7 @@ const N = NAME_TO_TYPE;
8
8
  const HAND_WRITTEN_FIELDS = {
9
9
  [N.list]: ["ordered", "start", "spread"],
10
10
  [N.listItem]: ["spread", "checked"],
11
+ [N.descriptionDetails]: ["spread"],
11
12
  [N.table]: ["align"],
12
13
  [N.containerDirective]: ["name", "attributes"],
13
14
  [N.leafDirective]: ["name", "attributes"],
@@ -16,11 +17,18 @@ const HAND_WRITTEN_FIELDS = {
16
17
  [N.mdxJsxTextElement]: ["name", "attributes"],
17
18
  };
18
19
  const TYPE_NAME_BY_TAG = flatByTag(TYPE_NAMES);
20
+ /** Internal tag for user-defined nodes; its stored `name` field is folded into
21
+ * the open `node.type`, so the stub exposes `value` (leaf content) but no
22
+ * separate `name`. */
23
+ const MDAST_CUSTOM = NAME_TO_TYPE.custom;
19
24
  const MDAST_STUB_DESCRIPTORS = [];
20
25
  for (const tag of Object.keys(TYPE_NAMES)) {
21
26
  const nodeType = Number(tag);
22
- const fields = [...(MDAST_LAYOUT_KEYS[nodeType] ?? HAND_WRITTEN_FIELDS[nodeType] ?? [])];
23
- if (!LEAF_TYPES.has(nodeType))
27
+ const fields = nodeType === MDAST_CUSTOM
28
+ ? ["value"]
29
+ : [...(MDAST_LAYOUT_KEYS[nodeType] ?? HAND_WRITTEN_FIELDS[nodeType] ?? [])];
30
+ // `custom` gets its own `children` getter: leafness is per node, not per type.
31
+ if (nodeType !== MDAST_CUSTOM && !LEAF_TYPES.has(nodeType))
24
32
  fields.push("children");
25
33
  MDAST_STUB_DESCRIPTORS[nodeType] = stubDescriptors(fields);
26
34
  }
@@ -29,9 +37,12 @@ const FALLBACK_DESCRIPTORS = stubDescriptors([]);
29
37
  /**
30
38
  * Walk-path child stub: arena id + `type` eagerly, every other field a lazy
31
39
  * forward to the materialized node (first read snapshots the arena via
32
- * `materializeOne`, which enforces the handle epoch — the pass seal is checked
33
- * where the stubs are built). Spread/identity rules are enforced by `nid()`
34
- * (authoritative doc in hast-visitor.ts).
40
+ * `materializeOne`, which enforces the handle epoch). Spread/identity rules
41
+ * are enforced by `nid()` (authoritative doc in hast-visitor.ts).
42
+ *
43
+ * A user-defined node's `type` is the one exception: it lives in the arena,
44
+ * not the tag, so it joins the lazy fields rather than making every sibling
45
+ * list snapshot the arena up front.
35
46
  */
36
47
  export class MdastChildStub {
37
48
  _resolver;
@@ -40,7 +51,59 @@ export class MdastChildStub {
40
51
  constructor(resolver, id, nodeType) {
41
52
  this._resolver = resolver;
42
53
  this._id = id;
43
- this.type = TYPE_NAME_BY_TAG[nodeType] ?? `unknown(${nodeType})`;
44
- Object.defineProperties(this, MDAST_STUB_DESCRIPTORS[nodeType] ?? FALLBACK_DESCRIPTORS);
54
+ if (nodeType === MDAST_CUSTOM) {
55
+ installLazyCustomType(this);
56
+ installLazyCustomChildren(this);
57
+ }
58
+ else {
59
+ this.type = TYPE_NAME_BY_TAG[nodeType] ?? `unknown(${nodeType})`;
60
+ }
61
+ installStubDescriptors(this, MDAST_STUB_DESCRIPTORS[nodeType] ?? FALLBACK_DESCRIPTORS);
45
62
  }
63
+ /** @internal */
64
+ get _refs() {
65
+ return this._resolver.refs;
66
+ }
67
+ }
68
+ /** `children` as a self-replacing accessor, and self-*removing* for a leaf: the
69
+ * stub only knows the tag, so it cannot tell a leaf from a parent until the
70
+ * arena snapshot says which shape this node has. */
71
+ function installLazyCustomChildren(stub) {
72
+ Object.defineProperty(stub, "children", {
73
+ get() {
74
+ const real = this._resolver.materializeOne(this._id);
75
+ const value = real.children;
76
+ if (value === undefined) {
77
+ delete this.children;
78
+ return undefined;
79
+ }
80
+ Object.defineProperty(this, "children", {
81
+ value,
82
+ writable: false,
83
+ enumerable: true,
84
+ configurable: false,
85
+ });
86
+ return value;
87
+ },
88
+ enumerable: true,
89
+ configurable: true,
90
+ });
91
+ }
92
+ /** `type` as a self-replacing accessor: the materialized node folds the stored
93
+ * name into `type`. Enumerable so a spread copy still carries it. */
94
+ function installLazyCustomType(stub) {
95
+ Object.defineProperty(stub, "type", {
96
+ get() {
97
+ const value = this._resolver.materializeOne(this._id).type;
98
+ Object.defineProperty(this, "type", {
99
+ value,
100
+ writable: true,
101
+ enumerable: true,
102
+ configurable: true,
103
+ });
104
+ return value;
105
+ },
106
+ enumerable: true,
107
+ configurable: true,
108
+ });
46
109
  }
@@ -1,7 +1,6 @@
1
1
  import type { MdastReader } from "../mdast-reader.js";
2
2
  /** Materialized property names per tag (the non-skip `js` names above),
3
- * precomputed so `materializeMdastFields` doesn't rebuild them per node.
4
- * Exported for the child-stub field tables. */
3
+ * exported for the child-stub field tables. */
5
4
  export declare const MDAST_LAYOUT_KEYS: Readonly<Record<number, readonly string[]>>;
6
5
  /**
7
6
  * Decode a node's type-specific `type_data` from the walk buffer onto `node`,
@@ -14,6 +13,6 @@ export declare function decodeMdastTypeData(view: DataView, buf: Uint8Array, sta
14
13
  * Attach a node's fixed `type_data` fields, materialized from the arena
15
14
  * snapshot, driven by `MDAST_LAYOUTS`. Returns `false` for tags with no entry,
16
15
  * so the materializer falls through to its hand-written cases (list, table,
17
- * directives, MDX JSX). Fields resolve lazily on first access.
16
+ * directives, MDX JSX). Fields are eager plain stores.
18
17
  */
19
18
  export declare function materializeMdastFields(reader: MdastReader, node: object, nodeId: number, nodeType: number): boolean;