@mui/internal-docs-infra
Version:
MUI Infra - internal documentation creation tools.
406 lines (375 loc) • 15.9 kB
JavaScript
// 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()
};
}
}