Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[docs-infra] Simplify CSS classes extraction in API docs generator #39808

Merged
merged 10 commits into from
Nov 30, 2023
60 changes: 9 additions & 51 deletions packages/api-docs-builder/ApiBuilders/ComponentApiBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ import remark from 'remark';
import remarkVisit from 'unist-util-visit';
import { Link } from 'mdast';
import { defaultHandlers, parse as docgenParse, ReactDocgenApi } from 'react-docgen';
import { unstable_generateUtilityClass as generateUtilityClass } from '@mui/utils';
import { renderMarkdown } from '@mui/markdown';
import { ComponentClassDefinition } from '@mui-internal/docs-utilities';
import { ProjectSettings } from '../ProjectSettings';
import { ComponentInfo, writePrettifiedFile } from '../buildApiUtils';
import muiDefaultPropsHandler from '../utils/defaultPropsHandler';
Expand All @@ -20,7 +20,6 @@ import createDescribeableProp, {
DescribeablePropDescriptor,
} from '../utils/createDescribeableProp';
import generatePropDescription from '../utils/generatePropDescription';
import parseStyles, { Classes, Styles } from '../utils/parseStyles';
import { TypeScriptProject } from '../utils/createTypeScriptProject';
import parseSlotsAndClasses, { Slot } from '../utils/parseSlotsAndClasses';

