725 lines
29 KiB
JavaScript
725 lines
29 KiB
JavaScript
import { materializeHastNode } from "./hast-materializer.js";
|
||
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";
|
||
import { TYPE_NAMES, NAME_TO_TYPE, VISITOR_KEYS, HAST_OPSTREAM_TYPES, } from "./generated/node-types.js";
|
||
import { CommandBuffer } from "../command-buffer.js";
|
||
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";
|
||
import { restorePhantomSpaces } from "../phantom.js";
|
||
import { decodeMdxJsxAttr } from "../mdx-attr.js";
|
||
import { decodeElementProp } from "./element-props.js";
|
||
import { readPosition, rstr } from "../wire-read.js";
|
||
import { walkHandle, applyCommandsToHandle, textContentHandle, parseExpression as napiParseExpression, parseEsm as napiParseEsm, } from "#binding";
|
||
import { asArray, makeRequireNid, mergeAndReset, unencodableContentError, } from "../visitor-shared.js";
|
||
import { LazyChildResolver, markHandleMutated } from "../lazy-child-resolver.js";
|
||
import { HastChildStub } from "./child-stub.js";
|
||
/** Maps HastNode objects to their arena node IDs without Object.defineProperty overhead. */
|
||
const nodeIdMap = new WeakMap();
|
||
/** Attach `parseExpression()` to an MDX expression or ESM node. */
|
||
function attachParseExpression(node, parseFn) {
|
||
Object.defineProperty(node, "parseExpression", {
|
||
value() {
|
||
const value = this.value;
|
||
if (typeof value !== "string")
|
||
return null;
|
||
const json = parseFn(value);
|
||
if (json == null)
|
||
return null;
|
||
return JSON.parse(json);
|
||
},
|
||
writable: false,
|
||
enumerable: false,
|
||
configurable: true,
|
||
});
|
||
}
|
||
/**
|
||
* Arena identity of a node, rejecting impostors — the one place the
|
||
* spread/identity invariant is enforced. A spread copy of a matched node or
|
||
* stub must read as NEW content: trusting a copied id would splice the
|
||
* original in as a ref and drop the copy's edits. Walk elements carry their
|
||
* id in a private field behind `instanceof` (spread copies fail the check);
|
||
* other walk-built nodes are keyed in the WeakMap (invisible to spread);
|
||
* `HastChildStub`s (enumerable `_id`, but that key is ignored on plain
|
||
* objects) are recognized by `instanceof`. Plain objects are trusted only via
|
||
* the WeakMap or a NON-enumerable `_nodeId` (the materializers' convention,
|
||
* which spread cannot copy).
|
||
*/
|
||
function nid(node) {
|
||
if (node instanceof WalkElement)
|
||
return node._nid;
|
||
if (node instanceof HastChildStub)
|
||
return node._id;
|
||
const id = nodeIdMap.get(node);
|
||
if (id !== undefined)
|
||
return id;
|
||
const d = Object.getOwnPropertyDescriptor(node, "_nodeId");
|
||
return d !== undefined && !d.enumerable ? d.value : undefined;
|
||
}
|
||
const requireNid = makeRequireNid(nid);
|
||
function hastReusedId(node) {
|
||
if (node === null || typeof node !== "object")
|
||
return undefined;
|
||
const id = nid(node);
|
||
return typeof id === "number" ? id : undefined;
|
||
}
|
||
// Reused across replacements in a pass — see the note on `mdastWriter`.
|
||
const hastWriter = new OpWriter();
|
||
/** Compile a set-children payload: a root-wrapped child list, the shape
|
||
* `Patch::SetChildren` splices in. Reused children become refs. */
|
||
function compileHastChildrenToOpstream(children) {
|
||
if (!Array.isArray(children))
|
||
return null;
|
||
hastWriter.begin();
|
||
try {
|
||
hastWriter.open(NAME_TO_TYPE.root);
|
||
for (const c of children) {
|
||
if (!emitHastOp(hastWriter, c, false))
|
||
return null;
|
||
}
|
||
hastWriter.close();
|
||
return hastWriter.take();
|
||
}
|
||
finally {
|
||
hastWriter.end();
|
||
}
|
||
}
|
||
/** Encode `node` as the `op` structural command. HAST content is always a
|
||
* declarative node (no raw escape hatch), so it compiles to the op-stream or
|
||
* it's a hard error — the op-stream is the only structural encoding. The
|
||
* switch stays inline so the buffer calls are monomorphic (computed method
|
||
* names defeat inline caches on this warm path). */
|
||
function emitHastTree(buffer, op, id, node) {
|
||
const ops = compileHastToOpstream(node);
|
||
if (ops === null)
|
||
throw unencodableContentError(node);
|
||
switch (op) {
|
||
case "replace":
|
||
return buffer.replaceOpstream(id, ops);
|
||
case "insertBefore":
|
||
return buffer.insertBeforeOpstream(id, ops);
|
||
case "insertAfter":
|
||
return buffer.insertAfterOpstream(id, ops);
|
||
case "prependChild":
|
||
return buffer.prependChildOpstream(id, ops);
|
||
case "appendChild":
|
||
return buffer.appendChildOpstream(id, ops);
|
||
case "wrapNode":
|
||
return buffer.wrapNodeOpstream(id, ops);
|
||
}
|
||
}
|
||
/**
|
||
* Compile a declarative HAST replacement tree to the op-stream — the only
|
||
* structural encoding. Reused nodes become `ref`s (transparent passthrough).
|
||
* Returns null when the tree holds a type the replay can't reproduce
|
||
* identically; the caller throws.
|
||
*/
|
||
function compileHastToOpstream(root) {
|
||
hastWriter.begin();
|
||
try {
|
||
if (!emitHastOp(hastWriter, root, true))
|
||
return null;
|
||
return hastWriter.take();
|
||
}
|
||
finally {
|
||
hastWriter.end();
|
||
}
|
||
}
|
||
function emitHastOp(w, node, isRoot) {
|
||
if (node === null || typeof node !== "object")
|
||
return false;
|
||
if (!isRoot) {
|
||
const id = hastReusedId(node);
|
||
if (id !== undefined) {
|
||
w.ref(id);
|
||
return true;
|
||
}
|
||
}
|
||
const n = node;
|
||
const type = HAST_OPSTREAM_TYPES[n.type];
|
||
if (type === undefined)
|
||
return false;
|
||
w.open(type);
|
||
if (type === HAST_ELEMENT) {
|
||
w.str(OF_TAGNAME, typeof n.tagName === "string" ? n.tagName : "div");
|
||
const props = n.properties;
|
||
if (props !== null && typeof props === "object") {
|
||
for (const key in props) {
|
||
emitHastProp(w, key, props[key]);
|
||
}
|
||
}
|
||
}
|
||
else if (type === HAST_MDX_JSX_ELEMENT || type === HAST_MDX_JSX_TEXT_ELEMENT) {
|
||
// Name falls back to tagName, matching `encode_hast_js_node_data`.
|
||
const name = typeof n.name === "string" ? n.name : typeof n.tagName === "string" ? n.tagName : "";
|
||
if (name !== "")
|
||
w.str(OF_NAME, name);
|
||
if (Array.isArray(n.attributes)) {
|
||
for (const a of n.attributes)
|
||
emitMdxAttr(w, a);
|
||
}
|
||
if (n.data?._mdxExplicitJsx === true) {
|
||
w.bool(OF_EXPLICIT, true);
|
||
}
|
||
}
|
||
else {
|
||
w.str(OF_VALUE, typeof n.value === "string" ? n.value : "");
|
||
}
|
||
if (n.data != null)
|
||
w.data(n.data);
|
||
const children = n.children;
|
||
if (Array.isArray(children)) {
|
||
for (const c of children)
|
||
if (!emitHastOp(w, c, false))
|
||
return false;
|
||
}
|
||
w.close();
|
||
return true;
|
||
}
|
||
/** Emit one element property, mirroring `encode_hast_js_node_data` exactly:
|
||
* bool/string/number/array → kind; null/object → skip. */
|
||
function emitHastProp(w, name, value) {
|
||
if (value === true)
|
||
w.prop(name, PROP_BOOL_TRUE, "");
|
||
else if (value === false)
|
||
w.prop(name, PROP_BOOL_FALSE, "");
|
||
else if (typeof value === "string")
|
||
w.prop(name, PROP_STRING, value);
|
||
else if (typeof value === "number")
|
||
w.prop(name, PROP_INT, String(value));
|
||
else if (Array.isArray(value))
|
||
w.prop(name, PROP_SPACE_SEP, value.filter((v) => typeof v === "string").join(" "));
|
||
}
|
||
class HastVisitorContextImpl {
|
||
#commandBuffer = new CommandBuffer();
|
||
#diagnostics = [];
|
||
/** Track accumulated node state for multiple setProperty calls on the same node. */
|
||
#pendingNodes = new Map();
|
||
#handle;
|
||
#getSource;
|
||
#resolver;
|
||
/** One canonical object per parent id, so visitors can dedupe by identity.
|
||
* Null until the first `parent()` call; most passes never make one. */
|
||
#parentsById = null;
|
||
fileURL;
|
||
data;
|
||
constructor(handle, getSource, fileURL, resolver, data) {
|
||
this.#handle = handle;
|
||
this.#getSource = getSource;
|
||
this.fileURL = fileURL;
|
||
this.#resolver = resolver;
|
||
this.data = data;
|
||
}
|
||
get source() {
|
||
const value = this.#getSource();
|
||
Object.defineProperty(this, "source", { value, writable: false, enumerable: true });
|
||
return value;
|
||
}
|
||
removeNode(node) {
|
||
this.#commandBuffer.removeNode(requireNid(node, "removeNode"));
|
||
}
|
||
replaceNode(node, newNode) {
|
||
const id = requireNid(node, "replaceNode");
|
||
emitHastTree(this.#commandBuffer, "replace", id, newNode);
|
||
// Track the replacement so a later mdxJsx setProperty can fold into it.
|
||
this.#pendingNodes.set(id, newNode);
|
||
}
|
||
insertBefore(node, newNode) {
|
||
const id = requireNid(node, "insertBefore");
|
||
for (const n of asArray(newNode))
|
||
emitHastTree(this.#commandBuffer, "insertBefore", id, n);
|
||
}
|
||
insertAfter(node, newNode) {
|
||
const id = requireNid(node, "insertAfter");
|
||
for (const n of asArray(newNode))
|
||
emitHastTree(this.#commandBuffer, "insertAfter", id, n);
|
||
}
|
||
wrapNode(node, parentNode) {
|
||
const id = requireNid(node, "wrapNode");
|
||
emitHastTree(this.#commandBuffer, "wrapNode", id, parentNode);
|
||
}
|
||
prependChild(node, childNode) {
|
||
const id = requireNid(node, "prependChild");
|
||
for (const n of asArray(childNode))
|
||
emitHastTree(this.#commandBuffer, "prependChild", id, n);
|
||
}
|
||
appendChild(node, childNode) {
|
||
const id = requireNid(node, "appendChild");
|
||
for (const n of asArray(childNode))
|
||
emitHastTree(this.#commandBuffer, "appendChild", id, n);
|
||
}
|
||
insertChildAt(node, index, childNode) {
|
||
const children = "children" in node ? node.children : [];
|
||
if (index <= 0 || children.length === 0) {
|
||
this.prependChild(node, childNode);
|
||
}
|
||
else if (index >= children.length) {
|
||
this.appendChild(node, childNode);
|
||
}
|
||
else {
|
||
this.insertBefore(children[index], childNode);
|
||
}
|
||
}
|
||
removeChildAt(node, index) {
|
||
const child = "children" in node ? node.children[index] : undefined;
|
||
if (child)
|
||
this.removeNode(child);
|
||
}
|
||
setProperty(node, key, value) {
|
||
const id = requireNid(node, "setProperty");
|
||
if (key === "children") {
|
||
// children is structural: set-children keeps the node and swaps only its
|
||
// child list (reused children keep their id).
|
||
const ops = compileHastChildrenToOpstream(value);
|
||
if (!ops)
|
||
throw unencodableContentError(value);
|
||
this.#commandBuffer.setChildrenOpstream(id, ops);
|
||
return;
|
||
}
|
||
if (key === "data") {
|
||
this.#commandBuffer.setProperty(id, key, value != null ? JSON.stringify(value) : null);
|
||
return;
|
||
}
|
||
if (node.type === "element") {
|
||
this.#commandBuffer.setProperty(id, key, value);
|
||
return;
|
||
}
|
||
if (node.type === "mdxJsxFlowElement" || node.type === "mdxJsxTextElement") {
|
||
// MDX JSX nodes carry `attributes`, not `properties`. If a replacement is
|
||
// already queued for this node, fold the attribute into it so the change
|
||
// survives the rebuild. This spreads the queued replacement object, not
|
||
// the matched node, so it never forces the matched node's children to
|
||
// materialize.
|
||
const pending = this.#pendingNodes.get(id);
|
||
if (pending !== undefined) {
|
||
const updated = { ...pending };
|
||
const attrs = [...(updated.attributes ?? [])];
|
||
const idx = attrs.findIndex((a) => a.type === "mdxJsxAttribute" && a.name === key);
|
||
if (idx !== -1)
|
||
attrs.splice(idx, 1);
|
||
// Arrays space-join, matching the binary path's PROP_SPACE_SEP encoding
|
||
// (hast convention for list-valued properties like className).
|
||
const attrValue = value === true || value === null || value === undefined
|
||
? null
|
||
: typeof value === "string"
|
||
? value
|
||
: Array.isArray(value)
|
||
? value.join(" ")
|
||
: String(value);
|
||
attrs.push({ type: "mdxJsxAttribute", name: key, value: attrValue });
|
||
updated.attributes = attrs;
|
||
this.replaceNode(node, updated);
|
||
return;
|
||
}
|
||
// Binary attribute upsert in the arena's type_data — no child
|
||
// materialization. Rust maps the value-type to a boolean (true/null) or
|
||
// literal (string/number/false) attribute, mirroring the fold path above.
|
||
this.#commandBuffer.setProperty(id, key, value);
|
||
return;
|
||
}
|
||
// Text-like nodes (text, comment, raw, expressions, esm): Rust handles
|
||
// `value` directly on these types.
|
||
this.#commandBuffer.setProperty(id, key, value);
|
||
}
|
||
textContent(node) {
|
||
return textContentHandle(this.#handle, requireNid(node, "textContent"));
|
||
}
|
||
parent(node) {
|
||
const parentId = this.#resolver.parentIdOf(requireNid(node, "parent"));
|
||
if (parentId === undefined)
|
||
return undefined;
|
||
const byId = (this.#parentsById ??= new Map());
|
||
let parent = byId.get(parentId);
|
||
if (parent === undefined) {
|
||
parent = this.#resolver.materializeOne(parentId);
|
||
byId.set(parentId, parent);
|
||
}
|
||
return parent;
|
||
}
|
||
indexOf(node) {
|
||
return this.#resolver.indexInParent(requireNid(node, "indexOf"));
|
||
}
|
||
report({ message, node, severity = "error", }) {
|
||
this.#diagnostics.push({ message, nodeId: node ? nid(node) : undefined, severity });
|
||
}
|
||
getCommandBuffer() {
|
||
return this.#commandBuffer;
|
||
}
|
||
getDiagnostics() {
|
||
return this.#diagnostics;
|
||
}
|
||
}
|
||
/** Node types that use filtered visitors (have tag/component names). */
|
||
const FILTERED_METHODS = new Set(["element", "mdxJsxFlowElement", "mdxJsxTextElement"]);
|
||
export function resolveSubscriptions(plugin) {
|
||
const subs = [];
|
||
for (const [methodName, nodeType] of Object.entries(METHOD_TO_TYPE)) {
|
||
const value = plugin[methodName];
|
||
if (value === undefined)
|
||
continue;
|
||
if (FILTERED_METHODS.has(methodName)) {
|
||
const items = Array.isArray(value) ? value : [value];
|
||
for (const fv of items) {
|
||
subs.push({
|
||
nodeType,
|
||
tagFilter: fv.filter,
|
||
visitFn: fv.visit,
|
||
});
|
||
}
|
||
}
|
||
else {
|
||
// Bare function, empty filter matches all nodes of this type
|
||
subs.push({ nodeType, tagFilter: [], visitFn: value });
|
||
}
|
||
}
|
||
return subs;
|
||
}
|
||
/** Visitor method name → node-type tag (method names are the subscribable AST names). */
|
||
const METHOD_TO_TYPE = Object.fromEntries([...VISITOR_KEYS].map((name) => [name, NAME_TO_TYPE[name]]));
|
||
function decodeProperties(view, buf, pos) {
|
||
const propCount = view.getUint16(pos, true);
|
||
pos += 2;
|
||
const properties = {};
|
||
for (let i = 0; i < propCount; i++) {
|
||
const nameLen = view.getUint16(pos, true);
|
||
pos += 2;
|
||
const name = rstr(buf, pos, nameLen);
|
||
pos += nameLen;
|
||
const kind = buf[pos];
|
||
pos += 1;
|
||
const valLen = view.getUint16(pos, true);
|
||
pos += 2;
|
||
const valStr = rstr(buf, pos, valLen);
|
||
pos += valLen;
|
||
properties[name] = decodeElementProp(kind, valStr);
|
||
}
|
||
return properties;
|
||
}
|
||
/** Build the child-stub list for a matched node from the wire's `[child_ids]
|
||
* [child_types]` blocks — no arena snapshot. The seal check still applies:
|
||
* post-pass ids are stale, and a stub built from them could later splice the
|
||
* wrong node as a ref. */
|
||
function readChildStubs(view, buf, idsPos, typesPos, count, resolver) {
|
||
resolver.assertUnsealed();
|
||
const stubs = new Array(count);
|
||
for (let i = 0; i < count; i++) {
|
||
stubs[i] = new HastChildStub(resolver, view.getUint32(idsPos + i * 4, true), buf[typesPos + i]);
|
||
}
|
||
return stubs;
|
||
}
|
||
// Shared own-getter descriptors for WalkElement's lazy fields, populated in
|
||
// its static block so the getters can read the private wire fields.
|
||
let WALK_PROPS_DESC;
|
||
let WALK_CHILDREN_DESC;
|
||
/**
|
||
* Walk-path element. Spread-correctness requires `properties`/`children` as
|
||
* own enumerable keys (`{ ...node }` copies nothing else), but construction
|
||
* runs per matched element, so everything stays off the expensive paths:
|
||
* wire state in private fields (plain stores, invisible to spread — a WeakMap
|
||
* entry per element caused major-GC ephemeron stalls at this volume), shared
|
||
* getter functions instead of per-node closures, at most one define per lazy
|
||
* field, and `instanceof` gating identity so copies read as new content.
|
||
*/
|
||
class WalkElement {
|
||
type = "element";
|
||
tagName;
|
||
#nodeId;
|
||
#view;
|
||
#buf;
|
||
#propsPos;
|
||
#childIdsPos;
|
||
#childTypesPos;
|
||
#childCount;
|
||
#resolver;
|
||
constructor(tagName, nodeId, view, buf, propsPos, propCount, childIdsPos, childTypesPos, childCount, resolver) {
|
||
this.tagName = tagName;
|
||
this.#nodeId = nodeId;
|
||
this.#view = view;
|
||
this.#buf = buf;
|
||
this.#propsPos = propsPos;
|
||
this.#childIdsPos = childIdsPos;
|
||
this.#childTypesPos = childTypesPos;
|
||
this.#childCount = childCount;
|
||
this.#resolver = resolver;
|
||
if (propCount === 0) {
|
||
this.properties = {};
|
||
}
|
||
else {
|
||
Object.defineProperty(this, "properties", WALK_PROPS_DESC);
|
||
}
|
||
if (childCount === 0) {
|
||
this.children = [];
|
||
}
|
||
else {
|
||
Object.defineProperty(this, "children", WALK_CHILDREN_DESC);
|
||
}
|
||
}
|
||
/** @internal */
|
||
get _nid() {
|
||
return this.#nodeId;
|
||
}
|
||
static {
|
||
WALK_PROPS_DESC = {
|
||
enumerable: true,
|
||
configurable: true,
|
||
get() {
|
||
const val = decodeProperties(this.#view, this.#buf, this.#propsPos);
|
||
Object.defineProperty(this, "properties", {
|
||
value: val,
|
||
writable: true,
|
||
enumerable: true,
|
||
configurable: true,
|
||
});
|
||
return val;
|
||
},
|
||
};
|
||
WALK_CHILDREN_DESC = {
|
||
enumerable: true,
|
||
configurable: true,
|
||
get() {
|
||
const val = readChildStubs(this.#view, this.#buf, this.#childIdsPos, this.#childTypesPos, this.#childCount, this.#resolver);
|
||
Object.defineProperty(this, "children", {
|
||
value: val,
|
||
writable: true,
|
||
enumerable: true,
|
||
configurable: true,
|
||
});
|
||
return val;
|
||
},
|
||
};
|
||
}
|
||
}
|
||
/** Read the tail of a matched element node (tag + properties).
|
||
* Common prelude (data/position/children) is already consumed by `readMatchedNode`. */
|
||
function readElementFromBinary(view, buf, offset, nodeId, resolver, position, childIdsPos, childTypesPos, childCount, data) {
|
||
let pos = offset;
|
||
// Eager: tagName (almost always accessed by visitors)
|
||
const tagLen = view.getUint16(pos, true);
|
||
pos += 2;
|
||
const tagName = rstr(buf, pos, tagLen);
|
||
pos += tagLen;
|
||
const propCount = view.getUint16(pos, true);
|
||
const node = new WalkElement(tagName, nodeId, view, buf, pos, propCount, childIdsPos, childTypesPos, childCount, resolver);
|
||
if (position !== undefined)
|
||
node.position = position;
|
||
if (data !== null)
|
||
node.data = data;
|
||
return node;
|
||
}
|
||
/** Value-carrying types read by `readTextFromBinary` (tag → AST name). */
|
||
const TEXT_NODE_TYPES = Object.fromEntries(["text", "comment", "raw", "mdxFlowExpression", "mdxTextExpression", "mdxjsEsm"].map((name) => [NAME_TO_TYPE[name], name]));
|
||
function readTextFromBinary(view, buf, offset, nodeId, nodeType, position, data) {
|
||
const valLen = view.getUint32(offset, true);
|
||
const rawValue = rstr(buf, offset + 4, valLen);
|
||
// MDX flow/text expressions store phantom-space sentinels; restore them so
|
||
// the value matches the reader path. ESM and plain text keep their value.
|
||
const value = nodeType === HAST_MDX_FLOW_EXPRESSION || nodeType === HAST_MDX_TEXT_EXPRESSION
|
||
? restorePhantomSpaces(rawValue)
|
||
: rawValue;
|
||
const base = { type: TEXT_NODE_TYPES[nodeType], value };
|
||
if (position !== undefined)
|
||
base.position = position;
|
||
if (data !== null)
|
||
base.data = data;
|
||
const node = base;
|
||
nodeIdMap.set(node, nodeId);
|
||
if (nodeType === HAST_MDX_FLOW_EXPRESSION || nodeType === HAST_MDX_TEXT_EXPRESSION) {
|
||
attachParseExpression(node, napiParseExpression);
|
||
}
|
||
else if (nodeType === HAST_MDX_ESM) {
|
||
attachParseExpression(node, napiParseEsm);
|
||
}
|
||
return node;
|
||
}
|
||
function readMdxJsxFromBinary(view, buf, offset, nodeId, nodeType, resolver, position, childIdsPos, childTypesPos, childCount, data) {
|
||
let pos = offset;
|
||
const nameLen = view.getUint16(pos, true);
|
||
pos += 2;
|
||
const name = nameLen > 0 ? rstr(buf, pos, nameLen) : null;
|
||
pos += nameLen;
|
||
// Attributes: [kind: u8][nameLen: u16][name][valLen: u32][val]
|
||
const attrCount = view.getUint16(pos, true);
|
||
pos += 2;
|
||
const attributes = [];
|
||
for (let i = 0; i < attrCount; i++) {
|
||
const kind = buf[pos];
|
||
pos += 1;
|
||
const attrNameLen = view.getUint16(pos, true);
|
||
pos += 2;
|
||
const attrName = rstr(buf, pos, attrNameLen);
|
||
pos += attrNameLen;
|
||
const attrValLen = view.getUint32(pos, true);
|
||
pos += 4;
|
||
const attrVal = rstr(buf, pos, attrValLen);
|
||
pos += attrValLen;
|
||
attributes.push(decodeMdxJsxAttr(kind, attrName, attrVal));
|
||
}
|
||
const typeName = nodeType === HAST_MDX_JSX_ELEMENT ? "mdxJsxFlowElement" : "mdxJsxTextElement";
|
||
const base = { type: typeName, name, attributes };
|
||
if (position !== undefined)
|
||
base.position = position;
|
||
if (data !== null)
|
||
base.data = data;
|
||
nodeIdMap.set(base, nodeId);
|
||
makeLazyChildren(base, view, buf, childIdsPos, childTypesPos, childCount, resolver);
|
||
return base;
|
||
}
|
||
function readMatchedNode(view, buf, offset, nodeId, nodeType, resolver) {
|
||
let pos = offset;
|
||
// Shared prelude (matches serialize_hast_node_inline / serialize_mdast_node_inline):
|
||
// [data_len: u32][data_bytes][position: 24B][child_count: u32][child_ids: N×u32][child_types: N×u8]
|
||
const dataLen = view.getUint32(pos, true);
|
||
pos += 4;
|
||
let data = null;
|
||
if (dataLen > 0) {
|
||
const jsonStr = rstr(buf, pos, dataLen);
|
||
try {
|
||
data = JSON.parse(jsonStr);
|
||
}
|
||
catch (err) {
|
||
if (process.env.NODE_ENV !== "production") {
|
||
console.warn(`readMatchedNode: malformed node_data for nodeId=${nodeId}`, err);
|
||
}
|
||
}
|
||
pos += dataLen;
|
||
}
|
||
const position = readPosition(view, pos);
|
||
pos += 24;
|
||
const childCount = view.getUint32(pos, true);
|
||
pos += 4;
|
||
// Ids/types decode lazily with `.children` — most matched nodes never read them.
|
||
const childIdsPos = pos;
|
||
pos += childCount * 4;
|
||
const childTypesPos = pos;
|
||
pos += childCount;
|
||
// Dispatch to type-specific tail (pos now sits at the type-specific section)
|
||
if (nodeType === HAST_ELEMENT) {
|
||
return readElementFromBinary(view, buf, pos, nodeId, resolver, position, childIdsPos, childTypesPos, childCount, data);
|
||
}
|
||
else if (nodeType === HAST_TEXT ||
|
||
nodeType === HAST_COMMENT ||
|
||
nodeType === HAST_RAW ||
|
||
nodeType === HAST_MDX_FLOW_EXPRESSION ||
|
||
nodeType === HAST_MDX_TEXT_EXPRESSION ||
|
||
nodeType === HAST_MDX_ESM) {
|
||
return readTextFromBinary(view, buf, pos, nodeId, nodeType, position, data);
|
||
}
|
||
else if (nodeType === HAST_MDX_JSX_ELEMENT || nodeType === HAST_MDX_JSX_TEXT_ELEMENT) {
|
||
return readMdxJsxFromBinary(view, buf, pos, nodeId, nodeType, resolver, position, childIdsPos, childTypesPos, childCount, data);
|
||
}
|
||
// Fallback (e.g. doctype): minimal node carrying whatever prelude data we found
|
||
const base = { type: TYPE_NAMES[nodeType] ?? `unknown(${nodeType})` };
|
||
if (position !== undefined)
|
||
base.position = position;
|
||
if (data !== null)
|
||
base.data = data;
|
||
const node = base;
|
||
nodeIdMap.set(node, nodeId);
|
||
return node;
|
||
}
|
||
class HastLazyChildResolver extends LazyChildResolver {
|
||
createReader(wire) {
|
||
return new HastReader(wire);
|
||
}
|
||
materializeNode(reader, nodeId) {
|
||
return materializeHastNode(reader, nodeId);
|
||
}
|
||
readParentId(reader, nodeId) {
|
||
return reader.getParentId(nodeId);
|
||
}
|
||
readChildIds(reader, nodeId) {
|
||
return reader.getChildIds(nodeId);
|
||
}
|
||
}
|
||
/** Install `children` as an own enumerable getter (spread must carry it),
|
||
* self-replacing with the one stable stub array on first read. One closure
|
||
* and one define per node — installing the wire locals as hidden slots
|
||
* instead measurably regressed every matching pipeline. */
|
||
function makeLazyChildren(node, view, buf, childIdsPos, childTypesPos, childCount, resolver) {
|
||
Object.defineProperty(node, "children", {
|
||
get() {
|
||
const val = readChildStubs(view, buf, childIdsPos, childTypesPos, childCount, resolver);
|
||
Object.defineProperty(this, "children", {
|
||
value: val,
|
||
writable: true,
|
||
enumerable: true,
|
||
configurable: true,
|
||
});
|
||
return val;
|
||
},
|
||
enumerable: true,
|
||
configurable: true,
|
||
});
|
||
}
|
||
/** A result that is the same object as the input node is a no-op, so context
|
||
* mutations (e.g. setProperty) are not clobbered. */
|
||
function handleVisitResult(result, nodeId, returnBuffer, deferred, originalNode) {
|
||
if (result == null)
|
||
return deferred;
|
||
if (result === originalNode)
|
||
return deferred;
|
||
if (result instanceof Promise) {
|
||
const list = deferred ?? [];
|
||
list.push({ nodeId, promise: result, originalNode });
|
||
return list;
|
||
}
|
||
emitHastTree(returnBuffer, "replace", nodeId, result);
|
||
return deferred;
|
||
}
|
||
/**
|
||
* Dispatch matched nodes from a binary match buffer to visitor functions.
|
||
* Returns null if all sync, or an array of deferred promises if any visitor was async.
|
||
*/
|
||
function dispatchMatches(matchBuf, subs, ctx, returnBuffer, resolver) {
|
||
const matchView = new DataView(matchBuf.buffer, matchBuf.byteOffset, matchBuf.byteLength);
|
||
const matchCount = matchView.getUint32(0, true);
|
||
let deferred = null;
|
||
for (let i = 0; i < matchCount; i++) {
|
||
const indexBase = 4 + i * 10;
|
||
const nodeId = matchView.getUint32(indexBase, true);
|
||
const subIndex = matchBuf[indexBase + 4];
|
||
const dataOffset = matchView.getUint32(indexBase + 6, true);
|
||
const sub = subs[subIndex];
|
||
const node = readMatchedNode(matchView, matchBuf, dataOffset, nodeId, sub.nodeType, resolver);
|
||
const result = sub.visitFn(node, ctx);
|
||
deferred = handleVisitResult(result, nodeId, returnBuffer, deferred, node);
|
||
}
|
||
return deferred;
|
||
}
|
||
/**
|
||
* Walk a handle's arena in Rust, dispatch matched nodes to JS visitor functions,
|
||
* and apply mutations back to the handle. No arena buffers cross NAPI.
|
||
*
|
||
* Returns the number of patches dropped because their target was removed or
|
||
* replaced earlier in the same pass (the caller warns when non-zero), or a
|
||
* Promise of that count if any visitor is async.
|
||
*/
|
||
export function visitHastHandle(handle, plugin, subs, source, fileURL, data = {}) {
|
||
const getSource = typeof source === "function" ? source : () => source;
|
||
const resolver = new HastLazyChildResolver(handle);
|
||
const ctx = new HastVisitorContextImpl(handle, getSource, fileURL, resolver, data);
|
||
const returnBuffer = new CommandBuffer();
|
||
const rustSubs = subs.map((s) => ({ nodeType: s.nodeType, tagFilter: s.tagFilter }));
|
||
const deferred = dispatchMatches(walkHandle(handle, rustSubs), subs, ctx, returnBuffer, resolver);
|
||
if (deferred) {
|
||
return Promise.all(deferred.map((d) => d.promise.then((result) => ({ nodeId: d.nodeId, result, originalNode: d.originalNode })))).then((results) => {
|
||
for (const { nodeId, result, originalNode } of results) {
|
||
if (result != null && result !== originalNode) {
|
||
emitHastTree(returnBuffer, "replace", nodeId, result);
|
||
}
|
||
}
|
||
// Mutations land next, renumbering the arena: snapshots taken after
|
||
// this point would resolve match-time child ids against wrong nodes.
|
||
resolver.seal();
|
||
return applyMutations(handle, returnBuffer, ctx);
|
||
});
|
||
}
|
||
resolver.seal();
|
||
return applyMutations(handle, returnBuffer, ctx);
|
||
}
|
||
/** Returns the number of patches dropped as stranded (0 when none). */
|
||
function applyMutations(handle, returnBuffer, ctx) {
|
||
const { merged, hasMutations } = mergeAndReset(returnBuffer, ctx);
|
||
if (hasMutations) {
|
||
markHandleMutated(handle);
|
||
return applyCommandsToHandle(handle, merged);
|
||
}
|
||
return 0;
|
||
}
|