@mui/internal-docs-infra
Version:
MUI Infra - internal documentation creation tools.
1,270 lines (1,203 loc) • 87.6 kB
JavaScript
import { getHastTextContent } from "../loadServerTypes/hastTypeUtils.mjs";
import { isLinkableSpan, isPropertySpan, isKeywordSpan, isCommentSpan, isSmiSpan, isStringLiteralSpan, getTextContent, getClassName, propPathToString } from "./hastUtils.mjs";
import { createLinkElement, createPropRefElement, createParamRefElement, createValueRefElement } from "./createElements.mjs";
import { currentOwner, buildPropHref, buildParamHref, finalizePendingDefaultExport, getResolvedValueExportAt, resetImportState, resetExportState, recordExport } from "./scanState.mjs";
import { flushLiteralCandidate, flushPendingExpression, processTextNode, tokenFromLiteral } from "./processTextNode.mjs";
/**
* Represents a chain of linkable spans that may form a dotted identifier.
*/
/**
* Options passed to enhanceChildren for cleaner signatures.
*/
/**
* Builds the full identifier string from a chain.
*/
function chainToIdentifier(chain) {
return chain.spans.map(getTextContent).join('.');
}
function isCssClassSelector(identifier, lang) {
return lang.semantics === 'css' && identifier.startsWith('.');
}
function findLastSignificantChar(value) {
for (let index = value.length - 1; index >= 0; index -= 1) {
const char = value[index];
if (!/\s/.test(char)) {
return char;
}
}
return null;
}
function findFirstSignificantChar(value) {
for (let index = 0; index < value.length; index += 1) {
const char = value[index];
if (!/\s/.test(char)) {
return char;
}
}
return null;
}
function isBarePositionChar(char) {
return char === ',' || char === '{' || char === '}' || char === ';';
}
function isBareCssClassDefinition(children, startIndex, endIndex) {
// `:local(.class)` explicitly opts into local scoping and is
// treated like a bare class definition, but we still need to check
// the trailing context after the closing `)` for compound selectors
// like `:local(.button):hover` which are NOT bare.
let insideLocal = false;
for (let index = startIndex - 1; index >= 0; index -= 1) {
const node = children[index];
if (node.type === 'text') {
const char = findLastSignificantChar(node.value);
if (!char) {
continue;
}
if (char === '(' && node.value.trimEnd().endsWith(':local(')) {
insideLocal = true;
break;
}
return isBarePositionChar(char);
}
return false;
}
for (let index = endIndex + 1; index < children.length; index += 1) {
const node = children[index];
if (node.type === 'text') {
const char = findFirstSignificantChar(node.value);
if (!char) {
continue;
}
// When wrapped in :local(), skip the closing `)` and check
// the character after it for bare-position context.
if (insideLocal && char === ')') {
const rest = node.value.slice(node.value.indexOf(')') + 1);
const afterParen = findFirstSignificantChar(rest);
return afterParen === null || isBarePositionChar(afterParen);
}
return isBarePositionChar(char);
}
return false;
}
return true;
}
function getCssClassExportName(identifier) {
return identifier.slice(1).replace(/-+([a-zA-Z0-9])/g, (_, segment) => segment.toUpperCase());
}
function getCssClassAnchor(identifier) {
return `#${getCssClassExportName(identifier)}`;
}
function hasResolvedCssModuleExport(state, name) {
return state.cssModuleExports.has(name);
}
/**
* Checks whether a CSS class selector span is wrapped in `:global(...)`.
* In CSS Modules, `:global(.class)` means the class is not scoped locally
* and should not be treated as a local export or linked to one.
* `:local(.class)` explicitly opts into local scoping and is treated like
* a bare class selector.
*
* Handles multi-selector forms like `:global(.a, .b)` and
* `:global(.a .b)` by scanning backward past sibling nodes to find
* an unmatched `:global(` and forward to find the matching `)`.
*/
function isGlobalCssSelector(children, startIndex, endIndex) {
// Scan backward through preceding siblings to find `:global(` or `:global `
let foundGlobal = false;
let shorthandGlobal = false;
let seenComma = false;
for (let index = startIndex - 1; index >= 0; index -= 1) {
const node = children[index];
if (node.type === 'text') {
const trimmed = node.value.trimEnd();
if (trimmed.length === 0) {
continue;
}
// Shorthand syntax: `:global .button` (space-separated, no parens).
// Everything after `:global` is global scope.
if (trimmed.endsWith(':global')) {
shorthandGlobal = true;
foundGlobal = true;
break;
}
if (trimmed.endsWith(':global(')) {
foundGlobal = true;
break;
}
// Check for `:global(` anywhere in the text. If found, verify
// that paren depth from that point never drops to 0 (which would
// mean `:global(...)` was closed before reaching this span).
// This handles nested functional selectors like `:global(:where(`,
// `:global(:is(`, etc.
const globalIdx = trimmed.lastIndexOf(':global(');
if (globalIdx !== -1) {
const afterGlobal = trimmed.slice(globalIdx + ':global('.length);
let depth = 1; // starts at 1 for the `(` in `:global(`
let closed = false;
for (let ci = 0; ci < afterGlobal.length; ci += 1) {
if (afterGlobal[ci] === '(') {
depth += 1;
} else if (afterGlobal[ci] === ')') {
depth -= 1;
if (depth === 0) {
closed = true;
break;
}
}
}
if (!closed) {
foundGlobal = true;
break;
}
}
// If we hit text that contains a `)` or a bare-position char,
// we're not inside :global().
if (/[){};\n]/.test(trimmed)) {
return false;
}
// Track commas — they separate selector lists and break
// shorthand :global scope (but not functional :global()).
if (trimmed.includes(',')) {
seenComma = true;
}
// Otherwise keep scanning — could be intermediate text like ` `
continue;
}
// Skip past element nodes (other selector spans inside the :global)
if (node.type === 'element') {
continue;
}
return false;
}
if (!foundGlobal) {
return false;
}
// Shorthand :global doesn't use parens — no `)` to verify.
// However, commas separate selector lists and break shorthand scope:
// `:global .a, .b {}` means .a is global but .b is local.
if (shorthandGlobal) {
return !seenComma;
}
// Verify there's a matching `)` after the selector span.
// The text between the class and `)` can contain pseudo-selectors,
// attribute selectors, combinators, etc. — e.g. `:hover) {}`.
// Scan for `)` before any structural char (`{`, `}`, `;`).
for (let j = endIndex + 1; j < children.length; j += 1) {
const after = children[j];
if (after.type === 'text') {
for (let ci = 0; ci < after.value.length; ci += 1) {
const ch = after.value[ci];
if (ch === ')') {
return true;
}
if (ch === '{' || ch === '}' || ch === ';') {
return false;
}
}
// Text had no `)` or structural char — keep scanning
continue;
} else if (after.type === 'element') {
// Skip past sibling spans (other selectors inside :global)
continue;
} else {
return false;
}
}
return false;
}
/**
* Single-pass function that enhances children by:
* 1. Linking type/export name spans (chains) to their anchors
* 2. Tracking owner context for property linking
* 3. Wrapping property names (spans or plain text) with prop ref elements
*
* State is threaded through recursive calls so context flows across
* nested frame/line elements.
*/
export function enhanceChildren(children, options, state) {
const {
linkMap,
typePropRefComponent,
linkProps,
linkParams,
linkScope,
linkValues,
linkArrays,
lang,
moduleLinkMap
} = options;
const newChildren = [];
let i = 0;
while (i < children.length) {
const node = children[i];
// --- Text node: process for brace tracking and plain text properties ---
if (node.type === 'text') {
const processed = processTextNode(node.value, state, linkMap, linkProps, linkParams, linkScope, linkValues, linkArrays, typePropRefComponent, lang, children, i);
newChildren.push(...processed);
// Finalize deferred dynamic import link/annotation when `)` just closed the expression
if (state.dynamicImportDepth === 0) {
if (state.pendingDynamicImportLink) {
const {
node: linkNode,
href,
rawValue
} = state.pendingDynamicImportLink;
const originalChildren = [...linkNode.children];
linkNode.children = [createLinkElement(href, originalChildren, rawValue)];
if (options.moduleLinkMap) {
const moduleEntry = options.moduleLinkMap[rawValue];
if (moduleEntry) {
recordResolvedImport(state, rawValue, moduleEntry, []);
}
}
state.pendingDynamicImportLink = null;
} else if (state.pendingDynamicImportAnnotation) {
const {
node: annoNode,
rawValue
} = state.pendingDynamicImportAnnotation;
annoNode.properties['data-import'] = rawValue;
state.unresolvedImports.add(rawValue);
state.pendingDynamicImportAnnotation = null;
}
}
// Wrap expression nodes if an expression was just evaluated at `;`
if (state.lastFlushedExpression) {
wrapExpressionNodes(newChildren, state, options);
}
i += 1;
continue;
}
// --- Element node ---
if (node.type === 'element') {
// Mark dynamic import as computed when a non-string element appears
// inside `import(...)`. This prevents linking in computed expressions
// like `import(cond ? '@foo' : '@bar')` or `import('@foo' + bar)`.
if (state.dynamicImportDepth > 0 && !isStringLiteralSpan(node)) {
state.dynamicImportIsComputed = true;
state.pendingDynamicImportLink = null;
state.pendingDynamicImportAnnotation = null;
}
// Tighten expectingFunctionBody: allow return-type annotation tokens
// (keyword spans like `:`, `=>`, `|`, `&`, linkable type-name spans,
// and property spans like pl-v for type parameters e.g. `T` in `Map<K, T>`)
// to pass through; clear on other spans (identifiers like pl-smi).
if (state.expectingFunctionBody && linkScope) {
const isAllowedSpan = isKeywordSpan(node) || isLinkableSpan(node, lang) || isPropertySpan(node);
if (!isAllowedSpan) {
state.expectingFunctionBody = false;
state.sawArrowForBody = false;
state.expressionArrowBody = false;
state.pendingFunctionBindings = null;
}
}
// Flush (commit) any pending literal candidate when a recognized
// syntax span (identifier, reference, or string literal) appears that
// is not a keyword or property. If the expression were compound
// (e.g. `42 + foo`), the operator in the intervening text node would
// have already cleared the candidate. A surviving candidate here
// means the literal was a complete initializer and this span starts
// a new statement (ASI boundary).
// Structural wrapper elements (e.g. <span class="line">) are ignored
// so they don't prematurely commit candidates across line boundaries.
if (state.pendingLiteralCandidate && !isKeywordSpan(node) && !isPropertySpan(node)) {
const isSyntaxSpan = isLinkableSpan(node, lang) || isSmiSpan(node) || isStringLiteralSpan(node);
if (isSyntaxSpan) {
if (linkScope) {
flushLiteralCandidate(state);
} else {
state.pendingLiteralCandidate = null;
}
// ASI boundary: clear stale export indices so a previous export's
// record is not mutated by the next statement's type/arrow tokens.
state.pendingExportTypeIndex = null;
state.pendingExportKindIndex = null;
state.pendingMultiDeclKind = null;
state.pendingReExportEntries = [];
state.pendingStarReExport = false;
}
}
// Flush a pending expression that was marked as complete at a newline
// (ASI boundary). The deferred approach lets next-line continuation
// syntax (`.`, `[`, `(`) invalidate the expression in processTextNode
// before we commit here.
if (state.expressionNewlineReady && state.pendingExpression && linkScope && !isKeywordSpan(node) && !isPropertySpan(node)) {
const isSyntaxSpan = isLinkableSpan(node, lang) || isSmiSpan(node) || isStringLiteralSpan(node);
if (isSyntaxSpan) {
flushLiteralCandidate(state);
const exprResult = flushPendingExpression(state);
if (exprResult) {
state.lastFlushedExpression = exprResult;
}
state.expressionNewlineReady = false;
// ASI boundary: clear stale export indices.
state.pendingExportTypeIndex = null;
state.pendingExportKindIndex = null;
state.pendingMultiDeclKind = null;
state.pendingReExportEntries = [];
state.pendingStarReExport = false;
wrapExpressionNodes(newChildren, state, options);
}
}
// Track span-tokenized object keys: when inside a pending object literal
// at depth 1, any identifier-class span could be a property key. Store
// its text tentatively; the next text node will confirm (`:`) or discard.
if (state.pendingObjectValue && state.pendingObjectValue.braceDepth === 1 && !state.pendingObjectValue.currentPropName) {
const isIdentifierSpan = isLinkableSpan(node, lang) || isPropertySpan(node) || isSmiSpan(node);
if (isIdentifierSpan) {
state.pendingObjectValue.pendingSpanKey = getTextContent(node);
}
}
// Linkable span (pl-c1, pl-en; also pl-v, pl-e in CSS): handle chains + state updates
if (isLinkableSpan(node, lang)) {
const hadCandidate = state.pendingLiteralCandidate !== null;
const result = handleLinkableSpan(children, i, options, state);
newChildren.push(...result.nodes);
// When the identifier was an export list name, linking may have replaced
// the original span. Update the pending entry's node reference.
if (state.inExportBraces && state.pendingExportNames.length > 0) {
const outputNode = result.nodes[0];
if (outputNode && outputNode.type === 'element') {
state.pendingExportNames[state.pendingExportNames.length - 1].node = outputNode;
}
}
// Stamp startChildIndex when a new literal candidate was just created
if (!hadCandidate && state.pendingLiteralCandidate?.startChildIndex === -1) {
state.pendingLiteralCandidate.startChildIndex = newChildren.length - 1;
state.pendingLiteralCandidate.targetChildren = newChildren;
}
i = result.nextIndex;
continue;
}
// Property span (pl-v, pl-e): wrap as prop ref if in owner context
if (isPropertySpan(node)) {
const result = handlePropertySpan(node, state, options);
newChildren.push(result);
i += 1;
continue;
}
// Keyword span (pl-k): update state
if (isKeywordSpan(node)) {
handleKeywordSpan(node, state, options);
newChildren.push(node);
i += 1;
continue;
}
// String literal span (pl-s): capture value for const tracking
if (isStringLiteralSpan(node)) {
const hadCandidate = state.pendingLiteralCandidate !== null;
const hadExpression = state.pendingExpression !== null;
handleStringLiteralSpan(node, state, options);
newChildren.push(node);
// Stamp startChildIndex when a new literal candidate was just created
if (!hadCandidate && state.pendingLiteralCandidate?.startChildIndex === -1) {
state.pendingLiteralCandidate.startChildIndex = newChildren.length - 1;
state.pendingLiteralCandidate.targetChildren = newChildren;
}
// Stamp startChildIndex when a template literal created a new pendingExpression
if (!hadExpression && state.pendingExpression?.startChildIndex === -1) {
state.pendingExpression.startChildIndex = newChildren.length - 1;
state.pendingExpression.targetChildren = newChildren;
}
i += 1;
continue;
}
// Comment span (pl-c): pass through without processing text content.
// Comments may contain operator-like characters (e.g. `//`) that would
// incorrectly interact with expression tracking if recursively processed.
if (isCommentSpan(node)) {
const commentText = getTextContent(node);
const isLineComment = commentText.startsWith('//');
if (isLineComment && state.pendingExpression && state.pendingExpression.endChildIndex === -1) {
state.pendingExpression.endChildIndex = newChildren.length;
}
newChildren.push(node);
i += 1;
continue;
}
// Import identifier collection: pl-smi spans inside an import statement
// are imported names (Starry Night tokenizes them as pl-smi, not pl-c1).
if (moduleLinkMap && isSmiSpan(node) && state.sawJsImportKeyword) {
collectImportIdentifier(getTextContent(node), state);
newChildren.push(node);
i += 1;
continue;
}
// Export identifier collection: pl-smi spans inside an export { } block
// are exported names. Collect the name but don't skip — let the identifier
// continue to scope resolution below so it can be linked via TypeRefComponent.
if (isSmiSpan(node) && state.sawExportKeyword && state.inExportBraces) {
collectExportIdentifier(getTextContent(node), node, state);
}
// Export default expression: pl-smi span after `export default` records the default export
if (isSmiSpan(node) && state.sawExportKeyword && state.sawExportDefaultKeyword && state.pendingExportKind === 'unknown') {
recordExport(state, 'default', 'unknown');
resetExportState(state);
}
// Identifier reference span (pl-smi): resolve against scope stack
if ((linkScope || moduleLinkMap) && isSmiSpan(node)) {
const hadCandidate = state.pendingLiteralCandidate !== null;
const result = handleSmiSpan(node, children, i, state, options);
newChildren.push(...result.nodes);
// When the identifier was an export list name, scope/module resolution
// may have replaced the original span with a link element. Update the
// pending entry's node reference so the id lands on the rendered node.
if (state.inExportBraces && state.pendingExportNames.length > 0) {
const outputNode = result.nodes[0];
if (outputNode && outputNode.type === 'element') {
state.pendingExportNames[state.pendingExportNames.length - 1].node = outputNode;
}
}
// Stamp startChildIndex when a new literal candidate was just created from a variable
if (!hadCandidate && state.pendingLiteralCandidate?.startChildIndex === -1) {
state.pendingLiteralCandidate.startChildIndex = newChildren.length - 1;
state.pendingLiteralCandidate.targetChildren = newChildren;
}
i = result.nextIndex;
continue;
}
// Other element (frame, line, etc.): recursively process children
if (node.children) {
const processedChildren = enhanceChildren(node.children, options, state);
newChildren.push({
...node,
children: processedChildren
});
} else {
newChildren.push(node);
}
i += 1;
continue;
}
// Any other node type: pass through
newChildren.push(node);
i += 1;
}
return newChildren;
}
/**
* Collects an import identifier name into the scan state.
* Called for identifier spans (pl-smi, pl-c1) inside an `import` statement.
*/
function collectImportIdentifier(text, state) {
if (state.importSawAs) {
// `as X` — alias the previously collected name
if (state.importSawStar) {
// `import * as X` — namespace import
state.pendingNamespaceImport = text;
} else if (state.inImportBraces && state.pendingImportNames.length > 0) {
// `import { foo as X }` — alias the last named import
state.pendingImportNames[state.pendingImportNames.length - 1].localName = text;
} else if (state.pendingDefaultImport) {
// Edge case: `import default as X` — unlikely but handle
state.pendingDefaultImport = text;
}
state.importSawAs = false;
} else if (state.inImportBraces) {
// Inside `{ }` — named import
state.pendingImportNames.push({
localName: text,
exportedName: text
});
} else if (state.importSawStar) {
// After `* as` — this shouldn't happen (handled by `as` branch above)
state.pendingNamespaceImport = text;
state.importSawStar = false;
} else {
// Before `{` or `from` — default import
state.pendingDefaultImport = text;
}
}
/**
* Collects an export identifier name into the scan state.
* Called for identifier spans (pl-smi, pl-c1) inside `export { ... }`.
*/
function collectExportIdentifier(text, node, state) {
if (state.exportSawAs) {
// `as X` — alias the previously collected name
if (state.pendingExportNames.length > 0) {
const last = state.pendingExportNames[state.pendingExportNames.length - 1];
last.exportedName = text;
last.node = node;
}
state.exportSawAs = false;
} else {
// Named export identifier
state.pendingExportNames.push({
localName: text,
exportedName: text,
node
});
}
}
/**
* Resolves a type reference href by checking the user-provided linkMap first,
* then falling back to scope-tracked type bindings (e.g., from imports).
*/
function resolveTypeHref(identifier, linkMap, state) {
const href = linkMap[identifier];
if (href) {
return href;
}
for (let k = state.scopeStack.length - 1; k >= 0; k -= 1) {
const binding = state.scopeStack[k].bindings.get(identifier);
if (binding) {
return binding.refKind === 'type' ? binding.href : undefined;
}
}
return undefined;
}
/**
* Handles a linkable span (pl-c1 or pl-en).
* Detects chains (Accordion.Trigger.State), links them, and updates state.
*/
function handleLinkableSpan(children, startIndex, options, state) {
const {
linkMap,
typeRefComponent,
linkProps,
lang
} = options;
const startNode = children[startIndex];
// CSS @import context: spans like `url` should not be linked or tracked.
if (state.sawCssImportKeyword) {
return {
nodes: [startNode],
nextIndex: startIndex + 1
};
}
// Try to build a chain (look ahead for "." + linkable span)
const chain = {
spans: [startNode],
dotTexts: [],
startIndex,
endIndex: startIndex
};
let j = startIndex + 1;
while (j < children.length - 1) {
const maybeText = children[j];
const maybeNextSpan = children[j + 1];
if (maybeText.type === 'text' && maybeText.value === '.' && maybeNextSpan.type === 'element' && isLinkableSpan(maybeNextSpan, lang)) {
chain.dotTexts.push(maybeText);
chain.spans.push(maybeNextSpan);
chain.endIndex = j + 1;
j += 2;
} else {
break;
}
}
const identifier = chainToIdentifier(chain);
const isCssClass = isCssClassSelector(identifier, lang);
const isGlobalCss = isCssClass && isGlobalCssSelector(children, startIndex, chain.endIndex);
const isBareCssClass = isCssClass && !isGlobalCss && chain.spans.length === 1 && isBareCssClassDefinition(children, startIndex, chain.endIndex);
const cssClassHref = isCssClass && !isBareCssClass && !isGlobalCss && hasResolvedCssModuleExport(state, getCssClassExportName(identifier)) ? getCssClassAnchor(identifier) : null;
// Variable declarations: when a `const`/`let`/`var` re-declares a name
// that already has a scope binding (e.g., from an import), replace it
// with a shadow binding before resolving. This prevents the declaration
// site itself from being linked, and blocks later usage from inheriting
// the previous binding when no type annotation provides new provenance.
// If a type annotation follows, recordScopeBinding overwrites the shadow.
const isVarDecl = state.lastVarKeyword && chain.spans.length === 1 && getClassName(startNode)?.includes('pl-c1');
if (isVarDecl && state.scopeStack.length > 0) {
for (let k = state.scopeStack.length - 1; k >= 0; k -= 1) {
if (state.scopeStack[k].bindings.has(identifier)) {
const targetScope = state.lastVarKeyword === 'var' ? state.scopeStack.find(s => s.kind === 'function') ?? state.scopeStack[0] : state.scopeStack[state.scopeStack.length - 1];
targetScope.bindings.set(identifier, {
refKind: 'shadow'
});
break;
}
}
}
const href = cssClassHref ?? resolveTypeHref(identifier, linkMap, state);
const nodes = [];
if (isBareCssClass) {
const exportName = getCssClassExportName(identifier);
const exportId = exportName;
if (hasResolvedCssModuleExport(state, exportName)) {
nodes.push(createLinkElement(getCssClassAnchor(identifier), startNode.children, identifier, getClassName(startNode), typeRefComponent));
} else {
startNode.properties.id = exportId;
nodes.push(startNode);
state.cssModuleExports.set(exportName, identifier);
}
} else if (href) {
// Matched: create link element
if (chain.spans.length === 1) {
const className = getClassName(startNode);
nodes.push(createLinkElement(href, startNode.children, identifier, className, typeRefComponent));
} else {
const wrappedChildren = [];
for (let k = 0; k < chain.spans.length; k += 1) {
wrappedChildren.push(chain.spans[k]);
if (k < chain.dotTexts.length) {
wrappedChildren.push(chain.dotTexts[k]);
}
}
nodes.push(createLinkElement(href, wrappedChildren, identifier, undefined, typeRefComponent));
}
} else {
// CSS value: if inside a CSS property owner context, create a prop ref element.
// Skip numeric values and CSS function calls (e.g., var(), calc(), rgb()).
const cssOwner = lang.semantics === 'css' ? currentOwner(state) : null;
const nextAfterChain = children[chain.endIndex + 1];
const isCssFunction = nextAfterChain?.type === 'text' && nextAfterChain.value.startsWith('(');
if (cssOwner?.kind === 'css-property' && linkProps && !isCssFunction && !/^\d+(\.\d+)?$/.test(identifier)) {
const propPathStr = propPathToString(cssOwner.propPath, identifier);
const anchor = buildPropHref(cssOwner, propPathStr);
const className = chain.spans.length === 1 ? getClassName(startNode) : undefined;
const valueChildren = chain.spans.length === 1 ? startNode.children : chain.spans.flatMap((span, k) => k < chain.dotTexts.length ? [span, chain.dotTexts[k]] : [span]);
nodes.push(createPropRefElement(anchor, valueChildren, cssOwner.name, propPathStr, false, className, options.typePropRefComponent));
} else {
// No match: keep original nodes
for (let k = chain.startIndex; k <= chain.endIndex; k += 1) {
nodes.push(children[k]);
}
}
}
// Update state based on this entity (use full chain identifier, not just first span)
if (!isCssClass) {
updateStateForEntity(identifier, startNode, state, linkMap, linkProps, options.linkScope, options.linkValues, lang);
}
return {
nodes,
nextIndex: chain.endIndex + 1
};
}
/**
* Records a scope binding when a type annotation is found for a parameter or variable.
*/
function recordScopeBinding(typeName, state, linkMap) {
const href = resolveTypeHref(typeName, linkMap, state);
if (!href) {
return;
}
// Inside a function parameter context
if (state.funcParamContext) {
const ctx = state.funcParamContext;
// Destructured param: { a, b }: TypeName → each gets a prop-ref binding.
// Skip when a rename colon was detected — uncertain provenance (conservative).
if (ctx.destructuredNames.length > 0) {
if (!ctx.sawColonInDestructuring) {
for (const name of ctx.destructuredNames) {
ctx.pendingScopeBindings.set(name, {
refKind: 'prop',
href: `${href}:${name}`,
ownerName: typeName,
propPath: name
});
}
}
ctx.destructuredNames = [];
ctx.sawColonInDestructuring = false;
ctx.lastParamName = null;
return;
}
// Simple param: x: TypeName → upgrade to type-ref binding
if (ctx.lastParamName) {
ctx.pendingScopeBindings.set(ctx.lastParamName, {
refKind: 'type',
href,
typeName
});
ctx.lastParamName = null;
return;
}
return;
}
// Variable declaration outside funcParamContext
if (state.lastDeclaredVarName) {
const binding = {
refKind: 'type',
href,
typeName,
declKind: state.lastVarKeyword ?? undefined
};
const varName = state.lastDeclaredVarName;
if (state.lastVarKeyword === 'var') {
// var: add to nearest function scope (function-scoped)
for (let k = state.scopeStack.length - 1; k >= 0; k -= 1) {
if (state.scopeStack[k].kind === 'function') {
state.scopeStack[k].bindings.set(varName, binding);
break;
}
}
} else {
// const/let: add to current (innermost) scope (block-scoped)
const current = state.scopeStack[state.scopeStack.length - 1];
if (current) {
current.bindings.set(varName, binding);
}
}
state.lastDeclaredVarName = null;
state.lastVarKeyword = null;
}
}
/**
* Updates scan state after seeing a linkable entity (pl-c1 or pl-en).
*/
function updateStateForEntity(text, element, state, linkMap, linkProps, linkScope, linkValues, lang) {
const className = getClassName(element);
const isEn = className?.includes('pl-en');
const isC1 = className?.includes('pl-c1');
// Import identifier collection: when inside an import statement,
// identifier spans are imported names — collect them and skip other processing.
// Starry Night tokenizes import identifiers as pl-smi; pl-c1 is kept as a
// fallback for robustness.
if ((isC1 || className?.includes('pl-smi')) && state.sawJsImportKeyword) {
collectImportIdentifier(text, state);
return;
}
// Export identifier collection: when inside an export { } block,
// identifier spans are exported names. Collect the name but don't return —
// let the identifier continue to scope resolution so it can be linked.
if ((isC1 || className?.includes('pl-smi')) && state.sawExportKeyword && state.inExportBraces) {
collectExportIdentifier(text, element, state);
}
// Export name capture: after `export function`, `export const`, `export type`, etc.
// the next identifier is the export's name.
if (state.sawExportKeyword && state.pendingExportKind) {
const kind = state.pendingExportKind;
if (kind === 'unknown' && state.sawExportDefaultKeyword) {
// `export default expr` — bare expression without a declaration keyword
recordExport(state, 'default', 'unknown');
resetExportState(state);
} else if (state.sawExportDefaultKeyword && (kind === 'function' || kind === 'class') && isEn) {
// `export default function foo` / `export default class Foo`
// Name is 'default' since that's the export binding, kind is the declaration type.
recordExport(state, 'default', kind);
resetExportState(state);
} else if (kind === 'function' && isEn || kind === 'class' && isEn || kind === 'interface' && isEn || kind === 'enum' && isEn || kind === 'type' && isEn && state.pendingTypeDefName === null) {
// Named export where the identifier is an entity name span (pl-en)
recordExport(state, text, kind);
resetExportState(state);
} else if ((kind === 'const' || kind === 'let' || kind === 'var') && isC1) {
// `export const foo` — pl-c1 span for the variable name.
// Remember the declaration keyword for multi-declarator exports
// (`export const a = 1, b = 2`). The comma handler in processTextNode
// re-arms sawExportKeyword + pendingExportKind on the separating `,`.
const idx = recordExport(state, text, kind);
state.pendingExportTypeIndex = idx;
state.pendingExportKindIndex = idx;
state.exportKindParenDepth = 0;
state.pendingMultiDeclKind = kind;
state.multiDeclNestingDepth = 0;
// Clear export-statement flags so nested keywords in the initializer
// (e.g., `const inner` inside a function body) don't get confused.
state.sawExportKeyword = false;
state.pendingExportKind = null;
state.sawExportDefaultKeyword = false;
state.pendingExportKeywordNode = null;
}
}
// After "type" keyword, the next pl-en is the type name
if (isEn && state.sawTypeKeyword) {
state.pendingTypeDefName = text;
state.expectingTypeDefBrace = true;
state.sawTypeKeyword = false;
return;
}
// After pl-k(":") for type annotations, pl-en is the type name
if (isEn && state.pendingAnnotationType === '') {
state.pendingAnnotationType = text;
// Update the pending export record with the type annotation
const pendingTypeExport = getResolvedValueExportAt(state, state.pendingExportTypeIndex);
if (pendingTypeExport) {
pendingTypeExport.type = text;
const href = resolveTypeHref(text, linkMap, state);
if (href) {
pendingTypeExport.typeHref = href;
}
state.pendingExportTypeIndex = null;
}
// Record scope bindings when type annotation is found
if (linkScope) {
recordScopeBinding(text, state, linkMap);
}
return;
}
// JSX opening: after "<", pl-c1 is the component name
if (isC1 && state.sawJsxOpen && linkProps && lang.supportsJsx) {
const href = resolveTypeHref(text, linkMap, state);
if (href) {
const paramKey = `${text}[0]`;
const paramAnchorHref = linkMap[paramKey] ?? null;
state.ownerStack.push({
name: text,
anchorHref: href,
kind: 'jsx',
braceDepth: 1,
// JSX doesn't use brace depth, but 1 means "active"
propPath: [],
propPathDepths: [],
paramIndex: 0,
paramAnchorHref
});
}
state.sawJsxOpen = false;
state.jsxComponentName = text;
return;
}
// Track pl-en as potential function name or type annotation name
if (isEn) {
// Some highlighters emit "type" as pl-en instead of pl-k
if (text === 'type' && lang.supportsTypes) {
state.sawTypeKeyword = true;
state.lastEntityName = null;
return;
}
state.lastEntityName = text;
// A pl-en entity after `=` means the initializer is a call/expression,
// not a simple literal. Clear pendingValueVar to avoid false captures.
if (state.pendingValueVar && !state.pendingObjectValue && !state.pendingArrayValue) {
state.pendingValueVar = null;
}
}
// CSS: track linked pl-c1 spans as potential CSS property owners
if (lang.semantics === 'css' && isC1 && !state.sawCssImportKeyword && currentOwner(state)?.kind !== 'css-property' && text in linkMap) {
state.pendingCssProperty = {
name: text,
anchorHref: linkMap[text]
};
}
// Track variable name for scope binding (const x, let x, var x)
if (isC1 && linkScope && state.lastVarKeyword) {
state.lastDeclaredVarName = text;
}
// Capture number/boolean literals for const value tracking.
// pl-c1 is used for both identifiers (myVar) and literals (42, true, false, null).
// We capture when inside an active array/object, or when pendingValueVar is set
// AND linkValues is enabled (so linkArrays alone doesn't trigger scalar annotation).
// Infer type from number/boolean literal for export records without a type annotation.
// This runs independently of linkValues since export metadata is always collected.
// Skip when inside a function call — the literal is an argument, not the initializer.
if (isC1 && state.pendingExportTypeIndex !== null && !state.pendingFuncCall && (/^\d/.test(text) || text === 'true' || text === 'false' || text === 'null')) {
const pendingTypeExport = getResolvedValueExportAt(state, state.pendingExportTypeIndex);
if (pendingTypeExport) {
pendingTypeExport.type = text;
}
state.pendingExportTypeIndex = null;
// A literal value proves the initializer is not an arrow function,
// so clear the kind refinement index to prevent stale => mutation.
state.pendingExportKindIndex = null;
}
if (isC1 && (state.pendingArrayValue || state.pendingObjectValue || state.pendingExpression || state.pendingValueVar && linkValues)) {
if (/^\d/.test(text) || text === 'true' || text === 'false' || text === 'null') {
if (state.pendingArrayValue) {
state.pendingArrayValue.elements.push(text);
} else if (state.pendingObjectValue && state.pendingObjectValue.braceDepth === 1 && state.pendingObjectValue.currentPropName) {
state.pendingObjectValue.properties.set(state.pendingObjectValue.currentPropName, text);
state.pendingObjectValue.currentPropName = null;
} else if (state.pendingExpression) {
state.pendingExpression.tokens.push({
kind: 'number',
value: text
});
} else if (state.pendingValueVar) {
// Defer value binding — store as a candidate rather than committing
// immediately, so that compound expressions like `42 + 1` are invalidated.
state.pendingLiteralCandidate = {
varName: state.pendingValueVar,
value: text,
startChildIndex: -1,
targetChildren: null
};
state.pendingValueVar = null;
}
} else if (state.pendingArrayValue) {
// Variable reference inside an array literal — resolve from scope
if (state.pendingArrayValue.pendingSpread) {
resolveSpreadIntoArray(state, text);
} else {
const resolved = resolveFromScope(state, text);
state.pendingArrayValue.elements.push(resolved ?? text);
}
} else if (state.pendingValueVar) {
// pl-c1 identifier (not a literal) after `=` means the initializer is a
// variable reference or complex expression — not a direct literal value.
state.pendingValueVar = null;
}
}
state.sawJsxOpen = false;
}
/**
* Handles a property span (pl-v or pl-e).
* If inside a func param context, wraps it as a param ref.
* If inside an owner context, wraps it as a prop ref.
*/
function handlePropertySpan(node, state, options) {
const {
linkProps,
linkParams,
linkScope,
typePropRefComponent,
linkMap
} = options;
// Function parameter context takes priority over owner context
if (state.funcParamContext && state.funcParamContext.nestedBracketDepth === 0 && state.funcParamContext.nestedAngleDepth === 0 && !state.funcParamContext.inDefaultValue) {
const paramName = getTextContent(node);
const className = getClassName(node);
// Record param name for scope binding when the type annotation fills
if (linkScope) {
state.funcParamContext.lastParamName = paramName;
}
// Only create param ref element when linkParams is enabled
if (linkParams) {
const anchor = buildParamHref(state.funcParamContext, paramName, linkMap);
return createParamRefElement(anchor, node.children, state.funcParamContext.ownerName, paramName, state.funcParamContext.isDefinition, className, options.typeParamRefComponent);
}
// When only linkScope (not linkParams), return original node
return node;
}
// Destructured parameter names (inside { }) — only flat depth-1 bindings
if (state.funcParamContext && linkScope && state.funcParamContext.nestedBracketDepth === 1 && state.funcParamContext.nestedAngleDepth === 0 && !state.funcParamContext.inDefaultValue) {
const paramName = getTextContent(node);
state.funcParamContext.destructuredNames.push(paramName);
}
const owner = currentOwner(state);
if (!owner || !linkProps) {
return node;
}
// In shallow mode, skip nested properties
if (linkProps === 'shallow' && owner.braceDepth > 1) {
return node;
}
const propName = getTextContent(node);
const propPathStr = propPathToString(owner.propPath, propName);
const anchor = buildPropHref(owner, propPathStr);
const className = getClassName(node);
const isDefinition = owner.kind === 'type-def';
state.lastLinkedProp = propName;
return createPropRefElement(anchor, node.children, owner.name, propPathStr, isDefinition, className, typePropRefComponent);
}
/**
* Handles an identifier reference span (pl-smi) by resolving it against the scope stack.
* Returns linked element(s) if a binding is found, otherwise the original node.
*
* For `value-object` bindings, performs dot-access lookahead: if the next siblings
* are a "." text + a pl-smi/pl-c1 span matching a property, those siblings are
* consumed and a single value ref element is produced for the resolved property value.
*/
function handleSmiSpan(node, siblings, index, state, options) {
const text = getTextContent(node);
const className = getClassName(node);
// Variable reference inside a pendingArrayValue — resolve and push
if (state.pendingArrayValue) {
if (state.pendingArrayValue.pendingSpread) {
resolveSpreadIntoArray(state, text);
} else {
const resolved = resolveFromScope(state, text);
state.pendingArrayValue.elements.push(resolved ?? text);
}
return {
nodes: [node],
nextIndex: index + 1
};
}
// Variable reference inside a pendingExpression — resolve and push as token
if (state.pendingExpression) {
const resolved = resolveFromScope(state, text, true);
if (resolved) {
state.pendingExpression.tokens.push(tokenFromLiteral(resolved));
} else if (hasObjectOrArrayBinding(state, text)) {
// Object/array bindings cannot participate in expressions — invalidate
state.pendingExpression = null;
} else {
// Value not resolvable — check for a type/prop/param binding whose ref
// we can carry through partial expression evaluation.
const ref = resolveRefFromScope(state, text) ?? undefined;
// Keep as a variable token for partial evaluation (string context).
// If the expression turns out to be pure numeric with variables,
// evaluateExpression will return null.
state.pendingExpression.tokens.push({
kind: 'variable',
value: text,
ref
});
}
return {
nodes: [node],
nextIndex: index + 1
};
}
// When pendingValueVar is set and this smi resolves to a tracked value,
// seed a pendingLiteralCandidate so that a subsequent operator can promote
// it to a pendingExpression (e.g. `const b = a + 5` where `a` is tracked).
// Array-shaped values are excluded — they cannot participate in expressions.
if (state.pendingValueVar && options.linkValues) {
for (let k = state.scopeStack.length - 1; k >= 0; k -= 1) {
const binding = state.scopeStack[k].bindings.get(text);
if (binding && binding.refKind === 'value' && !binding.value.startsWith('[')) {
state.pendingLiteralCandidate = {
varName: state.pendingValueVar,
value: binding.value,
startChildIndex: -1,
targetChildren: null
};
state.pendingValueVar = null;
return {
nodes: [node],
nextIndex: index + 1
};
}
}
}
// Search scope stack innermost-to-outermost
for (let k = state.scopeStack.length - 1; k >= 0; k -= 1) {
const binding = state.scopeStack[k].bindings.get(text);
if (binding) {
switch (binding.refKind) {
case 'type':
return {
nodes: [createLinkElement(binding.href, node.children, binding.typeName, className, options.typeRefComponent)],
nextIndex: index + 1
};
case 'prop':
return {
nodes: [createPropRefElement(binding.href, node.children, binding.ownerName, binding.propPath, false, className, options.typePropRefComponent)],
nextIndex: index + 1
};
case 'param':
return {
nodes: [createParamRefElement(binding.href, node.children, binding.paramOwnerName, binding.paramName, false, className, options.typeParamRefComponent)],
nextIndex: index + 1
};
case 'value':
{
if (!options.linkValues && !options.linkArrays) {
break;
}
return {
nodes: [createValueRefElement(binding.value, node.children, binding.varName, className, options.typeValueRefComponent, binding.refs)],
nextIndex: index + 1
};
}
case 'value-object':
{
// Dot-access lookahead: check for `.propName` after this smi span
const dotResult = tryResolveDotAccess(binding, text, siblings, index, className, options);
if (dotResult) {
return dotResult;
}
// No dot access — annotate with the full object shape
const shapeStr = formatObjectShape(binding.properties);
return {
nodes: [createValueRefElement(shapeStr, node.children, binding.varName, className, options.typeValueRefComponent)],
nextIndex: index + 1
};
}
case 'shadow':
return {
nodes: [node],
nextIndex: index + 1
};
case 'module':
{
// Namespace dot-access: `NS.exportName` — look for `.` + span
const moduleResult = tryResolveModuleDotAccess(binding, text, siblings, index, className, options);
if (moduleResult) {
return moduleResult;
}
// No dot-access — just render the namespace identifier as a link
// to the module page if it has a default href
if (binding.defaultHref) {
return {
nodes: [createLinkElement(binding.defaultHref, node.children, text, className, options.typeRefComponent)],
nextIndex: index + 1
};
}
return {
nodes: [node],
nextIndex: index + 1
};
}
default:
break;
}
}
}
return {
nodes: [node],
nextIndex: index + 1
};
}
/**
* Handles a keyword span (pl-k) by updating scan state.
*/
function handleKeywordSpan(node, state, options) {
const {
lang,
linkValues,
linkArrays,
moduleLinkMap
} = options;
const text = getTextContent(node);
if (lang.semantics === 'js' && state.sawExportKeyword && state.sawExportDefaultKeyword && ['const', 'let', 'var', 'function', 'class', 'enum', 'interface', 'type', 'export', 'import'].includes(text)) {
const isDefaultContinuation = state.pendingExportKind === 'unknown' && (text === 'function' || text === 'class');
if (!isDefaultContinuation) {
finalizePendingDefaultExport(state);
}
}
switch (text) {
case '@import':
if (lang.semantics !== 'css') {
break;
}
if (moduleLinkMap) {
state.sawCssImportKeyword = true;
}
break;
case 'export':
if (lang.semantics !== 'js') {
break;
}
// New export keyword is a statement boundary — clear stale indices
// from a previous export so its record is not mutated.
state.pendingExportTypeIndex = null;
state.pendingExportKindIndex = null;
state.pendingMultiDeclKind = null;
state.pendingReExportEntries = [];
state.pendingStarReExport = false;
state.sawExportKeyword = true;
state.pendingExportKeywordNode = node;
break;
case 'import':
if (lang.semantics !== 'js') {
break;
}
// `export import` is not a standalone import; clear export state
if (state.sawExportKeyword) {
resetExportState(state);
}
if (moduleLinkMap) {
state.sawJsImportKeyword = true;
state.pendingImportNames = [];
state.pendingDefaultImport = null;
state.pendingNamespaceImport = null;
state.importSawAs = false;
state.sawFromKeyword = false;
state.inImportBraces = false;
state.importSawStar = false;
}
break;
case 'from':
if (state.sawJsImportKeyword && moduleLinkMap) {
state.sawFromKeyword = true;
}
// Re-exports: `export { foo } from './mod'` — the export braces
// already closed and set pendingReExportEntries. Arm sawFromKeyword
// so the module string handler can enrich those entries.
if ((state.pendingReExportEntries.length > 0 || state.pendingStarReExport) && moduleLinkMap) {
state.sawFromKeyword = true;
}
break;
case 'as':
if (state.sawJsImportKeyword && moduleLinkMap) {
state.importSawAs = true;
}
if (state.sawExportKeyword && state.inExportBraces) {
state.exportSawAs = true;
}
break;
case 'default':
// Inside `import { default as Foo }`, `default` is tokenized as pl-k.
// Collect it as a named imp