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,3 @@
export { projectSettings as joyUiProjectSettings } from './joyUi/projectSettings';
export { projectSettings as materialUiProjectSettings } from './materialUi/projectSettings';
export { projectSettings as muiSystemProjectSettings } from './muiSystem/projectSettings';

View File

@@ -0,0 +1,69 @@
import fs from 'fs';
import path from 'path';
import { kebabCase } from 'es-toolkit/string';
import { getHeaders, getTitle } from '@mui/internal-markdown';
import {
ComponentInfo,
extractPackageFile,
fixPathname,
getMuiName,
getSystemComponents,
parseFile,
} from '@mui-internal/api-docs-builder/buildApiUtils';
import findPagesMarkdown from '@mui-internal/api-docs-builder/utils/findPagesMarkdown';
export function getJoyUiComponentInfo(filename: string): ComponentInfo {
const { name } = extractPackageFile(filename);
let srcInfo: null | ReturnType<ComponentInfo['readFile']> = null;
if (!name) {
throw new Error(`Could not find the component name from: ${filename}`);
}
return {
filename,
name,
muiName: getMuiName(name),
apiPathname: `/joy-ui/api/${kebabCase(name)}/`,
apiPagesDirectory: path.join(process.cwd(), `docs/pages/joy-ui/api`),
isSystemComponent: getSystemComponents().includes(name),
readFile: () => {
srcInfo = parseFile(filename);
return srcInfo;
},
getInheritance: (inheritedComponent = srcInfo?.inheritedComponent) => {
if (!inheritedComponent || inheritedComponent.match(/unstyled/i)) {
return null;
}
const urlComponentName = kebabCase(inheritedComponent.replace(/unstyled/i, ''));
// `inheritedComponent` node is coming from test files.
// `inheritedComponent` must include `Unstyled` suffix for parser to recognise that the component inherits Base UI component
// e.g., Joy UI Menu inherits Base UI Popper, and its test file uses the name `PopperUnstyled` so that we can recognise here that
// Joy UI Menu is inheriting a base component. In terms of documentation, we should no longer use the name `PopperUnstyled`, and hence
// we remove the suffix here.
return {
name: inheritedComponent,
apiPathname: `/joy-ui/api/${urlComponentName}/`,
};
},
getDemos: () => {
const allMarkdowns = findPagesMarkdown().map((markdown) => {
const markdownContent = fs.readFileSync(markdown.filename, 'utf8');
const markdownHeaders = getHeaders(markdownContent);
return {
...markdown,
markdownContent,
components: markdownHeaders.components,
};
});
return allMarkdowns
.filter((page) => page.pathname.startsWith('/joy') && page.components.includes(name))
.map((page) => ({
filePath: page.filename, // pathname of the markdown file
demoPageTitle: getTitle(page.markdownContent),
demoPathname: fixPathname(page.pathname),
}));
},
};
}

View File

@@ -0,0 +1,34 @@
import path from 'path';
import { LANGUAGES } from 'docs/config';
import { ProjectSettings } from '@mui-internal/api-docs-builder';
import findApiPages from '@mui-internal/api-docs-builder/utils/findApiPages';
import generateUtilityClass, { isGlobalState } from '@mui/utils/generateUtilityClass';
import { getJoyUiComponentInfo } from './getJoyUiComponentInfo';
export const projectSettings: ProjectSettings = {
output: {
apiManifestPath: path.join(process.cwd(), 'docs/data/joy/pagesApi.js'),
},
typeScriptProjects: [
{
name: 'joy',
rootPath: path.join(process.cwd(), 'packages/mui-joy'),
entryPointPath: 'src/index.ts',
},
],
getApiPages: () => findApiPages('docs/pages/joy-ui/api'),
getComponentInfo: getJoyUiComponentInfo,
translationLanguages: LANGUAGES,
skipComponent(filename: string) {
// Container's demo isn't ready
// GlobalStyles's demo isn't ready
return (
filename.match(
/(ThemeProvider|CssVarsProvider|Container|ColorInversion|GlobalStyles|InitColorSchemeScript)/,
) !== null
);
},
translationPagesDirectory: 'docs/translations/api-docs-joy',
generateClassName: generateUtilityClass,
isGlobalClassName: isGlobalState,
};

View File

@@ -0,0 +1,49 @@
import path from 'path';
import fs from 'fs';
import { expect } from 'chai';
import sinon from 'sinon';
import { getMaterialUiComponentInfo } from './getMaterialUiComponentInfo';
describe('getMaterialUiComponentInfo', () => {
it('return correct info for material component file', () => {
const componentInfo = getMaterialUiComponentInfo(
path.join(process.cwd(), `/packages/mui-material/src/Button/Button.js`),
);
sinon.assert.match(componentInfo, {
name: 'Button',
apiPathname: '/material-ui/api/button/',
muiName: 'MuiButton',
apiPagesDirectory: sinon.match((value) =>
value.endsWith(path.join('docs', 'pages', 'material-ui', 'api')),
),
});
expect(componentInfo.getInheritance('ButtonBase')).to.deep.equal({
name: 'ButtonBase',
apiPathname: '/material-ui/api/button-base/',
});
let existed = false;
try {
fs.readdirSync(path.join(process.cwd(), 'docs/data'));
existed = true;
// eslint-disable-next-line no-empty
} catch (error) {}
if (existed) {
const demos = componentInfo.getDemos();
expect(demos).to.not.have.lengthOf(0);
expect(demos[0]).to.deep.include({
demoPageTitle: 'Button Group',
demoPathname: '/material-ui/react-button-group/',
});
expect(demos[0].filePath).to.include('button-group/button-group.md');
expect(demos[1]).to.deep.include({
demoPageTitle: 'Button',
demoPathname: '/material-ui/react-button/',
});
expect(demos[1].filePath).to.include('buttons/buttons.md');
}
});
});

View File

@@ -0,0 +1,71 @@
import fs from 'fs';
import path from 'path';
import { kebabCase } from 'es-toolkit/string';
import { getHeaders, getTitle } from '@mui/internal-markdown';
import {
ComponentInfo,
extractPackageFile,
fixPathname,
getMuiName,
getSystemComponents,
parseFile,
} from '@mui-internal/api-docs-builder/buildApiUtils';
import findPagesMarkdown from '@mui-internal/api-docs-builder/utils/findPagesMarkdown';
export function getMaterialUiComponentInfo(filename: string): ComponentInfo {
const { name } = extractPackageFile(filename);
let srcInfo: null | ReturnType<ComponentInfo['readFile']> = null;
if (!name) {
throw new Error(`Could not find the component name from: ${filename}`);
}
return {
filename,
name,
muiName: getMuiName(name),
apiPathname: `/material-ui/api/${kebabCase(name)}/`,
apiPagesDirectory: path.join(process.cwd(), `docs/pages/material-ui/api`),
isSystemComponent: getSystemComponents().includes(name),
readFile: () => {
srcInfo = parseFile(filename);
return srcInfo;
},
getInheritance: (inheritedComponent = srcInfo?.inheritedComponent) => {
if (!inheritedComponent) {
return null;
}
// `inheritedComponent` node is coming from test files.
// `inheritedComponent` must include `Unstyled` suffix for parser to recognise that the component inherits Base UI component
// e.g., Joy Menu inherits Base UI Popper, and its test file uses the name `PopperUnstyled` so that we can recognise here that
// Joy Menu is inheriting a base component. In terms of documentation, we should no longer use the name `PopperUnstyled`, and hence
// we remove the suffix here.
return {
name: inheritedComponent.replace(/unstyled/i, ''),
apiPathname:
inheritedComponent === 'Transition'
? 'https://reactcommunity.org/react-transition-group/transition/#Transition-props'
: `/${
inheritedComponent.match(/unstyled/i) ? 'base-ui' : 'material-ui'
}/api/${kebabCase(inheritedComponent.replace(/unstyled/i, ''))}/`,
};
},
getDemos: () => {
const allMarkdowns = findPagesMarkdown().map((markdown) => {
const markdownContent = fs.readFileSync(markdown.filename, 'utf8');
const markdownHeaders = getHeaders(markdownContent);
return {
...markdown,
markdownContent,
components: markdownHeaders.components,
};
});
return allMarkdowns
.filter((page) => page.pathname.startsWith('/material') && page.components.includes(name))
.map((page) => ({
filePath: page.filename, // pathname of the markdown file
demoPageTitle: getTitle(page.markdownContent),
demoPathname: fixPathname(page.pathname),
}));
},
};
}

View File

@@ -0,0 +1,58 @@
import path from 'path';
import { LANGUAGES } from 'docs/config';
import { ProjectSettings } from '@mui-internal/api-docs-builder';
import findApiPages from '@mui-internal/api-docs-builder/utils/findApiPages';
import generateUtilityClass, { isGlobalState } from '@mui/utils/generateUtilityClass';
import { getMaterialUiComponentInfo } from './getMaterialUiComponentInfo';
const generateClassName = (componentName: string, slot: string, globalStatePrefix = 'Mui') => {
if (componentName === 'MuiSwipeableDrawer') {
// SwipeableDrawer uses Drawer classes without modifying them
return generateUtilityClass('MuiDrawer', slot, globalStatePrefix);
}
return generateUtilityClass(componentName, slot, globalStatePrefix);
};
export const projectSettings: ProjectSettings = {
output: {
apiManifestPath: path.join(process.cwd(), 'docs/data/material/pagesApi.js'),
},
typeScriptProjects: [
{
name: 'material',
rootPath: path.join(process.cwd(), 'packages/mui-material'),
entryPointPath: [
'src/index.d.ts',
'src/PigmentStack/PigmentStack.tsx',
'src/PigmentContainer/PigmentContainer.tsx',
'src/PigmentGrid/PigmentGrid.tsx',
],
},
{
name: 'lab',
rootPath: path.join(process.cwd(), 'packages/mui-lab'),
entryPointPath: 'src/index.d.ts',
},
],
getApiPages: () => findApiPages('docs/pages/material-ui/api'),
getComponentInfo: getMaterialUiComponentInfo,
translationLanguages: LANGUAGES,
skipComponent(filename: string) {
return filename.match(/(ThemeProvider|CssVarsProvider|DefaultPropsProvider)/) !== null;
},
translationPagesDirectory: 'docs/translations/api-docs',
generateClassName,
isGlobalClassName: isGlobalState,
// #host-reference
baseApiUrl: 'https://mui.com',
pagesManifestPath: path.join(process.cwd(), 'docs/data/material/pages.ts'),
nonComponentFolders: [
'material/getting-started',
'material/customization',
'material/experimental-api',
'material/guides',
'material/integrations',
'material/migration',
],
};

View File

@@ -0,0 +1,108 @@
import fs from 'fs';
import path from 'path';
import { kebabCase } from 'es-toolkit/string';
import { getHeaders, getTitle } from '@mui/internal-markdown';
import {
ComponentInfo,
extractPackageFile,
getMuiName,
parseFile,
fixPathname,
} from '@mui-internal/api-docs-builder/buildApiUtils';
import findPagesMarkdown from '@mui-internal/api-docs-builder/utils/findPagesMarkdown';
const migratedBaseComponents = [
'Badge',
'Button',
'ClickAwayListener',
'FocusTrap',
'Input',
'MenuItem',
'Menu',
'Modal',
'NoSsr',
'OptionGroup',
'Option',
'Popper',
'Portal',
'Select',
'Slider',
'Switch',
'TablePagination',
'TabPanel',
'TabsList',
'Tabs',
'Tab',
];
export function getSystemComponentInfo(filename: string): ComponentInfo {
const { name } = extractPackageFile(filename);
let srcInfo: null | ReturnType<ComponentInfo['readFile']> = null;
if (!name) {
throw new Error(`Could not find the component name from: ${filename}`);
}
return {
filename,
name,
muiName: getMuiName(name),
apiPathname: `/system/api/${kebabCase(name)}/`,
apiPagesDirectory: path.join(process.cwd(), `docs/pages/system/api`),
isSystemComponent: true,
readFile: () => {
srcInfo = parseFile(filename);
return srcInfo;
},
getInheritance() {
return null;
},
getDemos: () => {
const allMarkdowns = findPagesMarkdown()
.filter((markdown) => {
if (migratedBaseComponents.some((component) => filename.includes(component))) {
return markdown.filename.match(/[\\/]data[\\/]system[\\/]/);
}
return true;
})
.map((markdown) => {
const markdownContent = fs.readFileSync(markdown.filename, 'utf8');
const markdownHeaders = getHeaders(markdownContent);
return {
...markdown,
markdownContent,
components: markdownHeaders.components,
};
});
return allMarkdowns
.filter((page) => page.components.includes(name))
.map((page) => ({
filePath: page.filename, // pathname of the markdown file
demoPageTitle: pathToSystemTitle({
...page,
title: getTitle(page.markdownContent),
}),
demoPathname: fixPathname(page.pathname),
}));
},
};
}
interface PageMarkdown {
pathname: string;
title: string;
components: readonly string[];
}
function pathToSystemTitle(page: PageMarkdown) {
const defaultTitle = page.title;
if (page.pathname.startsWith('/material')) {
return `${defaultTitle} (Material UI)`;
}
if (page.pathname.startsWith('/system')) {
return `${defaultTitle} (MUI System)`;
}
if (page.pathname.startsWith('/joy')) {
return `${defaultTitle} (Joy UI)`;
}
return defaultTitle;
}

View File

@@ -0,0 +1,32 @@
import path from 'path';
import { LANGUAGES } from 'docs/config';
import { ProjectSettings } from '@mui-internal/api-docs-builder';
import findApiPages from '@mui-internal/api-docs-builder/utils/findApiPages';
import generateUtilityClass, { isGlobalState } from '@mui/utils/generateUtilityClass';
import { getSystemComponentInfo } from './getSystemComponentInfo';
export const projectSettings: ProjectSettings = {
output: {
apiManifestPath: path.join(process.cwd(), 'docs/data/system/pagesApi.js'),
},
typeScriptProjects: [
{
name: 'system',
rootPath: path.join(process.cwd(), 'packages/mui-system'),
entryPointPath: 'src/index.d.ts',
},
],
getApiPages: () => findApiPages('docs/pages/system/api'),
getComponentInfo: getSystemComponentInfo,
translationLanguages: LANGUAGES,
skipComponent(filename) {
return (
filename.match(
/(ThemeProvider|CssVarsProvider|DefaultPropsProvider|GlobalStyles|InitColorSchemeScript)/,
) !== null
);
},
translationPagesDirectory: 'docs/translations/api-docs',
generateClassName: generateUtilityClass,
isGlobalClassName: isGlobalState,
};

View File

@@ -0,0 +1,30 @@
{
"name": "@mui-internal/api-docs-builder-core",
"version": "1.0.0",
"description": "MUI Core-specific settings for API docs generator",
"private": "true",
"main": "./index.ts",
"scripts": {
"test": "pnpm --workspace-root test:unit --project \"*:@mui-internal/api-docs-builder-core\"",
"typescript": "tsc -p tsconfig.json"
},
"repository": {
"type": "git",
"url": "git+https://github.com/mui/material-ui.git",
"directory": "packages/api-docs-builder-core"
},
"dependencies": {
"@mui-internal/api-docs-builder": "workspace:^",
"@mui/internal-markdown": "workspace:^",
"docs": "workspace:^",
"es-toolkit": "^1.42.0"
},
"devDependencies": {
"@types/chai": "^5.2.3",
"@types/node": "^20.19.25",
"@types/sinon": "^17.0.4",
"chai": "^6.0.1",
"sinon": "^21.0.0",
"typescript": "^5.9.2"
}
}

View File

@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"types": ["node", "vitest/globals"]
},
"include": ["./**/*.ts", "./**/*.js"],
"exclude": ["node_modules", "vitest.config.ts"]
}

View File

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

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

View File

@@ -0,0 +1,5 @@
# Changelog
## 1.0.0
First release as an npm package.

View File

@@ -0,0 +1,11 @@
# @mui-internal/markdown
MUI markdown parser and webpack loader.
This is an internal package not meant for general use.
## Release
There is no build step.
Sources are meant to be used directly.
To publish the build artifacts to npm run `pnpm release:publish`.

View File

