UNPKG

@mui/internal-docs-infra

Version:

MUI Infra - internal documentation creation tools.

846 lines (790 loc) 32.7 kB
import { visit } from 'unist-util-visit'; import { dirname, relative } from 'node:path'; import { syncPageIndex } from "../syncPageIndex/index.mjs"; import { markdownToMetadata } from "../syncPageIndex/metadataToMarkdown.mjs"; /** * Extracts text content from paragraph nodes */ function extractParagraphText(node) { let text = ''; for (const child of node.children) { if (child.type === 'text') { text += child.value; } else if (child.type === 'inlineCode') { // Preserve backticks for inline code text += `\`${child.value}\``; } else if ('children' in child) { // Handle nested elements like strong, emphasis, etc. text += extractTextFromChildren(child.children); } } // Replace newlines with spaces and normalize whitespace, preserving non-breaking spaces return text.replace(/[ \t\n\r]+/g, ' ').trim(); } /** * Recursively extracts text from children nodes */ function extractTextFromChildren(children) { let text = ''; for (const child of children) { if (child.type === 'text') { text += child.value; } else if (child.type === 'inlineCode') { // Preserve backticks for inline code text += `\`${child.value}\``; } else if ('children' in child) { // Recursively extract from nested elements (strong, emphasis, etc.) text += extractTextFromChildren(child.children); } } return text; } /** * Extracts plain text from children nodes (without formatting markers) */ function extractPlainTextFromChildren(children) { let text = ''; for (const child of children) { if (child.type === 'text') { text += child.value; } else if (child.type === 'inlineCode') { // Include inline code without backticks text += child.value; } else if ('children' in child) { // Recursively extract from nested elements text += extractPlainTextFromChildren(child.children); } } return text; } /** * Slugify function for generating URL-friendly slugs from heading text. * Lowercases text and replaces non-alphanumeric characters with hyphens. */ function slugify(text) { return text.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, ''); } /** * Builds a hierarchical structure from flat headings array * Skips the first H1 (page title) and starts from H2 */ function buildHeadingHierarchy(headings) { // Skip the first heading (H1 - page title) const contentHeadings = headings.slice(1); if (contentHeadings.length === 0) { return {}; } const root = {}; const stack = [{ depth: 0, node: root }]; for (const heading of contentHeadings) { // Pop from stack until we find the parent level while (stack.length > 0 && stack[stack.length - 1].depth >= heading.depth) { stack.pop(); } // Get the current parent node const parent = stack[stack.length - 1].node; // Create slug from heading text using the provided slugify function const slug = slugify(heading.text); // Create new node for this heading const newNode = { title: heading.text, // Plain text for display titleMarkdown: heading.children, // AST nodes preserving formatting children: {} }; parent[slug] = newNode; // Push this heading onto the stack stack.push({ depth: heading.depth, node: newNode.children }); } return root; } /** * Parses the metadata object from an ESTree node */ function parseMetadataFromEstree(estree) { try { const body = estree?.body || []; for (const node of body) { if (node.type === 'ExportNamedDeclaration' && node.declaration?.type === 'VariableDeclaration') { const declarations = node.declaration.declarations || []; for (const decl of declarations) { if (decl.id?.type === 'Identifier' && decl.id.name === 'metadata' && decl.init?.type === 'ObjectExpression') { // Convert ESTree ObjectExpression to plain object return convertEstreeObjectToPlain(decl.init); } } } } } catch (error) { console.error('Error parsing metadata from estree:', error); } return null; } /** * Converts an ESTree ObjectExpression to a plain JavaScript object */ function convertEstreeObjectToPlain(node) { if (typeof node === 'object' && node !== null && 'type' in node && node.type === 'ObjectExpression') { const obj = {}; if ('properties' in node && Array.isArray(node.properties)) { for (const prop of node.properties) { if (typeof prop === 'object' && prop !== null && 'type' in prop && prop.type === 'Property' && 'key' in prop && prop.key) { let key = ''; if ('name' in prop.key && typeof prop.key.name === 'string') { key = prop.key.name; } else if ('value' in prop.key) { key = String(prop.key.value); } if (key && 'value' in prop) { obj[key] = convertEstreeObjectToPlain(prop.value); } } } } return obj; } if (typeof node === 'object' && node !== null && 'type' in node && node.type === 'ArrayExpression' && 'elements' in node && Array.isArray(node.elements)) { return node.elements.map(el => convertEstreeObjectToPlain(el)); } if (typeof node === 'object' && node !== null && 'type' in node && node.type === 'Literal' && 'value' in node) { return node.value; } if (typeof node === 'object' && node !== null && 'type' in node && node.type === 'Identifier' && 'name' in node) { return node.name; } return undefined; } /** * Checks if a directory name is a Next.js route group (wrapped in parentheses) */ function isRouteGroup(dirName) { return dirName.startsWith('(') && dirName.endsWith(')'); } /** * Gets the parent directory, skipping over Next.js route groups if requested */ function getParentDir(path, skipRouteGroups = false) { let parent = dirname(path); if (skipRouteGroups) { while (parent !== dirname(parent) && isRouteGroup(parent.split('/').pop() || '')) { parent = dirname(parent); } } return parent; } /** * Converts extracted metadata to PageMetadata format for index updates */ function toPageMetadata(metadata, filePath, options = {}) { // Extract the slug from the file path (directory name containing the page) const parts = filePath.split('/'); const pageFileName = parts[parts.length - 1]; // e.g., 'page.mdx' // Get the directory containing the page file const pageDir = dirname(filePath); // Find the directory name by looking backwards for the first non-route-group directory let dirName = ''; for (let i = parts.length - 2; i >= 0; i -= 1) { if (!isRouteGroup(parts[i])) { dirName = parts[i]; break; } } // Generate slug from title if available, otherwise from directory name // This ensures consistency with section slug generation (kebab-case for multi-word, lowercase for camelCase) const slug = metadata.title ? metadata.title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') : dirName; // Calculate parent directory (skipping route groups, matching syncPageIndex behavior) const parentDir = getParentDir(pageDir, true); // Create relative path from parent to page const relativePath = relative(parentDir, pageDir); const path = `./${relativePath}/${pageFileName}`; return { slug, path, title: metadata.title, description: options.visibleDescription ?? metadata.description, descriptionMarkdown: options.visibleDescriptionMarkdown ?? metadata.descriptionMarkdown, keywords: metadata.keywords, sections: metadata.sections, embeddings: metadata.embeddings, image: metadata.image, audience: metadata.other?.audience }; } /** * Converts a plain value to an ESTree literal or expression */ function valueToEstree(value) { if (value === null || value === undefined) { return { type: 'Literal', value: null }; } if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { return { type: 'Literal', value }; } if (Array.isArray(value)) { return { type: 'ArrayExpression', elements: value.map(item => valueToEstree(item)) }; } if (typeof value === 'object') { return { type: 'ObjectExpression', properties: Object.entries(value).filter(([, val]) => val !== undefined).map(([key, val]) => { // Check if key is a valid JavaScript identifier const isValidIdentifier = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(key); return { type: 'Property', key: isValidIdentifier ? { type: 'Identifier', name: key } : { type: 'Literal', value: key }, value: valueToEstree(val), kind: 'init', method: false, shorthand: false, computed: false }; }) }; } return { type: 'Literal', value: null }; } /** * Updates the metadata object in an existing ESTree */ function updateMetadataInEstree(estree, metadata) { const body = estree?.body || []; for (const node of body) { if (node.type === 'ExportNamedDeclaration' && node.declaration?.type === 'VariableDeclaration') { const declarations = node.declaration.declarations || []; for (const decl of declarations) { if (decl.id?.type === 'Identifier' && decl.id.name === 'metadata' && decl.init?.type === 'ObjectExpression') { // Merge new metadata into existing object const existingProps = decl.init.properties || []; const existingKeys = new Set(existingProps.filter(prop => prop.type === 'Property').map(prop => { if ('name' in prop.key) { return prop.key.name; } if ('value' in prop.key) { return String(prop.key.value || ''); } return ''; })); // Add missing properties const metadataObj = valueToEstree(metadata); if (typeof metadataObj === 'object' && metadataObj !== null && 'properties' in metadataObj && Array.isArray(metadataObj.properties)) { for (const prop of metadataObj.properties) { if (typeof prop === 'object' && prop !== null && 'key' in prop && typeof prop.key === 'object' && prop.key !== null && 'name' in prop.key) { const key = prop.key.name; if (key && !existingKeys.has(String(key))) { existingProps.push(prop); } } } } return; } } } } } /** * Creates a new metadata export node */ function createMetadataExport(metadata) { const metadataObj = valueToEstree(metadata); return { type: 'mdxjsEsm', value: '', data: { estree: { type: 'Program', sourceType: 'module', body: [{ type: 'ExportNamedDeclaration', declaration: { type: 'VariableDeclaration', declarations: [{ type: 'VariableDeclarator', id: { type: 'Identifier', name: 'metadata' }, init: metadataObj }], kind: 'const' }, specifiers: [], source: null, attributes: [] }] } } }; } export const transformMarkdownMetadata = (options = {}) => { return async (tree, file) => { const root = tree; const headings = []; let metadata = null; let firstH1 = null; let firstParagraphAfterH1 = null; let metadataNode = null; // Track the ESM node containing metadata let metaDescription = null; // Track meta tag description let metaKeywords = null; // Track meta tag keywords let foundFirstH1 = false; let nextNodeAfterH1 = null; let firstParagraphMarkdown; const fullTextParts = []; // Collect text parts for fullText - headings and content alternating // Single pass: extract metadata export, meta tags, headings, first paragraph, and full text visit(root, (node, index, parent) => { // Extract metadata export if it exists if (node.type === 'mdxjsEsm' && 'data' in node && node.data && typeof node.data === 'object' && 'estree' in node.data && node.data.estree) { const extracted = parseMetadataFromEstree(node.data.estree); if (extracted) { metadata = extracted; metadataNode = node; } } // Look for meta tags (can appear anywhere in the document) if ((node.type === 'mdxJsxFlowElement' || node.type === 'mdxJsxTextElement') && 'name' in node && (node.name === 'meta' || node.name === 'Meta')) { // Check attributes to find the meta tag type const attributes = 'attributes' in node && Array.isArray(node.attributes) ? node.attributes : []; let metaName = null; let contentValue = null; for (const attr of attributes) { if (typeof attr === 'object' && attr !== null && 'type' in attr && attr.type === 'mdxJsxAttribute' && 'name' in attr) { if (attr.name === 'name' && 'value' in attr) { metaName = typeof attr.value === 'string' ? attr.value : null; } if (attr.name === 'content' && 'value' in attr) { // Extract the content value if (typeof attr.value === 'string') { contentValue = attr.value; } else if (typeof attr.value === 'object' && attr.value !== null && 'type' in attr.value && attr.value.type === 'mdxJsxAttributeValueExpression' && 'value' in attr.value && typeof attr.value.value === 'string') { // Handle expression values if needed contentValue = attr.value.value; } } } } // Process based on meta tag name if (metaName === 'description' && contentValue) { metaDescription = contentValue; } else if (metaName === 'keywords' && contentValue) { // Parse keywords CSV into array metaKeywords = contentValue.split(',').map(keyword => keyword.trim()); } } // Extract headings if (node.type === 'heading') { const heading = node; const text = extractTextFromChildren(heading.children); headings.push({ depth: heading.depth, text, children: heading.children // Preserve AST nodes for formatting }); // Track first h1 if (heading.depth === 1 && !foundFirstH1) { firstH1 = text; foundFirstH1 = true; // Mark that we need to check the next node if (parent && index !== undefined && 'children' in parent && Array.isArray(parent.children)) { if (index + 1 < parent.children.length) { nextNodeAfterH1 = parent.children[index + 1]; } } } } // Check if this is the paragraph right after the first h1 if (foundFirstH1 && !firstParagraphAfterH1 && nextNodeAfterH1 && node === nextNodeAfterH1 && node.type === 'paragraph') { const paragraphNode = node; firstParagraphAfterH1 = extractParagraphText(paragraphNode); // If the paragraph contains a single JSX element wrapper, unwrap it // This handles cases like <Description>text</Description> if (paragraphNode.children.length === 1 && paragraphNode.children[0].type === 'mdxJsxTextElement' && 'children' in paragraphNode.children[0] && Array.isArray(paragraphNode.children[0].children)) { // Use the children of the JSX element instead of the wrapper firstParagraphMarkdown = paragraphNode.children[0].children; } else { // Preserve AST nodes for formatting firstParagraphMarkdown = paragraphNode.children; } nextNodeAfterH1 = null; // Clear the marker } // Check if this is a JSX flow element right after the first h1 (multi-line JSX like <Subtitle>...</Subtitle>) // This handles cases where the JSX element spans multiple lines and becomes a block-level element if (foundFirstH1 && !firstParagraphAfterH1 && nextNodeAfterH1 && node === nextNodeAfterH1 && node.type === 'mdxJsxFlowElement' && 'name' in node && node.name !== 'meta' && node.name !== 'Meta' && 'children' in node && Array.isArray(node.children)) { // Extract text from the JSX element's children, preserving non-breaking spaces const textContent = extractPlainTextFromChildren(node.children).replace(/[ \t\n\r]+/g, ' ').trim(); if (textContent) { firstParagraphAfterH1 = textContent; firstParagraphMarkdown = node.children; } nextNodeAfterH1 = null; // Clear the marker } // Extract full text content (excluding code blocks and metadata) // When we encounter a heading, push the heading text and then create a new empty slot for content if (node.type === 'heading') { const heading = node; // Preserve non-breaking spaces in heading text const text = extractPlainTextFromChildren(heading.children).replace(/[ \t\n\r]+/g, ' ').trim(); if (text) { fullTextParts.push(text); // Add heading fullTextParts.push(''); // Create slot for content after this heading } return; } // Skip multiline code blocks if (node.type === 'code') { return; } // Skip metadata exports if (node.type === 'mdxjsEsm') { return; } // Skip meta tags (they don't contribute to page content) if ((node.type === 'mdxJsxFlowElement' || node.type === 'mdxJsxTextElement') && 'name' in node && (node.name === 'meta' || node.name === 'Meta')) { return; } // Handle paragraphs for full text, preserving non-breaking spaces if (node.type === 'paragraph') { const text = extractPlainTextFromChildren(node.children).replace(/[ \t\n\r]+/g, ' ').trim(); if (text) { // Append to the last slot (current section's content) if (fullTextParts.length === 0) { fullTextParts.push(text); } else { const lastIndex = fullTextParts.length - 1; fullTextParts[lastIndex] = fullTextParts[lastIndex] ? `${fullTextParts[lastIndex]} ${text}` : text; } } } // Handle lists - condense into comma-separated items if (node.type === 'list' && 'children' in node && Array.isArray(node.children)) { const listItems = []; for (const item of node.children) { if (item.type === 'listItem' && 'children' in item && Array.isArray(item.children)) { // Preserve non-breaking spaces in list item text const itemText = extractPlainTextFromChildren(item.children).replace(/[ \t\n\r]+/g, ' ').trim(); if (itemText) { listItems.push(itemText); } } } if (listItems.length > 0) { const text = listItems.join(', '); // Append to the last slot (current section's content) if (fullTextParts.length === 0) { fullTextParts.push(text); } else { const lastIndex = fullTextParts.length - 1; fullTextParts[lastIndex] = fullTextParts[lastIndex] ? `${fullTextParts[lastIndex]} ${text}` : text; } } } // Handle blockquotes - their paragraphs will be picked up by the paragraph handler above }); // Fill in missing title and description if we have them from content let shouldUpdateMetadata = false; if (metadata) { const mutableMetadata = metadata; // Add title if missing if (firstH1 && !mutableMetadata.title) { mutableMetadata.title = firstH1; shouldUpdateMetadata = true; } // Add description - prioritize meta tag over paragraph if (!mutableMetadata.description) { if (metaDescription) { mutableMetadata.description = metaDescription; shouldUpdateMetadata = true; } else if (firstParagraphAfterH1) { mutableMetadata.description = firstParagraphAfterH1; shouldUpdateMetadata = true; } } // Add descriptionMarkdown if missing // If we used meta tag, set to empty array since meta has no children // Otherwise use the paragraph markdown nodes if (!mutableMetadata.descriptionMarkdown) { if (metaDescription) { mutableMetadata.descriptionMarkdown = []; shouldUpdateMetadata = true; } else if (firstParagraphMarkdown) { mutableMetadata.descriptionMarkdown = firstParagraphMarkdown; shouldUpdateMetadata = true; } } // Add keywords from meta tag if present if (metaKeywords && !mutableMetadata.keywords) { mutableMetadata.keywords = metaKeywords; shouldUpdateMetadata = true; } // Add sections hierarchy if we have headings const hasSections = mutableMetadata.sections && Object.keys(mutableMetadata.sections).length > 0; if (headings.length > 1 && !hasSections) { mutableMetadata.sections = buildHeadingHierarchy(headings); shouldUpdateMetadata = true; } // Update the metadata in the ESTree if we added any fields if (shouldUpdateMetadata && metadataNode) { const esmNode = metadataNode; if (esmNode.data?.estree) { // Apply titleSuffix only to the exported metadata, not to the internal metadata const exportMetadata = options.titleSuffix && mutableMetadata.title ? { ...mutableMetadata, title: mutableMetadata.title + options.titleSuffix } : mutableMetadata; updateMetadataInEstree(esmNode.data.estree, exportMetadata); } } } else if (firstH1 || firstParagraphAfterH1 || metaDescription || metaKeywords) { // Create metadata if we found h1, paragraph, or meta tags but no metadata export exists // Prioritize meta tag description over paragraph const descriptionValue = metaDescription || firstParagraphAfterH1 || undefined; const descriptionMarkdownValue = metaDescription ? [] : firstParagraphMarkdown || undefined; metadata = { title: firstH1 || undefined, description: descriptionValue, descriptionMarkdown: descriptionMarkdownValue, keywords: metaKeywords || undefined, sections: headings.length > 1 ? buildHeadingHierarchy(headings) : undefined }; // Create a new metadata export and add it to the tree // Apply titleSuffix only to the exported metadata, not to the internal metadata const exportMetadata = options.titleSuffix && metadata.title ? { ...metadata, title: metadata.title + options.titleSuffix } : metadata; const metadataExport = createMetadataExport(exportMetadata); root.children.unshift(metadataExport); } // Inject sitemap data into wrapper components in autogenerated index files // This uses the indexWrapperComponent from extractToIndex options const wrapperComponent = typeof options.extractToIndex === 'object' ? options.extractToIndex.indexWrapperComponent : undefined; const extractBaseDir = typeof options.extractToIndex === 'object' ? options.extractToIndex.baseDir : undefined; if (wrapperComponent && file.path) { let fileContent = null; if (typeof file.value === 'string') { fileContent = file.value; } else if (file.value instanceof Buffer) { fileContent = file.value.toString('utf-8'); } // Check if this is an autogenerated index file if (fileContent && (fileContent.includes("[//]: # 'This section is autogenerated") || // TODO: Remove this old marker check once all index files have been migrated to the new format. fileContent.includes("[//]: # 'This file is autogenerated"))) { // Parse the page list metadata from the markdown const pagesMetadata = await markdownToMetadata(fileContent); if (pagesMetadata) { // Compute prefix from file path let prefix = '/'; let filePath = file.path; // Strip baseDir if provided if (extractBaseDir && filePath.startsWith(extractBaseDir)) { filePath = filePath.substring(extractBaseDir.length); if (filePath.startsWith('/')) { filePath = filePath.substring(1); } } // Get directory path and convert to URL prefix const dirPath = dirname(filePath); // Filter out src, app, and route groups from the path // First, split and filter to find meaningful segments const segments = dirPath.split('/').filter(seg => { if (seg.startsWith('(') && seg.endsWith(')')) { return false; } if (seg === '.' || seg === '') { return false; } return true; }); // Find and remove 'src' followed by 'app' pattern (common in Next.js projects) const srcIndex = segments.indexOf('src'); if (srcIndex !== -1) { // Remove 'src' segments.splice(srcIndex, 1); // Check if 'app' now follows where 'src' was if (segments[srcIndex] === 'app') { segments.splice(srcIndex, 1); } } else { // No 'src', check for standalone 'app' at the same position pattern const appIndex = segments.indexOf('app'); if (appIndex !== -1 && (appIndex === 0 || appIndex === srcIndex)) { segments.splice(appIndex, 1); } } prefix = segments.length > 0 ? `/${segments.join('/')}/` : '/'; // Convert PagesMetadata to SitemapSectionData const sitemapData = { title: pagesMetadata.title, prefix, pages: pagesMetadata.pages.map(page => ({ title: page.displayTitle ?? page.title, slug: page.slug, path: page.path, description: page.description, keywords: page.keywords, sections: page.sections, parts: page.parts, exports: page.exports, tags: page.tags, skipDetailSection: page.skipDetailSection, audience: page.audience, index: page.index, image: page.image })) }; // Find and update the wrapper component in the AST visit(root, node => { if (node.type === 'mdxJsxFlowElement' && 'name' in node && node.name === wrapperComponent) { // Create the data attribute with expression value const dataAttr = { type: 'mdxJsxAttribute', name: 'data', value: { type: 'mdxJsxAttributeValueExpression', value: JSON.stringify(sitemapData), data: { estree: { type: 'Program', sourceType: 'module', body: [{ type: 'ExpressionStatement', expression: valueToEstree(sitemapData) }] } } } }; // Add the attribute to the element if ('attributes' in node && Array.isArray(node.attributes)) { // Remove existing data attribute if present const existingIndex = node.attributes.findIndex(attr => attr && typeof attr === 'object' && 'type' in attr && attr.type === 'mdxJsxAttribute' && 'name' in attr && attr.name === 'data'); if (existingIndex !== -1) { node.attributes.splice(existingIndex, 1); } node.attributes.push(dataAttr); } } }); } } } // Update parent index if requested and file path matches filters if (options.extractToIndex && metadata && file.path) { // Normalize extractToIndex options let shouldExtract = false; if (typeof options.extractToIndex === 'boolean') { shouldExtract = options.extractToIndex; } else { const { include = [], exclude = [], baseDir } = options.extractToIndex; let filePath = file.path; // Strip base directory if provided if (baseDir && filePath.startsWith(baseDir)) { filePath = filePath.substring(baseDir.length); // Remove leading slash if present if (filePath.startsWith('/')) { filePath = filePath.substring(1); } } // Normalize path by removing Next.js route groups (parentheses) // e.g., "app/(shared)/page.mdx" becomes "app/page.mdx" const normalizedPath = filePath.replace(/\/\([^)]+\)/g, ''); // Skip if the file is an index file (pattern/page.mdx) // This prevents index files from extracting metadata to their parent const isIndexFile = include.some(pattern => { return normalizedPath === `${pattern}/page.mdx`; }); if (isIndexFile) { shouldExtract = false; } else { // Check if file matches any include pattern (must be inside the directory and not the index itself) // The file must start with "pattern/" to ensure it's a child, not a sibling // and must not be "pattern/page.mdx" to ensure it's not the index file itself const matchedIncludePattern = include.find(pattern => { return normalizedPath.startsWith(`${pattern}/`) && normalizedPath !== `${pattern}/page.mdx`; }); const isIncluded = include.length === 0 || matchedIncludePattern !== undefined; // Check if file matches any exclude pattern const matchedExcludePattern = exclude.find(pattern => { return normalizedPath.startsWith(`${pattern}/`); }); const isExcluded = matchedExcludePattern !== undefined; shouldExtract = isIncluded && !isExcluded; } } if (shouldExtract) { try { // Determine if we should use visible description instead of meta tag const useVisibleDescription = typeof options.extractToIndex !== 'boolean' && options.extractToIndex.useVisibleDescription; const pageMetadataOptions = {}; if (useVisibleDescription && firstParagraphAfterH1) { pageMetadataOptions.visibleDescription = firstParagraphAfterH1; pageMetadataOptions.visibleDescriptionMarkdown = firstParagraphMarkdown; } const pageMetadata = toPageMetadata(metadata, file.path, pageMetadataOptions); const updateOptions = { pagePath: file.path, metadata: pageMetadata, updateParents: true }; // Pass through baseDir, include, exclude, onlyUpdateIndexes, and markerDir if they were configured if (typeof options.extractToIndex !== 'boolean') { if (options.extractToIndex.baseDir) { updateOptions.baseDir = options.extractToIndex.baseDir; } if (options.extractToIndex.include) { updateOptions.include = options.extractToIndex.include; } if (options.extractToIndex.exclude) { updateOptions.exclude = options.extractToIndex.exclude; } if (options.extractToIndex.onlyUpdateIndexes !== undefined) { updateOptions.onlyUpdateIndexes = options.extractToIndex.onlyUpdateIndexes; } if (options.extractToIndex.markerDir !== undefined) { updateOptions.markerDir = options.extractToIndex.markerDir; } if (options.extractToIndex.errorIfOutOfDate !== undefined) { updateOptions.errorIfOutOfDate = options.extractToIndex.errorIfOutOfDate; } if (options.extractToIndex.indexWrapperComponent) { updateOptions.indexWrapperComponent = options.extractToIndex.indexWrapperComponent; } } await syncPageIndex(updateOptions); } catch (error) { // Don't fail the build if index update fails console.error('Failed to update page index for', file.path, error); } } } }; };