@mui/internal-docs-infra
Version:
MUI Infra - internal documentation creation tools.
498 lines (457 loc) • 17.4 kB
JavaScript
/**
* 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
};
}