@mui/internal-docs-infra
Version:
MUI Infra - internal documentation creation tools.
217 lines (197 loc) • 9.32 kB
JavaScript
// webpack does not like node: imports
// eslint-disable-next-line n/prefer-node-protocol
import { readFile } from 'fs/promises';
// eslint-disable-next-line n/prefer-node-protocol
import path from 'path';
// eslint-disable-next-line n/prefer-node-protocol
import { fileURLToPath, pathToFileURL } from 'url';
import { parseCreateFactoryCall } from "../parseCreateFactoryCall/parseCreateFactoryCall.mjs";
import { generateResolvedExternals } from "./generateResolvedExternals.mjs";
import { loadIsomorphicCodeVariant } from "../loadIsomorphicCodeVariant/loadIsomorphicCodeVariant.mjs";
import { createLoadServerCodeSource } from "../loadServerCodeSource/index.mjs";
import { collectDeclaredNames } from "./collectDeclaredNames.mjs";
import { resolveVariantPathsWithFs } from "../loadServerCodeMeta/resolveModulePathWithFs.mjs";
import { getFileNameFromUrl } from "../loaderUtils/index.mjs";
import { mergeExternals } from "../loaderUtils/mergeExternals.mjs";
import { filterRuntimeExternals } from "./filterRuntimeExternals.mjs";
import { findServerOnlyExternals } from "./findServerOnlyExternals.mjs";
import { injectImportsIntoSource } from "./injectImportsIntoSource.mjs";
import { replacePrecomputeValue } from "../parseCreateFactoryCall/replacePrecomputeValue.mjs";
/**
* Webpack loader that processes demo client files and precomputes externals.
*
* Finds createDemoClient calls and injects all required externals as imports
* at the top of the file, then passes them to the function as precompute.externals.
*
* The pattern expected is: create*Client(import.meta.url, { options: true })
* The result will be: create*Client(import.meta.url, { options: true, precompute: { externals } })
*
* Automatically skips processing if skipPrecompute: true is set.
*/
export async function loadPrecomputedCodeHighlighterClient(source) {
const callback = this.async();
this.cacheable();
try {
// Convert the filesystem path to a file:// URL for cross-platform compatibility
// pathToFileURL handles Windows drive letters correctly (e.g., C:\... → file:///C:/...)
const resourceFileUrl = pathToFileURL(this.resourcePath).toString();
// Parse the source to find a single createDemoClient call
// Use metadataOnly mode since client calls only have (url, options?) arguments
const demoCall = await parseCreateFactoryCall(source, resourceFileUrl, {
metadataOnly: true
});
// If no createDemoClient call found, return the source unchanged
if (!demoCall) {
callback(null, source);
return;
}
// Only process client factory calls (functions with "Client" in the name)
if (!demoCall.functionName.includes('Client')) {
callback(null, source);
return;
}
// If skipPrecompute is true, return the source unchanged
if (demoCall.options.skipPrecompute) {
callback(null, source);
return;
}
// Load variant data for all variants to collect externals
const allDependencies = [];
const allExternalsArray = [];
// For client files, we need to read the corresponding index.ts to get variants
// The client.ts and index.ts should be in the same directory
const clientDir = path.dirname(this.resourcePath);
const indexPath = path.join(clientDir, 'index.ts');
// Convert to file:// URL for parseCreateFactoryCall
const indexFileUrl = pathToFileURL(indexPath).toString();
// Read and parse the index.ts file to get variant information
let indexDemoCall = null;
try {
const indexSource = await readFile(indexPath, 'utf-8');
// Add index.ts as a dependency for hot reloading
this.addDependency(indexPath);
indexDemoCall = await parseCreateFactoryCall(indexSource, indexFileUrl);
} catch (error) {
// If we can't read index.ts, we can't determine variants
console.warn(`Could not read ${indexPath} to determine variants for client: ${error}`);
callback(null, source);
return;
}
if (!indexDemoCall || !indexDemoCall.variants) {
console.warn(`No createDemo call or variants found in ${indexPath} for client processing`);
callback(null, source);
return;
}
// Use variants from the index.ts file
const resolvedVariantMap = await resolveVariantPathsWithFs(indexDemoCall.variants);
// Create loader functions
const loadSource = createLoadServerCodeSource({
includeDependencies: true,
storeAt: 'flat' // TODO: choose whichever is most performant as it shouldn't affect the output
});
// Process variants in parallel to collect externals
const variantPromises = Array.from(resolvedVariantMap.entries()).map(async ([variantName, fileUrl]) => {
const namedExport = indexDemoCall.namedExports?.[variantName];
let variant = fileUrl;
if (namedExport) {
const {
fileName
} = getFileNameFromUrl(variant);
if (!fileName) {
throw new Error(`Cannot determine fileName from URL "${variant}" for variant "${variantName}". ` + `Please ensure the URL has a valid file extension.`);
}
variant = {
url: fileUrl,
fileName,
namedExport
};
}
try {
// Use loadIsomorphicCodeVariant to collect all dependencies and externals
const {
dependencies,
externals
} = await loadIsomorphicCodeVariant(fileUrl,
// URL for the variant entry point (already includes file://)
variantName, variant, {
loadSource,
// For loading source files and dependencies
maxDepth: 5,
disableParsing: true,
disableTransforms: true
});
return {
variantName,
dependencies,
// All files that were loaded
externals // Combined externals from all loaded files
};
} catch (error) {
throw new Error(`Failed to load variant ${variantName} from ${fileUrl}: ${error}`);
}
});
const variantResults = await Promise.all(variantPromises);
// Process results and collect dependencies and externals
for (const result of variantResults) {
if (result) {
result.dependencies.forEach(file => {
allDependencies.push(file);
});
// Collect externals for proper merging
allExternalsArray.push(result.externals);
}
}
// Properly merge externals from all variants
const allExternals = mergeExternals(allExternalsArray);
// Bail out if any external is server-only (e.g. `fs`, `node:*`, or the
// `server-only` poison package). This check runs against the unfiltered
// externals so that side-effect imports like `import 'server-only';` —
// which have no bound names and would be dropped by `filterRuntimeExternals`
// below — are still detected. Inlining server-only modules into the client
// bundle would either fail at build time or leak server code, so we leave
// the source untouched. The downstream `abstractCreateDemoClient` factory
// will then see no `precompute.externals` and skip wrapping with a provider.
const serverOnlyModules = findServerOnlyExternals(allExternals);
if (serverOnlyModules.length > 0) {
// Still register watched dependencies so HMR works while the user fixes
// the demo, but don't modify the source.
allDependencies.forEach(dep => {
this.addDependency(dep.startsWith('file://') ? fileURLToPath(dep) : dep);
});
callback(null, source);
return;
}
// Filter out type-only imports since they don't exist at runtime
const runtimeExternals = filterRuntimeExternals(allExternals);
// Generate import statements and resolved externals object. Seed the
// conflict resolver with identifiers already declared in the source so
// injected imports get aliased rather than shadowing existing bindings.
const existingNames = collectDeclaredNames(source);
const {
imports: importLines,
resolvedExternals
} = generateResolvedExternals(runtimeExternals, existingNames);
// Add externals argument to the createDemoClient call using replacePrecomputeValue first
// (before injecting imports, so the original positions are still valid)
// with passPrecomputeAsIs enabled so externals are passed as resolved objects
const precomputeData = {
externals: resolvedExternals
};
let modifiedSource = replacePrecomputeValue(source, precomputeData, demoCall, {
passPrecomputeAsIs: true
});
// Then inject imports at the top of the file (after 'use client' if present)
modifiedSource = injectImportsIntoSource(modifiedSource, importLines);
// Add all dependencies to webpack's watch list
allDependencies.forEach(dep => {
// Convert file:// URLs to proper file system paths for webpack's dependency tracking
// Using fileURLToPath handles Windows drive letters correctly (e.g., file:///C:/... → C:\...)
this.addDependency(dep.startsWith('file://') ? fileURLToPath(dep) : dep);
});
callback(null, modifiedSource);
} catch (error) {
callback(error instanceof Error ? error : new Error(String(error)));
}
}
// Default export for webpack loader
export default loadPrecomputedCodeHighlighterClient;