UNPKG

@mui/internal-docs-infra

Version:

MUI Infra - internal documentation creation tools.

1,270 lines (1,203 loc) 87.6 kB
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