init project
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
Scorecards supply-chain security / Scorecards analysis (push) Has been cancelled
CodeQL / Analyze (push) Has been cancelled

This commit is contained in:
how2ice
2025-12-12 14:26:25 +09:00
commit 005cf56baf
43188 changed files with 1079531 additions and 0 deletions

View File

@@ -0,0 +1 @@
.tsbuildinfo

View File

@@ -0,0 +1,13 @@
# Changelog
## 1.0.3
Renamed the package to @mui/internal-docs-utils
## 1.0.2
Fixed incorrectly released package.
## 1.0.0
Initial release as an npm package.

View File

@@ -0,0 +1,9 @@
# @mui/internal-docs-utils
This package contains utilities shared between MUI docs generation scripts.
This is an internal package not meant for general use.
## Release
1. Build the project: `pnpm build`
2. Publish the build artifacts to npm: `pnpm release:publish`

View File

@@ -0,0 +1,30 @@
{
"name": "@mui/internal-docs-utils",
"version": "2.0.5",
"author": "MUI Team",
"description": "Utilities for MUI docs. This is an internal package not meant for general use.",
"main": "./build/index.js",
"exports": {
".": "./build/index.js"
},
"types": "./build/index.d.ts",
"repository": {
"type": "git",
"url": "git+https://github.com/mui/material-ui.git",
"directory": "packages-internal/docs-utils"
},
"scripts": {
"prebuild": "rimraf ./build",
"build": "tsc -p tsconfig.build.json",
"typescript": "tsc -p tsconfig.json",
"release:publish": "pnpm build && pnpm publish --tag latest",
"release:publish:dry-run": "pnpm build && pnpm publish --tag latest --registry=\"http://localhost:4873/\""
},
"dependencies": {
"rimraf": "^6.1.2",
"typescript": "^5.9.3"
},
"publishConfig": {
"access": "public"
}
}

View File

@@ -0,0 +1,11 @@
/**
* @deprecated Import if from '@mui-internal/api-docs-builder'
*/
export interface ComponentClassDefinition {
key: string;
className: string;
description: string;
isGlobal: boolean;
isDeprecated?: boolean;
deprecationInfo?: string;
}

View File

@@ -0,0 +1,131 @@
import path from 'path';
import fs from 'fs';
import * as ts from 'typescript';
export interface TypeScriptProject {
name: string;
rootPath: string;
exports: Record<string, ts.Symbol>;
program: ts.Program;
checker: ts.TypeChecker;
}
export interface CreateTypeScriptProjectOptions {
name: string;
rootPath: string;
/**
* Config to use to build this package.
* The path must be relative to the root path.
* @default 'tsconfig.build.json`
*/
tsConfigPath?: string;
/**
* File used as root of the package.
* This property is used to gather the exports of the project.
* The path must be relative to the root path.
*/
entryPointPath?: string;
/**
* Files to include in the project.
* By default, it will use the files defined in the tsconfig.
*/
files?: string[];
}
export const createTypeScriptProject = (
options: CreateTypeScriptProjectOptions,
): TypeScriptProject => {
const {
name,
rootPath,
tsConfigPath: inputTsConfigPath = 'tsconfig.build.json',
entryPointPath: inputEntryPointPath,
files,
} = options;
const tsConfigPath = path.join(rootPath, inputTsConfigPath);
const tsConfigFile = ts.readConfigFile(tsConfigPath, (filePath) =>
fs.readFileSync(filePath).toString(),
);
if (tsConfigFile.error) {
throw tsConfigFile.error;
}
// The build config does not parse the `.d.ts` files, but we sometimes need them to get the exports.
if (tsConfigFile.config.exclude) {
tsConfigFile.config.exclude = tsConfigFile.config.exclude.filter(
(pattern: string) => pattern !== 'src/**/*.d.ts',
);
}
const tsConfigFileContent = ts.parseJsonConfigFileContent(
tsConfigFile.config,
ts.sys,
path.dirname(tsConfigPath),
);
if (tsConfigFileContent.errors.length > 0) {
throw tsConfigFileContent.errors[0];
}
const program = ts.createProgram({
rootNames: files ?? tsConfigFileContent.fileNames,
options: tsConfigFileContent.options,
});
const checker = program.getTypeChecker();
let exports: TypeScriptProject['exports'];
if (inputEntryPointPath) {
const entryPointPath = path.join(rootPath, inputEntryPointPath);
const sourceFile = program.getSourceFile(entryPointPath);
exports = Object.fromEntries(
checker.getExportsOfModule(checker.getSymbolAtLocation(sourceFile!)!).map((symbol) => {
return [symbol.name, symbol];
}),
);
} else {
exports = {};
}
return {
name,
rootPath,
exports,
program,
checker,
};
};
export type TypeScriptProjectBuilder = (
projectName: string,
options?: { files?: string[] },
) => TypeScriptProject;
export const createTypeScriptProjectBuilder = (
projectsConfig: Record<string, Omit<CreateTypeScriptProjectOptions, 'name'>>,
): TypeScriptProjectBuilder => {
const projects = new Map<string, TypeScriptProject>();
return (projectName: string, options: { files?: string[] } = {}) => {
const cachedProject = projects.get(projectName);
if (cachedProject != null) {
return cachedProject;
}
// eslint-disable-next-line no-console
console.log(`Building new TS project: ${projectName}`);
const project = createTypeScriptProject({
name: projectName,
...projectsConfig[projectName],
...options,
});
projects.set(projectName, project);
return project;
};
};

View File

@@ -0,0 +1,358 @@
import * as ts from 'typescript';
import { TypeScriptProject } from './createTypeScriptProject';
export interface ParsedProp {
/**
* If `true`, some signatures do not contain this property.
* For example: `id` in `{ id: number, value: string } | { value: string }`
*/
onlyUsedInSomeSignatures: boolean;
signatures: { symbol: ts.Symbol; componentType: ts.Type }[];
}
export interface ParsedComponent {
name: string;
location: ts.Node;
type: ts.Type;
sourceFile: ts.SourceFile | undefined;
props: Record<string, ParsedProp>;
}
function isTypeJSXElementLike(type: ts.Type, project: TypeScriptProject): boolean {
const symbol = type.symbol ?? type.aliasSymbol;
if (symbol) {
const name = project.checker.getFullyQualifiedName(symbol);
return (
// Remove once global JSX namespace is no longer used by React
name === 'global.JSX.Element' ||
name === 'React.JSX.Element' ||
name === 'React.ReactElement' ||
name === 'React.ReactNode'
);
}
if (type.isUnion()) {
return type.types.every(
// eslint-disable-next-line no-bitwise
(subType) => subType.flags & ts.TypeFlags.Null || isTypeJSXElementLike(subType, project),
);
}
return false;
}
function isStyledFunction(node: ts.VariableDeclaration): boolean {
return (
!!node.initializer &&
ts.isCallExpression(node.initializer) &&
ts.isCallExpression(node.initializer.expression) &&
ts.isIdentifier(node.initializer.expression.expression) &&
node.initializer.expression.expression.escapedText === 'styled'
);
}
function getJSXLikeReturnValueFromFunction(type: ts.Type, project: TypeScriptProject) {
return type
.getCallSignatures()
.filter((signature) => isTypeJSXElementLike(signature.getReturnType(), project));
}
function parsePropsType({
name,
type,
shouldInclude = () => true,
location,
sourceFile,
}: {
name: string;
type: ts.Type;
location: ts.Node;
shouldInclude?: (data: { name: string; depth: number }) => boolean;
sourceFile: ts.SourceFile | undefined;
}): ParsedComponent {
const parsedProps: Record<string, ParsedProp> = {};
type
.getProperties()
.filter((property) => shouldInclude({ name: property.getName(), depth: 1 }))
.forEach((property) => {
parsedProps[property.getName()] = {
signatures: [
{
symbol: property,
componentType: type,
},
],
onlyUsedInSomeSignatures: false,
};
});
return {
name,
location,
type,
sourceFile,
props: parsedProps,
};
}
function parseFunctionComponent({
node,
shouldInclude,
project,
}: {
node: ts.VariableDeclaration | ts.FunctionDeclaration;
shouldInclude?: (data: { name: string; depth: number }) => boolean;
project: TypeScriptProject;
}): ParsedComponent | null {
if (!node.name) {
return null;
}
const symbol = project.checker.getSymbolAtLocation(node.name);
if (!symbol) {
return null;
}
const componentName = node.name.getText();
// Discriminate render functions to components
if (componentName[0].toUpperCase() !== componentName[0]) {
return null;
}
const signatures = getJSXLikeReturnValueFromFunction(
project.checker.getTypeOfSymbolAtLocation(symbol, symbol.valueDeclaration!),
project,
);
if (signatures.length === 0) {
return null;
}
const parsedComponents = signatures.map((signature) =>
parsePropsType({
shouldInclude,
name: componentName,
type: project.checker.getTypeOfSymbolAtLocation(
signature.parameters[0],
signature.parameters[0].valueDeclaration!,
),
location: signature.parameters[0].valueDeclaration!,
sourceFile: node.getSourceFile(),
}),
);
const squashedProps: Record<string, ParsedProp> = {};
parsedComponents.forEach((parsedComponent) => {
Object.keys(parsedComponent.props).forEach((propName) => {
if (!squashedProps[propName]) {
squashedProps[propName] = parsedComponent.props[propName];
} else {
squashedProps[propName].signatures = [
...squashedProps[propName].signatures,
...parsedComponent.props[propName].signatures,
];
}
});
});
const squashedParsedComponent: ParsedComponent = {
...parsedComponents[0],
props: squashedProps,
};
Object.keys(squashedParsedComponent.props).forEach((propName) => {
squashedParsedComponent.props[propName].onlyUsedInSomeSignatures =
squashedParsedComponent.props[propName].signatures.length < signatures.length;
});
return squashedParsedComponent;
}
export interface GetPropsFromComponentDeclarationOptions {
project: TypeScriptProject;
node: ts.Node;
/**
* Called before a PropType is added to a component/object
* @returns true to include the prop, false to skip it
*/
shouldInclude?: (data: { name: string; depth: number }) => boolean;
/**
* Control if const declarations should be checked
* @default false
* @example declare const Component: React.JSXElementConstructor<Props>;
*/
checkDeclarations?: boolean;
}
function getPropsFromVariableDeclaration({
node,
project,
checkDeclarations,
shouldInclude,
}: { node: ts.VariableDeclaration } & Pick<
GetPropsFromComponentDeclarationOptions,
'project' | 'checkDeclarations' | 'shouldInclude'
>) {
const type = project.checker.getTypeAtLocation(node.name);
if (!node.initializer) {
if (
checkDeclarations &&
type.aliasSymbol &&
type.aliasTypeArguments &&
project.checker.getFullyQualifiedName(type.aliasSymbol) === 'React.JSXElementConstructor'
) {
const propsType = type.aliasTypeArguments[0];
if (propsType === undefined) {
throw new TypeError(
'Unable to find symbol for `props`. This is a bug in typescript-to-proptypes.',
);
}
return parsePropsType({
name: node.name.getText(),
type: propsType,
location: node.name,
shouldInclude,
sourceFile: node.getSourceFile(),
});
}
if (checkDeclarations) {
return parseFunctionComponent({
node,
shouldInclude,
project,
});
}
return null;
}
if (
(ts.isArrowFunction(node.initializer) || ts.isFunctionExpression(node.initializer)) &&
node.initializer.parameters.length === 1
) {
return parseFunctionComponent({
node,
shouldInclude,
project,
});
}
// x = React.memo((props:type) { return <div/> })
// x = React.forwardRef((props:type) { return <div/> })
if (ts.isCallExpression(node.initializer) && node.initializer.arguments.length > 0) {
const potentialComponent = node.initializer.arguments[0];
if (
(ts.isArrowFunction(potentialComponent) || ts.isFunctionExpression(potentialComponent)) &&
potentialComponent.parameters.length > 0 &&
getJSXLikeReturnValueFromFunction(
project.checker.getTypeAtLocation(potentialComponent),
project,
).length > 0
) {
const propsSymbol = project.checker.getSymbolAtLocation(
potentialComponent.parameters[0].name,
);
if (propsSymbol) {
return parsePropsType({
name: node.name.getText(),
type: project.checker.getTypeOfSymbolAtLocation(
propsSymbol,
propsSymbol.valueDeclaration!,
),
location: propsSymbol.valueDeclaration!,
shouldInclude,
sourceFile: node.getSourceFile(),
});
}
}
}
// handle component factories: x = createComponent()
if (
checkDeclarations &&
node.initializer &&
!isStyledFunction(node) &&
getJSXLikeReturnValueFromFunction(type, project).length > 0
) {
return parseFunctionComponent({
node,
shouldInclude,
project,
});
}
return null;
}
export function getPropsFromComponentNode({
node,
shouldInclude,
project,
checkDeclarations,
}: GetPropsFromComponentDeclarationOptions): ParsedComponent | null {
let parsedComponent: ParsedComponent | null = null;
// function x(props: type) { return <div/> }
if (
ts.isFunctionDeclaration(node) &&
node.name &&
node.parameters.length === 1 &&
getJSXLikeReturnValueFromFunction(project.checker.getTypeAtLocation(node.name), project)
.length > 0
) {
parsedComponent = parseFunctionComponent({ node, shouldInclude, project });
} else if (ts.isVariableDeclaration(node)) {
parsedComponent = getPropsFromVariableDeclaration({
node,
project,
checkDeclarations,
shouldInclude,
});
} else if (ts.isVariableStatement(node)) {
// const x = ...
ts.forEachChild(node.declarationList, (variableNode) => {
if (parsedComponent != null) {
return;
}
// x = (props: type) => { return <div/> }
// x = function(props: type) { return <div/> }
// x = function y(props: type) { return <div/> }
// x = react.memo((props:type) { return <div/> })
if (ts.isVariableDeclaration(variableNode) && variableNode.name) {
parsedComponent = getPropsFromVariableDeclaration({
node: variableNode,
project,
checkDeclarations,
shouldInclude,
});
}
if (
ts.isClassDeclaration(variableNode) &&
variableNode.name &&
variableNode.heritageClauses &&
variableNode.heritageClauses.length === 1
) {
const heritage = variableNode.heritageClauses[0];
if (heritage.types.length !== 1) {
return;
}
const arg = heritage.types[0];
if (!arg.typeArguments) {
return;
}
parsedComponent = parsePropsType({
shouldInclude,
name: variableNode.name.getText(),
location: arg.typeArguments[0],
type: project.checker.getTypeAtLocation(arg.typeArguments[0]),
sourceFile: node.getSourceFile(),
});
}
});
}
return parsedComponent;
}

View File

@@ -0,0 +1,55 @@
import { EOL } from 'os';
export * from './createTypeScriptProject';
export { type ComponentClassDefinition } from './ComponentClassDefinition';
export * from './getPropsFromComponentNode';
export function getLineFeed(source: string): string {
const match = source.match(/\r?\n/);
return match === null ? EOL : match[0];
}
const fixBabelIssuesRegExp = /(?<=(\/>)|,)(\r?\n){2}/g;
export function fixBabelGeneratorIssues(source: string): string {
return source.replace(fixBabelIssuesRegExp, '\n');
}
export function fixLineEndings(source: string, target: string): string {
return target.replace(/\r?\n/g, getLineFeed(source));
}
/**
* Converts styled or regular component d.ts file to unstyled d.ts
* @param filename - the file of the styled or regular mui component
*/
export function getUnstyledFilename(filename: string, definitionFile: boolean = false): string {
if (filename.includes('mui-base')) {
return filename;
}
let unstyledFile = '';
const separator = filename.includes('/') ? '/' : '\\';
if (!filename.includes('mui-base')) {
unstyledFile = filename
.replace(/.d.ts$/, '')
.replace(/.tsx?$/, '')
.replace(/.js$/, '');
unstyledFile = unstyledFile.replace(/Styled/g, '');
if (separator === '/') {
unstyledFile = unstyledFile.replace(
/packages\/mui-lab|packages\/mui-material/g,
'packages/mui-base',
);
} else {
unstyledFile = unstyledFile.replace(
/packages\\mui-lab|packages\\mui-material/g,
'packages\\mui-base',
);
}
}
return definitionFile ? `${unstyledFile}.d.ts` : `${unstyledFile}.js`;
}

View File

@@ -0,0 +1,14 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./build",
"declaration": true,
"noEmit": false,
"composite": true,
"tsBuildInfoFile": "./build/.tsbuildinfo",
"target": "ES2020",
"types": ["node"]
},
"exclude": ["./test/*.ts"]
}

View File

@@ -0,0 +1,12 @@
{
"compilerOptions": {
"noEmit": true,
"module": "nodenext",
"moduleResolution": "nodenext",
"types": ["node"],
"strict": true,
"esModuleInterop": true,
"isolatedModules": true
},
"include": ["./src/**/*.ts"]
}

View File

@@ -0,0 +1 @@
.tsbuildinfo