@@ -0,0 +1,12 @@
// @ts-check
const importModuleRegexp =
/^\s*import (?:["'\s]*(?:[\w*{}\n, ]+)from\s*)?["'\s]*([^"'{}$\s]+)["'\s].*/gm;
/**
* @param {string} code
* @returns {string[]}
*/
export default function extractImports(code) {
return (code.match(importModuleRegexp) || []).map((x) => x.replace(importModuleRegexp, '$1'));
}

View File

@@ -0,0 +1,39 @@
import { expect } from 'chai';
import extractImports from './extractImports.mjs';
describe('extractImports', () => {
it('finds all imports', () => {
const imports = extractImports(`
import {
Component
} from '@angular2/core';
import defaultMember from "module-1";
import * as name from "module-2 ";
import { member } from " module-3";
import { member as alias } from "module-4";
import { member1 ,
member2 } from "module-5";
import { member1 , member2 as alias2 , member3 as alias3 } from "module-6";
import defaultMember, { member, member } from "module-7";
import defaultMember, * as name from "module-8";
import "module-9";
import "module-10";
import * from './smdn';
import \${importName} from 'module11/\${importName}';
`);
expect(imports[0]).to.equal('@angular2/core');
expect(imports[1]).to.equal('module-1');
expect(imports[2]).to.equal('module-2');
expect(imports[3]).to.equal('module-3');
expect(imports[4]).to.equal('module-4');
expect(imports[5]).to.equal('module-5');
expect(imports[6]).to.equal('module-6');
expect(imports[7]).to.equal('module-7');
expect(imports[8]).to.equal('module-8');
expect(imports[9]).to.equal('module-9');
expect(imports[10]).to.equal('module-10');
expect(imports[11]).to.equal('./smdn');
expect(imports[12]).to.equal(undefined); // It's not a valid import
});
});

View File

@@ -0,0 +1,38 @@
interface TableOfContentsEntry {
children: TableOfContentsEntry[];
hash: string;
level: number;
text: string;
}
export function createRender(context: {
headingHashes?: Record<string, string>;
toc?: TableOfContentsEntry[];
userLanguage?: string;
ignoreLanguagePages?: (path: string) => boolean;
options: object;
}): (markdown: string) => string;
export interface MarkdownHeaders {
packageName?: string;
productId: string;
githubLabel?: string;
waiAria?: string;
materialDesign?: string;
components: string[];
hooks?: string[];
slug?: string;
title?: string;
description?: string;
image?: string;
tags?: string[];
authors?: string[];
date?: string;
githubSource?: string;
}
export function getHeaders(markdown: string): MarkdownHeaders;
export function getTitle(markdown: string): string;
export function renderMarkdown(markdown: string): string;

View File

@@ -0,0 +1 @@
export { createRender, getHeaders, getTitle, renderMarkdown } from './parseMarkdown.mjs';

View File

@@ -0,0 +1,719 @@
// @ts-check
import { promises as fs, readdirSync, statSync } from 'fs';
import path from 'path';
import prepareMarkdown from './prepareMarkdown.mjs';
import extractImports from './extractImports.mjs';
const notEnglishMarkdownRegExp = /-([a-z]{2})\.md$/;
/**
* @param {string} string
*/
function upperCaseFirst(string) {
return `${string[0].toUpperCase()}${string.slice(1)}`;
}
/**
* @param {string} moduleID
* @example moduleIDToJSIdentifier('./Box.js') === '$$IndexJs'
* @example moduleIDToJSIdentifier('./Box-new.js') === '$$BoxNewJs'
* @example moduleIDToJSIdentifier('../Box-new.js') === '$$$BoxNewJs'
*/
function moduleIDToJSIdentifier(moduleID) {
const delimiter = /(@|\.|-|\/|:)/;
return moduleID
.split(delimiter)
.filter((part) => !delimiter.test(part))
.map((part) => (part.length === 0 ? '$' : part))
.map(upperCaseFirst)
.join('');
}
/**
* @typedef {Record<string, Record<string, string>> } ComponentPackageMapping
*/
/** @type {ComponentPackageMapping | null} */
let componentPackageMapping = null;
/**
* @typedef {Object} Package
* @property {string[]} paths
* @property {string} productId
*/
/**
* @param {Package[]} packages
*/
function findComponents(packages) {
/** @type {ComponentPackageMapping} */
const mapping = {};
packages.forEach((pkg) => {
pkg.paths.forEach((pkgPath) => {
const match = pkgPath.match(/packages(?:\\|\/)([^/\\]+)(?:\\|\/)src/);
const packageName = match ? match[1] : null;
if (!packageName) {
throw new Error(`cannot find package name from path: ${pkgPath}`);
}
const filePaths = readdirSync(pkgPath);
filePaths.forEach((folder) => {
if (folder.match(/^[A-Z]/)) {
if (!mapping[pkg.productId]) {
mapping[pkg.productId] = {};
}
// filename starts with Uppercase = component
mapping[pkg.productId][folder] = packageName;
}
});
});
});
return mapping;
}
/**
* @typedef {Object} LoaderOptions
* @property {Package[]} packages
* @property {string[]} languagesInProgress
* @property {string} workspaceRoot
*/
/**
* @typedef {Object} ModuleData
* @property {string} module
* @property {string} raw
*/
/**
* @typedef {Object} Translation
* @property {string} filename
* @property {string} userLanguage
* @property {string} [markdown]
*/
/**
* @typedef {Object} Demo
* @property {string} module
* @property {string} [moduleTS]
* @property {string} [moduleTailwind]
* @property {string} [moduleTSTailwind]
* @property {string} [moduleCSS]
* @property {string} [moduleTSCSS]
* @property {string} raw
* @property {string} [rawTS]
* @property {string} [rawTailwind]
* @property {string} [rawTailwindTS]
* @property {string} [rawCSS]
* @property {string} [rawCSSTS]
* @property {string} [jsxPreview]
* @property {string} [tailwindJsxPreview]
* @property {string} [cssJsxPreview]
* @property {Object.<string, ModuleData[]>} [relativeModules]
*/
/**
* @type {import('webpack').LoaderDefinitionFunction<LoaderOptions>}
* @this {import('webpack').LoaderContext<LoaderOptions>}
*/
export default async function demoLoader() {
const englishFilepath = this.resourcePath;
const options = this.getOptions();
if (componentPackageMapping === null) {
componentPackageMapping = findComponents(options.packages ?? []);
}
const englishFilename = path.basename(englishFilepath, '.md');
const files = await fs.readdir(path.dirname(englishFilepath));
const translations = await Promise.all(
/** @type {Translation[]} */ (
files
.map((filename) => {
if (filename === `${englishFilename}.md`) {
return {
filename,
userLanguage: 'en',
};
}
const matchNotEnglishMarkdown = filename.match(notEnglishMarkdownRegExp);
if (
filename.startsWith(englishFilename) &&
matchNotEnglishMarkdown !== null &&
options.languagesInProgress.includes(matchNotEnglishMarkdown[1])
) {
return {
filename,
userLanguage: matchNotEnglishMarkdown[1],
};
}
return null;
})
.filter((translation) => translation)
).map(async (translation) => {
const filepath = path.join(path.dirname(englishFilepath), translation.filename);
this.addDependency(filepath);
const markdown = await fs.readFile(filepath, { encoding: 'utf8' });
return {
...translation,
markdown,
};
}),
);
// Use .. as the docs runs from the /docs folder
const fileRelativeContext = path
.relative(options.workspaceRoot, this.context)
// win32 to posix
.replace(/\\/g, '/');
const { docs } = prepareMarkdown({
fileRelativeContext,
translations,
componentPackageMapping,
options,
});
/** @type {Record<string, Demo>} */
const demos = {};
/** @type {Set<string>} */
const importedModuleIDs = new Set();
/** @type {Record<string, string>} */
const components = {};
/** @type {Set<string>} */
const demoModuleIDs = new Set();
/** @type {Set<string>} */
const componentModuleIDs = new Set();
/** @type {Set<string>} */
const nonEditableDemos = new Set();
/** @type {Map<string, Map<string, string[]>>} */
const relativeModules = new Map();
/** @type {string[]} */
const demoNames = Array.from(
new Set(
/** @type {import('./prepareMarkdown.mjs').DemoEntry[]} */ (
docs.en.rendered.filter((markdownOrComponentConfig) => {
return typeof markdownOrComponentConfig !== 'string' && markdownOrComponentConfig.demo;
})
).map((demoConfig) => {
if (demoConfig.hideToolbar) {
nonEditableDemos.add(demoConfig.demo);
}
return demoConfig.demo;
}),
),
);
/**
* @param {*} demoName
* @param {*} moduleFilepath
* @param {*} variant
* @param {*} importModuleID
* @returns {string} The name of the imported module along with a resolved extension if not provided
* @example detectRelativeImports('ComboBox.js', '', JS', './top100Films') => relativeModules.set('ComboBox.js', new Map([['./top100Films.js', ['JS']]]))
*/
function detectRelativeImports(demoName, moduleFilepath, variant, importModuleID) {
let relativeModuleFilename = importModuleID;
if (importModuleID.startsWith('.')) {
const demoMap = relativeModules.get(demoName);
// If the moduleID does not end with an extension, or ends with an unsupported extension (e.g. ".styling") we need to resolve it
// Fastest way to get a file extension, see: https://stackoverflow.com/a/12900504/
const importType = importModuleID.slice(
(Math.max(0, importModuleID.lastIndexOf('.')) || Infinity) + 1,
);
const supportedTypes = ['js', 'jsx', 'ts', 'tsx', 'css', 'json'];
if (!importType || !supportedTypes.includes(importType)) {
// If the demo is a JS demo, we can assume that the relative import is either
// a `.js` or a `.jsx` file, with `.js` taking precedence over `.jsx`
// likewise for TS demos, with `.ts` taking precedence over `.tsx`
const extensions =
variant === 'JS' ? ['.js', '.jsx', '.ts', '.tsx'] : ['.ts', '.tsx', '.js', '.jsx'];
const extension = extensions.find((ext) => {
try {
return statSync(path.join(moduleFilepath, '..', `${importModuleID}${ext}`));
} catch (error) {
// If the file does not exist, we return false and continue to the next extension
return false;
}
});
if (!extension) {
throw new Error(
[
`You are trying to import a module "${importModuleID}" in the demo "${demoName}" that could not be resolved.`,
`Please make sure that one of the following file exists:`,
...extensions.map((ext) => `- ${importModuleID}${ext}`),
].join('\n'),
);
} else {
relativeModuleFilename = `${importModuleID}${extension}`;
}
}
if (!demoMap) {
relativeModules.set(demoName, new Map([[relativeModuleFilename, [variant]]]));
} else {
const variantArray = demoMap.get(relativeModuleFilename);
if (variantArray) {
variantArray.push(variant);
} else {
demoMap.set(relativeModuleFilename, [variant]);
}
}
}
return relativeModuleFilename;
}
/**
* Inserts the moduleData into the relativeModules object
* @param {string} demoName
* @param {ModuleData} moduleData
* @param {string} variant
*/
function updateRelativeModules(demoName, moduleData, variant) {
const variantModule = /** @type {Object.<string, ModuleData[]>} */ (
demos[demoName].relativeModules
);
if (variantModule[variant]) {
// Avoid duplicates
if (!variantModule[variant].some((elem) => elem.module === moduleData.module)) {
variantModule[variant].push(moduleData);
}
} else {
variantModule[variant] = [moduleData];
}
}
await Promise.all(
demoNames.map(async (demoName) => {
const multipleDemoVersionsUsed = !demoName.endsWith('.js');
// TODO: const moduleID = demoName;
// The import paths currently use a completely different format.
// They should just use relative imports.
let moduleID = `./${demoName.replace(
`pages/${fileRelativeContext.replace(/^docs\/src\/pages\//, '')}/`,
'',
)}`;
if (multipleDemoVersionsUsed) {
moduleID = `${moduleID}/system/index.js`;
}
const moduleFilepath = path.join(
path.dirname(this.resourcePath),
moduleID.replace(/\//g, path.sep),
);
this.addDependency(moduleFilepath);
demos[demoName] = {
module: moduleID,
raw: await fs.readFile(moduleFilepath, { encoding: 'utf8' }),
};
demoModuleIDs.add(moduleID);
// Skip non-editable demos
if (!nonEditableDemos.has(demoName)) {
extractImports(demos[demoName].raw).forEach((importModuleID) => {
// detect relative import
detectRelativeImports(demoName, moduleFilepath, 'JS', importModuleID);
importedModuleIDs.add(importModuleID);
});
}
if (multipleDemoVersionsUsed) {
// Add Tailwind demo data
const tailwindModuleID = moduleID.replace('/system/index.js', '/tailwind/index.js');
try {
// Add JS demo data
const tailwindModuleFilepath = path.join(
path.dirname(this.resourcePath),
tailwindModuleID.replace(/\//g, path.sep),
);
demos[demoName].moduleTailwind = tailwindModuleID;
demos[demoName].rawTailwind = await fs.readFile(tailwindModuleFilepath, {
encoding: 'utf8',
});
this.addDependency(tailwindModuleFilepath);
demoModuleIDs.add(tailwindModuleID);
extractImports(demos[demoName].rawTailwind).forEach((importModuleID) =>
importedModuleIDs.add(importModuleID),
);
demoModuleIDs.add(demos[demoName].moduleTailwind);
} catch (error) {
// tailwind js demo doesn't exists
}
try {
// Add TS demo data
const tailwindTSModuleID = tailwindModuleID.replace('.js', '.tsx');
const tailwindTSModuleFilepath = path.join(
path.dirname(this.resourcePath),
tailwindTSModuleID.replace(/\//g, path.sep),
);
demos[demoName].moduleTSTailwind = tailwindTSModuleID;
demos[demoName].rawTailwindTS = await fs.readFile(tailwindTSModuleFilepath, {
encoding: 'utf8',
});
this.addDependency(tailwindTSModuleFilepath);
demoModuleIDs.add(tailwindTSModuleID);
extractImports(demos[demoName].rawTailwindTS).forEach((importModuleID) =>
importedModuleIDs.add(importModuleID),
);
demoModuleIDs.add(demos[demoName].moduleTSTailwind);
} catch (error) {
// tailwind TS demo doesn't exists
}
// Add plain CSS demo data
const cssModuleID = moduleID.replace('/system/index.js', '/css/index.js');
try {
// Add JS demo data
const cssModuleFilepath = path.join(
path.dirname(this.resourcePath),
cssModuleID.replace(/\//g, path.sep),
);
demos[demoName].moduleCSS = cssModuleID;
demos[demoName].rawCSS = await fs.readFile(cssModuleFilepath, {
encoding: 'utf8',
});
this.addDependency(cssModuleFilepath);
demoModuleIDs.add(cssModuleID);
extractImports(demos[demoName].rawCSS).forEach((importModuleID) =>
importedModuleIDs.add(importModuleID),
);
demoModuleIDs.add(demos[demoName].moduleCSS);
} catch (error) {
// plain css js demo doesn't exists
}
try {
// Add TS demo data
const cssTSModuleID = cssModuleID.replace('.js', '.tsx');
const cssTSModuleFilepath = path.join(
path.dirname(this.resourcePath),
cssTSModuleID.replace(/\//g, path.sep),
);
demos[demoName].moduleTSCSS = cssTSModuleID;
demos[demoName].rawCSSTS = await fs.readFile(cssTSModuleFilepath, {
encoding: 'utf8',
});
this.addDependency(cssTSModuleFilepath);
demoModuleIDs.add(cssTSModuleID);
extractImports(demos[demoName].rawCSSTS).forEach((importModuleID) =>
importedModuleIDs.add(importModuleID),
);
demoModuleIDs.add(demos[demoName].moduleTSCSS);
} catch (error) {
// plain css demo doesn't exists
}
// Tailwind preview
try {
const tailwindPreviewFilepath = moduleFilepath.replace(
`${path.sep}system${path.sep}index.js`,
`${path.sep}tailwind${path.sep}index.tsx.preview`,
);
const tailwindJsxPreview = await fs.readFile(tailwindPreviewFilepath, {
encoding: 'utf8',
});
this.addDependency(tailwindPreviewFilepath);
demos[demoName].tailwindJsxPreview = tailwindJsxPreview;
} catch (error) {
// No preview exists. This is fine.
}
// CSS preview
try {
const cssPreviewFilepath = moduleFilepath.replace(
`${path.sep}system${path.sep}index.js`,
`${path.sep}css${path.sep}index.tsx.preview`,
);
const cssJsxPreview = await fs.readFile(cssPreviewFilepath, {
encoding: 'utf8',
});
this.addDependency(cssPreviewFilepath);
demos[demoName].cssJsxPreview = cssJsxPreview;
} catch (error) {
// No preview exists. This is fine.
}
}
try {
const previewFilepath = moduleFilepath.replace(/\.js$/, '.tsx.preview');
const jsxPreview = await fs.readFile(previewFilepath, { encoding: 'utf8' });
this.addDependency(previewFilepath);
demos[demoName].jsxPreview = jsxPreview;
} catch (error) {
// No preview exists. This is fine.
}
try {
const moduleTS = moduleID.replace(/\.js$/, '.tsx');
const moduleTSFilepath = path.join(
path.dirname(this.resourcePath),
moduleTS.replace(/\//g, path.sep),
);
this.addDependency(moduleTSFilepath);
const rawTS = await fs.readFile(moduleTSFilepath, { encoding: 'utf8' });
// In development devs can choose whether they want to work on the TS or JS version.
// But this leads to building both demo version i.e. more build time.
demos[demoName].moduleTS = this.mode === 'production' ? moduleID : moduleTS;
demos[demoName].rawTS = rawTS;
// Extract relative imports from the TypeScript version
// of demos which have relative imports in the JS version
if (relativeModules.has(demoName)) {
extractImports(demos[demoName].rawTS).forEach((importModuleID) => {
detectRelativeImports(demoName, moduleTSFilepath, 'TS', importModuleID);
importedModuleIDs.add(importModuleID);
});
}
demoModuleIDs.add(demos[demoName].moduleTS);
} catch (error) {
// TS version of the demo doesn't exist. This is fine.
}
/* Map over relative import module IDs and resolve them
* while grouping by demo variant
* From:
* relativeModules: { 'ComboBox.js' =>
* { './top100Films.js' => ['JS', 'TS'] }
* }
* To:
* demos["ComboBox.js"].relativeModules = {
* JS: [{ module: './top100Films.js', raw: '...' }],
* TS: [{ module: './top100Films.js', raw: '...' }]
* }
* }
*/
if (relativeModules.has(demoName)) {
if (!demos[demoName].relativeModules) {
demos[demoName].relativeModules = {};
}
/** @type {Record<string, Set<string>>} */
const addedModulesRelativeToModulePathPerVariant = {};
const demoRelativeModules = /** @type {Map<string, string[]>} */ (
relativeModules.get(demoName)
);
await Promise.all(
Array.from(demoRelativeModules).map(async ([relativeModuleID, variants]) => {
for (const variant of variants) {
addedModulesRelativeToModulePathPerVariant[variant] ??= new Set();
const addedModulesRelativeToModulePath =
addedModulesRelativeToModulePathPerVariant[variant];
let raw = '';
const relativeModuleFilePath = path.join(
path.dirname(moduleFilepath),
relativeModuleID,
);
// the file has already been processed
if (addedModulesRelativeToModulePath.has(relativeModuleFilePath)) {
continue;
}
try {
// We are only iterating through an array that looks
// like this: ['JS', 'TS'], so it is safe to await
// eslint-disable-next-line no-await-in-loop
raw = await fs.readFile(relativeModuleFilePath, {
encoding: 'utf8',
});
const importedProcessedModuleIDs = new Set();
const importedProcessedModulesIDsParents = new Map();
// Find the relative paths in the relative module
extractImports(raw).forEach((importModuleID) => {
// detect relative import
const importModuleIdWithExtension = detectRelativeImports(
relativeModuleID,
relativeModuleFilePath,
variant,
importModuleID,
);
if (importModuleID.startsWith('.')) {
importedProcessedModuleIDs.add(importModuleIdWithExtension);
importedProcessedModulesIDsParents.set(
importModuleIdWithExtension,
relativeModuleFilePath,
);
}
});
updateRelativeModules(demoName, { module: relativeModuleID, raw }, variant);
addedModulesRelativeToModulePath.add(relativeModuleFilePath);
// iterate recursively over the relative imports
while (importedProcessedModuleIDs.size > 0) {
for (const entry of importedProcessedModuleIDs) {
if (entry.startsWith('.')) {
const entryModuleFilePath = path.join(
path.dirname(importedProcessedModulesIDsParents.get(entry)),
entry,
);
// We are only iterating through an array that looks
// like this: ['JS', 'TS'], so it is safe to await
// eslint-disable-next-line no-await-in-loop
const rawEntry = await fs.readFile(entryModuleFilePath, {
encoding: 'utf8',
});
extractImports(rawEntry).forEach((importModuleID) => {
// detect relative import
const importModuleIdWithExtension = detectRelativeImports(
relativeModuleID,
entryModuleFilePath,
variant,
importModuleID,
);
if (importModuleID.startsWith('.')) {
importedProcessedModuleIDs.add(importModuleIdWithExtension);
importedProcessedModulesIDsParents.set(
importModuleIdWithExtension,
entryModuleFilePath,
);
}
});
if (!addedModulesRelativeToModulePath.has(entryModuleFilePath)) {
const modulePathDirectory = moduleFilepath
.split('/')
.slice(0, -1)
.join('/');
const moduleData = {
module: `.${entryModuleFilePath.replace(modulePathDirectory, '')}`,
raw: rawEntry,
};
updateRelativeModules(demoName, moduleData, variant);
addedModulesRelativeToModulePath.add(entryModuleFilePath);
}
}
importedProcessedModuleIDs.delete(entry);
}
}
} catch {
throw new Error(
`Could not find a module for the relative import "${relativeModuleID}" in the demo "${demoName}"`,
);
}
}
}),
);
}
}),
);
/** @type {string[]} */
const componentNames = Array.from(
new Set(
/** @type {import('./prepareMarkdown.mjs').ComponentEntry[]} */ (
docs.en.rendered.filter((markdownOrComponentConfig) => {
return (
typeof markdownOrComponentConfig !== 'string' && markdownOrComponentConfig.component
);
})
).map((componentConfig) => {
return componentConfig.component;
}),
),
);
componentNames.forEach((componentName) => {
const moduleID = componentName.startsWith('@mui/docs/')
? componentName
: path.join(this.rootContext, 'src', componentName).replace(/\\/g, '/');
components[moduleID] = componentName;
componentModuleIDs.add(moduleID);
});
const transformed = `
${Array.from(importedModuleIDs)
.map((moduleID) => {
return `import * as ${moduleIDToJSIdentifier(
moduleID.replace('@', '$'),
)} from '${moduleID}';`;
})
.join('\n')}
${Array.from(demoModuleIDs)
.map((moduleID) => {
return `import ${moduleIDToJSIdentifier(moduleID)} from '${moduleID}';`;
})
.join('\n')}
${Array.from(componentModuleIDs)
.map((moduleID) => {
return `import ${moduleIDToJSIdentifier(moduleID)} from '${moduleID}';`;
})
.join('\n')}
export const docs = ${JSON.stringify(docs, null, 2)};
export const demos = ${JSON.stringify(demos, null, 2)};
demos.scope = {
process: {},
import: {
${Array.from(importedModuleIDs)
.map((moduleID) => ` "${moduleID}": ${moduleIDToJSIdentifier(moduleID.replace('@', '$'))},`)
.join('\n')}
},
};
export const demoComponents = {
${Array.from(demoModuleIDs)
.map((moduleID) => {
return ` "${moduleID}": ${moduleIDToJSIdentifier(moduleID)},`;
})
.join('\n')}
};
export const srcComponents = {
${Array.from(componentModuleIDs)
.map((moduleID) => {
return ` "${components[moduleID]}": ${moduleIDToJSIdentifier(moduleID)},`;
})
.join('\n')}
};
`;
return transformed;
}

View File

@@ -0,0 +1,40 @@
{
"name": "@mui/internal-markdown",
"version": "2.0.13",
"author": "MUI Team",
"description": "MUI markdown parser. This is an internal package not meant for general use.",
"main": "./index.mjs",
"browser": "./index.mjs",
"types": "./index.d.mts",
"exports": {
".": "./index.mjs",
"./loader": "./loader.mjs",
"./prism": {
"types": "./prism.d.mts",
"require": null,
"import": "./prism.mjs"
}
},
"repository": {
"type": "git",
"url": "git+https://github.com/mui/material-ui.git",
"directory": "packages/markdown"
},
"scripts": {
"release:publish": "pnpm publish --tag latest",
"release:publish:dry-run": "pnpm publish --tag latest --registry=\"http://localhost:4873/\""
},
"dependencies": {
"@babel/runtime": "^7.28.4",
"es-toolkit": "^1.42.0",
"marked": "^17.0.1",
"prismjs": "^1.30.0"
},
"devDependencies": {
"@types/chai": "^5.2.3",
"chai": "^6.0.1"
},
"publishConfig": {
"access": "public"
}
}

View File

@@ -0,0 +1,498 @@
import { marked } from 'marked';
import textToHash from './textToHash.mjs';
import prism from './prism.mjs';
/**
* Option used by `marked` the library parsing markdown.
*/
const markedOptions = {
gfm: true,
breaks: false,
pedantic: false,
};
const headerRegExp = /---[\r\n]([\s\S]*)[\r\n]---/;
const titleRegExp = /# (.*)[\r\n]/;
const descriptionRegExp = /<p class="description">(.*?)<\/p>/s;
const headerKeyValueRegExp = /(.*?):[\r\n]?\s+(\[[^\]]+\]|.*)/g;
const emptyRegExp = /^\s*$/;
/**
* Same as https://github.com/markedjs/marked/blob/master/src/helpers.js
* Need to duplicate because `marked` does not export `escape` function
*/
const escapeTest = /[&<>"']/;
const escapeReplace = /[&<>"']/g;
const escapeTestNoEncode = /[<>"']|&(?!#?\w+;)/;
const escapeReplaceNoEncode = /[<>"']|&(?!#?\w+;)/g;
const escapeReplacements = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;',
};
const getEscapeReplacement = (ch) => escapeReplacements[ch];
function escape(html, encode) {
if (encode) {
if (escapeTest.test(html)) {
return html.replace(escapeReplace, getEscapeReplacement);
}
} else if (escapeTestNoEncode.test(html)) {
return html.replace(escapeReplaceNoEncode, getEscapeReplacement);
}
return html;
}
function checkUrlHealth(href, linkText, context) {
const url = new URL(href, 'https://mui.com/');
if (/\/{2,}$/.test(url.pathname)) {
throw new Error(
[
'docs-infra: Duplicated trailing slashes. The following link:',
`[${linkText}](${href}) in ${context.location} has duplicated trailing slashes, please only add one.`,
'',
'See https://ahrefs.com/blog/trailing-slash/ for more details.',
'',
].join('\n'),
);
}
// External links to MUI, ignore
if (url.host !== 'mui.com') {
return;
}
/**
* Break for links like:
* /material-ui/customization/theming
*
* It needs to be:
* /material-ui/customization/theming/
*/
if (!url.pathname.endsWith('/')) {
throw new Error(
[
'docs-infra: Missing trailing slash. The following link:',
`[${linkText}](${href}) in ${context.location} is missing a trailing slash, please add it.`,
'',
'See https://ahrefs.com/blog/trailing-slash/ for more details.',
'',
].join('\n'),
);
}
// Relative links
if (href[0] !== '#' && !(href.startsWith('https://') || href.startsWith('http://'))) {
/**
* Break for links like:
* material-ui/customization/theming/
*
* It needs to be:
* /material-ui/customization/theming/
*/
if (href[0] !== '/') {
throw new Error(
[
'docs-infra: Missing leading slash. The following link:',
`[${linkText}](${href}) in ${context.location} is missing a leading slash, please add it.`,
'',
].join('\n'),
);
}
}
}
/**
* Extract information from the top of the markdown.
* For instance, the following input:
*
* ---
* title: Backdrop React Component
* components: Backdrop
* ---
*
* # Backdrop
*
* should output:
* { title: 'Backdrop React Component', components: ['Backdrop'] }
*/
function getHeaders(markdown) {
let header = markdown.match(headerRegExp);
if (!header) {
return {
components: [],
};
}
header = header[1];
try {
let regexMatches;
const headers = {};
// eslint-disable-next-line no-cond-assign
while ((regexMatches = headerKeyValueRegExp.exec(header)) !== null) {
const key = regexMatches[1];
let value = regexMatches[2].replace(/(.*)/, '$1');
if (value.startsWith('[')) {
// Need double quotes to JSON parse.
value = value.replace(/'/g, '"');
// Remove the comma after the last value e.g. ["foo", "bar",] -> ["foo", "bar"].
value = value.replace(/,\s+\]$/g, ']');
headers[key] = JSON.parse(value);
} else {
// Remove quote YAML escaping.
headers[key] = value.replace(/^"|"$|^'|'$/g, '');
}
}
if (headers.components) {
headers.components = headers.components
.split(',')
.map((x) => x.trim())
.sort();
} else {
headers.components = [];
}
if (headers.hooks) {
headers.hooks = headers.hooks
.split(',')
.map((x) => x.trim())
.sort();
} else {
headers.hooks = [];
}
return headers;
} catch (err) {
throw new Error(
`docs-infra: ${err.message} in getHeader(markdown) with markdown: \n\n${header}\n`,
);
}
}
function getContents(markdown) {
const rep = markdown
.replace(headerRegExp, '') // Remove header information
.split(/^{{("(?:demo|component)":.*)}}$/gm) // Split markdown into an array, separating demos
.flatMap((text) => text.split(/^(<codeblock.*?<\/codeblock>)$/gmsu))
.flatMap((text) => text.split(/^(<featureList.*?<\/featureList>)$/gmsu))
.filter((content) => !emptyRegExp.test(content)); // Remove empty lines
return rep;
}
function getTitle(markdown) {
const matches = markdown.match(titleRegExp);
if (matches === null) {
return '';
}
return matches[1].replace(/`/g, '');
}
function getDescription(markdown) {
const matches = markdown.match(descriptionRegExp);
if (matches === null) {
return undefined;
}
return matches[1].trim().replace(/`/g, '');
}
function getCodeblock(content) {
if (!content.startsWith('<codeblock')) {
return undefined;
}
// The regexes below have a negative lookahead to prevent ReDoS
// See https://github.com/mui/material-ui/issues/44078
const storageKey = content.match(
/^<codeblock (?!<codeblock )[^>]*storageKey=["|'](?!storageKey=["|'])(\S*)["|'].*>/m,
)?.[1];
const blocks = [...content.matchAll(/^```(\S*) (\S*)\n(.*?)\n```/gmsu)].map(
([, language, tab, code]) => ({ language, tab, code }),
);
const blocksData = blocks.filter(
(block) => block.tab !== undefined && !emptyRegExp.test(block.code),
);
return {
type: 'codeblock',
data: blocksData,
storageKey,
};
}
function getFeatureList(content) {
if (!content.startsWith('<featureList')) {
return undefined;
}
const lines = content
.split('\n')
.filter((line) => line.startsWith('- '))
.map((line) => line.slice(2));
return ['<ul class="feature-list">', ...lines.map((line) => `<li>${line}</li>`), '</ul>'].join(
'',
);
}
/**
* @param {string} markdown
*/
function renderMarkdown(markdown) {
// Check if the markdown contains an inline list. Unordered lists are block elements and cannot be parsed inline.
if (/[-*+] `([A-Za-z]+)`/g.test(markdown)) {
return marked.parse(markdown, markedOptions);
}
// Two new lines result in a newline in the table.
// All other new lines must be eliminated to prevent markdown mayhem.
return marked
.parseInline(markdown, markedOptions)
.replace(/(\r?\n){2}/g, '<br>')
.replace(/\r?\n/g, ' ');
}
// Help rank mui.com on component searches first.
const noSEOadvantage = [
'https://m2.material.io/',
'https://m3.material.io/',
'https://material.io/',
'https://getbootstrap.com/',
'https://icons.getbootstrap.com/',
'https://pictogrammers.com/',
'https://www.w3.org/',
'https://tailwindcss.com/',
'https://heroicons.com/',
'https://react-icons.github.io/',
'https://fontawesome.com/',
'https://react-spectrum.adobe.com/',
'https://headlessui.com/',
'https://refine.dev/',
'https://scaffoldhub.io/',
'https://marmelab.com/',
'https://framesxdesign.com/',
];
/**
* Creates a function that MUST be used to render non-inline markdown.
* It keeps track of a table of contents and hashes of its items.
* This is important to create anchors that are invariant between languages.
*
* @typedef {object} TableOfContentsEntry
* @property {TableOfContentsEntry[]} children
* @property {string} hash
* @property {number} level
* @property {string} text
* @param {object} context
* @param {Record<string, string>} [context.headingHashes] - WILL BE MUTATED
* @param {TableOfContentsEntry[]} [context.toc] - WILL BE MUTATED
* @param {string} [context.userLanguage]
* @param {object} context.options
*/
function createRender(context) {
const { headingHashes = {}, toc = [], userLanguage = 'en', options } = context;
const headingHashesFallbackTranslated = {};
let headingIndex = -1;
/**
* @param {string} markdown
*/
function render(markdown) {
const renderer = new marked.Renderer();
renderer.heading = function heading({ tokens, depth: level }) {
// Main title, no need for an anchor.
// It adds noises to the URL.
//
// Small title, no need for an anchor.
// It reduces the risk of duplicated id and it's fewer elements in the DOM.
const headingHtml = this.parser.parseInline(tokens);
if (level === 1 || level >= 4) {
return `<h${level}>${headingHtml}</h${level}>`;
}
// Remove links to avoid nested links in the TOCs
let headingText = headingHtml.replace(/<a\b[^>]*>/gi, '').replace(/<\/a>/gi, '');
// Remove `code` tags
headingText = headingText.replace(/<code\b[^>]*>/gi, '').replace(/<\/code>/gi, '');
// Standardizes the hash from the default location (en) to different locations
// Need english.md file parsed first
let hash;
if (userLanguage === 'en') {
hash = textToHash(headingText, headingHashes);
} else {
headingIndex += 1;
hash = Object.keys(headingHashes)[headingIndex];
if (!hash) {
hash = textToHash(headingText, headingHashesFallbackTranslated);
}
}
// enable splitting of long words from function name + first arg name
// Closing parens are less interesting since this would only allow breaking one character earlier.
// Applying the same mechanism would also allow breaking of non-function signatures like "Community help (free)".
// To detect that we enabled breaking of open/closing parens we'd need a context-sensitive parser.
const displayText = headingText.replace(/([^\s]\()/g, '$1&#8203;');
// create a nested structure with 2 levels starting with level 2 e.g.
// [{...level2, children: [level3, level3, level3]}, level2]
if (level === 2) {
toc.push({
text: displayText,
level,
hash,
children: [],
});
} else if (level === 3) {
if (!toc[toc.length - 1]) {
throw new Error(`docs-infra: Missing parent level for: ${headingText}\n`);
}
toc[toc.length - 1].children.push({
text: displayText,
level,
hash,
});
}
return [
headingHtml.includes('<a ')
? [
// Avoid breaking the anchor link button
`<h${level} id="${hash}">${headingHtml}`,
`<a href="#${hash}" class="title-link-to-anchor" aria-labelledby="${hash}"><span class="anchor-icon"><svg><use xlink:href="#anchor-link-icon" /></svg></span></a>`,
].join('')
: `<h${level} id="${hash}"><a href="#${hash}" class="title-link-to-anchor">${headingHtml}<span class="anchor-icon"><svg><use xlink:href="#anchor-link-icon" /></svg></span></a>`,
`<button title="Post a comment" class="comment-link" data-feedback-hash="${hash}">`,
'<svg><use xlink:href="#comment-link-icon" /></svg>',
`</button>`,
`</h${level}>`,
].join('');
};
renderer.link = function link({ href, title, tokens }) {
const linkText = this.parser.parseInline(tokens);
let more = '';
if (title) {
more += ` title="${title}"`;
}
if (noSEOadvantage.some((domain) => href.includes(domain))) {
more = ' target="_blank" rel="noopener nofollow"';
}
let finalHref = href;
checkUrlHealth(href, linkText, context);
if (userLanguage !== 'en' && href.startsWith('/') && !options.ignoreLanguagePages(href)) {
finalHref = `/${userLanguage}${href}`;
}
// This logic turns link like:
// https://github.com/mui/material-ui/blob/-/packages/mui-joy/src/styles/components.d.ts
// into a permalink:
// https://github.com/mui/material-ui/blob/v5.11.15/packages/mui-joy/src/styles/components.d.ts
if (finalHref.startsWith(`${options.env.SOURCE_CODE_REPO}/blob/-/`)) {
finalHref = finalHref.replace(
`${options.env.SOURCE_CODE_REPO}/blob/-/`,
`${options.env.SOURCE_CODE_REPO}/blob/v${options.env.LIB_VERSION}/`,
);
}
return `<a href="${finalHref}"${more}>${linkText}</a>`;
};
renderer.code = ({ lang, text, escaped }) => {
// https://github.com/markedjs/marked/blob/30e90e5175700890e6feb1836c57b9404c854466/src/Renderer.js#L15
const langString = (lang || '').match(/\S*/)[0];
const title = (lang || '').match(/title="([^"]*)"/)?.[1];
const out = prism(text, langString);
if (out != null && out !== text) {
escaped = true;
text = out;
}
const code = `${text.replace(/\n$/, '')}\n`;
if (!lang) {
return `<pre><code>${escaped ? code : escape(code, true)}</code></pre>\n`;
}
return `<div class="MuiCode-root">${title ? `<div class="MuiCode-title">${title}</div>` : ''}<pre><code class="language-${escape(lang, true)}">${
escaped ? code : escape(code, true)
}</code></pre>${[
'<button data-ga-event-category="code" data-ga-event-action="copy-click" aria-label="Copy the code" class="MuiCode-copy">',
'<span class="MuiCode-copy-label">Copy</span>',
'<span class="MuiCode-copied-label">Copied</span>',
'<span class="MuiCode-copyKeypress"><span>(or</span> $keyC<span>)</span></span></button></div>',
].join('')}\n`;
};
marked.use({
extensions: [
{
name: 'callout',
level: 'block',
start(src) {
const match = src.match(/:::/);
return match ? match.index : undefined;
},
tokenizer(src) {
const rule =
/^ {0,3}(:{3,}(?=[^:\n]*\n)|~{3,})([^\n]*)\n(?:|([\s\S]*?)\n)(?: {0,3}\1[~:]* *(?=\n|$)|$)/;
const match = rule.exec(src);
if (match) {
const token = {
type: 'callout',
raw: match[0],
text: match[3].trim(),
severity: match[2],
tokens: [],
};
this.lexer.blockTokens(token.text, token.tokens);
return token;
}
return undefined;
},
renderer(token) {
if (!['info', 'success', 'warning', 'error'].includes(token.severity)) {
throw new Error(`docs-infra: Callout :::${token.severity} is not supported`);
}
return `<aside class="MuiCallout-root MuiCallout-${token.severity}">${[
'<div class="MuiCallout-icon-container">',
'<svg focusable="false" aria-hidden="true" viewBox="0 0 24 24">',
`<use class="MuiCode-copied-icon" xlink:href="#${token.severity}-icon" />`,
'</svg>',
'</div>',
].join(
'\n',
)}<div class="MuiCallout-content">${this.parser.parse(token.tokens)}</div></aside>`;
},
},
],
});
return marked(markdown, { ...markedOptions, renderer });
}
return render;
}
export {
createRender,
getContents,
getDescription,
getCodeblock,
getFeatureList,
getHeaders,
getTitle,
renderMarkdown,
};

View File

@@ -0,0 +1,329 @@
import { expect } from 'chai';
import {
getContents,
getDescription,
getTitle,
getHeaders,
getCodeblock,
renderMarkdown,
createRender,
} from './parseMarkdown.mjs';
describe('parseMarkdown', () => {
describe('getTitle', () => {
it('remove backticks', () => {
expect(
getTitle(`
# \`@mui/styled-engine\`
<p class="description">Configuring your preferred styling library.</p>
`),
).to.equal('@mui/styled-engine');
});
});
describe('getDescription', () => {
it('trims the description', () => {
expect(
getDescription(`
<p class="description">
Some description
</p>
`),
).to.equal('Some description');
});
it('remove backticks', () => {
expect(
getDescription(`
<p class="description">
Some \`description\`
</p>
`),
).to.equal('Some description');
});
it('should not be greedy', () => {
expect(
getDescription(`
<p class="description">
Some description
</p>
## Foo
<p>bar</p>
`),
).to.equal('Some description');
});
});
describe('getHeaders', () => {
it('should return a correct result', () => {
expect(
getHeaders(`
---
title: React Alert component
components: Alert, AlertTitle
hooks: useAlert
githubLabel: 'scope: alert'
packageName: '@mui/lab'
waiAria: https://www.w3.org/TR/wai-aria-practices/#alert
authors: ['foo', 'bar']
---
`),
).to.deep.equal({
components: ['Alert', 'AlertTitle'],
hooks: ['useAlert'],
githubLabel: 'scope: alert',
packageName: '@mui/lab',
title: 'React Alert component',
waiAria: 'https://www.w3.org/TR/wai-aria-practices/#alert',
authors: ['foo', 'bar'],
});
});
it('should work with authors broken in two lines', () => {
expect(
getHeaders(`
---
title: React Alert component
components: Alert, AlertTitle
authors:
['foo', 'bar']
---
`),
).to.deep.equal({
components: ['Alert', 'AlertTitle'],
hooks: [],
title: 'React Alert component',
authors: ['foo', 'bar'],
});
});
it('should work with one author per line', () => {
expect(
getHeaders(`
---
title: React Alert component
components: Alert, AlertTitle
authors:
[
'foo',
'bar',
]
---
`),
).to.deep.equal({
components: ['Alert', 'AlertTitle'],
hooks: [],
title: 'React Alert component',
authors: ['foo', 'bar'],
});
});
it('should work with quotes', () => {
expect(
getHeaders(`
---
title: "Our docs just got a major upgrade—here's what that means for you"
---
`),
).to.deep.equal({
title: "Our docs just got a major upgrade—here's what that means for you",
components: [],
hooks: [],
});
});
});
describe('getContents', () => {
describe('Split markdown into an array, separating demos', () => {
it('returns a single entry without a demo', () => {
expect(getContents('# SomeGuide\nwhich has no demo')).to.deep.equal([
'# SomeGuide\nwhich has no demo',
]);
});
it('uses a `{{"demo"` marker to split', () => {
expect(
getContents('# SomeGuide\n{{"demo": "GuideDemo.js" }}\n## NextHeading'),
).to.deep.equal(['# SomeGuide\n', '"demo": "GuideDemo.js" ', '\n## NextHeading']);
});
it('ignores possible code', () => {
expect(getContents('# SomeGuide\n```jsx\n<Button props={{\nfoo: 1\n}}')).to.deep.equal([
'# SomeGuide\n```jsx\n<Button props={{\nfoo: 1\n}}',
]);
});
});
describe('Split markdown into an array, separating codeblocks', () => {
it('uses a `<codeblock>` tag to split', () => {
expect(
getContents(
[
'## Tabs',
'',
'<codeblock storageKey="package-manager">',
'',
'```bash npm',
'npm install @mui/material @emotion/react @emotion/styled',
'```',
'',
'```bash yarn',
'yarn add @mui/material @emotion/react @emotion/styled',
'```',
'',
'</codeblock>',
].join('\n'),
),
).to.deep.equal([
'## Tabs\n\n',
[
'<codeblock storageKey="package-manager">',
'',
'```bash npm',
'npm install @mui/material @emotion/react @emotion/styled',
'```',
'',
'```bash yarn',
'yarn add @mui/material @emotion/react @emotion/styled',
'```',
'',
'</codeblock>',
].join('\n'),
]);
});
});
});
describe('getCodeblock', () => {
it('should return undefined if no codeblock found', () => {
const codeblock = getCodeblock('## Tabs');
expect(codeblock).to.equal(undefined);
});
it('should return the codeblock', () => {
const codeblock = getCodeblock(
[
'<codeblock storageKey="package-manager">',
'',
'```bash npm',
'npm install @mui/material @emotion/react @emotion/styled',
'# `@emotion/react` and `@emotion/styled` are peer dependencies',
'```',
'',
'```sh yarn',
'yarn add @mui/material @emotion/react @emotion/styled',
'# `@emotion/react` and `@emotion/styled` are peer dependencies',
'```',
'',
'</codeblock>',
].join('\n'),
);
expect(codeblock).to.deep.equal({
type: 'codeblock',
storageKey: 'package-manager',
data: [
{
language: 'bash',
tab: 'npm',
code: [
'npm install @mui/material @emotion/react @emotion/styled',
'# `@emotion/react` and `@emotion/styled` are peer dependencies',
].join('\n'),
},
{
language: 'sh',
tab: 'yarn',
code: [
'yarn add @mui/material @emotion/react @emotion/styled',
'# `@emotion/react` and `@emotion/styled` are peer dependencies',
].join('\n'),
},
],
});
});
});
describe('renderMarkdown', () => {
it('should render markdown lists correctly', () => {
expect(
renderMarkdown(
[
'The track presentation:',
'- `normal` the track will render a bar representing the slider value.',
'- `inverted` the track will render a bar representing the remaining slider value.',
'- `false` the track will render without a bar.',
].join('\n'),
),
).to.equal(
[
'<p>The track presentation:</p>',
'<ul>',
'<li><code>normal</code> the track will render a bar representing the slider value.</li>',
'<li><code>inverted</code> the track will render a bar representing the remaining slider value.</li>',
'<li><code>false</code> the track will render without a bar.</li>',
'</ul>',
'',
].join('\n'),
);
});
it('should render inline descriptions correctly', () => {
expect(
renderMarkdown(
'Allows to control whether the dropdown is open. This is a controlled counterpart of `defaultOpen`.',
),
).to.equal(
'Allows to control whether the dropdown is open. This is a controlled counterpart of <code>defaultOpen</code>.',
);
});
});
describe('createRender', () => {
it('should collect headers correctly', () => {
const context = { toc: [], headingHashes: {} };
// eslint-disable-next-line testing-library/render-result-naming-convention
const render = createRender(context);
expect(
render(
[
'# Accordion',
'## Basic features 🧪',
'## Using `slots` and `slotProps`',
'### Specific example',
].join('\n'),
),
).to.equal(
[
`<h1>Accordion</h1>`,
`<h2 id="basic-features"><a href="#basic-features" class="title-link-to-anchor">Basic features 🧪<span class="anchor-icon"><svg><use xlink:href="#anchor-link-icon" /></svg></span></a><button title="Post a comment" class="comment-link" data-feedback-hash="basic-features"><svg><use xlink:href="#comment-link-icon" /></svg></button></h2>`,
`<h2 id="using-slots-and-slotprops"><a href="#using-slots-and-slotprops" class="title-link-to-anchor">Using <code>slots</code> and <code>slotProps</code><span class="anchor-icon"><svg><use xlink:href="#anchor-link-icon" /></svg></span></a><button title="Post a comment" class="comment-link" data-feedback-hash="using-slots-and-slotprops"><svg><use xlink:href="#comment-link-icon" /></svg></button></h2>`,
`<h3 id="specific-example"><a href="#specific-example" class="title-link-to-anchor">Specific example<span class="anchor-icon"><svg><use xlink:href="#anchor-link-icon" /></svg></span></a><button title="Post a comment" class="comment-link" data-feedback-hash="specific-example"><svg><use xlink:href="#comment-link-icon" /></svg></button></h3>`,
].join(''),
);
expect(context.toc).to.deep.equal([
{
children: [],
hash: 'basic-features',
level: 2,
text: 'Basic features 🧪',
},
{
children: [
{
hash: 'specific-example',
level: 3,
text: 'Specific example',
},
],
hash: 'using-slots-and-slotprops',
level: 2,
text: 'Using slots and slotProps',
},
]);
});
});
});

