blume 1.4.3 → 1.5.1

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.
Files changed (204) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/README.md +16 -12
  3. package/dist/cli/index.js +1784 -633
  4. package/dist/cli/index.js.map +111 -106
  5. package/dist/types/ai/component-markdown.d.ts +14 -4
  6. package/dist/types/core/config-input.d.ts +80 -28
  7. package/dist/types/core/config.d.ts +2 -1
  8. package/dist/types/core/data.d.ts +19 -3
  9. package/dist/types/core/diagnostics.d.ts +5 -1
  10. package/dist/types/core/i18n-ui.d.ts +12 -0
  11. package/dist/types/core/schema.d.ts +112 -15
  12. package/dist/types/core/sources/types.d.ts +3 -1
  13. package/dist/types/core/standard-schema.d.ts +7 -3
  14. package/dist/types/core/types.d.ts +43 -2
  15. package/dist/types/core/ui-packs/index.d.ts +9 -1
  16. package/dist/types/openapi/references.d.ts +6 -5
  17. package/dist/types/seo/x-handle.d.ts +3 -2
  18. package/dist/types/theme/fonts.d.ts +11 -2
  19. package/docs/advanced/api-reference.mdx +8 -6
  20. package/docs/advanced/custom-pages.mdx +5 -1
  21. package/docs/configuration/index.mdx +1 -1
  22. package/docs/configuration/search.mdx +2 -0
  23. package/docs/configuration/seo.mdx +1 -1
  24. package/docs/configuration/theming.mdx +4 -2
  25. package/docs/content/i18n.mdx +1 -1
  26. package/docs/content/meta.mdx +2 -1
  27. package/docs/content/meta.ts +1 -0
  28. package/docs/content/navigation.mdx +35 -1
  29. package/docs/content/versioning.mdx +106 -0
  30. package/docs/reference/cli.mdx +2 -1
  31. package/docs/reference/frontmatter.mdx +3 -0
  32. package/package.json +3 -1
  33. package/skills/blume-migrate/SKILL.md +2 -2
  34. package/skills/blume-migrate/references/docusaurus.md +1 -1
  35. package/skills/blume-migrate/references/fumadocs.md +1 -1
  36. package/skills/blume-migrate/references/mintlify.md +1 -1
  37. package/src/ai/agent-readability.ts +37 -10
  38. package/src/ai/ask-context.ts +5 -1
  39. package/src/ai/ask.ts +10 -1
  40. package/src/ai/component-markdown.ts +80 -43
  41. package/src/ai/llms.ts +40 -16
  42. package/src/ai/mcp/data.ts +48 -12
  43. package/src/ai/mcp/discovery.ts +28 -11
  44. package/src/ai/mcp/server.ts +183 -38
  45. package/src/ai/mcp/tools.ts +3 -3
  46. package/src/ai/skills.ts +32 -9
  47. package/src/ai/visibility.ts +2 -2
  48. package/src/astro/component-slots.ts +2 -0
  49. package/src/astro/examples.ts +6 -2
  50. package/src/astro/generate.ts +64 -34
  51. package/src/astro/integration.ts +13 -2
  52. package/src/astro/islands.ts +16 -9
  53. package/src/astro/templates.ts +181 -40
  54. package/src/audit/agent.ts +2 -2
  55. package/src/audit/checks/content.ts +26 -11
  56. package/src/audit/checks/dns-aid.ts +3 -0
  57. package/src/audit/checks/indexability.ts +24 -6
  58. package/src/audit/checks/llms.ts +9 -4
  59. package/src/audit/checks/network.ts +2 -0
  60. package/src/audit/checks/social.ts +18 -10
  61. package/src/audit/crawl.ts +37 -9
  62. package/src/audit/report.ts +20 -19
  63. package/src/audit/run.ts +5 -2
  64. package/src/audit/snapshot.ts +2 -4
  65. package/src/audit/types.ts +25 -3
  66. package/src/blume-modules.d.ts +5 -1
  67. package/src/cli/commands/audit.ts +9 -4
  68. package/src/cli/commands/build.ts +15 -9
  69. package/src/cli/commands/dev.ts +2 -0
  70. package/src/cli/commands/doctor.ts +2 -0
  71. package/src/cli/commands/eval.ts +7 -3
  72. package/src/cli/commands/init.ts +9 -9
  73. package/src/cli/commands/mcp-stdio.ts +3 -0
  74. package/src/cli/commands/translate.ts +14 -3
  75. package/src/cli/commands/version.ts +85 -0
  76. package/src/cli/dev-lock.ts +31 -10
  77. package/src/cli/eject-scripts.ts +17 -2
  78. package/src/cli/index.ts +2 -0
  79. package/src/cli/init/questions.ts +1 -1
  80. package/src/cli/init/scaffold.ts +22 -15
  81. package/src/cli/internal-error.ts +1 -0
  82. package/src/components/content/auto-type-table.ts +3 -0
  83. package/src/components/content/diff.ts +9 -5
  84. package/src/components/content/github-info.ts +2 -0
  85. package/src/components/islands/ask-ai.tsx +33 -25
  86. package/src/components/islands/hooks.ts +5 -1
  87. package/src/components/islands/webmcp.ts +49 -12
  88. package/src/components/layout/Fonts.astro +23 -3
  89. package/src/components/layout/Header.astro +25 -1
  90. package/src/components/layout/NavSelector.astro +11 -2
  91. package/src/components/layout/NavTree.astro +4 -2
  92. package/src/components/layout/PageLayout.astro +72 -3
  93. package/src/components/layout/ReferenceLayout.astro +2 -1
  94. package/src/components/layout/RootLayout.astro +20 -1
  95. package/src/components/layout/Search.astro +77 -13
  96. package/src/components/layout/VersionBanner.astro +39 -0
  97. package/src/components/layout/analytics-client.ts +8 -5
  98. package/src/components/layout/hydration-hint.ts +1 -1
  99. package/src/components/layout/nav-utils.ts +1 -4
  100. package/src/components/layout/overrides.ts +25 -12
  101. package/src/components/layout/search/algolia.ts +18 -5
  102. package/src/components/layout/search/endpoint.ts +3 -0
  103. package/src/components/layout/search/flexsearch.ts +23 -7
  104. package/src/components/layout/search/orama-cloud.ts +1 -1
  105. package/src/components/layout/search/orama.ts +4 -1
  106. package/src/components/layout/search/pagefind.ts +2 -0
  107. package/src/components/layout/search/types.ts +13 -1
  108. package/src/components/layout/search/typesense.ts +19 -3
  109. package/src/components/openapi/ApiOverview.astro +32 -6
  110. package/src/components/openapi/AsyncApiOperation.astro +237 -0
  111. package/src/components/openapi/Bindings.astro +89 -0
  112. package/src/components/openapi/MethodBadge.astro +3 -0
  113. package/src/components/openapi/Operation.astro +7 -2
  114. package/src/components/openapi/PanelTabs.astro +131 -0
  115. package/src/components/openapi/ParametersTable.astro +2 -0
  116. package/src/components/openapi/RequestPanel.astro +12 -119
  117. package/src/components/openapi/async-snippets.ts +174 -0
  118. package/src/components/openapi/async.ts +348 -0
  119. package/src/components/openapi/helpers.ts +52 -20
  120. package/src/components/openapi/security.ts +102 -29
  121. package/src/components/openapi/snippets.ts +11 -11
  122. package/src/core/component-overrides.ts +28 -23
  123. package/src/core/config-input.ts +89 -28
  124. package/src/core/config.ts +20 -7
  125. package/src/core/content.ts +3 -1
  126. package/src/core/data.ts +19 -3
  127. package/src/core/define-components.ts +5 -0
  128. package/src/core/diagnostics.ts +46 -38
  129. package/src/core/frontmatter.ts +33 -7
  130. package/src/core/graph.ts +137 -53
  131. package/src/core/i18n-ui.ts +15 -0
  132. package/src/core/i18n.ts +16 -8
  133. package/src/core/last-modified.ts +49 -0
  134. package/src/core/load-module.ts +1 -0
  135. package/src/core/manifest.ts +92 -3
  136. package/src/core/meta.ts +44 -14
  137. package/src/core/nav-diagnostics.ts +3 -3
  138. package/src/core/navigation.ts +247 -67
  139. package/src/core/project-graph.ts +26 -3
  140. package/src/core/schema.ts +214 -68
  141. package/src/core/sources/assets.ts +2 -0
  142. package/src/core/sources/cache.ts +6 -0
  143. package/src/core/sources/github-releases.ts +39 -31
  144. package/src/core/sources/mdx-remote.ts +4 -0
  145. package/src/core/sources/normalize.ts +67 -20
  146. package/src/core/sources/notion.ts +49 -17
  147. package/src/core/sources/portable-text.ts +32 -11
  148. package/src/core/sources/sanity.ts +68 -14
  149. package/src/core/sources/types.ts +4 -0
  150. package/src/core/sources/watch.ts +1 -1
  151. package/src/core/standard-schema.ts +9 -3
  152. package/src/core/text-width.ts +26 -0
  153. package/src/core/tsconfig-aliases.ts +9 -5
  154. package/src/core/types.ts +45 -2
  155. package/src/core/ui-packs/index.ts +9 -1
  156. package/src/core/version-cut.ts +301 -0
  157. package/src/core/version.ts +2 -0
  158. package/src/core/versions.ts +170 -0
  159. package/src/deploy/adapter-output.ts +5 -2
  160. package/src/deploy/cloudflare-negotiation.ts +25 -10
  161. package/src/deploy/sitemap.ts +33 -1
  162. package/src/deploy/vercel-negotiation.ts +45 -18
  163. package/src/eval/report.ts +4 -4
  164. package/src/eval/run.ts +2 -2
  165. package/src/eval/schema.ts +1 -1
  166. package/src/markdown/base-links.ts +6 -6
  167. package/src/markdown/directives.ts +7 -1
  168. package/src/markdown/heading-anchors.ts +17 -6
  169. package/src/markdown/index.ts +73 -24
  170. package/src/markdown/inline-code.ts +14 -2
  171. package/src/markdown/language-icon.ts +6 -2
  172. package/src/markdown/mdast.ts +18 -4
  173. package/src/markdown/package-commands.ts +6 -8
  174. package/src/markdown/table-wrap.ts +4 -1
  175. package/src/markdown/twoslash.ts +2 -0
  176. package/src/og/card.ts +33 -12
  177. package/src/og/derive.ts +43 -27
  178. package/src/openapi/asyncapi.ts +366 -0
  179. package/src/openapi/model.ts +126 -57
  180. package/src/openapi/parse.ts +97 -5
  181. package/src/openapi/references.ts +12 -10
  182. package/src/openapi/render-mdx.ts +73 -34
  183. package/src/openapi/scalar.ts +6 -8
  184. package/src/openapi/source.ts +98 -28
  185. package/src/registry/eject.ts +7 -2
  186. package/src/search/documents.ts +25 -5
  187. package/src/search/facets.ts +7 -5
  188. package/src/search/orama-index.ts +66 -20
  189. package/src/search/popular.ts +10 -5
  190. package/src/search/providers.ts +2 -2
  191. package/src/search/sync/index.ts +2 -0
  192. package/src/search/sync/typesense.ts +4 -2
  193. package/src/seo/jsonld.ts +24 -6
  194. package/src/seo/x-handle.ts +8 -3
  195. package/src/theme/chrome-icons.ts +7 -2
  196. package/src/theme/entry.ts +24 -2
  197. package/src/theme/fonts.ts +83 -7
  198. package/src/theme/icons.ts +4 -2
  199. package/src/theme/palette.ts +22 -14
  200. package/src/translate/meta.ts +15 -6
  201. package/src/translate/report.ts +9 -5
  202. package/src/translate/run.ts +10 -4
  203. package/src/translate/validate.ts +52 -17
  204. package/src/translate/work-list.ts +0 -0
