UNPKG

@mui/internal-docs-infra

Version:

MUI Infra - internal documentation creation tools.

714 lines (663 loc) 25.3 kB
import { getFileNameFromUrl } from "./getFileNameFromUrl.mjs"; import { fileUrlToPortablePath, portablePathToFileUrl } from "./fileUrlToPortablePath.mjs"; /** * Isomorphic path joining function that works in both Node.js and browser environments. * Uses string concatenation to handle path joining consistently across platforms. */ function joinPath(basePath, ...segments) { // Start with the base path, ensuring it has a trailing slash for URL construction let result = basePath.endsWith('/') ? basePath : `${basePath}/`; // Handle each segment for (let i = 0; i < segments.length; i += 1) { const segment = segments[i]; if (segment) { // Remove leading slash from segment to avoid double slashes const cleanSegment = segment.startsWith('/') ? segment.slice(1) : segment; // Append segment result += cleanSegment; // Add trailing slash for intermediate segments if (i < segments.length - 1) { result += '/'; } } } return result; } /** * Default file extensions for JavaScript/TypeScript modules that can be resolved */ export const JAVASCRIPT_MODULE_EXTENSIONS = ['.ts', '.tsx', '.js', '.jsx', '.mdx', '.d.ts']; /** * Extension priority for type-only imports - prioritize .d.ts first */ export const TYPE_IMPORT_EXTENSIONS = ['.d.ts', '.ts', '.tsx', '.js', '.jsx', '.mdx']; /** * Extension priority for value imports - standard priority with .d.ts last */ export const VALUE_IMPORT_EXTENSIONS = ['.ts', '.tsx', '.js', '.jsx', '.mdx', '.d.ts']; /** * Static asset extensions that should NOT be resolved as JS modules */ const STATIC_ASSET_EXTENSIONS = ['.css', '.scss', '.json', '.svg', '.png', '.jpg', '.jpeg', '.gif', '.webp', '.woff2']; /** * Asset extensions that are intentionally unsupported. * Importing one of these throws so the issue surfaces at build time. */ const UNSUPPORTED_ASSET_EXTENSIONS = ['.sass', // use '.scss' instead '.less', // legacy '.ico', // legacy '.woff', // legacy, use '.woff2' (https://web.dev/articles/font-best-practices#use_woff2) '.eot', // legacy '.ttf', // desktop font format '.otf' // desktop font format ]; /** * Checks if a file path or import path represents a static asset * @param path - The file path or import path to check * @returns true if it's a static asset, false if it should be resolved as a JS module */ function isStaticAsset(path) { return STATIC_ASSET_EXTENSIONS.some(ext => path.endsWith(ext)); } /** * Checks if a file path or import path represents a JavaScript/TypeScript module * @param path - The file path or import path to check * @returns true if it's a JS/TS module, false otherwise */ export function isJavaScriptModule(path) { // If the path has an extension, check if it's one of the JS/TS extensions if (/\.[^/]+$/.test(path)) { return JAVASCRIPT_MODULE_EXTENSIONS.some(ext => path.endsWith(ext)); } // If no extension, assume it's a JS/TS module (will be resolved to one) return true; } /** * Resolves a module path by reading directory contents to find matching files. * This is more efficient than checking each file individually with stat calls. * * Given a path like `file:///Code/mui-public/packages/docs-infra/docs/app/components/code-highlighter/demos/code/BasicCode`, * this function will try to find the actual file by checking for: * - `BasicCode.ts`, `BasicCode.tsx`, `BasicCode.js`, `BasicCode.jsx` * - `BasicCode/index.ts`, `BasicCode/index.tsx`, `BasicCode/index.js`, `BasicCode/index.jsx` * * @param moduleUrl - The module URL to resolve (file:// URL or portable path, without file extension) * @param readDirectory - Function to read directory contents * @param options - Configuration options * @param includeTypeDefs - If true, returns both import and typeImport paths with different extension priorities * @returns Promise<string | TypeAwareResolveResult> - The resolved file:// URL(s) */ export async function resolveModulePath(moduleUrl, readDirectory, options = {}, includeTypeDefs) { const { extensions = JAVASCRIPT_MODULE_EXTENSIONS } = options; // Convert file URL to portable path for internal processing const modulePath = moduleUrl.startsWith('file://') ? fileUrlToPortablePath(moduleUrl) : moduleUrl; // If includeTypeDefs is true, we need to resolve with both type and value extension priorities if (includeTypeDefs) { return resolveWithTypeAwareness(modulePath, readDirectory, options); } // Extract the parent directory and the module name const lastSlashIndex = modulePath.lastIndexOf('/'); const parentDir = modulePath.substring(0, lastSlashIndex); const moduleName = modulePath.substring(lastSlashIndex + 1); const resolvedPath = await resolveSinglePath(modulePath, parentDir, moduleName, readDirectory, extensions); return resolvedPath; } /** * Resolves a module path with type-aware resolution, returning both import and typeImport paths * This function is optimized to do only a single directory read instead of two separate reads. */ async function resolveWithTypeAwareness(modulePath, readDirectory, _options = {}) { const lastSlashIndex = modulePath.lastIndexOf('/'); const parentDir = modulePath.substring(0, lastSlashIndex); const moduleName = modulePath.substring(lastSlashIndex + 1); // Single filesystem read to get directory contents const dirContents = await readDirectory(portablePathToFileUrl(parentDir)); // Build a map of available files by basename const filesByBaseName = new Map(); for (const entry of dirContents) { if (entry.isFile) { const fileName = entry.name; let fileBaseName; let actualExtension; // Handle .d.ts files specially since getFileNameFromUrl returns .ts for types.d.ts if (fileName.endsWith('.d.ts')) { actualExtension = '.d.ts'; fileBaseName = fileName.substring(0, fileName.length - 5); // Remove .d.ts } else { const { extension: fileExt } = getFileNameFromUrl(fileName); actualExtension = fileExt; fileBaseName = fileName.substring(0, fileName.length - fileExt.length); } if (!filesByBaseName.has(fileBaseName)) { filesByBaseName.set(fileBaseName, []); } // Store the entry with its actual extension for later matching filesByBaseName.get(fileBaseName).push({ ...entry, actualExtension }); } } // Check for the module in both priority orders const matchingFiles = filesByBaseName.get(moduleName); if (matchingFiles) { const entryMap = matchingFiles; // Find best match for value imports (VALUE_IMPORT_EXTENSIONS priority) let importPath = null; for (const ext of VALUE_IMPORT_EXTENSIONS) { for (const entry of entryMap) { if (entry.actualExtension === ext) { importPath = portablePathToFileUrl(joinPath(parentDir, entry.name)); break; } } if (importPath) { break; } } // Find best match for type imports (TYPE_IMPORT_EXTENSIONS priority) let typeImportPath = null; for (const ext of TYPE_IMPORT_EXTENSIONS) { for (const entry of entryMap) { if (entry.actualExtension === ext) { typeImportPath = portablePathToFileUrl(joinPath(parentDir, entry.name)); break; } } if (typeImportPath) { break; } } if (importPath && typeImportPath && importPath !== typeImportPath) { return { import: importPath, typeImport: typeImportPath }; } if (importPath) { return { import: importPath }; } if (typeImportPath) { return { import: typeImportPath }; } } // Try index files with the same single-pass approach const directoryMatches = dirContents.filter(entry => entry.isDirectory && entry.name === moduleName); if (directoryMatches.length > 0) { const moduleDir = joinPath(parentDir, directoryMatches[0].name); try { const moduleDirContents = await readDirectory(portablePathToFileUrl(moduleDir)); // Build a map of available index files by basename const indexFilesByBaseName = new Map(); for (const moduleFile of moduleDirContents) { if (moduleFile.isFile) { const fileName = moduleFile.name; let fileBaseName; let actualExtension; // Handle .d.ts files specially since getFileNameFromUrl returns .ts for index.d.ts if (fileName.endsWith('.d.ts')) { actualExtension = '.d.ts'; fileBaseName = fileName.substring(0, fileName.length - 5); // Remove .d.ts } else { const { extension: fileExt } = getFileNameFromUrl(fileName); actualExtension = fileExt; fileBaseName = fileName.substring(0, fileName.length - fileExt.length); } if (!indexFilesByBaseName.has(fileBaseName)) { indexFilesByBaseName.set(fileBaseName, []); } // Store the entry with its actual extension for later matching indexFilesByBaseName.get(fileBaseName).push({ ...moduleFile, actualExtension }); } } // Check for index files in both priority orders const indexFiles = indexFilesByBaseName.get('index'); if (indexFiles) { const indexEntryMap = indexFiles; // Find best match for value imports let importPath = null; for (const ext of VALUE_IMPORT_EXTENSIONS) { for (const entry of indexEntryMap) { if (entry.actualExtension === ext) { importPath = portablePathToFileUrl(joinPath(moduleDir, entry.name)); break; } } if (importPath) { break; } } // Find best match for type imports let typeImportPath = null; for (const ext of TYPE_IMPORT_EXTENSIONS) { for (const entry of indexEntryMap) { if (entry.actualExtension === ext) { typeImportPath = portablePathToFileUrl(joinPath(moduleDir, entry.name)); break; } } if (typeImportPath) { break; } } if (importPath && typeImportPath && importPath !== typeImportPath) { return { import: importPath, typeImport: typeImportPath }; } if (importPath) { return { import: importPath }; } if (typeImportPath) { return { import: typeImportPath }; } } } catch { // Could not read module directory, continue } } throw new Error(`Could not resolve module at path "${modulePath}". Tried extensions: ${VALUE_IMPORT_EXTENSIONS.join(', ')}, ${TYPE_IMPORT_EXTENSIONS.join(', ')}`); } /** * Internal function to resolve a single path with given extensions */ async function resolveSinglePath(modulePath, parentDir, moduleName, readDirectory, extensions) { try { // Read the parent directory contents const dirContents = await readDirectory(portablePathToFileUrl(parentDir)); // Look for direct file matches in extension priority order // Create a map of baseName -> files with that basename for efficient lookup const filesByBaseName = new Map(); for (const entry of dirContents) { if (entry.isFile) { const fileName = entry.name; let fileBaseName; let actualExtension; // Handle .d.ts files specially since getFileNameFromUrl returns .ts for types.d.ts if (fileName.endsWith('.d.ts')) { actualExtension = '.d.ts'; fileBaseName = fileName.substring(0, fileName.length - 5); // Remove .d.ts } else { const { extension: fileExt } = getFileNameFromUrl(fileName); actualExtension = fileExt; fileBaseName = fileName.substring(0, fileName.length - fileExt.length); } if (!filesByBaseName.has(fileBaseName)) { filesByBaseName.set(fileBaseName, []); } // Store the entry with its actual extension for later matching filesByBaseName.get(fileBaseName).push({ ...entry, // Add a custom property to track the actual extension actualExtension }); } } // Check for the module in extension priority order const matchingFiles = filesByBaseName.get(moduleName); if (matchingFiles) { for (const ext of extensions) { for (const entry of matchingFiles) { const entryWithExt = entry; if (entryWithExt.actualExtension === ext) { const resolvedPath = joinPath(parentDir, entry.name); return portablePathToFileUrl(resolvedPath); } } } } // Look for directory with index files const directoryMatches = dirContents.filter(entry => entry.isDirectory && entry.name === moduleName); if (directoryMatches.length > 0) { const moduleDir = joinPath(parentDir, directoryMatches[0].name); try { const moduleDirContents = await readDirectory(portablePathToFileUrl(moduleDir)); // Look for index files in extension priority order // Create a map of baseName -> files for efficient lookup const indexFilesByBaseName = new Map(); for (const moduleFile of moduleDirContents) { if (moduleFile.isFile) { const fileName = moduleFile.name; let fileBaseName; let actualExtension; // Handle .d.ts files specially since getFileNameFromUrl returns .ts for index.d.ts if (fileName.endsWith('.d.ts')) { actualExtension = '.d.ts'; fileBaseName = fileName.substring(0, fileName.length - 5); // Remove .d.ts } else { const { extension: fileExt } = getFileNameFromUrl(fileName); actualExtension = fileExt; fileBaseName = fileName.substring(0, fileName.length - fileExt.length); } if (!indexFilesByBaseName.has(fileBaseName)) { indexFilesByBaseName.set(fileBaseName, []); } // Store the entry with its actual extension for later matching indexFilesByBaseName.get(fileBaseName).push({ ...moduleFile, actualExtension }); } } // Check for index files in extension priority order const indexFiles = indexFilesByBaseName.get('index'); if (indexFiles) { for (const ext of extensions) { for (const entry of indexFiles) { const entryWithExt = entry; if (entryWithExt.actualExtension === ext) { return portablePathToFileUrl(joinPath(moduleDir, entry.name)); } } } } } catch { // Could not read module directory, continue } } } catch { // Could not read parent directory } throw new Error(`Could not resolve module at path "${modulePath}". Tried extensions: ${extensions.join(', ')}`); } /** * Resolves multiple module paths efficiently by grouping them by directory * and performing batch directory lookups. * * @param modulePaths - Array of module paths to resolve (without file extensions) * @param readDirectory - Function to read directory contents * @param options - Configuration options * @returns Promise<Map<string, string>> - Map from input path to resolved file path */ export async function resolveModulePaths(modulePaths, readDirectory, options = {}) { const { extensions = JAVASCRIPT_MODULE_EXTENSIONS } = options; const results = new Map(); // Group paths by their parent directory const pathsByDirectory = new Map(); for (const modulePath of modulePaths) { const lastSlashIndex = modulePath.lastIndexOf('/'); const parentDir = modulePath.substring(0, lastSlashIndex); const moduleName = modulePath.substring(lastSlashIndex + 1); if (!pathsByDirectory.has(parentDir)) { pathsByDirectory.set(parentDir, []); } pathsByDirectory.get(parentDir).push({ fullPath: modulePath, moduleName }); } // Process each directory group const directoryEntries = Array.from(pathsByDirectory.entries()); const directoryResults = await Promise.all(directoryEntries.map(async ([parentDir, pathGroup]) => { try { // Read the directory contents once for all paths in this directory const dirContents = await readDirectory(portablePathToFileUrl(parentDir)); const unresolved = []; const resolved = []; // Look for direct file matches in extension priority order // Create a map of baseName -> files for efficient lookup const filesByBaseName = new Map(); for (const entry of dirContents) { if (entry.isFile) { const fileName = entry.name; const { extension: fileExt } = getFileNameFromUrl(fileName); const fileBaseName = fileName.substring(0, fileName.length - fileExt.length); if (!filesByBaseName.has(fileBaseName)) { filesByBaseName.set(fileBaseName, []); } filesByBaseName.get(fileBaseName).push(entry); } } // Check each module path against the file map for (const { fullPath, moduleName } of pathGroup) { let foundMatch = false; const matchingFiles = filesByBaseName.get(moduleName); if (matchingFiles) { for (const ext of extensions) { for (const entry of matchingFiles) { const { extension: entryExt } = getFileNameFromUrl(entry.name); if (entryExt === ext) { resolved.push({ fullPath, resolvedPath: portablePathToFileUrl(joinPath(parentDir, entry.name)) }); foundMatch = true; break; } } if (foundMatch) { break; } } } if (!foundMatch) { unresolved.push({ fullPath, moduleName }); } } // For unresolved paths, check if they are directories with index files if (unresolved.length > 0) { const directories = new Set(dirContents.filter(entry => entry.isDirectory).map(entry => entry.name)); const indexResults = await Promise.all(unresolved.map(async ({ fullPath, moduleName }) => { if (directories.has(moduleName)) { const moduleDir = joinPath(parentDir, moduleName); try { const moduleDirContents = await readDirectory(portablePathToFileUrl(moduleDir)); // Look for index files in extension priority order // Create a map of baseName -> files for efficient lookup const indexFilesByBaseName = new Map(); for (const moduleFile of moduleDirContents) { if (moduleFile.isFile) { const fileName = moduleFile.name; const { extension: fileExt } = getFileNameFromUrl(fileName); const fileBaseName = fileName.substring(0, fileName.length - fileExt.length); if (!indexFilesByBaseName.has(fileBaseName)) { indexFilesByBaseName.set(fileBaseName, []); } indexFilesByBaseName.get(fileBaseName).push(moduleFile); } } // Check for index files in extension priority order const indexFiles = indexFilesByBaseName.get('index'); if (indexFiles) { for (const ext of extensions) { for (const entry of indexFiles) { const { extension: entryExt } = getFileNameFromUrl(entry.name); if (entryExt === ext) { return { fullPath, resolvedPath: portablePathToFileUrl(joinPath(moduleDir, entry.name)) }; } } } } } catch { // Could not read module directory, leave unresolved } } return { fullPath, resolvedPath: null }; })); for (const { fullPath, resolvedPath } of indexResults) { if (resolvedPath) { resolved.push({ fullPath, resolvedPath }); } } } return resolved; } catch { // Could not read parent directory, return empty array return []; } })); // Collect all resolved paths for (const directoryResult of directoryResults) { for (const { fullPath, resolvedPath } of directoryResult) { results.set(fullPath, resolvedPath); } } return results; } /** * Resolves import result by separating JavaScript modules from static assets, * only resolving JavaScript modules and returning a combined map. * This function uses the new type-aware resolveModulePath function internally. * * @param importResult - The result from parseImports containing all imports * @param readDirectory - Function to read directory contents * @param options - Configuration options for module resolution * @returns Promise<Map<string, string>> - Map from import path to resolved file path */ export async function resolveImportResult(importResult, readDirectory, options = {}) { const resolvedPathsMap = new Map(); // Separate imports into categories for processing const jsModulesToResolve = []; const jsModulesWithExtensions = []; const staticAssets = []; for (const [importPath, { url, includeTypeDefs }] of Object.entries(importResult)) { if (UNSUPPORTED_ASSET_EXTENSIONS.some(ext => importPath.endsWith(ext))) { throw new Error(`Unsupported import extension: "${importPath}".`); } if (isStaticAsset(importPath)) { // Static asset - use url as-is staticAssets.push(url); } else if (JAVASCRIPT_MODULE_EXTENSIONS.some(ext => importPath.endsWith(ext))) { // If the import path already has a JS/TS extension, use it as-is jsModulesWithExtensions.push(url); } else { // Needs to be resolved jsModulesToResolve.push({ url, includeTypeDefs }); } } // Add modules with extensions as-is jsModulesWithExtensions.forEach(url => { resolvedPathsMap.set(url, url); }); // Add static assets as-is staticAssets.forEach(url => { resolvedPathsMap.set(url, url); }); // Resolve JS modules without extensions if (jsModulesToResolve.length > 0) { const resolutionPromises = jsModulesToResolve.map(async ({ url, includeTypeDefs }) => { try { const resolved = await resolveModulePath(url, readDirectory, options, includeTypeDefs); if (typeof resolved === 'string') { // Simple string result return { url, resolved }; } // Type-aware result - for now, just use the import path // TODO: We might want to store both paths in the future return { url, resolved: resolved.import }; } catch (error) { return null; // Mark as failed } }); const resolutionResults = await Promise.all(resolutionPromises); // Add successful resolutions to the map resolutionResults.forEach(result => { if (result) { resolvedPathsMap.set(result.url, result.resolved); } }); } return resolvedPathsMap; } /** * Resolves variant paths from a variants object mapping variant names to their file paths. * This function extracts the paths, resolves them using resolveModulePaths, and returns * a map from variant name to resolved file URL. * * @param variants - Object mapping variant names to their file paths * @param readDirectory - Function to read directory contents * @param options - Configuration options for module resolution * @returns Promise<Map<string, string>> - Map from variant name to resolved file URL */ export async function resolveVariantPaths(variants, readDirectory, options = {}) { // Extract the variant paths and resolve them const variantPaths = Object.values(variants); const resolvedVariantPaths = await resolveModulePaths(variantPaths, readDirectory, options); // Build a map from variant name to resolved file URL const variantMap = new Map(); for (const [variantName, variantPath] of Object.entries(variants)) { const resolvedVariantPath = resolvedVariantPaths.get(variantPath); if (resolvedVariantPath) { // Store as a file URL (portablePathToFileUrl handles portable paths correctly) variantMap.set(variantName, portablePathToFileUrl(resolvedVariantPath)); } } return variantMap; }