@mui/internal-docs-infra
Version:
MUI Infra - internal documentation creation tools.
342 lines (333 loc) • 12.3 kB
JavaScript
import { access } from 'node:fs/promises';
import path from 'node:path';
import { pathToFileURL } from 'node:url';
import { createJiti } from 'jiti';
const TYPES_LOADER = '@mui/internal-docs-infra/pipeline/loadPrecomputedTypes';
const CODE_HIGHLIGHTER_LOADER = '@mui/internal-docs-infra/pipeline/loadPrecomputedCodeHighlighter';
const TRANSFORM_METADATA_PLUGIN = '@mui/internal-docs-infra/pipeline/transformMarkdownMetadata';
const TRANSFORM_METADATA_PLUGIN_FUNCTION_NAME = 'transformMarkdownMetadata';
/**
* Reads useVisibleDescription from a remarkPlugins array.
*/
function extractUseVisibleDescriptionFromRemarkPlugins(remarkPlugins) {
if (!Array.isArray(remarkPlugins)) {
return undefined;
}
for (const entry of remarkPlugins) {
if (!Array.isArray(entry)) {
continue;
}
const plugin = entry[0];
const isPluginMatch = plugin === TRANSFORM_METADATA_PLUGIN || typeof plugin === 'function' && plugin.name === TRANSFORM_METADATA_PLUGIN_FUNCTION_NAME;
if (!isPluginMatch || !entry[1] || typeof entry[1] !== 'object') {
continue;
}
const pluginOptions = entry[1];
if (typeof pluginOptions.extractToIndex?.useVisibleDescription === 'boolean') {
return pluginOptions.extractToIndex.useVisibleDescription;
}
}
return undefined;
}
/**
* Extracts docs-infra options (ordering, descriptionReplacements, socketDir,
* useVisibleDescription) from loader options in a single pass.
*/
function extractOptionsFromLoaderEntries(loaders) {
const result = {};
for (const loader of loaders) {
if (typeof loader !== 'object') {
continue;
}
if (!result.ordering && loader.loader === TYPES_LOADER && loader.options?.ordering) {
result.ordering = loader.options.ordering;
}
if (!result.descriptionReplacements && loader.loader === TYPES_LOADER && loader.options?.descriptionReplacements) {
result.descriptionReplacements = loader.options.descriptionReplacements;
}
if (!result.socketDir && loader.loader === TYPES_LOADER && typeof loader.options?.socketDir === 'string') {
result.socketDir = loader.options.socketDir;
}
if (result.useVisibleDescription === undefined && loader.options?.remarkPlugins) {
const extracted = extractUseVisibleDescriptionFromRemarkPlugins(loader.options.remarkPlugins);
if (typeof extracted === 'boolean') {
result.useVisibleDescription = extracted;
}
}
}
return result;
}
/**
* Searches turbopack rules for docs-infra options (ordering,
* descriptionReplacements, socketDir, useVisibleDescription).
*/
function extractOptionsFromTurbopack(config) {
const rules = config?.turbopack?.rules;
if (!rules) {
return {};
}
const merged = {};
for (const rule of Object.values(rules)) {
const loaders = rule?.loaders;
if (!Array.isArray(loaders)) {
continue;
}
const extracted = extractOptionsFromLoaderEntries(loaders);
merged.ordering ??= extracted.ordering;
merged.descriptionReplacements ??= extracted.descriptionReplacements;
merged.useVisibleDescription ??= extracted.useVisibleDescription;
merged.socketDir ??= extracted.socketDir;
}
return merged;
}
/**
* Builds a mock webpack config rich enough to satisfy common patterns used by
* real `next.config` webpack functions (e.g. `config.resolve.extensions.filter`,
* `config.module.rules.forEach`, `config.externals.slice`). Real webpack passes
* an object with these properties populated, so a too-minimal mock causes
* configs to throw before we can read their rules.
*/
function createMockWebpackConfig() {
return {
module: {
rules: []
},
resolve: {
alias: {},
extensions: ['.mjs', '.js', '.jsx', '.ts', '.tsx', '.json'],
modules: [],
fallback: {}
},
plugins: [],
externals: [],
optimization: {},
output: {},
experiments: {}
};
}
/**
* Calls the webpack function with mock config + options pairs for both client
* and server builds, returning a merged config or `null` if both variants
* throw. Some Next.js configs only add loader rules when `options.isServer`
* is true, so we need to evaluate both branches.
*/
function callWebpackSafely(config) {
if (typeof config?.webpack !== 'function') {
return null;
}
const results = [];
for (const isServer of [false, true]) {
try {
results.push(config.webpack(createMockWebpackConfig(), {
defaultLoaders: {
babel: {}
},
isServer,
nextRuntime: isServer ? 'nodejs' : undefined,
dev: false,
buildId: 'docs-infra-validate',
config: {
env: {}
},
webpack: () => ({})
}));
} catch {
// try next variant
}
}
if (results.length === 0) {
return null;
}
const mergedRules = results.flatMap(result => Array.isArray(result?.module?.rules) ? result.module.rules : []);
return {
...results[0],
module: {
...(results[0]?.module ?? {}),
rules: mergedRules
}
};
}
/**
* Calls the webpack function with a minimal config and extracts docs-infra
* options (ordering, descriptionReplacements, socketDir, useVisibleDescription)
* from the resulting rules.
*/
function extractOptionsFromWebpackResult(result) {
const merged = {};
for (const rule of result?.module?.rules ?? []) {
const useEntries = Array.isArray(rule?.use) ? rule.use : [];
const extracted = extractOptionsFromLoaderEntries(useEntries);
merged.ordering ??= extracted.ordering;
merged.descriptionReplacements ??= extracted.descriptionReplacements;
merged.useVisibleDescription ??= extracted.useVisibleDescription;
merged.socketDir ??= extracted.socketDir;
}
return merged;
}
const NEXT_CONFIG_EXTENSIONS = ['.mjs', '.js', '.ts'];
/**
* Dynamically imports the next config from the given directory and extracts
* docs-infra options needed by validate.
*/
/**
* Walks Turbopack rules to collect demo patterns that opted into automatic
* `client.ts` generation via the `requireClient` option.
*/
function extractDemoClientRequirementsFromTurbopack(config) {
const rules = config?.turbopack?.rules;
if (!rules || typeof rules !== 'object') {
return [];
}
const requirements = [];
for (const [pattern, rule] of Object.entries(rules)) {
const loaders = rule?.loaders;
if (!Array.isArray(loaders)) {
continue;
}
for (const loader of loaders) {
if (loader?.loader === CODE_HIGHLIGHTER_LOADER && typeof loader?.options?.requireClient === 'string') {
requirements.push({
pattern,
requireClient: loader.options.requireClient
});
break;
}
}
}
return requirements;
}
/**
* Walks webpack rules to collect demo `test` regexes that opted into automatic
* `client.ts` generation via the `requireClient` option. Mirrors the Turbopack
* extractor but uses the rule's RegExp `test` as the pattern.
*/
function extractDemoClientRequirementsFromWebpackResult(result) {
const requirements = [];
for (const rule of result?.module?.rules ?? []) {
if (!(rule?.test instanceof RegExp)) {
continue;
}
const useEntries = Array.isArray(rule.use) ? rule.use : [];
for (const loader of useEntries) {
if (loader?.loader === CODE_HIGHLIGHTER_LOADER && typeof loader?.options?.requireClient === 'string') {
requirements.push({
pattern: rule.test,
requireClient: loader.options.requireClient
});
break;
}
}
}
return requirements;
}
/**
* Walks Turbopack rules to collect demo patterns that opted into automatic
* `page.tsx` generation via the `requirePage` option.
*
* Exported for tests.
*/
export function extractDemoPageRequirementsFromTurbopack(config) {
const rules = config?.turbopack?.rules;
if (!rules || typeof rules !== 'object') {
return [];
}
const requirements = [];
for (const [pattern, rule] of Object.entries(rules)) {
const loaders = rule?.loaders;
if (!Array.isArray(loaders)) {
continue;
}
for (const loader of loaders) {
if (loader?.loader === CODE_HIGHLIGHTER_LOADER && loader?.options?.requirePage === true) {
requirements.push({
pattern
});
break;
}
}
}
return requirements;
}
/**
* Walks webpack rules to collect demo `test` regexes that opted into automatic
* `page.tsx` generation via the `requirePage` option. Mirrors the Turbopack
* extractor but uses the rule's RegExp `test` as the pattern.
*
* Exported for tests.
*/
export function extractDemoPageRequirementsFromWebpackResult(result) {
const requirements = [];
for (const rule of result?.module?.rules ?? []) {
if (!(rule?.test instanceof RegExp)) {
continue;
}
const useEntries = Array.isArray(rule.use) ? rule.use : [];
for (const loader of useEntries) {
if (loader?.loader === CODE_HIGHLIGHTER_LOADER && loader?.options?.requirePage === true) {
requirements.push({
pattern: rule.test
});
break;
}
}
}
return requirements;
}
export async function extractDocsInfraOptionsFromNextConfig(dir) {
const configPath = await findNextConfig(dir);
if (!configPath) {
return {};
}
let config;
try {
if (configPath.endsWith('.ts')) {
// Use jiti so TypeScript configs (and their transitive .ts imports
// without extensions) load the same way Next.js itself loads them.
const jiti = createJiti(configPath, {
interopDefault: true
});
const configModule = await jiti.import(configPath);
config = configModule?.default ?? configModule;
} else {
const configModule = await import(pathToFileURL(configPath).href);
config = configModule.default;
}
} catch (error) {
// Surface the failure: a silently-swallowed import error here means
// demoClientRequirements (and other extracted options) end up empty,
// which usually presents to the user as `validate` doing nothing.
const message = error instanceof Error ? error.message : String(error);
console.warn(`[docs-infra] Failed to load ${path.relative(dir, configPath)} for option extraction: ${message}`);
return {};
}
const turbopack = extractOptionsFromTurbopack(config);
const webpackResult = callWebpackSafely(config);
const webpack = webpackResult ? extractOptionsFromWebpackResult(webpackResult) : {};
const turbopackDemoClientRequirements = extractDemoClientRequirementsFromTurbopack(config);
const webpackDemoClientRequirements = webpackResult ? extractDemoClientRequirementsFromWebpackResult(webpackResult) : [];
const demoClientRequirements = [...new Map([...turbopackDemoClientRequirements, ...webpackDemoClientRequirements].map(requirement => [`${typeof requirement.pattern === 'string' ? requirement.pattern : requirement.pattern.toString()}::${requirement.requireClient}`, requirement])).values()];
const turbopackDemoPageRequirements = extractDemoPageRequirementsFromTurbopack(config);
const webpackDemoPageRequirements = webpackResult ? extractDemoPageRequirementsFromWebpackResult(webpackResult) : [];
const demoPageRequirements = [...new Map([...turbopackDemoPageRequirements, ...webpackDemoPageRequirements].map(requirement => [typeof requirement.pattern === 'string' ? requirement.pattern : requirement.pattern.toString(), requirement])).values()];
return {
ordering: turbopack.ordering ?? webpack.ordering,
descriptionReplacements: turbopack.descriptionReplacements ?? webpack.descriptionReplacements,
useVisibleDescription: turbopack.useVisibleDescription ?? webpack.useVisibleDescription,
socketDir: turbopack.socketDir ?? webpack.socketDir,
demoClientRequirements: demoClientRequirements.length > 0 ? demoClientRequirements : undefined,
demoPageRequirements: demoPageRequirements.length > 0 ? demoPageRequirements : undefined
};
}
async function findNextConfig(dir) {
const checks = NEXT_CONFIG_EXTENSIONS.map(async ext => {
const configPath = path.join(dir, `next.config${ext}`);
try {
await access(configPath);
return configPath;
} catch {
return undefined;
}
});
const results = await Promise.all(checks);
return results.find(Boolean);
}