@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.
- package/createSitemap/index.d.mts +2 -1
- package/createSitemap/types.d.mts +27 -0
- package/package.json +22 -2
- package/pipeline/loadPrecomputedSitemap/loadPrecomputedSitemap.d.mts +1 -1
- package/pipeline/loadServerPageIndex/enrichPageIndex.mjs +12 -2
- package/pipeline/loadServerPageIndex/extractPrefixAndTitle.mjs +2 -1
- package/pipeline/loadServerPageIndex/loadServerPageIndex.d.mts +1 -1
- package/pipeline/loadServerPageIndex/resolvePageIndexCacheKey.d.mts +9 -2
- package/pipeline/loadServerPageIndex/resolvePageIndexCacheKey.mjs +9 -2
- package/pipeline/loaderUtils/extractNameAndSlugFromUrl.mjs +1 -31
- package/pipeline/loaderUtils/index.d.mts +2 -1
- package/pipeline/loaderUtils/index.mjs +2 -1
- package/pipeline/loaderUtils/kebabToTitleCase.d.mts +11 -0
- package/pipeline/loaderUtils/kebabToTitleCase.mjs +34 -0
- package/pipeline/loaderUtils/stripRouteGroups.d.mts +36 -0
- package/pipeline/loaderUtils/stripRouteGroups.mjs +44 -0
- package/pipeline/syncPageIndex/mergeMetadataMarkdown.d.mts +7 -0
- package/pipeline/syncPageIndex/mergeMetadataMarkdown.mjs +156 -18
- package/pipeline/syncPageIndex/metadataToMarkdown.d.mts +147 -1
- package/pipeline/syncPageIndex/metadataToMarkdown.mjs +446 -71
- package/pipeline/syncPageIndex/syncPageIndex.d.mts +14 -1
- package/pipeline/syncPageIndex/syncPageIndex.mjs +21 -19
- package/pipeline/syncTypes/syncTypes.mjs +6 -4
- package/pipeline/transformMarkdownMetadata/transformMarkdownMetadata.mjs +19 -31
- package/pipeline/transformMarkdownMetadata/types.d.mts +1 -1
- package/pipeline/transformMarkdownRelativePaths/transformMarkdownRelativePaths.mjs +5 -2
- package/resolvePageUrl/index.d.mts +1 -0
- package/resolvePageUrl/index.mjs +1 -0
- package/resolvePageUrl/resolvePageUrl.d.mts +12 -0
- package/resolvePageUrl/resolvePageUrl.mjs +27 -0
- package/useSearch/useSearch.mjs +3 -1
- 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.
|
|
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": "
|
|
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 (
|
|
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
|
-
/**
|
|
2
|
-
|
|
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
|
-
/**
|
|
4
|
-
|
|
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
|
-
//
|
|
30
|
-
//
|
|
31
|
-
|
|
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:
|
|
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
|
|
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
|
-
//
|
|
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
|