@mui/internal-docs-infra 0.12.1-canary.45 → 0.12.1-canary.46

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.
Files changed (32) hide show
  1. package/createSitemap/index.d.mts +2 -1
  2. package/createSitemap/types.d.mts +27 -0
  3. package/package.json +22 -2
  4. package/pipeline/loadPrecomputedSitemap/loadPrecomputedSitemap.d.mts +1 -1
  5. package/pipeline/loadServerPageIndex/enrichPageIndex.mjs +12 -2
  6. package/pipeline/loadServerPageIndex/extractPrefixAndTitle.mjs +2 -1
  7. package/pipeline/loadServerPageIndex/loadServerPageIndex.d.mts +1 -1
  8. package/pipeline/loadServerPageIndex/resolvePageIndexCacheKey.d.mts +9 -2
  9. package/pipeline/loadServerPageIndex/resolvePageIndexCacheKey.mjs +9 -2
  10. package/pipeline/loaderUtils/extractNameAndSlugFromUrl.mjs +1 -31
  11. package/pipeline/loaderUtils/index.d.mts +2 -1
  12. package/pipeline/loaderUtils/index.mjs +2 -1
  13. package/pipeline/loaderUtils/kebabToTitleCase.d.mts +11 -0
  14. package/pipeline/loaderUtils/kebabToTitleCase.mjs +34 -0
  15. package/pipeline/loaderUtils/stripRouteGroups.d.mts +36 -0
  16. package/pipeline/loaderUtils/stripRouteGroups.mjs +44 -0
  17. package/pipeline/syncPageIndex/mergeMetadataMarkdown.d.mts +7 -0
  18. package/pipeline/syncPageIndex/mergeMetadataMarkdown.mjs +156 -18
  19. package/pipeline/syncPageIndex/metadataToMarkdown.d.mts +147 -1
  20. package/pipeline/syncPageIndex/metadataToMarkdown.mjs +446 -71
  21. package/pipeline/syncPageIndex/syncPageIndex.d.mts +14 -1
  22. package/pipeline/syncPageIndex/syncPageIndex.mjs +21 -19
  23. package/pipeline/syncTypes/syncTypes.mjs +6 -4
  24. package/pipeline/transformMarkdownMetadata/transformMarkdownMetadata.mjs +19 -31
  25. package/pipeline/transformMarkdownMetadata/types.d.mts +1 -1
  26. package/pipeline/transformMarkdownRelativePaths/transformMarkdownRelativePaths.mjs +5 -2
  27. package/resolvePageUrl/index.d.mts +1 -0
  28. package/resolvePageUrl/index.mjs +1 -0
  29. package/resolvePageUrl/resolvePageUrl.d.mts +12 -0
  30. package/resolvePageUrl/resolvePageUrl.mjs +27 -0
  31. package/useSearch/useSearch.mjs +3 -1
  32. package/withDocsInfra/withDocsInfra.d.mts +2 -2
@@ -1 +1,2 @@
1
- export * from "./createSitemap.mjs";
1
+ export * from "./createSitemap.mjs";
2
+ export type * from "./types.mjs";
@@ -41,11 +41,30 @@ export interface SitemapPage {
41
41
  skipDetailSection?: boolean;
42
42
  audience?: Audience;
43
43
  index?: boolean;
44
+ /**
45
+ * The title of the route-group section this page is listed under in a grouped index
46
+ * (e.g. `Components`), resolved from the page's route group. `null` for a page in a flat
47
+ * index or with no route group, so the field is always present. Useful for
48
+ * grouping/faceting search results.
49
+ */
50
+ section: string | null;
44
51
  image?: {
45
52
  url: string;
46
53
  alt?: string;
47
54
  };
48
55
  }
56
+ /**
57
+ * A route-group section heading in a grouped index (see `SitemapSectionData.sections`).
58
+ * Maps a Next.js route group to the human-editable heading its pages are listed under.
59
+ */
60
+ export interface PageIndexSection {
61
+ /** The route group this section collects (e.g. `(components)`). */
62
+ group: string;
63
+ /** The (human-editable) heading text shown for the section. */
64
+ title: string;
65
+ /** Heading depth for the section title. Defaults to 2 (`##`). */
66
+ depth?: number;
67
+ }
49
68
  /**
50
69
  * Section data from sitemap
51
70
  */
