Files
how2ice 005cf56baf
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
init project
2025-12-12 14:26:25 +09:00

513 lines
17 KiB
TypeScript

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