UNPKG

@mui/internal-docs-infra

Version:

MUI Infra - internal documentation creation tools.

342 lines (333 loc) 12.3 kB
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); }