blume 1.7.1 → 1.7.3

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 (138) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/dist/cli/chunk-0qymqwzz.js +164 -0
  3. package/dist/cli/chunk-0qymqwzz.js.map +15 -0
  4. package/dist/cli/{chunk-8gnpdsn1.js → chunk-0xjyb285.js} +2 -2
  5. package/dist/cli/{chunk-12dzsn9b.js → chunk-1jefwnfs.js} +82 -81
  6. package/dist/cli/{chunk-12dzsn9b.js.map → chunk-1jefwnfs.js.map} +3 -3
  7. package/dist/cli/{chunk-27gtm2ym.js → chunk-2mzebbbz.js} +1 -1
  8. package/dist/cli/{chunk-s5e5jt53.js → chunk-2z47ypj8.js} +1 -1
  9. package/dist/cli/{chunk-jtb45atp.js → chunk-3r45185y.js} +10 -12
  10. package/dist/cli/{chunk-jtb45atp.js.map → chunk-3r45185y.js.map} +2 -2
  11. package/dist/cli/{chunk-6mq7qkve.js → chunk-4x36ddpw.js} +18 -24
  12. package/dist/cli/{chunk-6mq7qkve.js.map → chunk-4x36ddpw.js.map} +2 -2
  13. package/dist/cli/{chunk-he2zfgah.js → chunk-5093q3n7.js} +22 -30
  14. package/dist/cli/{chunk-he2zfgah.js.map → chunk-5093q3n7.js.map} +2 -2
  15. package/dist/cli/{chunk-k0v1f8bb.js → chunk-5g0w1e2c.js} +21 -28
  16. package/dist/cli/{chunk-k0v1f8bb.js.map → chunk-5g0w1e2c.js.map} +2 -2
  17. package/dist/cli/{chunk-5gfw0q4j.js → chunk-5qk08vmp.js} +26 -34
  18. package/dist/cli/{chunk-5gfw0q4j.js.map → chunk-5qk08vmp.js.map} +2 -2
  19. package/dist/cli/{chunk-r99hynxh.js → chunk-7s8hm3b6.js} +41 -9
  20. package/dist/cli/{chunk-r99hynxh.js.map → chunk-7s8hm3b6.js.map} +3 -3
  21. package/dist/cli/{chunk-vyqj481z.js → chunk-8cjtbafj.js} +68 -66
  22. package/dist/cli/chunk-8cjtbafj.js.map +13 -0
  23. package/dist/cli/{chunk-aqjvpd03.js → chunk-97r59kpr.js} +27 -33
  24. package/dist/cli/{chunk-aqjvpd03.js.map → chunk-97r59kpr.js.map} +2 -2
  25. package/dist/cli/{chunk-np8dmfb0.js → chunk-ahnw3kxw.js} +26 -33
  26. package/dist/cli/{chunk-np8dmfb0.js.map → chunk-ahnw3kxw.js.map} +2 -2
  27. package/dist/cli/{chunk-j5f2wrj5.js → chunk-b27xqwn9.js} +10 -15
  28. package/dist/cli/{chunk-j5f2wrj5.js.map → chunk-b27xqwn9.js.map} +2 -2
  29. package/dist/cli/{chunk-kmx2mydj.js → chunk-bf6bt1xt.js} +8 -8
  30. package/dist/cli/{chunk-kmx2mydj.js.map → chunk-bf6bt1xt.js.map} +1 -1
  31. package/dist/cli/{chunk-90pdhkpm.js → chunk-bvwwhd84.js} +23 -32
  32. package/dist/cli/{chunk-90pdhkpm.js.map → chunk-bvwwhd84.js.map} +2 -2
  33. package/dist/cli/{chunk-mfm4sjwx.js → chunk-cjtn640a.js} +32 -43
  34. package/dist/cli/{chunk-mfm4sjwx.js.map → chunk-cjtn640a.js.map} +2 -2
  35. package/dist/cli/{chunk-pxj10x8y.js → chunk-ct47dqpx.js} +14 -3
  36. package/dist/cli/{chunk-pxj10x8y.js.map → chunk-ct47dqpx.js.map} +4 -3
  37. package/dist/cli/{chunk-x66c5yjn.js → chunk-dwgcp5sm.js} +2 -2
  38. package/dist/cli/{chunk-4trphnvy.js → chunk-e7f42gdj.js} +10 -13
  39. package/dist/cli/{chunk-4trphnvy.js.map → chunk-e7f42gdj.js.map} +2 -2
  40. package/dist/cli/{chunk-82atea4k.js → chunk-esphfr8p.js} +14 -18
  41. package/dist/cli/{chunk-82atea4k.js.map → chunk-esphfr8p.js.map} +2 -2
  42. package/dist/cli/{chunk-q56730e0.js → chunk-ex56aa81.js} +53 -53
  43. package/dist/cli/chunk-ex56aa81.js.map +13 -0
  44. package/dist/cli/{chunk-ywn7t0pb.js → chunk-garjf5z9.js} +3 -3
  45. package/dist/cli/{chunk-ev67ycx0.js → chunk-jq5n4avg.js} +1 -1
  46. package/dist/cli/{chunk-ka5k7cz9.js → chunk-js7saxwm.js} +35 -39
  47. package/dist/cli/{chunk-ka5k7cz9.js.map → chunk-js7saxwm.js.map} +4 -6
  48. package/dist/cli/{chunk-x1wvw7a8.js → chunk-k79xp7av.js} +168 -208
  49. package/dist/cli/chunk-k79xp7av.js.map +39 -0
  50. package/dist/cli/{chunk-3r94j3tc.js → chunk-nn13znc2.js} +2 -2
  51. package/dist/cli/{chunk-4ae4f395.js → chunk-ps4m1xh4.js} +60 -35
  52. package/dist/cli/chunk-ps4m1xh4.js.map +15 -0
  53. package/dist/cli/{chunk-wd27zjcz.js → chunk-q4rae3bg.js} +1 -1
  54. package/dist/cli/{chunk-pdwg3q9g.js → chunk-rqy0s5wh.js} +21 -30
  55. package/dist/cli/{chunk-pdwg3q9g.js.map → chunk-rqy0s5wh.js.map} +2 -2
  56. package/dist/cli/{chunk-52cwcqvp.js → chunk-rz9jmfhz.js} +15 -24
  57. package/dist/cli/{chunk-52cwcqvp.js.map → chunk-rz9jmfhz.js.map} +2 -2
  58. package/dist/cli/{chunk-8p3xe5jv.js → chunk-vacwm2hv.js} +3 -3
  59. package/dist/cli/{chunk-cbjnx4s8.js → chunk-vh9w1sgp.js} +1 -1
  60. package/dist/cli/{chunk-sbdqrjbb.js → chunk-vrfp10qk.js} +1 -1
  61. package/dist/cli/{chunk-h9ekmtz7.js → chunk-yg63d42r.js} +28 -35
  62. package/dist/cli/{chunk-h9ekmtz7.js.map → chunk-yg63d42r.js.map} +2 -2
  63. package/dist/cli/{chunk-5hs6gb7n.js → chunk-yzhm0j9q.js} +1 -1
  64. package/dist/cli/index.js +397 -34
  65. package/dist/cli/index.js.map +12 -4
  66. package/dist/types/core/config-input.d.ts +59 -0
  67. package/dist/types/core/data.d.ts +2 -0
  68. package/dist/types/core/schema.d.ts +47 -3
  69. package/dist/types/core/types.d.ts +5 -0
  70. package/docs/configuration/ask-ai.mdx +61 -0
  71. package/docs/configuration/index.mdx +3 -1
  72. package/docs/content/navigation.mdx +3 -0
  73. package/docs/content/syntax.mdx +10 -0
  74. package/docs/discoverability/agent-discovery.mdx +82 -1
  75. package/docs/discoverability/index.mdx +1 -1
  76. package/docs/discoverability/llms-txt.mdx +1 -1
  77. package/docs/reference/frontmatter.mdx +2 -0
  78. package/package.json +1 -1
  79. package/src/ai/agent-readability.ts +5 -0
  80. package/src/ai/ai-catalog.ts +241 -0
  81. package/src/ai/cors.ts +87 -0
  82. package/src/ai/link-headers.ts +12 -0
  83. package/src/ai/llms.ts +6 -0
  84. package/src/ai/mcp/discovery.ts +1 -1
  85. package/src/astro/generate.ts +4 -0
  86. package/src/astro/templates.ts +88 -23
  87. package/src/cli/commands/build.ts +3 -1
  88. package/src/components/islands/hooks.ts +50 -1
  89. package/src/components/layout/NavTree.astro +75 -66
  90. package/src/components/layout/RootLayout.astro +28 -2
  91. package/src/components/layout/analytics-client.ts +36 -7
  92. package/src/components/layout/nav-utils.ts +17 -3
  93. package/src/core/adapter.ts +61 -0
  94. package/src/core/config-input.ts +60 -0
  95. package/src/core/data.ts +2 -0
  96. package/src/core/navigation.ts +22 -3
  97. package/src/core/schema.ts +88 -0
  98. package/src/core/types.ts +5 -0
  99. package/src/deploy/artifacts.ts +12 -1
  100. package/src/deploy/headers.ts +6 -0
  101. package/src/deploy/vercel-negotiation.ts +25 -2
  102. package/src/registry/eject.ts +2 -0
  103. package/src/search/build.ts +25 -3
  104. package/src/theme/entry.ts +23 -0
  105. package/dist/cli/chunk-2aj8ddew.js +0 -72
  106. package/dist/cli/chunk-2aj8ddew.js.map +0 -10
  107. package/dist/cli/chunk-4ae4f395.js.map +0 -15
  108. package/dist/cli/chunk-4xyggvgf.js +0 -21
  109. package/dist/cli/chunk-4xyggvgf.js.map +0 -10
  110. package/dist/cli/chunk-6kzzpsx8.js +0 -26
  111. package/dist/cli/chunk-6kzzpsx8.js.map +0 -10
  112. package/dist/cli/chunk-bcy492zc.js +0 -16
  113. package/dist/cli/chunk-bcy492zc.js.map +0 -10
  114. package/dist/cli/chunk-btfr9yvw.js +0 -41
  115. package/dist/cli/chunk-btfr9yvw.js.map +0 -10
  116. package/dist/cli/chunk-ey89bjj1.js +0 -209
  117. package/dist/cli/chunk-ey89bjj1.js.map +0 -11
  118. package/dist/cli/chunk-q56730e0.js.map +0 -13
  119. package/dist/cli/chunk-qvvpnwaz.js +0 -69
  120. package/dist/cli/chunk-qvvpnwaz.js.map +0 -11
  121. package/dist/cli/chunk-vt8fgygt.js +0 -23
  122. package/dist/cli/chunk-vt8fgygt.js.map +0 -10
  123. package/dist/cli/chunk-vxv4x1n8.js +0 -17
  124. package/dist/cli/chunk-vxv4x1n8.js.map +0 -10
  125. package/dist/cli/chunk-vyqj481z.js.map +0 -13
  126. package/dist/cli/chunk-x1wvw7a8.js.map +0 -40
  127. /package/dist/cli/{chunk-8gnpdsn1.js.map → chunk-0xjyb285.js.map} +0 -0
  128. /package/dist/cli/{chunk-27gtm2ym.js.map → chunk-2mzebbbz.js.map} +0 -0
  129. /package/dist/cli/{chunk-s5e5jt53.js.map → chunk-2z47ypj8.js.map} +0 -0
  130. /package/dist/cli/{chunk-x66c5yjn.js.map → chunk-dwgcp5sm.js.map} +0 -0
  131. /package/dist/cli/{chunk-ywn7t0pb.js.map → chunk-garjf5z9.js.map} +0 -0
  132. /package/dist/cli/{chunk-ev67ycx0.js.map → chunk-jq5n4avg.js.map} +0 -0
  133. /package/dist/cli/{chunk-3r94j3tc.js.map → chunk-nn13znc2.js.map} +0 -0
  134. /package/dist/cli/{chunk-wd27zjcz.js.map → chunk-q4rae3bg.js.map} +0 -0
  135. /package/dist/cli/{chunk-8p3xe5jv.js.map → chunk-vacwm2hv.js.map} +0 -0
  136. /package/dist/cli/{chunk-cbjnx4s8.js.map → chunk-vh9w1sgp.js.map} +0 -0
  137. /package/dist/cli/{chunk-sbdqrjbb.js.map → chunk-vrfp10qk.js.map} +0 -0
  138. /package/dist/cli/{chunk-5hs6gb7n.js.map → chunk-yzhm0j9q.js.map} +0 -0
