UNPKG

@mui/internal-docs-infra

Version:

MUI Infra - internal documentation creation tools.

1,369 lines (1,277 loc) 59.3 kB
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(`![${image.alt || pageTitle}](${image.url})`); 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,