View File

@@ -0,0 +1,276 @@
import fs from 'fs';
import path from 'path';
import { kebabCase } from 'es-toolkit/string';
import {
createRender,
getContents,
getDescription,
getCodeblock,
getFeatureList,
getHeaders,
getTitle,
} from './parseMarkdown.mjs';
/**
* @type {string | string[]}
*/
const BaseUIReexportedComponents = [];
/**
* @param {string} productId
* @example 'material'
* @param {string} componentPkg
* @example 'mui-base'
* @param {string} component
* @example 'Button'
* @returns {string}
*/
function resolveComponentApiUrl(productId, componentPkg, component) {
if (!productId) {
return `/api/${kebabCase(component)}/`;
}
if (productId === 'x-date-pickers') {
return `/x/api/date-pickers/${kebabCase(component)}/`;
}
if (productId === 'x-charts') {
return `/x/api/charts/${kebabCase(component)}/`;
}
if (productId === 'x-tree-view') {
return `/x/api/tree-view/${kebabCase(component)}/`;
}
if (productId === 'x-data-grid') {
return `/x/api/data-grid/${kebabCase(component)}/`;
}
if (componentPkg === 'mui-base' || BaseUIReexportedComponents.includes(component)) {
return `/base-ui/react-${kebabCase(component)}/components-api/#${kebabCase(component)}`;
}
if (productId === 'toolpad-core') {
return `/toolpad/core/api/${kebabCase(component)}/`;
}
return `/${productId}/api/${kebabCase(component)}/`;
}
/**
* @typedef {{ component: string, demo?: undefined }} ComponentEntry
* @typedef {{ component?: undefined, demo: string, hideToolbar?: boolean }} DemoEntry
*/
/**
* @typedef {{ rendered: Array<string | ComponentEntry | DemoEntry> }} TranslatedDoc
*/
/**
* @param {object} config
* @param {Array<{ markdown: string, filename: string, userLanguage: string }>} config.translations - Mapping of locale to its markdown
* @param {string} config.fileRelativeContext - posix filename relative to repository root directory
* @param {object} config.options - provided to the webpack loader
* @param {string} config.options.workspaceRoot - The absolute path of the repository root directory
* @param {object} [config.componentPackageMapping] - Mapping of productId to mapping of component name to package name
* @example { 'material': { 'Button': 'mui-material' } }
* @returns {{ docs: Record<string, TranslatedDoc> }} - Mapping of locale to its prepared markdown
*/
function prepareMarkdown(config) {
const { fileRelativeContext, translations, componentPackageMapping = {}, options } = config;
/**
* @type {Record<string, TranslatedDoc>}
*/
const docs = {};
const headingHashes = {};
translations
// Process the English markdown before the other locales.
// English ToC anchor links are used in all languages
.sort((a) => (a.userLanguage === 'en' ? -1 : 1))
.forEach((translation) => {
const { filename, markdown, userLanguage } = translation;
const headers = getHeaders(markdown);
const location = headers.filename || `/${fileRelativeContext}/${filename}`;
const markdownH1 = getTitle(markdown);
const title = headers.title || markdownH1;
const description = headers.description || getDescription(markdown);
if (title == null || title === '') {
throw new Error(`docs-infra: Missing title in the page: ${location}\n`);
}
if (title.length > 70) {
throw new Error(
[
`docs-infra: The title "${title}" is too long (${title.length} characters).`,
'It needs to have fewer than 70 characters—ideally less than 60. For more details, see:',
'https://developers.google.com/search/docs/advanced/appearance/title-link',
'',
].join('\n'),
);
}
if (description == null || description === '') {
throw new Error(`docs-infra: Missing description in the page: ${location}\n`);
}
if (description.length > 160) {
throw new Error(
[
`docs-infra: The description "${description}" is too long (${description.length} characters).`,
'It needs to have fewer than 170 characters—ideally less than 160. For more details, see:',
'https://ahrefs.com/blog/meta-description/#4-be-concise',
'',
].join('\n'),
);
}
if (description.slice(-1) !== '.' && description.slice(-1) !== '!') {
throw new Error(
`docs-infra: The description "${description}" should end with a "." or "!", those are sentences.`,
);
}
const contents = getContents(markdown);
if (headers.components.length > 0 && headers.productId !== 'base-ui') {
contents.push(`
## API
See the documentation below for a complete reference to all of the props and classes available to the components mentioned here.
${headers.components
.map((component) => {
const componentPkgMap = componentPackageMapping[headers.productId];
const componentPkg = componentPkgMap ? componentPkgMap[component] : null;
return `- [\`<${component} />\`](${resolveComponentApiUrl(
headers.productId,
componentPkg,
component,
)})`;
})
.join('\n')}
${headers.hooks
.map((hook) => {
const componentPkgMap = componentPackageMapping[headers.productId];
const componentPkg = componentPkgMap ? componentPkgMap[hook] : null;
return `- [\`${hook}\`](${resolveComponentApiUrl(headers.productId, componentPkg, hook)})`;
})
.join('\n')}
`);
}
const toc = [];
const render = createRender({
headingHashes,
toc,
userLanguage,
location,
options,
});
const rendered = contents.map((content) => {
if (/^"(demo|component)": "(.*)"/.test(content)) {
try {
return JSON.parse(`{${content}}`);
} catch (err) {
console.error('JSON.parse fails with: ', `{${content}}`);
console.error(err);
return null;
}
}
const codeblock = getCodeblock(content);
if (codeblock) {
return codeblock;
}
const featureList = getFeatureList(content);
if (featureList) {
return featureList;
}
return render(content);
});
// fragment link symbol
rendered.unshift(`<svg style="display: none;" xmlns="http://www.w3.org/2000/svg">
<symbol id="comment-link-icon" viewBox="0 0 24 24">
<path d="M22.8481 4C22.8481 2.9 21.9481 2 20.8481 2H4.84814C3.74814 2 2.84814 2.9 2.84814 4V16C2.84814 17.1 3.74814 18 4.84814 18H18.8481L22.8481 22V4ZM16.8481 11H13.8481V14C13.8481 14.55 13.3981 15 12.8481 15C12.2981 15 11.8481 14.55 11.8481 14V11H8.84814C8.29814 11 7.84814 10.55 7.84814 10C7.84814 9.45 8.29814 9 8.84814 9H11.8481V6C11.8481 5.45 12.2981 5 12.8481 5C13.3981 5 13.8481 5.45 13.8481 6V9H16.8481C17.3981 9 17.8481 9.45 17.8481 10C17.8481 10.55 17.3981 11 16.8481 11Z" />
</symbol>
</svg>`);
rendered.unshift(`<svg style="display: none;" xmlns="http://www.w3.org/2000/svg">
<symbol id="anchor-link-icon" viewBox="0 0 12 6">
<path d="M8.9176 0.083252H7.1676C6.84677 0.083252 6.58427 0.345752 6.58427 0.666585C6.58427 0.987419 6.84677 1.24992 7.1676 1.24992H8.9176C9.8801 1.24992 10.6676 2.03742 10.6676 2.99992C10.6676 3.96242 9.8801 4.74992 8.9176 4.74992H7.1676C6.84677 4.74992 6.58427 5.01242 6.58427 5.33325C6.58427 5.65409 6.84677 5.91659 7.1676 5.91659H8.9176C10.5276 5.91659 11.8343 4.60992 11.8343 2.99992C11.8343 1.38992 10.5276 0.083252 8.9176 0.083252ZM3.6676 2.99992C3.6676 3.32075 3.9301 3.58325 4.25094 3.58325H7.75094C8.07177 3.58325 8.33427 3.32075 8.33427 2.99992C8.33427 2.67909 8.07177 2.41659 7.75094 2.41659H4.25094C3.9301 2.41659 3.6676 2.67909 3.6676 2.99992ZM4.83427 4.74992H3.08427C2.12177 4.74992 1.33427 3.96242 1.33427 2.99992C1.33427 2.03742 2.12177 1.24992 3.08427 1.24992H4.83427C5.1551 1.24992 5.4176 0.987419 5.4176 0.666585C5.4176 0.345752 5.1551 0.083252 4.83427 0.083252H3.08427C1.47427 0.083252 0.167603 1.38992 0.167603 2.99992C0.167603 4.60992 1.47427 5.91659 3.08427 5.91659H4.83427C5.1551 5.91659 5.4176 5.65409 5.4176 5.33325C5.4176 5.01242 5.1551 4.74992 4.83427 4.74992Z" />
</symbol>
</svg>`);
rendered.unshift(`<svg style="display: none;" xmlns="http://www.w3.org/2000/svg">
<symbol id="copy-icon" viewBox="0 0 24 24">
<path d="M15 20H5V7c0-.55-.45-1-1-1s-1 .45-1 1v13c0 1.1.9 2 2 2h10c.55 0 1-.45 1-1s-.45-1-1-1zm5-4V4c0-1.1-.9-2-2-2H9c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h9c1.1 0 2-.9 2-2zm-2 0H9V4h9v12z" />
+</symbol>
</svg>`);
rendered.unshift(`<svg style="display: none;" xmlns="http://www.w3.org/2000/svg">
<symbol id="copied-icon" viewBox="0 0 24 24">
<path d="M20 2H8c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-8.24 11.28L9.69 11.2c-.38-.39-.38-1.01 0-1.4.39-.39 1.02-.39 1.41 0l1.36 1.37 4.42-4.46c.39-.39 1.02-.39 1.41 0 .38.39.38 1.01 0 1.4l-5.13 5.17c-.37.4-1.01.4-1.4 0zM3 6c-.55 0-1 .45-1 1v13c0 1.1.9 2 2 2h13c.55 0 1-.45 1-1s-.45-1-1-1H5c-.55 0-1-.45-1-1V7c0-.55-.45-1-1-1z" />
</symbol>
</svg>`);
// icons for callout (info, success, warning, error)
rendered.unshift(
`<svg style="display: none;" xmlns="http://www.w3.org/2000/svg">
<symbol id="info-icon" viewBox="0 0 20 20">
<path d="M9.996 14c.21 0 .39-.072.535-.216a.72.72 0 0 0 .219-.534v-3.5a.728.728 0 0 0-.214-.534.72.72 0 0 0-.532-.216.734.734 0 0 0-.535.216.72.72 0 0 0-.219.534v3.5c0 .213.071.39.214.534a.72.72 0 0 0 .532.216Zm0-6.5c.21 0 .39-.071.535-.214a.714.714 0 0 0 .219-.532.736.736 0 0 0-.214-.535.714.714 0 0 0-.532-.219.736.736 0 0 0-.535.214.714.714 0 0 0-.219.532c0 .21.071.39.214.535.143.146.32.219.532.219Zm.01 10.5a7.81 7.81 0 0 1-3.11-.625 8.065 8.065 0 0 1-2.552-1.719 8.066 8.066 0 0 1-1.719-2.551A7.818 7.818 0 0 1 2 9.99c0-1.104.208-2.14.625-3.105a8.066 8.066 0 0 1 4.27-4.26A7.818 7.818 0 0 1 10.009 2a7.75 7.75 0 0 1 3.106.625 8.083 8.083 0 0 1 4.26 4.265A7.77 7.77 0 0 1 18 9.994a7.81 7.81 0 0 1-.625 3.11 8.066 8.066 0 0 1-1.719 2.552 8.083 8.083 0 0 1-2.546 1.719 7.77 7.77 0 0 1-3.104.625Z"/>
</symbol>
</svg>`,
);
rendered.unshift(
`<svg style="display: none;" xmlns="http://www.w3.org/2000/svg">
<symbol id="success-icon" viewBox="0 0 20 20">
<path d="m8.938 10.875-1.25-1.23a.718.718 0 0 0-.521-.228.718.718 0 0 0-.521.229.73.73 0 0 0 0 1.062l1.77 1.771c.153.153.327.23.521.23a.718.718 0 0 0 .521-.23l3.896-3.896a.73.73 0 0 0 0-1.062.718.718 0 0 0-.52-.23.718.718 0 0 0-.521.23l-3.376 3.354ZM10 18a7.796 7.796 0 0 1-3.104-.625 8.065 8.065 0 0 1-2.552-1.719 8.064 8.064 0 0 1-1.719-2.552A7.797 7.797 0 0 1 2 10c0-1.111.208-2.15.625-3.115a8.064 8.064 0 0 1 4.27-4.26A7.797 7.797 0 0 1 10 2c1.111 0 2.15.208 3.115.625a8.096 8.096 0 0 1 4.26 4.26C17.792 7.851 18 8.89 18 10a7.797 7.797 0 0 1-.625 3.104 8.066 8.066 0 0 1-4.26 4.271A7.774 7.774 0 0 1 10 18Z"/>
</symbol>
</svg>`,
);
rendered.unshift(
`<svg style="display: none;" xmlns="http://www.w3.org/2000/svg">
<symbol id="warning-icon" viewBox="0 0 20 20">
<path d="M2.33 17a.735.735 0 0 1-.665-.375.631.631 0 0 1-.094-.375.898.898 0 0 1 .115-.396L9.353 3.062a.621.621 0 0 1 .281-.27.85.85 0 0 1 .729 0 .622.622 0 0 1 .281.27l7.667 12.792c.07.125.108.257.114.396a.63.63 0 0 1-.093.375.842.842 0 0 1-.271.27.728.728 0 0 1-.394.105H2.33Zm7.664-2.5c.211 0 .39-.072.536-.214a.714.714 0 0 0 .218-.532.736.736 0 0 0-.214-.535.714.714 0 0 0-.531-.22.736.736 0 0 0-.536.215.714.714 0 0 0-.219.531c0 .212.072.39.215.536.143.146.32.219.531.219Zm0-2.5c.211 0 .39-.072.536-.216a.72.72 0 0 0 .218-.534v-2.5a.728.728 0 0 0-.214-.534.72.72 0 0 0-.531-.216.734.734 0 0 0-.536.216.72.72 0 0 0-.219.534v2.5c0 .212.072.39.215.534a.72.72 0 0 0 .531.216Z"/>
</symbol>
</svg>`,
);
rendered.unshift(
`<svg style="display: none;" xmlns="http://www.w3.org/2000/svg">
<symbol id="error-icon" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M2 7.4v5.2a2 2 0 0 0 .586 1.414l3.4 3.4A2 2 0 0 0 7.4 18h5.2a2 2 0 0 0 1.414-.586l3.4-3.4A2 2 0 0 0 18 12.6V7.4a2 2 0 0 0-.586-1.414l-3.4-3.4A2 2 0 0 0 12.6 2H7.4a2 2 0 0 0-1.414.586l-3.4 3.4A2 2 0 0 0 2 7.4Zm11.03-.43a.75.75 0 0 1 0 1.06L11.06 10l1.97 1.97a.75.75 0 1 1-1.06 1.06L10 11.06l-1.97 1.97a.75.75 0 0 1-1.06-1.06L8.94 10 6.97 8.03a.75.75 0 0 1 1.06-1.06L10 8.94l1.97-1.97a.75.75 0 0 1 1.06 0Z" clip-rule="evenodd"/>
</symbol>
</svg>`,
);
docs[userLanguage] = {
description,
location,
rendered,
toc,
title,
headers,
};
});
if (docs.en.headers.card === 'true') {
const slug = docs.en.location.replace(/(.*)\/(.*)\.md/, '$2');
const exists = fs.existsSync(
path.resolve(config.options.workspaceRoot, `docs/public/static/blog/${slug}/card.png`),
);
if (!exists) {
throw new Error(
[
`MUI: the card image for the blog post "${slug}" is missing.`,
`Add a docs/public/static/blog/${slug}/card.png file and then restart Next.js or else remove card: true from the headers.`,
].join('\n'),
);
}
}
return { docs };
}
export default prepareMarkdown;

View File

@@ -0,0 +1,417 @@
import { expect } from 'chai';
import prepareMarkdown from './prepareMarkdown.mjs';
describe('prepareMarkdown', () => {
const defaultParams = {
fileRelativeContext: 'test/bar',
options: {
env: {},
},
};
it('returns the table of contents with html and emojis preserved and <a> tags stripped', () => {
const markdown = `
# Support
<p class="description">Foo.</p>
## Community help (free)
### GitHub <img src="/static/images/logos/github.svg" width="24" height="24" alt="GitHub logo" loading="lazy" />
### Unofficial 👍
### Warning ⚠️
### Header with Pro plan <a title="Pro plan" href="/x/introduction/licensing/#plan-pro"><span class="plan-pro"></span></a>
### Header with \`code\`
`;
const {
docs: {
en: { toc },
},
} = prepareMarkdown({
...defaultParams,
translations: [{ filename: 'index.md', markdown, userLanguage: 'en' }],
});
expect(toc).to.have.deep.ordered.members([
{
children: [
{
hash: 'github',
level: 3,
text: 'GitHub <img src="/static/images/logos/github.svg" width="24" height="24" alt="GitHub logo" loading="lazy" />',
},
{ hash: 'unofficial', level: 3, text: 'Unofficial 👍' },
{ hash: 'warning', level: 3, text: 'Warning ⚠️' },
{
hash: 'header-with-pro-plan',
level: 3,
text: 'Header with Pro plan <span class="plan-pro"></span>',
},
{
hash: 'header-with-code',
level: 3,
text: 'Header with code',
},
],
hash: 'community-help-free',
level: 2,
text: 'Community help (free)',
},
]);
});
it('enables word-break for function signatures', () => {
const markdown = `
# Theming
<p class="description">Foo.</p>
## API
### responsiveFontSizes(theme, options) => theme
### createTheme(options, ...args) => theme
`;
const {
docs: {
en: { toc },
},
} = prepareMarkdown({
...defaultParams,
translations: [{ filename: 'index.md', markdown, userLanguage: 'en' }],
});
expect(toc).to.have.deep.ordered.members([
{
children: [
{
hash: 'responsivefontsizes-theme-options-theme',
level: 3,
text: 'responsiveFontSizes(&#8203;theme, options) =&gt; theme',
},
{
hash: 'createtheme-options-args-theme',
level: 3,
text: 'createTheme(&#8203;options, ...args) =&gt; theme',
},
],
hash: 'api',
level: 2,
text: 'API',
},
]);
});
it('use english hash for different locales', () => {
const markdownEn = `
# Localization
<p class="description">Foo.</p>
## Locales
### Example
### Use same hash
`;
const markdownPt = `
# Localização
<p class="description">Foo.</p>
## Idiomas
### Exemplo
### Usar o mesmo hash
`;
const markdownZh = `
# 所在位置
<p class="description">Foo.</p>
## 语言环境
### 例
### 使用相同的哈希
`;
const {
docs: {
en: { toc: tocEn },
pt: { toc: tocPt },
zh: { toc: tocZh },
},
} = prepareMarkdown({
pageFilename: '/same-hash-test',
translations: [
{ filename: 'localization.md', markdown: markdownEn, userLanguage: 'en' },
{ filename: 'localization-pt.md', markdown: markdownPt, userLanguage: 'pt' },
{ filename: 'localization-zh.md', markdown: markdownZh, userLanguage: 'zh' },
],
});
expect(tocZh).to.have.deep.ordered.members([
{
children: [
{
hash: 'example',
level: 3,
text: '例',
},
{
hash: 'use-same-hash',
level: 3,
text: '使用相同的哈希',
},
],
hash: 'locales',
level: 2,
text: '语言环境',
},
]);
expect(tocPt).to.have.deep.ordered.members([
{
children: [
{
hash: 'example',
level: 3,
text: 'Exemplo',
},
{
hash: 'use-same-hash',
level: 3,
text: 'Usar o mesmo hash',
},
],
hash: 'locales',
level: 2,
text: 'Idiomas',
},
]);
expect(tocEn).to.have.deep.ordered.members([
{
children: [
{
hash: 'example',
level: 3,
text: 'Example',
},
{
hash: 'use-same-hash',
level: 3,
text: 'Use same hash',
},
],
hash: 'locales',
level: 2,
text: 'Locales',
},
]);
});
it('use translated hash for translations are not synced', () => {
const markdownEn = `
# Localization
<p class="description">Foo.</p>
## Locales
### Example
### Use same hash
`;
const markdownPt = `
# Localização
<p class="description">Foo.</p>
## Idiomas
### Exemplo
### Usar o mesmo hash
### Usar traduzido
`;
const {
docs: {
en: { toc: tocEn },
pt: { toc: tocPt },
},
} = prepareMarkdown({
pageFilename: '/same-hash-test',
translations: [
{ filename: 'localization.md', markdown: markdownEn, userLanguage: 'en' },
{ filename: 'localization-pt.md', markdown: markdownPt, userLanguage: 'pt' },
],
});
expect(tocPt).to.have.deep.ordered.members([
{
children: [
{
hash: 'example',
level: 3,
text: 'Exemplo',
},
{
hash: 'use-same-hash',
level: 3,
text: 'Usar o mesmo hash',
},
{
hash: 'usar-traduzido',
level: 3,
text: 'Usar traduzido',
},
],
hash: 'locales',
level: 2,
text: 'Idiomas',
},
]);
expect(tocEn).to.have.deep.ordered.members([
{
children: [
{
hash: 'example',
level: 3,
text: 'Example',
},
{
hash: 'use-same-hash',
level: 3,
text: 'Use same hash',
},
],
hash: 'locales',
level: 2,
text: 'Locales',
},
]);
});
it('should report missing trailing splashes', () => {
const markdown = `
# Localization
<p class="description">Foo.</p>
[bar](/bar/)
[foo](/foo)
`;
expect(() => {
prepareMarkdown({
...defaultParams,
translations: [{ filename: 'index.md', markdown, userLanguage: 'en' }],
});
}).to.throw(`docs-infra: Missing trailing slash. The following link:
[foo](/foo) in /test/bar/index.md is missing a trailing slash, please add it.
See https://ahrefs.com/blog/trailing-slash/ for more details.
`);
});
it('should report missing leading splashes', () => {
const markdown = `
# Localization
<p class="description">Foo.</p>
[bar](/bar/)
[foo](foo/)
`;
expect(() => {
prepareMarkdown({
...defaultParams,
translations: [{ filename: 'index.md', markdown, userLanguage: 'en' }],
});
}).to.throw(`docs-infra: Missing leading slash. The following link:
[foo](foo/) in /test/bar/index.md is missing a leading slash, please add it.
`);
});
it('should report title too long', () => {
const markdown = `
# Foooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooo
<p class="description">Foo.</p>
`;
expect(() => {
prepareMarkdown({
...defaultParams,
translations: [{ filename: 'index.md', markdown, userLanguage: 'en' }],
});
}).to
.throw(`docs-infra: The title "Foooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooo" is too long (117 characters).
It needs to have fewer than 70 characters—ideally less than 60. For more details, see:
https://developers.google.com/search/docs/advanced/appearance/title-link
`);
});
it('should report description too long', () => {
const markdown = `
# Foo
<p class="description">Foooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooo.</p>
`;
expect(() => {
prepareMarkdown({
...defaultParams,
translations: [{ filename: 'index.md', markdown, userLanguage: 'en' }],
});
}).to
.throw(`docs-infra: The description "Foooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooo." is too long (188 characters).
It needs to have fewer than 170 characters—ideally less than 160. For more details, see:
https://ahrefs.com/blog/meta-description/#4-be-concise
`);
});
it('should not accept sh', () => {
const markdown = `
# Foo
<p class="description">Fo.</p>
\`\`\`sh
npm install @mui/material
\`\`\`
`;
expect(() => {
prepareMarkdown({
...defaultParams,
translations: [{ filename: 'index.md', markdown, userLanguage: 'en' }],
});
}).to.throw(`docs-infra: Unsupported language: "sh" in:
\`\`\`sh
npm install @mui/material
\`\`\`
Use "bash" instead.
`);
});
it('should report duplicated trailing splashes', () => {
const markdown = `
# Localization
<p class="description">Foo.</p>
[foo](/foo/)
[bar](/bar//#foo)
`;
expect(() => {
prepareMarkdown({
...defaultParams,
translations: [{ filename: 'index.md', markdown, userLanguage: 'en' }],
});
}).to.throw(`docs-infra: Duplicated trailing slashes.`);
});
});

