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
@@ -588,6 +588,8 @@ export interface AskSuggestion {
588
588
  /** Backends that can route an Ask AI request. */
589
589
  type AskProviderGateway = "gateway" | "openrouter" | "llmgateway";
590
590
  type AskProvider = AskProviderGateway | "inkeep" | "openai-compatible";
591
+ /** How much the model reasons before answering (`ai.ask.reasoning`). */
592
+ type AskReasoning = "none" | "minimal" | "low" | "medium" | "high" | "xhigh";
591
593
  /** How much retrieved documentation each Ask AI question carries. */
592
594
  export interface AskRetrievalConfig {
593
595
  /**
@@ -620,6 +622,18 @@ export interface AskConfig {
620
622
  * overrides the built-in preset.
621
623
  */
622
624
  baseUrl?: string;
625
+ /**
626
+ * Origins allowed to call the generated endpoint from another site — a
627
+ * marketing page that embeds an ask box, for example — or `"*"` to allow
628
+ * every origin. The route answers preflight requests and names a listed
629
+ * origin on every response, errors included; every other origin stays
630
+ * subject to the browser's same-origin rule. Callers must send the body as
631
+ * JSON with a `content-type: application/json` header, or Astro's cross-site
632
+ * request check rejects the `POST` before the route runs. Only the generated
633
+ * route reads this; an external `endpoint` handles its own CORS and can't be
634
+ * combined with it.
635
+ */
636
+ cors?: string[];
623
637
  /** Turn Ask AI on. Defaults to `false`. */
624
638
  enabled?: boolean;
625
639
  /**
@@ -628,6 +642,13 @@ export interface AskConfig {
628
642
  * limiting, and streaming. Accepts an absolute URL or root-relative path.
629
643
  */
630
644
  endpoint?: string;
645
+ /**
646
+ * Static request headers sent to the provider on every call — a
647
+ * caller-identifying header for a shared backend, for example. Values are
648
+ * written into the generated route as literals, so keep secrets in
649
+ * `apiKeyEnv` rather than here.
650
+ */
651
+ headers?: Record<string, string>;
631
652
  /**
632
653
  * Extra system-prompt text appended to the built-in instructions — use it
633
654
  * for identity, language, or tone. The built-in grounding behavior (answer
@@ -638,6 +659,18 @@ export interface AskConfig {
638
659
  model?: string;
639
660
  /** Which backend routes the request. Defaults to `gateway`. */
640
661
  provider?: AskProvider;
662
+ /**
663
+ * How much the model reasons before answering, from `"none"` to `"xhigh"`.
664
+ * Sent as the backend's own reasoning-effort control: the AI SDK's
665
+ * `reasoning` option on the gateway, `reasoning.effort` on OpenRouter, and
666
+ * `reasoning_effort` on OpenAI-compatible endpoints. The model has to
667
+ * support the level — OpenAI rejects one a model doesn't offer — and the
668
+ * endpoint has to accept the parameter; Inkeep has no reasoning control,
669
+ * so the field is rejected there. Omitted keeps the model's default.
670
+ * `"none"` is the fastest and cheapest for grounded docs Q&A, where the
671
+ * retrieved excerpts carry the answer.
672
+ */
673
+ reasoning?: AskReasoning;
641
674
  /**
642
675
  * How much documentation each question carries into the model's prompt.
643
676
  * Lower values cut time-to-first-token — which dominates on a self-hosted
@@ -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,8 +339,10 @@ 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>;
345
+ headers: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
339
346
  instructions: z.ZodOptional<z.ZodString>;
340
347
  model: z.ZodDefault<z.ZodString>;
341
348
  provider: z.ZodDefault<z.ZodEnum<{
@@ -345,6 +352,14 @@ declare const aiConfigSchema: z.ZodObject<{
345
352
  inkeep: "inkeep";
346
353
  "openai-compatible": "openai-compatible";
347
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
+ }>>;
348
363
  retrieval: z.ZodOptional<z.ZodObject<{
349
364
  contextBudget: z.ZodOptional<z.ZodNumber>;
350
365
  excerptChars: z.ZodOptional<z.ZodNumber>;
@@ -390,6 +405,7 @@ declare const aiConfigSchema: z.ZodObject<{
390
405
  webmcp: z.ZodDefault<z.ZodBoolean>;
391
406
  }, z.core.$strict>;
392
407
  export type AskAiProvider = (typeof askAiProviders)[number];
408
+ export type AskReasoning = (typeof askReasoningLevels)[number];
393
409
  export type AskAiConfig = NonNullable<z.infer<typeof aiConfigSchema>["ask"]>;
394
410
  export { openInChatProviders } from "./open-in-chat.ts";
395
411
  export type { OpenInChatProvider } from "./open-in-chat.ts";
@@ -504,9 +520,9 @@ declare const contentSignalsSchema: z.ZodPipe<z.ZodUnion<readonly [z.ZodBoolean,
504
520
  declare const dateFormatConfigSchema: z.ZodObject<{
505
521
  calendar: z.ZodOptional<z.ZodString>;
506
522
  dateStyle: z.ZodOptional<z.ZodEnum<{
523
+ medium: "medium";
507
524
  full: "full";
508
525
  long: "long";
509
- medium: "medium";
510
526
  short: "short";
511
527
  }>>;
512
528
  day: z.ZodOptional<z.ZodEnum<{
@@ -575,8 +591,10 @@ export declare const blumeConfigSchema: z.ZodObject<{
575
591
  ask: z.ZodOptional<z.ZodObject<{
576
592
  apiKeyEnv: z.ZodOptional<z.ZodString>;
577
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>>]>>>;
578
595
  enabled: z.ZodDefault<z.ZodBoolean>;
579
596
  endpoint: z.ZodOptional<z.ZodString>;
597
+ headers: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
580
598
  instructions: z.ZodOptional<z.ZodString>;
581
599
  model: z.ZodDefault<z.ZodString>;
582
600
  provider: z.ZodDefault<z.ZodEnum<{
@@ -586,6 +604,14 @@ export declare const blumeConfigSchema: z.ZodObject<{
586
604
  inkeep: "inkeep";
587
605
  "openai-compatible": "openai-compatible";
588
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
+ }>>;
589
615
  retrieval: z.ZodOptional<z.ZodObject<{
590
616
  contextBudget: z.ZodOptional<z.ZodNumber>;
591
617
  excerptChars: z.ZodOptional<z.ZodNumber>;
@@ -771,9 +797,9 @@ export declare const blumeConfigSchema: z.ZodObject<{
771
797
  dateFormat: z.ZodDefault<z.ZodObject<{
772
798
  calendar: z.ZodOptional<z.ZodString>;
773
799
  dateStyle: z.ZodOptional<z.ZodEnum<{
800
+ medium: "medium";
774
801
  full: "full";
775
802
  long: "long";
776
- medium: "medium";
777
803
  short: "short";
778
804
  }>>;
779
805
  day: z.ZodOptional<z.ZodEnum<{
@@ -1059,6 +1085,7 @@ export declare const blumeConfigSchema: z.ZodObject<{
1059
1085
  label: z.ZodString;
1060
1086
  }, z.core.$strict>>>;
1061
1087
  provider: z.ZodDefault<z.ZodEnum<{
1088
+ none: "none";
1062
1089
  algolia: "algolia";
1063
1090
  mixedbread: "mixedbread";
1064
1091
  orama: "orama";
@@ -1066,7 +1093,6 @@ export declare const blumeConfigSchema: z.ZodObject<{
1066
1093
  flexsearch: "flexsearch";
1067
1094
  "orama-cloud": "orama-cloud";
1068
1095
  typesense: "typesense";
1069
- none: "none";
1070
1096
  }>>;
1071
1097
  typesense: z.ZodOptional<z.ZodObject<{
1072
1098
  collection: z.ZodString;
package/docs/08-faq.mdx CHANGED
@@ -160,3 +160,24 @@ Patch oxfmt so it preserves the line break that sits directly against a `:::` fe
160
160
  :::warning[Version-pinned]
161
161
  The patch targets a specific oxfmt build — its diff references a file whose name is hashed per release (`dist/markdown-*.js`). When you bump oxfmt, regenerate the patch (e.g. `bun patch oxfmt`) or check whether the upstream fix has landed and the patch is no longer needed.
162
162
  :::
163
+
164
+ ## Why does Knip report my `blume.config.ts` dependencies as unused?
165
+
166
+ [Knip](https://knip.dev) only follows imports from files it knows are entry points, and it learns those from its built-in plugins. There is no Blume plugin yet, and Knip's Astro plugin doesn't switch on either: it looks for `astro` in your own `package.json`, but a Blume project depends on `blume`, and the generated `.blume/` Astro project is gitignored, so Knip never sees it. Nothing references `blume.config.ts`, so any package it imports gets reported as unused.
167
+
168
+ Register the files Blume loads from your project root as entries. In `knip.json`:
169
+
170
+ ```json knip.json
171
+ {
172
+ "entry": [
173
+ "blume.config.{ts,mjs,js}",
174
+ "components.{ts,tsx}",
175
+ "islands/**/*.{ts,tsx}",
176
+ "pages/**/*"
177
+ ]
178
+ }
179
+ ```
180
+
181
+ In a monorepo, put the same `entry` list under the docs workspace in `workspaces` instead. Drop any line for a convention you don't use — `components.ts` for [component overrides](/docs/configuration/customization#component-overrides), `islands/` for [interactive islands](/docs/configuration/customization#interactive-islands), and `pages/` for [custom pages](/docs/configuration/customization#custom-pages) (adjust the last one if you've changed `content.pages`).
182
+
183
+ Knip can only follow real imports. A package that's only named inside a string — say, an [Astro integration](/docs/configuration/customization#astro-integrations) that calls `injectScript("page", "import('some-package')")` — still needs an `ignoreDependencies` entry.
@@ -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:
@@ -166,12 +197,44 @@ ai: {
166
197
 
167
198
  Set `apiKeyEnv` (and, for the named providers, `baseUrl`) on any backend to point at a different env var or proxy.
168
199
 
200
+ To send static request headers with every call — a caller-identifying header for a shared backend, say, so its own observability or rate limiting can tell your docs apart from other traffic — set `headers`. It works on every backend, including the gateway:
201
+
202
+ ```ts blume.config.ts lineNumbers
203
+ ai: {
204
+ ask: {
205
+ enabled: true,
206
+ provider: "openai-compatible",
207
+ baseUrl: "https://llm.internal.example.com/v1",
208
+ apiKeyEnv: "INTERNAL_LLM_API_KEY",
209
+ headers: { "X-Caller-Id": "docs" },
210
+ },
211
+ }
212
+ ```
213
+
214
+ The values are written into the generated route as-is, so keep secrets in `apiKeyEnv` rather than in `headers`. The API key's `Authorization` header is applied first, so a custom header can't displace it.
215
+
169
216
  :::note
170
217
  **Inkeep** answers from the content you've indexed in the Inkeep dashboard — it runs its own retrieval — so Blume leaves it ungrounded. Every other backend is [grounded](#grounding) in this site's pages.
171
218
  :::
172
219
 
173
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).
174
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
+
175
238
  ## Rate limiting
176
239
 
177
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.
@@ -163,7 +163,7 @@ A read token for a private dataset comes from the `SANITY_TOKEN` environment var
163
163
 
164
164
  ## Notion
165
165
 
166
- The built-in `notion` source turns a Notion database into a collection: each row becomes a page, its properties become frontmatter, and its block tree becomes MDX. Callouts, toggles, columns, and code blocks map to the matching Blume components. `@notionhq/client` (v5 or later) is an optional peer dependency; Blume reads the database through its first data source.
166
+ The built-in `notion` source turns a Notion database into a collection: each row becomes a page, its properties become frontmatter, and its block tree becomes MDX. Callouts, toggles, columns, and code blocks map to the matching Blume components. Video blocks become a `<YouTube>` embed when they hold a YouTube link and a `<video>` player otherwise, with the block's caption as a `<Frame>` caption either way; a link to a video page rather than a media file (a Vimeo or Loom URL, say) is reported as a warning instead of embedded. `@notionhq/client` (v5 or later) is an optional peer dependency; Blume reads the database through its first data source.
167
167
 
168
168
  ```ts blume.config.ts
