IOweb/node_modules/satteri/dist/mdast/mdast-visitor.js
2026-07-03 15:07:38 -05:00

572 lines
23 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { materializeNode } from "./mdast-materializer.js";
import { MdastReader } from "./mdast-reader.js";
import { CommandBuffer, classifyReturn } from "../command-buffer.js";
import { ru32, rstr, readPosition } from "../wire-read.js";
import { decodeMdastTypeData } from "./generated/layout.js";
import { TYPE_NAMES, NAME_TO_TYPE, VISITOR_KEYS, MDAST_OPSTREAM_TYPES, } from "./generated/node-types.js";
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";
import { walkMdastHandle, mdastTextContentHandle } from "#binding";
import { asArray, makeRequireNid, mergeAndReset, unencodableContentError, } from "../visitor-shared.js";
import { LazyChildResolver } from "../lazy-child-resolver.js";
import { MdastChildStub } from "./child-stub.js";
/** Maps MdastNode objects to their arena node IDs without Object.defineProperty overhead. */
const mdastNodeIdMap = new WeakMap();
function nid(node) {
// Genuine stubs carry their id as a plain field; a spread copy is not
// `instanceof` and has no `_nodeId`, so it correctly reads as new content.
if (node instanceof MdastChildStub)
return node._id;
const id = mdastNodeIdMap.get(node);
if (id !== undefined)
return id;
// Plain objects are trusted only via the WeakMap or a NON-enumerable
// `_nodeId` (the materializer's convention, which spread cannot copy) — an
// enumerable one rode in on a copy and must read as new content.
const d = Object.getOwnPropertyDescriptor(node, "_nodeId");
return d !== undefined && !d.enumerable ? d.value : undefined;
}
const requireNid = makeRequireNid(nid);
export class MdastVisitorContext {
#commandBuffer = new CommandBuffer();
#diagnostics = [];
#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;
/**
* The URL of the document being processed (the compile `fileURL` option),
* or `undefined` when none was given. Use `fileURLToPath(ctx.fileURL)` for a
* decoded filesystem path.
*/
fileURL;
/**
* Document-level data bag, shared across every plugin in the compile and
* across the mdast→hast phase boundary. Mutate keys directly
* (`ctx.data.foo = x`); the bag itself isn't reassignable. Values are kept
* on the JS side, so any value is allowed, including functions and class
* instances. Returned to the caller as `result.data`.
*/
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"));
}
insertBefore(node, newNode) {
const id = requireNid(node, "insertBefore");
for (const n of asArray(newNode))
emitMdastTree(this.#commandBuffer, "insertBefore", id, n);
}
insertAfter(node, newNode) {
const id = requireNid(node, "insertAfter");
for (const n of asArray(newNode))
emitMdastTree(this.#commandBuffer, "insertAfter", id, n);
}
/**
* Wrap `node` in `parentNode`, making it `parentNode`'s first child. Any
* children `parentNode` declares are kept after it.
*/
wrapNode(node, parentNode) {
const id = requireNid(node, "wrapNode");
emitMdastTree(this.#commandBuffer, "wrapNode", id, parentNode);
}
prependChild(node, childNode) {
const id = requireNid(node, "prependChild");
for (const n of asArray(childNode))
emitMdastTree(this.#commandBuffer, "prependChild", id, n);
}
appendChild(node, childNode) {
const id = requireNid(node, "appendChild");
for (const n of asArray(childNode))
emitMdastTree(this.#commandBuffer, "appendChild", id, n);
}
/** Insert one node or an array at `index`; clamps (`0` or less prepends, past the end appends). */
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);
}
}
/** Remove the `index`-th child of `node`; a no-op when there is no such child. */
removeChildAt(node, index) {
const child = "children" in node ? node.children[index] : undefined;
if (child)
this.removeNode(child);
}
replaceNode(node, newNode) {
const id = requireNid(node, "replaceNode");
emitMdastTree(this.#commandBuffer, "replace", id, newNode, true);
}
setProperty(node, key, value) {
if (key === "children") {
// children is structural: set-children keeps the node and swaps only its
// child list (reused children keep their id).
const id = requireNid(node, "setProperty");
const ops = compileMdastChildrenToOpstream(value);
if (!ops)
throw unencodableContentError(value);
this.#commandBuffer.setChildrenOpstream(id, ops);
return;
}
if (key === "data") {
// data is stored as JSON in the arena, serialize it for the command buffer
this.#commandBuffer.setProperty(requireNid(node, "setProperty"), key, value != null ? JSON.stringify(value) : null);
return;
}
this.#commandBuffer.setProperty(requireNid(node, "setProperty"), key, value);
}
/** Collect the concatenated text of all descendant text nodes (like mdast-util-to-string). */
textContent(node, options) {
return mdastTextContentHandle(this.#handle, requireNid(node, "textContent"), options);
}
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;
}
/**
* Index of `node` within its parent's children, or `undefined` at the root.
* Use this rather than `parent.children.indexOf(node)`, which won't find it.
*/
indexOf(node) {
return this.#resolver.indexInParent(requireNid(node, "indexOf"));
}
report({ message, node, severity = "error", }) {
this.#diagnostics.push({
message,
nodeId: node ? nid(node) : undefined,
position: node?.position,
severity,
});
}
/** Get the binary command buffer for all mutations recorded via context methods. */
getCommandBuffer() {
return this.#commandBuffer;
}
getDiagnostics() {
return this.#diagnostics;
}
}
export function resolveMdastSubscriptions(plugin) {
const subs = [];
for (const [name, fn] of Object.entries(plugin)) {
if (VISITOR_KEYS.has(name) && typeof fn === "function") {
const nodeType = NAME_TO_TYPE[name];
if (nodeType !== undefined) {
subs.push({
nodeType,
visitFn: fn,
});
}
}
}
return subs;
}
class MdastLazyChildResolver extends LazyChildResolver {
createReader(wire) {
return new MdastReader(wire);
}
materializeNode(reader, nodeId) {
return materializeNode(reader, nodeId);
}
readParentId(reader, nodeId) {
return reader.getParentId(nodeId);
}
readChildIds(reader, nodeId) {
return reader.getChildIds(nodeId);
}
}
/** 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 readMdastChildStubs(view, buf, idsPos, typesPos, count, resolver) {
resolver.assertUnsealed();
const stubs = new Array(count);
for (let i = 0; i < count; i++) {
stubs[i] = new MdastChildStub(resolver, ru32(view, idsPos + i * 4), buf[typesPos + i]);
}
return stubs;
}
/** 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 = readMdastChildStubs(view, buf, childIdsPos, childTypesPos, childCount, resolver);
Object.defineProperty(this, "children", {
value: val,
writable: true,
enumerable: true,
configurable: true,
});
return val;
},
enumerable: true,
configurable: true,
});
}
/**
* Read an MDAST node from the inline data in a match buffer entry.
*
* Inline format (from Rust serialize_mdast_node_inline):
* [node_data: u32+bytes][position: 6×u32 = 24B][child_count: u32][child_ids: N×u32]
* [child_types: N×u8][type-specific data]
*/
function readMdastMatchedNode(view, buf, dataOffset, nodeId, nodeType, resolver) {
let pos = dataOffset;
const dataJsonLen = ru32(view, pos);
pos += 4;
let initialData = null;
if (dataJsonLen > 0) {
const jsonStr = rstr(buf, pos, dataJsonLen);
try {
initialData = JSON.parse(jsonStr);
}
catch (err) {
if (process.env.NODE_ENV !== "production") {
console.warn(`readMdastMatchedNode: malformed node_data for nodeId=${nodeId}`, err);
}
}
pos += dataJsonLen;
}
const position = readPosition(view, pos);
pos += 24;
const childCount = ru32(view, pos);
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;
const typeName = TYPE_NAMES[nodeType] ?? `unknown(${nodeType})`;
const node = { type: typeName };
if (position !== undefined)
node.position = position;
if (childCount > 0) {
makeLazyChildren(node, view, buf, childIdsPos, childTypesPos, childCount, resolver);
}
// Fixed-field types decode from the generated layout table; the rest
// (variable-length / cross-field) stay in the hand-written switch.
if (!decodeMdastTypeData(view, buf, pos, nodeType, node)) {
switch (nodeType) {
case 5: {
// list
node.start = ru32(view, pos);
node.ordered = buf[pos + 4] !== 0;
node.spread = buf[pos + 5] !== 0;
if (!node.ordered)
node.start = null;
break;
}
case 6: {
// listItem
const checked = buf[pos];
node.checked = checked === 2 ? null : checked === 1;
node.spread = buf[pos + 1] !== 0;
break;
}
// table (21), directives (30/31/32) and mdxJsx elements (100/101) are
// decoded by the generated `decodeMdastTypeData` from their tails.
// root(0), paragraph(1), thematicBreak(3), blockquote(4), emphasis(11),
// strong(12), break(14), tableRow(22), tableCell(23), delete(24): no extra data
}
}
mdastNodeIdMap.set(node, nodeId);
if (initialData) {
node.data = initialData;
}
return node;
}
/** The arena id of a node if it is an existing (materialized) node, else
* undefined for a freshly-built one. */
function reusedId(node) {
if (node === null || typeof node !== "object")
return undefined;
const id = nid(node);
return typeof id === "number" ? id : undefined;
}
// Reused across every replacement in a pass: compile is synchronous and its
// result is copied into the command buffer before the next call, so a single
// writer is safe and avoids a 512-byte allocation per built node.
const mdastWriter = new OpWriter();
/**
* Compile a declarative MDAST replacement tree to the op-stream — the only
* structural encoding. Reused nodes (those still carrying an arena id) become
* `ref`s so the rebuild splices the original back in place. Returns null when
* the replay can't reproduce the tree identically (unsupported node type or
* out-of-range numeric field); the caller turns that into a hard error.
*/
function compileMdastToOpstream(root, forReplace = false) {
mdastWriter.begin();
try {
if (!emitMdastOp(mdastWriter, root, true, forReplace))
return null;
return mdastWriter.take();
}
finally {
mdastWriter.end();
}
}
/** Compile a set-children payload: a root-wrapped child list, the shape
* `Patch::SetChildren` splices in. Reused children become refs. */
function compileMdastChildrenToOpstream(children) {
if (!Array.isArray(children))
return null;
mdastWriter.begin();
try {
mdastWriter.open(NAME_TO_TYPE.root);
for (const c of children) {
if (!emitMdastOp(mdastWriter, c, false, false))
return null;
}
mdastWriter.close();
return mdastWriter.take();
}
finally {
mdastWriter.end();
}
}
function emitMdastOp(w, node, isRoot, forReplace) {
if (node === null || typeof node !== "object")
return false;
if (!isRoot) {
const id = reusedId(node);
if (id !== undefined) {
w.ref(id);
return true;
}
}
const n = node;
const type = MDAST_OPSTREAM_TYPES[n.type];
if (type === undefined)
return false;
w.open(type);
if (typeof n.value === "string")
w.str(OF_VALUE, n.value);
if (typeof n.url === "string")
w.str(OF_URL, n.url);
if (typeof n.title === "string")
w.str(OF_TITLE, n.title);
if (typeof n.alt === "string")
w.str(OF_ALT, n.alt);
if (typeof n.lang === "string")
w.str(OF_LANG, n.lang);
if (typeof n.meta === "string")
w.str(OF_META, n.meta);
if (typeof n.identifier === "string")
w.str(OF_IDENTIFIER, n.identifier);
if (typeof n.label === "string")
w.str(OF_LABEL, n.label);
if (typeof n.referenceType === "string")
w.str(OF_REFERENCE_TYPE, n.referenceType);
// Out-of-range numbers compile to null and the caller throws — a visible
// error instead of silently masking the bits.
if (typeof n.depth === "number") {
if (!Number.isInteger(n.depth) || n.depth < 0 || n.depth > 255)
return false;
w.u8(OF_DEPTH, n.depth);
}
if (typeof n.checked === "boolean")
w.u8(OF_CHECKED, n.checked ? 1 : 0);
if (typeof n.start === "number") {
if (!Number.isInteger(n.start) || n.start < 0 || n.start > 4294967295)
return false;
w.u32(OF_START, n.start);
}
if (typeof n.ordered === "boolean")
w.bool(OF_ORDERED, n.ordered);
if (typeof n.spread === "boolean")
w.bool(OF_SPREAD, n.spread);
if (typeof n.name === "string")
w.str(OF_NAME, n.name);
const attrs = n.attributes;
if (Array.isArray(attrs)) {
for (const a of attrs)
emitMdxAttr(w, a);
}
else if (attrs !== null && typeof attrs === "object") {
// Directive attributes: a string→string map; non-string values are
// dropped, since the stored form holds only strings.
for (const key in attrs) {
const v = attrs[key];
if (typeof v === "string")
w.prop(key, PROP_STRING, v);
}
}
if (Array.isArray(n.align))
w.align(n.align.map(alignCode));
if (n.data?._mdxExplicitJsx === true) {
w.bool(OF_EXPLICIT, true);
}
if (n.data != null)
w.data(n.data);
if (isRoot && forReplace && n._keepChildren === true) {
// Replace splices the target's original children, discarding any the
// replacement declares.
w.keepChildren();
}
else {
// `_keepChildren` only applies to replace; other ops ignore the marker
// and emit the declared children.
const children = n.children;
if (Array.isArray(children)) {
for (const c of children)
if (!emitMdastOp(w, c, false, forReplace))
return false;
}
}
w.close();
return true;
}
/** Map a table `align` entry to its arena code (none=0). */
function alignCode(a) {
return a === "left" ? 1 : a === "right" ? 2 : a === "center" ? 3 : 0;
}
/** True for the `{raw}` / `{rawHtml}` escape hatches — re-parsed by Rust rather
* than compiled to an op-stream, so they ride the RAW_MARKDOWN / RAW_HTML
* payloads instead of the declarative encoder. */
function isRawMdastContent(content) {
const c = content;
return typeof c.raw === "string" || typeof c.rawHtml === "string";
}
/** Encode `content` as the `op` structural command. Declarative nodes compile
* to the op-stream; the `{raw}`/`{rawHtml}` escape hatches ride the raw
* re-parse payloads. Anything that compiles to neither is a hard error — the
* op-stream is the only declarative encoding. The switches stay inline so the
* buffer calls are monomorphic (computed method names defeat inline caches on
* this warm path). */
function emitMdastTree(buffer, op, id, content, forReplace = false) {
if (isRawMdastContent(content)) {
switch (op) {
case "replace":
return buffer.replace(id, content);
case "insertBefore":
return buffer.insertBefore(id, content);
case "insertAfter":
return buffer.insertAfter(id, content);
case "prependChild":
return buffer.prependChild(id, content);
case "appendChild":
return buffer.appendChild(id, content);
case "wrapNode":
return buffer.wrapNode(id, content);
}
}
const ops = compileMdastToOpstream(content, forReplace);
if (ops === null)
throw unencodableContentError(content);
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);
}
}
/** 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 applyMdastVisitResult(result, nodeId, returnBuffer, originalNode) {
if (result === undefined || result === null)
return;
if (result === originalNode)
return;
const cls = classifyReturn(result);
switch (cls) {
case "raw_markdown":
returnBuffer.replace(nodeId, result);
break;
case "raw_html":
returnBuffer.replace(nodeId, result);
break;
case "structured_node":
emitMdastTree(returnBuffer, "replace", nodeId, result, true);
break;
}
}
/**
* Walk an MDAST handle in Rust, dispatch matched nodes to JS visitor functions,
* and apply mutations back to the handle. No arena buffers cross NAPI.
*
* Returns MdastVisitResult synchronously if all visitors are sync,
* or Promise<MdastVisitResult> if any visitor is async.
*/
export function visitMdastHandle(handle, plugin, subs, source, fileURL, data = {}) {
const getSource = typeof source === "function" ? source : () => source;
const resolver = new MdastLazyChildResolver(handle);
const context = new MdastVisitorContext(handle, getSource, fileURL, resolver, data);
const returnBuffer = new CommandBuffer();
const rustSubs = subs.map((s) => ({ nodeType: s.nodeType, tagFilter: [] }));
const matchBuf = walkMdastHandle(handle, rustSubs);
const matchView = new DataView(matchBuf.buffer, matchBuf.byteOffset, matchBuf.byteLength);
const matchCount = ru32(matchView, 0);
let deferred = null;
for (let i = 0; i < matchCount; i++) {
const indexBase = 4 + i * 10;
const nodeId = ru32(matchView, indexBase);
const subIndex = matchBuf[indexBase + 4];
const dataOffset = ru32(matchView, indexBase + 6);
const sub = subs[subIndex];
const node = readMdastMatchedNode(matchView, matchBuf, dataOffset, nodeId, sub.nodeType, resolver);
const result = sub.visitFn.call(plugin, node, context);
if (result instanceof Promise) {
deferred ??= [];
deferred.push({ nodeId, promise: result, originalNode: node });
}
else {
applyMdastVisitResult(result, nodeId, returnBuffer, node);
}
}
if (deferred) {
return Promise.all(deferred.map((d) => d.promise.then((r) => ({ nodeId: d.nodeId, result: r, originalNode: d.originalNode })))).then((results) => {
for (const { nodeId, result, originalNode } of results) {
applyMdastVisitResult(result, nodeId, returnBuffer, originalNode);
}
// End of the pass — the caller applies the returned command buffer next,
// renumbering the arena, so later snapshots would resolve match-time
// child ids against wrong nodes. This is the last point we control.
resolver.seal();
return finalizeMdastVisit(handle, context, returnBuffer);
});
}
resolver.seal();
return finalizeMdastVisit(handle, context, returnBuffer);
}
function finalizeMdastVisit(handle, context, returnBuffer) {
const { merged, hasMutations } = mergeAndReset(returnBuffer, context);
return { commandBuffer: merged, diagnostics: context.getDiagnostics(), hasMutations };
}