@mui/internal-docs-infra
Version:
MUI Infra - internal documentation creation tools.
1,369 lines (1,277 loc) • 59.3 kB
JavaScript
import { unified } from 'unified';
import remarkParse from 'remark-parse';
import { visit } from 'unist-util-visit';
import { heading, paragraph, text, link, comment } from "./createMarkdownNodes.mjs";
/**
* Escapes underscores in a string for markdown compatibility.
* Prevents underscores from being interpreted as emphasis markers.
* @example escapeUnderscores('_options') -> '\_options'
*/
function escapeUnderscores(str) {
return str.replace(/_/g, '\\_');
}
/**
* Unescapes underscores in a string that was escaped for markdown.
* @example unescapeUnderscores('\_options') -> '_options'
*/
function unescapeUnderscores(str) {
return str.replace(/\\_/g, '_');
}
// Definition nodes are used for markdown-style comments like [//]: # "Comment text"
/**
* Converts AST nodes (from heading.children) back to markdown string
*/
function astNodesToMarkdown(nodes) {
let result = '';
for (const node of nodes) {
if (node.type === 'text') {
result += node.value;
} else if (node.type === 'inlineCode') {
result += `\`${node.value}\``;
} else if (node.type === 'emphasis') {
// Prettier serializes emphasis with underscores; match it to keep the
// generated file stable when prettier formats it afterwards.
result += `_${astNodesToMarkdown(node.children)}_`;
} else if (node.type === 'strong') {
result += `**${astNodesToMarkdown(node.children)}**`;
} else if (node.type === 'link') {
result += `[${astNodesToMarkdown(node.children)}](${node.url})`;
} else if ('children' in node) {
result += astNodesToMarkdown(node.children);
}
}
return result;
}
/**
* Options for metadataToMarkdown and metadataToMarkdownAst functions
*/
/**
* Serializes a JS value to a JavaScript-style literal string (unquoted keys, trailing commas).
* This produces output like `{ robots: { index: false } }` instead of JSON's `{ "robots": { "index": false } }`.
*/
function serializeJsValue(value, indent) {
if (value === null || value === undefined) {
return String(value);
}
if (typeof value === 'string') {
// Use single quotes for strings, escaping any internal single quotes
return `'${String(value).replace(/'/g, "\\'")}'`;
}
if (typeof value !== 'object') {
return String(value);
}
if (Array.isArray(value)) {
if (value.length === 0) {
return '[]';
}
const items = value.map(item => `${' '.repeat(indent + 1)}${serializeJsValue(item, indent + 1)},`);
return `[\n${items.join('\n')}\n${' '.repeat(indent)}]`;
}
const entries = Object.entries(value);
if (entries.length === 0) {
return '{}';
}
const lines = entries.map(([key, val]) => `${' '.repeat(indent + 1)}${key}: ${serializeJsValue(val, indent + 1)},`);
return `{\n${lines.join('\n')}\n${' '.repeat(indent)}}`;
}
/**
* Gets the status label for a page based on its audience and index flags.
*
* Status labels in the editable list:
* - \"Private\": audience='private', index=false
* - \"Index\": audience='private', index=true
* - \"Public Index\": audience!='private', index=true
* - \"Introductory\": audience='introductory'
* - \"Intermediate\": audience='intermediate'
* - \"Advanced\": audience='advanced'
* - \"Business\": audience='business'
*/
function getStatusLabel(page) {
const audience = page.audience;
const isIndex = page.index ?? false;
if (audience === 'private' && isIndex) {
return 'Index';
}
if (audience === 'private' && !isIndex) {
return 'Private';
}
if (audience && audience !== 'private' && isIndex) {
const audienceLabel = audience.charAt(0).toUpperCase() + audience.slice(1);
return `${audienceLabel} Index`;
}
if (audience && audience !== 'private') {
return audience.charAt(0).toUpperCase() + audience.slice(1);
}
if (isIndex) {
return 'Public Index';
}
return undefined;
}
/**
* Parses a status label string back into audience/index flags.
*/
function parseStatusLabel(label) {
const trimmed = label.trim();
switch (trimmed) {
case 'Private':
return {
audience: 'private',
index: false
};
case 'Index':
return {
audience: 'private',
index: true
};
case 'Public Index':
return {
index: true
};
case 'Introductory':
return {
audience: 'introductory',
index: false
};
case 'Introductory Index':
return {
audience: 'introductory',
index: true
};
case 'Intermediate':
return {
audience: 'intermediate',
index: false
};
case 'Intermediate Index':
return {
audience: 'intermediate',
index: true
};
case 'Advanced':
return {
audience: 'advanced',
index: false
};
case 'Advanced Index':
return {
audience: 'advanced',
index: true
};
case 'Business':
return {
audience: 'business',
index: false
};
case 'Business Index':
return {
audience: 'business',
index: true
};
default:
throw new Error(`Unknown status label "${trimmed}". ` + 'Valid labels are: Private, Index, Public Index, Introductory, Introductory Index, ' + 'Intermediate, Intermediate Index, Advanced, Advanced Index, Business, Business Index.');
}
}
/**
* Converts a HeadingHierarchy into markdown list format
*/
function headingHierarchyToMarkdown(hierarchy, basePath, depth = 0) {
let result = '';
const indent = ' '.repeat(depth);
for (const node of Object.values(hierarchy)) {
const {
titleMarkdown,
children
} = node;
// Convert AST nodes back to markdown string with preserved formatting
let titleString = astNodesToMarkdown(titleMarkdown);
// Escape numbered list syntax (e.g., "1. Text" -> "1\. Text")
// This prevents markdown from treating "- 1. Text" as a nested ordered list
titleString = titleString.replace(/^(\d+)\.\s/, '$1\\. ');
result += `${indent}- ${titleString}\n`;
if (Object.keys(children).length > 0) {
result += headingHierarchyToMarkdown(children, basePath, depth + 1);
}
}
return result;
}
/**
* Converts a HeadingHierarchy into markdown AST list nodes
*/
function headingHierarchyToListNodes(hierarchy, basePath) {
const listItems = [];
for (const node of Object.values(hierarchy)) {
const {
titleMarkdown,
children
} = node;
const listItem = {
type: 'listItem',
children: [{
type: 'paragraph',
children: titleMarkdown // Use the preserved AST nodes directly
}]
};
// Add nested children if they exist
if (Object.keys(children).length > 0) {
const nestedList = {
type: 'list',
ordered: false,
children: headingHierarchyToListNodes(children, basePath)
};
listItem.children.push(nestedList);
}
listItems.push(listItem);
}
return listItems;
}
/**
* Strips position metadata from AST nodes recursively
*/
function stripPositions(nodes) {
return nodes.map(node => {
const {
position,
...rest
} = node;
if (rest.children) {
rest.children = stripPositions(rest.children);
}
return rest;
});
}
/**
* Parses exports metadata from a nested list structure
* Expects format:
* - Exports:
* - ComponentName - PartName
* - Props: a, b, c
* - Parameters: x, y (for hooks/functions)
* - Returns: ReturnType (for hooks/functions)
* - Data Attributes: x, y
* - CSS Variables: --var1, --var2
*/
function parseExportsFromListItem(listItem) {
const exports = {};
const parts = {};
// Find the nested list within this list item
const nestedList = listItem.children?.find(child => child.type === 'list');
if (!nestedList?.children) {
return {
exports
};
}
// Parse each export/part item from the nested list
for (const exportListItem of nestedList.children) {
if (exportListItem.type !== 'listItem') {
continue;
}
// Find the paragraph with the export/part name
const exportParagraph = exportListItem.children?.find(child => child.type === 'paragraph');
if (!exportParagraph) {
continue;
}
// Extract the name from the text node
const textNode = exportParagraph.children?.find(child => child.type === 'text');
if (!textNode) {
continue;
}
const fullName = textNode.value || '';
// Check if this is a part (has dash) or export (no dash)
const hasDash = fullName.includes(' - ');
// Find the nested list with props/dataAttributes/cssVariables
const metadataList = exportListItem.children?.find(child => child.type === 'list');
// Initialize the metadata (only add properties that have content)
const metadata = {};
if (metadataList) {
// Parse each metadata item
for (const metadataItem of metadataList.children) {
if (metadataItem.type !== 'listItem') {
continue;
}
const metadataParagraph = metadataItem.children?.find(child => child.type === 'paragraph');
if (!metadataParagraph) {
continue;
}
const metadataText = extractPlainTextFromNode(metadataParagraph);
if (metadataText.startsWith('Props:')) {
const propsText = metadataText.replace('Props:', '').trim();
if (propsText) {
metadata.props = propsText.split(',').map(p => unescapeUnderscores(p.trim()));
}
} else if (metadataText.startsWith('Parameters:')) {
const parametersText = metadataText.replace('Parameters:', '').trim();
if (parametersText) {
// When parameters are wrapped in ( ), they represent properties of a
// single object parameter. Store as a nested string[] element.
const objectMatch = parametersText.match(/^\((.+)\)$/);
if (objectMatch) {
const inner = objectMatch[1].trim();
const keys = inner.split(',').map(p => unescapeUnderscores(p.trim()));
metadata.parameters = [keys];
} else {
metadata.parameters = parametersText.split(',').map(p => unescapeUnderscores(p.trim()));
}
}
} else if (metadataText.startsWith('Data Attributes:')) {
const dataAttributesText = metadataText.replace('Data Attributes:', '').trim();
if (dataAttributesText) {
metadata.dataAttributes = dataAttributesText.split(',').map(attr => unescapeUnderscores(attr.trim()));
}
} else if (metadataText.startsWith('CSS Variables:')) {
const cssVariablesText = metadataText.replace('CSS Variables:', '').trim();
if (cssVariablesText) {
metadata.cssVariables = cssVariablesText.split(',').map(cssVar => unescapeUnderscores(cssVar.trim()));
}
} else if (metadataText.startsWith('Returns:')) {
const returnsText = metadataText.replace('Returns:', '').trim();
if (returnsText) {
metadata.returns = returnsText.split(',').map(r => unescapeUnderscores(r.trim()));
}
}
}
}
if (hasDash) {
// This is a part name (e.g., "ComponentName - PartName")
const partName = fullName.split(' - ').pop() || fullName;
// Always add the part, even if it has no properties
parts[partName] = metadata;
} else {
// This is an export name (no dash)
const exportName = fullName;
// Always add the export, even if it has no properties
exports[exportName] = metadata;
}
}
return {
exports: Object.keys(exports).length > 0 ? exports : undefined,
parts: Object.keys(parts).length > 0 ? parts : undefined
};
}
/**
* Parses a list of section links back into a HeadingHierarchy structure
* Expects list items with links in the format: [Title](path#slug)
* OR plain text in the format: Title
*/
function parseHeadingSections(listNode) {
const hierarchy = {};
const stack = [{
depth: -1,
node: hierarchy
}];
// Helper to calculate depth from list nesting
function processListItems(items, baseDepth, parentIsOrdered = false, startIndex = 1) {
for (let i = 0; i < items.length; i += 1) {
const item = items[i];
if (item.type !== 'listItem') {
continue;
}
// Find the paragraph content
const itemParagraph = item.children?.find(child => child.type === 'paragraph');
if (!itemParagraph) {
continue;
}
// Try to find a link first (linked format)
const itemLink = itemParagraph?.children?.find(child => child.type === 'link');
let title = '';
let titleMarkdown = [];
let slug = '';
if (itemLink) {
// Linked format: [Title](path#slug)
title = itemLink.children?.[0]?.value || '';
// Strip position metadata from titleMarkdown
titleMarkdown = stripPositions(itemLink.children || []);
const url = itemLink.url || '';
slug = url.split('#')[1] || '';
} else {
// Plain text format: extract all children (preserves formatting)
// Strip position metadata from titleMarkdown
titleMarkdown = stripPositions(itemParagraph.children || []);
// Extract plain text for slug generation
let rawTitle = itemParagraph.children.map(child => {
if (child.type === 'text') {
return child.value;
}
if (child.type === 'inlineCode') {
return child.value;
}
if ('children' in child) {
// Recursively extract text from nested nodes
return astNodesToMarkdown(child.children).replace(/[*`_]/g, '');
}
return '';
}).join('').trim();
// Unescape numbered list syntax (e.g., "1\. Text" -> "1. Text")
// This handles titles that were escaped during serialization
rawTitle = rawTitle.replace(/^(\d+)\\\.\s/, '$1. ');
// If this is from an ordered list, prepend the number
if (parentIsOrdered) {
const itemNumber = startIndex + i;
title = `${itemNumber}. ${rawTitle}`;
// Update titleMarkdown to include the number
titleMarkdown = [{
type: 'text',
value: title
}];
} else {
title = rawTitle;
}
// Generate slug from the title (with number if applicable)
slug = title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
}
if (title && slug && titleMarkdown.length > 0) {
// Pop stack until we find the parent at the right depth
while (stack.length > 0 && stack[stack.length - 1].depth >= baseDepth) {
stack.pop();
}
const parent = stack[stack.length - 1].node;
const newNode = {
title,
titleMarkdown,
children: {}
};
parent[slug] = newNode;
stack.push({
depth: baseDepth,
node: newNode.children
});
// Check for nested lists (can be ordered or unordered)
const nestedLists = item.children?.filter(child => child.type === 'list');
for (const nestedList of nestedLists || []) {
if (nestedList.children) {
const nestedIsOrdered = nestedList.ordered === true;
const nestedStart = nestedList.start || 1;
// Always increment depth for true nesting
processListItems(nestedList.children, baseDepth + 1, nestedIsOrdered, nestedStart);
}
}
}
}
}
if (listNode?.type === 'list' && listNode.children) {
processListItems(listNode.children, 0);
}
return hierarchy;
} /**
* Converts an array of page metadata into a markdown AST
*/
export function metadataToMarkdownAst(data, options = {}) {
const {
editableMarker,
indexWrapperComponent,
path
} = options;
const {
title: mainTitle,
description: mainDescription,
pages,
pageMetadata
} = data;
const children = [];
// Add main title
children.push(heading(1, mainTitle));
// Add description if provided (editable section)
if (mainDescription) {
children.push(paragraph(mainDescription));
}
// Add editable section marker
// Extract just the comment text from the marker (strip [//]: # 'text' wrapper)
const defaultMarkerText = 'This section is autogenerated, but the following list order, title, and [Tag]s can be modified, but nothing within the parentheses.';
let markerText = defaultMarkerText;
if (editableMarker) {
// Extract text between single quotes: [//]: # 'text'
const match = editableMarker.match(/\[\/\/\]: # '(.+)'/);
markerText = match ? match[1] : defaultMarkerText;
}
children.push(comment(markerText));
// Open wrapper component if provided
if (indexWrapperComponent) {
children.push({
type: 'html',
value: `<${indexWrapperComponent}>`
});
}
// Add page list (editable section) as proper list items
const listItems = [];
for (const page of pages) {
// List items use displayTitle when the user has overridden the title
const listTitle = page.displayTitle ?? page.title ?? page.slug;
// Check if this is a single-link entry (external link or no detail section)
const isSingleLink = page.skipDetailSection || false;
let paragraphChildren;
if (isSingleLink) {
// Format: - [Title](./path) [Tag1] [Tag2]
paragraphChildren = [link(page.path, listTitle)];
// Add tags if present (directly after link)
if (page.tags && page.tags.length > 0) {
for (const tag of page.tags) {
paragraphChildren.push(text(` [${tag}]`));
}
}
} else {
// Format: - Title [Tag1] [Tag2] - (StatusLabel, [Outline](#slug), [Contents](./path))
const statusLabel = getStatusLabel(page);
paragraphChildren = [text(listTitle)];
// Add tags if present (directly after component name)
if (page.tags && page.tags.length > 0) {
for (const tag of page.tags) {
paragraphChildren.push(text(` [${tag}]`));
}
}
// Add separator and parenthetical links (status label first if present)
paragraphChildren.push(text(statusLabel ? ` - (${statusLabel}, ` : ' - ('));
paragraphChildren.push(link(`#${page.slug}`, 'Outline'));
paragraphChildren.push(text(', '));
paragraphChildren.push(link(page.path, 'Contents'));
paragraphChildren.push(text(')'));
}
listItems.push({
type: 'listItem',
spread: false,
children: [paragraph(paragraphChildren)]
});
}
// Add the list to children
children.push({
type: 'list',
ordered: false,
spread: false,
children: listItems
});
// Add non-editable section marker
// Trim common prefixes and suffixes from path, quote if contains parentheses for shell safety
const normalizedPath = typeof path === 'string' ? path.replace(/\\/g, '/') : undefined;
const trimmedPath = normalizedPath?.replace(/^(src\/app\/|app\/)/, '').replace(/\/page\.mdx$/, '');
const quotedPath = trimmedPath && /[()]/.test(trimmedPath) ? `"${trimmedPath}"` : trimmedPath;
const doNotEditComment = quotedPath ? `This section is autogenerated, DO NOT EDIT AFTER THIS LINE, run: pnpm docs:validate ${quotedPath}` : 'This section is autogenerated, DO NOT EDIT AFTER THIS LINE';
children.push(comment(doNotEditComment));
// Add detailed page sections (non-editable)
for (const page of pages) {
const pageTitle = page.title || page.slug;
// Note: We don't replace newlines here to allow natural line breaks in detailed sections
const description = page.description || 'No description available';
const keywords = page.keywords || [];
const image = page.image;
// Add page heading
children.push(heading(2, pageTitle));
// Add description
children.push(paragraph(description));
// Add image if available
if (image) {
children.push({
type: 'paragraph',
children: [{
type: 'image',
url: image.url,
alt: image.alt || pageTitle
}]
});
}
// Add metadata list (keywords, sections, parts, and exports combined)
const hasKeywords = keywords.length > 0;
const hasSections = page.sections && Object.keys(page.sections).length > 0;
const hasParts = page.parts && Object.keys(page.parts).length > 0;
const hasExports = page.exports && Object.keys(page.exports).length > 0;
const hasTypes = page.types && page.types.length > 0;
if (hasKeywords || hasSections || hasParts || hasExports || hasTypes) {
const metadataListItems = [];
if (hasKeywords) {
metadataListItems.push({
type: 'listItem',
children: [paragraph(`Keywords: ${keywords.join(', ')}`)]
});
}
if (hasSections && page.sections) {
const sectionListItems = headingHierarchyToListNodes(page.sections, page.path);
metadataListItems.push({
type: 'listItem',
children: [paragraph('Sections:'), {
type: 'list',
ordered: false,
children: sectionListItems
}]
});
}
if (hasParts || hasExports) {
const exportsListItems = [];
// First, add all parts with their metadata (use dash format)
if (hasParts && page.parts) {
for (const [partName, partMetadata] of Object.entries(page.parts)) {
const partListItems = [];
if (partMetadata.props && partMetadata.props.length > 0) {
partListItems.push({
type: 'listItem',
children: [paragraph(`Props: ${partMetadata.props.map(escapeUnderscores).join(', ')}`)]
});
}
if (partMetadata.dataAttributes && partMetadata.dataAttributes.length > 0) {
partListItems.push({
type: 'listItem',
children: [paragraph(`Data Attributes: ${partMetadata.dataAttributes.map(escapeUnderscores).join(', ')}`)]
});
}
if (partMetadata.cssVariables && partMetadata.cssVariables.length > 0) {
partListItems.push({
type: 'listItem',
children: [paragraph(`CSS Variables: ${partMetadata.cssVariables.map(escapeUnderscores).join(', ')}`)]
});
}
if (partMetadata.parameters && partMetadata.parameters.length > 0) {
const paramsText = partMetadata.parameters.map(p => Array.isArray(p) ? `(${p.map(escapeUnderscores).join(', ')})` : escapeUnderscores(p)).join(', ');
partListItems.push({
type: 'listItem',
children: [paragraph(`Parameters: ${paramsText}`)]
});
}
if (partMetadata.returns && partMetadata.returns.length > 0) {
partListItems.push({
type: 'listItem',
children: [paragraph(`Returns: ${partMetadata.returns.map(escapeUnderscores).join(', ')}`)]
});
}
// Add the part with dash separator
if (partListItems.length > 0) {
exportsListItems.push({
type: 'listItem',
children: [{
type: 'paragraph',
children: [{
type: 'text',
value: `${page.title} - ${partName}`
}]
}, {
type: 'list',
ordered: false,
children: partListItems
}]
});
} else {
// Part with no properties - just add the part name with dash
exportsListItems.push({
type: 'listItem',
children: [{
type: 'paragraph',
children: [{
type: 'text',
value: `${page.title} - ${partName}`
}]
}]
});
}
}
}
// Then add all exports with their metadata (no dash format)
if (hasExports && page.exports) {
for (const [exportName, exportMetadata] of Object.entries(page.exports)) {
const exportListItems = [];
if (exportMetadata.props && exportMetadata.props.length > 0) {
exportListItems.push({
type: 'listItem',
children: [paragraph(`Props: ${exportMetadata.props.map(escapeUnderscores).join(', ')}`)]
});
}
if (exportMetadata.parameters && exportMetadata.parameters.length > 0) {
const paramsText = exportMetadata.parameters.map(p => Array.isArray(p) ? `(${p.map(escapeUnderscores).join(', ')})` : escapeUnderscores(p)).join(', ');
exportListItems.push({
type: 'listItem',
children: [paragraph(`Parameters: ${paramsText}`)]
});
}
if (exportMetadata.returns && exportMetadata.returns.length > 0) {
exportListItems.push({
type: 'listItem',
children: [paragraph(`Returns: ${exportMetadata.returns.map(escapeUnderscores).join(', ')}`)]
});
}
if (exportMetadata.dataAttributes && exportMetadata.dataAttributes.length > 0) {
exportListItems.push({
type: 'listItem',
children: [paragraph(`Data Attributes: ${exportMetadata.dataAttributes.map(escapeUnderscores).join(', ')}`)]
});
}
if (exportMetadata.cssVariables && exportMetadata.cssVariables.length > 0) {
exportListItems.push({
type: 'listItem',
children: [paragraph(`CSS Variables: ${exportMetadata.cssVariables.map(escapeUnderscores).join(', ')}`)]
});
}
// Always add the export, even if it has no properties
if (exportListItems.length > 0) {
exportsListItems.push({
type: 'listItem',
children: [{
type: 'paragraph',
children: [{
type: 'text',
value: exportName
}]
}, {
type: 'list',
ordered: false,
children: exportListItems
}]
});
} else {
// Export with no properties - just add the export name
exportsListItems.push({
type: 'listItem',
children: [{
type: 'paragraph',
children: [{
type: 'text',
value: exportName
}]
}]
});
}
}
}
if (exportsListItems.length > 0) {
metadataListItems.push({
type: 'listItem',
children: [paragraph('Exports:'), {
type: 'list',
ordered: false,
children: exportsListItems
}]
});
}
}
if (hasTypes && page.types) {
metadataListItems.push({
type: 'listItem',
children: [paragraph(`Types: ${page.types.join(', ')}`)]
});
}
// Wrap metadata in details/summary tags
children.push({
type: 'html',
value: '<details>'
});
children.push(paragraph(''));
children.push({
type: 'html',
value: '<summary>Outline</summary>'
});
children.push(paragraph(''));
children.push({
type: 'list',
ordered: false,
children: metadataListItems
});
children.push(paragraph(''));
children.push({
type: 'html',
value: '</details>'
});
}
// Add embeddings as a comment if available
if (page.embeddings && page.embeddings.length > 0) {
children.push(comment(`Embeddings: ${JSON.stringify(page.embeddings)}`));
}
// Add read more link
children.push(paragraph([link(page.path, 'Read more')]));
}
// Add metadata marker (inside wrapper component if provided)
children.push(comment('The above section is autogenerated, but the remainder of the file can be modified.'));
// Close wrapper component if provided
if (indexWrapperComponent) {
children.push({
type: 'html',
value: `</${indexWrapperComponent}>`
});
}
const metadataObj = pageMetadata && Object.keys(pageMetadata).length > 0 ? pageMetadata : {
robots: {
index: false
},
other: {
audience: 'private'
}
};
const typeAnnotation = "/** @type {import('@mui/internal-docs-infra/createSitemap/types').NextMetadata} */";
const metadataCode = `export const metadata =\n ${typeAnnotation} (${serializeJsValue(metadataObj, 1)});`;
// Output as raw MDX/JSX code (mdxjsEsm node type)
children.push({
type: 'mdxjsEsm',
value: metadataCode
});
return {
type: 'root',
children
};
}
/**
* Converts an array of page metadata into the markdown format (string)
*/
export function metadataToMarkdown(data, options = {}) {
// Support legacy signature where second param was editableMarker string
const normalizedOptions = typeof options === 'string' ? {
editableMarker: options
} : options;
const {
editableMarker,
indexWrapperComponent,
path
} = normalizedOptions;
const {
title,
description,
pages,
pageMetadata
} = data;
const lines = [];
// Add main title
lines.push(`# ${title}`);
lines.push('');
// Add description if provided (editable section)
if (description) {
lines.push(description);
lines.push('');
}
// Add editable section marker
const marker = editableMarker ?? "[//]: # 'This section is autogenerated, but the following list order, title, and [Tag]s can be modified, but nothing within the parentheses.'";
lines.push(marker);
lines.push('');
// Open wrapper component if provided
if (indexWrapperComponent) {
lines.push(`<${indexWrapperComponent}>`);
lines.push('');
}
// Add page list (editable section)
for (const page of pages) {
// List items use displayTitle when the user has overridden the title
const listTitle = page.displayTitle ?? page.title ?? page.slug;
// Check if this is a single-link entry (external link or no detail section)
const isSingleLink = page.skipDetailSection || false;
let line;
if (isSingleLink) {
// Format: - [Title](./path) [Tag1] [Tag2]
line = `- [${listTitle}](${page.path})`;
// Add tags if present (directly after link)
if (page.tags && page.tags.length > 0) {
for (const tag of page.tags) {
line += ` [${tag}]`;
}
}
} else {
// Format: - Title [Tag1] [Tag2] - (StatusLabel, [Outline](#slug), [Contents](./path))
const statusLabel = getStatusLabel(page);
line = `- ${listTitle}`;
// Add tags if present (directly after component name)
if (page.tags && page.tags.length > 0) {
for (const tag of page.tags) {
line += ` [${tag}]`;
}
}
// Add separator and parenthetical links (status label first if present)
line += statusLabel ? ` - (${statusLabel}, [Outline](#${page.slug}), [Contents](${page.path}))` : ` - ([Outline](#${page.slug}), [Contents](${page.path}))`;
}
lines.push(line);
}
lines.push('');
// Add non-editable section marker
// Trim common prefixes and suffixes from path, quote if contains parentheses for shell safety
const normalizedPath = typeof path === 'string' ? path.replace(/\\/g, '/') : undefined;
const trimmedPath = normalizedPath?.replace(/^(src\/app\/|app\/)/, '').replace(/\/page\.mdx$/, '');
const quotedPath = trimmedPath && /[()]/.test(trimmedPath) ? `"${trimmedPath}"` : trimmedPath;
const doNotEditMarker = quotedPath ? `[//]: # 'This section is autogenerated, DO NOT EDIT AFTER THIS LINE, run: pnpm docs:validate ${quotedPath}'` : "[//]: # 'This section is autogenerated, DO NOT EDIT AFTER THIS LINE'";
lines.push(doNotEditMarker);
lines.push('');
// Add detailed page sections (non-editable)
for (const page of pages) {
// Skip detail section for single-link entries (external links)
if (page.skipDetailSection) {
continue;
}
const pageTitle = page.title || page.slug;
// Use descriptionMarkdown to preserve formatting if available
// Note: We don't replace newlines here to allow natural line breaks in detailed sections
let pageDescription;
if (page.descriptionMarkdown && page.descriptionMarkdown.length > 0) {
pageDescription = astNodesToMarkdown(page.descriptionMarkdown);
} else {
pageDescription = page.description || 'No description available';
}
const keywords = page.keywords || [];
const image = page.image;
// Add page heading
lines.push(`## ${pageTitle}`);
lines.push('');
// Add description
lines.push(pageDescription);
lines.push('');
// Add image if available
if (image) {
lines.push(``);
lines.push('');
}
// Add metadata list (keywords, sections, parts, and exports)
const hasKeywords = keywords.length > 0;
const hasSections = page.sections && Object.keys(page.sections).length > 0;
const hasParts = page.parts && Object.keys(page.parts).length > 0;
const hasExports = page.exports && Object.keys(page.exports).length > 0;
const hasTypes = page.types && page.types.length > 0;
// Track if we actually add any metadata content
let hasMetadataContent = false;
if (hasKeywords || hasSections || hasParts || hasExports || hasTypes) {
lines.push('<details>');
lines.push('');
lines.push('<summary>Outline</summary>');
lines.push('');
if (hasKeywords) {
lines.push(`- Keywords: ${keywords.join(', ')}`);
hasMetadataContent = true;
}
if (hasSections && page.sections) {
const sectionLines = headingHierarchyToMarkdown(page.sections, page.path, 1); // Start at depth 1 for indentation
lines.push('- Sections:');
lines.push(sectionLines.trimEnd());
hasMetadataContent = true;
}
// Handle both parts and exports
// Parts and exports are combined into a single "Exports:" section
// Parts use format "ComponentName - PartName" (written with dash)
// Exports use just "ExportName" (written without dash)
if (hasParts || hasExports) {
lines.push('- Exports:');
// First, list all parts with their metadata (use dash format)
if (hasParts && page.parts) {
for (const [partName, partMetadata] of Object.entries(page.parts)) {
const hasProps = partMetadata.props && partMetadata.props.length > 0;
const hasDataAttributes = partMetadata.dataAttributes && partMetadata.dataAttributes.length > 0;
const hasCssVariables = partMetadata.cssVariables && partMetadata.cssVariables.length > 0;
const hasParameters = partMetadata.parameters && partMetadata.parameters.length > 0;
lines.push(` - ${page.title} - ${partName}`);
if (hasProps) {
lines.push(` - Props: ${partMetadata.props.map(escapeUnderscores).join(', ')}`);
}
if (hasDataAttributes) {
lines.push(` - Data Attributes: ${partMetadata.dataAttributes.map(escapeUnderscores).join(', ')}`);
}
if (hasCssVariables) {
lines.push(` - CSS Variables: ${partMetadata.cssVariables.map(escapeUnderscores).join(', ')}`);
}
if (hasParameters) {
const paramsText = partMetadata.parameters.map(p => Array.isArray(p) ? `(${p.map(escapeUnderscores).join(', ')})` : escapeUnderscores(p)).join(', ');
lines.push(` - Parameters: ${paramsText}`);
}
if (partMetadata.returns && partMetadata.returns.length > 0) {
lines.push(` - Returns: ${partMetadata.returns.map(escapeUnderscores).join(', ')}`);
}
}
}
// Then list all exports with their metadata (no dash format)
if (hasExports && page.exports) {
for (const [exportName, exportMetadata] of Object.entries(page.exports)) {
const hasProps = exportMetadata.props && exportMetadata.props.length > 0;
const hasDataAttributes = exportMetadata.dataAttributes && exportMetadata.dataAttributes.length > 0;
const hasCssVariables = exportMetadata.cssVariables && exportMetadata.cssVariables.length > 0;
const hasParameters = exportMetadata.parameters && exportMetadata.parameters.length > 0;
lines.push(` - ${exportName}`);
if (hasProps) {
lines.push(` - Props: ${exportMetadata.props.map(escapeUnderscores).join(', ')}`);
}
if (hasParameters) {
const paramsText = exportMetadata.parameters.map(p => Array.isArray(p) ? `(${p.map(escapeUnderscores).join(', ')})` : escapeUnderscores(p)).join(', ');
lines.push(` - Parameters: ${paramsText}`);
}
if (exportMetadata.returns && exportMetadata.returns.length > 0) {
lines.push(` - Returns: ${exportMetadata.returns.map(escapeUnderscores).join(', ')}`);
}
if (hasDataAttributes) {
lines.push(` - Data Attributes: ${exportMetadata.dataAttributes.map(escapeUnderscores).join(', ')}`);
}
if (hasCssVariables) {
lines.push(` - CSS Variables: ${exportMetadata.cssVariables.map(escapeUnderscores).join(', ')}`);
}
}
}
hasMetadataContent = true;
}
if (hasTypes && page.types) {
lines.push(`- Types: ${page.types.join(', ')}`);
hasMetadataContent = true;
}
lines.push('');
lines.push('</details>');
// Only add blank line if we actually added metadata content
if (hasMetadataContent) {
lines.push('');
}
}
// Add embeddings as a comment if available
if (page.embeddings && page.embeddings.length > 0) {
lines.push(`[//]: # 'Embeddings: ${JSON.stringify(page.embeddings)}'`);
lines.push('');
}
// Add read more link
lines.push(`[Read more](${page.path})`);
lines.push('');
}
// Add metadata marker (inside wrapper component if provided)
lines.push("[//]: # 'The above section is autogenerated, but the remainder of the file can be modified.'");
lines.push('');
// Close wrapper component if provided
if (indexWrapperComponent) {
lines.push(`</${indexWrapperComponent}>`);
lines.push('');
}
const TYPE_ANNOTATION = "/** @type {import('@mui/internal-docs-infra/createSitemap/types').NextMetadata} */";
if (pageMetadata && Object.keys(pageMetadata).length > 0) {
lines.push(`export const metadata =\n ${TYPE_ANNOTATION} (${serializeJsValue(pageMetadata, 1)});`);
} else {
lines.push(`export const metadata =\n ${TYPE_ANNOTATION} (${serializeJsValue({
robots: {
index: false
},
other: {
audience: 'private'
}
}, 1)});`);
}
lines.push('');
// Remove trailing empty line
return `${lines.join('\n').trimEnd()}\n`;
}
/**
* Parses markdown content and extracts page metadata using unified
*/
export async function markdownToMetadata(markdown) {
const tree = unified().use(remarkParse).parse(markdown);
let title = null;
let description;
let pageMetadata;
let indexWrapperComponent;
const pages = [];
// Track the title from the editable list for each page (by slug)
// Used to detect user overrides after the detail section is parsed
const listTitles = new Map();
let currentSection = 'header';
let currentPage = null;
// Visit all nodes in the AST
visit(tree, (node, index, parent) => {
// Track sections based on definition nodes (HTML-style comments)
if (node.type === 'definition') {
const defNode = node;
if (defNode.title?.includes('following list can be modified') || defNode.title?.includes('following list order')) {
currentSection = 'editable';
return;
}
if (defNode.title?.includes('DO NOT EDIT AFTER THIS LINE')) {
currentSection = 'details';
return;
}
if (defNode.title?.includes('remainder of the file can be modified') ||
// TODO: Remove this old marker check once all index files have been migrated to the new format.
defNode.title?.includes('following metadata can be modified')) {
currentSection = 'metadata';
return;
}
// Parse embeddings from comment
if (currentPage && defNode.title?.includes('Embeddings:')) {
const embeddingsText = defNode.title.replace('Embeddings:', '').trim();
try {
currentPage.embeddings = JSON.parse(embeddingsText);
} catch (error) {
console.error('Failed to parse embeddings:', error);
}
return;
}
}
// Extract wrapper component from HTML nodes (e.g., <PagesIndex>)
if (node.type === 'html' && !indexWrapperComponent) {
const htmlNode = node;
// Match opening tag like <PagesIndex> or <ComponentsIndex>
const openingTagMatch = htmlNode.value.match(/^<([A-Z][a-zA-Z0-9]*)>$/);
if (openingTagMatch) {
indexWrapperComponent = openingTagMatch[1];
}
}
// Extract main title (H1)
if (node.type === 'heading') {
const headingNode = node;
if (headingNode.depth === 1) {
title = extractPlainTextFromNode(headingNode);
currentSection = 'header';
return;
}
}
// Parse description in header section (paragraph after H1, before editable marker)
if (currentSection === 'header' && node.type === 'paragraph' && parent?.type === 'root') {
const paragraphNode = node;
const paragraphText = extractPlainTextFromNode(paragraphNode);
if (paragraphText && !description) {
description = paragraphText;
}
return;
}
// Parse editable list items - check if we're in a paragraph that's a child of a listItem
if (currentSection === 'editable' && node.type === 'paragraph' && parent?.type === 'listItem') {
const paragraphNode = node;
if (paragraphNode.children) {
// Look for links in the paragraph
const links = paragraphNode.children.filter(child => child.type === 'link');
if (links.length === 0) {
// No links found, skip this item
return;
}
if (links.length === 1) {
// Single link format: - [Title](./path) [Tag1] [Tag2]
// This is for external links or pages that don't have detail sections
const singleLink = links[0];
const pageTitle = extractPlainTextFromNode(singleLink);
const path = singleLink.url;
// Generate slug from title for consistency
const slug = titleToSlug(pageTitle);
// Extract tags from text nodes after the link
// Tags are in the format [Tag] where Tag can be New, Hot, Beta, External, etc.
const tags = [];
let foundLink = false;
for (const child of paragraphNode.children) {
if (child === singleLink) {
foundLink = true;
continue;
}
if (foundLink && child.type === 'text') {
// Match [Tag] patterns in the text
const tagRegex = /\[(\w+)\]/g;
let match = tagRegex.exec(child.value);
while (match !== null) {
tags.push(match[1]);
match = tagRegex.exec(child.value);
}
}
}
// These entries are preserved as-is in the editable section
// They won't have detail sections generated
pages.push({
slug,
path,
title: pageTitle,
description: 'No description available',
tags: tags.length > 0 ? tags : undefined,
skipDetailSection: true // Mark as external/single-link entry
});
listTitles.set(slug, pageTitle);
} else if (links.length >= 2) {
const firstChild = paragraphNode.children[0];
if (firstChild && firstChild.type === 'text' && firstChild.value.includes(' - (')) {
// Format: Title [Tags] - (Status, [Outline](#slug), [Contents](./path))
const firstText = firstChild.value;
const dashParenIndex = firstText.lastIndexOf(' - (');
const titlePart = firstText.substring(0, dashParenIndex);
// Extract tags from title part
const tags = [];
const tagRegex = /\[([^\]]+)\]/g;
let match = tagRegex.exec(titlePart);
while (match !== null) {
tags.push(match[1]);
match = tagRegex.exec(titlePart);
}
const cleanTitle = titlePart.replace(/\s*\[[^\]]+\]/g, '').trim();
// Find Outline and Contents links
const outlineLink = links.find(l => extractPlainTextFromNode(l) === 'Outline' || l.url.startsWith('#'));
const contentsLink = links.find(l => extractPlainTextFromNode(l) === 'Contents' || !l.url.startsWith('#'));
if (outlineLink && contentsLink) {
const slug = outlineLink.url.replace('#', '');
const pagePath = contentsLink.url;
// Parse status label from the text before the first link
// Format: " - (Status, " or " - ("
let parsedAudience;
let isIndex = false;
const afterDashParen = firstText.substring(dashParenIndex + 4); // after ' - ('
if (afterDashParen.length > 0) {
// Status label is the text before the first comma that precedes a link
const commaIndex = afterDashParen.indexOf(',');
if (commaIndex >= 0) {
const statusStr = afterDashParen.substring(0, commaIndex).trim();
if (statusStr.length > 0) {
const status = parseStatusLabel(statusStr);
parsedAudience = status.audience;
isIndex = status.index;
}
}
}
pages.push({
slug,
path: pagePath,
title: cleanTitle,
description: 'No description available',
tags: tags.length > 0 ? tags : undefined,
audience: parsedAudience,
index: isIndex || undefined
});
listTitles.set(slug, cleanTitle);
}
} else {
// TODO: Remove this old format parsing once all index files have been migrated.
// Old format: - [Title](#slug) [Tag1] [Tag2] - [Full Docs](./path/page.mdx)
const sectionLink = links[0];
const docsLink = links[1];
const pageTitle = extractPlainTextFromNode(sectionLink);
const slug = sectionLink.url.replace('#', '');
const pagePath = docsLink.url;
const tags = [];
let foundSectionLink = false;
let foundDocsLink = false;
for (const child of paragraphNode.children) {
if (child === sectionLink) {
foundSectionLink = true;
continue;
}
if (child === docsLink) {
foundDocsLink = true;
break;
}
if (foundSectionLink && !foundDocsLink && child.type === 'text') {
const tagRegex = /\[(\w+)\]/g;
let match = tagRegex.exec(child.value);
while (match !== null) {
tags.push(match[1]);
match = tagRegex.exec(child.value);
}
}
}
pages.push({
slug,
path: pagePath,
title: pageTitle,
description: 'No description available',
tags: tags.length > 0 ? tags : undefined
});
listTitles.set(slug, pageTitle);
}
}
}
return;
}
// Parse detail sections
if (currentSection === 'details') {
// Start of a new page section (H2)
if (node.type === 'heading') {
const headingNode = node;
if (headingNode.depth === 2) {
// Save previous page if exists
if (currentPage?.slug) {
const savedSlug = currentPage.slug;
const foundIndex = pages.findIndex(c => c.slug === savedSlug);
if (foundIndex !== -1) {
pages[foundIndex] = {
...pages[foundIndex],
...currentPage
};
}
}
const pageTitle = extractPlainTextFromNode(headingNode);
// Find the page in the existing pages array by matching the title first,
// then fall back to slug matching. Slug matching is needed when the user
// has renamed the list item (so list title ≠ heading title).
let existingPage = pages.find(p => p.title === pageTitle);
if (!existingPage) {
const derivedSlug = titleToSlug(pageTitle);
existingPage = pages.find(p => p.slug === derivedSlug);
}
if (existingPage) {
// Start updating this existing page
currentPage = {
slug: existingPage.slug,