@@ -53,6 +72,14 @@ export interface SitemapSectionData {
53
72
  title: string;
54
73
  prefix: string;
55
74
  pages: SitemapPage[];
75
+ /**
76
+ * Ordered route-group sections when the index is grouped (its `##` subtitles), so
77
+ * search can recover the sections a page belongs to. An empty array for a flat index,
78
+ * so the field is always present.
79
+ */
80
+ sections: PageIndexSection[];
81
+ /** Heading of the detail-region wrapper in a grouped index (defaults to `Details`). */
82
+ detailsSectionTitle?: string;
56
83
  }
57
84
  /**
58
85
  * Orama schema property types
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mui/internal-docs-infra",
3
- "version": "0.12.1-canary.45",
3
+ "version": "0.12.1-canary.46",
4
4
  "author": "MUI Team",
5
5
  "description": "MUI Infra - internal documentation creation tools.",
6
6
  "license": "MIT",
@@ -281,6 +281,26 @@
281
281
  "default": "./createSitemap/types.mjs"
282
282
  }
283
283
  },
284
+ "./resolvePageUrl": {
285
+ "import": {
286
+ "types": "./resolvePageUrl/index.d.mts",
287
+ "default": "./resolvePageUrl/index.mjs"
288
+ },
289
+ "default": {
290
+ "types": "./resolvePageUrl/index.d.mts",
291
+ "default": "./resolvePageUrl/index.mjs"
292
+ }
293
+ },
294
+ "./routeGroups": {
295
+ "import": {
296
+ "types": "./pipeline/loaderUtils/stripRouteGroups.d.mts",
297
+ "default": "./pipeline/loaderUtils/stripRouteGroups.mjs"
298
+ },
299
+ "default": {
300
+ "types": "./pipeline/loaderUtils/stripRouteGroups.d.mts",
301
+ "default": "./pipeline/loaderUtils/stripRouteGroups.mjs"
302
+ }
303
+ },
284
304
  "./useStream": {
285
305
  "import": {
286
306
  "types": "./useStream/index.d.mts",
@@ -795,5 +815,5 @@
795
815
  "bin": {
796
816
  "docs-infra": "./cli/index.mjs"
797
817
  },
798
- "gitSha": "1c5627440adc8bc5990c9b1c95fb4b74d375345e"
818
+ "gitSha": "0534275eee6a8935af28abb295f1ed280d459e95"
799
819
  }
@@ -8,7 +8,7 @@ export type LoaderOptions = {
8
8
  };
9
9
  /**
10
10
  * Directory for the sha256-validated JSON cache of parsed page indexes. When set,
11
- * unchanged indexes are read from `{cacheDir}/pages-index/{route}.json` instead of
11
+ * unchanged indexes are read from `{cacheDir}/pages-index-v2/{route}.json` instead of
12
12
  * being re-parsed.
13
13
  */
14
14
  cacheDir?: string;
@@ -1,5 +1,5 @@
1
1
  import { extractPrefixAndTitle, stripTitleMarkdown } from "./extractPrefixAndTitle.mjs";
