@umami/shiso 0.55.0 → 1.0.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/CHANGELOG.md +39 -0
- package/README.md +9 -49
- package/bin/shiso.mjs +132 -0
- package/docs.schema.json +896 -0
- package/mdx.config.ts +143 -0
- package/package.json +74 -83
- package/scripts/check-package.mjs +54 -0
- package/scripts/generate-icon-registry.mjs +196 -0
- package/scripts/generate-last-modified.mjs +128 -0
- package/scripts/generate-search-index.mjs +298 -0
- package/scripts/lib/mdast.mjs +23 -0
- package/scripts/lib/slug.mjs +21 -0
- package/scripts/load-docs-config.mjs +244 -0
- package/scripts/prerender.mjs +194 -0
- package/scripts/validate-config.mjs +104 -0
- package/scripts/vite-docs-config.mjs +60 -0
- package/src/App.tsx +38 -0
- package/src/components/Banner.tsx +69 -0
- package/src/components/CodeBlock.tsx +46 -0
- package/src/components/ConfiguredIcon.tsx +15 -0
- package/src/components/ContextualMenu.tsx +93 -0
- package/src/components/DocContent.tsx +105 -0
- package/src/components/Docs.tsx +134 -0
- package/src/components/Footer.tsx +81 -0
- package/src/components/Header.tsx +82 -0
- package/src/components/LanguageSwitcher.tsx +59 -0
- package/src/components/Layout.tsx +24 -0
- package/src/components/PageLinks.tsx +71 -0
- package/src/components/Search.tsx +217 -0
- package/src/components/SideNav.tsx +347 -0
- package/src/components/SocialIcon.tsx +88 -0
- package/src/components/ThemeToggle.tsx +34 -0
- package/src/components/TopNav.tsx +127 -0
- package/src/components/VersionSwitcher.tsx +60 -0
- package/src/components/docs/Accordion.tsx +68 -0
- package/src/components/docs/Badge.tsx +171 -0
- package/src/components/docs/Callout.tsx +73 -0
- package/src/components/docs/Card.tsx +158 -0
- package/src/components/docs/CodeGroup.tsx +73 -0
- package/src/components/docs/Columns.tsx +20 -0
- package/src/components/docs/Expandable.tsx +28 -0
- package/src/components/docs/Frame.tsx +56 -0
- package/src/components/docs/Icon.tsx +30 -0
- package/src/components/docs/ParamField.tsx +45 -0
- package/src/components/docs/PropertiesTable.tsx +84 -0
- package/src/components/docs/ResponseField.tsx +36 -0
- package/src/components/docs/Steps.tsx +47 -0
- package/src/components/docs/Tabs.tsx +116 -0
- package/src/components/docs/Tooltip.tsx +21 -0
- package/src/components/docs/index.ts +15 -0
- package/src/components/docs/styles.ts +82 -0
- package/src/components/docs/utils.ts +118 -0
- package/src/components/icons/index.ts +17 -0
- package/src/components/ui/accordion.tsx +69 -0
- package/src/components/ui/alert.tsx +69 -0
- package/src/components/ui/badge.tsx +49 -0
- package/src/components/ui/button.tsx +58 -0
- package/src/components/ui/card.tsx +88 -0
- package/src/components/ui/collapsible.tsx +15 -0
- package/src/components/ui/command.tsx +173 -0
- package/src/components/ui/dialog.tsx +137 -0
- package/src/components/ui/dropdown-menu.tsx +257 -0
- package/src/components/ui/scroll-area.tsx +71 -0
- package/src/components/ui/sheet.tsx +124 -0
- package/src/components/ui/tabs.tsx +73 -0
- package/src/components/ui/tooltip.tsx +52 -0
- package/src/declarations.d.ts +9 -0
- package/src/entry-client.tsx +17 -0
- package/src/entry-server.tsx +89 -0
- package/src/generated/last-modified.ts +2 -0
- package/src/lib/content.ts +44 -0
- package/src/lib/docs-config.ts +986 -0
- package/src/lib/head.ts +231 -0
- package/src/lib/icon-registry.generated.ts +4 -0
- package/src/lib/icons.ts +29 -0
- package/src/lib/inline-markdown.tsx +86 -0
- package/src/lib/locale.ts +39 -0
- package/src/lib/mdast.ts +56 -0
- package/src/lib/paths.ts +86 -0
- package/src/lib/remark-toc.ts +71 -0
- package/src/lib/search/config.ts +43 -0
- package/src/lib/search/provider.ts +85 -0
- package/src/lib/search/providers/local.ts +15 -0
- package/src/lib/search-index.generated.ts +4 -0
- package/src/lib/search.ts +128 -0
- package/src/lib/site-config.ts +117 -0
- package/src/lib/site-model.ts +221 -0
- package/src/lib/slug.ts +38 -0
- package/src/lib/types.ts +515 -0
- package/src/lib/utils.ts +6 -0
- package/src/pages/DocPage.tsx +33 -0
- package/src/styles/global.css +268 -0
- package/src/styles/tokens.css +114 -0
- package/types/client.d.ts +3 -0
- package/types/search.d.ts +27 -0
- package/vite.config.ts +342 -0
- package/LICENSE +0 -21
- package/dist/index.css +0 -189
- package/dist/index.d.ts +0 -57
- package/dist/index.js +0 -464
- package/dist/index.mjs +0 -437
- package/server/index.d.ts +0 -30
- package/server/index.js +0 -189
- package/styles.css +0 -4766
|
@@ -0,0 +1,986 @@
|
|
|
1
|
+
import { DOCS_PREFIX } from '@/lib/paths';
|
|
2
|
+
import { slugifyId } from '@/lib/slug';
|
|
3
|
+
import type {
|
|
4
|
+
AnchorItem,
|
|
5
|
+
DocsConfig,
|
|
6
|
+
DocsScope,
|
|
7
|
+
DropdownItem,
|
|
8
|
+
GroupItem,
|
|
9
|
+
LanguageItem,
|
|
10
|
+
LinkTarget,
|
|
11
|
+
NavGroupNode,
|
|
12
|
+
NavigationConfig,
|
|
13
|
+
NavLinkNode,
|
|
14
|
+
NavNode,
|
|
15
|
+
NavPageNode,
|
|
16
|
+
NormalizedDocsConfig,
|
|
17
|
+
NormalizedDocsPage,
|
|
18
|
+
NormalizedDocsSite,
|
|
19
|
+
NormalizeOptions,
|
|
20
|
+
PageItem,
|
|
21
|
+
TabItem,
|
|
22
|
+
VersionItem,
|
|
23
|
+
} from '@/lib/types';
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Resolves a page reference from docs.json (e.g. "components/tabs") to the
|
|
27
|
+
* module key of a real content file (e.g. "/content/docs/components/tabs.mdx").
|
|
28
|
+
* Returns undefined when no file exists for the reference.
|
|
29
|
+
*/
|
|
30
|
+
export type DocFileResolver = (fileSlug: string) => string | undefined;
|
|
31
|
+
|
|
32
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
33
|
+
return !!value && typeof value === 'object' && !Array.isArray(value);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function normalizeLinkTarget(href: string, target?: LinkTarget): LinkTarget {
|
|
37
|
+
return target || (/^(?:#|\/|\.\.?\/)/.test(href) ? '_self' : '_blank');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function assertDocsConfig(value: unknown, sourceName: string): asserts value is DocsConfig {
|
|
41
|
+
if (!isRecord(value)) {
|
|
42
|
+
throw new Error(`Invalid docs config in "${sourceName}": expected a JSON object.`);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Common mistake: pointing at a JSON schema document instead of an actual config object.
|
|
46
|
+
if ('anyOf' in value && 'definitions' in value && !('navigation' in value)) {
|
|
47
|
+
throw new Error(
|
|
48
|
+
`Invalid docs config in "${sourceName}": this looks like a JSON schema, not a project config object.`,
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (!isRecord(value.navigation)) {
|
|
53
|
+
throw new Error(`Invalid docs config in "${sourceName}": missing "navigation" object.`);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** "code-blocks" -> "Code blocks". Sentence case: only the first word is capitalized. */
|
|
58
|
+
function toLabel(value: string): string {
|
|
59
|
+
const words = value.split(/[-_]/g).filter(Boolean);
|
|
60
|
+
|
|
61
|
+
return words
|
|
62
|
+
.map((word, index) => (index === 0 ? word[0]?.toUpperCase() + word.slice(1) : word))
|
|
63
|
+
.join(' ');
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function normalizePageReference(pageRef: string): { fileSlug: string; slug: string } {
|
|
67
|
+
const value = pageRef
|
|
68
|
+
.trim()
|
|
69
|
+
.replace(/\\/g, '/')
|
|
70
|
+
.replace(/^\/+/, '')
|
|
71
|
+
.replace(/^docs\//, '')
|
|
72
|
+
.replace(/\.mdx?$/, '')
|
|
73
|
+
.replace(/\/+$/, '');
|
|
74
|
+
|
|
75
|
+
if (!value) {
|
|
76
|
+
return { fileSlug: 'index', slug: 'index' };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const fileSlug = value;
|
|
80
|
+
const slug = value === 'index' ? 'index' : value.replace(/\/index$/, '') || 'index';
|
|
81
|
+
|
|
82
|
+
return { fileSlug, slug };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function getDefaultLabel(fileSlug: string): string {
|
|
86
|
+
const parts = fileSlug.split('/').filter(Boolean);
|
|
87
|
+
const leaf = parts.at(-1) || fileSlug;
|
|
88
|
+
return toLabel(leaf);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function pageToUrl(slug: string, docsPrefix: string): string {
|
|
92
|
+
const base = docsPrefix || '';
|
|
93
|
+
return slug === 'index' ? base || '/' : `${base}/${slug}`;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function dropdownsToTabs(dropdowns: DropdownItem[]): TabItem[] {
|
|
97
|
+
return dropdowns.map((dropdown, index) => {
|
|
98
|
+
const label = dropdown.dropdown?.trim();
|
|
99
|
+
|
|
100
|
+
if (!label) {
|
|
101
|
+
throw new Error(`Invalid docs config: dropdown at index ${index} is missing "dropdown".`);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return {
|
|
105
|
+
tab: label,
|
|
106
|
+
groups: dropdown.groups || [],
|
|
107
|
+
pages: dropdown.pages || [],
|
|
108
|
+
icon: dropdown.icon,
|
|
109
|
+
hidden: dropdown.hidden,
|
|
110
|
+
presentation: 'dropdown',
|
|
111
|
+
};
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
interface NavContainer {
|
|
116
|
+
tabs?: TabItem[];
|
|
117
|
+
dropdowns?: DropdownItem[];
|
|
118
|
+
groups?: GroupItem[];
|
|
119
|
+
pages?: PageItem[];
|
|
120
|
+
versions?: VersionItem[];
|
|
121
|
+
languages?: LanguageItem[];
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* The mutually exclusive primary navigation modes a container may declare.
|
|
126
|
+
* `groups` and `pages` combine into one simple-navigation mode.
|
|
127
|
+
*/
|
|
128
|
+
function getNavigationModes(container: NavContainer): string[] {
|
|
129
|
+
const modes: string[] = [];
|
|
130
|
+
|
|
131
|
+
if (container.tabs !== undefined) {
|
|
132
|
+
modes.push('tabs');
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (container.dropdowns !== undefined) {
|
|
136
|
+
modes.push('dropdowns');
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (container.versions !== undefined) {
|
|
140
|
+
modes.push('versions');
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (container.languages !== undefined) {
|
|
144
|
+
modes.push('languages');
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (container.groups !== undefined || container.pages !== undefined) {
|
|
148
|
+
modes.push('groups/pages');
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return modes;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Rejects a container that declares more than one primary navigation mode. */
|
|
155
|
+
function assertSingleMode(container: NavContainer, where: string): void {
|
|
156
|
+
const modes = getNavigationModes(container);
|
|
157
|
+
|
|
158
|
+
if (modes.length > 1) {
|
|
159
|
+
throw new Error(
|
|
160
|
+
`Invalid docs config: ${where} must define exactly one of tabs, dropdowns, ` +
|
|
161
|
+
`versions, languages, or groups/pages — found ${modes.join(' and ')}.`,
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Rejects arrays where more than one entry claims to be the default. */
|
|
167
|
+
function assertSingleDefault<T extends { default?: boolean }>(
|
|
168
|
+
items: T[],
|
|
169
|
+
kind: string,
|
|
170
|
+
getLabel: (item: T) => string | undefined,
|
|
171
|
+
) {
|
|
172
|
+
const defaults = items.filter(item => item.default === true);
|
|
173
|
+
|
|
174
|
+
if (defaults.length > 1) {
|
|
175
|
+
const labels = defaults.map(getLabel).filter(Boolean);
|
|
176
|
+
|
|
177
|
+
throw new Error(
|
|
178
|
+
`Invalid docs config: multiple ${kind} are marked "default": ${labels.join(', ')}. ` +
|
|
179
|
+
`Only one ${kind.replace(/s$/, '')} may be the default.`,
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Explicit default first, then the first visible entry, then the first entry. */
|
|
185
|
+
function pickDefaultEntry<T extends { default?: boolean; hidden?: boolean }>(items: T[]): T {
|
|
186
|
+
return items.find(item => item.default === true) || items.find(item => !item.hidden) || items[0];
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Resolves a leaf navigation container (one that no longer carries versions or
|
|
191
|
+
* languages) down to a flat list of tabs.
|
|
192
|
+
*/
|
|
193
|
+
function getTabsFromLeafContainer(container: NavContainer, fallbackLabel = ''): TabItem[] {
|
|
194
|
+
if (Array.isArray(container.tabs)) {
|
|
195
|
+
if (!container.tabs.length) {
|
|
196
|
+
throw new Error('Invalid docs config: "tabs" must contain at least one tab.');
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return container.tabs;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (Array.isArray(container.dropdowns)) {
|
|
203
|
+
if (!container.dropdowns.length) {
|
|
204
|
+
throw new Error('Invalid docs config: "dropdowns" must contain at least one dropdown.');
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
return dropdownsToTabs(container.dropdowns);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (Array.isArray(container.groups) || Array.isArray(container.pages)) {
|
|
211
|
+
return [
|
|
212
|
+
{
|
|
213
|
+
tab: fallbackLabel,
|
|
214
|
+
groups: container.groups || [],
|
|
215
|
+
pages: container.pages || [],
|
|
216
|
+
},
|
|
217
|
+
];
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
throw new Error(
|
|
221
|
+
'Invalid docs config: navigation must define tabs, dropdowns, groups, pages, versions, or languages.',
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function hasExplicitTopNavigation(container: NavContainer): boolean {
|
|
226
|
+
return (
|
|
227
|
+
(Array.isArray(container.tabs) && container.tabs.length > 0) ||
|
|
228
|
+
(Array.isArray(container.dropdowns) && container.dropdowns.length > 0)
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/* ---------------------------------------------------------------------------
|
|
233
|
+
* Scope collection
|
|
234
|
+
*
|
|
235
|
+
* A docs site normalizes into one or more scopes: the ordinary navigation, one
|
|
236
|
+
* per version, one per language, or one per version nested inside a language.
|
|
237
|
+
* Page references alone determine URLs — scopes never add URL prefixes.
|
|
238
|
+
* ------------------------------------------------------------------------- */
|
|
239
|
+
|
|
240
|
+
interface ScopeSource {
|
|
241
|
+
id: string;
|
|
242
|
+
language?: string;
|
|
243
|
+
version?: string;
|
|
244
|
+
hidden?: boolean;
|
|
245
|
+
isDefault: boolean;
|
|
246
|
+
/** Landing scope of its language: the language's default version. */
|
|
247
|
+
isLanguageDefault: boolean;
|
|
248
|
+
/** Leaf navigation container for this scope. */
|
|
249
|
+
container: NavContainer;
|
|
250
|
+
/** Label used for the synthetic tab in simple groups/pages navigation. */
|
|
251
|
+
fallbackLabel: string;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function makeVersionScopeSource(
|
|
255
|
+
version: VersionItem,
|
|
256
|
+
language: LanguageItem | undefined,
|
|
257
|
+
isDefault: boolean,
|
|
258
|
+
isLanguageDefault: boolean,
|
|
259
|
+
): ScopeSource {
|
|
260
|
+
const versionLabel = version.version?.trim();
|
|
261
|
+
|
|
262
|
+
if (!versionLabel) {
|
|
263
|
+
throw new Error('Invalid docs config: version entry is missing "version".');
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
assertSingleMode(version, `version "${versionLabel}"`);
|
|
267
|
+
|
|
268
|
+
const languageLabel = language?.language?.trim();
|
|
269
|
+
const idBase = [languageLabel, versionLabel].filter(Boolean).join('-');
|
|
270
|
+
|
|
271
|
+
return {
|
|
272
|
+
id: slugifyId(idBase, 'scope'),
|
|
273
|
+
language: languageLabel || undefined,
|
|
274
|
+
version: versionLabel,
|
|
275
|
+
hidden: version.hidden || language?.hidden || undefined,
|
|
276
|
+
isDefault,
|
|
277
|
+
isLanguageDefault,
|
|
278
|
+
container: version,
|
|
279
|
+
fallbackLabel: versionLabel,
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function collectScopeSources(navigation: NavigationConfig): ScopeSource[] {
|
|
284
|
+
assertSingleMode(navigation, 'navigation');
|
|
285
|
+
|
|
286
|
+
if (navigation.versions !== undefined) {
|
|
287
|
+
const versions = navigation.versions;
|
|
288
|
+
|
|
289
|
+
if (!Array.isArray(versions) || !versions.length) {
|
|
290
|
+
throw new Error('Invalid docs config: "versions" must contain at least one version.');
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
assertSingleDefault(versions, 'versions', item => item.version);
|
|
294
|
+
|
|
295
|
+
const defaultVersion = pickDefaultEntry(versions);
|
|
296
|
+
|
|
297
|
+
return versions.map(version =>
|
|
298
|
+
makeVersionScopeSource(
|
|
299
|
+
version,
|
|
300
|
+
undefined,
|
|
301
|
+
version === defaultVersion,
|
|
302
|
+
version === defaultVersion,
|
|
303
|
+
),
|
|
304
|
+
);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
if (navigation.languages !== undefined) {
|
|
308
|
+
const languages = navigation.languages;
|
|
309
|
+
|
|
310
|
+
if (!Array.isArray(languages) || !languages.length) {
|
|
311
|
+
throw new Error('Invalid docs config: "languages" must contain at least one language.');
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
assertSingleDefault(languages, 'languages', item => item.language);
|
|
315
|
+
|
|
316
|
+
const defaultLanguage = pickDefaultEntry(languages);
|
|
317
|
+
|
|
318
|
+
return languages.flatMap(language => {
|
|
319
|
+
const languageLabel = language.language?.trim();
|
|
320
|
+
|
|
321
|
+
if (!languageLabel) {
|
|
322
|
+
throw new Error('Invalid docs config: language entry is missing "language".');
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
assertSingleMode(language, `language "${languageLabel}"`);
|
|
326
|
+
|
|
327
|
+
if (language.versions !== undefined) {
|
|
328
|
+
const versions = language.versions;
|
|
329
|
+
|
|
330
|
+
if (!Array.isArray(versions) || !versions.length) {
|
|
331
|
+
throw new Error(
|
|
332
|
+
`Invalid docs config: language "${languageLabel}" "versions" must contain at least one version.`,
|
|
333
|
+
);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
assertSingleDefault(versions, `language "${languageLabel}" versions`, item => item.version);
|
|
337
|
+
|
|
338
|
+
const defaultVersion = pickDefaultEntry(versions);
|
|
339
|
+
|
|
340
|
+
return versions.map(version =>
|
|
341
|
+
makeVersionScopeSource(
|
|
342
|
+
version,
|
|
343
|
+
language,
|
|
344
|
+
language === defaultLanguage && version === defaultVersion,
|
|
345
|
+
version === defaultVersion,
|
|
346
|
+
),
|
|
347
|
+
);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
return [
|
|
351
|
+
{
|
|
352
|
+
id: slugifyId(languageLabel, 'scope'),
|
|
353
|
+
language: languageLabel,
|
|
354
|
+
hidden: language.hidden || undefined,
|
|
355
|
+
isDefault: language === defaultLanguage,
|
|
356
|
+
isLanguageDefault: true,
|
|
357
|
+
container: language,
|
|
358
|
+
fallbackLabel: languageLabel,
|
|
359
|
+
},
|
|
360
|
+
];
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
return [
|
|
365
|
+
{
|
|
366
|
+
id: 'default',
|
|
367
|
+
isDefault: true,
|
|
368
|
+
isLanguageDefault: true,
|
|
369
|
+
container: navigation,
|
|
370
|
+
fallbackLabel: '',
|
|
371
|
+
},
|
|
372
|
+
];
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/* ---------------------------------------------------------------------------
|
|
376
|
+
* Pass 1 — walk the config into a tree of pending nodes
|
|
377
|
+
*
|
|
378
|
+
* Pages cannot be fully normalized during the walk because their file paths are
|
|
379
|
+
* resolved (and validated) in one batch afterwards. So the walk records page
|
|
380
|
+
* references by `order`, and pass 2 swaps in the resolved pages.
|
|
381
|
+
* ------------------------------------------------------------------------- */
|
|
382
|
+
|
|
383
|
+
interface PendingPage {
|
|
384
|
+
fileSlug: string;
|
|
385
|
+
slug: string;
|
|
386
|
+
label: string;
|
|
387
|
+
section: string;
|
|
388
|
+
tabId: string;
|
|
389
|
+
tabLabel: string;
|
|
390
|
+
order: number;
|
|
391
|
+
hidden?: boolean;
|
|
392
|
+
icon?: string;
|
|
393
|
+
tag?: string;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
type PendingNode =
|
|
397
|
+
| { kind: 'page'; order: number }
|
|
398
|
+
| NavLinkNode
|
|
399
|
+
| {
|
|
400
|
+
kind: 'group';
|
|
401
|
+
label: string;
|
|
402
|
+
rootOrder?: number;
|
|
403
|
+
children: PendingNode[];
|
|
404
|
+
icon?: string;
|
|
405
|
+
expanded?: boolean;
|
|
406
|
+
collapsible?: boolean;
|
|
407
|
+
hidden?: boolean;
|
|
408
|
+
};
|
|
409
|
+
|
|
410
|
+
interface WalkContext {
|
|
411
|
+
tabId: string;
|
|
412
|
+
tabLabel: string;
|
|
413
|
+
section: string;
|
|
414
|
+
/** Set when an ancestor group is hidden, so descendants inherit it. */
|
|
415
|
+
hidden?: boolean;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
interface WalkState {
|
|
419
|
+
pages: PendingPage[];
|
|
420
|
+
order: { value: number };
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
function addPage(
|
|
424
|
+
pageRef: string,
|
|
425
|
+
context: WalkContext,
|
|
426
|
+
state: WalkState,
|
|
427
|
+
extra: { label?: string; icon?: string; tag?: string; hidden?: boolean } = {},
|
|
428
|
+
): number {
|
|
429
|
+
const { fileSlug, slug } = normalizePageReference(pageRef);
|
|
430
|
+
const order = state.order.value++;
|
|
431
|
+
|
|
432
|
+
state.pages.push({
|
|
433
|
+
fileSlug,
|
|
434
|
+
slug,
|
|
435
|
+
label: extra.label?.trim() || getDefaultLabel(fileSlug),
|
|
436
|
+
section: context.section,
|
|
437
|
+
tabId: context.tabId,
|
|
438
|
+
tabLabel: context.tabLabel,
|
|
439
|
+
order,
|
|
440
|
+
hidden: extra.hidden || context.hidden || undefined,
|
|
441
|
+
icon: extra.icon,
|
|
442
|
+
tag: extra.tag,
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
return order;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
function collectPages(items: PageItem[], context: WalkContext, state: WalkState): PendingNode[] {
|
|
449
|
+
const nodes: PendingNode[] = [];
|
|
450
|
+
|
|
451
|
+
items.forEach(item => {
|
|
452
|
+
if (typeof item === 'string') {
|
|
453
|
+
nodes.push({ kind: 'page', order: addPage(item, context, state) });
|
|
454
|
+
return;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
if (!isRecord(item)) {
|
|
458
|
+
throw new Error(
|
|
459
|
+
'Invalid docs config: page items must be strings, { page }, { href }, or { group, pages } blocks.',
|
|
460
|
+
);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// External link: { href, label | anchor }
|
|
464
|
+
if (typeof item.href === 'string' && item.href) {
|
|
465
|
+
const label =
|
|
466
|
+
(typeof item.label === 'string' && item.label.trim()) ||
|
|
467
|
+
(typeof item.anchor === 'string' && item.anchor.trim());
|
|
468
|
+
|
|
469
|
+
if (!label) {
|
|
470
|
+
throw new Error(`Invalid docs config: external link "${item.href}" is missing a label.`);
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
nodes.push({
|
|
474
|
+
kind: 'link',
|
|
475
|
+
label,
|
|
476
|
+
href: item.href,
|
|
477
|
+
icon: typeof item.icon === 'string' ? item.icon : undefined,
|
|
478
|
+
hidden: item.hidden === true || context.hidden || undefined,
|
|
479
|
+
target: normalizeLinkTarget(
|
|
480
|
+
item.href,
|
|
481
|
+
item.target === '_self' || item.target === '_blank' ? item.target : undefined,
|
|
482
|
+
),
|
|
483
|
+
});
|
|
484
|
+
return;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// Page reference: { page, title | label }
|
|
488
|
+
if (typeof item.page === 'string') {
|
|
489
|
+
const label =
|
|
490
|
+
(typeof item.label === 'string' && item.label) ||
|
|
491
|
+
(typeof item.title === 'string' && item.title) ||
|
|
492
|
+
undefined;
|
|
493
|
+
|
|
494
|
+
nodes.push({
|
|
495
|
+
kind: 'page',
|
|
496
|
+
order: addPage(item.page, context, state, {
|
|
497
|
+
label,
|
|
498
|
+
icon: typeof item.icon === 'string' ? item.icon : undefined,
|
|
499
|
+
tag: typeof item.tag === 'string' ? item.tag : undefined,
|
|
500
|
+
hidden: item.hidden === true,
|
|
501
|
+
}),
|
|
502
|
+
});
|
|
503
|
+
return;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
// Nested group: { group, pages, root }
|
|
507
|
+
if (typeof item.group === 'string' && Array.isArray(item.pages)) {
|
|
508
|
+
const label = item.group.trim();
|
|
509
|
+
|
|
510
|
+
if (!label) {
|
|
511
|
+
throw new Error('Invalid docs config: navigation group is missing "group".');
|
|
512
|
+
}
|
|
513
|
+
const hidden = item.hidden === true || context.hidden || undefined;
|
|
514
|
+
const childContext: WalkContext = { ...context, section: label, hidden };
|
|
515
|
+
|
|
516
|
+
nodes.push({
|
|
517
|
+
kind: 'group',
|
|
518
|
+
label,
|
|
519
|
+
rootOrder:
|
|
520
|
+
typeof item.root === 'string' && item.root.trim()
|
|
521
|
+
? addPage(item.root, childContext, state)
|
|
522
|
+
: undefined,
|
|
523
|
+
children: collectPages(item.pages as PageItem[], childContext, state),
|
|
524
|
+
icon: typeof item.icon === 'string' ? item.icon : undefined,
|
|
525
|
+
expanded: item.expanded === true || undefined,
|
|
526
|
+
collapsible: item.collapsible === false ? false : undefined,
|
|
527
|
+
hidden,
|
|
528
|
+
});
|
|
529
|
+
return;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
throw new Error(
|
|
533
|
+
`Invalid docs config: unrecognized page item with keys [${Object.keys(item).join(', ')}]. ` +
|
|
534
|
+
'Supported items are strings, { page }, { href }, or { group, pages } blocks.',
|
|
535
|
+
);
|
|
536
|
+
});
|
|
537
|
+
|
|
538
|
+
return nodes;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
function collectAnchors(anchors: AnchorItem[] | undefined): NavLinkNode[] {
|
|
542
|
+
if (!Array.isArray(anchors)) {
|
|
543
|
+
return [];
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
return anchors.map((anchor, index) => {
|
|
547
|
+
const label = isRecord(anchor) && typeof anchor.anchor === 'string' ? anchor.anchor.trim() : '';
|
|
548
|
+
const href = isRecord(anchor) && typeof anchor.href === 'string' ? anchor.href : '';
|
|
549
|
+
|
|
550
|
+
if (!label || !href) {
|
|
551
|
+
throw new Error(
|
|
552
|
+
`Invalid docs config: anchor at index ${index} must define both "anchor" and "href".`,
|
|
553
|
+
);
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
return {
|
|
557
|
+
kind: 'link' as const,
|
|
558
|
+
label,
|
|
559
|
+
href,
|
|
560
|
+
icon: anchor.icon,
|
|
561
|
+
hidden: anchor.hidden || undefined,
|
|
562
|
+
target: normalizeLinkTarget(href, anchor.target),
|
|
563
|
+
};
|
|
564
|
+
});
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
/* ---------------------------------------------------------------------------
|
|
568
|
+
* Normalization
|
|
569
|
+
* ------------------------------------------------------------------------- */
|
|
570
|
+
|
|
571
|
+
function normalizeScope(
|
|
572
|
+
name: string | undefined,
|
|
573
|
+
source: ScopeSource,
|
|
574
|
+
anchors: AnchorItem[] | undefined,
|
|
575
|
+
resolveDocFile: DocFileResolver,
|
|
576
|
+
docsPrefix: string,
|
|
577
|
+
): NormalizedDocsConfig {
|
|
578
|
+
const tabs = getTabsFromLeafContainer(source.container, source.fallbackLabel);
|
|
579
|
+
const showTabs = hasExplicitTopNavigation(source.container);
|
|
580
|
+
const state: WalkState = { pages: [], order: { value: 0 } };
|
|
581
|
+
const treeByTab = new Map<string, PendingNode[]>();
|
|
582
|
+
const seenTabIds = new Set<string>();
|
|
583
|
+
const tabIds: string[] = [];
|
|
584
|
+
|
|
585
|
+
tabs.forEach((tab, index) => {
|
|
586
|
+
const tabLabel = tab.tab?.trim() || '';
|
|
587
|
+
|
|
588
|
+
if (!tabLabel && showTabs) {
|
|
589
|
+
throw new Error(`Invalid docs config: tab at index ${index} is missing "tab".`);
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
const tabId = slugifyId(tabLabel || 'documentation', `tab-${index + 1}`);
|
|
593
|
+
|
|
594
|
+
if (seenTabIds.has(tabId)) {
|
|
595
|
+
throw new Error(
|
|
596
|
+
`Invalid docs config: duplicate tab label "${tabLabel}" resolves to duplicate id "${tabId}".`,
|
|
597
|
+
);
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
seenTabIds.add(tabId);
|
|
601
|
+
tabIds.push(tabId);
|
|
602
|
+
|
|
603
|
+
const context: WalkContext = {
|
|
604
|
+
tabId,
|
|
605
|
+
tabLabel,
|
|
606
|
+
section: tabLabel,
|
|
607
|
+
hidden: tab.hidden || undefined,
|
|
608
|
+
};
|
|
609
|
+
const nodes: PendingNode[] = [];
|
|
610
|
+
const pagesBefore = state.pages.length;
|
|
611
|
+
|
|
612
|
+
if (Array.isArray(tab.groups)) {
|
|
613
|
+
tab.groups.forEach(group => {
|
|
614
|
+
if (!group?.group || !Array.isArray(group.pages)) {
|
|
615
|
+
throw new Error(`Invalid docs config: tab "${tabLabel}" has an invalid group entry.`);
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
nodes.push(...collectPages([group], context, state));
|
|
619
|
+
});
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
if (Array.isArray(tab.dropdowns)) {
|
|
623
|
+
tab.dropdowns.forEach((dropdown, dropdownIndex) => {
|
|
624
|
+
const dropdownLabel = dropdown?.dropdown?.trim();
|
|
625
|
+
|
|
626
|
+
if (!dropdownLabel) {
|
|
627
|
+
throw new Error(
|
|
628
|
+
`Invalid docs config: tab "${tabLabel}" dropdown at index ${dropdownIndex} is missing "dropdown".`,
|
|
629
|
+
);
|
|
630
|
+
}
|
|
631
|
+
const children: PendingNode[] = [];
|
|
632
|
+
const dropdownContext: WalkContext = {
|
|
633
|
+
...context,
|
|
634
|
+
section: dropdownLabel,
|
|
635
|
+
hidden: dropdown.hidden || context.hidden || undefined,
|
|
636
|
+
};
|
|
637
|
+
|
|
638
|
+
if (Array.isArray(dropdown.groups)) {
|
|
639
|
+
dropdown.groups.forEach(group => {
|
|
640
|
+
if (!group?.group || !Array.isArray(group.pages)) {
|
|
641
|
+
throw new Error(
|
|
642
|
+
`Invalid docs config: tab "${tabLabel}" dropdown "${dropdownLabel}" has an invalid group entry.`,
|
|
643
|
+
);
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
children.push(...collectPages([group], dropdownContext, state));
|
|
647
|
+
});
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
if (Array.isArray(dropdown.pages) && dropdown.pages.length) {
|
|
651
|
+
children.push(...collectPages(dropdown.pages, dropdownContext, state));
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
nodes.push({
|
|
655
|
+
kind: 'group',
|
|
656
|
+
label: dropdownLabel,
|
|
657
|
+
children,
|
|
658
|
+
icon: dropdown.icon,
|
|
659
|
+
hidden: dropdownContext.hidden,
|
|
660
|
+
});
|
|
661
|
+
});
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
if (Array.isArray(tab.pages) && tab.pages.length) {
|
|
665
|
+
nodes.push(...collectPages(tab.pages, context, state));
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
if (state.pages.length === pagesBefore) {
|
|
669
|
+
throw new Error(
|
|
670
|
+
`Invalid docs config: tab "${tabLabel}" does not contain any supported page entries.`,
|
|
671
|
+
);
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
treeByTab.set(tabId, nodes);
|
|
675
|
+
});
|
|
676
|
+
|
|
677
|
+
const pending = state.pages;
|
|
678
|
+
|
|
679
|
+
if (!pending.length) {
|
|
680
|
+
throw new Error('Invalid docs config: no pages found in navigation.');
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
const seenFileSlugs = new Set<string>();
|
|
684
|
+
const seenRouteSlugs = new Set<string>();
|
|
685
|
+
|
|
686
|
+
for (const page of pending) {
|
|
687
|
+
if (seenFileSlugs.has(page.fileSlug)) {
|
|
688
|
+
throw new Error(`Invalid docs config: duplicate page reference "${page.fileSlug}".`);
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
if (seenRouteSlugs.has(page.slug)) {
|
|
692
|
+
throw new Error(`Invalid docs config: duplicate route slug "${page.slug}".`);
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
seenFileSlugs.add(page.fileSlug);
|
|
696
|
+
seenRouteSlugs.add(page.slug);
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
const filePathBySlug = new Map(
|
|
700
|
+
[...seenFileSlugs].map(fileSlug => {
|
|
701
|
+
const filePath = resolveDocFile(fileSlug);
|
|
702
|
+
|
|
703
|
+
if (!filePath) {
|
|
704
|
+
throw new Error(
|
|
705
|
+
`Missing docs page file for "${fileSlug}": expected "${fileSlug}.mdx" or ".md".`,
|
|
706
|
+
);
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
return [fileSlug, filePath] as const;
|
|
710
|
+
}),
|
|
711
|
+
);
|
|
712
|
+
|
|
713
|
+
const pageBySlug: Record<string, NormalizedDocsPage> = {};
|
|
714
|
+
const pageByLookupSlug: Record<string, NormalizedDocsPage> = {};
|
|
715
|
+
const pageByOrder = new Map<number, NormalizedDocsPage>();
|
|
716
|
+
const pages: NormalizedDocsPage[] = [];
|
|
717
|
+
|
|
718
|
+
for (const page of pending) {
|
|
719
|
+
const filePath = filePathBySlug.get(page.fileSlug);
|
|
720
|
+
|
|
721
|
+
if (!filePath) {
|
|
722
|
+
throw new Error(`Invalid docs config: failed to resolve file for "${page.fileSlug}".`);
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
const normalized: NormalizedDocsPage = {
|
|
726
|
+
...page,
|
|
727
|
+
url: pageToUrl(page.slug, docsPrefix),
|
|
728
|
+
filePath,
|
|
729
|
+
scopeId: source.id,
|
|
730
|
+
language: source.language,
|
|
731
|
+
version: source.version,
|
|
732
|
+
};
|
|
733
|
+
|
|
734
|
+
pages.push(normalized);
|
|
735
|
+
pageByOrder.set(normalized.order, normalized);
|
|
736
|
+
pageBySlug[normalized.slug] = normalized;
|
|
737
|
+
pageByLookupSlug[normalized.slug] = normalized;
|
|
738
|
+
pageByLookupSlug[normalized.fileSlug] = normalized;
|
|
739
|
+
pageByLookupSlug[normalized.fileSlug.replace(/\/index$/, '') || 'index'] = normalized;
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
// Pass 2: swap resolved pages into the pending tree.
|
|
743
|
+
function materialize(node: PendingNode): NavNode | null {
|
|
744
|
+
if (node.kind === 'link') {
|
|
745
|
+
return node;
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
if (node.kind === 'page') {
|
|
749
|
+
const page = pageByOrder.get(node.order);
|
|
750
|
+
return page ? ({ kind: 'page', page } satisfies NavPageNode) : null;
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
const root = node.rootOrder === undefined ? undefined : pageByOrder.get(node.rootOrder);
|
|
754
|
+
|
|
755
|
+
return {
|
|
756
|
+
kind: 'group',
|
|
757
|
+
label: node.label,
|
|
758
|
+
root: root ? { kind: 'page', page: root } : undefined,
|
|
759
|
+
children: node.children.map(materialize).filter((child): child is NavNode => !!child),
|
|
760
|
+
icon: node.icon,
|
|
761
|
+
expanded: node.expanded,
|
|
762
|
+
collapsible: node.collapsible,
|
|
763
|
+
hidden: node.hidden,
|
|
764
|
+
} satisfies NavGroupNode;
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
const navigation: NormalizedDocsConfig['navigation'] = {};
|
|
768
|
+
|
|
769
|
+
for (const [tabId, nodes] of treeByTab) {
|
|
770
|
+
navigation[tabId] = nodes.map(materialize).filter((node): node is NavNode => !!node);
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
const firstVisiblePageByTab = new Map<string, NormalizedDocsPage>();
|
|
774
|
+
|
|
775
|
+
for (const page of pages) {
|
|
776
|
+
if (!page.hidden && !firstVisiblePageByTab.has(page.tabId)) {
|
|
777
|
+
firstVisiblePageByTab.set(page.tabId, page);
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
const normalizedTabs = tabs.map((tab, index) => {
|
|
782
|
+
const tabId = tabIds[index];
|
|
783
|
+
const firstPage = firstVisiblePageByTab.get(tabId);
|
|
784
|
+
|
|
785
|
+
return {
|
|
786
|
+
id: tabId,
|
|
787
|
+
label: tab.tab?.trim() || '',
|
|
788
|
+
url: firstPage?.url || pageToUrl('index', docsPrefix),
|
|
789
|
+
icon: tab.icon,
|
|
790
|
+
presentation: tab.presentation || 'tab',
|
|
791
|
+
hidden: tab.hidden || undefined,
|
|
792
|
+
};
|
|
793
|
+
});
|
|
794
|
+
|
|
795
|
+
return {
|
|
796
|
+
name,
|
|
797
|
+
tabs: normalizedTabs,
|
|
798
|
+
showTabs,
|
|
799
|
+
navigation,
|
|
800
|
+
anchors: collectAnchors(anchors),
|
|
801
|
+
pages,
|
|
802
|
+
pageBySlug,
|
|
803
|
+
pageByLookupSlug,
|
|
804
|
+
};
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
/**
|
|
808
|
+
* Normalizes the complete docs site: one scope for ordinary navigation, one per
|
|
809
|
+
* version, one per language, and one per version nested inside a language.
|
|
810
|
+
* Every scope builds, including hidden ones; hidden scopes are only omitted
|
|
811
|
+
* from switcher UI. Page references and route URLs are validated globally.
|
|
812
|
+
*/
|
|
813
|
+
export function normalizeDocsSite(
|
|
814
|
+
docsConfig: DocsConfig,
|
|
815
|
+
resolveDocFile: DocFileResolver,
|
|
816
|
+
options: NormalizeOptions = {},
|
|
817
|
+
): NormalizedDocsSite {
|
|
818
|
+
const docsPrefix = options.docsPrefix ?? DOCS_PREFIX;
|
|
819
|
+
const sources = collectScopeSources(docsConfig.navigation);
|
|
820
|
+
const anchors = docsConfig.navigation.anchors;
|
|
821
|
+
|
|
822
|
+
const seenScopeIds = new Set<string>();
|
|
823
|
+
|
|
824
|
+
for (const source of sources) {
|
|
825
|
+
if (seenScopeIds.has(source.id)) {
|
|
826
|
+
throw new Error(
|
|
827
|
+
`Invalid docs config: duplicate version/language label resolves to duplicate scope id "${source.id}".`,
|
|
828
|
+
);
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
seenScopeIds.add(source.id);
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
const scopes: DocsScope[] = sources.map(source => {
|
|
835
|
+
const docs = normalizeScope(docsConfig.name, source, anchors, resolveDocFile, docsPrefix);
|
|
836
|
+
const firstPage = docs.pages.find(page => !page.hidden) || docs.pages[0];
|
|
837
|
+
|
|
838
|
+
return {
|
|
839
|
+
id: source.id,
|
|
840
|
+
language: source.language,
|
|
841
|
+
version: source.version,
|
|
842
|
+
hidden: source.hidden,
|
|
843
|
+
isDefault: source.isDefault,
|
|
844
|
+
isLanguageDefault: source.isLanguageDefault,
|
|
845
|
+
firstPageUrl: firstPage.url,
|
|
846
|
+
docs,
|
|
847
|
+
};
|
|
848
|
+
});
|
|
849
|
+
|
|
850
|
+
const pages: NormalizedDocsPage[] = [];
|
|
851
|
+
const pageByUrl: Record<string, NormalizedDocsPage> = {};
|
|
852
|
+
const fileOwners = new Map<string, string>();
|
|
853
|
+
|
|
854
|
+
for (const scope of scopes) {
|
|
855
|
+
for (const page of scope.docs.pages) {
|
|
856
|
+
const owner = fileOwners.get(page.fileSlug);
|
|
857
|
+
|
|
858
|
+
if (owner !== undefined) {
|
|
859
|
+
throw new Error(
|
|
860
|
+
`Invalid docs config: page "${page.fileSlug}" is referenced by multiple navigation ` +
|
|
861
|
+
'scopes. Each version/language must reference its own content files.',
|
|
862
|
+
);
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
fileOwners.set(page.fileSlug, scope.id);
|
|
866
|
+
|
|
867
|
+
if (pageByUrl[page.url]) {
|
|
868
|
+
throw new Error(
|
|
869
|
+
`Invalid docs config: duplicate route URL "${page.url}" across versions/languages.`,
|
|
870
|
+
);
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
pageByUrl[page.url] = page;
|
|
874
|
+
pages.push(page);
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
const defaultScope = scopes.find(scope => scope.isDefault) || scopes[0];
|
|
879
|
+
|
|
880
|
+
return { scopes, defaultScopeId: defaultScope.id, pages, pageByUrl };
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
/**
|
|
884
|
+
* Normalizes only the default scope of a site. Retained for callers that need
|
|
885
|
+
* a single navigation; multi-scope consumers use `normalizeDocsSite`.
|
|
886
|
+
*/
|
|
887
|
+
export function normalizeDocsConfig(
|
|
888
|
+
docsConfig: DocsConfig,
|
|
889
|
+
resolveDocFile: DocFileResolver,
|
|
890
|
+
options: NormalizeOptions = {},
|
|
891
|
+
): NormalizedDocsConfig {
|
|
892
|
+
return getDefaultScope(normalizeDocsSite(docsConfig, resolveDocFile, options)).docs;
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
/* ---------------------------------------------------------------------------
|
|
896
|
+
* Site helpers
|
|
897
|
+
* ------------------------------------------------------------------------- */
|
|
898
|
+
|
|
899
|
+
export function getDefaultScope(site: NormalizedDocsSite): DocsScope {
|
|
900
|
+
return site.scopes.find(scope => scope.id === site.defaultScopeId) || site.scopes[0];
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
export function getScopeById(site: NormalizedDocsSite, scopeId: string): DocsScope | null {
|
|
904
|
+
return site.scopes.find(scope => scope.id === scopeId) || null;
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
export function getScopeForPage(site: NormalizedDocsSite, page: NormalizedDocsPage): DocsScope {
|
|
908
|
+
return getScopeById(site, page.scopeId) || getDefaultScope(site);
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
/**
|
|
912
|
+
* One landing scope per language, for language switchers: the language's
|
|
913
|
+
* default-version scope, or its first visible scope when the default is
|
|
914
|
+
* hidden. Fully hidden languages are omitted.
|
|
915
|
+
*/
|
|
916
|
+
export function getLanguageScopes(site: NormalizedDocsSite): DocsScope[] {
|
|
917
|
+
const byLanguage = new Map<string, DocsScope[]>();
|
|
918
|
+
|
|
919
|
+
for (const scope of site.scopes) {
|
|
920
|
+
if (!scope.language) {
|
|
921
|
+
continue;
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
const list = byLanguage.get(scope.language) || [];
|
|
925
|
+
list.push(scope);
|
|
926
|
+
byLanguage.set(scope.language, list);
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
const landings: DocsScope[] = [];
|
|
930
|
+
|
|
931
|
+
for (const scopes of byLanguage.values()) {
|
|
932
|
+
const visible = scopes.filter(scope => !scope.hidden);
|
|
933
|
+
|
|
934
|
+
if (visible.length) {
|
|
935
|
+
landings.push(visible.find(scope => scope.isLanguageDefault) || visible[0]);
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
return landings;
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
/**
|
|
943
|
+
* Exact page lookup by pathname. Tolerates trailing slashes and explicit
|
|
944
|
+
* `/index` suffixes; everything else must match a page URL exactly.
|
|
945
|
+
*/
|
|
946
|
+
export function getPageByPathname(
|
|
947
|
+
site: NormalizedDocsSite,
|
|
948
|
+
pathname: string,
|
|
949
|
+
): NormalizedDocsPage | null {
|
|
950
|
+
const trimmed = pathname.replace(/\/+$/, '') || '/';
|
|
951
|
+
const collapsed = trimmed === '/index' ? '/' : trimmed.replace(/\/index$/, '') || '/';
|
|
952
|
+
|
|
953
|
+
return site.pageByUrl[trimmed] || site.pageByUrl[collapsed] || null;
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
/** Flattens a navigation tree to the routed pages it contains, in document order. */
|
|
957
|
+
export function flattenNav(nodes: NavNode[]): NormalizedDocsPage[] {
|
|
958
|
+
const pages: NormalizedDocsPage[] = [];
|
|
959
|
+
|
|
960
|
+
for (const node of nodes) {
|
|
961
|
+
if (node.kind === 'page') {
|
|
962
|
+
pages.push(node.page);
|
|
963
|
+
} else if (node.kind === 'group') {
|
|
964
|
+
if (node.root) {
|
|
965
|
+
pages.push(node.root.page);
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
pages.push(...flattenNav(node.children));
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
return pages;
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
/** True when a node (or all of its descendants) should be omitted from the sidebar. */
|
|
976
|
+
export function isNodeHidden(node: NavNode): boolean {
|
|
977
|
+
if (node.kind === 'page') {
|
|
978
|
+
return !!node.page.hidden;
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
if (node.kind === 'link') {
|
|
982
|
+
return !!node.hidden;
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
return !!node.hidden || node.children.every(isNodeHidden);
|
|
986
|
+
}
|