@@ -1,6 +1,7 @@
1
1
  import { useCallback, useEffect, useRef, useState } from "react";
2
2
 
3
3
  import type { BlumeClientData } from "../../core/data.ts";
4
+ import { track } from "../layout/analytics-client.ts";
4
5
  import type { SearchFn, SearchResult } from "../layout/search/types.ts";
5
6
  import { joinBase, stripBase } from "./base-path.ts";
6
7
 
@@ -196,6 +197,42 @@ export const useAskAI = (options: UseAskAIOptions = {}): UseAskAI => {
196
197
  const controller = new AbortController();
197
198
  abortRef.current = controller;
198
199
  const live = () => current === generation.current;
200
+ const path = currentPath();
201
+ // Usage reaches the configured analytics providers the same way page
202
+ // feedback does: the question now, its outcome once the stream settles.
203
+ // A reset mid-answer revokes the outcome along with the UI update.
204
+ // Analytics keys on the raw pathname, like page feedback and the
205
+ // providers' own pageviews, so the events join under a `base`; the
206
+ // endpoint gets the base-stripped route for grounding. Providers receive
207
+ // the question's length only: its text is free-form reader input (pasted
208
+ // keys, error logs) that would breach their PII terms and their
209
+ // per-value size caps, so it rides the `blume:track` event alone for a
210
+ // site to bridge on its own terms.
211
+ const { pathname } = window.location;
212
+ const report = (
213
+ event: "ask" | "ask_answer" | "ask_error",
214
+ props: Record<string, number>
215
+ ) =>
216
+ track(
217
+ event,
218
+ { ...props, path: pathname, questionChars: trimmed.length },
219
+ { question: trimmed }
220
+ );
221
+ report("ask", {});
222
+ // A monotonic clock: the wall clock can jump mid-stream (NTP, sleep).
223
+ const startedAt = performance.now();
224
+ const outcome = (
225
+ event: "ask_answer" | "ask_error",
226
+ props: Record<string, number>
227
+ ) =>
228
+ report(event, {
229
+ ...props,
230
+ ms: Math.round(performance.now() - startedAt),
231
+ });
232
+ // The HTTP status once a response exists. `streamText` defers provider
233
+ // errors to stream consumption, so a 200 can still break mid-flight;
234
+ // that reports as a 200 error, not as "no response".
235
+ let status = 0;
199
236
  const history: AskMessage[] = [
200
237
  ...messages,
201
238
  { content: trimmed, role: "user" },
@@ -207,16 +244,18 @@ export const useAskAI = (options: UseAskAIOptions = {}): UseAskAI => {
207
244
  const response = await fetch(endpoint, {
208
245
  body: JSON.stringify({
209
246
  messages: history,
210
- page: { path: currentPath() },
247
+ page: { path },
211
248
  }),
212
249
  headers: { "content-type": "application/json" },
213
250
  method: "POST",
214
251
  signal: controller.signal,
215
252
  });
253
+ ({ status } = response);
216
254
  if (!response.ok) {
217
255
  // An error body (JSON, HTML error page) must not stream in as the
218
256
  // assistant's answer.
219
257
  if (live()) {
258
+ outcome("ask_error", { status });
220
259
  assistant.content = errorMessage;
221
260
  setMessages([...history, { ...assistant }]);
222
261
  }
@@ -243,11 +282,21 @@ export const useAskAI = (options: UseAskAIOptions = {}): UseAskAI => {
243
282
  }
244
283
  }
245
284
  }
285
+ if (live()) {
286
+ // A 200 with nothing in it (no body, an empty stream) leaves the
287
+ // reader a blank bubble — that is not an answer.
288
+ if (assistant.content) {
289
+ outcome("ask_answer", { chars: assistant.content.length });
290
+ } else {
291
+ outcome("ask_error", { status });
292
+ }
293
+ }
246
294
  } catch {
247
295
  // A thrown fetch (offline, DNS failure, CORS) must not strand the
248
296
  // pre-appended empty assistant message as a stuck placeholder. A
249
297
  // reset's abort lands here too — the guard keeps it silent.
250
298
  if (live()) {
299
+ outcome("ask_error", { status });
251
300
  assistant.content = errorMessage;
252
301
  setMessages([...history, { ...assistant }]);
253
302
  }
@@ -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}
@@ -146,7 +146,11 @@ interface Props {
146
146
  * the site root. The HTML counterpart of the homepage-only HTTP `Link`
147
147
  * header (see `ai/link-headers.ts`).
148
148
  */
149
- discovery?: { agentReadability: boolean; llmsTxt: boolean } | null;
149
+ discovery?: {
150
+ agentReadability: boolean;
151
+ aiCatalog: boolean;
152
+ llmsTxt: boolean;
153
+ } | null;
150
154
  siteUrl?: string | null;
151
155
  pageType?: string;
152
156
  published?: string | Date | null;
@@ -407,7 +411,10 @@ const sidebar = sidebarForRoute(
407
411
  navigation.root
408
412
  );
409
413
  // 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.
414
+ // panels and deferred fragments the same way every other page does. A
415
+ // container the scoping rebuilt is not in the map, and NavTree renders it in
416
+ // full rather than deferring it to a fragment that would render the full
417
+ // tree's version.
411
418
  const navIds = navGroupIds(navigation.sidebar);
412
419
  const activeTab = currentTabForRoute(
413
420
  navigation.tabs,
@@ -545,6 +552,25 @@ createIconSprite(Astro.locals);
545
552
  <link href={withBase("/llms.txt")} rel="describedby" type="text/plain" />
546
553
  )
547
554
  }
555
+ {/* The AI Catalog / ARD manifest under both relations its two specs
556
+ define: `ai-catalog` (ai-catalog spec) and `ard` (ARD v0.91), each
557
+ pointing at that spec's own well-known path. */}
558
+ {
559
+ discovery?.aiCatalog && (
560
+ <>
561
+ <link
562
+ href={withBase("/.well-known/ai-catalog.json")}
563
+ rel="ai-catalog"
564
+ type="application/ai-catalog+json"
565
+ />
566
+ <link
567
+ href={withBase("/.well-known/ard.json")}
568
+ rel="ard"
569
+ type="application/json"
570
+ />
571
+ </>
572
+ )
573
+ }
548
574
  {
549
575
  markdownMirror && (
550
576
  <link href={markdownMirror} rel="alternate" type="text/markdown" />
@@ -6,7 +6,10 @@
6
6
  * `analytics.scripts` is reached via best-effort global detection or the
7
7
  * `blume:track` CustomEvent, which fires unconditionally so a project can bridge
8
8
  * the event to anything. Every call no-ops cleanly when a provider isn't present
9
- * — for example during `blume dev`, where `Analytics.astro` injects nothing.
9
+ * — for example during `blume dev`, where `Analytics.astro` injects nothing —
10
+ * and a provider that throws (a consent shim that stubs `gtag` with a raise, a
11
+ * broken snippet) is isolated so it neither starves the providers after it nor
12
+ * surfaces in the feature that reported the event.
10
13
  */
11
14
  import { track as vercelTrack } from "@vercel/analytics";
12
15
 
@@ -19,7 +22,27 @@ interface AnalyticsWindow {
19
22
  posthog?: { capture?: (event: string, props?: TrackProps) => void };
20
23
  }
21
24
 
22
- export const track = (event: string, props: TrackProps): void => {
25
+ /** Run one provider call; its failure must not reach the others or the caller. */
26
+ const attempt = (send: () => void): void => {
27
+ try {
28
+ send();
29
+ } catch {
30
+ // Analytics never breaks the feature that reported the event.
31
+ }
32
+ };
33
+
34
+ /**
35
+ * @param event The event name.
36
+ * @param props Properties every provider receives.
37
+ * @param local Properties only the `blume:track` CustomEvent carries — free
38
+ * text a site may bridge to a provider on its own terms, but that must not
39
+ * reach third parties unasked (a reader's Ask AI question, for instance).
40
+ */
41
+ export const track = (
42
+ event: string,
43
+ props: TrackProps,
44
+ local: TrackProps = {}
45
+ ): void => {
23
46
  // Read through `globalThis` so an SSR/import-time call sees `undefined`
24
47
  // instead of a bare-identifier ReferenceError.
25
48
  const browserWindow = globalThis.window;
@@ -31,12 +54,18 @@ export const track = (event: string, props: TrackProps): void => {
31
54
  const w = browserWindow as typeof browserWindow & AnalyticsWindow;
32
55
 
33
56
  // Vercel Web Analytics — self-gates to a no-op until `window.va` is set up.
34
- vercelTrack(event, props);
57
+ attempt(() => vercelTrack(event, props));
35
58
  // PostHog — the injected array.js stub queues calls until the lib loads.
36
- w.posthog?.capture?.(event, props);
59
+ attempt(() => w.posthog?.capture?.(event, props));
37
60
  // Popular providers wired through `analytics.scripts` (GA4/GTM, Plausible).
38
- w.gtag?.("event", event, props);
39
- w.plausible?.(event, { props });
61
+ attempt(() => w.gtag?.("event", event, props));
62
+ attempt(() => w.plausible?.(event, { props }));
40
63
  // Universal hook for any other integration.
41
- w.dispatchEvent(new CustomEvent("blume:track", { detail: { event, props } }));
64
+ attempt(() =>
65
+ w.dispatchEvent(
66
+ new CustomEvent("blume:track", {
67
+ detail: { event, props: { ...props, ...local } },
68
+ })
69
+ )
70
+ );
42
71
  };
@@ -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 {
@@ -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
  /**
@@ -740,6 +754,18 @@ export interface AskConfig {
740
754
  model?: string;
741
755
  /** Which backend routes the request. Defaults to `gateway`. */
742
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;
743
769
  /**
744
770
  * How much documentation each question carries into the model's prompt.
745
771
  * Lower values cut time-to-first-token — which dominates on a self-hosted
@@ -750,6 +776,31 @@ export interface AskConfig {
750
776
  suggestions?: AskSuggestion[];
751
777
  }
752
778
 
779
+ /** What the AI Catalog (ARD) manifest carries. */
780
+ export interface AiCatalogConfig {
781
+ /** Emit `/.well-known/ai-catalog.json` and `/.well-known/ard.json`. Defaults to `true`. */
782
+ enabled?: boolean;
783
+ /**
784
+ * Representative queries per entry, keyed by the entry's `<namespace>:<name>`
785
+ * — its identifier minus the `urn:air:<host>:` prefix (`mcp:docs`,
786
+ * `skill:blume`, `api:docs`, `reference:<slug>`, `docs:llms-txt`). Each
787
+ * list replaces the generated defaults for that entry: 2–5 short
788
+ * natural-language questions the resource can answer, which agent
789
+ * registries embed for semantic search.
790
+ *
791
+ * ```ts
792
+ * ai: {
793
+ * catalog: {
794
+ * queries: {
795
+ * "mcp:acme": ["how do I install Acme", "search the Acme docs"],
796
+ * },
797
+ * },
798
+ * }
799
+ * ```
800
+ */
801
+ queries?: Record<string, string[]>;
802
+ }
803
+
753
804
  /** What the `llms.txt`/`llms-full.txt` files include. */
754
805
  export interface LlmsTxtConfig {
755
806
  /**
@@ -806,6 +857,15 @@ export interface AiConfig {
806
857
  api?: boolean;
807
858
  /** The Ask AI chat assistant. */
808
859
  ask?: AskConfig;
860
+ /**
861
+ * The AI Catalog / ARD manifest (`/.well-known/ai-catalog.json`, mirrored
862
+ * at `/.well-known/ard.json`): a domain-level index of the agent-facing
863
+ * resources the site publishes — MCP server, agent skills, the JSON docs
864
+ * API, API references, llms.txt — for agent registries. Needs a
865
+ * `deployment.site`. Defaults to `true`; the object form overrides the
866
+ * generated representative queries per entry.
867
+ */
868
+ catalog?: boolean | AiCatalogConfig;
809
869
  /**
810
870
  * Emit `llms.txt` (an index of the docs for LLMs). Defaults to `true`.
811
871
  * The object form adds knobs for what the files include.
package/src/core/data.ts CHANGED
@@ -140,6 +140,8 @@ export interface BlumeDataConfig {
140
140
  */
141
141
  discovery: {
142
142
  agentReadability: boolean;
143
+ /** Whether the AI Catalog / ARD manifest is published (`ai.catalog`). */
144
+ aiCatalog: boolean;
143
145
  /** Whether the JSON docs API and its `/openapi.json` are published. */
144
146
  api: boolean;
145
147
  llmsTxt: boolean;
@@ -112,6 +112,8 @@ interface MutableGroup {
112
112
  path: string;
113
113
  /** The group's URL path (folder route prefix); set as pages are inserted. */
114
114
  routePath?: string;
115
+ /** The folder's index page route, when it has one; the group row's link. */
116
+ route?: string;
115
117
  label: string;
116
118
  icon?: string;
117
119
  collapsed?: boolean;
@@ -214,8 +216,14 @@ const applyFolderMeta = (
214
216
  sharedMeta: Map<string, FolderMeta>,
215
217
  metaPrefix: string,
216
218
  sharedMetaPrefix: string,
217
- indexDisplay: Map<string, SidebarDisplay>
219
+ indexDisplay: Map<string, SidebarDisplay>,
220
+ indexRoute: Map<string, string>
218
221
  ): void => {
222
+ // A folder with an index page links its group row to it — the same shape
223
+ // as an explicit-config group's `root`, and the only sidebar link to the
224
+ // section's own page once the index row is hidden. Index-less folders keep
225
+ // no link: their row would 404.
226
+ group.route = indexRoute.get(group.path);
219
227
  // Locale-specific meta wins; a shared `meta.$.*` (keyed by the locale-stripped
220
228
  // group path — version-prefixed inside a snapshot) applies to every locale
221
229
  // otherwise.
@@ -254,7 +262,8 @@ const applyFolderMeta = (
254
262
  sharedMeta,
255
263
  metaPrefix,
256
264
  sharedMetaPrefix,
257
- indexDisplay
265
+ indexDisplay,
266
+ indexRoute
258
267
  );
259
268
  }
260
269
  }
@@ -510,6 +519,7 @@ const toNavNode = (node: MutableNode, display: SidebarDisplay): NavNode => {
510
519
  kind: "group",
511
520
  label: node.label,
512
521
  path: node.routePath,
522
+ route: node.route,
513
523
  };
514
524
  };
515
525
 
@@ -529,6 +539,11 @@ const buildFileSystemSidebar = (
529
539
  // Collected before the hidden filter (like the title check): hiding the index
530
540
  // row from the panel shouldn't stop it configuring its group.
531
541
  const indexDisplay = new Map<string, SidebarDisplay>();
542
+ // Folder path -> that folder's index page route, for the group row's link.
543
+ // Also collected before the hidden filter: hiding the index row is how a
544
+ // site drops the duplicate label under a linked header, so the link must
545
+ // survive it. The content root is not a group, so its index is skipped.
546
+ const indexRoute = new Map<string, string>();
532
547
 
533
548
  for (const page of pages) {
534
549
  // Group by the locale-stripped path so the locale dir is not a nav group.
@@ -558,6 +573,9 @@ const buildFileSystemSidebar = (
558
573
  if (page.meta.sidebar.display && !page.fallback) {
559
574
  indexDisplay.set(dirs.join("/"), page.meta.sidebar.display);
560
575
  }
576
+ if (dirs.length > 0) {
577
+ indexRoute.set(dirs.join("/"), page.route);
578
+ }
561
579
  }
562
580
 
563
581
  if (page.meta.sidebar.hidden) {
@@ -611,7 +629,8 @@ const buildFileSystemSidebar = (
611
629
  sharedMeta,
612
630
  metaPrefix,
613
631
  sharedMetaPrefix,
614
- indexDisplay
632
+ indexDisplay,
633
+ indexRoute
615
634
  );
616
635
  sortNodes(root.children, diagnostics);
617
636
  hoistPages(root.children, display, true);