UNPKG

@mui/internal-docs-infra

Version:

MUI Infra - internal documentation creation tools.

241 lines (231 loc) 9.17 kB
import { readFile, writeFile } from 'node:fs/promises'; import path from 'node:path'; import { parseCreateFactoryCall } from "../pipeline/parseCreateFactoryCall/parseCreateFactoryCall.mjs"; import { serializeFunctionArguments } from "../pipeline/parseCreateFactoryCall/serializeFunctionArguments.mjs"; import { findDemoIndexFiles } from "./findDemoIndexFiles.mjs"; import { fileExists, formatWithPrettier } from "./fileUtils/index.mjs"; const CLIENT_FILE_NAME = 'client.ts'; const CLIENT_PROVIDER_IDENTIFIER = 'ClientProvider'; const CLIENT_RELATIVE_IMPORT = './client'; /** * Generates the contents for an auto-created demo `client.ts`. * Exported for tests and reuse. */ export function generateClientFileContent(requireClient) { const escaped = requireClient.replace(/\\/g, '\\\\').replace(/'/g, "\\'"); return [`'use client';`, ``, `import { createDemoClient } from '${escaped}';`, ``, `const ${CLIENT_PROVIDER_IDENTIFIER} = createDemoClient(import.meta.url);`, ``, `export default ${CLIENT_PROVIDER_IDENTIFIER};`, ``].join('\n'); } /** * Returns the import specifier to use inside an auto-generated `client.ts` for * a given requirement. * * - Bare specifiers (e.g. `@/foo`, `package/x`) pass through unchanged. * - Relative specifiers (`./foo`, `../foo`) are resolved against `configDir` * and rewritten to be relative to `clientDir`, ensuring the generated file * imports the same module regardless of how deep it sits in the workspace. */ export function resolveRequireClientSpecifier(requireClient, configDir, clientDir) { if (!requireClient.startsWith('./') && !requireClient.startsWith('../')) { return requireClient; } const absoluteTarget = path.resolve(configDir, requireClient); let rewritten = path.relative(clientDir, absoluteTarget); if (path.sep !== '/') { rewritten = rewritten.split(path.sep).join('/'); } if (!rewritten.startsWith('.')) { rewritten = `./${rewritten}`; } return rewritten; } /** * Result of mutating an `index.ts` file. */ /** * Patches a demo `index.ts` so it imports `ClientProvider` from `./client` * and passes it through to the `create*` factory call. * * Reuses the same `parseCreateFactoryCall` + `serializeFunctionArguments` * helpers the precomputed code highlighter loader uses, so quirky source * (trailing commas, comments containing `create*` tokens, multi-line option * objects) is parsed structurally instead of via ad-hoc regexes. * * Exported for tests. */ export async function addClientProviderToIndex(source, filePath) { let parsed; try { parsed = await parseCreateFactoryCall(source, filePath, { allowExternalVariants: true, allowMultipleFactories: true }); } catch { // Structural problems (missing imports, malformed args, etc.) are surfaced // by the regular pipeline. Skip the file here so we don't mangle it further. return { content: null }; } if (!parsed) { return { content: null }; } const { argumentsStartIndex, argumentsEndIndex, structuredUrl, structuredVariants, structuredOptions, importsAndComments } = parsed; const hasMetaProperty = !!(structuredOptions && Object.prototype.hasOwnProperty.call(structuredOptions, CLIENT_PROVIDER_IDENTIFIER)); const hasImport = hasClientProviderImport(importsAndComments); if (hasImport && hasMetaProperty) { return { content: null }; } let next = source; if (!hasMetaProperty) { // Preserve existing options order, then append `ClientProvider` as a shorthand. // `serializeObject` emits shorthand when the value matches the key, so passing // the identifier name as a string produces `{ ..., ClientProvider }`. const newOptions = { ...(structuredOptions ?? {}) }; newOptions[CLIENT_PROVIDER_IDENTIFIER] = CLIENT_PROVIDER_IDENTIFIER; const args = structuredVariants !== undefined ? [structuredUrl, structuredVariants, newOptions] : [structuredUrl, newOptions]; const serialized = serializeFunctionArguments(args); next = `${next.slice(0, argumentsStartIndex)}${serialized}${next.slice(argumentsEndIndex)}`; } if (!hasImport) { next = insertClientProviderImport(next, importsAndComments); } return { content: next === source ? null : next }; } /** * Returns true when the source already pulls in a default-export `ClientProvider` * from `./client` (the only form the generated `client.ts` produces). */ function hasClientProviderImport(importsAndComments) { if (!importsAndComments) { return false; } for (const entry of Object.values(importsAndComments.relative)) { if (!entry.url.endsWith('/client') && !entry.url.endsWith('/client.ts')) { continue; } if (entry.names.some(name => name.type === 'default' && name.name === CLIENT_PROVIDER_IDENTIFIER)) { return true; } } return false; } /** * Inserts `import ClientProvider from './client';` on its own line right after the * last existing import statement. Falls back to the top of the file (after a * leading directive like `'use client'`) when no imports exist. */ function insertClientProviderImport(source, importsAndComments) { const importLine = `import ${CLIENT_PROVIDER_IDENTIFIER} from '${CLIENT_RELATIVE_IMPORT}';`; // Find the rightmost path-quote position across all imports the parser saw. let lastPathEnd = -1; if (importsAndComments) { for (const entry of Object.values(importsAndComments.relative)) { for (const pos of entry.positions) { if (pos.end > lastPathEnd) { lastPathEnd = pos.end; } } } for (const entry of Object.values(importsAndComments.externals)) { for (const pos of entry.positions) { if (pos.end > lastPathEnd) { lastPathEnd = pos.end; } } } } if (lastPathEnd === -1) { // No imports — insert after a leading directive if present, otherwise at the top. const directiveMatch = source.match(/^['"][^'"\n]+['"]\s*;?\s*\n/); const insertAt = directiveMatch ? directiveMatch[0].length : 0; const needsLeadingNewline = insertAt > 0 && source[insertAt - 1] !== '\n'; return `${source.slice(0, insertAt)}${needsLeadingNewline ? '\n' : ''}${importLine}\n\n${source.slice(insertAt)}`; } // Walk forward past an optional `;` and to the end of the current line so the // new import lands on its own line right after the existing statement. let cursor = lastPathEnd; while (cursor < source.length && source[cursor] !== '\n') { cursor += 1; } return `${source.slice(0, cursor)}\n${importLine}${source.slice(cursor)}`; } /** * Ensures every demo `index.ts` matched by the configured demo patterns has a * sibling `client.ts` and that the `index.ts` wires it up via `ClientProvider`. * * Returns the list of files that were created or modified, plus any errors * encountered. */ export async function ensureDemoClients(options) { const { baseDir, configDir = baseDir, requirements } = options; if (requirements.length === 0) { return { demoCount: 0, updatedFiles: [], errors: [] }; } // Group requirements by import specifier so each pattern uses its declared value. const patterns = requirements.map(entry => entry.pattern); const requireClientByPattern = new Map(requirements.map(entry => [entry.pattern, entry.requireClient])); const indexFiles = await findDemoIndexFiles(baseDir, patterns); const updatedFiles = []; const errors = []; await Promise.all(Array.from(indexFiles.entries()).map(async ([indexPath, pattern]) => { const requireClient = requireClientByPattern.get(pattern); if (!requireClient) { return; } try { const dir = path.dirname(indexPath); const clientPath = path.join(dir, CLIENT_FILE_NAME); const [clientExists, indexSource] = await Promise.all([fileExists(clientPath), readFile(indexPath, 'utf-8')]); // Only generate the client.ts when it does not yet exist. Existing files // are left alone so developers can customise the import path or wrap the // ClientProvider with additional logic. if (!clientExists) { const resolvedSpecifier = resolveRequireClientSpecifier(requireClient, configDir, dir); const generated = generateClientFileContent(resolvedSpecifier); const expectedClientContent = await formatWithPrettier(generated, clientPath); await writeFile(clientPath, expectedClientContent, 'utf-8'); updatedFiles.push(path.relative(baseDir, clientPath)); } const update = await addClientProviderToIndex(indexSource, indexPath); if (update.content !== null) { const formatted = await formatWithPrettier(update.content, indexPath); await writeFile(indexPath, formatted, 'utf-8'); updatedFiles.push(path.relative(baseDir, indexPath)); } } catch (error) { errors.push({ filePath: path.relative(baseDir, indexPath), message: error?.message ?? String(error) }); } })); updatedFiles.sort(); return { demoCount: indexFiles.size, updatedFiles, errors }; }