blume 1.4.2 → 1.5.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.
Files changed (227) hide show
  1. package/CHANGELOG.md +71 -0
  2. package/dist/cli/index.js +2260 -1100
  3. package/dist/cli/index.js.map +123 -117
  4. package/dist/types/ai/component-markdown.d.ts +14 -4
  5. package/dist/types/core/base-path.d.ts +8 -0
  6. package/dist/types/core/config-input.d.ts +87 -27
  7. package/dist/types/core/config.d.ts +2 -1
  8. package/dist/types/core/data.d.ts +16 -1
  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 +116 -15
  12. package/dist/types/core/sources/types.d.ts +11 -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 +14 -7
  17. package/dist/types/seo/x-handle.d.ts +3 -2
  18. package/docs/advanced/api-reference.mdx +8 -6
  19. package/docs/configuration/ai.mdx +26 -8
  20. package/docs/configuration/search.mdx +2 -0
  21. package/docs/configuration/seo.mdx +1 -1
  22. package/docs/content/i18n.mdx +1 -1
  23. package/docs/content/meta.mdx +2 -1
  24. package/docs/content/meta.ts +1 -0
  25. package/docs/content/navigation.mdx +35 -1
  26. package/docs/content/sources.mdx +1 -1
  27. package/docs/content/versioning.mdx +106 -0
  28. package/docs/reference/cli.mdx +1 -0
  29. package/docs/reference/frontmatter.mdx +3 -0
  30. package/package.json +13 -1
  31. package/skills/blume-migrate/SKILL.md +2 -2
  32. package/skills/blume-migrate/references/docusaurus.md +1 -1
  33. package/skills/blume-migrate/references/fumadocs.md +1 -1
  34. package/skills/blume-migrate/references/mintlify.md +1 -1
  35. package/src/ai/agent-readability.ts +40 -12
  36. package/src/ai/api-catalog.ts +2 -2
  37. package/src/ai/ask-context.ts +49 -12
  38. package/src/ai/ask.ts +10 -1
  39. package/src/ai/component-markdown.ts +80 -43
  40. package/src/ai/llms.ts +42 -17
  41. package/src/ai/mcp/data.ts +48 -12
  42. package/src/ai/mcp/discovery.ts +52 -16
  43. package/src/ai/mcp/server.ts +280 -125
  44. package/src/ai/mcp/tools.ts +3 -3
  45. package/src/ai/skills.ts +32 -9
  46. package/src/ai/tar.ts +29 -70
  47. package/src/ai/visibility.ts +2 -2
  48. package/src/astro/component-slots.ts +2 -0
  49. package/src/astro/examples.ts +13 -5
  50. package/src/astro/generate.ts +113 -63
  51. package/src/astro/integration.ts +13 -2
  52. package/src/astro/islands.ts +23 -12
  53. package/src/astro/templates.ts +185 -41
  54. package/src/audit/agent.ts +16 -31
  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 +78 -25
  62. package/src/audit/report.ts +20 -19
  63. package/src/audit/run.ts +15 -5
  64. package/src/audit/snapshot.ts +29 -6
  65. package/src/audit/types.ts +25 -3
  66. package/src/blume-modules.d.ts +5 -1
  67. package/src/cli/commands/audit.ts +21 -21
  68. package/src/cli/commands/build.ts +30 -16
  69. package/src/cli/commands/dev.ts +15 -15
  70. package/src/cli/commands/doctor.ts +2 -0
  71. package/src/cli/commands/eject.ts +4 -4
  72. package/src/cli/commands/eval.ts +24 -30
  73. package/src/cli/commands/init.ts +9 -9
  74. package/src/cli/commands/mcp-stdio.ts +3 -0
  75. package/src/cli/commands/translate.ts +14 -3
  76. package/src/cli/commands/version.ts +85 -0
  77. package/src/cli/dev-lock.ts +31 -10
  78. package/src/cli/eject-scripts.ts +17 -2
  79. package/src/cli/env.ts +13 -30
  80. package/src/cli/index.ts +2 -0
  81. package/src/cli/init/questions.ts +1 -1
  82. package/src/cli/init/scaffold.ts +41 -13
  83. package/src/cli/internal-error.ts +1 -0
  84. package/src/cli/report-format.ts +22 -0
  85. package/src/components/content/AccordionItem.astro +2 -9
  86. package/src/components/content/ColorItem.astro +5 -13
  87. package/src/components/content/Component.astro +12 -8
  88. package/src/components/content/Frame.astro +2 -12
  89. package/src/components/content/Prompt.astro +12 -31
  90. package/src/components/content/Tab.astro +2 -9
  91. package/src/components/content/Tooltip.astro +1 -9
  92. package/src/components/content/Update.astro +2 -9
  93. package/src/components/content/auto-type-table.ts +3 -0
  94. package/src/components/content/diff.ts +9 -5
  95. package/src/components/content/github-info.ts +2 -0
  96. package/src/components/content/inline-markdown.ts +28 -0
  97. package/src/components/copy-feedback.ts +96 -0
  98. package/src/components/islands/ask-ai.tsx +111 -34
  99. package/src/components/islands/hooks.ts +5 -1
  100. package/src/components/islands/webmcp.ts +49 -12
  101. package/src/components/layout/Header.astro +25 -1
  102. package/src/components/layout/NavSelector.astro +11 -2
  103. package/src/components/layout/NavTree.astro +4 -2
  104. package/src/components/layout/PageActions.astro +20 -32
  105. package/src/components/layout/PageLayout.astro +8 -28
  106. package/src/components/layout/RootLayout.astro +24 -48
  107. package/src/components/layout/Search.astro +133 -22
  108. package/src/components/layout/VersionBanner.astro +39 -0
  109. package/src/components/layout/analytics-client.ts +8 -5
  110. package/src/components/layout/drawer-inert.ts +31 -0
  111. package/src/components/layout/hydration-hint.ts +1 -1
  112. package/src/components/layout/nav-utils.ts +1 -4
  113. package/src/components/layout/overrides.ts +25 -12
  114. package/src/components/layout/search/algolia.ts +18 -5
  115. package/src/components/layout/search/endpoint.ts +3 -0
  116. package/src/components/layout/search/flexsearch.ts +23 -7
  117. package/src/components/layout/search/orama-cloud.ts +1 -1
  118. package/src/components/layout/search/orama.ts +4 -1
  119. package/src/components/layout/search/pagefind.ts +8 -5
  120. package/src/components/layout/search/types.ts +45 -1
  121. package/src/components/layout/search/typesense.ts +19 -3
  122. package/src/components/openapi/ApiOverview.astro +32 -6
  123. package/src/components/openapi/AsyncApiOperation.astro +237 -0
  124. package/src/components/openapi/Bindings.astro +89 -0
  125. package/src/components/openapi/MethodBadge.astro +3 -0
  126. package/src/components/openapi/Operation.astro +7 -2
  127. package/src/components/openapi/PanelTabs.astro +131 -0
  128. package/src/components/openapi/ParametersTable.astro +2 -0
  129. package/src/components/openapi/RequestPanel.astro +12 -119
  130. package/src/components/openapi/async-snippets.ts +174 -0
  131. package/src/components/openapi/async.ts +348 -0
  132. package/src/components/openapi/helpers.ts +52 -20
  133. package/src/components/openapi/panel.ts +11 -8
  134. package/src/components/openapi/security.ts +102 -29
  135. package/src/components/openapi/snippets.ts +11 -11
  136. package/src/components/raf-throttle.ts +21 -0
  137. package/src/components/slug.ts +14 -0
  138. package/src/core/base-path.ts +18 -1
  139. package/src/core/component-overrides.ts +28 -23
  140. package/src/core/config-input.ts +96 -27
  141. package/src/core/config.ts +20 -7
  142. package/src/core/content.ts +3 -1
  143. package/src/core/data.ts +16 -1
  144. package/src/core/define-components.ts +5 -0
  145. package/src/core/diagnostics.ts +46 -38
  146. package/src/core/frontmatter.ts +74 -4
  147. package/src/core/graph.ts +137 -53
  148. package/src/core/i18n-ui.ts +15 -0
  149. package/src/core/i18n.ts +16 -8
  150. package/src/core/load-module.ts +1 -0
  151. package/src/core/manifest.ts +92 -3
  152. package/src/core/meta.ts +44 -14
  153. package/src/core/nav-diagnostics.ts +3 -3
  154. package/src/core/navigation.ts +247 -67
  155. package/src/core/probe.ts +7 -19
  156. package/src/core/project-graph.ts +27 -4
  157. package/src/core/schema.ts +219 -67
  158. package/src/core/site-url.ts +27 -0
  159. package/src/core/sources/assets.ts +2 -0
  160. package/src/core/sources/cache.ts +16 -8
  161. package/src/core/sources/github-releases.ts +39 -11
  162. package/src/core/sources/mdx-remote.ts +4 -0
  163. package/src/core/sources/normalize.ts +93 -22
  164. package/src/core/sources/notion.ts +76 -22
  165. package/src/core/sources/portable-text.ts +48 -12
  166. package/src/core/sources/resolve.ts +1 -0
  167. package/src/core/sources/sanity.ts +68 -14
  168. package/src/core/sources/types.ts +17 -1
  169. package/src/core/sources/watch.ts +1 -1
  170. package/src/core/standard-schema.ts +9 -3
  171. package/src/core/text-width.ts +26 -0
  172. package/src/core/tsconfig-aliases.ts +9 -5
  173. package/src/core/types.ts +45 -2
  174. package/src/core/ui-packs/index.ts +9 -1
  175. package/src/core/version-cut.ts +301 -0
  176. package/src/core/version.ts +2 -0
  177. package/src/core/versions.ts +170 -0
  178. package/src/deploy/adapter-output.ts +5 -2
  179. package/src/deploy/cloudflare-negotiation.ts +40 -11
  180. package/src/deploy/robots.ts +2 -1
  181. package/src/deploy/rss.ts +2 -1
  182. package/src/deploy/sitemap.ts +89 -8
  183. package/src/deploy/vercel-negotiation.ts +11 -4
  184. package/src/eval/agents.ts +13 -10
  185. package/src/eval/report.ts +5 -18
  186. package/src/eval/run.ts +2 -2
  187. package/src/eval/schema.ts +1 -1
  188. package/src/markdown/base-links.ts +6 -6
  189. package/src/markdown/directives.ts +7 -1
  190. package/src/markdown/heading-anchors.ts +17 -6
  191. package/src/markdown/index.ts +73 -24
  192. package/src/markdown/inline-code.ts +14 -2
  193. package/src/markdown/language-icon.ts +6 -2
  194. package/src/markdown/mdast.ts +18 -4
  195. package/src/markdown/package-commands.ts +63 -58
  196. package/src/markdown/table-wrap.ts +4 -1
  197. package/src/markdown/twoslash.ts +2 -0
  198. package/src/og/card.ts +50 -33
  199. package/src/og/derive.ts +43 -27
  200. package/src/openapi/asyncapi.ts +366 -0
  201. package/src/openapi/model.ts +135 -66
  202. package/src/openapi/parse.ts +166 -33
  203. package/src/openapi/references.ts +47 -22
  204. package/src/openapi/render-mdx.ts +137 -59
  205. package/src/openapi/scalar.ts +8 -10
  206. package/src/openapi/source.ts +126 -29
  207. package/src/registry/eject.ts +7 -2
  208. package/src/search/documents.ts +103 -39
  209. package/src/search/facets.ts +7 -5
  210. package/src/search/orama-index.ts +117 -32
  211. package/src/search/popular.ts +10 -5
  212. package/src/search/providers.ts +2 -2
  213. package/src/search/sync/index.ts +2 -0
  214. package/src/search/sync/typesense.ts +4 -2
  215. package/src/seo/jsonld.ts +24 -6
  216. package/src/seo/x-handle.ts +8 -3
  217. package/src/theme/chrome-icons.ts +7 -2
  218. package/src/theme/fonts.ts +8 -4
  219. package/src/theme/icons.ts +4 -2
  220. package/src/theme/palette.ts +27 -15
  221. package/src/translate/ledger.ts +4 -2
  222. package/src/translate/meta.ts +15 -6
  223. package/src/translate/report.ts +10 -19
  224. package/src/translate/run.ts +29 -38
  225. package/src/translate/validate.ts +52 -17
  226. package/src/translate/work-list.ts +0 -0
  227. package/src/cli/coalesce.ts +0 -43
