blume 0.6.7 → 0.8.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 (211) hide show
  1. package/CHANGELOG.md +618 -0
  2. package/LICENSE +21 -0
  3. package/README.md +107 -0
  4. package/dist/cli/index.js +2609 -1041
  5. package/dist/cli/index.js.map +110 -103
  6. package/dist/types/ai/component-markdown.d.ts +34 -0
  7. package/dist/types/components/content/youtube.d.ts +18 -0
  8. package/dist/types/core/base-path.d.ts +47 -0
  9. package/dist/types/core/config-input.d.ts +110 -12
  10. package/dist/types/core/config.d.ts +6 -4
  11. package/dist/types/core/data.d.ts +4 -0
  12. package/dist/types/core/i18n-ui.d.ts +477 -135
  13. package/dist/types/core/schema.d.ts +309 -195
  14. package/dist/types/core/sources/types.d.ts +2 -0
  15. package/dist/types/core/types.d.ts +6 -1
  16. package/dist/types/index.d.ts +1 -0
  17. package/dist/types/openapi/references.d.ts +60 -0
  18. package/docs/01-quickstart.mdx +5 -2
  19. package/docs/02-deployment.mdx +24 -9
  20. package/docs/03-faq.mdx +46 -16
  21. package/docs/advanced/custom-pages.mdx +1 -1
  22. package/docs/advanced/skills.mdx +1 -1
  23. package/docs/configuration/ai.mdx +49 -10
  24. package/docs/configuration/customization.mdx +11 -0
  25. package/docs/configuration/index.mdx +33 -3
  26. package/docs/configuration/seo.mdx +2 -2
  27. package/docs/content/components.mdx +30 -3
  28. package/docs/content/i18n.mdx +1 -1
  29. package/docs/content/islands.mdx +8 -0
  30. package/docs/content/navigation.mdx +3 -3
  31. package/docs/content/sources.mdx +1 -1
  32. package/docs/content/syntax.mdx +17 -2
  33. package/docs/index.mdx +2 -2
  34. package/docs/reference/cli.mdx +8 -6
  35. package/package.json +15 -4
  36. package/skills/blume/SKILL.md +5 -3
  37. package/skills/blume-update-docs/SKILL.md +3 -2
  38. package/src/ai/agent-readability.ts +11 -5
  39. package/src/ai/ask-context.ts +7 -2
  40. package/src/ai/ask-data.ts +3 -0
  41. package/src/ai/ask.ts +12 -7
  42. package/src/ai/component-markdown.ts +461 -0
  43. package/src/ai/llms.ts +143 -23
  44. package/src/ai/markdown.ts +35 -6
  45. package/src/ai/mcp/data.ts +33 -8
  46. package/src/ai/mcp/discovery.ts +10 -3
  47. package/src/ai/mcp/server.ts +24 -7
  48. package/src/ai/visibility.ts +74 -0
  49. package/src/astro/component-slots.ts +16 -4
  50. package/src/astro/examples.ts +12 -7
  51. package/src/astro/generate.ts +393 -189
  52. package/src/astro/index.ts +5 -1
  53. package/src/astro/integration.ts +9 -5
  54. package/src/astro/islands.ts +11 -5
  55. package/src/astro/markdown-negotiation.ts +2 -2
  56. package/src/astro/pages.ts +89 -22
  57. package/src/astro/templates.ts +259 -25
  58. package/src/blume-modules.d.ts +8 -0
  59. package/src/cli/commands/build.ts +131 -38
  60. package/src/cli/commands/check.ts +1 -1
  61. package/src/cli/commands/dev.ts +71 -17
  62. package/src/cli/commands/doctor.ts +2 -2
  63. package/src/cli/commands/eject.ts +47 -19
  64. package/src/cli/commands/init.ts +120 -180
  65. package/src/cli/commands/preview.ts +4 -1
  66. package/src/cli/commands/validate.ts +44 -2
  67. package/src/cli/dev-lock.ts +34 -19
  68. package/src/cli/eject-scripts.ts +72 -0
  69. package/src/cli/env.ts +15 -5
  70. package/src/cli/init/questions.ts +158 -0
  71. package/src/cli/init/scaffold.ts +380 -0
  72. package/src/cli/required-secrets.ts +2 -1
  73. package/src/components/content/AccordionItem.astro +23 -4
  74. package/src/components/content/Badge.astro +3 -1
  75. package/src/components/content/Card.astro +4 -2
  76. package/src/components/content/CodeBlock.astro +3 -0
  77. package/src/components/content/Component.astro +30 -16
  78. package/src/components/content/Diff.astro +3 -1
  79. package/src/components/content/Step.astro +10 -1
  80. package/src/components/content/Tabs.astro +15 -3
  81. package/src/components/content/Tile.astro +2 -1
  82. package/src/components/content/Tooltip.astro +3 -1
  83. package/src/components/content/Update.astro +9 -2
  84. package/src/components/content/auto-type-table.ts +25 -9
  85. package/src/components/content/base-href.ts +33 -0
  86. package/src/components/content/changelog-element.ts +9 -2
  87. package/src/components/content/diff.ts +12 -6
  88. package/src/components/content/mermaid-element.ts +10 -2
  89. package/src/components/index.ts +23 -1
  90. package/src/components/islands/AskAI.astro +5 -2
  91. package/src/components/islands/ask-ai.tsx +68 -12
  92. package/src/components/islands/base-path.ts +28 -0
  93. package/src/components/islands/hooks.ts +44 -9
  94. package/src/components/layout/Banner.astro +12 -3
  95. package/src/components/layout/Breadcrumbs.astro +2 -1
  96. package/src/components/layout/Favicon.astro +3 -2
  97. package/src/components/layout/Header.astro +15 -5
  98. package/src/components/layout/LanguageSwitcher.astro +2 -1
  99. package/src/components/layout/Logo.astro +13 -4
  100. package/src/components/layout/NavSelector.astro +2 -1
  101. package/src/components/layout/NavTree.astro +22 -7
  102. package/src/components/layout/PageActions.astro +25 -10
  103. package/src/components/layout/PageFeedback.astro +4 -1
  104. package/src/components/layout/PageLayout.astro +51 -9
  105. package/src/components/layout/Pagination.astro +3 -2
  106. package/src/components/layout/ReferenceLayout.astro +8 -1
  107. package/src/components/layout/RootLayout.astro +74 -13
  108. package/src/components/layout/Search.astro +107 -27
  109. package/src/components/layout/nav-utils.ts +18 -10
  110. package/src/components/layout/search/algolia.ts +11 -2
  111. package/src/components/layout/search/endpoint.ts +11 -5
  112. package/src/components/layout/search/orama-cloud.ts +8 -2
  113. package/src/components/layout/search/pagefind.ts +3 -0
  114. package/src/components/layout/search/types.ts +5 -1
  115. package/src/components/layout/search/typesense.ts +4 -1
  116. package/src/components/layout/toc-element.ts +8 -2
  117. package/src/components/openapi/ApiTagOperations.astro +2 -1
  118. package/src/components/openapi/Operation.astro +47 -40
  119. package/src/components/openapi/RequestPanel.astro +8 -2
  120. package/src/components/openapi/helpers.ts +71 -3
  121. package/src/components/openapi/panel.ts +1 -1
  122. package/src/components/openapi/snippets.ts +25 -11
  123. package/src/core/base-path.ts +94 -0
  124. package/src/core/builtin-tags.ts +2 -0
  125. package/src/core/component-overrides.ts +103 -74
  126. package/src/core/config-input.ts +118 -17
  127. package/src/core/config.ts +8 -5
  128. package/src/core/content.ts +2 -0
  129. package/src/core/data.ts +4 -0
  130. package/src/core/diagnostics.ts +54 -34
  131. package/src/core/gitignore.ts +4 -1
  132. package/src/core/graph.ts +166 -88
  133. package/src/core/i18n-ui.ts +63 -3
  134. package/src/core/last-modified.ts +15 -6
  135. package/src/core/links.ts +69 -25
  136. package/src/core/manifest.ts +62 -45
  137. package/src/core/nav-diagnostics.ts +1 -1
  138. package/src/core/navigation.ts +144 -58
  139. package/src/core/package-json.ts +17 -2
  140. package/src/core/project-graph.ts +25 -15
  141. package/src/core/schema.ts +605 -620
  142. package/src/core/sources/assets.ts +6 -1
  143. package/src/core/sources/filesystem.ts +4 -0
  144. package/src/core/sources/github-releases.ts +2 -1
  145. package/src/core/sources/mdx-remote.ts +76 -63
  146. package/src/core/sources/normalize.ts +236 -91
  147. package/src/core/sources/notion.ts +27 -18
  148. package/src/core/sources/types.ts +2 -0
  149. package/src/core/tsconfig-aliases.ts +59 -30
  150. package/src/core/types.ts +6 -1
  151. package/src/core/ui-packs/ar.ts +1 -0
  152. package/src/core/ui-packs/bg.ts +1 -0
  153. package/src/core/ui-packs/bn.ts +1 -0
  154. package/src/core/ui-packs/ca.ts +1 -0
  155. package/src/core/ui-packs/cs.ts +1 -0
  156. package/src/core/ui-packs/da.ts +1 -0
  157. package/src/core/ui-packs/de.ts +1 -0
  158. package/src/core/ui-packs/el.ts +1 -0
  159. package/src/core/ui-packs/es.ts +1 -0
  160. package/src/core/ui-packs/fa.ts +1 -0
  161. package/src/core/ui-packs/fi.ts +1 -0
  162. package/src/core/ui-packs/fr.ts +2 -1
  163. package/src/core/ui-packs/he.ts +1 -0
  164. package/src/core/ui-packs/hi.ts +1 -0
  165. package/src/core/ui-packs/hr.ts +1 -0
  166. package/src/core/ui-packs/hu.ts +1 -0
  167. package/src/core/ui-packs/id.ts +1 -0
  168. package/src/core/ui-packs/it.ts +1 -0
  169. package/src/core/ui-packs/ja.ts +1 -0
  170. package/src/core/ui-packs/ko.ts +1 -0
  171. package/src/core/ui-packs/nl.ts +1 -0
  172. package/src/core/ui-packs/no.ts +1 -0
  173. package/src/core/ui-packs/pl.ts +1 -0
  174. package/src/core/ui-packs/pt-br.ts +1 -0
  175. package/src/core/ui-packs/pt.ts +1 -0
  176. package/src/core/ui-packs/ro.ts +1 -0
  177. package/src/core/ui-packs/ru.ts +1 -0
  178. package/src/core/ui-packs/sk.ts +1 -0
  179. package/src/core/ui-packs/sr.ts +1 -0
  180. package/src/core/ui-packs/sv.ts +1 -0
  181. package/src/core/ui-packs/th.ts +1 -0
  182. package/src/core/ui-packs/tr.ts +1 -0
  183. package/src/core/ui-packs/uk.ts +1 -0
  184. package/src/core/ui-packs/vi.ts +1 -0
  185. package/src/core/ui-packs/zh-tw.ts +1 -0
  186. package/src/core/ui-packs/zh.ts +1 -0
  187. package/src/deploy/adapter-output.ts +18 -8
  188. package/src/deploy/redirects.ts +25 -2
  189. package/src/deploy/robots.ts +6 -1
  190. package/src/deploy/rss.ts +10 -3
  191. package/src/deploy/sitemap.ts +59 -13
  192. package/src/index.ts +5 -0
  193. package/src/markdown/base-links.ts +60 -0
  194. package/src/markdown/code-title.ts +11 -14
  195. package/src/markdown/index.ts +46 -9
  196. package/src/markdown/inline-code.ts +14 -4
  197. package/src/markdown/package-commands.ts +10 -4
  198. package/src/markdown/themes.ts +24 -0
  199. package/src/openapi/model.ts +15 -5
  200. package/src/openapi/parse.ts +21 -0
  201. package/src/openapi/references.ts +75 -21
  202. package/src/openapi/render-mdx.ts +11 -6
  203. package/src/openapi/scalar.ts +32 -16
  204. package/src/openapi/source.ts +59 -10
  205. package/src/registry/eject.ts +247 -19
  206. package/src/registry/registry.ts +0 -3
  207. package/src/search/build.ts +3 -0
  208. package/src/search/documents.ts +36 -4
  209. package/src/search/sync/typesense.ts +6 -4
  210. package/src/seo/jsonld.ts +28 -17
  211. package/src/theme/entry.ts +85 -20