View File

@@ -0,0 +1,11 @@
# Changelog
## 1.0.1
- Unpinned version of the @mui/internal-docs-utils dependency.
- Corrected the README file.
## 1.0.0
Initial release as an npm package.
The package contains the typescript-to-proptypes module.

View File

@@ -0,0 +1,10 @@
# @mui/internal-scripts
This is that code infra scripts for the MUI organization repositories.
It is not meant for general use.
## Scripts
- `build` - transpiles TypeScript files into the build directory.
- `test` - runs all the tests.
- `typescript` - checks validity of types.

View File

@@ -0,0 +1,3 @@
// Export all functions from both modules
export * from './processComponent';
export * from './processApi';

View File

@@ -0,0 +1,347 @@
import * as fs from 'fs';
interface ApiProp {
type: {
name: string;
description?: string;
};
required?: boolean;
default?: string;
deprecated?: boolean;
deprecationInfo?: string;
signature?: {
type: string;
describedArgs?: string[];
};
additionalInfo?: {
cssApi?: boolean;
sx?: boolean;
};
}
interface ApiSlot {
name: string;
description: string;
default: string;
class: string | null;
}
interface ApiClass {
key: string;
className: string;
description: string;
isGlobal: boolean;
}
interface ApiInheritance {
component: string;
pathname: string;
}
interface ApiJson {
props: Record<string, ApiProp>;
name: string;
imports: string[];
slots?: ApiSlot[];
classes?: ApiClass[];
spread?: boolean;
themeDefaultProps?: boolean;
muiName?: string;
forwardsRefTo?: string | null;
filename?: string;
inheritance?: ApiInheritance;
demos?: string;
cssComponent?: boolean;
deprecated?: boolean;
deprecationInfo?: string;
}
export interface ProcessApiOptions {
origin?: string;
}
/**
* Convert prop type description from HTML format
*/
function formatPropTypeDescription(html: string): string {
// Decode HTML entities
const result = html
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#124;/g, '|')
.replace(/&nbsp;/g, ' ')
.replace(/&amp;/g, '&')
// Replace <br> tags with space to maintain readability
.replace(/<br\s*\/?>/gi, ' ')
// Clean up excessive whitespace
.replace(/\s+/g, ' ')
.trim();
return result;
}
/**
* Convert HTML to markdown
*/
function htmlToMarkdown(html: string, origin?: string): string {
// First pass: decode entities and handle inline elements
let markdown = html
// Decode HTML entities first
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#124;/g, '|')
.replace(/&nbsp;/g, ' ')
.replace(/&amp;/g, '&')
// Convert <code> to backticks
.replace(/<code>([^<]+)<\/code>/gi, '`$1`')
// Convert <a> to markdown links
.replace(/<a\s+href="([^"]+)">([^<]+)<\/a>/gi, (match, href, text) => {
const url = origin && href.startsWith('/') ? new URL(href, origin).href : href;
return `[${text}](${url})`;
});
// Handle lists - process them as complete units to avoid extra line breaks
markdown = markdown.replace(/<ul[^>]*>(.*?)<\/ul>/gis, (match, listContent: string) => {
// Process each list item
const items = listContent
.split(/<\/li>/)
.map((item) => item.replace(/<li[^>]*>/, '').trim())
.filter((item) => item.length > 0)
.map((item) => `- ${item}`)
.join('\n');
return `\n${items}\n`;
});
// Handle other block elements
markdown = markdown
// Convert <br> to newline
.replace(/<br\s*\/?>/gi, '\n')
// Convert <p> to double newline
.replace(/<p[^>]*>/gi, '\n\n')
.replace(/<\/p>/gi, '')
// Remove any remaining HTML tags
.replace(/<[^>]+>/g, '')
// Clean up excessive whitespace (but preserve intentional line breaks)
.replace(/[ \t]+/g, ' ')
.replace(/ *\n */g, '\n')
.replace(/\n{3,}/g, '\n\n')
.trim();
return markdown;
}
/**
* Format prop type for markdown
*/
function formatPropType(prop: ApiProp): string {
let type = prop.type.name;
if (prop.type.description) {
// Use specialized function for prop type descriptions
type = formatPropTypeDescription(prop.type.description);
}
if (prop.signature) {
type = prop.signature.type;
}
// Escape pipes in union types for better markdown readability
type = type.replace(/\s*\|\s*/g, ' \\| ');
// Wrap all prop types in backticks to prevent markdown table issues with pipes
return `\`${type}\``;
}
/**
* Generate props table
*/
function generatePropsTable(props: Record<string, ApiProp>, origin?: string): string {
const propEntries = Object.entries(props);
if (propEntries.length === 0) {
return '';
}
let table = '## Props\n\n';
table += '| Name | Type | Default | Required | Description |\n';
table += '|------|------|---------|----------|-------------|\n';
for (const [propName, prop] of propEntries) {
const name = prop.deprecated ? `${propName} (deprecated)` : propName;
const type = formatPropType(prop);
const defaultValue = prop.default ? `\`${prop.default}\`` : '-';
const required = prop.required ? 'Yes' : 'No';
let description = '';
if (prop.deprecated && prop.deprecationInfo) {
description = `⚠️ ${htmlToMarkdown(prop.deprecationInfo, origin)}`;
} else if (prop.additionalInfo?.cssApi) {
description = 'Override or extend the styles applied to the component.';
} else if (prop.additionalInfo?.sx) {
description =
'The system prop that allows defining system overrides as well as additional CSS styles.';
}
table += `| ${name} | ${type} | ${defaultValue} | ${required} | ${description} |\n`;
}
return table;
}
/**
* Generate slots table
*/
function generateSlotsTable(slots: ApiSlot[], origin?: string): string {
if (!slots || slots.length === 0) {
return '';
}
let table = '## Slots\n\n';
table += '| Name | Default | Class | Description |\n';
table += '|------|---------|-------|-------------|\n';
for (const slot of slots) {
const className = slot.class ? `\`.${slot.class}\`` : '-';
const description = htmlToMarkdown(slot.description, origin);
table += `| ${slot.name} | \`${slot.default}\` | ${className} | ${description} |\n`;
}
return table;
}
/**
* Generate classes table
*/
function generateClassesTable(classes: ApiClass[], origin?: string): string {
if (!classes || classes.length === 0) {
return '';
}
let table = '## CSS\n\n';
table += '### Rule name\n\n';
table += '| Global class | Rule name | Description |\n';
table += '|--------------|-----------|-------------|\n';
for (const cls of classes) {
const globalClass = cls.isGlobal ? `\`.${cls.className}\`` : '-';
const ruleName = cls.isGlobal ? '-' : cls.key;
const description = htmlToMarkdown(cls.description, origin);
table += `| ${globalClass} | ${ruleName} | ${description} |\n`;
}
return table;
}
/**
* Process API JSON and convert to markdown
*/
export function processApiJson(apiJson: ApiJson | string, options: ProcessApiOptions = {}): string {
const api: ApiJson = typeof apiJson === 'string' ? JSON.parse(apiJson) : apiJson;
const { origin } = options;
let markdown = `# ${api.name} API\n\n`;
// Add deprecation warning if applicable
if (api.deprecated) {
const warningText = api.deprecationInfo
? htmlToMarkdown(api.deprecationInfo, origin)
: 'This component is deprecated. Consider using an alternative component.';
markdown += `> ⚠️ **Warning**: ${warningText}\n\n`;
}
// Add demos section
if (api.demos) {
markdown += '## Demos\n\n';
markdown +=
'For examples and details on the usage of this React component, visit the component demo pages:\n\n';
markdown += `${htmlToMarkdown(api.demos, origin)}\n\n`;
}
// Add import section
markdown += '## Import\n\n';
markdown += '```jsx\n';
markdown += api.imports.join('\n// or\n');
markdown += '\n```\n\n';
// Add props section
const propsTable = generatePropsTable(api.props, origin);
if (propsTable) {
markdown += `${propsTable}\n`;
}
// Add ref information
if (api.forwardsRefTo === null) {
markdown += '> **Note**: This component cannot hold a ref.\n\n';
} else {
markdown += `> **Note**: The \`ref\` is forwarded to the root element${api.forwardsRefTo ? ` (${api.forwardsRefTo})` : ''}.\n\n`;
}
// Add spread information
if (api.spread) {
const inheritanceUrl =
origin && api.inheritance?.pathname.startsWith('/')
? `${origin}${api.inheritance.pathname}`
: api.inheritance?.pathname;
const spreadElement = api.inheritance
? `[${api.inheritance.component}](${inheritanceUrl})`
: 'native element';
markdown += `> Any other props supplied will be provided to the root element (${spreadElement}).\n\n`;
}
// Add inheritance section
if (api.inheritance) {
markdown += '## Inheritance\n\n';
const inheritanceUrl =
origin && api.inheritance.pathname.startsWith('/')
? `${origin}${api.inheritance.pathname}`
: api.inheritance.pathname;
markdown += `While not explicitly documented above, the props of the [${api.inheritance.component}](${inheritanceUrl}) component are also available on ${api.name}.`;
if (api.inheritance.component === 'Transition') {
markdown +=
' A subset of components support [react-transition-group](https://reactcommunity.org/react-transition-group/transition/) out of the box.';
}
markdown += '\n\n';
}
// Add theme default props section
if (api.themeDefaultProps && api.muiName) {
markdown += '## Theme default props\n\n';
markdown += `You can use \`${api.muiName}\` to change the default props of this component with the theme.\n\n`;
}
// Add slots section
const slotsTable = generateSlotsTable(api.slots || [], origin);
if (slotsTable) {
markdown += `${slotsTable}\n`;
}
// Add classes section
const classesTable = generateClassesTable(api.classes || [], origin);
if (classesTable) {
markdown += `${classesTable}\n`;
}
// Add CSS component note
if (api.cssComponent) {
markdown += `> **Note**: As a CSS utility, the \`${api.name}\` component also supports all system properties. You can use them as props directly on the component.\n\n`;
}
// Add source code section
if (api.filename) {
markdown += '## Source code\n\n';
markdown += `If you did not find the information on this page, consider having a look at the implementation of the component for more detail.\n\n`;
markdown += `- [${api.filename}](https://github.com/mui/material-ui/tree/HEAD${api.filename})\n\n`;
}
return markdown.trim();
}
/**
* Process API JSON file and return markdown
*/
export function processApiFile(filePath: string, options: ProcessApiOptions = {}): string {
const content = fs.readFileSync(filePath, 'utf-8');
return processApiJson(content, options);
}

View File

