blume 1.7.1 → 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 (109) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/dist/cli/{chunk-mfm4sjwx.js → chunk-12dxjqk7.js} +32 -43
  3. package/dist/cli/{chunk-mfm4sjwx.js.map → chunk-12dxjqk7.js.map} +2 -2
  4. package/dist/cli/{chunk-vyqj481z.js → chunk-196vjxp9.js} +64 -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-52cwcqvp.js → chunk-30e87n55.js} +15 -24
  9. package/dist/cli/{chunk-52cwcqvp.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-kmx2mydj.js → chunk-450a7rcr.js} +8 -8
  13. package/dist/cli/{chunk-kmx2mydj.js.map → chunk-450a7rcr.js.map} +1 -1
  14. package/dist/cli/{chunk-x1wvw7a8.js → chunk-5n7t497w.js} +129 -131
  15. package/dist/cli/{chunk-x1wvw7a8.js.map → chunk-5n7t497w.js.map} +4 -4
  16. package/dist/cli/{chunk-r99hynxh.js → chunk-61j18dwk.js} +36 -9
  17. package/dist/cli/{chunk-r99hynxh.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-h9ekmtz7.js → chunk-8cd8tj54.js} +28 -35
  21. package/dist/cli/{chunk-h9ekmtz7.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-k0v1f8bb.js → chunk-9he6crym.js} +21 -28
  24. package/dist/cli/{chunk-k0v1f8bb.js.map → chunk-9he6crym.js.map} +2 -2
  25. package/dist/cli/{chunk-aqjvpd03.js → chunk-aztttvb3.js} +27 -33
  26. package/dist/cli/{chunk-aqjvpd03.js.map → chunk-aztttvb3.js.map} +2 -2
  27. package/dist/cli/{chunk-6mq7qkve.js → chunk-cvky9gb2.js} +18 -24
  28. package/dist/cli/{chunk-6mq7qkve.js.map → chunk-cvky9gb2.js.map} +2 -2
  29. package/dist/cli/{chunk-90pdhkpm.js → chunk-eevwt1sc.js} +23 -32
  30. package/dist/cli/{chunk-90pdhkpm.js.map → chunk-eevwt1sc.js.map} +2 -2
  31. package/dist/cli/{chunk-4ae4f395.js → chunk-ejjx8znq.js} +53 -34
  32. package/dist/cli/chunk-ejjx8znq.js.map +15 -0
  33. package/dist/cli/{chunk-8p3xe5jv.js → chunk-exeeb35e.js} +3 -3
  34. package/dist/cli/{chunk-q56730e0.js → chunk-fmceyezb.js} +41 -50
  35. package/dist/cli/{chunk-q56730e0.js.map → chunk-fmceyezb.js.map} +2 -2
  36. package/dist/cli/{chunk-12dzsn9b.js → chunk-hdm2dkd2.js} +72 -77
  37. package/dist/cli/{chunk-12dzsn9b.js.map → chunk-hdm2dkd2.js.map} +2 -2
  38. package/dist/cli/{chunk-j5f2wrj5.js → chunk-hs3gbh8p.js} +10 -15
  39. package/dist/cli/{chunk-j5f2wrj5.js.map → chunk-hs3gbh8p.js.map} +2 -2
  40. package/dist/cli/{chunk-ywn7t0pb.js → chunk-jbj4qhfw.js} +3 -3
  41. package/dist/cli/{chunk-ev67ycx0.js → chunk-jq5n4avg.js} +1 -1
  42. package/dist/cli/{chunk-he2zfgah.js → chunk-mqb2ka8m.js} +22 -30
  43. package/dist/cli/{chunk-he2zfgah.js.map → chunk-mqb2ka8m.js.map} +2 -2
  44. package/dist/cli/{chunk-5gfw0q4j.js → chunk-mt76t7dj.js} +26 -34
  45. package/dist/cli/{chunk-5gfw0q4j.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-np8dmfb0.js → chunk-t3tj0dgr.js} +26 -33
  52. package/dist/cli/{chunk-np8dmfb0.js.map → chunk-t3tj0dgr.js.map} +2 -2
  53. package/dist/cli/{chunk-qvvpnwaz.js → chunk-tqa1s0k8.js} +4 -4
  54. package/dist/cli/{chunk-cbjnx4s8.js → chunk-vh9w1sgp.js} +1 -1
  55. package/dist/cli/{chunk-82atea4k.js → chunk-vkrsvbr5.js} +13 -17
  56. package/dist/cli/{chunk-82atea4k.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-ka5k7cz9.js → chunk-wjt80jps.js} +27 -27
  59. package/dist/cli/{chunk-ka5k7cz9.js.map → chunk-wjt80jps.js.map} +2 -2
  60. package/dist/cli/{chunk-pdwg3q9g.js → chunk-xhtpx3ff.js} +21 -30
  61. package/dist/cli/{chunk-pdwg3q9g.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/core/config-input.d.ts +26 -0
  66. package/dist/types/core/schema.d.ts +27 -3
  67. package/docs/configuration/ask-ai.mdx +47 -0
  68. package/package.json +1 -1
  69. package/src/ai/cors.ts +87 -0
  70. package/src/astro/generate.ts +2 -0
  71. package/src/astro/templates.ts +88 -23
  72. package/src/components/layout/NavTree.astro +75 -66
  73. package/src/components/layout/RootLayout.astro +4 -1
  74. package/src/components/layout/nav-utils.ts +17 -3
  75. package/src/core/adapter.ts +61 -0
  76. package/src/core/config-input.ts +26 -0
  77. package/src/core/schema.ts +56 -0
  78. package/src/registry/eject.ts +2 -0
  79. package/dist/cli/chunk-2aj8ddew.js +0 -72
  80. package/dist/cli/chunk-2aj8ddew.js.map +0 -10
  81. package/dist/cli/chunk-4ae4f395.js.map +0 -15
  82. package/dist/cli/chunk-4xyggvgf.js +0 -21
  83. package/dist/cli/chunk-4xyggvgf.js.map +0 -10
  84. package/dist/cli/chunk-6kzzpsx8.js +0 -26
  85. package/dist/cli/chunk-6kzzpsx8.js.map +0 -10
  86. package/dist/cli/chunk-bcy492zc.js +0 -16
  87. package/dist/cli/chunk-bcy492zc.js.map +0 -10
  88. package/dist/cli/chunk-btfr9yvw.js +0 -41
  89. package/dist/cli/chunk-btfr9yvw.js.map +0 -10
  90. package/dist/cli/chunk-ey89bjj1.js +0 -209
  91. package/dist/cli/chunk-ey89bjj1.js.map +0 -11
  92. package/dist/cli/chunk-vt8fgygt.js +0 -23
  93. package/dist/cli/chunk-vt8fgygt.js.map +0 -10
  94. package/dist/cli/chunk-vyqj481z.js.map +0 -13
  95. /package/dist/cli/{chunk-27gtm2ym.js.map → chunk-2mzebbbz.js.map} +0 -0
  96. /package/dist/cli/{chunk-s5e5jt53.js.map → chunk-2z47ypj8.js.map} +0 -0
  97. /package/dist/cli/{chunk-3r94j3tc.js.map → chunk-688e0dde.js.map} +0 -0
  98. /package/dist/cli/{chunk-vxv4x1n8.js.map → chunk-88by27n5.js.map} +0 -0
  99. /package/dist/cli/{chunk-8gnpdsn1.js.map → chunk-9bkjd11x.js.map} +0 -0
  100. /package/dist/cli/{chunk-8p3xe5jv.js.map → chunk-exeeb35e.js.map} +0 -0
  101. /package/dist/cli/{chunk-ywn7t0pb.js.map → chunk-jbj4qhfw.js.map} +0 -0
  102. /package/dist/cli/{chunk-ev67ycx0.js.map → chunk-jq5n4avg.js.map} +0 -0
  103. /package/dist/cli/{chunk-x66c5yjn.js.map → chunk-ppfvdcd4.js.map} +0 -0
  104. /package/dist/cli/{chunk-wd27zjcz.js.map → chunk-q4rae3bg.js.map} +0 -0
  105. /package/dist/cli/{chunk-pxj10x8y.js.map → chunk-ra1v2nc2.js.map} +0 -0
  106. /package/dist/cli/{chunk-qvvpnwaz.js.map → chunk-tqa1s0k8.js.map} +0 -0
  107. /package/dist/cli/{chunk-cbjnx4s8.js.map → chunk-vh9w1sgp.js.map} +0 -0
  108. /package/dist/cli/{chunk-sbdqrjbb.js.map → chunk-vrfp10qk.js.map} +0 -0
  109. /package/dist/cli/{chunk-5hs6gb7n.js.map → chunk-yzhm0j9q.js.map} +0 -0
@@ -327,6 +327,11 @@ export type SidebarItemConfig = string | {
327
327
  root?: string;
328
328
  };
329
329
  export declare const searchProviders: readonly ["orama", "pagefind", "flexsearch", "algolia", "orama-cloud", "typesense", "mixedbread", "none"];
330
+ /**
331
+ * The `ai.ask.reasoning` levels: the AI SDK's top-level `reasoning` values
332
+ * minus `provider-default`, which is what omitting the field means.
333
+ */
334
+ export declare const askReasoningLevels: readonly ["none", "minimal", "low", "medium", "high", "xhigh"];
330
335
  /** Ask AI backends. `gateway` (default) routes through the Vercel AI Gateway. */
331
336
  export declare const askAiProviders: readonly ["gateway", "openrouter", "llmgateway", "inkeep", "openai-compatible"];
332
337
  declare const aiConfigSchema: z.ZodObject<{
@@ -334,6 +339,7 @@ declare const aiConfigSchema: z.ZodObject<{
334
339
  ask: z.ZodOptional<z.ZodObject<{
335
340
  apiKeyEnv: z.ZodOptional<z.ZodString>;
336
341
  baseUrl: z.ZodOptional<z.ZodURL>;
342
+ cors: z.ZodOptional<z.ZodArray<z.ZodUnion<readonly [z.ZodLiteral<"*">, z.ZodPipe<z.ZodURL, z.ZodTransform<string, string>>]>>>;
337
343
  enabled: z.ZodDefault<z.ZodBoolean>;
338
344
  endpoint: z.ZodOptional<z.ZodString>;
339
345
  headers: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
@@ -346,6 +352,14 @@ declare const aiConfigSchema: z.ZodObject<{
346
352
  inkeep: "inkeep";
347
353
  "openai-compatible": "openai-compatible";
348
354
  }>>;
355
+ reasoning: z.ZodOptional<z.ZodEnum<{
356
+ none: "none";
357
+ minimal: "minimal";
358
+ low: "low";
359
+ medium: "medium";
360
+ high: "high";
361
+ xhigh: "xhigh";
362
+ }>>;
349
363
  retrieval: z.ZodOptional<z.ZodObject<{
350
364
  contextBudget: z.ZodOptional<z.ZodNumber>;
351
365
  excerptChars: z.ZodOptional<z.ZodNumber>;
@@ -391,6 +405,7 @@ declare const aiConfigSchema: z.ZodObject<{
391
405
  webmcp: z.ZodDefault<z.ZodBoolean>;
392
406
  }, z.core.$strict>;
393
407
  export type AskAiProvider = (typeof askAiProviders)[number];
408
+ export type AskReasoning = (typeof askReasoningLevels)[number];
394
409
  export type AskAiConfig = NonNullable<z.infer<typeof aiConfigSchema>["ask"]>;
395
410
  export { openInChatProviders } from "./open-in-chat.ts";
396
411
  export type { OpenInChatProvider } from "./open-in-chat.ts";
@@ -505,9 +520,9 @@ declare const contentSignalsSchema: z.ZodPipe<z.ZodUnion<readonly [z.ZodBoolean,
505
520
  declare const dateFormatConfigSchema: z.ZodObject<{
506
521
  calendar: z.ZodOptional<z.ZodString>;
507
522
  dateStyle: z.ZodOptional<z.ZodEnum<{
523
+ medium: "medium";
508
524
  full: "full";
509
525
  long: "long";
510
- medium: "medium";
511
526
  short: "short";
512
527
  }>>;
513
528
  day: z.ZodOptional<z.ZodEnum<{
@@ -576,6 +591,7 @@ export declare const blumeConfigSchema: z.ZodObject<{
576
591
  ask: z.ZodOptional<z.ZodObject<{
577
592
  apiKeyEnv: z.ZodOptional<z.ZodString>;
578
593
  baseUrl: z.ZodOptional<z.ZodURL>;
594
+ cors: z.ZodOptional<z.ZodArray<z.ZodUnion<readonly [z.ZodLiteral<"*">, z.ZodPipe<z.ZodURL, z.ZodTransform<string, string>>]>>>;
579
595
  enabled: z.ZodDefault<z.ZodBoolean>;
580
596
  endpoint: z.ZodOptional<z.ZodString>;
581
597
  headers: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
@@ -588,6 +604,14 @@ export declare const blumeConfigSchema: z.ZodObject<{
588
604
  inkeep: "inkeep";
589
605
  "openai-compatible": "openai-compatible";
590
606
  }>>;
607
+ reasoning: z.ZodOptional<z.ZodEnum<{
608
+ none: "none";
609
+ minimal: "minimal";
610
+ low: "low";
611
+ medium: "medium";
612
+ high: "high";
613
+ xhigh: "xhigh";
614
+ }>>;
591
615
  retrieval: z.ZodOptional<z.ZodObject<{
592
616
  contextBudget: z.ZodOptional<z.ZodNumber>;
593
617
  excerptChars: z.ZodOptional<z.ZodNumber>;
@@ -773,9 +797,9 @@ export declare const blumeConfigSchema: z.ZodObject<{
773
797
  dateFormat: z.ZodDefault<z.ZodObject<{
774
798
  calendar: z.ZodOptional<z.ZodString>;
775
799
  dateStyle: z.ZodOptional<z.ZodEnum<{
800
+ medium: "medium";
776
801
  full: "full";
777
802
  long: "long";
778
- medium: "medium";
779
803
  short: "short";
780
804
  }>>;
781
805
  day: z.ZodOptional<z.ZodEnum<{
@@ -1061,6 +1085,7 @@ export declare const blumeConfigSchema: z.ZodObject<{
1061
1085
  label: z.ZodString;
1062
1086
  }, z.core.$strict>>>;
1063
1087
  provider: z.ZodDefault<z.ZodEnum<{
1088
+ none: "none";
1064
1089
  algolia: "algolia";
1065
1090
  mixedbread: "mixedbread";
1066
1091
  orama: "orama";
@@ -1068,7 +1093,6 @@ export declare const blumeConfigSchema: z.ZodObject<{
1068
1093
  flexsearch: "flexsearch";
1069
1094
  "orama-cloud": "orama-cloud";
1070
1095
  typesense: "typesense";
1071
- none: "none";
1072
1096
  }>>;
1073
1097
  typesense: z.ZodOptional<z.ZodObject<{
1074
1098
  collection: z.ZodString;
@@ -109,6 +109,37 @@ Blume sends the same `POST` body as its built-in route:
109
109
 
110
110
  Return a successful response whose body is a plain UTF-8 text stream. If the endpoint is on another origin, allow the docs origin with CORS: accept `OPTIONS` and `POST`, permit the `content-type` request header, and return the CORS headers on both the preflight and streamed response. With `endpoint` set, Blume generates the chat UI but no server route, grounding snapshot, provider dependency, or provider-secret warning; your backend owns retrieval, authentication, rate limiting, model access, and citations.
111
111
 
112
+ ## Cross-origin callers
113
+
114
+ The generated endpoint answers the in-page assistant on its own origin. To call it from another site as well — a marketing page with an ask box, say — list that site's origin in `cors`:
115
+
116
+ ```ts blume.config.ts lineNumbers
117
+ ai: {
118
+ ask: {
119
+ enabled: true,
120
+ cors: ["https://www.example.com"],
121
+ },
122
+ }
123
+ ```
124
+
125
+ The route then answers the browser's `OPTIONS` preflight and names a listed origin on every response — the streamed answer and the error statuses alike, so the caller can tell a rejected body from a provider failure. Origins that aren't listed get no header and stay subject to the browser's same-origin rule. Each entry is reduced to its origin, so `https://www.example.com/docs/` and `https://www.example.com` mean the same thing. To let any page call the route, list `"*"` instead of origins.
126
+
127
+ The caller sends the same `POST` body the [external endpoint](#external-endpoint) contract describes and reads back the same text stream. Send it as JSON with a `content-type: application/json` header:
128
+
129
+ ```ts
130
+ const response = await fetch("https://docs.example.com/api/ask", {
131
+ body: JSON.stringify({
132
+ messages: [{ role: "user", content: "How do I deploy?" }],
133
+ }),
134
+ headers: { "content-type": "application/json" },
135
+ method: "POST",
136
+ });
137
+ ```
138
+
139
+ The content type matters: Astro's cross-site request check rejects a cross-origin `POST` that has no content type, or a form-like one such as `text/plain`, with a 403 before the route runs, and that response carries no CORS headers, so the browser reports it as a network error rather than a status. The preflight allows whatever request headers the caller asks for, so a fetch wrapper that adds its own headers needs no extra configuration.
140
+
141
+ `cors` only affects the generated route; with an external `endpoint`, CORS is that backend's job, and setting both is a config error. The endpoint stays unauthenticated either way, so the [rate limiting](#rate-limiting) advice applies to cross-origin traffic too.
142
+
112
143
  ## Server output required
113
144
 
114
145
  Blume's built-in Ask AI backend is a server route (`POST /api/ask`), so it can't run on a static build. Switch to server output and pick an adapter:
@@ -188,6 +219,22 @@ The values are written into the generated route as-is, so keep secrets in `apiKe
188
219
 
189
220
  Keys are read through Astro's [`getSecret()`](https://docs.astro.build/en/guides/environment-variables/#retrieving-secrets-programmatically), so each adapter supplies them its own way: environment variables on Node, Vercel, and Netlify, and the Worker's [bindings](https://docs.astro.build/en/guides/integrations-guide/cloudflare/#environment-variables-and-secrets) on Cloudflare. Enabling Ask AI also turns on React for the in-page island — see [Customization](/docs/configuration/customization#interactive-islands).
190
221
 
222
+ ## Reasoning
223
+
224
+ Reasoning models think before they answer, and how much they do so by default varies by model. For grounded docs Q&A the retrieved excerpts carry the answer, so most of that thinking is latency the reader waits through. `reasoning` sets how much the model reasons: `"none"`, `"minimal"`, `"low"`, `"medium"`, `"high"`, or `"xhigh"`:
225
+
226
+ ```ts blume.config.ts lineNumbers
227
+ ai: {
228
+ ask: {
229
+ enabled: true,
230
+ model: "openai/gpt-5.5",
231
+ reasoning: "none",
232
+ },
233
+ }
234
+ ```
235
+
236
+ The value is sent as the backend's own reasoning-effort control. Through the gateway it travels as the [AI SDK's `reasoning` option](https://ai-sdk.dev/docs/ai-sdk-core/reasoning), which the gateway maps to the model's setting — OpenAI's `reasoning_effort`, for example. On OpenRouter it is sent as `reasoning.effort`, and on LLMGateway or a custom `openai-compatible` endpoint as `reasoning_effort` in the request, so the endpoint has to accept that parameter. The model has to support the level you pick: OpenAI rejects a level a model doesn't offer (`"none"` and `"xhigh"` exist only on some), so check the model's documentation before setting one. Inkeep runs its own QA pipeline and has no reasoning control, so setting `reasoning` with that backend is a config error. Leave it unset to keep the model's default. Like [retrieval size](#retrieval-size), it trades thoroughness for time-to-first-token, and answers stay grounded either way.
237
+
191
238
  ## Rate limiting
192
239
 
193
240
  The `POST /api/ask` endpoint is **unauthenticated** — it has to be, so the in-page assistant can call it. Blume validates each request — rejecting malformed bodies, capping it to 1–40 messages, and accepting only `user`/`assistant` roles so a caller can't inject their own system prompt and repurpose the route as a general LLM proxy — to bound how much a single call can spend against your model, but it can't stop someone from calling the endpoint repeatedly. If cost abuse is a concern, put the route behind a rate limiter — your host's (e.g. Vercel's) edge rate limiting, a middleware, or your model provider's per-key spend limits.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "blume",
3
- "version": "1.7.1",
3
+ "version": "1.7.2",
4
4
  "description": "Documentation that's fast, AI-ready, and zero-config.",
5
5
  "keywords": [
6
6
  "astro",
package/src/ai/cors.ts ADDED
@@ -0,0 +1,87 @@
1
+ /**
2
+ * CORS for the generated Ask AI route (`ai.ask.cors`).
3
+ *
4
+ * A browser only lets a page on another origin read a response that names
5
+ * that origin, and a JSON `POST` preflights first. `preflightResponse` answers
6
+ * the `OPTIONS`; `withCors` wraps the `POST` handler so every response it
7
+ * returns — the stream, a 400, a 500 — carries the headers. Wrapping once,
8
+ * rather than stamping each `return`, keeps the next return site added to the
9
+ * handler from shipping an opaque failure for that one status.
10
+ *
11
+ * `allowed` is the `ai.ask.cors` list: origins already reduced to their
12
+ * `scheme://host[:port]` form by the config schema, or the single entry `"*"`
13
+ * to admit every origin.
14
+ */
15
+
16
+ /** The `ai.ask.cors` entry that admits every origin. */
17
+ export const ANY_ORIGIN = "*";
18
+
19
+ /** The headers a response carries for a cross-origin caller. */
20
+ export interface CorsHeaders {
21
+ "access-control-allow-origin"?: string;
22
+ vary?: string;
23
+ }
24
+
25
+ /** The response headers that name the caller's origin when `allowed` lists it. */
26
+ export const corsHeaders = (
27
+ request: Request,
28
+ allowed: readonly string[]
29
+ ): CorsHeaders => {
30
+ if (allowed.includes(ANY_ORIGIN)) {
31
+ // A wildcard answer is the same for every caller, so nothing to vary on.
32
+ return { "access-control-allow-origin": ANY_ORIGIN };
33
+ }
34
+ // `Vary` rides on both branches: the answer depends on `Origin` whether or
35
+ // not it was listed, so a shared cache never hands one origin's response
36
+ // (or the header-less one) to another.
37
+ const origin = request.headers.get("origin");
38
+ return origin && allowed.includes(origin)
39
+ ? { "access-control-allow-origin": origin, vary: "origin" }
40
+ : { vary: "origin" };
41
+ };
42
+
43
+ /** Answer the browser's `OPTIONS` preflight for the route. */
44
+ export const preflightResponse = (
45
+ request: Request,
46
+ allowed: readonly string[]
47
+ ): Response =>
48
+ new Response(null, {
49
+ headers: {
50
+ ...corsHeaders(request, allowed),
51
+ // Reflect whatever the caller's fetch wrapper asks to send, falling back
52
+ // to the JSON POST's own `content-type`; a listed origin shouldn't need
53
+ // an eject to add a header of its own.
54
+ "access-control-allow-headers":
55
+ request.headers.get("access-control-request-headers") ?? "content-type",
56
+ "access-control-allow-methods": "POST",
57
+ "access-control-max-age": "86400",
58
+ },
59
+ status: 204,
60
+ });
61
+
62
+ /** The slice of Astro's `APIContext` the wrapped handler reads. */
63
+ interface RequestContext {
64
+ request: Request;
65
+ }
66
+
67
+ /** Stamp the CORS headers on every response `handler` returns. */
68
+ export const withCors =
69
+ (
70
+ allowed: readonly string[],
71
+ handler: (context: RequestContext) => Promise<Response> | Response
72
+ ): ((context: RequestContext) => Promise<Response>) =>
73
+ async (context) => {
74
+ const response = await handler(context);
75
+ for (const [key, value] of Object.entries(
76
+ corsHeaders(context.request, allowed)
77
+ )) {
78
+ // `Vary` accumulates (the handler may already vary on something), the
79
+ // rest replace.
80
+ if (key === "vary") {
81
+ response.headers.append(key, value);
82
+ } else {
83
+ response.headers.set(key, value);
84
+ }
85
+ }
86
+ return response;
87
+ };
@@ -1826,7 +1826,9 @@ const writeAskFiles = async (
1826
1826
  await write(
1827
1827
  join(srcDir, "pages", "api", "ask.ts"),
1828
1828
  askEndpointTemplate(resolveAskBackend(ask), grounded, {
1829
+ cors: ask.cors,
1829
1830
  instructions: ask.instructions,
1831
+ reasoning: ask.reasoning,
1830
1832
  retrieval: ask.retrieval,
1831
1833
  })
1832
1834
  );
@@ -9,7 +9,7 @@ import type { AskBackend } from "../ai/ask.ts";
9
9
  import { buildHomeLinkHeader } from "../ai/link-headers.ts";
10
10
  import { normalizeBasePath } from "../core/base-path.ts";
11
11
  import { TOC_HIDDEN_KEY } from "../core/heading-markers.ts";
12
- import type { ResolvedConfig } from "../core/schema.ts";
12
+ import type { AskReasoning, ResolvedConfig } from "../core/schema.ts";
13
13
  import { BLUME_IGNORE_DIRS } from "../core/sources/watch.ts";
14
14
  import { trimChar } from "../core/trim.ts";
15
15
  import type { ProjectContext } from "../core/types.ts";
@@ -1040,12 +1040,58 @@ const ASK_FALLBACK_PROMPT =
1040
1040
 
1041
1041
  /** The `ai.ask` values the generated endpoint has to carry with it. */
1042
1042
  export interface AskEndpointOptions {
1043
+ /** `ai.ask.cors` — origins allowed to call the route from another site. */
1044
+ cors?: string[];
1043
1045
  /** `ai.ask.instructions` — extra system-prompt text. */
1044
1046
  instructions?: string;
1047
+ /**
1048
+ * `ai.ask.reasoning` — how much the model reasons before answering, sent
1049
+ * as the backend's own reasoning-effort control.
1050
+ */
1051
+ reasoning?: AskReasoning;
1045
1052
  /** `ai.ask.retrieval` — how much documentation each question carries. */
1046
1053
  retrieval?: AskRetrievalOptions;
1047
1054
  }
1048
1055
 
1056
+ /** The pieces `askEndpointTemplate` splices in for `ai.ask.cors`. */
1057
+ interface AskCorsTemplate {
1058
+ /** The route's closing token: `});` when the POST is wrapped, `};` otherwise. */
1059
+ close: string;
1060
+ /** The runtime import, when anything is listed. */
1061
+ imports: string[];
1062
+ /** The opening of `export const POST: APIRoute = `. */
1063
+ open: string;
1064
+ /** The allow list and the `OPTIONS` handler, spliced after the provider setup. */
1065
+ setup: string;
1066
+ }
1067
+
1068
+ /**
1069
+ * `ai.ask.cors`: a browser only lets another origin read the stream when the
1070
+ * response names that origin, and a JSON POST preflights first, so the route
1071
+ * answers `OPTIONS` and wraps the `POST` in `withCors`, which stamps a listed
1072
+ * origin on every response — errors included, so a cross-origin caller can
1073
+ * tell a 400 from a 500 — without each `return` having to remember to.
1074
+ * Unlisted origins get no allow header and stay subject to the same-origin
1075
+ * rule. Left out entirely when nothing is listed, so the default route is
1076
+ * unchanged.
1077
+ */
1078
+ const askCorsTemplate = (cors: readonly string[] = []): AskCorsTemplate =>
1079
+ cors.length > 0
1080
+ ? {
1081
+ close: "});",
1082
+ imports: [
1083
+ 'import { preflightResponse, withCors } from "blume/ai/cors.ts";',
1084
+ ],
1085
+ open: "withCors(ALLOWED_ORIGINS, async ({ request }) => {",
1086
+ setup: `
1087
+ const ALLOWED_ORIGINS = ${JSON.stringify(cors)};
1088
+
1089
+ export const OPTIONS: APIRoute = ({ request }) =>
1090
+ preflightResponse(request, ALLOWED_ORIGINS);
1091
+ `,
1092
+ }
1093
+ : { close: "};", imports: [], open: "async ({ request }) => {", setup: "" };
1094
+
1049
1095
  /**
1050
1096
  * Generate the Ask AI server endpoint (`.blume/src/pages/api/ask.ts`).
1051
1097
  *
@@ -1053,15 +1099,24 @@ export interface AskEndpointOptions {
1053
1099
  * built-in prompt on every path: the grounded prompt via `createAskContext`,
1054
1100
  * and the plain fallback here. `options.retrieval` (the `ai.ask.retrieval`
1055
1101
  * config) is forwarded to `createAskContext` on the grounded path, where it
1056
- * sizes retrieval. Both travel in one options object so a new call site can't
1057
- * silently drop one of them.
1102
+ * sizes retrieval. `options.reasoning` (the `ai.ask.reasoning` config)
1103
+ * reaches the model call on both paths. `options.cors` (the `ai.ask.cors`
1104
+ * config) adds a preflight handler and wraps the `POST` so every response
1105
+ * names a listed origin. All four travel in one options object so a new call
1106
+ * site can't silently drop one of them.
1058
1107
  */
1059
1108
  export const askEndpointTemplate = (
1060
1109
  backend: AskBackend,
1061
1110
  grounded: boolean,
1062
1111
  options?: AskEndpointOptions
1063
1112
  ): string => {
1064
- const instructions = options?.instructions;
1113
+ const { instructions, reasoning, retrieval } = options ?? {};
1114
+ // `ai.ask.reasoning`. The gateway and OpenAI-compatible providers take it
1115
+ // from `streamText`'s top-level `reasoning` (the gateway maps it to the
1116
+ // model's own control, the OpenAI-compatible provider sends it as
1117
+ // `reasoning_effort`). OpenRouter's provider ignores that call option and
1118
+ // only reads its own model setting, so there the level rides on the model
1119
+ // as `reasoning.effort`. Omitted keeps the provider default on every path.
1065
1120
  const fallbackPrompt = instructions
1066
1121
  ? `${ASK_FALLBACK_PROMPT}\n\n${instructions}`
1067
1122
  : ASK_FALLBACK_PROMPT;
@@ -1097,7 +1152,10 @@ export const askEndpointTemplate = (
1097
1152
  setup = `\nconst openrouter = createOpenRouter({
1098
1153
  apiKey: getSecret(${JSON.stringify(backend.apiKeyEnv)}),${headersField}
1099
1154
  });\n`;
1100
- modelExpr = `openrouter(${JSON.stringify(backend.model)})`;
1155
+ const settings = reasoning
1156
+ ? `, { reasoning: { effort: ${JSON.stringify(reasoning)} } }`
1157
+ : "";
1158
+ modelExpr = `openrouter(${JSON.stringify(backend.model)}${settings})`;
1101
1159
  } else if (backend.kind === "openai-compatible") {
1102
1160
  imports.push(
1103
1161
  'import { createOpenAICompatible } from "@ai-sdk/openai-compatible";'
@@ -1120,13 +1178,15 @@ export const askEndpointTemplate = (
1120
1178
  if (instructions) {
1121
1179
  groundFields.push(`instructions: ${JSON.stringify(instructions)}`);
1122
1180
  }
1123
- if (options?.retrieval) {
1124
- groundFields.push(`retrieval: ${JSON.stringify(options.retrieval)}`);
1181
+ if (retrieval) {
1182
+ groundFields.push(`retrieval: ${JSON.stringify(retrieval)}`);
1125
1183
  }
1126
1184
  const groundOptions =
1127
1185
  groundFields.length > 0 ? `, { ${groundFields.join(", ")} }` : "";
1128
1186
  setup += `\nconst ground = createAskContext(askData${groundOptions});\n`;
1129
1187
  }
1188
+ const cors = askCorsTemplate(options?.cors);
1189
+ imports.push(...cors.imports);
1130
1190
  // Validate the client-supplied body and cap its size. The endpoint is
1131
1191
  // unauthenticated, so bounding message count/length limits how much a caller
1132
1192
  // can spend against the model per request, and restricting roles to
@@ -1183,24 +1243,29 @@ export const askEndpointTemplate = (
1183
1243
  const onError = ` onError({ error }) {
1184
1244
  console.error("Ask AI provider error:", error);
1185
1245
  },`;
1246
+ // The `streamText` argument list, built once so the grounded and plain
1247
+ // paths can't drift: they differ only in where the instructions come from.
1248
+ const streamFields = [
1249
+ `model: ${modelExpr}`,
1250
+ grounded
1251
+ ? "instructions"
1252
+ : `instructions:\n ${JSON.stringify(fallbackPrompt)}`,
1253
+ "messages",
1254
+ ];
1255
+ if (reasoning && backend.kind !== "openrouter") {
1256
+ streamFields.push(`reasoning: ${JSON.stringify(reasoning)}`);
1257
+ }
1258
+ const call = ` const result = streamText({
1259
+ ${streamFields.join(",\n ")},
1260
+ ${onError}
1261
+ });`;
1186
1262
  const stream = grounded
1187
1263
  ? ` const instructions =
1188
1264
  (await ground(messages, body.page)) ??
1189
1265
  ${JSON.stringify(fallbackPrompt)};
1190
- const result = streamText({
1191
- model: ${modelExpr},
1192
- instructions,
1193
- messages,
1194
- ${onError}
1195
- });`
1196
- : ` const result = streamText({
1197
- model: ${modelExpr},
1198
- instructions:
1199
- ${JSON.stringify(fallbackPrompt)},
1200
- messages,
1201
- ${onError}
1202
- });`;
1203
- const handler = `export const POST: APIRoute = async ({ request }) => {
1266
+ ${call}`
1267
+ : call;
1268
+ const handler = `export const POST: APIRoute = ${cors.open}
1204
1269
  ${validate}
1205
1270
  ${keyCheck}
1206
1271
  try {
@@ -1209,12 +1274,12 @@ ${stream}
1209
1274
  } catch {
1210
1275
  return new Response("Failed to generate a response.", { status: 500 });
1211
1276
  }
1212
- };`;
1277
+ ${cors.close}`;
1213
1278
  return `// Generated by Blume. Do not edit.
1214
1279
  ${imports.join("\n")}
1215
1280
 
1216
1281
  export const prerender = false;
1217
- ${setup}
1282
+ ${setup}${cors.setup}
1218
1283
  ${handler}
1219
1284
  `;
1220
1285
  };
@@ -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,