@@ -1,3 +1,4 @@
1
+ import { withBasePath } from "./base-path.ts";
1
2
  import { localizeRoute, resolveFallbackLocale } from "./i18n.ts";
2
3
  import type { ResolvedConfig } from "./schema.ts";
3
4
  import type {
@@ -26,6 +27,64 @@ export const contentIndexable = (
26
27
  !page.meta.search.exclude &&
27
28
  (!page.meta.sidebar.hidden || config.search.indexing.includeHiddenPages);
28
29
 
30
+ /**
31
+ * Fallback materialization: render the fallback locale's content at the
32
+ * localized URL for any translation a non-default locale is missing, so static
33
+ * output is fully prerendered (render-fallback, no client redirect). Fallback
34
+ * routes are not indexed and carry no `hreflang` of their own.
35
+ */
36
+ const buildFallbackRoutes = (
37
+ graph: ContentGraph,
38
+ i18n: NonNullable<ResolvedConfig["i18n"]>,
39
+ alternatesByKey: Map<string, RouteAlternate[]>,
40
+ basePath: string
41
+ ): RouteManifestEntry[] => {
42
+ const fallback = resolveFallbackLocale(i18n);
43
+ if (!fallback) {
44
+ return [];
45
+ }
46
+ const fallbackPages = new Map(
47
+ graph.pages.flatMap((page) =>
48
+ page.locale === fallback ? [[page.translationKey, page] as const] : []
49
+ )
50
+ );
51
+ const routes: RouteManifestEntry[] = [];
52
+ for (const { code } of i18n.locales) {
53
+ if (code === fallback) {
54
+ continue;
55
+ }
56
+ const present = new Set(
57
+ graph.pages.flatMap((page) =>
58
+ page.locale === code ? [page.translationKey] : []
59
+ )
60
+ );
61
+ for (const [key, source] of fallbackPages) {
62
+ if (present.has(key)) {
63
+ continue;
64
+ }
65
+ routes.push({
66
+ alternates: alternatesByKey.get(key) ?? [],
67
+ collection: source.collection ?? "docs",
68
+ contentType: source.contentType,
69
+ draft: source.meta.draft,
70
+ editUrl: source.editUrl,
71
+ entryId: source.entryId ?? source.source.ref,
72
+ fallback: true,
73
+ hidden: source.meta.sidebar.hidden,
74
+ id: source.id,
75
+ indexable: false,
76
+ lastModified: source.lastModified,
77
+ locale: code,
78
+ path: withBasePath(basePath, localizeRoute(key, code, i18n)),
79
+ source: source.source,
80
+ sourcePath: source.sourcePath,
81
+ title: source.title,
82
+ });
83
+ }
84
+ }
85
+ return routes;
86
+ };
87
+
29
88
  /** Build the runtime manifest that bridges core and the generated Astro app. */
30
89
  export const buildManifest = (options: {
31
90
  context: ProjectContext;
@@ -65,52 +124,10 @@ export const buildManifest = (options: {
65
124
  title: page.title,
66
125
  }));
67
126
 
68
- // Fallback materialization: render the fallback locale's content at the
69
- // localized URL for any translation a non-default locale is missing, so static
70
- // output is fully prerendered (render-fallback, no client redirect). Fallback
71
- // routes are not indexed and carry no `hreflang` of their own.
72
127
  if (i18n) {
73
- const fallback = resolveFallbackLocale(i18n);
74
- if (fallback) {
75
- const fallbackPages = new Map(
76
- graph.pages
77
- .filter((page) => page.locale === fallback)
78
- .map((page) => [page.translationKey, page] as const)
79
- );
80
- for (const { code } of i18n.locales) {
81
- if (code === fallback) {
82
- continue;
83
- }
84
- const present = new Set(
85
- graph.pages
86
- .filter((page) => page.locale === code)
87
- .map((page) => page.translationKey)
88
- );
89
- for (const [key, source] of fallbackPages) {
90
- if (present.has(key)) {
91
- continue;
92
- }
93
- routes.push({
94
- alternates: alternatesByKey.get(key) ?? [],
95
- collection: source.collection ?? "docs",
96
- contentType: source.contentType,
97
- draft: source.meta.draft,
98
- editUrl: source.editUrl,
99
- entryId: source.entryId ?? source.source.ref,
100
- fallback: true,
101
- hidden: source.meta.sidebar.hidden,
102
- id: source.id,
103
- indexable: false,
104
- lastModified: source.lastModified,
105
- locale: code,
106
- path: localizeRoute(key, code, i18n),
107
- source: source.source,
108
- sourcePath: source.sourcePath,
109
- title: source.title,
110
- });
111
- }
112
- }
113
- }
128
+ routes.push(
129
+ ...buildFallbackRoutes(graph, i18n, alternatesByKey, config.basePath)
130
+ );
114
131
  }
115
132
 
116
133
  routes.sort((a, b) => a.path.localeCompare(b.path));
@@ -157,7 +157,7 @@ const hiddenInSidebarDiagnostics = (
157
157
  pages: PageRecord[]
158
158
  ): Diagnostic[] => {
159
159
  const hidden = new Set(
160
- pages.filter((page) => page.meta.sidebar.hidden).map((page) => page.id)
160
+ pages.flatMap((page) => (page.meta.sidebar.hidden ? [page.id] : []))
161
161
  );
162
162
  if (hidden.size === 0) {
163
163
  return [];
@@ -1,5 +1,6 @@
1
1
  import { extname } from "pathe";
2
2
 
3
+ import { stripBasePath, withBasePath } from "./base-path.ts";
3
4
  import type {
4
5
  FolderMeta,
5
6
  SidebarDisplay,
@@ -28,7 +29,7 @@ const humanize = (segment: string): string =>
28
29
 
29
30
  const numericOrder = (segment: string): number => {
30
31
  const value = segment.match(NUMERIC_PREFIX)?.groups?.order;
31
- return value ? Number.parseInt(value, 10) : Number.POSITIVE_INFINITY;
32
+ return value ? Math.trunc(Number(value)) : Number.POSITIVE_INFINITY;
32
33
  };
33
34
 
34
35
  /** The nav key of a raw path segment: group label or numeric-stripped name. */
@@ -37,6 +38,14 @@ const segmentKey = (raw: string): string => {
37
38
  return (group ?? raw).replace(NUMERIC_PREFIX, "");
38
39
  };
39
40
 
41
+ /**
42
+ * Whether a filename stem is a directory index, ignoring an ordering prefix:
43
+ * route mapping strips the prefix before dropping `index`, so `01-index` routes
44
+ * exactly like `index` and must be treated as one here too.
45
+ */
46
+ const isIndexStem = (stem: string): boolean =>
47
+ stem.replace(NUMERIC_PREFIX, "") === "index";
48
+
40
49
  interface MutablePage {
41
50
  kind: "page";
42
51
  key: string;
@@ -105,7 +114,7 @@ const pageOrder = (page: PageRecord, filename: string): number => {
105
114
  if (page.meta.sidebar.order !== undefined) {
106
115
  return page.meta.sidebar.order;
107
116
  }
108
- if (filename.replace(extname(filename), "") === "index") {
117
+ if (isIndexStem(filename.replace(extname(filename), ""))) {
109
118
  return Number.NEGATIVE_INFINITY;
110
119
  }
111
120
  // Changelog entries read newest-first, matching the generated timeline. Sort
@@ -276,8 +285,9 @@ const buildFileSystemSidebar = (
276
285
  // An index page's route IS its folder's route (no page segment to drop),
277
286
  // and `(group)` folders contribute no route segment at all.
278
287
  const routeSegments = page.route.split("/").filter(Boolean);
279
- const folderParts =
280
- stem === "index" ? routeSegments : routeSegments.slice(0, -1);
288
+ const folderParts = isIndexStem(stem)
289
+ ? routeSegments
290
+ : routeSegments.slice(0, -1);
281
291
  const routeDirCount = dirs.filter((dir) => !GROUP_FOLDER.test(dir)).length;
282
292
  const offset = Math.max(0, folderParts.length - routeDirCount);
283
293
 
@@ -326,82 +336,100 @@ const normalizeRef = (ref: string): string => {
326
336
 
327
337
  const routeForRef = (
328
338
  ref: string | undefined,
329
- byRoute: Map<string, PageRecord>
339
+ byRoute: Map<string, PageRecord>,
340
+ basePath: string
330
341
  ): string | undefined => {
331
342
  if (!ref) {
332
343
  return undefined;
333
344
  }
334
345
  const normalized = normalizeRef(ref);
335
- return byRoute.get(normalized)?.route ?? normalized;
346
+ // A matched page carries an already-based `route`; an unmatched ref is an
347
+ // author-written root-relative path that still needs the base applied.
348
+ return byRoute.get(normalized)?.route ?? withBasePath(basePath, normalized);
349
+ };
350
+
351
+ /**
352
+ * Convert one non-group explicit-config sidebar item (string ref, `root`, or
353
+ * `href`) to a nav node, or null to skip. Group items (`item.items`) are handled
354
+ * by `buildConfigSidebar` itself so it owns the recursion.
355
+ */
356
+ const configItemToNode = (
357
+ item: SidebarItemConfig,
358
+ byRoute: Map<string, PageRecord>,
359
+ basePath: string
360
+ ): NavNode | null => {
361
+ if (typeof item === "string") {
362
+ const page = byRoute.get(normalizeRef(item));
363
+ if (!page) {
364
+ return null;
365
+ }
366
+ return {
367
+ badge: page.meta.sidebar.badge,
368
+ deprecated: page.meta.deprecated || undefined,
369
+ description: page.description,
370
+ icon: page.meta.sidebar.icon,
371
+ kind: "page",
372
+ label: page.meta.sidebar.label ?? page.title,
373
+ pageId: page.id,
374
+ route: page.route,
375
+ };
376
+ }
377
+
378
+ if (item.root) {
379
+ const page = byRoute.get(normalizeRef(item.root));
380
+ return {
381
+ badge: item.badge,
382
+ deprecated: page?.meta.deprecated || undefined,
383
+ icon: item.icon,
384
+ kind: "page",
385
+ label: item.label,
386
+ pageId: page?.id ?? "",
387
+ route: page?.route ?? withBasePath(basePath, normalizeRef(item.root)),
388
+ };
389
+ }
390
+
391
+ if (item.href) {
392
+ return {
393
+ badge: item.badge,
394
+ icon: item.icon,
395
+ kind: "page",
396
+ label: item.label,
397
+ pageId: "",
398
+ route: withBasePath(basePath, item.href),
399
+ };
400
+ }
401
+
402
+ return null;
336
403
  };
337
404
 
338
405
  /** Build the sidebar tree from an explicit config spec. */
339
406
  const buildConfigSidebar = (
340
407
  items: SidebarItemConfig[],
341
408
  byRoute: Map<string, PageRecord>,
342
- display: SidebarDisplay
409
+ display: SidebarDisplay,
410
+ basePath: string
343
411
  ): NavNode[] => {
344
412
  const nodes: NavNode[] = [];
345
-
346
413
  for (const item of items) {
347
- if (typeof item === "string") {
348
- const page = byRoute.get(normalizeRef(item));
349
- if (page) {
350
- nodes.push({
351
- badge: page.meta.sidebar.badge,
352
- deprecated: page.meta.deprecated || undefined,
353
- description: page.description,
354
- icon: page.meta.sidebar.icon,
355
- kind: "page",
356
- label: page.meta.sidebar.label ?? page.title,
357
- pageId: page.id,
358
- route: page.route,
359
- });
360
- }
361
- continue;
362
- }
363
-
364
- if (item.items) {
414
+ if (typeof item !== "string" && item.items) {
365
415
  nodes.push({
366
416
  badge: item.badge,
367
- children: buildConfigSidebar(item.items, byRoute, display),
417
+ children: buildConfigSidebar(item.items, byRoute, display, basePath),
368
418
  collapsed: item.collapsed,
369
419
  directory: item.directory,
370
420
  display: item.display ?? display,
371
421
  icon: item.icon,
372
422
  kind: "group",
373
423
  label: item.label,
374
- route: routeForRef(item.root, byRoute),
375
- });
376
- continue;
377
- }
378
-
379
- if (item.root) {
380
- const page = byRoute.get(normalizeRef(item.root));
381
- nodes.push({
382
- badge: item.badge,
383
- deprecated: page?.meta.deprecated || undefined,
384
- icon: item.icon,
385
- kind: "page",
386
- label: item.label,
387
- pageId: page?.id ?? "",
388
- route: page?.route ?? normalizeRef(item.root),
424
+ route: routeForRef(item.root, byRoute, basePath),
389
425
  });
390
426
  continue;
391
427
  }
392
-
393
- if (item.href) {
394
- nodes.push({
395
- badge: item.badge,
396
- icon: item.icon,
397
- kind: "page",
398
- label: item.label,
399
- pageId: "",
400
- route: item.href,
401
- });
428
+ const node = configItemToNode(item, byRoute, basePath);
429
+ if (node) {
430
+ nodes.push(node);
402
431
  }
403
432
  }
404
-
405
433
  return nodes;
406
434
  };
407
435
 
@@ -409,6 +437,8 @@ const buildConfigSidebar = (
409
437
  export const buildNavigation = (
410
438
  pages: PageRecord[],
411
439
  options: {
440
+ /** Site-wide route mount point (`""` or `/seg`); applied to config paths. */
441
+ basePath?: string;
412
442
  folderMeta: Map<string, FolderMeta>;
413
443
  /** Global display mode for every sidebar group (default `flat`). */
414
444
  display?: SidebarDisplay;
@@ -426,30 +456,84 @@ export const buildNavigation = (
426
456
  refByLogical?: boolean;
427
457
  /** Shared `meta.$.*` meta, keyed by locale-stripped dir path. */
428
458
  sharedFolderMeta?: Map<string, FolderMeta>;
459
+ /**
460
+ * The tree's root route before `basePath` (`"/"`, or the locale prefix
461
+ * under i18n, e.g. `/fr` — tab paths arrive already localized). The tab
462
+ * pointing here spans the whole tree, so it is excluded from tab-section
463
+ * scoping.
464
+ */
465
+ localizedRoot?: string;
429
466
  }
430
467
  ): Navigation => {
431
- const featured = options.featured ?? [];
432
- const selectors = options.selectors ?? [];
433
- const tabs = options.tabs ?? [];
468
+ const basePath = options.basePath ?? "";
434
469
  const display = options.display ?? "flat";
435
470
  const metaPrefix = options.metaPrefix ?? "";
436
471
  const sharedFolderMeta = options.sharedFolderMeta ?? new Map();
472
+
473
+ // Config-provided nav paths are authored as if mounted at root, so the base
474
+ // is applied here (idempotently, and only to internal paths — external URLs
475
+ // pass through). Content-derived sidebar routes are already based via
476
+ // `page.route`. The based tab paths also feed tab-scoping below, so they must
477
+ // agree with the based content routes. With no base, this is a pure pass-
478
+ // through — the arrays keep their exact authored shape.
479
+ const rebasePath = <T extends { path: string }>(item: T): T => ({
480
+ ...item,
481
+ path: withBasePath(basePath, item.path),
482
+ });
483
+ const featured = basePath
484
+ ? (options.featured ?? []).map((link) => ({
485
+ ...link,
486
+ href: withBasePath(basePath, link.href),
487
+ }))
488
+ : (options.featured ?? []);
489
+ const selectors = basePath
490
+ ? (options.selectors ?? []).map((selector) => ({
491
+ ...selector,
492
+ items: selector.items.map(rebasePath),
493
+ }))
494
+ : (options.selectors ?? []);
495
+ const tabs = basePath
496
+ ? (options.tabs ?? []).map((tab) => ({
497
+ ...tab,
498
+ items: tab.items?.map(rebasePath),
499
+ path: withBasePath(basePath, tab.path),
500
+ }))
501
+ : (options.tabs ?? []);
437
502
  const byRoute = new Map(
438
503
  pages.map((page) => [
439
504
  options.refByLogical ? page.translationKey : page.route,
440
505
  page,
441
506
  ])
442
507
  );
508
+ // Explicit-sidebar refs (`"foo/index"`) are authored as if mounted at root,
509
+ // but `page.route` carries the base — alias each page under its base-less
510
+ // route so a bare ref still resolves. (The i18n `refByLogical` map is already
511
+ // keyed by the base-less `translationKey`, so it needs no alias.)
512
+ if (basePath && !options.refByLogical) {
513
+ for (const page of pages) {
514
+ const bare = stripBasePath(basePath, page.route);
515
+ if (!byRoute.has(bare)) {
516
+ byRoute.set(bare, page);
517
+ }
518
+ }
519
+ }
443
520
 
444
521
  if (options.sidebar) {
445
522
  return {
446
523
  featured,
447
524
  selectors,
448
- sidebar: buildConfigSidebar(options.sidebar, byRoute, display),
525
+ sidebar: buildConfigSidebar(options.sidebar, byRoute, display, basePath),
449
526
  tabs,
450
527
  };
451
528
  }
452
529
 
530
+ // A tab pointing at the tree root spans the whole sidebar rather than one
531
+ // section, so it must not feed tab-section hoisting. `tabs` carries final
532
+ // paths (localized, then based), so the root is compared in the same space —
533
+ // a root-level `(group)` folder's routePath is exactly the based/localized
534
+ // prefix (`/docs`, `/fr`) and a bare `"/"` check would miss the match (or,
535
+ // under a base, falsely scope a group named like the prefix).
536
+ const rootTabPath = withBasePath(basePath, options.localizedRoot ?? "/");
453
537
  return {
454
538
  featured,
455
539
  selectors,
@@ -459,7 +543,9 @@ export const buildNavigation = (
459
543
  sharedFolderMeta,
460
544
  metaPrefix,
461
545
  display,
462
- new Set(tabs.map((tab) => tab.path).filter((path) => path !== "/"))
546
+ new Set(
547
+ tabs.flatMap((tab) => (tab.path === rootTabPath ? [] : [tab.path]))
548
+ )
463
549
  ),
464
550
  tabs,
465
551
  };
@@ -15,8 +15,22 @@ export const toPackageName = (raw: string): string =>
15
15
  * dependency pinned to the installed version plus `dev`/`build`/`doctor`
16
16
  * scripts, so `npm install && npm run dev` works immediately. Shared by
17
17
  * `blume init` and the migrators, which scaffold one when a project has none.
18
+ * `extraDeps` adds source SDKs (e.g. `@notionhq/client`) beside `blume`.
18
19
  */
19
- export const blumePackageJson = (name: string): string => `{
20
+ export const blumePackageJson = (
21
+ name: string,
22
+ extraDeps: Record<string, string> = {}
23
+ ): string => {
24
+ const dependencies = Object.entries({
25
+ blume: `^${getBlumeVersion()}`,
26
+ ...extraDeps,
27
+ })
28
+ .toSorted(([a], [b]) => (a < b ? -1 : 1))
29
+ .map(
30
+ ([dep, range]) => ` ${JSON.stringify(dep)}: ${JSON.stringify(range)}`
31
+ )
32
+ .join(",\n");
33
+ return `{
20
34
  "name": ${JSON.stringify(name)},
21
35
  "private": true,
22
36
  "type": "module",
@@ -26,7 +40,8 @@ export const blumePackageJson = (name: string): string => `{
26
40
  "doctor": "blume doctor"
27
41
  },
28
42
  "dependencies": {
29
- "blume": "^${getBlumeVersion()}"
43
+ ${dependencies}
30
44
  }
31
45
  }
32
46
  `;
47
+ };
@@ -1,6 +1,7 @@
1
1
  import { relative } from "pathe";
2
2
 
3
3
  import { loadConfig } from "./config.ts";
4
+ import { applyDeploymentEnv } from "./deployment-env.ts";
4
5
  import { buildContentGraph } from "./graph.ts";
5
6
  import { i18nDiagnostics } from "./i18n.ts";
6
7
  import {
@@ -108,7 +109,7 @@ const entryIdDiagnostics = (
108
109
  message: `Content source "${page.source.name}" is rooted outside the docs collection base, so ${page.route} resolves entry id "${entryId}" but the collection would generate "${expected}" — the page would 404 at runtime.`,
109
110
  severity: "error",
110
111
  suggestion:
111
- "Give each filesystem source a root under content.root, or use a single filesystem source so the collection can root at it.",
112
+ "Use a single filesystem source (the docs collection roots at it), or root every filesystem source at content.root and partition them with include globs — a root at a subdirectory of content.root still mismatches.",
112
113
  });
113
114
  }
114
115
  }
@@ -139,7 +140,13 @@ export const scanProject = async (
139
140
  const configResult = await loadConfig(root, {
140
141
  devServerUrl: options.devServerUrl,
141
142
  });
142
- const config = applyConfigOverrides(configResult.config, options.overrides);
143
+ // Re-run platform detection after CLI overrides: `loadConfig` already ran it,
144
+ // but adapter inference keys off `deployment.output`, which `--output server`
145
+ // only sets here. Idempotent for already-resolved fields — `deployment.site`
146
+ // keeps loadConfig's explicit > platform env > devServerUrl precedence.
147
+ const config = applyDeploymentEnv(
148
+ applyConfigOverrides(configResult.config, options.overrides)
149
+ );
143
150
  const context = resolveProjectContext(root, config, {
144
151
  runtimeDir: options.runtimeDir,
145
152
  });
@@ -158,12 +165,11 @@ export const scanProject = async (
158
165
  // Folder meta is discovered per filesystem source, under each source's own
159
166
  // root and keyed by its route prefix, so a prefixed/root-differing source's
160
167
  // `meta.ts` still lines up with its (prefixed) sidebar group path.
161
- const metaSources: FolderMetaSource[] = sources
162
- .filter((source) => !source.staged && source.contentRoot)
163
- .map((source) => ({
164
- prefix: source.prefix,
165
- root: source.contentRoot ?? "",
166
- }));
168
+ const metaSources: FolderMetaSource[] = sources.flatMap((source) =>
169
+ source.staged || !source.contentRoot
170
+ ? []
171
+ : [{ prefix: source.prefix, root: source.contentRoot }]
172
+ );
167
173
 
168
174
  // Run every source's `load()` in parallel, then funnel each entry through the
169
175
  // shared `normalizeEntry` so route mapping is identical regardless of origin.
@@ -172,9 +178,9 @@ export const scanProject = async (
172
178
  // `discoverFolderMeta`).
173
179
  const localeDirs =
174
180
  config.i18n && config.i18n.parser === "dir"
175
- ? config.i18n.locales
176
- .map((locale) => locale.code)
177
- .filter((code) => code !== config.i18n?.defaultLocale)
181
+ ? config.i18n.locales.flatMap((locale) =>
182
+ locale.code === config.i18n?.defaultLocale ? [] : [locale.code]
183
+ )
178
184
  : undefined;
179
185
 
180
186
  const [loaded, folderMeta] = await Promise.all([
@@ -190,6 +196,7 @@ export const scanProject = async (
190
196
  contentDiagnostics.push(...diagnostics);
191
197
  for (const entry of entries) {
192
198
  const normalized = normalizeEntry(entry, {
199
+ basePath: config.basePath,
193
200
  defaultType: config.content.defaultType,
194
201
  i18n: config.i18n,
195
202
  source: {
@@ -217,11 +224,13 @@ export const scanProject = async (
217
224
  const fsPaths = pages
218
225
  .map((page) => page.sourcePath)
219
226
  .filter((path): path is string => path !== undefined);
220
- const gitTimes = gitLastModifiedTimes(
221
- context.root,
222
- context.contentRoot,
223
- fsPaths
227
+ // The git pathspecs must cover where the pages actually live: each
228
+ // filesystem source's own root, which diverges from the global
229
+ // `content.root` when a source configures a non-default `root`.
230
+ const contentRoots = sources.flatMap((source) =>
231
+ source.staged || !source.contentRoot ? [] : [source.contentRoot]
224
232
  );
233
+ const gitTimes = gitLastModifiedTimes(context.root, contentRoots, fsPaths);
225
234
  for (const page of pages) {
226
235
  if (!page.lastModified && page.sourcePath) {
227
236
  page.lastModified = gitTimes.get(page.sourcePath);
@@ -230,6 +239,7 @@ export const scanProject = async (
230
239
  }
231
240
 
232
241
  const graph = buildContentGraph(pages, {
242
+ basePath: config.basePath,
233
243
  folderMeta: folderMeta.meta,
234
244
  i18n: config.i18n,
235
245
  navigation: config.navigation,