MUI Docs Infra

Warning

This is an internal project, and is not intended for public use. No support or stability guarantees are provided.

Sync Page Index

The syncPageIndex function automatically maintains index pages by extracting metadata from documentation pages and updating parent directory indexes. It's designed to work with Next.js file-based routing, keeping navigation indexes in sync as pages are added or modified.

Note

This function is typically called by the transformMarkdownMetadata plugin during the build process. Most users won't need to call it directly.

Overview

When you have documentation pages organized in directories:

app/components/
├── page.mdx          ← Index page (auto-updated)
├── button/
│   └── page.mdx      ← Button docs
├── checkbox/
│   └── page.mdx      ← Checkbox docs
└── dialog/
    └── page.mdx      ← Dialog docs

The syncPageIndex function:

  1. Receives metadata (title, description, sections) for a page
  2. Updates the parent page.mdx with links and descriptions
  3. Optionally propagates up the directory tree

Basic Usage

import { syncPageIndex } from '@mui/internal-docs-infra/pipeline/syncPageIndex';

await syncPageIndex({
  pagePath: './app/components/button/page.mdx',
  metadata: {
    slug: 'button',
    path: './button/page.mdx',
    title: 'Button',
    description: 'A clickable button component.',
  },
});

This updates ./app/components/page.mdx with an entry for the Button page.


Common Patterns

Batch Updates

Update multiple pages in a single operation (more efficient with file locking):

await syncPageIndex({
  pagePath: './app/components/page.mdx', // The index file itself
  metadataList: [
    { slug: 'button', path: './button/page.mdx', title: 'Button', description: '...' },
    { slug: 'checkbox', path: './checkbox/page.mdx', title: 'Checkbox', description: '...' },
    { slug: 'dialog', path: './dialog/page.mdx', title: 'Dialog', description: '...' },
  ],
});

Recursive Parent Updates

Update all parent indexes up to a base directory:

await syncPageIndex({
  pagePath: './app/components/forms/text-field/page.mdx',
  metadata: { ... },
  updateParents: true,
  baseDir: './app',
});

This updates:

  • ./app/components/forms/page.mdx
  • ./app/components/page.mdx
  • ./app/page.mdx

Include/Exclude Patterns

Control which directories receive index updates:

await syncPageIndex({
  pagePath: './app/docs/getting-started/page.mdx',
  metadata: { ... },
  baseDir: './app',
  include: ['docs'],           // Only update indexes under 'docs'
  exclude: ['docs/internal'],  // But skip 'docs/internal'
});

CI Validation Mode

Check if indexes are up-to-date without modifying files:

await syncPageIndex({
  pagePath: './app/components/button/page.mdx',
  metadata: { ... },
  errorIfOutOfDate: true,  // Throws if index needs updating
  onlyUpdateIndexes: true, // Don't create new indexes
});

Generated Index Format

The function generates markdown with a specific structure:

# Components

