blume 1.7.0 → 1.7.2

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 (119) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/dist/cli/{chunk-9qs6acpw.js → chunk-12dxjqk7.js} +32 -43
  3. package/dist/cli/{chunk-9qs6acpw.js.map → chunk-12dxjqk7.js.map} +2 -2
  4. package/dist/cli/{chunk-s5dsk8bj.js → chunk-196vjxp9.js} +65 -64
  5. package/dist/cli/chunk-196vjxp9.js.map +13 -0
  6. package/dist/cli/{chunk-27gtm2ym.js → chunk-2mzebbbz.js} +1 -1
  7. package/dist/cli/{chunk-s5e5jt53.js → chunk-2z47ypj8.js} +1 -1
  8. package/dist/cli/{chunk-tnskyrej.js → chunk-30e87n55.js} +15 -24
  9. package/dist/cli/{chunk-tnskyrej.js.map → chunk-30e87n55.js.map} +2 -2
  10. package/dist/cli/{chunk-4trphnvy.js → chunk-3w7b2vcx.js} +10 -13
  11. package/dist/cli/{chunk-4trphnvy.js.map → chunk-3w7b2vcx.js.map} +2 -2
  12. package/dist/cli/{chunk-v5mm027v.js → chunk-450a7rcr.js} +8 -8
  13. package/dist/cli/{chunk-v5mm027v.js.map → chunk-450a7rcr.js.map} +1 -1
  14. package/dist/cli/{chunk-5d4q7121.js → chunk-5n7t497w.js} +142 -134
  15. package/dist/cli/{chunk-5d4q7121.js.map → chunk-5n7t497w.js.map} +5 -5
  16. package/dist/cli/{chunk-xv91q4nm.js → chunk-61j18dwk.js} +37 -9
  17. package/dist/cli/{chunk-xv91q4nm.js.map → chunk-61j18dwk.js.map} +3 -3
  18. package/dist/cli/{chunk-3r94j3tc.js → chunk-688e0dde.js} +2 -2
  19. package/dist/cli/{chunk-vxv4x1n8.js → chunk-88by27n5.js} +2 -2
  20. package/dist/cli/{chunk-cfw6x4rm.js → chunk-8cd8tj54.js} +28 -35
  21. package/dist/cli/{chunk-cfw6x4rm.js.map → chunk-8cd8tj54.js.map} +2 -2
  22. package/dist/cli/{chunk-8gnpdsn1.js → chunk-9bkjd11x.js} +2 -2
  23. package/dist/cli/{chunk-qq9nm3qd.js → chunk-9he6crym.js} +21 -28
  24. package/dist/cli/{chunk-qq9nm3qd.js.map → chunk-9he6crym.js.map} +2 -2
  25. package/dist/cli/{chunk-jk1zwka1.js → chunk-aztttvb3.js} +27 -33
  26. package/dist/cli/{chunk-jk1zwka1.js.map → chunk-aztttvb3.js.map} +2 -2
  27. package/dist/cli/{chunk-ckh3a410.js → chunk-cvky9gb2.js} +18 -24
  28. package/dist/cli/{chunk-ckh3a410.js.map → chunk-cvky9gb2.js.map} +2 -2
  29. package/dist/cli/{chunk-kwx90v78.js → chunk-eevwt1sc.js} +23 -32
  30. package/dist/cli/{chunk-kwx90v78.js.map → chunk-eevwt1sc.js.map} +2 -2
  31. package/dist/cli/{chunk-agy5rzxy.js → chunk-ejjx8znq.js} +126 -77
  32. package/dist/cli/chunk-ejjx8znq.js.map +15 -0
  33. package/dist/cli/{chunk-ye9zdkgv.js → chunk-exeeb35e.js} +3 -3
  34. package/dist/cli/{chunk-v2ymm99c.js → chunk-fmceyezb.js} +41 -50
  35. package/dist/cli/{chunk-v2ymm99c.js.map → chunk-fmceyezb.js.map} +2 -2
  36. package/dist/cli/{chunk-s102bysw.js → chunk-hdm2dkd2.js} +209 -97
  37. package/dist/cli/{chunk-s102bysw.js.map → chunk-hdm2dkd2.js.map} +6 -5
  38. package/dist/cli/{chunk-zr3ygrq3.js → chunk-hs3gbh8p.js} +10 -15
  39. package/dist/cli/{chunk-zr3ygrq3.js.map → chunk-hs3gbh8p.js.map} +2 -2
  40. package/dist/cli/{chunk-n0nyat6g.js → chunk-jbj4qhfw.js} +3 -3
  41. package/dist/cli/{chunk-ev67ycx0.js → chunk-jq5n4avg.js} +1 -1
  42. package/dist/cli/{chunk-jxkxjsc1.js → chunk-mqb2ka8m.js} +22 -30
  43. package/dist/cli/{chunk-jxkxjsc1.js.map → chunk-mqb2ka8m.js.map} +2 -2
  44. package/dist/cli/{chunk-drke6t0h.js → chunk-mt76t7dj.js} +26 -34
  45. package/dist/cli/{chunk-drke6t0h.js.map → chunk-mt76t7dj.js.map} +2 -2
  46. package/dist/cli/{chunk-jtb45atp.js → chunk-n9sra6sy.js} +13 -13
  47. package/dist/cli/{chunk-jtb45atp.js.map → chunk-n9sra6sy.js.map} +1 -1
  48. package/dist/cli/{chunk-x66c5yjn.js → chunk-ppfvdcd4.js} +2 -2
  49. package/dist/cli/{chunk-wd27zjcz.js → chunk-q4rae3bg.js} +1 -1
  50. package/dist/cli/{chunk-pxj10x8y.js → chunk-ra1v2nc2.js} +1 -1
  51. package/dist/cli/{chunk-y3g15rvv.js → chunk-t3tj0dgr.js} +26 -33
  52. package/dist/cli/{chunk-y3g15rvv.js.map → chunk-t3tj0dgr.js.map} +2 -2
  53. package/dist/cli/{chunk-j6pxe0dt.js → chunk-tqa1s0k8.js} +4 -4
  54. package/dist/cli/{chunk-cbjnx4s8.js → chunk-vh9w1sgp.js} +1 -1
  55. package/dist/cli/{chunk-0qhq7b8q.js → chunk-vkrsvbr5.js} +13 -17
  56. package/dist/cli/{chunk-0qhq7b8q.js.map → chunk-vkrsvbr5.js.map} +2 -2
  57. package/dist/cli/{chunk-sbdqrjbb.js → chunk-vrfp10qk.js} +1 -1
  58. package/dist/cli/{chunk-ynacq3ev.js → chunk-wjt80jps.js} +27 -40
  59. package/dist/cli/{chunk-ynacq3ev.js.map → chunk-wjt80jps.js.map} +3 -4
  60. package/dist/cli/{chunk-18tjv4f7.js → chunk-xhtpx3ff.js} +21 -30
  61. package/dist/cli/{chunk-18tjv4f7.js.map → chunk-xhtpx3ff.js.map} +2 -2
  62. package/dist/cli/{chunk-5hs6gb7n.js → chunk-yzhm0j9q.js} +1 -1
  63. package/dist/cli/index.js +397 -34
  64. package/dist/cli/index.js.map +12 -4
  65. package/dist/types/components/layout/nav-utils.d.ts +15 -2
  66. package/dist/types/core/config-input.d.ts +33 -0
  67. package/dist/types/core/schema.d.ts +29 -3
  68. package/docs/08-faq.mdx +21 -0
  69. package/docs/configuration/ask-ai.mdx +63 -0
  70. package/docs/content/sources.mdx +2 -2
  71. package/package.json +1 -1
  72. package/src/ai/ask.ts +31 -4
  73. package/src/ai/cors.ts +87 -0
  74. package/src/astro/generate.ts +3 -0
  75. package/src/astro/templates.ts +192 -83
  76. package/src/components/content/YouTube.astro +1 -1
  77. package/src/components/layout/Header.astro +1 -1
  78. package/src/components/layout/NavTree.astro +75 -66
  79. package/src/components/layout/RootLayout.astro +4 -1
  80. package/src/components/layout/Search.astro +11 -0
  81. package/src/components/layout/nav-utils.ts +47 -15
  82. package/src/core/adapter.ts +61 -0
  83. package/src/core/config-input.ts +33 -0
  84. package/src/core/schema.ts +61 -0
  85. package/src/core/sources/assets.ts +162 -26
  86. package/src/core/sources/notion.ts +60 -1
  87. package/src/registry/eject.ts +3 -0
  88. package/src/theme/entry.ts +9 -0
  89. package/dist/cli/chunk-2aj8ddew.js +0 -72
  90. package/dist/cli/chunk-2aj8ddew.js.map +0 -10
  91. package/dist/cli/chunk-4xyggvgf.js +0 -21
  92. package/dist/cli/chunk-4xyggvgf.js.map +0 -10
  93. package/dist/cli/chunk-6kzzpsx8.js +0 -26
  94. package/dist/cli/chunk-6kzzpsx8.js.map +0 -10
  95. package/dist/cli/chunk-agy5rzxy.js.map +0 -15
  96. package/dist/cli/chunk-bcy492zc.js +0 -16
  97. package/dist/cli/chunk-bcy492zc.js.map +0 -10
  98. package/dist/cli/chunk-btfr9yvw.js +0 -41
  99. package/dist/cli/chunk-btfr9yvw.js.map +0 -10
  100. package/dist/cli/chunk-ey89bjj1.js +0 -209
  101. package/dist/cli/chunk-ey89bjj1.js.map +0 -11
  102. package/dist/cli/chunk-s5dsk8bj.js.map +0 -13
  103. package/dist/cli/chunk-vt8fgygt.js +0 -23
  104. package/dist/cli/chunk-vt8fgygt.js.map +0 -10
  105. /package/dist/cli/{chunk-27gtm2ym.js.map → chunk-2mzebbbz.js.map} +0 -0
  106. /package/dist/cli/{chunk-s5e5jt53.js.map → chunk-2z47ypj8.js.map} +0 -0
  107. /package/dist/cli/{chunk-3r94j3tc.js.map → chunk-688e0dde.js.map} +0 -0
  108. /package/dist/cli/{chunk-vxv4x1n8.js.map → chunk-88by27n5.js.map} +0 -0
  109. /package/dist/cli/{chunk-8gnpdsn1.js.map → chunk-9bkjd11x.js.map} +0 -0
  110. /package/dist/cli/{chunk-ye9zdkgv.js.map → chunk-exeeb35e.js.map} +0 -0
  111. /package/dist/cli/{chunk-n0nyat6g.js.map → chunk-jbj4qhfw.js.map} +0 -0
  112. /package/dist/cli/{chunk-ev67ycx0.js.map → chunk-jq5n4avg.js.map} +0 -0
  113. /package/dist/cli/{chunk-x66c5yjn.js.map → chunk-ppfvdcd4.js.map} +0 -0
  114. /package/dist/cli/{chunk-wd27zjcz.js.map → chunk-q4rae3bg.js.map} +0 -0
  115. /package/dist/cli/{chunk-pxj10x8y.js.map → chunk-ra1v2nc2.js.map} +0 -0
  116. /package/dist/cli/{chunk-j6pxe0dt.js.map → chunk-tqa1s0k8.js.map} +0 -0
  117. /package/dist/cli/{chunk-cbjnx4s8.js.map → chunk-vh9w1sgp.js.map} +0 -0
  118. /package/dist/cli/{chunk-sbdqrjbb.js.map → chunk-vrfp10qk.js.map} +0 -0
  119. /package/dist/cli/{chunk-5hs6gb7n.js.map → chunk-yzhm0j9q.js.map} +0 -0
