UNPKG

@mui/internal-docs-infra

Version:

MUI Infra - internal documentation creation tools.

498 lines (457 loc) 17.4 kB
/** * Export variant functionality to add extra files like package.json, tsconfig, etc. * Users can pass configuration options that vary the output here. */ import { externalsToPackages } from "../pipeline/loaderUtils/index.mjs"; import { getFileNameFromUrl } from "../pipeline/loaderUtils/getFileNameFromUrl.mjs"; import { examineCodeVariant } from "../pipeline/loadIsomorphicCodeVariant/examineCodeVariant.mjs"; import { decodeSource } from "../pipeline/loadIsomorphicCodeVariant/decodeSource.mjs"; import { mergeCodeMetadata, extractCodeMetadata } from "../pipeline/loadIsomorphicCodeVariant/mergeCodeMetadata.mjs"; /** * Merges multiple file objects into a single object. * Similar to mergeExternals but for file structures. * Automatically adds metadata: false to files that don't have a metadata property. */ function mergeFiles(...fileSets) { const merged = {}; for (const fileSet of fileSets) { for (const [fileName, fileData] of Object.entries(fileSet)) { // Later files override earlier ones (similar to Object.assign behavior) const normalizedData = typeof fileData === 'string' ? { source: fileData } : { ...fileData }; // Add metadata: false if not already set (source files default to false) if (!('metadata' in normalizedData)) { normalizedData.metadata = false; } merged[fileName] = normalizedData; } } return merged; } /** * Decode every `source` in an extra-files map to `string | HastRoot` (never a * serialized `hastJson` / `hastCompressed` payload), reusing the shared decode * cache. Each file's own `fallback` is the DEFLATE dictionary for a * `hastCompressed` source. URL-only string entries are passed through untouched. */ function decodeExtraFilesSources(files) { const decoded = {}; for (const [name, fileData] of Object.entries(files)) { if (typeof fileData === 'string' || fileData.source === undefined) { decoded[name] = fileData; } else { decoded[name] = { ...fileData, source: decodeSource(fileData.source, fileData.fallback) }; } } return decoded; } /** * Decode a variant's main `source` and all of its extra-file sources to * `string | HastRoot` (see {@link decodeExtraFilesSources}), so downstream * consumers — most notably the user-supplied `transformVariant` hook — never * have to handle serialized `hastCompressed` / `hastJson` payloads or thread * their dictionaries. */ function decodeVariantSources(variant) { const decoded = { ...variant }; if (variant.source !== undefined) { decoded.source = decodeSource(variant.source, variant.fallback); } if (variant.extraFiles) { decoded.extraFiles = decodeExtraFilesSources(variant.extraFiles); } return decoded; } /** * Extract filename from URL or return undefined if not available */ export function getFilenameFromVariant(variantCode) { if (variantCode.fileName) { return variantCode.fileName; } if (variantCode.url) { const { fileName } = getFileNameFromUrl(variantCode.url); return fileName || undefined; } return undefined; } /** * Generate a unique entrypoint filename that doesn't conflict with existing files */ export function generateEntrypointFilename(existingFiles, sourceFilename, useTypescript, pathPrefix = '') { const ext = useTypescript ? 'tsx' : 'jsx'; const candidates = [`${pathPrefix}App.${ext}`, `${pathPrefix}entrypoint.${ext}`, `${pathPrefix}main.${ext}`]; // If we have a source filename, also try variations if (sourceFilename) { const baseName = sourceFilename.replace(/\.[^.]*$/, ''); candidates.push(`${pathPrefix}${baseName}-entry.${ext}`); } for (const candidate of candidates) { if (candidate !== `${pathPrefix}${sourceFilename}` && !existingFiles[candidate]) { return candidate; } } // Generate with hash if all candidates are taken const hash = Math.random().toString(36).substring(2, 8); return `${pathPrefix}entrypoint-${hash}.${ext}`; } /** * Generate the relative import path from entrypoint to source file */ export function getRelativeImportPath(sourceFilename) { if (!sourceFilename) { return './App'; // Default fallback } // Remove extension for import const baseName = sourceFilename.replace(/\.[^.]*$/, ''); return `./${baseName}`; } /** * Default HTML template function for Vite-based demos */ export function defaultHtmlTemplate({ language, title, description, head, entrypoint }) { return `<!DOCTYPE html> <html lang="${language}"> <head> <meta charset="utf-8" /> <title>${title}</title> ${description ? `<meta name="description" content="${description}" />` : ''} <meta name="viewport" content="initial-scale=1, width=device-width" />${head ? `\n ${head.split('\n').join('\n ')}` : ''} </head> <body> <div id="root"></div>${entrypoint ? `\n <script type="module" src="${entrypoint}"></script>` : ''} </body> </html> `; } /** * Export a variant as a standalone project with metadata files properly scoped */ export function exportVariant(variantCode, config = {}) { const { title = 'Demo', titlePrefix, titleSuffix, description = 'Demo created with Vite', descriptionPrefix, descriptionSuffix, variantName, language = 'en', htmlPrefix = '', sourcePrefix = 'src/', assetPrefix = '', frameworkHandlesEntrypoint = false, htmlSkipJsLink = false, htmlTemplate, headTemplate, rootIndexTemplate, dependencies = {}, devDependencies = {}, scripts = {}, packageType, packageJsonFields = {}, tsconfigOptions = {}, viteConfig = {}, useTypescript = false, extraMetadataFiles = {}, frameworkFiles = {}, transformVariant, versions = {}, resolveDependencies } = config; // Build final title and description with prefixes and suffixes const finalTitle = [titlePrefix, title, titleSuffix].filter(Boolean).join(''); const finalDescription = [descriptionPrefix, description, descriptionSuffix].filter(Boolean).join(''); // Use extractCodeMetadata to properly separate metadata and non-metadata files let { variant: processedVariantCode, metadata: processedGlobals } = extractCodeMetadata(variantCode); // Run optional transform hook to modify variant and globals before processing. if (transformVariant) { // Decode sources before handing them to the hook so it only ever sees a // plain string or a live `HastRoot`, never a serialized `hastJson` / // `hastCompressed` payload. Precomputed / SSR variants can still carry // serialized sources here; the co-located `fallback` is the dictionary // needed to decode them. `decodeSource` reuses the shared decode cache (so a // source already decoded for rendering is not inflated again) and clones the // tree so the hook owns what it receives. Decoding lazily here keeps it off // the no-transform path, where `flattenCodeVariant` decodes at the end. const decodedVariant = decodeVariantSources(processedVariantCode); const decodedGlobals = decodeExtraFilesSources(processedGlobals); const transformed = transformVariant(decodedVariant, variantName, decodedGlobals); if (transformed) { // Re-extract metadata after transformation const result = transformed.variant && extractCodeMetadata(transformed.variant); processedVariantCode = result?.variant || decodedVariant; // Start fresh with only the new metadata and explicitly transformed globals // Do NOT merge with the original processedGlobals to avoid duplication processedGlobals = { ...result?.metadata, ...transformed.globals }; } } // If packageType is explicitly provided (even as undefined), use that value let finalPackageType; if ('packageType' in config) { finalPackageType = packageType; } else { finalPackageType = !Object.keys(frameworkFiles).length ? 'module' : undefined; } // Get existing extraFiles and source filename const sourceFilename = getFilenameFromVariant(processedVariantCode); // Get path context to understand navigation const pathContext = examineCodeVariant(variantCode); // Determine if we need to rename the source file const ext = useTypescript ? 'tsx' : 'jsx'; const isSourceFileIndex = sourceFilename === `index.${ext}`; const hasBackNavigation = pathContext.maxSourceBackNavigation > 0; let actualSourceFilename = sourceFilename; // Use urlDirectory to construct the full path from src root const directoryPath = pathContext.urlDirectory.slice(1).join('/'); // Remove 'src' and join the rest let actualRootFile = directoryPath ? `${sourcePrefix}${directoryPath}/${sourceFilename}` : `${sourcePrefix}${sourceFilename}`; // If the source file is index.tsx and it's in the src root, we need to rename it if (isSourceFileIndex && !hasBackNavigation) { actualSourceFilename = generateEntrypointFilename(processedVariantCode.extraFiles || {}, sourceFilename, useTypescript); // When renaming due to conflicts, place the file in src root regardless of original location actualRootFile = `${sourcePrefix}${actualSourceFilename}`; } // The main entrypoint is always src/index.tsx (or .jsx) const mainEntrypointFilename = `index.${ext}`; const entrypoint = !htmlSkipJsLink ? `${sourcePrefix}${mainEntrypointFilename}` : undefined; // Get relative import path for the main component let importPath; if (!hasBackNavigation) { // Component is in src root - import directly importPath = getRelativeImportPath(actualSourceFilename); } else { // Component is in a subdirectory - import with full path from src root const componentPath = directoryPath ? `${directoryPath}/${actualSourceFilename}` : actualSourceFilename; importPath = `./${(componentPath || '').replace(/\.[^.]*$/, '')}`; // Remove extension } // Strip /index from the end of import paths since module resolution handles it automatically if (importPath.endsWith('/index')) { importPath = importPath.slice(0, -6); // Remove '/index' } const importString = processedVariantCode.namedExport ? `import { ${processedVariantCode.namedExport} as App } from '${importPath}';` : `import App from '${importPath}';`; // Collect all files that will be generated const generatedFiles = {}; // Update the variant's fileName if we renamed it if (isSourceFileIndex && !hasBackNavigation && actualSourceFilename && actualSourceFilename !== sourceFilename) { processedVariantCode.fileName = actualSourceFilename; } // Check if they're providing their own framework const isFramework = 'frameworkFiles' in config; const externalPackages = externalsToPackages(processedVariantCode.externals || []); const variantDeps = Object.keys(externalPackages).reduce((acc, pkg) => { // Check if we have a specific version for this package first if (versions[pkg]) { acc[pkg] = versions[pkg]; } else if (resolveDependencies) { const resolvedDeps = resolveDependencies(pkg); Object.assign(acc, resolvedDeps); } else { // Simple fallback: just use 'latest' for each package acc[pkg] = 'latest'; } return acc; }, {}); // Collect metadata files to be generated const metadataFiles = {}; // Generate package.json const packageJson = { private: true, name: finalTitle.toLowerCase().replace(/[^a-z0-9]/g, '-'), version: '0.0.0', description: finalDescription, ...(finalPackageType && { type: finalPackageType }), // Add type if specified scripts: { ...(!isFramework && { dev: 'vite', build: 'vite build', preview: 'vite preview' }), ...scripts }, dependencies: { react: versions.react || 'latest', 'react-dom': versions['react-dom'] || 'latest', ...variantDeps, ...dependencies }, devDependencies: { ...(!isFramework && { // Pinned to major versions instead of `latest` to work around // https://github.com/stackblitz/webcontainer-core/issues/2104 '@vitejs/plugin-react': '^5', vite: '^7' }), ...(useTypescript && { typescript: 'latest', '@types/react': versions['@types/react'] || 'latest', '@types/react-dom': versions['@types/react-dom'] || 'latest' }), ...devDependencies }, ...packageJsonFields }; metadataFiles['package.json'] = { source: `${JSON.stringify(packageJson, null, 2)}\n` }; // Generate entrypoint and HTML files unless framework handles them if (!frameworkHandlesEntrypoint) { // Create entrypoint file that imports the main component const defaultEntrypointContent = `import * as React from 'react'; import * as ReactDOM from 'react-dom/client'; ${importString} ReactDOM.createRoot(document.getElementById('root')${useTypescript ? '!' : ''}).render( <React.StrictMode> <App /> </React.StrictMode> ); `; const entrypointContent = rootIndexTemplate ? rootIndexTemplate({ importString, useTypescript }) : defaultEntrypointContent; generatedFiles[mainEntrypointFilename] = { source: entrypointContent }; } // Add Vite config file only if no framework files (Vite-specific) if (!isFramework) { const viteConfigContent = `import { defineConfig } from 'vite'; import react from '@vitejs/plugin-react'; // https://vitejs.dev/config/ export default defineConfig({ plugins: [react()], define: { 'process.env': {} }, ...${JSON.stringify(viteConfig, null, 2).split('\n').join('\n ')} }); `; metadataFiles[`vite.config.${useTypescript ? 'ts' : 'js'}`] = { source: viteConfigContent }; } // Add TypeScript configuration if requested if (useTypescript) { // Check if frameworkFiles already includes a tsconfig const hasFrameworkTsConfig = frameworkFiles?.globals && Object.keys(frameworkFiles.globals).some(fileName => fileName.includes('tsconfig.json') && !fileName.includes('tsconfig.node.json')); if (!hasFrameworkTsConfig) { // Main tsconfig.json (default Vite config) const defaultTsConfig = { compilerOptions: { target: 'ES2020', useDefineForClassFields: true, lib: ['ES2020', 'DOM', 'DOM.Iterable'], module: 'ESNext', skipLibCheck: true, moduleResolution: 'bundler', allowImportingTsExtensions: true, resolveJsonModule: true, isolatedModules: true, noEmit: true, jsx: 'react-jsx', strict: true, noUnusedLocals: true, noUnusedParameters: true, noFallthroughCasesInSwitch: true, ...tsconfigOptions }, include: ['src'], ...(!isFramework && { references: [{ path: './tsconfig.node.json' }] }) }; metadataFiles['tsconfig.json'] = { source: `${JSON.stringify(defaultTsConfig, null, 2)}\n` }; } // Only add tsconfig.node.json for Vite (not for framework files) if (!isFramework) { // Node tsconfig for Vite config const nodeTsConfig = { compilerOptions: { composite: true, skipLibCheck: true, module: 'ESNext', moduleResolution: 'bundler', allowSyntheticDefaultImports: true }, include: ['vite.config.ts'] }; metadataFiles['tsconfig.node.json'] = { source: `${JSON.stringify(nodeTsConfig, null, 2)}\n` }; } } // Generate HTML file after all files are ready if (!frameworkHandlesEntrypoint) { // Add index.html const headContent = headTemplate ? headTemplate({ sourcePrefix, assetPrefix, variant: processedVariantCode, variantName }) : undefined; const htmlContent = htmlTemplate ? htmlTemplate({ language, title: finalTitle, description: finalDescription, head: headContent, entrypoint, variant: processedVariantCode, variantName }) : defaultHtmlTemplate({ language, title: finalTitle, description: finalDescription, head: headContent, entrypoint }); const htmlFileName = htmlPrefix ? `${htmlPrefix}index.html` : 'index.html'; metadataFiles[htmlFileName] = { source: htmlContent }; } // Merge all metadata files including framework metadata and globals const allMetadataFiles = mergeFiles(processedGlobals || {}, metadataFiles, extraMetadataFiles, frameworkFiles.globals || {}); // Merge all files using mergeCodeMetadata to properly position everything with 'src/' (sourcePrefix opt) prefix const allSourceFilesWithFramework = mergeFiles(processedVariantCode.extraFiles || {}, generatedFiles, frameworkFiles.variant?.extraFiles || {}); // Update the variant with all source files including framework source files const finalVariantWithSources = { ...processedVariantCode, extraFiles: allSourceFilesWithFramework }; // Use mergeCodeMetadata to position everything correctly const finalVariant = mergeCodeMetadata(finalVariantWithSources, allMetadataFiles, { metadataPrefix: sourcePrefix }); // Return new VariantCode with properly positioned files return { exported: finalVariant, rootFile: actualRootFile }; }