// @ts-check
const { declare } = require('@babel/helper-plugin-utils');
/**
* @typedef {import('@babel/core')} babel
* @typedef {{ id: babel.types.Expression, computed?: boolean }} ComponentIdentifier
*/
// remember to set `cacheDirectory` to `false` when modifying this plugin
const DEFAULT_ALLOWED_CALLEES = {
react: ['createContext', 'forwardRef', 'memo'],
};
/** @type {Map<string, string[]>} */
const calleeModuleMapping = new Map(); // Mapping of callee name to module name
const seenDisplayNames = new Set();
/**
* Applies allowed callees mapping to the internal calleeModuleMapping.
* @param {Record<string, string[]>} mapping - The mapping of module names to method names.
*/
function applyAllowedCallees(mapping) {
Object.entries(mapping).forEach(([moduleName, methodNames]) => {
methodNames.forEach((methodName) => {
const moduleNames = calleeModuleMapping.get(methodName) ?? [];
moduleNames.push(moduleName);
calleeModuleMapping.set(methodName, moduleNames);
});
});
}
module.exports = declare((api, options) => {
api.assertVersion(7);
calleeModuleMapping.clear();
applyAllowedCallees(DEFAULT_ALLOWED_CALLEES);
if (options.allowedCallees) {
applyAllowedCallees(options.allowedCallees);
}
const t = api.types;
return {
name: '@probablyup/babel-plugin-react-displayname',
visitor: {
Program() {
// We allow duplicate names across files,
// so we clear when we're transforming on a new file
seenDisplayNames.clear();
},
'FunctionExpression|ArrowFunctionExpression|ObjectMethod': (
/** @type {babel.NodePath<babel.types.FunctionExpression|babel.types.ArrowFunctionExpression|babel.types.ObjectMethod>} */ path,
) => {
// if the parent is a call expression, make sure it's an allowed one
if (
path.parentPath && path.parentPath.isCallExpression()
? isAllowedCallExpression(t, path.parentPath)
: true
) {
if (doesReturnJSX(t, path.node.body)) {
addDisplayNamesToFunctionComponent(t, path);
}
}
},
CallExpression(path) {
if (isAllowedCallExpression(t, path)) {
addDisplayNamesToFunctionComponent(t, path);
}
},
},
};
});
/**
* Checks if this function returns JSX nodes.
* It does not do type-checking, which means calling
* other functions that return JSX will still return `false`.
*
* @param {babel.types} t content of @babel/types package
* @param {babel.types.Statement | babel.types.Expression} node function node
*/
function doesReturnJSX(t, node) {
if (!node) {
return false;
}
const body = t.toBlock(node).body;
if (!body) {
return false;
}
return body.some((statement) => {
/** @type {babel.Node | null | undefined} */
let currentNode;
if (t.isReturnStatement(statement)) {
currentNode = statement.argument;
} else if (
t.isExpressionStatement(statement) &&
!t.isCallExpression(statement.expression)
) {
currentNode = statement.expression;
} else {
return false;
}
if (
t.isCallExpression(currentNode) &&
// detect *.createElement and count it as returning JSX
// this could be improved a lot but will work for the 99% case
t.isMemberExpression(currentNode.callee) &&
t.isIdentifier(currentNode.callee.property) &&
currentNode.callee.property.name === 'createElement'
) {
return true;
}
if (t.isConditionalExpression(currentNode)) {
return (
isJSX(t, currentNode.consequent) || isJSX(t, currentNode.alternate)
);
}
if (t.isLogicalExpression(currentNode)) {
return isJSX(t, currentNode.left) || isJSX(t, currentNode.right);
}
if (t.isArrayExpression(currentNode)) {
return currentNode.elements.some((ele) => isJSX(t, ele));
}
return isJSX(t, currentNode);
});
}
/**
* Checks if this node is JSXElement or JSXFragment,
* which are the root nodes of react components.
*
* @param {babel.types} t content of @babel/types package
* @param {babel.Node | null | undefined} node babel node
*/
function isJSX(t, node) {
return t.isJSXElement(node) || t.isJSXFragment(node);
}
/**
* Checks if this path is an allowed CallExpression.
*
* @param {babel.types} t content of @babel/types package
* @param {babel.NodePath<babel.types.CallExpression>} path path of callee
*/
function isAllowedCallExpression(t, path) {
const calleePath = path.get('callee');
const callee = /** @type {babel.types.Expression} */ path.node.callee;
/** @type {string | undefined} */
const calleeName =
/** @type {any} */ callee.name || /** @type {any} */ callee.property?.name;
const moduleNames = calleeName && calleeModuleMapping.get(calleeName);
if (!moduleNames) {
return false;
}
// If the callee is an identifier expression, then check if it matches
// a named import, e.g. `import {createContext} from 'react'`.
if (calleePath.isIdentifier()) {
return moduleNames.some((moduleName) =>
calleePath.referencesImport(moduleName, calleeName),
);
}
if (calleePath.isMemberExpression()) {
const object = calleePath.get('object');
return moduleNames.some(
(moduleName) =>
object.referencesImport(moduleName, 'default') ||
object.referencesImport(moduleName, '*'),
);
}
return false;
}
/**
* Adds displayName to the function component if it is:
* - assigned to a variable or object path
* - not within other JSX elements
* - not called by a react hook or _createClass helper
*
* @param {babel.types} t content of @babel/types package
* @param {babel.NodePath<babel.types.FunctionExpression|babel.types.ArrowFunctionExpression|babel.types.ObjectMethod|babel.types.CallExpression>} path path of function
*/
function addDisplayNamesToFunctionComponent(t, path) {
/** @type {ComponentIdentifier[]} */
const componentIdentifiers = [];
if (/** @type {any} */ path.node.key) {
componentIdentifiers.push({ id: /** @type {any} */ path.node.key });
}
/** @type {babel.NodePath | undefined} */
let assignmentPath;
let hasCallee = false;
let hasObjectProperty = false;
const scopePath = path.scope.parent && path.scope.parent.path;
path.find((parentPath) => {
// we've hit the scope, stop going further up
if (parentPath === scopePath) {
return true;
}
// Ignore functions within jsx
if (isJSX(t, parentPath.node)) {
return true;
}
if (parentPath.isCallExpression()) {
// Ignore immediately invoked function expressions (IIFEs)
const callee =
/** @types {babel.types.Expression} */ parentPath.node.callee;
if (
t.isArrowFunctionExpression(callee) ||
t.isFunctionExpression(callee)
) {
return true;
}
// Ignore instances where displayNames are disallowed
// _createClass(() => <Element />)
// useMemo(() => <Element />)
const calleeName = t.isIdentifier(callee) ? callee.name : undefined;
if (
calleeName &&
(calleeName.startsWith('_') || calleeName.startsWith('use'))
) {
return true;
}
hasCallee = true;
}
// componentIdentifier = <Element />
if (parentPath.isAssignmentExpression()) {
assignmentPath = parentPath.parentPath;
componentIdentifiers.unshift({
id: /** @type {babel.types.Expression} */ parentPath.node.left,
});
return true;
}
// const componentIdentifier = <Element />
if (parentPath.isVariableDeclarator()) {
// Ternary expression
if (t.isConditionalExpression(parentPath.node.init)) {
const { consequent, alternate } = parentPath.node.init;
const isConsequentFunction =
t.isArrowFunctionExpression(consequent) ||
t.isFunctionExpression(consequent);
const isAlternateFunction =
t.isArrowFunctionExpression(alternate) ||
t.isFunctionExpression(alternate);
// Only add display name if variable is a function
if (!isConsequentFunction || !isAlternateFunction) {
return false;
}
}
assignmentPath = parentPath.parentPath;
componentIdentifiers.unshift({
id: /** @type {babel.types.Expression} */ parentPath.node.id,
});
return true;
}
// if this is not a continuous object key: value pair, stop processing it
if (
hasObjectProperty &&
!(parentPath.isObjectProperty() || parentPath.isObjectExpression())
) {
return true;
}
// { componentIdentifier: <Element /> }
if (parentPath.isObjectProperty()) {
hasObjectProperty = true;
const node = parentPath.node;
componentIdentifiers.unshift({
id: /** @type {babel.types.Expression} */ node.key,
computed: node.computed,
});
}
return false;
});
if (!assignmentPath || componentIdentifiers.length === 0) {
return;
}
const name = generateDisplayName(t, componentIdentifiers);
const pattern = `${name}.displayName`;
// disallow duplicate names if they were assigned in different scopes
if (
seenDisplayNames.has(name) &&
!hasBeenAssignedPrev(t, assignmentPath, pattern, name)
) {
return;
}
// skip unnecessary addition of name if it is reassigned later on
if (hasBeenAssignedNext(t, assignmentPath, pattern)) {
return;
}
// at this point we're ready to start pushing code
if (hasCallee) {
// if we're getting called by some wrapper function,
// give this function a name
setInternalFunctionName(t, path, name);
}
const displayNameStatement = createDisplayNameStatement(
t,
componentIdentifiers,
name,
);
assignmentPath.insertAfter(displayNameStatement);
seenDisplayNames.add(name);
}
/**
* Generate a displayName string based on the ids collected.
*
* @param {babel.types} t content of @babel/types package
* @param {ComponentIdentifier[]} componentIdentifiers list of { id, computed } objects
*/
function generateDisplayName(t, componentIdentifiers) {
let displayName = '';
componentIdentifiers.forEach((componentIdentifier) => {
const node = componentIdentifier.id;
if (!node) {
return;
}
const name = generateNodeDisplayName(t, node);
displayName += componentIdentifier.computed ? `[${name}]` : `.${name}`;
});
return displayName.slice(1);
}
/**
* Generate a displayName string based on the node.
*
* @param {babel.types} t content of @babel/types package
* @param {babel.Node} node identifier or member expression node
* @returns {string}
*/
function generateNodeDisplayName(t, node) {
if (t.isIdentifier(node)) {
return node.name;
}
if (t.isMemberExpression(node)) {
const objectDisplayName = generateNodeDisplayName(t, node.object);
const propertyDisplayName = generateNodeDisplayName(t, node.property);
const res = node.computed
? `${objectDisplayName}[${propertyDisplayName}]`
: `${objectDisplayName}.${propertyDisplayName}`;
return res;
}
return '';
}
/**
* Checks if this path has been previously assigned to a particular value.
*
* @param {babel.types} t content of @babel/types package
* @param {babel.NodePath} assignmentPath path where assignement will take place
* @param {string} pattern assignment path in string form e.g. `x.y.z`
* @param {string} value assignment value to compare with
* @returns {boolean}
*/
function hasBeenAssignedPrev(t, assignmentPath, pattern, value) {
return assignmentPath.getAllPrevSiblings().some((sibling) => {
const expression = /** @type {babel.NodePath} */ sibling.get('expression');
if (!t.isAssignmentExpression(expression.node, { operator: '=' })) {
return false;
}
if (!t.isStringLiteral(expression.node.right, { value })) {
return false;
}
return /** @type {babel.NodePath} */ expression
.get('left')
.matchesPattern(pattern);
});
}
/**
* Checks if this path will be assigned later in the scope.
*
* @param {babel.types} t content of @babel/types package
* @param {babel.NodePath} assignmentPath path where assignement will take place
* @param {string} pattern assignment path in string form e.g. `x.y.z`
* @returns {boolean}
*/
function hasBeenAssignedNext(t, assignmentPath, pattern) {
return assignmentPath.getAllNextSiblings().some((sibling) => {
const expression = /** @type {babel.NodePath} */ sibling.get('expression');
if (!t.isAssignmentExpression(expression.node, { operator: '=' })) {
return false;
}
return /** @type {babel.NodePath} */ expression
.get('left')
.matchesPattern(pattern);
});
}
/**
* Generate a displayName ExpressionStatement node based on the ids.
*
* @param {babel.types} t content of @babel/types package
* @param {ComponentIdentifier[]} componentIdentifiers list of { id, computed } objects
* @param {string} displayName name of the function component
*/
function createDisplayNameStatement(t, componentIdentifiers, displayName) {
const node = createMemberExpression(t, componentIdentifiers);
const expression = t.assignmentExpression(
'=',
t.memberExpression(node, t.identifier('displayName')),
t.stringLiteral(displayName),
);
const ifStatement = t.ifStatement(
t.binaryExpression(
'!==',
t.memberExpression(
t.memberExpression(t.identifier('process'), t.identifier('env')),
t.identifier('NODE_ENV'),
),
t.stringLiteral('production'),
),
t.expressionStatement(expression),
);
return ifStatement;
}
/**
* Helper that creates a MemberExpression node from the ids.
*
* @param {babel.types} t content of @babel/types package
* @param {ComponentIdentifier[]} componentIdentifiers list of { id, computed } objects
* @returns {babel.types.Expression}
*/
function createMemberExpression(t, componentIdentifiers) {
let node = componentIdentifiers[0].id;
if (componentIdentifiers.length > 1) {
for (let i = 1; i < componentIdentifiers.length; i += 1) {
const { id, computed } = componentIdentifiers[i];
node = t.memberExpression(node, id, computed);
}
}
return node;
}
/**
* Changes the arrow function to a function expression and gives it a name.
* `name` will be changed to ensure that it is unique within the scope. e.g. `helper` -> `_helper`
*
* @param {babel.types} t content of @babel/types package
* @param {babel.NodePath<babel.types.ArrowFunctionExpression | babel.types.CallExpression | babel.types.FunctionExpression | babel.types.ObjectMethod>} path path to the function node
* @param {string} name name of function to follow after
*/
function setInternalFunctionName(t, path, name) {
if (
!name ||
('id' in path.node && path.node.id != null) ||
('key' in path.node && path.node.key != null)
) {
return;
}
const id = path.scope.generateUidIdentifier(name);
if (path.isArrowFunctionExpression()) {
path.arrowFunctionToExpression();
}
// @ts-expect-error
path.node.id = id;
}
const cssComponents = ['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
}:
${leadingCommentBlocks
.map(({ type, value }, index) => `#${index} (${type}): ${value}`)
.join('
')}`,
);
}
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
}:
${leadingCommentBlocks
.map(({ type, value }, index) => `#${index} (${type}): ${value}`)
.join('
')}`,
);
}
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('
');
// Ensure a newline between manual and generated description.
if (markdownLines[markdownLines.length - 1] !== '') {
markdownLines.push('');
}
if (api.customAnnotation) {
markdownLines.push(
...api.customAnnotation
.split('
')
.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 = `/**
${markdownLines
.map((line) => (line.length > 0 ? ` * ${line}` : ` *`))
.join('
')}
*/`;
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: _.fromPairs(
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('
')}</ul>`,
cssComponent: cssComponents.includes(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),
);
export default function Page(props) {
const { descriptions, pageContent } = props;
return <ApiPage ${layoutConfigPath === '' ? '' : '{...layoutConfig} '}descriptions={descriptions} pageContent={pageContent} />;
}
Page.getInitialProps = () => {
const req = require.context(
'${importTranslationPagesDirectory}/${kebabCase(reactApi.name)}',
false,
/\.\/${kebabCase(reactApi.name)}.*.json$/,
);
const descriptions = mapApiPageTranslations(req);
return {
descriptions,
pageContent: jsonPageContent,
};
};
`.replace(/
?
/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'] = _.fromPairs(
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:
${propErrors
.map(([propName, error]) => {
return ` - ${propName}: ${error}`;
})
.join('
')}`,
);
}
// 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';
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, ...rest } = cssVarDefinition;
return {
...acc,
[name]: rest,
};
}, {});
if (errors.length > 0) {
throw new Error(
`There were errors creating ${attribute.replace(/([A-Z])/g, ' $1')} descriptions:
${errors
.map(([item, error]) => {
return ` - ${item}: ${error}`;
})
.join('
')}`,
);
}
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):
?
?
/;
if (componentInfo.customAnnotation) {
annotationBoundary = new RegExp(
escapeRegExp(componentInfo.customAnnotation.trim().split('
')[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.
' +
`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.
For 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;
}