@mui/internal-docs-infra 0.4.1-canary.9 → 0.5.1-canary.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/cli/index.mjs CHANGED
@@ -7,4 +7,4 @@ function getVersion() {
7
7
  }
8
8
  yargs().scriptName('docs-infra').usage('$0 <command> [args]').command(runValidate).demandCommand(1, 'You need at least one command before moving on').strict().help()
9
9
  // MUI_VERSION is set through the code-infra build command.
10
- .version("0.4.0" || getVersion()).parse(hideBin(process.argv));
10
+ .version("0.5.0" || getVersion()).parse(hideBin(process.argv));
@@ -9,7 +9,7 @@ function isNextJsContext() {
9
9
  typeof process.env.__NEXT_PROCESSED_ENV === 'string' ||
10
10
  // Next.js build
11
11
  typeof process.env.NEXT_PHASE === 'string') // Next.js build phase
12
- ;
12
+ ;
13
13
  }
14
14
 
15
15
  /**
@@ -1,3 +1,4 @@
1
+ import type { Metadata } from 'next';
1
2
  /**
2
3
  * Section data structure from sitemap
3
4
  */
@@ -35,6 +36,8 @@ export interface SitemapPage {
35
36
  exports?: Record<string, SitemapExport>;
36
37
  tags?: string[];
37
38
  skipDetailSection?: boolean;
39
+ audience?: Audience;
40
+ index?: boolean;
38
41
  image?: {
39
42
  url: string;
40
43
  alt?: string;
@@ -59,4 +62,36 @@ export type OramaSchemaType = 'string' | 'number' | 'boolean' | 'string[]' | 'nu
59
62
  export interface Sitemap {
60
63
  schema: Record<string, OramaSchemaType>;
61
64
  data: Record<string, SitemapSectionData>;
62
- }
65
+ }
66
+ export type Audience = 'private' | 'introductory' | 'intermediate' | 'advanced' | 'business';
67
+ /**
68
+ * Page metadata type extending Next.js `Metadata`.
69
+ *
70
+ * Adds the `audience` field under `other` using the WHATWG MetaExtensions `audience` meta name.
71
+ * All standard Next.js metadata fields (title, description, openGraph, etc.) remain available.
72
+ *
73
+ * @see https://nextjs.org/docs/app/api-reference/functions/generate-metadata#metadata-fields
74
+ */
75
+ export type NextMetadata = Metadata & {
76
+ other?: {
77
+ /**
78
+ * Categorize the principal intended audience for the page.
79
+ * Uses the WHATWG MetaExtensions `audience` meta name.
80
+ *
81
+ * When omitted, the page is public and intended for all audiences.
82
+ *
83
+ * - `'private'`: Internal page, not intended for public consumption.
84
+ * Should be paired with `robots: { index: false }` to exclude from public indexing.
85
+ * - `'introductory'`: Content aimed at beginners.
86
+ * - `'intermediate'`: Content aimed at intermediate users.
87
+ * - `'advanced'`: Content aimed at advanced users.
88
+ * - `'business'`: Content aimed at prospective customers and decision-makers
89
+ * (e.g. marketing pages, pricing, product overviews).
90
+ *
91
+ * @see https://wiki.whatwg.org/wiki/MetaExtensions
92
+ * @see https://brittlebit.org/specifications/html-meta-audience/specification-for-html-meta-element-with-name-value-audience.html
93
+ */
94
+ audience?: Audience;
95
+ [key: string]: unknown;
96
+ };
97
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mui/internal-docs-infra",
3
- "version": "0.4.1-canary.9",
3
+ "version": "0.5.1-canary.0",
4
4
  "author": "MUI Team",
5
5
  "description": "MUI Infra - internal documentation creation tools.",
6
6
  "keywords": [
@@ -22,12 +22,12 @@
22
22
  "homepage": "https://github.com/mui/mui-public/tree/master/packages/docs-infra",
23
23
  "dependencies": {
24
24
  "@babel/runtime": "^7.28.6",
25
- "@babel/standalone": "^7.28.6",
25
+ "@babel/standalone": "^7.29.1",
26
26
  "@orama/orama": "^3.1.18",
27
27
  "@orama/plugin-qps": "^3.1.18",
28
28
  "@orama/stemmers": "^3.1.18",
29
29
  "@orama/stopwords": "^3.1.18",
30
- "@wooorm/starry-night": "^3.8.0",
30
+ "@wooorm/starry-night": "^3.9.0",
31
31
  "chalk": "^5.6.2",
32
32
  "clipboard-copy": "^4.0.1",
33
33
  "fflate": "^0.8.2",
@@ -506,5 +506,5 @@
506
506
  "bin": {
507
507
  "docs-infra": "./cli/index.mjs"
508
508
  },
509
- "gitSha": "5a397056f9e93982f8e275c0214fc882d8af3b1c"
509
+ "gitSha": "df142b55588e09b911db8be848c133b0ebad1cc0"
510
510
  }
@@ -38,8 +38,8 @@ export interface MergeMetadataMarkdownOptions extends Omit<MetadataToMarkdownOpt
38
38
  * @example
39
39
  * ```ts
40
40
  * const existingMarkdown = `# Components
41
- * - [Button](#button) - [Full Docs](./button/page.mdx) - A button
42
- * - [Checkbox](#checkbox) - [Full Docs](./checkbox/page.mdx) - A checkbox
41
+ * - Button - ([Outline](#button), [Contents](./button/page.mdx)) - A button
42
+ * - Checkbox - ([Outline](#checkbox), [Contents](./checkbox/page.mdx)) - A checkbox
43
43
  * `;
44
44
  *
45
45
  * const newMetadata = {
@@ -24,8 +24,8 @@ import { markdownToMetadata, metadataToMarkdown } from "./metadataToMarkdown.mjs
24
24
  * @example
25
25
  * ```ts
26
26
  * const existingMarkdown = `# Components
27
- * - [Button](#button) - [Full Docs](./button/page.mdx) - A button
28
- * - [Checkbox](#checkbox) - [Full Docs](./checkbox/page.mdx) - A checkbox
27
+ * - Button - ([Outline](#button), [Contents](./button/page.mdx)) - A button
28
+ * - Checkbox - ([Outline](#checkbox), [Contents](./checkbox/page.mdx)) - A checkbox
29
29
  * `;
30
30
  *
31
31
  * const newMetadata = {
@@ -108,7 +108,11 @@ export async function mergeMetadataMarkdown(existingMarkdown, newMetadata, optio
108
108
  // Preserve skipDetailSection from existing (user-managed for external links)
109
109
  skipDetailSection: existingPage.skipDetailSection,
110
110
  // Preserve sections from existing if new doesn't have them
111
- sections: newPage.sections || existingPage.sections
111
+ sections: newPage.sections || existingPage.sections,
112
+ // Preserve displayTitle (user-managed title override) only if it still
113
+ // differs from the new title. If the override now matches the actual title,
114
+ // clear it so the titles stay in sync going forward.
115
+ displayTitle: existingPage.displayTitle && existingPage.displayTitle !== newPage.title ? existingPage.displayTitle : undefined
112
116
  };
113
117
  pages.push(merged);
114
118
  addedPaths.add(newPage.path);
@@ -130,12 +134,14 @@ export async function mergeMetadataMarkdown(existingMarkdown, newMetadata, optio
130
134
  }
131
135
 
132
136
  // If alphabetical sorting is requested, sort pages alphabetically by title
133
- const alphabeticalSortMarker = "[//]: # 'This file is autogenerated, but the following list can be modified. Automatically sorted alphabetically.'";
134
- const requestsAlphabeticalSort = existingMarkdown.includes(alphabeticalSortMarker);
137
+ const alphabeticalSortMarker = "[//]: # 'This section is autogenerated, but the following list order, title, and [Tag]s can be modified, but nothing within the parentheses. Automatically sorted alphabetically.'";
138
+ // TODO: Remove the old marker check once all index files have been migrated to the new format.
139
+ const oldAlphabeticalSortMarker = "[//]: # 'This file is autogenerated, but the following list can be modified. Automatically sorted alphabetically.'";
140
+ const requestsAlphabeticalSort = existingMarkdown.includes(alphabeticalSortMarker) || existingMarkdown.includes(oldAlphabeticalSortMarker);
135
141
  if (requestsAlphabeticalSort) {
136
142
  pages = pages.sort((a, b) => {
137
- const titleA = a.title || a.slug;
138
- const titleB = b.title || b.slug;
143
+ const titleA = a.displayTitle ?? a.title ?? a.slug;
144
+ const titleB = b.displayTitle ?? b.title ?? b.slug;
139
145
  return titleA.localeCompare(titleB);
140
146
  });
141
147
  }
@@ -144,7 +150,9 @@ export async function mergeMetadataMarkdown(existingMarkdown, newMetadata, optio
144
150
  const mergedMetadata = {
145
151
  title: newMetadata.title,
146
152
  // Always use the new title
147
- pages
153
+ pages,
154
+ // Preserve the existing pageMetadata (e.g., robots config) from the current file
155
+ pageMetadata: existingMetadata.pageMetadata
148
156
  };
149
157
 
150
158
  // Preserve the alphabetical sorting marker if it was present
@@ -1,14 +1,31 @@
1
1
  import type { Root } from 'mdast';
2
2
  import type { ExtractedMetadata } from "../transformMarkdownMetadata/types.mjs";
3
+ import { Audience } from "../../createSitemap/types.mjs";
3
4
  export interface PageMetadata extends ExtractedMetadata {
4
5
  /** The slug/path for this page (e.g., 'button', 'checkbox') */
5
6
  slug: string;
6
7
  /** The relative path to the page's MDX file */
7
8
  path: string;
9
+ /**
10
+ * User-customized display title for the page.
11
+ * When set, this overrides `title` in the navigation list and sitemap.
12
+ * The `title` field continues to be used for the heading section.
13
+ *
14
+ * This value is detected automatically: if a user edits the list item text
15
+ * so it no longer matches the heading, the edited text is preserved here.
16
+ */
17
+ displayTitle?: string;
8
18
  /** Tags for this entry (e.g., 'New', 'Hot', 'Beta') */
9
19
  tags?: string[];
10
20
  /** Skip generating detail section for this entry (for external links) */
11
21
  skipDetailSection?: boolean;
22
+ /**
23
+ * The intended audience for this page.
24
+ * When omitted, the page is public and intended for all audiences.
25
+ */
26
+ audience?: Audience;
27
+ /** Whether this page is an index page (has the autogenerated comment marker) */
28
+ index?: boolean;
12
29
  /** Component parts with their API metadata (for multi-part components) */
13
30
  parts?: Record<string, {
14
31
  props?: string[];
@@ -32,6 +32,135 @@ function astNodesToMarkdown(nodes) {
32
32
  * Options for metadataToMarkdown and metadataToMarkdownAst functions
33
33
  */
34
34
 
35
+ /**
36
+ * Serializes a JS value to a JavaScript-style literal string (unquoted keys, trailing commas).
37
+ * This produces output like `{ robots: { index: false } }` instead of JSON's `{ "robots": { "index": false } }`.
38
+ */
39
+ function serializeJsValue(value, indent) {
40
+ if (value === null || value === undefined) {
41
+ return String(value);
42
+ }
43
+ if (typeof value === 'string') {
44
+ // Use single quotes for strings, escaping any internal single quotes
45
+ return `'${String(value).replace(/'/g, "\\'")}'`;
46
+ }
47
+ if (typeof value !== 'object') {
48
+ return String(value);
49
+ }
50
+ if (Array.isArray(value)) {
51
+ if (value.length === 0) {
52
+ return '[]';
53
+ }
54
+ const items = value.map(item => `${' '.repeat(indent + 1)}${serializeJsValue(item, indent + 1)},`);
55
+ return `[\n${items.join('\n')}\n${' '.repeat(indent)}]`;
56
+ }
57
+ const entries = Object.entries(value);
58
+ if (entries.length === 0) {
59
+ return '{}';
60
+ }
61
+ const lines = entries.map(([key, val]) => `${' '.repeat(indent + 1)}${key}: ${serializeJsValue(val, indent + 1)},`);
62
+ return `{\n${lines.join('\n')}\n${' '.repeat(indent)}}`;
63
+ }
64
+
65
+ /**
66
+ * Gets the status label for a page based on its audience and index flags.
67
+ *
68
+ * Status labels in the editable list:
69
+ * - \"Private\": audience='private', index=false
70
+ * - \"Index\": audience='private', index=true
71
+ * - \"Public Index\": audience!='private', index=true
72
+ * - \"Introductory\": audience='introductory'
73
+ * - \"Intermediate\": audience='intermediate'
74
+ * - \"Advanced\": audience='advanced'
75
+ * - \"Business\": audience='business'
76
+ */
77
+ function getStatusLabel(page) {
78
+ const audience = page.audience;
79
+ const isIndex = page.index ?? false;
80
+ if (audience === 'private' && isIndex) {
81
+ return 'Index';
82
+ }
83
+ if (audience === 'private' && !isIndex) {
84
+ return 'Private';
85
+ }
86
+ if (audience && audience !== 'private' && isIndex) {
87
+ const audienceLabel = audience.charAt(0).toUpperCase() + audience.slice(1);
88
+ return `${audienceLabel} Index`;
89
+ }
90
+ if (audience && audience !== 'private') {
91
+ return audience.charAt(0).toUpperCase() + audience.slice(1);
92
+ }
93
+ if (isIndex) {
94
+ return 'Public Index';
95
+ }
96
+ return undefined;
97
+ }
98
+
99
+ /**
100
+ * Parses a status label string back into audience/index flags.
101
+ */
102
+ function parseStatusLabel(label) {
103
+ const trimmed = label.trim();
104
+ switch (trimmed) {
105
+ case 'Private':
106
+ return {
107
+ audience: 'private',
108
+ index: false
109
+ };
110
+ case 'Index':
111
+ return {
112
+ audience: 'private',
113
+ index: true
114
+ };
115
+ case 'Public Index':
116
+ return {
117
+ index: true
118
+ };
119
+ case 'Introductory':
120
+ return {
121
+ audience: 'introductory',
122
+ index: false
123
+ };
124
+ case 'Introductory Index':
125
+ return {
126
+ audience: 'introductory',
127
+ index: true
128
+ };
129
+ case 'Intermediate':
130
+ return {
131
+ audience: 'intermediate',
132
+ index: false
133
+ };
134
+ case 'Intermediate Index':
135
+ return {
136
+ audience: 'intermediate',
137
+ index: true
138
+ };
139
+ case 'Advanced':
140
+ return {
141
+ audience: 'advanced',
142
+ index: false
143
+ };
144
+ case 'Advanced Index':
145
+ return {
146
+ audience: 'advanced',
147
+ index: true
148
+ };
149
+ case 'Business':
150
+ return {
151
+ audience: 'business',
152
+ index: false
153
+ };
154
+ case 'Business Index':
155
+ return {
156
+ audience: 'business',
157
+ index: true
158
+ };
159
+ default:
160
+ 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.');
161
+ }
162
+ }
163
+
35
164
  /**
36
165
  * Converts a HeadingHierarchy into markdown list format
37
166
  */
@@ -339,7 +468,7 @@ export function metadataToMarkdownAst(data, options = {}) {
339
468
 
340
469
  // Add editable section marker
341
470
  // Extract just the comment text from the marker (strip [//]: # 'text' wrapper)
342
- const defaultMarkerText = 'This file is autogenerated, but the following list can be modified.';
471
+ const defaultMarkerText = 'This section is autogenerated, but the following list order, title, and [Tag]s can be modified, but nothing within the parentheses.';
343
472
  let markerText = defaultMarkerText;
344
473
  if (editableMarker) {
345
474
  // Extract text between single quotes: [//]: # 'text'
@@ -359,14 +488,15 @@ export function metadataToMarkdownAst(data, options = {}) {
359
488
  // Add page list (editable section) as proper list items
360
489
  const listItems = [];
361
490
  for (const page of pages) {
362
- const pageTitle = page.title || page.slug;
491
+ // List items use displayTitle when the user has overridden the title
492
+ const listTitle = page.displayTitle ?? page.title ?? page.slug;
363
493
 
364
494
  // Check if this is a single-link entry (external link or no detail section)
365
495
  const isSingleLink = page.skipDetailSection || false;
366
496
  let paragraphChildren;
367
497
  if (isSingleLink) {
368
498
  // Format: - [Title](./path) [Tag1] [Tag2]
369
- paragraphChildren = [link(page.path, pageTitle)];
499
+ paragraphChildren = [link(page.path, listTitle)];
370
500
 
371
501
  // Add tags if present (directly after link)
372
502
  if (page.tags && page.tags.length > 0) {
@@ -375,8 +505,9 @@ export function metadataToMarkdownAst(data, options = {}) {
375
505
  }
376
506
  }
377
507
  } else {
378
- // Format: - [Title](#slug) [Tag1] [Tag2] - [Full Docs](./path/page.mdx)
379
- paragraphChildren = [link(`#${page.slug}`, pageTitle)];
508
+ // Format: - Title [Tag1] [Tag2] - (StatusLabel, [Outline](#slug), [Contents](./path))
509
+ const statusLabel = getStatusLabel(page);
510
+ paragraphChildren = [text(listTitle)];
380
511
 
381
512
  // Add tags if present (directly after component name)
382
513
  if (page.tags && page.tags.length > 0) {
@@ -385,9 +516,12 @@ export function metadataToMarkdownAst(data, options = {}) {
385
516
  }
386
517
  }
387
518
 
388
- // Add separator and Full Docs link
389
- paragraphChildren.push(text(' - '));
390
- paragraphChildren.push(link(page.path, 'Full Docs'));
519
+ // Add separator and parenthetical links (status label first if present)
520
+ paragraphChildren.push(text(statusLabel ? ` - (${statusLabel}, ` : ' - ('));
521
+ paragraphChildren.push(link(`#${page.slug}`, 'Outline'));
522
+ paragraphChildren.push(text(', '));
523
+ paragraphChildren.push(link(page.path, 'Contents'));
524
+ paragraphChildren.push(text(')'));
391
525
  }
392
526
  listItems.push({
393
527
  type: 'listItem',
@@ -409,7 +543,7 @@ export function metadataToMarkdownAst(data, options = {}) {
409
543
  const normalizedPath = typeof path === 'string' ? path.replace(/\\/g, '/') : undefined;
410
544
  const trimmedPath = normalizedPath?.replace(/^(src\/app\/|app\/)/, '').replace(/\/page\.mdx$/, '');
411
545
  const quotedPath = trimmedPath && /[()]/.test(trimmedPath) ? `"${trimmedPath}"` : trimmedPath;
412
- const doNotEditComment = quotedPath ? `This file is autogenerated, DO NOT EDIT AFTER THIS LINE, run: pnpm docs:validate ${quotedPath}` : 'This file is autogenerated, DO NOT EDIT AFTER THIS LINE';
546
+ 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';
413
547
  children.push(comment(doNotEditComment));
414
548
 
415
549
  // Add detailed page sections (non-editable)
@@ -618,6 +752,9 @@ export function metadataToMarkdownAst(data, options = {}) {
618
752
  children.push(paragraph([link(page.path, 'Read more')]));
619
753
  }
620
754
 
755
+ // Add metadata marker (inside wrapper component if provided)
756
+ children.push(comment('The above section is autogenerated, but the remainder of the file can be modified.'));
757
+
621
758
  // Close wrapper component if provided
622
759
  if (indexWrapperComponent) {
623
760
  children.push({
@@ -625,20 +762,16 @@ export function metadataToMarkdownAst(data, options = {}) {
625
762
  value: `</${indexWrapperComponent}>`
626
763
  });
627
764
  }
628
-
629
- // Add metadata export at the end
630
- children.push(comment('This file is autogenerated, but the following metadata can be modified.'));
631
- let metadataCode;
632
- if (pageMetadata && Object.keys(pageMetadata).length > 0) {
633
- metadataCode = `export const metadata = ${JSON.stringify(pageMetadata, null, 2)};`;
634
- } else {
635
- // Default metadata with robots noindex
636
- metadataCode = `export const metadata = {
637
- robots: {
638
- index: false,
639
- },
640
- };`;
641
- }
765
+ const metadataObj = pageMetadata && Object.keys(pageMetadata).length > 0 ? pageMetadata : {
766
+ robots: {
767
+ index: false
768
+ },
769
+ other: {
770
+ audience: 'private'
771
+ }
772
+ };
773
+ const typeAnnotation = "/** @type {import('@mui/internal-docs-infra/createSitemap/types').NextMetadata} */";
774
+ const metadataCode = `export const metadata =\n ${typeAnnotation} (${serializeJsValue(metadataObj, 1)});`;
642
775
  // Output as raw MDX/JSX code (mdxjsEsm node type)
643
776
  children.push({
644
777
  type: 'mdxjsEsm',
@@ -682,7 +815,7 @@ export function metadataToMarkdown(data, options = {}) {
682
815
  }
683
816
 
684
817
  // Add editable section marker
685
- const marker = editableMarker ?? "[//]: # 'This file is autogenerated, but the following list can be modified.'";
818
+ const marker = editableMarker ?? "[//]: # 'This section is autogenerated, but the following list order, title, and [Tag]s can be modified, but nothing within the parentheses.'";
686
819
  lines.push(marker);
687
820
  lines.push('');
688
821
 
@@ -694,14 +827,15 @@ export function metadataToMarkdown(data, options = {}) {
694
827
 
695
828
  // Add page list (editable section)
696
829
  for (const page of pages) {
697
- const pageTitle = page.title || page.slug;
830
+ // List items use displayTitle when the user has overridden the title
831
+ const listTitle = page.displayTitle ?? page.title ?? page.slug;
698
832
 
699
833
  // Check if this is a single-link entry (external link or no detail section)
700
834
  const isSingleLink = page.skipDetailSection || false;
701
835
  let line;
702
836
  if (isSingleLink) {
703
837
  // Format: - [Title](./path) [Tag1] [Tag2]
704
- line = `- [${pageTitle}](${page.path})`;
838
+ line = `- [${listTitle}](${page.path})`;
705
839
 
706
840
  // Add tags if present (directly after link)
707
841
  if (page.tags && page.tags.length > 0) {
@@ -710,8 +844,9 @@ export function metadataToMarkdown(data, options = {}) {
710
844
  }
711
845
  }
712
846
  } else {
713
- // Format: - [Title](#slug) [Tag1] [Tag2] - [Full Docs](./path/page.mdx)
714
- line = `- [${pageTitle}](#${page.slug})`;
847
+ // Format: - Title [Tag1] [Tag2] - (StatusLabel, [Outline](#slug), [Contents](./path))
848
+ const statusLabel = getStatusLabel(page);
849
+ line = `- ${listTitle}`;
715
850
 
716
851
  // Add tags if present (directly after component name)
717
852
  if (page.tags && page.tags.length > 0) {
@@ -720,8 +855,8 @@ export function metadataToMarkdown(data, options = {}) {
720
855
  }
721
856
  }
722
857
 
723
- // Add separator and Full Docs link
724
- line += ` - [Full Docs](${page.path})`;
858
+ // Add separator and parenthetical links (status label first if present)
859
+ line += statusLabel ? ` - (${statusLabel}, [Outline](#${page.slug}), [Contents](${page.path}))` : ` - ([Outline](#${page.slug}), [Contents](${page.path}))`;
725
860
  }
726
861
  lines.push(line);
727
862
  }
@@ -732,7 +867,7 @@ export function metadataToMarkdown(data, options = {}) {
732
867
  const normalizedPath = typeof path === 'string' ? path.replace(/\\/g, '/') : undefined;
733
868
  const trimmedPath = normalizedPath?.replace(/^(src\/app\/|app\/)/, '').replace(/\/page\.mdx$/, '');
734
869
  const quotedPath = trimmedPath && /[()]/.test(trimmedPath) ? `"${trimmedPath}"` : trimmedPath;
735
- const doNotEditMarker = quotedPath ? `[//]: # 'This file is autogenerated, DO NOT EDIT AFTER THIS LINE, run: pnpm docs:validate ${quotedPath}'` : "[//]: # 'This file is autogenerated, DO NOT EDIT AFTER THIS LINE'";
870
+ 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'";
736
871
  lines.push(doNotEditMarker);
737
872
  lines.push('');
738
873
 
@@ -856,24 +991,27 @@ export function metadataToMarkdown(data, options = {}) {
856
991
  lines.push('');
857
992
  }
858
993
 
994
+ // Add metadata marker (inside wrapper component if provided)
995
+ lines.push("[//]: # 'The above section is autogenerated, but the remainder of the file can be modified.'");
996
+ lines.push('');
997
+
859
998
  // Close wrapper component if provided
860
999
  if (indexWrapperComponent) {
861
1000
  lines.push(`</${indexWrapperComponent}>`);
862
1001
  lines.push('');
863
1002
  }
864
-
865
- // Add metadata export at the end
866
- lines.push("[//]: # 'This file is autogenerated, but the following metadata can be modified.'");
867
- lines.push('');
1003
+ const TYPE_ANNOTATION = "/** @type {import('@mui/internal-docs-infra/createSitemap/types').NextMetadata} */";
868
1004
  if (pageMetadata && Object.keys(pageMetadata).length > 0) {
869
- lines.push(`export const metadata = ${JSON.stringify(pageMetadata, null, 2)};`);
1005
+ lines.push(`export const metadata =\n ${TYPE_ANNOTATION} (${serializeJsValue(pageMetadata, 1)});`);
870
1006
  } else {
871
- // Default metadata with robots noindex
872
- lines.push(`export const metadata = {
873
- robots: {
874
- index: false,
875
- },
876
- };`);
1007
+ lines.push(`export const metadata =\n ${TYPE_ANNOTATION} (${serializeJsValue({
1008
+ robots: {
1009
+ index: false
1010
+ },
1011
+ other: {
1012
+ audience: 'private'
1013
+ }
1014
+ }, 1)});`);
877
1015
  }
878
1016
  lines.push('');
879
1017
 
@@ -891,6 +1029,9 @@ export async function markdownToMetadata(markdown) {
891
1029
  let pageMetadata;
892
1030
  let indexWrapperComponent;
893
1031
  const pages = [];
1032
+ // Track the title from the editable list for each page (by slug)
1033
+ // Used to detect user overrides after the detail section is parsed
1034
+ const listTitles = new Map();
894
1035
  let currentSection = 'header';
895
1036
  let currentPage = null;
896
1037
 
@@ -899,7 +1040,7 @@ export async function markdownToMetadata(markdown) {
899
1040
  // Track sections based on definition nodes (HTML-style comments)
900
1041
  if (node.type === 'definition') {
901
1042
  const defNode = node;
902
- if (defNode.title?.includes('following list can be modified')) {
1043
+ if (defNode.title?.includes('following list can be modified') || defNode.title?.includes('following list order')) {
903
1044
  currentSection = 'editable';
904
1045
  return;
905
1046
  }
@@ -907,7 +1048,9 @@ export async function markdownToMetadata(markdown) {
907
1048
  currentSection = 'details';
908
1049
  return;
909
1050
  }
910
- if (defNode.title?.includes('following metadata can be modified')) {
1051
+ if (defNode.title?.includes('remainder of the file can be modified') ||
1052
+ // TODO: Remove this old marker check once all index files have been migrated to the new format.
1053
+ defNode.title?.includes('following metadata can be modified')) {
911
1054
  currentSection = 'metadata';
912
1055
  return;
913
1056
  }
@@ -1003,49 +1146,98 @@ export async function markdownToMetadata(markdown) {
1003
1146
  tags: tags.length > 0 ? tags : undefined,
1004
1147
  skipDetailSection: true // Mark as external/single-link entry
1005
1148
  });
1149
+ listTitles.set(slug, pageTitle);
1006
1150
  } else if (links.length >= 2) {
1007
- // Two-link format: - [Title](#slug) [Tag1] [Tag2] - [Full Docs](./path/page.mdx)
1008
- const sectionLink = links[0];
1009
- const docsLink = links[1];
1010
- const pageTitle = extractPlainTextFromNode(sectionLink);
1011
- const slug = sectionLink.url.replace('#', ''); // Extract slug from #slug
1012
- const path = docsLink.url; // Get path from full docs link
1013
-
1014
- // Extract tags from text nodes between the section link and full docs link
1015
- // Tags are in the format [Tag] where Tag can be New, Hot, Beta, etc.
1016
- const tags = [];
1017
- let foundSectionLink = false;
1018
- let foundDocsLink = false;
1019
- for (const child of paragraphNode.children) {
1020
- if (child === sectionLink) {
1021
- foundSectionLink = true;
1022
- continue;
1151
+ const firstChild = paragraphNode.children[0];
1152
+ if (firstChild && firstChild.type === 'text' && firstChild.value.includes(' - (')) {
1153
+ // Format: Title [Tags] - (Status, [Outline](#slug), [Contents](./path))
1154
+ const firstText = firstChild.value;
1155
+ const dashParenIndex = firstText.lastIndexOf(' - (');
1156
+ const titlePart = firstText.substring(0, dashParenIndex);
1157
+
1158
+ // Extract tags from title part
1159
+ const tags = [];
1160
+ const tagRegex = /\[([^\]]+)\]/g;
1161
+ let match = tagRegex.exec(titlePart);
1162
+ while (match !== null) {
1163
+ tags.push(match[1]);
1164
+ match = tagRegex.exec(titlePart);
1023
1165
  }
1024
- if (child === docsLink) {
1025
- foundDocsLink = true;
1026
- break;
1166
+ const cleanTitle = titlePart.replace(/\s*\[[^\]]+\]/g, '').trim();
1167
+
1168
+ // Find Outline and Contents links
1169
+ const outlineLink = links.find(l => extractPlainTextFromNode(l) === 'Outline' || l.url.startsWith('#'));
1170
+ const contentsLink = links.find(l => extractPlainTextFromNode(l) === 'Contents' || !l.url.startsWith('#'));
1171
+ if (outlineLink && contentsLink) {
1172
+ const slug = outlineLink.url.replace('#', '');
1173
+ const pagePath = contentsLink.url;
1174
+
1175
+ // Parse status label from the text before the first link
1176
+ // Format: " - (Status, " or " - ("
1177
+ let parsedAudience;
1178
+ let isIndex = false;
1179
+ const afterDashParen = firstText.substring(dashParenIndex + 4); // after ' - ('
1180
+ if (afterDashParen.length > 0) {
1181
+ // Status label is the text before the first comma that precedes a link
1182
+ const commaIndex = afterDashParen.indexOf(',');
1183
+ if (commaIndex >= 0) {
1184
+ const statusStr = afterDashParen.substring(0, commaIndex).trim();
1185
+ if (statusStr.length > 0) {
1186
+ const status = parseStatusLabel(statusStr);
1187
+ parsedAudience = status.audience;
1188
+ isIndex = status.index;
1189
+ }
1190
+ }
1191
+ }
1192
+ pages.push({
1193
+ slug,
1194
+ path: pagePath,
1195
+ title: cleanTitle,
1196
+ description: 'No description available',
1197
+ tags: tags.length > 0 ? tags : undefined,
1198
+ audience: parsedAudience,
1199
+ index: isIndex || undefined
1200
+ });
1201
+ listTitles.set(slug, cleanTitle);
1027
1202
  }
1028
- if (foundSectionLink && !foundDocsLink && child.type === 'text') {
1029
- // Match [Tag] patterns in the text
1030
- const tagRegex = /\[(\w+)\]/g;
1031
- let match = tagRegex.exec(child.value);
1032
- while (match !== null) {
1033
- tags.push(match[1]);
1034
- match = tagRegex.exec(child.value);
1203
+ } else {
1204
+ // TODO: Remove this old format parsing once all index files have been migrated.
1205
+ // Old format: - [Title](#slug) [Tag1] [Tag2] - [Full Docs](./path/page.mdx)
1206
+ const sectionLink = links[0];
1207
+ const docsLink = links[1];
1208
+ const pageTitle = extractPlainTextFromNode(sectionLink);
1209
+ const slug = sectionLink.url.replace('#', '');
1210
+ const pagePath = docsLink.url;
1211
+ const tags = [];
1212
+ let foundSectionLink = false;
1213
+ let foundDocsLink = false;
1214
+ for (const child of paragraphNode.children) {
1215
+ if (child === sectionLink) {
1216
+ foundSectionLink = true;
1217
+ continue;
1218
+ }
1219
+ if (child === docsLink) {
1220
+ foundDocsLink = true;
1221
+ break;
1222
+ }
1223
+ if (foundSectionLink && !foundDocsLink && child.type === 'text') {
1224
+ const tagRegex = /\[(\w+)\]/g;
1225
+ let match = tagRegex.exec(child.value);
1226
+ while (match !== null) {
1227
+ tags.push(match[1]);
1228
+ match = tagRegex.exec(child.value);
1229
+ }
1035
1230
  }
1036
1231
  }
1232
+ pages.push({
1233
+ slug,
1234
+ path: pagePath,
1235
+ title: pageTitle,
1236
+ description: 'No description available',
1237
+ tags: tags.length > 0 ? tags : undefined
1238
+ });
1239
+ listTitles.set(slug, pageTitle);
1037
1240
  }
1038
-
1039
- // Only extract slug, path, title, and tags from the editable list
1040
- // The description will be filled in from the details section
1041
- pages.push({
1042
- slug,
1043
- path,
1044
- title: pageTitle,
1045
- description: 'No description available',
1046
- // Will be updated from details section
1047
- tags: tags.length > 0 ? tags : undefined
1048
- });
1049
1241
  }
1050
1242
  }
1051
1243
  return;
@@ -1069,8 +1261,14 @@ export async function markdownToMetadata(markdown) {
1069
1261
  }
1070
1262
  }
1071
1263
  const pageTitle = extractPlainTextFromNode(headingNode);
1072
- // Find the page in the existing pages array by matching the title
1073
- const existingPage = pages.find(p => p.title === pageTitle);
1264
+ // Find the page in the existing pages array by matching the title first,
1265
+ // then fall back to slug matching. Slug matching is needed when the user
1266
+ // has renamed the list item (so list title ≠ heading title).
1267
+ let existingPage = pages.find(p => p.title === pageTitle);
1268
+ if (!existingPage) {
1269
+ const derivedSlug = titleToSlug(pageTitle);
1270
+ existingPage = pages.find(p => p.slug === derivedSlug);
1271
+ }
1074
1272
  if (existingPage) {
1075
1273
  // Start updating this existing page
1076
1274
  currentPage = {
@@ -1129,8 +1327,21 @@ export async function markdownToMetadata(markdown) {
1129
1327
  }
1130
1328
  }
1131
1329
 
1132
- // Skip read more links
1330
+ // Extract path from Read more link for robust page matching.
1331
+ // If the current page's slug doesn't match any page in the list
1332
+ // (e.g., because titleToSlug(heading) ≠ actual slug), the path match
1333
+ // provides a final correction.
1133
1334
  if (paragraphText.startsWith('[Read more]')) {
1335
+ if (currentPage) {
1336
+ const linkNode = paragraphNode.children.find(child => child.type === 'link');
1337
+ if (linkNode) {
1338
+ const readMorePath = linkNode.url;
1339
+ const matchedPage = pages.find(p => p.path === readMorePath);
1340
+ if (matchedPage && currentPage.slug !== matchedPage.slug) {
1341
+ currentPage.slug = matchedPage.slug;
1342
+ }
1343
+ }
1344
+ }
1134
1345
  return;
1135
1346
  }
1136
1347
 
@@ -1160,8 +1371,8 @@ export async function markdownToMetadata(markdown) {
1160
1371
  if (currentSection === 'metadata' && node.type === 'code') {
1161
1372
  const codeNode = node;
1162
1373
  const codeValue = codeNode.value;
1163
- // Parse the export const metadata = { ... } statement
1164
- const metadataMatch = codeValue.match(/export\s+const\s+metadata\s*=\s*(\{[\s\S]*\})/);
1374
+ // Parse the export const metadata = /** @type ... */ ({ ... }) or { ... } statement
1375
+ const metadataMatch = codeValue.match(/export\s+const\s+metadata\s*=\s*(?:\/\*\*[^*]*\*\/\s*\()?\s*(\{[\s\S]*\})\)?/);
1165
1376
  if (metadataMatch) {
1166
1377
  try {
1167
1378
  // Use Function constructor to safely parse the object literal
@@ -1177,7 +1388,13 @@ export async function markdownToMetadata(markdown) {
1177
1388
 
1178
1389
  // Also try to parse metadata from raw export statement in the markdown
1179
1390
  // This handles MDX files where the export is not in a code block
1180
- const metadataExportMatch = markdown.match(/\[\/\/\]: # 'This file is autogenerated, but the following metadata can be modified\.'\s*\n\s*\n\s*export\s+const\s+metadata\s*=\s*(\{[\s\S]*?\n\})/);
1391
+ // The regex allows optional HTML closing tags (e.g., </PagesIndex>) between the marker and the export
1392
+ // Greedy `[\s\S]*` ensures the capture extends to the LAST `\n\s*\}` (outermost closing brace)
1393
+ // rather than stopping at an inner closing brace. This is safe because the metadata
1394
+ // export is always the last statement in the file.
1395
+ const metadataExportMatch = markdown.match(/\[\/\/\]: # 'The above section is autogenerated, but the remainder of the file can be modified\.'[\s\S]*?export\s+const\s+metadata\s*=\s*(?:\/\*\*[^*]*\*\/\s*\()?\s*(\{[\s\S]*\n\s*\})\)?\s*;?/) ??
1396
+ // TODO: Remove this old marker match once all index files have been migrated to the new format.
1397
+ markdown.match(/\[\/\/\]: # 'This (?:section|file) is autogenerated, but the following metadata can be modified\.'[\s\S]*?export\s+const\s+metadata\s*=\s*(?:\/\*\*[^*]*\*\/\s*\()?\s*(\{[\s\S]*\n\s*\})\)?\s*;?/);
1181
1398
  if (metadataExportMatch && !pageMetadata) {
1182
1399
  try {
1183
1400
  // eslint-disable-next-line no-new-func
@@ -1200,6 +1417,16 @@ export async function markdownToMetadata(markdown) {
1200
1417
  }
1201
1418
  }
1202
1419
  }
1420
+
1421
+ // Detect title overrides: compare the list title with the heading title.
1422
+ // After the detail section is parsed, page.title reflects the heading's title.
1423
+ // If the list had a different title, the user renamed it — store as displayTitle.
1424
+ for (const page of pages) {
1425
+ const listTitle = listTitles.get(page.slug);
1426
+ if (listTitle && listTitle !== page.title) {
1427
+ page.displayTitle = listTitle;
1428
+ }
1429
+ }
1203
1430
  if (!title) {
1204
1431
  return null;
1205
1432
  }
@@ -173,7 +173,9 @@ export async function syncPageIndex(options) {
173
173
 
174
174
  // Step 1.5: Verify the file has the autogeneration marker if it exists
175
175
  if (fileExists && existingContent) {
176
- const hasMarker = existingContent.includes("[//]: # 'This file is autogenerated");
176
+ const hasMarker = existingContent.includes("[//]: # 'This section is autogenerated") ||
177
+ // TODO: Remove this old marker check once all index files have been migrated to the new format.
178
+ existingContent.includes("[//]: # 'This file is autogenerated");
177
179
  if (!hasMarker) {
178
180
  // File exists but doesn't have the autogeneration marker - skip updating it
179
181
  return;
@@ -226,6 +228,7 @@ export async function syncPageIndex(options) {
226
228
  }
227
229
  let release;
228
230
  let mergedPages = []; // Store merged pages for parent update
231
+ let currentPageMetadata; // Store the index's own metadata for parent update
229
232
 
230
233
  try {
231
234
  // Step 5: Acquire lock on the index file
@@ -258,6 +261,7 @@ export async function syncPageIndex(options) {
258
261
  const parsed = await markdownToMetadata(currentMarkdown);
259
262
  if (parsed) {
260
263
  currentPages = parsed.pages;
264
+ currentPageMetadata = parsed.pageMetadata;
261
265
  }
262
266
  }
263
267
 
@@ -349,6 +353,16 @@ export async function syncPageIndex(options) {
349
353
  description: 'No description available'
350
354
  };
351
355
 
356
+ // Determine audience/index flags from the index page's own metadata
357
+ const audience = currentPageMetadata?.other?.audience;
358
+ if (audience) {
359
+ indexMetadata.audience = audience;
360
+ }
361
+ // An index page with child pages is always an index
362
+ if (mergedPages.length > 0) {
363
+ indexMetadata.index = true;
364
+ }
365
+
352
366
  // Convert child pages to sections format (no subsections, just page names)
353
367
  // Use mergedPages which contains the complete merged state
354
368
  // Skip single-link entries (external links) as they don't have detail sections
@@ -360,13 +374,10 @@ export async function syncPageIndex(options) {
360
374
  continue;
361
375
  }
362
376
  sections[childPage.slug] = {
363
- title: childPage.title || childPage.slug,
364
- titleMarkdown: childPage.title ? [{
365
- type: 'text',
366
- value: childPage.title
367
- }] : [{
377
+ title: childPage.displayTitle ?? childPage.title ?? childPage.slug,
378
+ titleMarkdown: [{
368
379
  type: 'text',
369
- value: childPage.slug
380
+ value: childPage.displayTitle ?? childPage.title ?? childPage.slug
370
381
  }],
371
382
  children: {} // Don't include any subsections in parent index
372
383
  };
@@ -230,7 +230,8 @@ function toPageMetadata(metadata, filePath, options = {}) {
230
230
  keywords: metadata.keywords,
231
231
  sections: metadata.sections,
232
232
  embeddings: metadata.embeddings,
233
- image: metadata.image
233
+ image: metadata.image,
234
+ audience: metadata.other?.audience
234
235
  };
235
236
  }
236
237
 
@@ -637,7 +638,9 @@ export const transformMarkdownMetadata = (options = {}) => {
637
638
  }
638
639
 
639
640
  // Check if this is an autogenerated index file
640
- if (fileContent && fileContent.includes("[//]: # 'This file is autogenerated")) {
641
+ if (fileContent && (fileContent.includes("[//]: # 'This section is autogenerated") ||
642
+ // TODO: Remove this old marker check once all index files have been migrated to the new format.
643
+ fileContent.includes("[//]: # 'This file is autogenerated"))) {
641
644
  // Parse the page list metadata from the markdown
642
645
  const pagesMetadata = await markdownToMetadata(fileContent);
643
646
  if (pagesMetadata) {
@@ -687,7 +690,7 @@ export const transformMarkdownMetadata = (options = {}) => {
687
690
  title: pagesMetadata.title,
688
691
  prefix,
689
692
  pages: pagesMetadata.pages.map(page => ({
690
- title: page.title,
693
+ title: page.displayTitle ?? page.title,
691
694
  slug: page.slug,
692
695
  path: page.path,
693
696
  description: page.description,
@@ -697,6 +700,8 @@ export const transformMarkdownMetadata = (options = {}) => {
697
700
  exports: page.exports,
698
701
  tags: page.tags,
699
702
  skipDetailSection: page.skipDetailSection,
703
+ audience: page.audience,
704
+ index: page.index,
700
705
  image: page.image
701
706
  }))
702
707
  };
@@ -1,4 +1,5 @@
1
1
  import type { PhrasingContent } from 'mdast';
2
+ import { Audience } from "../../createSitemap/types.mjs";
2
3
  /**
3
4
  * Plugin options for transformMarkdownMetadata
4
5
  */
@@ -101,4 +102,11 @@ export interface ExtractedMetadata {
101
102
  url: string;
102
103
  alt?: string;
103
104
  };
105
+ robots?: {
106
+ index?: boolean;
107
+ };
108
+ other?: {
109
+ audience?: Audience;
110
+ [key: string]: unknown;
111
+ };
104
112
  }
@@ -89,6 +89,19 @@ export interface UseSearchOptions {
89
89
  boost?: Partial<Record<string, number>>;
90
90
  /** Include page categories in groups: "Overview Pages" vs "Pages" */
91
91
  includeCategoryInGroup?: boolean;
92
+ /**
93
+ * When true, pages with `audience: 'private'` are included in the search index
94
+ * and default results. Use this for internal deployments where private pages
95
+ * should be discoverable.
96
+ *
97
+ * Typically driven by an environment variable:
98
+ * ```ts
99
+ * showPrivatePages: process.env.SHOW_PRIVATE_PAGES === 'true'
100
+ * ```
101
+ *
102
+ * @default false
103
+ */
104
+ showPrivatePages?: boolean;
92
105
  /**
93
106
  * When true, excludes `sections` and `subsections` fields from page-type results.
94
107
  * The individual section and subsection entries are still created.
@@ -308,7 +308,8 @@ export function useSearch(options) {
308
308
  enableStemming = true,
309
309
  generateSlug,
310
310
  flattenPage = defaultFlattenPage,
311
- formatResult = defaultFormatResult
311
+ formatResult = defaultFormatResult,
312
+ showPrivatePages = false
312
313
  } = options;
313
314
  const [index, setIndex] = React.useState(null);
314
315
  const [defaultResults, setDefaultResults] = React.useState({
@@ -356,6 +357,10 @@ export function useSearch(options) {
356
357
  let pageResultsCount = 0;
357
358
  Object.entries(sitemap.data).forEach(([_sectionKey, sectionData]) => {
358
359
  (sectionData.pages || []).forEach(page => {
360
+ // Skip private pages in public deployments
361
+ if (!showPrivatePages && page.audience === 'private') {
362
+ return;
363
+ }
359
364
  const flattened = flattenPage(page, sectionData, options.includeCategoryInGroup || false, options.excludeSections, generateSlug);
360
365
  pages.push(...flattened);
361
366
 
@@ -414,7 +419,7 @@ export function useSearch(options) {
414
419
  setDefaultResults(defaultResultsValue);
415
420
  setResults(defaultResultsValue);
416
421
  })();
417
- }, [sitemapImport, maxDefaultResults, flattenPage, generateSlug, enableStemming, options.includeCategoryInGroup, options.excludeSections]);
422
+ }, [sitemapImport, maxDefaultResults, flattenPage, generateSlug, enableStemming, options.includeCategoryInGroup, options.excludeSections, showPrivatePages]);
418
423
  const search = React.useCallback(async (value, {
419
424
  facets,
420
425
  groupBy,
@@ -34,6 +34,8 @@ if ((process.env.CONTEXT === 'production' || process.env.CONTEXT === 'branch-dep
34
34
  */
35
35
 
36
36
  process.env.DEPLOY_ENV = DEPLOY_ENV;
37
+ const SHOW_PRIVATE_PAGES = String(process.env.DEPLOY_ENV !== 'production' && process.env.DEPLOY_ENV !== 'staging');
38
+ process.env.SHOW_PRIVATE_PAGES = SHOW_PRIVATE_PAGES;
37
39
  export function withDeploymentConfig(nextConfig) {
38
40
  return {
39
41
  trailingSlash: true,
@@ -43,6 +45,7 @@ export function withDeploymentConfig(nextConfig) {
43
45
  env: {
44
46
  // production | staging | pull-request | development
45
47
  DEPLOY_ENV,
48
+ SHOW_PRIVATE_PAGES,
46
49
  ...nextConfig.env,
47
50
  // https://docs.netlify.com/configure-builds/environment-variables/#git-metadata
48
51
  // reference ID (also known as "SHA" or "hash") of the commit we're building.