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,925 @@
import { readFileSync, writeFileSync } from 'fs';
import path from 'path';
import * as astTypes from 'ast-types';
import * as babel from '@babel/core';
import traverse from '@babel/traverse';
import { kebabCase, escapeRegExp } from 'es-toolkit/string';
import { remark } from 'remark';
import { visit as remarkVisit } from 'unist-util-visit';
import type { Link } from 'mdast';
import { defaultHandlers, parse as docgenParse } from 'react-docgen';
import { parse as parseDoctrine, Annotation } from 'doctrine';
import { renderCodeTags, renderMarkdown } from '../buildApi';
import { ProjectSettings, SortingStrategiesType } from '../ProjectSettings';
import { toGitHubPath, writePrettifiedFile } from '../buildApiUtils';
import muiDefaultPropsHandler from '../utils/defaultPropsHandler';
import parseTest from '../utils/parseTest';
import generatePropTypeDescription, { getChained } from '../utils/generatePropTypeDescription';
import createDescribeableProp, {
CreateDescribeablePropSettings,
DescribeablePropDescriptor,
} from '../utils/createDescribeableProp';
import generatePropDescription from '../utils/generatePropDescription';
import { TypeScriptProject } from '../utils/createTypeScriptProject';
import parseSlotsAndClasses from '../utils/parseSlotsAndClasses';
import generateApiTranslations from '../utils/generateApiTranslation';
import { sortAlphabetical } from '../utils/sortObjects';
import {
AdditionalPropsInfo,
ComponentApiContent,
ComponentReactApi,
ParsedProperty,
TypeDescriptions,
} from '../types/ApiBuilder.types';
import { Slot, ComponentInfo, ApiItemDescription } from '../types/utils.types';
import extractInfoFromEnum from '../utils/extractInfoFromEnum';
const cssComponents = new Set(['Box', 'Grid', 'Typography', 'Stack']);
/**
* Produces markdown of the description that can be hosted anywhere.
*
* By default we assume that the markdown is hosted on mui.com which is
* why the source includes relative url. We transform them to absolute urls with
* this method.
*/
export async function computeApiDescription(
api: { description: ComponentReactApi['description'] },
options: { host: string },
): Promise<string> {
const { host } = options;
const file = await remark()
.use(function docsLinksAttacher() {
return function transformer(tree) {
remarkVisit(tree, 'link', (linkNode) => {
const link = linkNode as Link;
if ((link.url as string).startsWith('/')) {
link.url = `${host}${link.url}`;
}
});
};
})
.process(api.description);
return file.toString().trim();
}
/**
* Add demos & API comment block to type definitions, e.g.:
* /**
* * Demos:
* *
* * - [Icons](https://mui.com/components/icons/)
* * - [Material Icons](https://mui.com/components/material-icons/)
* *
* * API:
* *
* * - [Icon API](https://mui.com/api/icon/)
*/
async function annotateComponentDefinition(
api: ComponentReactApi,
componentJsdoc: Annotation,
projectSettings: ProjectSettings,
) {
const HOST = projectSettings.baseApiUrl ?? 'https://mui.com';
const typesFilename = api.filename.replace(/\.js$/, '.d.ts');
const fileName = path.parse(api.filename).name;
const typesSource = readFileSync(typesFilename, { encoding: 'utf8' });
const typesAST = await babel.parseAsync(typesSource, {
configFile: false,
filename: typesFilename,
presets: [require.resolve('@babel/preset-typescript')],
});
if (typesAST === null) {
throw new Error('No AST returned from babel.');
}
let start = 0;
let end = null;
traverse(typesAST, {
ExportDefaultDeclaration(babelPath) {
/**
* export default function Menu() {}
*/
let node: babel.Node = babelPath.node;
if (node.declaration.type === 'Identifier') {
// declare const Menu: {};
// export default Menu;
if (babel.types.isIdentifier(babelPath.node.declaration)) {
const bindingId = babelPath.node.declaration.name;
const binding = babelPath.scope.bindings[bindingId];
// The JSDoc MUST be located at the declaration
if (babel.types.isFunctionDeclaration(binding.path.node)) {
// For function declarations the binding is equal to the declaration
// /**
// */
// function Component() {}
node = binding.path.node;
} else {
// For variable declarations the binding points to the declarator.
// /**
// */
// const Component = () => {}
node = binding.path.parentPath!.node;
}
}
}
const { leadingComments } = node;
const leadingCommentBlocks =
leadingComments != null
? leadingComments.filter(({ type }) => type === 'CommentBlock')
: null;
const jsdocBlock = leadingCommentBlocks != null ? leadingCommentBlocks[0] : null;
if (leadingCommentBlocks != null && leadingCommentBlocks.length > 1) {
throw new Error(
`Should only have a single leading jsdoc block but got ${
leadingCommentBlocks.length
}:\n${leadingCommentBlocks
.map(({ type, value }, index) => `#${index} (${type}): ${value}`)
.join('\n')}`,
);
}
if (jsdocBlock?.start != null && jsdocBlock?.end != null) {
start = jsdocBlock.start;
end = jsdocBlock.end;
} else if (node.start != null) {
start = node.start - 1;
end = start;
}
},
ExportNamedDeclaration(babelPath) {
let node: babel.Node = babelPath.node;
if (node.declaration == null) {
// export { Menu };
node.specifiers.forEach((specifier) => {
if (specifier.type === 'ExportSpecifier' && specifier.local.name === fileName) {
const binding = babelPath.scope.bindings[specifier.local.name];
if (babel.types.isFunctionDeclaration(binding.path.node)) {
// For function declarations the binding is equal to the declaration
// /**
// */
// function Component() {}
node = binding.path.node;
} else {
// For variable declarations the binding points to the declarator.
// /**
// */
// const Component = () => {}
node = binding.path.parentPath!.node;
}
}
});
} else if (babel.types.isFunctionDeclaration(node.declaration)) {
// export function Menu() {}
if (node.declaration.id?.name === fileName) {
node = node.declaration;
}
} else {
return;
}
const { leadingComments } = node;
const leadingCommentBlocks =
leadingComments != null
? leadingComments.filter(({ type }) => type === 'CommentBlock')
: null;
const jsdocBlock = leadingCommentBlocks != null ? leadingCommentBlocks[0] : null;
if (leadingCommentBlocks != null && leadingCommentBlocks.length > 1) {
throw new Error(
`Should only have a single leading jsdoc block but got ${
leadingCommentBlocks.length
}:\n${leadingCommentBlocks
.map(({ type, value }, index) => `#${index} (${type}): ${value}`)
.join('\n')}`,
);
}
if (jsdocBlock?.start != null && jsdocBlock?.end != null) {
start = jsdocBlock.start;
end = jsdocBlock.end;
} else if (node.start != null) {
start = node.start - 1;
end = start;
}
},
});
if (end === null || start === 0) {
throw new TypeError(
`${api.filename}: Don't know where to insert the jsdoc block. Probably no default export or named export matching the file name was found.`,
);
}
let inheritanceAPILink = null;
if (api.inheritance) {
inheritanceAPILink = `[${api.inheritance.name} API](${
api.inheritance.apiPathname.startsWith('http')
? api.inheritance.apiPathname
: `${HOST}${api.inheritance.apiPathname}`
})`;
}
const markdownLines = (await computeApiDescription(api, { host: HOST })).split('\n');
// Ensure a newline between manual and generated description.
if (markdownLines[markdownLines.length - 1] !== '') {
markdownLines.push('');
}
if (api.customAnnotation) {
markdownLines.push(
...api.customAnnotation
.split('\n')
.map((line) => line.trim())
.filter(Boolean),
);
} else {
markdownLines.push(
'Demos:',
'',
...api.demos.map((demo) => {
return `- [${demo.demoPageTitle}](${
demo.demoPathname.startsWith('http') ? demo.demoPathname : `${HOST}${demo.demoPathname}`
})`;
}),
'',
);
markdownLines.push(
'API:',
'',
`- [${api.name} API](${
api.apiPathname.startsWith('http') ? api.apiPathname : `${HOST}${api.apiPathname}`
})`,
);
if (api.inheritance) {
markdownLines.push(`- inherits ${inheritanceAPILink}`);
}
}
if (componentJsdoc.tags.length > 0) {
markdownLines.push('');
}
componentJsdoc.tags.forEach((tag) => {
markdownLines.push(`@${tag.title}${tag.name ? ` ${tag.name} -` : ''} ${tag.description}`);
});
const jsdoc = `/**\n${markdownLines
.map((line) => (line.length > 0 ? ` * ${line}` : ` *`))
.join('\n')}\n */`;
const typesSourceNew = typesSource.slice(0, start) + jsdoc + typesSource.slice(end);
writeFileSync(typesFilename, typesSourceNew, { encoding: 'utf8' });
}
/**
* Substitute CSS class description conditions with placeholder
*/
function extractClassCondition(description: string) {
const stylesRegex =
/((Styles|State class|Class name) applied to )(.*?)(( if | unless | when |, ){1}(.*))?\./;
const conditions = description.match(stylesRegex);
if (conditions && conditions[6]) {
return {
description: renderMarkdown(
description.replace(stylesRegex, '$1{{nodeName}}$5{{conditions}}.'),
),
nodeName: renderMarkdown(conditions[3]),
conditions: renderMarkdown(renderCodeTags(conditions[6])),
};
}
if (conditions && conditions[3] && conditions[3] !== 'the root element') {
return {
description: renderMarkdown(description.replace(stylesRegex, '$1{{nodeName}}$5.')),
nodeName: renderMarkdown(conditions[3]),
};
}
return { description: renderMarkdown(description) };
}
const generateApiPage = async (
apiPagesDirectory: string,
importTranslationPagesDirectory: string,
reactApi: ComponentReactApi,
sortingStrategies?: SortingStrategiesType,
onlyJsonFile: boolean = false,
layoutConfigPath: string = '',
) => {
const normalizedApiPathname = reactApi.apiPathname.replace(/\\/g, '/');
/**
* Gather the metadata needed for the component's API page.
*/
const pageContent: ComponentApiContent = {
// Sorted by required DESC, name ASC
props: Object.fromEntries(
Object.entries(reactApi.propsTable).sort(([aName, aData], [bName, bData]) => {
if ((aData.required && bData.required) || (!aData.required && !bData.required)) {
return aName.localeCompare(bName);
}
if (aData.required) {
return -1;
}
return 1;
}),
),
name: reactApi.name,
imports: reactApi.imports,
...(reactApi.slots?.length > 0 && { slots: reactApi.slots }),
...(Object.keys(reactApi.cssVariables).length > 0 && { cssVariables: reactApi.cssVariables }),
...(Object.keys(reactApi.dataAttributes).length > 0 && {
dataAttributes: reactApi.dataAttributes,
}),
classes: reactApi.classes,
spread: reactApi.spread,
themeDefaultProps: reactApi.themeDefaultProps,
muiName: normalizedApiPathname.startsWith('/joy-ui')
? reactApi.muiName.replace('Mui', 'Joy')
: reactApi.muiName,
forwardsRefTo: reactApi.forwardsRefTo,
filename: toGitHubPath(reactApi.filename),
inheritance: reactApi.inheritance
? {
component: reactApi.inheritance.name,
pathname: reactApi.inheritance.apiPathname,
}
: null,
demos: `<ul>${reactApi.demos
.map((item) => `<li><a href="${item.demoPathname}">${item.demoPageTitle}</a></li>`)
.join('\n')}</ul>`,
cssComponent: cssComponents.has(reactApi.name),
deprecated: reactApi.deprecated,
};
const { classesSort = sortAlphabetical('key'), slotsSort = null } = {
...sortingStrategies,
};
if (classesSort) {
pageContent.classes = [...pageContent.classes].sort(classesSort);
}
if (slotsSort && pageContent.slots) {
pageContent.slots = [...pageContent.slots].sort(slotsSort);
}
await writePrettifiedFile(
path.resolve(apiPagesDirectory, `${kebabCase(reactApi.name)}.json`),
JSON.stringify(pageContent),
);
if (!onlyJsonFile) {
await writePrettifiedFile(
path.resolve(apiPagesDirectory, `${kebabCase(reactApi.name)}.js`),
`import * as React from 'react';
import ApiPage from 'docs/src/modules/components/ApiPage';
import mapApiPageTranslations from 'docs/src/modules/utils/mapApiPageTranslations';${
layoutConfigPath === ''
? ''
: `
import layoutConfig from '${layoutConfigPath}';`
}
import jsonPageContent from './${kebabCase(reactApi.name)}.json';
export default function Page(props) {
const { descriptions } = props;
return <ApiPage ${layoutConfigPath === '' ? '' : '{...layoutConfig} '}descriptions={descriptions} pageContent={jsonPageContent} />;
}
export async function getStaticProps() {
const req = require.context(
'${importTranslationPagesDirectory}/${kebabCase(reactApi.name)}',
false,
/\\.\\/${kebabCase(reactApi.name)}.*\\.json$/,
);
const descriptions = mapApiPageTranslations(req);
return { props: { descriptions } };
}
`.replace(/\r?\n/g, reactApi.EOL),
);
}
};
const attachTranslations = (
reactApi: ComponentReactApi,
deprecationInfo: string | undefined,
settings?: CreateDescribeablePropSettings,
) => {
const translations: ComponentReactApi['translations'] = {
componentDescription: reactApi.description,
deprecationInfo: deprecationInfo ? renderMarkdown(deprecationInfo) : undefined,
propDescriptions: {},
classDescriptions: {},
};
Object.entries(reactApi.props!).forEach(([propName, propDescriptor]) => {
let prop: DescribeablePropDescriptor | null;
try {
prop = createDescribeableProp(propDescriptor, propName, settings);
} catch (error) {
prop = null;
}
if (prop) {
const { deprecated, seeMore, jsDocText, signatureArgs, signatureReturn, requiresRef } =
generatePropDescription(prop, propName);
// description = renderMarkdownInline(`${description}`);
const typeDescriptions: TypeDescriptions = {};
(signatureArgs || [])
.concat(signatureReturn || [])
.forEach(({ name, description, argType, argTypeDescription }) => {
typeDescriptions[name] = {
name,
description: renderMarkdown(description),
argType,
argTypeDescription: argTypeDescription ? renderMarkdown(argTypeDescription) : undefined,
};
});
translations.propDescriptions[propName] = {
description: renderMarkdown(jsDocText),
requiresRef: requiresRef || undefined,
deprecated: renderMarkdown(deprecated) || undefined,
typeDescriptions: Object.keys(typeDescriptions).length > 0 ? typeDescriptions : undefined,
seeMoreText: seeMore?.description,
};
}
});
/**
* Slot descriptions.
*/
if (reactApi.slots?.length > 0) {
translations.slotDescriptions = {};
[...reactApi.slots]
.sort(sortAlphabetical('name')) // Sort to ensure consistency of object key order
.forEach((slot: Slot) => {
const { name, description } = slot;
translations.slotDescriptions![name] = renderMarkdown(description);
});
}
/**
* CSS class descriptions and deprecations.
*/
[...reactApi.classes]
.sort(sortAlphabetical('key')) // Sort to ensure consistency of object key order
.forEach((classDefinition) => {
translations.classDescriptions[classDefinition.key] = {
...extractClassCondition(classDefinition.description),
deprecationInfo: classDefinition.deprecationInfo,
};
});
reactApi.classes.forEach((classDefinition, index) => {
delete reactApi.classes[index].deprecationInfo; // store deprecation info in translations only
});
/**
* CSS variables descriptions.
*/
if (Object.keys(reactApi.cssVariables).length > 0) {
translations.cssVariablesDescriptions = {};
[...Object.keys(reactApi.cssVariables)]
.sort() // Sort to ensure consistency of object key order
.forEach((cssVariableName: string) => {
const cssVariable = reactApi.cssVariables[cssVariableName];
const { description } = cssVariable;
translations.cssVariablesDescriptions![cssVariableName] = renderMarkdown(description);
});
}
/**
* Data attributes descriptions.
*/
if (Object.keys(reactApi.dataAttributes).length > 0) {
translations.dataAttributesDescriptions = {};
[...Object.keys(reactApi.dataAttributes)]
.sort() // Sort to ensure consistency of object key order
.forEach((dataAttributeName: string) => {
const dataAttribute = reactApi.dataAttributes[dataAttributeName];
const { description } = dataAttribute;
translations.dataAttributesDescriptions![dataAttributeName] = renderMarkdown(description);
});
}
reactApi.translations = translations;
};
const attachPropsTable = (
reactApi: ComponentReactApi,
settings?: CreateDescribeablePropSettings,
) => {
const propErrors: Array<[propName: string, error: Error]> = [];
type Pair = [string, ComponentReactApi['propsTable'][string]];
const componentProps: ComponentReactApi['propsTable'] = Object.fromEntries(
Object.entries(reactApi.props!).map(([propName, propDescriptor]): Pair => {
let prop: DescribeablePropDescriptor | null;
try {
prop = createDescribeableProp(propDescriptor, propName, settings);
} catch (error) {
propErrors.push([`[${reactApi.name}] \`${propName}\``, error as Error]);
prop = null;
}
if (prop === null) {
// have to delete `componentProps.undefined` later
return [] as any;
}
const defaultValue = propDescriptor.jsdocDefaultValue?.value;
const {
signature: signatureType,
signatureArgs,
signatureReturn,
seeMore,
} = generatePropDescription(prop, propName);
const propTypeDescription = generatePropTypeDescription(propDescriptor.type);
const chainedPropType = getChained(prop.type);
const requiredProp =
prop.required ||
prop.type.raw?.includes('.isRequired') ||
(chainedPropType !== false && chainedPropType.required);
const deprecation = (propDescriptor.description || '').match(/@deprecated(\s+(?<info>.*))?/);
const additionalPropsInfo: AdditionalPropsInfo = {};
const normalizedApiPathname = reactApi.apiPathname.replace(/\\/g, '/');
if (propName === 'classes') {
additionalPropsInfo.cssApi = true;
} else if (propName === 'sx') {
additionalPropsInfo.sx = true;
} else if (propName === 'slots' && !normalizedApiPathname.startsWith('/material-ui')) {
additionalPropsInfo.slotsApi = true;
} else if (normalizedApiPathname.startsWith('/joy-ui')) {
switch (propName) {
case 'size':
additionalPropsInfo['joy-size'] = true;
break;
case 'color':
additionalPropsInfo['joy-color'] = true;
break;
case 'variant':
additionalPropsInfo['joy-variant'] = true;
break;
default:
}
}
let signature: ComponentReactApi['propsTable'][string]['signature'];
if (signatureType !== undefined) {
signature = {
type: signatureType,
describedArgs: signatureArgs?.map((arg) => arg.name),
returned: signatureReturn?.name,
};
}
return [
propName,
{
type: {
name: propDescriptor.type.name,
description:
propTypeDescription !== propDescriptor.type.name ? propTypeDescription : undefined,
},
default: defaultValue,
// undefined values are not serialized => saving some bytes
required: requiredProp || undefined,
deprecated: !!deprecation || undefined,
deprecationInfo: renderMarkdown(deprecation?.groups?.info || '').trim() || undefined,
signature,
additionalInfo:
Object.keys(additionalPropsInfo).length === 0 ? undefined : additionalPropsInfo,
seeMoreLink: seeMore?.link,
},
];
}),
);
if (propErrors.length > 0) {
throw new Error(
`There were errors creating prop descriptions:\n${propErrors
.map(([propName, error]) => {
return ` - ${propName}: ${error}`;
})
.join('\n')}`,
);
}
// created by returning the `[]` entry
delete componentProps.undefined;
reactApi.propsTable = componentProps;
};
/**
* Helper to get the import options
* @param name The name of the component
* @param filename The filename where its defined (to infer the package)
* @returns an array of import command
*/
const defaultGetComponentImports = (name: string, filename: string) => {
const githubPath = toGitHubPath(filename);
const rootImportPath = githubPath.replace(
/\/packages\/mui(?:-(.+?))?\/src\/.*/,
(match, pkg) => `@mui/${pkg}`,
);
const subdirectoryImportPath = githubPath.replace(
/\/packages\/mui(?:-(.+?))?\/src\/([^\\/]+)\/.*/,
(match, pkg, directory) => `@mui/${pkg}/${directory}`,
);
let namedImportName = name;
const defaultImportName = name;
if (githubPath.includes('Unstable_')) {
namedImportName = `Unstable_${name} as ${name}`;
}
const useNamedImports = rootImportPath === '@mui/base';
const subpathImport = useNamedImports
? `import { ${namedImportName} } from '${subdirectoryImportPath}';`
: `import ${defaultImportName} from '${subdirectoryImportPath}';`;
const rootImport = `import { ${namedImportName} } from '${rootImportPath}';`;
return [subpathImport, rootImport];
};
const attachTable = (
reactApi: ComponentReactApi,
params: ParsedProperty[],
attribute: 'cssVariables' | 'dataAttributes',
defaultType?: string,
) => {
const errors: Array<[propName: string, error: Error]> = [];
const data: { [key: string]: ApiItemDescription } = params
.map((p) => {
const { name: propName, ...propDescriptor } = p;
let prop: Omit<ParsedProperty, 'name'> | null;
try {
prop = propDescriptor;
} catch (error) {
errors.push([propName, error as Error]);
prop = null;
}
if (prop === null) {
// have to delete `componentProps.undefined` later
return [] as any;
}
const deprecationTag = propDescriptor.tags?.deprecated;
const deprecation = deprecationTag?.text?.[0]?.text;
const typeTag = propDescriptor.tags?.type;
let type = typeTag?.text?.[0]?.text ?? defaultType;
if (typeof type === 'string') {
type = type.replace(/{|}/g, '');
}
return {
name: propName,
description: propDescriptor.description,
type,
deprecated: !!deprecation || undefined,
deprecationInfo: renderMarkdown(deprecation || '').trim() || undefined,
};
})
.reduce((acc, cssVarDefinition) => {
const { name, ...other } = cssVarDefinition;
return {
...acc,
[name]: other,
};
}, {});
if (errors.length > 0) {
throw new Error(
`There were errors creating ${attribute.replace(/([A-Z])/g, ' $1')} descriptions:\n${errors
.map(([item, error]) => {
return ` - ${item}: ${error}`;
})
.join('\n')}`,
);
}
reactApi[attribute] = data;
};
/**
* - Build react component (specified filename) api by lookup at its definition (.d.ts or ts)
* and then generate the API page + json data
* - Generate the translations
* - Add the comment in the component filename with its demo & API urls (including the inherited component).
* this process is done by sourcing markdown files and filter matched `components` in the frontmatter
*/
export default async function generateComponentApi(
componentInfo: ComponentInfo,
project: TypeScriptProject,
projectSettings: ProjectSettings,
) {
const { shouldSkip, spread, EOL, src } = componentInfo.readFile();
if (shouldSkip) {
return null;
}
const filename = componentInfo.filename;
let reactApi: ComponentReactApi;
try {
reactApi = docgenParse(src, null, defaultHandlers.concat(muiDefaultPropsHandler), {
filename,
});
} catch (error) {
// fallback to default logic if there is no `create*` definition.
if ((error as Error).message === 'No suitable component definition found.') {
reactApi = docgenParse(
src,
(ast) => {
let node;
// TODO migrate to react-docgen v6, using Babel AST now
astTypes.visit(ast, {
visitFunctionDeclaration: (functionPath) => {
// @ts-ignore
if (functionPath.node.params[0].name === 'props') {
node = functionPath;
}
return false;
},
visitVariableDeclaration: (variablePath) => {
const definitions: any[] = [];
if (variablePath.node.declarations) {
variablePath
.get('declarations')
.each((declarator: any) => definitions.push(declarator.get('init')));
}
definitions.forEach((definition) => {
// definition.value.expression is defined when the source is in TypeScript.
const expression = definition.value?.expression
? definition.get('expression')
: definition;
if (expression.value?.callee) {
const definitionName = expression.value.callee.name;
if (definitionName === `create${componentInfo.name}`) {
node = expression;
}
}
});
return false;
},
});
return node;
},
defaultHandlers.concat(muiDefaultPropsHandler),
{
filename,
},
);
} else {
throw error;
}
}
if (!reactApi.props) {
reactApi.props = {};
}
const { getComponentImports = defaultGetComponentImports } = projectSettings;
const componentJsdoc = parseDoctrine(reactApi.description);
// We override `reactApi.description` with `componentJsdoc.description` because
// the former can include JSDoc tags that we don't want to render in the docs.
reactApi.description = componentJsdoc.description;
// Ignore what we might have generated in `annotateComponentDefinition`
let annotationBoundary: RegExp = /(Demos|API):\r?\n\r?\n/;
if (componentInfo.customAnnotation) {
annotationBoundary = new RegExp(
escapeRegExp(componentInfo.customAnnotation.trim().split('\n')[0].trim()),
);
}
const annotatedDescriptionMatch = reactApi.description.match(new RegExp(annotationBoundary));
if (annotatedDescriptionMatch !== null) {
reactApi.description = reactApi.description.slice(0, annotatedDescriptionMatch.index).trim();
}
reactApi.filename = filename;
reactApi.name = componentInfo.name;
reactApi.imports = getComponentImports(componentInfo.name, filename);
reactApi.muiName = componentInfo.muiName;
reactApi.apiPathname = componentInfo.apiPathname;
reactApi.EOL = EOL;
reactApi.slots = [];
reactApi.classes = [];
reactApi.demos = componentInfo.getDemos();
reactApi.customAnnotation = componentInfo.customAnnotation;
reactApi.inheritance = null;
if (reactApi.demos.length === 0) {
throw new Error(
'Unable to find demos. \n' +
`Be sure to include \`components: ${reactApi.name}\` in the markdown pages where the \`${reactApi.name}\` component is relevant. ` +
'Every public component should have a demo.\nFor internal component, add the name of the component to the `skipComponent` method of the product.',
);
}
try {
const testInfo = await parseTest(reactApi.filename);
// no Object.assign to visually check for collisions
reactApi.forwardsRefTo = testInfo.forwardsRefTo;
reactApi.spread = testInfo.spread ?? spread;
reactApi.themeDefaultProps = testInfo.themeDefaultProps;
reactApi.inheritance = componentInfo.getInheritance(testInfo.inheritComponent);
} catch (error: any) {
console.error(error.message);
if (project.name.includes('grid')) {
// TODO: Use `describeConformance` for the DataGrid components
reactApi.forwardsRefTo = 'GridRoot';
}
}
if (!projectSettings.skipSlotsAndClasses) {
const { slots, classes } = parseSlotsAndClasses({
typescriptProject: project,
projectSettings,
componentName: reactApi.name,
muiName: reactApi.muiName,
slotInterfaceName: componentInfo.slotInterfaceName,
});
reactApi.slots = slots;
reactApi.classes = classes;
}
const deprecation = componentJsdoc.tags.find((tag) => tag.title === 'deprecated');
const deprecationInfo = deprecation?.description || undefined;
reactApi.deprecated = !!deprecation || undefined;
const cssVars = await extractInfoFromEnum(
`${componentInfo.name}CssVars`,
new RegExp(`${componentInfo.name}(CssVars|Classes)?.tsx?$`, 'i'),
project,
);
const dataAttributes = await extractInfoFromEnum(
`${componentInfo.name}DataAttributes`,
new RegExp(`${componentInfo.name}(DataAttributes)?.tsx?$`, 'i'),
project,
);
attachPropsTable(reactApi, projectSettings.propsSettings);
attachTable(reactApi, cssVars, 'cssVariables', 'string');
attachTable(reactApi, dataAttributes, 'dataAttributes');
attachTranslations(reactApi, deprecationInfo, projectSettings.propsSettings);
// eslint-disable-next-line no-console
console.log('Built API docs for', reactApi.apiPathname);
if (!componentInfo.skipApiGeneration) {
const {
skipAnnotatingComponentDefinition,
translationPagesDirectory,
importTranslationPagesDirectory,
generateJsonFileOnly,
} = projectSettings;
await generateApiTranslations(
path.join(process.cwd(), translationPagesDirectory),
reactApi,
projectSettings.translationLanguages,
);
// Once we have the tabs API in all projects, we can make this default
await generateApiPage(
componentInfo.apiPagesDirectory,
importTranslationPagesDirectory ?? translationPagesDirectory,
reactApi,
projectSettings.sortingStrategies,
generateJsonFileOnly,
componentInfo.layoutConfigPath,
);
if (
typeof skipAnnotatingComponentDefinition === 'function'
? !skipAnnotatingComponentDefinition(reactApi.filename)
: !skipAnnotatingComponentDefinition
) {
// Add comment about demo & api links (including inherited component) to the component file
await annotateComponentDefinition(reactApi, componentJsdoc, projectSettings);
}
}
return reactApi;
}

View File

@@ -0,0 +1,512 @@
import { readFileSync, writeFileSync } from 'fs';
import path from 'path';
import * as astTypes from 'ast-types';
import * as babel from '@babel/core';
import traverse from '@babel/traverse';
import { defaultHandlers, parse as docgenParse } from 'react-docgen';
import { kebabCase, upperFirst, escapeRegExp } from 'es-toolkit/string';
import { parse as parseDoctrine, Annotation } from 'doctrine';
import { escapeEntities, renderMarkdown } from '../buildApi';
import { ProjectSettings } from '../ProjectSettings';
import { computeApiDescription } from './ComponentApiBuilder';
import { toGitHubPath, writePrettifiedFile } from '../buildApiUtils';
import { TypeScriptProject } from '../utils/createTypeScriptProject';
import generateApiTranslations from '../utils/generateApiTranslation';
import { HookApiContent, HookReactApi, ParsedProperty } from '../types/ApiBuilder.types';
import { HookInfo } from '../types/utils.types';
import extractInfoFromType from '../utils/extractInfoFromType';
/**
* Add demos & API comment block to type definitions, e.g.:
* /**
* * Demos:
* *
* * - [Button](https://mui.com/base-ui/react-button/)
* *
* * API:
* *
* * - [useButton API](https://mui.com/base-ui/api/use-button/)
*/
async function annotateHookDefinition(
api: HookReactApi,
hookJsdoc: Annotation,
projectSettings: ProjectSettings,
) {
const HOST = projectSettings.baseApiUrl ?? 'https://mui.com';
const typesFilename = api.filename.replace(/\.js$/, '.d.ts');
const fileName = path.parse(api.filename).name;
const typesSource = readFileSync(typesFilename, { encoding: 'utf8' });
const typesAST = await babel.parseAsync(typesSource, {
configFile: false,
filename: typesFilename,
presets: [require.resolve('@babel/preset-typescript')],
});
if (typesAST === null) {
throw new Error('No AST returned from babel.');
}
let start = 0;
let end = null;
traverse(typesAST, {
ExportDefaultDeclaration(babelPath) {
/**
* export default function Menu() {}
*/
let node: babel.Node = babelPath.node;
if (node.declaration.type === 'Identifier') {
// declare const Menu: {};
// export default Menu;
if (babel.types.isIdentifier(babelPath.node.declaration)) {
const bindingId = babelPath.node.declaration.name;
const binding = babelPath.scope.bindings[bindingId];
// The JSDoc MUST be located at the declaration
if (babel.types.isFunctionDeclaration(binding.path.node)) {
// For function declarations the binding is equal to the declaration
// /**
// */
// function Component() {}
node = binding.path.node;
} else {
// For variable declarations the binding points to the declarator.
// /**
// */
// const Component = () => {}
node = binding.path.parentPath!.node;
}
}
}
const { leadingComments } = node;
const leadingCommentBlocks =
leadingComments != null
? leadingComments.filter(({ type }) => type === 'CommentBlock')
: null;
const jsdocBlock = leadingCommentBlocks != null ? leadingCommentBlocks[0] : null;
if (leadingCommentBlocks != null && leadingCommentBlocks.length > 1) {
throw new Error(
`Should only have a single leading jsdoc block but got ${
leadingCommentBlocks.length
}:\n${leadingCommentBlocks
.map(({ type, value }, index) => `#${index} (${type}): ${value}`)
.join('\n')}`,
);
}
if (jsdocBlock?.start != null && jsdocBlock?.end != null) {
start = jsdocBlock.start;
end = jsdocBlock.end;
} else if (node.start != null) {
start = node.start - 1;
end = start;
}
},
ExportNamedDeclaration(babelPath) {
let node: babel.Node = babelPath.node;
if (babel.types.isTSDeclareFunction(node.declaration)) {
// export function useHook() in .d.ts
if (node.declaration.id?.name !== fileName) {
return;
}
} else if (node.declaration == null) {
// export { useHook };
node.specifiers.forEach((specifier) => {
if (specifier.type === 'ExportSpecifier' && specifier.local.name === fileName) {
const binding = babelPath.scope.bindings[specifier.local.name];
if (babel.types.isFunctionDeclaration(binding.path.node)) {
// For function declarations the binding is equal to the declaration
// /**
// */
// function useHook() {}
node = binding.path.node;
} else {
// For variable declarations the binding points to the declarator.
// /**
// */
// const useHook = () => {}
node = binding.path.parentPath!.node;
}
}
});
} else if (babel.types.isFunctionDeclaration(node.declaration)) {
// export function useHook() in .ts
if (node.declaration.id?.name !== fileName) {
return;
}
} else {
return;
}
const { leadingComments } = node;
const leadingCommentBlocks =
leadingComments != null
? leadingComments.filter(({ type }) => type === 'CommentBlock')
: null;
const jsdocBlock = leadingCommentBlocks != null ? leadingCommentBlocks[0] : null;
if (leadingCommentBlocks != null && leadingCommentBlocks.length > 1) {
throw new Error(
`Should only have a single leading jsdoc block but got ${
leadingCommentBlocks.length
}:\n${leadingCommentBlocks
.map(({ type, value }, index) => `#${index} (${type}): ${value}`)
.join('\n')}`,
);
}
if (jsdocBlock?.start != null && jsdocBlock?.end != null) {
start = jsdocBlock.start;
end = jsdocBlock.end;
} else if (node.start != null) {
start = node.start - 1;
end = start;
}
},
});
if (end === null || start === 0) {
throw new TypeError(
`${api.filename}: Don't know where to insert the jsdoc block. Probably no default export found`,
);
}
const markdownLines = (await computeApiDescription(api, { host: HOST })).split('\n');
// Ensure a newline between manual and generated description.
if (markdownLines[markdownLines.length - 1] !== '') {
markdownLines.push('');
}
if (api.customAnnotation) {
markdownLines.push(
...api.customAnnotation
.split('\n')
.map((line) => line.trim())
.filter(Boolean),
);
} else {
if (api.demos && api.demos.length > 0) {
markdownLines.push(
'Demos:',
'',
...api.demos.map((item) => {
return `- [${item.demoPageTitle}](${
item.demoPathname.startsWith('http') ? item.demoPathname : `${HOST}${item.demoPathname}`
})`;
}),
'',
);
}
markdownLines.push(
'API:',
'',
`- [${api.name} API](${
api.apiPathname.startsWith('http') ? api.apiPathname : `${HOST}${api.apiPathname}`
})`,
);
}
if (hookJsdoc.tags.length > 0) {
markdownLines.push('');
}
hookJsdoc.tags.forEach((tag) => {
markdownLines.push(`@${tag.title}${tag.name ? ` ${tag.name} -` : ''} ${tag.description}`);
});
const jsdoc = `/**\n${markdownLines
.map((line) => (line.length > 0 ? ` * ${line}` : ` *`))
.join('\n')}\n */`;
const typesSourceNew = typesSource.slice(0, start) + jsdoc + typesSource.slice(end);
writeFileSync(typesFilename, typesSourceNew, { encoding: 'utf8' });
}
const attachTable = (
reactApi: HookReactApi,
params: ParsedProperty[],
tableName: 'parametersTable' | 'returnValueTable',
) => {
const propErrors: Array<[propName: string, error: Error]> = [];
const parameters: HookReactApi[typeof tableName] = params
.map((p) => {
const { name: propName, ...propDescriptor } = p;
let prop: Omit<ParsedProperty, 'name'> | null;
try {
prop = propDescriptor;
} catch (error) {
propErrors.push([propName, error as Error]);
prop = null;
}
if (prop === null) {
// have to delete `componentProps.undefined` later
return [] as any;
}
const defaultTag = propDescriptor.tags?.default;
const defaultValue: string | undefined = defaultTag?.text?.[0]?.text;
const requiredProp = prop.required;
const deprecation = (propDescriptor.description || '').match(/@deprecated(\s+(?<info>.*))?/);
const typeDescription = escapeEntities(propDescriptor.typeStr ?? '');
return {
[propName]: {
type: {
// The docgen generates this structure for the components. For consistency in the structure
// we are adding the same value in both the name and the description
name: typeDescription,
description: typeDescription,
},
default: defaultValue,
// undefined values are not serialized => saving some bytes
required: requiredProp || undefined,
deprecated: !!deprecation || undefined,
deprecationInfo: renderMarkdown(deprecation?.groups?.info || '').trim() || undefined,
},
};
})
.reduce((acc, curr) => ({ ...acc, ...curr }), {}) as unknown as HookReactApi['parametersTable'];
if (propErrors.length > 0) {
throw new Error(
`There were errors creating prop descriptions:\n${propErrors
.map(([propName, error]) => {
return ` - ${propName}: ${error}`;
})
.join('\n')}`,
);
}
// created by returning the `[]` entry
delete parameters.undefined;
reactApi[tableName] = parameters;
};
const generateTranslationDescription = (description: string) => {
return renderMarkdown(description.replace(/\n@default.*$/, ''));
};
const attachTranslations = (reactApi: HookReactApi, deprecationInfo: string | undefined) => {
const translations: HookReactApi['translations'] = {
hookDescription: reactApi.description,
deprecationInfo: deprecationInfo ? renderMarkdown(deprecationInfo).trim() : undefined,
parametersDescriptions: {},
returnValueDescriptions: {},
};
(reactApi.parameters ?? []).forEach(({ name: propName, description }) => {
if (description) {
translations.parametersDescriptions[propName] = {
description: generateTranslationDescription(description),
};
const deprecation = (description || '').match(/@deprecated(\s+(?<info>.*))?/);
if (deprecation !== null) {
translations.parametersDescriptions[propName].deprecated =
renderMarkdown(deprecation?.groups?.info || '').trim() || undefined;
}
}
});
(reactApi.returnValue ?? []).forEach(({ name: propName, description }) => {
if (description) {
translations.returnValueDescriptions[propName] = {
description: generateTranslationDescription(description),
};
const deprecation = (description || '').match(/@deprecated(\s+(?<info>.*))?/);
if (deprecation !== null) {
translations.parametersDescriptions[propName].deprecated =
renderMarkdown(deprecation?.groups?.info || '').trim() || undefined;
}
}
});
reactApi.translations = translations;
};
const generateApiJson = async (outputDirectory: string, reactApi: HookReactApi) => {
/**
* Gather the metadata needed for the component's API page.
*/
const pageContent: HookApiContent = {
// Sorted by required DESC, name ASC
parameters: Object.fromEntries(
Object.entries(reactApi.parametersTable).sort(([aName, aData], [bName, bData]) => {
if ((aData.required && bData.required) || (!aData.required && !bData.required)) {
return aName.localeCompare(bName);
}
if (aData.required) {
return -1;
}
return 1;
}),
),
returnValue: Object.fromEntries(
Object.entries(reactApi.returnValueTable).sort(([aName, aData], [bName, bData]) => {
if ((aData.required && bData.required) || (!aData.required && !bData.required)) {
return aName.localeCompare(bName);
}
if (aData.required) {
return -1;
}
return 1;
}),
),
name: reactApi.name,
filename: toGitHubPath(reactApi.filename),
imports: reactApi.imports,
demos: `<ul>${reactApi.demos
.map((item) => `<li><a href="${item.demoPathname}">${item.demoPageTitle}</a></li>`)
.join('\n')}</ul>`,
deprecated: reactApi.deprecated,
};
await writePrettifiedFile(
path.resolve(outputDirectory, `${kebabCase(reactApi.name)}.json`),
JSON.stringify(pageContent),
);
};
/**
* Helper to get the import options
* @param name The name of the hook
* @param filename The filename where its defined (to infer the package)
* @returns an array of import command
*/
const defaultGetHookImports = (name: string, filename: string) => {
const githubPath = toGitHubPath(filename);
const rootImportPath = githubPath.replace(
/\/packages\/mui(?:-(.+?))?\/src\/.*/,
(match, pkg) => `@mui/${pkg}`,
);
const subdirectoryImportPath = githubPath.replace(
/\/packages\/mui(?:-(.+?))?\/src\/([^\\/]+)\/.*/,
(match, pkg, directory) => `@mui/${pkg}/${directory}`,
);
let namedImportName = name;
const defaultImportName = name;
if (/unstable_/.test(githubPath)) {
namedImportName = `unstable_${name} as ${name}`;
}
const useNamedImports = rootImportPath === '@mui/base';
const subpathImport = useNamedImports
? `import { ${namedImportName} } from '${subdirectoryImportPath}';`
: `import ${defaultImportName} from '${subdirectoryImportPath}';`;
const rootImport = `import { ${namedImportName} } from '${rootImportPath}';`;
return [subpathImport, rootImport];
};
export default async function generateHookApi(
hooksInfo: HookInfo,
project: TypeScriptProject,
projectSettings: ProjectSettings,
) {
const {
filename,
name,
apiPathname,
apiPagesDirectory,
getDemos,
readFile,
skipApiGeneration,
customAnnotation,
} = hooksInfo;
const { shouldSkip, EOL, src } = readFile();
if (shouldSkip) {
return null;
}
const reactApi: HookReactApi = docgenParse(
src,
(ast) => {
let node;
astTypes.visit(ast, {
visitFunctionDeclaration: (functionPath) => {
if (functionPath.node?.id?.name === name) {
node = functionPath;
}
return false;
},
});
return node;
},
defaultHandlers,
{ filename },
);
const parameters = await extractInfoFromType(`${upperFirst(name)}Parameters`, project);
const returnValue = await extractInfoFromType(`${upperFirst(name)}ReturnValue`, project);
const hookJsdoc = parseDoctrine(reactApi.description);
// We override `reactApi.description` with `hookJsdoc.description` because
// the former can include JSDoc tags that we don't want to render in the docs.
reactApi.description = hookJsdoc.description;
// Ignore what we might have generated in `annotateComponentDefinition`
let annotationBoundary: RegExp = /(Demos|API):\r?\n\r?\n/;
if (customAnnotation) {
annotationBoundary = new RegExp(escapeRegExp(customAnnotation.trim().split('\n')[0].trim()));
}
const annotatedDescriptionMatch = reactApi.description.match(new RegExp(annotationBoundary));
if (annotatedDescriptionMatch !== null) {
reactApi.description = reactApi.description.slice(0, annotatedDescriptionMatch.index).trim();
}
const { getHookImports = defaultGetHookImports, translationPagesDirectory } = projectSettings;
reactApi.filename = filename;
reactApi.name = name;
reactApi.imports = getHookImports(name, filename);
reactApi.apiPathname = apiPathname;
reactApi.EOL = EOL;
reactApi.demos = getDemos();
reactApi.customAnnotation = customAnnotation;
if (reactApi.demos.length === 0) {
// TODO: Enable this error once all public hooks are documented
// throw new Error(
// 'Unable to find demos. \n' +
// `Be sure to include \`hooks: ${reactApi.name}\` in the markdown pages where the \`${reactApi.name}\` hook is relevant. ` +
// 'Every public hook should have a demo. ',
// );
}
attachTable(reactApi, parameters, 'parametersTable');
reactApi.parameters = parameters;
attachTable(reactApi, returnValue, 'returnValueTable');
reactApi.returnValue = returnValue;
const deprecation = hookJsdoc.tags.find((tag) => tag.title === 'deprecated');
const deprecationInfo = deprecation?.description || undefined;
reactApi.deprecated = !!deprecation || undefined;
attachTranslations(reactApi, deprecationInfo);
// eslint-disable-next-line no-console
console.log('Built API docs for', reactApi.name);
if (!skipApiGeneration) {
// Generate pages, json and translations
await generateApiTranslations(
path.join(process.cwd(), translationPagesDirectory),
reactApi,
projectSettings.translationLanguages,
);
await generateApiJson(apiPagesDirectory, reactApi);
// Add comment about demo & api links to the component hook file
await annotateHookDefinition(reactApi, hookJsdoc, projectSettings);
}
return reactApi;
}

View File

@@ -0,0 +1,125 @@
import { CreateTypeScriptProjectOptions } from './utils/createTypeScriptProject';
import { CreateDescribeablePropSettings } from './utils/createDescribeableProp';
import {
ComponentClassDefinition,
ComponentReactApi,
HookReactApi,
} from './types/ApiBuilder.types';
import { Slot, ComponentInfo, HookInfo } from './types/utils.types';
export type SortingStrategiesType = {
/**
* Sort slots items. Setting it to `null` keeps the order defined in the source code.
* @default alphabetical order.
*/
classesSort?: null | ((a: ComponentClassDefinition, b: ComponentClassDefinition) => number);
/**
* Sort slots items. Setting null result in no sorting (respect the order provided by TS).
* @default required props first and alphabetical order otherwise.
*/
slotsSort?: null | ((a: Slot, b: Slot) => number);
};
export interface ProjectSettings {
output: {
/**
* The output path of `pagesApi` generated from `input.pageDirectory`
*/
apiManifestPath: string;
/**
* Determine if the API manifest file is created.
* @default true
*/
writeApiManifest?: boolean;
};
/**
* Component directories to be used to generate API
*/
typeScriptProjects: CreateTypeScriptProjectOptions[];
getApiPages: () => Array<{ pathname: string }>;
getComponentInfo: (filename: string) => ComponentInfo;
getHookInfo?: (filename: string) => HookInfo;
/**
* Allows to force sorting strategies for listed properties.
*/
sortingStrategies?: SortingStrategiesType;
/**
* Callback function to be called when the API generation is completed
*/
onCompleted?: () => void | Promise<void>;
/**
* Callback to customize the manifest file before it's written to the disk
*/
onWritingManifestFile?: (
builds: PromiseSettledResult<ComponentReactApi | HookReactApi | null | never[]>[],
source: string,
) => string;
/**
* Languages to which the API docs will be generated
*/
translationLanguages: string[];
/**
* Function called to determine whether to skip the generation of a particular component's API docs
*/
skipComponent: (filename: string) => boolean;
/**
* Function called to determine whether to skip the generation of a particular hook's API docs
*/
skipHook?: (filename: string) => boolean;
/**
* Determine is the component definition should be updated.
*/
skipAnnotatingComponentDefinition?: boolean | ((filename: string) => boolean);
/**
* If `true`, skips extracting CSS class and slot information from the component.
*/
skipSlotsAndClasses?: boolean;
/**
* The path to the translation directory.
*/
translationPagesDirectory: string;
/**
* The path to import the translation directory.
* @default the `translationPagesDirectory` value
*/
importTranslationPagesDirectory?: string;
/**
* Returns an array of import commands used for the component API page header.
*/
getComponentImports?: (name: string, filename: string) => string[];
/**
* Returns an array of import commands used for the hook API page header.
*/
getHookImports?: (name: string, filename: string) => string[];
/**
* Settings to configure props definition tests.
*/
propsSettings?: CreateDescribeablePropSettings;
/**
* If `true`, the script does not generate JS page file.
* Once we have the API tabs in all projects, we can make this `true` by default.
* @default false
*/
generateJsonFileOnly?: boolean;
/**
* Function called to generate the class name for a component (or its slot)
*/
generateClassName: (componentName: string, slotOrState: string) => string;
/**
* Determines if a given slot or state is a global state
*/
isGlobalClassName: (slotOrState: string) => boolean;
/**
* Determines the base API URL for generated JSDocs
*/
baseApiUrl?: string;
/**
* The path to the `pages.ts` manifest file for public markdown generation.
*/
pagesManifestPath?: string;
/**
* Determines the non-component folders for ordering in the llms.txt file.
* The folders are relative to the `docs/data` directory.
*/
nonComponentFolders?: string[];
}

View File

@@ -0,0 +1,232 @@
import path from 'path';
import fs from 'node:fs/promises';
import { renderMarkdown as _renderMarkdown } from '@mui/internal-markdown';
import findComponents from './utils/findComponents';
import findHooks from './utils/findHooks';
import { writePrettifiedFile } from './buildApiUtils';
import generateComponentApi from './ApiBuilders/ComponentApiBuilder';
import generateHookApi from './ApiBuilders/HookApiBuilder';
import {
CreateTypeScriptProjectOptions,
TypeScriptProjectBuilder,
createTypeScriptProjectBuilder,
} from './utils/createTypeScriptProject';
import { ProjectSettings } from './ProjectSettings';
import { ComponentReactApi } from './types/ApiBuilder.types';
import _escapeCell from './utils/escapeCell';
import _escapeEntities from './utils/escapeEntities';
async function removeOutdatedApiDocsTranslations(
components: readonly ComponentReactApi[],
apiDocsTranslationsDirectories: string[],
): Promise<void> {
const componentDirectories = new Set<string>();
const projectFiles = await Promise.all(
apiDocsTranslationsDirectories.map(async (directory) => ({
directory: path.resolve(directory),
files: await fs.readdir(directory),
})),
);
await Promise.all(
projectFiles.map(async ({ directory, files }) => {
await Promise.all(
files.map(async (filename) => {
const filepath = path.join(directory, filename);
const stats = await fs.stat(filepath);
if (stats.isDirectory()) {
componentDirectories.add(filepath);
}
}),
);
}),
);
const currentComponentDirectories = new Set(
components
.map((component) => {
if (component.apiDocsTranslationFolder) {
return path.resolve(component.apiDocsTranslationFolder);
}
console.warn(`Component ${component.name} did not generate an API translation file.`);
return null;
})
.filter((filename): filename is string => filename !== null),
);
const outdatedComponentDirectories = new Set(componentDirectories);
currentComponentDirectories.forEach((componentDirectory) => {
outdatedComponentDirectories.delete(componentDirectory);
});
await Promise.all(
Array.from(outdatedComponentDirectories, (outdatedComponentDirectory) =>
fs.rm(outdatedComponentDirectory, { recursive: true, force: true }),
),
);
}
let rawDescriptionsCurrent = false;
export async function buildApi(
projectsSettings: ProjectSettings[],
grep: RegExp | null = null,
rawDescriptions = false,
) {
rawDescriptionsCurrent = rawDescriptions;
const allTypeScriptProjects = projectsSettings
.flatMap((setting) => setting.typeScriptProjects)
.reduce(
(acc, project) => {
acc[project.name] = project;
return acc;
},
{} as Record<string, CreateTypeScriptProjectOptions>,
);
const buildTypeScriptProject = createTypeScriptProjectBuilder(allTypeScriptProjects);
let allBuilds: Array<PromiseSettledResult<ComponentReactApi | null | never[]>> = [];
for (let i = 0; i < projectsSettings.length; i += 1) {
const setting = projectsSettings[i];
// eslint-disable-next-line no-await-in-loop
const projectBuilds = await buildSingleProject(setting, buildTypeScriptProject, grep);
// @ts-ignore ignore hooks builds for now
allBuilds = [...allBuilds, ...projectBuilds];
}
if (grep === null) {
const componentApis = allBuilds
.filter((build): build is PromiseFulfilledResult<ComponentReactApi> => {
return build.status === 'fulfilled' && build.value !== null && !Array.isArray(build.value);
})
.map((build) => {
return build.value;
});
const apiTranslationFolders = projectsSettings.map(
(setting) => setting.translationPagesDirectory,
);
await removeOutdatedApiDocsTranslations(componentApis, apiTranslationFolders);
}
}
async function buildSingleProject(
projectSettings: ProjectSettings,
buildTypeScriptProject: TypeScriptProjectBuilder,
grep: RegExp | null,
) {
const tsProjects = projectSettings.typeScriptProjects.map((project) =>
buildTypeScriptProject(project.name),
);
const { apiManifestPath: apiPagesManifestPath, writeApiManifest = true } = projectSettings.output;
const manifestDir = apiPagesManifestPath.match(/(.*)\/[^/]+\./)?.[1];
if (manifestDir) {
await fs.mkdir(manifestDir, { recursive: true });
}
const apiBuilds = tsProjects.flatMap((project) => {
const projectComponents = findComponents(path.join(project.rootPath, 'src')).filter(
(component) => {
if (projectSettings.skipComponent(component.filename)) {
return false;
}
if (grep === null) {
return true;
}
return grep.test(component.filename);
},
);
const projectHooks = findHooks(path.join(project.rootPath, 'src')).filter((hook) => {
if (projectSettings.skipHook?.(hook.filename)) {
return false;
}
if (grep === null) {
return true;
}
return grep.test(hook.filename);
});
const componentsBuilds = projectComponents.map(async (component) => {
try {
const componentInfo = projectSettings.getComponentInfo(component.filename);
await fs.mkdir(componentInfo.apiPagesDirectory, { mode: 0o777, recursive: true });
return await generateComponentApi(componentInfo, project, projectSettings);
} catch (error: any) {
error.message = `${path.relative(process.cwd(), component.filename)}: ${error.message}`;
throw error;
}
});
const hooksBuilds = projectHooks.map(async (hook) => {
if (!projectSettings.getHookInfo) {
return [];
}
try {
const { filename } = hook;
const hookInfo = projectSettings.getHookInfo(filename);
await fs.mkdir(hookInfo.apiPagesDirectory, { mode: 0o777, recursive: true });
return generateHookApi(hookInfo, project, projectSettings);
} catch (error: any) {
error.message = `${path.relative(process.cwd(), hook.filename)}: ${error.message}`;
throw error;
}
});
return [...componentsBuilds, ...hooksBuilds];
});
const builds = await Promise.allSettled(apiBuilds);
const fails = builds.filter(
(promise): promise is PromiseRejectedResult => promise.status === 'rejected',
);
fails.forEach((build) => {
console.error(build.reason);
});
if (fails.length > 0) {
process.exit(1);
}
if (writeApiManifest) {
let source = `export default ${JSON.stringify(projectSettings.getApiPages())}`;
if (projectSettings.onWritingManifestFile) {
source = projectSettings.onWritingManifestFile(builds, source);
}
await writePrettifiedFile(apiPagesManifestPath, source);
}
await projectSettings.onCompleted?.();
return builds;
}
export function renderMarkdown(markdown: string) {
return rawDescriptionsCurrent ? markdown : _renderMarkdown(markdown);
}
export function renderCodeTags(value: string) {
return rawDescriptionsCurrent ? value : value.replace(/`(.*?)`/g, '<code>$1</code>');
}
export function escapeEntities(value: string) {
return rawDescriptionsCurrent ? value : _escapeEntities(value);
}
export function escapeCell(value: string) {
return rawDescriptionsCurrent ? value : _escapeCell(value);
}
export function removeNewLines(value: string) {
return rawDescriptionsCurrent ? value : value.replace(/\r*\n/g, ' ');
}
export function joinUnionTypes(value: string[]) {
// Use unopinionated formatting for raw descriptions
return rawDescriptionsCurrent ? value.join(' | ') : value.join('<br>&#124;&nbsp;');
}

View File

@@ -0,0 +1,56 @@
import sinon from 'sinon';
import { extractPackageFile } from './buildApiUtils';
describe('buildApiUtils', () => {
describe('extractPackageFilePath', () => {
it('return info if path is a package (material)', () => {
const result = extractPackageFile('/material-ui/packages/mui-material/src/Button/Button.js');
sinon.assert.match(result, {
packagePath: 'mui-material',
muiPackage: 'mui-material',
name: 'Button',
});
});
it('return info if path is a package (lab)', () => {
const result = extractPackageFile(
'/material-ui/packages/mui-lab/src/LoadingButton/LoadingButton.js',
);
sinon.assert.match(result, {
packagePath: 'mui-lab',
muiPackage: 'mui-lab',
name: 'LoadingButton',
});
});
it('return info if path is a package (data-grid)', () => {
const result = extractPackageFile('/material-ui/packages/grid/x-data-grid/src/DataGrid.tsx');
sinon.assert.match(result, {
packagePath: 'x-data-grid',
muiPackage: 'mui-data-grid',
name: 'DataGrid',
});
});
it('return info if path is a package (data-grid-pro)', () => {
const result = extractPackageFile(
'/material-ui/packages/grid/x-data-grid-pro/src/DataGridPro.tsx',
);
sinon.assert.match(result, {
packagePath: 'x-data-grid-pro',
muiPackage: 'mui-data-grid-pro',
name: 'DataGridPro',
});
});
it('return null if path is not a package', () => {
const result = extractPackageFile(
'/material-ui/docs/pages/material/getting-started/getting-started.md',
);
sinon.assert.match(result, {
packagePath: null,
name: null,
});
});
});
});

View File

@@ -0,0 +1,182 @@
import fs from 'fs';
import path from 'path';
import * as ts from 'typescript';
import * as prettier from 'prettier';
import { kebabCase } from 'es-toolkit/string';
import { getLineFeed } from '@mui/internal-docs-utils';
import { replaceComponentLinks } from './utils/replaceUrl';
import { TypeScriptProject } from './utils/createTypeScriptProject';
export type { ComponentInfo, HookInfo } from './types/utils.types';
/**
* TODO: this should really be fixed in findPagesMarkdown().
* Plus replaceComponentLinks() shouldn't exist in the first place,
* the markdown folder location should match the URLs.
*/
export function fixPathname(pathname: string): string {
let fixedPathname;
if (pathname.startsWith('/material')) {
fixedPathname = replaceComponentLinks(`${pathname.replace(/^\/material/, '')}/`);
} else if (pathname.startsWith('/joy')) {
fixedPathname = replaceComponentLinks(`${pathname.replace(/^\/joy/, '')}/`).replace(
'material-ui',
'joy-ui',
);
} else if (pathname.startsWith('/base')) {
fixedPathname = `${pathname
.replace('/base/', '/base-ui/')
.replace('/components/', '/react-')}/`;
} else {
fixedPathname = `${pathname.replace('/components/', '/react-')}/`;
}
return fixedPathname;
}
const DEFAULT_PRETTIER_CONFIG_PATH = path.join(process.cwd(), 'prettier.config.mjs');
export async function writePrettifiedFile(
filename: string,
data: string,
prettierConfigPath: string = DEFAULT_PRETTIER_CONFIG_PATH,
options: object = {},
) {
const prettierConfig = await prettier.resolveConfig(filename, {
config: prettierConfigPath,
});
if (prettierConfig === null) {
throw new Error(
`Could not resolve config for '${filename}' using prettier config path '${prettierConfigPath}'.`,
);
}
const formatted = await prettier.format(data, { ...prettierConfig, filepath: filename });
fs.writeFileSync(filename, formatted, {
encoding: 'utf8',
...options,
});
}
let systemComponents: string[] | undefined;
// making the resolution lazy to avoid issues when importing something irrelevant from this file (i.e. `getSymbolDescription`)
// the eager resolution results in errors when consuming externally (i.e. `mui-x`)
export function getSystemComponents() {
if (!systemComponents) {
systemComponents = fs
.readdirSync(path.resolve('packages', 'mui-system', 'src'))
// Normalization, the Unstable_ prefix doesn't count.
.map((pathname) => pathname.replace('Unstable_', ''))
.filter((pathname) => pathname.match(/^[A-Z][a-zA-Z]+$/));
}
return systemComponents;
}
export function getMuiName(name: string) {
return `Mui${name.replace('Styled', '')}`;
}
export function extractPackageFile(filePath: string) {
filePath = filePath.replace(new RegExp(`\\${path.sep}`, 'g'), '/');
const match = filePath.match(
/.*\/packages.*\/(?<packagePath>[^/]+)\/src\/(.*\/)?(?<name>[^/]+)\.(js|tsx|ts|d\.ts)/,
);
const result = {
packagePath: match ? match.groups?.packagePath : null,
name: match ? match.groups?.name : null,
};
return {
...result,
muiPackage: result.packagePath?.replace('x-', 'mui-'),
};
}
export function parseFile(filename: string) {
const src = fs.readFileSync(filename, 'utf8');
return {
src,
shouldSkip:
filename.includes('internal') ||
!!src.match(/@ignore - internal component\./) ||
!!src.match(/@ignore - internal hook\./) ||
!!src.match(/@ignore - do not document\./),
spread: !src.match(/ = exactProp\(/),
EOL: getLineFeed(src),
inheritedComponent: src.match(/\/\/ @inheritedComponent (.*)/)?.[1],
};
}
export function getApiPath(
demos: Array<{ demoPageTitle: string; demoPathname: string }>,
name: string,
) {
let apiPath = null;
if (demos && demos.length > 0) {
// remove the hash from the demoPathname, for e.g. "#hooks"
const cleanedDemosPathname = demos[0].demoPathname.split('#')[0];
apiPath = `${cleanedDemosPathname}${
name.startsWith('use') ? 'hooks-api' : 'components-api'
}/#${kebabCase(name)}`;
}
return apiPath;
}
export async function formatType(rawType: string) {
if (!rawType) {
return '';
}
const prefix = 'type FakeType = ';
const signatureWithTypeName = `${prefix}${rawType}`;
const prettifiedSignatureWithTypeName = await prettier.format(signatureWithTypeName, {
printWidth: 999,
singleQuote: true,
semi: false,
trailingComma: 'none',
parser: 'typescript',
});
return prettifiedSignatureWithTypeName.slice(prefix.length).replace(/\n$/, '');
}
export function getSymbolDescription(symbol: ts.Symbol, project: TypeScriptProject) {
return symbol
.getDocumentationComment(project.checker)
.flatMap((comment) => comment.text.split('\n'))
.filter((line) => !line.startsWith('TODO'))
.join('\n');
}
export function getSymbolJSDocTags(symbol: ts.Symbol) {
return Object.fromEntries(symbol.getJsDocTags().map((tag) => [tag.name, tag]));
}
export async function stringifySymbol(symbol: ts.Symbol, project: TypeScriptProject) {
let rawType: string;
const declaration = symbol.declarations?.[0];
if (declaration && ts.isPropertySignature(declaration)) {
rawType = declaration.type?.getText() ?? '';
} else {
rawType = project.checker.typeToString(
project.checker.getTypeOfSymbolAtLocation(symbol, symbol.valueDeclaration!),
symbol.valueDeclaration,
ts.TypeFormatFlags.NoTruncation,
);
}
return formatType(rawType);
}
/**
* @param filepath - absolute path
* @example toGitHubPath('/home/user/material-ui/packages/Accordion') === '/packages/Accordion'
* @example toGitHubPath('C:\\Development\\material-ui\\packages\\Accordion') === '/packages/Accordion'
*/
export function toGitHubPath(filepath: string): string {
return `/${path.relative(process.cwd(), filepath).replace(/\\/g, '/')}`;
}

View File

@@ -0,0 +1,13 @@
export { buildApi } from './buildApi';
export type { ProjectSettings } from './ProjectSettings';
export type {
ComponentReactApi,
ComponentApiContent,
HookReactApi,
PropsTableItem,
PropsTranslations,
HooksTranslations,
HookApiContent,
ComponentClassDefinition,
} from './types/ApiBuilder.types';
export type { Slot, ComponentInfo } from './types/utils.types';

View File

@@ -0,0 +1,44 @@
{
"name": "@mui-internal/api-docs-builder",
"version": "1.0.0",
"private": "true",
"main": "./index.ts",
"scripts": {
"test": "pnpm --workspace-root test:unit --project \"*:@mui-internal/api-docs-builder\"",
"typescript": "tsc -p tsconfig.json"
},
"repository": {
"type": "git",
"url": "git+https://github.com/mui/material-ui.git",
"directory": "packages/api-docs-builder"
},
"dependencies": {
"@babel/core": "^7.28.5",
"@babel/preset-typescript": "^7.28.5",
"@babel/traverse": "^7.28.5",
"@mui/internal-docs-utils": "workspace:^",
"@mui/internal-markdown": "workspace:^",
"ast-types": "^0.14.2",
"doctrine": "^3.0.0",
"es-toolkit": "^1.42.0",
"fast-glob": "^3.3.3",
"prettier": "^3.6.2",
"react-docgen": "^5.4.3",
"recast": "^0.23.9",
"remark": "^15.0.1",
"typescript": "^5.9.3",
"unist-util-visit": "^5.0.0"
},
"devDependencies": {
"@types/babel__core": "^7.20.5",
"@types/babel__traverse": "^7.28.0",
"@types/chai": "^5.2.3",
"@types/doctrine": "^0.0.9",
"@types/mdast": "4.0.4",
"@types/node": "^20.19.25",
"@types/react-docgen": "workspace:*",
"@types/sinon": "^17.0.4",
"chai": "^6.0.1",
"sinon": "^21.0.0"
}
}

View File

@@ -0,0 +1,21 @@
{
"compilerOptions": {
"allowJs": true,
"isolatedModules": true,
"noEmit": true,
"noUnusedLocals": false,
"resolveJsonModule": true,
"skipLibCheck": true,
"esModuleInterop": true,
"types": ["node", "vitest/globals"],
"target": "ES2020",
"module": "nodenext",
"moduleResolution": "nodenext",
"strict": true,
"paths": {
"@mui/internal-docs-utils": ["../../packages-internal/docs-utils/src"]
}
},
"include": ["./**/*.ts", "./**/*.js"],
"exclude": ["node_modules"]
}

View File

@@ -0,0 +1,196 @@
import { ReactDocgenApi } from 'react-docgen';
import { JSDocTagInfo } from 'typescript';
import { ComponentInfo, Slot, HookInfo, SeeMore, ApiItemDescription } from './utils.types';
export type AdditionalPropsInfo = {
cssApi?: boolean;
sx?: boolean;
slotsApi?: boolean;
'joy-size'?: boolean;
'joy-color'?: boolean;
'joy-variant'?: boolean;
};
/**
* Common interface for both Component and Hook API builders.
*/
interface CommonReactApi extends ReactDocgenApi {
demos: ReturnType<HookInfo['getDemos']>;
EOL: string;
filename: string;
apiPathname: string;
description: string;
/**
* Different ways to import components
*/
imports: string[];
/**
* result of path.readFileSync from the `filename` in utf-8
*/
src: string;
/**
* The folder used to store the API translation.
*/
apiDocsTranslationFolder?: string;
deprecated: true | undefined;
customAnnotation?: string;
}
export interface PropsTableItem {
default: string | undefined;
required: boolean | undefined;
type: { name: string; description: string | undefined };
deprecated: true | undefined;
deprecationInfo: string | undefined;
signature: undefined | { type: string; describedArgs?: string[]; returned?: string };
additionalInfo?: AdditionalPropsInfo;
seeMoreLink?: SeeMore['link'];
}
export interface PropsTranslations {
componentDescription: string;
deprecationInfo: string | undefined;
propDescriptions: {
[key: string]: PropDescription;
};
classDescriptions: {
[key: string]: ClassDescription;
};
slotDescriptions?: { [key: string]: string };
cssVariablesDescriptions?: { [key: string]: string };
dataAttributesDescriptions?: { [key: string]: string };
}
export interface TypeDescription {
name: string;
description: string;
argType?: string;
argTypeDescription?: string;
}
export interface TypeDescriptions {
[t: string]: TypeDescription;
}
interface PropDescription {
description: string;
requiresRef?: boolean;
deprecated?: string;
typeDescriptions?: { [t: string]: TypeDescription };
seeMoreText?: string;
}
interface ClassDescription {
description: string;
conditions?: string;
nodeName?: string;
deprecationInfo?: string;
}
export interface ComponentReactApi extends CommonReactApi {
forwardsRefTo: string | undefined;
inheritance: ReturnType<ComponentInfo['getInheritance']>;
/**
* react component name
* @example 'Accordion'
*/
name: string;
muiName: string;
spread: boolean | undefined;
/**
* If `true`, the component supports theme default props customization.
* If `null`, we couldn't infer this information.
* If `undefined`, it's not applicable in this context, for example Base UI components.
*/
themeDefaultProps: boolean | undefined | null;
classes: ComponentClassDefinition[];
slots: Slot[];
cssVariables: { [key: string]: ApiItemDescription };
dataAttributes: { [key: string]: ApiItemDescription };
propsTable: { [key: string]: PropsTableItem };
translations: PropsTranslations;
}
export interface ComponentApiContent {
props: { [name: string]: PropsTableItem };
name: string;
imports: string[];
slots?: Slot[];
cssVariables?: { [key: string]: ApiItemDescription };
dataAttributes?: { [key: string]: ApiItemDescription };
classes: ComponentClassDefinition[];
spread: boolean | undefined;
themeDefaultProps: boolean | null | undefined;
muiName: string;
forwardsRefTo: string | undefined;
filename: string;
inheritance: null | { component: string; pathname: string };
demos: string;
cssComponent: boolean;
deprecated: true | undefined;
}
export interface ParsedProperty {
name: string;
description: string;
tags: { [tagName: string]: JSDocTagInfo };
required: boolean;
typeStr: string;
}
export interface HooksTranslations {
hookDescription: string;
deprecationInfo: string | undefined;
parametersDescriptions: {
[key: string]: {
description: string;
deprecated?: string;
};
};
returnValueDescriptions: {
[key: string]: {
description: string;
deprecated?: string;
};
};
}
export interface ComponentClassDefinition {
key: string;
className: string;
description: string;
isGlobal: boolean;
isDeprecated?: boolean;
deprecationInfo?: string;
}
interface AttributeDefinition {
default: string | undefined;
required: boolean | undefined;
type: { name: string; description: string | undefined };
deprecated: true | undefined;
deprecationInfo: string | undefined;
}
export interface HookReactApi extends CommonReactApi {
parameters?: ParsedProperty[];
returnValue?: ParsedProperty[];
/**
* hook name
* @example 'useButton'
*/
name: string;
parametersTable: { [key: string]: AttributeDefinition };
returnValueTable: { [key: string]: AttributeDefinition };
translations: HooksTranslations;
}
export interface HookApiContent {
parameters: Record<string, AttributeDefinition>;
returnValue: Record<string, AttributeDefinition>;
name: string;
filename: string;
imports: string[];
demos: string;
deprecated: boolean | undefined;
}

View File

@@ -0,0 +1,85 @@
export type SeeMore = { description: string; link: { text: string; url: string } };
export interface Slot {
class: string | null;
name: string;
description: string;
default?: string;
}
export interface ApiItemDescription {
name: string;
description: string;
}
export type ComponentInfo = {
/**
* Full path to the source file.
*/
filename: string;
/**
* Component name as imported in the docs, in the global MUI namespace.
*/
name: string;
/**
* Component name with `Mui` prefix, in the global HTML page namespace.
*/
muiName: string;
/**
* The name of the slots interface. By default we consider `${componentName}Slots`.
*/
slotInterfaceName?: string;
apiPathname: string;
readFile: () => {
src: string;
spread: boolean;
shouldSkip: boolean;
EOL: string;
inheritedComponent?: string;
};
getInheritance: (inheritedComponent?: string) => null | {
/**
* Component name
*/
name: string;
/**
* API pathname
*/
apiPathname: string;
};
getDemos: () => Array<{ demoPageTitle: string; demoPathname: string; filePath: string }>;
apiPagesDirectory: string;
/**
* The path to import specific layout config of the page if needed.
*/
layoutConfigPath?: string;
skipApiGeneration?: boolean;
/**
* If `true`, the component's name match one of the MUI System components.
*/
isSystemComponent?: boolean;
/**
* If provided, this annotation will be used instead of the auto-generated demo & API links
*/
customAnnotation?: string;
};
export type HookInfo = {
/**
* Full path to the source file.
*/
filename: string;
/**
* Hook name as imported in the docs, in the global MUI namespace.
*/
name: string;
apiPathname: string;
readFile: ComponentInfo['readFile'];
getDemos: ComponentInfo['getDemos'];
apiPagesDirectory: string;
skipApiGeneration?: boolean;
/**
* If provided, this annotation will be used instead of the auto-generated demo & API links
*/
customAnnotation?: string;
};

View File

@@ -0,0 +1,91 @@
import * as doctrine from 'doctrine';
import { PropDescriptor, PropTypeDescriptor } from 'react-docgen';
export interface DescribeablePropDescriptor {
annotation: doctrine.Annotation;
defaultValue: string | null;
required: boolean;
type: PropTypeDescriptor;
}
export type CreateDescribeablePropSettings = {
/**
* Names of props that do not check if the annotations equal runtime default.
*/
propsWithoutDefaultVerification?: string[];
};
/**
* Returns `null` if the prop should be ignored.
* Throws if it is invalid.
* @param prop
* @param propName
*/
export default function createDescribeableProp(
prop: PropDescriptor,
propName: string,
settings: CreateDescribeablePropSettings = {},
): DescribeablePropDescriptor | null {
const { defaultValue, jsdocDefaultValue, description, required, type } = prop;
const { propsWithoutDefaultVerification = [] } = settings;
const renderedDefaultValue = defaultValue?.value.replace(/\r?\n/g, '');
const renderDefaultValue = Boolean(
renderedDefaultValue &&
// Ignore "large" default values that would break the table layout.
renderedDefaultValue.length <= 150,
);
if (description === undefined) {
throw new Error(`The "${propName}" prop is missing a description.`);
}
const annotation = doctrine.parse(description, {
sloppy: true,
});
if (
annotation.description.trim() === '' ||
annotation.tags.some((tag) => tag.title === 'ignore')
) {
return null;
}
if (jsdocDefaultValue !== undefined && defaultValue === undefined) {
// Assume that this prop:
// 1. Is typed by another component
// 2. Is forwarded to that component
// Then validation is handled by the other component.
// Though this does break down if the prop is used in other capacity in the implementation.
// So let's hope we don't make this mistake too often.
} else if (jsdocDefaultValue === undefined && defaultValue !== undefined && renderDefaultValue) {
const shouldHaveDefaultAnnotation =
// Discriminator for polymorphism which is not documented at the component level.
// The documentation of `component` does not know in which component it is used.
propName !== 'component';
if (shouldHaveDefaultAnnotation) {
throw new Error(
`JSDoc @default annotation not found. Add \`@default ${defaultValue.value}\` to the JSDoc of this prop.`,
);
}
} else if (
jsdocDefaultValue !== undefined &&
!propsWithoutDefaultVerification.includes(propName)
) {
// `defaultValue` can't be undefined or we would've thrown earlier.
if (jsdocDefaultValue.value !== defaultValue!.value) {
throw new Error(
`Expected JSDoc @default annotation for prop '${propName}' of "${jsdocDefaultValue.value}" to equal runtime default value of "${defaultValue?.value}"`,
);
}
}
return {
annotation,
defaultValue: renderDefaultValue ? renderedDefaultValue! : null,
required: Boolean(required),
type,
};
}

View File

@@ -0,0 +1,150 @@
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.
*
* Use an array to target more than one entrypoint.
*
* @example 'src/index.d.ts'
* @example ['src/index.d.ts', 'src/PigmentStack/PigmentStack.tsx']
* `PigmentStack` cannot be included in the `index.d.ts` file because it is using Pigment CSS specific API.
*/
entryPointPath?: string | 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 arrayEntryPointPath = Array.isArray(inputEntryPointPath)
? inputEntryPointPath
: [inputEntryPointPath];
arrayEntryPointPath.forEach((entry) => {
const entryPointPath = path.join(rootPath, entry);
const sourceFile = program.getSourceFile(entryPointPath);
const pathData = path.parse(entryPointPath);
exports = {
...exports,
...Object.fromEntries(
checker.getExportsOfModule(checker.getSymbolAtLocation(sourceFile!)!).map((symbol) => {
return [symbol.name, symbol];
}),
),
...(pathData.name !== 'index' && {
// use the default export when the entrypoint is not `index`.
[pathData.name]: checker.getSymbolAtLocation(sourceFile!)!,
}),
};
});
} 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,176 @@
import { namedTypes as types } from 'ast-types';
import { parse as parseDoctrine, Annotation } from 'doctrine';
import { utils as docgenUtils, NodePath, Documentation, Importer, Handler } from 'react-docgen';
const { getPropertyName, isReactForwardRefCall, printValue, resolveToValue } = docgenUtils;
// based on https://github.com/reactjs/react-docgen/blob/735f39ef784312f4c0e740d4bfb812f0a7acd3d5/src/handlers/defaultPropsHandler.js#L1-L112
// adjusted for material-ui getThemedProps
function getDefaultValue(propertyPath: NodePath, importer: Importer) {
if (!types.AssignmentPattern.check(propertyPath.get('value').node)) {
return null;
}
let path: NodePath = propertyPath.get('value', 'right');
let node = path.node;
let defaultValue: string | undefined;
if (types.Literal.check(path.node)) {
// @ts-expect-error TODO upstream fix
defaultValue = node.raw;
} else {
if (types.AssignmentPattern.check(path.node)) {
path = resolveToValue(path.get('right'), importer);
} else {
path = resolveToValue(path, importer);
}
if (types.ImportDeclaration.check(path.node)) {
if (types.TSAsExpression.check(node)) {
node = node.expression;
}
if (!types.Identifier.check(node)) {
const locationHint =
node.loc != null ? `${node.loc.start.line}:${node.loc.start.column}` : 'unknown location';
throw new TypeError(
`Unable to follow data flow. Expected an 'Identifier' resolve to an 'ImportDeclaration'. Instead attempted to resolve a '${node.type}' at ${locationHint}.`,
);
}
defaultValue = node.name;
} else {
node = path.node;
defaultValue = printValue(path);
}
}
if (defaultValue !== undefined) {
return {
value: defaultValue,
computed:
types.CallExpression.check(node) ||
types.MemberExpression.check(node) ||
types.Identifier.check(node),
};
}
return null;
}
function getJsdocDefaultValue(jsdoc: Annotation): { value: string } | undefined {
const defaultTag = jsdoc.tags.find((tag) => tag.title === 'default');
if (defaultTag === undefined) {
return undefined;
}
return { value: defaultTag.description || '' };
}
function getDefaultValuesFromProps(
properties: NodePath,
documentation: Documentation,
importer: Importer,
) {
const { props: documentedProps } = documentation.toObject();
const implementedProps: Record<string, NodePath> = {};
properties
.filter((propertyPath: NodePath) => types.Property.check(propertyPath.node), undefined)
.forEach((propertyPath: NodePath) => {
const propName = getPropertyName(propertyPath);
if (propName) {
implementedProps[propName] = propertyPath;
}
});
// Sometimes we list props in .propTypes even though they're implemented by another component
// These props are spread so they won't appear in the component implementation.
Object.entries(documentedProps || []).forEach(([propName, propDescriptor]) => {
if (propDescriptor.description === undefined) {
// private props have no propsType validator and therefore
// not description.
// They are either not subject to eslint react/prop-types
// or are and then we catch these issues during linting.
return;
}
const jsdocDefaultValue = getJsdocDefaultValue(
parseDoctrine(propDescriptor.description, {
sloppy: true,
}),
);
if (jsdocDefaultValue) {
propDescriptor.jsdocDefaultValue = jsdocDefaultValue;
}
const propertyPath = implementedProps[propName];
if (propertyPath !== undefined) {
const defaultValue = getDefaultValue(propertyPath, importer);
if (defaultValue) {
propDescriptor.defaultValue = defaultValue;
}
}
});
}
function getRenderBody(componentDefinition: NodePath, importer: Importer): NodePath {
const value = resolveToValue(componentDefinition, importer);
if (isReactForwardRefCall(value, importer)) {
return value.get('arguments', 0, 'body', 'body');
}
return value.get('body', 'body');
}
/**
* Handle the case where `props` is explicitly declared with/without `React.forwardRef(…)`:
*
* @example
* const Component = React.forwardRef((props, ref) => {
* const { className, ...other } = props;
* })
*/
function getExplicitPropsDeclaration(
componentDefinition: NodePath,
importer: Importer,
): NodePath | undefined {
const functionNode = getRenderBody(componentDefinition, importer);
// No function body available to inspect.
if (!functionNode.value) {
return undefined;
}
let propsPath: NodePath | undefined;
// visitVariableDeclarator, can't use visit body.node since it looses scope information
functionNode
.filter((path: NodePath) => {
return types.VariableDeclaration.check(path.node);
}, undefined)
.forEach((path: NodePath) => {
const declaratorPath = path.get('declarations', 0);
// find `const {} = props`
// but not `const ownerState = props`
if (
declaratorPath.get('init', 'name').value === 'props' &&
declaratorPath.get('id', 'type').value === 'ObjectPattern'
) {
propsPath = declaratorPath.get('id');
}
});
if (!propsPath) {
console.error(`${functionNode.parent.value.id.name}: could not find props declaration to generate jsdoc table. The component declaration should be in this format:
function Component(props: ComponentProps) {
const { ...spreadAsUsual } = props;
...
}
`);
}
return propsPath;
}
const defaultPropsHandler: Handler = (documentation, componentDefinition, importer) => {
const props = getExplicitPropsDeclaration(componentDefinition, importer);
getDefaultValuesFromProps(props?.get('properties') ?? [], documentation, importer);
};
export default defaultPropsHandler;

View File

@@ -0,0 +1,52 @@
import { expect } from 'chai';
import escapeCell from './escapeCell';
describe('escapeCell', () => {
it('escapes pipes outside backticks', () => {
const input = 'true | false';
const result = escapeCell(input);
expect(result).to.equal('true \\| false');
});
it('does not escape pipes inside single backticks', () => {
const input = '`true | false`';
const result = escapeCell(input);
expect(result).to.equal('`true | false`');
});
it('does not escape pipes inside multiple inline code spans', () => {
const input = 'Use `a | b` and `x | y` here';
const result = escapeCell(input);
expect(result).to.equal('Use `a | b` and `x | y` here');
});
it('escapes pipes in normal text but not inside backticks', () => {
const input = '`a | b` or c | d';
const result = escapeCell(input);
expect(result).to.equal('`a | b` or c \\| d');
});
it('handles strings without any pipes', () => {
const input = 'no pipes here';
const result = escapeCell(input);
expect(result).to.equal('no pipes here');
});
it('keeps < inside code spans but escapes outside', () => {
const input = 'Use <b>bold</b> and `<div>` tags';
const result = escapeCell(input);
expect(result).to.equal('Use &lt;b>bold&lt;/b> and `<div>` tags');
});
it('does not escape pipe at string start or end inside backticks', () => {
const input = '`| start and end |`';
const result = escapeCell(input);
expect(result).to.equal('`| start and end |`');
});
it('escapes pipe at string start or end outside backticks', () => {
const input = '| start | and end |';
const result = escapeCell(input);
expect(result).to.equal('\\| start \\| and end \\|');
});
});

View File

@@ -0,0 +1,15 @@
function escapePipesOutsideBackticks(value: string): string {
// Split into chunks that are either code spans or normal text.
// Capturing group keeps the code chunks in the array.
return value
.split(/(`[^`]*`)/g)
.map((chunk) => (chunk.startsWith('`') ? chunk : chunk.replace(/\|/g, '\\|')))
.join('');
}
export default function escapeCell(value: string): string {
// As the pipe is use for the table structure
const newValue = escapePipesOutsideBackticks(value);
return newValue.replace(/</g, '&lt;').replace(/`&lt;/g, '`<');
}

View File

@@ -0,0 +1,8 @@
export default function escapeEntities(value: string): string {
return value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}

View File

@@ -0,0 +1,70 @@
import { Symbol, isPropertySignature, isEnumDeclaration, forEachChild, Node } from 'typescript';
import { TypeScriptProject } from './createTypeScriptProject';
import { ParsedProperty } from '../types/ApiBuilder.types';
import { getSymbolDescription, getSymbolJSDocTags, stringifySymbol } from '../buildApiUtils';
const parseProperty = async (
propertySymbol: Symbol,
project: TypeScriptProject,
name: string,
): Promise<ParsedProperty> => ({
name,
description: getSymbolDescription(propertySymbol, project),
tags: getSymbolJSDocTags(propertySymbol),
required: !propertySymbol.declarations?.find(isPropertySignature)?.questionToken,
typeStr: await stringifySymbol(propertySymbol, project),
});
const extractInfoFromEnum = async (
typeName: string,
sourceFileNamePattern: RegExp,
project: TypeScriptProject,
): Promise<ParsedProperty[]> => {
// Generate the params
let result: ParsedProperty[] = [];
try {
const declarationCandidates = project.program
.getSourceFiles()
.filter((file) => sourceFileNamePattern.test(file.fileName));
let enumSymbol: Symbol | null = null;
declarationCandidates.forEach((file) => {
forEachChild(file, (node: Node) => {
if (isEnumDeclaration(node) && node.name.getText() === typeName) {
enumSymbol = project.checker.getSymbolAtLocation(node.name)!;
}
});
});
if (!enumSymbol) {
return [];
}
const type = project.checker.getDeclaredTypeOfSymbol(enumSymbol!);
// @ts-ignore
const typeDeclaration = type?.types ?? [type];
if (!typeDeclaration) {
return [];
}
const properties: Record<string, ParsedProperty> = {};
// @ts-ignore
await Promise.all(
typeDeclaration.map(async (t: any) => {
const propertySymbol = t.symbol;
properties[t.value] = await parseProperty(propertySymbol, project, t.value);
}),
);
result = Object.values(properties)
.filter((property) => !property.tags.ignore)
.sort((a, b) => a.name.localeCompare(b.name));
} catch {
console.error(`No declaration for ${typeName}`);
}
return result;
};
export default extractInfoFromEnum;

View File

@@ -0,0 +1,54 @@
import { Symbol, isPropertySignature } from 'typescript';
import { TypeScriptProject } from './createTypeScriptProject';
import { ParsedProperty } from '../types/ApiBuilder.types';
import { getSymbolDescription, getSymbolJSDocTags, stringifySymbol } from '../buildApiUtils';
const parseProperty = async (
propertySymbol: Symbol,
project: TypeScriptProject,
): Promise<ParsedProperty> => ({
name: propertySymbol.name,
description: getSymbolDescription(propertySymbol, project),
tags: getSymbolJSDocTags(propertySymbol),
required: !propertySymbol.declarations?.find(isPropertySignature)?.questionToken,
typeStr: await stringifySymbol(propertySymbol, project),
});
const extractInfoFromType = async (
typeName: string,
project: TypeScriptProject,
): Promise<ParsedProperty[]> => {
// Generate the params
let result: ParsedProperty[] = [];
try {
const exportedSymbol = project.exports[typeName];
const type = project.checker.getDeclaredTypeOfSymbol(exportedSymbol);
// @ts-ignore
const typeDeclaration = type?.symbol?.declarations?.[0];
if (!typeDeclaration) {
return [];
}
const properties: Record<string, ParsedProperty> = {};
// @ts-ignore
const propertiesOnProject = type.getProperties();
// @ts-ignore
await Promise.all(
propertiesOnProject.map(async (propertySymbol) => {
properties[propertySymbol.name] = await parseProperty(propertySymbol, project);
}),
);
result = Object.values(properties)
.filter((property) => !property.tags.ignore)
.sort((a, b) => a.name.localeCompare(b.name));
} catch {
console.error(`No declaration for ${typeName}`);
}
return result;
};
export default extractInfoFromType;

View File

@@ -0,0 +1,12 @@
import { expect } from 'chai';
import { extractApiPage } from './findApiPages';
describe('extractApiPage', () => {
it('return info for api page', () => {
expect(
extractApiPage('/material-ui/docs/pages/material-ui/api/accordion-actions.js'),
).to.deep.equal({
apiPathname: '/material-ui/api/accordion-actions',
});
});
});

View File

@@ -0,0 +1,61 @@
import path from 'path';
import * as fs from 'node:fs';
const getAllFiles = (dirPath: string, arrayOfFiles: string[] = []) => {
const files = fs.readdirSync(dirPath);
files.forEach((file) => {
if (fs.statSync(`${dirPath}/${file}`).isDirectory()) {
arrayOfFiles = getAllFiles(`${dirPath}/${file}`, arrayOfFiles);
} else {
arrayOfFiles.push(path.join(__dirname, dirPath, '/', file));
}
});
return arrayOfFiles;
};
export function extractApiPage(filePath: string) {
filePath = filePath.replace(new RegExp(`\\${path.sep}`, 'g'), '/');
return {
apiPathname: filePath
.replace(/^.*\/pages/, '')
.replace(/\.(js|tsx)/, '')
.replace(/^\/index$/, '/') // Replace `index` by `/`.
.replace(/\/index$/, ''),
};
}
export default function findApiPages(relativeFolder: string) {
let pages: Array<{ pathname: string }> = [];
let filePaths: string[] = [];
try {
filePaths = getAllFiles(path.join(process.cwd(), relativeFolder));
} catch (error) {
// eslint-disable-next-line no-console
console.log(error);
return [];
}
filePaths.forEach((itemPath) => {
if (itemPath.endsWith('.js')) {
const data = extractApiPage(itemPath);
pages.push({ pathname: data.apiPathname });
}
});
// sort by pathnames without '-' so that e.g. card comes before card-action
pages = pages.sort((a, b) => {
const pathnameA = a.pathname.replace(/-/g, '');
const pathnameB = b.pathname.replace(/-/g, '');
if (pathnameA < pathnameB) {
return -1;
}
if (pathnameA > pathnameB) {
return 1;
}
return 0;
});
return pages;
}

View File

@@ -0,0 +1,39 @@
import fs from 'fs';
import path from 'path';
import findIndexFile from './findIndexFile';
const componentRegex = /^(Unstable_)?([A-Z][a-z]*)+2?\.(js|tsx)/;
/**
* Returns the component source in a flat array.
* @param {string} directory
* @param {Array<{ filename: string, indexFilename: string }>} components
*/
export default function findComponents(
directory: string,
components: { filename: string; indexFilename: string | null }[] = [],
) {
const items = fs.readdirSync(directory);
items.forEach((item) => {
const itemPath = path.resolve(directory, item);
if (fs.statSync(itemPath).isDirectory()) {
findComponents(itemPath, components);
return;
}
if (!componentRegex.test(item)) {
return;
}
const indexFile = findIndexFile(directory);
components.push({
filename: itemPath,
...indexFile,
});
});
return components;
}

View File

@@ -0,0 +1,39 @@
import fs from 'fs';
import path from 'path';
import findIndexFile from './findIndexFile';
const hooksRegexp = /use([A-Z][a-z]+)+\.(js|tsx|ts)/;
/**
* Returns the hook source in a flat array.
* @param {string} directory
* @param {Array<{ filename: string }>} hooks
*/
export default function findHooks(
directory: string,
hooks: { filename: string; indexFilename: string | null }[] = [],
) {
const items = fs.readdirSync(directory);
items.forEach((item) => {
const itemPath = path.resolve(directory, item);
if (fs.statSync(itemPath).isDirectory()) {
findHooks(itemPath, hooks);
return;
}
if (!hooksRegexp.test(item)) {
return;
}
const indexFile = findIndexFile(directory);
hooks.push({
filename: itemPath,
...indexFile,
});
});
return hooks;
}

View File

@@ -0,0 +1,23 @@
import fs from 'fs';
import path from 'path';
const indexFileRegex = /^index.(js|ts)$/;
/**
* Returns index.js/ts in any directory or null
* @param {string} directory
*/
export default function getIndexFile(directory: string) {
const items = fs.readdirSync(directory);
const indexFile = items.reduce((prev, curr) => {
if (!indexFileRegex.test(curr)) {
return prev;
}
return curr;
}, '');
return {
indexFilename: indexFile ? path.join(directory, indexFile) : null,
};
}

View File

@@ -0,0 +1,51 @@
import fs from 'fs';
import path from 'path';
interface MarkdownPage {
filename: string;
pathname: string;
}
/**
* Returns the markdowns of the documentation in a flat array.
*/
export default function findPagesMarkdown(
directory: string = path.resolve(__dirname, '../../../docs/data'),
pagesMarkdown: MarkdownPage[] = [],
) {
const items = fs.readdirSync(directory);
items.forEach((item) => {
const filename = path.resolve(directory, item);
if (fs.statSync(filename).isDirectory()) {
findPagesMarkdown(filename, pagesMarkdown);
return;
}
// Ignore non en-US source markdown.
if (!/\.mdx?$/.test(item) || /-(zh|pt)\.mdx?/.test(item)) {
return;
}
let pathname = filename
.replace(new RegExp(`\\${path.sep}`, 'g'), '/')
.replace(/^.*\/data/, '')
.replace(/\.mdx?/, '');
// Remove the last pathname segment.
pathname = pathname
.split('/')
.slice(0, pathname.split('/').length - 1)
.join('/');
pagesMarkdown.push({
// Relative location of the markdown file in the file system.
filename,
// Relative location of the page in the URL.
pathname,
});
});
return pagesMarkdown;
}

View File

@@ -0,0 +1,55 @@
import { mkdirSync } from 'fs';
import path from 'path';
import { kebabCase } from 'es-toolkit/string';
import { writePrettifiedFile } from '../buildApiUtils';
import { HooksTranslations, PropsTranslations } from '../types/ApiBuilder.types';
interface MinimalReactAPI {
name: string;
apiDocsTranslationFolder?: string;
translations: PropsTranslations | HooksTranslations;
}
export default async function generateApiTranslations<ReactApi extends MinimalReactAPI>(
outputDirectory: string,
reactApi: ReactApi,
languages: string[],
) {
const definitionName = reactApi.name;
const apiDocsTranslationPath = path.resolve(outputDirectory, kebabCase(definitionName));
function resolveApiDocsTranslationsComponentLanguagePath(
language: (typeof languages)[0],
): string {
const languageSuffix = language === 'en' ? '' : `-${language}`;
return path.join(apiDocsTranslationPath, `${kebabCase(definitionName)}${languageSuffix}.json`);
}
mkdirSync(apiDocsTranslationPath, {
mode: 0o777,
recursive: true,
});
reactApi.apiDocsTranslationFolder = apiDocsTranslationPath;
await writePrettifiedFile(
resolveApiDocsTranslationsComponentLanguagePath('en'),
JSON.stringify(reactApi.translations),
);
await Promise.all(
languages.map(async (language) => {
if (language !== 'en') {
try {
await writePrettifiedFile(
resolveApiDocsTranslationsComponentLanguagePath(language),
JSON.stringify(reactApi.translations),
undefined,
{ flag: 'wx' },
);
} catch (error) {
// File exists
}
}
}),
);
}

View File

@@ -0,0 +1,211 @@
import * as doctrine from 'doctrine';
import * as recast from 'recast';
import { PropTypeDescriptor } from 'react-docgen';
import { escapeCell, removeNewLines } from '../buildApi';
import {
isElementTypeAcceptingRefProp,
isElementAcceptingRefProp,
} from './generatePropTypeDescription';
import { DescribeablePropDescriptor } from './createDescribeableProp';
import { SeeMore } from '../types/utils.types';
function resolveType(type: NonNullable<doctrine.Tag['type']>): string {
if (type.type === 'AllLiteral') {
return 'any';
}
if (type.type === 'VoidLiteral') {
return 'void';
}
if (type.type === 'NullLiteral') {
return 'null';
}
if (type.type === 'UndefinedLiteral') {
return 'undefined';
}
if (type.type === 'TypeApplication') {
return `${resolveType(type.expression)}<${type.applications
.map((typeApplication) => {
return resolveType(typeApplication);
})
.join(', ')}>`;
}
if (type.type === 'UnionType') {
return type.elements.map((t) => resolveType(t)).join(' | ');
}
if (type.type === 'RecordType') {
if (type.fields.length === 0) {
return '{}';
}
return `{ ${type.fields.map((field) => resolveType(field)).join(', ')} }`;
}
if (type.type === 'FieldType') {
return `${type.key}: ${type.value ? resolveType(type.value) : 'any'}`;
}
if ('name' in type) {
return type.name;
}
throw new TypeError(`resolveType for '${type.type}' not implemented`);
}
function getDeprecatedInfo(type: PropTypeDescriptor) {
const marker = /deprecatedPropType\((\r*\n)*\s*PropTypes\./g;
const match = type.raw.match(marker);
const startIndex = type.raw.search(marker);
if (match) {
const offset = match[0].length;
return {
propTypes: type.raw.substring(startIndex + offset, type.raw.indexOf(',')),
explanation: recast.parse(type.raw).program.body[0].expression.arguments[1].value,
};
}
return false;
}
interface PropTemplateDescriptor {
key: string;
description: string;
}
export default function generatePropDescription(
prop: DescribeablePropDescriptor,
propName: string,
): {
deprecated: string;
seeMore?: SeeMore;
jsDocText: string;
signature?: string;
generics?: { name: string; description: string }[];
signatureArgs?: {
name: string;
description: string;
argType?: string;
argTypeDescription?: string;
}[];
signatureReturn?: { name: string; description: string };
requiresRef?: boolean;
} {
const { annotation } = prop;
const type = prop.type;
let deprecated = '';
if (type.name === 'custom') {
const deprecatedInfo = getDeprecatedInfo(type);
if (deprecatedInfo) {
deprecated = `*Deprecated*. ${deprecatedInfo.explanation}<br><br>`;
}
}
const seeTag = annotation.tags.find((tag) => tag.title === 'see');
let seeMore;
if (seeTag && seeTag.description) {
const params = seeTag.description.match(/{@link ([^|| ]*)[|| ]([^}]*)}/);
if (params?.length === 3) {
seeMore = {
description: seeTag.description.replace(/{@link ([^|| ]*)[|| ]([^}]*)}/, '{{link}}'),
link: {
url: params[1],
text: params[2],
},
};
}
}
const jsDocText = escapeCell(annotation.description);
// Split up the parsed tags into 'arguments' and 'returns' parsed objects. If there's no
// 'returns' parsed object (i.e., one with title being 'returns'), make one of type 'void'.
const parsedArgs: readonly doctrine.Tag[] = annotation.tags.filter(
(tag) => tag.title === 'param',
);
let parsedReturns: { description?: string | null; type?: doctrine.Type | null } | undefined =
annotation.tags.find((tag) => tag.title === 'returns');
let signature;
let signatureArgs;
let signatureReturn;
const generics = annotation.tags
.filter((tag) => tag.title === 'template')
.map((template) => {
const [key, description] = template.description?.split(/(?<=^\S+)\s/) || [];
if (!description) {
return null;
}
return { key, description };
})
.filter(Boolean) as PropTemplateDescriptor[];
if (type.name === 'func' && (parsedArgs.length > 0 || parsedReturns !== undefined)) {
parsedReturns = parsedReturns ?? { type: { type: 'VoidLiteral' } };
// Remove new lines from tag descriptions to avoid markdown errors.
annotation.tags.forEach((tag) => {
if (tag.description) {
tag.description = removeNewLines(tag.description);
}
});
const returnType = parsedReturns.type;
if (returnType == null) {
throw new TypeError(
`Function signature for prop '${propName}' has no return type. Try \`@returns void\`. Otherwise it might be a bug with doctrine.`,
);
}
const returnTypeName = resolveType(returnType);
signature = `function(${parsedArgs
.map((tag, index) => {
if (tag.type != null && tag.type.type === 'OptionalType') {
return `${tag.name}?: ${(tag.type.expression as any).name}`;
}
if (tag.type === undefined) {
throw new TypeError(
`In function signature for prop '${propName}' Argument #${index} has no type.`,
);
}
return `${tag.name}: ${resolveType(tag.type!)}`;
})
.join(', ')}) => ${returnTypeName}`;
signatureArgs = parsedArgs
.filter((tag) => tag.description && tag.name)
.map((tag) => {
const generic = generics.find(
(g) => tag.type?.type === 'NameExpression' && tag.type?.name === g.key,
);
return {
name: tag.name!,
description: tag.description!,
argType: generic?.key,
argTypeDescription: generic?.description,
};
});
if (parsedReturns.description) {
signatureReturn = { name: returnTypeName, description: parsedReturns.description };
}
}
const requiresRef =
isElementAcceptingRefProp(type) || isElementTypeAcceptingRefProp(type) || undefined;
return {
deprecated,
seeMore,
jsDocText,
signature,
signatureArgs,
signatureReturn,
requiresRef,
};
}

View File

@@ -0,0 +1,142 @@
import * as recast from 'recast';
import { parse as docgenParse, PropTypeDescriptor } from 'react-docgen';
import { escapeCell, escapeEntities, joinUnionTypes } from '../buildApi';
function getDeprecatedInfo(type: PropTypeDescriptor) {
const marker = /deprecatedPropType\((\r*\n)*\s*PropTypes\./g;
const match = type.raw.match(marker);
const startIndex = type.raw.search(marker);
if (match) {
const offset = match[0].length;
return {
propTypes: type.raw.substring(startIndex + offset, type.raw.indexOf(',')),
explanation: recast.parse(type.raw).program.body[0].expression.arguments[1].value,
};
}
return false;
}
export function getChained(type: PropTypeDescriptor) {
if (type.raw) {
const marker = 'chainPropTypes';
const indexStart = type.raw.indexOf(marker);
if (indexStart !== -1) {
const parsed = docgenParse(
`
import PropTypes from 'prop-types';
const Foo = () => <div />
Foo.propTypes = {
bar: ${recast.print(recast.parse(type.raw).program.body[0].expression.arguments[0]).code}
}
export default Foo
`,
null,
null,
// helps react-docgen pickup babel.config.js
{ filename: './' },
);
return {
type: parsed.props.bar.type,
required: parsed.props.bar.required,
};
}
}
return false;
}
export function isElementTypeAcceptingRefProp(type: PropTypeDescriptor): boolean {
return type.raw === 'elementTypeAcceptingRef';
}
function isRefType(type: PropTypeDescriptor): boolean {
return type.raw === 'refType';
}
function isIntegerType(type: PropTypeDescriptor): boolean {
return type.raw.startsWith('integerPropType');
}
export function isElementAcceptingRefProp(type: PropTypeDescriptor): boolean {
return /^elementAcceptingRef/.test(type.raw);
}
export default function generatePropTypeDescription(type: PropTypeDescriptor): string | undefined {
switch (type.name) {
case 'custom': {
if (isElementTypeAcceptingRefProp(type)) {
return 'element type';
}
if (isElementAcceptingRefProp(type)) {
return 'element';
}
if (isIntegerType(type)) {
return 'integer';
}
if (isRefType(type)) {
return 'ref';
}
if (type.raw === 'HTMLElementType') {
return 'HTML element';
}
if (type.raw === '() => null') {
return 'any';
}
const deprecatedInfo = getDeprecatedInfo(type);
if (deprecatedInfo !== false) {
return generatePropTypeDescription({
// eslint-disable-next-line react/forbid-foreign-prop-types
name: deprecatedInfo.propTypes,
} as any);
}
const chained = getChained(type);
if (chained !== false) {
return generatePropTypeDescription(chained.type);
}
return type.raw;
}
case 'shape':
return `{ ${Object.keys(type.value)
.map((subValue) => {
const subType = type.value[subValue];
return `${subValue}${subType.required ? '' : '?'}: ${generatePropTypeDescription(
subType,
)}`;
})
.join(', ')} }`;
case 'union':
return joinUnionTypes(
type.value.map((type2) => {
return generatePropTypeDescription(type2) ?? '';
}),
);
case 'enum':
return joinUnionTypes(
type.value.map((type2) => {
return escapeCell(type2.value);
}),
);
case 'arrayOf': {
return `Array${escapeEntities('<')}${generatePropTypeDescription(type.value)}${escapeEntities('>')}`;
}
case 'instanceOf': {
if (type.value.startsWith('typeof')) {
return /typeof (.*) ===/.exec(type.value)![1];
}
return type.value;
}
default:
return type.name;
}
}

View File

@@ -0,0 +1,359 @@
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'
);
}
// TODO update to reflect https://github.com/DefinitelyTyped/DefinitelyTyped/pull/65135
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,236 @@
import * as ts from 'typescript';
import { ComponentClassDefinition } from '@mui-internal/api-docs-builder';
import { renderMarkdown } from '../buildApi';
import { getSymbolDescription, getSymbolJSDocTags } from '../buildApiUtils';
import { TypeScriptProject } from './createTypeScriptProject';
import { getPropsFromComponentNode } from './getPropsFromComponentNode';
import resolveExportSpecifier from './resolveExportSpecifier';
import { ProjectSettings } from '../ProjectSettings';
import { Slot } from '../types/utils.types';
interface ClassInfo {
description: string;
isDeprecated?: true;
deprecationInfo?: string;
}
/**
* Gets the deprecation information for a given symbol.
* @param symbol - The TypeScript symbol.
* @returns An object containing the deprecation information, if the symbol is deprecated.
*/
function getClassDeprecationObject(symbol: ts.Symbol): {
isDeprecated?: true;
deprecationInfo?: string;
} {
const tags = getSymbolJSDocTags(symbol);
if (tags.deprecated) {
return {
isDeprecated: true,
deprecationInfo: renderMarkdown(tags.deprecated.text?.[0].text || '').trim() || undefined,
};
}
return {};
}
interface ParseSlotsAndClassesParameters {
typescriptProject: TypeScriptProject;
projectSettings: ProjectSettings;
componentName: string;
muiName: string;
slotInterfaceName?: string;
}
export default function parseSlotsAndClasses({
typescriptProject,
projectSettings,
componentName,
muiName,
slotInterfaceName,
}: ParseSlotsAndClassesParameters): {
slots: Slot[];
classes: ComponentClassDefinition[];
} {
// Obtain an array of classes for the given component
const classDefinitions = extractClasses(
typescriptProject,
projectSettings,
componentName,
muiName,
);
const slots = extractSlots(typescriptProject, componentName, classDefinitions, slotInterfaceName);
const nonSlotClassDefinitions = classDefinitions.filter(
(classDefinition) => !Object.keys(slots).includes(classDefinition.key),
);
return {
slots: Object.values(slots),
classes: nonSlotClassDefinitions,
};
}
function extractClasses(
typescriptProject: TypeScriptProject,
projectSettings: ProjectSettings,
componentName: string,
muiName: string,
): ComponentClassDefinition[] {
return (
extractClassesFromProps(typescriptProject, projectSettings, componentName, muiName) ??
extractClassesFromInterface(typescriptProject, projectSettings, componentName, muiName)
);
}
/**
* Gets class names and descriptions from the {ComponentName}Classes interface.
*/
function extractClassesFromInterface(
typescriptProject: TypeScriptProject,
projectSettings: ProjectSettings,
componentName: string,
muiName: string,
): ComponentClassDefinition[] {
const result: ComponentClassDefinition[] = [];
const classesInterfaceName = `${componentName}Classes`;
if (!typescriptProject.exports[classesInterfaceName]) {
return result;
}
const classesType = typescriptProject.checker.getDeclaredTypeOfSymbol(
typescriptProject.exports[classesInterfaceName],
);
const classesTypeDeclaration = classesType?.symbol?.declarations?.[0];
if (classesTypeDeclaration && ts.isInterfaceDeclaration(classesTypeDeclaration)) {
const classesProperties = classesType.getProperties();
classesProperties.forEach((symbol) => {
const tags = getSymbolJSDocTags(symbol);
if (tags.ignore) {
return;
}
result.push({
key: symbol.name,
className: projectSettings.generateClassName(muiName, symbol.name),
description: getSymbolDescription(symbol, typescriptProject),
isGlobal: projectSettings.isGlobalClassName(symbol.name),
...getClassDeprecationObject(symbol),
});
});
}
return result;
}
function extractClassesFromProps(
typescriptProject: TypeScriptProject,
projectSettings: ProjectSettings,
componentName: string,
muiName: string,
): ComponentClassDefinition[] | null {
const unstableName = `Unstable_${componentName}`;
const exportedSymbol =
typescriptProject.exports[componentName] ?? typescriptProject.exports[unstableName];
if (!exportedSymbol) {
throw new Error(
`No export found in "${typescriptProject.rootPath}" for component "${componentName}" or "${unstableName}".`,
);
}
const localeSymbol = resolveExportSpecifier(exportedSymbol, typescriptProject);
const declaration = localeSymbol.valueDeclaration!;
const classesProp = getPropsFromComponentNode({
node: declaration,
project: typescriptProject,
shouldInclude: ({ name }) => name === 'classes',
checkDeclarations: true,
})?.props.classes;
if (classesProp == null) {
return null;
}
const classes: Record<string, ClassInfo> = {};
classesProp.signatures.forEach((propType) => {
const type = typescriptProject.checker.getTypeAtLocation(propType.symbol.declarations![0]);
removeUndefinedFromType(type)
?.getProperties()
.forEach((property) => {
const tags = getSymbolJSDocTags(property);
if (tags.ignore) {
return;
}
const description = getSymbolDescription(property, typescriptProject);
classes[property.escapedName.toString()] = {
description,
...getClassDeprecationObject(property),
};
});
});
return Object.keys(classes).map((name) => ({
key: name,
className: projectSettings.generateClassName(muiName, name),
description: name !== classes[name].description ? classes[name].description : '',
isGlobal: projectSettings.isGlobalClassName(name),
isDeprecated: classes[name].isDeprecated,
deprecationInfo: classes[name].deprecationInfo,
}));
}
function extractSlots(
project: TypeScriptProject,
componentName: string,
classDefinitions: ComponentClassDefinition[],
slotsInterfaceNameParams?: string,
): Record<string, Slot> {
const defaultSlotsInterfaceName = `${componentName}Slots`;
const slotsInterfaceName = slotsInterfaceNameParams ?? defaultSlotsInterfaceName;
const exportedSymbol = project.exports[slotsInterfaceName];
if (!exportedSymbol) {
console.warn(`No declaration for ${slotsInterfaceName}`);
return {};
}
const type = project.checker.getDeclaredTypeOfSymbol(exportedSymbol);
const typeDeclaration = type?.symbol?.declarations?.[0];
if (!typeDeclaration || !ts.isInterfaceDeclaration(typeDeclaration)) {
return {};
}
const slots: Record<string, Slot> = {};
const propertiesOnProject = type.getProperties();
const classDefMap = new Map(classDefinitions.map((classDef) => [classDef.key, classDef]));
propertiesOnProject.forEach((propertySymbol) => {
const tags = getSymbolJSDocTags(propertySymbol);
if (tags.ignore) {
return;
}
const slotName = propertySymbol.name;
const slotClassDefinition = classDefMap.get(slotName);
slots[slotName] = {
name: slotName,
description: getSymbolDescription(propertySymbol, project),
default: tags.default?.text?.[0].text,
class: slotClassDefinition?.className ?? null,
};
});
return slots;
}
function removeUndefinedFromType(type: ts.Type) {
// eslint-disable-next-line no-bitwise
if (type.flags & ts.TypeFlags.Union) {
return (type as ts.UnionType).types.find((subType) => {
return subType.flags !== ts.TypeFlags.Undefined;
});
}
return type;
}

View File

@@ -0,0 +1,193 @@
import * as path from 'path';
import * as babel from '@babel/core';
import * as fs from 'node:fs/promises';
import glob from 'fast-glob';
function getTestFilesNames(filepath: string) {
return glob.sync(
path
.join(
path.dirname(filepath),
`/{tests/,}{*.,}${path.basename(filepath, path.extname(filepath))}.test.{js,ts,tsx}`,
)
.replace(/\\/g, '/'),
{ absolute: true },
);
}
async function parseWithConfig(filename: string) {
const source = await fs.readFile(filename, { encoding: 'utf8' });
const partialConfig = babel.loadPartialConfig({
filename,
});
if (partialConfig === null) {
throw new Error(`Could not load a babel config for ${filename}.`);
}
return babel.parseAsync(source, partialConfig.options);
}
function findConformanceDescriptor(
file: babel.ParseResult,
): null | { name: string; body: babel.types.ObjectExpression } {
const { types: t } = babel;
let descriptor = null;
babel.traverse(file, {
CallExpression(babelPath) {
const { node: callExpression } = babelPath;
const { callee } = callExpression;
if (t.isIdentifier(callee) && callee.name.startsWith('describeConformance')) {
const [, optionsFactory] = callExpression.arguments;
if (
t.isArrowFunctionExpression(optionsFactory) &&
t.isObjectExpression(optionsFactory.body)
) {
// describeConformance(element, () => options);
descriptor = {
name: callee.name,
body: optionsFactory.body,
};
} else {
throw new Error(
`Only an arrow function returning an object expression is supported as the second argument to \`describeConformance\` ` +
`e.g. \`describeConformance(element, () => ({ someOption: someValue }))\` `,
);
}
}
},
});
return descriptor;
}
function getRefInstance(valueNode: babel.Node): string | undefined {
if (babel.types.isIdentifier(valueNode)) {
return valueNode.name;
}
if (!babel.types.isMemberExpression(valueNode)) {
throw new Error(
'Expected a member expression (for example window.HTMLDivElement) or a global identifier (for example Object) in refInstanceof. ' +
'If the ref will not be resolved use `refInstanceof: undefined`.',
);
}
const { object, property } = valueNode;
if (!babel.types.isIdentifier(object)) {
throw new Error(
`Expected an Identifier as the object of the MemberExpression of refInstanceOf but got '${object.type}'`,
);
}
if (!babel.types.isIdentifier(property)) {
throw new Error(
`Expected an Identifier as the property of the MemberExpression of refInstanceOf but got '${object.type}'`,
);
}
switch (object.name) {
case 'window':
return property.name;
case 'React':
return `React.${property.name}`;
default:
throw new Error(`Unrecognized member expression starting with '${object.name}'`);
}
}
function getInheritComponentName(valueNode: babel.types.Node): string | undefined {
return (valueNode as any).name;
}
function getSkippedTests(valueNode: babel.types.Node): string[] {
if (!babel.types.isArrayExpression(valueNode)) {
throw new TypeError(
`Unable to determine skipped tests from '${valueNode.type}'. Expected an 'ArrayExpression' i.e. \`skippedTests: ["a", "b"]\`.`,
);
}
return valueNode.elements.map((element) => {
if (!babel.types.isStringLiteral(element)) {
throw new TypeError(
`Unable to determine skipped test from '${element?.type}'. Expected a 'StringLiter' i.e. \`"a"\`.`,
);
}
return element.value;
});
}
export interface ParseResult {
forwardsRefTo: string | undefined;
inheritComponent: string | undefined;
spread: boolean | undefined;
themeDefaultProps: boolean | undefined | null;
}
export default async function parseTest(componentFilename: string): Promise<ParseResult> {
const testFilenames = getTestFilesNames(componentFilename);
if (testFilenames.length === 0) {
throw new Error(
`Could not find any test file next to ${componentFilename}. The test filename should end with '.test.{js,ts,tsx}'.`,
);
}
let descriptor: ReturnType<typeof findConformanceDescriptor> = null;
try {
for await (const testFilename of testFilenames) {
if (descriptor === null) {
const babelParseResult = await parseWithConfig(testFilename);
if (babelParseResult === null) {
throw new Error(`Could not parse ${testFilename}.`);
}
descriptor = findConformanceDescriptor(babelParseResult);
}
}
} catch (error) {
console.error(error);
}
const result: ParseResult = {
forwardsRefTo: undefined,
inheritComponent: undefined,
spread: undefined,
themeDefaultProps: null,
};
if (descriptor === null) {
return result;
}
let skippedTests: string[] = [];
descriptor.body.properties.forEach((property) => {
if (!babel.types.isObjectProperty(property)) {
return;
}
const key: string = (property.key as any).name;
switch (key) {
case 'refInstanceof':
result.forwardsRefTo = getRefInstance(property.value);
break;
case 'inheritComponent':
result.inheritComponent = getInheritComponentName(property.value);
break;
case 'skip':
skippedTests = getSkippedTests(property.value);
break;
default:
break;
}
});
result.spread = !skippedTests.includes('propsSpread');
result.themeDefaultProps =
descriptor.name === 'describeConformanceUnstyled'
? undefined
: !skippedTests.includes('themeDefaultProps');
return result;
}