@@ -0,0 +1,120 @@
import * as fs from 'fs';
import * as path from 'path';
interface DemoReplaceOptions {
basePath?: string;
includeTypeScript?: boolean;
}
/**
* Removes {{"component": ...}} syntax from markdown content
* @param markdownContent - The markdown content to clean
* @returns The cleaned markdown content
*/
export function removeComponentSyntax(markdownContent: string): string {
// Regular expression to match {{"component": "ComponentName"}} pattern
const componentRegex = /\{\{\s*"component":\s*"[^"]+"\s*\}\}/g;
return markdownContent.replace(componentRegex, '');
}
/**
* Converts <p class="description"> HTML tags to plain text in markdown
* @param markdownContent - The markdown content to clean
* @returns The cleaned markdown content
*/
export function cleanDescriptionTags(markdownContent: string): string {
// Replace <p class="description">...</p> with just the content
return markdownContent.replace(/<p class="description">([^<]+)<\/p>/g, '$1');
}
/**
* Parses markdown content and replaces demo syntax with code snippets
* @param markdownContent - The markdown content to parse
* @param markdownPath - The path to the markdown file (used to resolve relative demo paths)
* @param options - Options for parsing
* @returns The processed markdown with demo code snippets
*/
export function replaceDemoWithSnippet(
markdownContent: string,
markdownPath: string,
options: DemoReplaceOptions = {},
): string {
const { basePath = '' } = options;
// Regular expression to match {{"demo": "filename.js"}} pattern
const demoRegex = /\{\{\s*"demo":\s*"([^"]+)"(?:,\s*[^}]+)?\s*\}\}/g;
return markdownContent.replace(demoRegex, (match, filename) => {
try {
// Extract the base filename without extension
const baseFilename = filename.replace(/\.(js|tsx?)$/, '');
// Get the directory of the markdown file
const markdownDir = path.dirname(markdownPath);
let codeSnippet = '';
// Try to read TypeScript file before JavaScript file
const tsPath = basePath
? path.join(basePath, `${baseFilename}.tsx`)
: path.join(markdownDir, `${baseFilename}.tsx`);
if (fs.existsSync(tsPath)) {
const tsContent = fs.readFileSync(tsPath, 'utf-8');
if (codeSnippet) {
codeSnippet += '\n\n';
}
codeSnippet += `\`\`\`tsx\n${tsContent}\n\`\`\``;
} else {
// Try to read JavaScript file
const jsPath = basePath
? path.join(basePath, `${baseFilename}.js`)
: path.join(markdownDir, `${baseFilename}.js`);
if (fs.existsSync(jsPath)) {
const jsContent = fs.readFileSync(jsPath, 'utf-8');
codeSnippet += `\`\`\`jsx\n${jsContent}\n\`\`\``;
}
}
// If no files found, return original match
if (!codeSnippet) {
if (process.env.NODE_ENV !== 'test') {
console.warn(`Demo file not found: ${filename}`);
}
return match;
}
return codeSnippet;
} catch (error) {
console.error(`Error processing demo ${filename}:`, error);
return match;
}
});
}
/**
* Processes a markdown file and replaces demo syntax with code snippets
* @param filePath - Path to the markdown file
* @param options - Options for parsing
* @returns The processed markdown content
*/
export function processMarkdownFile(filePath: string, options: DemoReplaceOptions = {}): string {
let content = fs.readFileSync(filePath, 'utf-8');
const dir = path.dirname(filePath);
// Set basePath relative to markdown file location if not provided
const processOptions = {
...options,
basePath: options.basePath || dir,
};
// First, remove component syntax
content = removeComponentSyntax(content);
// Clean description HTML tags
content = cleanDescriptionTags(content);
// Then, replace demo syntax with code snippets
return replaceDemoWithSnippet(content, filePath, processOptions);
}

View File

@@ -0,0 +1,465 @@
import { expect } from 'chai';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { processApiJson, processApiFile } from '../src/processApi';
describe('processApi', () => {
describe('processApiJson', () => {
it('should generate basic component API markdown', () => {
const apiJson = {
name: 'Button',
imports: [
"import Button from '@mui/material/Button';",
"import { Button } from '@mui/material';",
],
props: {
color: {
type: { name: 'string' },
default: "'primary'",
required: false,
},
disabled: {
type: { name: 'bool' },
default: 'false',
required: false,
},
},
};
const result = processApiJson(apiJson);
expect(result).to.include('# Button API');
expect(result).to.include('## Import');
expect(result).to.include("import Button from '@mui/material/Button';");
expect(result).to.include('## Props');
expect(result).to.include("| color | `string` | `'primary'` | No |");
expect(result).to.include('| disabled | `bool` | `false` | No |');
});
it('should handle deprecated component', () => {
const apiJson = {
name: 'DeprecatedComponent',
imports: ["import DeprecatedComponent from '@mui/material/DeprecatedComponent';"],
props: {},
deprecated: true,
deprecationInfo: 'Use <code>NewComponent</code> instead.',
};
const result = processApiJson(apiJson);
expect(result).to.include('> ⚠️ **Warning**: Use `NewComponent` instead.');
});
it('should handle deprecated props', () => {
const apiJson = {
name: 'Component',
imports: ["import Component from '@mui/material/Component';"],
props: {
oldProp: {
type: { name: 'string' },
deprecated: true,
deprecationInfo: 'Use <code>newProp</code> instead.',
},
},
};
const result = processApiJson(apiJson);
expect(result).to.include('| oldProp (deprecated) |');
expect(result).to.include('⚠️ Use `newProp` instead.');
});
it('should handle complex prop types', () => {
const apiJson = {
name: 'Component',
imports: ["import Component from '@mui/material/Component';"],
props: {
onChange: {
type: { name: 'func' },
signature: {
type: 'function(event: React.SyntheticEvent, value: number) => void',
describedArgs: ['event', 'value'],
},
},
slots: {
type: {
name: 'shape',
description: '{ root?: elementType, icon?: elementType }',
},
},
sx: {
type: {
name: 'union',
description:
'Array&lt;func<br>&#124;&nbsp;object&gt;<br>&#124;&nbsp;func<br>&#124;&nbsp;object',
},
additionalInfo: { sx: true },
},
},
};
const result = processApiJson(apiJson);
expect(result).to.include('`function(event: React.SyntheticEvent, value: number) => void`');
expect(result).to.include('`{ root?: elementType, icon?: elementType }`');
expect(result).to.include('`Array<func \\| object> \\| func \\| object`');
expect(result).to.include('The system prop that allows defining system overrides');
});
it('should handle demos section', () => {
const apiJson = {
name: 'Accordion',
imports: ["import Accordion from '@mui/material/Accordion';"],
props: {},
demos: '<ul><li><a href="/material-ui/react-accordion/">Accordion</a></li></ul>',
};
const result = processApiJson(apiJson);
expect(result).to.include('## Demos');
expect(result).to.include('- [Accordion](/material-ui/react-accordion/)');
});
it('should add origin to relative URLs in demos when origin option is provided', () => {
const apiJson = {
name: 'Accordion',
imports: ["import Accordion from '@mui/material/Accordion';"],
props: {},
demos: '<ul><li><a href="/material-ui/react-accordion/">Accordion</a></li></ul>',
};
const result = processApiJson(apiJson, { origin: 'https://mui.com' });
expect(result).to.include('## Demos');
expect(result).to.include('- [Accordion](https://mui.com/material-ui/react-accordion/)');
});
it('should not modify absolute URLs when origin option is provided', () => {
const apiJson = {
name: 'Component',
imports: ["import Component from '@mui/material/Component';"],
props: {},
demos: '<ul><li><a href="https://example.com/demo">External Demo</a></li></ul>',
};
const result = processApiJson(apiJson, { origin: 'https://mui.com' });
expect(result).to.include('- [External Demo](https://example.com/demo)');
expect(result).to.not.include('https://mui.com/https://example.com');
});
it('should handle slots section', () => {
const apiJson = {
name: 'Component',
imports: ["import Component from '@mui/material/Component';"],
props: {},
slots: [
{
name: 'root',
description: 'The component that renders the root.',
default: 'Paper',
class: 'MuiComponent-root',
},
{
name: 'icon',
description: 'The icon element.',
default: 'svg',
class: null,
},
],
};
const result = processApiJson(apiJson);
expect(result).to.include('## Slots');
expect(result).to.include(
'| root | `Paper` | `.MuiComponent-root` | The component that renders the root. |',
);
expect(result).to.include('| icon | `svg` | - | The icon element. |');
});
it('should handle classes section', () => {
const apiJson = {
name: 'Component',
imports: ["import Component from '@mui/material/Component';"],
props: {},
classes: [
{
key: 'disabled',
className: 'Mui-disabled',
description: 'State class applied to the root element if `disabled={true}`.',
isGlobal: true,
},
{
key: 'root',
className: 'MuiComponent-root',
description: 'Styles applied to the root element.',
isGlobal: false,
},
],
};
const result = processApiJson(apiJson);
expect(result).to.include('## CSS');
expect(result).to.include('### Rule name');
expect(result).to.include(
'| `.Mui-disabled` | - | State class applied to the root element if `disabled={true}`. |',
);
expect(result).to.include('| - | root | Styles applied to the root element. |');
});
it('should handle inheritance', () => {
const apiJson = {
name: 'Accordion',
imports: ["import Accordion from '@mui/material/Accordion';"],
props: {},
inheritance: {
component: 'Paper',
pathname: '/material-ui/api/paper/',
},
};
const result = processApiJson(apiJson);
expect(result).to.include('## Inheritance');
expect(result).to.include('[Paper](/material-ui/api/paper/)');
expect(result).to.include(
'the props of the [Paper](/material-ui/api/paper/) component are also available on Accordion',
);
});
it('should add origin to inheritance URLs when origin option is provided', () => {
const apiJson = {
name: 'Accordion',
imports: ["import Accordion from '@mui/material/Accordion';"],
props: {},
inheritance: {
component: 'Paper',
pathname: '/material-ui/api/paper/',
},
};
const result = processApiJson(apiJson, { origin: 'https://mui.com' });
expect(result).to.include('## Inheritance');
expect(result).to.include('[Paper](https://mui.com/material-ui/api/paper/)');
expect(result).to.include(
'the props of the [Paper](https://mui.com/material-ui/api/paper/) component are also available on Accordion',
);
});
it('should handle spread props', () => {
const apiJson = {
name: 'Component',
imports: ["import Component from '@mui/material/Component';"],
props: {},
spread: true,
inheritance: {
component: 'Paper',
pathname: '/material-ui/api/paper/',
},
};
const result = processApiJson(apiJson);
expect(result).to.include(
'Any other props supplied will be provided to the root element ([Paper](/material-ui/api/paper/))',
);
});
it('should add origin to spread props inheritance URL when origin option is provided', () => {
const apiJson = {
name: 'Component',
imports: ["import Component from '@mui/material/Component';"],
props: {},
spread: true,
inheritance: {
component: 'Paper',
pathname: '/material-ui/api/paper/',
},
};
const result = processApiJson(apiJson, { origin: 'https://mui.com' });
expect(result).to.include(
'Any other props supplied will be provided to the root element ([Paper](https://mui.com/material-ui/api/paper/))',
);
});
it('should handle ref forwarding', () => {
const apiJson = {
name: 'Component',
imports: ["import Component from '@mui/material/Component';"],
props: {},
forwardsRefTo: 'HTMLDivElement',
};
const result = processApiJson(apiJson);
expect(result).to.include('The `ref` is forwarded to the root element (HTMLDivElement)');
});
it('should handle components that cannot hold refs', () => {
const apiJson = {
name: 'Component',
imports: ["import Component from '@mui/material/Component';"],
props: {},
forwardsRefTo: null,
};
const result = processApiJson(apiJson);
expect(result).to.include('This component cannot hold a ref');
});
it('should handle theme default props', () => {
const apiJson = {
name: 'Button',
imports: ["import Button from '@mui/material/Button';"],
props: {},
themeDefaultProps: true,
muiName: 'MuiButton',
};
const result = processApiJson(apiJson);
expect(result).to.include('## Theme default props');
expect(result).to.include('You can use `MuiButton` to change the default props');
});
it('should handle CSS component', () => {
const apiJson = {
name: 'Box',
imports: ["import Box from '@mui/material/Box';"],
props: {},
cssComponent: true,
};
const result = processApiJson(apiJson);
expect(result).to.include(
'As a CSS utility, the `Box` component also supports all system properties',
);
});
it('should handle source code section', () => {
const apiJson = {
name: 'Component',
imports: ["import Component from '@mui/material/Component';"],
props: {},
filename: '/packages/mui-material/src/Component/Component.js',
};
const result = processApiJson(apiJson);
expect(result).to.include('## Source code');
expect(result).to.include(
'https://github.com/mui/material-ui/tree/HEAD/packages/mui-material/src/Component/Component.js',
);
});
it('should handle required props', () => {
const apiJson = {
name: 'Component',
imports: ["import Component from '@mui/material/Component';"],
props: {
children: {
type: { name: 'node' },
required: true,
},
optional: {
type: { name: 'string' },
required: false,
},
},
};
const result = processApiJson(apiJson);
expect(result).to.include('| children | `node` | - | Yes |');
expect(result).to.include('| optional | `string` | - | No |');
});
});
describe('processApiFile', () => {
let tempDir: string;
beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'api-test-'));
});
afterEach(() => {
fs.rmSync(tempDir, { recursive: true, force: true });
});
it('should process API JSON file', () => {
const apiJson = {
name: 'TestComponent',
imports: ["import TestComponent from '@mui/material/TestComponent';"],
props: {
test: {
type: { name: 'bool' },
default: 'true',
},
},
};
const filePath = path.join(tempDir, 'test-component.json');
fs.writeFileSync(filePath, JSON.stringify(apiJson, null, 2));
const result = processApiFile(filePath);
expect(result).to.include('# TestComponent API');
expect(result).to.include('| test | `bool` | `true` | No |');
});
it('should process API JSON file with origin option', () => {
const apiJson = {
name: 'TestComponent',
imports: ["import TestComponent from '@mui/material/TestComponent';"],
props: {},
demos: '<ul><li><a href="/material-ui/react-test/">Test Demo</a></li></ul>',
inheritance: {
component: 'BaseComponent',
pathname: '/material-ui/api/base/',
},
};
const filePath = path.join(tempDir, 'test-component-with-links.json');
fs.writeFileSync(filePath, JSON.stringify(apiJson, null, 2));
const result = processApiFile(filePath, { origin: 'https://example.com' });
expect(result).to.include('# TestComponent API');
expect(result).to.include('[Test Demo](https://example.com/material-ui/react-test/)');
expect(result).to.include('[BaseComponent](https://example.com/material-ui/api/base/)');
});
});
describe('HTML to Markdown conversion', () => {
it('should convert HTML entities and tags correctly', () => {
const apiJson = {
name: 'Component',
imports: ["import Component from '@mui/material/Component';"],
props: {
complexProp: {
type: {
name: 'union',
description: 'Array&lt;func<br>&#124;&nbsp;object&gt;<br>&#124;&nbsp;func',
},
},
},
demos: '<p>Test paragraph</p><ul><li>Item 1</li><li>Item 2</li></ul>',
};
const result = processApiJson(apiJson);
expect(result).to.include('`Array<func \\| object> \\| func`');
expect(result).to.include('Test paragraph');
expect(result).to.include('- Item 1');
expect(result).to.include('- Item 2');
});
});
});

View File

@@ -0,0 +1,174 @@
import { expect } from 'chai';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { replaceDemoWithSnippet, processMarkdownFile } from '../src/index';
describe('generate-llms-txt', () => {
let tempDir: string;
beforeEach(() => {
// Create a temporary directory for test files
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'llms-txt-test-'));
});
afterEach(() => {
// Clean up temporary directory
fs.rmSync(tempDir, { recursive: true, force: true });
});
describe('replaceDemoWithSnippet', () => {
it('should replace demo syntax with code snippet', () => {
const markdown = `# Test Component
Here is a demo:
{{"demo": "BasicButton.js"}}
More content here.`;
const jsContent = `import React from 'react';
export default function BasicButton() {
return <button>Click me</button>;
}`;
// Create test files
fs.writeFileSync(path.join(tempDir, 'BasicButton.js'), jsContent);
const result = replaceDemoWithSnippet(markdown, path.join(tempDir, 'test.md'), {
basePath: tempDir,
});
expect(result).to.include('```jsx');
expect(result).to.include(jsContent);
expect(result).to.not.include('{{"demo": "BasicButton.js"}}');
});
it('should handle white spaces in demo syntax', () => {
const markdown = `{{ "demo": "Component.js" }}`;
const jsContent = `// JavaScript version`;
fs.writeFileSync(path.join(tempDir, 'Component.js'), jsContent);
const result = replaceDemoWithSnippet(markdown, path.join(tempDir, 'test.md'), {
basePath: tempDir,
});
expect(result).to.include('```jsx');
expect(result).to.include(jsContent);
});
it('should include only TS files', () => {
const markdown = `{{"demo": "Component.js"}}`;
const jsContent = `// JavaScript version`;
const tsContent = `// TypeScript version`;
fs.writeFileSync(path.join(tempDir, 'Component.js'), jsContent);
fs.writeFileSync(path.join(tempDir, 'Component.tsx'), tsContent);
const result = replaceDemoWithSnippet(markdown, path.join(tempDir, 'test.md'), {
basePath: tempDir,
});
expect(result).to.include('```tsx');
expect(result).to.include(tsContent);
expect(result).to.not.include('```jsx');
expect(result).to.not.include(jsContent);
});
it('should only include JS file when TS file does not exist', () => {
const markdown = `{{"demo": "Component.js"}}`;
const jsContent = `// JavaScript version`;
fs.writeFileSync(path.join(tempDir, 'Component.js'), jsContent);
const result = replaceDemoWithSnippet(markdown, path.join(tempDir, 'test.md'), {
basePath: tempDir,
});
expect(result).to.include('```jsx');
expect(result).to.include(jsContent);
expect(result).to.not.include('```tsx');
});
it('should handle multiple demos in the same markdown', () => {
const markdown = `# Multiple Demos
{{"demo": "First.js"}}
Some text in between.
{{"demo": "Second.js"}}`;
fs.writeFileSync(path.join(tempDir, 'First.js'), 'First component');
fs.writeFileSync(path.join(tempDir, 'Second.js'), 'Second component');
const result = replaceDemoWithSnippet(markdown, path.join(tempDir, 'test.md'), {
basePath: tempDir,
});
expect(result).to.include('First component');
expect(result).to.include('Second component');
expect(result.match(/```jsx/g)).to.have.lengthOf(2);
});
it('should return original match when demo file is not found', () => {
const markdown = `{{"demo": "NonExistent.js"}}`;
const result = replaceDemoWithSnippet(markdown, path.join(tempDir, 'test.md'), {
basePath: tempDir,
});
expect(result).to.equal(markdown);
});
it('should handle demos with additional properties', () => {
const markdown = `{{"demo": "Button.js", "defaultCodeOpen": false}}`;
fs.writeFileSync(path.join(tempDir, 'Button.js'), 'Button code');
const result = replaceDemoWithSnippet(markdown, path.join(tempDir, 'test.md'), {
basePath: tempDir,
});
expect(result).to.include('```jsx');
expect(result).to.include('Button code');
});
});
describe('processMarkdownFile', () => {
it('should process a markdown file correctly', () => {
const markdownPath = path.join(tempDir, 'test.md');
const markdown = `# Test
{{"demo": "Demo.js"}}`;
fs.writeFileSync(markdownPath, markdown);
fs.writeFileSync(path.join(tempDir, 'Demo.js'), 'Demo content');
const result = processMarkdownFile(markdownPath);
expect(result).to.include('```jsx');
expect(result).to.include('Demo content');
});
it('should handle nested directory structures', () => {
const subDir = path.join(tempDir, 'components', 'buttons');
fs.mkdirSync(subDir, { recursive: true });
const markdownPath = path.join(subDir, 'buttons.md');
const markdown = `{{"demo": "BasicButton.js"}}`;
fs.writeFileSync(markdownPath, markdown);
fs.writeFileSync(path.join(subDir, 'BasicButton.js'), 'Button component');
const result = processMarkdownFile(markdownPath);
expect(result).to.include('Button component');
});
});
});

View File

@@ -0,0 +1,12 @@
{
"extends": "../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "../build/generate-llms-txt",
"tsBuildInfoFile": "../build/generate-llms-txt/.tsbuildinfo",
"noImplicitAny": false,
"strict": false,
"skipLibCheck": true
},
"include": ["./src/*"]
}

View File

@@ -0,0 +1,12 @@
{
"compilerOptions": {
"noEmit": true,
"moduleResolution": "",
"types": ["node", "vitest/globals"],
"strict": true,
"esModuleInterop": true,
"isolatedModules": true
},
"include": ["./src/*.ts", "./test/*.ts"],
"references": [{ "path": "../../docs-utils/tsconfig.build.json" }]
}

View File

@@ -0,0 +1,57 @@
{
"name": "@mui/internal-scripts",
"version": "2.0.16",
"author": "MUI Team",
"description": "Utilities supporting MUI libraries build and docs generation. This is an internal package not meant for general use.",
"exports": {
"./typescript-to-proptypes": {
"types": "./build/typescript-to-proptypes/index.d.ts",
"default": "./build/typescript-to-proptypes/index.js"
},
"./generate-llms-txt": {
"types": "./build/generate-llms-txt/index.d.ts",
"default": "./build/generate-llms-txt/index.js"
}
},
"repository": {
"type": "git",
"url": "git+https://github.com/mui/material-ui.git",
"directory": "packages-internal/scripts"
},
"license": "MIT",
"scripts": {
"prebuild": "rimraf ./build",
"build": "tsc -b tsconfig.json",
"release:publish": "pnpm build && pnpm publish --tag latest",
"release:publish:dry-run": "pnpm build && pnpm publish --tag latest --registry=\"http://localhost:4873/\"",
"test": "pnpm --workspace-root test:unit --project \"*:@mui/internal-scripts\"",
"typescript": "tsc -b tsconfig.typecheck.json"
},
"dependencies": {
"@babel/core": "^7.28.5",
"@babel/plugin-syntax-class-properties": "^7.12.13",
"@babel/plugin-syntax-jsx": "^7.27.1",
"@babel/plugin-syntax-typescript": "^7.27.1",
"@babel/types": "^7.28.5",
"@mui/internal-docs-utils": "workspace:^",
"doctrine": "^3.0.0",
"es-toolkit": "^1.42.0",
"typescript": "^5.9.3"
},
"devDependencies": {
"@babel/register": "^7.28.3",
"@types/babel__core": "^7.20.5",
"@types/chai": "^5.2.3",
"@types/doctrine": "^0.0.9",
"@types/node": "^20.19.25",
"@types/react": "^19.2.7",
"@types/uuid": "^10.0.0",
"chai": "^6.0.1",
"fast-glob": "^3.3.3",
"prettier": "^3.6.2",
"rimraf": "^6.0.1"
},
"publishConfig": {
"access": "public"
}
}

View File

@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2020",
"moduleResolution": "nodenext",
"module": "nodenext",
"types": ["node"],
"strict": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"composite": true,
"esModuleInterop": true,
"isolatedModules": true
}
}

View File