@@ -47,6 +47,19 @@ const segmentKey = (raw: string): string => {
47
47
  const isIndexStem = (stem: string): boolean =>
48
48
  stem.replace(NUMERIC_PREFIX, "") === "index";
49
49
 
50
+ /** A filename's stem: the name with its extension stripped. */
51
+ const stemOf = (filename: string): string =>
52
+ filename.replace(extname(filename), "");
53
+
54
+ /**
55
+ * The stem of a nav path's last segment. The single index-detection input for
56
+ * the warn path (`sidebarDisplayIgnoredDiagnostics`) and the apply path
57
+ * (`buildFileSystemSidebar`), so the two can't drift apart on what counts as
58
+ * a folder's index page.
59
+ */
60
+ const navStem = (navPath: string): string =>
61
+ stemOf(navPath.split("/").at(-1) ?? navPath);
62
+
50
63
  interface MutablePage {
51
64
  kind: "page";
52
65
  key: string;
@@ -79,6 +92,7 @@ interface MutableGroup {
79
92
  label: string;
80
93
  icon?: string;
81
94
  collapsed?: boolean;
95
+ display?: SidebarDisplay;
82
96
  order: number;
83
97
  children: MutableNode[];
84
98
  index: Map<string, MutableGroup>;
@@ -121,14 +135,17 @@ const ensureGroup = (
121
135
  return group;
122
136
  };
123
137
 
124
- const pageOrder = (
125
- page: PageRecord,
126
- filename: string
127
- ): { order: number; orderIsAuthored: boolean } => {
138
+ interface PageOrder {
139
+ order: number;
140
+ /** Whether the order came from the author (frontmatter or a rank prefix). */
141
+ orderIsAuthored: boolean;
142
+ }
143
+
144
+ const pageOrder = (page: PageRecord, filename: string): PageOrder => {
128
145
  if (page.meta.sidebar.order !== undefined) {
129
146
  return { order: page.meta.sidebar.order, orderIsAuthored: true };
130
147
  }
131
- if (isIndexStem(filename.replace(extname(filename), ""))) {
148
+ if (isIndexStem(stemOf(filename))) {
132
149
  return { order: Number.NEGATIVE_INFINITY, orderIsAuthored: false };
133
150
  }
134
151
  // Changelog entries read newest-first, matching the generated timeline. Sort
@@ -164,18 +181,28 @@ const metaKey = (path: string, metaPrefix: string): string => {
164
181
  return path ? `${metaPrefix}/${path}` : metaPrefix;
165
182
  };
166
183
 
167
- /** Apply folder meta (title/order/icon/collapsed and explicit page order). */
184
+ /**
185
+ * Apply folder meta (title/order/icon/collapsed/display and explicit page
186
+ * order), plus the index-frontmatter display sugar collected per folder path.
187
+ */
168
188
  const applyFolderMeta = (
169
189
  group: MutableGroup,
170
190
  folderMeta: Map<string, FolderMeta>,
171
191
  sharedMeta: Map<string, FolderMeta>,
172
- metaPrefix: string
192
+ metaPrefix: string,
193
+ sharedMetaPrefix: string,
194
+ indexDisplay: Map<string, SidebarDisplay>
173
195
  ): void => {
174
196
  // Locale-specific meta wins; a shared `meta.$.*` (keyed by the locale-stripped
175
- // group path) applies to every locale otherwise.
197
+ // group path — version-prefixed inside a snapshot) applies to every locale
198
+ // otherwise.
176
199
  const meta =
177
200
  folderMeta.get(metaKey(group.path, metaPrefix)) ??
178
- sharedMeta.get(group.path);
201
+ sharedMeta.get(metaKey(group.path, sharedMetaPrefix));
202
+ // The group's own render mode, resolved index frontmatter first, then folder
203
+ // meta; `toNavNode` falls back to the global mode. Applies to this group
204
+ // only — nested subgroups resolve their own value through the same chain.
205
+ group.display = indexDisplay.get(group.path) ?? meta?.display;
179
206
  if (meta) {
180
207
  group.label = meta.title ?? group.label;
181
208
  group.icon = meta.icon ?? group.icon;
@@ -198,7 +225,14 @@ const applyFolderMeta = (
198
225
 
199
226
  for (const child of group.children) {
200
227
  if (child.kind === "group") {
201
- applyFolderMeta(child, folderMeta, sharedMeta, metaPrefix);
228
+ applyFolderMeta(
229
+ child,
230
+ folderMeta,
231
+ sharedMeta,
232
+ metaPrefix,
233
+ sharedMetaPrefix,
234
+ indexDisplay
235
+ );
202
236
  }
203
237
  }
204
238
  };
@@ -229,14 +263,15 @@ const indexTitleMismatchDiagnostic = (
229
263
  folderPath: string,
230
264
  folderMeta: Map<string, FolderMeta>,
231
265
  sharedMeta: Map<string, FolderMeta>,
232
- metaPrefix: string
266
+ metaPrefix: string,
267
+ sharedMetaPrefix: string
233
268
  ): Diagnostic | undefined => {
234
269
  if (!page.meta.title || page.fallback || folderPath === "") {
235
270
  return undefined;
236
271
  }
237
272
  const meta =
238
273
  folderMeta.get(metaKey(folderPath, metaPrefix)) ??
239
- sharedMeta.get(folderPath);
274
+ sharedMeta.get(metaKey(folderPath, sharedMetaPrefix));
240
275
  if (!meta?.title || meta.title === page.title) {
241
276
  return undefined;
242
277
  }
@@ -249,6 +284,71 @@ const indexTitleMismatchDiagnostic = (
249
284
  };
250
285
  };
251
286
 
287
+ /**
288
+ * Warn wherever a page's `sidebar.display` is dead weight, instead of
289
+ * silently dropping it:
290
+ *
291
+ * - On any page under an explicit `navigation.sidebar` config: the config
292
+ * items own each group's display mode, and neither index frontmatter nor
293
+ * folder meta is read — index pages included.
294
+ * - On the content root's own index page: the root is not a sidebar group,
295
+ * so there is nothing for the value to configure; the sidebar-wide mode
296
+ * lives in `navigation.sidebar.display`.
297
+ * - On any other non-index page: only a folder's index page can configure
298
+ * its group's display mode.
299
+ *
300
+ * A non-root folder index under the generated sidebar is the one placement
301
+ * that IS honored, so it alone is exempt. Under i18n fallback fill the same
302
+ * source file is checked once per locale with an identical message, and the
303
+ * graph-level code+file+message dedupe collapses the copies.
304
+ */
305
+ const sidebarDisplayIgnoredDiagnostics = (
306
+ pages: PageRecord[],
307
+ explicitSidebar: boolean
308
+ ): Diagnostic[] => {
309
+ const diagnostics: Diagnostic[] = [];
310
+ for (const page of pages) {
311
+ if (!page.meta.sidebar.display) {
312
+ continue;
313
+ }
314
+ const isIndex = isIndexStem(navStem(page.navPath));
315
+ if (explicitSidebar) {
316
+ diagnostics.push({
317
+ code: "BLUME_SIDEBAR_DISPLAY_IGNORED",
318
+ file: page.sourcePath ?? page.id,
319
+ message: `"${page.navPath}" sets sidebar.display, but the explicit navigation.sidebar config owns each group's display mode — the value is ignored.`,
320
+ severity: "warning",
321
+ suggestion:
322
+ "Set display on the matching group in navigation.sidebar, or remove the frontmatter key.",
323
+ });
324
+ continue;
325
+ }
326
+ if (isIndex && !page.navPath.includes("/")) {
327
+ diagnostics.push({
328
+ code: "BLUME_SIDEBAR_DISPLAY_IGNORED",
329
+ file: page.sourcePath ?? page.id,
330
+ message: `"${page.navPath}" sets sidebar.display, but the content root is not a sidebar group — the value is ignored.`,
331
+ severity: "warning",
332
+ suggestion:
333
+ "Set navigation.sidebar.display in blume.config to change the sidebar-wide mode.",
334
+ });
335
+ continue;
336
+ }
337
+ if (isIndex) {
338
+ continue;
339
+ }
340
+ diagnostics.push({
341
+ code: "BLUME_SIDEBAR_DISPLAY_IGNORED",
342
+ file: page.sourcePath ?? page.id,
343
+ message: `"${page.navPath}" sets sidebar.display, but only a folder's index page can set its group's display mode — the value is ignored.`,
344
+ severity: "warning",
345
+ suggestion:
346
+ "Move display to the folder's index page frontmatter, or set it in the folder's meta.ts.",
347
+ });
348
+ }
349
+ return diagnostics;
350
+ };
351
+
252
352
  /** Whether a node's `order` reflects a deliberate authoring choice. */
253
353
  const isAuthoredOrder = (node: MutableNode): boolean =>
254
354
  node.kind === "group" || node.orderIsAuthored;
@@ -319,20 +419,28 @@ const sortNodes = (nodes: MutableNode[], diagnostics: Diagnostic[]): void => {
319
419
  };
320
420
 
321
421
  /**
322
- * Hoist loose pages above groups so root-level pages read as top-level entries
323
- * rather than a group's trailing children (relative order otherwise preserved).
324
- * All display modes hoist the root level. Flat additionally recurses into every
325
- * group: there a group renders as a plain section header, so a loose page sorted
326
- * after a group would visually read as that group's last child.
422
+ * Hoist loose pages above groups so they read as their own level's entries
423
+ * rather than a preceding group's trailing children (relative order otherwise
424
+ * preserved). The root level always hoists, in every display mode. A deeper
425
+ * level hoists only when a sibling group renders flat a flat group is a
426
+ * plain section header, so a loose page sorted after it would visually read
427
+ * as its last child — while `group`/`page` groups are self-delimiting
428
+ * disclosure/drill-in rows, so authored interleaving is kept. Each group's
429
+ * render mode is its own resolved `display` falling back to the global mode,
430
+ * mirroring `toNavNode`.
327
431
  */
328
- const hoistPages = (nodes: MutableNode[], recurse: boolean): void => {
329
- const pages = nodes.filter((node) => node.kind === "page");
432
+ const hoistPages = (
433
+ nodes: MutableNode[],
434
+ display: SidebarDisplay,
435
+ hoist: boolean
436
+ ): void => {
330
437
  const groups = nodes.filter((node) => node.kind === "group");
331
- nodes.splice(0, nodes.length, ...pages, ...groups);
332
- if (recurse) {
333
- for (const group of groups) {
334
- hoistPages(group.children, recurse);
335
- }
438
+ if (hoist || groups.some((group) => (group.display ?? display) === "flat")) {
439
+ const pages = nodes.filter((node) => node.kind === "page");
440
+ nodes.splice(0, nodes.length, ...pages, ...groups);
441
+ }
442
+ for (const group of groups) {
443
+ hoistPages(group.children, display, false);
336
444
  }
337
445
  };
338
446
 
@@ -345,16 +453,16 @@ const hoistPages = (nodes: MutableNode[], recurse: boolean): void => {
345
453
  const hoistTabSections = (
346
454
  nodes: MutableNode[],
347
455
  tabPaths: Set<string>,
348
- recurse: boolean
456
+ display: SidebarDisplay
349
457
  ): void => {
350
458
  for (const node of nodes) {
351
459
  if (node.kind !== "group") {
352
460
  continue;
353
461
  }
354
462
  if (node.routePath !== undefined && tabPaths.has(node.routePath)) {
355
- hoistPages(node.children, recurse);
463
+ hoistPages(node.children, display, true);
356
464
  }
357
- hoistTabSections(node.children, tabPaths, recurse);
465
+ hoistTabSections(node.children, tabPaths, display);
358
466
  }
359
467
  };
360
468
 
@@ -374,7 +482,7 @@ const toNavNode = (node: MutableNode, display: SidebarDisplay): NavNode => {
374
482
  return {
375
483
  children: node.children.map((child) => toNavNode(child, display)),
376
484
  collapsed: node.collapsed,
377
- display,
485
+ display: node.display ?? display,
378
486
  icon: node.icon,
379
487
  kind: "group",
380
488
  label: node.label,
@@ -388,17 +496,22 @@ const buildFileSystemSidebar = (
388
496
  folderMeta: Map<string, FolderMeta>,
389
497
  sharedMeta: Map<string, FolderMeta>,
390
498
  metaPrefix: string,
499
+ sharedMetaPrefix: string,
391
500
  display: SidebarDisplay,
392
501
  tabPaths: Set<string>,
393
502
  diagnostics: Diagnostic[] = []
394
503
  ): NavNode[] => {
395
504
  const root = createGroup("", "", "", 0);
505
+ // Folder path -> `sidebar.display` from that folder's index page frontmatter.
506
+ // Collected before the hidden filter (like the title check): hiding the index
507
+ // row from the panel shouldn't stop it configuring its group.
508
+ const indexDisplay = new Map<string, SidebarDisplay>();
396
509
 
397
510
  for (const page of pages) {
398
511
  // Group by the locale-stripped path so the locale dir is not a nav group.
399
512
  const parts = page.navPath.split("/");
400
513
  const filename = parts.at(-1) ?? page.navPath;
401
- const stem = filename.replace(extname(filename), "");
514
+ const stem = stemOf(filename);
402
515
  const dirs = parts.slice(0, -1);
403
516
 
404
517
  // Checked before the hidden filter: a sidebar-hidden index page still
@@ -409,11 +522,19 @@ const buildFileSystemSidebar = (
409
522
  dirs.join("/"),
410
523
  folderMeta,
411
524
  sharedMeta,
412
- metaPrefix
525
+ metaPrefix,
526
+ sharedMetaPrefix
413
527
  );
414
528
  if (diagnostic) {
415
529
  diagnostics.push(diagnostic);
416
530
  }
531
+ // Fallback-filled pages are exempt (like the title check above): their
532
+ // frontmatter belongs to the fallback locale, and letting it through
533
+ // would override this locale's own authored `meta.ts` display — then
534
+ // silently flip again the day the index gets translated.
535
+ if (page.meta.sidebar.display && !page.fallback) {
536
+ indexDisplay.set(dirs.join("/"), page.meta.sidebar.display);
537
+ }
417
538
  }
418
539
 
419
540
  if (page.meta.sidebar.hidden) {
@@ -461,10 +582,17 @@ const buildFileSystemSidebar = (
461
582
  });
462
583
  }
463
584
 
464
- applyFolderMeta(root, folderMeta, sharedMeta, metaPrefix);
585
+ applyFolderMeta(
586
+ root,
587
+ folderMeta,
588
+ sharedMeta,
589
+ metaPrefix,
590
+ sharedMetaPrefix,
591
+ indexDisplay
592
+ );
465
593
  sortNodes(root.children, diagnostics);
466
- hoistPages(root.children, display === "flat");
467
- hoistTabSections(root.children, tabPaths, display === "flat");
594
+ hoistPages(root.children, display, true);
595
+ hoistTabSections(root.children, tabPaths, display);
468
596
  return root.children.map((child) => toNavNode(child, display));
469
597
  };
470
598
 
@@ -498,6 +626,10 @@ const routeForRef = (
498
626
  return byRoute.get(normalized)?.route ?? withBasePath(basePath, normalized);
499
627
  };
500
628
 
629
+ /** An explicit-config sidebar item written as a bare page-ref string. */
630
+ const isPageRef = (item: SidebarItemConfig): item is string =>
631
+ typeof item === "string";
632
+
501
633
  /**
502
634
  * Convert one non-group explicit-config sidebar item (string ref, `root`, or
503
635
  * `href`) to a nav node, or null to skip. Group items (`item.items`) are handled
@@ -508,7 +640,7 @@ const configItemToNode = (
508
640
  byRoute: Map<string, PageRecord>,
509
641
  basePath: string
510
642
  ): NavNode | null => {
511
- if (typeof item === "string") {
643
+ if (isPageRef(item)) {
512
644
  const page = byRoute.get(normalizeRef(item));
513
645
  if (!page) {
514
646
  return null;
@@ -561,7 +693,7 @@ const buildConfigSidebar = (
561
693
  ): NavNode[] => {
562
694
  const nodes: NavNode[] = [];
563
695
  for (const item of items) {
564
- if (typeof item !== "string" && item.items) {
696
+ if (!isPageRef(item) && item.items) {
565
697
  nodes.push({
566
698
  badge: item.badge,
567
699
  children: buildConfigSidebar(item.items, byRoute, display, basePath),
@@ -626,6 +758,60 @@ const withTabHrefs = (tabs: NavTab[], sidebar: NavNode[]): NavTab[] =>
626
758
  return href === tab.path ? tab : { ...tab, href };
627
759
  });
628
760
 
761
+ /**
762
+ * Rebase config-provided nav chrome (featured links, selectors, tabs) under the
763
+ * site base path. Config paths are authored as if mounted at root, so the base
764
+ * is applied here (idempotently, and only to internal paths — external URLs
765
+ * pass through). With no base this is a pure pass-through — the arrays keep
766
+ * their exact authored shape.
767
+ */
768
+ interface NavChrome {
769
+ featured: FeaturedLink[];
770
+ selectors: NavSelector[];
771
+ tabs: NavTab[];
772
+ }
773
+
774
+ const rebaseNavChrome = (
775
+ basePath: string,
776
+ options: {
777
+ featured?: FeaturedLink[];
778
+ selectors?: NavSelector[];
779
+ tabs?: NavTab[];
780
+ }
781
+ ): NavChrome => {
782
+ const featured = options.featured ?? [];
783
+ const selectors = options.selectors ?? [];
784
+ const tabs = options.tabs ?? [];
785
+ if (!basePath) {
786
+ return { featured, selectors, tabs };
787
+ }
788
+ const rebasePath = <T extends { path: string }>(item: T): T => ({
789
+ ...item,
790
+ path: withBasePath(basePath, item.path),
791
+ });
792
+ return {
793
+ featured: featured.map((link) => ({
794
+ ...link,
795
+ href: withBasePath(basePath, link.href),
796
+ })),
797
+ selectors: selectors.map((selector) => ({
798
+ ...selector,
799
+ items: selector.items.map(rebasePath),
800
+ })),
801
+ tabs: tabs.map((tab) => {
802
+ const rebased: NavTab = {
803
+ ...tab,
804
+ items: tab.items?.map(rebasePath),
805
+ path: withBasePath(basePath, tab.path),
806
+ };
807
+ if (tab.href) {
808
+ rebased.href = withBasePath(basePath, tab.href);
809
+ }
810
+ return rebased;
811
+ }),
812
+ };
813
+ };
814
+
629
815
  /** Build the complete navigation model from pages, meta, and config. */
630
816
  export const buildNavigation = (
631
817
  pages: PageRecord[],
@@ -639,8 +825,18 @@ export const buildNavigation = (
639
825
  selectors?: NavSelector[];
640
826
  tabs?: NavTab[];
641
827
  sidebar?: SidebarItemConfig[];
642
- /** Locale dir prefix for folder-meta lookup (`""` for the default locale). */
828
+ /**
829
+ * Folder-meta lookup prefix (`""` for the default locale of the current
830
+ * version): the version dir and/or locale dir hoisted in front of the
831
+ * group path, e.g. `fr`, `v1.0`, or `v1.0/fr`.
832
+ */
643
833
  metaPrefix?: string;
834
+ /**
835
+ * Prefix for shared `meta.$.*` lookups — the version dir inside a
836
+ * snapshot (`v1.0`), since shared meta is locale-agnostic but still
837
+ * version-specific. `""` for the current version.
838
+ */
839
+ sharedMetaPrefix?: string;
644
840
  /**
645
841
  * Resolve explicit-sidebar references against each page's locale-agnostic
646
842
  * `translationKey` instead of its localized `route`. Used under i18n so a
@@ -667,38 +863,13 @@ export const buildNavigation = (
667
863
  const basePath = options.basePath ?? "";
668
864
  const display = options.display ?? "flat";
669
865
  const metaPrefix = options.metaPrefix ?? "";
866
+ const sharedMetaPrefix = options.sharedMetaPrefix ?? "";
670
867
  const sharedFolderMeta = options.sharedFolderMeta ?? new Map();
671
868
 
672
- // Config-provided nav paths are authored as if mounted at root, so the base
673
- // is applied here (idempotently, and only to internal paths external URLs
674
- // pass through). Content-derived sidebar routes are already based via
675
- // `page.route`. The based tab paths also feed tab-scoping below, so they must
676
- // agree with the based content routes. With no base, this is a pure pass-
677
- // through — the arrays keep their exact authored shape.
678
- const rebasePath = <T extends { path: string }>(item: T): T => ({
679
- ...item,
680
- path: withBasePath(basePath, item.path),
681
- });
682
- const featured = basePath
683
- ? (options.featured ?? []).map((link) => ({
684
- ...link,
685
- href: withBasePath(basePath, link.href),
686
- }))
687
- : (options.featured ?? []);
688
- const selectors = basePath
689
- ? (options.selectors ?? []).map((selector) => ({
690
- ...selector,
691
- items: selector.items.map(rebasePath),
692
- }))
693
- : (options.selectors ?? []);
694
- const tabs = basePath
695
- ? (options.tabs ?? []).map((tab) => ({
696
- ...tab,
697
- ...(tab.href ? { href: withBasePath(basePath, tab.href) } : {}),
698
- items: tab.items?.map(rebasePath),
699
- path: withBasePath(basePath, tab.path),
700
- }))
701
- : (options.tabs ?? []);
869
+ // Content-derived sidebar routes are already based via `page.route`; the
870
+ // based tab paths also feed tab-scoping below, so they must agree with the
871
+ // based content routes.
872
+ const { featured, selectors, tabs } = rebaseNavChrome(basePath, options);
702
873
  const byRoute = new Map(
703
874
  pages.map((page) => [
704
875
  options.refByLogical ? page.translationKey : page.route,
@@ -727,6 +898,14 @@ export const buildNavigation = (
727
898
  // returned navigation so render-time scoping compares in the same space too.
728
899
  const rootTabPath = withBasePath(basePath, options.localizedRoot ?? "/");
729
900
 
901
+ // Emitted here, before the sidebar-mode branch: an explicit config sidebar
902
+ // ignores a stray `sidebar.display` just like the filesystem sidebar does
903
+ // (and ignores it on index pages too), so both paths must warn.
904
+ const diagnostics = options.diagnostics ?? [];
905
+ diagnostics.push(
906
+ ...sidebarDisplayIgnoredDiagnostics(pages, Boolean(options.sidebar))
907
+ );
908
+
730
909
  if (options.sidebar) {
731
910
  const sidebar = buildConfigSidebar(
732
911
  options.sidebar,
@@ -748,11 +927,12 @@ export const buildNavigation = (
748
927
  options.folderMeta,
749
928
  sharedFolderMeta,
750
929
  metaPrefix,
930
+ sharedMetaPrefix,
751
931
  display,
752
932
  new Set(
753
933
  tabs.flatMap((tab) => (tab.path === rootTabPath ? [] : [tab.path]))
754
934
  ),
755
- options.diagnostics
935
+ diagnostics
756
936
  );
757
937
  return {
758
938
  featured,
package/src/core/probe.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import pMap from "p-map";
2
+
1
3
  import type { DiagnosticSeverity } from "./types.ts";
2
4
 
3
5
  export const PROBE_CONCURRENCY = 8;
@@ -113,24 +115,10 @@ export const probeAll = async (
113
115
  options: { concurrency?: number; timeoutMs?: number } = {}
114
116
  ): Promise<Map<string, ProbeResult>> => {
115
117
  const unique = [...new Set(urls)];
116
- const results = new Map<string, ProbeResult>();
117
- const limit = Math.min(
118
- options.concurrency ?? PROBE_CONCURRENCY,
119
- unique.length
118
+ const entries = await pMap(
119
+ unique,
120
+ async (url) => [url, await probe(url, options)] as const,
121
+ { concurrency: Math.max(1, options.concurrency ?? PROBE_CONCURRENCY) }
120
122
  );
121
-
122
- let cursor = 0;
123
- const worker = async (): Promise<void> => {
124
- while (cursor < unique.length) {
125
- const url = unique[cursor];
126
- cursor += 1;
127
- if (url !== undefined) {
128
- // oxlint-disable-next-line no-await-in-loop -- bounded-concurrency pool
129
- results.set(url, await probe(url, options));
130
- }
131
- }
132
- };
133
- await Promise.all(Array.from({ length: limit }, worker));
134
-
135
- return results;
123
+ return new Map(entries);
136
124
  };
@@ -24,6 +24,7 @@ import type {
24
24
  PageRecord,
25
25
  ProjectContext,
26
26
  } from "./types.ts";
27
+ import { versionsDiagnostics } from "./versions.ts";
27
28
 
28
29
  /** Build mode: drafts are kept in `dev` and dropped in `build`. */
29
30
  export type BuildMode = "dev" | "build";
@@ -142,7 +143,7 @@ const entryIdDiagnostics = (
142
143
  const normalizeLoadedEntries = (
143
144
  loaded: ({ source: ContentSource } & SourceLoadResult)[],
144
145
  config: ResolvedConfig
145
- ): { pages: PageRecord[]; diagnostics: Diagnostic[]; droppedPages: number } => {
146
+ ) => {
146
147
  // Only thread `frontmatter.extend` / `content.types` through when a project
147
148
  // opts in, so the known-key split in `normalizeEntry` stays off the default
148
149
  // path.
@@ -177,6 +178,7 @@ const normalizeLoadedEntries = (
177
178
  staged: source.staged,
178
179
  },
179
180
  typeFrontmatter,
181
+ versions: config.versions,
180
182
  });
181
183
  if (normalized.pages.length === 0 && normalized.diagnostics.length > 0) {
182
184
  droppedPages += 1;
@@ -259,7 +261,21 @@ export const scanProject = async (
259
261
  Promise.all(
260
262
  sources.map(async (source) => ({ source, ...(await source.load()) }))
261
263
  ),
262
- discoverFolderMeta(metaSources, { localeDirs }),
264
+ discoverFolderMeta(metaSources, {
265
+ localeDirs,
266
+ versionDirs: config.versions?.archived.map((version) => version.id),
267
+ }),
268
+ ]);
269
+
270
+ // Folder meta contributed by the sources themselves (the OpenAPI source
271
+ // labels each tag directory with the spec's own tag name). It applies to
272
+ // every locale, so it merges into the shared map — beneath user-authored
273
+ // entries, which are spread last and win.
274
+ const sharedFolderMeta = new Map([
275
+ ...loaded.flatMap(({ folderMeta: sourceMeta }) =>
276
+ Object.entries(sourceMeta ?? {})
277
+ ),
278
+ ...folderMeta.shared,
263
279
  ]);
264
280
 
265
281
  const {
@@ -301,11 +317,17 @@ export const scanProject = async (
301
317
  folderMeta: folderMeta.meta,
302
318
  i18n: config.i18n,
303
319
  navigation: config.navigation,
304
- sharedFolderMeta: folderMeta.shared,
320
+ sharedFolderMeta,
321
+ versions: config.versions,
305
322
  });
306
323
  const manifest = buildManifest({ config, context, graph });
307
324
 
308
- const i18nWarnings = config.i18n ? i18nDiagnostics(pages, config.i18n) : [];
325
+ const i18nWarnings = config.i18n
326
+ ? i18nDiagnostics(pages, config.i18n, config.versions)
327
+ : [];
328
+ const versionWarnings = config.versions
329
+ ? versionsDiagnostics(pages, config.versions)
330
+ : [];
309
331
 
310
332
  return {
311
333
  config,
@@ -316,6 +338,7 @@ export const scanProject = async (
316
338
  ...entryIdDiagnostics(pages, resolveDocsCollection(config, context).base),
317
339
  ...graph.diagnostics,
318
340
  ...i18nWarnings,
341
+ ...versionWarnings,
319
342
  ],
320
343
  droppedPages,
321
344
  graph,