View File

@@ -0,0 +1,306 @@
import { expect } from 'chai';
import replaceUrl, {
replaceMaterialLinks,
replaceAPILinks,
replaceComponentLinks,
} from './replaceUrl';
describe('replaceUrl', () => {
it('replace material related pathname', () => {
expect(replaceMaterialLinks(`/guides/minimizing-bundle-size/`)).to.equal(
`/material-ui/guides/minimizing-bundle-size/`,
);
expect(replaceMaterialLinks(`/customization/theme-components/#default-props`)).to.equal(
`/material-ui/customization/theme-components/#default-props`,
);
expect(replaceMaterialLinks(`/getting-started/usage/`)).to.equal(
`/material-ui/getting-started/usage/`,
);
expect(replaceMaterialLinks(`/discover-more/related-projects/`)).to.equal(
`/material-ui/discover-more/related-projects/`,
);
expect(replaceMaterialLinks(`/experimental-api/css-theme-variables/overview/`)).to.equal(
`/material-ui/experimental-api/css-theme-variables/overview/`,
);
expect(replaceMaterialLinks(`/migration/upgrade-to-grid-v2/`)).to.equal(
`/material-ui/migration/upgrade-to-grid-v2/`,
);
});
it('should not change if links have been updated', () => {
expect(replaceMaterialLinks(`/material-ui/guides/minimizing-bundle-size/`)).to.equal(
`/material-ui/guides/minimizing-bundle-size/`,
);
expect(
replaceMaterialLinks(`/material-ui/customization/theme-components/#default-props`),
).to.equal(`/material-ui/customization/theme-components/#default-props`);
expect(replaceMaterialLinks(`/material-ui/getting-started/usage/`)).to.equal(
`/material-ui/getting-started/usage/`,
);
expect(replaceMaterialLinks(`/material-ui/discover-more/related-projects/`)).to.equal(
`/material-ui/discover-more/related-projects/`,
);
});
it('replace correct component links', () => {
expect(replaceComponentLinks(`/components/button-group/`)).to.equal(
`/material-ui/react-button-group/`,
);
expect(replaceComponentLinks(`/components/button-group/#main-content`)).to.equal(
`/material-ui/react-button-group/#main-content`,
);
expect(replaceComponentLinks(`/components/buttons/`)).to.equal(`/material-ui/react-button/`);
expect(replaceComponentLinks(`/components/buttons/#main-content`)).to.equal(
`/material-ui/react-button/#main-content`,
);
expect(replaceComponentLinks(`/components/checkboxes/`)).to.equal(
`/material-ui/react-checkbox/`,
);
expect(replaceComponentLinks(`/components/checkboxes/#main-content`)).to.equal(
`/material-ui/react-checkbox/#main-content`,
);
expect(replaceComponentLinks(`/components/radio-buttons/`)).to.equal(
`/material-ui/react-radio-button/`,
);
expect(replaceComponentLinks(`/components/radio-buttons/#main-content`)).to.equal(
`/material-ui/react-radio-button/#main-content`,
);
expect(replaceComponentLinks(`/components/selects/`)).to.equal(`/material-ui/react-select/`);
expect(replaceComponentLinks(`/components/selects/#main-content`)).to.equal(
`/material-ui/react-select/#main-content`,
);
expect(replaceComponentLinks(`/components/switches/`)).to.equal(`/material-ui/react-switch/`);
expect(replaceComponentLinks(`/components/switches/#main-content`)).to.equal(
`/material-ui/react-switch/#main-content`,
);
expect(replaceComponentLinks(`/components/text-fields/`)).to.equal(
`/material-ui/react-text-field/`,
);
expect(replaceComponentLinks(`/components/text-fields/#main-content`)).to.equal(
`/material-ui/react-text-field/#main-content`,
);
expect(replaceComponentLinks(`/components/avatars/`)).to.equal(`/material-ui/react-avatar/`);
expect(replaceComponentLinks(`/components/avatars/#main-content`)).to.equal(
`/material-ui/react-avatar/#main-content`,
);
expect(replaceComponentLinks(`/components/badges/`)).to.equal(`/material-ui/react-badge/`);
expect(replaceComponentLinks(`/components/badges/#main-content`)).to.equal(
`/material-ui/react-badge/#main-content`,
);
expect(replaceComponentLinks(`/components/chips/`)).to.equal(`/material-ui/react-chip/`);
expect(replaceComponentLinks(`/components/chips/#main-content`)).to.equal(
`/material-ui/react-chip/#main-content`,
);
expect(replaceComponentLinks(`/components/dividers/`)).to.equal(`/material-ui/react-divider/`);
expect(replaceComponentLinks(`/components/dividers/#main-content`)).to.equal(
`/material-ui/react-divider/#main-content`,
);
expect(replaceComponentLinks(`/components/icons/`)).to.equal(`/material-ui/icons/`);
expect(replaceComponentLinks(`/components/material-icons/`)).to.equal(
`/material-ui/material-icons/`,
);
expect(replaceComponentLinks(`/components/lists/`)).to.equal(`/material-ui/react-list/`);
expect(replaceComponentLinks(`/components/lists/#main-content`)).to.equal(
`/material-ui/react-list/#main-content`,
);
expect(replaceComponentLinks(`/components/image-list/`)).to.equal(
`/material-ui/react-image-list/`,
);
expect(replaceComponentLinks(`/components/image-list/#main-content`)).to.equal(
`/material-ui/react-image-list/#main-content`,
);
expect(replaceComponentLinks(`/components/no-ssr/`)).to.equal(`/material-ui/react-no-ssr/`);
expect(replaceComponentLinks(`/components/no-ssr/#main-content`)).to.equal(
`/material-ui/react-no-ssr/#main-content`,
);
expect(replaceComponentLinks(`/components/trap-focus/`)).to.equal(
`/material-ui/react-trap-focus/`,
);
expect(replaceComponentLinks(`/components/trap-focus/#main-content`)).to.equal(
`/material-ui/react-trap-focus/#main-content`,
);
expect(replaceComponentLinks(`/components/progress/`)).to.equal(`/material-ui/react-progress/`);
expect(replaceComponentLinks(`/components/progress/#main-content`)).to.equal(
`/material-ui/react-progress/#main-content`,
);
expect(replaceComponentLinks(`/components/tables/`)).to.equal(`/material-ui/react-table/`);
expect(replaceComponentLinks(`/components/tables/#main-content`)).to.equal(
`/material-ui/react-table/#main-content`,
);
expect(replaceComponentLinks(`/components/tooltips/`)).to.equal(`/material-ui/react-tooltip/`);
expect(replaceComponentLinks(`/components/tooltips/#main-content`)).to.equal(
`/material-ui/react-tooltip/#main-content`,
);
expect(replaceComponentLinks(`/components/dialogs/`)).to.equal(`/material-ui/react-dialog/`);
expect(replaceComponentLinks(`/components/dialogs/#main-content`)).to.equal(
`/material-ui/react-dialog/#main-content`,
);
expect(replaceComponentLinks(`/components/snackbars/`)).to.equal(
`/material-ui/react-snackbar/`,
);
expect(replaceComponentLinks(`/components/snackbars/#main-content`)).to.equal(
`/material-ui/react-snackbar/#main-content`,
);
expect(replaceComponentLinks(`/components/cards/`)).to.equal(`/material-ui/react-card/`);
expect(replaceComponentLinks(`/components/cards/#main-content`)).to.equal(
`/material-ui/react-card/#main-content`,
);
expect(replaceComponentLinks(`/components/breadcrumbs/`)).to.equal(
`/material-ui/react-breadcrumbs/`,
);
expect(replaceComponentLinks(`/components/breadcrumbs/#main-content`)).to.equal(
`/material-ui/react-breadcrumbs/#main-content`,
);
expect(replaceComponentLinks(`/components/drawers/`)).to.equal(`/material-ui/react-drawer/`);
expect(replaceComponentLinks(`/components/drawers/#main-content`)).to.equal(
`/material-ui/react-drawer/#main-content`,
);
expect(replaceComponentLinks(`/components/links/`)).to.equal(`/material-ui/react-link/`);
expect(replaceComponentLinks(`/components/links/#main-content`)).to.equal(
`/material-ui/react-link/#main-content`,
);
expect(replaceComponentLinks(`/components/menus/`)).to.equal(`/material-ui/react-menu/`);
expect(replaceComponentLinks(`/components/menus/#main-content`)).to.equal(
`/material-ui/react-menu/#main-content`,
);
expect(replaceComponentLinks(`/components/steppers/`)).to.equal(`/material-ui/react-stepper/`);
expect(replaceComponentLinks(`/components/steppers/#main-content`)).to.equal(
`/material-ui/react-stepper/#main-content`,
);
expect(replaceComponentLinks(`/components/tabs/`)).to.equal(`/material-ui/react-tabs/`);
expect(replaceComponentLinks(`/components/tabs/#main-content`)).to.equal(
`/material-ui/react-tabs/#main-content`,
);
expect(replaceComponentLinks(`/components/transitions/`)).to.equal(`/material-ui/transitions/`);
expect(replaceComponentLinks(`/components/pickers/`)).to.equal(`/material-ui/pickers/`);
expect(replaceComponentLinks(`/components/about-the-lab/`)).to.equal(
`/material-ui/about-the-lab/`,
);
expect(replaceComponentLinks(`/components/data-grid/demo/`)).to.equal(
`/x/react-data-grid/demo/`,
);
});
it('replace correct API links', () => {
expect(replaceAPILinks(`/api/button/`)).to.equal(`/material-ui/api/button/`);
expect(replaceAPILinks(`/api/no-ssr/`)).to.equal(`/base-ui/api/no-ssr/`);
expect(replaceAPILinks(`/api/portal/`)).to.equal(`/base-ui/api/portal/`);
expect(replaceAPILinks(`/api/textarea-autosize/`)).to.equal(`/base-ui/api/textarea-autosize/`);
expect(replaceAPILinks(`/api/button-unstyled/`)).to.equal(`/base-ui/api/button-unstyled/`);
expect(replaceAPILinks(`/api/loading-button/`)).to.equal(`/material-ui/api/loading-button/`);
expect(replaceAPILinks(`/api/tab-list/`)).to.equal(`/material-ui/api/tab-list/`);
expect(replaceAPILinks(`/api/tab-panel/`)).to.equal(`/material-ui/api/tab-panel/`);
expect(replaceAPILinks(`/api/tab-panel-unstyled/`)).to.equal(
`/base-ui/api/tab-panel-unstyled/`,
);
expect(replaceAPILinks(`/api/tabs-list-unstyled/`)).to.equal(
`/base-ui/api/tabs-list-unstyled/`,
);
expect(replaceAPILinks(`/api/tabs-unstyled/`)).to.equal(`/base-ui/api/tabs-unstyled/`);
expect(replaceAPILinks(`/api/unstable-trap-focus/`)).to.equal(
`/base-ui/api/unstable-trap-focus/`,
);
expect(replaceAPILinks(`/api/click-away-listener/`)).to.equal(
`/base-ui/api/click-away-listener/`,
);
expect(replaceAPILinks(`/api/data-grid/data-grid/`)).to.equal(`/x/api/data-grid/data-grid/`);
expect(replaceAPILinks(`/system/basic/`)).to.equal(`/system/basic/`);
});
it('should do nothing if the components have updated', () => {
expect(replaceComponentLinks(`/material-ui/react-button-group/`)).to.equal(
`/material-ui/react-button-group/`,
);
expect(replaceComponentLinks(`/x/react-data-grid/demo/`)).to.equal(`/x/react-data-grid/demo/`);
});
it('should do nothing if the APIs have updated', () => {
expect(replaceAPILinks(`/material-ui/api/button/`)).to.equal(`/material-ui/api/button/`);
expect(replaceAPILinks(`/base-ui/api/button-unstyled/`)).to.equal(
`/base-ui/api/button-unstyled/`,
);
expect(replaceAPILinks(`/material-ui/api/loading-button/`)).to.equal(
`/material-ui/api/loading-button/`,
);
expect(replaceAPILinks(`/x/api/data-grid/`)).to.equal(`/x/api/data-grid/`);
});
it('only replace links for new routes (/material-ui/* & /x/*)', () => {
expect(replaceUrl(`/guides/minimizing-bundle-size/`, '/material-ui/react-buttons')).to.equal(
`/material-ui/guides/minimizing-bundle-size/`,
);
expect(replaceUrl(`/integrations/tailwindcss/`, '/material-ui')).to.equal(
`/material-ui/integrations/tailwindcss/`,
);
expect(
replaceUrl(`/components/data-grid/getting-started/#main-content`, '/x/react-data-grid'),
).to.equal(`/x/react-data-grid/getting-started/#main-content`);
expect(
replaceUrl(`/components/data-grid/components/#main-content`, '/x/react-data-grid'),
).to.equal(`/x/react-data-grid/components/#main-content`);
expect(replaceUrl(`/api/button-unstyled`, '/base-ui/api/button-unstyled')).to.equal(
`/base-ui/api/button-unstyled`,
);
expect(replaceUrl(`/styles/api/`, `/system/basics`)).to.equal(`/system/styles/api/`);
});
it('[i18n] only replace links for new routes (/material-ui/* & /x/*)', () => {
expect(
replaceUrl(`/zh/guides/minimizing-bundle-size/`, '/zh/material-ui/react-buttons'),
).to.equal(`/zh/material-ui/guides/minimizing-bundle-size/`);
expect(
replaceUrl(`/zh/components/data-grid/getting-started/#main-content`, '/zh/x/react-data-grid'),
).to.equal(`/zh/x/react-data-grid/getting-started/#main-content`);
expect(
replaceUrl(`/zh/components/data-grid/components/#main-content`, '/zh/x/react-data-grid'),
).to.equal(`/zh/x/react-data-grid/components/#main-content`);
expect(replaceUrl(`/zh/api/button-unstyled`, '/zh/base-ui/api/button-unstyled')).to.equal(
`/zh/base-ui/api/button-unstyled`,
);
expect(replaceUrl(`/zh/styles/api/`, `/system/basics`)).to.equal(`/zh/system/styles/api/`);
});
it('does not replace for old routes', () => {
expect(replaceUrl(`/guides/minimizing-bundle-size/`, '/components/buttons')).to.equal(
`/guides/minimizing-bundle-size/`,
);
expect(
replaceUrl(`/components/data-grid/getting-started/#main-content`, '/components/buttons'),
).to.equal(`/components/data-grid/getting-started/#main-content`);
});
it('does not replace for x marketing page', () => {
expect(replaceUrl(`/components/data-grid/getting-started/#main-content`, '/x/')).to.equal(
`/components/data-grid/getting-started/#main-content`,
);
});
});