169
169
  import { defineConfig } from "blume";
@@ -185,7 +185,7 @@ export default defineConfig({
185
185
  });
186
186
  ```
187
187
 
188
- The integration token comes from the `NOTION_TOKEN` environment variable (share the database with your integration). By default every page is imported; set `publishedValue` to make the `Status` property a publish gate — any other value then maps to `draft: true`, which production builds drop. **Notion image URLs are signed and expire**, so the adapter downloads them at build time into the site's assets and rewrites the references — a CMS image never rots a static build. API calls are paced through a small request pool (3 at a time, matching Notion's per-integration rate limit) so databases with hundreds of pages import without tripping `429` responses; set `concurrency` on the source to tune it.
188
+ The integration token comes from the `NOTION_TOKEN` environment variable (share the database with your integration). By default every page is imported; set `publishedValue` to make the `Status` property a publish gate — any other value then maps to `draft: true`, which production builds drop. **Notion image and video URLs are signed and expire**, so the adapter downloads them at build time into the site's assets and rewrites the references — a CMS asset never rots a static build. API calls are paced through a small request pool (3 at a time, matching Notion's per-integration rate limit) so databases with hundreds of pages import without tripping `429` responses; set `concurrency` on the source to tune it.
189
189
 
190
190
  ## Preview and sync
191
191
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "blume",
3
- "version": "1.7.0",
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/ask.ts CHANGED
@@ -7,16 +7,31 @@ import type { AskAiConfig } from "../core/schema.ts";
7
7
  * AI SDK's OpenAI-compatible provider.
8
8
  */
9
9
  export type AskBackend =
10
- | { kind: "gateway"; model: string }
11
- | { apiKeyEnv: string; kind: "openrouter"; model: string }
10
+ | { headers?: AskHeaders; kind: "gateway"; model: string }
11
+ | {
12
+ apiKeyEnv: string;
13
+ headers?: AskHeaders;
14
+ kind: "openrouter";
15
+ model: string;
16
+ }
12
17
  | {
13
18
  apiKeyEnv: string;
14
19
  baseUrl: string;
20
+ headers?: AskHeaders;
15
21
  kind: "openai-compatible";
16
22
  model: string;
17
23
  name: string;
18
24
  };
19
25
 
26
+ /**
27
+ * Static request headers (`ai.ask.headers`) every backend forwards to its
28
+ * provider factory. Every provider Blume generates against accepts the same
29
+ * `headers` option, so the map travels unchanged; the OpenAI-compatible
30
+ * provider applies them after the `Authorization` header it derives from the
31
+ * API key, so a custom header can't displace auth.
32
+ */
33
+ export type AskHeaders = Record<string, string>;
34
+
20
35
  interface AskPreset {
21
36
  apiKeyEnv: string;
22
37
  baseUrl?: string;
@@ -69,17 +84,28 @@ const ASK_PRESETS: AskPresetRegistry = {
69
84
 
70
85
  const DEFAULT_MODEL = "openai/gpt-5.5";
71
86
 
87
+ /**
88
+ * The `headers` field every backend variant shares. An empty map is dropped so
89
+ * the generated route only carries a `headers` option when there is something
90
+ * to send.
91
+ */
92
+ const askHeadersField = (ask?: AskAiConfig): { headers?: AskHeaders } =>
93
+ ask?.headers && Object.keys(ask.headers).length > 0
94
+ ? { headers: ask.headers }
95
+ : {};
96
+
72
97
  /** Resolve the `ai.ask` config into the backend the endpoint is built against. */
73
98
  export const resolveAskBackend = (ask?: AskAiConfig): AskBackend => {
74
99
  const provider = ask?.provider ?? "gateway";
75
100
  const model = ask?.model ?? DEFAULT_MODEL;
101
+ const headers = askHeadersField(ask);
76
102
  if (provider === "gateway") {
77
- return { kind: "gateway", model };
103
+ return { ...headers, kind: "gateway", model };
78
104
  }
79
105
  const preset = ASK_PRESETS[provider];
80
106
  const apiKeyEnv = ask?.apiKeyEnv ?? preset?.apiKeyEnv ?? "API_KEY";
81
107
  if (provider === "openrouter") {
82
- return { apiKeyEnv, kind: "openrouter", model };
108
+ return { apiKeyEnv, ...headers, kind: "openrouter", model };
83
109
  }
84
110
  // `llmgateway`, `inkeep`, and the generic `openai-compatible` provider all
85
111
  // stream through the AI SDK's OpenAI-compatible provider. The schema requires
@@ -87,6 +113,7 @@ export const resolveAskBackend = (ask?: AskAiConfig): AskBackend => {
87
113
  return {
88
114
  apiKeyEnv,
89
115
  baseUrl: ask?.baseUrl ?? preset?.baseUrl ?? "",
116
+ ...headers,
90
117
  kind: OPENAI_COMPATIBLE,
91
118
  model,
92
119
  name: preset?.name ?? OPENAI_COMPATIBLE,
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
  );
@@ -2339,6 +2341,7 @@ export const generateRuntime = async (
2339
2341
  changelogIndexTemplate({
2340
2342
  exportEpub,
2341
2343
  exportPdf,
2344
+ mathEnabled: usesMath,
2342
2345
  needsReact,
2343
2346
  staged: hasStaged,
2344
2347
  })