UNPKG

@mui/internal-docs-infra

Version:

MUI Infra - internal documentation creation tools.

406 lines (375 loc) 15.9 kB
// eslint-disable-next-line n/prefer-node-protocol import { fileURLToPath } from 'url'; import { parseFromProgram } from 'typescript-api-extractor'; import ts from 'typescript'; import { createOptimizedProgram } from "./createOptimizedProgram.mjs"; import { extractJSDocText, isJSDocNodeArray } from "./extractJSDocText.mjs"; import { PerformanceTracker } from "./performanceTracking.mjs"; import { nameMark } from "../loadPrecomputedCodeHighlighter/performanceLogger.mjs"; /** * Strips functions from objects so they can cross the worker boundary. * Structured clone can't handle functions but handles everything else fine. * Also normalizes typescript-api-extractor JSDoc description arrays to strings, * resolving `{@link}` references against `documentedNames` so only symbols with * a heading on the page become anchor links. */ function stripFunctions(value, documentedNames, visited = new WeakMap()) { // Primitives, null, undefined - return as-is if (value === null || value === undefined || typeof value !== 'object') { return value; } // Already processed - return cached result (handles circular refs) if (visited.has(value)) { return visited.get(value); } // Arrays if (Array.isArray(value)) { // Normalize JSDoc description arrays to strings if (isJSDocNodeArray(value)) { return extractJSDocText(value, documentedNames); } const result = []; visited.set(value, result); for (const item of value) { if (typeof item !== 'function') { result.push(stripFunctions(item, documentedNames, visited)); } } return result; } // Objects - copy properties, skip functions const result = {}; visited.set(value, result); for (const key of Object.keys(value)) { const propValue = value[key]; if (typeof propValue !== 'function') { result[key] = stripFunctions(propValue, documentedNames, visited); } } return result; } /** * Collects every symbol name documented on this page - all exports and resolved * types across all variants. `{@link}` references are linked only when they hit * this set, since those are the names that get a heading (and thus an anchor). */ function collectDocumentedNames(variantData) { const names = new Set(); const addName = name => { if (!name) { return; } names.add(name); // Namespaced exports (e.g. "Menu.Root") are also referenced by their flat // ("MenuRoot") and leaf ("Root") forms in {@link} comments. if (name.includes('.')) { names.add(name.replaceAll('.', '')); names.add(name.slice(name.lastIndexOf('.') + 1)); } }; for (const variant of Object.values(variantData)) { for (const node of variant.allTypes) { addName(node.name); } if (variant.typeNameMap) { for (const [flatName, dottedName] of Object.entries(variant.typeNameMap)) { addName(flatName); addName(dottedName); } } } return names; } /** * Recursively collects all source file dependencies of a given source file. * This walks the import graph starting from the given file and collects all * non-declaration, non-node_modules files that it imports (directly or transitively). * * @param sourceFile - The starting source file * @param program - The TypeScript program * @param visited - Set of already visited file paths to prevent cycles * @returns Array of file paths that are dependencies of the source file */ function collectSourceFileDependencies(sourceFile, program, visited) { const dependencies = []; const checker = program.getTypeChecker(); // Mark this file as visited to prevent cycles if (visited.has(sourceFile.fileName)) { return dependencies; } visited.add(sourceFile.fileName); // Walk through all import/export declarations in the source file ts.forEachChild(sourceFile, function visit(node) { let moduleSpecifier; if (ts.isImportDeclaration(node) && node.moduleSpecifier) { moduleSpecifier = node.moduleSpecifier; } else if (ts.isExportDeclaration(node) && node.moduleSpecifier) { moduleSpecifier = node.moduleSpecifier; } if (moduleSpecifier && ts.isStringLiteral(moduleSpecifier)) { // Resolve the module to get the actual file path const symbol = checker.getSymbolAtLocation(moduleSpecifier); if (symbol) { const declarations = symbol.getDeclarations(); if (declarations && declarations.length > 0) { const declSourceFile = declarations[0].getSourceFile(); const fileName = declSourceFile.fileName; // Skip declaration files and node_modules if (!declSourceFile.isDeclarationFile && !fileName.includes('node_modules')) { dependencies.push(fileName); // Recursively collect dependencies of this file const nestedDeps = collectSourceFileDependencies(declSourceFile, program, visited); dependencies.push(...nestedDeps); } } } } }); return dependencies; } /** * Builds a mapping from flat type names to dotted namespace names. * * typescript-api-extractor now returns exports with proper dotted names like * "Component.Root.Props" directly. This function builds a map from the flat * equivalent names to the dotted names for type reference transformation. * * For each dotted export like "Component.Root.Props", we create a mapping: * ComponentRootProps -> Component.Root.Props * * BUT only if the flat name is ALSO exported (e.g., there's an actual * `export type ComponentRootProps = ...` in the entrypoint). */ function buildTypeNameMap(exports) { const typeNameMap = new Map(); // Build a set of all export names const exportNames = new Set(exports.map(exp => exp.name)); for (const exp of exports) { if (exp.name.includes('.')) { // e.g., "Component.Root.Props" -> flatName "ComponentRootProps" const flatName = exp.name.replace(/\./g, ''); // Only add if the flat name is ALSO an export if (exportNames.has(flatName)) { typeNameMap.set(flatName, exp.name); } } } return typeNameMap; } /** * Recursively collects all type references from a type tree. * This helps build a more complete typeNameMap by finding all referenced types. * Only adds entries if the flat name is also an export. */ function collectTypeReferences(type, typeNameMap, exportNames) { if (!type) { return; } // Check if this type has a typeName with namespaces if ('typeName' in type && type.typeName) { const typeName = type.typeName; if (typeName.namespaces && typeName.namespaces.length > 0) { const flatName = typeName.namespaces.join('') + typeName.name; const dottedName = [...typeName.namespaces, typeName.name].join('.'); // Only add if the flat name is different from dotted name, // it's not already in the map, AND it's actually an export if (flatName !== dottedName && !typeNameMap.has(flatName) && exportNames.has(flatName)) { typeNameMap.set(flatName, dottedName); } } } // Recursively process nested types if ('types' in type && Array.isArray(type.types)) { for (const t of type.types) { collectTypeReferences(t, typeNameMap, exportNames); } } if ('properties' in type && Array.isArray(type.properties)) { for (const prop of type.properties) { if ('type' in prop) { collectTypeReferences(prop.type, typeNameMap, exportNames); } } } if ('props' in type && Array.isArray(type.props)) { for (const prop of type.props) { if ('type' in prop) { collectTypeReferences(prop.type, typeNameMap, exportNames); } } } if ('callSignatures' in type && Array.isArray(type.callSignatures)) { for (const sig of type.callSignatures) { if ('parameters' in sig && Array.isArray(sig.parameters)) { for (const param of sig.parameters) { if ('type' in param) { collectTypeReferences(param.type, typeNameMap, exportNames); } } } if ('returnValue' in sig && sig.returnValue) { const returnValue = sig.returnValue; if (returnValue.type) { collectTypeReferences(returnValue.type, typeNameMap, exportNames); } } } } } /** * Extracts unique namespace names from exports. * * For example, from exports like ["Menu.Root", "Menu.Item", "Dialog.Root"], * this returns ["Menu", "Dialog"]. */ function extractNamespaces(exports) { const namespaces = new Set(); for (const exp of exports) { // Check if the export name contains a dot (indicating a namespace) const firstDot = exp.name.indexOf('.'); if (firstDot !== -1) { namespaces.add(exp.name.substring(0, firstDot)); } } return Array.from(namespaces); } // Worker returns raw export nodes and metadata for formatting in main thread /** * Process TypeScript types for the given request. * This function creates a TypeScript program, parses exports, and returns type metadata. */ export async function processTypes(request) { const tracker = new PerformanceTracker(); const functionName = '[Worker] Process Types'; try { // Create optimized TypeScript program const programWrapperStart = tracker.mark(nameMark(functionName, 'Program Creation Start', [request.relativePath], true)); const program = createOptimizedProgram(request.projectPath, request.compilerOptions, request.allEntrypoints, {}, tracker, functionName, [request.relativePath]); const programWrapperEnd = tracker.mark(nameMark(functionName, 'Program Creation End', [request.relativePath], true)); tracker.measure(nameMark(functionName, 'Program Creation', [request.relativePath], true), programWrapperStart, programWrapperEnd); const internalTypesCache = {}; const parserOptions = { includeExternalTypes: false, shouldInclude: ({ depth }) => depth <= 15, shouldResolveObject: ({ propertyCount, depth }) => propertyCount <= 50 && depth <= 15 }; // Process variants in parallel const resolvedVariantMap = new Map(request.resolvedVariantMap); const variantPromises = Array.from(resolvedVariantMap.entries()).map(async ([variantName, fileUrl]) => { const variantStart = tracker.mark(nameMark(functionName, `Variant ${variantName} Start`, [request.relativePath], true)); // Convert file:// URL to filesystem path for TypeScript const entrypoint = fileURLToPath(fileUrl); try { // Ensure the entrypoint exists and is accessible to the TypeScript program const sourceFile = program.getSourceFile(entrypoint); if (!sourceFile) { throw new Error(`Source file not found in TypeScript program: ${entrypoint}\n` + `Make sure the file exists and is included in the TypeScript compilation.`); } const parseStart = tracker.mark(nameMark(functionName, `Variant ${variantName} Parse Start`, [request.relativePath])); // Use parseFromProgram directly - it now handles namespace exports, // type aliases, and re-exports properly const { exports } = parseFromProgram(entrypoint, program, parserOptions); // Extract namespaces from the exports (e.g., "Menu" from "Menu.Root") const namespaces = extractNamespaces(exports); // Build a set of all export names for filtering const exportNames = new Set(exports.map(exp => exp.name)); // Build typeNameMap from exports (maps flat names to dotted names) // Only includes entries where the flat name is also an export const mergedTypeNameMap = buildTypeNameMap(exports); // Also collect type references from all exports to build a more complete map for (const exp of exports) { if ('type' in exp && exp.type) { collectTypeReferences(exp.type, mergedTypeNameMap, exportNames); } } // Get all source files that are dependencies of this entrypoint // Include files from the TypeScript program for hot reloading support // We collect only files imported by THIS entrypoint, not all files in the program const entrypointDependencies = collectSourceFileDependencies(sourceFile, program, new Set()); const dependencies = [...request.dependencies, entrypoint, ...request.metaFiles, ...entrypointDependencies]; // Parse meta files (DataAttributes, CssVars) for additional type information const allInternalTypes = request.metaFiles.map(file => { if (internalTypesCache[file]) { return internalTypesCache[file]; } // Ensure the file is loaded in the program first // This is important for meta files (DataAttributes, CssVars) that aren't imported const fileSourceFile = program.getSourceFile(file); if (!fileSourceFile) { console.warn(`[processTypes] ${variantName} - Could not load source file: ${file}`); return []; } const { exports: internalExport } = parseFromProgram(file, program, parserOptions); internalTypesCache[file] = internalExport; return internalExport; }); const internalTypes = allInternalTypes.reduce((acc, cur) => { acc.push(...cur); return acc; }, []); const allTypes = [...exports, ...internalTypes]; const parseEnd = tracker.mark(nameMark(functionName, `Variant ${variantName} Parsed`, [request.relativePath])); tracker.measure(nameMark(functionName, `Variant ${variantName} Parsing`, [request.relativePath]), parseStart, parseEnd); const variantEnd = tracker.mark(nameMark(functionName, `Variant ${variantName} Complete`, [request.relativePath], true)); tracker.measure(nameMark(functionName, `Variant ${variantName} Total`, [request.relativePath], true), variantStart, variantEnd); return { variantName, variantData: { exports, allTypes, namespaces, // Convert Map to Record for serialization across worker boundary typeNameMap: mergedTypeNameMap.size > 0 ? Object.fromEntries(mergedTypeNameMap) : undefined }, dependencies, debug: { metaFilesCount: request.metaFiles.length } }; } catch (error) { throw new Error(`Failed to parse variant ${variantName} (${fileUrl}): \n${error && typeof error === 'object' && 'message' in error && error.message}`); } }); const variantResults = await Promise.all(variantPromises); // Process results and collect dependencies and debug info const variantData = {}; const allDependencies = []; const debugInfo = {}; for (const result of variantResults) { if (result) { variantData[result.variantName] = result.variantData; result.dependencies.forEach(file => { allDependencies.push(file); }); if (result.debug) { debugInfo[result.variantName] = result.debug; } } } // Strip functions so data can cross worker boundary (structured clone can't handle functions) const documentedNames = collectDocumentedNames(variantData); const serializedVariantData = stripFunctions(variantData, documentedNames); return { success: true, variantData: serializedVariantData, allDependencies, performanceLogs: tracker.getLogs(), debug: Object.keys(debugInfo).length > 0 ? debugInfo[Object.keys(debugInfo)[0]] : undefined }; } catch (error) { return { success: false, error: error instanceof Error ? error.message : String(error), performanceLogs: tracker.getLogs() }; } }