Some checks failed
No response / noResponse (push) Has been cancelled
CI / Continuous releases (push) Has been cancelled
CI / test-dev (macos-latest) (push) Has been cancelled
CI / test-dev (ubuntu-latest) (push) Has been cancelled
CI / test-dev (windows-latest) (push) Has been cancelled
Maintenance / main (push) Has been cancelled
393 lines
12 KiB
TypeScript
393 lines
12 KiB
TypeScript
/* eslint-disable no-console */
|
|
import * as path from 'path';
|
|
import * as fs from 'node:fs/promises';
|
|
import * as prettier from 'prettier';
|
|
import glob from 'fast-glob';
|
|
import { flatten } from 'es-toolkit/array';
|
|
import yargs from 'yargs';
|
|
import { hideBin } from 'yargs/helpers';
|
|
import type { LiteralType } from '@mui/internal-scripts/typescript-to-proptypes';
|
|
import {
|
|
fixBabelGeneratorIssues,
|
|
fixLineEndings,
|
|
getUnstyledFilename,
|
|
} from '@mui/internal-docs-utils';
|
|
import {
|
|
getPropTypesFromFile,
|
|
injectPropTypesInFile,
|
|
InjectPropTypesInFileOptions,
|
|
} from '@mui/internal-scripts/typescript-to-proptypes';
|
|
import {
|
|
createTypeScriptProjectBuilder,
|
|
TypeScriptProject,
|
|
} from '@mui-internal/api-docs-builder/utils/createTypeScriptProject';
|
|
|
|
import CORE_TYPESCRIPT_PROJECTS from './coreTypeScriptProjects';
|
|
|
|
const useExternalPropsFromInputBase = [
|
|
'autoComplete',
|
|
'autoFocus',
|
|
'color',
|
|
'defaultValue',
|
|
'disabled',
|
|
'endAdornment',
|
|
'error',
|
|
'id',
|
|
'inputProps',
|
|
'inputRef',
|
|
'margin',
|
|
'maxRows',
|
|
'minRows',
|
|
'name',
|
|
'onChange',
|
|
'placeholder',
|
|
'readOnly',
|
|
'required',
|
|
'rows',
|
|
'startAdornment',
|
|
'value',
|
|
];
|
|
|
|
/**
|
|
* A map of components and their props that should be documented
|
|
* but are not used directly in their implementation.
|
|
*
|
|
* TODO: In the future we want to remove them from the API docs in favor
|
|
* of dynamically loading them. At that point this list should be removed.
|
|
* TODO: typecheck values
|
|
*/
|
|
const useExternalDocumentation: Record<string, '*' | readonly string[]> = {
|
|
Button: ['disableRipple'],
|
|
Box: ['component', 'sx'],
|
|
// `classes` is always external since it is applied from a HOC
|
|
// In DialogContentText we pass it through
|
|
// Therefore it's considered "unused" in the actual component but we still want to document it.
|
|
DialogContentText: ['classes'],
|
|
FilledInput: useExternalPropsFromInputBase,
|
|
IconButton: ['disableRipple'],
|
|
Input: useExternalPropsFromInputBase,
|
|
MenuItem: ['dense'],
|
|
OutlinedInput: useExternalPropsFromInputBase,
|
|
Radio: ['disableRipple', 'id', 'inputProps', 'inputRef', 'required'],
|
|
Checkbox: ['defaultChecked'],
|
|
Container: ['component'],
|
|
Stack: ['component'],
|
|
Switch: [
|
|
'checked',
|
|
'defaultChecked',
|
|
'disabled',
|
|
'disableRipple',
|
|
'edge',
|
|
'id',
|
|
'inputProps',
|
|
'inputRef',
|
|
'onChange',
|
|
'required',
|
|
'value',
|
|
],
|
|
SwipeableDrawer: [
|
|
'anchor',
|
|
'hideBackdrop',
|
|
'ModalProps',
|
|
'PaperProps',
|
|
'transitionDuration',
|
|
'variant',
|
|
],
|
|
Tab: ['disableRipple'],
|
|
TextField: ['margin'],
|
|
ToggleButton: ['disableRipple'],
|
|
};
|
|
const transitionCallbacks = [
|
|
'onEnter',
|
|
'onEntered',
|
|
'onEntering',
|
|
'onExit',
|
|
'onExiting',
|
|
'onExited',
|
|
];
|
|
/**
|
|
* These are components that use props implemented by external components.
|
|
* Those props have their own JSDoc which we don't want to emit in our docs
|
|
* but do want them to have JSDoc in IntelliSense
|
|
* TODO: In the future we want to ignore external docs on the initial load anyway
|
|
* since they will be fetched dynamically.
|
|
*/
|
|
const ignoreExternalDocumentation: Record<string, readonly string[]> = {
|
|
Button: ['focusVisibleClassName', 'type'],
|
|
Collapse: transitionCallbacks,
|
|
CardActionArea: ['focusVisibleClassName'],
|
|
AccordionSummary: ['onFocusVisible'],
|
|
Dialog: ['BackdropProps'],
|
|
Drawer: ['BackdropProps'],
|
|
Fab: ['focusVisibleClassName'],
|
|
Fade: transitionCallbacks,
|
|
Grow: transitionCallbacks,
|
|
ListItem: ['focusVisibleClassName'],
|
|
InputBase: ['aria-describedby'],
|
|
Menu: ['PaperProps'],
|
|
MenuItem: ['disabled'],
|
|
Slide: transitionCallbacks,
|
|
SwipeableDrawer: ['anchor', 'hideBackdrop', 'ModalProps', 'PaperProps', 'variant'],
|
|
TextField: ['hiddenLabel'],
|
|
Zoom: transitionCallbacks,
|
|
};
|
|
|
|
function sortSizeByScaleAscending(a: LiteralType, b: LiteralType) {
|
|
const sizeOrder: readonly unknown[] = ['"small"', '"medium"', '"large"'];
|
|
return sizeOrder.indexOf(a.value) - sizeOrder.indexOf(b.value);
|
|
}
|
|
|
|
// Custom order of literal unions by component
|
|
const getSortLiteralUnions: InjectPropTypesInFileOptions['getSortLiteralUnions'] = (
|
|
component,
|
|
propType,
|
|
) => {
|
|
if (propType.name === 'size') {
|
|
return sortSizeByScaleAscending;
|
|
}
|
|
|
|
return undefined;
|
|
};
|
|
|
|
async function generateProptypes(
|
|
project: TypeScriptProject,
|
|
sourceFile: string,
|
|
tsFile: string,
|
|
): Promise<void> {
|
|
const components = getPropTypesFromFile({
|
|
filePath: tsFile,
|
|
project,
|
|
shouldResolveObject: ({ name }) => {
|
|
if (
|
|
name.toLowerCase().endsWith('classes') ||
|
|
name === 'theme' ||
|
|
name === 'ownerState' ||
|
|
(name.endsWith('Props') && name !== 'componentsProps' && name !== 'slotProps')
|
|
) {
|
|
return false;
|
|
}
|
|
return undefined;
|
|
},
|
|
checkDeclarations: true,
|
|
});
|
|
|
|
if (components.length === 0) {
|
|
return;
|
|
}
|
|
|
|
// exclude internal slot components, for example ButtonRoot
|
|
const cleanComponents = components.filter((component) => {
|
|
if (component.propsFilename?.endsWith('.tsx')) {
|
|
// only check for .tsx
|
|
const match = component.propsFilename.match(/.*\/([A-Z][a-zA-Z]+)\.tsx/);
|
|
if (match) {
|
|
return component.name === match[1];
|
|
}
|
|
}
|
|
return true;
|
|
});
|
|
|
|
cleanComponents.forEach((component) => {
|
|
component.types.forEach((prop) => {
|
|
if (
|
|
!prop.jsDoc ||
|
|
(project.name !== 'base' &&
|
|
ignoreExternalDocumentation[component.name] &&
|
|
ignoreExternalDocumentation[component.name].includes(prop.name))
|
|
) {
|
|
prop.jsDoc = '@ignore';
|
|
}
|
|
});
|
|
});
|
|
|
|
const sourceContent = await fs.readFile(sourceFile, 'utf8');
|
|
const isTsFile = /(\.(ts|tsx))/.test(sourceFile);
|
|
// If the component inherits the props from some unstyled components
|
|
// we don't want to add those propTypes again in the Material UI/Joy UI propTypes
|
|
const unstyledFile = getUnstyledFilename(tsFile, true);
|
|
const unstyledPropsFile = unstyledFile.replace('.d.ts', '.types.ts');
|
|
|
|
// TODO remove, should only have .types.ts
|
|
const propsFile = tsFile.replace(/(\.d\.ts|\.tsx|\.ts)/g, 'Props.ts');
|
|
const propsFileAlternative = tsFile.replace(/(\.d\.ts|\.tsx|\.ts)/g, '.types.ts');
|
|
const generatedForTypeScriptFile = sourceFile === tsFile;
|
|
const result = injectPropTypesInFile({
|
|
components,
|
|
target: sourceContent,
|
|
options: {
|
|
disablePropTypesTypeChecking: generatedForTypeScriptFile,
|
|
babelOptions: {
|
|
filename: sourceFile,
|
|
},
|
|
comment: [
|
|
'┌────────────────────────────── Warning ──────────────────────────────┐',
|
|
'│ These PropTypes are generated from the TypeScript type definitions. │',
|
|
isTsFile
|
|
? '│ To update them, edit the TypeScript types and run `pnpm proptypes`. │'
|
|
: '│ To update them, edit the d.ts file and run `pnpm proptypes`. │',
|
|
'└─────────────────────────────────────────────────────────────────────┘',
|
|
].join('\n'),
|
|
ensureBabelPluginTransformReactRemovePropTypesIntegration: true,
|
|
getSortLiteralUnions,
|
|
reconcilePropTypes: (prop, previous, generated) => {
|
|
const usedCustomValidator = previous !== undefined && !previous.startsWith('PropTypes');
|
|
const ignoreGenerated =
|
|
previous !== undefined &&
|
|
previous.startsWith('PropTypes /* @typescript-to-proptypes-ignore */');
|
|
|
|
if (
|
|
ignoreGenerated &&
|
|
// `ignoreGenerated` implies that `previous !== undefined`
|
|
previous!
|
|
.replace('PropTypes /* @typescript-to-proptypes-ignore */', 'PropTypes')
|
|
.replace(/\s/g, '') === generated.replace(/\s/g, '')
|
|
) {
|
|
throw new Error(
|
|
`Unused \`@typescript-to-proptypes-ignore\` directive for prop '${prop.name}'.`,
|
|
);
|
|
}
|
|
|
|
if (usedCustomValidator || ignoreGenerated) {
|
|
// `usedCustomValidator` and `ignoreGenerated` narrow `previous` to `string`
|
|
return previous!;
|
|
}
|
|
|
|
return generated;
|
|
},
|
|
shouldInclude: ({ component, prop }) => {
|
|
if (prop.name === 'children') {
|
|
return true;
|
|
}
|
|
let shouldDocument;
|
|
const { name: componentName } = component;
|
|
|
|
prop.filenames.forEach((filename) => {
|
|
const isExternal = filename !== tsFile;
|
|
const implementedByUnstyledVariant =
|
|
filename === unstyledFile || filename === unstyledPropsFile;
|
|
const implementedBySelfPropsFile =
|
|
filename === propsFile || filename === propsFileAlternative;
|
|
if (!isExternal || implementedByUnstyledVariant || implementedBySelfPropsFile) {
|
|
shouldDocument = true;
|
|
}
|
|
});
|
|
|
|
if (
|
|
useExternalDocumentation[componentName] &&
|
|
(useExternalDocumentation[componentName] === '*' ||
|
|
useExternalDocumentation[componentName].includes(prop.name))
|
|
) {
|
|
shouldDocument = true;
|
|
}
|
|
|
|
return shouldDocument;
|
|
},
|
|
},
|
|
});
|
|
|
|
if (!result) {
|
|
throw new Error('Unable to produce inject propTypes into code.');
|
|
}
|
|
|
|
const prettierConfig = await prettier.resolveConfig(process.cwd(), {
|
|
config: path.join(__dirname, '../prettier.config.mjs'),
|
|
});
|
|
|
|
const prettified = await prettier.format(result, { ...prettierConfig, filepath: sourceFile });
|
|
const formatted = fixBabelGeneratorIssues(prettified);
|
|
const correctedLineEndings = fixLineEndings(sourceContent, formatted);
|
|
|
|
await fs.writeFile(sourceFile, correctedLineEndings);
|
|
}
|
|
|
|
interface HandlerArgv {
|
|
pattern: string;
|
|
}
|
|
async function run(argv: HandlerArgv) {
|
|
const { pattern } = argv;
|
|
|
|
const filePattern = new RegExp(pattern);
|
|
if (pattern.length > 0) {
|
|
console.log(`Only considering declaration files matching ${filePattern}`);
|
|
}
|
|
|
|
const buildProject = createTypeScriptProjectBuilder(CORE_TYPESCRIPT_PROJECTS);
|
|
|
|
// Matches files where the folder and file both start with uppercase letters
|
|
// Example: AppBar/AppBar.d.ts
|
|
const allFiles = await Promise.all(
|
|
[
|
|
path.resolve(__dirname, '../packages/mui-system/src'),
|
|
path.resolve(__dirname, '../packages/mui-base/src'),
|
|
path.resolve(__dirname, '../packages/mui-material/src'),
|
|
path.resolve(__dirname, '../packages/mui-lab/src'),
|
|
path.resolve(__dirname, '../packages/mui-joy/src'),
|
|
].map((folderPath) =>
|
|
glob('+([A-Z])*/+([A-Z])*.*@(d.ts|ts|tsx)', {
|
|
absolute: true,
|
|
cwd: folderPath,
|
|
}),
|
|
),
|
|
);
|
|
|
|
const files = flatten(allFiles)
|
|
.filter((filePath) => {
|
|
// Filter out files where the directory name and filename doesn't match
|
|
// Example: Modal/ModalManager.d.ts
|
|
let folderName = path.basename(path.dirname(filePath));
|
|
const fileName = path.basename(filePath).replace(/(\.d\.ts|\.tsx|\.ts)/g, '');
|
|
|
|
// An exception is if the folder name starts with Unstable_/unstable_
|
|
// Example: Unstable_Grid2/Grid2.tsx
|
|
if (/(u|U)nstable_/g.test(folderName)) {
|
|
folderName = folderName.slice(9);
|
|
}
|
|
|
|
return fileName === folderName;
|
|
})
|
|
.filter((filePath) => filePattern.test(filePath));
|
|
|
|
const promises = files.map<Promise<void>>(async (tsFile) => {
|
|
const sourceFile = tsFile.includes('.d.ts') ? tsFile.replace('.d.ts', '.js') : tsFile;
|
|
try {
|
|
const projectName = tsFile.match(/packages\/mui-([a-zA-Z-]+)\/src/)![1];
|
|
const project = buildProject(projectName);
|
|
await generateProptypes(project, sourceFile, tsFile);
|
|
} catch (error: any) {
|
|
error.message = `${tsFile}: ${error.message}`;
|
|
throw error;
|
|
}
|
|
});
|
|
|
|
const results = await Promise.allSettled(promises);
|
|
|
|
const fails = results.filter((result): result is PromiseRejectedResult => {
|
|
return result.status === 'rejected';
|
|
});
|
|
|
|
fails.forEach((result) => {
|
|
console.error(result.reason);
|
|
});
|
|
if (fails.length > 0) {
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
yargs()
|
|
.command<HandlerArgv>({
|
|
command: '$0',
|
|
describe: 'Generates Component.propTypes from TypeScript declarations',
|
|
builder: (command) => {
|
|
return command.option('pattern', {
|
|
default: '',
|
|
describe: 'Only considers declaration files matching this pattern.',
|
|
type: 'string',
|
|
});
|
|
},
|
|
handler: run,
|
|
})
|
|
.help()
|
|
.strict(true)
|
|
.version(false)
|
|
.parse(hideBin(process.argv));
|