[//]: # 'This section is autogenerated, but the following list can be modified except within the parentheses. Automatically sorted alphabetically.'

<PagesIndex>

- Button - ([Outline](#button), [Contents](./button/page.mdx))
- Checkbox - ([Outline](#checkbox), [Contents](./checkbox/page.mdx))
- Dialog - ([Outline](#dialog), [Contents](./dialog/page.mdx))

[//]: # 'This section is autogenerated, DO NOT EDIT AFTER THIS LINE, run: pnpm docs-infra validate'

## Button

A clickable button component.

<details>
<summary>Outline</summary>

- Sections:
  - Installation
  - Usage
  - Props
  - Examples

</details>

[Read more](./button/page.mdx)

[//]: # 'More entries...'

</PagesIndex>

The index has two zones separated by the DO NOT EDIT AFTER THIS LINE marker. You can freely edit the list above the marker to reorder entries, rename titles, and add tags. The outline sections below the marker are fully managed — never edit them manually. After changing the list, run pnpm docs-infra validate and the outlines will be regenerated to match.


Extra Metadata

Ordering

To change the display order, rearrange the list items in the index file. The order you set is preserved across future syncs. New pages are appended at the end. After reordering, run pnpm docs-infra validate to regenerate the outline sections to match.

Tags

Tags are user-managed labels that appear in the index list as [Tag] indicators. They are edited directly in the index page.mdx file, not passed via the syncPageIndex API.

To add tags, edit the list entries in your index file:

- Button [New] - ([Outline](#button), [Contents](./button/page.mdx))

Tag behavior:

  • User-managed: The plugin never deletes tags — only you can remove them
  • Preserved during updates: When syncPageIndex updates the index, existing tags are kept
  • Automatic [New] tag: New pages added to the index automatically receive a [New] tag

See transformMarkdownMetadata for the full tag format reference.

Why Tags Live in the Index

Tags describe a page's lifecycle status relative to other pages (e.g., New, Preview, Private) rather than intrinsic content. They are stored in the page index—not in the individual page's frontmatter or metadata export—for several reasons:

  • No circular rebuilds: Tags like [New] can be added automatically when a page is created. If the tag were written back into the page itself, it would trigger a rebuild of that page, which in turn would re-run the indexer—creating a circular dependency. Hoisting tags into the parent index avoids this.

  • User files stay untouched: Progressing a page from [Private][Preview][Stable] only requires editing the index file. The source Markdown is never modified, which keeps diffs clean and Code Owners predictable—changing a release phase triggers different reviewers than changing page content.

  • Route-prefix scope: A tag applies to the entire route prefix, not just a single page. For a component with sub-routes (overview, examples, API reference), the tag on the index entry covers all of them:

    app/components/drawer/   (tag: New)
        layout.tsx
        page.mdx              (overview)
        examples/
            page.mdx
        api-reference/
            page.mdx
    
  • Quick scanning: Reviewing the index file gives an at-a-glance view of which pages are new, in preview, private, etc.—without opening each page individually.

Tags vs Keywords

Both tags and keywords end up in the sitemap, but they serve distinct purposes:

AspectTagsKeywords
PurposeLifecycle / status metadataContent-related concepts
Examples[New], [Preview], [Private]interactive, forms, layout
SourceEdited in the index fileExported from the page's metadata
ScopeRelative to sibling pagesIntrinsic to the page's content
Use caseSidebar badges, filtering by phaseSearch relevance, content categorization

Think of it as: a component is "New" relative to the other components in the index, while a keyword like "forms" is an inherent property of the page's content. Both are available on page.tags and page.keywords in the sitemap for consumers to use however they like—for example, page.tags?.includes('New') to show a badge, or page.keywords?.includes('forms') to filter search results.

Status Labels

Pages are automatically labeled based on their audience and index flags. The label is derived from the audience value, with "Index" appended for pages that have child pages:

- Button - ([Outline](#button), [Contents](./button/page.mdx))
- InternalGrid - (Private, [Outline](#internal-grid), [Contents](./internal-grid/page.mdx))
- Components - (Index, [Outline](#components), [Contents](./components/page.mdx))
- Getting Started - (Public Index, [Outline](#getting-started), [Contents](./getting-started/page.mdx))
- Tutorial - (Introductory, [Outline](#tutorial), [Contents](./tutorial/page.mdx))
  • audience is read from metadata.other?.audience on each page (for example, export const metadata = { other: { audience: 'private' } })
  • index is set automatically when a page has child pages (i.e., it serves as a section index)
  • Pages with no audience and no children have no label

Title Overrides

You can override a page's display title by editing the list item text in the index file. The list item title is independent of the heading title that appears in the detail section below it.

For example, if a page's H1 is "Button Component" but you want a shorter label in the list:

- Button - ([Outline](#button-component), [Contents](./button-component/page.mdx))

Here the list item says "Button" while the detail section heading remains "Button Component". The override is detected whenever the list item text differs from the heading text.

Override behavior:

  • Preserved during syncs: When syncPageIndex updates the index, an overridden title is kept—only the detail section heading and metadata are updated to match the page's actual title
  • Auto-cleared when matching: If the page's title is later changed to match the override, the override is cleared automatically
  • Used in sitemaps: Consumers like useSearch and the sitemap see the overridden title (via SitemapPage.title) rather than the original page title
  • Sorting: Alphabetical sorting in the index uses the overridden title when present

Parts and Exports

For components with API documentation, include parts or exports to make them searchable:

await syncPageIndex({
  pagePath: './app/components/dialog/page.mdx',
  metadata: {
    slug: 'dialog',
    path: './dialog/page.mdx',
    title: 'Dialog',
    description: 'A modal dialog component.',
    // For multi-part components
    parts: {
      Root: { props: ['open', 'onOpenChange'], dataAttributes: ['data-state'] },
      Trigger: { props: ['asChild'] },
      Content: { props: ['side', 'align'], cssVariables: ['--dialog-width'] },
    },
    // Or for single exports
    exports: {
      Dialog: { props: ['open', 'onOpenChange', 'modal'] },
    },
  },
});

This metadata is used by useSearch to enable searching for specific props, data attributes, and CSS variables.

For links to external resources, use skipDetailSection:

await syncPageIndex({
  pagePath: './app/resources/page.mdx',
  metadata: {
    slug: 'github',
    path: 'https://github.com/mui/base-ui',
    title: 'GitHub',
    tags: ['External'],
    skipDetailSection: true, // Don't generate a detail section
  },
});

Helper Functions

markdownToMetadata

Parses a markdown index file and extracts page metadata:

import { markdownToMetadata } from '@mui/internal-docs-infra/pipeline/syncPageIndex';

const result = await markdownToMetadata(markdownContent);
// result.title - Index title
// result.pages - Array of PageMetadata
// result.description - Optional description

mergeMetadataMarkdown

Merges new page metadata into existing markdown:

import { mergeMetadataMarkdown } from '@mui/internal-docs-infra/pipeline/syncPageIndex';

const updatedMarkdown = await mergeMetadataMarkdown(existingMarkdown, newMetadata, options);

File Locking

The function uses proper-lockfile to prevent concurrent writes:

  • Locks are acquired before reading/writing
  • Multiple processes can safely update the same index
  • Batch updates (metadataList) are more efficient as they require only one lock

Route Group Handling

Next.js route groups (directories in parentheses) are handled specially:

  • (public), (content), etc. are skipped when finding parent directories
  • URL prefixes exclude route group segments
  • Title generation ignores route groups
app/(public)/(content)/components/button/page.mdx
                       ↓
Updates: app/(public)/(content)/components/page.mdx
Title derived from: 'components' → 'Components'

Types

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
PropertyTypeDescription
pagePath
string

The path to the page file (e.g., ‘./app/components/button/page.mdx’) OR the path to the index file itself when using metadataList

metadata
PageMetadata | undefined

The metadata extracted from the page Either provide this for a single update, or metadataList for batch updates

metadataList
PageMetadata[] | undefined

Array of metadata for batch updates When provided, all metadata will be merged in a single file lock/write operation

indexTitle
string | undefined

The title for the index file (e.g., ‘Components’) If not provided, will be derived from the parent directory name (e.g., ‘app/components/page.mdx’ -> ‘Components’)

indexFileName
string | undefined

The name of the index file to update (e.g., ‘page.mdx’) Defaults to ‘page.mdx’

lockOptions
lockfile.LockOptions | undefined

Lock options for proper-lockfile

baseDir
string | undefined

The base directory to stop recursion at (e.g., ‘./app’) If not provided, will continue until reaching the root directory

updateParents
boolean | undefined

Whether to update parent indexes recursively

include
string[] | undefined

Path patterns to include when creating/updating indexes Only indexes within these paths will be created or modified Patterns are matched against the directory path relative to baseDir

exclude
string[] | undefined

Path patterns to exclude when creating/updating indexes Indexes matching these patterns will not be created or modified Patterns are matched against the directory path relative to baseDir

onlyUpdateIndexes
boolean | undefined

Only update existing indexes, don’t create new ones When true, will skip updating if the index file doesn’t already exist

markerDir
string | false | undefined

Directory to write marker files when indexes are updated. Path is relative to baseDir. Set to false to disable marker file creation. A marker file will be created at: ${markerDir}/${relativePath}/page.mdx

errorIfOutOfDate
boolean | undefined

Throw an error if the index is out of date or missing. Useful for CI environments to ensure indexes are committed.

indexWrapperComponent
string | undefined

Optional component name to wrap the autogenerated index content. When provided, the content will be wrapped like: <ComponentName>...</ComponentName>

preserveExistingTitleAndSlug
boolean | undefined

If true, preserve existing page titles and slugs when they exist. New metadata titles/slugs will only be used if the existing page doesn’t have them. Useful when auto-generating metadata that shouldn’t override user-set values.

Return Type
Promise<void>
SyncPageIndexOptions
type SyncPageIndexOptions = {
  /**
   * The path to the page file (e.g., './app/components/button/page.mdx')
   * OR the path to the index file itself when using metadataList
   */
  pagePath: string;
  /**
   * The metadata extracted from the page
   * Either provide this for a single update, or metadataList for batch updates
   */
  metadata?: PageMetadata;
  /**
   * Array of metadata for batch updates
   * When provided, all metadata will be merged in a single file lock/write operation
   */
  metadataList?: PageMetadata[];
  /**
   * The title for the index file (e.g., 'Components')
   * If not provided, will be derived from the parent directory name
   * (e.g., 'app/components/page.mdx' -> 'Components')
   */
  indexTitle?: string;
  /**
   * The name of the index file to update (e.g., 'page.mdx')
   * Defaults to 'page.mdx'
   */
  indexFileName?: string;
  /** Lock options for proper-lockfile */
  lockOptions?: lockfile.LockOptions;
  /**
   * The base directory to stop recursion at (e.g., './app')
   * If not provided, will continue until reaching the root directory
   */
  baseDir?: string;
  /**
   * Whether to update parent indexes recursively
   * @default false
   */
  updateParents?: boolean;
  /**
   * Path patterns to include when creating/updating indexes
   * Only indexes within these paths will be created or modified
   * Patterns are matched against the directory path relative to baseDir
   */
  include?: string[];
  /**
   * Path patterns to exclude when creating/updating indexes
   * Indexes matching these patterns will not be created or modified
   * Patterns are matched against the directory path relative to baseDir
   */
  exclude?: string[];
  /**
   * Only update existing indexes, don't create new ones
   * When true, will skip updating if the index file doesn't already exist
   * @default false
   */
  onlyUpdateIndexes?: boolean;
  /**
   * Directory to write marker files when indexes are updated.
   * Path is relative to baseDir.
   * Set to false to disable marker file creation.
   * A marker file will be created at: `${markerDir}/${relativePath}/page.mdx`
   * @default false
   */
  markerDir?: string | false;
  /**
   * Throw an error if the index is out of date or missing.
   * Useful for CI environments to ensure indexes are committed.
   * @default false
   */
  errorIfOutOfDate?: boolean;
  /**
   * Optional component name to wrap the autogenerated index content.
   * When provided, the content will be wrapped like: `<ComponentName>...</ComponentName>`
   * @example 'PagesIndex'
   */
  indexWrapperComponent?: string;
  /**
   * If true, preserve existing page titles and slugs when they exist.
   * New metadata titles/slugs will only be used if the existing page doesn't have them.
   * Useful when auto-generating metadata that shouldn't override user-set values.
   * @default false
   */
  preserveExistingTitleAndSlug?: boolean;
}