@umami/shiso 0.55.0 → 0.61.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 +12 -0
- package/README.md +9 -49
- package/bin/shiso.mjs +132 -0
- package/docs.schema.json +835 -0
- package/mdx.config.ts +143 -0
- package/package.json +73 -83
- package/scripts/check-package.mjs +18 -0
- package/scripts/generate-icon-registry.mjs +196 -0
- package/scripts/generate-last-modified.mjs +128 -0
- package/scripts/generate-search-index.mjs +252 -0
- package/scripts/load-docs-config.mjs +244 -0
- package/scripts/prerender.mjs +187 -0
- package/scripts/validate-config.mjs +104 -0
- package/scripts/vite-docs-config.mjs +60 -0
- package/src/App.tsx +39 -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 +100 -0
- package/src/components/Docs.tsx +126 -0
- package/src/components/Footer.tsx +84 -0
- package/src/components/Header.tsx +72 -0
- package/src/components/Layout.tsx +16 -0
- package/src/components/PageLinks.tsx +71 -0
- package/src/components/Search.tsx +208 -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/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 +77 -0
- package/src/generated/last-modified.ts +2 -0
- package/src/lib/content.ts +44 -0
- package/src/lib/docs-config.ts +682 -0
- package/src/lib/head.ts +220 -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/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 +80 -0
- package/src/lib/search/providers/local.ts +15 -0
- package/src/lib/search-index.generated.ts +4 -0
- package/src/lib/search.ts +100 -0
- package/src/lib/site-config.ts +104 -0
- package/src/lib/site-model.ts +221 -0
- package/src/lib/slug.ts +38 -0
- package/src/lib/types.ts +478 -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 +17 -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,682 @@
|
|
|
1
|
+
import { DOCS_PREFIX } from '@/lib/paths';
|
|
2
|
+
import { slugifyId } from '@/lib/slug';
|
|
3
|
+
import type {
|
|
4
|
+
AnchorItem,
|
|
5
|
+
DocsConfig,
|
|
6
|
+
DropdownItem,
|
|
7
|
+
GroupItem,
|
|
8
|
+
LanguageItem,
|
|
9
|
+
LinkTarget,
|
|
10
|
+
NavGroupNode,
|
|
11
|
+
NavLinkNode,
|
|
12
|
+
NavNode,
|
|
13
|
+
NavPageNode,
|
|
14
|
+
NormalizedDocsConfig,
|
|
15
|
+
NormalizedDocsPage,
|
|
16
|
+
NormalizeOptions,
|
|
17
|
+
PageItem,
|
|
18
|
+
TabItem,
|
|
19
|
+
VersionItem,
|
|
20
|
+
} from '@/lib/types';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Resolves a page reference from docs.json (e.g. "components/tabs") to the
|
|
24
|
+
* module key of a real content file (e.g. "/content/docs/components/tabs.mdx").
|
|
25
|
+
* Returns undefined when no file exists for the reference.
|
|
26
|
+
*/
|
|
27
|
+
export type DocFileResolver = (fileSlug: string) => string | undefined;
|
|
28
|
+
|
|
29
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
30
|
+
return !!value && typeof value === 'object' && !Array.isArray(value);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function normalizeLinkTarget(href: string, target?: LinkTarget): LinkTarget {
|
|
34
|
+
return target || (/^(?:#|\/|\.\.?\/)/.test(href) ? '_self' : '_blank');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Recognized config keys that Shiso does not implement.
|
|
39
|
+
* Policy: warn once and skip. Never fail a build over an unimplemented feature —
|
|
40
|
+
* the same rule the schema validator applies to top-level keys.
|
|
41
|
+
*/
|
|
42
|
+
const RESERVED_PAGE_KEYS = ['openapi', 'api', 'asyncapi', 'graphql', 'menu', 'product'];
|
|
43
|
+
|
|
44
|
+
const warned = new Set<string>();
|
|
45
|
+
|
|
46
|
+
function warnOnce(message: string) {
|
|
47
|
+
if (warned.has(message)) {
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
warned.add(message);
|
|
52
|
+
console.warn(`[shiso] ${message}`);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function assertDocsConfig(value: unknown, sourceName: string): asserts value is DocsConfig {
|
|
56
|
+
if (!isRecord(value)) {
|
|
57
|
+
throw new Error(`Invalid docs config in "${sourceName}": expected a JSON object.`);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Common mistake: pointing at a JSON schema document instead of an actual config object.
|
|
61
|
+
if ('anyOf' in value && 'definitions' in value && !('navigation' in value)) {
|
|
62
|
+
throw new Error(
|
|
63
|
+
`Invalid docs config in "${sourceName}": this looks like a JSON schema, not a project config object.`,
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (!isRecord(value.navigation)) {
|
|
68
|
+
throw new Error(`Invalid docs config in "${sourceName}": missing "navigation" object.`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** "code-blocks" -> "Code blocks". Sentence case: only the first word is capitalized. */
|
|
73
|
+
function toLabel(value: string): string {
|
|
74
|
+
const words = value.split(/[-_]/g).filter(Boolean);
|
|
75
|
+
|
|
76
|
+
return words
|
|
77
|
+
.map((word, index) => (index === 0 ? word[0]?.toUpperCase() + word.slice(1) : word))
|
|
78
|
+
.join(' ');
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function normalizePageReference(pageRef: string): { fileSlug: string; slug: string } {
|
|
82
|
+
const value = pageRef
|
|
83
|
+
.trim()
|
|
84
|
+
.replace(/\\/g, '/')
|
|
85
|
+
.replace(/^\/+/, '')
|
|
86
|
+
.replace(/^docs\//, '')
|
|
87
|
+
.replace(/\.mdx?$/, '')
|
|
88
|
+
.replace(/\/+$/, '');
|
|
89
|
+
|
|
90
|
+
if (!value) {
|
|
91
|
+
return { fileSlug: 'index', slug: 'index' };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const fileSlug = value;
|
|
95
|
+
const slug = value === 'index' ? 'index' : value.replace(/\/index$/, '') || 'index';
|
|
96
|
+
|
|
97
|
+
return { fileSlug, slug };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function getDefaultLabel(fileSlug: string): string {
|
|
101
|
+
const parts = fileSlug.split('/').filter(Boolean);
|
|
102
|
+
const leaf = parts.at(-1) || fileSlug;
|
|
103
|
+
return toLabel(leaf);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function pageToUrl(slug: string, docsPrefix: string): string {
|
|
107
|
+
const base = docsPrefix || '';
|
|
108
|
+
return slug === 'index' ? base || '/' : `${base}/${slug}`;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function pickDefaultItem<T extends { default?: boolean }>(items: T[]): T {
|
|
112
|
+
return items.find(item => item.default) || items[0];
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function dropdownsToTabs(dropdowns: DropdownItem[]): TabItem[] {
|
|
116
|
+
return dropdowns.map((dropdown, index) => {
|
|
117
|
+
const label = dropdown.dropdown?.trim();
|
|
118
|
+
|
|
119
|
+
if (!label) {
|
|
120
|
+
throw new Error(`Invalid docs config: dropdown at index ${index} is missing "dropdown".`);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return {
|
|
124
|
+
tab: label,
|
|
125
|
+
groups: dropdown.groups || [],
|
|
126
|
+
pages: dropdown.pages || [],
|
|
127
|
+
icon: dropdown.icon,
|
|
128
|
+
hidden: dropdown.hidden,
|
|
129
|
+
presentation: 'dropdown',
|
|
130
|
+
};
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
interface NavContainer {
|
|
135
|
+
tabs?: TabItem[];
|
|
136
|
+
dropdowns?: DropdownItem[];
|
|
137
|
+
groups?: GroupItem[];
|
|
138
|
+
pages?: PageItem[];
|
|
139
|
+
versions?: VersionItem[];
|
|
140
|
+
languages?: LanguageItem[];
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Resolves a navigation container down to a flat list of tabs.
|
|
145
|
+
*
|
|
146
|
+
* Versions and languages collapse to their default entry for now. That is a
|
|
147
|
+
* real limitation, so the skipped entries are reported rather than silently
|
|
148
|
+
* dropped; Phases 6 and 7 replace this by normalizing once per version/locale.
|
|
149
|
+
*/
|
|
150
|
+
function getTabsFromContainer(container: NavContainer, fallbackLabel = ''): TabItem[] {
|
|
151
|
+
if (Array.isArray(container.tabs) && container.tabs.length) {
|
|
152
|
+
return container.tabs;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (Array.isArray(container.dropdowns) && container.dropdowns.length) {
|
|
156
|
+
return dropdownsToTabs(container.dropdowns);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (Array.isArray(container.groups) || Array.isArray(container.pages)) {
|
|
160
|
+
return [
|
|
161
|
+
{
|
|
162
|
+
tab: fallbackLabel,
|
|
163
|
+
groups: container.groups || [],
|
|
164
|
+
pages: container.pages || [],
|
|
165
|
+
},
|
|
166
|
+
];
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (Array.isArray(container.versions) && container.versions.length) {
|
|
170
|
+
const version = pickDefaultItem(container.versions);
|
|
171
|
+
const skipped = container.versions.filter(item => item !== version).map(item => item.version);
|
|
172
|
+
|
|
173
|
+
if (skipped.length) {
|
|
174
|
+
warnOnce(
|
|
175
|
+
`Only the default version "${version.version}" is rendered; skipped: ${skipped.join(', ')}. ` +
|
|
176
|
+
'Multi-version output is not implemented yet.',
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return getTabsFromContainer(version, version.version?.trim() || fallbackLabel);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (Array.isArray(container.languages) && container.languages.length) {
|
|
184
|
+
const language = pickDefaultItem(container.languages);
|
|
185
|
+
const skipped = container.languages
|
|
186
|
+
.filter(item => item !== language)
|
|
187
|
+
.map(item => item.language);
|
|
188
|
+
|
|
189
|
+
if (skipped.length) {
|
|
190
|
+
warnOnce(
|
|
191
|
+
`Only the default language "${language.language}" is rendered; skipped: ${skipped.join(', ')}. ` +
|
|
192
|
+
'Multi-language output is not implemented yet.',
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return getTabsFromContainer(language, language.language?.trim() || fallbackLabel);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
throw new Error(
|
|
200
|
+
'Invalid docs config: navigation must define tabs, dropdowns, groups, pages, versions, or languages.',
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function hasExplicitTopNavigation(container: NavContainer): boolean {
|
|
205
|
+
if (
|
|
206
|
+
(Array.isArray(container.tabs) && container.tabs.length > 0) ||
|
|
207
|
+
(Array.isArray(container.dropdowns) && container.dropdowns.length > 0)
|
|
208
|
+
) {
|
|
209
|
+
return true;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (Array.isArray(container.versions) && container.versions.length) {
|
|
213
|
+
return hasExplicitTopNavigation(pickDefaultItem(container.versions));
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
if (Array.isArray(container.languages) && container.languages.length) {
|
|
217
|
+
return hasExplicitTopNavigation(pickDefaultItem(container.languages));
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return false;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/* ---------------------------------------------------------------------------
|
|
224
|
+
* Pass 1 — walk the config into a tree of pending nodes
|
|
225
|
+
*
|
|
226
|
+
* Pages cannot be fully normalized during the walk because their file paths are
|
|
227
|
+
* resolved (and validated) in one batch afterwards. So the walk records page
|
|
228
|
+
* references by `order`, and pass 2 swaps in the resolved pages.
|
|
229
|
+
* ------------------------------------------------------------------------- */
|
|
230
|
+
|
|
231
|
+
interface PendingPage {
|
|
232
|
+
fileSlug: string;
|
|
233
|
+
slug: string;
|
|
234
|
+
label: string;
|
|
235
|
+
section: string;
|
|
236
|
+
tabId: string;
|
|
237
|
+
tabLabel: string;
|
|
238
|
+
order: number;
|
|
239
|
+
hidden?: boolean;
|
|
240
|
+
icon?: string;
|
|
241
|
+
tag?: string;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
type PendingNode =
|
|
245
|
+
| { kind: 'page'; order: number }
|
|
246
|
+
| NavLinkNode
|
|
247
|
+
| {
|
|
248
|
+
kind: 'group';
|
|
249
|
+
label: string;
|
|
250
|
+
rootOrder?: number;
|
|
251
|
+
children: PendingNode[];
|
|
252
|
+
icon?: string;
|
|
253
|
+
expanded?: boolean;
|
|
254
|
+
collapsible?: boolean;
|
|
255
|
+
hidden?: boolean;
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
interface WalkContext {
|
|
259
|
+
tabId: string;
|
|
260
|
+
tabLabel: string;
|
|
261
|
+
section: string;
|
|
262
|
+
/** Set when an ancestor group is hidden, so descendants inherit it. */
|
|
263
|
+
hidden?: boolean;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
interface WalkState {
|
|
267
|
+
pages: PendingPage[];
|
|
268
|
+
order: { value: number };
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function addPage(
|
|
272
|
+
pageRef: string,
|
|
273
|
+
context: WalkContext,
|
|
274
|
+
state: WalkState,
|
|
275
|
+
extra: { label?: string; icon?: string; tag?: string; hidden?: boolean } = {},
|
|
276
|
+
): number {
|
|
277
|
+
const { fileSlug, slug } = normalizePageReference(pageRef);
|
|
278
|
+
const order = state.order.value++;
|
|
279
|
+
|
|
280
|
+
state.pages.push({
|
|
281
|
+
fileSlug,
|
|
282
|
+
slug,
|
|
283
|
+
label: extra.label?.trim() || getDefaultLabel(fileSlug),
|
|
284
|
+
section: context.section,
|
|
285
|
+
tabId: context.tabId,
|
|
286
|
+
tabLabel: context.tabLabel,
|
|
287
|
+
order,
|
|
288
|
+
hidden: extra.hidden || context.hidden || undefined,
|
|
289
|
+
icon: extra.icon,
|
|
290
|
+
tag: extra.tag,
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
return order;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function collectPages(items: PageItem[], context: WalkContext, state: WalkState): PendingNode[] {
|
|
297
|
+
const nodes: PendingNode[] = [];
|
|
298
|
+
|
|
299
|
+
items.forEach(item => {
|
|
300
|
+
if (typeof item === 'string') {
|
|
301
|
+
nodes.push({ kind: 'page', order: addPage(item, context, state) });
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
if (!isRecord(item)) {
|
|
306
|
+
throw new Error(
|
|
307
|
+
'Invalid docs config: page items must be strings, { page }, { href }, or { group, pages } blocks.',
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// External link: { href, label | anchor }
|
|
312
|
+
if (typeof item.href === 'string' && item.href) {
|
|
313
|
+
const label =
|
|
314
|
+
(typeof item.label === 'string' && item.label.trim()) ||
|
|
315
|
+
(typeof item.anchor === 'string' && item.anchor.trim());
|
|
316
|
+
|
|
317
|
+
if (!label) {
|
|
318
|
+
throw new Error(`Invalid docs config: external link "${item.href}" is missing a label.`);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
nodes.push({
|
|
322
|
+
kind: 'link',
|
|
323
|
+
label,
|
|
324
|
+
href: item.href,
|
|
325
|
+
icon: typeof item.icon === 'string' ? item.icon : undefined,
|
|
326
|
+
hidden: item.hidden === true || context.hidden || undefined,
|
|
327
|
+
target: normalizeLinkTarget(
|
|
328
|
+
item.href,
|
|
329
|
+
item.target === '_self' || item.target === '_blank' ? item.target : undefined,
|
|
330
|
+
),
|
|
331
|
+
});
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// Page reference: { page, title | label }
|
|
336
|
+
if (typeof item.page === 'string') {
|
|
337
|
+
const label =
|
|
338
|
+
(typeof item.label === 'string' && item.label) ||
|
|
339
|
+
(typeof item.title === 'string' && item.title) ||
|
|
340
|
+
undefined;
|
|
341
|
+
|
|
342
|
+
nodes.push({
|
|
343
|
+
kind: 'page',
|
|
344
|
+
order: addPage(item.page, context, state, {
|
|
345
|
+
label,
|
|
346
|
+
icon: typeof item.icon === 'string' ? item.icon : undefined,
|
|
347
|
+
tag: typeof item.tag === 'string' ? item.tag : undefined,
|
|
348
|
+
hidden: item.hidden === true,
|
|
349
|
+
}),
|
|
350
|
+
});
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// Nested group: { group, pages, root }
|
|
355
|
+
if (typeof item.group === 'string' && Array.isArray(item.pages)) {
|
|
356
|
+
const label = item.group.trim();
|
|
357
|
+
|
|
358
|
+
if (!label) {
|
|
359
|
+
throw new Error('Invalid docs config: navigation group is missing "group".');
|
|
360
|
+
}
|
|
361
|
+
const hidden = item.hidden === true || context.hidden || undefined;
|
|
362
|
+
const childContext: WalkContext = { ...context, section: label, hidden };
|
|
363
|
+
|
|
364
|
+
nodes.push({
|
|
365
|
+
kind: 'group',
|
|
366
|
+
label,
|
|
367
|
+
rootOrder:
|
|
368
|
+
typeof item.root === 'string' && item.root.trim()
|
|
369
|
+
? addPage(item.root, childContext, state)
|
|
370
|
+
: undefined,
|
|
371
|
+
children: collectPages(item.pages as PageItem[], childContext, state),
|
|
372
|
+
icon: typeof item.icon === 'string' ? item.icon : undefined,
|
|
373
|
+
expanded: item.expanded === true || undefined,
|
|
374
|
+
collapsible: item.collapsible === false ? false : undefined,
|
|
375
|
+
hidden,
|
|
376
|
+
});
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
const reservedKey = Object.keys(item).find(key => RESERVED_PAGE_KEYS.includes(key));
|
|
381
|
+
|
|
382
|
+
if (reservedKey) {
|
|
383
|
+
warnOnce(
|
|
384
|
+
`Navigation item with "${reservedKey}" is recognized but is not ` +
|
|
385
|
+
'implemented yet — it will be skipped.',
|
|
386
|
+
);
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
throw new Error(
|
|
391
|
+
`Invalid docs config: unrecognized page item with keys [${Object.keys(item).join(', ')}]. ` +
|
|
392
|
+
'Supported items are strings, { page }, { href }, or { group, pages } blocks.',
|
|
393
|
+
);
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
return nodes;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function collectAnchors(anchors: AnchorItem[] | undefined): NavLinkNode[] {
|
|
400
|
+
if (!Array.isArray(anchors)) {
|
|
401
|
+
return [];
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
return anchors
|
|
405
|
+
.filter(anchor => isRecord(anchor) && typeof anchor.href === 'string' && anchor.href)
|
|
406
|
+
.map(anchor => ({
|
|
407
|
+
kind: 'link' as const,
|
|
408
|
+
label: anchor.anchor?.trim() || (anchor.href as string),
|
|
409
|
+
href: anchor.href as string,
|
|
410
|
+
icon: anchor.icon,
|
|
411
|
+
hidden: anchor.hidden || undefined,
|
|
412
|
+
target: normalizeLinkTarget(anchor.href as string, anchor.target),
|
|
413
|
+
}));
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/* ---------------------------------------------------------------------------
|
|
417
|
+
* Normalization
|
|
418
|
+
* ------------------------------------------------------------------------- */
|
|
419
|
+
|
|
420
|
+
export function normalizeDocsConfig(
|
|
421
|
+
docsConfig: DocsConfig,
|
|
422
|
+
resolveDocFile: DocFileResolver,
|
|
423
|
+
options: NormalizeOptions = {},
|
|
424
|
+
): NormalizedDocsConfig {
|
|
425
|
+
const docsPrefix = options.docsPrefix ?? DOCS_PREFIX;
|
|
426
|
+
const tabs = getTabsFromContainer(docsConfig.navigation);
|
|
427
|
+
const showTabs = hasExplicitTopNavigation(docsConfig.navigation);
|
|
428
|
+
const state: WalkState = { pages: [], order: { value: 0 } };
|
|
429
|
+
const treeByTab = new Map<string, PendingNode[]>();
|
|
430
|
+
const seenTabIds = new Set<string>();
|
|
431
|
+
const tabIds: string[] = [];
|
|
432
|
+
|
|
433
|
+
tabs.forEach((tab, index) => {
|
|
434
|
+
const tabLabel = tab.tab?.trim() || '';
|
|
435
|
+
|
|
436
|
+
if (!tabLabel && showTabs) {
|
|
437
|
+
throw new Error(`Invalid docs config: tab at index ${index} is missing "tab".`);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
const tabId = slugifyId(tabLabel || 'documentation', `tab-${index + 1}`);
|
|
441
|
+
|
|
442
|
+
if (seenTabIds.has(tabId)) {
|
|
443
|
+
throw new Error(
|
|
444
|
+
`Invalid docs config: duplicate tab label "${tabLabel}" resolves to duplicate id "${tabId}".`,
|
|
445
|
+
);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
seenTabIds.add(tabId);
|
|
449
|
+
tabIds.push(tabId);
|
|
450
|
+
|
|
451
|
+
const context: WalkContext = {
|
|
452
|
+
tabId,
|
|
453
|
+
tabLabel,
|
|
454
|
+
section: tabLabel,
|
|
455
|
+
hidden: tab.hidden || undefined,
|
|
456
|
+
};
|
|
457
|
+
const nodes: PendingNode[] = [];
|
|
458
|
+
const pagesBefore = state.pages.length;
|
|
459
|
+
|
|
460
|
+
if (Array.isArray(tab.groups)) {
|
|
461
|
+
tab.groups.forEach(group => {
|
|
462
|
+
if (!group?.group || !Array.isArray(group.pages)) {
|
|
463
|
+
throw new Error(`Invalid docs config: tab "${tabLabel}" has an invalid group entry.`);
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
nodes.push(...collectPages([group], context, state));
|
|
467
|
+
});
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
if (Array.isArray(tab.dropdowns)) {
|
|
471
|
+
tab.dropdowns.forEach((dropdown, dropdownIndex) => {
|
|
472
|
+
const dropdownLabel = dropdown?.dropdown?.trim();
|
|
473
|
+
|
|
474
|
+
if (!dropdownLabel) {
|
|
475
|
+
throw new Error(
|
|
476
|
+
`Invalid docs config: tab "${tabLabel}" dropdown at index ${dropdownIndex} is missing "dropdown".`,
|
|
477
|
+
);
|
|
478
|
+
}
|
|
479
|
+
const children: PendingNode[] = [];
|
|
480
|
+
const dropdownContext: WalkContext = {
|
|
481
|
+
...context,
|
|
482
|
+
section: dropdownLabel,
|
|
483
|
+
hidden: dropdown.hidden || context.hidden || undefined,
|
|
484
|
+
};
|
|
485
|
+
|
|
486
|
+
if (Array.isArray(dropdown.groups)) {
|
|
487
|
+
dropdown.groups.forEach(group => {
|
|
488
|
+
if (!group?.group || !Array.isArray(group.pages)) {
|
|
489
|
+
throw new Error(
|
|
490
|
+
`Invalid docs config: tab "${tabLabel}" dropdown "${dropdownLabel}" has an invalid group entry.`,
|
|
491
|
+
);
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
children.push(...collectPages([group], dropdownContext, state));
|
|
495
|
+
});
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
if (Array.isArray(dropdown.pages) && dropdown.pages.length) {
|
|
499
|
+
children.push(...collectPages(dropdown.pages, dropdownContext, state));
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
nodes.push({
|
|
503
|
+
kind: 'group',
|
|
504
|
+
label: dropdownLabel,
|
|
505
|
+
children,
|
|
506
|
+
icon: dropdown.icon,
|
|
507
|
+
hidden: dropdownContext.hidden,
|
|
508
|
+
});
|
|
509
|
+
});
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
if (Array.isArray(tab.pages) && tab.pages.length) {
|
|
513
|
+
nodes.push(...collectPages(tab.pages, context, state));
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
if (state.pages.length === pagesBefore) {
|
|
517
|
+
throw new Error(
|
|
518
|
+
`Invalid docs config: tab "${tabLabel}" does not contain any supported page entries.`,
|
|
519
|
+
);
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
treeByTab.set(tabId, nodes);
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
const pending = state.pages;
|
|
526
|
+
|
|
527
|
+
if (!pending.length) {
|
|
528
|
+
throw new Error('Invalid docs config: no pages found in navigation.');
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
const seenFileSlugs = new Set<string>();
|
|
532
|
+
const seenRouteSlugs = new Set<string>();
|
|
533
|
+
|
|
534
|
+
for (const page of pending) {
|
|
535
|
+
if (seenFileSlugs.has(page.fileSlug)) {
|
|
536
|
+
throw new Error(`Invalid docs config: duplicate page reference "${page.fileSlug}".`);
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
if (seenRouteSlugs.has(page.slug)) {
|
|
540
|
+
throw new Error(`Invalid docs config: duplicate route slug "${page.slug}".`);
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
seenFileSlugs.add(page.fileSlug);
|
|
544
|
+
seenRouteSlugs.add(page.slug);
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
const filePathBySlug = new Map(
|
|
548
|
+
[...seenFileSlugs].map(fileSlug => {
|
|
549
|
+
const filePath = resolveDocFile(fileSlug);
|
|
550
|
+
|
|
551
|
+
if (!filePath) {
|
|
552
|
+
throw new Error(
|
|
553
|
+
`Missing docs page file for "${fileSlug}": expected "${fileSlug}.mdx" or ".md".`,
|
|
554
|
+
);
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
return [fileSlug, filePath] as const;
|
|
558
|
+
}),
|
|
559
|
+
);
|
|
560
|
+
|
|
561
|
+
const pageBySlug: Record<string, NormalizedDocsPage> = {};
|
|
562
|
+
const pageByLookupSlug: Record<string, NormalizedDocsPage> = {};
|
|
563
|
+
const pageByOrder = new Map<number, NormalizedDocsPage>();
|
|
564
|
+
const pages: NormalizedDocsPage[] = [];
|
|
565
|
+
|
|
566
|
+
for (const page of pending) {
|
|
567
|
+
const filePath = filePathBySlug.get(page.fileSlug);
|
|
568
|
+
|
|
569
|
+
if (!filePath) {
|
|
570
|
+
throw new Error(`Invalid docs config: failed to resolve file for "${page.fileSlug}".`);
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
const normalized: NormalizedDocsPage = {
|
|
574
|
+
...page,
|
|
575
|
+
url: pageToUrl(page.slug, docsPrefix),
|
|
576
|
+
filePath,
|
|
577
|
+
};
|
|
578
|
+
|
|
579
|
+
pages.push(normalized);
|
|
580
|
+
pageByOrder.set(normalized.order, normalized);
|
|
581
|
+
pageBySlug[normalized.slug] = normalized;
|
|
582
|
+
pageByLookupSlug[normalized.slug] = normalized;
|
|
583
|
+
pageByLookupSlug[normalized.fileSlug] = normalized;
|
|
584
|
+
pageByLookupSlug[normalized.fileSlug.replace(/\/index$/, '') || 'index'] = normalized;
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
// Pass 2: swap resolved pages into the pending tree.
|
|
588
|
+
function materialize(node: PendingNode): NavNode | null {
|
|
589
|
+
if (node.kind === 'link') {
|
|
590
|
+
return node;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
if (node.kind === 'page') {
|
|
594
|
+
const page = pageByOrder.get(node.order);
|
|
595
|
+
return page ? ({ kind: 'page', page } satisfies NavPageNode) : null;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
const root = node.rootOrder === undefined ? undefined : pageByOrder.get(node.rootOrder);
|
|
599
|
+
|
|
600
|
+
return {
|
|
601
|
+
kind: 'group',
|
|
602
|
+
label: node.label,
|
|
603
|
+
root: root ? { kind: 'page', page: root } : undefined,
|
|
604
|
+
children: node.children.map(materialize).filter((child): child is NavNode => !!child),
|
|
605
|
+
icon: node.icon,
|
|
606
|
+
expanded: node.expanded,
|
|
607
|
+
collapsible: node.collapsible,
|
|
608
|
+
hidden: node.hidden,
|
|
609
|
+
} satisfies NavGroupNode;
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
const navigation: NormalizedDocsConfig['navigation'] = {};
|
|
613
|
+
|
|
614
|
+
for (const [tabId, nodes] of treeByTab) {
|
|
615
|
+
navigation[tabId] = nodes.map(materialize).filter((node): node is NavNode => !!node);
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
const firstVisiblePageByTab = new Map<string, NormalizedDocsPage>();
|
|
619
|
+
|
|
620
|
+
for (const page of pages) {
|
|
621
|
+
if (!page.hidden && !firstVisiblePageByTab.has(page.tabId)) {
|
|
622
|
+
firstVisiblePageByTab.set(page.tabId, page);
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
const normalizedTabs = tabs.map((tab, index) => {
|
|
627
|
+
const tabId = tabIds[index];
|
|
628
|
+
const firstPage = firstVisiblePageByTab.get(tabId);
|
|
629
|
+
|
|
630
|
+
return {
|
|
631
|
+
id: tabId,
|
|
632
|
+
label: tab.tab?.trim() || '',
|
|
633
|
+
url: firstPage?.url || pageToUrl('index', docsPrefix),
|
|
634
|
+
icon: tab.icon,
|
|
635
|
+
presentation: tab.presentation || 'tab',
|
|
636
|
+
hidden: tab.hidden || undefined,
|
|
637
|
+
};
|
|
638
|
+
});
|
|
639
|
+
|
|
640
|
+
return {
|
|
641
|
+
name: docsConfig.name,
|
|
642
|
+
tabs: normalizedTabs,
|
|
643
|
+
showTabs,
|
|
644
|
+
navigation,
|
|
645
|
+
anchors: collectAnchors(docsConfig.navigation.anchors),
|
|
646
|
+
pages,
|
|
647
|
+
pageBySlug,
|
|
648
|
+
pageByLookupSlug,
|
|
649
|
+
};
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
/** Flattens a navigation tree to the routed pages it contains, in document order. */
|
|
653
|
+
export function flattenNav(nodes: NavNode[]): NormalizedDocsPage[] {
|
|
654
|
+
const pages: NormalizedDocsPage[] = [];
|
|
655
|
+
|
|
656
|
+
for (const node of nodes) {
|
|
657
|
+
if (node.kind === 'page') {
|
|
658
|
+
pages.push(node.page);
|
|
659
|
+
} else if (node.kind === 'group') {
|
|
660
|
+
if (node.root) {
|
|
661
|
+
pages.push(node.root.page);
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
pages.push(...flattenNav(node.children));
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
return pages;
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
/** True when a node (or all of its descendants) should be omitted from the sidebar. */
|
|
672
|
+
export function isNodeHidden(node: NavNode): boolean {
|
|
673
|
+
if (node.kind === 'page') {
|
|
674
|
+
return !!node.page.hidden;
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
if (node.kind === 'link') {
|
|
678
|
+
return !!node.hidden;
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
return !!node.hidden || node.children.every(isNodeHidden);
|
|
682
|
+
}
|