@@ -14,17 +14,29 @@ type MatterOptions = Parameters<typeof baseMatter>[1];
14
14
  type ReadArgs = Parameters<typeof baseMatter.read>;
15
15
  type StringifyArgs = Parameters<typeof baseMatter.stringify>;
16
16
 
17
+ /** Front matter data as gray-matter types it (`GrayMatterFile["data"]`). */
18
+ type FrontMatterData = ReturnType<typeof baseMatter>["data"];
19
+
17
20
  const yamlEngine = {
21
+ // SAFETY: gray-matter's engine contract expects an object; a front matter
22
+ // block is a YAML mapping, and js-yaml returns a scalar only for degenerate
23
+ // input, which gray-matter treats the same way its bundled engine's output
24
+ // is treated.
18
25
  parse: (input: string): object => (load(input) ?? {}) as object,
19
- stringify: (data: object): string => dump(data),
26
+ stringify: (data: FrontMatterData): string => dump(data),
20
27
  };
21
28
 
22
- const withYamlEngine = <O>(options: O): O =>
29
+ const withYamlEngine = <O extends { engines?: object } | undefined>(
30
+ options: O
31
+ ): O =>
32
+ // SAFETY: the spread keeps every field of `options`; adding a default yaml
33
+ // engine (overridden by any caller-supplied `engines`) stays within O's
34
+ // shape — TS just can't prove a spread of a generic re-satisfies O.
23
35
  ({
24
36
  ...options,
25
37
  engines: {
26
38
  yaml: yamlEngine,
27
- ...(options as { engines?: object })?.engines,
39
+ ...options?.engines,
28
40
  },
29
41
  }) as O;
