UNPKG

@mui/internal-docs-infra

Version:

MUI Infra - internal documentation creation tools.

599 lines (557 loc) 26.6 kB
// Can use node: imports here since this is server-only code import path from 'node:path'; import { readFile, stat } from 'node:fs/promises'; import { fileURLToPath, pathToFileURL } from 'node:url'; import { parseImportsAndComments, extractNameAndSlugFromUrl } from "../loaderUtils/index.mjs"; import { nameMark, performanceMeasure } from "../loadPrecomputedCodeHighlighter/performanceLogger.mjs"; import { loadTypescriptConfig } from "./loadTypescriptConfig.mjs"; import { resolveLibrarySourceFiles } from "./resolveLibrarySourceFiles.mjs"; import { formatClassData, isPublicClass } from "./formatClass.mjs"; import { formatComponentData, isPublicComponent } from "./formatComponent.mjs"; import { formatHookData, isPublicHook } from "./formatHook.mjs"; import { formatFunctionData, isPublicFunction } from "./formatFunction.mjs"; import { formatRawData } from "./formatRaw.mjs"; import { prettyFormat } from "./format.mjs"; import { buildTypeCompatibilityMap } from "./rewriteTypes.mjs"; import { findMetaFiles } from "./findMetaFiles.mjs"; import { getWorkerManager } from "./workerManager.mjs"; import { reconstructPerformanceLogs } from "./performanceTracking.mjs"; import { typeSuffixes as defaultTypeSuffixes } from "../loadServerTypesText/order.mjs"; import { organizeTypesByExport } from "../loadServerTypesText/organizeTypesByExport.mjs"; const functionName = 'Load Server Types Meta'; /** * Loads and formats TypeScript types from source files. * * This function handles: * - Loading TypeScript configuration * - Resolving library source files and variants * - Finding meta files (DataAttributes, CssVars) * - Processing types via worker thread * - Formatting component, hook, function, and raw types * - Collecting external types referenced in props/params * * The result can be used by syncTypes for markdown generation or by other consumers. */ export async function loadServerTypesMeta(options) { const { typesMarkdownPath, rootContext, variants, watchSourceDirectly, formattingOptions, socketDir } = options; // Derive relative path and resource name from inputs const relativePath = path.relative(rootContext, typesMarkdownPath); const resourceName = extractNameAndSlugFromUrl(new URL('.', pathToFileURL(typesMarkdownPath)).pathname).name; // Ensure rootContext always ends with / for correct URL resolution const rootContextDir = rootContext.endsWith('/') ? rootContext : `${rootContext}/`; let currentMark = nameMark(functionName, 'Start Loading', [relativePath]); performance.mark(currentMark); const config = await loadTypescriptConfig(path.join(rootContext, 'tsconfig.json')); currentMark = performanceMeasure(currentMark, { mark: 'tsconfig.json loaded', measure: 'tsconfig.json loading' }, [functionName, relativePath]); let resolvedVariantMap = new Map(); if (variants) { // Ensure pathsBasePath ends with / for correct URL resolution (if defined) const pathsBasePath = config.options.pathsBasePath ? String(config.options.pathsBasePath) : undefined; const pathsBaseDir = pathsBasePath && (pathsBasePath.endsWith('/') ? pathsBasePath : `${pathsBasePath}/`); const result = await resolveLibrarySourceFiles({ variants, resourcePath: typesMarkdownPath, rootContextDirUrl: pathToFileURL(rootContextDir).href, tsconfigPaths: config.options.paths, pathsBaseDir, watchSourceDirectly: Boolean(watchSourceDirectly) }); resolvedVariantMap = result.resolvedVariantMap; currentMark = performanceMeasure(currentMark, { mark: 'Paths Resolved', measure: 'Path Resolution' }, [functionName, relativePath]); } // Collect all entrypoints for optimized program creation // Include both the component entrypoints and their meta files (DataAttributes, CssVars) // These are file:// URLs from resolveLibrarySourceFiles const resolvedEntrypointUrls = Array.from(resolvedVariantMap.values()); // Parse exports from library source files to find re-exported directories // This helps us discover DataAttributes/CssVars files from re-exported components const reExportedDirUrls = new Set(); await Promise.all(resolvedEntrypointUrls.map(async entrypointUrl => { try { // Convert file:// URL to filesystem path for Node.js fs APIs const fsEntrypoint = fileURLToPath(entrypointUrl); const sourceCode = await readFile(fsEntrypoint, 'utf-8'); const parsed = await parseImportsAndComments(sourceCode, entrypointUrl); // Look for relative exports (e.g., '../menu/', './Button', etc.) await Promise.all(Object.keys(parsed.relative || {}).map(async exportPath => { if (exportPath.startsWith('..') || exportPath.startsWith('.')) { // Resolve to absolute filesystem path const absoluteFsPath = path.resolve(path.dirname(fsEntrypoint), exportPath); // Check if this path exists as a directory // If not, it might be a module reference (e.g., '../menu/backdrop/MenuBackdrop' -> MenuBackdrop.tsx) // In that case, we want to add the parent directory try { const stats = await stat(absoluteFsPath); if (stats.isDirectory()) { // It's a directory, add it with trailing slash so path.dirname returns this directory reExportedDirUrls.add(pathToFileURL(`${absoluteFsPath}/`).href); } } catch { // Path doesn't exist as-is. Check if it exists with common extensions const extensions = ['.tsx', '.ts', '.jsx', '.js', '.mjs', '.cjs']; for (const ext of extensions) { try { // eslint-disable-next-line no-await-in-loop const fileStats = await stat(absoluteFsPath + ext); if (fileStats.isFile()) { // It's a file reference, add the parent directory as file:// URL // Add trailing slash so path.dirname returns this directory, not its parent const parentDir = path.dirname(absoluteFsPath); reExportedDirUrls.add(pathToFileURL(`${parentDir}/`).href); break; } } catch { // Continue checking other extensions } } // If not found as file or directory, it might be a bare module reference - skip it } } })); } catch (error) { // If we can't parse a file, just skip it console.warn(`[Main] Failed to parse exports from ${entrypointUrl}:`, error instanceof Error ? error.message : error); } })); // Find meta files from the library source directories and re-exported directories // Convert file:// URLs to filesystem paths for findMetaFiles const entrypointFiles = resolvedEntrypointUrls.map(url => fileURLToPath(url)); const reExportedDirs = Array.from(reExportedDirUrls).map(url => fileURLToPath(url)); // findMetaFiles accepts filesystem paths and returns filesystem paths // We search both entrypoint files and re-exported directories for meta files, // but only include actual files (entrypoints + found meta files), not directories const metaFilesFromEntrypoints = await Promise.all(entrypointFiles.map(fsPath => findMetaFiles(fsPath))).then(results => results.flat()); const metaFilesFromReExports = await Promise.all(reExportedDirs.map(fsPath => findMetaFiles(fsPath))).then(results => results.flat()); // Meta files are DataAttributes/CssVars files that aren't imported but contain type info const metaFiles = [...metaFilesFromEntrypoints, ...metaFilesFromReExports]; // All files needed for the TypeScript program (entrypoints + meta files) const allEntrypoints = [...entrypointFiles, ...metaFiles]; currentMark = performanceMeasure(currentMark, { mark: 'Meta Files Resolved', measure: 'Meta Files Resolution' }, [functionName, relativePath]); // Process types — use the worker manager singleton (which adapts to main vs worker thread) const workerManager = getWorkerManager(socketDir); const workerStartTime = performance.now(); const workerResult = await workerManager.processTypes({ projectPath: config.projectPath, compilerOptions: config.options, allEntrypoints, metaFiles, resolvedVariantMap: Array.from(resolvedVariantMap.entries()), dependencies: config.dependencies, rootContextDir, relativePath }); if (!workerResult.success) { throw new Error(workerResult.error || 'Worker failed to process types'); } // Reconstruct worker performance logs in main thread // Note: Worker logs already include relativePath in their names, // so they'll be automatically filtered by the PerformanceObserver if (workerResult.performanceLogs) { reconstructPerformanceLogs(workerResult.performanceLogs, workerStartTime); } currentMark = performanceMeasure(currentMark, { prefix: 'worker', mark: 'processed', measure: 'processing' }, [functionName, relativePath], true); const rawVariantData = workerResult.variantData || {}; const allDependencies = workerResult.allDependencies || []; // Format the raw exports from the worker into TypesMeta const variantData = {}; // Create external types collector — shared across all formatting calls. // External types are collected during formatting as type names are encountered // in the formatted output, eliminating the need for a separate tree walk + filtering step. const collectedExternalTypes = new Map(); // Parse external types pattern once if provided const externalTypesPatternRegex = options.externalTypesPattern ? new RegExp(options.externalTypesPattern) : undefined; // Build type compatibility map once from all exports across all variants // This map is used to rewrite type references (e.g., Dialog.Trigger.State -> AlertDialog.Trigger.State) const allRawExports = Object.values(rawVariantData).flatMap(v => v.allTypes); const allExportNames = Array.from(new Set(allRawExports.map(exp => exp.name))); const typeCompatibilityMap = buildTypeCompatibilityMap(allRawExports, allExportNames); // Build merged typeNameMap from all variants for type string rewriting // typeNameMap maps flat names like "AlertDialogTriggerState" to dotted names like "AlertDialog.Trigger.State" const mergedTypeNameMapForRewrite = {}; for (const variant of Object.values(rawVariantData)) { if (variant.typeNameMap) { Object.assign(mergedTypeNameMapForRewrite, variant.typeNameMap); } } const rewriteContext = { typeCompatibilityMap, exportNames: allExportNames, typeNameMap: Object.keys(mergedTypeNameMapForRewrite).length > 0 ? mergedTypeNameMapForRewrite : undefined }; // Process all variants in parallel await Promise.all(Object.entries(rawVariantData).map(async ([variantName, variantResult]) => { // Create a per-variant external types collector. // Each variant shares the same collected map so types are deduplicated automatically. const externalTypesCollector = { collected: collectedExternalTypes, allExports: variantResult.allTypes, pattern: externalTypesPatternRegex, typeNameMap: variantResult.typeNameMap }; // Process all exports in parallel within each variant const types = await Promise.all(variantResult.exports.map(async exportNode => { if (isPublicComponent(exportNode)) { const formattedData = await formatComponentData(exportNode, variantResult.allTypes, variantResult.typeNameMap || {}, rewriteContext, { formatting: formattingOptions, externalTypes: externalTypesCollector, ordering: options.ordering, descriptionReplacements: options.descriptionReplacements }); return { type: 'component', name: exportNode.name, data: formattedData }; } if (isPublicHook(exportNode)) { const formattedData = await formatHookData(exportNode, variantResult.typeNameMap || {}, rewriteContext, { formatting: formattingOptions, externalTypes: externalTypesCollector, descriptionReplacements: options.descriptionReplacements }); return { type: 'hook', name: exportNode.name, data: formattedData }; } if (isPublicFunction(exportNode)) { const formattedData = await formatFunctionData(exportNode, variantResult.typeNameMap || {}, rewriteContext, { formatting: formattingOptions, externalTypes: externalTypesCollector, descriptionReplacements: options.descriptionReplacements }); return { type: 'function', name: exportNode.name, data: formattedData }; } if (isPublicClass(exportNode)) { const formattedData = await formatClassData(exportNode, variantResult.typeNameMap || {}, rewriteContext, { formatting: formattingOptions, externalTypes: externalTypesCollector, descriptionReplacements: options.descriptionReplacements }); return { type: 'class', name: exportNode.name, data: formattedData }; } // For all other types (type aliases, interfaces, enums), format as raw const formattedData = await formatRawData(exportNode, exportNode.name, variantResult.typeNameMap || {}, rewriteContext, { formatting: formattingOptions, externalTypes: externalTypesCollector, descriptionReplacements: options.descriptionReplacements }); return { type: 'raw', name: exportNode.name, data: formattedData }; })); variantData[variantName] = { types, typeNameMap: variantResult.typeNameMap }; })); // Group types by component name when there's a single Default variant with sub-components // This creates per-component groupings (e.g., "Accordion.Root", "Accordion.Header") // For multi-variant cases (CssModules, Tailwind), keep the original structure // // Key distinction: // - Accordion: Has sub-components like Accordion.Root, Accordion.Trigger -> group by sub-component // - Button: Just Button with Button.Props, Button.State -> all stay in "Default" // // We detect this by checking if there are any 2-part names that are NOT suffixes. // If all 2-part names are just type suffixes (Props, State, etc.), keep everything in Default. const variantNames = Object.keys(variantData); if (variantNames.length === 1 && variantNames[0] === 'Default') { const defaultData = variantData.Default; // Check if there are actual sub-components (2-part names that are NOT suffixes) // e.g., "Accordion.Root" is a sub-component, but "Button.Props" is just a suffix // and "Form.ValidationMode" is a raw type, not a sub-component const hasSubComponents = defaultData.types.some(t => { const parts = t.name.split('.'); if (parts.length !== 2) { return false; } // It's a sub-component if: // 1. The second part is NOT a type suffix, AND // 2. It's an actual component/hook/function/class (not a raw type) return !(options.ordering?.typeSuffixes ?? defaultTypeSuffixes).includes(parts[1]) && (t.type === 'component' || t.type === 'hook' || t.type === 'function' || t.type === 'class'); }); if (hasSubComponents) { // Group types by component name const groupedVariantData = {}; for (const typeMeta of defaultData.types) { // Determine the component group name: // - 3+ parts (e.g., "Accordion.Root.State"): first two parts ("Accordion.Root") // - 2 parts AND it's a component/hook/function/class (e.g., "Accordion.Root", "Progress.Value"): both parts // - 2 parts AND it's a raw type (e.g., "Toolbar.Orientation", "Field.ValidityData"): "Default" group // - 1 part (e.g., "DirectionProvider"): "Default" group let groupName; const parts = typeMeta.name.split('.'); if (parts.length >= 3) { groupName = `${parts[0]}.${parts[1]}`; } else if (parts.length === 2) { if (typeMeta.type === 'component' || typeMeta.type === 'hook' || typeMeta.type === 'function' || typeMeta.type === 'class') { // Actual sub-component like Accordion.Root, Progress.Value -> group under full name groupName = typeMeta.name; } else { // Raw type like Toolbar.Orientation, Field.ValidityData -> group under "Default" groupName = 'Default'; } } else { groupName = 'Default'; } if (!groupedVariantData[groupName]) { groupedVariantData[groupName] = { types: [], typeNameMap: defaultData.typeNameMap }; } groupedVariantData[groupName].types.push(typeMeta); } // Replace variantData with grouped version // Clear and repopulate to maintain the same object reference for (const key of Object.keys(variantData)) { delete variantData[key]; } Object.assign(variantData, groupedVariantData); } } currentMark = performanceMeasure(currentMark, { mark: 'formatting complete', measure: 'type formatting' }, [functionName, relativePath]); // Collect all types for further processing let allTypes = Object.values(variantData).flatMap(v => v.types); // Deduplicate types by name - can happen when same component is exported from multiple entrypoints // (e.g., DirectionProvider exported from both index.ts and DirectionProvider.tsx) // Prefer components/hooks over other types when there are duplicates const typesByName = new Map(); allTypes.forEach(typeMeta => { const existing = typesByName.get(typeMeta.name); if (!existing) { typesByName.set(typeMeta.name, typeMeta); } else if (typeMeta.type === 'component' || typeMeta.type === 'hook') { // Prefer components/hooks over other types typesByName.set(typeMeta.name, typeMeta); } // else: keep existing entry (don't replace with 'other' type) }); allTypes = Array.from(typesByName.values()); // Merge typeNameMaps from all variants for filtering // typeNameMap maps flat names like "AccordionItemChangeEventReason" to dotted names like "Accordion.Item.ChangeEventReason" // While variants typically have identical mappings (they parse the same source), merging ensures completeness const mergedTypeNameMap = {}; for (const variant of Object.values(variantData)) { if (variant.typeNameMap) { Object.assign(mergedTypeNameMap, variant.typeNameMap); } } // Filter out flat-named types when a corresponding namespaced version exists // e.g., if we have "Accordion.Item.ChangeEventReason" (namespaced), filter out "AccordionItemChangeEventReason" (flat) // Build a set of all dotted names that exist in allTypes const existingDottedNames = new Set(); for (const typeMeta of allTypes) { if (typeMeta.name.includes('.')) { existingDottedNames.add(typeMeta.name); } } // Filter out flat types that have a namespaced equivalent allTypes = allTypes.filter(typeMeta => { // Keep namespaced types if (typeMeta.name.includes('.')) { return true; } // Check if this flat type has a corresponding dotted name in typeNameMap const dottedName = mergedTypeNameMap[typeMeta.name]; if (!dottedName) { // No mapping found, keep the type return true; } // Check if the full dotted name exists in our types // e.g., if typeNameMap says AccordionItemChangeEventReason → Accordion.Item.ChangeEventReason // and we have Accordion.Item.ChangeEventReason in existingDottedNames, filter out the flat version return !existingDottedNames.has(dottedName); }); // Attach aliases from typeNameMap to namespaced types so they can also be looked up by their flat name // e.g., "Accordion.Item.ChangeEventDetails" gets alias "AccordionItemChangeEventDetails" // The flat name is confirmed to be a real export (typeNameMap only contains verified exports) const dottedToFlatNames = new Map(); for (const [flatName, dottedName] of Object.entries(mergedTypeNameMap)) { // Only add aliases for flat names that were actually filtered out (i.e., the dotted version exists) if (existingDottedNames.has(dottedName)) { const existing = dottedToFlatNames.get(dottedName); if (existing) { existing.push(flatName); } else { dottedToFlatNames.set(dottedName, [flatName]); } } } if (dottedToFlatNames.size > 0) { allTypes = allTypes.map(typeMeta => { const flatAliases = dottedToFlatNames.get(typeMeta.name); if (flatAliases) { return { ...typeMeta, aliases: flatAliases }; } return typeMeta; }); } // Detect re-exports: check if type exports (like ButtonProps) are just re-exports of component props // For 'raw' types, update the data.reExportOf field allTypes = allTypes.map(typeMeta => { if (typeMeta.type !== 'raw') { return typeMeta; } // Skip if already marked as a re-export if (typeMeta.data.reExportOf) { return typeMeta; } // Extract component name and suffix (e.g., "ButtonProps" -> component: "Button", suffix: "Props") // Handle both namespaced (ContextMenu.Root.Props) and non-namespaced (ButtonProps) names const parts = typeMeta.name.match(/^(.+)\.(Props|State|DataAttributes|CssVars)$/); if (!parts) { return typeMeta; } const [, componentName, suffix] = parts; // Find the corresponding component by checking both the full name and just the last part // e.g., for "ContextMenu.Root.Props", check both "ContextMenu.Root" and "Root" const correspondingComponent = allTypes.find(t => t.type === 'component' && (t.name === componentName || t.name.endsWith(`.${componentName}`))); if (!correspondingComponent || correspondingComponent.type !== 'component') { return typeMeta; } // Check if Props is a re-export of the component's props if (suffix === 'Props' && correspondingComponent.data.props) { const hasProps = Object.keys(correspondingComponent.data.props).length > 0; if (hasProps) { // Extract the display name (last part after dot) for the link text const displayName = componentName.includes('.') ? componentName.split('.').pop() : componentName; // Mark this as a re-export by updating the data return { type: 'raw', name: typeMeta.name, data: { ...typeMeta.data, reExportOf: { name: displayName, slug: `#${displayName.toLowerCase()}`, suffix: 'props' } } }; } } // Check if DataAttributes is a re-export of the component's data attributes if (suffix === 'DataAttributes' && correspondingComponent.data.dataAttributes) { const hasDataAttributes = Object.keys(correspondingComponent.data.dataAttributes).length > 0; if (hasDataAttributes) { // Extract the display name (last part after dot) for the link text const displayName = componentName.includes('.') ? componentName.split('.').pop() : componentName; return { type: 'raw', name: typeMeta.name, data: { ...typeMeta.data, reExportOf: { name: displayName, slug: `#${displayName.toLowerCase()}`, suffix: 'data-attributes' } } }; } } // Check if CssVars is a re-export of the component's CSS variables if (suffix === 'CssVars' && correspondingComponent.data.cssVariables) { const hasCssVariables = Object.keys(correspondingComponent.data.cssVariables).length > 0; if (hasCssVariables) { // Extract the display name (last part after dot) for the link text const displayName = componentName.includes('.') ? componentName.split('.').pop() : componentName; return { type: 'raw', name: typeMeta.name, data: { ...typeMeta.data, reExportOf: { name: displayName, slug: `#${displayName.toLowerCase()}`, suffix: 'css-variables' } } }; } } return typeMeta; }); // Update variantData with the modified types (with reExportOf set) // allTypes was modified by the re-export detection above, but variantData still references the old objects // Create a lookup map from the updated allTypes const updatedTypesByName = new Map(); for (const typeMeta of allTypes) { updatedTypesByName.set(typeMeta.name, typeMeta); } // Update each variant's types array with the modified types for (const variant of Object.values(variantData)) { variant.types = variant.types.map(typeMeta => { const updated = updatedTypesByName.get(typeMeta.name); return updated ?? typeMeta; }); } // Get typeNameMap from first variant (they should all be the same) const typeNameMap = Object.values(variantData)[0]?.typeNameMap; // External types were collected during formatting — no separate filtering needed. // The collection happens in formatType() which only encounters types that appear // in the formatted output, so every collected type is actually referenced. // Convert collected external types to a simple Record<string, string>, formatted with prettier. // Store the full declaration (e.g., `type NAME = ...;`) so generateTypesMarkdown uses it as-is. const externalTypes = {}; await Promise.all(Array.from(collectedExternalTypes.entries()).map(async ([name, meta]) => { const formatted = await prettyFormat(meta.definition, name); externalTypes[name] = formatted.trimEnd(); })); performanceMeasure(currentMark, { mark: 'complete', measure: 'total processing' }, [functionName, relativePath], true); // Organize types into exports structure for UI consumption const organized = organizeTypesByExport(variantData, typeNameMap, options.ordering); return { allDependencies, typeNameMap, externalTypes, resourceName, exports: organized.exports, additionalTypes: organized.additionalTypes, variantOnlyAdditionalTypes: organized.variantOnlyAdditionalTypes, variantTypeNames: organized.variantTypeNames, variantTypeNameMaps: organized.variantTypeNameMaps }; }