@mui/internal-docs-infra
Version:
MUI Infra - internal documentation creation tools.
406 lines (364 loc) • 14.5 kB
JavaScript
import { readFile, writeFile, mkdir } from 'node:fs/promises';
import { basename, dirname, resolve, relative, join } from 'node:path';
import * as lockfile from 'proper-lockfile';
import { mergeMetadataMarkdown } from "./mergeMetadataMarkdown.mjs";
import { markdownToMetadata } from "./metadataToMarkdown.mjs";
/**
* Converts a kebab-case string to Title Case
* @example kebabToTitleCase('my-component') -> 'My Component'
* @example kebabToTitleCase('hello-world') -> 'Hello World'
*/
function kebabToTitleCase(str) {
return str.split('-').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(' ');
}
/**
* Checks if a directory name is a Next.js route group (wrapped in parentheses)
* @example isRouteGroup('(public)') -> true
* @example isRouteGroup('components') -> false
*/
function isRouteGroup(dirName) {
return dirName.startsWith('(') && dirName.endsWith(')');
}
/**
* Gets the parent directory, skipping over Next.js route groups
* @example getParentDir('/app/(public)/(content)/react') -> '/app/(public)/(content)'
* When recursing, skips route groups: '/app/(public)/(content)' -> '/app'
*/
function getParentDir(path, skipRouteGroups = false) {
let parent = dirname(path);
// If we should skip route groups, keep going up until we find a non-route-group directory
if (skipRouteGroups) {
while (parent !== dirname(parent) && isRouteGroup(basename(parent))) {
parent = dirname(parent);
}
}
return parent;
}
/**
* Checks if a path should be included based on include/exclude patterns
* @param path The path to check (relative to baseDir)
* @param include Include patterns - if provided, path must match at least one
* @param exclude Exclude patterns - if path matches any, it's excluded
* @returns true if the path should be included, false otherwise
*/
function shouldIncludePath(path, include, exclude) {
// Normalize path separators to forward slashes and remove Next.js route groups
const normalizedPath = path.replace(/\\/g, '/').replace(/\/\([^)]+\)/g, '');
// Check exclude patterns first
if (exclude && exclude.length > 0) {
for (const pattern of exclude) {
const normalizedPattern = pattern.replace(/\\/g, '/');
if (normalizedPath.startsWith(normalizedPattern)) {
return false;
}
}
}
// If no include patterns, include by default (unless excluded above)
if (!include || include.length === 0) {
return true;
}
// Check if path matches any include pattern
for (const pattern of include) {
const normalizedPattern = pattern.replace(/\\/g, '/');
if (normalizedPath.startsWith(normalizedPattern)) {
return true;
}
}
// Path doesn't match any include pattern
return false;
}
/**
* Updates the parent directory's index file with metadata from a page.
*
* This function:
* 1. Acquires a lock on the index file
* 2. Reads the existing index markdown (if it exists)
* 3. Merges the new page metadata with existing metadata
* 4. Writes the updated markdown back to the index file
* 5. Releases the lock
* 6. Optionally updates parent indexes recursively
*
* @example
* ```ts
* await syncPageIndex({
* pagePath: './app/components/button/page.mdx',
* metadata: {
* slug: 'button',
* path: './button/page.mdx',
* title: 'Button',
* description: 'A button component.',
* },
* indexTitle: 'Components',
* });
* ```
*/
export async function syncPageIndex(options) {
const {
pagePath,
metadata,
metadataList,
indexFileName = 'page.mdx',
lockOptions = {},
baseDir,
updateParents = false,
include,
exclude,
onlyUpdateIndexes = false,
markerDir = false,
errorIfOutOfDate = false,
indexWrapperComponent,
preserveExistingTitleAndSlug
} = options;
// Validate that either metadata or metadataList is provided
if (!metadata && (!metadataList || metadataList.length === 0)) {
throw new Error('Either metadata or metadataList must be provided');
}
// Determine if we're doing a batch update
const isBatchUpdate = !!metadataList;
const metadataArray = isBatchUpdate ? metadataList : [metadata];
// Resolve the index file path
// For batch updates, pagePath is the index file itself
// For single updates, pagePath is a child page and we need the parent's index
const indexPath = isBatchUpdate ? resolve(pagePath) : resolve(getParentDir(dirname(pagePath), true), indexFileName);
const parentDir = dirname(indexPath);
// Check if this index path should be processed based on include/exclude filters
if (baseDir) {
const relativePath = relative(resolve(baseDir), resolve(parentDir));
if (!shouldIncludePath(relativePath, include, exclude)) {
// This index is outside the configured paths - skip it
return;
}
}
// Check if we've reached the base directory
const shouldStop = baseDir && resolve(parentDir) === resolve(baseDir);
// Derive index title from directory name if not provided
const indexTitle = options.indexTitle ?? kebabToTitleCase(basename(parentDir));
// Step 1: Read the file without acquiring a lock to check if we need to make changes
let existingContent = '';
let fileExists = true;
try {
existingContent = await readFile(indexPath, 'utf-8');
} catch (error) {
if (error.code !== 'ENOENT') {
throw error;
}
// File doesn't exist
fileExists = false;
// If onlyUpdateIndexes is true and file doesn't exist, skip this update
if (onlyUpdateIndexes) {
return;
}
// If errorIfOutOfDate is true, throw an error for missing index
if (errorIfOutOfDate) {
const relativeIndexPath = baseDir ? relative(resolve(baseDir), indexPath) : indexPath;
throw new Error(`Index file is missing: ${relativeIndexPath}\n` + `Please run next build locally and commit the updated index files.\n` + `Don't forget to add it to the \`app/sitemap/index.ts\` to list it publicly.`);
}
}
// Step 1.5: Verify the file has the autogeneration marker if it exists
if (fileExists && existingContent) {
const hasMarker = existingContent.includes("[//]: # 'This section is autogenerated") ||
// TODO: Remove this old marker check once all index files have been migrated to the new format.
existingContent.includes("[//]: # 'This file is autogenerated");
if (!hasMarker) {
// File exists but doesn't have the autogeneration marker - skip updating it
return;
}
}
// Step 2: Parse existing content and check if our specific page needs updating
const existingMarkdown = existingContent.trim() ? existingContent : undefined;
let existingPages = [];
if (existingMarkdown) {
const parsed = await markdownToMetadata(existingMarkdown);
if (parsed) {
existingPages = parsed.pages;
}
}
// Step 3: Check if any of our metadata items need updating
let needsUpdate = false;
for (const metaItem of metadataArray) {
const existingPageIndex = existingPages.findIndex(p => p.slug === metaItem.slug);
if (existingPageIndex >= 0) {
const existingPage = existingPages[existingPageIndex];
// Compare metadata - if different, we need to update
const existingPageJson = JSON.stringify(existingPage);
const newPageJson = JSON.stringify(metaItem);
if (existingPageJson !== newPageJson) {
needsUpdate = true;
break;
}
} else {
// Page doesn't exist, we need to add it
needsUpdate = true;
break;
}
}
if (!needsUpdate) {
// All pages are already up-to-date, no need to acquire lock or write
return;
}
// If errorIfOutOfDate is true, throw an error instead of updating
if (errorIfOutOfDate) {
const relativeIndexPath = baseDir ? relative(resolve(baseDir), indexPath) : indexPath;
throw new Error(`Index file is out of date: ${relativeIndexPath}\n` + `Please run the validation command (or next build) locally and commit the updated index files.`);
}
// Step 4: Ensure the file exists before locking (proper-lockfile requires an existing file)
if (!fileExists) {
await writeFile(indexPath, '', 'utf-8');
}
let release;
let mergedPages = []; // Store merged pages for parent update
let currentPageMetadata; // Store the index's own metadata for parent update
try {
// Step 5: Acquire lock on the index file
release = await lockfile.lock(indexPath, {
retries: {
retries: 300,
minTimeout: 1,
// Start with 1ms for fast retries
maxTimeout: 150,
randomize: true
},
stale: 30000,
...lockOptions
});
// Step 6: Re-read and re-merge to catch any concurrent updates from other processes
// This ensures we don't lose updates from other pages being processed in parallel
let currentContent = '';
try {
currentContent = await readFile(indexPath, 'utf-8');
} catch (error) {
if (error.code !== 'ENOENT') {
throw error;
}
// File was deleted while waiting - that's okay, we'll create it
}
const currentMarkdown = currentContent.trim() ? currentContent : undefined;
let currentPages = [];
if (currentMarkdown) {
const parsed = await markdownToMetadata(currentMarkdown);
if (parsed) {
currentPages = parsed.pages;
currentPageMetadata = parsed.pageMetadata;
}
}
// For batch updates, merge the metadata items with existing pages
// Build a map keyed by path (not slug) to match mergeMetadataMarkdown's logic
const updatedPagesMap = new Map();
// First, add all current pages
for (const page of currentPages) {
updatedPagesMap.set(page.path, page);
}
// Then update/add the new metadata items
for (const metaItem of metadataArray) {
updatedPagesMap.set(metaItem.path, metaItem);
}
// Convert back to array - this is the COMPLETE list of pages that should exist
const allPages = Array.from(updatedPagesMap.values());
// Store for parent update
mergedPages = allPages;
// Re-merge with the latest content, passing the COMPLETE list of pages
// mergeMetadataMarkdown will preserve the order from currentMarkdown
// Only include path in the comment when baseDir is set (otherwise it's an absolute path)
const relativeIndexPath = baseDir ? relative(resolve(baseDir), indexPath) : undefined;
const finalMarkdown = await mergeMetadataMarkdown(currentMarkdown, {
title: indexTitle,
pages: allPages
}, {
indexWrapperComponent,
path: relativeIndexPath,
preserveExistingTitleAndSlug
});
// Defensive check
if (!finalMarkdown || !finalMarkdown.trim()) {
throw new Error(`Cannot write empty content to ${indexPath}`);
}
// Step 7: Write only if the final content differs from what's currently on disk
if (currentContent !== finalMarkdown) {
await writeFile(indexPath, finalMarkdown, 'utf-8');
// Create a marker file unless explicitly disabled
if (markerDir) {
// Compute relative path for marker (always compute, not used for comments)
const markerRelativePath = baseDir ? relative(resolve(baseDir), indexPath) : indexPath;
// Resolve markerDir relative to baseDir (if baseDir is provided)
const markerDirResolved = baseDir ? resolve(baseDir, markerDir) : markerDir;
const markerPath = join(markerDirResolved, markerRelativePath);
const markerDirPath = dirname(markerPath);
// Ensure the marker directory exists
await mkdir(markerDirPath, {
recursive: true
});
// Create an empty marker file
await writeFile(markerPath, '', 'utf-8');
}
}
} finally {
// Always release the lock
if (release) {
await release();
}
}
// After releasing the lock, update the parent index if needed
if (updateParents && !shouldStop) {
// Get the grandparent directory, skipping over Next.js route groups
const grandParentDir = getParentDir(parentDir, true);
// Only continue if we're not at the filesystem root
if (grandParentDir !== parentDir) {
// CRITICAL: Use the merged pages from Step 6, not a re-read of the file
// Re-reading could get a stale version if other processes are still writing
// mergedPages already contains ALL pages after the merge in Step 6
// Calculate the relative path from grandparent to this index, preserving route groups
const relativePathFromGrandparent = relative(grandParentDir, parentDir);
// Extract metadata for the current index to add to its parent
const indexMetadata = {
slug: basename(parentDir),
path: `./${relativePathFromGrandparent}/${indexFileName}`,
title: indexTitle,
description: 'No description available'
};
// Determine audience/index flags from the index page's own metadata
const audience = currentPageMetadata?.other?.audience;
if (audience) {
indexMetadata.audience = audience;
}
// An index page with child pages is always an index
if (mergedPages.length > 0) {
indexMetadata.index = true;
}
// Convert child pages to sections format (no subsections, just page names)
// Use mergedPages which contains the complete merged state
// Skip single-link entries (external links) as they don't have detail sections
if (mergedPages.length > 0) {
const sections = {};
for (const childPage of mergedPages) {
// Skip entries that don't have detail sections (external links, etc.)
if (childPage.skipDetailSection) {
continue;
}
sections[childPage.slug] = {
title: childPage.displayTitle ?? childPage.title ?? childPage.slug,
titleMarkdown: [{
type: 'text',
value: childPage.displayTitle ?? childPage.title ?? childPage.slug
}],
children: {} // Don't include any subsections in parent index
};
}
indexMetadata.sections = sections;
}
// Recursively update the parent index (will create it if it doesn't exist)
await syncPageIndex({
pagePath: indexPath,
metadata: indexMetadata,
indexFileName,
lockOptions,
baseDir,
updateParents: true,
include,
exclude,
onlyUpdateIndexes,
markerDir,
errorIfOutOfDate
});
}
}
}