View File

@@ -0,0 +1,92 @@
export function isNewLocation(url: string) {
url = url.replace(/^\/[a-z]{2}\//, '/');
if (url === '/x' || url === '/x/') {
// skipped if it is the X marketing page
return false;
}
return (
url.startsWith('/x') ||
url.startsWith('/material-ui') ||
url.startsWith('/base') ||
url.startsWith('/joy-ui') ||
url.startsWith('/system')
);
}
export const replaceMaterialLinks = (url: string) => {
if (isNewLocation(url)) {
return url;
}
return url.replace(
/(guides|customization|getting-started|discover-more|experimental-api|migration|integrations)/,
'material-ui/$1',
);
};
export const replaceComponentLinks = (url: string) => {
if (isNewLocation(url)) {
return url;
}
url = url.replace(/\/components\/data-grid/, '/x/react-data-grid');
if (isNewLocation(url)) {
return url;
}
if (url.startsWith('/customization')) {
url = url.replace('customization', 'material-ui/customization');
} else if (url.match(/components\/(icons|material-icons|transitions|pickers|about-the-lab)/)) {
url = url.replace(/\/components\/(.*)/, '/material-ui/$1');
} else {
url = url.replace(/\/components\/(.*)/, '/material-ui/react-$1');
// TODO remove, fix the markdown files to match the URLs
if (!url.match(/\/react-(tabs|breadcrumbs)/)) {
url = url
.replace(/(react-[-a-z]+)(x|ch)es([^a-z-])/, '$1$2$3')
.replace(/(react-[-a-z]+)s([^a-z-])/, '$1$2')
.replace(/(react-[-a-z]+)(x|ch)es$/, '$1$2')
.replace(/(react-[-a-z]+)s$/, '$1')
.replace(/react-trap-focu/, 'react-trap-focus')
.replace(/react-circular-progres/, 'react-circular-progress')
.replace(/react-linear-progres/, 'react-linear-progress')
.replace(/react-progres/, 'react-progress');
}
}
return url;
};
export const replaceAPILinks = (url: string) => {
if (isNewLocation(url) || !url.replace(/^\/[a-zA-Z]{2}\//, '/').startsWith('/api')) {
return url;
}
url = url
.replace(/\/api\/data-grid(.*)/, '/x/api/data-grid$1')
.replace(
/\/api\/(unstable-trap-focus|click-away-listener|no-ssr|portal|textarea-autosize)(.*)/,
'/base-ui/api/$1$2',
)
.replace(/\/api\/([^/]+-unstyled)(.*)/, '/base-ui/api/$1$2');
if (isNewLocation(url)) {
return url;
}
url = url.replace(
/\/api\/(loading-button|tab-list|tab-panel|date-picker|date-time-picker|time-picker|calendar-picker|calendar-picker-skeleton|desktop-picker|mobile-date-picker|month-picker|pickers-day|static-date-picker|year-picker|masonry|timeline|timeline-connector|timeline-content|timeline-dot|timeline-item|timeline-opposite-content|timeline-separator|unstable-trap-focus|tree-item|tree-view)(.*)/,
'/material-ui/api/$1$2',
);
if (isNewLocation(url)) {
return url;
}
return url.replace(/\/api\/(.*)/, '/material-ui/api/$1');
};
export default function replaceUrl(url: string, asPath: string) {
if (isNewLocation(asPath)) {
url = replaceMaterialLinks(replaceAPILinks(replaceComponentLinks(url)));
url = url.replace(/^\/styles\/(.*)/, '/system/styles/$1');
url = url.replace(/^\/([a-z]{2})\/styles\/(.*)/, '/$1/system/styles/$2');
}
return url;
}

View File

@@ -0,0 +1,61 @@
import * as ts from 'typescript';
import { TypeScriptProject } from './createTypeScriptProject';
function shouldAliasSymbol(symbol: ts.Symbol) {
const declaration = symbol.declarations?.[0];
if (!declaration) {
return false;
}
/**
* - `export { XXX }`
* - `export { XXX } from './modules'`
*/
if (ts.isExportSpecifier(declaration)) {
return true;
}
/**
* - `export default XXX`
*/
if (ts.isExportAssignment(declaration)) {
/**
* Return `true` only for `export default XXX`
* Not for `export default React.memo(XXX)` for example.
*/
return declaration.expression.kind === ts.SyntaxKind.Identifier;
}
return false;
}
/**
* Goes to the root symbol of ExportSpecifier
* That corresponds to one of the following patterns
* - `export { XXX }`
* - `export { XXX } from './modules'`
* - `export default XXX`
*
* Do not go to the root definition for TypeAlias (ie: `export type XXX = YYY`)
* Because we usually want to keep the description and tags of the aliased symbol.
*/
export default function resolveExportSpecifier(symbol: ts.Symbol, project: TypeScriptProject) {
let resolvedSymbol = symbol;
while (shouldAliasSymbol(resolvedSymbol)) {
let newResolvedSymbol;
try {
newResolvedSymbol = project.checker.getImmediateAliasedSymbol(resolvedSymbol);
} catch (err) {
newResolvedSymbol = null;
}
if (!newResolvedSymbol) {
throw new Error(`Impossible to resolve export specifier for symbol "${symbol.escapedName}"`);
}
resolvedSymbol = newResolvedSymbol;
}
return resolvedSymbol;
}

View File

@@ -0,0 +1,4 @@
export const sortAlphabetical =
<K extends string>(key: K) =>
<T extends { [key in K]: string }>(a: T, b: T) =>
a[key].localeCompare(b[key]);

View File

@@ -0,0 +1,4 @@
// eslint-disable-next-line import/no-relative-packages
import sharedConfig from '../../vitest.shared.mts';
export default sharedConfig(import.meta.url);