View File

@@ -0,0 +1 @@
export default function highlight(code: string, language: string): string;

View File

@@ -0,0 +1,61 @@
import prism from 'prismjs';
import 'prismjs/components/prism-css.js';
import 'prismjs/components/prism-bash.js';
import 'prismjs/components/prism-diff.js';
import 'prismjs/components/prism-javascript.js';
import 'prismjs/components/prism-json.js';
import 'prismjs/components/prism-jsx.js';
import 'prismjs/components/prism-markup.js';
import 'prismjs/components/prism-yaml.js';
import 'prismjs/components/prism-tsx.js';
function highlight(code, language) {
let prismLanguage;
switch (language) {
case 'ts':
prismLanguage = prism.languages.tsx;
break;
case 'js':
prismLanguage = prism.languages.jsx;
break;
case 'sh':
throw new Error(
[
`docs-infra: Unsupported language: "sh" in:`,
'',
'```sh',
code,
'```',
'',
'Use "bash" instead.',
'',
].join('\n'),
);
case 'diff':
prismLanguage = { ...prism.languages.diff };
// original `/^[-<].*$/m` matches lines starting with `<` which matches
// <SomeComponent />
// we will only use `-` as the deleted marker
prismLanguage.deleted = /^[-].*$/m;
break;
default:
prismLanguage = prism.languages[language];
break;
}
if (!prismLanguage) {
if (language) {
throw new Error(`unsupported language: "${language}", "${code}"`);
} else {
prismLanguage = prism.languages.jsx;
}
}
return prism.highlight(code, prismLanguage);
}
export default highlight;