30
42
 
@@ -52,13 +64,21 @@ const opensWithThematicBreak = (input: string): boolean => {
52
64
  return !input.includes("\n---", 1);
53
65
  };
54
66
 
67
+ /**
68
+ * gray-matter's runtime result carries `isEmpty`, which its declared
69
+ * GrayMatterFile type omits.
70
+ */
71
+ interface MatterResult extends ReturnType<typeof baseMatter> {
72
+ isEmpty: boolean;
73
+ }
74
+
55
75
  /**
56
76
  * The parse result for a document with no front matter: the input passes
57
77
  * through as content, untouched. Shaped like gray-matter's own no-matter
58
78
  * result (every Blume call site reads only `content` and `data`).
59
79
  */
60
- const passthrough = (input: string): ReturnType<typeof baseMatter> =>
61
- ({
80
+ const passthrough = (input: string): ReturnType<typeof baseMatter> => {
81
+ const file: MatterResult = {
62
82
  content: input,
63
83
  data: {},
64
84
  excerpt: "",
@@ -68,7 +88,13 @@ const passthrough = (input: string): ReturnType<typeof baseMatter> =>
68
88
  orig: input,
69
89
  // Recomposing a file with no matter and empty data is the content itself.
70
90
  stringify: (): string => input,
71
- }) as unknown as ReturnType<typeof baseMatter>;
91
+ };
92
+ return file;
93
+ };
94
+
95
+ /** Narrows gray-matter's input union to the raw-string form. */
96
+ const isStringInput = (input: MatterInput): input is string =>
97
+ typeof input === "string";
72
98
 