2
- import { collapseInlineWhitespace } from "../syncPageIndex/metadataToMarkdown.mjs";
2
+ import { collapseInlineWhitespace, createPageSectionResolver } from "../syncPageIndex/metadataToMarkdown.mjs";
3
3
  /**
4
4
  * Converts parsed page-index metadata into the `SitemapSectionData` read-model.
5
5
  *
@@ -18,10 +18,15 @@ export function enrichPageIndex(metadata, absolutePath, rootContext) {
18
18
  prefix,
19
19
  title
20
20
  } = extractPrefixAndTitle(absolutePath, rootContext);
21
+
22
+ // Resolve each page's section title from its route group (grouped indexes only).
23
+ const resolveSection = createPageSectionResolver(metadata.sections);
21
24
  return {
22
25
  ...metadata,
23
26
  prefix,
24
27
  title,
28
+ // Always present so consumers never branch on absence: an empty array for a flat index.
29
+ sections: metadata.sections ?? [],
25
30
  // Normalize the index's own (leaked) description like the parser. Spread instead of an explicit
26
31
  // key because SitemapSectionData does not declare `description`.
27
32
  ...(metadata.description !== undefined ? {
@@ -35,6 +40,7 @@ export function enrichPageIndex(metadata, absolutePath, rootContext) {
35
40
  const {
36
41
  descriptionMarkdown,
37
42
  sections,
43
+ sectionGroup,
38
44
  ...pageWithoutMarkdown
39
45
  } = page;
40
46
  return {
@@ -43,7 +49,11 @@ export function enrichPageIndex(metadata, absolutePath, rootContext) {
43
49
  keywords: page.keywords?.map(collapseInlineWhitespace),
44
50
  types: page.types?.map(collapseInlineWhitespace),
45
51
  // Strip titleMarkdown from the sections hierarchy.
46
- sections: sections ? stripTitleMarkdown(sections) : undefined
52
+ sections: sections ? stripTitleMarkdown(sections) : undefined,
53
+ // Resolve the route-group section title for grouped indexes (search faceting),
54
+ // honoring the header a human filed an otherwise-ungrouped page under. Always present
55
+ // (`null` when the page has no section) so consumers never branch on absence.
56
+ section: resolveSection(page.path, sectionGroup) ?? null
47
57
  };
48
58
  })
49
59
  };
@@ -1,4 +1,5 @@
1
1
  import path from 'node:path';
2
+ import { isRouteGroup } from "../loaderUtils/stripRouteGroups.mjs";
2
3
  /**
3
4
  * Converts a path segment to a title
4
5
  * e.g., "docs-infra" -> "Docs Infra", "components" -> "Components"
@@ -58,7 +59,7 @@ export function extractPrefixAndTitle(absolutePath, rootContext) {
58
59
  return false;
59
60
  }
60
61
  // Filter out Next.js route groups (e.g., '(public)', '(content)')
61
- if (seg.startsWith('(') && seg.endsWith(')')) {
62
+ if (isRouteGroup(seg)) {
62
63
  return false;
63
64
  }
64
65
  // Filter out current directory markers
@@ -11,7 +11,7 @@ export interface CreateLoadServerPageIndexOptions {
11
11
  /**
12
12
  * Directory for the sha256-validated JSON cache of parsed page indexes.
13
13
  *
14
- * When set, a read first checks `{cacheDir}/pages-index/{route}.json`; on a hash
14
+ * When set, a read first checks `{cacheDir}/pages-index-v2/{route}.json`; on a hash
15
15
  * match it returns the cached data and skips the markdown parse, and on a miss it
16
16
  * parses, writes the cache, and returns. When unset, no cache is read or written.
17
17
  */
@@ -1,5 +1,12 @@
1
- /** Cache namespace (subdirectory) for page-index entries. */
2
- export declare const PAGE_INDEX_CACHE_NAMESPACE = "pages-index";
1
+ /**
2
+ * Cache namespace (subdirectory) for page-index entries. The `-v2` suffix is a schema version:
3
+ * grouped indexes added always-present `sections` and per-page `section` fields to the cached
4
+ * read-model, so a pre-existing `pages-index` entry (keyed only by content hash) could otherwise
5
+ * survive a docs-infra upgrade and serve the old shape for an unchanged index file. Bumping the
6
+ * namespace forces a clean miss the first time the new code runs. Bump again on any future
7
+ * breaking change to the cached page-index shape.
8
+ */
9
+ export declare const PAGE_INDEX_CACHE_NAMESPACE = "pages-index-v2";
3
10
  /**
4
11
  * Derives the cache key for a page-index file from its path, reusing the same
5
12
  * route derivation as the loaded data so a writer and reader of the same index
@@ -1,7 +1,14 @@
1
1
  import { extractPrefixAndTitle } from "./extractPrefixAndTitle.mjs";
2
2
 
3
- /** Cache namespace (subdirectory) for page-index entries. */
4
- export const PAGE_INDEX_CACHE_NAMESPACE = 'pages-index';
3
+ /**
4
+ * Cache namespace (subdirectory) for page-index entries. The `-v2` suffix is a schema version:
5
+ * grouped indexes added always-present `sections` and per-page `section` fields to the cached
6
+ * read-model, so a pre-existing `pages-index` entry (keyed only by content hash) could otherwise
7
+ * survive a docs-infra upgrade and serve the old shape for an unchanged index file. Bumping the
8
+ * namespace forces a clean miss the first time the new code runs. Bump again on any future
9
+ * breaking change to the cached page-index shape.
10
+ */
11
+ export const PAGE_INDEX_CACHE_NAMESPACE = 'pages-index-v2';
5
12
 
