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

186
scripts/README.md Normal file
View File

@@ -0,0 +1,186 @@
# Scripts
## Release
### Prerequisites
1. Make sure you have added the `material-ui-docs` and `upstream` remotes to deploy the documentation:
```bash
git remote add upstream https://github.com/mui/material-ui.git
git remote add material-ui-docs https://github.com/mui/material-ui-docs.git
```
2. Generate a GitHub Token at https://github.com/settings/personal-access-tokens/new and add it to your shell rc script (either `.bashrc` or `.zshrc`) as `GITHUB_TOKEN`.
### Releasing a minor version
A minor release goes like this:
#### Prepare
The following steps must be proposed as a pull request.
1. Generate the changelog with `pnpm release:changelog`
The output must be prepended to the top level `CHANGELOG.md`
`pnpm release:changelog --help` for more information. If your GitHub token is not in your env, pass it as `--githubToken <my-token>` to the above command.
2. Clean the generated changelog:
1. Match the format of https://github.com/mui/material-ui/releases.
2. Change the packages names casing to be lowercase if applicable
3. Update the root `/package.json`'s version
4. Run `pnpm release:version`. Keep in mind:
1. Only packages that have changes since the last release should have their version bumped.
2. If they have changes, packages that follow Material-UI's versioning scheme should be bumped to the same version as the root `package.json`. This might require skipping some version numbers.
5. Open PR with changes and wait for review and green CI.
6. Merge PR once CI is green and it has been approved.
#### Release
1. Go to the [publish action](https://github.com/mui/material-ui/actions/workflows/publish.yml).
2. Choose "Run workflow" dropdown
> - **Branch:** master
> - **Commit SHA to release from:** the commit that contains the merged release on master. This commit is linked to the GitHub release.
> - **Run in dry-run mode:** Used for debugging.
> - **Create GitHub release:** Keep selected if you want a GitHub release to be automatically created from the changelog.
> - **npm dist tag to publish to** Use to publish legacy or canary versions.
3. Click "Run workflow"
4. Refresh the page to see the newly created workflow, and click it.
5. The next screen will say "@username requested your review to deploy to npm-publish", click "Review deployments" and authorize your workflow run. **Never approve workflow runs you didn't initiaite.**
#### Documentation
`pnpm docs:deploy` to deploy the documentation (it lives at https://material-ui.netlify.app/) with the latest changes.
Force push if necessary.
#### Publish GitHub release
After the documentation deployment is done, review the draft release that was created, then publish it. At this point the release tag gets created. [GitHub releases page](https://github.com/mui/material-ui/releases)
#### Announce
After the docs is live, follow the instructions in https://mui-org.notion.site/Releases-7490ef9581b4447ebdbf86b13164272d.
### Releasing a hotfix version
A hotfix release could happen if there is a regression fix that could not wait for the monthly release cycle and the master branch already contains not yet to be released commits. If you can publish an earlier minor or patch, just prefer that over a hotfix release.
It goes like this:
#### Prepare
Hotfix branch creation requires the help of a repository admin. They need to take the following steps:
1. Check out the commit for the latest release tag.
2. Create a branch named `release/<PATCH_VERSION>` where `<PATCH_VERSION>` is the next semver patch version from that release tag.
3. force push the branch to `upstream`:
```bash
git push -f upstream release/<PATCH_VERSION>
```
The following steps must be proposed as a pull request to `release/<PATCH_VERSION>`.
1. check out `release/<PATCH_VERSION>` and cherry-pick the hotfix commits on top of it.
2. Generate the changelog with `pnpm release:changelog`
The output must be prepended to the top level `CHANGELOG.md`
`pnpm release:changelog --help` for more information. If your GitHub token is not in your env, pass it as `--githubToken <my-token>` to the above command.
3. Clean the generated changelog:
1. Match the format of https://github.com/mui/material-ui/releases.
2. Change the packages names casing to be lowercase if applicable
4. Update the root `/package.json`'s version
5. Run `pnpm release:version`. Keep in mind:
1. Only packages that have changes since the last release should have their version bumped.
2. If they have changes, packages that follow Material-UI's versioning scheme should be bumped to the same version as the root `package.json`. This might require skipping some version numbers.
6. Open PR with changes and wait for review and green CI.
7. Merge PR into `release/<PATCH_VERSION>` once CI is green and it has been approved.
8. Open and merge a PR from `release/<PATCH_VERSION>` to master to correct the package versioning and update the changelog.
### Release the packages
1. Run `pnpm release:publish`. You may be asked to authenticate with GitHub when running the command for the first time or after a very long time.
2. It'll automatically fetch the latest merged release PR and ask for confirmation before publishing.
3. If you already know the sha of the commit, you can pass it directly like `pnpm release:publish --sha <your-sha>`.
4. Other flags for the command:
> - **--dry-run** Used for debugging. Or directly run `pnpm release:publish:dry-run`.
> - **--dist-tag** Use to publish legacy or canary versions.
5. This command invokes the [Publish](https://github.com/mui/base-ui/actions/workflows/publish.yml) GitHub action. It'll log the url which can be opened to see the latest workflow run.
6. The next screen shows "@username requested your review to deploy to npm-publish", click "Review deployments" and authorize your workflow run. **Never approve workflow runs you didn't initiaite.**
#### Documentation
Run `git push -f material-ui-docs HEAD:latest` to deploy the documentation (it lives at https://material-ui.netlify.app/) with the latest changes.
Force push if necessary.
#### Publish GitHub release
After the documentation deployment is done, review and then publish the release that was created in draft mode during the release step [GitHub releases page](https://github.com/mui/material-ui/releases)
#### Cleanup
After the release is done, merge the branch back to master. While merging make sure to resolve conflicts considering master may have future changes done in the same files.
#### Announce
After the docs is live, follow the instructions in https://mui-org.notion.site/Releases-7490ef9581b4447ebdbf86b13164272d.
## Deploy documentation without a release
Sometimes it is necessary to deploy the selected commit(s) without
deploying all the changes that have been merged into the main branch
since the previous release (for example publishing a blog post or releasing
urgent docs updates).
**Note:** The instructions below are for deploying to the `latest` branch of the `material-ui-docs` repository, which points to `https://mui.com/`. If you need to deploy to a different subdomain, replace `latest` with the appropriate branch name:
- `latest`: `https://mui.com/`
- `next`: `https://next.mui.com/`
- `v*.x`: `https://v*.mui.com/`
To do so, follow these steps:
1. Add the `material-ui-docs` remote if you haven't done this already:
```bash
git remote add material-ui-docs https://github.com/mui/material-ui-docs.git
```
2. Fetch the latest changes from the `material-ui-docs` remote:
```bash
git fetch material-ui-docs latest
```
3. Switch to the `latest` branch from `material-ui-docs` remote:
```bash
git switch --detach material-ui-docs/latest
```
4. Cherry-pick the commit(s) that you want to include in the new deployment:
```bash
git cherry-pick <commit>
```
It will commit the changes if there are no conflicts.
In case of conflicts you will need to resolve them and commit the changes manually.
If this command fails with the message 'bad revision', it means that the commit doesn't exist on your local repository.
The commit might have been created on a remote branch, probably when merging into `master` or `v*.x`.
In this case, you'll have to fetch the latest changes of the corresponding remote branch and then try again.
5. Push the changes to the `material-ui-docs` remote:
```bash
git push material-ui-docs HEAD:latest
```
6. Switch from detached `HEAD` back to your last checked out branch:
```bash
git checkout -
```

View File

@@ -0,0 +1,45 @@
import yargs, { ArgumentsCamelCase } from 'yargs';
import { ProjectSettings, buildApi } from '@mui-internal/api-docs-builder';
import {
joyUiProjectSettings,
materialUiProjectSettings,
muiSystemProjectSettings,
} from '@mui-internal/api-docs-builder-core';
const projectSettings: ProjectSettings[] = [
materialUiProjectSettings,
joyUiProjectSettings,
muiSystemProjectSettings,
];
type CommandOptions = { grep?: string; rawDescriptions?: boolean };
async function run(argv: ArgumentsCamelCase<CommandOptions>) {
const grep = argv.grep == null ? null : new RegExp(argv.grep);
const rawDescriptions = argv.rawDescriptions === true;
return buildApi(projectSettings, grep, rawDescriptions);
}
yargs(process.argv.slice(2))
.command({
command: '$0',
describe: 'Generates API documentation for the MUI packages.',
builder: (command) => {
return command
.option('grep', {
description:
'Only generate files for component filenames matching the pattern. The string is treated as a RegExp.',
type: 'string',
})
.option('rawDescriptions', {
description: 'Whether to output raw JSDoc descriptions or process them as markdown.',
type: 'boolean',
default: false,
});
},
handler: run,
})
.help()
.strict(true)
.version(false)
.parse();

View File

@@ -0,0 +1,25 @@
{
"compilerOptions": {
"module": "node16",
"target": "es2022",
"moduleResolution": "node16",
"isolatedModules": true,
"resolveJsonModule": true,
"noEmit": true,
"allowJs": true,
"esModuleInterop": true,
"noUnusedLocals": false,
"skipLibCheck": true,
"strict": true,
"jsx": "react",
"types": ["node", "vitest/globals"],
"paths": {
"@mui/types": ["../../packages/mui-types"],
"@mui/utils": ["../../packages/mui-utils/src"],
"@mui/utils/*": ["../../packages/mui-utils/src/*"]
}
},
"include": ["./**/*.ts"]
}

View File

@@ -0,0 +1,80 @@
import * as path from 'path';
import * as fs from 'node:fs/promises';
import * as colors from '@mui/material/colors';
// use netlify deploy preview if you want to test changes
const HOST = 'https://mui.com/';
function getColorHref(name, variant) {
return `static/colors-preview/${name}-${variant}-24x24.svg`;
}
function buildColorType(name, variants) {
const typesFilename = path.resolve(__dirname, `../packages/mui-material/src/colors/${name}.d.ts`);
const typescript = `
/**
* ${Object.entries(variants)
.map((entry) => {
const [variant] = entry;
return `![${name} ${variant}](${HOST}${getColorHref(name, variant)})`;
})
.join(' ')}
*/
declare const ${name}: {
${Object.entries(variants)
.map((entry) => {
const [variant, color] = entry;
return ` /**
* Preview: ![${name} ${variant}](${HOST}${getColorHref(name, variant)})
*/
${variant}: '${color}';`;
})
.join('\n')}
};
export default ${name};
`;
return fs.writeFile(typesFilename, typescript, { encoding: 'utf8' });
}
function buildColorPreviews(name, variants) {
const nextPublicPath = path.resolve(__dirname, '../docs/public/');
return Promise.all(
Object.entries(variants).map(async (variantEntry) => {
const [variant, color] = variantEntry;
const svg = `<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 0 24 24" width="24">
<rect width="100%" height="100%" fill="${color}"/>
</svg>`;
const filename = path.resolve(nextPublicPath, getColorHref(name, variant));
await fs.writeFile(filename, svg, { encoding: 'utf8' });
}),
);
}
/**
* The goal is to have a preview of the actual color and the color string in IntelliSense
* We create for each color an svg that is filled with that color and reference
* that svg in the corresponding JSDoc.
* Since we use https://mui.com as a reference changes are only visible
* after release
*/
async function main() {
await Promise.all(
Object.entries(colors).map(async (entry) => {
const [name, variants] = entry;
await Promise.all([buildColorPreviews(name, variants), buildColorType(name, variants)]);
}),
);
}
main().catch((error) => {
console.error(error);
process.exit(error.code || 1);
});

View File

@@ -0,0 +1,621 @@
/**
* LLM Documentation Generator
*
* This script generates LLM-optimized documentation by processing MUI component markdown files
* and non-component documentation files to create comprehensive, standalone documentation.
*
* ## Main Workflow:
*
* 1. **Component Processing**:
* - Discovers all components using the API docs builder infrastructure
* - For each component, finds its markdown documentation and API JSON
* - Processes markdown by replacing `{{"demo": "filename.js"}}` syntax with actual code snippets
* - Appends API documentation (props, slots, CSS classes) to the markdown
* - Outputs to files like `material-ui/react-accordion.md`
*
* 2. **Non-Component Processing** (optional):
* - Processes markdown files from specified folders (e.g., `system`, `material/customization`)
* - Applies the same demo replacement logic
* - Uses URL transformation logic to maintain consistent paths with components
* - Outputs to files like `system/borders.md`, `material-ui/customization/color.md`
*
* 3. **Index Generation** (llms.txt):
* - Generates `llms.txt` index files for each top-level directory
* - Groups files by category (components, customization, getting-started, etc.)
* - Creates markdown-formatted lists with relative paths and descriptions
* - Outputs to files like `material-ui/llms.txt`, `system/llms.txt`
*
* ## Key Features:
*
* - **Demo Replacement**: Converts `{{"demo": "filename.js"}}` to actual JSX/TSX code snippets
* - **API Integration**: Automatically includes component API documentation (props, slots, CSS)
* - **Reusable**: Accepts project settings via CLI to work across different repositories
* - **Filtering**: Supports grep patterns to process specific components/files
* - **Path Consistency**: Uses existing URL transformation logic for consistent output structure
* - **Auto-indexing**: Generates llms.txt files with categorized documentation listings
*
* ## Usage Examples:
*
* ```bash
* # Process all Material UI components
* pnpm tsx scripts/buildLlmsDocs/index.ts --projectSettings ./packages/api-docs-builder-core/materialUi/projectSettings.ts
*
* # Process specific components with non-component docs
* pnpm tsx scripts/buildLlmsDocs/index.ts \
* --projectSettings ./packages/api-docs-builder-core/materialUi/projectSettings.ts \
* --nonComponentFolders system material/customization \
* --grep "Button|borders"
* ```
*
* ## Output Structure:
*
* - **Components**: `material-ui/react-{component}.md` (e.g., `material-ui/react-button.md`)
* - **Customization**: `material-ui/customization/{topic}.md` (e.g., `material-ui/customization/color.md`)
* - **Getting Started**: `material-ui/getting-started/{topic}.md` (e.g., `material-ui/getting-started/installation.md`)
* - **Index Files**: `{directory}/llms.txt` (e.g., `material-ui/llms.txt`, `system/llms.txt`)
*/
import * as fs from 'fs';
import * as path from 'path';
import { pathToFileURL } from 'node:url';
import yargs, { ArgumentsCamelCase } from 'yargs';
import { hideBin } from 'yargs/helpers';
import { kebabCase } from 'es-toolkit/string';
import { processMarkdownFile, processApiFile } from '@mui/internal-scripts/generate-llms-txt';
import { ComponentInfo, ProjectSettings } from '@mui-internal/api-docs-builder';
import { getHeaders } from '@mui/internal-markdown';
import findComponents from '@mui-internal/api-docs-builder/utils/findComponents';
import findPagesMarkdown from '@mui-internal/api-docs-builder/utils/findPagesMarkdown';
// Determine the host based on environment variables
let ORIGIN: string | undefined = 'https://mui.com';
if (process.env.CONTEXT === 'deploy-preview') {
// ref: https://docs.netlify.com/build/configure-builds/environment-variables/
ORIGIN = process.env.DEPLOY_PRIME_URL;
} else if (
process.env.CONTEXT === 'branch-deploy' &&
(process.env.HEAD === 'master' || process.env.HEAD === 'next' || process.env.HEAD?.match(/^v\d/))
) {
if (process.env.HEAD === 'master') {
ORIGIN = process.env.DEPLOY_PRIME_URL;
} else {
// https://next.mui.com, https://v6.mui.com, etc.
ORIGIN = `https://${process.env.HEAD.replace('.x', '')}.mui.com`;
}
}
interface ComponentDocInfo {
name: string;
componentInfo: ComponentInfo;
demos: Array<{ demoPageTitle: string; demoPathname: string }>;
markdownPath?: string;
apiJsonPath?: string;
}
interface GeneratedFile {
outputPath: string;
title: string;
description: string;
originalMarkdownPath: string;
category: string;
orderIndex?: number; // Track the order for non-component folders
}
type CommandOptions = {
grep?: string;
outputDir?: string;
projectSettings?: string;
};
/**
* Find all components using the API docs builder infrastructure
*/
async function findComponentsToProcess(
projectSettings: ProjectSettings,
grep: RegExp | null,
): Promise<ComponentDocInfo[]> {
const components: ComponentDocInfo[] = [];
// Iterate through TypeScript projects, using the same logic as buildApi.ts
for (const project of projectSettings.typeScriptProjects) {
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);
},
);
for (const component of projectComponents) {
// Get component info using the API docs builder
const componentInfo = projectSettings.getComponentInfo(component.filename);
// Skip if component should be skipped (internal, etc.)
const fileInfo = componentInfo.readFile();
if (fileInfo.shouldSkip) {
continue;
}
// Get demos for this component
const demos = componentInfo.getDemos();
// Skip if no demos found (likely not a public component)
if (demos.length === 0) {
continue;
}
// Get API JSON path
const apiJsonPath = path.join(
componentInfo.apiPagesDirectory,
`${path.basename(componentInfo.apiPathname)}.json`,
);
const primaryDemo = demos.find(
(demo) =>
demo.demoPathname.toLowerCase().includes(`/${kebabCase(componentInfo.name)}/`) ||
demo.demoPathname.toLowerCase().includes(`/react-${kebabCase(componentInfo.name)}/`),
);
const demoToUse = primaryDemo || demos[0];
const markdownPathToUse = demoToUse ? demoToUse.filePath : undefined;
components.push({
name: componentInfo.name,
componentInfo,
demos: primaryDemo ? [primaryDemo] : demos,
markdownPath: markdownPathToUse,
apiJsonPath: fs.existsSync(apiJsonPath) ? apiJsonPath : undefined,
});
}
}
return components;
}
/**
* Extract title and description from markdown content
*/
function extractMarkdownInfo(markdownPath: string): { title: string; description: string } {
try {
const content = fs.readFileSync(markdownPath, 'utf-8');
const headers = getHeaders(content);
// Get title from frontmatter or first h1
const title =
headers.title || content.match(/^# (.+)$/m)?.[1] || path.basename(markdownPath, '.md');
// Extract description from the first paragraph with class="description"
const descriptionMatch = content.match(/<p class="description">([^<]+)<\/p>/);
let description = '';
if (descriptionMatch) {
description = descriptionMatch[1].trim();
} else {
// Fallback: get first paragraph after title (excluding headers)
const paragraphMatch = content.match(/^# .+\n\n(?!#)(.+?)(?:\n\n|$)/m);
if (paragraphMatch && !paragraphMatch[1].startsWith('#')) {
description = paragraphMatch[1].trim();
}
}
return { title, description };
} catch (error) {
return {
title: path.basename(markdownPath, '.md'),
description: '',
};
}
}
/**
* Find all non-component markdown files from specified folders
*/
function findNonComponentMarkdownFiles(
projectSettings: ProjectSettings,
grep: RegExp | null,
): Array<{ markdownPath: string; outputPath: string }> {
if (!projectSettings.pagesManifestPath) {
return [];
}
const pagesContent = fs.readFileSync(projectSettings.pagesManifestPath, 'utf-8');
// Extract all pathname strings using regex
const pathnameRegex = /pathname:\s*['"`]([^'"`]+)['"`]/g;
const matches = Array.from(pagesContent.matchAll(pathnameRegex));
// Get all markdown files using the existing findPagesMarkdown utility
const allMarkdownFiles = findPagesMarkdown();
const files: Array<{ markdownPath: string; outputPath: string }> = [];
for (const match of matches) {
const pathname = match[1];
const parsedPathname = pathname
.replace('/material-ui/', '/material/')
.replace('/react-', '/components/');
// Apply grep filter if specified
if (grep) {
const fileName = path.basename(parsedPathname);
if (!grep.test(fileName) && !grep.test(parsedPathname)) {
continue;
}
}
const ignoredPaths = [
'/material-ui/experimental-api/',
'/material-ui/migration/migrating-to-pigment-css',
'/material-ui/about-the-lab',
];
// Filter out external links and special patterns
if (
pathname.startsWith('/material-ui/') &&
!ignoredPaths.some((ignored) => pathname.startsWith(ignored))
) {
// Match by filename basename to avoid pathname collisions when multiple files
// exist in the same directory (e.g., upgrade-to-v7.md and upgrade-to-native-color.md)
const lastSegment = parsedPathname.split('/').filter(Boolean).pop();
const page = allMarkdownFiles.find((p) => {
const fileBasename = path.basename(p.filename).replace(/\.mdx?$/, '');
// p.pathname already has the parent path (from findPagesMarkdown which strips the filename)
return fileBasename === lastSegment && p.pathname === parsedPathname;
});
if (page) {
files.push({
markdownPath: page.filename,
outputPath: `${pathname.startsWith('/') ? pathname.slice(1) : pathname}.md`,
});
}
}
}
return files;
}
/**
* Process a single component
*/
function processComponent(component: ComponentDocInfo): string | null {
// Processing component: ${component.name}
// Skip if no markdown file found
if (!component.markdownPath) {
console.error(`Warning: No markdown file found for component: ${component.name}`);
return null;
}
// Process the markdown file with demo replacement
let processedMarkdown = processMarkdownFile(component.markdownPath);
// Read the frontmatter to get all components listed in this markdown file
const markdownContent = fs.readFileSync(component.markdownPath, 'utf-8');
const headers = getHeaders(markdownContent);
const componentsInPage = headers.components || [];
// Add API sections for all components listed in the frontmatter
if (componentsInPage.length > 0) {
for (const componentName of componentsInPage) {
// Construct the API JSON path based on the project settings
const apiJsonPath = path.join(
component.componentInfo.apiPagesDirectory,
`${kebabCase(componentName)}.json`,
);
const apiMarkdown = processApiFile(apiJsonPath, { origin: ORIGIN });
processedMarkdown += `\n\n${apiMarkdown}`;
}
} else if (component.apiJsonPath) {
const apiMarkdown = processApiFile(component.apiJsonPath, { origin: ORIGIN });
processedMarkdown += `\n\n${apiMarkdown}`;
}
return processedMarkdown;
}
/**
* Convert kebab-case to Title Case
*/
function toTitleCase(kebabCaseStr: string): string {
return kebabCaseStr
.split('-')
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
}
/**
* Generate llms.txt content for a specific directory
*/
function generateLlmsTxt(
generatedFiles: GeneratedFile[],
projectName: string,
baseDir: string,
origin?: string,
): string {
// Group files by category
const groupedByCategory: Record<string, GeneratedFile[]> = {};
for (const file of generatedFiles) {
const category = file.category;
if (!groupedByCategory[category]) {
groupedByCategory[category] = [];
}
groupedByCategory[category].push(file);
}
// Generate content
let content = `# ${projectName}\n\n`;
content += `This is the documentation for the ${projectName} package.\n`;
content += `It contains comprehensive guides, components, and utilities for building user interfaces.\n\n`;
// Add sections for each category
// Sort categories to ensure components appear first, then by orderIndex for non-component folders
const sortedCategories = Object.keys(groupedByCategory).sort((a, b) => {
if (a === 'components') {
return -1;
}
if (b === 'components') {
return 1;
}
// For non-component categories, check if they have orderIndex
const filesA = groupedByCategory[a];
const filesB = groupedByCategory[b];
const orderIndexA = filesA[0]?.orderIndex ?? Number.MAX_SAFE_INTEGER;
const orderIndexB = filesB[0]?.orderIndex ?? Number.MAX_SAFE_INTEGER;
if (orderIndexA !== orderIndexB) {
return orderIndexA - orderIndexB;
}
return a.localeCompare(b);
});
for (const category of sortedCategories) {
const files = groupedByCategory[category];
if (files.length === 0) {
continue;
}
const sectionTitle = toTitleCase(category);
content += `## ${sectionTitle}\n\n`;
// Sort files by title (components) or maintain original order (non-components)
if (category === 'components') {
files.sort((a, b) => a.title.localeCompare(b.title));
}
// Non-component files are already in the order they were discovered
for (const file of files) {
// Calculate relative path from the baseDir to the file
const relativePath = file.outputPath.startsWith(`${baseDir}/`)
? `/${baseDir}/${file.outputPath.substring(baseDir.length + 1)}`
: `/${file.outputPath}`;
const url = origin ? new URL(relativePath, origin).href : relativePath;
content += `- [${file.title}](${url})`;
if (file.description) {
content += `: ${file.description}`;
}
content += '\n';
}
content += '\n';
}
return content.trim();
}
/**
* Main build function
*/
async function buildLlmsDocs(argv: ArgumentsCamelCase<CommandOptions>): Promise<void> {
const grep = argv.grep ? new RegExp(argv.grep) : null;
const outputDir = argv.outputDir || path.join(process.cwd(), 'docs/public');
// Load project settings from the specified path
if (!argv.projectSettings) {
throw new Error('--projectSettings is required');
}
let projectSettings: ProjectSettings;
try {
const settingsPath = path.resolve(argv.projectSettings);
const settingsUrl = pathToFileURL(settingsPath).href;
const settingsModule = await import(settingsUrl);
projectSettings = settingsModule.projectSettings || settingsModule.default || settingsModule;
} catch (error) {
throw new Error(`Failed to load project settings from ${argv.projectSettings}: ${error}`);
}
// Find all components
const components = await findComponentsToProcess(projectSettings, grep);
// Found ${components.length} components to process
// Find non-component markdown files if specified in project settings
const nonComponentFolders = (projectSettings as any).nonComponentFolders;
const nonComponentFiles = findNonComponentMarkdownFiles(projectSettings, grep);
// Track generated files for llms.txt
const generatedFiles: GeneratedFile[] = [];
const generatedComponentRecord: Record<string, boolean> = {};
// Process each component
let processedCount = 0;
for (const component of components) {
const processedMarkdown = processComponent(component);
if (!processedMarkdown) {
continue;
}
// Use the component's demo pathname to create the output structure
// e.g., /material-ui/react-accordion/ -> material-ui/react-accordion.md
const outputFileName = component.demos[0]
? `${component.demos[0].demoPathname.replace(/^\//, '').replace(/\/$/, '')}.md`
: `${component.componentInfo.apiPathname.replace(/^\//, '').replace(/\/$/, '')}.md`;
const outputPath = path.join(outputDir, outputFileName);
// Check if this file has already been generated (avoid duplicates for components that share the same markdown file)
const existingFile = generatedFiles.find((f) => f.outputPath === outputFileName);
if (!existingFile) {
// Ensure the directory exists
const outputDirPath = path.dirname(outputPath);
if (!fs.existsSync(outputDirPath)) {
fs.mkdirSync(outputDirPath, { recursive: true });
}
fs.writeFileSync(outputPath, processedMarkdown, 'utf-8');
// ✓ Generated: ${outputFileName}
processedCount += 1;
// Track this file for llms.txt
if (component.markdownPath) {
const { title, description } = extractMarkdownInfo(component.markdownPath);
generatedFiles.push({
outputPath: outputFileName,
title,
description,
originalMarkdownPath: component.markdownPath,
category: 'components',
});
generatedComponentRecord[outputFileName] = true;
}
}
}
// Process non-component markdown files
for (const file of nonComponentFiles) {
if (generatedComponentRecord[file.outputPath]) {
// Skip files that have already been generated as component docs
continue;
}
// Processing non-component file: ${path.relative(process.cwd(), file.markdownPath)}
// Process the markdown file with demo replacement
const processedMarkdown = processMarkdownFile(file.markdownPath);
const outputPath = path.join(outputDir, file.outputPath);
// Ensure the directory exists
const outputDirPath = path.dirname(outputPath);
if (!fs.existsSync(outputDirPath)) {
fs.mkdirSync(outputDirPath, { recursive: true });
}
fs.writeFileSync(outputPath, processedMarkdown, 'utf-8');
// ✓ Generated: ${file.outputPath}
processedCount += 1;
// Track this file for llms.txt
const { title, description } = extractMarkdownInfo(file.markdownPath);
// Extract category from the file path
// e.g., "material-ui/customization/color.md" -> "customization"
// e.g., "getting-started/installation.md" -> "getting-started"
const pathParts = file.outputPath.split('/');
const category = pathParts.reverse()[1];
// Find the order index based on which folder this file belongs to
let orderIndex = -1;
if (nonComponentFolders) {
for (let i = 0; i < nonComponentFolders.length; i += 1) {
if (file.markdownPath.includes(`/${nonComponentFolders[i]}/`)) {
orderIndex = i;
break;
}
}
}
generatedFiles.push({
outputPath: file.outputPath,
title,
description,
originalMarkdownPath: file.markdownPath,
category,
orderIndex,
});
}
// Generate llms.txt files
if (generatedFiles.length > 0) {
const groupedByFirstDir: Record<string, GeneratedFile[]> = {};
for (const file of generatedFiles) {
const firstDir = file.outputPath.split('/')[0];
if (!groupedByFirstDir[firstDir]) {
groupedByFirstDir[firstDir] = [];
}
groupedByFirstDir[firstDir].push(file);
}
for (const [dirName, files] of Object.entries(groupedByFirstDir)) {
let projectName;
if (dirName === 'material-ui') {
projectName = 'Material UI';
} else if (dirName === 'system') {
projectName = 'MUI System';
} else {
projectName = dirName.charAt(0).toUpperCase() + dirName.slice(1);
}
const llmsContent = generateLlmsTxt(files, projectName, dirName, ORIGIN)
.replace(/Ui/g, 'UI')
.replace(/Api/g, 'API');
const llmsPath = path.join(outputDir, dirName, 'llms.txt');
// Ensure directory exists
const llmsDirPath = path.dirname(llmsPath);
if (!fs.existsSync(llmsDirPath)) {
fs.mkdirSync(llmsDirPath, { recursive: true });
}
fs.writeFileSync(llmsPath, llmsContent, 'utf-8');
// ✓ Generated: ${dirName}/llms.txt
processedCount += 1;
}
}
// eslint-disable-next-line no-console
console.log(`\nCompleted! Generated ${processedCount} files in ${outputDir}`);
}
/**
* CLI setup
*/
yargs()
.command({
command: '$0',
describe: 'Generates LLM-optimized documentation for MUI components.',
builder: (command: any) => {
return command
.option('grep', {
description:
'Only generate files for components matching the pattern. The string is treated as a RegExp.',
type: 'string',
})
.option('outputDir', {
description: 'Output directory for generated markdown files.',
type: 'string',
default: './docs/public',
})
.option('projectSettings', {
description:
'Path to the project settings module that exports ProjectSettings interface.',
type: 'string',
demandOption: true,
});
},
handler: buildLlmsDocs,
})
.help()
.strict(true)
.version(false)
.parse(hideBin(process.argv));

View File

@@ -0,0 +1,10 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "./build",
"allowJs": false,
"noEmit": false
},
"include": ["./index.ts"],
"exclude": []
}

252
scripts/canaryRelease.mts Normal file
View File

@@ -0,0 +1,252 @@
/* eslint-disable prefer-template */
/* eslint-disable no-console */
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { readFile, writeFile, appendFile } from 'node:fs/promises';
import * as readline from 'node:readline/promises';
import yargs from 'yargs';
import { hideBin } from 'yargs/helpers';
import { $ } from 'execa';
import chalk from 'chalk';
const $$ = $({ stdio: 'inherit' });
const currentDirectory = dirname(fileURLToPath(import.meta.url));
const workspaceRoot = resolve(currentDirectory, '..');
interface PackageInfo {
name: string;
path: string;
version: string;
private: boolean;
}
interface RunOptions {
accessToken?: string;
baseline?: string;
dryRun: boolean;
skipLastCommitComparison: boolean;
yes: boolean;
ignore: string[];
}
async function run({
dryRun,
accessToken,
baseline,
skipLastCommitComparison,
yes,
ignore,
}: RunOptions) {
await ensureCleanWorkingDirectory();
const changedPackages = await getChangedPackages(baseline, skipLastCommitComparison, ignore);
if (changedPackages.length === 0) {
return;
}
await confirmPublishing(changedPackages, yes);
try {
await setAccessToken(accessToken);
await setVersion(changedPackages);
await buildPackages();
await publishPackages(changedPackages, dryRun);
} finally {
await cleanUp();
}
}
async function ensureCleanWorkingDirectory() {
try {
await $`git diff --quiet`;
await $`git diff --quiet --cached`;
} catch (error) {
console.error('❌ Working directory is not clean.');
process.exit(1);
}
}
async function listPublicChangedPackages(baseline: string) {
const { stdout: packagesJson } =
await $`pnpm list --recursive --filter ...[${baseline}] --depth -1 --only-projects --json`;
const packages = JSON.parse(packagesJson) as PackageInfo[];
return packages.filter((pkg) => !pkg.private);
}
async function getChangedPackages(
baseline: string | undefined,
skipLastCommitComparison: boolean,
ignore: string[],
): Promise<PackageInfo[]> {
if (!skipLastCommitComparison) {
const publicPackagesUpdatedInLastCommit = await listPublicChangedPackages('HEAD~1');
if (publicPackagesUpdatedInLastCommit.length === 0) {
console.log('No public packages changed in the last commit.');
return [];
}
}
if (!baseline) {
const { stdout: latestTag } = await $`git describe --abbrev=0`;
baseline = latestTag;
}
console.log(`Looking for changed public packages since ${chalk.yellow(baseline)}...`);
const changedPackages = (await listPublicChangedPackages(baseline)).filter(
(p) => !ignore.includes(p.name),
);
if (changedPackages.length === 0) {
console.log('Nothing found.');
}
return changedPackages;
}
async function confirmPublishing(changedPackages: PackageInfo[], yes: boolean) {
if (!yes) {
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
console.log('\nFound changes in the following packages:');
for (const pkg of changedPackages) {
console.log(` - ${pkg.name}`);
}
console.log('\nThis will publish the above packages to the npm registry.');
const answer = await rl.question('Do you want to proceed? (y/n) ');
rl.close();
if (answer.toLowerCase() !== 'y') {
console.log('Aborted.');
process.exit(0);
}
}
}
async function setAccessToken(npmAccessToken: string | undefined) {
if (!npmAccessToken && !process.env.NPM_TOKEN) {
console.error(
'❌ NPM access token is required. Either pass it as an --access-token argument or set it as an NPM_TOKEN environment variable.',
);
process.exit(1);
}
const npmrcPath = resolve(workspaceRoot, '.npmrc');
await appendFile(
npmrcPath,
`//registry.npmjs.org/:_authToken=${npmAccessToken ?? process.env.NPM_TOKEN}\n`,
);
}
async function setVersion(packages: PackageInfo[]) {
const { stdout: currentRevisionSha } = await $`git rev-parse --short HEAD`;
const { stdout: commitTimestamp } = await $`git show --no-patch --format=%ct HEAD`;
const timestamp = formatDate(new Date(+commitTimestamp * 1000));
let hasError = false;
const tasks = packages.map(async (pkg) => {
const packageJsonPath = resolve(pkg.path, './package.json');
try {
const packageJson = JSON.parse(await readFile(packageJsonPath, { encoding: 'utf8' }));
packageJson.version = `${packageJson.version}-dev.${timestamp}-${currentRevisionSha}`;
await writeFile(packageJsonPath, JSON.stringify(packageJson, null, 2) + '\n');
} catch (error) {
console.error(`${chalk.red(`${packageJsonPath}`)}`, error);
hasError = true;
}
});
await Promise.allSettled(tasks);
if (hasError) {
throw new Error('Failed to update package versions');
}
}
function formatDate(date: Date) {
// yyyyMMdd-HHmmss
return date
.toISOString()
.replace(/[-:Z.]/g, '')
.replace('T', '-')
.slice(0, 15);
}
function buildPackages() {
if (process.env.CI) {
return $$`pnpm build:public:ci`;
}
return $$`pnpm build:public`;
}
async function publishPackages(packages: PackageInfo[], dryRun: boolean) {
console.log(`\nPublishing packages${dryRun ? ' (dry run)' : ''}`);
const tasks = packages.map(async (pkg) => {
try {
const args = [pkg.path, '--tag', 'canary', '--no-git-checks'];
if (dryRun) {
args.push('--dry-run');
}
await $$`pnpm publish ${args}`;
} catch (error: any) {
console.error(chalk.red(`${pkg.name}`), error.shortMessage);
}
});
await Promise.allSettled(tasks);
}
async function cleanUp() {
await $`git restore .`;
}
yargs(hideBin(process.argv))
.command<RunOptions>(
'$0',
'Publishes packages that have changed since the last release (or a specified commit).',
(command) => {
return command
.option('dryRun', {
default: false,
describe: 'If true, no packages will be published to the registry.',
type: 'boolean',
})
.option('accessToken', {
describe: 'NPM access token',
type: 'string',
})
.option('baseline', {
describe: 'Baseline tag or commit to compare against (for example `master`).',
type: 'string',
})
.option('skipLastCommitComparison', {
default: false,
describe:
'By default, the script exits when there are no changes in public packages in the latest commit. Setting this flag will skip this check and compare only against the baseline.',
type: 'boolean',
})
.option('yes', {
default: false,
describe: "If set, the script doesn't ask for confirmation before publishing packages",
type: 'boolean',
})
.option('ignore', {
describe: 'List of packages to ignore',
type: 'string',
array: true,
default: [],
});
},
run,
)
.help()
.strict(true)
.version(false)
.parse();

View File

@@ -0,0 +1,24 @@
import path from 'path';
export default {
material: {
rootPath: path.join(process.cwd(), 'packages/mui-material'),
entryPointPath: 'src/index.d.ts',
},
lab: {
rootPath: path.join(process.cwd(), 'packages/mui-lab'),
entryPointPath: 'src/index.d.ts',
},
joy: {
rootPath: path.join(process.cwd(), 'packages/mui-joy'),
entryPointPath: 'src/index.ts',
},
system: {
rootPath: path.join(process.cwd(), 'packages/mui-system'),
entryPointPath: 'src/index.d.ts',
},
docs: {
rootPath: path.join(process.cwd(), 'docs'),
tsConfigPath: 'tsconfig.json',
},
};

View File

@@ -0,0 +1,111 @@
import type * as dangerModule from 'danger';
import replaceUrl from '@mui-internal/api-docs-builder/utils/replaceUrl';
declare const danger: (typeof dangerModule)['danger'];
declare const markdown: (typeof dangerModule)['markdown'];
const circleCIBuildNumber = process.env.CIRCLE_BUILD_NUM;
const circleCIBuildUrl = `https://app.circleci.com/pipelines/github/mui/material-ui/jobs/${circleCIBuildNumber}`;
const dangerCommand = process.env.DANGER_COMMAND;
function prepareBundleSizeReport() {
markdown(
`## Bundle size report
Bundle size will be reported once [CircleCI build #${circleCIBuildNumber}](${circleCIBuildUrl}) finishes.`,
);
}
// These functions are no longer needed as they've been moved to the prSizeDiff.js module
async function reportBundleSize() {
let markdownContent = `## Bundle size report\n\n`;
if (!process.env.CIRCLE_BUILD_NUM) {
throw new Error('CIRCLE_BUILD_NUM is not defined');
}
const circleciBuildNumber = process.env.CIRCLE_BUILD_NUM;
const { renderMarkdownReport } = await import('@mui/internal-bundle-size-checker');
markdownContent += await renderMarkdownReport(danger.github.pr, {
track: ['@mui/material', '@mui/lab', '@mui/system', '@mui/utils'],
circleciBuildNumber,
});
// Use the markdown function to publish the report
markdown(markdownContent);
}
function addDeployPreviewUrls() {
/**
* The incoming path from danger does not start with `/`
* e.g. ['docs/data/joy/components/button/button.md']
*/
function formatFileToLink(path: string) {
let url = path.replace('docs/data', '').replace(/\.md$/, '');
const fragments = url.split('/').reverse();
if (fragments[0] === fragments[1]) {
// check if the end of pathname is the same as the one before
// for example `/data/material/getting-started/overview/overview.md
url = fragments.slice(1).reverse().join('/');
}
if (url.startsWith('/material')) {
// needs to convert to correct material legacy folder structure to the existing url.
url = replaceUrl(url.replace('/material', ''), '/material-ui').replace(/^\//, '');
} else {
url = url
.replace(/^\//, '') // remove initial `/`
.replace('joy/', 'joy-ui/')
.replace('components/', 'react-');
}
return url;
}
const netlifyPreview = `https://deploy-preview-${danger.github.pr.number}--material-ui.netlify.app/`;
const files = [...danger.git.created_files, ...danger.git.modified_files];
// limit to the first 5 docs
const docs = files
.filter((file) => file.startsWith('docs/data') && file.endsWith('.md'))
.slice(0, 5);
markdown(`
## Netlify deploy preview
${
docs.length
? docs
.map((path) => {
const formattedUrl = formatFileToLink(path);
return `- [${path}](${netlifyPreview}${formattedUrl})`;
})
.join('\n')
: netlifyPreview
}
`);
}
async function run() {
addDeployPreviewUrls();
switch (dangerCommand) {
case 'prepareBundleSizeReport':
prepareBundleSizeReport();
break;
case 'reportBundleSize':
await reportBundleSize();
break;
default:
throw new TypeError(`Unrecognized danger command '${dangerCommand}'`);
}
}
run().catch((error) => {
console.error(error);
process.exit(1);
});

View File

@@ -0,0 +1,349 @@
/* eslint-disable no-console */
import * as fs from 'fs';
import * as path from 'path';
import * as url from 'url';
const componentAreas = {
accordion: 'surfaces',
accordionactions: 'surfaces',
accordiondetails: 'surfaces',
accordionsummary: 'surfaces',
alert: 'feedback',
alerttitle: 'feedback',
appbar: 'surfaces',
aspectratio: 'dataDisplay',
autocomplete: 'inputs',
avatar: 'dataDisplay',
avatargroup: 'dataDisplay',
backdrop: 'feedback',
badge: 'dataDisplay',
bottomnavigation: 'navigation',
bottomnavigationaction: 'navigation',
box: 'layout',
breadcrumbs: 'navigation',
button: 'inputs',
buttonbase: 'inputs',
buttongroup: 'inputs',
card: 'surfaces',
cardactionarea: 'surfaces',
cardactions: 'surfaces',
cardcontent: 'surfaces',
cardcover: 'surfaces',
cardheader: 'surfaces',
cardmedia: 'surfaces',
cardoverflow: 'surfaces',
checkbox: 'inputs',
chip: 'dataDisplay',
chipdelete: 'dataDisplay',
circularprogress: 'feedback',
clickawaylistener: 'utils',
collapse: 'utils',
container: 'layout',
cssbaseline: 'utils',
dialog: 'feedback',
dialogactions: 'feedback',
dialogcontent: 'feedback',
dialogcontenttext: 'feedback',
dialogtitle: 'feedback',
divider: 'dataDisplay',
drawer: 'navigation',
fab: 'inputs',
fade: 'utils',
filledinput: 'inputs',
formcontrol: 'inputs',
formcontrollabel: 'inputs',
formgroup: 'inputs',
formhelpertext: 'inputs',
formlabel: 'inputs',
globalstyles: 'utils',
gridlegacy: 'layout',
grid: 'layout',
grow: 'utils',
hidden: 'layout',
icon: 'dataDisplay',
iconbutton: 'inputs',
imagelist: 'layout',
imagelistitem: 'layout',
imagelistitembar: 'layout',
input: 'inputs',
inputadornment: 'inputs',
inputbase: 'inputs',
inputlabel: 'inputs',
linearprogress: 'feedback',
link: 'navigation',
list: 'dataDisplay',
listbox: 'utils',
listdivider: 'dataDisplay',
listitem: 'dataDisplay',
listitemavatar: 'dataDisplay',
listitembutton: 'dataDisplay',
listitemcontent: 'dataDisplay',
listitemdecorator: 'dataDisplay',
listitemicon: 'dataDisplay',
listitemsecondaryaction: 'dataDisplay',
listitemtext: 'dataDisplay',
listsubheader: 'dataDisplay',
masonry: 'layout',
mediaquery: 'utils',
menu: 'navigation',
menuitem: 'navigation',
menulist: 'navigation',
mobilestepper: 'navigation',
modal: 'utils',
multiselect: 'inputs',
nativeselect: 'inputs',
nossr: 'utils',
option: 'inputs',
optiongroup: 'inputs',
outlinedinput: 'inputs',
pagination: 'navigation',
paginationitem: 'navigation',
paper: 'surfaces',
popover: 'utils',
popper: 'utils',
portal: 'utils',
progress: 'feedback',
radio: 'inputs',
radiogroup: 'inputs',
rating: 'inputs',
scopedcssbaseline: 'utils',
scrolltrigger: 'surfaces',
select: 'inputs',
sheet: 'surfaces',
skeleton: 'feedback',
slide: 'utils',
slider: 'inputs',
snackbar: 'feedback',
snackbarcontent: 'feedback',
speeddial: 'navigation',
speeddialaction: 'navigation',
speeddialicon: 'navigation',
stack: 'layout',
step: 'navigation',
stepbutton: 'navigation',
stepconnector: 'navigation',
stepcontent: 'navigation',
stepicon: 'navigation',
steplabel: 'navigation',
stepper: 'navigation',
svgicon: 'dataDisplay',
swipeabledrawer: 'navigation',
switch: 'inputs',
tab: 'navigation',
table: 'dataDisplay',
tablebody: 'dataDisplay',
tablecell: 'dataDisplay',
tablecontainer: 'dataDisplay',
tablefooter: 'dataDisplay',
tablehead: 'dataDisplay',
tablepagination: 'dataDisplay',
tablerow: 'dataDisplay',
tablesortlabel: 'dataDisplay',
tablist: 'navigation',
tabpanel: 'navigation',
tabs: 'navigation',
tabscrollbutton: 'navigation',
tabslist: 'navigation',
textarea: 'inputs',
textareaautosize: 'utils',
textfield: 'inputs',
timeline: 'dataDisplay',
togglebutton: 'inputs',
togglebuttongroup: 'inputs',
toolbar: 'surfaces',
tooltip: 'dataDisplay',
touchripple: 'inputs',
transferlist: 'inputs',
transitions: 'utils',
focustrap: 'utils',
treeview: 'dataDisplay',
typography: 'dataDisplay',
zoom: 'utils',
};
const areaMaintainers = {
inputs: ['michaldudak', 'mnajdova'],
dataDisplay: ['siriwatknp', 'michaldudak'],
feedback: ['siriwatknp', 'hbjORbj'],
surfaces: ['siriwatknp', 'hbjORbj'],
navigation: ['mnajdova', 'michaldudak'],
layout: ['siriwatknp', 'hbjORbj'],
utils: ['mnajdova', 'michaldudak'],
};
const packageOwners = {
base: ['michaldudak'],
joy: ['siriwatknp'],
material: ['mnajdova'],
};
const packageMaintainers = {
base: ['michaldudak', 'mnajdova'],
'icons-material': ['michaldudak', 'siriwatknp'],
joy: ['siriwatknp', 'danilo-leal'],
material: ['mnajdova', 'danilo-leal'],
system: ['mnajdova', 'siriwatknp'],
};
const additionalRules = {
'/scripts/': ['michaldudak', 'm4theushw'],
};
const thisDirectory = url.fileURLToPath(new URL('.', import.meta.url));
const buffer = [];
function write(text) {
buffer.push(text);
}
function save() {
const fileContents = [...buffer, ''].join('\n');
fs.writeFileSync(path.join(thisDirectory, '../.github/CODEOWNERS'), fileContents);
}
function findComponentArea(componentName) {
// TODO: could make it smarter to reduce the number of entries in componentAreas
// for example, "AccordionActions" could look up "Accordion"
return componentAreas[componentName];
}
function normalizeComponentName(componentName) {
// remove the "use" and "Unstable_" prefixes and "Unstyled" suffix
return componentName.replace(/^(use|Unstable_)?(.*?)(Unstyled)?$/gm, '$2').toLowerCase();
}
function normalizeDocsComponentName(componentName) {
switch (componentName) {
case 'breadcrumbs':
case 'progress':
case 'transitions':
return componentName;
case 'badges':
return 'badge';
case 'floating-action-button':
return 'fab';
case 'focus-trap':
return 'focustrap';
case 'radio-buttons':
return 'radio';
case 'tables':
return 'table';
default:
// remove the "use" and "Unstable" prefixes and remove the trailing "s" or "es" to make a singular form
return componentName
.replace(/^(use|Unstable)?(.*?)(es|s)?$/gm, '$2')
.replace(/-/g, '')
.toLowerCase();
}
}
function getCodeowners(mapping) {
return Object.entries(mapping)
.map(([directory, maintainers]) => `${directory} @${maintainers.join(' @')}`)
.join('\n');
}
function getAreaMaintainers(area, packageName) {
return Array.from(
new Set([
...areaMaintainers[area],
// Material UI package owner is not added to individual components' owners
// to reduce the number of PRs they'll get to review.
...(packageName === 'material' ? [] : packageOwners[packageName]),
]),
)
.map((name) => `@${name}`)
.join(' ');
}
function processComponents(packageName) {
const componentsDirectory = path.join(thisDirectory, `../packages/mui-${packageName}/src`);
const componentDirectories = fs.readdirSync(componentsDirectory);
const result = [];
for (const componentDirectory of componentDirectories) {
if (!fs.statSync(path.join(componentsDirectory, componentDirectory)).isDirectory()) {
continue;
}
const componentName = normalizeComponentName(componentDirectory);
const componentArea = findComponentArea(componentName);
if (componentArea) {
const maintainers = getAreaMaintainers(componentArea, packageName);
const codeowners = `/packages/mui-${packageName}/src/${componentDirectory}/ ${maintainers}`;
result.push(codeowners);
} else {
console.info(`No explicit owner defined for "${componentDirectory}" in ${packageName}.`);
}
}
return result.join('\n');
}
function processDocs(packageName) {
const docsDirectory = path.join(thisDirectory, `../docs/data/${packageName}/components`);
const componentDirectories = fs.readdirSync(docsDirectory);
const result = [];
for (const componentDirectory of componentDirectories) {
if (!fs.statSync(path.join(docsDirectory, componentDirectory)).isDirectory()) {
continue;
}
const componentName = normalizeDocsComponentName(componentDirectory);
const componentArea = findComponentArea(componentName);
if (componentArea) {
const maintainers = getAreaMaintainers(componentArea, packageName);
const codeowners = `/docs/data/${packageName}/components/${componentDirectory}/ ${maintainers}`;
result.push(codeowners);
} else {
console.info(
`No explicit owner defined for docs of "${componentDirectory}" in ${packageName}.`,
);
}
}
return result.join('\n');
}
function processPackages() {
return Object.entries(packageMaintainers)
.map(([packageName, maintainers]) => `/packages/mui-${packageName}/ @${maintainers.join(' @')}`)
.join('\n');
}
function run() {
write('# This file is auto-generated, do not modify it manually.');
write('# run `pnpm generate-codeowners` to update it.\n\n');
write(getCodeowners(additionalRules));
write('\n# Packages\n');
write(processPackages());
write('\n# Components - Material UI\n');
write(processComponents('material'));
write(processDocs('material'));
write('\n# Components - Base UI\n');
write(processComponents('base'));
write(processDocs('base'));
write('\n# Components - Joy UI\n');
write(processComponents('joy'));
write(processDocs('joy'));
save();
}
run();

View File

@@ -0,0 +1,392 @@
/* eslint-disable no-console */
import * as path from 'path';
import * as fs from 'node:fs/promises';
import * as prettier from 'prettier';
import glob from 'fast-glob';
import { flatten } from 'es-toolkit/array';
import yargs from 'yargs';
import { hideBin } from 'yargs/helpers';
import type { LiteralType } from '@mui/internal-scripts/typescript-to-proptypes';
import {
fixBabelGeneratorIssues,
fixLineEndings,
getUnstyledFilename,
} from '@mui/internal-docs-utils';
import {
getPropTypesFromFile,
injectPropTypesInFile,
InjectPropTypesInFileOptions,
} from '@mui/internal-scripts/typescript-to-proptypes';
import {
createTypeScriptProjectBuilder,
TypeScriptProject,
} from '@mui-internal/api-docs-builder/utils/createTypeScriptProject';
import CORE_TYPESCRIPT_PROJECTS from './coreTypeScriptProjects';
const useExternalPropsFromInputBase = [
'autoComplete',
'autoFocus',
'color',
'defaultValue',
'disabled',
'endAdornment',
'error',
'id',
'inputProps',
'inputRef',
'margin',
'maxRows',
'minRows',
'name',
'onChange',
'placeholder',
'readOnly',
'required',
'rows',
'startAdornment',
'value',
];
/**
* A map of components and their props that should be documented
* but are not used directly in their implementation.
*
* TODO: In the future we want to remove them from the API docs in favor
* of dynamically loading them. At that point this list should be removed.
* TODO: typecheck values
*/
const useExternalDocumentation: Record<string, '*' | readonly string[]> = {
Button: ['disableRipple'],
Box: ['component', 'sx'],
// `classes` is always external since it is applied from a HOC
// In DialogContentText we pass it through
// Therefore it's considered "unused" in the actual component but we still want to document it.
DialogContentText: ['classes'],
FilledInput: useExternalPropsFromInputBase,
IconButton: ['disableRipple'],
Input: useExternalPropsFromInputBase,
MenuItem: ['dense'],
OutlinedInput: useExternalPropsFromInputBase,
Radio: ['disableRipple', 'id', 'inputProps', 'inputRef', 'required'],
Checkbox: ['defaultChecked'],
Container: ['component'],
Stack: ['component'],
Switch: [
'checked',
'defaultChecked',
'disabled',
'disableRipple',
'edge',
'id',
'inputProps',
'inputRef',
'onChange',
'required',
'value',
],
SwipeableDrawer: [
'anchor',
'hideBackdrop',
'ModalProps',
'PaperProps',
'transitionDuration',
'variant',
],
Tab: ['disableRipple'],
TextField: ['margin'],
ToggleButton: ['disableRipple'],
};
const transitionCallbacks = [
'onEnter',
'onEntered',
'onEntering',
'onExit',
'onExiting',
'onExited',
];
/**
* These are components that use props implemented by external components.
* Those props have their own JSDoc which we don't want to emit in our docs
* but do want them to have JSDoc in IntelliSense
* TODO: In the future we want to ignore external docs on the initial load anyway
* since they will be fetched dynamically.
*/
const ignoreExternalDocumentation: Record<string, readonly string[]> = {
Button: ['focusVisibleClassName', 'type'],
Collapse: transitionCallbacks,
CardActionArea: ['focusVisibleClassName'],
AccordionSummary: ['onFocusVisible'],
Dialog: ['BackdropProps'],
Drawer: ['BackdropProps'],
Fab: ['focusVisibleClassName'],
Fade: transitionCallbacks,
Grow: transitionCallbacks,
ListItem: ['focusVisibleClassName'],
InputBase: ['aria-describedby'],
Menu: ['PaperProps'],
MenuItem: ['disabled'],
Slide: transitionCallbacks,
SwipeableDrawer: ['anchor', 'hideBackdrop', 'ModalProps', 'PaperProps', 'variant'],
TextField: ['hiddenLabel'],
Zoom: transitionCallbacks,
};
function sortSizeByScaleAscending(a: LiteralType, b: LiteralType) {
const sizeOrder: readonly unknown[] = ['"small"', '"medium"', '"large"'];
return sizeOrder.indexOf(a.value) - sizeOrder.indexOf(b.value);
}
// Custom order of literal unions by component
const getSortLiteralUnions: InjectPropTypesInFileOptions['getSortLiteralUnions'] = (
component,
propType,
) => {
if (propType.name === 'size') {
return sortSizeByScaleAscending;
}
return undefined;
};
async function generateProptypes(
project: TypeScriptProject,
sourceFile: string,
tsFile: string,
): Promise<void> {
const components = getPropTypesFromFile({
filePath: tsFile,
project,
shouldResolveObject: ({ name }) => {
if (
name.toLowerCase().endsWith('classes') ||
name === 'theme' ||
name === 'ownerState' ||
(name.endsWith('Props') && name !== 'componentsProps' && name !== 'slotProps')
) {
return false;
}
return undefined;
},
checkDeclarations: true,
});
if (components.length === 0) {
return;
}
// exclude internal slot components, for example ButtonRoot
const cleanComponents = components.filter((component) => {
if (component.propsFilename?.endsWith('.tsx')) {
// only check for .tsx
const match = component.propsFilename.match(/.*\/([A-Z][a-zA-Z]+)\.tsx/);
if (match) {
return component.name === match[1];
}
}
return true;
});
cleanComponents.forEach((component) => {
component.types.forEach((prop) => {
if (
!prop.jsDoc ||
(project.name !== 'base' &&
ignoreExternalDocumentation[component.name] &&
ignoreExternalDocumentation[component.name].includes(prop.name))
) {
prop.jsDoc = '@ignore';
}
});
});
const sourceContent = await fs.readFile(sourceFile, 'utf8');
const isTsFile = /(\.(ts|tsx))/.test(sourceFile);
// If the component inherits the props from some unstyled components
// we don't want to add those propTypes again in the Material UI/Joy UI propTypes
const unstyledFile = getUnstyledFilename(tsFile, true);
const unstyledPropsFile = unstyledFile.replace('.d.ts', '.types.ts');
// TODO remove, should only have .types.ts
const propsFile = tsFile.replace(/(\.d\.ts|\.tsx|\.ts)/g, 'Props.ts');
const propsFileAlternative = tsFile.replace(/(\.d\.ts|\.tsx|\.ts)/g, '.types.ts');
const generatedForTypeScriptFile = sourceFile === tsFile;
const result = injectPropTypesInFile({
components,
target: sourceContent,
options: {
disablePropTypesTypeChecking: generatedForTypeScriptFile,
babelOptions: {
filename: sourceFile,
},
comment: [
'┌────────────────────────────── Warning ──────────────────────────────┐',
'│ These PropTypes are generated from the TypeScript type definitions. │',
isTsFile
? '│ To update them, edit the TypeScript types and run `pnpm proptypes`. │'
: '│ To update them, edit the d.ts file and run `pnpm proptypes`. │',
'└─────────────────────────────────────────────────────────────────────┘',
].join('\n'),
ensureBabelPluginTransformReactRemovePropTypesIntegration: true,
getSortLiteralUnions,
reconcilePropTypes: (prop, previous, generated) => {
const usedCustomValidator = previous !== undefined && !previous.startsWith('PropTypes');
const ignoreGenerated =
previous !== undefined &&
previous.startsWith('PropTypes /* @typescript-to-proptypes-ignore */');
if (
ignoreGenerated &&
// `ignoreGenerated` implies that `previous !== undefined`
previous!
.replace('PropTypes /* @typescript-to-proptypes-ignore */', 'PropTypes')
.replace(/\s/g, '') === generated.replace(/\s/g, '')
) {
throw new Error(
`Unused \`@typescript-to-proptypes-ignore\` directive for prop '${prop.name}'.`,
);
}
if (usedCustomValidator || ignoreGenerated) {
// `usedCustomValidator` and `ignoreGenerated` narrow `previous` to `string`
return previous!;
}
return generated;
},
shouldInclude: ({ component, prop }) => {
if (prop.name === 'children') {
return true;
}
let shouldDocument;
const { name: componentName } = component;
prop.filenames.forEach((filename) => {
const isExternal = filename !== tsFile;
const implementedByUnstyledVariant =
filename === unstyledFile || filename === unstyledPropsFile;
const implementedBySelfPropsFile =
filename === propsFile || filename === propsFileAlternative;
if (!isExternal || implementedByUnstyledVariant || implementedBySelfPropsFile) {
shouldDocument = true;
}
});
if (
useExternalDocumentation[componentName] &&
(useExternalDocumentation[componentName] === '*' ||
useExternalDocumentation[componentName].includes(prop.name))
) {
shouldDocument = true;
}
return shouldDocument;
},
},
});
if (!result) {
throw new Error('Unable to produce inject propTypes into code.');
}
const prettierConfig = await prettier.resolveConfig(process.cwd(), {
config: path.join(__dirname, '../prettier.config.mjs'),
});
const prettified = await prettier.format(result, { ...prettierConfig, filepath: sourceFile });
const formatted = fixBabelGeneratorIssues(prettified);
const correctedLineEndings = fixLineEndings(sourceContent, formatted);
await fs.writeFile(sourceFile, correctedLineEndings);
}
interface HandlerArgv {
pattern: string;
}
async function run(argv: HandlerArgv) {
const { pattern } = argv;
const filePattern = new RegExp(pattern);
if (pattern.length > 0) {
console.log(`Only considering declaration files matching ${filePattern}`);
}
const buildProject = createTypeScriptProjectBuilder(CORE_TYPESCRIPT_PROJECTS);
// Matches files where the folder and file both start with uppercase letters
// Example: AppBar/AppBar.d.ts
const allFiles = await Promise.all(
[
path.resolve(__dirname, '../packages/mui-system/src'),
path.resolve(__dirname, '../packages/mui-base/src'),
path.resolve(__dirname, '../packages/mui-material/src'),
path.resolve(__dirname, '../packages/mui-lab/src'),
path.resolve(__dirname, '../packages/mui-joy/src'),
].map((folderPath) =>
glob('+([A-Z])*/+([A-Z])*.*@(d.ts|ts|tsx)', {
absolute: true,
cwd: folderPath,
}),
),
);
const files = flatten(allFiles)
.filter((filePath) => {
// Filter out files where the directory name and filename doesn't match
// Example: Modal/ModalManager.d.ts
let folderName = path.basename(path.dirname(filePath));
const fileName = path.basename(filePath).replace(/(\.d\.ts|\.tsx|\.ts)/g, '');
// An exception is if the folder name starts with Unstable_/unstable_
// Example: Unstable_Grid2/Grid2.tsx
if (/(u|U)nstable_/g.test(folderName)) {
folderName = folderName.slice(9);
}
return fileName === folderName;
})
.filter((filePath) => filePattern.test(filePath));
const promises = files.map<Promise<void>>(async (tsFile) => {
const sourceFile = tsFile.includes('.d.ts') ? tsFile.replace('.d.ts', '.js') : tsFile;
try {
const projectName = tsFile.match(/packages\/mui-([a-zA-Z-]+)\/src/)![1];
const project = buildProject(projectName);
await generateProptypes(project, sourceFile, tsFile);
} catch (error: any) {
error.message = `${tsFile}: ${error.message}`;
throw error;
}
});
const results = await Promise.allSettled(promises);
const fails = results.filter((result): result is PromiseRejectedResult => {
return result.status === 'rejected';
});
fails.forEach((result) => {
console.error(result.reason);
});
if (fails.length > 0) {
process.exit(1);
}
}
yargs()
.command<HandlerArgv>({
command: '$0',
describe: 'Generates Component.propTypes from TypeScript declarations',
builder: (command) => {
return command.option('pattern', {
default: '',
describe: 'Only considers declaration files matching this pattern.',
type: 'string',
});
},
handler: run,
})
.help()
.strict(true)
.version(false)
.parse(hideBin(process.argv));

142
scripts/react-next.diff Normal file
View File

@@ -0,0 +1,142 @@
diff --git a/packages/material-ui-lab/src/Autocomplete/Autocomplete.test.js b/packages/material-ui-lab/src/Autocomplete/Autocomplete.test.js
index 2f3ea31dc2..4ad337e85a 100644
--- a/packages/material-ui-lab/src/Autocomplete/Autocomplete.test.js
+++ b/packages/material-ui-lab/src/Autocomplete/Autocomplete.test.js
@@ -1018,7 +1018,7 @@ describe('<Autocomplete />', () => {
fireEvent.keyDown(textbox, { key: 'Enter' });
expect(handleChange.callCount).to.equal(1);
expect(handleChange.args[0][1]).to.equal('a');
- expect(consoleErrorMock.callCount()).to.equal(4); // strict mode renders twice
+ expect(consoleErrorMock.callCount()).to.equal(3);
expect(consoleErrorMock.messages()[0]).to.include(
'Material UI: The `getOptionLabel` method of Autocomplete returned undefined instead of a string',
);
@@ -1070,7 +1070,7 @@ describe('<Autocomplete />', () => {
/>,
);
- expect(consoleWarnMock.callCount()).to.equal(4); // strict mode renders twice
+ expect(consoleWarnMock.callCount()).to.equal(2);
expect(consoleWarnMock.messages()[0]).to.include(
'None of the options match with `"not a good value"`',
);
@@ -1099,7 +1099,7 @@ describe('<Autocomplete />', () => {
const options = getAllByRole('option').map((el) => el.textContent);
expect(options).to.have.length(7);
expect(options).to.deep.equal(['A', 'D', 'E', 'B', 'G', 'F', 'C']);
- expect(consoleWarnMock.callCount()).to.equal(2); // strict mode renders twice
+ expect(consoleWarnMock.callCount()).to.equal(1);
expect(consoleWarnMock.messages()[0]).to.include('returns duplicated headers');
});
});
diff --git a/packages/material-ui-lab/src/TreeView/TreeView.test.js b/packages/material-ui-lab/src/TreeView/TreeView.test.js
index 50c9f5d05c..59ff4d8fd0 100644
--- a/packages/material-ui-lab/src/TreeView/TreeView.test.js
+++ b/packages/material-ui-lab/src/TreeView/TreeView.test.js
@@ -118,8 +118,7 @@ describe('<TreeView />', () => {
const {
current: { errors },
} = errorRef;
- expect(errors).to.have.length(1);
- expect(errors[0].toString()).to.include('RangeError: Maximum call stack size exceeded');
+ expect(errors).to.have.length(0);
});
});
diff --git a/packages/material-ui-styles/src/ThemeProvider/ThemeProvider.test.js b/packages/material-ui-styles/src/ThemeProvider/ThemeProvider.test.js
index 9013d90955..7f0862466d 100644
--- a/packages/material-ui-styles/src/ThemeProvider/ThemeProvider.test.js
+++ b/packages/material-ui-styles/src/ThemeProvider/ThemeProvider.test.js
@@ -135,7 +135,7 @@ describe('ThemeProvider', () => {
<div />
</ThemeProvider>,
);
- expect(consoleErrorMock.callCount()).to.equal(2); // strict mode renders twice
+ expect(consoleErrorMock.callCount()).to.equal(1);
expect(consoleErrorMock.messages()[0]).to.include('However, no outer theme is present.');
});
@@ -148,7 +148,7 @@ describe('ThemeProvider', () => {
,
</ThemeProvider>,
);
- expect(consoleErrorMock.callCount()).to.equal(2); // strict mode renders twice
+ expect(consoleErrorMock.callCount()).to.equal(1);
expect(consoleErrorMock.messages()[0]).to.include(
'Material UI: You should return an object from your theme function',
);
diff --git a/packages/material-ui/src/Breadcrumbs/Breadcrumbs.test.js b/packages/material-ui/src/Breadcrumbs/Breadcrumbs.test.js
index ed0e37f214..49d8ea9b0f 100644
--- a/packages/material-ui/src/Breadcrumbs/Breadcrumbs.test.js
+++ b/packages/material-ui/src/Breadcrumbs/Breadcrumbs.test.js
@@ -102,7 +102,7 @@ describe('<Breadcrumbs />', () => {
);
expect(getAllByRole('listitem', { hidden: false })).to.have.length(4);
expect(getByRole('list')).to.have.text('first/second/third/fourth');
- expect(consoleErrorMock.callCount()).to.equal(2); // strict mode renders twice
+ expect(consoleErrorMock.callCount()).to.equal(1);
expect(consoleErrorMock.messages()[0]).to.include(
'Material UI: You have provided an invalid combination of props to the Breadcrumbs.\nitemsAfterCollapse={2} + itemsBeforeCollapse={2} >= maxItems={3}',
);
diff --git a/packages/material-ui/src/ClickAwayListener/ClickAwayListener.test.js b/packages/material-ui/src/ClickAwayListener/ClickAwayListener.test.js
index fdf7e6e3ae..5d58e3fdeb 100644
--- a/packages/material-ui/src/ClickAwayListener/ClickAwayListener.test.js
+++ b/packages/material-ui/src/ClickAwayListener/ClickAwayListener.test.js
@@ -160,8 +160,7 @@ describe('<ClickAwayListener />', () => {
expect(handleClickAway.callCount).to.equal(0);
fireEvent.click(getByText('Stop inside a portal'));
- // True-negative, we don't have enough information to do otherwise.
- expect(handleClickAway.callCount).to.equal(1);
+ expect(handleClickAway.callCount).to.equal(0);
});
it('should not be called during the same event that mounted the ClickAwayListener', () => {
diff --git a/packages/material-ui/src/TextareaAutosize/TextareaAutosize.test.js b/packages/material-ui/src/TextareaAutosize/TextareaAutosize.test.js
index 09daadd961..1eaf806289 100644
--- a/packages/material-ui/src/TextareaAutosize/TextareaAutosize.test.js
+++ b/packages/material-ui/src/TextareaAutosize/TextareaAutosize.test.js
@@ -261,7 +261,7 @@ describe('<TextareaAutosize />', () => {
});
forceUpdate();
- expect(consoleErrorMock.callCount()).to.equal(3); // strict mode renders twice
+ expect(consoleErrorMock.callCount()).to.equal(1);
expect(consoleErrorMock.messages()[0]).to.include('Material UI: Too many re-renders.');
});
});
diff --git a/packages/material-ui/src/internal/SwitchBase.test.js b/packages/material-ui/src/internal/SwitchBase.test.js
index 41a38bc073..c9397fd133 100644
--- a/packages/material-ui/src/internal/SwitchBase.test.js
+++ b/packages/material-ui/src/internal/SwitchBase.test.js
@@ -373,7 +373,7 @@ describe('<SwitchBase />', () => {
wrapper.setProps({ checked: true });
expect(consoleErrorMock.callCount()).to.equal(2);
expect(consoleErrorMock.messages()[0]).to.include(
- 'Warning: A component is changing an uncontrolled input of type checkbox to be controlled.',
+ 'Warning: A component is changing an uncontrolled input to be controlled.',
);
expect(consoleErrorMock.messages()[1]).to.include(
'Material UI: A component is changing the uncontrolled checked state of SwitchBase to be controlled.',
@@ -392,7 +392,7 @@ describe('<SwitchBase />', () => {
setProps({ checked: undefined });
expect(consoleErrorMock.callCount()).to.equal(2);
expect(consoleErrorMock.messages()[0]).to.include(
- 'Warning: A component is changing a controlled input of type checkbox to be uncontrolled.',
+ 'Warning: A component is changing a controlled input to be uncontrolled.',
);
expect(consoleErrorMock.messages()[1]).to.include(
'Material UI: A component is changing the controlled checked state of SwitchBase to be uncontrolled.',
diff --git a/packages/material-ui/src/useMediaQuery/useMediaQuery.test.js b/packages/material-ui/src/useMediaQuery/useMediaQuery.test.js
index ba9977d1a2..b5ca0ca4b9 100644
--- a/packages/material-ui/src/useMediaQuery/useMediaQuery.test.js
+++ b/packages/material-ui/src/useMediaQuery/useMediaQuery.test.js
@@ -285,7 +285,7 @@ describe('useMediaQuery', () => {
render(<MyComponent />);
// logs warning twice in StrictMode
- expect(consoleErrorMock.callCount()).to.equal(2); // strict mode renders twice
+ expect(consoleErrorMock.callCount()).to.equal(1);
expect(consoleErrorMock.messages()[0]).to.include(
'Material UI: The `query` argument provided is invalid',
);

View File

@@ -0,0 +1,172 @@
import * as fs from 'node:fs/promises';
import * as path from 'node:path';
import { Octokit } from '@octokit/rest';
import {
fetchCommitsBetweenRefs,
findLatestTaggedVersion,
} from '@mui/internal-code-infra/changelog';
import yargs from 'yargs';
/**
* @TODO: Add it to @mui/internal-code-infra/changelog
*
* @param {string} login
* @returns {boolean}
*/
function isBot(login) {
return login.endsWith('[bot]') && !login.includes('copilot');
}
/**
* @param {string} commitMessage
* @returns {string} The tags in lowercases, ordered ascending and comma separated
*/
function parseTags(commitMessage) {
const tagMatch = commitMessage.match(/^(\[[\w-]+\])+/);
if (tagMatch === null) {
return '';
}
const [tagsWithBracketDelimiter] = tagMatch;
return tagsWithBracketDelimiter
.match(/([\w-]+)/g)
.map((tag) => {
return tag.toLocaleLowerCase();
})
.sort((a, b) => {
return a.localeCompare(b);
})
.join(',');
}
// Match commit messages like:
// "[docs] Fix small typo on Grid2 page (#44062)"
const prLinkRegEx = /\(#[0-9]+\)$/;
/**
*
* @param {import('@mui/internal-code-infra/changelog').FetchedCommitDetails[]} commits
* @returns {string[]}
*/
function getAllContributors(commits) {
const authors = Array.from(
new Set(
commits
.filter((commit) => !!commit.author?.login)
.map((commit) => {
return commit.author.login;
}),
),
);
return authors.sort((a, b) => a.localeCompare(b)).map((author) => `@${author}`);
}
async function main(argv) {
const { lastRelease: previousReleaseParam, release } = argv;
const latestTaggedVersion = await findLatestTaggedVersion({
cwd: process.cwd(),
fetchAll: false,
});
const previousRelease =
previousReleaseParam !== undefined ? previousReleaseParam : latestTaggedVersion;
if (previousRelease !== latestTaggedVersion) {
console.warn(
`Creating changelog for ${previousRelease}..${release} while the latest tagged version is '${latestTaggedVersion}'.`,
);
}
if (process.env.GITHUB_TOKEN) {
console.warn(
`Using GITHUB_TOKEN from environment variables have been deprecated. Please remove it if set locally.`,
);
}
const commitsItems = (
await fetchCommitsBetweenRefs({
lastRelease: previousRelease,
release,
repo: 'material-ui',
octokit: process.env.GITHUB_TOKEN
? new Octokit({ auth: process.env.GITHUB_TOKEN })
: undefined,
})
).filter((commit) => !isBot(commit.author.login) && !commit.message.startsWith('[website]'));
const contributorHandles = getAllContributors(commitsItems);
// We don't know when a particular commit was made from the API.
// Only that the commits are ordered by date ASC
const commitsItemsByOrder = new Map(commitsItems.map((item, index) => [item, index]));
// Sort by tags ASC, date desc
// Will only consider exact matches of tags so `[Slider]` will not be grouped with `[Slider][Modal]`
commitsItems.sort((a, b) => {
const aTags = parseTags(a.message);
const bTags = parseTags(b.message);
if (aTags === bTags) {
return commitsItemsByOrder.get(b) - commitsItemsByOrder.get(a);
}
return aTags.localeCompare(bTags);
});
const changes = commitsItems.map((commitsItem) => {
let shortMessage = commitsItem.message.split('\n')[0];
// If the commit message doesn't have an associated PR, add the commit sha for reference.
if (!prLinkRegEx.test(shortMessage)) {
shortMessage += ` (${commitsItem.sha.substring(0, 7)})`;
}
return `- ${shortMessage} @${commitsItem.author.login}`;
});
const generationDate = new Date().toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
});
const releaseName = /** @type {string} */ (
JSON.parse(await fs.readFile(path.join(process.cwd(), 'package.json'), 'utf-8')).version
);
const changelog = `
## ${releaseName}
<!-- generated comparing ${previousRelease}..${release} -->
_${generationDate}_
A big thanks to the ${contributorHandles.length} contributors who made this release possible. Here are some highlights ✨:
TODO INSERT HIGHLIGHTS
${changes.join('\n')}
All contributors of this release in alphabetical order: ${contributorHandles.join(', ')}
`;
// eslint-disable-next-line no-console -- output of this script
console.log(changelog);
}
yargs(process.argv.slice(2))
.command({
command: '$0',
description: 'Creates a changelog',
builder: (command) =>
command
.option('lastRelease', {
describe:
'The release to compare against e.g. `v5.0.0-alpha.23`. Default: The latest tag on the current branch.',
type: 'string',
})
.option('release', {
// #target-branch-reference
default: 'master',
describe: 'Ref which we want to release',
type: 'string',
}),
handler: main,
})
.help()
.strict(true)
.version(false)
.parse();

107
scripts/releasePack.mts Normal file
View File

@@ -0,0 +1,107 @@
/* eslint-disable no-console */
import yargs from 'yargs';
import { hideBin } from 'yargs/helpers';
import { $ } from 'execa';
import * as path from 'path';
import * as fs from 'fs/promises';
interface WorkspaceDefinition {
name: string;
version: string;
path: string;
private: boolean;
}
interface Manifest {
packages: Record<string, string>;
}
interface RunOptions {
packages?: string[];
outDir: string;
concurrency: number;
}
async function packWorkspace(workspace: WorkspaceDefinition, outDir: string): Promise<string> {
const packages: Record<string, string> = {};
const { stdout: zipFilePath } = await $({
cwd: workspace.path,
})`pnpm pack --pack-destination ${outDir}`;
packages[workspace.name] = zipFilePath;
return zipFilePath;
}
async function run({ packages, outDir, concurrency }: RunOptions) {
const allWorkspaces: WorkspaceDefinition[] = await $`pnpm -r ls --depth -1 --json`.then(
(result) => JSON.parse(result.stdout),
);
const workspacesMap = new Map(allWorkspaces.map((workspace) => [workspace.name, workspace]));
const publicPackages = allWorkspaces
.filter((workspace) => !workspace.private)
.map((workspace) => workspace.name);
const packagesToPack = packages || publicPackages;
const workspacesToPack = packagesToPack.map((name) => {
const workspace = workspacesMap.get(name);
if (!workspace) {
throw new Error(`Workspace ${name} not found`);
}
return workspace;
});
const absoluteDestination = path.resolve(outDir);
const workspacesIterator = workspacesToPack.values();
const manifest: Manifest = { packages: {} };
const workers = Array.from({ length: concurrency }).map(async () => {
for (const workspace of workspacesIterator) {
/* eslint-disable no-await-in-loop */
console.log(`packing "${workspace.name}"`);
const zipFilePath = await packWorkspace(workspace, absoluteDestination);
const newName = path.join(absoluteDestination, `${workspace.name}.tgz`);
await fs.mkdir(path.dirname(newName), { recursive: true });
await fs.rename(zipFilePath, newName);
const relativeZipFilePath = path.relative(absoluteDestination, newName);
manifest.packages[workspace.name] = relativeZipFilePath;
console.log(`packed "${zipFilePath}"`);
/* eslint-enable no-await-in-loop */
}
});
await Promise.all(workers);
await fs.writeFile(
path.join(absoluteDestination, 'manifest.json'),
JSON.stringify(manifest, null, 2),
);
}
yargs(hideBin(process.argv))
.command<RunOptions>(
'$0',
'Pack workspaces.',
(command) => {
return command
.option('packages', {
describe: 'Workspace Packages to pack, defaults to public packages',
type: 'array',
alias: 'p',
})
.option('outDir', {
default: './packed',
describe: 'Destination folder',
type: 'string',
})
.option('concurrency', {
default: 5,
describe: 'Number of concurrent packing processes',
type: 'number',
});
},
run,
)
.help()
.strict(true)
.version(false)
.parse();

34
scripts/test.mjs Normal file
View File

@@ -0,0 +1,34 @@
/* eslint-disable no-console */
import { spawn } from 'node:child_process';
import chalk from 'chalk';
/*
This script ensures that we can use the same commands to run tests
when using pnpm as when using Yarn.
It enables to run `pnpm test` (or `pnpm t`) without any arguments, to run all tests,
or `pnpm test <test-name>` (or `pnpm t <test-name>`) to run a subset of tests in watch mode.
See https://github.com/mui/material-ui/pull/40430 for more context.
*/
if (process.argv.length < 3) {
console.log('Running ESLint, type checker, and unit tests...');
spawn('pnpm', ['test:extended'], {
shell: true,
stdio: ['inherit', 'inherit', 'inherit'],
});
} else {
console.log('Running selected tests in watch mode...');
console.log(
chalk.yellow(
'Note: run `pnpm tc` to have a better experience (and be able to pass in additional parameters).',
),
);
console.log('cmd', ['tc', ...process.argv.slice(2)]);
spawn('pnpm', ['tc', ...process.argv.slice(2)], {
shell: true,
stdio: ['inherit', 'inherit', 'inherit'],
});
}

View File

@@ -0,0 +1,38 @@
import { globby } from 'globby';
import fs from 'node:fs/promises';
import path from 'path';
import { findWorkspaceDir } from '@pnpm/find-workspace-dir';
async function main() {
const workspaceRoot = await findWorkspaceDir(process.cwd());
const declarationFiles = await globby('**/build/**/*.d.ts', {
absolute: true,
cwd: workspaceRoot,
ignore: ['node_modules'],
followSymbolicLinks: false,
});
await Promise.all(
declarationFiles.map(async (declarationFilePath) => {
const declarationFile = await fs.readFile(declarationFilePath, { encoding: 'utf8' });
// find occurrences of e.g. `import("../../mui-*/src/...")`
const typeImportsRelativeToWorkspace = declarationFile.match(/import\(("|')(\.\.\/)+mui/g);
if (typeImportsRelativeToWorkspace !== null) {
console.error(
// readable path for CI while making it clickable locally
`${path.relative(process.cwd(), declarationFilePath)} possibly imports types ${
typeImportsRelativeToWorkspace.length
} times that are unreachable once published.`,
);
process.exitCode = 1;
}
}),
);
}
main().catch((error) => {
console.error(error);
process.exit(1);
});

115
scripts/useReactVersion.mjs Normal file
View File

@@ -0,0 +1,115 @@
/* eslint-disable no-console */
/**
* Given the dist tag fetch the corresponding
* version and make sure this version is used throughout the repository.
*
* If you work on this file:
* WARNING: This script can only use built-in modules since it has to run before
* `pnpm install`
*/
import childProcess from 'child_process';
import fs from 'fs';
import os from 'os';
import path from 'path';
import { promisify } from 'util';
const exec = promisify(childProcess.exec);
// packages published from the react monorepo using the same version
const reactPackageNames = ['react', 'react-dom', 'react-is', 'scheduler'];
const devDependenciesPackageNames = ['@testing-library/react'];
// if we need to support more versions we will need to add new mapping here
const additionalVersionsMappings = {
17: {
'@testing-library/react': '^12.1.0',
},
};
async function main(version) {
if (typeof version !== 'string') {
throw new TypeError(`expected version: string but got '${version}'`);
}
if (version === 'stable') {
console.log('Nothing to do with stable');
return;
}
const packageJsonPath = path.resolve(process.cwd(), 'package.json');
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, { encoding: 'utf8' }));
// the version is something in format: "17.0.0"
let majorVersion = null;
if (version.startsWith('^') || version.startsWith('~') || !Number.isNaN(version.charAt(0))) {
majorVersion = version.replace('^', '').replace('~', '').split('.')[0];
}
await Promise.all(
reactPackageNames.map(async (reactPackageName) => {
const { stdout: versions } = await exec(`npm dist-tag ls ${reactPackageName} ${version}`);
const tagMapping = versions.split('\n').find((mapping) => {
return mapping.startsWith(`${version}: `);
});
let packageVersion = null;
if (tagMapping === undefined) {
// Some specific version is being requested
if (majorVersion) {
packageVersion = version;
if (reactPackageName === 'scheduler') {
// get the scheduler version from the react-dom's dependencies entry
const { stdout: reactDOMDependenciesString } = await exec(
`npm view --json react-dom@${version} dependencies`,
);
packageVersion = JSON.parse(reactDOMDependenciesString).scheduler;
}
} else {
throw new Error(`Could not find '${version}' in "${versions}"`);
}
} else {
packageVersion = tagMapping.replace(`${version}: `, '');
}
packageJson.resolutions[reactPackageName] = packageVersion;
}),
);
// At this moment all dist tags reference React 18 version, so we don't need
// to update these dependencies unless an older version is used, or when the
// next/experimental dist tag reference to a future version of React
// packageJson.devDependencies['@testing-library/react'] = 'alpha';
if (majorVersion && additionalVersionsMappings[majorVersion]) {
devDependenciesPackageNames.forEach((packageName) => {
if (!additionalVersionsMappings[majorVersion][packageName]) {
throw new Error(
`Version ${majorVersion} does not have version defined for the ${packageName}`,
);
}
packageJson.resolutions[packageName] = additionalVersionsMappings[majorVersion][packageName];
});
}
// add newline for clean diff
fs.writeFileSync(packageJsonPath, `${JSON.stringify(packageJson, null, 2)}${os.EOL}`);
console.log('Installing dependencies...');
const pnpmInstall = childProcess.spawn('pnpm', ['install', '--no-frozen-lockfile'], {
shell: true,
stdio: ['inherit', 'inherit', 'inherit'],
});
pnpmInstall.on('exit', (exitCode) => {
if (exitCode !== 0) {
throw new Error('Failed to install dependencies');
}
});
}
const [version = process.env.REACT_VERSION] = process.argv.slice(2);
main(version).catch((error) => {
console.error(error);
process.exit(1);
});

View File

@@ -0,0 +1,28 @@
/* eslint-disable no-console */
import { globbySync } from 'globby';
import fs from 'fs';
/**
* Validates if there are no missing exports from TS files that would
* result in an import from a local file.
*/
function validateFiles() {
const declarationFiles = globbySync(['packages/*/build/**/*.d.ts'], {
followSymbolicLinks: false,
});
const invalidFiles = declarationFiles.filter((file) => {
const content = fs.readFileSync(file, 'utf8');
const regex = /import\(["']packages\//gm;
return regex.test(content);
});
if (invalidFiles.length > 0) {
console.error('Found invalid imports in the following files:');
invalidFiles.forEach((file) => console.error(file));
process.exit(1);
}
console.log('Found no invalid import statements in built declaration files.');
}
validateFiles();