73
99
  // Every helper that parses or emits YAML (`read`, `stringify`) must be
74
100
  // re-wrapped here — Object.assign copies gray-matter's own helpers, which use
@@ -76,7 +102,7 @@ const passthrough = (input: string): ReturnType<typeof baseMatter> =>
76
102
  // checks for a delimiter, so the copied original is safe.
77
103
  const matter = Object.assign(
78
104
  (input: MatterInput, options?: MatterOptions) =>
79
- typeof input === "string" && opensWithThematicBreak(input)
105
+ isStringInput(input) && opensWithThematicBreak(input)
80
106
  ? passthrough(input)
81
107
  : baseMatter(input, withYamlEngine(options)),
82
108
  baseMatter,
package/src/core/graph.ts CHANGED
@@ -7,6 +7,7 @@ import type {
7
7
  LocalizableLabel,
8
8
  ResolvedConfig,
9
9
  ResolvedI18nConfig,
10
+ ResolvedVersionsConfig,
10
11
  } from "./schema.ts";
11
12
  import type {
12
13
  ContentGraph,
@@ -15,6 +16,7 @@ import type {
15
16
  NavTab,
16
17
  PageRecord,
17
18
  } from "./types.ts";
19
+ import { versionizeRoute } from "./versions.ts";
18
20
 
19
21
  interface BuildContentGraphOptions {
20
22
  /** Site-wide route mount point (`""` or `/seg`); invisible to the nav tree. */
@@ -23,14 +25,13 @@ interface BuildContentGraphOptions {
23
25
  sharedFolderMeta?: Map<string, FolderMeta>;
24
26
  navigation: ResolvedConfig["navigation"];
25
27
  i18n?: ResolvedI18nConfig;
28
+ versions?: ResolvedVersionsConfig;
26
29
  }
27
30
 
28
31
  type FallbackLocale = ReturnType<typeof resolveFallbackLocale>;
29
32
 
30
33
  /** Build the route → page-id map, flagging any duplicate-route collisions. */
31
- const collectRoutes = (
32
- pages: PageRecord[]
33
- ): { diagnostics: Diagnostic[]; routes: Map<string, string> } => {
34
+ const collectRoutes = (pages: PageRecord[]) => {
34
35
  const routes = new Map<string, string>();
35
36
  const diagnostics: Diagnostic[] = [];
36
37
  for (const page of pages) {
@@ -86,12 +87,16 @@ const localePagesFor = (
86
87
  * the active locale's entry, else the default locale's, else the map's first
87
88
  * entry (which is also what a single-locale site gets).
88
89
  */
90
+ /** A label is either one string for every locale or a per-locale map. */
91
+ const isSingleLabel = (label: LocalizableLabel): label is string =>
92
+ typeof label === "string";
93
+
89
94
  const resolveLabel = (
90
95
  label: LocalizableLabel,
91
96
  locale: string,
92
97
  defaultLocale?: string
93
98
  ): string => {
94
- if (typeof label === "string") {
99
+ if (isSingleLabel(label)) {
95
100
  return label;
96
101
  }
97
102
  return (
@@ -117,7 +122,12 @@ const resolveTabLabels = (
117
122
  label: resolveLabel(tab.label, locale, defaultLocale),
118
123
  }));
119
124
 
120
- /** Build one locale's navigation tree from its own pages and folder meta. */
125
+ /**
126
+ * Build one locale's navigation tree from its own pages and folder meta.
127
+ * `version` is the archived version id when building a snapshot's tree
128
+ * (`""` for the current docs): it shifts the folder-meta lookups into the
129
+ * snapshot's key space and roots the tree at the localized version root.
130
+ */
121
131
  const buildLocaleNavigation = (
122
132
  code: string,
123
133
  pages: PageRecord[],
@@ -125,7 +135,8 @@ const buildLocaleNavigation = (
125
135
  fallbackByKey: Map<string, PageRecord>,
126
136
  options: BuildContentGraphOptions,
127
137
  i18n: ResolvedI18nConfig,
128
- diagnostics: Diagnostic[]
138
+ diagnostics: Diagnostic[],
139
+ version = ""
129
140
  ): Navigation => {
130
141
  // Localize internal tab paths — the tab's own and its dropdown items' — so a
131
142
  // header tab points to its in-locale route (e.g. `/docs` -> `/fr/docs`);
@@ -137,15 +148,20 @@ const buildLocaleNavigation = (
137
148
  options.navigation.tabs,
138
149
  code,
139
150
  i18n.defaultLocale
140
- ).map((tab) => ({
141
- ...tab,
142
- ...(tab.href ? { href: localizePath(tab.href) } : {}),
143
- items: tab.items?.map((item) => ({
144
- ...item,
145
- path: localizePath(item.path),
146
- })),
147
- path: localizePath(tab.path),
148
- }));
151
+ ).map((tab) => {
152
+ const localized = {
153
+ ...tab,
154
+ items: tab.items?.map((item) => ({
155
+ ...item,
156
+ path: localizePath(item.path),
157
+ })),
158
+ path: localizePath(tab.path),
159
+ };
160
+ if (localized.href) {
161
+ localized.href = localizePath(localized.href);
162
+ }
163
+ return localized;
164
+ });
149
165
  const real = pages.filter((page) => page.locale === code);
150
166
  const localePages = localePagesFor(
151
167
  code,
@@ -155,6 +171,13 @@ const buildLocaleNavigation = (
155
171
  i18n,
156
172
  options.basePath ?? ""
157
173
  );
174
+ // Meta files live in locale directories only under the `dir` parser
175
+ // (`fr/guides/meta.ts` -> key `fr/guides`). Under `dot`, translations sit
176
+ // next to the originals and `guides/meta.ts` applies to every locale —
177
+ // prefixing would look up keys that can never exist. Inside a snapshot the
178
+ // version dir is hoisted in front (`v1.0/fr`), matching `discoverFolderMeta`.
179
+ const localeDir =
180
+ i18n.parser === "dir" && code !== i18n.defaultLocale ? code : "";
158
181
  return buildNavigation(localePages, {
159
182
  basePath: options.basePath ?? "",
160
183
  diagnostics,
@@ -167,34 +190,37 @@ const buildLocaleNavigation = (
167
190
  href: localizePath(link.href),
168
191
  })),
169
192
  folderMeta: options.folderMeta,
170
- // The localized tree root ("/" for the hidden default, "/fr" otherwise):
171
- // the tab pointing here spans the whole tree and must not be treated as a
172
- // tab section.
173
- localizedRoot: localizeRoute("/", code, i18n),
174
- // Meta files live in locale directories only under the `dir` parser
175
- // (`fr/guides/meta.ts` -> key `fr/guides`). Under `dot`, translations sit
176
- // next to the originals and `guides/meta.ts` applies to every locale —
177
- // prefixing would look up keys that can never exist.
178
- metaPrefix:
179
- i18n.parser === "dir" && code !== i18n.defaultLocale ? code : "",
193
+ // The localized tree root ("/" for the hidden default, "/fr" otherwise;
194
+ // "/fr/v1.0" inside a snapshot): the tab pointing here spans the whole
195
+ // tree and must not be treated as a tab section.
196
+ localizedRoot: localizeRoute(versionizeRoute("/", version), code, i18n),
197
+ metaPrefix: [version, localeDir].filter(Boolean).join("/"),
180
198
  refByLogical: true,
181
199
  selectors: options.navigation.selectors,
182
200
  sharedFolderMeta: options.sharedFolderMeta,
183
- sidebar: options.navigation.sidebar.items,
201
+ // Shared `meta.$.*` files are locale-agnostic but version-specific: a
202
+ // snapshot's shared meta keys under its version dir.
203
+ sharedMetaPrefix: version,
204
+ // A configured explicit sidebar describes the current docs; a frozen
205
+ // snapshot's structure comes from the snapshot itself, so archived trees
206
+ // always build from the filesystem.
207
+ sidebar: version ? undefined : options.navigation.sidebar.items,
184
208
  tabs,
185
209
  });
186
210
  };
187
211
 
188
- /** Per-locale navigation trees plus the default-locale tree for i18n sites. */
212
+ /**
213
+ * Per-locale navigation trees plus the default-locale tree for i18n sites.
214
+ * Called once for the current docs and once per archived version (with that
215
+ * version's pages and its id as `version`).
216
+ */
189
217
  const buildI18nNavigation = (
190
218
  pages: PageRecord[],
191
219
  options: BuildContentGraphOptions,
192
220
  i18n: ResolvedI18nConfig,
193
- diagnostics: Diagnostic[]
194
- ): {
195
- navigation: Navigation;
196
- navigationByLocale: Record<string, Navigation>;
197
- } => {
221
+ diagnostics: Diagnostic[],
222
+ version = ""
223
+ ) => {
198
224
  // Pages of the fallback locale, by translation key — used to fill in a
199
225
  // locale's sidebar for pages it hasn't translated yet.
200
226
  const fallback = resolveFallbackLocale(i18n);
@@ -223,7 +249,8 @@ const buildI18nNavigation = (
223
249
  fallbackByKey,
224
250
  options,
225
251
  i18n,
226
- localeDiagnostics
252
+ localeDiagnostics,
253
+ version
227
254
  );
228
255
  for (const diagnostic of localeDiagnostics) {
229
256
  const key = `${diagnostic.code}\n${diagnostic.file ?? ""}\n${diagnostic.message}`;
@@ -233,7 +260,7 @@ const buildI18nNavigation = (
233
260
  }
234
261
  }
235
262
  }
236
- const navigation = navigationByLocale[i18n.defaultLocale] ?? {
263
+ const navigation: Navigation = navigationByLocale[i18n.defaultLocale] ?? {
237
264
  featured: [],
238
265
  selectors: [],
239
266
  sidebar: [],
@@ -242,6 +269,37 @@ const buildI18nNavigation = (
242
269
  return { navigation, navigationByLocale };
243
270
  };
244
271
 
272
+ /** One archived version's navigation trees, keyed by locale (`""` sans i18n). */
273
+ const buildVersionNavigation = (
274
+ id: string,
275
+ versionPages: PageRecord[],
276
+ options: BuildContentGraphOptions,
277
+ diagnostics: Diagnostic[]
278
+ ) => {
279
+ const { i18n } = options;
280
+ if (i18n) {
281
+ return buildI18nNavigation(versionPages, options, i18n, diagnostics, id)
282
+ .navigationByLocale;
283
+ }
284
+ return {
285
+ "": buildNavigation(versionPages, {
286
+ basePath: options.basePath ?? "",
287
+ diagnostics,
288
+ display: options.navigation.sidebar.display,
289
+ featured: options.navigation.featured,
290
+ folderMeta: options.folderMeta,
291
+ localizedRoot: versionizeRoute("/", id),
292
+ metaPrefix: id,
293
+ selectors: options.navigation.selectors,
294
+ sharedFolderMeta: options.sharedFolderMeta,
295
+ sharedMetaPrefix: id,
296
+ // A configured explicit sidebar describes the current docs; a snapshot's
297
+ // structure comes from the snapshot itself.
298
+ tabs: resolveTabLabels(options.navigation.tabs, ""),
299
+ }),
300
+ };
301
+ };
302
+
245
303
  /** Assemble the content graph: routes map, nav, and duplicate diagnostics. */
246
304
  export const buildContentGraph = (
247
305
  pages: PageRecord[],
@@ -250,37 +308,63 @@ export const buildContentGraph = (
250
308
  const { diagnostics, routes } = collectRoutes(pages);
251
309
  const { i18n } = options;
252
310
 
253
- const { navigation, navigationByLocale } = i18n
254
- ? buildI18nNavigation(pages, options, i18n, diagnostics)
255
- : {
256
- navigation: buildNavigation(pages, {
257
- basePath: options.basePath ?? "",
258
- diagnostics,
259
- display: options.navigation.sidebar.display,
260
- featured: options.navigation.featured,
261
- folderMeta: options.folderMeta,
262
- selectors: options.navigation.selectors,
263
- sharedFolderMeta: options.sharedFolderMeta,
264
- sidebar: options.navigation.sidebar.items,
265
- // No locale to prefer: a per-locale label map resolves to its first
266
- // entry on a single-locale site.
267
- tabs: resolveTabLabels(options.navigation.tabs, ""),
268
- }),
269
- navigationByLocale: {} as Record<string, Navigation>,
270
- };
311
+ // Under versioning, each version gets independent trees: navPath is
312
+ // version-stripped, so mixing versions into one build would collide the
313
+ // same logical page once per version.
314
+ const currentPages = options.versions
315
+ ? pages.filter((page) => page.version === "")
316
+ : pages;
317
+
318
+ // A single-locale site has no per-locale trees; the empty map is its
319
+ // navigationByLocale.
320
+ let navigationByLocale: Record<string, Navigation> = {};
321
+ let navigation: Navigation;
322
+ if (i18n) {
323
+ ({ navigation, navigationByLocale } = buildI18nNavigation(
324
+ currentPages,
325
+ options,
326
+ i18n,
327
+ diagnostics
328
+ ));
329
+ } else {
330
+ navigation = buildNavigation(currentPages, {
331
+ basePath: options.basePath ?? "",
332
+ diagnostics,
333
+ display: options.navigation.sidebar.display,
334
+ featured: options.navigation.featured,
335
+ folderMeta: options.folderMeta,
336
+ selectors: options.navigation.selectors,
337
+ sharedFolderMeta: options.sharedFolderMeta,
338
+ sidebar: options.navigation.sidebar.items,
339
+ // No locale to prefer: a per-locale label map resolves to its first
340
+ // entry on a single-locale site.
341
+ tabs: resolveTabLabels(options.navigation.tabs, ""),
342
+ });
343
+ }
344
+
345
+ const navigationByVersion: Record<string, Record<string, Navigation>> = {};
346
+ for (const { id } of options.versions?.archived ?? []) {
347
+ navigationByVersion[id] = buildVersionNavigation(
348
+ id,
349
+ pages.filter((page) => page.version === id),
350
+ options,
351
+ diagnostics
352
+ );
353
+ }
271
354
 
272
355
  // Icon typos, duplicate labels, and hidden-page-in-sidebar are validated on
273
356
  // the built navigation. Missing-target detection needs the full route set
274
357
  // (incl. custom + generated pages), so it runs later in generateRuntime.
275
358
  diagnostics.push(
276
359
  ...validateNavIcons(navigation),
277
- ...validateNavStructure(navigation, pages)
360
+ ...validateNavStructure(navigation, currentPages)
278
361
  );
279
362
 
280
363
  return {
281
364
  diagnostics,
282
365
  navigation,
283
366
  navigationByLocale,
367
+ navigationByVersion,
284
368
  pages,
285
369
  routes,
286
370
  };
@@ -123,6 +123,7 @@ const uiStringsObject = z.object({
123
123
  .object({
124
124
  all: z.string().default("All"),
125
125
  allLanguages: z.string().default("All languages"),
126
+ allVersions: z.string().default("All versions"),
126
127
  askAi: z.string().default("Ask AI"),
127
128
  askAiHint: z.string().default("Get an instant answer from AI"),
128
129
  button: z.string().default("Search"),
@@ -145,6 +146,18 @@ const uiStringsObject = z.object({
145
146
  title: z.string().default("On this page"),
146
147
  })
147
148
  .prefault({}),
149
+ versions: z
150
+ .object({
151
+ latest: z.string().default("Go to latest"),
152
+ // `{version}` is replaced with the archived version's label at render time.
153
+ notice: z
154
+ .string()
155
+ .default(
156
+ "You're viewing documentation for {version}. It may be out of date."
157
+ ),
158
+ switcher: z.string().default("Version"),
159
+ })
160
+ .prefault({}),
148
161
  });
149
162
 
150
163
  export const uiStringsSchema = uiStringsObject.prefault({});
@@ -186,6 +199,8 @@ const mergeUI = (base: UIStrings, override?: UIStringsOverride): UIStrings => {
186
199
  }
187
200
  const out: UIStrings = structuredClone(base);
188
201
  for (const [group, values] of Object.entries(override)) {
202
+ // SAFETY: UIStrings is exactly two levels of string leaves, and the
203
+ // override schema mirrors its groups, so `group` indexes a string map.
189
204
  const target = (out as Record<string, Record<string, string>>)[group];
190
205
  if (target && values) {
191
206
  Object.assign(target, values);
package/src/core/i18n.ts CHANGED
@@ -1,4 +1,8 @@
1
- import type { ResolvedConfig, ResolvedI18nConfig } from "./schema.ts";
1
+ import type {
2
+ ResolvedConfig,
3
+ ResolvedI18nConfig,
4
+ ResolvedVersionsConfig,
5
+ } from "./schema.ts";
2
6
  import type { Diagnostic, PageRecord } from "./types.ts";
3
7
  import { UI_PACKS } from "./ui-packs/index.ts";
4
8
 
@@ -70,10 +74,7 @@ export const localizeRoute = (
70
74
  * matched as a leading segment. Returns the resolved locale and the remaining
71
75
  * (locale-stripped) segments.
72
76
  */
73
- export const detectLocale = (
74
- parts: string[],
75
- i18n: ResolvedI18nConfig
76
- ): { locale: string; rest: string[] } => {
77
+ export const detectLocale = (parts: string[], i18n: ResolvedI18nConfig) => {
77
78
  // BCP 47 codes are case-insensitive: a conventional lowercase folder
78
79
  // (`pt-br/`) must match a configured `pt-BR`. The configured casing is what
79
80
  // flows into routes and labels.
@@ -102,7 +103,7 @@ export const localePlacement = (
102
103
  rel: string,
103
104
  ext: string,
104
105
  i18n: ResolvedI18nConfig
105
- ): { navPath: string; locales: string[] } => {
106
+ ) => {
106
107
  const base = rel.slice(0, rel.length - ext.length);
107
108
 
108
109
  // Shared `$` file: the same content in every locale. A shared file placed
@@ -187,17 +188,24 @@ export const localeTargetPath = (
187
188
  */
188
189
  export const i18nDiagnostics = (
189
190
  pages: PageRecord[],
190
- i18n: ResolvedI18nConfig
191
+ i18n: ResolvedI18nConfig,
192
+ versions?: ResolvedVersionsConfig
191
193
  ): Diagnostic[] => {
192
194
  const configured = new Set(
193
195
  i18n.locales.map((locale) => locale.code.toLowerCase())
194
196
  );
197
+ const versionDirs = new Set(versions?.archived.map((version) => version.id));
195
198
  const seen = new Set<string>();
196
199
  const diagnostics: Diagnostic[] = [];
197
200
  for (const page of pages) {
198
201
  // The locale-looking folder is the first segment of the source-local ref
199
202
  // (e.g. `fr/guide.md`), not the namespaced id (`filesystem:fr/guide.md`).
200
- const first = page.source.ref.split("/")[0]?.toLowerCase();
203
+ // Inside a version snapshot the locale dir sits one level deeper
204
+ // (`v1.0/fr/guide.md`), so a configured version segment is skipped first.
205
+ const parts = page.source.ref.split("/");
206
+ const first = (
207
+ parts[0] && versionDirs.has(parts[0]) ? parts[1] : parts[0]
208
+ )?.toLowerCase();
201
209
  if (
202
210
  first &&
203
211
  !seen.has(first) &&
@@ -2,6 +2,8 @@ import { execFileSync } from "node:child_process";
2
2
 
3
3
  import { relative } from "pathe";
4
4
 
5
+ import type { Diagnostic } from "./types.ts";
6
+
5
7
  /** Normalized form of the `lastModified` config. */
6
8
  export interface ResolvedLastModified {
7
9
  enabled: boolean;
@@ -95,3 +97,50 @@ export const gitLastModifiedTimes = (
95
97
  return new Map();
96
98
  }
97
99
  };
100
+
101
+ /**
102
+ * Whether the repository containing `root` is a shallow clone. Returns false
103
+ * when git is unavailable or the project isn't a repo — those cases already
104
+ * yield no dates at all, and the shallow warning would only mislead.
105
+ */
106
+ export const isShallowGitRepository = (root: string): boolean => {
107
+ try {
108
+ return (
109
+ execFileSync(
110
+ // oxlint-disable-next-line sonarjs/no-os-command-from-path -- git is a required dev-tool dependency resolved from PATH
111
+ "git",
112
+ ["-C", root, "rev-parse", "--is-shallow-repository"],
113
+ // stderr silenced: outside a repository the probe fails by design.
114
+ { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] }
115
+ ).trim() === "true"
116
+ );
117
+ } catch {
118
+ return false;
119
+ }
120
+ };
121
+
122
+ /**
123
+ * A warning for git-derived dates silently missing because the build ran in a
124
+ * shallow clone — the default on Vercel and `actions/checkout`, where `git log`
125
+ * only sees the last few commits, so most pages get no date and the sitemap's
126
+ * `<lastmod>` / "Last updated" stamps quietly disappear in production while
127
+ * working locally. Empty when every page got a date or the clone isn't
128
+ * shallow.
129
+ */
130
+ export const lastModifiedShallowWarning = (
131
+ root: string,
132
+ undatedCount: number
133
+ ): Diagnostic[] => {
134
+ if (undatedCount === 0 || !isShallowGitRepository(root)) {
135
+ return [];
136
+ }
137
+ return [
138
+ {
139
+ code: "BLUME_SHALLOW_GIT_HISTORY",
140
+ message: `lastModified is on, but this build runs in a shallow git clone, so ${undatedCount} page(s) have no git-derived date — their sitemap <lastmod> and "Last updated" stamps are omitted.`,
141
+ severity: "warning",
142
+ suggestion:
143
+ "Fetch full history in CI: set the VERCEL_DEEP_CLONE=true environment variable on Vercel, or fetch-depth: 0 for actions/checkout.",
144
+ },
145
+ ];
146
+ };
@@ -6,6 +6,7 @@ import { createJiti } from "jiti";
6
6
  * is called with. `moduleCache: false` ensures edits are picked up on each load,
7
7
  * which is what makes dev-server regeneration reflect config/meta changes.
8
8
  */
9
+ // oxlint-disable-next-line anti-slop/no-unknown-returns -- user-authored modules can export anything; callers validate the loaded value at their own boundary
9
10
  export const createModuleLoader = (): ((file: string) => Promise<unknown>) => {
10
11
  const jiti = createJiti(import.meta.url, { moduleCache: false });
11
12
  return async (file: string) => {