@@ -0,0 +1,5 @@
{
"files": [],
"include": [],
"references": [{ "path": "./typescript-to-proptypes" }, { "path": "./generate-llms-txt" }]
}

View File

@@ -0,0 +1,11 @@
{
"extends": "./tsconfig.base.json",
"compilerOptions": {
"rootDir": "../..",
"types": ["node", "vitest/globals"],
"noEmit": true
},
"include": ["./**/*.ts"],
"exclude": ["./build", "./node_modules", "vitest.config.ts"],
"references": [{ "path": "../../packages-internal/docs-utils/tsconfig.build.json" }]
}

View File

@@ -0,0 +1,179 @@
# Changelog
This file documents changes in the @merceyz's `typescript-to-proptypes` package.
For changes after the package was forked and published as `@mui-internal/typescript-to-proptypes`, see [CHANGELOG.md](./CHANGELOG.md).
All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines.
## [2.0.1](https://github.com/merceyz/typescript-to-proptypes/compare/v2.0.0...v2.0.1) (2020-06-02)
### Bug Fixes
- Use symbol type when there's no baseconstraint ([#23](https://github.com/merceyz/typescript-to-proptypes/issues/23)) ([0b170af](https://github.com/merceyz/typescript-to-proptypes/commit/0b170afb02a2edd1ea0b80406f1a86375c3a13f3))
## [2.0.0](https://github.com/merceyz/typescript-to-proptypes/compare/v1.5.0...v2.0.0) (2020-05-31)
### ⚠ BREAKING CHANGES
- Support for Node versions less than 10.3.0 has been dropped
### Features
- consider squashed call signatures of function components ([#20](https://github.com/merceyz/typescript-to-proptypes/issues/20)) ([514d8ed](https://github.com/merceyz/typescript-to-proptypes/commit/514d8ed55375406a70640d64c4a166aa52e24ae2))
### Bug Fixes
- allow non-string literals ([#21](https://github.com/merceyz/typescript-to-proptypes/issues/21)) ([546e7ad](https://github.com/merceyz/typescript-to-proptypes/commit/546e7addc86198e641d3bfd3dd08ecb55c970600))
### Build System
- drop support for node versions less than 10.3.0 ([2fbca64](https://github.com/merceyz/typescript-to-proptypes/commit/2fbca64e0964509e1a74d29f564be41a78e9fa29))
## [1.5.0](https://github.com/merceyz/typescript-to-proptypes/compare/v1.4.2...v1.5.0) (2020-04-06)
### Features
- **injector:** add reconcilePropTypes ([#10](https://github.com/merceyz/typescript-to-proptypes/issues/10)) ([7b0bff9](https://github.com/merceyz/typescript-to-proptypes/commit/7b0bff9666d1beb1bde445e92fbb702cf1fb3d89))
- add `filenames` to component and proptype nodes ([#9](https://github.com/merceyz/typescript-to-proptypes/issues/9)) ([ce9a700](https://github.com/merceyz/typescript-to-proptypes/commit/ce9a7002c7fda27965b50e0b1af3ecef540a90e5))
- **injector:** add `component` to `shouldInclude` ([#8](https://github.com/merceyz/typescript-to-proptypes/issues/8)) ([18a7fce](https://github.com/merceyz/typescript-to-proptypes/commit/18a7fcee1b3f7d64541fb0f9bd1de72e0ea0db5b))
- **injector:** allow providing babel options ([2ab6f43](https://github.com/merceyz/typescript-to-proptypes/commit/2ab6f43ef4b785d20dd6f951b2f4b928a5521b53))
### Bug Fixes
- check nodeType for dom elements ([#13](https://github.com/merceyz/typescript-to-proptypes/issues/13)) ([fd028e6](https://github.com/merceyz/typescript-to-proptypes/commit/fd028e639bb28383d6e4f925368b6e2afacdbf23))
- replace existing propTypes when removeExistingPropTypes ([#15](https://github.com/merceyz/typescript-to-proptypes/issues/15)) ([3166104](https://github.com/merceyz/typescript-to-proptypes/commit/3166104889d4f58fc22f85800664d2bb1fce6aff))
- **injector:** always call injectPropTypes to allow shouldInclude to run ([277258d](https://github.com/merceyz/typescript-to-proptypes/commit/277258ddc73c3da816aba6fccb739c69dfe8e83a))
- handle all props getting ignored by shouldInclude ([b69112e](https://github.com/merceyz/typescript-to-proptypes/commit/b69112e1011f089b6d5cb60f88ce75b6394252be))
- **parser:** export ParserOptions ([3a5d55e](https://github.com/merceyz/typescript-to-proptypes/commit/3a5d55e68a723208a4b76e79d4bafe92ddf4f85a))
## [1.4.2](https://github.com/merceyz/typescript-to-proptypes/compare/v1.4.1...v1.4.2) (2020-03-27)
### Bug Fixes
- build had a broken output ([97b0326](https://github.com/merceyz/typescript-to-proptypes/commit/97b0326c8b3b811fd5167cefa95a5dc1aa22a212))
## [1.4.1](https://github.com/merceyz/typescript-to-proptypes/compare/v1.4.0...v1.4.1) (2020-03-27)
### Bug Fixes
- include string literal object keys as used ([#5](https://github.com/merceyz/typescript-to-proptypes/issues/5)) ([3fd7b70](https://github.com/merceyz/typescript-to-proptypes/commit/3fd7b703d30e650e6692f87d3929d4ae67314cb6))
- unknown can be optional ([#7](https://github.com/merceyz/typescript-to-proptypes/issues/7)) ([c5e8ca3](https://github.com/merceyz/typescript-to-proptypes/commit/c5e8ca31e2cae20216b1f7e45c9f3ef5198b2f93))
## [1.4.0](https://github.com/merceyz/typescript-to-proptypes/compare/v1.3.0...v1.4.0) (2019-11-16)
### Bug Fixes
- **parser:** handle prop of type ReactElement ([adfcca4](https://github.com/merceyz/typescript-to-proptypes/commit/adfcca4))
### Features
- **parser:** support forwardRef ([3f5c0c9](https://github.com/merceyz/typescript-to-proptypes/commit/3f5c0c9)), closes [#2](https://github.com/merceyz/typescript-to-proptypes/issues/2)
## [1.3.0](https://github.com/merceyz/typescript-to-proptypes/compare/v1.2.5...v1.3.0) (2019-09-03)
### Features
- **generator:** add comment to proptype blocks ([2c5627e](https://github.com/merceyz/typescript-to-proptypes/commit/2c5627e))
### [1.2.5](https://github.com/merceyz/typescript-to-proptypes/compare/v1.2.4...v1.2.5) (2019-09-03)
### Bug Fixes
- **parser:** use doctrine to unwrap comments ([53a9d43](https://github.com/merceyz/typescript-to-proptypes/commit/53a9d43))
### Tests
- add missing test config ([d00c7f2](https://github.com/merceyz/typescript-to-proptypes/commit/d00c7f2))
## [1.2.4](https://github.com/merceyz/typescript-to-proptypes/compare/v1.2.3...v1.2.4) (2019-08-16)
### Bug Fixes
- **injector:** use require.resolve ([b9d04ea](https://github.com/merceyz/typescript-to-proptypes/commit/b9d04ea))
## [1.2.3](https://github.com/merceyz/typescript-to-proptypes/compare/v1.2.2...v1.2.3) (2019-07-24)
### Bug Fixes
- **parser:** handle return type of JSX.Element | null ([cbe5564](https://github.com/merceyz/typescript-to-proptypes/commit/cbe5564))
## [1.2.2](https://github.com/merceyz/typescript-to-proptypes/compare/v1.2.1...v1.2.2) (2019-07-23)
### Bug Fixes
- **parser:** remove leftover asterisk ([2e720df](https://github.com/merceyz/typescript-to-proptypes/commit/2e720df))
## [1.2.1](https://github.com/merceyz/typescript-to-proptypes/compare/v1.2.0...v1.2.1) (2019-07-23)
### Bug Fixes
- **parser:** handle single line comments ([0025d53](https://github.com/merceyz/typescript-to-proptypes/commit/0025d53))
## [1.2.0](https://github.com/merceyz/typescript-to-proptypes/compare/v1.1.0...v1.2.0) (2019-07-23)
### Bug Fixes
- **generator:** multiline comments ([d576597](https://github.com/merceyz/typescript-to-proptypes/commit/d576597))
- **generator:** sort interface correctly ([f88c5fb](https://github.com/merceyz/typescript-to-proptypes/commit/f88c5fb))
- **generator:** wrap prop name in quotes ([709a819](https://github.com/merceyz/typescript-to-proptypes/commit/709a819))
- **parser:** don't modify comments ([95cd63e](https://github.com/merceyz/typescript-to-proptypes/commit/95cd63e))
- **parser:** fallback to object if element is undefined ([eadaf3f](https://github.com/merceyz/typescript-to-proptypes/commit/eadaf3f))
- **parser:** handle comments with just tags ([d0b0a82](https://github.com/merceyz/typescript-to-proptypes/commit/d0b0a82))
- **parser:** handle comments with tags ([ad4dddd](https://github.com/merceyz/typescript-to-proptypes/commit/ad4dddd))
- **parser:** handle optional any ([30f56ec](https://github.com/merceyz/typescript-to-proptypes/commit/30f56ec))
- **parser:** handle optional React.ElementType ([c7a87fd](https://github.com/merceyz/typescript-to-proptypes/commit/c7a87fd))
- **parser:** treat ComponentType as elementType ([53f1e21](https://github.com/merceyz/typescript-to-proptypes/commit/53f1e21))
- export TypeScript as "ts" ([ba90e22](https://github.com/merceyz/typescript-to-proptypes/commit/ba90e22))
### Features
- **generator:** support instanceOf ([6bd563a](https://github.com/merceyz/typescript-to-proptypes/commit/6bd563a))
- **injector:** control included props ([4f8eaa1](https://github.com/merceyz/typescript-to-proptypes/commit/4f8eaa1))
- **injector:** remove existing proptypes ([d2a978c](https://github.com/merceyz/typescript-to-proptypes/commit/d2a978c))
- **parser:** check const declarations of React.ComponentType ([cbd2eb6](https://github.com/merceyz/typescript-to-proptypes/commit/cbd2eb6))
- **parser:** handle React.Component and Element instanceOf ([570d73b](https://github.com/merceyz/typescript-to-proptypes/commit/570d73b))
- **parser:** support elementType ([448d5a6](https://github.com/merceyz/typescript-to-proptypes/commit/448d5a6))
## [1.1.0](https://github.com/merceyz/typescript-to-proptypes/compare/v1.0.4...v1.1.0) (2019-07-15)
### Bug Fixes
- **generator:** don't pass shouldInclude on interfaceNode ([1302502](https://github.com/merceyz/typescript-to-proptypes/commit/1302502))
### Features
- **parser:** circular references ([7de51cc](https://github.com/merceyz/typescript-to-proptypes/commit/7de51cc))
- **parser:** control included proptypes ([2952e78](https://github.com/merceyz/typescript-to-proptypes/commit/2952e78))
- **parser:** objects / shapes ([81f1a82](https://github.com/merceyz/typescript-to-proptypes/commit/81f1a82))
## [1.0.4](https://github.com/merceyz/typescript-to-proptypes/compare/v1.0.3...v1.0.4) (2019-07-10)
### Bug Fixes
- **generator:** omit null if proptype is optional ([21351a4](https://github.com/merceyz/typescript-to-proptypes/commit/21351a4))
- **parser:** reactnode should make proptype optional ([c84b611](https://github.com/merceyz/typescript-to-proptypes/commit/c84b611))
## [1.0.3](https://github.com/merceyz/typescript-to-proptypes/compare/v1.0.2...v1.0.3) (2019-07-10)
### Bug Fixes
- export types ([7583291](https://github.com/merceyz/typescript-to-proptypes/commit/7583291))
## [1.0.2](https://github.com/merceyz/typescript-to-proptypes/compare/v1.0.1...v1.0.2) (2019-07-09)
### Bug Fixes
- **injector:** don't visit FunctionDeclarations more than once ([236276b](https://github.com/merceyz/typescript-to-proptypes/commit/236276b))
## [1.0.1](https://github.com/merceyz/typescript-to-proptypes/compare/v1.0.0...v1.0.1) (2019-07-09)
### Bug Fixes
- **injector:** don't import prop-types if it's already imported ([9d4dfd1](https://github.com/merceyz/typescript-to-proptypes/commit/9d4dfd1))
- **injector:** insert import after the first one ([6cb31a0](https://github.com/merceyz/typescript-to-proptypes/commit/6cb31a0))
## 1.0.0 (2019-07-08)
### Build System
- disable incremental ([37b0277](https://github.com/merceyz/typescript-to-proptypes/commit/37b0277))

View File

@@ -0,0 +1,174 @@
import ts from 'typescript';
import { uniqBy } from 'es-toolkit/array';
import {
PropType,
ArrayType,
LiteralType,
BooleanType,
AnyType,
UnionType,
BasePropType,
ElementType,
DOMElementType,
InstanceOfType,
InterfaceType,
FunctionType,
StringType,
ObjectType,
NumericType,
} from './models';
import getTypeHash from './getTypeHash';
export function createAnyType(init: { jsDoc: string | undefined }): AnyType {
return {
type: 'any',
jsDoc: init.jsDoc,
};
}
export function createArrayType(init: {
arrayType: PropType;
jsDoc: string | undefined;
}): ArrayType {
return {
type: 'array',
jsDoc: init.jsDoc,
arrayType: init.arrayType,
};
}
export function createBooleanType(init: { jsDoc: string | undefined }): BooleanType {
return {
type: 'boolean',
jsDoc: init.jsDoc,
};
}
export function createDOMElementType(init: {
optional: boolean | undefined;
jsDoc: string | undefined;
}): DOMElementType {
return {
type: 'DOMElementNode',
jsDoc: init.jsDoc,
optional: init.optional,
};
}
export function createElementType(init: {
elementType: ElementType['elementType'];
jsDoc: string | undefined;
}): ElementType {
return {
type: 'ElementNode',
jsDoc: init.jsDoc,
elementType: init.elementType,
};
}
export function createFunctionType(init: { jsDoc: string | undefined }): FunctionType {
return {
type: 'FunctionNode',
jsDoc: init.jsDoc,
};
}
export function createInstanceOfType(init: {
jsDoc: string | undefined;
instance: string;
}): InstanceOfType {
return {
type: 'InstanceOfNode',
instance: init.instance,
jsDoc: init.jsDoc,
};
}
export function createInterfaceType(init: {
jsDoc: string | undefined;
types: ReadonlyArray<[string, PropType]> | undefined;
}): InterfaceType {
return {
type: 'InterfaceNode',
jsDoc: init.jsDoc,
types: init.types ?? [],
};
}
export function createLiteralType(init: {
value: string | number | ts.PseudoBigInt;
jsDoc: string | undefined;
}): LiteralType {
return {
type: 'LiteralNode',
value: init.value,
jsDoc: init.jsDoc,
};
}
export function createNumericType(init: { jsDoc: string | undefined }): NumericType {
return {
type: 'NumericNode',
jsDoc: init.jsDoc,
};
}
export function createObjectType(init: { jsDoc: string | undefined }): ObjectType {
return {
type: 'ObjectNode',
jsDoc: init.jsDoc,
};
}
export function createStringType(init: { jsDoc: string | undefined }): StringType {
return {
type: 'StringNode',
jsDoc: init.jsDoc,
};
}
export interface UndefinedType extends BasePropType {
type: 'UndefinedNode';
}
export function createUndefinedType(init: { jsDoc: string | undefined }): UndefinedType {
return {
type: 'UndefinedNode',
jsDoc: init.jsDoc,
};
}
export function uniqueUnionTypes(node: UnionType): UnionType {
return {
type: node.type,
jsDoc: node.jsDoc,
types: uniqBy(node.types, (type) => {
return getTypeHash(type);
}),
};
}
export function createUnionType(init: {
jsDoc: string | undefined;
types: readonly PropType[];
}): UnionType {
const flatTypes: PropType[] = [];
function flattenTypes(nodes: readonly PropType[]) {
nodes.forEach((type) => {
if (type.type === 'UnionNode') {
flattenTypes(type.types);
} else {
flatTypes.push(type);
}
});
}
flattenTypes(init.types);
return uniqueUnionTypes({
type: 'UnionNode',
jsDoc: init.jsDoc,
types: flatTypes,
});
}

View File

@@ -0,0 +1,347 @@
import partition from 'es-toolkit/compat/partition';
import { PropTypeDefinition, PropTypesComponent, PropType, LiteralType } from './models';
import { createDOMElementType, createBooleanType, uniqueUnionTypes } from './createType';
export interface GeneratePropTypesOptions {
/**
* If source itself written in typescript prop-types disable prop-types validation
* by injecting propTypes as
* ```jsx
* .propTypes = { ... } as any
* ```
*/
disablePropTypesTypeChecking?: boolean;
/**
* Set to true if you want to make sure `babel-plugin-transform-react-remove-prop-types` recognizes the generated .propTypes.
*/
ensureBabelPluginTransformReactRemovePropTypesIntegration?: boolean;
/**
* Enable/disable the default sorting (ascending) or provide your own sort function
* @default true
*/
sortProptypes?: boolean | ((a: PropTypeDefinition, b: PropTypeDefinition) => 0 | -1 | 1);
/**
* The name used when importing prop-types
* @default 'PropTypes'
*/
importedName?: string;
/**
* Enable/disable including JSDoc comments
* @default true
*/
includeJSDoc?: boolean;
/**
* Previous source code of the validator for each prop type
*/
previousPropTypesSource?: Map<string, string>;
/**
* Given the `prop`, the `previous` source of the validator and the `generated` source:
* What source should be injected? `previous` is `undefined` if the validator
* didn't exist before
* @default Uses `generated` source
*/
reconcilePropTypes?(
proptype: PropTypeDefinition,
previous: string | undefined,
generated: string,
): string;
/**
* Control which PropTypes are included in the final result
* @param proptype The current PropType about to be converted to text
*/
shouldInclude?(proptype: PropTypeDefinition): boolean | undefined;
/**
* A comment that will be added to the start of the PropTypes code block
* @example
* foo.propTypes = {
* // Comment goes here
* }
*/
comment?: string;
/**
* Overrides the given `sortLiteralUnions` based on the proptype.
* If `undefined` is returned the default `sortLiteralUnions` will be used.
*/
getSortLiteralUnions?: (
component: PropTypesComponent,
propType: PropTypeDefinition,
) => ((a: LiteralType, b: LiteralType) => number) | undefined;
/**
* By default literals in unions are sorted by:
* - numbers last, ascending
* - anything else by their stringified value using localeCompare
*/
sortLiteralUnions?: (a: LiteralType, b: LiteralType) => number;
}
function defaultSortLiteralUnions(a: LiteralType, b: LiteralType) {
const { value: valueA } = a;
const { value: valueB } = b;
// numbers ascending
if (typeof valueA === 'number' && typeof valueB === 'number') {
return valueA - valueB;
}
// numbers last
if (typeof valueA === 'number') {
return 1;
}
if (typeof valueB === 'number') {
return -1;
}
// sort anything else by their stringified value
return String(valueA).localeCompare(String(valueB));
}
/**
* Generates code from the given component
* @param component The component to convert to code
* @param options The options used to control the way the code gets generated
*/
export function generatePropTypes(
component: PropTypesComponent,
options: GeneratePropTypesOptions = {},
): string {
const {
disablePropTypesTypeChecking = false,
ensureBabelPluginTransformReactRemovePropTypesIntegration = false,
importedName = 'PropTypes',
includeJSDoc = true,
sortProptypes = true,
previousPropTypesSource = new Map<string, string>(),
reconcilePropTypes = (_prop: PropTypeDefinition, _previous: string, generated: string) =>
generated,
shouldInclude,
getSortLiteralUnions = () => defaultSortLiteralUnions,
} = options;
function jsDoc(documentedNode: PropTypeDefinition | LiteralType): string {
if (!includeJSDoc || !documentedNode.jsDoc) {
return '';
}
return `/**\n* ${documentedNode.jsDoc
.split(/\r?\n/)
.reduce((prev, curr) => `${prev}\n* ${curr}`)}\n*/\n`;
}
function generatePropType(
propType: PropType,
context: { component: PropTypesComponent; propTypeDefinition: PropTypeDefinition },
): string {
if (propType.type === 'InterfaceNode') {
return `${importedName}.shape({\n${propType.types
.slice()
.sort((a, b) => a[0].localeCompare(b[0]))
.map(([name, type]) => {
let regex = /^(UnionNode|DOMElementNode)$/;
if (name !== 'children') {
regex = /^(UnionNode|DOMElementNode|ElementNode)$/;
}
return `"${name}": ${generatePropType(type, context)}${
!type.type.match(regex) ? '.isRequired' : ''
}`;
})
.join(',\n')}\n})`;
}
if (propType.type === 'FunctionNode') {
return `${importedName}.func`;
}
if (propType.type === 'StringNode') {
return `${importedName}.string`;
}
if (propType.type === 'boolean') {
return `${importedName}.bool`;
}
if (propType.type === 'NumericNode') {
return `${importedName}.number`;
}
if (propType.type === 'LiteralNode') {
return `${importedName}.oneOf([${jsDoc(propType)}${propType.value}])`;
}
if (propType.type === 'ObjectNode') {
return `${importedName}.object`;
}
if (propType.type === 'any') {
// key isn't a prop like the others, see
// https://github.com/mui/material-ui/issues/25304
if (context.propTypeDefinition.name === 'key') {
return '() => null';
}
return `${importedName}.any`;
}
if (propType.type === 'ElementNode') {
return `${importedName}.${propType.elementType}`;
}
if (propType.type === 'InstanceOfNode') {
return `${importedName}.instanceOf(${propType.instance})`;
}
if (propType.type === 'DOMElementNode') {
return `(props, propName) => {
if (props[propName] == null) {
return ${
propType.optional
? 'null'
: `new Error(\`Prop '\${propName}' is required but wasn't specified\`)`
}
}
if (typeof props[propName] !== 'object' || props[propName].nodeType !== 1) {
return new Error(\`Expected prop '\${propName}' to be of type Element\`)
}
return null;
}`;
}
if (propType.type === 'array') {
if (propType.arrayType.type === 'any') {
return `${importedName}.array`;
}
return `${importedName}.arrayOf(${generatePropType(propType.arrayType, context)})`;
}
if (propType.type === 'UnionNode') {
const uniqueTypes = uniqueUnionTypes(propType).types;
const isOptional = uniqueTypes.some(
(type) =>
type.type === 'UndefinedNode' || (type.type === 'LiteralNode' && type.value === 'null'),
);
const nonNullishUniqueTypes = uniqueTypes.filter((type) => {
return (
type.type !== 'UndefinedNode' && !(type.type === 'LiteralNode' && type.value === 'null')
);
});
if (uniqueTypes.length === 2 && uniqueTypes.some((type) => type.type === 'DOMElementNode')) {
return generatePropType(
createDOMElementType({ jsDoc: undefined, optional: isOptional }),
context,
);
}
let [literals, rest] = partition(
isOptional ? nonNullishUniqueTypes : uniqueTypes,
(type): type is LiteralType => type.type === 'LiteralNode',
);
const sortLiteralUnions =
getSortLiteralUnions(context.component, context.propTypeDefinition) ||
defaultSortLiteralUnions;
literals = literals.sort(sortLiteralUnions);
const nodeToStringName = (type: PropType): string => {
if (type.type === 'InstanceOfNode') {
return `${type.type}.${type.instance}`;
}
if (type.type === 'InterfaceNode') {
// An interface is PropTypes.shape
// Use `ShapeNode` to get it sorted in the correct order
return `ShapeNode`;
}
return type.type;
};
rest = rest.sort((a, b) => nodeToStringName(a).localeCompare(nodeToStringName(b)));
if (literals.find((x) => x.value === 'true') && literals.find((x) => x.value === 'false')) {
rest.push(createBooleanType({ jsDoc: undefined }));
literals = literals.filter((x) => x.value !== 'true' && x.value !== 'false');
}
const literalProps =
literals.length !== 0
? `${importedName}.oneOf([${literals
.map((x) => `${jsDoc(x)}${x.value}`)
.reduce((prev, curr) => `${prev},${curr}`)}])`
: '';
if (rest.length === 0) {
return `${literalProps}${isOptional ? '' : '.isRequired'}`;
}
if (literals.length === 0 && rest.length === 1) {
return `${generatePropType(rest[0], context)}${isOptional ? '' : '.isRequired'}`;
}
return `${importedName}.oneOfType([${literalProps ? `${literalProps}, ` : ''}${rest
.map((type) => generatePropType(type, context))
.reduce((prev, curr) => `${prev},${curr}`)}])${isOptional ? '' : '.isRequired'}`;
}
throw new Error(
`Nothing to handle node of type "${propType.type}" in "${context.propTypeDefinition.name}"`,
);
}
function generatePropTypeDefinition(
propTypeDefinition: PropTypeDefinition,
context: { component: PropTypesComponent },
): string {
let isRequired: boolean | undefined = true;
if (propTypeDefinition.propType.type === 'DOMElementNode') {
// DOMElement generator decides
isRequired = undefined;
} else if (propTypeDefinition.propType.type === 'UnionNode') {
// union generator decides
isRequired = undefined;
}
const validatorSource = reconcilePropTypes(
propTypeDefinition,
previousPropTypesSource.get(propTypeDefinition.name),
`${generatePropType(propTypeDefinition.propType, {
component: context.component,
propTypeDefinition,
})}${isRequired === true ? '.isRequired' : ''}`,
);
return `${jsDoc(propTypeDefinition)}"${propTypeDefinition.name}": ${validatorSource},`;
}
const propTypes = component.types.slice();
if (typeof sortProptypes === 'function') {
propTypes.sort(sortProptypes);
} else if (sortProptypes === true) {
propTypes.sort((a, b) => a.name.localeCompare(b.name));
}
let filteredNodes = propTypes;
if (shouldInclude) {
filteredNodes = filteredNodes.filter((type) => shouldInclude(type));
}
if (filteredNodes.length === 0) {
return '';
}
const generated = filteredNodes
.map((prop) => generatePropTypeDefinition(prop, { component }))
.reduce((prev, curr) => `${prev}\n${curr}`);
if (generated.length === 0) {
return '';
}
const comment =
options.comment &&
`// ${options.comment.split(/\r?\n/gm).reduce((prev, curr) => `${prev}\n// ${curr}`)}\n`;
const propTypesMemberTrailingComment = ensureBabelPluginTransformReactRemovePropTypesIntegration
? '/* remove-proptypes */'
: '';
const propTypesCasting = disablePropTypesTypeChecking ? ' as any' : '';
const propTypesBanner = comment !== undefined ? comment : '';
return `${component.name}.propTypes ${propTypesMemberTrailingComment}= {\n${propTypesBanner}${generated}\n}${propTypesCasting}`;
}

View File

@@ -0,0 +1,675 @@
import ts from 'typescript';
import * as doctrine from 'doctrine';
import {
GetPropsFromComponentDeclarationOptions,
getPropsFromComponentNode,
TypeScriptProject,
} from '@mui/internal-docs-utils';
import {
createUnionType,
createUndefinedType,
createAnyType,
createElementType,
createDOMElementType,
createArrayType,
createFunctionType,
createInstanceOfType,
createLiteralType,
createInterfaceType,
createNumericType,
createObjectType,
createStringType,
} from './createType';
import { PropTypeDefinition, PropTypesComponent, PropType } from './models';
function getSymbolFileNames(symbol: ts.Symbol): Set<string> {
const declarations = symbol.getDeclarations() || [];
return new Set(declarations.map((declaration) => declaration.getSourceFile().fileName));
}
function getSymbolDocumentation({
symbol,
project,
}: {
symbol: ts.Symbol | undefined;
project: TypeScriptProject;
}): string | undefined {
if (symbol === undefined) {
return undefined;
}
const decl = symbol.getDeclarations();
if (decl) {
// @ts-ignore
const comments = ts.getJSDocCommentsAndTags(decl[0]) as readonly any[];
if (comments && comments.length === 1) {
const commentNode = comments[0];
if (ts.isJSDoc(commentNode)) {
return doctrine.unwrapComment(commentNode.getText()).trim();
}
}
}
const comment = ts.displayPartsToString(symbol.getDocumentationComment(project.checker));
return comment !== '' ? comment : undefined;
}
function getType({
project,
symbol,
declaration,
location,
}: {
project: PropTypesProject;
symbol: ts.Symbol;
declaration: ts.Declaration | undefined;
location: ts.Node;
}) {
const symbolType = declaration
? // The proptypes aren't detailed enough that we need all the different combinations
// so we just pick the first and ignore the rest
project.checker.getTypeOfSymbolAtLocation(symbol, declaration)
: project.checker.getTypeOfSymbolAtLocation(symbol, location);
let type: ts.Type;
if (declaration === undefined) {
type = symbolType;
} else {
const declaredType = project.checker.getTypeAtLocation(declaration);
const baseConstraintOfType = project.checker.getBaseConstraintOfType(declaredType);
if (baseConstraintOfType === undefined || baseConstraintOfType === declaredType) {
type = symbolType;
}
// get `React.ElementType` from `C extends React.ElementType`
else if (baseConstraintOfType.aliasSymbol?.escapedName === 'ElementType') {
type = baseConstraintOfType;
} else {
type = symbolType;
}
}
if (!type) {
throw new Error('No types found');
}
return type;
}
function checkType({
type,
location,
typeStack,
name,
project,
}: {
type: ts.Type;
location: ts.Node;
typeStack: readonly number[];
name: string;
project: PropTypesProject;
}): PropType {
// If the typeStack contains type.id we're dealing with an object that references itself.
// To prevent getting stuck in an infinite loop we just set it to an createObjectType
if (typeStack.includes((type as any).id)) {
return createObjectType({ jsDoc: undefined });
}
const typeNode = type as any;
const symbol = typeNode.aliasSymbol ? typeNode.aliasSymbol : typeNode.symbol;
const jsDoc = getSymbolDocumentation({ symbol, project });
{
const typeName = symbol ? project.checker.getFullyQualifiedName(symbol) : null;
switch (typeName) {
// Remove once global JSX namespace is no longer used by React
case 'global.JSX.Element':
case 'React.JSX.Element':
case 'React.ReactElement': {
return createElementType({ jsDoc, elementType: 'element' });
}
case 'React.ComponentType':
case 'React.ElementType': {
return createElementType({
jsDoc,
elementType: 'elementType',
});
}
case 'React.ReactNode': {
return createUnionType({
jsDoc,
types: [
createElementType({ elementType: 'node', jsDoc: undefined }),
createUndefinedType({ jsDoc: undefined }),
],
});
}
case 'React.Component': {
return createInstanceOfType({ jsDoc, instance: typeName });
}
case 'Element':
case 'HTMLElement': {
return createDOMElementType({ jsDoc, optional: undefined });
}
case 'RegExp': {
return createInstanceOfType({ jsDoc, instance: 'RegExp' });
}
case 'URL': {
return createInstanceOfType({ jsDoc, instance: 'URL' });
}
case 'URLSearchParams': {
return createInstanceOfType({ jsDoc, instance: 'URLSearchParams' });
}
case 'Date': {
if (!project.shouldUseObjectForDate?.({ name })) {
return createInstanceOfType({ jsDoc, instance: 'Date' });
}
return createObjectType({ jsDoc });
}
default:
// continue with function execution
break;
}
}
if (project.checker.isArrayType(type)) {
// @ts-ignore
const arrayType: ts.Type = project.checker.getElementTypeOfArrayType(type);
return createArrayType({
arrayType: checkType({ type: arrayType, location, typeStack, name, project }),
jsDoc,
});
}
const isTupleType = project.checker.isTupleType(type);
if (isTupleType) {
return createArrayType({
arrayType: createUnionType({
jsDoc: undefined,
types: (type as any).typeArguments.map((x: ts.Type) =>
checkType({ type: x, location, typeStack, name, project }),
),
}),
jsDoc,
});
}
if (type.isUnion()) {
const hasStringIntersection = type.types.some((t) => {
if (t.isIntersection && t.isIntersection()) {
const hasString = t.types.some((it) => it.flags & ts.TypeFlags.String);
const hasEmptyObject = t.types.some(
(it) =>
it.flags & ts.TypeFlags.Object &&
(!it.symbol || !it.symbol.members || it.symbol.members.size === 0),
);
return hasString && hasEmptyObject;
}
return false;
});
if (hasStringIntersection) {
const hasLiterals = type.types.some((t) => t.flags & ts.TypeFlags.Literal);
if (hasLiterals) {
const hasUndefined = type.types.some((t) => t.flags & ts.TypeFlags.Undefined);
if (hasUndefined) {
return createUnionType({
jsDoc,
types: [
createStringType({ jsDoc: undefined }),
createUndefinedType({ jsDoc: undefined }),
],
});
}
return createStringType({ jsDoc });
}
}
const node = createUnionType({
jsDoc,
types: type.types.map((x) => checkType({ type: x, location, typeStack, name, project })),
});
return node.types.length === 1 ? node.types[0] : node;
}
if (type.isIntersection && type.isIntersection()) {
const hasString = type.types.some((t) => t.flags & ts.TypeFlags.String);
const hasEmptyObject = type.types.some(
(t) =>
t.flags & ts.TypeFlags.Object &&
(!t.symbol || !t.symbol.members || t.symbol.members.size === 0),
);
if (hasString && hasEmptyObject) {
return createStringType({ jsDoc });
}
}
if (type.flags & ts.TypeFlags.TypeParameter) {
const baseConstraintOfType = project.checker.getBaseConstraintOfType(type);
if (baseConstraintOfType) {
if (
baseConstraintOfType.flags & ts.TypeFlags.Object &&
baseConstraintOfType.symbol.members?.size === 0
) {
return createAnyType({ jsDoc });
}
return checkType({ type: baseConstraintOfType!, location, typeStack, name, project });
}
}
if (type.flags & ts.TypeFlags.String) {
return createStringType({ jsDoc });
}
if (type.flags & ts.TypeFlags.Number) {
return createNumericType({ jsDoc });
}
if (type.flags & ts.TypeFlags.Undefined) {
return createUndefinedType({ jsDoc });
}
if (type.flags & ts.TypeFlags.Any || type.flags & ts.TypeFlags.Unknown) {
return createAnyType({ jsDoc });
}
if (type.flags & ts.TypeFlags.Literal) {
if (type.isLiteral()) {
return createLiteralType({
value: type.isStringLiteral() ? `"${type.value}"` : type.value,
jsDoc,
});
}
return createLiteralType({
jsDoc,
value: project.checker.typeToString(type),
});
}
if (type.flags & ts.TypeFlags.Null) {
return createLiteralType({ jsDoc, value: 'null' });
}
if (type.flags & ts.TypeFlags.IndexedAccess) {
const objectType = (type as ts.IndexedAccessType).objectType;
if (objectType.flags & ts.TypeFlags.Conditional) {
const node = createUnionType({
jsDoc,
types: [
(objectType as ts.ConditionalType).resolvedTrueType,
(objectType as ts.ConditionalType).resolvedFalseType,
]
.map((resolveType) => resolveType?.getProperty(name))
.filter((propertySymbol): propertySymbol is ts.Symbol => !!propertySymbol)
.map((propertySymbol) =>
checkType({
type: getType({
project,
symbol: propertySymbol,
declaration: propertySymbol.declarations?.[0],
location,
}),
location,
typeStack,
name,
project,
}),
),
});
return node.types.length === 1 ? node.types[0] : node;
}
}
if (type.getCallSignatures().length) {
return createFunctionType({ jsDoc });
}
// () => new ClassInstance
if (type.getConstructSignatures().length) {
return createFunctionType({ jsDoc });
}
// Object-like type
{
const properties = type.getProperties();
if (properties.length) {
if (
project.shouldResolveObject({
name,
propertyCount: properties.length,
depth: typeStack.length,
})
) {
const filtered = properties.filter((x) =>
project.shouldInclude({ name: x.getName(), depth: typeStack.length + 1 }),
);
if (filtered.length > 0) {
return createInterfaceType({
jsDoc,
types: filtered.map((x) => {
const definition = checkSymbol({
symbol: x,
location,
project,
typeStack: [...typeStack, (type as any).id],
});
definition.propType.jsDoc = definition.jsDoc;
return [definition.name, definition.propType];
}),
});
}
}
return createObjectType({ jsDoc });
}
}
// Object without properties or object keyword
if (
type.flags & ts.TypeFlags.Object ||
(type.flags & ts.TypeFlags.NonPrimitive && project.checker.typeToString(type) === 'object')
) {
return createObjectType({ jsDoc });
}
console.warn(
`${project.reactComponentName}: Unable to handle node of type "ts.TypeFlags.${
ts.TypeFlags[type.flags]
}", using any`,
);
return createAnyType({ jsDoc });
}
function checkSymbol({
project,
symbol,
location,
typeStack,
}: {
project: PropTypesProject;
symbol: ts.Symbol;
location: ts.Node;
typeStack: readonly number[];
}): PropTypeDefinition {
const declarations = symbol.getDeclarations();
const declaration = declarations && declarations[0];
const symbolFilenames = getSymbolFileNames(symbol);
const jsDoc = getSymbolDocumentation({ symbol, project });
// TypeChecker keeps the name for
// { a: React.ElementType, b: React.ReactElement | boolean }
// but not
// { a?: React.ElementType, b: React.ReactElement }
// get around this by not using the TypeChecker
if (
declaration &&
ts.isPropertySignature(declaration) &&
declaration.type &&
ts.isTypeReferenceNode(declaration.type)
) {
const name = declaration.type.typeName.getText();
if (
name === 'React.ElementType' ||
name === 'React.ComponentType' ||
name === 'React.JSXElementConstructor' ||
name === 'React.ReactElement'
) {
const elementNode = createElementType({
elementType: name === 'React.ReactElement' ? 'element' : 'elementType',
jsDoc,
});
return {
$$id: project.createPropTypeId(symbol),
name: symbol.getName(),
jsDoc,
filenames: symbolFilenames,
propType: declaration.questionToken
? createUnionType({
jsDoc: elementNode.jsDoc,
types: [
createUndefinedType({ jsDoc: undefined }),
{
...elementNode,
// jsDoc was hoisted to the union type
jsDoc: undefined,
},
],
})
: elementNode,
};
}
}
const type = getType({ project, symbol, declaration, location });
// Typechecker only gives the type "any" if it's present in a union
// This means the type of "a" in {a?:any} isn't "any | undefined"
// So instead we check for the questionmark to detect optional types
let parsedType: PropType | undefined;
if (
(type.flags & ts.TypeFlags.Any || type.flags & ts.TypeFlags.Unknown) &&
declaration &&
ts.isPropertySignature(declaration)
) {
parsedType =
symbol.flags & ts.SymbolFlags.Optional
? createUnionType({
jsDoc,
types: [createUndefinedType({ jsDoc: undefined }), createAnyType({ jsDoc: undefined })],
})
: createAnyType({ jsDoc });
} else {
parsedType = checkType({ type, location, typeStack, name: symbol.getName(), project });
}
return {
$$id: project.createPropTypeId(type),
name: symbol.getName(),
jsDoc,
propType: parsedType,
filenames: symbolFilenames,
};
}
/**
* Squashes props from:
* { variant: 'a', href: string } & { variant: 'b' }
* Into:
* { variant: 'a' | 'b', href?: string }
*/
function squashPropTypeDefinitions({
propTypeDefinitions,
onlyUsedInSomeSignatures,
}: {
propTypeDefinitions: PropTypeDefinition[];
onlyUsedInSomeSignatures: boolean;
}): PropTypeDefinition {
const distinctDefinitions = new Map<number, PropTypeDefinition>();
propTypeDefinitions.forEach((definition) => {
if (!distinctDefinitions.has(definition.$$id)) {
distinctDefinitions.set(definition.$$id, definition);
}
});
if (distinctDefinitions.size === 1 && !onlyUsedInSomeSignatures) {
return propTypeDefinitions[0];
}
const definitions = Array.from(distinctDefinitions.values());
const types = definitions.map((definition) => definition.propType);
if (onlyUsedInSomeSignatures) {
types.push(createUndefinedType({ jsDoc: undefined }));
}
return {
name: definitions[0].name,
jsDoc: definitions[0].jsDoc,
propType: createUnionType({
// TODO: jsDoc from squashing is dropped
jsDoc: undefined,
types,
}),
filenames: new Set(definitions.flatMap((definition) => Array.from(definition.filenames))),
$$id: definitions[0].$$id,
};
}
function generatePropTypesFromNode(
params: Omit<GetPropsFromComponentDeclarationOptions, 'project'> & { project: PropTypesProject },
): PropTypesComponent | null {
const parsedComponent = getPropsFromComponentNode(params);
if (parsedComponent == null) {
return null;
}
const propsFilename =
parsedComponent.sourceFile !== undefined ? parsedComponent.sourceFile.fileName : undefined;
const types = Object.values(parsedComponent.props).map((prop) => {
const propTypeDefinitions = prop.signatures.map(({ symbol, componentType }) =>
checkSymbol({
symbol,
project: params.project,
location: parsedComponent.location,
typeStack: [(componentType as any).id],
}),
);
return squashPropTypeDefinitions({
propTypeDefinitions,
onlyUsedInSomeSignatures: prop.onlyUsedInSomeSignatures,
});
});
return {
name: parsedComponent.name,
types,
propsFilename,
};
}
export function getPropTypesFromFile({
filePath,
project,
shouldInclude: inShouldInclude,
shouldResolveObject: inShouldResolveObject,
shouldUseObjectForDate,
checkDeclarations,
}: GetPropTypesFromFileOptions) {
const sourceFile = project.program.getSourceFile(filePath);
const reactComponentName = filePath.match(/.*\/([^/]+)/)?.[1];
const components: PropTypesComponent[] = [];
const sigilIds: Map<ts.Symbol | ts.Type, number> = new Map();
/**
*
* @param sigil - Prefer ts.Type if available since these are re-used in the type checker. Symbols (especially those for literals) are oftentimes re-created on every usage.
*/
function createPropTypeId(sigil: ts.Symbol | ts.Type) {
if (!sigilIds.has(sigil)) {
sigilIds.set(sigil, sigilIds.size);
}
return sigilIds.get(sigil)!;
}
const shouldResolveObject: PropTypesProject['shouldResolveObject'] = (data) => {
if (inShouldResolveObject) {
const result = inShouldResolveObject(data);
if (result !== undefined) {
return result;
}
}
return data.propertyCount <= 50 && data.depth <= 3;
};
const shouldInclude: PropTypesProject['shouldInclude'] = (data): boolean => {
// ref is a reserved prop name in React
// for example https://github.com/reactjs/rfcs/pull/107
// no need to add a prop-type
if (data.name === 'ref') {
return false;
}
if (inShouldInclude) {
const result = inShouldInclude(data);
if (result !== undefined) {
return result;
}
}
return true;
};
const propTypesProject: PropTypesProject = {
...project,
reactComponentName,
shouldResolveObject,
shouldUseObjectForDate,
shouldInclude,
createPropTypeId,
};
if (sourceFile) {
ts.forEachChild(sourceFile, (node) => {
const component = generatePropTypesFromNode({
project: propTypesProject,
node,
shouldInclude,
checkDeclarations,
});
if (component != null) {
components.push(component);
}
});
} else {
throw new Error(`Program doesn't contain file "${filePath}"`);
}
return components;
}
export interface GetPropTypesFromFileOptions
extends Pick<
GetPropsFromComponentDeclarationOptions,
'shouldInclude' | 'project' | 'checkDeclarations'
> {
filePath: string;
/**
* Called before the shape of an object is resolved
* @returns true to resolve the shape of the object, false to just use a object, or undefined to
* use the default behavior
* @default propertyCount <= 50 && depth <= 3
*/
shouldResolveObject?: (data: {
name: string;
propertyCount: number;
depth: number;
}) => boolean | undefined;
/**
* Called to know if a date should be represented as `PropTypes.object` or `PropTypes.instanceOf(Date)
* @returns true to use `PropTypes.object`, false to use `PropTypes.instanceOf(Date)`.
* @default false
*/
shouldUseObjectForDate?: (data: { name: string }) => boolean;
}
interface PropTypesProject extends TypeScriptProject {
reactComponentName: string | undefined;
shouldResolveObject: NonNullable<GetPropTypesFromFileOptions['shouldResolveObject']>;
shouldUseObjectForDate: GetPropTypesFromFileOptions['shouldUseObjectForDate'];
shouldInclude: NonNullable<GetPropTypesFromFileOptions['shouldInclude']>;
createPropTypeId: (sigil: ts.Symbol | ts.Type) => number;
}

View File

@@ -0,0 +1,24 @@
import { PropType } from './models';
export default function getTypeHash(type: PropType): string {
switch (type.type) {
case 'LiteralNode':
return type.value.toString();
case 'InstanceOfNode':
return `${type.type}.${type.instance}`;
case 'array':
return `array(${getTypeHash(type.arrayType)})`;
case 'InterfaceNode':
return `interface(${[...type.types]
.sort((a, b) => a[0].localeCompare(b[0]))
.map((t) => `${t[0]}:${getTypeHash(t[1])}`)
.join(',')})`;
case 'UnionNode':
return `union(${[...type.types]
.map((t) => getTypeHash(t))
.sort((a, b) => a.localeCompare(b))
.join(',')})`;
default:
return type.type;
}
}

View File

@@ -0,0 +1,7 @@
export { getPropTypesFromFile } from './getPropTypesFromFile';
export type { GetPropTypesFromFileOptions } from './getPropTypesFromFile';
export { injectPropTypesInFile } from './injectPropTypesInFile';
export type { InjectPropTypesInFileOptions } from './injectPropTypesInFile';
export { generatePropTypes } from './generatePropTypes';
export type { GeneratePropTypesOptions } from './generatePropTypes';
export type { LiteralType } from './models';

View File

@@ -0,0 +1,502 @@
import * as babel from '@babel/core';
import * as babelTypes from '@babel/types';
import { randomUUID } from 'node:crypto';
import { generatePropTypes, GeneratePropTypesOptions } from './generatePropTypes';
import { PropTypesComponent, PropTypeDefinition, LiteralType } from './models';
export interface InjectPropTypesInFileOptions
extends Pick<
GeneratePropTypesOptions,
| 'sortProptypes'
| 'includeJSDoc'
| 'comment'
| 'disablePropTypesTypeChecking'
| 'reconcilePropTypes'
| 'ensureBabelPluginTransformReactRemovePropTypesIntegration'
> {
/**
* By default, all unused props are omitted from the result.
* Set this to true to include them instead.
*/
includeUnusedProps?: boolean;
/**
* Used to control which props are includes in the result
* @returns true to include the prop, false to skip it, or undefined to
* use the default behavior
* @default includeUnusedProps ? true : data.usedProps.includes(data.prop.name)
*/
shouldInclude?(data: {
component: PropTypesComponent;
prop: PropTypeDefinition;
usedProps: readonly string[];
}): boolean | undefined;
/**
* You can override the order of literals in unions based on the proptype.
*
* By default, literals in unions are sorted by:
* - numbers last, ascending
* - anything else by their stringified value using localeCompare
* Note: The order of the literals as they "appear" in the typings cannot be preserved.
* Sometimes the type checker preserves it, sometimes it doesn't.
* By always returning 0 from the sort function you keep the order the type checker dictates.
*/
getSortLiteralUnions?: (
component: PropTypesComponent,
propType: PropTypeDefinition,
) => ((a: LiteralType, b: LiteralType) => number) | undefined;
/**
* Options passed to babel.transformSync
*/
babelOptions?: babel.TransformOptions;
}
/**
* Gets used props from path
* @param rootPath The path to search for uses of rootNode
* @param rootNode The node to start the search, if undefined searches for `this.props`
*/
function getUsedProps(
rootPath: babel.NodePath,
rootNode: babelTypes.ObjectPattern | babelTypes.Identifier | undefined,
) {
const usedProps: string[] = [];
function getUsedPropsInternal(
node: babelTypes.ObjectPattern | babelTypes.Identifier | undefined,
) {
if (node && babelTypes.isObjectPattern(node)) {
node.properties.forEach((x) => {
if (babelTypes.isObjectProperty(x)) {
if (babelTypes.isStringLiteral(x.key)) {
usedProps.push(x.key.value);
} else if (babelTypes.isIdentifier(x.key)) {
usedProps.push(x.key.name);
} else {
console.warn(
'Possibly used prop missed because object property key was not an Identifier or StringLiteral.',
);
}
} else if (babelTypes.isIdentifier(x.argument)) {
// get access props from rest-spread (`{...other}`)
getUsedPropsInternal(x.argument);
}
});
} else {
rootPath.traverse({
VariableDeclarator(path) {
const init = path.node.init;
if (
(node
? babelTypes.isIdentifier(init, { name: node.name })
: babelTypes.isMemberExpression(init) &&
babelTypes.isThisExpression(init.object) &&
babelTypes.isIdentifier(init.property, { name: 'props' })) &&
babelTypes.isObjectPattern(path.node.id)
) {
getUsedPropsInternal(path.node.id);
} else if (
// currently tracking `inProps` which stands for the given props e.g. `function Modal(inProps) {}`
babelTypes.isIdentifier(node, { name: 'inProps' }) &&
// `const props = ...` assuming the right-hand side has `inProps` as input.
babelTypes.isIdentifier(path.node.id, { name: 'props' })
) {
getUsedPropsInternal(path.node.id);
}
},
MemberExpression(path) {
if (
(node
? babelTypes.isIdentifier(path.node.object, { name: node.name })
: babelTypes.isMemberExpression(path.node.object) &&
babelTypes.isMemberExpression(path.node.object.object) &&
babelTypes.isThisExpression(path.node.object.object.object) &&
babelTypes.isIdentifier(path.node.object.object.property, { name: 'props' })) &&
babelTypes.isIdentifier(path.node.property)
) {
usedProps.push(path.node.property.name);
}
},
});
}
}
getUsedPropsInternal(rootNode);
return usedProps;
}
function flattenTsAsExpression(node: babel.types.Node | null | undefined) {
if (babelTypes.isTSAsExpression(node)) {
return node.expression as babel.Node;
}
return node;
}
function createBabelPlugin({
components,
options,
mapOfPropTypes,
}: {
components: PropTypesComponent[];
options: InjectPropTypesInFileOptions;
mapOfPropTypes: Map<string, string>;
}): babel.PluginObj {
const {
includeUnusedProps = false,
reconcilePropTypes = (
_prop: PropTypeDefinition,
_previous: string | undefined,
generated: string,
) => generated,
...otherOptions
} = options;
const shouldInclude: Exclude<InjectPropTypesInFileOptions['shouldInclude'], undefined> = (
data,
) => {
// key is a reserved prop name in React
// for example https://github.com/reactjs/rfcs/pull/107
// no need to add a prop-type if we won't generate the docs for it.
if (data.prop.name === 'key' && data.prop.jsDoc === '@ignore') {
return false;
}
if (options.shouldInclude) {
const result = options.shouldInclude(data);
if (result !== undefined) {
return result;
}
}
return includeUnusedProps ? true : data.usedProps.includes(data.prop.name);
};
let importName = '';
let needImport = false;
let alreadyImported = false;
const originalPropTypesPaths = new Map<string, babel.NodePath>();
const previousPropTypesSources = new Map<string, Map<string, string>>();
function injectPropTypes(injectOptions: {
path: babel.NodePath;
usedProps: readonly string[];
props: PropTypesComponent;
nodeName: string;
}) {
const { path, props, usedProps, nodeName } = injectOptions;
const previousPropTypesSource =
previousPropTypesSources.get(nodeName) || new Map<string, string>();
const source = generatePropTypes(props, {
...otherOptions,
importedName: importName,
previousPropTypesSource,
reconcilePropTypes,
shouldInclude: (prop) => shouldInclude({ component: props, prop, usedProps }),
});
const emptyPropTypes = source === '';
if (!emptyPropTypes) {
needImport = true;
}
const placeholder = `const a${randomUUID().replace(/-/g, '_')} = null;`;
mapOfPropTypes.set(placeholder, source);
const originalPropTypesPath = originalPropTypesPaths.get(nodeName);
// `Component.propTypes` already exists
if (originalPropTypesPath) {
originalPropTypesPath.replaceWith(babel.template.ast(placeholder) as babel.Node);
} else if (!emptyPropTypes && babelTypes.isExportNamedDeclaration(path.parent)) {
// in:
// export function Component() {}
// out:
// function Component() {}
// Component.propTypes = {}
// export { Component }
path.insertAfter(babel.template.ast(`export { ${nodeName} };`));
path.insertAfter(babel.template.ast(placeholder));
path.parentPath!.replaceWith(path.node);
} else if (!emptyPropTypes && babelTypes.isExportDefaultDeclaration(path.parent)) {
// in:
// export default function Component() {}
// out:
// function Component() {}
// Component.propTypes = {}
// export default Component
path.insertAfter(babel.template.ast(`export default ${nodeName};`));
path.insertAfter(babel.template.ast(placeholder));
path.parentPath!.replaceWith(path.node);
} else {
path.insertAfter(babel.template.ast(placeholder));
}
}
return {
visitor: {
Program: {
enter(path, state: any) {
if (
!path.node.body.some((n) => {
if (
babelTypes.isImportDeclaration(n) &&
n.source.value === 'prop-types' &&
n.specifiers.length
) {
importName = n.specifiers[0].local.name;
alreadyImported = true;
return true;
}
return false;
})
) {
importName = 'PropTypes';
}
path.get('body').forEach((nodePath) => {
const { node } = nodePath;
if (
babelTypes.isExpressionStatement(node) &&
babelTypes.isAssignmentExpression(node.expression, { operator: '=' }) &&
babelTypes.isMemberExpression(node.expression.left) &&
babelTypes.isIdentifier(node.expression.left.property, { name: 'propTypes' })
) {
babelTypes.assertIdentifier(node.expression.left.object);
const componentName = node.expression.left.object.name;
originalPropTypesPaths.set(componentName, nodePath);
const previousPropTypesSource = new Map<string, string>();
previousPropTypesSources.set(componentName, previousPropTypesSource);
let maybeObjectExpression = node.expression.right;
// Component.propTypes = {} as any;
// ^^^^^^^^^ expression.right
// ^^^^^^^^^ TSAsExpression
// ^^ ObjectExpression
// TODO: Not covered by a unit test but by e2e usage with the docs.
// Testing infra not setup to handle input=output.
if (babelTypes.isTSAsExpression(node.expression.right)) {
maybeObjectExpression = node.expression.right.expression;
}
if (babelTypes.isObjectExpression(maybeObjectExpression)) {
const { code } = state.file;
maybeObjectExpression.properties.forEach((property) => {
if (babelTypes.isObjectProperty(property)) {
const validatorSource = code.slice(property.value.start, property.value.end);
if (babelTypes.isIdentifier(property.key)) {
previousPropTypesSource.set(property.key.name, validatorSource);
} else if (babelTypes.isStringLiteral(property.key)) {
previousPropTypesSource.set(property.key.value, validatorSource);
} else {
console.warn(
`${state.filename}: Possibly missed original proTypes source. Can only determine names for 'Identifiers' and 'StringLiteral' but received '${property.key.type}'.`,
);
}
}
});
}
}
});
},
exit(path) {
if (alreadyImported || !needImport) {
return;
}
const propTypesImport = babel.template.ast(
`import ${importName} from 'prop-types'`,
) as babel.types.ImportDeclaration;
const firstImport = path
.get('body')
.find((nodePath) => babelTypes.isImportDeclaration(nodePath.node));
// Insert import after the first one to avoid issues with comment flags
if (firstImport) {
firstImport.insertAfter(propTypesImport);
} else {
path.node.body = [propTypesImport, ...path.node.body];
}
},
},
FunctionDeclaration(path) {
const { node } = path;
// Prevent visiting again
if ((node as any).hasBeenVisited) {
path.skip();
return;
}
if (!node.id) {
return;
}
const props = components.find((component) => component.name === node.id!.name);
if (!props) {
return;
}
// Prevent visiting again
(node as any).hasBeenVisited = true;
path.skip();
const prop = node.params[0];
injectPropTypes({
nodeName: node.id.name,
usedProps:
babelTypes.isIdentifier(prop) || babelTypes.isObjectPattern(prop)
? getUsedProps(path as babel.NodePath, prop)
: [],
path: path as babel.NodePath,
props,
});
},
VariableDeclarator(path) {
const { node } = path;
// Prevent visiting again
if ((node as any).hasBeenVisited) {
path.skip();
return;
}
if (!babelTypes.isIdentifier(node.id)) {
return;
}
const nodeName = node.id.name;
const props = components.find((component) => component.name === nodeName);
if (!props) {
return;
}
function getFromProp(propsNode: babelTypes.Node) {
// Prevent visiting again
(node as any).hasBeenVisited = true;
path.skip();
injectPropTypes({
path: path.parentPath,
usedProps:
babelTypes.isIdentifier(propsNode) || babelTypes.isObjectPattern(propsNode)
? getUsedProps(path as babel.NodePath, propsNode)
: [],
props: props!,
nodeName,
});
}
const nodeInit = flattenTsAsExpression(node.init);
if (
babelTypes.isArrowFunctionExpression(nodeInit) ||
babelTypes.isFunctionExpression(nodeInit)
) {
getFromProp(nodeInit.params[0]);
} else if (babelTypes.isCallExpression(nodeInit)) {
if ((nodeInit.callee as babel.types.Identifier)?.name?.match(/create[A-Z].*/)) {
// Any components that are created by a factory function, for example System Box | Container | Grid.
getFromProp(node);
} else {
// x = react.memo(props => <div/>) / react.forwardRef(props => <div />)
let resolvedNode: babel.Node = nodeInit;
while (babelTypes.isCallExpression(resolvedNode)) {
resolvedNode = resolvedNode.arguments[0];
}
if (
babelTypes.isArrowFunctionExpression(resolvedNode) ||
babelTypes.isFunctionExpression(resolvedNode)
) {
getFromProp(resolvedNode.params[0]);
}
}
}
},
ClassDeclaration(path) {
const { node } = path;
// Prevent visiting again
if ((node as any).hasBeenVisited) {
path.skip();
return;
}
if (!babelTypes.isIdentifier(node.id)) {
return;
}
const nodeName = node.id.name;
const props = components.find((component) => component.name === nodeName);
if (!props) {
return;
}
// Prevent visiting again
(node as any).hasBeenVisited = true;
path.skip();
injectPropTypes({
nodeName,
usedProps: getUsedProps(path as babel.NodePath, undefined),
path: path as babel.NodePath,
props,
});
},
},
};
}
/**
* Injects the PropTypes from `parse` into the provided JavaScript code
* @param components Result from `generateFilePropTypes` to inject into the JavaScript code
* @param target The JavaScript code to add the PropTypes to
* @param options Options controlling the final result
*/
export function injectPropTypesInFile({
components,
target,
options = {},
}: {
components: PropTypesComponent[];
target: string;
options?: InjectPropTypesInFileOptions;
}): string | null {
if (components.length === 0) {
return target;
}
const mapOfPropTypes = new Map<string, string>();
const { plugins: babelPlugins = [], ...babelOptions } = options.babelOptions || {};
const result = babel.transformSync(target, {
plugins: [
require.resolve('@babel/plugin-syntax-class-properties'),
require.resolve('@babel/plugin-syntax-jsx'),
[require.resolve('@babel/plugin-syntax-typescript'), { isTSX: true }],
createBabelPlugin({ components, options, mapOfPropTypes }),
...(babelPlugins || []),
],
configFile: false,
babelrc: false,
retainLines: true,
...babelOptions,
});
let code = result?.code;
if (!code) {
return null;
}
// Replace the placeholders with the generated prop-types
// Workaround for issues with comments getting removed and malformed
mapOfPropTypes.forEach((value, key) => {
code = code!.replace(key, `\n\n${value}\n\n`);
});
return code;
}

View File

@@ -0,0 +1,102 @@
import ts from 'typescript';
export interface PropTypeDefinition {
name: string;
jsDoc: string | undefined;
propType: PropType;
filenames: Set<string>;
/**
* @internal
*/
$$id: number;
}
export interface PropTypesComponent {
name: string;
propsFilename?: string;
types: PropTypeDefinition[];
}
export type PropType =
| AnyType
| ArrayType
| BooleanType
| DOMElementType
| ElementType
| FunctionType
| InstanceOfType
| InterfaceType
| LiteralType
| NumericType
| ObjectType
| StringType
| UndefinedType
| UnionType;
export interface BasePropType {
jsDoc: string | undefined;
type: string;
}
export interface UndefinedType extends BasePropType {
type: 'UndefinedNode';
}
export interface AnyType extends BasePropType {
type: 'any';
}
export interface ArrayType extends BasePropType {
arrayType: PropType;
type: 'array';
}
export interface BooleanType extends BasePropType {
type: 'boolean';
}
export interface DOMElementType extends BasePropType {
optional?: boolean;
type: 'DOMElementNode';
}
export interface ElementType extends BasePropType {
elementType: 'element' | 'node' | 'elementType';
type: 'ElementNode';
}
export interface FunctionType extends BasePropType {
type: 'FunctionNode';
}
export interface InstanceOfType extends BasePropType {
instance: string;
type: 'InstanceOfNode';
}
export interface InterfaceType extends BasePropType {
type: 'InterfaceNode';
types: ReadonlyArray<[string, PropType]>;
}
export interface LiteralType extends BasePropType {
value: string | number | ts.PseudoBigInt;
type: 'LiteralNode';
}
export interface NumericType extends BasePropType {
type: 'NumericNode';
}
export interface ObjectType extends BasePropType {
type: 'ObjectNode';
}
export interface StringType extends BasePropType {
type: 'StringNode';
}
export interface UnionType extends BasePropType {
type: 'UnionNode';
types: readonly PropType[];
}

View File

@@ -0,0 +1,7 @@
type Props = {
foo?: boolean;
bar?: true;
baz?: false;
};
export default function Foo(props: Props): React.JSX.Element;

View File

@@ -0,0 +1,5 @@
Foo.propTypes = {
bar: PropTypes.oneOf([true]),
baz: PropTypes.oneOf([false]),
foo: PropTypes.bool,
};

View File

@@ -0,0 +1,7 @@
type Props = {
foo: boolean;
bar: true;
baz: false;
};
export default function Foo(props: Props): React.JSX.Element;

View File

@@ -0,0 +1,5 @@
Foo.propTypes = {
bar: PropTypes.oneOf([true]).isRequired,
baz: PropTypes.oneOf([false]).isRequired,
foo: PropTypes.bool.isRequired,
};

View File

@@ -0,0 +1,7 @@
import * as React from 'react';
export interface Props {
value: unknown;
}
export default function Component(props: Props): React.JSX.Element;

View File

@@ -0,0 +1,15 @@
import * as React from 'react';
import PropTypes from 'prop-types';
function Component(props) {
const { value } = props;
return <div>{value}</div>;
}
const someValidator = () => new Error();
Component.propTypes = {
value: PropTypes.any,
};
export default Component;

View File

@@ -0,0 +1,15 @@
import * as React from 'react';
import PropTypes from 'prop-types';
function Component(props) {
const { value } = props;
return <div>{value}</div>;
}
const someValidator = () => new Error();
Component.propTypes = {
value: PropTypes.any.isRequired,
};
export default Component;

View File

@@ -0,0 +1,8 @@
import * as React from 'react';
export interface Props {
Foo?: React.ComponentType;
Bar: React.ComponentType;
}
export default function Component(props: Props): React.JSX.Element;

View File

@@ -0,0 +1,13 @@
import * as React from 'react';
function Component(props) {
const { Foo, Bar } = props;
return (
<>
<Foo />
<Bar />
</>
);
}
export default Component;

View File

@@ -0,0 +1,19 @@
import * as React from 'react';
import PropTypes from 'prop-types';
function Component(props) {
const { Foo, Bar } = props;
return (
<>
<Foo />
<Bar />
</>
);
}
Component.propTypes = {
Bar: PropTypes.elementType.isRequired,
Foo: PropTypes.elementType,
};
export default Component;

View File

@@ -0,0 +1,9 @@
interface Props {
/**
* The type of the button relevant to its `<form>`.
* @default 'button'
*/
type?: 'button' | 'reset' | 'submit';
}
export function Foo(props: Props): React.JSX.Element;

View File

@@ -0,0 +1,7 @@
Foo.propTypes = {
/**
* The type of the button relevant to its `<form>`.
* @default 'button'
*/
type: PropTypes.oneOf(['button', 'reset', 'submit']),
};

View File

@@ -0,0 +1,6 @@
export function Foo(props: {
element: Element;
optional?: Element;
htmlElement: HTMLElement;
bothTypes: Element | HTMLElement;
}): React.JSX.Element;

View File

@@ -0,0 +1,38 @@
Foo.propTypes = {
bothTypes: (props, propName) => {
if (props[propName] == null) {
return new Error(`Prop '${propName}' is required but wasn't specified`);
}
if (typeof props[propName] !== 'object' || props[propName].nodeType !== 1) {
return new Error(`Expected prop '${propName}' to be of type Element`);
}
return null;
},
element: (props, propName) => {
if (props[propName] == null) {
return new Error(`Prop '${propName}' is required but wasn't specified`);
}
if (typeof props[propName] !== 'object' || props[propName].nodeType !== 1) {
return new Error(`Expected prop '${propName}' to be of type Element`);
}
return null;
},
htmlElement: (props, propName) => {
if (props[propName] == null) {
return new Error(`Prop '${propName}' is required but wasn't specified`);
}
if (typeof props[propName] !== 'object' || props[propName].nodeType !== 1) {
return new Error(`Expected prop '${propName}' to be of type Element`);
}
return null;
},
optional: (props, propName) => {
if (props[propName] == null) {
return null;
}
if (typeof props[propName] !== 'object' || props[propName].nodeType !== 1) {
return new Error(`Expected prop '${propName}' to be of type Element`);
}
return null;
},
};

View File

@@ -0,0 +1,10 @@
type Type = 'one' | 'two' | 'three'
interface ParentProps<T extends Type> {
optionalType?: T;
requiredType: T
}
interface ChildProps extends ParentProps<'one' | 'two'> {}
export function Foo(props: ChildProps): React.JSX.Element;

View File

@@ -0,0 +1,4 @@
Foo.propTypes = {
optionalType: PropTypes.oneOf(['one', 'two']),
requiredType: PropTypes.oneOf(['one', 'two']).isRequired,
};

View File

@@ -0,0 +1,31 @@
import * as React from 'react';
type PieValueType = string;
export interface PieSeriesType<Tdata = PieValueType> {
type: 'pie';
data: Tdata[];
}
type LineValueType = number;
export interface LineSeriesType<Tdata = LineValueType> {
type: 'line';
data: Tdata[];
}
interface Config {
pie: { series: PieSeriesType };
line: { series: LineSeriesType };
}
type ChartSeries<T extends 'line' | 'pie'> = Config[T]['series'];
interface Props<T extends 'line' | 'pie' = 'line' | 'pie'> {
series: ChartSeries<T>;
}
export default function Grid(props: Props) {
const { series } = props;
return <div>{series.type}</div>;
}

View File

@@ -0,0 +1,21 @@
import * as React from 'react';
import PropTypes from 'prop-types';
function Grid(props) {
const { series } = props;
return <div>{series.type}</div>;
}
Grid.propTypes = {
series: PropTypes.oneOfType([
PropTypes.shape({
data: PropTypes.arrayOf(PropTypes.number).isRequired,
type: PropTypes.oneOf(['line']).isRequired,
}),
PropTypes.shape({
data: PropTypes.arrayOf(PropTypes.string).isRequired,
type: PropTypes.oneOf(['pie']).isRequired,
}),
]).isRequired,
};
export default Grid;

View File

@@ -0,0 +1,3 @@
import * as React from 'react';
export default function Modal(props: React.HTMLAttributes<HTMLDivElement>): React.JSX.Element;

View File

@@ -0,0 +1,12 @@
export default function Modal(inProps) {
const props = getThemeProps({ props: inProps });
const { onKeyDown, ...other } = props;
function handleKeyDown(event) {
if (onKeyDown) {
onKeyDown(event);
}
}
return <div onKeyDown={handleKeyDown} {...other} />;
}

View File

@@ -0,0 +1,19 @@
import PropTypes from 'prop-types';
function Modal(inProps) {
const props = getThemeProps({ props: inProps });
const { onKeyDown, ...other } = props;
function handleKeyDown(event) {
if (onKeyDown) {
onKeyDown(event);
}
}
return <div onKeyDown={handleKeyDown} {...other} />;
}
Modal.propTypes = {
onKeyDown: PropTypes.func,
};
export default Modal;

View File

@@ -0,0 +1,3 @@
export function Foo(props: { className: string }) {
return <div className={props.className}></div>;
}

View File

@@ -0,0 +1,11 @@
import { TestOptions } from '../types';
const options: TestOptions = {
injector: {
shouldInclude() {
return false;
},
},
};
export default options;

View File

@@ -0,0 +1,3 @@
export function Foo(props) {
return <div className={props.className}></div>;
}

View File

@@ -0,0 +1,20 @@
import * as React from 'react';
export interface SnackBarProps {
/**
* Some hints about why this is useful
*/
id?: string;
/**
* some prop that is inherited which we don't care about here
*/
onChange?: () => void;
}
export function Snackbar(props: SnackBarProps) {
return <div {...props} />;
}
export function SomeOtherComponent(props: { id?: string }) {
return <div {...props} />;
}

View File

@@ -0,0 +1,13 @@
import { TestOptions } from '../types';
const options: TestOptions = {
injector: {
shouldInclude({ component, prop }) {
if (component.name === 'Snackbar' && prop.name === 'id') {
return true;
}
},
},
};
export default options;

View File

@@ -0,0 +1,17 @@
import * as React from 'react';
import PropTypes from 'prop-types';
function Snackbar(props) {
return <div {...props} />;
}
Snackbar.propTypes = {
/**
* Some hints about why this is useful
*/
id: PropTypes.string,
};
export { Snackbar };
export function SomeOtherComponent(props) {
return <div {...props} />;
}

View File

@@ -0,0 +1,20 @@
import * as React from 'react';
// it's technically not correct since this describes props the component
// sees not just the one available to the user. We're abusing this to provide
// some concrete documentation for `key` regarding this component
export interface SnackBarProps extends React.HTMLAttributes<any> {
/**
* some hints about state reset that relates to prop of this component
*/
key?: any;
}
export function Snackbar(props: SnackBarProps) {
return <div {...props} />;
}
// here we don't care about `key`
export function SomeOtherComponent(props: { children?: React.ReactNode }) {
return <div>{props.children}</div>;
}

View File

@@ -0,0 +1,19 @@
import * as path from 'path';
import { TestOptions } from '../types';
const options: TestOptions = {
injector: {
includeUnusedProps: true,
shouldInclude: ({ prop }) => {
let isLocallyTyped = false;
prop.filenames.forEach((filename) => {
if (!path.relative(__dirname, filename).startsWith('..')) {
isLocallyTyped = true;
}
});
return isLocallyTyped;
},
},
};
export default options;

View File

@@ -0,0 +1,24 @@
import * as React from 'react';
import PropTypes from 'prop-types';
function Snackbar(props) {
return <div {...props} />;
}
// here we don't care about `key`
Snackbar.propTypes = {
/**
* some hints about state reset that relates to prop of this component
*/
key: () => null,
};
export { Snackbar };
function SomeOtherComponent(props) {
return <div>{props.children}</div>;
}
SomeOtherComponent.propTypes = {
children: PropTypes.node,
};
export { SomeOtherComponent };

View File

@@ -0,0 +1,4 @@
export default function Dialog(props: { 'aria-describedby': string }) {
const { 'aria-describedby': ariaDescribedby } = props;
return <div></div>;
}

View File

@@ -0,0 +1,11 @@
import PropTypes from 'prop-types';
function Dialog(props) {
const { 'aria-describedby': ariaDescribedby } = props;
return <div></div>;
}
Dialog.propTypes = {
'aria-describedby': PropTypes.string.isRequired,
};
export default Dialog;

View File

@@ -0,0 +1,3 @@
export default function Foo(props: { className: string }) {
return <div {...props}></div>;
}

View File

@@ -0,0 +1,11 @@
import { TestOptions } from '../types';
const options: TestOptions = {
injector: {
shouldInclude() {
return true;
},
},
};
export default options;

View File

@@ -0,0 +1,10 @@
import PropTypes from 'prop-types';
function Foo(props) {
return <div {...props}></div>;
}
Foo.propTypes = {
className: PropTypes.string.isRequired,
};
export default Foo;

View File

@@ -0,0 +1,32 @@
import * as React from 'react';
type PieValueType = string;
export interface PieSeriesType<Tdata = PieValueType> {
type: 'pie';
data: Tdata[];
}
interface PieSeriesType2 {
type: 'pie';
data: string[];
}
type LineValueType = number;
export interface LineSeriesType<Tdata = LineValueType> {
type: 'line';
data: Tdata[];
}
type ChartSeries = PieSeriesType | LineSeriesType;
interface Props {
series: ChartSeries;
pieSeries: PieSeriesType | PieSeriesType2;
}
export default function Grid(props: Props) {
const { series, pieSeries } = props;
return <div>{series.type}</div>;
}

View File

@@ -0,0 +1,25 @@
import * as React from 'react';
import PropTypes from 'prop-types';
function Grid(props) {
const { series, pieSeries } = props;
return <div>{series.type}</div>;
}
Grid.propTypes = {
pieSeries: PropTypes.shape({
data: PropTypes.arrayOf(PropTypes.string).isRequired,
type: PropTypes.oneOf(['pie']).isRequired,
}).isRequired,
series: PropTypes.oneOfType([
PropTypes.shape({
data: PropTypes.arrayOf(PropTypes.string).isRequired,
type: PropTypes.oneOf(['pie']).isRequired,
}),
PropTypes.shape({
data: PropTypes.arrayOf(PropTypes.number).isRequired,
type: PropTypes.oneOf(['line']).isRequired,
}),
]).isRequired,
};
export default Grid;

View File

@@ -0,0 +1,14 @@
export interface Classes {
/**
* root description
*/
root: string;
/**
* slot description
*/
slot: string;
}
const classes: Classes = { root: 'root', slot: 'slot' };
export default classes;

View File

@@ -0,0 +1,20 @@
import * as React from 'react';
import type { Classes } from './classes';
interface Props {
/**
* the classes
*/
classes?: Partial<Classes>;
}
export default function Component(props: Props) {
const { classes } = props;
return (
<ul>
<li>root: {classes?.root}</li>
<li>slot: {classes?.slot}</li>
</ul>
);
}

View File

@@ -0,0 +1,23 @@
import * as React from 'react';
import PropTypes from 'prop-types';
function Component(props) {
const { classes } = props;
return (
<ul>
<li>root: {classes?.root}</li>
<li>slot: {classes?.slot}</li>
</ul>
);
}
Component.propTypes = {
/**
* the classes
*/
classes: PropTypes.shape({
root: PropTypes.string,
slot: PropTypes.string,
}),
};
export default Component;

View File

@@ -0,0 +1,8 @@
import * as React from 'react';
export interface ComponentProps {
color?: 'primary' | 'secondary' | 'error' | (string & {});
variant: 'text' | 'outlined' | 'contained' | (string & {});
}
export default function Component(props: ComponentProps): React.JSX.Element;

View File

@@ -0,0 +1,4 @@
Component.propTypes = {
color: PropTypes.string,
variant: PropTypes.string.isRequired,
};

View File

@@ -0,0 +1,8 @@
interface GridProps {
spacing?: 'initial' | 1 | 2 | 3 | 4 | 5 | 'auto';
}
export default function Grid(props: GridProps) {
const { spacing } = props;
return <div>spacing: {spacing}</div>;
}

View File

@@ -0,0 +1,11 @@
import PropTypes from 'prop-types';
function Grid(props) {
const { spacing } = props;
return <div>spacing: {spacing}</div>;
}
Grid.propTypes = {
spacing: PropTypes.oneOf(['auto', 'initial', 1, 2, 3, 4, 5]),
};
export default Grid;

View File

@@ -0,0 +1,5 @@
type TextFieldProps<A extends boolean> = A extends true ? { testProp: string } : { testProp: boolean }
type Props<A extends boolean = false> = Omit<TextFieldProps<A>, 'b'>
export function Foo<A extends boolean = false>(props: Props<A>): React.JSX.Element;

View File

@@ -0,0 +1,3 @@
Foo.propTypes = {
testProp: PropTypes.oneOfType([PropTypes.string, PropTypes.bool]).isRequired,
};

View File

@@ -0,0 +1,16 @@
type DeepOptions = {
PropB: string;
};
type Options = {
/**
* This jsdoc will be ignored
*/
PropA: string;
TestProps: DeepOptions;
};
export default function Foo(props: Options) {
const { PropA, TestProps } = props;
return <div></div>;
}

View File

@@ -0,0 +1,18 @@
import { TestOptions } from '../types';
const options: TestOptions = {
parser: {
shouldResolveObject({ name }) {
if (name.endsWith('Props')) {
return false;
}
return true;
},
} as TestOptions['parser'],
injector: {
includeJSDoc: false,
comment: 'Proptypes generated automatically',
},
};
export default options;

View File

@@ -0,0 +1,13 @@
import PropTypes from 'prop-types';
function Foo(props) {
const { PropA, TestProps } = props;
return <div></div>;
}
Foo.propTypes = {
// Proptypes generated automatically
PropA: PropTypes.string.isRequired,
TestProps: PropTypes.object.isRequired,
};
export default Foo;

View File

@@ -0,0 +1,13 @@
import * as React from 'react';
interface ButtonProps {
variant?: string;
}
interface Component<C extends React.ElementType = 'div'> {
(props: ButtonProps): React.JSX.Element;
(props: { component: C } & ButtonProps): React.JSX.Element;
}
// a component using overloading and intersection of function signature
declare const ButtonBase: Component & ((props: { href: string } & ButtonProps) => React.JSX.Element);

View File

@@ -0,0 +1,9 @@
import { TestOptions } from '../types';
const options: TestOptions = {
parser: {
checkDeclarations: true,
} as TestOptions['parser'],
};
export default options;

View File

@@ -0,0 +1,5 @@
ButtonBase.propTypes = {
component: PropTypes.elementType,
href: PropTypes.string,
variant: PropTypes.string,
};

View File

@@ -0,0 +1,5 @@
type Props = {
foo: any;
};
export default function Foo(props: Partial<Props>): React.JSX.Element;

View File

@@ -0,0 +1,3 @@
Foo.propTypes = {
foo: PropTypes.any,
};

View File

@@ -0,0 +1,12 @@
import * as React from 'react';
import PropTypes from 'prop-types';
// empty props are likely a mistake.
// We want to make sure we catch this instead of keeping .propTypes
export default function Component(props: {}): React.JSX.Element {
return <div />;
}
Component.propTypes = {
foo: PropTypes.string.isRequired,
} as any;

View File

@@ -0,0 +1,7 @@
import * as React from 'react';
import PropTypes from 'prop-types';
// empty props are likely a mistake.
// We want to make sure we catch this instead of keeping .propTypes
export default function Component(props) {
return <div />;
}

View File

@@ -0,0 +1,7 @@
import * as React from 'react';
interface Props {
children?: React.ReactNode;
}
export default function Component(props: Props): React.JSX.Element;

View File

@@ -0,0 +1,29 @@
import * as React from 'react';
import PropTypes from 'prop-types';
import { chainPropTypes } from 'some-utils-module';
function Component(props) {
const { children } = props;
return (
<button>
<span>{children}</span>
</button>
);
}
Component.propTypes = {
children: chainPropTypes(PropTypes.node.isRequired, (props) => {
const summary = React.Children.toArray(props.children)[0];
if (isFragment(summary)) {
return new Error('Not accepting Fragments');
}
if (!React.isValidElement(summary)) {
return new Error('First child must be an element');
}
return null;
}),
};
export default Component;

View File

@@ -0,0 +1,16 @@
import { TestOptions } from '../types';
const options: TestOptions = {
injector: {
reconcilePropTypes: (prop, previous: any, generated) => {
const isCustomValidator = previous !== undefined && !previous.startsWith('PropTypes');
if (isCustomValidator) {
return previous;
}
return generated;
},
},
};
export default options;

View File

@@ -0,0 +1,29 @@
import * as React from 'react';
import PropTypes from 'prop-types';
import { chainPropTypes } from 'some-utils-module';
function Component(props) {
const { children } = props;
return (
<button>
<span>{children}</span>
</button>
);
}
Component.propTypes = {
children: chainPropTypes(PropTypes.node.isRequired, (props) => {
const summary = React.Children.toArray(props.children)[0];
if (isFragment(summary)) {
return new Error('Not accepting Fragments');
}
if (!React.isValidElement(summary)) {
return new Error('First child must be an element');
}
return null;
}),
};
export default Component;

View File

@@ -0,0 +1,12 @@
import * as React from 'react';
interface Props {
/**
* UI to render
*/
children?: React.ReactNode;
}
export default function Component(props: Props) {
return <div>{props.children}</div>;
}

View File

@@ -0,0 +1,13 @@
import { TestOptions } from '../types';
const options: TestOptions = {
injector: {
ensureBabelPluginTransformReactRemovePropTypesIntegration: true,
includeUnusedProps: true,
},
parser: {
checkDeclarations: true,
} as TestOptions['parser'],
};
export default options;

View File

@@ -0,0 +1,14 @@
import * as React from 'react';
import PropTypes from 'prop-types';
function Component(props) {
return <div>{props.children}</div>;
}
Component.propTypes /* remove-proptypes */ = {
/**
* UI to render
*/
children: PropTypes.node,
};
export default Component;

View File

@@ -0,0 +1,14 @@
type Breakpoint = 'xs' | 'md' | 'xl';
export interface Props {
/**
* will be sorted alphanumeric
*/
color?: 'inherit' | 'default' | 'primary' | 'secondary';
/**
* will be sorted by viewport size descending
*/
only?: Breakpoint | Breakpoint[];
}
export default function Hidden(props: Props): React.JSX.Element;

View File

@@ -0,0 +1,7 @@
import * as React from 'react';
export default function Hidden(props) {
const { color, only } = props;
return <div color={color} hidden={only !== 'xs'} />;
}

View File

@@ -0,0 +1,21 @@
import { TestOptions } from '../types';
const options: TestOptions = {
injector: {
getSortLiteralUnions: (component, propTypeDefinition) => {
if (component.name === 'Hidden' && propTypeDefinition.name === 'only') {
return (a, b) => {
// descending here to check that we actually change the order of the typings
// It's unclear why TypeScript changes order of union members sometimes so we need to be sure
const breakpointOrder: unknown[] = ['"xl"', '"md"', '"xs"'];
return breakpointOrder.indexOf(a.value) - breakpointOrder.indexOf(b.value);
};
}
// default sort
return undefined;
},
},
};
export default options;

View File

@@ -0,0 +1,24 @@
import * as React from 'react';
import PropTypes from 'prop-types';
function Hidden(props) {
const { color, only } = props;
return <div color={color} hidden={only !== 'xs'} />;
}
Hidden.propTypes = {
/**
* will be sorted alphanumeric
*/
color: PropTypes.oneOf(['default', 'inherit', 'primary', 'secondary']),
/**
* will be sorted by viewport size descending
*/
only: PropTypes.oneOfType([
PropTypes.oneOf(['xl', 'md', 'xs']),
PropTypes.arrayOf(PropTypes.oneOf(['xl', 'md', 'xs']).isRequired),
]),
};
export default Hidden;

View File

@@ -0,0 +1 @@
require('@babel/register')({ extensions: ['.js', '.ts'] });

Some files were not shown because too many files have changed in this diff Show More