@@ -35,7 +35,10 @@ interface Props {
35
35
  /**
36
36
  * Stable ids for the full sidebar's groups (`navGroupIds`), so panel and
37
37
  * fragment ids match across the scoped views the layout renders. Absent,
38
- * ids fall back to positions within this render.
38
+ * ids fall back to positions within this render. A group missing from the
39
+ * map exists only in this render — a container the tab scoping rebuilt
40
+ * without a nested tab section — so no fragment can serve it: it renders
41
+ * in full instead of being deferred.
39
42
  */
40
43
  ids?: Map<NavNode, string>;
41
44
  /**
@@ -63,9 +66,12 @@ const {
63
66
  const idOf = (node: NavNode, positional: string): string =>
64
67
  ids?.get(node) ?? positional;
65
68
 
66
- /** The fragment a deferred section loads from, when sections are deferred. */
67
- const fragmentFor = (id: string): string | undefined =>
68
- fragmentBase ? `${fragmentBase}/${id}` : undefined;
69
+ /**
70
+ * The fragment a group's section loads from, when sections are deferred and
71
+ * the group is one the fragments know (in `ids`, or `ids` is absent).
72
+ */
73
+ const fragmentFor = (node: NavNode, id: string): string | undefined =>
74
+ fragmentBase && (ids?.has(node) ?? true) ? `${fragmentBase}/${id}` : undefined;
69
75
 
70
76
  // Merge over the English defaults so a label missing from a translation (or
71
77
  // from a not-yet-regenerated snapshot) still renders instead of coming out
@@ -150,66 +156,56 @@ const initialId =
150
156
  strings={n}
151
157
  />
152
158
  </div>
153
- {panels.map((panel) => (
154
- <div
155
- data-nav-depth={panel.depth}
156
- data-nav-panel={panel.id}
157
- data-nav-src={panel.active ? undefined : fragmentFor(panel.id)}
158
- hidden={panel.id !== initialId}
159
- >
160
- {/* The title is the only sidebar link to the section's own page, so
161
- a routed panel keeps it as a link; without a route the whole row
162
- becomes the back button. Either way every part of the row is
163
- interactive. */}
164
- {panel.route ? (
165
- <div class="mb-3 flex items-center gap-0.5">
159
+ {panels.map((panel) => {
160
+ const src = panel.active ? undefined : fragmentFor(panel.node, panel.id);
161
+ return (
162
+ <div
163
+ data-nav-depth={panel.depth}
164
+ data-nav-panel={panel.id}
165
+ data-nav-src={src}
166
+ hidden={panel.id !== initialId}
167
+ >
168
+ {/* The title is the only sidebar link to the section's own page, so
169
+ a routed panel keeps it as a link; without a route the whole row
170
+ becomes the back button. Either way every part of the row is
171
+ interactive. */}
172
+ {panel.route ? (
173
+ <div class="mb-3 flex items-center gap-0.5">
174
+ <button
175
+ aria-label={n.back}
176
+ class="-ml-1 flex shrink-0 items-center justify-center self-stretch rounded-[0.65rem] px-1 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
177
+ data-nav-back={panel.parentId}
178
+ type="button"
179
+ >
180
+ <Icon class="rtl:-scale-x-100" name="arrow-left" size={16} />
181
+ </button>
182
+ <a
183
+ aria-current={panel.route === currentRoute ? "page" : undefined}
184
+ class="flex-1 truncate rounded-[0.65rem] px-1 py-1 font-semibold text-foreground text-sm transition-colors hover:bg-muted"
185
+ href={withBase(panel.route)}
186
+ >
187
+ {panel.label}
188
+ </a>
189
+ </div>
190
+ ) : (
166
191
  <button
167
- aria-label={n.back}
168
- class="-ml-1 flex shrink-0 items-center justify-center self-stretch rounded-[0.65rem] px-1 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
192
+ aria-label={`${n.back}: ${panel.label}`}
193
+ class="-ml-1 mb-3 flex w-full items-center gap-1.5 rounded-[0.65rem] p-1 text-left font-semibold text-foreground text-sm transition-colors hover:bg-muted"
169
194
  data-nav-back={panel.parentId}
170
195
  type="button"
171
196
  >
172
- <Icon class="rtl:-scale-x-100" name="arrow-left" size={16} />
197
+ <Icon
198
+ class="shrink-0 text-muted-foreground rtl:-scale-x-100"
199
+ name="arrow-left"
200
+ size={16}
201
+ />
202
+ <span class="flex-1 truncate">{panel.label}</span>
173
203
  </button>
174
- <a
175
- aria-current={panel.route === currentRoute ? "page" : undefined}
176
- class="flex-1 truncate rounded-[0.65rem] px-1 py-1 font-semibold text-foreground text-sm transition-colors hover:bg-muted"
177
- href={withBase(panel.route)}
178
- >
179
- {panel.label}
180
- </a>
181
- </div>
182
- ) : (
183
- <button
184
- aria-label={`${n.back}: ${panel.label}`}
185
- class="-ml-1 mb-3 flex w-full items-center gap-1.5 rounded-[0.65rem] p-1 text-left font-semibold text-foreground text-sm transition-colors hover:bg-muted"
186
- data-nav-back={panel.parentId}
187
- type="button"
188
- >
189
- <Icon
190
- class="shrink-0 text-muted-foreground rtl:-scale-x-100"
191
- name="arrow-left"
192
- size={16}
193
- />
194
- <span class="flex-1 truncate">{panel.label}</span>
195
- </button>
196
- )}
197
- {/* An inactive panel's contents are deferred (fetched into this
198
- element on drill-in) when fragments are on; otherwise the
199
- build-time cache renders them once. */}
200
- {panel.active ? (
201
- <Self
202
- currentRoute={currentRoute}
203
- depth={1}
204
- fragmentBase={fragmentBase}
205
- idPrefix={panel.id}
206
- ids={ids}
207
- items={panel.children}
208
- root={false}
209
- strings={n}
210
- />
211
- ) : fragmentBase ? null : (
212
- <NavTreeCache node={panel.node} variant={`${panel.id}|${n.back}|${n.deprecated}`}>
204
+ )}
205
+ {/* An inactive panel's contents are deferred (fetched into this
206
+ element on drill-in) when it has a fragment; otherwise the
207
+ build-time cache renders them once. */}
208
+ {panel.active ? (
213
209
  <Self
214
210
  currentRoute={currentRoute}
215
211
  depth={1}
@@ -220,10 +216,23 @@ const initialId =
220
216
  root={false}
221
217
  strings={n}
222
218
  />
223
- </NavTreeCache>
224
- )}
225
- </div>
226
- ))}
219
+ ) : src ? null : (
220
+ <NavTreeCache node={panel.node} variant={`${panel.id}|${n.back}|${n.deprecated}`}>
221
+ <Self
222
+ currentRoute={currentRoute}
223
+ depth={1}
224
+ fragmentBase={fragmentBase}
225
+ idPrefix={panel.id}
226
+ ids={ids}
227
+ items={panel.children}
228
+ root={false}
229
+ strings={n}
230
+ />
231
+ </NavTreeCache>
232
+ )}
233
+ </div>
234
+ );
235
+ })}
227
236
  </blume-nav>
228
237
  ) : (
229
238
  <ul class="m-0 list-none space-y-px p-0">
@@ -362,11 +371,11 @@ const initialId =
362
371
  </summary>
363
372
  <div
364
373
  class="space-y-0.5 border-border border-l pl-3"
365
- data-nav-src={open ? undefined : fragmentFor(id)}
374
+ data-nav-src={open ? undefined : fragmentFor(item, id)}
366
375
  >
367
376
  {/* A closed group's children are deferred (fetched into this
368
- element on first open) when fragments are on. */}
369
- {!open && fragmentBase ? null : active ? (
377
+ element on first open) when it has a fragment. */}
378
+ {!open && fragmentFor(item, id) ? null : active ? (
370
379
  <Self
371
380
  currentRoute={currentRoute}
372
381
  depth={depth + 1}
@@ -407,7 +407,10 @@ const sidebar = sidebarForRoute(
407
407
  navigation.root
408
408
  );
409
409
  // Stable group ids over the full tree, so the scoped view above names its
410
- // panels and deferred fragments the same way every other page does.
410
+ // panels and deferred fragments the same way every other page does. A
411
+ // container the scoping rebuilt is not in the map, and NavTree renders it in
412
+ // full rather than deferring it to a fragment that would render the full
413
+ // tree's version.
411
414
  const navIds = navGroupIds(navigation.sidebar);
412
415
  const activeTab = currentTabForRoute(
413
416
  navigation.tabs,
@@ -445,6 +445,7 @@ const kbd = "rounded border border-border bg-muted px-1 py-0.5 font-mono";
445
445
  this.dialog.addEventListener("keydown", (event) =>
446
446
  this.onKeydown(event)
447
447
  );
448
+ this.dialog.addEventListener("close", () => this.unlockPageScroll());
448
449
  this.dialog.addEventListener("click", (event) => {
449
450
  if (event.target === this.dialog) {
450
451
  this.dialog.close();
@@ -454,6 +455,7 @@ const kbd = "rounded border border-border bg-muted px-1 py-0.5 font-mono";
454
455
 
455
456
  disconnectedCallback() {
456
457
  document.removeEventListener("keydown", this.#onDocumentKeydown);
458
+ this.unlockPageScroll();
457
459
  }
458
460
 
459
461
  isField(target: EventTarget | null): boolean {
@@ -473,6 +475,7 @@ const kbd = "rounded border border-border bg-muted px-1 py-0.5 font-mono";
473
475
  return;
474
476
  }
475
477
  this.dialog.showModal();
478
+ this.lockPageScroll();
476
479
  this.input.focus();
477
480
  this.input.select();
478
481
  if (!this.loaded) {
@@ -498,6 +501,14 @@ const kbd = "rounded border border-border bg-muted px-1 py-0.5 font-mono";
498
501
  this.render();
499
502
  }
500
503
 
504
+ lockPageScroll() {
505
+ document.documentElement.setAttribute("data-blume-search-dialog-open", "");
506
+ }
507
+
508
+ unlockPageScroll() {
509
+ document.documentElement.removeAttribute("data-blume-search-dialog-open");
510
+ }
511
+
501
512
  onKeydown(event: KeyboardEvent) {
502
513
  if (event.key === "ArrowDown") {
503
514
  event.preventDefault();
@@ -151,6 +151,15 @@ const isTabSection = (node: NavNode, tabPaths: Set<string>): boolean => {
151
151
  * instead of duplicating each tab as a sidebar group. A container left empty by
152
152
  * this pruning is dropped too, so no bare heading is stranded. The root tab
153
153
  * spans everything, so it never removes anything.
154
+ *
155
+ * A group with no tab section anywhere beneath it is kept as the same object:
156
+ * the stable group ids (`navGroupIds`) and the build-time subtree cache are
157
+ * both keyed by node identity, so a copy would lose its id — its collapsed
158
+ * fragment was then requested by a positional name no route serves — and
159
+ * re-render on every page. A container that did lose a section is rebuilt,
160
+ * so it has no id: the deferred fragments render from the full tree, which
161
+ * would put the section back, so `NavTree` renders such a container in full
162
+ * instead (its untouched children still defer by their own ids).
154
163
  */
155
164
  const withoutTabSections = (
156
165
  nodes: NavNode[],
@@ -173,10 +182,15 @@ const withoutTabSections = (
173
182
  continue;
174
183
  }
175
184
  if (item.kind === "group") {
176
- // A container left empty by pruning is dropped, so no bare heading is
177
- // stranded.
178
185
  const children = prune(item.children);
179
- if (children.length > 0) {
186
+ if (
187
+ children.length === item.children.length &&
188
+ children.every((child, index) => child === item.children[index])
189
+ ) {
190
+ kept.push(item);
191
+ } else if (children.length > 0) {
192
+ // A container left empty by pruning is dropped, so no bare heading
193
+ // is stranded.
180
194
  kept.push({ ...item, children });
181
195
  }
182
196
  } else {
@@ -280,27 +294,45 @@ export interface NavVariant {
280
294
  navigation: Navigation;
281
295
  }
282
296
 
297
+ /**
298
+ * The locale whose code must not appear as a fragment URL segment: the
299
+ * default locale while its URL prefix is hidden. Astro's i18n routing 404s any
300
+ * page URL carrying that locale's code as a segment (it expects the default
301
+ * locale to be unprefixed), so its trees are keyed `default` instead, the same
302
+ * way its page routes drop the prefix. `null` when every locale is prefixed
303
+ * or the site is single-locale.
304
+ */
305
+ export const hiddenDefaultLocale = (
306
+ i18n: { defaultLocale: string; hideDefaultLocalePrefix: boolean } | null
307
+ ): string | null => (i18n?.hideDefaultLocalePrefix ? i18n.defaultLocale : null);
308
+
283
309
  /**
284
310
  * Every navigation tree the runtime data holds — the default, each locale's,
285
311
  * and each archived version's per locale — keyed the way the deferred
286
312
  * sidebar fragments' URLs are (`/blume-nav/<version>/<locale>/…`). An
287
313
  * unlocalized version tree is keyed by `""` in the data; it maps to
288
- * `default` here.
314
+ * `default` here, as does the hidden-prefix default locale's (see
315
+ * `hiddenDefaultLocale`), whose current tree is `data.navigation` already.
289
316
  */
290
- export const navVariants = (data: {
291
- navigation: Navigation;
292
- navigationByLocale: Record<string, Navigation>;
293
- navigationByVersion: Record<string, Record<string, Navigation>>;
294
- }): NavVariant[] => [
317
+ export const navVariants = (
318
+ data: {
319
+ navigation: Navigation;
320
+ navigationByLocale: Record<string, Navigation>;
321
+ navigationByVersion: Record<string, Record<string, Navigation>>;
322
+ },
323
+ hiddenDefault: string | null = null
324
+ ): NavVariant[] => [
295
325
  { locale: "default", navigation: data.navigation, version: "current" },
296
- ...Object.entries(data.navigationByLocale).map(([locale, navigation]) => ({
297
- locale,
298
- navigation,
299
- version: "current",
300
- })),
326
+ ...Object.entries(data.navigationByLocale)
327
+ .filter(([locale]) => locale !== hiddenDefault)
328
+ .map(([locale, navigation]) => ({
329
+ locale,
330
+ navigation,
331
+ version: "current",
332
+ })),
301
333
  ...Object.entries(data.navigationByVersion).flatMap(([version, byLocale]) =>
302
334
  Object.entries(byLocale).map(([locale, navigation]) => ({
303
- locale: locale || "default",
335
+ locale: locale && locale !== hiddenDefault ? locale : "default",
304
336
  navigation,
305
337
  version,
306
338
  }))
@@ -0,0 +1,61 @@
1
+ import { z } from "zod";
2
+
3
+ /**
4
+ * The serializable descriptor an integration factory returns — `posthog({ key })`,
5
+ * `vercel()`, `script({ src })` and their siblings. A descriptor is plain data:
6
+ * the CLI evaluates `blume.config.ts` once, validates what came back, and
7
+ * writes it into the generated project's data snapshot as a JSON literal. That
8
+ * is where the runtime reads it — nothing generated imports the config at
9
+ * request time — so a descriptor can't carry functions, class instances, or
10
+ * anything else JSON drops.
11
+ *
12
+ * Every consumer reads the descriptor instead of switching on a provider name:
13
+ * the head emitter and `blume doctor` branch on `kind`, the generated
14
+ * `.blume/package.json` declares `runtimeDeps`, and the secrets check warns
15
+ * when an entry of `requiredSecrets` is unset.
16
+ *
17
+ * An adapter's verbatim option passthrough is typed as {@link JsonValue} and
18
+ * validated with `z.json()`, so a function, `undefined`, a bigint, or a
19
+ * non-finite number fails config validation with a path instead of vanishing
20
+ * (or throwing) when the snapshot is serialized.
21
+ */
22
+ export interface AdapterDescriptor<Kind extends string, Options> {
23
+ /** Which integration this is. */
24
+ kind: Kind;
25
+ /** The options the factory was called with, verbatim. */
26
+ options: Options;
27
+ /** Env vars the integration reads at runtime; `blume dev`/`build` warn when one is unset. */
28
+ requiredSecrets: string[];
29
+ /** Extra packages the generated `.blume/package.json` must declare. */
30
+ runtimeDeps: string[];
31
+ }
32
+
33
+ /**
34
+ * A value JSON can carry unchanged — what an adapter's passthrough options are
35
+ * typed as. Structurally identical to what `z.json()` accepts.
36
+ */
37
+ export type JsonValue =
38
+ | string
39
+ | number
40
+ | boolean
41
+ | null
42
+ | JsonValue[]
43
+ | { [key: string]: JsonValue };
44
+
45
+ /**
46
+ * The schema for one descriptor `kind`, validating its `options` with the
47
+ * adapter's own option schema. Members of a `z.discriminatedUnion("kind", …)`.
48
+ */
49
+ export const adapterDescriptorSchema = <
50
+ Kind extends string,
51
+ Options extends z.ZodType,
52
+ >(
53
+ kind: Kind,
54
+ options: Options
55
+ ) =>
56
+ z.strictObject({
57
+ kind: z.literal(kind),
58
+ options,
59
+ requiredSecrets: z.array(z.string()),
60
+ runtimeDeps: z.array(z.string()),
61
+ });
@@ -681,6 +681,8 @@ export interface AskSuggestion {
681
681
  /** Backends that can route an Ask AI request. */
682
682
  type AskProviderGateway = "gateway" | "openrouter" | "llmgateway";
683
683
  type AskProvider = AskProviderGateway | "inkeep" | "openai-compatible";
684
+ /** How much the model reasons before answering (`ai.ask.reasoning`). */
685
+ type AskReasoning = "none" | "minimal" | "low" | "medium" | "high" | "xhigh";
684
686
 
685
687
  /** How much retrieved documentation each Ask AI question carries. */
686
688
  export interface AskRetrievalConfig {
@@ -715,6 +717,18 @@ export interface AskConfig {
715
717
  * overrides the built-in preset.
716
718
  */
717
719
  baseUrl?: string;
720
+ /**
721
+ * Origins allowed to call the generated endpoint from another site — a
722
+ * marketing page that embeds an ask box, for example — or `"*"` to allow
723
+ * every origin. The route answers preflight requests and names a listed
724
+ * origin on every response, errors included; every other origin stays
725
+ * subject to the browser's same-origin rule. Callers must send the body as
726
+ * JSON with a `content-type: application/json` header, or Astro's cross-site
727
+ * request check rejects the `POST` before the route runs. Only the generated
728
+ * route reads this; an external `endpoint` handles its own CORS and can't be
729
+ * combined with it.
730
+ */
731
+ cors?: string[];
718
732
  /** Turn Ask AI on. Defaults to `false`. */
719
733
  enabled?: boolean;
720
734
  /**
@@ -723,6 +737,13 @@ export interface AskConfig {
723
737
  * limiting, and streaming. Accepts an absolute URL or root-relative path.
724
738
  */
725
739
  endpoint?: string;
740
+ /**
741
+ * Static request headers sent to the provider on every call — a
742
+ * caller-identifying header for a shared backend, for example. Values are
743
+ * written into the generated route as literals, so keep secrets in
744
+ * `apiKeyEnv` rather than here.
745
+ */
746
+ headers?: Record<string, string>;
726
747
  /**
727
748
  * Extra system-prompt text appended to the built-in instructions — use it
728
749
  * for identity, language, or tone. The built-in grounding behavior (answer
@@ -733,6 +754,18 @@ export interface AskConfig {
733
754
  model?: string;
734
755
  /** Which backend routes the request. Defaults to `gateway`. */
735
756
  provider?: AskProvider;
757
+ /**
758
+ * How much the model reasons before answering, from `"none"` to `"xhigh"`.
759
+ * Sent as the backend's own reasoning-effort control: the AI SDK's
760
+ * `reasoning` option on the gateway, `reasoning.effort` on OpenRouter, and
761
+ * `reasoning_effort` on OpenAI-compatible endpoints. The model has to
762
+ * support the level — OpenAI rejects one a model doesn't offer — and the
763
+ * endpoint has to accept the parameter; Inkeep has no reasoning control,
764
+ * so the field is rejected there. Omitted keeps the model's default.
765
+ * `"none"` is the fastest and cheapest for grounded docs Q&A, where the
766
+ * retrieved excerpts carry the answer.
767
+ */
768
+ reasoning?: AskReasoning;
736
769
  /**
737
770
  * How much documentation each question carries into the model's prompt.
738
771
  * Lower values cut time-to-first-token — which dominates on a self-hosted
@@ -767,6 +767,19 @@ const searchConfigSchema = z
767
767
  }
768
768
  });
769
769
 
770
+ /**
771
+ * The `ai.ask.reasoning` levels: the AI SDK's top-level `reasoning` values
772
+ * minus `provider-default`, which is what omitting the field means.
773
+ */
774
+ export const askReasoningLevels = [
775
+ "none",
776
+ "minimal",
777
+ "low",
778
+ "medium",
779
+ "high",
780
+ "xhigh",
781
+ ] as const;
782
+
770
783
  /** Ask AI backends. `gateway` (default) routes through the Vercel AI Gateway. */
771
784
  export const askAiProviders = [
772
785
  "gateway",
@@ -872,17 +885,43 @@ const aiConfigSchema = z.strictObject({
872
885
  // Base URL of the backend. Required for `openai-compatible` only when no
873
886
  // external endpoint is supplied; for named providers it overrides the preset.
874
887
  baseUrl: z.url().optional(),
888
+ // Origins allowed to call the generated `/api/ask` from another site (a
889
+ // marketing page that embeds an ask box, say), or `"*"` for every
890
+ // origin. Each URL is reduced to its origin so a trailing slash or path
891
+ // can't defeat the exact match the route performs. Read by the
892
+ // generated route only; an external `endpoint` owns its own CORS.
893
+ cors: z
894
+ .array(
895
+ z.union([
896
+ z.literal("*"),
897
+ z
898
+ .url({ protocol: /^https?$/u })
899
+ .transform((value) => new URL(value).origin),
900
+ ])
901
+ )
902
+ .optional(),
875
903
  enabled: z.boolean().default(false),
876
904
  // Optional external endpoint for projects that keep their docs static
877
905
  // and host Ask AI in an existing backend. Absolute URLs and root-relative
878
906
  // paths are both valid; the built-in request/stream contract is unchanged.
879
907
  endpoint: askEndpointSchema.optional(),
908
+ // Static request headers the generated endpoint sends the provider on
909
+ // every call (a caller-identifying header for a shared backend, say).
910
+ // Values are inlined into the generated route as literals, so the API
911
+ // key stays in `apiKeyEnv`; these are for non-secret metadata.
912
+ headers: z.record(z.string(), z.string()).optional(),
880
913
  // Extra system-prompt text (identity, language, tone) appended to the
881
914
  // built-in instructions, so the grounding contract — answer from the
882
915
  // retrieved excerpts, cite pages as Markdown links — stays intact.
883
916
  instructions: z.string().trim().min(1).optional(),
884
917
  model: z.string().default("openai/gpt-5.5"),
885
918
  provider: z.enum(askAiProviders).default("gateway"),
919
+ // How much the model reasons before answering, sent as the backend's
920
+ // own reasoning-effort control (see `askEndpointTemplate`). Omitted
921
+ // keeps the provider's default; `none` is the fastest and cheapest for
922
+ // grounded docs Q&A, where the excerpts carry the answer. Not for
923
+ // Inkeep, which has no such control (refined below).
924
+ reasoning: z.enum(askReasoningLevels).optional(),
886
925
  // How much documentation each question carries. Injected characters are
887
926
  // the dominant term in time-to-first-token on a self-hosted backend, so
888
927
  // these trade recall for latency. No zod defaults here: only what the
@@ -921,6 +960,27 @@ const aiConfigSchema = z.strictObject({
921
960
  path: ["baseUrl"],
922
961
  });
923
962
  }
963
+ // `cors` configures the generated route, which an external `endpoint`
964
+ // replaces; accepting both would silently do nothing.
965
+ if (value.cors && value.endpoint) {
966
+ ctx.addIssue({
967
+ code: z.ZodIssueCode.custom,
968
+ message:
969
+ "ai.ask.cors only applies to the generated route; with ai.ask.endpoint, CORS is that backend's job.",
970
+ path: ["cors"],
971
+ });
972
+ }
973
+ // Inkeep runs its own QA pipeline behind an OpenAI-compatible endpoint
974
+ // with no reasoning control; a level would only reach it as an
975
+ // unsupported `reasoning_effort`, so refuse it up front.
976
+ if (value.provider === "inkeep" && value.reasoning) {
977
+ ctx.addIssue({
978
+ code: z.ZodIssueCode.custom,
979
+ message:
980
+ 'ai.ask.reasoning is not supported when provider is "inkeep".',
981
+ path: ["reasoning"],
982
+ });
983
+ }
924
984
  })
925
985
  .optional(),
926
986
  /**
@@ -1073,6 +1133,7 @@ const navigationConfigSchema = z.strictObject({
1073
1133
  });
1074
1134
 
1075
1135
  export type AskAiProvider = (typeof askAiProviders)[number];
1136
+ export type AskReasoning = (typeof askReasoningLevels)[number];
1076
1137
  export type AskAiConfig = NonNullable<z.infer<typeof aiConfigSchema>["ask"]>;
1077
1138
  export { openInChatProviders } from "./open-in-chat.ts";
1078
1139
  export type { OpenInChatProvider } from "./open-in-chat.ts";