@mui/internal-docs-infra
Version:
MUI Infra - internal documentation creation tools.
578 lines (536 loc) • 21.7 kB
JavaScript
import { uniq, sortBy } from 'es-toolkit';
import prettier from 'prettier/standalone';
import prettierPluginEstree from 'prettier/plugins/estree';
import prettierPluginTypescript from 'prettier/plugins/typescript';
import prettierPluginMarkdown from 'prettier/plugins/markdown';
import { unified } from 'unified';
import remarkParse from 'remark-parse';
import remarkGfm from 'remark-gfm';
import remarkTypography from 'remark-typography';
import remarkRehype from 'remark-rehype';
import transformMarkdownCode from "../transformMarkdownCode/index.mjs";
import { formatType, getFullyQualifiedName } from "./formatType.mjs";
import { isExternalType, isUnionType, isIntersectionType, isObjectType, isAnonymousObjectType, isTypeParameterType } from "./typeGuards.mjs";
/**
* A pattern/replacement pair for transforming description text.
* The pattern is compiled into a RegExp internally.
*/
const regexCache = new WeakMap();
function getRegex(desc) {
let regex = regexCache.get(desc);
if (!regex) {
regex = new RegExp(desc.pattern, desc.flags);
regexCache.set(desc, regex);
}
return regex;
}
/**
* Applies a list of description replacements to a text string.
* Each replacement's pattern is compiled into a RegExp and cached per object reference.
*/
export function applyDescriptionReplacements(text, replacements = []) {
if (!text) {
return text;
}
let result = text;
for (const desc of replacements) {
result = result.replace(getRegex(desc), desc.replacement);
}
return result;
}
/**
* Formatted property metadata with plain text types and parsed markdown descriptions.
*
* Type highlighting (type → HAST, shortType, detailedType) is deferred to
* the loadServerTypes stage via highlightTypesMeta() after highlightTypes().
*/
/**
* Formatted enum member metadata.
*/
/**
* Formatted parameter metadata for functions and hooks.
*
* Type highlighting is deferred to the loadServerTypes stage via
* highlightTypesMeta() after highlightTypes().
*/
/**
* Extract a human-readable label from a URL (e.g. "github.com" from "http://github.com/foo").
*/
function labelFromUrl(url) {
try {
const {
hostname
} = new URL(url);
return hostname.replace(/^www\./, '');
} catch {
return url;
}
}
/**
* Transform a single JSDoc `@see` tag value into a markdown list-item string.
*
* Supported input forms:
* - `{@link http://example.com}` → `- See [example.com](http://example.com)`
* - `{@link http://example.com|My Label}` → `- See [My Label](http://example.com)`
* - `{@link http://example.com} trailing text` → `- See [example.com](http://example.com) trailing text`
* - `http://example.com` (bare URL) → `- See [example.com](http://example.com)`
* - `plain text` (no link) → `- See plain text`
*/
function formatSeeTag(value) {
let trimmed = value.trim();
// Workaround: TypeScript's parser splits `@see https://example.com` into
// tag.name="https" and tag.comment="://example.com", so the extractor may
// deliver a truncated value starting with "://". Restore the protocol.
// TODO: fix this in typescript-api-extractor
if (trimmed.startsWith('://')) {
trimmed = `https${trimmed}`;
}
// Replace all {@link ...} occurrences
const linkPattern = /\{@link\s+([^|}]+?)(?:\|([^}]+))?\}/g;
if (linkPattern.test(trimmed)) {
// Reset lastIndex after test
linkPattern.lastIndex = 0;
const replaced = trimmed.replace(linkPattern, (_match, url, text) => {
const linkUrl = url.trim();
const linkText = text ? text.trim() : labelFromUrl(linkUrl);
return `[${linkText}](${linkUrl})`;
});
return `- See ${replaced}`;
}
// Bare URL (no {@link ...} wrapper)
const bareUrlMatch = trimmed.match(/^(https?:\/\/\S+)(.*)$/);
if (bareUrlMatch) {
const url = bareUrlMatch[1];
const rest = bareUrlMatch[2];
return `- See [${labelFromUrl(url)}](${url})${rest}`;
}
// Plain text reference
return `- See ${trimmed}`;
}
/**
* Transform an array of raw `@see` tag values into a markdown bullet list.
* Returns `undefined` when the input is empty.
*/
export function formatSeeTags(values) {
const items = values.filter(v => v != null && v.trim().length > 0);
if (items.length === 0) {
return undefined;
}
return items.map(formatSeeTag).join('\n');
}
/**
* Formats an array of type arguments into a type parameter declaration string.
*
* Only includes entries that are `TypeParameterNode`s (i.e., actual type parameters
* like `T`, not concrete type arguments like `string`). Each parameter is formatted
* with its constraint and default value when present.
*
* @returns A string like `<T, K extends string>` or `''` if there are no type parameters.
*/
export function formatTypeParameterDeclaration(typeArguments, typeNameMap = {}) {
const typeParams = typeArguments.filter(arg => isTypeParameterType(arg.type)).map(arg => {
const param = arg.type;
let result = param.name;
if (param.constraint !== undefined) {
const constraintStr = formatType(param.constraint, {
exportNames: [],
typeNameMap
});
result += ` extends ${constraintStr}`;
}
if (param.defaultValue !== undefined) {
const defaultStr = formatType(param.defaultValue, {
exportNames: [],
typeNameMap
});
result += ` = ${defaultStr}`;
}
return result;
});
if (typeParams.length === 0) {
return '';
}
return `<${typeParams.join(', ')}>`;
}
/**
* Extracts type parameter declarations from an AnyType node.
*
* Reads `typeName.typeArguments` from types that carry a `typeName` property
* (ObjectNode, UnionNode, IntersectionNode) and formats them as a declaration string.
*
* @returns A string like `<T, K extends string>` or `''` if the type has no type parameters.
*/
export function extractTypeParameters(type, typeNameMap = {}) {
const typeWithName = type;
if (!typeWithName.typeName?.typeArguments?.length) {
return '';
}
return formatTypeParameterDeclaration(typeWithName.typeName.typeArguments, typeNameMap);
}
/**
* Converts markdown text to HAST (HTML Abstract Syntax Tree) with syntax-highlighted code blocks.
*
* This enables rendering rich formatted descriptions including code examples, lists, and links
* while preserving all markdown features and applying syntax highlighting to code blocks.
*/
export async function parseMarkdownToHast(markdown) {
const processor = unified().use(remarkParse).use(remarkGfm).use(transformMarkdownCode).use(remarkTypography, []).use(remarkRehype).freeze();
const mdast = processor.parse(markdown);
const result = await processor.run(mdast);
return result;
}
/**
* Ensures an @example tag value is wrapped in a code fence.
* If the text already contains triple-backtick fences, it's returned as-is.
* Otherwise, wraps it in ```tsx fences.
*/
function ensureExampleFenced(exampleText) {
if (exampleText.includes('```')) {
return exampleText;
}
return `\`\`\`tsx\n${exampleText.trim()}\n\`\`\``;
}
/**
* Options for formatting inline types as HAST.
*/
/**
* Formats a TypeScript type string with Prettier, optionally preserving the type declaration.
*
* This function wraps the type in a `type Name = ...` declaration, formats it with Prettier,
* and then removes or preserves the prefix based on the provided typeName and formatting.
*
* @param type - The type string to format
* @param typeName - Optional type name to use in the declaration. If provided and the type
* is multi-line, the `type Name = ...` prefix will be preserved.
* @param printWidth - Optional maximum line width for Prettier formatting (default: 100)
* @returns The formatted type string
*/
/**
* Formats a markdown string with Prettier's markdown parser.
* Used for non-code sections of generated markdown to ensure consistent formatting.
*
* @param markdown - The markdown string to format
* @param printWidth - Optional maximum line width for Prettier formatting (default: 100)
* @returns The formatted markdown string
*/
export async function prettyFormatMarkdown(markdown, printWidth = 100) {
try {
const prettierOptions = {
plugins: [prettierPluginEstree, prettierPluginTypescript, prettierPluginMarkdown],
parser: 'markdown',
singleQuote: true,
trailingComma: 'all',
printWidth
};
return (await prettier.format(markdown, prettierOptions)).trimEnd();
} catch (error) {
console.warn(`[prettyFormatMarkdown] Prettier failed: ${error instanceof Error ? error.message : String(error)}`);
return markdown;
}
}
export async function prettyFormat(type, typeName, printWidth = 100) {
let formattedType;
// When typeName is null, format the code directly without any prefix
// When typeName is undefined, use a placeholder '_' that will be stripped later
// When typeName is a string, keep the full `type X = ` prefix in output
const usePrefix = typeName !== null;
const codePrefix = usePrefix ? `type ${typeName || '_'} = ` : '';
try {
// Format as a markdown code block so the output matches what prettier
// produces when formatting the final markdown file with embedded TypeScript.
// We format twice because prettier is not idempotent for certain patterns:
// - First pass: expands single-line types to multi-line
// - Second pass: collapses unnecessary line breaks (e.g., single param functions)
const markdown = `\`\`\`tsx\n${codePrefix}${type}\n\`\`\``;
const prettierOptions = {
plugins: [prettierPluginEstree, prettierPluginTypescript, prettierPluginMarkdown],
parser: 'markdown',
singleQuote: true,
trailingComma: 'all',
printWidth
};
let formattedMarkdown = await prettier.format(markdown, prettierOptions);
formattedMarkdown = await prettier.format(formattedMarkdown, prettierOptions);
// Extract the TypeScript code from the formatted markdown
const match = formattedMarkdown.match(/```tsx\n([\s\S]*?)\n```/);
formattedType = match ? match[1] : `${codePrefix}${type}`;
} catch (error) {
// If Prettier fails on extremely complex types, return the original type
console.warn(`[prettyFormat] Prettier failed for type "${typeName || 'unknown'}": ${error instanceof Error ? error.message : String(error)}`);
return type;
}
// When typeName is null, return the formatted code directly (no prefix was added)
if (typeName === null) {
return formattedType.trimEnd();
}
if (typeName) {
return formattedType.trimEnd();
}
// Improve readability by formatting complex types with Prettier.
// Prettier either formats the type on a single line or multiple lines.
// If it's on a single line, we remove the `type _ = ` prefix.
// If it's on multiple lines, we remove the `type _ = ` prefix but keep the rest of the first line.
const lines = formattedType.trimEnd().split('\n');
if (lines.length === 1) {
type = lines[0].replace(/^type _ = /, '');
} else {
let codeLines;
if (typeName) {
codeLines = lines;
} else {
// For multi-line types without a typeName, replace the `type _ = ` prefix
// on the first line, but keep the rest of the line (e.g., opening parenthesis)
const firstLine = lines[0].replace(/^type _ = ?/, '');
codeLines = [firstLine, ...lines.slice(1)];
}
const nonEmptyLines = codeLines.filter(l => l.trim() !== '');
if (nonEmptyLines.length > 0) {
const minIndent = Math.min(...nonEmptyLines.map(l => l.match(/^\s*/)?.[0].length ?? 0));
if (Number.isFinite(minIndent) && minIndent > 0) {
type = nonEmptyLines.map(l => l.substring(minIndent)).join('\n');
} else {
type = nonEmptyLines.join('\n');
}
} else {
type = '';
}
}
return type;
}
/**
* Options for formatting properties.
*/
/**
* Formats component or hook properties into a structured object with plain text types.
*
* Each property includes its type (as plain text), description (parsed markdown),
* and default value. Type highlighting (type → HAST, shortType, detailedType) is
* deferred to the loadServerTypes stage via highlightTypesMeta() after highlightTypes().
*
* This function handles the conversion of TypeScript type information into a format
* suitable for documentation display.
*/
export async function formatProperties(props, options = {}) {
const {
exportNames,
typeNameMap,
isComponentContext = false,
externalTypes,
descriptionReplacements
} = options;
// Filter out props that should not be documented:
// - `ref` is typically forwarded and not useful in component API docs
// - Props with @ignore tag are intentionally hidden from documentation
const filteredProps = props.filter(prop => {
// Skip `ref` for components (when isComponentContext is true)
if (prop.name === 'ref' && isComponentContext) {
return false;
}
// Skip props marked with @ignore
// Check both hasTag method (from tae.Documentation class) and tags array (for plain objects)
const hasIgnoreTag = prop.documentation?.hasTag?.('ignore') || prop.documentation?.tags?.some(tag => tag.name === 'ignore');
if (hasIgnoreTag) {
return false;
}
return true;
});
const propEntries = await Promise.all(filteredProps.map(async prop => {
const rawExampleTag = prop.documentation?.tags?.filter(tag => tag.name === 'example').map(tag => tag.value).join('\n');
const exampleTag = rawExampleTag ? ensureExampleFenced(rawExampleTag) : undefined;
const seeTagValues = prop.documentation?.tags?.filter(tag => tag.name === 'see').map(tag => tag.value) ?? [];
const seeText = formatSeeTags(seeTagValues);
const formattedType = formatType(prop.type, {
removeUndefined: prop.optional,
jsdocTags: prop.documentation?.tags,
exportNames,
typeNameMap,
externalTypesCollector: externalTypes
});
// Parse description as markdown and convert to HAST for rich rendering
const propDescriptionText = prop.documentation?.description ? applyDescriptionReplacements(prop.documentation.description, descriptionReplacements) : undefined;
const description = propDescriptionText ? await parseMarkdownToHast(propDescriptionText) : undefined;
// Parse example as markdown if present
// Use fenced exampleTag so that parseMarkdownToHast produces <pre><code> HAST.
// This ensures transformHtmlCodeBlock in highlightTypes can process it.
const example = exampleTag ? await parseMarkdownToHast(exampleTag) : undefined;
// Parse @see references as markdown if present
const see = seeText ? await parseMarkdownToHast(seeText) : undefined;
// Get default value as plain text if present
const defaultValueText = prop.documentation?.defaultValue !== undefined ? String(prop.documentation.defaultValue) : undefined;
const resultObject = {
typeText: formattedType,
required: !prop.optional || undefined,
description,
descriptionText: propDescriptionText,
example,
exampleText: exampleTag,
see,
seeText
};
// Only include defaultText if it exists
if (defaultValueText) {
resultObject.defaultText = defaultValueText;
}
// For optional props, append `| undefined` to typeText if not already present.
// formatType strips `| undefined` for cleaner markdown display, but we want
// the full type available for HAST highlighting.
if (prop.optional && !resultObject.typeText.endsWith('| undefined')) {
resultObject.typeText = `${resultObject.typeText} | undefined`;
}
return [prop.name, resultObject];
}));
return Object.fromEntries(propEntries);
}
/**
* Options for formatting parameters.
*/
/**
* Formats function or hook parameters into a structured object.
*
* Each parameter includes its type (as plain text string), description (parsed markdown as HAST),
* default value, and whether it's optional. Type highlighting is deferred to the
* loadServerTypes stage via highlightTypesMeta() after highlightTypes().
*/
export async function formatParameters(params, options = {}) {
const {
exportNames,
typeNameMap,
externalTypes,
descriptionReplacements
} = options;
const result = await Promise.all(params.map(async param => {
const rawExampleTag = param.documentation?.tags?.filter(tag => tag.name === 'example').map(tag => tag.value).join('\n');
const exampleTag = rawExampleTag ? ensureExampleFenced(rawExampleTag) : undefined;
const seeTagValues = param.documentation?.tags?.filter(tag => tag.name === 'see').map(tag => tag.value) ?? [];
const seeText = formatSeeTags(seeTagValues);
const paramDescriptionText = param.documentation?.description ? applyDescriptionReplacements(param.documentation.description, descriptionReplacements) : undefined;
const description = paramDescriptionText ? await parseMarkdownToHast(paramDescriptionText) : undefined;
// Use fenced exampleTag so that parseMarkdownToHast produces <pre><code> HAST.
// This ensures transformHtmlCodeBlock in highlightTypes can process it.
const example = exampleTag ? await parseMarkdownToHast(exampleTag) : undefined;
// Parse @see references as markdown if present
const see = seeText ? await parseMarkdownToHast(seeText) : undefined;
// Get default value as plain text if present
const defaultValueText = param.defaultValue !== undefined ? String(param.defaultValue) : undefined;
// Format type as plain text
// Only expand anonymous object types (no type name) — named types like
// `ExportConfig` should be shown as type references, not expanded inline.
const shouldExpand = isObjectType(param.type) && isAnonymousObjectType(param.type);
const typeText = formatType(param.type, {
removeUndefined: param.optional,
jsdocTags: param.documentation?.tags,
expandObjects: shouldExpand,
exportNames,
typeNameMap,
externalTypesCollector: externalTypes
});
const paramResult = {
name: param.name,
typeText,
optional: param.optional || undefined,
description,
descriptionText: paramDescriptionText,
example,
exampleText: exampleTag,
see,
seeText
};
// Only include defaultText if it exists
if (defaultValueText) {
paramResult.defaultText = defaultValueText;
}
// For optional params, append `| undefined` to typeText if not already present.
// formatType strips `| undefined` for cleaner markdown display, but we want
// the full type available for HAST highlighting.
if (param.optional && !paramResult.typeText.endsWith('| undefined')) {
paramResult.typeText = `${paramResult.typeText} | undefined`;
}
return paramResult;
}));
return result;
}
/**
* Options for formatting detailed types.
*/
/**
* Recursively expands type aliases and external type references to their full definitions.
*
* This function resolves external types by looking them up in the provided exports,
* and recursively expands union and intersection types. It includes cycle detection
* to prevent infinite recursion on self-referential types.
*/
export function formatDetailedType(type, options) {
const {
allExports,
exportNames,
typeNameMap,
visited = new Set()
} = options;
// Prevent infinite recursion
if (isExternalType(type)) {
const qualifiedName = getFullyQualifiedName(type.typeName, exportNames, typeNameMap);
if (visited.has(qualifiedName)) {
return qualifiedName;
}
visited.add(qualifiedName);
const exportNode = allExports.find(node => node.name === type.typeName.name);
if (exportNode) {
return formatDetailedType(exportNode.type ?? type, {
allExports,
exportNames,
typeNameMap,
visited
});
}
// Manually expand known external aliases when declaration is not in local exports
switch (true) {
case qualifiedName.endsWith('Padding'):
return '{ top?: number; right?: number; bottom?: number; left?: number } | number';
default:
return qualifiedName;
}
}
if (isUnionType(type)) {
const memberTypes = type.types.map(t => formatDetailedType(t, {
allExports,
exportNames,
typeNameMap,
visited
}));
return uniq(memberTypes).join(' | ');
}
if (isIntersectionType(type)) {
const memberTypes = type.types.map(t => formatDetailedType(t, {
allExports,
exportNames,
typeNameMap,
visited
}));
return uniq(memberTypes).join(' & ');
}
// For objects and everything else, reuse existing formatter with object expansion enabled
return formatType(type, {
expandObjects: true,
exportNames,
typeNameMap
});
}
/**
* Formats an enum type into a structured object mapping enum values to their metadata.
*
* The result includes each enum member's description (parsed markdown as HAST) and type
* information from JSDoc tags. Members are sorted by their value for consistent output.
*/
export async function formatEnum(enumNode, descriptionReplacements = []) {
const result = {};
await Promise.all(sortBy(enumNode.members, ['value']).map(async member => {
const descriptionText = applyDescriptionReplacements(member.documentation?.description, descriptionReplacements);
const description = descriptionText ? await parseMarkdownToHast(descriptionText) : undefined;
result[member.value] = {
description,
descriptionText,
type: member.documentation?.tags?.find(tag => tag.name === 'type')?.value
};
}));
return result;
}