@umami/shiso 0.61.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 +27 -0
- package/bin/shiso.mjs +0 -0
- package/docs.schema.json +81 -20
- package/package.json +3 -2
- package/scripts/check-package.mjs +36 -0
- package/scripts/generate-search-index.mjs +79 -33
- package/scripts/lib/mdast.mjs +23 -0
- package/scripts/lib/slug.mjs +21 -0
- package/scripts/prerender.mjs +24 -17
- package/src/App.tsx +4 -5
- package/src/components/DocContent.tsx +7 -2
- package/src/components/Docs.tsx +13 -5
- package/src/components/Footer.tsx +6 -9
- package/src/components/Header.tsx +13 -3
- package/src/components/LanguageSwitcher.tsx +59 -0
- package/src/components/Layout.tsx +8 -0
- package/src/components/Search.tsx +12 -3
- package/src/components/VersionSwitcher.tsx +60 -0
- package/src/components/docs/PropertiesTable.tsx +7 -7
- package/src/entry-server.tsx +20 -8
- package/src/lib/docs-config.ts +397 -93
- package/src/lib/head.ts +15 -4
- package/src/lib/locale.ts +39 -0
- package/src/lib/search/provider.ts +7 -2
- package/src/lib/search/providers/local.ts +3 -3
- package/src/lib/search.ts +28 -0
- package/src/lib/site-config.ts +35 -22
- package/src/lib/types.ts +39 -2
- package/types/search.d.ts +11 -1
package/src/lib/docs-config.ts
CHANGED
|
@@ -3,16 +3,19 @@ import { slugifyId } from '@/lib/slug';
|
|
|
3
3
|
import type {
|
|
4
4
|
AnchorItem,
|
|
5
5
|
DocsConfig,
|
|
6
|
+
DocsScope,
|
|
6
7
|
DropdownItem,
|
|
7
8
|
GroupItem,
|
|
8
9
|
LanguageItem,
|
|
9
10
|
LinkTarget,
|
|
10
11
|
NavGroupNode,
|
|
12
|
+
NavigationConfig,
|
|
11
13
|
NavLinkNode,
|
|
12
14
|
NavNode,
|
|
13
15
|
NavPageNode,
|
|
14
16
|
NormalizedDocsConfig,
|
|
15
17
|
NormalizedDocsPage,
|
|
18
|
+
NormalizedDocsSite,
|
|
16
19
|
NormalizeOptions,
|
|
17
20
|
PageItem,
|
|
18
21
|
TabItem,
|
|
@@ -34,24 +37,6 @@ function normalizeLinkTarget(href: string, target?: LinkTarget): LinkTarget {
|
|
|
34
37
|
return target || (/^(?:#|\/|\.\.?\/)/.test(href) ? '_self' : '_blank');
|
|
35
38
|
}
|
|
36
39
|
|
|
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
40
|
export function assertDocsConfig(value: unknown, sourceName: string): asserts value is DocsConfig {
|
|
56
41
|
if (!isRecord(value)) {
|
|
57
42
|
throw new Error(`Invalid docs config in "${sourceName}": expected a JSON object.`);
|
|
@@ -108,10 +93,6 @@ function pageToUrl(slug: string, docsPrefix: string): string {
|
|
|
108
93
|
return slug === 'index' ? base || '/' : `${base}/${slug}`;
|
|
109
94
|
}
|
|
110
95
|
|
|
111
|
-
function pickDefaultItem<T extends { default?: boolean }>(items: T[]): T {
|
|
112
|
-
return items.find(item => item.default) || items[0];
|
|
113
|
-
}
|
|
114
|
-
|
|
115
96
|
function dropdownsToTabs(dropdowns: DropdownItem[]): TabItem[] {
|
|
116
97
|
return dropdowns.map((dropdown, index) => {
|
|
117
98
|
const label = dropdown.dropdown?.trim();
|
|
@@ -141,18 +122,88 @@ interface NavContainer {
|
|
|
141
122
|
}
|
|
142
123
|
|
|
143
124
|
/**
|
|
144
|
-
*
|
|
145
|
-
*
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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.
|
|
149
192
|
*/
|
|
150
|
-
function
|
|
151
|
-
if (Array.isArray(container.tabs)
|
|
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
|
+
|
|
152
199
|
return container.tabs;
|
|
153
200
|
}
|
|
154
201
|
|
|
155
|
-
if (Array.isArray(container.dropdowns)
|
|
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
|
+
|
|
156
207
|
return dropdownsToTabs(container.dropdowns);
|
|
157
208
|
}
|
|
158
209
|
|
|
@@ -166,58 +217,159 @@ function getTabsFromContainer(container: NavContainer, fallbackLabel = ''): TabI
|
|
|
166
217
|
];
|
|
167
218
|
}
|
|
168
219
|
|
|
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
220
|
throw new Error(
|
|
200
221
|
'Invalid docs config: navigation must define tabs, dropdowns, groups, pages, versions, or languages.',
|
|
201
222
|
);
|
|
202
223
|
}
|
|
203
224
|
|
|
204
225
|
function hasExplicitTopNavigation(container: NavContainer): boolean {
|
|
205
|
-
|
|
226
|
+
return (
|
|
206
227
|
(Array.isArray(container.tabs) && container.tabs.length > 0) ||
|
|
207
228
|
(Array.isArray(container.dropdowns) && container.dropdowns.length > 0)
|
|
208
|
-
)
|
|
209
|
-
|
|
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".');
|
|
210
264
|
}
|
|
211
265
|
|
|
212
|
-
|
|
213
|
-
|
|
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
|
+
);
|
|
214
305
|
}
|
|
215
306
|
|
|
216
|
-
if (
|
|
217
|
-
|
|
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
|
+
});
|
|
218
362
|
}
|
|
219
363
|
|
|
220
|
-
return
|
|
364
|
+
return [
|
|
365
|
+
{
|
|
366
|
+
id: 'default',
|
|
367
|
+
isDefault: true,
|
|
368
|
+
isLanguageDefault: true,
|
|
369
|
+
container: navigation,
|
|
370
|
+
fallbackLabel: '',
|
|
371
|
+
},
|
|
372
|
+
];
|
|
221
373
|
}
|
|
222
374
|
|
|
223
375
|
/* ---------------------------------------------------------------------------
|
|
@@ -377,16 +529,6 @@ function collectPages(items: PageItem[], context: WalkContext, state: WalkState)
|
|
|
377
529
|
return;
|
|
378
530
|
}
|
|
379
531
|
|
|
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
532
|
throw new Error(
|
|
391
533
|
`Invalid docs config: unrecognized page item with keys [${Object.keys(item).join(', ')}]. ` +
|
|
392
534
|
'Supported items are strings, { page }, { href }, or { group, pages } blocks.',
|
|
@@ -401,30 +543,40 @@ function collectAnchors(anchors: AnchorItem[] | undefined): NavLinkNode[] {
|
|
|
401
543
|
return [];
|
|
402
544
|
}
|
|
403
545
|
|
|
404
|
-
return anchors
|
|
405
|
-
|
|
406
|
-
|
|
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 {
|
|
407
557
|
kind: 'link' as const,
|
|
408
|
-
label
|
|
409
|
-
href
|
|
558
|
+
label,
|
|
559
|
+
href,
|
|
410
560
|
icon: anchor.icon,
|
|
411
561
|
hidden: anchor.hidden || undefined,
|
|
412
|
-
target: normalizeLinkTarget(
|
|
413
|
-
}
|
|
562
|
+
target: normalizeLinkTarget(href, anchor.target),
|
|
563
|
+
};
|
|
564
|
+
});
|
|
414
565
|
}
|
|
415
566
|
|
|
416
567
|
/* ---------------------------------------------------------------------------
|
|
417
568
|
* Normalization
|
|
418
569
|
* ------------------------------------------------------------------------- */
|
|
419
570
|
|
|
420
|
-
|
|
421
|
-
|
|
571
|
+
function normalizeScope(
|
|
572
|
+
name: string | undefined,
|
|
573
|
+
source: ScopeSource,
|
|
574
|
+
anchors: AnchorItem[] | undefined,
|
|
422
575
|
resolveDocFile: DocFileResolver,
|
|
423
|
-
|
|
576
|
+
docsPrefix: string,
|
|
424
577
|
): NormalizedDocsConfig {
|
|
425
|
-
const
|
|
426
|
-
const
|
|
427
|
-
const showTabs = hasExplicitTopNavigation(docsConfig.navigation);
|
|
578
|
+
const tabs = getTabsFromLeafContainer(source.container, source.fallbackLabel);
|
|
579
|
+
const showTabs = hasExplicitTopNavigation(source.container);
|
|
428
580
|
const state: WalkState = { pages: [], order: { value: 0 } };
|
|
429
581
|
const treeByTab = new Map<string, PendingNode[]>();
|
|
430
582
|
const seenTabIds = new Set<string>();
|
|
@@ -574,6 +726,9 @@ export function normalizeDocsConfig(
|
|
|
574
726
|
...page,
|
|
575
727
|
url: pageToUrl(page.slug, docsPrefix),
|
|
576
728
|
filePath,
|
|
729
|
+
scopeId: source.id,
|
|
730
|
+
language: source.language,
|
|
731
|
+
version: source.version,
|
|
577
732
|
};
|
|
578
733
|
|
|
579
734
|
pages.push(normalized);
|
|
@@ -638,17 +793,166 @@ export function normalizeDocsConfig(
|
|
|
638
793
|
});
|
|
639
794
|
|
|
640
795
|
return {
|
|
641
|
-
name
|
|
796
|
+
name,
|
|
642
797
|
tabs: normalizedTabs,
|
|
643
798
|
showTabs,
|
|
644
799
|
navigation,
|
|
645
|
-
anchors: collectAnchors(
|
|
800
|
+
anchors: collectAnchors(anchors),
|
|
646
801
|
pages,
|
|
647
802
|
pageBySlug,
|
|
648
803
|
pageByLookupSlug,
|
|
649
804
|
};
|
|
650
805
|
}
|
|
651
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
|
+
|
|
652
956
|
/** Flattens a navigation tree to the routed pages it contains, in document order. */
|
|
653
957
|
export function flattenNav(nodes: NavNode[]): NormalizedDocsPage[] {
|
|
654
958
|
const pages: NormalizedDocsPage[] = [];
|
package/src/lib/head.ts
CHANGED
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
import { useEffect, useRef } from 'react';
|
|
2
2
|
import { getDocModule, getLastModified } from '@/lib/content';
|
|
3
|
+
import { resolveLocale } from '@/lib/locale';
|
|
3
4
|
import { SITE_URL, toAbsoluteUrl } from '@/lib/paths';
|
|
4
5
|
import {
|
|
6
|
+
getLocaleByPathname,
|
|
5
7
|
getPageByPathname,
|
|
6
8
|
getPageTitle,
|
|
9
|
+
getScopeByPathname,
|
|
7
10
|
getSeo,
|
|
8
11
|
showTimestamp,
|
|
9
12
|
siteConfig,
|
|
13
|
+
siteModel,
|
|
10
14
|
siteName,
|
|
11
15
|
} from '@/lib/site-config';
|
|
12
16
|
|
|
@@ -53,10 +57,10 @@ export function buildHead(pathname: string): HeadTag[] {
|
|
|
53
57
|
const title = getPageTitle(pageTitle);
|
|
54
58
|
const description = frontmatter?.description || siteConfig.description;
|
|
55
59
|
const canonical = page ? toAbsoluteUrl(page.url) : undefined;
|
|
56
|
-
// `seo.indexing: "all"` opts hidden pages
|
|
57
|
-
// per-page `noindex` frontmatter always wins.
|
|
58
|
-
const
|
|
59
|
-
|
|
60
|
+
// `seo.indexing: "all"` opts hidden pages (and hidden versions/languages)
|
|
61
|
+
// into the index; explicit per-page `noindex` frontmatter always wins.
|
|
62
|
+
const hidden = !!page && (!!page.hidden || !!getScopeByPathname(pathname).hidden);
|
|
63
|
+
const noindex = !page || frontmatter?.noindex === true || (hidden && seo.indexing !== 'all');
|
|
60
64
|
|
|
61
65
|
const tags: HeadTag[] = [{ tag: 'title', children: title }];
|
|
62
66
|
|
|
@@ -140,6 +144,7 @@ export function buildHead(pathname: string): HeadTag[] {
|
|
|
140
144
|
headline: pageTitle || page.label,
|
|
141
145
|
description,
|
|
142
146
|
url: canonical,
|
|
147
|
+
inLanguage: resolveLocale(page.language, siteModel.locale),
|
|
143
148
|
...(siteName ? { isPartOf: { '@type': 'WebSite', name: siteName, url: SITE_URL } } : {}),
|
|
144
149
|
},
|
|
145
150
|
{
|
|
@@ -216,5 +221,11 @@ export function useHead(pathname: string) {
|
|
|
216
221
|
}
|
|
217
222
|
|
|
218
223
|
applyHead(buildHead(pathname));
|
|
224
|
+
|
|
225
|
+
// Keep the document language and direction in sync when navigating
|
|
226
|
+
// between language scopes.
|
|
227
|
+
const { lang, dir } = getLocaleByPathname(pathname);
|
|
228
|
+
document.documentElement.lang = lang;
|
|
229
|
+
document.documentElement.dir = dir;
|
|
219
230
|
}, [pathname]);
|
|
220
231
|
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/** Primary language subtags written right-to-left. */
|
|
2
|
+
const RTL_LANGUAGES = new Set(['ar', 'ckb', 'dv', 'fa', 'he', 'iw', 'ps', 'sd', 'ug', 'ur', 'yi']);
|
|
3
|
+
|
|
4
|
+
/** BCP 47-shaped tags with a 2-3 letter primary subtag, e.g. "es" or "pt-BR". */
|
|
5
|
+
const LOCALE_PATTERN = /^[a-z]{2,3}(-[a-z0-9]{2,8})*$/i;
|
|
6
|
+
|
|
7
|
+
export function isValidLocale(value: string | undefined): value is string {
|
|
8
|
+
if (!value || !LOCALE_PATTERN.test(value)) {
|
|
9
|
+
return false;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
try {
|
|
13
|
+
return Intl.getCanonicalLocales(value).length > 0;
|
|
14
|
+
} catch {
|
|
15
|
+
return false;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Locale for a page: its scope's language code when valid, then the
|
|
21
|
+
* site-wide `$shiso.locale`, then en-US.
|
|
22
|
+
*/
|
|
23
|
+
export function resolveLocale(language: string | undefined, fallback: string | undefined): string {
|
|
24
|
+
if (isValidLocale(language)) {
|
|
25
|
+
return Intl.getCanonicalLocales(language)[0];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (isValidLocale(fallback)) {
|
|
29
|
+
return Intl.getCanonicalLocales(fallback)[0];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return 'en-US';
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Document direction for a locale, e.g. "ar" and "he" read right-to-left. */
|
|
36
|
+
export function getTextDirection(locale: string): 'ltr' | 'rtl' {
|
|
37
|
+
const primary = locale.split('-')[0]?.toLowerCase() || '';
|
|
38
|
+
return RTL_LANGUAGES.has(primary) ? 'rtl' : 'ltr';
|
|
39
|
+
}
|