View File

@@ -0,0 +1,34 @@
function makeUnique(hash, unique, i = 1) {
const uniqueHash = i === 1 ? hash : `${hash}-${i}`;
if (!unique[uniqueHash]) {
unique[uniqueHash] = true;
return uniqueHash;
}
return makeUnique(hash, unique, i + 1);
}
/**
* @param {string} text - HTML from e.g. parseMarkdown#render
* @param {Record<string, boolean>} [unique] - Ensures that each output is unique in `unique`
* @returns {string} that is safe to use in fragment links
*/
export default function textToHash(text, unique = {}) {
return makeUnique(
encodeURI(
text
.toLowerCase()
.replace(/<\/?[^>]+(>|$)/g, '') // remove HTML
.replace(/=&gt;|&lt;| \/&gt;|<code>|<\/code>|&#39;/g, '')
.replace(/[!@#$%^&*()=_+[\]{}`~;:'"|,.<>/?\s]+/g, '-')
.replace(
/([\uE000-\uF8FF]|\uD83C[\uDC00-\uDFFF]|\uD83D[\uDC00-\uDFFF]|[\u2011-\u26FF]|\uD83E[\uDD10-\uDDFF])\uFE0F?/g,
'',
) // remove emojis
.replace(/-+/g, '-')
.replace(/^-|-$/g, ''),
),
unique,
);
}

View File

@@ -0,0 +1,33 @@
import { expect } from 'chai';
import { parseInline as renderInlineMarkdown } from 'marked';
import textToHash from './textToHash.mjs';
describe('textToHash', () => {
it('should hash as expected', () => {
const table = [
['createTheme(options) => theme', 'createtheme-options-theme'],
['Typography - Font family', 'typography-font-family'],
["barre d'application", 'barre-dapplication'],
[
'createGenerateClassName([options]) => class name generator',
'creategenerateclassname-options-class-name-generator',
],
['@mui/material/styles vs @mui/styles', 'mui-material-styles-vs-mui-styles'],
['Blog 📝', 'blog'],
];
table.forEach((entry, index) => {
const [markdown, expected] = entry;
// eslint-disable-next-line testing-library/render-result-naming-convention
const text = renderInlineMarkdown(markdown, { mangle: false, headerIds: false });
const actual = textToHash(text);
expect(actual).to.equal(expected, `snapshot #${index} matches`);
});
});
it('should generate a unique hash', () => {
const unique = {};
expect(textToHash('Styling solution', unique)).to.equal('styling-solution');
expect(textToHash('Styling solution', unique)).to.equal('styling-solution-2');
});
});

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

View File

@@ -0,0 +1,4 @@
/*
!/src/*.js
!/lib/*.js
*.test.js

View File

@@ -0,0 +1,64 @@
# Contributing
## Understanding the codemod
The codemod is a tool that helps developers migrate their codebase when we introduce changes in a new version. The changes could be deprecations, enhancements, or breaking changes.
The codemods for JavaScript files are based on [jscodeshift](https://github.com/facebook/jscodeshift) which is a wrapper of [recast](https://github.com/benjamn/recast).
The codemods for CSS files are based on [postcss](https://github.com/postcss/postcss).
## Adding a new codemod
1. Create a new folder in `packages/mui-codemod/src/*/*` with the name of the codemod.
2. The folder should include:
- `<codemod>.js` - the transform implementation
- `index.js` - exports the transform function
- `postcss-plugin.js` - the postcss plugin (optional)
- `postcss.config.js` - the postcss config file (optional)
- `<codemod>.test.js` - tests for the codemods (use jscodeshift from the `testUtils` folder)
- `test-cases` - folder with fixtures for the codemod
- `actual.js` - the input for the codemod
- `expected.js` - the expected output of the codemod
- `actual.css` - the input for the postcss plugin (optional)
- `expected.css` - the expected output of the postcss plugin (optional)
3. Use [astexplorer](https://astexplorer.net/) to check the AST types and properties
- For JavaScript codemods set </> to @babel/parser because we use [`tsx`](https://github.com/benjamn/recast/blob/master/parsers/babel.ts) as a default parser.
- For CSS codemods set </> to postcss
4. [Test the codemod locally](#local)
5. Add the codemod to README.md
## Testing
I recommend to follow these steps to test the codemod:
- Create an `actual.js` file with the code you want to transform.
- Run [local](#local) transformation to check if the codemod is correct.
- Copy the transformed code to `expected.js`.
- Run `pnpm tc <codemod>` to final check if the codemod is correct.
💡 The reason that I don't recommend creating the `expected.js` and run the test with `pnpm` script is because the transformation is likely not pretty-printed and it's hard to compare the output with the expected output.
### Local transformation (while developing)
Open the terminal at root directory and run the codemod to test the transformation, for example, testing the `accordion-props` codemod:
```bash
node packages/mui-codemod/codemod deprecations/accordion-props packages/mui-codemod/src/deprecations/accordion-props/test-cases/theme.actual.js
```
### CI (after opening a PR)
To simulate a consumer-facing experience on any project before merging the PR, open the CodeSandbox CI build and copy the link from the "Local Install Instructions" section.
Run the codemod to test the transformation:
```bash
npx @mui/codemod@<link> <codemod> <path>
```
For example:
```bash
npx @mui/codemod@https://pkg.csb.dev/mui/material-ui/commit/39bf9464/@mui/codemod deprecations/accordion-props docs/src/modules/brandingTheme.ts
```

File diff suppressed because it is too large Load Diff

215
packages/mui-codemod/codemod.js Executable file
View File

@@ -0,0 +1,215 @@
#!/usr/bin/env node
const childProcess = require('child_process');
const { promises: fs } = require('fs');
const path = require('path');
const yargs = require('yargs');
const jscodeshiftPackage = require('jscodeshift/package.json');
const postcssCliPackage = require('postcss-cli/package.json');
const jscodeshiftDirectory = path.dirname(require.resolve('jscodeshift'));
const jscodeshiftExecutable = path.join(jscodeshiftDirectory, jscodeshiftPackage.bin.jscodeshift);
const postcssCliDirectory = path.dirname(require.resolve('postcss-cli'));
const postcssExecutable = path.join(postcssCliDirectory, postcssCliPackage.bin.postcss);
async function runJscodeshiftTransform(transform, files, flags, codemodFlags) {
const paths = [
path.resolve(__dirname, './src', `${transform}/index.js`),
path.resolve(__dirname, './src', `${transform}.js`),
path.resolve(__dirname, './', `${transform}/index.js`),
path.resolve(__dirname, './', `${transform}.js`),
];
let transformerPath;
let error;
for (const item of paths) {
try {
// eslint-disable-next-line no-await-in-loop
await fs.stat(item);
error = undefined;
transformerPath = item;
break;
} catch (srcPathError) {
error = srcPathError;
continue;
}
}
if (error) {
if (error?.code === 'ENOENT') {
throw new Error(
`Transform '${transform}' not found. Check out ${path.resolve(
__dirname,
'./README.md for a list of available codemods.',
)}`,
);
}
throw error;
}
const args = [
// can't directly spawn `jscodeshiftExecutable` due to https://github.com/facebook/jscodeshift/issues/424
jscodeshiftExecutable,
'--transform',
transformerPath,
...codemodFlags,
'--extensions',
'js,ts,jsx,tsx,json',
'--parser',
flags.parser || 'tsx',
'--ignore-pattern',
'**/node_modules/**',
'--ignore-pattern',
'**/*.css',
];
if (flags.dry) {
args.push('--dry');
}
if (flags.print) {
args.push('--print');
}
if (flags.jscodeshift) {
args.push(flags.jscodeshift);
}
if (flags.packageName) {
args.push(`--packageName=${flags.packageName}`);
}
args.push(...files);
// eslint-disable-next-line no-console -- debug information
console.log(`Executing command: jscodeshift ${args.join(' ')}`);
const jscodeshiftProcess = childProcess.spawnSync('node', args, { stdio: 'inherit' });
if (jscodeshiftProcess.error) {
throw jscodeshiftProcess.error;
}
}
const parseCssFilePaths = async (files) => {
const cssFiles = await Promise.all(
files.map(async (filePath) => {
const stat = await fs.stat(filePath);
if (stat.isDirectory()) {
return `${filePath}/**/*.css`;
}
if (filePath.endsWith('.css')) {
return filePath;
}
return null;
}),
);
return cssFiles.filter(Boolean);
};
async function runPostcssTransform(transform, files) {
// local postcss plugins are loaded through config files https://github.com/postcss/postcss-load-config/issues/17#issuecomment-253125559
const paths = [
path.resolve(__dirname, './src', `${transform}/postcss.config.js`),
path.resolve(__dirname, './', `${transform}/postcss.config.js`),
];
let configPath;
let error;
for (const item of paths) {
try {
// eslint-disable-next-line no-await-in-loop
await fs.stat(item);
error = undefined;
configPath = item;
break;
} catch (srcPathError) {
error = srcPathError;
continue;
}
}
if (error) {
// don't throw if the file is not found, postcss transform is optional
if (error?.code !== 'ENOENT') {
throw error;
}
} else {
const cssPaths = await parseCssFilePaths(files);
if (cssPaths.length > 0) {
const args = [
postcssExecutable,
...cssPaths,
'--config',
configPath,
'--replace',
'--verbose',
];
// eslint-disable-next-line no-console -- debug information
console.log(`Executing command: postcss ${args.join(' ')}`);
const postcssProcess = childProcess.spawnSync('node', args, { stdio: 'inherit' });
if (postcssProcess.error) {
throw postcssProcess.error;
}
}
}
}
function run(argv) {
const { codemod, paths, ...flags } = argv;
const files = paths.map((filePath) => path.resolve(filePath));
runJscodeshiftTransform(codemod, files, flags, argv._);
runPostcssTransform(codemod, files);
}
yargs
.command({
command: '$0 <codemod> <paths...>',
describe: 'Applies a `@mui/codemod` to the specified paths',
builder: (command) => {
return command
.positional('codemod', {
description: 'The name of the codemod',
type: 'string',
})
.positional('paths', {
array: true,
description: 'Paths forwarded to `jscodeshift`',
type: 'string',
})
.option('dry', {
description: 'dry run (no changes are made to files)',
default: false,
type: 'boolean',
})
.option('parser', {
description: 'which parser for jscodeshift to use',
default: 'tsx',
type: 'string',
})
.option('print', {
description: 'print transformed files to stdout, useful for development',
default: false,
type: 'boolean',
})
.option('jscodeshift', {
description: '(Advanced) Pass options directly to jscodeshift',
default: false,
type: 'string',
})
.option('packageName', {
description: 'The package name to look for in the import statements',
default: '@mui/material',
type: 'string',
});
},
handler: run,
})
.scriptName('npx @mui/codemod')
.example('$0 v4.0.0/theme-spacing-api src')
.example('$0 v5.0.0/component-rename-prop src -- --component=Grid --from=prop --to=newProp')
.help()
.parse();

View File

@@ -0,0 +1,55 @@
{
"name": "@mui/codemod",
"version": "7.3.6",
"author": "MUI Team",
"description": "Codemod scripts for Material UI.",
"bin": "./codemod.js",
"keywords": [
"react",
"react-component",
"mui",
"codemod",
"jscodeshift"
],
"scripts": {
"test": "pnpm --workspace-root test:unit --project \"*:@mui/codemod\"",
"build": "code-infra build --bundle cjs --buildTypes false --copy codemod.js",
"release": "pnpm build && pnpm publish"
},
"repository": {
"type": "git",
"url": "git+https://github.com/mui/material-ui.git",
"directory": "packages/mui-codemod"
},
"license": "MIT",
"homepage": "https://github.com/mui/material-ui/tree/master/packages/mui-codemod",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/mui-org"
},
"dependencies": {
"@babel/core": "^7.28.5",
"@babel/runtime": "^7.28.4",
"@babel/traverse": "^7.28.5",
"jscodeshift": "^17.1.2",
"jscodeshift-add-imports": "^1.0.11",
"postcss": "^8.5.6",
"postcss-cli": "^11.0.1",
"yargs": "^17.7.2"
},
"devDependencies": {
"@material-ui/core": "^4.12.4",
"@mui/material-v5": "npm:@mui/material@5.18.0",
"@types/chai": "^5.2.3",
"@types/jscodeshift": "0.12.0",
"chai": "^6.0.1"
},
"sideEffects": false,
"publishConfig": {
"access": "public",
"directory": "build"
},
"engines": {
"node": ">=14.0.0"
}
}

View File

@@ -0,0 +1,30 @@
import movePropIntoSlots from '../utils/movePropIntoSlots';
import movePropIntoSlotProps from '../utils/movePropIntoSlotProps';
/**
* @param {import('jscodeshift').FileInfo} file
* @param {import('jscodeshift').API} api
*/
export default function transformer(file, api, options) {
const j = api.jscodeshift;
const root = j(file.source);
const printOptions = options.printOptions;
movePropIntoSlots(j, {
root,
packageName: options.packageName,
componentName: 'Accordion',
propName: 'TransitionComponent',
slotName: 'transition',
});
movePropIntoSlotProps(j, {
root,
packageName: options.packageName,
componentName: 'Accordion',
propName: 'TransitionProps',
slotName: 'transition',
});
return root.toSource(printOptions);
}

View File

@@ -0,0 +1,77 @@
import path from 'path';
import { expect } from 'chai';
import { jscodeshift } from '../../../testUtils';
import transform from './accordion-props';
import readFile from '../../util/readFile';
function read(fileName) {
return readFile(path.join(__dirname, fileName));
}
describe('@mui/codemod', () => {
describe('deprecations', () => {
describe('accordion-props', () => {
it('transforms props as needed', () => {
const actual = transform({ source: read('./test-cases/actual.js') }, { jscodeshift }, {});
const expected = read('./test-cases/expected.js');
expect(actual).to.equal(expected, 'The transformed version should be correct');
});
it('should be idempotent', () => {
const actual = transform({ source: read('./test-cases/expected.js') }, { jscodeshift }, {});
const expected = read('./test-cases/expected.js');
expect(actual).to.equal(expected, 'The transformed version should be correct');
});
});
describe('[theme] accordion-props', () => {
it('transforms props as needed', () => {
const actual = transform(
{ source: read('./test-cases/theme.actual.js') },
{ jscodeshift },
{},
);
const expected = read('./test-cases/theme.expected.js');
expect(actual).to.equal(expected, 'The transformed version should be correct');
});
it('should be idempotent', () => {
const actual = transform(
{ source: read('./test-cases/theme.expected.js') },
{ jscodeshift },
{},
);
const expected = read('./test-cases/theme.expected.js');
expect(actual).to.equal(expected, 'The transformed version should be correct');
});
});
describe('[custom package] accordion-props', () => {
it('transforms props as needed', () => {
const actual = transform(
{ source: read('./test-cases/package.actual.js') },
{ jscodeshift },
{ packageName: '@org/ui/material' },
);
const expected = read('./test-cases/package.expected.js');
expect(actual).to.equal(expected, 'The transformed version should be correct');
});
it('should be idempotent', () => {
const actual = transform(
{ source: read('./test-cases/package.expected.js') },
{ jscodeshift },
{ packageName: '@org/ui/material' },
);
const expected = read('./test-cases/package.expected.js');
expect(actual).to.equal(expected, 'The transformed version should be correct');
});
});
});
});

View File

@@ -0,0 +1 @@
export { default } from './accordion-props';

View File

@@ -0,0 +1,32 @@
import Accordion from '@mui/material/Accordion';
import { Accordion as MyAccordion } from '@mui/material';
<Accordion TransitionComponent={CustomTransition} TransitionProps={{ unmountOnExit: true }} />;
<MyAccordion TransitionComponent={CustomTransition} TransitionProps={transitionVars} />;
<Accordion
TransitionComponent={CustomTransition}
TransitionProps={{ unmountOnExit: true }}
slots={{
root: 'div',
}}
slotProps={{
root: { className: 'foo' },
}}
/>;
<MyAccordion
TransitionComponent={CustomTransition}
TransitionProps={{ unmountOnExit: true }}
slots={{
...outerSlots,
}}
slotProps={{
...outerSlotProps,
}}
/>;
<Accordion TransitionComponent={ComponentTransition} slots={{ transition: SlotTransition }} />;
<Accordion TransitionProps={{ unmountOnExit: true }} slotProps={{ transition: { id: 'test' } }} />;
// should skip non MUI components
<NonMuiAccordion
TransitionComponent={CustomTransition}
TransitionProps={{ unmountOnExit: true }}
/>;

View File

@@ -0,0 +1,42 @@
import Accordion from '@mui/material/Accordion';
import { Accordion as MyAccordion } from '@mui/material';
<Accordion slots={{
transition: CustomTransition
}} slotProps={{
transition: { unmountOnExit: true }
}} />;
<MyAccordion slots={{
transition: CustomTransition
}} slotProps={{
transition: transitionVars
}} />;
<Accordion
slots={{
root: 'div',
transition: CustomTransition
}}
slotProps={{
root: { className: 'foo' },
transition: { unmountOnExit: true }
}} />;
<MyAccordion
slots={{
...outerSlots,
transition: CustomTransition
}}
slotProps={{
...outerSlotProps,
transition: { unmountOnExit: true }
}} />;
<Accordion slots={{ transition: SlotTransition }} />;
<Accordion
slotProps={{ transition: {
...{ unmountOnExit: true },
...{ id: 'test' }
} }} />;
// should skip non MUI components
<NonMuiAccordion
TransitionComponent={CustomTransition}
TransitionProps={{ unmountOnExit: true }}
/>;

View File

@@ -0,0 +1,34 @@
import Accordion from '@org/ui/material/Accordion';
import { Accordion as MyAccordion } from '@org/ui/material';
<Accordion TransitionProps={{ unmountOnExit: true }} slots={{
transition: CustomTransition
}} />;
<MyAccordion TransitionProps={transitionVars} slots={{
transition: CustomTransition
}} />;
<Accordion
TransitionProps={{ unmountOnExit: true }}
slots={{
root: 'div',
transition: CustomTransition
}}
slotProps={{
root: { className: 'foo' },
}} />;
<MyAccordion
TransitionProps={{ unmountOnExit: true }}
slots={{
...outerSlots,
transition: CustomTransition
}}
slotProps={{
...outerSlotProps,
}} />;
<Accordion slots={{ transition: SlotTransition }} />;
<Accordion TransitionProps={{ unmountOnExit: true }} slotProps={{ transition: { id: 'test' } }} />;
// should skip non MUI components
<NonMuiAccordion
TransitionComponent={CustomTransition}
TransitionProps={{ unmountOnExit: true }}
/>;

View File

@@ -0,0 +1,42 @@
import Accordion from '@org/ui/material/Accordion';
import { Accordion as MyAccordion } from '@org/ui/material';
<Accordion slots={{
transition: CustomTransition
}} slotProps={{
transition: { unmountOnExit: true }
}} />;
<MyAccordion slots={{
transition: CustomTransition
}} slotProps={{
transition: transitionVars
}} />;
<Accordion
slots={{
root: 'div',
transition: CustomTransition
}}
slotProps={{
root: { className: 'foo' },
transition: { unmountOnExit: true }
}} />;
<MyAccordion
slots={{
...outerSlots,
transition: CustomTransition
}}
slotProps={{
...outerSlotProps,
transition: { unmountOnExit: true }
}} />;
<Accordion slots={{ transition: SlotTransition }} />;
<Accordion
slotProps={{ transition: {
...{ unmountOnExit: true },
...{ id: 'test' }
} }} />;
// should skip non MUI components
<NonMuiAccordion
TransitionComponent={CustomTransition}
TransitionProps={{ unmountOnExit: true }}
/>;

View File

@@ -0,0 +1,26 @@
fn({
MuiAccordion: {
defaultProps: {
TransitionComponent: CustomTransition,
TransitionProps: { unmountOnExit: true },
},
},
});
fn({
MuiAccordion: {
defaultProps: {
TransitionComponent: ComponentTransition,
slots: { transition: SlotTransition },
},
},
});
fn({
MuiAccordion: {
defaultProps: {
slotProps: { transition: { id: 'test' } },
TransitionProps: { unmountOnExit: true },
},
},
});

View File

@@ -0,0 +1,32 @@
fn({
MuiAccordion: {
defaultProps: {
slots: {
transition: CustomTransition
},
slotProps: {
transition: { unmountOnExit: true }
}
},
},
});
fn({
MuiAccordion: {
defaultProps: {
slots: { transition: SlotTransition }
},
},
});
fn({
MuiAccordion: {
defaultProps: {
slotProps: { transition: {
...{ unmountOnExit: true },
...{ id: 'test' }
} }
},
},
});

View File

@@ -0,0 +1,77 @@
import { deprecatedClass, replacementSelector } from './postcss-plugin';
/**
* @param {import('jscodeshift').FileInfo} file
* @param {import('jscodeshift').API} api
*/
export default function transformer(file, api, options) {
const j = api.jscodeshift;
const root = j(file.source);
const printOptions = options.printOptions;
// contentGutters is a special case as it's applied to the content child
// but gutters is applied to the parent element, so the gutter class needs to go on the parent
root
.find(j.ImportDeclaration)
.filter((path) =>
path.node.source.value.match(
new RegExp(`^${options.packageName || '@mui/material'}(/AccordionSummary)?$`),
),
)
.forEach((path) => {
path.node.specifiers.forEach((specifier) => {
if (
specifier.type === 'ImportSpecifier' &&
specifier.imported.name === 'accordionSummaryClasses'
) {
root
.find(j.MemberExpression, {
object: { name: specifier.local.name },
property: { name: 'contentGutters' },
})
.forEach((memberExpression) => {
const parent = memberExpression.parentPath.parentPath.value;
if (parent.type === j.TemplateLiteral.name) {
const memberExpressionIndex = parent.expressions.findIndex(
(expression) => expression === memberExpression.value,
);
const precedingTemplateElement = parent.quasis[memberExpressionIndex];
if (precedingTemplateElement.value.raw.endsWith(' .')) {
parent.expressions.splice(
memberExpressionIndex,
1,
j.memberExpression(memberExpression.value.object, j.identifier('gutters')),
j.memberExpression(memberExpression.value.object, j.identifier('content')),
);
parent.quasis.splice(
memberExpressionIndex,
1,
j.templateElement(
{
raw: precedingTemplateElement.value.raw.replace(' ', ''),
cooked: precedingTemplateElement.value.cooked.replace(' ', ''),
},
false,
),
j.templateElement({ raw: ' .', cooked: ' .' }, false),
);
}
}
});
}
});
});
const selectorRegex = new RegExp(`^& ${deprecatedClass}`);
root
.find(
j.Literal,
(literal) => typeof literal.value === 'string' && literal.value.match(selectorRegex),
)
.forEach((path) => {
path.replace(j.literal(path.value.value.replace(selectorRegex, `&${replacementSelector}`)));
});
return root.toSource(printOptions);
}

View File

@@ -0,0 +1,105 @@
import path from 'path';
import { expect } from 'chai';
import postcss from 'postcss';
import { jscodeshift } from '../../../testUtils';
import jsTransform from './accordion-summary-classes';
import { plugin as postcssPlugin } from './postcss-plugin';
import readFile from '../../util/readFile';
function read(fileName) {
return readFile(path.join(__dirname, fileName));
}
const postcssProcessor = postcss([postcssPlugin]);
describe('@mui/codemod', () => {
describe('deprecations', () => {
describe('accordion-summary-classes', () => {
describe('js-transform', () => {
it('transforms props as needed', () => {
const actual = jsTransform(
{ source: read('./test-cases/actual.js') },
{ jscodeshift },
{ printOptions: { quote: 'single', trailingComma: true } },
);
const expected = read('./test-cases/expected.js');
expect(actual).to.equal(expected, 'The transformed version should be correct');
});
it('should be idempotent', () => {
const actual = jsTransform(
{ source: read('./test-cases/expected.js') },
{ jscodeshift },
{},
);
const expected = read('./test-cases/expected.js');
expect(actual).to.equal(expected, 'The transformed version should be correct');
});
});
describe('[package] js-transform', () => {
it('transforms props as needed', () => {
const actual = jsTransform(
{ source: read('./test-cases/package.actual.js') },
{ jscodeshift },
{
printOptions: { quote: 'single', trailingComma: true },
packageName: '@org/ui/material',
},
);
const expected = read('./test-cases/package.expected.js');
expect(actual).to.equal(expected, 'The transformed version should be correct');
});
it('should be idempotent', () => {
const actual = jsTransform(
{ source: read('./test-cases/package.expected.js') },
{ jscodeshift },
{ packageName: '@org/ui/material' },
);
const expected = read('./test-cases/package.expected.js');
expect(actual).to.equal(expected, 'The transformed version should be correct');
});
});
describe('css-transform', () => {
it('transforms classes as needed', async () => {
const actual = await postcssProcessor.process(read('./test-cases/actual.css'), {
from: undefined,
});
const expected = read('./test-cases/expected.css');
expect(actual.css).to.equal(expected, 'The transformed version should be correct');
});
it('should be idempotent', async () => {
const actual = await postcssProcessor.process(read('./test-cases/expected.css'), {
from: undefined,
});
const expected = read('./test-cases/expected.css');
expect(actual.css).to.equal(expected, 'The transformed version should be correct');
});
});
describe('test-cases', () => {
it('should not be the same', () => {
const actualJS = read('./test-cases/actual.js');
const expectedJS = read('./test-cases/expected.js');
expect(actualJS).not.to.equal(expectedJS, 'The actual and expected should be different');
const actualCSS = read('./test-cases/actual.css');
const expectedCSS = read('./test-cases/expected.css');
expect(actualCSS).not.to.equal(
expectedCSS,
'The actual and expected should be different',
);
});
});
});
});
});

View File

@@ -0,0 +1 @@
export { default } from './accordion-summary-classes';

View File

@@ -0,0 +1,26 @@
const deprecatedClass = '.MuiAccordionSummary-contentGutters';
const replacementSelector = '.MuiAccordionSummary-gutters .MuiAccordionSummary-content';
const plugin = () => {
return {
postcssPlugin: `Replace ${deprecatedClass} with ${replacementSelector}`,
Rule(rule) {
const { selector } = rule;
// contentGutters is a special case as it's applied to the content child
// but gutters is applied to the parent element, so the gutter class needs to go on the parent
const selectorRegex = new RegExp(` ${deprecatedClass}`);
if (selector.match(selectorRegex)) {
rule.selector = selector.replace(selectorRegex, replacementSelector);
}
},
};
};
plugin.postcss = true;
module.exports = {
plugin,
deprecatedClass,
replacementSelector,
};

View File

@@ -0,0 +1,5 @@
const { plugin } = require('./postcss-plugin');
module.exports = {
plugins: [plugin],
};

View File

@@ -0,0 +1,3 @@
.MuiAccordionSummary-root .MuiAccordionSummary-contentGutters {
color: red;
}

View File

@@ -0,0 +1,57 @@
import { accordionSummaryClasses } from '@mui/material/AccordionSummary';
fn({
MuiAccordionSummary: {
styleOverrides: {
root: {
'& .MuiAccordionSummary-contentGutters': {
color: 'red',
},
},
},
},
});
fn({
MuiAccordionSummary: {
styleOverrides: {
root: {
[`& .${accordionSummaryClasses.contentGutters}`]: {
color: 'red',
},
},
},
},
});
styled(Component)(() => {
return {
'& .MuiAccordionSummary-contentGutters': {
color: 'red',
},
};
});
styled(Component)(() => {
return {
[`& .${accordionSummaryClasses.contentGutters}`]: {
color: 'red',
},
};
});
<AccordionSummary
sx={{
'& .MuiAccordionSummary-contentGutters': {
color: 'red',
},
}}
/>;
<AccordionSummary
sx={{
[`& .${accordionSummaryClasses.contentGutters}`]: {
color: 'red',
},
}}
/>;

View File

@@ -0,0 +1,3 @@
.MuiAccordionSummary-root.MuiAccordionSummary-gutters .MuiAccordionSummary-content {
color: red;
}

View File

@@ -0,0 +1,57 @@
import { accordionSummaryClasses } from '@mui/material/AccordionSummary';
fn({
MuiAccordionSummary: {
styleOverrides: {
root: {
'&.MuiAccordionSummary-gutters .MuiAccordionSummary-content': {
color: 'red',
},
},
},
},
});
fn({
MuiAccordionSummary: {
styleOverrides: {
root: {
[`&.${accordionSummaryClasses.gutters} .${accordionSummaryClasses.content}`]: {
color: 'red',
},
},
},
},
});
styled(Component)(() => {
return {
'&.MuiAccordionSummary-gutters .MuiAccordionSummary-content': {
color: 'red',
},
};
});
styled(Component)(() => {
return {
[`&.${accordionSummaryClasses.gutters} .${accordionSummaryClasses.content}`]: {
color: 'red',
},
};
});
<AccordionSummary
sx={{
'&.MuiAccordionSummary-gutters .MuiAccordionSummary-content': {
color: 'red',
},
}}
/>;
<AccordionSummary
sx={{
[`&.${accordionSummaryClasses.gutters} .${accordionSummaryClasses.content}`]: {
color: 'red',
},
}}
/>;

View File

@@ -0,0 +1,57 @@
import { accordionSummaryClasses } from '@org/ui/material/AccordionSummary';
fn({
MuiAccordionSummary: {
styleOverrides: {
root: {
'& .MuiAccordionSummary-contentGutters': {
color: 'red',
},
},
},
},
});
fn({
MuiAccordionSummary: {
styleOverrides: {
root: {
[`& .${accordionSummaryClasses.contentGutters}`]: {
color: 'red',
},
},
},
},
});
styled(Component)(() => {
return {
'& .MuiAccordionSummary-contentGutters': {
color: 'red',
},
};
});
styled(Component)(() => {
return {
[`& .${accordionSummaryClasses.contentGutters}`]: {
color: 'red',
},
};
});
<AccordionSummary
sx={{
'& .MuiAccordionSummary-contentGutters': {
color: 'red',
},
}}
/>;
<AccordionSummary
sx={{
[`& .${accordionSummaryClasses.contentGutters}`]: {
color: 'red',
},
}}
/>;

View File

@@ -0,0 +1,57 @@
import { accordionSummaryClasses } from '@org/ui/material/AccordionSummary';
fn({
MuiAccordionSummary: {
styleOverrides: {
root: {
'&.MuiAccordionSummary-gutters .MuiAccordionSummary-content': {
color: 'red',
},
},
},
},
});
fn({
MuiAccordionSummary: {
styleOverrides: {
root: {
[`&.${accordionSummaryClasses.gutters} .${accordionSummaryClasses.content}`]: {
color: 'red',
},
},
},
},
});
styled(Component)(() => {
return {
'&.MuiAccordionSummary-gutters .MuiAccordionSummary-content': {
color: 'red',
},
};
});
styled(Component)(() => {
return {
[`&.${accordionSummaryClasses.gutters} .${accordionSummaryClasses.content}`]: {
color: 'red',
},
};
});
<AccordionSummary
sx={{
'&.MuiAccordionSummary-gutters .MuiAccordionSummary-content': {
color: 'red',
},
}}
/>;
<AccordionSummary
sx={{
[`&.${accordionSummaryClasses.gutters} .${accordionSummaryClasses.content}`]: {
color: 'red',
},
}}
/>;

View File

@@ -0,0 +1,84 @@
import { classes } from './postcss-plugin';
/**
* @param {import('jscodeshift').FileInfo} file
* @param {import('jscodeshift').API} api
*/
export default function transformer(file, api, options) {
const j = api.jscodeshift;
const root = j(file.source);
const printOptions = options.printOptions;
classes.forEach(({ deprecatedClass, replacementSelector }) => {
root
.find(j.ImportDeclaration)
.filter((path) =>
path.node.source.value.match(
new RegExp(`^${options.packageName || '@mui/material'}(/Alert)?$`),
),
)
.forEach((path) => {
path.node.specifiers.forEach((specifier) => {
if (specifier.type === 'ImportSpecifier' && specifier.imported.name === 'alertClasses') {
const deprecatedAtomicClass = deprecatedClass.replace('.MuiAlert-', '');
root
.find(j.MemberExpression, {
object: { name: specifier.local.name },
property: { name: deprecatedAtomicClass },
})
.forEach((memberExpression) => {
const parent = memberExpression.parentPath.parentPath.value;
if (parent.type === j.TemplateLiteral.name) {
const memberExpressionIndex = parent.expressions.findIndex(
(expression) => expression === memberExpression.value,
);
const precedingTemplateElement = parent.quasis[memberExpressionIndex];
const atomicClasses = replacementSelector
.replaceAll('MuiAlert-', '')
.split('.')
.filter(Boolean);
if (precedingTemplateElement.value.raw.endsWith('&.')) {
parent.expressions.splice(
memberExpressionIndex,
1,
j.memberExpression(
memberExpression.value.object,
j.identifier(atomicClasses[0]),
),
j.memberExpression(
memberExpression.value.object,
j.identifier(atomicClasses[1]),
),
);
parent.quasis.splice(
memberExpressionIndex,
1,
j.templateElement(
{
raw: precedingTemplateElement.value.raw,
cooked: precedingTemplateElement.value.cooked,
},
false,
),
j.templateElement({ raw: '.', cooked: '.' }, false),
);
}
}
});
}
});
});
const selectorRegex = new RegExp(`^&${deprecatedClass}`);
root
.find(
j.Literal,
(literal) => typeof literal.value === 'string' && literal.value.match(selectorRegex),
)
.forEach((path) => {
path.replace(j.literal(path.value.value.replace(selectorRegex, `&${replacementSelector}`)));
});
});
return root.toSource(printOptions);
}

View File

@@ -0,0 +1,105 @@
import path from 'path';
import { expect } from 'chai';
import postcss from 'postcss';
import { jscodeshift } from '../../../testUtils';
import jsTransform from './alert-classes';
import { plugin as postcssPlugin } from './postcss-plugin';
import readFile from '../../util/readFile';
function read(fileName) {
return readFile(path.join(__dirname, fileName));
}
const postcssProcessor = postcss([postcssPlugin]);
describe('@mui/codemod', () => {
describe('deprecations', () => {
describe('alert-classes', () => {
describe('js-transform', () => {
it('transforms props as needed', () => {
const actual = jsTransform(
{ source: read('./test-cases/actual.js') },
{ jscodeshift },
{ printOptions: { quote: 'single', trailingComma: true } },
);
const expected = read('./test-cases/expected.js');
expect(actual).to.equal(expected, 'The transformed version should be correct');
});
it('should be idempotent', () => {
const actual = jsTransform(
{ source: read('./test-cases/expected.js') },
{ jscodeshift },
{},
);
const expected = read('./test-cases/expected.js');
expect(actual).to.equal(expected, 'The transformed version should be correct');
});
});
describe('[package] js-transform', () => {
it('transforms props as needed', () => {
const actual = jsTransform(
{ source: read('./test-cases/package.actual.js') },
{ jscodeshift },
{
printOptions: { quote: 'single', trailingComma: true },
packageName: '@org/ui/material',
},
);
const expected = read('./test-cases/package.expected.js');
expect(actual).to.equal(expected, 'The transformed version should be correct');
});
it('should be idempotent', () => {
const actual = jsTransform(
{ source: read('./test-cases/package.expected.js') },
{ jscodeshift },
{ packageName: '@org/ui/material' },
);
const expected = read('./test-cases/package.expected.js');
expect(actual).to.equal(expected, 'The transformed version should be correct');
});
});
describe('css-transform', () => {
it('transforms classes as needed', async () => {
const actual = await postcssProcessor.process(read('./test-cases/actual.css'), {
from: undefined,
});
const expected = read('./test-cases/expected.css');
expect(actual.css).to.equal(expected, 'The transformed version should be correct');
});
it('should be idempotent', async () => {
const actual = await postcssProcessor.process(read('./test-cases/expected.css'), {
from: undefined,
});
const expected = read('./test-cases/expected.css');
expect(actual.css).to.equal(expected, 'The transformed version should be correct');
});
});
describe('test-cases', () => {
it('should not be the same', () => {
const actualJS = read('./test-cases/actual.js');
const expectedJS = read('./test-cases/expected.js');
expect(actualJS).not.to.equal(expectedJS, 'The actual and expected should be different');
const actualCSS = read('./test-cases/actual.css');
const expectedCSS = read('./test-cases/expected.css');
expect(actualCSS).not.to.equal(
expectedCSS,
'The actual and expected should be different',
);
});
});
});
});
});

View File

@@ -0,0 +1 @@
export { default } from './alert-classes';

View File

@@ -0,0 +1,39 @@
const variants = ['standard', 'outlined', 'filled'];
const colors = ['Success', 'Info', 'Warning', 'Error'];
const classes = variants.reduce((acc, variant) => {
return acc.concat(
colors.map((color) => {
const deprecatedClass = `.MuiAlert-${variant}${color}`;
const replacementSelector = `.MuiAlert-${variant}.MuiAlert-color${color}`;
return {
deprecatedClass,
replacementSelector,
};
}),
);
}, []);
const plugin = () => {
return {
postcssPlugin: `Replace deprecated Alert classes with new classes`,
Rule(rule) {
const { selector } = rule;
classes.forEach(({ deprecatedClass, replacementSelector }) => {
const selectorRegex = new RegExp(`${deprecatedClass}`);
if (selector.match(selectorRegex)) {
rule.selector = selector.replace(selectorRegex, replacementSelector);
}
});
},
};
};
plugin.postcss = true;
module.exports = {
plugin,
classes,
};

View File

@@ -0,0 +1,5 @@
const { plugin } = require('./postcss-plugin');
module.exports = {
plugins: [plugin],
};

View File

@@ -0,0 +1,47 @@
.MuiAlert-standardSuccess {
color: red;
}
.MuiAlert-standardInfo {
color: red;
}
.MuiAlert-standardWarning {
color: red;
}
.MuiAlert-standardError {
color: red;
}
.MuiAlert-outlinedSuccess {
color: red;
}
.MuiAlert-outlinedInfo {
color: red;
}
.MuiAlert-outlinedWarning {
color: red;
}
.MuiAlert-outlinedError {
color: red;
}
.MuiAlert-filledSuccess {
color: red;
}
.MuiAlert-filledInfo {
color: red;
}
.MuiAlert-filledWarning {
color: red;
}
.MuiAlert-filledError {
color: red;
}

View File

@@ -0,0 +1,26 @@
import { alertClasses } from '@mui/material/Alert';
('&.MuiAlert-standardSuccess');
('&.MuiAlert-standardInfo');
('&.MuiAlert-standardWarning');
('&.MuiAlert-standardError');
('&.MuiAlert-outlinedSuccess');
('&.MuiAlert-outlinedInfo');
('&.MuiAlert-outlinedWarning');
('&.MuiAlert-outlinedError');
('&.MuiAlert-filledSuccess');
('&.MuiAlert-filledInfo');
('&.MuiAlert-filledWarning');
('&.MuiAlert-filledError');
`&.${alertClasses.standardSuccess}`;
`&.${alertClasses.standardInfo}`;
`&.${alertClasses.standardWarning}`;
`&.${alertClasses.standardError}`;
`&.${alertClasses.outlinedSuccess}`;
`&.${alertClasses.outlinedInfo}`;
`&.${alertClasses.outlinedWarning}`;
`&.${alertClasses.outlinedError}`;
`&.${alertClasses.filledSuccess}`;
`&.${alertClasses.filledInfo}`;
`&.${alertClasses.filledWarning}`;
`&.${alertClasses.filledError}`;

View File

@@ -0,0 +1,47 @@
.MuiAlert-standard.MuiAlert-colorSuccess {
color: red;
}
.MuiAlert-standard.MuiAlert-colorInfo {
color: red;
}
.MuiAlert-standard.MuiAlert-colorWarning {
color: red;
}
.MuiAlert-standard.MuiAlert-colorError {
color: red;
}
.MuiAlert-outlined.MuiAlert-colorSuccess {
color: red;
}
.MuiAlert-outlined.MuiAlert-colorInfo {
color: red;
}
.MuiAlert-outlined.MuiAlert-colorWarning {
color: red;
}
.MuiAlert-outlined.MuiAlert-colorError {
color: red;
}
.MuiAlert-filled.MuiAlert-colorSuccess {
color: red;
}
.MuiAlert-filled.MuiAlert-colorInfo {
color: red;
}
.MuiAlert-filled.MuiAlert-colorWarning {
color: red;
}
.MuiAlert-filled.MuiAlert-colorError {
color: red;
}

View File

@@ -0,0 +1,26 @@
import { alertClasses } from '@mui/material/Alert';
('&.MuiAlert-standard.MuiAlert-colorSuccess');
('&.MuiAlert-standard.MuiAlert-colorInfo');
('&.MuiAlert-standard.MuiAlert-colorWarning');
('&.MuiAlert-standard.MuiAlert-colorError');
('&.MuiAlert-outlined.MuiAlert-colorSuccess');
('&.MuiAlert-outlined.MuiAlert-colorInfo');
('&.MuiAlert-outlined.MuiAlert-colorWarning');
('&.MuiAlert-outlined.MuiAlert-colorError');
('&.MuiAlert-filled.MuiAlert-colorSuccess');
('&.MuiAlert-filled.MuiAlert-colorInfo');
('&.MuiAlert-filled.MuiAlert-colorWarning');
('&.MuiAlert-filled.MuiAlert-colorError');
`&.${alertClasses.standard}.${alertClasses.colorSuccess}`;
`&.${alertClasses.standard}.${alertClasses.colorInfo}`;
`&.${alertClasses.standard}.${alertClasses.colorWarning}`;
`&.${alertClasses.standard}.${alertClasses.colorError}`;
`&.${alertClasses.outlined}.${alertClasses.colorSuccess}`;
`&.${alertClasses.outlined}.${alertClasses.colorInfo}`;
`&.${alertClasses.outlined}.${alertClasses.colorWarning}`;
`&.${alertClasses.outlined}.${alertClasses.colorError}`;
`&.${alertClasses.filled}.${alertClasses.colorSuccess}`;
`&.${alertClasses.filled}.${alertClasses.colorInfo}`;
`&.${alertClasses.filled}.${alertClasses.colorWarning}`;
`&.${alertClasses.filled}.${alertClasses.colorError}`;

View File

@@ -0,0 +1,26 @@
import { alertClasses } from '@org/ui/material/Alert';
('&.MuiAlert-standardSuccess');
('&.MuiAlert-standardInfo');
('&.MuiAlert-standardWarning');
('&.MuiAlert-standardError');
('&.MuiAlert-outlinedSuccess');
('&.MuiAlert-outlinedInfo');
('&.MuiAlert-outlinedWarning');
('&.MuiAlert-outlinedError');
('&.MuiAlert-filledSuccess');
('&.MuiAlert-filledInfo');
('&.MuiAlert-filledWarning');
('&.MuiAlert-filledError');
`&.${alertClasses.standardSuccess}`;
`&.${alertClasses.standardInfo}`;
`&.${alertClasses.standardWarning}`;
`&.${alertClasses.standardError}`;
`&.${alertClasses.outlinedSuccess}`;
`&.${alertClasses.outlinedInfo}`;
`&.${alertClasses.outlinedWarning}`;
`&.${alertClasses.outlinedError}`;
`&.${alertClasses.filledSuccess}`;
`&.${alertClasses.filledInfo}`;
`&.${alertClasses.filledWarning}`;
`&.${alertClasses.filledError}`;

View File

@@ -0,0 +1,26 @@
import { alertClasses } from '@org/ui/material/Alert';
('&.MuiAlert-standard.MuiAlert-colorSuccess');
('&.MuiAlert-standard.MuiAlert-colorInfo');
('&.MuiAlert-standard.MuiAlert-colorWarning');
('&.MuiAlert-standard.MuiAlert-colorError');
('&.MuiAlert-outlined.MuiAlert-colorSuccess');
('&.MuiAlert-outlined.MuiAlert-colorInfo');
('&.MuiAlert-outlined.MuiAlert-colorWarning');
('&.MuiAlert-outlined.MuiAlert-colorError');
('&.MuiAlert-filled.MuiAlert-colorSuccess');
('&.MuiAlert-filled.MuiAlert-colorInfo');
('&.MuiAlert-filled.MuiAlert-colorWarning');
('&.MuiAlert-filled.MuiAlert-colorError');
`&.${alertClasses.standard}.${alertClasses.colorSuccess}`;
`&.${alertClasses.standard}.${alertClasses.colorInfo}`;
`&.${alertClasses.standard}.${alertClasses.colorWarning}`;
`&.${alertClasses.standard}.${alertClasses.colorError}`;
`&.${alertClasses.outlined}.${alertClasses.colorSuccess}`;
`&.${alertClasses.outlined}.${alertClasses.colorInfo}`;
`&.${alertClasses.outlined}.${alertClasses.colorWarning}`;
`&.${alertClasses.outlined}.${alertClasses.colorError}`;
`&.${alertClasses.filled}.${alertClasses.colorSuccess}`;
`&.${alertClasses.filled}.${alertClasses.colorInfo}`;
`&.${alertClasses.filled}.${alertClasses.colorWarning}`;
`&.${alertClasses.filled}.${alertClasses.colorError}`;

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