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