6
13
  /**
7
14
  * Derives the cache key for a page-index file from its path, reusing the same
@@ -1,4 +1,5 @@
1
1
  import { fileUrlToPortablePath } from "./fileUrlToPortablePath.mjs";
2
+ import { kebabToTitleCase } from "./kebabToTitleCase.mjs";
2
3
 
3
4
  /**
4
5
  * Extracts and formats a name and slug from a URL path.
@@ -42,37 +43,6 @@ function camelToTitleCase(camelCase) {
42
43
  .replace(/^./, str => str.toUpperCase());
43
44
  }
44
45
 
45
- /**
46
- * Known brand names and acronyms that have a canonical capitalization.
47
- * Keys are lowercase, values are the canonical form to use in titles.
48
- */
49
- const BRAND_NAME_OVERRIDES = {
50
- javascript: 'JavaScript',
51
- typescript: 'TypeScript'
52
- };
53
-
54
- /**
55
- * Capitalizes a single word, applying brand-name overrides when applicable.
56
- */
57
- function capitalizeWord(word) {
58
- const lower = word.toLowerCase();
59
- const override = BRAND_NAME_OVERRIDES[lower];
60
- if (override) {
61
- return override;
62
- }
63
- return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase();
64
- }
65
-
66
- /**
67
- * Converts a kebab-case string to Title Case
68
- * @param kebabCase - The kebab-case string to convert
69
- * @returns Title case string
70
- */
71
- function kebabToTitleCase(kebabCase) {
72
- return kebabCase.split(/[-_]/) // Split on both hyphens and underscores
73
- .map(capitalizeWord).join(' ');
74
- }
75
-
76
46
  /**
77
47
  * Detects if a string is camelCase or PascalCase
78
48
  * @param str - The string to check
@@ -7,4 +7,5 @@ export * from "./extractNameAndSlugFromUrl.mjs";
7
7
  export * from "./externalsToPackages.mjs";
8
8
  export * from "./fileUrlToPortablePath.mjs";
9
9
  export * from "./getLanguageFromExtension.mjs";
10
- export * from "./generateFileSlug.mjs";
10
+ export * from "./generateFileSlug.mjs";
11
+ export * from "./stripRouteGroups.mjs";
@@ -7,4 +7,5 @@ export * from "./extractNameAndSlugFromUrl.mjs";
7
7
  export * from "./externalsToPackages.mjs";
8
8
  export * from "./fileUrlToPortablePath.mjs";
9
9
  export * from "./getLanguageFromExtension.mjs";
10
- export * from "./generateFileSlug.mjs";
10
+ export * from "./generateFileSlug.mjs";
11
+ export * from "./stripRouteGroups.mjs";
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Converts a kebab-case (or snake_case) string to Title Case, honoring brand-name overrides
3
+ * (e.g. `javascript` -> `JavaScript`). This is the single title-casing rule shared by the
4
+ * producers that turn a slug/folder name into a display title — directory-derived index titles,
5
+ * URL-segment names, and route-group section headings — so those titles never diverge.
6
+ *
7
+ * @example kebabToTitleCase('alert-dialog') -> 'Alert Dialog'
8
+ * @example kebabToTitleCase('getting_started') -> 'Getting Started'
9
+ * @example kebabToTitleCase('typescript') -> 'TypeScript'
10
+ */
11
+ export declare function kebabToTitleCase(kebabCase: string): string;
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Known brand names and acronyms that have a canonical capitalization.
3
+ * Keys are lowercase, values are the canonical form to use in titles.
4
+ */
5
+ const BRAND_NAME_OVERRIDES = {
6
+ javascript: 'JavaScript',
7
+ typescript: 'TypeScript'
8
+ };
9
+
10
+ /**
11
+ * Capitalizes a single word, applying brand-name overrides when applicable.
12
+ */
13
+ function capitalizeWord(word) {
14
+ const lower = word.toLowerCase();
15
+ const override = BRAND_NAME_OVERRIDES[lower];
16
+ if (override) {
17
+ return override;
18
+ }
19
+ return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase();
20
+ }
21
+
22
+ /**
23
+ * Converts a kebab-case (or snake_case) string to Title Case, honoring brand-name overrides
24
+ * (e.g. `javascript` -> `JavaScript`). This is the single title-casing rule shared by the
25
+ * producers that turn a slug/folder name into a display title — directory-derived index titles,
26
+ * URL-segment names, and route-group section headings — so those titles never diverge.
27
+ *
28
+ * @example kebabToTitleCase('alert-dialog') -> 'Alert Dialog'
29
+ * @example kebabToTitleCase('getting_started') -> 'Getting Started'
30
+ * @example kebabToTitleCase('typescript') -> 'TypeScript'
31
+ */
32
+ export function kebabToTitleCase(kebabCase) {
33
+ return kebabCase.split(/[-_]/).map(capitalizeWord).join(' ');
34
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Whether a single path segment is a Next.js route group: a directory name wrapped in
3
+ * parentheses (e.g. `(overview)`). Route groups organize files without appearing in the public
4
+ * URL. This is the one shared definition used by page grouping, parent-index resolution, and
5
+ * path-to-URL resolution.
6
+ *
7
+ * Because it tests a whole segment, a segment that merely contains parentheses (e.g.
8
+ * `(draft)notes`) is not a route group and is kept.
9
+ *
10
+ * @example isRouteGroup('(overview)') -> true
11
+ * @example isRouteGroup('components') -> false
12
+ * @example isRouteGroup('(draft)notes') -> false
13
+ */
14
+ export declare function isRouteGroup(segment: string): boolean;
15
+ /**
16
+ * The name inside a route-group segment (e.g. `(overview)` -> `overview`), or `undefined` when the
17
+ * segment is not a whole route group. Uses the same whole-segment rule as {@link isRouteGroup}, so
18
+ * callers get one definition of the parenthesis convention instead of re-deriving it. Useful for
19
+ * mapping a route group back to a section (e.g. resolving a section's default landing page).
20
+ *
21
+ * @example routeGroupName('(overview)') -> 'overview'
22
+ * @example routeGroupName('components') -> undefined
23
+ * @example routeGroupName('(draft)notes') -> undefined
24
+ */
25
+ export declare function routeGroupName(segment: string): string | undefined;
26
+ /**
27
+ * Removes whole Next.js route-group segments (`(group)`) from a `/`-separated path or URL, since
28
+ * they do not appear in the public URL, keeping every other segment intact. It splits on `/` and
29
+ * filters whole segments with {@link isRouteGroup}, so a folder that merely contains parentheses
30
+ * (e.g. `(draft)notes`) stays in the path. This is the one whole-segment rule the grouped index,
31
+ * rendered links, and search URL resolution all share.
32
+ *
33
+ * @example stripRouteGroupSegments('/components/(inputs)/checkbox') -> '/components/checkbox'
34
+ * @example stripRouteGroupSegments('/(draft)notes/guide') -> '/(draft)notes/guide'
35
+ */
36
+ export declare function stripRouteGroupSegments(pathOrUrl: string): string;
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Whether a single path segment is a Next.js route group: a directory name wrapped in
3
+ * parentheses (e.g. `(overview)`). Route groups organize files without appearing in the public
4
+ * URL. This is the one shared definition used by page grouping, parent-index resolution, and
5
+ * path-to-URL resolution.
6
+ *
7
+ * Because it tests a whole segment, a segment that merely contains parentheses (e.g.
8
+ * `(draft)notes`) is not a route group and is kept.
9
+ *
10
+ * @example isRouteGroup('(overview)') -> true
11
+ * @example isRouteGroup('components') -> false
12
+ * @example isRouteGroup('(draft)notes') -> false
13
+ */
14
+ export function isRouteGroup(segment) {
15
+ return segment.startsWith('(') && segment.endsWith(')');
16
+ }
17
+
18
+ /**
19
+ * The name inside a route-group segment (e.g. `(overview)` -> `overview`), or `undefined` when the
20
+ * segment is not a whole route group. Uses the same whole-segment rule as {@link isRouteGroup}, so
21
+ * callers get one definition of the parenthesis convention instead of re-deriving it. Useful for
22
+ * mapping a route group back to a section (e.g. resolving a section's default landing page).
23
+ *
24
+ * @example routeGroupName('(overview)') -> 'overview'
25
+ * @example routeGroupName('components') -> undefined
26
+ * @example routeGroupName('(draft)notes') -> undefined
27
+ */
28
+ export function routeGroupName(segment) {
29
+ return isRouteGroup(segment) ? segment.slice(1, -1) : undefined;
30
+ }
31
+
32
+ /**
33
+ * Removes whole Next.js route-group segments (`(group)`) from a `/`-separated path or URL, since
34
+ * they do not appear in the public URL, keeping every other segment intact. It splits on `/` and
35
+ * filters whole segments with {@link isRouteGroup}, so a folder that merely contains parentheses
36
+ * (e.g. `(draft)notes`) stays in the path. This is the one whole-segment rule the grouped index,
37
+ * rendered links, and search URL resolution all share.
38
+ *
39
+ * @example stripRouteGroupSegments('/components/(inputs)/checkbox') -> '/components/checkbox'
40
+ * @example stripRouteGroupSegments('/(draft)notes/guide') -> '/(draft)notes/guide'
41
+ */
42
+ export function stripRouteGroupSegments(pathOrUrl) {
43
+ return pathOrUrl.split('/').filter(segment => !isRouteGroup(segment)).join('/');
44
+ }
@@ -59,6 +59,13 @@ export declare function mergeMetadataPages(existingMarkdown: string | undefined,
59
59
  * This allows multiple pages to have the same slug (anchor) while still being treated
60
60
  * as distinct pages.
61
61
  *
62
+ * Known limitation: matching is by exact path, so a page whose stored path changes format —
63
+ * notably an index first regenerated after route groups became path-preserving, where an old
64
+ * entry `./accordion/page.mdx` no longer matches the new `./(components)/accordion/page.mdx` —
65
+ * is not reconciled with its old entry and can briefly duplicate. This is accepted rather than
66
+ * matched by route-group-stripped path (two distinct pages `./(a)/x` and `./(b)/x` share one
67
+ * stripped path), since grouped indexes are net-new; a stale entry clears once removed by hand.
68
+ *
62
69
  * @param existingMarkdown - The existing markdown content (or undefined if none exists)
63
70
  * @param newMetadata - The new metadata to merge in
64
71
  * @param options - Optional configuration
@@ -1,4 +1,135 @@
1
- import { markdownToMetadata, metadataToMarkdown } from "./metadataToMarkdown.mjs";
1
+ import { clusterPagesBySection, hasDetailSection, markdownToMetadata, metadataToMarkdown, orderPagesBySection, pageSectionGroup, resolveSectionGroup, routeGroupOfPath, routeGroupToTitle, DEFAULT_DETAILS_SECTION_TITLE } from "./metadataToMarkdown.mjs";
2
+ /**
3
+ * Derives the ordered route-group sections for an index, given the sections recovered from
4
+ * the existing file (whose titles/order humans may have edited) and each page's section group
5
+ * (`pageGroups`, aligned with the merged pages — own route group, else manual placement).
6
+ * Existing sections are kept in place so renames and reordering survive; a group seen for the
7
+ * first time is appended with a seeded title. Sections that no longer have any page are
8
+ * dropped. Returns undefined when no page belongs to a group (flat index).
9
+ */
10
+ function deriveIndexSections(existingSections, pageGroups) {
11
+ const usedGroups = new Set(pageGroups.filter(group => group !== undefined));
12
+ const result = [];
13
+ const seen = new Set();
14
+
15
+ // Keep existing sections (order + human-edited titles), de-duped by group and limited
16
+ // to groups still in use.
17
+ for (const section of existingSections ?? []) {
18
+ if (usedGroups.has(section.group) && !seen.has(section.group)) {
19
+ seen.add(section.group);
20
+ result.push(section);
21
+ }
22
+ }
23
+
24
+ // Append a seeded section for any in-use group not already covered, in page order.
25
+ for (const group of pageGroups) {
26
+ if (group && !seen.has(group)) {
27
+ seen.add(group);
28
+ result.push({
29
+ group,
30
+ title: routeGroupToTitle(group)
31
+ });
32
+ }
33
+ }
34
+ return result.length > 0 ? result : undefined;
35
+ }
36
+
37
+ /**
38
+ * Re-keys each derived section to the group a fresh parse of the rendered file would assign, and
39
+ * re-points the manual placement (`sectionGroup`) of the ungrouped pages under it to match. Once a
40
+ * section's last route-grouped page is removed, nothing in the rendered file still encodes the real
41
+ * `(group)` — the parser can only recover a synthetic id from the heading text (or drop the section
42
+ * to flat if a local page remains under it). Applying {@link resolveSectionGroup} — the same rule
43
+ * the parser uses — here keeps the merged (cache) metadata consistent with that parse. Route-grouped
44
+ * pages derive their section from their own path and are left untouched. Runs on a copy.
45
+ */
46
+ function reconcileSectionGroups(sections, pages, pageGroups) {
47
+ const {
48
+ bySection
49
+ } = clusterPagesBySection(pages, sections, pageGroups);
50
+
51
+ // Map each section's current group to the canonical one a parse would assign (undefined = the
52
+ // section collapses to flat and is dropped).
53
+ const canonicalByGroup = new Map();
54
+ const reconciledSections = [];
55
+ const seen = new Set();
56
+ for (const section of sections) {
57
+ const paths = (bySection.get(section.group) ?? []).map(page => page.path);
58
+ const canonical = resolveSectionGroup(section.title, paths);
59
+ canonicalByGroup.set(section.group, canonical);
60
+ if (canonical && !seen.has(canonical)) {
61
+ seen.add(canonical);
62
+ reconciledSections.push({
63
+ ...section,
64
+ group: canonical
65
+ });
66
+ }
67
+ }
68
+
69
+ // Re-point each ungrouped page's manual placement onto the canonical group, and report each
70
+ // page's resulting section group so the caller need not recompute it. A route-grouped page owns
71
+ // its group (derived from its path), so it is left untouched.
72
+ const reconciledPageGroups = [];
73
+ const reconciledPages = pages.map((page, index) => {
74
+ const group = pageGroups[index];
75
+ if (group === undefined || routeGroupOfPath(page.path) !== undefined) {
76
+ reconciledPageGroups.push(group);
77
+ return page;
78
+ }
79
+ const canonical = canonicalByGroup.get(group);
80
+ reconciledPageGroups.push(canonical);
81
+ if (canonical === undefined) {
82
+ const {
83
+ sectionGroup,
84
+ ...rest
85
+ } = page;
86
+ return rest;
87
+ }
88
+ return {
89
+ ...page,
90
+ sectionGroup: canonical
91
+ };
92
+ });
93
+ return {
94
+ sections: reconciledSections.length > 0 ? reconciledSections : undefined,
95
+ pages: reconciledPages,
96
+ pageGroups: reconciledPageGroups
97
+ };
98
+ }
99
+
100
+ /**
101
+ * Derives the grouped fields of a metadata result — sections, the canonically-ordered pages,
102
+ * and the "Details" wrapper title — from a base set of sections/pages. Pages are reordered
103
+ * into the section-clustered order the renderer emits, and `detailsSectionTitle` defaults to
104
+ * the value the renderer would write, so the pre-populated cache matches a fresh parse of the
105
+ * rendered file on every path (first sync, parse failure, and normal merge alike).
106
+ */
107
+ function deriveGroupedFields(baseSections, pages, existingDetailsSectionTitle) {
108
+ // Compute each page's section group once (own route group, else its manual placement) and
109
+ // key both the section derivation and the page ordering off it, so a section is kept as
110
+ // long as any page still belongs to it — even an ungrouped link filed under it by hand.
111
+ let pageGroups = pages.map(pageSectionGroup);
112
+ let sections = deriveIndexSections(baseSections, pageGroups);
113
+ let groupedPages = pages;
114
+ if (sections) {
115
+ // Reconcile each section's group with the id a fresh parse of the rendered file would assign,
116
+ // so a warm cache read never diverges from a cold read of the file it wrote.
117
+ const reconciled = reconcileSectionGroups(sections, groupedPages, pageGroups);
118
+ sections = reconciled.sections;
119
+ groupedPages = reconciled.pages;
120
+ pageGroups = reconciled.pageGroups;
121
+ }
122
+ // Mirror the renderer's `## Details` guard: the wrapper — and hence its title — only exists
123
+ // when the index is grouped AND at least one page actually renders a detail section. A grouped
124
+ // index made only of external links (all skipDetailSection) writes no wrapper, so a fresh parse
125
+ // yields no title; the pre-populated cache must agree, or the consistency check diverges.
126
+ const rendersDetailsWrapper = Boolean(sections) && hasDetailSection(groupedPages);
127
+ return {
128
+ sections,
129
+ pages: sections ? orderPagesBySection(groupedPages, sections, pageGroups) : groupedPages,
130
+ detailsSectionTitle: rendersDetailsWrapper ? existingDetailsSectionTitle ?? DEFAULT_DETAILS_SECTION_TITLE : undefined
131
+ };
132
+ }
2
133
 
3
134
  /**
4
135
  * Options for mergeMetadataMarkdown
@@ -26,22 +157,15 @@ export async function mergeMetadataPages(existingMarkdown, newMetadata, options
26
157
  preserveExistingTitleAndSlug
27
158
  } = options;
28
159
 
29
- // If no existing markdown, just use the new metadata.
30
- // Use the provided wrapper unless it's null (which means remove).
31
- if (!existingMarkdown) {
32
- return {
33
- metadata: newMetadata,
34
- indexWrapperComponent: indexWrapperComponent === null ? undefined : indexWrapperComponent
35
- };
36
- }
37
-
38
- // Parse the existing markdown to get the current order
39
- const existingMetadata = await markdownToMetadata(existingMarkdown);
40
-
41
- // If parsing failed, just use the new metadata
160
+ // With no existing markdown (or if it fails to parse), just use the new metadata. Use the
161
+ // provided wrapper unless it's null (which means remove).
162
+ const existingMetadata = existingMarkdown ? await markdownToMetadata(existingMarkdown) : undefined;
42
163
  if (!existingMetadata) {
43
164
  return {
44
- metadata: newMetadata,
165
+ metadata: {
166
+ ...newMetadata,
167
+ ...deriveGroupedFields(newMetadata.sections, newMetadata.pages, newMetadata.detailsSectionTitle)
168
+ },
45
169
  indexWrapperComponent: indexWrapperComponent === null ? undefined : indexWrapperComponent
46
170
  };
47
171
  }
@@ -91,6 +215,9 @@ export async function mergeMetadataPages(existingMarkdown, newMetadata, options
91
215
  tags: existingPage.tags,
92
216
  // Preserve skipDetailSection from existing (user-managed for external links)
93
217
  skipDetailSection: existingPage.skipDetailSection,
218
+ // Preserve the section a human filed this page under (user-managed placement for
219
+ // pages without a route group of their own, e.g. external links).
220
+ sectionGroup: existingPage.sectionGroup,
94
221
  // Preserve sections from existing if new doesn't have them
95
222
  sections: newPage.sections || existingPage.sections,
96
223
  // Preserve displayTitle (user-managed title override) only if it still
@@ -121,7 +248,7 @@ export async function mergeMetadataPages(existingMarkdown, newMetadata, options
121
248
  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.'";
122
249
  // TODO: Remove the old marker check once all index files have been migrated to the new format.
123
250
  const oldAlphabeticalSortMarker = "[//]: # 'This file is autogenerated, but the following list can be modified. Automatically sorted alphabetically.'";
124
- const requestsAlphabeticalSort = existingMarkdown.includes(alphabeticalSortMarker) || existingMarkdown.includes(oldAlphabeticalSortMarker);
251
+ const requestsAlphabeticalSort = existingMarkdown?.includes(alphabeticalSortMarker) || existingMarkdown?.includes(oldAlphabeticalSortMarker) || false;
125
252
  if (requestsAlphabeticalSort) {
126
253
  pages = pages.sort((a, b) => {
127
254
  const titleA = a.displayTitle ?? a.title ?? a.slug;
@@ -130,11 +257,15 @@ export async function mergeMetadataPages(existingMarkdown, newMetadata, options
130
257
  });
131
258
  }
132
259
 
133
- // Create the final metadata with merged pages
260
+ // Preserve route-group section headings (order + human-edited titles) from the existing
261
+ // file, appending sections for any newly-seen group; reorder pages into the section-
262
+ // clustered order the renderer emits; and keep the "Details" wrapper heading, falling back
263
+ // to the renderer's default when a flat index first becomes grouped — so the pre-populated
264
+ // cache stays consistent with a fresh parse.
134
265
  const mergedMetadata = {
135
266
  title: newMetadata.title,
136
267
  // Always use the new title
137
- pages,
268
+ ...deriveGroupedFields(existingMetadata.sections, pages, existingMetadata.detailsSectionTitle),
138
269
  // Preserve the existing pageMetadata (e.g., robots config) from the current file
139
270
  pageMetadata: existingMetadata.pageMetadata
140
271
  };
@@ -156,6 +287,13 @@ export async function mergeMetadataPages(existingMarkdown, newMetadata, options
156
287
  * This allows multiple pages to have the same slug (anchor) while still being treated
157
288
  * as distinct pages.
158
289
  *
290
+ * Known limitation: matching is by exact path, so a page whose stored path changes format —
291
+ * notably an index first regenerated after route groups became path-preserving, where an old
292
+ * entry `./accordion/page.mdx` no longer matches the new `./(components)/accordion/page.mdx` —
293
+ * is not reconciled with its old entry and can briefly duplicate. This is accepted rather than
294
+ * matched by route-group-stripped path (two distinct pages `./(a)/x` and `./(b)/x` share one
295
+ * stripped path), since grouped indexes are net-new; a stale entry clears once removed by hand.
296
+ *
159
297
  * @param existingMarkdown - The existing markdown content (or undefined if none exists)
160
298
  * @param newMetadata - The new metadata to merge in
161
299
  * @param options - Optional configuration