@umami/shiso 0.61.0 → 1.1.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 CHANGED
@@ -3,6 +3,33 @@
3
3
  All notable changes to `@umami/shiso` are documented here. This project follows
4
4
  [Semantic Versioning](https://semver.org/).
5
5
 
6
+ ## 1.0.0 - 2026-08-15
7
+
8
+ ### Added
9
+
10
+ - Full multi-version and multi-language navigation, including versions nested
11
+ inside languages and responsive header selectors.
12
+ - Per-scope prerendering, Markdown export, sitemap metadata, document locale,
13
+ text direction, and local search results.
14
+ - A multi-scope integration fixture covering visible and hidden pages,
15
+ versions, languages, redirects, search, and sitemap output.
16
+ - Public exports for the bundled `docs.json` schema.
17
+
18
+ ### Changed
19
+
20
+ - Navigation containers now require exactly one non-empty navigation mode, and
21
+ default to the first visible version or language when none is marked.
22
+ - Hidden versions and languages remain buildable and directly reachable while
23
+ staying out of selectors and search.
24
+ - Redirect sources are exact paths; wildcard and parameter patterns are
25
+ rejected during configuration validation.
26
+ - Supported Node.js versions now match Vite: `^20.19.0 || >=22.12.0`.
27
+
28
+ ### Fixed
29
+
30
+ - Client-side navigation now updates page metadata, document language, and
31
+ left-to-right or right-to-left text direction.
32
+
6
33
  ## 0.61.0 - 2026-08-13
7
34
 
8
35
  ### Added
package/bin/shiso.mjs CHANGED
File without changes
package/docs.schema.json CHANGED
@@ -104,6 +104,33 @@
104
104
  "navigation": {
105
105
  "type": "object",
106
106
  "description": "Site navigation. Define exactly one of: tabs, dropdowns, versions, languages, or top-level groups/pages.",
107
+ "anyOf": [
108
+ { "required": ["$ref"] },
109
+ { "required": ["tabs"] },
110
+ { "required": ["dropdowns"] },
111
+ { "required": ["versions"] },
112
+ { "required": ["languages"] },
113
+ { "required": ["groups"] },
114
+ { "required": ["pages"] }
115
+ ],
116
+ "not": {
117
+ "anyOf": [
118
+ { "required": ["tabs", "dropdowns"] },
119
+ { "required": ["tabs", "versions"] },
120
+ { "required": ["tabs", "languages"] },
121
+ { "required": ["tabs", "groups"] },
122
+ { "required": ["tabs", "pages"] },
123
+ { "required": ["dropdowns", "versions"] },
124
+ { "required": ["dropdowns", "languages"] },
125
+ { "required": ["dropdowns", "groups"] },
126
+ { "required": ["dropdowns", "pages"] },
127
+ { "required": ["versions", "languages"] },
128
+ { "required": ["versions", "groups"] },
129
+ { "required": ["versions", "pages"] },
130
+ { "required": ["languages", "groups"] },
131
+ { "required": ["languages", "pages"] }
132
+ ]
133
+ },
107
134
  "additionalProperties": false,
108
135
  "properties": {
109
136
  "$ref": { "$ref": "#/definitions/configRef" },
@@ -599,31 +626,31 @@
599
626
  },
600
627
  "redirectSourceValue": {
601
628
  "anyOf": [
602
- { "type": "string", "pattern": "^/[^*]*$" },
629
+ { "type": "string", "pattern": "^/[^*:]*$" },
603
630
  { "$ref": "#/definitions/configRefValue" }
604
631
  ]
605
632
  },
606
633
  "pageItemList": {
607
634
  "anyOf": [
608
- { "type": "array", "items": { "$ref": "#/definitions/pageItem" } },
635
+ { "type": "array", "minItems": 1, "items": { "$ref": "#/definitions/pageItem" } },
609
636
  { "$ref": "#/definitions/configRefValue" }
610
637
  ]
611
638
  },
612
639
  "groupList": {
613
640
  "anyOf": [
614
- { "type": "array", "items": { "$ref": "#/definitions/group" } },
641
+ { "type": "array", "minItems": 1, "items": { "$ref": "#/definitions/group" } },
615
642
  { "$ref": "#/definitions/configRefValue" }
616
643
  ]
617
644
  },
618
645
  "dropdownList": {
619
646
  "anyOf": [
620
- { "type": "array", "items": { "$ref": "#/definitions/dropdown" } },
647
+ { "type": "array", "minItems": 1, "items": { "$ref": "#/definitions/dropdown" } },
621
648
  { "$ref": "#/definitions/configRefValue" }
622
649
  ]
623
650
  },
624
651
  "tabList": {
625
652
  "anyOf": [
626
- { "type": "array", "items": { "$ref": "#/definitions/tab" } },
653
+ { "type": "array", "minItems": 1, "items": { "$ref": "#/definitions/tab" } },
627
654
  { "$ref": "#/definitions/configRefValue" }
628
655
  ]
629
656
  },
@@ -635,13 +662,13 @@
635
662
  },
636
663
  "versionList": {
637
664
  "anyOf": [
638
- { "type": "array", "items": { "$ref": "#/definitions/version" } },
665
+ { "type": "array", "minItems": 1, "items": { "$ref": "#/definitions/version" } },
639
666
  { "$ref": "#/definitions/configRefValue" }
640
667
  ]
641
668
  },
642
669
  "languageList": {
643
670
  "anyOf": [
644
- { "type": "array", "items": { "$ref": "#/definitions/language" } },
671
+ { "type": "array", "minItems": 1, "items": { "$ref": "#/definitions/language" } },
645
672
  { "$ref": "#/definitions/configRefValue" }
646
673
  ]
647
674
  },
@@ -723,7 +750,7 @@
723
750
  },
