@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.
@@ -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
- * 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.
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 getTabsFromContainer(container: NavContainer, fallbackLabel = ''): TabItem[] {
151
- if (Array.isArray(container.tabs) && container.tabs.length) {
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) && container.dropdowns.length) {
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
- if (
226
+ return (
206
227
  (Array.isArray(container.tabs) && container.tabs.length > 0) ||
207
228
  (Array.isArray(container.dropdowns) && container.dropdowns.length > 0)
208
- ) {
209
- return true;
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
- if (Array.isArray(container.versions) && container.versions.length) {
213
- return hasExplicitTopNavigation(pickDefaultItem(container.versions));
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 (Array.isArray(container.languages) && container.languages.length) {
217
- return hasExplicitTopNavigation(pickDefaultItem(container.languages));
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 false;
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
- .filter(anchor => isRecord(anchor) && typeof anchor.href === 'string' && anchor.href)
406
- .map(anchor => ({
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: anchor.anchor?.trim() || (anchor.href as string),
409
- href: anchor.href as string,
558
+ label,
559
+ href,
410
560
  icon: anchor.icon,
411
561
  hidden: anchor.hidden || undefined,
412
- target: normalizeLinkTarget(anchor.href as string, anchor.target),
413
- }));
562
+ target: normalizeLinkTarget(href, anchor.target),
563
+ };
564
+ });
414
565
  }
415
566
 
416
567
  /* ---------------------------------------------------------------------------
417
568
  * Normalization
418
569
  * ------------------------------------------------------------------------- */
419
570
 
420
- export function normalizeDocsConfig(
421
- docsConfig: DocsConfig,
571
+ function normalizeScope(
572
+ name: string | undefined,
573
+ source: ScopeSource,
574
+ anchors: AnchorItem[] | undefined,
422
575
  resolveDocFile: DocFileResolver,
423
- options: NormalizeOptions = {},
576
+ docsPrefix: string,
424
577
  ): NormalizedDocsConfig {
425
- const docsPrefix = options.docsPrefix ?? DOCS_PREFIX;
426
- const tabs = getTabsFromContainer(docsConfig.navigation);
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: docsConfig.name,
796
+ name,
642
797
  tabs: normalizedTabs,
643
798
  showTabs,
644
799
  navigation,
645
- anchors: collectAnchors(docsConfig.navigation.anchors),
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 into the index; explicit
57
- // per-page `noindex` frontmatter always wins.
58
- const noindex =
59
- !page || frontmatter?.noindex === true || (!!page.hidden && seo.indexing !== 'all');
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
+ }