Expand Down Expand Up @@ -58,8 +57,7 @@ export interface ReactApi extends ReactDocgenApi {
* result of path.readFileSync from the `filename` in utf-8
*/
src: string;
styles: Styles;
classes: Classes;
classes: ComponentClassDefinition[];
slots: Slot[];
propsTable: _.Dictionary<{
default: string | undefined;
Expand Down Expand Up @@ -427,25 +425,8 @@ const generateApiPage = (
),
name: reactApi.name,
imports: reactApi.imports,
styles: {
classes: reactApi.styles.classes,
globalClasses: _.fromPairs(
Object.entries(reactApi.styles.globalClasses).filter(([className, globalClassName]) => {
// Only keep "non-standard" global classnames
return globalClassName !== `Mui${reactApi.name}-${className}`;
}),
),
name: reactApi.styles.name,
},
...(reactApi.slots?.length > 0 && { slots: reactApi.slots }),
...((reactApi.classes?.classes.length > 0 ||
(reactApi.classes?.globalClasses &&
Object.keys(reactApi.classes.globalClasses).length > 0)) && {
classes: {
classes: reactApi.classes.classes,
globalClasses: reactApi.classes.globalClasses,
},
}),
classes: reactApi.classes,
spread: reactApi.spread,
themeDefaultProps: reactApi.themeDefaultProps,
muiName: normalizedApiPathname.startsWith('/joy-ui')
Expand Down Expand Up @@ -547,12 +528,12 @@ const attachTranslations = (reactApi: ReactApi) => {
/**
* CSS class descriptions.
*/
translations.classDescriptions = extractClassConditions(
reactApi.styles.classes.length || Object.keys(reactApi.styles.globalClasses).length
? reactApi.styles.descriptions
: reactApi.classes.descriptions,
);
const classDescriptions: Record<string, string> = {};
reactApi.classes.forEach((classDefinition) => {
classDescriptions[classDefinition.key] = classDefinition.description;
});

translations.classDescriptions = extractClassConditions(classDescriptions);
reactApi.translations = translations;
};

Expand Down Expand Up @@ -809,38 +790,15 @@ export default async function generateComponentApi(
reactApi.themeDefaultProps = testInfo.themeDefaultProps;
reactApi.inheritance = getInheritance(testInfo.inheritComponent);

// Both `slots` and `classes` are empty if
// interface `${componentName}Slots` wasn't found.
// Currently, Base UI and Joy UI components support this interface
const { slots, classes } = parseSlotsAndClasses({
project,
componentName: reactApi.name,
muiName: reactApi.muiName,
});

reactApi.slots = slots;
reactApi.classes = classes;

reactApi.styles = parseStyles({ project, componentName: reactApi.name });

if (reactApi.styles.classes.length > 0 && !filename.includes('mui-base')) {
reactApi.styles.name = reactApi.muiName;
}
reactApi.styles.classes.forEach((key) => {
const globalClass = generateUtilityClass(reactApi.styles.name || reactApi.muiName, key);
reactApi.styles.globalClasses[key] = globalClass;
});

// if `reactApi.classes` and `reactApi.styles` both exist,
// API documentation includes both "CSS" Section and "State classes" Section;
// we either want (1) "Slots" section and "State classes" section, or (2) "CSS" section
if (
(reactApi.styles.classes?.length || Object.keys(reactApi.styles.globalClasses || {})?.length) &&
(reactApi.classes.classes?.length || Object.keys(reactApi.classes.globalClasses || {})?.length)
) {
reactApi.styles.classes = [];
reactApi.styles.globalClasses = {};
}

attachPropsTable(reactApi);
attachTranslations(reactApi);

Expand Down
222 changes: 147 additions & 75 deletions packages/api-docs-builder/utils/parseSlotsAndClasses.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import * as ts from 'typescript';
import { ComponentClassDefinition } from '@mui-internal/docs-utilities';
import { unstable_generateUtilityClass as generateUtilityClass } from '@mui/utils';
import { getSymbolDescription, getSymbolJSDocTags } from '../buildApiUtils';
import { TypeScriptProject } from './createTypeScriptProject';
import { Classes } from './parseStyles';
import { getPropsFromComponentNode } from './getPropsFromComponentNode';
import resolveExportSpecifier from './resolveExportSpecifier';

// If GLOBAL_STATE_CLASSES is changed, GlobalStateSlot in
// \packages\mui-utils\src\generateUtilityClass\generateUtilityClass.ts must be updated accordingly.
Expand All @@ -27,99 +30,168 @@ export interface Slot {
default?: string;
}

function extractClasses({
export default function parseSlotsAndClasses({
project,
componentName,
muiName,
}: {
project: TypeScriptProject;
componentName: string;
}): { classNames: string[]; descriptions: Record<string, string> } {
const result: { classNames: string[]; descriptions: Record<string, string> } = {
classNames: [],
descriptions: {},
muiName: string;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would it be possible to use an enum here? Otherwise it's a pain when we navigate this kind of code and don't know exactly which convention is used

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you give an example? Where exactly would you see an enum?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I misunderstood what this property is about. I thought it was storing the mui project (core, base, joy, ...) but it's the component name

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, it's the name of the component used in style overrides object (such as MuiButton).

}): { slots: Slot[]; classes: ComponentClassDefinition[] } {
// Obtain an array of classes for the given component
const classDefinitions = extractClasses(project, componentName, muiName);
const slots = extractSlots(project, componentName, classDefinitions);

const nonSlotClassDefinitions = classDefinitions
.filter((classDefinition) => !Object.keys(slots).includes(classDefinition.key))
.sort((a, b) => a.key.localeCompare(b.key));

return {
slots: Object.values(slots),
classes: nonSlotClassDefinitions,
};
const classesInterface = `${componentName}Classes`;
const classesType = project.checker.getDeclaredTypeOfSymbol(project.exports[classesInterface]);
}

function extractClasses(
project: TypeScriptProject,
componentName: string,
muiName: string,
): ComponentClassDefinition[] {
return (
extractClassesFromProps(project, componentName, muiName) ??
extractClassesFromInterface(project, componentName, muiName)
);
}

/**
* Gets class names and descriptions from the {ComponentName}Classes interface.
*/
function extractClassesFromInterface(
project: TypeScriptProject,
componentName: string,
muiName: string,
): ComponentClassDefinition[] {
const result: ComponentClassDefinition[] = [];

const classesInterfaceName = `${componentName}Classes`;
if (!project.exports[classesInterfaceName]) {
return result;
}

const classesType = project.checker.getDeclaredTypeOfSymbol(
project.exports[classesInterfaceName],
);

const classesTypeDeclaration = classesType?.symbol?.declarations?.[0];
if (classesTypeDeclaration && ts.isInterfaceDeclaration(classesTypeDeclaration)) {
const classesProperties = classesType.getProperties();
classesProperties.forEach((symbol) => {
result.classNames.push(symbol.name);
result.descriptions[symbol.name] = getSymbolDescription(symbol, project);
result.push({
key: symbol.name,
className: generateUtilityClass(muiName, symbol.name),
description: getSymbolDescription(symbol, project),
isGlobal: GLOBAL_STATE_CLASSES.includes(symbol.name),
});
});
}

return result;
}

export default function parseSlotsAndClasses({
project,
componentName,
muiName,
}: {
project: TypeScriptProject;
componentName: string;
muiName: string;
}): { slots: Slot[]; classes: Classes } {
let result: { slots: Slot[]; classes: Classes } = {
slots: [],
classes: { classes: [], globalClasses: {}, descriptions: {} },
};
const slotsInterface = `${componentName}Slots`;
try {
const exportedSymbol = project.exports[slotsInterface];
const type = project.checker.getDeclaredTypeOfSymbol(exportedSymbol);
const typeDeclaration = type?.symbol?.declarations?.[0];
if (!typeDeclaration || !ts.isInterfaceDeclaration(typeDeclaration)) {
return result;
}
function extractClassesFromProps(
project: TypeScriptProject,
componentName: string,
muiName: string,
): ComponentClassDefinition[] | null {
const exportedSymbol =
project.exports[componentName] ?? project.exports[`Unstable_${componentName}`];
if (!exportedSymbol) {
throw new Error(`No exported component for the componentName "${componentName}"`);
}

// Obtain an array of classes for the given component
const { classNames, descriptions: classDescriptions } = extractClasses({
project,
componentName,
});
const slots: Record<string, Slot> = {};
const propertiesOnProject = type.getProperties();

propertiesOnProject.forEach((propertySymbol) => {
const tags = getSymbolJSDocTags(propertySymbol);
if (tags.ignore) {
return;
}
const slotName = propertySymbol.name;
slots[slotName] = {
name: slotName,
description: getSymbolDescription(propertySymbol, project),
default: tags.default?.text?.[0].text,
class: classNames.includes(slotName) ? `.${muiName}-${slotName}` : null,
};
});
const localeSymbol = resolveExportSpecifier(exportedSymbol, project);
const declaration = localeSymbol.valueDeclaration!;

const classesProp = getPropsFromComponentNode({
node: declaration,
project,
shouldInclude: ({ name }) => name === 'classes',
checkDeclarations: true,
})?.props.classes;

if (classesProp == null) {
return null;
}

const classNamesLeftover = classNames.filter(
(className) => !Object.keys(slots).includes(className),
const classes: Record<string, string> = {};
classesProp.signatures.forEach((propType) => {
const type = project.checker.getTypeAtLocation(propType.symbol.declarations?.[0]!);
removeUndefinedFromType(type)
?.getProperties()
.forEach((property) => {
classes[property.escapedName.toString()] = getSymbolDescription(property, project);
});
});

return Object.keys(classes).map((name) => ({
key: name,
className: generateUtilityClass(muiName, name),
description: name !== classes[name] ? classes[name] : '',
isGlobal: GLOBAL_STATE_CLASSES.includes(name),
}));
}

function extractSlots(
project: TypeScriptProject,
componentName: string,
classDefinitions: ComponentClassDefinition[],
): Record<string, Slot> {
const slotsInterfaceName = `${componentName}Slots`;
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();

propertiesOnProject.forEach((propertySymbol) => {
const tags = getSymbolJSDocTags(propertySymbol);
if (tags.ignore) {
return;
}
const slotName = propertySymbol.name;

const slotClassDefinition = classDefinitions.find(
(classDefinition) => classDefinition.key === slotName,
);
const globalStateClassNames: Record<string, string> = {};
const otherClassNames: string[] = [];
classNamesLeftover.forEach((className) => {
if (GLOBAL_STATE_CLASSES.includes(className)) {
globalStateClassNames[className] = `Mui-${className}`;
} else {
otherClassNames.push(className);
}
});

result = {
slots: Object.values(slots),
classes: {
classes: otherClassNames
.concat(Object.keys(globalStateClassNames))
.sort((a, b) => a.localeCompare(b)),
globalClasses: globalStateClassNames,
descriptions: classDescriptions,
},
slots[slotName] = {
name: slotName,
description: getSymbolDescription(propertySymbol, project),
default: tags.default?.text?.[0].text,
class: slotClassDefinition?.className ?? null,
};
} catch (e) {
console.error(`No declaration for ${slotsInterface}`);
});

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 result;

return type;
}
Loading