724
751
  "anchor": {
725
752
  "type": "object",
726
- "anyOf": [{ "required": ["$ref"] }, { "required": ["anchor"] }],
753
+ "anyOf": [{ "required": ["$ref"] }, { "required": ["anchor", "href"] }],
727
754
  "additionalProperties": false,
728
755
  "properties": {
729
756
  "$ref": { "$ref": "#/definitions/configRef" },
@@ -763,7 +790,10 @@
763
790
  },
764
791
  "dropdown": {
765
792
  "type": "object",
766
- "anyOf": [{ "required": ["$ref"] }, { "required": ["dropdown"] }],
793
+ "allOf": [
794
+ { "anyOf": [{ "required": ["$ref"] }, { "required": ["dropdown"] }] },
795
+ { "anyOf": [{ "required": ["$ref"] }, { "required": ["groups"] }, { "required": ["pages"] }] }
796
+ ],
767
797
  "additionalProperties": false,
768
798
  "properties": {
769
799
  "$ref": { "$ref": "#/definitions/configRef" },
@@ -779,7 +809,10 @@
779
809
  },
780
810
  "tab": {
781
811
  "type": "object",
782
- "anyOf": [{ "required": ["$ref"] }, { "required": ["tab"] }],
812
+ "allOf": [
813
+ { "anyOf": [{ "required": ["$ref"] }, { "required": ["tab"] }] },
814
+ { "anyOf": [{ "required": ["$ref"] }, { "required": ["groups"] }, { "required": ["pages"] }, { "required": ["dropdowns"] }] }
815
+ ],
783
816
  "additionalProperties": false,
784
817
  "properties": {
785
818
  "$ref": { "$ref": "#/definitions/configRef" },
@@ -793,16 +826,28 @@
793
826
  },
794
827
  "version": {
795
828
  "type": "object",
796
- "anyOf": [{ "required": ["$ref"] }, { "required": ["version"] }],
829
+ "allOf": [
830
+ { "anyOf": [{ "required": ["$ref"] }, { "required": ["version"] }] },
831
+ { "anyOf": [{ "required": ["$ref"] }, { "required": ["tabs"] }, { "required": ["dropdowns"] }, { "required": ["groups"] }, { "required": ["pages"] }] }
832
+ ],
833
+ "not": {
834
+ "anyOf": [
835
+ { "required": ["tabs", "dropdowns"] },
836
+ { "required": ["tabs", "groups"] },
837
+ { "required": ["tabs", "pages"] },
838
+ { "required": ["dropdowns", "groups"] },
839
+ { "required": ["dropdowns", "pages"] }
840
+ ]
841
+ },
797
842
  "additionalProperties": false,
798
843
  "properties": {
799
844
  "$ref": { "$ref": "#/definitions/configRef" },
800
845
  "version": {
801
- "allOf": [{ "$ref": "#/definitions/stringValue" }],
802
- "description": "Version label. Only the default (or first) version is rendered."
846
+ "allOf": [{ "$ref": "#/definitions/nonEmptyStringValue" }],
847
+ "description": "Version label, shown in the version selector. Every version builds; page references determine its URLs."
803
848
  },
804
- "default": { "allOf": [{ "$ref": "#/definitions/booleanValue" }], "description": "Marks this version as the one to render." },
805
- "hidden": { "allOf": [{ "$ref": "#/definitions/booleanValue" }], "description": "Hide this version." },
849
+ "default": { "allOf": [{ "$ref": "#/definitions/booleanValue" }], "description": "Marks this version as the default. At most one version may be the default; without one, the first visible version is used." },
850
+ "hidden": { "allOf": [{ "$ref": "#/definitions/booleanValue" }], "description": "Hide this version from the version selector, sitemap, and search. Its pages still build and stay reachable by URL." },
806
851
  "tabs": { "$ref": "#/definitions/tabList" },
807
852
  "dropdowns": { "$ref": "#/definitions/dropdownList" },
808
853
  "groups": { "$ref": "#/definitions/groupList" },
@@ -811,19 +856,35 @@
811
856
  },
812
857
  "language": {
813
858
  "type": "object",
814
- "anyOf": [{ "required": ["$ref"] }, { "required": ["language"] }],
859
+ "allOf": [
860
+ { "anyOf": [{ "required": ["$ref"] }, { "required": ["language"] }] },
861
+ { "anyOf": [{ "required": ["$ref"] }, { "required": ["versions"] }, { "required": ["tabs"] }, { "required": ["dropdowns"] }, { "required": ["groups"] }, { "required": ["pages"] }] }
862
+ ],
863
+ "not": {
864
+ "anyOf": [
865
+ { "required": ["tabs", "dropdowns"] },
866
+ { "required": ["tabs", "versions"] },
867
+ { "required": ["tabs", "groups"] },
868
+ { "required": ["tabs", "pages"] },
869
+ { "required": ["dropdowns", "versions"] },
870
+ { "required": ["dropdowns", "groups"] },
871
+ { "required": ["dropdowns", "pages"] },
872
+ { "required": ["versions", "groups"] },
873
+ { "required": ["versions", "pages"] }
874
+ ]
875
+ },
815
876
  "additionalProperties": false,
816
877
  "properties": {
817
878
  "$ref": { "$ref": "#/definitions/configRef" },
818
879
  "language": {
819
- "allOf": [{ "$ref": "#/definitions/stringValue" }],
820
- "description": "Language code or label. Only the default (or first) language is rendered."
880
+ "allOf": [{ "$ref": "#/definitions/nonEmptyStringValue" }],
881
+ "description": "Language code (e.g. \"es\"), shown in the language selector and used as the page locale when valid. Every language builds; page references determine its URLs."
821
882
  },
822
883
  "default": {
823
884
  "allOf": [{ "$ref": "#/definitions/booleanValue" }],
824
- "description": "Marks this language as the one to render."
885
+ "description": "Marks this language as the default. At most one language may be the default; without one, the first visible language is used."
825
886
  },
826
- "hidden": { "allOf": [{ "$ref": "#/definitions/booleanValue" }], "description": "Hide this language." },
887
+ "hidden": { "allOf": [{ "$ref": "#/definitions/booleanValue" }], "description": "Hide this language from the language selector, sitemap, and search. Its pages still build and stay reachable by URL." },
827
888
  "versions": { "$ref": "#/definitions/versionList" },
828
889
  "tabs": { "$ref": "#/definitions/tabList" },
829
890
  "dropdowns": { "$ref": "#/definitions/dropdownList" },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@umami/shiso",
3
- "version": "0.61.0",
3
+ "version": "1.1.0",
4
4
  "description": "Open-source documentation framework for Markdown and MDX sites.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -16,6 +16,7 @@
16
16
  "default": "./src/lib/search/provider.ts"
17
17
  },
18
18
  "./schema": "./docs.schema.json",
19
+ "./docs.schema.json": "./docs.schema.json",
19
20
  "./package.json": "./package.json"
20
21
  },
21
22
  "files": [
@@ -34,7 +35,7 @@
34
35
  "test:package": "node scripts/check-package.mjs"
35
36
  },
36
37
  "engines": {
37
- "node": ">=20"
38
+ "node": "^20.19.0 || >=22.12.0"
38
39
  },
39
40
  "repository": {
40
41
  "type": "git",
@@ -6,6 +6,8 @@ const root = path.resolve(import.meta.dirname, '..');
6
6
  for (const relativeFile of [
7
7
  'bin/shiso.mjs',
8
8
  'docs.schema.json',
9
+ 'scripts/lib/mdast.mjs',
10
+ 'scripts/lib/slug.mjs',
9
11
  'src/entry-client.tsx',
10
12
  'src/entry-server.tsx',
11
13
  'types/client.d.ts',
@@ -15,4 +17,38 @@ for (const relativeFile of [
15
17
  await fs.access(path.join(root, relativeFile));
16
18
  }
17
19
 
20
+ // Every public export subpath must resolve to real files, including its types.
21
+ const metadata = JSON.parse(await fs.readFile(path.join(root, 'package.json'), 'utf8'));
22
+
23
+ for (const [subpath, target] of Object.entries(metadata.exports)) {
24
+ const files = typeof target === 'string' ? [target] : Object.values(target);
25
+
26
+ for (const file of files) {
27
+ await fs.access(path.join(root, file)).catch(() => {
28
+ throw new Error(`Export "${subpath}" points at missing file "${file}".`);
29
+ });
30
+ }
31
+ }
32
+
33
+ // Node scripts must not import raw TypeScript: that would depend on Node's
34
+ // experimental type stripping, which the supported engine range does not have.
35
+ for (const directory of ['bin', 'scripts', 'scripts/lib']) {
36
+ const entries = await fs.readdir(path.join(root, directory), { withFileTypes: true });
37
+
38
+ for (const entry of entries) {
39
+ if (!entry.isFile() || !/\.(mjs|js|cjs)$/.test(entry.name)) {
40
+ continue;
41
+ }
42
+
43
+ const source = await fs.readFile(path.join(root, directory, entry.name), 'utf8');
44
+ const rawTsImport = source.match(/^import\s[^\n]*from\s+'[^']+\.tsx?';?$/m);
45
+
46
+ if (rawTsImport) {
47
+ throw new Error(
48
+ `${directory}/${entry.name} imports raw TypeScript: ${rawTsImport[0].trim()}`,
49
+ );
50
+ }
51
+ }
52
+ }
53
+
18
54
  console.log('shiso package contents are ready.');
@@ -21,8 +21,8 @@ import remarkGfm from 'remark-gfm';
21
21
  import remarkMdx from 'remark-mdx';
22
22
  import remarkParse from 'remark-parse';
23
23
  import { unified } from 'unified';
24
- import { headingText } from '../src/lib/mdast.ts';
25
- import { createSlugger } from '../src/lib/slug.ts';
24
+ import { headingText } from './lib/mdast.mjs';
25
+ import { createSlugger, slugifyId } from './lib/slug.mjs';
26
26
  import { loadDocsConfig } from './load-docs-config.mjs';
27
27
 
28
28
  const DEFAULT_ROOT = process.cwd();
@@ -49,6 +49,53 @@ function normalizePageReference(pageRef) {
49
49
  };
50
50
  }
51
51
 
52
+ /**
53
+ * One entry per navigation scope, mirroring collectScopeSources in
54
+ * src/lib/docs-config.ts: ordinary navigation, versions, languages, and
55
+ * versions nested inside languages. Hidden scopes are excluded from search,
56
+ * matching how hidden pages are excluded.
57
+ */
58
+ function collectScopes(navigation) {
59
+ if (Array.isArray(navigation.versions)) {
60
+ return navigation.versions
61
+ .filter(version => !version.hidden)
62
+ .map(version => ({
63
+ id: slugifyId(version.version?.trim() || '', 'scope'),
64
+ version: version.version?.trim(),
65
+ container: version,
66
+ }));
67
+ }
68
+
69
+ if (Array.isArray(navigation.languages)) {
70
+ return navigation.languages
71
+ .filter(language => !language.hidden)
72
+ .flatMap(language => {
73
+ const languageLabel = language.language?.trim();
74
+
75
+ if (Array.isArray(language.versions)) {
76
+ return language.versions
77
+ .filter(version => !version.hidden)
78
+ .map(version => ({
79
+ id: slugifyId(`${languageLabel}-${version.version?.trim()}`, 'scope'),
80
+ language: languageLabel,
81
+ version: version.version?.trim(),
82
+ container: version,
83
+ }));
84
+ }
85
+
86
+ return [
87
+ {
88
+ id: slugifyId(languageLabel || '', 'scope'),
89
+ language: languageLabel,
90
+ container: language,
91
+ },
92
+ ];
93
+ });
94
+ }
95
+
96
+ return [{ id: 'default', container: navigation }];
97
+ }
98
+
52
99
  /**
53
100
  * Collects `{ fileSlug, slug }` for every non-hidden page in the navigation
54
101
  * tree. A simplified mirror of the config walker: search only needs page refs
@@ -62,15 +109,6 @@ function collectVisiblePages(container, pages = [], hidden = false) {
62
109
  ...(container.dropdowns || []),
63
110
  ];
64
111
 
65
- // Versions and languages collapse to their default entry, like the app does.
66
- for (const key of ['versions', 'languages']) {
67
- const entries = container[key];
68
-
69
- if (Array.isArray(entries) && entries.length) {
70
- items.push(entries.find(entry => entry.default) || entries[0]);
71
- }
72
- }
73
-
74
112
  for (const item of items) {
75
113
  if (typeof item === 'string') {
76
114
  if (!hidden) {
@@ -172,36 +210,44 @@ export async function generateSearchIndex({
172
210
  const seen = new Set();
173
211
  const records = [];
174
212
 
175
- for (const { fileSlug, slug } of collectVisiblePages(docsJson.navigation || {})) {
176
- if (seen.has(fileSlug)) {
177
- continue;
178
- }
213
+ for (const scope of collectScopes(docsJson.navigation || {})) {
214
+ // Single-scope sites omit scope fields so their index stays unchanged.
215
+ const scopeFields =
216
+ scope.id === 'default'
217
+ ? {}
218
+ : { scopeId: scope.id, language: scope.language, version: scope.version };
219
+
220
+ for (const { fileSlug, slug } of collectVisiblePages(scope.container)) {
221
+ if (seen.has(fileSlug)) {
222
+ continue;
223
+ }
179
224
 
180
- seen.add(fileSlug);
225
+ seen.add(fileSlug);
181
226
 
182
- let source;
183
- let filePath;
227
+ let source;
228
+ let filePath;
184
229
 
185
- for (const extension of ['mdx', 'md']) {
186
- filePath = path.join(root, contentDir, `${fileSlug}.${extension}`);
187
- source = await fs.readFile(filePath, 'utf8').catch(() => undefined);
230
+ for (const extension of ['mdx', 'md']) {
231
+ filePath = path.join(root, contentDir, `${fileSlug}.${extension}`);
232
+ source = await fs.readFile(filePath, 'utf8').catch(() => undefined);
188
233
 
189
- if (source !== undefined) {
190
- break;
234
+ if (source !== undefined) {
235
+ break;
236
+ }
191
237
  }
192
- }
193
238
 
194
- if (source === undefined) {
195
- // Missing files fail config normalization; search just skips them.
196
- continue;
197
- }
239
+ if (source === undefined) {
240
+ // Missing files fail config normalization; search just skips them.
241
+ continue;
242
+ }
198
243
 
199
- const tree = parser.parse(source);
200
- const url = slug === 'index' ? docsPrefix || '/' : `${docsPrefix}/${slug}`;
201
- const page = frontmatterTitle(tree) || fileSlug;
244
+ const tree = parser.parse(source);
245
+ const url = slug === 'index' ? docsPrefix || '/' : `${docsPrefix}/${slug}`;
246
+ const page = frontmatterTitle(tree) || fileSlug;
202
247
 
203
- for (const { heading, id, text } of collectSections(tree)) {
204
- records.push({ url, page, heading, id, text });
248
+ for (const { heading, id, text } of collectSections(tree)) {
249
+ records.push({ url, page, heading, id, text, ...scopeFields });
250
+ }
205
251
  }
206
252
  }
207
253
 
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Mirrors the mdast helpers in src/lib/mdast.ts for Node scripts, which must
3
+ * not import raw TypeScript (that would depend on Node's experimental type
4
+ * stripping). tests/script-mirrors.test.ts asserts the two stay in sync.
5
+ */
6
+
7
+ /** Concatenates the text content of a node, including JSX text children. */
8
+ export function toText(node) {
9
+ if (!node) {
10
+ return '';
11
+ }
12
+
13
+ if (typeof node.value === 'string') {
14
+ return node.value;
15
+ }
16
+
17
+ return (node.children || []).map(toText).join('');
18
+ }
19
+
20
+ /** Text of a heading node, trimmed. */
21
+ export function headingText(node) {
22
+ return toText(node).trim();
23
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Mirrors src/lib/slug.ts for Node scripts, which must not import raw
3
+ * TypeScript (that would depend on Node's experimental type stripping).
4
+ * tests/script-mirrors.test.ts asserts the two stay in sync.
5
+ */
6
+ import GithubSlugger, { slug as slugifyOnce } from 'github-slugger';
7
+
8
+ /** Stateful slugger matching rehype-slug: repeated headings get -1, -2, ... */
9
+ export function createSlugger() {
10
+ return new GithubSlugger();
11
+ }
12
+
13
+ /** Stateless slugify for one-off ids that do not need de-duplication. */
14
+ export function slugify(value) {
15
+ return slugifyOnce(value.trim());
16
+ }
17
+
18
+ /** Slugifies a label into a config-level identifier, with a fallback. */
19
+ export function slugifyId(value, fallback) {
20
+ return slugify(value) || fallback;
21
+ }
@@ -16,7 +16,6 @@ import { mkdir, readFile, writeFile } from 'node:fs/promises';
16
16
  import path from 'node:path';
17
17
  import process from 'node:process';
18
18
  import { pathToFileURL } from 'node:url';
19
- import { loadDocsConfig } from './load-docs-config.mjs';
20
19
 
21
20
  const DEFAULT_HEAD_OPEN = '<!--shiso-default-head-->';
22
21
  const DEFAULT_HEAD_CLOSE = '<!--/shiso-default-head-->';
@@ -32,12 +31,8 @@ if (!template.includes('<!--app-html-->')) {
32
31
  );
33
32
  }
34
33
 
35
- const { config: docsJson } = await loadDocsConfig({ root });
36
- const docsPrefix = (docsJson.$shiso?.docsPrefix ?? '/docs').replace(/\/+$/, '');
37
-
38
- const { render, getRoutes, getRedirects, getSitemapEntries, getMarkdownPages } = await import(
39
- pathToFileURL(path.join(root, 'dist', 'server', 'entry-server.js')).href
40
- );
34
+ const { render, getRoutes, getRedirects, getSitemapEntries, getMarkdownPages, docsHomeUrl } =
35
+ await import(pathToFileURL(path.join(root, 'dist', 'server', 'entry-server.js')).href);
41
36
 
42
37
  /** Vite's `base`, normalized to "" or "/prefix". */
43
38
  function readBase() {
@@ -52,12 +47,21 @@ function withBase(routePath) {
52
47
  return `${base}${routePath}`.replace(/\/{2,}/g, '/') || '/';
53
48
  }
54
49
 
55
- function fillTemplate(head, html) {
50
+ function fillTemplate(head, html, htmlAttrs) {
56
51
  // Per-page head tags supersede the site-level defaults injected at build time.
57
- const output = head
52
+ let output = head
58
53
  ? template.replace(new RegExp(`${DEFAULT_HEAD_OPEN}[\\s\\S]*?${DEFAULT_HEAD_CLOSE}`), '')
59
54
  : template;
60
55
 
56
+ // Per-page document language and direction, replacing the template's own.
57
+ if (htmlAttrs) {
58
+ output = output.replace(/<html([^>]*)>/, (_match, attrs) => {
59
+ const kept = attrs.replace(/\s+lang="[^"]*"/, '').replace(/\s+dir="[^"]*"/, '');
60
+ const dir = htmlAttrs.dir === 'rtl' ? ' dir="rtl"' : '';
61
+ return `<html${kept} lang="${htmlAttrs.lang}"${dir}>`;
62
+ });
63
+ }
64
+
61
65
  return output.replace('<!--app-head-->', head).replace('<!--app-html-->', html);
62
66
  }
63
67
 
@@ -75,15 +79,15 @@ function outputPathFor(routePath) {
75
79
  const routes = getRoutes();
76
80
 
77
81
  for (const route of routes) {
78
- const { html, head } = render(route);
79
- await writePage(outputPathFor(route), fillTemplate(head, html));
82
+ const { html, head, htmlAttrs } = render(route);
83
+ await writePage(outputPathFor(route), fillTemplate(head, html, htmlAttrs));
80
84
  }
81
85
 
82
- // Root entry. When docs live at a prefix the root is a redirect; a meta refresh
83
- // alone is slow and SEO-hostile, so pair it with a canonical link and an
84
- // immediate history-replacing navigation.
85
- if (docsPrefix) {
86
- const target = withBase(`${docsPrefix}/`);
86
+ // Root entry. When the default scope's landing page is not the root itself the
87
+ // root is a redirect; a meta refresh alone is slow and SEO-hostile, so pair it
88
+ // with a canonical link and an immediate history-replacing navigation.
89
+ if (docsHomeUrl && docsHomeUrl !== '/') {
90
+ const target = withBase(`${docsHomeUrl}/`);
87
91
 
88
92
  await writePage(
89
93
  path.join(clientDir, 'index.html'),
@@ -166,7 +170,10 @@ if (sitemapEntries.length) {
166
170
  // 404 fallback renders the app shell so client routing can take over
167
171
  // on hosts that serve 404.html for unknown paths (e.g. GitHub Pages).
168
172
  const notFound = render('/404');
169
- await writePage(path.join(clientDir, '404.html'), fillTemplate(notFound.head, notFound.html));
173
+ await writePage(
174
+ path.join(clientDir, '404.html'),
175
+ fillTemplate(notFound.head, notFound.html, notFound.htmlAttrs),
176
+ );
170
177
 
171
178
  // Guard against a base/route mismatch silently producing unreachable files.
172
179
  const stray = routes.filter(route => !withBase(route).startsWith(base || '/'));
package/src/App.tsx CHANGED
@@ -11,8 +11,7 @@ import { CodeBlock } from '@/components/CodeBlock';
11
11
  import * as docsComponents from '@/components/docs/index';
12
12
  import { Layout } from '@/components/Layout';
13
13
  import { TooltipProvider } from '@/components/ui/tooltip';
14
- import { DOCS_PREFIX } from '@/lib/paths';
15
- import { siteModel } from '@/lib/site-config';
14
+ import { docsHomeUrl, siteModel } from '@/lib/site-config';
16
15
  import { DocPage } from '@/pages/DocPage';
17
16
 
18
17
  const mdxComponents = {
@@ -26,9 +25,9 @@ export function App() {
26
25
  <MDXProvider components={mdxComponents}>
27
26
  <Layout site={siteModel}>
28
27
  <Routes>
29
- {/* When docs are mounted at the site root there is nothing to redirect. */}
30
- {DOCS_PREFIX ? (
31
- <Route path="/" element={<Navigate to={DOCS_PREFIX} replace />} />
28
+ {/* When the default scope's landing page is the root there is nothing to redirect. */}
29
+ {docsHomeUrl !== '/' ? (
30
+ <Route path="/" element={<Navigate to={docsHomeUrl} replace />} />
32
31
  ) : null}
33
32
  <Route path="*" element={<DocPage site={siteModel} />} />
34
33
  </Routes>
@@ -2,6 +2,9 @@ import { Link } from 'react-router';
2
2
  import { ContextualMenu } from '@/components/ContextualMenu';
3
3
  import { ChevronRight } from '@/components/icons';
4
4
  import { getLastModified } from '@/lib/content';
5
+ import { getScopeForPage } from '@/lib/docs-config';
6
+ import { resolveLocale } from '@/lib/locale';
7
+ import { docsSite } from '@/lib/site-config';
5
8
  import { resolveContextualOptions } from '@/lib/site-model';
6
9
  import type { DocModule, NormalizedDocsPage, SiteModel } from '@/lib/types';
7
10
 
@@ -12,7 +15,8 @@ export interface DocContentProps {
12
15
  }
13
16
 
14
17
  export function DocContent({ page, doc, site }: DocContentProps) {
15
- const pagerPages = site.docs.pages.filter(item => !item.hidden);
18
+ // Prev/next paging never crosses a version or language boundary.
19
+ const pagerPages = getScopeForPage(docsSite, page).docs.pages.filter(item => !item.hidden);
16
20
  const pageIndex = pagerPages.findIndex(item => item.slug === page.slug);
17
21
  const prev = pageIndex > 0 ? pagerPages[pageIndex - 1] : undefined;
18
22
  const next = pageIndex >= 0 ? pagerPages[pageIndex + 1] : undefined;
@@ -30,7 +34,8 @@ export function DocContent({ page, doc, site }: DocContentProps) {
30
34
  ? [...new Set([page.tabLabel, page.section])].filter(Boolean).join(' / ')
31
35
  : page.section;
32
36
  const contextualOptions = resolveContextualOptions(site.contextualOptions, page, site.labels);
33
- const dateFormat = new Intl.DateTimeFormat(site.locale, {
37
+ // Dates follow the page's language when it is a valid locale code.
38
+ const dateFormat = new Intl.DateTimeFormat(resolveLocale(page.language, site.locale), {
34
39
  dateStyle: 'medium',
35
40
  timeZone: 'UTC',
36
41
  });