blume 2.1.1 → 2.1.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 (171) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/dist/cli/{chunk-ncj7w596.js → chunk-0pmb4tr4.js} +22 -18
  3. package/dist/cli/{chunk-ncj7w596.js.map → chunk-0pmb4tr4.js.map} +3 -3
  4. package/dist/cli/{chunk-nq1d20w2.js → chunk-0w6hs479.js} +15 -15
  5. package/dist/cli/{chunk-ehweg4fr.js → chunk-124fvmzq.js} +1031 -903
  6. package/dist/cli/{chunk-ehweg4fr.js.map → chunk-124fvmzq.js.map} +20 -18
  7. package/dist/cli/{chunk-kma9f6sf.js → chunk-1f882900.js} +2 -2
  8. package/dist/cli/{chunk-e8xw9qkh.js → chunk-297ajdcv.js} +4 -4
  9. package/dist/cli/{chunk-e6atmn4h.js → chunk-32khwcb6.js} +7 -7
  10. package/dist/cli/{chunk-073rdvgb.js → chunk-36bxs9c7.js} +16 -16
  11. package/dist/cli/{chunk-mrcccgnh.js → chunk-53jqw8z0.js} +3 -3
  12. package/dist/cli/{chunk-ft9vfys6.js → chunk-58bt3hag.js} +4 -4
  13. package/dist/cli/{chunk-sds923fa.js → chunk-5cmts6dv.js} +3 -3
  14. package/dist/cli/{chunk-cfyg47zg.js → chunk-60hz265r.js} +4 -4
  15. package/dist/cli/{chunk-j134vrp2.js → chunk-74wfhv79.js} +1 -1
  16. package/dist/cli/{chunk-nrns10mj.js → chunk-9qzgg6mj.js} +3 -3
  17. package/dist/cli/{chunk-tphac74f.js → chunk-a57yh0h9.js} +5 -5
  18. package/dist/cli/{chunk-hebn0tpx.js → chunk-ave1wcbr.js} +1 -1
  19. package/dist/cli/{chunk-htrwgabr.js → chunk-bqxaqn5p.js} +6 -6
  20. package/dist/cli/{chunk-5vjtgs6e.js → chunk-bxwhtvw8.js} +5 -5
  21. package/dist/cli/{chunk-66w9j7p6.js → chunk-ckrcm08h.js} +5 -3
  22. package/dist/cli/{chunk-66w9j7p6.js.map → chunk-ckrcm08h.js.map} +3 -3
  23. package/dist/cli/{chunk-n0v7m8ry.js → chunk-desvm72z.js} +3 -3
  24. package/dist/cli/{chunk-3v3ffbrj.js → chunk-dhwtqnnd.js} +2 -2
  25. package/dist/cli/{chunk-gb420vvz.js → chunk-dpy8qdjf.js} +7 -4
  26. package/dist/cli/chunk-dpy8qdjf.js.map +17 -0
  27. package/dist/cli/{chunk-c1y538kd.js → chunk-dqhg54mr.js} +1 -1
  28. package/dist/cli/{chunk-42t4jxdx.js → chunk-dv2z0rzf.js} +1 -1
  29. package/dist/cli/{chunk-cm89scdv.js → chunk-es8h0v3j.js} +9 -11
  30. package/dist/cli/{chunk-cm89scdv.js.map → chunk-es8h0v3j.js.map} +3 -3
  31. package/dist/cli/{chunk-hx3xqnk9.js → chunk-gfc5fqvf.js} +14 -14
  32. package/dist/cli/{chunk-7ppf1rva.js → chunk-gxtg6ed2.js} +16 -16
  33. package/dist/cli/{chunk-8y2kmjv8.js → chunk-hvcxng5f.js} +6 -6
  34. package/dist/cli/{chunk-54ztw1nj.js → chunk-j1x92mx9.js} +1 -1
  35. package/dist/cli/{chunk-313vnn40.js → chunk-k3gr1nm9.js} +11 -9
  36. package/dist/cli/chunk-k3gr1nm9.js.map +10 -0
  37. package/dist/cli/{chunk-c887fj0s.js → chunk-kdpzw38a.js} +2 -2
  38. package/dist/cli/{chunk-t2m5j4pr.js → chunk-ms9z66nz.js} +2 -2
  39. package/dist/cli/{chunk-t2m5j4pr.js.map → chunk-ms9z66nz.js.map} +2 -2
  40. package/dist/cli/{chunk-ep6nd0rn.js → chunk-nbyvgnnm.js} +26 -14
  41. package/dist/cli/{chunk-ep6nd0rn.js.map → chunk-nbyvgnnm.js.map} +7 -7
  42. package/dist/cli/{chunk-w1r4vase.js → chunk-s0bdnc7v.js} +2 -2
  43. package/dist/cli/{chunk-fdrkhpjy.js → chunk-sc93apfs.js} +14 -14
  44. package/dist/cli/{chunk-dzwehpy7.js → chunk-tfycddbt.js} +2 -2
  45. package/dist/cli/{chunk-3x6x3pph.js → chunk-twy7psdz.js} +7 -7
  46. package/dist/cli/{chunk-e9q7a2ak.js → chunk-y4msrwm9.js} +15 -15
  47. package/dist/cli/{chunk-tdqhyxfz.js → chunk-ydp3065p.js} +5 -5
  48. package/dist/cli/index.js +18 -18
  49. package/dist/types/ai/ask.d.ts +24 -5
  50. package/dist/types/analytics/clics.d.ts +37 -0
  51. package/dist/types/analytics/index.d.ts +2 -0
  52. package/dist/types/analytics/schema.d.ts +18 -0
  53. package/dist/types/astro/integration.d.ts +6 -0
  54. package/dist/types/core/config-input.d.ts +7 -3
  55. package/dist/types/core/powered-by.d.ts +12 -0
  56. package/dist/types/core/schema.d.ts +109 -3
  57. package/dist/types/core/sources/lower.d.ts +10 -2
  58. package/dist/types/core/sources/notion.d.ts +8 -0
  59. package/dist/types/deploy/cloudflare-negotiation.d.ts +5 -2
  60. package/dist/types/deploy/node-headers.d.ts +12 -4
  61. package/dist/types/deploy/platforms/vercel.d.ts +2 -2
  62. package/dist/types/deploy/vercel-negotiation.d.ts +4 -2
  63. package/dist/types/narration/build.d.ts +34 -12
  64. package/dist/types/narration/provider.d.ts +96 -8
  65. package/dist/types/openapi/sentence.d.ts +11 -1
  66. package/dist/types/seo/jsonld.d.ts +11 -6
  67. package/dist/types/sources/notion.d.ts +8 -0
  68. package/dist/types/sources/registry.d.ts +9 -0
  69. package/docs/02-deployment.mdx +29 -1
  70. package/docs/03-upgrading.mdx +2 -1
  71. package/docs/04-migrating.mdx +9 -9
  72. package/docs/08-faq.mdx +12 -12
  73. package/docs/advanced/changelog.mdx +19 -2
  74. package/docs/advanced/custom-pages.mdx +1 -1
  75. package/docs/cli/doctor.mdx +1 -1
  76. package/docs/configuration/analytics.mdx +19 -1
  77. package/docs/configuration/index.mdx +1 -0
  78. package/docs/configuration/narration.mdx +43 -6
  79. package/docs/content/navigation.mdx +4 -2
  80. package/docs/content/sources/contentful.mdx +2 -0
  81. package/docs/content/sources/github-releases.mdx +2 -0
  82. package/docs/content/sources/notion.mdx +16 -1
  83. package/docs/content/sources/obsidian.mdx +2 -0
  84. package/docs/content/sources/payload.mdx +2 -0
  85. package/docs/content/sources/remote-mdx.mdx +2 -0
  86. package/docs/content/sources/sanity.mdx +2 -0
  87. package/docs/content/sources/strapi.mdx +2 -0
  88. package/docs/discoverability/mcp.mdx +2 -0
  89. package/docs/discoverability/structured-data.mdx +8 -6
  90. package/docs/references/asyncapi.mdx +2 -0
  91. package/docs/references/graphql.mdx +2 -0
  92. package/docs/references/openapi.mdx +14 -0
  93. package/package.json +1 -1
  94. package/skills/blume/SKILL.md +1 -1
  95. package/skills/blume-migrate/SKILL.md +1 -1
  96. package/skills/blume-migrate/assets/{oxfmt@0.67.0.patch → oxfmt@0.71.0.patch} +7 -7
  97. package/skills/blume-migrate/references/monorepo.md +5 -5
  98. package/src/ai/ask.ts +24 -6
  99. package/src/analytics/clics.ts +58 -0
  100. package/src/analytics/head.ts +5 -0
  101. package/src/analytics/index.ts +2 -0
  102. package/src/analytics/schema.ts +2 -0
  103. package/src/astro/generate.ts +13 -8
  104. package/src/astro/integration.ts +56 -9
  105. package/src/astro/render-deps.ts +10 -5
  106. package/src/astro/runtime-deps.ts +23 -2
  107. package/src/astro/templates.ts +4 -1
  108. package/src/cli/init/scaffold.ts +6 -3
  109. package/src/cli/required-secrets.ts +2 -1
  110. package/src/components/content/view-switcher.ts +7 -4
  111. package/src/components/layout/NavTree.astro +49 -5
  112. package/src/components/layout/NavTreeScript.astro +18 -0
  113. package/src/components/layout/PageLayout.astro +13 -3
  114. package/src/components/layout/RootLayout.astro +17 -12
  115. package/src/components/layout/analytics-client.ts +8 -8
  116. package/src/components/openapi/playground-client.ts +53 -53
  117. package/src/components/powered-by-middleware.ts +33 -0
  118. package/src/core/config-input.ts +11 -3
  119. package/src/core/powered-by.ts +12 -0
  120. package/src/core/schema.ts +5 -4
  121. package/src/core/sources/lower.ts +19 -3
  122. package/src/core/sources/notion.ts +27 -9
  123. package/src/deploy/cloudflare-negotiation.ts +36 -7
  124. package/src/deploy/headers.ts +15 -1
  125. package/src/deploy/node-headers.ts +32 -20
  126. package/src/deploy/platforms/cloudflare.ts +1 -0
  127. package/src/deploy/platforms/vercel.ts +4 -3
  128. package/src/deploy/vercel-negotiation.ts +38 -4
  129. package/src/markdown/base-links.ts +15 -14
  130. package/src/markdown/package-commands.ts +9 -8
  131. package/src/narration/build.ts +218 -40
  132. package/src/narration/provider.ts +89 -9
  133. package/src/openapi/render-mdx.ts +68 -8
  134. package/src/openapi/sentence.ts +29 -2
  135. package/src/registry/eject.ts +5 -1
  136. package/src/seo/jsonld.ts +38 -20
  137. package/src/sources/notion.ts +7 -0
  138. package/src/theme/color.ts +9 -10
  139. package/src/theme/entry.ts +14 -3
  140. package/dist/cli/chunk-313vnn40.js.map +0 -10
  141. package/dist/cli/chunk-gb420vvz.js.map +0 -17
  142. /package/dist/cli/{chunk-nq1d20w2.js.map → chunk-0w6hs479.js.map} +0 -0
  143. /package/dist/cli/{chunk-kma9f6sf.js.map → chunk-1f882900.js.map} +0 -0
  144. /package/dist/cli/{chunk-e8xw9qkh.js.map → chunk-297ajdcv.js.map} +0 -0
  145. /package/dist/cli/{chunk-e6atmn4h.js.map → chunk-32khwcb6.js.map} +0 -0
  146. /package/dist/cli/{chunk-073rdvgb.js.map → chunk-36bxs9c7.js.map} +0 -0
  147. /package/dist/cli/{chunk-mrcccgnh.js.map → chunk-53jqw8z0.js.map} +0 -0
  148. /package/dist/cli/{chunk-ft9vfys6.js.map → chunk-58bt3hag.js.map} +0 -0
  149. /package/dist/cli/{chunk-sds923fa.js.map → chunk-5cmts6dv.js.map} +0 -0
  150. /package/dist/cli/{chunk-cfyg47zg.js.map → chunk-60hz265r.js.map} +0 -0
  151. /package/dist/cli/{chunk-j134vrp2.js.map → chunk-74wfhv79.js.map} +0 -0
  152. /package/dist/cli/{chunk-nrns10mj.js.map → chunk-9qzgg6mj.js.map} +0 -0
  153. /package/dist/cli/{chunk-tphac74f.js.map → chunk-a57yh0h9.js.map} +0 -0
  154. /package/dist/cli/{chunk-hebn0tpx.js.map → chunk-ave1wcbr.js.map} +0 -0
  155. /package/dist/cli/{chunk-htrwgabr.js.map → chunk-bqxaqn5p.js.map} +0 -0
  156. /package/dist/cli/{chunk-5vjtgs6e.js.map → chunk-bxwhtvw8.js.map} +0 -0
  157. /package/dist/cli/{chunk-n0v7m8ry.js.map → chunk-desvm72z.js.map} +0 -0
  158. /package/dist/cli/{chunk-3v3ffbrj.js.map → chunk-dhwtqnnd.js.map} +0 -0
  159. /package/dist/cli/{chunk-c1y538kd.js.map → chunk-dqhg54mr.js.map} +0 -0
  160. /package/dist/cli/{chunk-42t4jxdx.js.map → chunk-dv2z0rzf.js.map} +0 -0
  161. /package/dist/cli/{chunk-hx3xqnk9.js.map → chunk-gfc5fqvf.js.map} +0 -0
  162. /package/dist/cli/{chunk-7ppf1rva.js.map → chunk-gxtg6ed2.js.map} +0 -0
  163. /package/dist/cli/{chunk-8y2kmjv8.js.map → chunk-hvcxng5f.js.map} +0 -0
  164. /package/dist/cli/{chunk-54ztw1nj.js.map → chunk-j1x92mx9.js.map} +0 -0
  165. /package/dist/cli/{chunk-c887fj0s.js.map → chunk-kdpzw38a.js.map} +0 -0
  166. /package/dist/cli/{chunk-w1r4vase.js.map → chunk-s0bdnc7v.js.map} +0 -0
  167. /package/dist/cli/{chunk-fdrkhpjy.js.map → chunk-sc93apfs.js.map} +0 -0
  168. /package/dist/cli/{chunk-dzwehpy7.js.map → chunk-tfycddbt.js.map} +0 -0
  169. /package/dist/cli/{chunk-3x6x3pph.js.map → chunk-twy7psdz.js.map} +0 -0
  170. /package/dist/cli/{chunk-e9q7a2ak.js.map → chunk-y4msrwm9.js.map} +0 -0
  171. /package/dist/cli/{chunk-tdqhyxfz.js.map → chunk-ydp3065p.js.map} +0 -0
@@ -81,6 +81,7 @@ declare const sourceAdapterUnion: z.ZodDiscriminatedUnion<readonly [z.ZodObject<
81
81
  prefix: z.ZodOptional<z.ZodString>;
82
82
  concurrency: z.ZodOptional<z.ZodNumber>;
83
83
  database: z.ZodString;
84
+ mdx: z.ZodOptional<z.ZodBoolean>;
84
85
  properties: z.ZodOptional<z.ZodObject<{
85
86
  description: z.ZodOptional<z.ZodString>;
86
87
  order: z.ZodOptional<z.ZodString>;
@@ -255,6 +256,7 @@ export declare const resolvedSourceAdapterSchema: z.ZodPipe<z.ZodDiscriminatedUn
255
256
  prefix: z.ZodOptional<z.ZodString>;
256
257
  concurrency: z.ZodOptional<z.ZodNumber>;
257
258
  database: z.ZodString;
259
+ mdx: z.ZodOptional<z.ZodBoolean>;
258
260
  properties: z.ZodOptional<z.ZodObject<{
259
261
  description: z.ZodOptional<z.ZodString>;
260
262
  order: z.ZodOptional<z.ZodString>;
@@ -414,6 +416,7 @@ export declare const resolvedSourceAdapterSchema: z.ZodPipe<z.ZodDiscriminatedUn
414
416
  pollInterval?: number | undefined;
415
417
  prefix?: string | undefined;
416
418
  concurrency?: number | undefined;
419
+ mdx?: boolean | undefined;
417
420
  properties?: {
418
421
  description?: string | undefined;
419
422
  order?: string | undefined;
@@ -573,6 +576,7 @@ export declare const resolvedSourceAdapterSchema: z.ZodPipe<z.ZodDiscriminatedUn
573
576
  pollInterval?: number | undefined;
574
577
  prefix?: string | undefined;
575
578
  concurrency?: number | undefined;
579
+ mdx?: boolean | undefined;
576
580
  properties?: {
577
581
  description?: string | undefined;
578
582
  order?: string | undefined;
@@ -737,6 +741,7 @@ export declare const contentSourcesSchema: z.ZodArray<z.ZodPipe<z.ZodCustom<{
737
741
  pollInterval?: number | undefined;
738
742
  prefix?: string | undefined;
739
743
  concurrency?: number | undefined;
744
+ mdx?: boolean | undefined;
740
745
  properties?: {
741
746
  description?: string | undefined;
742
747
  order?: string | undefined;
@@ -896,6 +901,7 @@ export declare const contentSourcesSchema: z.ZodArray<z.ZodPipe<z.ZodCustom<{
896
901
  pollInterval?: number | undefined;
897
902
  prefix?: string | undefined;
898
903
  concurrency?: number | undefined;
904
+ mdx?: boolean | undefined;
899
905
  properties?: {
900
906
  description?: string | undefined;
901
907
  order?: string | undefined;
@@ -1055,6 +1061,7 @@ export declare const contentSourcesSchema: z.ZodArray<z.ZodPipe<z.ZodCustom<{
1055
1061
  prefix: z.ZodOptional<z.ZodString>;
1056
1062
  concurrency: z.ZodOptional<z.ZodNumber>;
1057
1063
  database: z.ZodString;
1064
+ mdx: z.ZodOptional<z.ZodBoolean>;
1058
1065
  properties: z.ZodOptional<z.ZodObject<{
1059
1066
  description: z.ZodOptional<z.ZodString>;
1060
1067
  order: z.ZodOptional<z.ZodString>;
@@ -1214,6 +1221,7 @@ export declare const contentSourcesSchema: z.ZodArray<z.ZodPipe<z.ZodCustom<{
1214
1221
  pollInterval?: number | undefined;
1215
1222
  prefix?: string | undefined;
1216
1223
  concurrency?: number | undefined;
1224
+ mdx?: boolean | undefined;
1217
1225
  properties?: {
1218
1226
  description?: string | undefined;
1219
1227
  order?: string | undefined;
@@ -1373,6 +1381,7 @@ export declare const contentSourcesSchema: z.ZodArray<z.ZodPipe<z.ZodCustom<{
1373
1381
  pollInterval?: number | undefined;
1374
1382
  prefix?: string | undefined;
1375
1383
  concurrency?: number | undefined;
1384
+ mdx?: boolean | undefined;
1376
1385
  properties?: {
1377
1386
  description?: string | undefined;
1378
1387
  order?: string | undefined;
@@ -82,6 +82,22 @@ deployment: {
82
82
 
83
83
  Every page keeps its own path under the base, even in a content folder that shares the base's name: with `base: "/docs"`, `docs/setup.md` is served at `/docs/docs/setup`, and its canonical URL, sidebar link, and `llms.txt` entry point there. A root-relative link you write that already starts with the base is taken to include it and left as written, so link to that page with a relative link (`./docs/setup`) or its full path (`/docs/docs/setup`).
84
84
 
85
+ ### A different base per deploy
86
+
87
+ `blume.config.ts` is a module, so `base` can come from an environment variable. That lets CI build each pull request's preview under its own path without editing the config:
88
+
89
+ ```ts blume.config.ts lineNumbers
90
+ deployment: {
91
+ base: process.env.DOCS_BASE,
92
+ }
93
+ ```
94
+
95
+ ```bash
96
+ DOCS_BASE=/pr-1234 blume build
97
+ ```
98
+
99
+ With `DOCS_BASE` unset, the site builds at the root, so production needs nothing extra.
100
+
85
101
  ## Mount the docs under a path
86
102
 
87
103
  `basePath` mounts every generated route under a segment (`/docs/getting-started`) while leaving the sidebar untouched — the top level is your sections, not a wrapper group. Use it when the docs live at `/docs/*` but the site root stays yours (like Docusaurus `routeBasePath` or Fumadocs `baseUrl`).
@@ -214,6 +230,18 @@ A pattern can't also match a page: hosts disagree on which of the two answers, s
214
230
 
215
231
  Where the host reads one, a build also emits a `_headers` file that pins `charset=utf-8` onto the raw AI-ready endpoints — `/<route>.md`, `/<route>.mdx`, and the `.txt` files (`llms.txt`, `llms-full.txt`). Those responses are valid UTF-8, but many static hosts serve them as `text/markdown` / `text/plain` with **no** charset, and browsers then fall back to Windows-1252 — so non-ASCII docs (Japanese, accented Latin, …) render as mojibake when the raw URL is opened directly. HTML pages are unaffected because they carry `<meta charset>`. Netlify reads `_headers` on a static deploy and Cloudflare (Pages, and Workers static assets) on both static and server builds, so those adapters and an unnamed static host get the file; Vercel (whose headers ride the routing config) and Node (whose server ignores the file) don't. A Netlify server build writes the same rules into the `headers` of its Frameworks API config (`.netlify/v1/config.json`) instead. A static Vercel build writes the same rules into `dist/vercel.json` instead, which a Git-connected project doesn't read (see [Redirects](#redirects)). A `_headers` you ship in `public/` is left untouched.
216
232
 
233
+ ## Powered-by header
234
+
235
+ Blume names itself in an `X-Powered-By: Blume` response header, so HTTP inspectors and technology detectors can tell what built the site. Set `poweredBy` to `false` to leave it out:
236
+
237
+ ```ts blume.config.ts
238
+ poweredBy: false,
239
+ ```
240
+
241
+ `blume dev` sends it on every response. A server build sends it wherever the host takes its headers from: the `node()` server and the `cloudflare()` Worker set it themselves, a `vercel()` build carries it in its routing config, and a `netlify()` build in the header rules of its Frameworks API config, with Blume's middleware adding it to the pages rendered on demand. A static build lists it in `_headers` and `dist/vercel.json` beside the [content types](#content-types), so it reaches the hosts those files reach: not a Git-connected Vercel project, and not a host that sends no custom headers, such as GitHub Pages.
242
+
243
+ In an [ejected](/docs/configuration/customization#eject) app, the switch is the `poweredBy` option of `blumeIntegration()` in `astro.config.mjs`. Its build sends the header on server-rendered pages and from `_headers`; the server-entry wrapper and routing configs that carry it elsewhere are `blume build` steps.
244
+
217
245
  ## Environment variables
218
246
 
219
247
  When a feature needs a secret, Blume warns at `blume dev`/`build` if it's missing — so the problem surfaces early instead of at the first request:
@@ -225,7 +253,7 @@ When a feature needs a secret, Blume warns at `blume dev`/`build` if it's missin
225
253
  | [Assistant bot protection](/docs/configuration/assistant#bot-protection) | `TURNSTILE_SECRET_KEY` (`turnstile()`), `HCAPTCHA_SECRET_KEY` (`hcaptcha()`) |
226
254
  | [Rate limiting](/docs/configuration/rate-limiting) (`upstash()`) | `UPSTASH_REDIS_REST_URL` and `UPSTASH_REDIS_REST_TOKEN` |
227
255
  | Mixedbread search | `MIXEDBREAD_API_KEY` |
228
- | [Narration](/docs/configuration/narration) (generated voices) | `AI_GATEWAY_API_KEY` (or Vercel OIDC), or the `apiKeyEnv` you passed `gateway()` |
256
+ | [Narration](/docs/configuration/narration) (generated voices) | `AI_GATEWAY_API_KEY` (or Vercel OIDC) for `gateway()`, `OPENAI_API_KEY` for `openai()` (none with a `baseUrl`, unless you name one), or the `apiKeyEnv` you passed to either |
229
257
  | [Content sources](/docs/content/sources) | `NOTION_TOKEN` (`notion()`), `SANITY_TOKEN` (`sanity()`), `CONTENTFUL_ACCESS_TOKEN` (`contentful()`, plus `CONTENTFUL_PREVIEW_TOKEN` for `--preview`, which fails without it), `PAYLOAD_API_KEY` (`payload()`), `STRAPI_API_TOKEN` (`strapi()`), `GITHUB_TOKEN` (`githubReleases()`, and `mdxRemote()` reading from GitHub) |
230
258
 
231
259
  Set them in `.env.local` for local dev and in your host's environment for production. A content source reads its token when it fetches, during `blume dev` and `blume build`, and narration reads its key during `blume build`, so set those where your site builds. Build-time secrets for search-index sync (`ALGOLIA_ADMIN_API_KEY`, `ORAMA_PRIVATE_API_KEY`, `TYPESENSE_ADMIN_API_KEY`) are checked by the sync step itself: when one is unset, the build warns and skips the sync, and when it's set, a failed sync [fails the build](/docs/configuration/search#adapters). The [GitHub info](/docs/content/components#github-info) card also sends `GITHUB_TOKEN`, when it's set, to lift GitHub's API rate limit.
@@ -88,7 +88,7 @@ export default defineConfig({
88
88
  - `netlify()`, `cloudflare()`, and `node()` work the same way. Naming a host adapter switches to server output; pass `output: "static"` to keep a static build with that host's platform files.
89
89
  - A config that only sets `site` or `base` stays as it is: `deployment: { site, base }` is still the static form.
90
90
  - `redirects` read `:name` segments and a trailing `*` as patterns, matched the same way on every host (see [Pattern redirects](/docs/deployment#pattern-redirects)). Blume 1 never supported patterns and handed them to each host as written, so check any `from` or `to` that holds one.
91
- - The `--adapter`, `--output`, and `--base` flags on `blume build` are gone, and passing one stops the build with an error naming the `deployment` setting that replaces it. Set the adapter in `blume.config.ts`, and name it explicitly: server output is no longer inferred from the platform's environment.
91
+ - The `--adapter`, `--output`, and `--base` flags on `blume build` are gone, and passing one stops the build with an error naming the `deployment` setting that replaces it. Set the adapter in `blume.config.ts`, and name it explicitly: server output is no longer inferred from the platform's environment. A CI step that passed a different `--base` per deploy can [read it from an environment variable](/docs/deployment#a-different-base-per-deploy) in the config instead.
92
92
 
93
93
  See [Deployment](/docs/deployment) for each adapter's options.
94
94
 
@@ -133,6 +133,7 @@ export default defineConfig({
133
133
  - `mdx-remote`, `sanity`, `notion`, and `obsidian` become `mdxRemote()`, `sanity()`, `notion()`, and `obsidian()`, with every other field moving into the call unchanged. `{ type: "custom", source }` becomes `custom(source)`.
134
134
  - `content.root`, `content.include`, and `content.exclude` are still the shorthand for a single folder, but they can't sit beside `sources` any more. Move them into the `filesystem()` entry.
135
135
  - Release pages from `githubReleases()` publish in one language now, so a multi-locale site no longer copies them to every other locale's URL (`/de/changelog/…`). If other sites link to those copies, add [redirects](/docs/deployment#redirects) to the default-locale pages.
136
+ - Text typed in Notion renders as written now: a component, `{`, or Markdown character in a `notion()` page shows as typed instead of being read as MDX. If your Notion pages hold components or Markdown typed as text, set [`mdx: true`](/docs/content/sources/notion#writing-mdx-in-notion) on the source.
136
137
 
137
138
  ## API references
138
139
 
@@ -22,15 +22,15 @@ Swap `fumadocs` for your framework, and `--codex` for `--claude` to use Claude C
22
22
 
23
23
  Name the framework you're migrating from, or leave it out and Blume detects it from the project's own files. When you name one and the project looks like another, Blume warns and continues with the one you named:
24
24
 
25
- | Source | Detected from |
26
- | --- | --- |
27
- | `mintlify` | `docs.json` or `mint.json` |
28
- | `fumadocs` | `source.config.ts`, or `fumadocs-core`, `fumadocs-ui`, or `fumadocs-mdx` in `package.json` |
29
- | `docusaurus` | `docusaurus.config.*` |
30
- | `starlight` | `@astrojs/starlight` in `package.json` |
31
- | `nextra` | `nextra` in `package.json` |
32
-
33
- Each source has its own mapping reference in the playbook. A site built with anything else still migrates: run the command without a source, and the agent inventories the repo first, then works from the playbook's general rules.
25
+ | Source | Detected from | Guide |
26
+ | --- | --- | --- |
27
+ | `mintlify` | `docs.json` or `mint.json` | [Migrate from Mintlify](/guides/migrate-from-mintlify) |
28
+ | `fumadocs` | `source.config.ts`, or `fumadocs-core`, `fumadocs-ui`, or `fumadocs-mdx` in `package.json` | [Migrate from Fumadocs](/guides/migrate-from-fumadocs) |
29
+ | `docusaurus` | `docusaurus.config.*` | [Migrate from Docusaurus](/guides/migrate-from-docusaurus) |
30
+ | `starlight` | `@astrojs/starlight` in `package.json` | [Migrate from Starlight](/guides/migrate-from-starlight) |
31
+ | `nextra` | `nextra` in `package.json` | [Migrate from Nextra](/guides/migrate-from-nextra) |
32
+
33
+ Each source has its own mapping reference in the playbook. A site built with anything else still migrates: run the command without a source, and the agent inventories the repo first, then works from the playbook's general rules. The [MkDocs Material guide](/guides/migrate-from-mkdocs-material) walks through a migration that way.
34
34
 
35
35
  ## What the agent does
36
36
 
package/docs/08-faq.mdx CHANGED
@@ -87,18 +87,18 @@ We reported it upstream in [oxc-project/oxc#24096](https://github.com/oxc-projec
87
87
 
88
88
  Patch oxfmt so it preserves the line break that sits directly against a `:::` fence. Blume ships the same fix in its own repo, and you can apply it in any project.
89
89
 
90
- 1. Save the patch as `patches/oxfmt@0.67.0.patch`:
91
-
92
- ```diff patches/oxfmt@0.67.0.patch
93
- diff --git a/dist/markdown-BMigo7Hm.js b/dist/markdown-BMigo7Hm.js
94
- index bc9037f6c0de5516b139d8cdb195b1e25cd33bc0..a02c284e28bb535f9964a8a086ebb6549657e416 100644
95
- --- a/dist/markdown-BMigo7Hm.js
96
- +++ b/dist/markdown-BMigo7Hm.js
97
- @@ -4872,7 +4872,43 @@ function lu(e, t, r) {
90
+ 1. Save the patch as `patches/oxfmt@0.71.0.patch`:
91
+
92
+ ```diff patches/oxfmt@0.71.0.patch
93
+ diff --git a/dist/markdown-gizI1LFO.js b/dist/markdown-gizI1LFO.js
94
+ index 95a638402fa6f73b29ebb8d7a7ef229bee68a690..e2e2ea5b443fe10698c49c2ef3dc3d5909335915 100644
95
+ --- a/dist/markdown-gizI1LFO.js
96
+ +++ b/dist/markdown-gizI1LFO.js
97
+ @@ -4898,7 +4898,43 @@ function lu(e, t, r) {
98
98
  case "sentence": return Oh(e, r);
99
99
  case "word": return t.parser !== "mdx" ? zh(e, t) : Uh(e);
100
100
  case "whitespace": {
101
- - let { next: a } = e, u = a && /^>|^(?:[*+-]|#{1,6}|\d+[).])$/.test(a.value) && !NE(e) && !(t.proseWrap === "preserve" && RE(e)) ? "never" : t.proseWrap;
101
+ - let { next: a } = e, u = a && /^>|^(?:[*+-]|#{1,6}|\d+[).])$/.test(a.value) && !RE(e) && !(t.proseWrap === "preserve" && ME(e)) ? "never" : t.proseWrap;
102
102
  + let { next: a, previous: oxfmtFencePrev } = e;
103
103
  + // Preserve line breaks that sit directly against a `:::` container
104
104
  + // directive fence, so `proseWrap: "never"` keeps the opening/closing
@@ -135,7 +135,7 @@ Patch oxfmt so it preserves the line break that sits directly against a `:::` fe
135
135
  + }
136
136
  + oxfmtPrevIsTitledFence = oxfmtIsFence(oxfmtLineFirst);
137
137
  + }
138
- + let u = oxfmtIsFence(oxfmtFencePrev) || oxfmtPrevIsTitledFence || oxfmtIsFence(a) ? "preserve" : a && /^>|^(?:[*+-]|#{1,6}|\d+[).])$/.test(a.value) && !NE(e) && !(t.proseWrap === "preserve" && RE(e)) ? "never" : t.proseWrap;
138
+ + let u = oxfmtIsFence(oxfmtFencePrev) || oxfmtPrevIsTitledFence || oxfmtIsFence(a) ? "preserve" : a && /^>|^(?:[*+-]|#{1,6}|\d+[).])$/.test(a.value) && !RE(e) && !(t.proseWrap === "preserve" && ME(e)) ? "never" : t.proseWrap;
139
139
  return ou(e, n.value, u, !1, t);
140
140
  }
141
141
  case "emphasis": {
@@ -146,7 +146,7 @@ Patch oxfmt so it preserves the line break that sits directly against a `:::` fe
146
146
  ```json package.json
147
147
  {
148
148
  "patchedDependencies": {
149
- "oxfmt@0.67.0": "patches/oxfmt@0.67.0.patch"
149
+ "oxfmt@0.71.0": "patches/oxfmt@0.71.0.patch"
150
150
  }
151
151
  }
152
152
  ```
@@ -155,7 +155,7 @@ Patch oxfmt so it preserves the line break that sits directly against a `:::` fe
155
155
 
156
156
  ```yaml pnpm-workspace.yaml
157
157
  patchedDependencies:
158
- oxfmt@0.67.0: patches/oxfmt@0.67.0.patch
158
+ oxfmt@0.71.0: patches/oxfmt@0.71.0.patch
159
159
  ```
160
160
 
161
161
  3. Reinstall so the patch is applied:
@@ -75,8 +75,6 @@ Each takes a map of locale code to text too, like a [header tab label](/docs/con
75
75
 
76
76
  The page appears only when nothing already occupies the `/changelog` route. To replace it with your own design, add a [custom page](/docs/advanced/custom-pages) at `pages/changelog.astro` — it takes over and Blume stops generating the default index. The `<Update>` component the previous timeline was built from still ships for a custom page that wants inline release notes. It isn't one of the MDX components, so import it in the `.astro` page itself: `import Update from "blume/components/content/Update.astro";`.
77
77
 
78
- A header [tab](/docs/content/navigation#tabs) pointing at `/changelog` opens this index — no `href` needed.
79
-
80
78
  ## From GitHub Releases
81
79
 
82
80
  Rather than authoring entries by hand, point the built-in [`githubReleases()` source](/docs/content/sources/github-releases) at a repo and every release becomes a `type: changelog` entry — the same timeline and feed, fed straight from the releases you already publish. Blume's own [changelog](/changelog) is built this way:
@@ -98,6 +96,25 @@ content: {
98
96
 
99
97
  The release name becomes the title, its tag becomes `changelog.version`, and its published date sorts the timeline. Each release page also gets a unique meta description summarized from its notes — markdown stripped, section headings and changeset commit-hash prefixes dropped, trimmed to the search-snippet length [`blume audit`](/docs/cli/audit) checks for — instead of falling back to the site description. A private repo authenticates with the `GITHUB_TOKEN` environment variable. See [GitHub Releases](/docs/content/sources/github-releases) for every option.
100
98
 
99
+ ## Keep entries out of the sidebar
100
+
101
+ Changelog entries are ordinary pages, so the generated sidebar lists them like any other: a `changelog/` folder, or a `githubReleases()` source with `prefix: "changelog"`, becomes a Changelog group, newest entry first, that grows with every release. Previous/next links run through it too, so the last docs page can lead into the newest release.
102
+
103
+ To keep the entries out of the docs sidebar, link the changelog with a header [tab](/docs/content/navigation#tabs) rather than a [header action](/docs/content/navigation#header-actions):
104
+
105
+ ```ts blume.config.ts
106
+ navigation: {
107
+ tabs: [
108
+ { label: "Docs", path: "/" },
109
+ { label: "Changelog", path: "/changelog" },
110
+ ],
111
+ }
112
+ ```
113
+
114
+ The `/changelog` tab opens the index, with no `href` needed, and owns the `changelog/` section: pages outside it stop listing the releases, and their previous/next links stay among the docs. An entry's own page shows the release list as its sidebar. The index, the [RSS feed](#the-rss-feed), `llms.txt`, the sitemap, and search still list every entry. The tab's `path` has to match the folder or `prefix` the entries live under. The `Docs` tab at `/` is optional; it gives readers a tab to return to.
115
+
116
+ Setting `sidebar.hidden` on the entries doesn't do this: it unlists them everywhere, the index and the feed included.
117
+
101
118
  ## The RSS feed
102
119
 
103
120
  Blume also builds a changelog feed at **`/changelog/rss.xml`**, sorted newest-first by `date`. Feeds need an absolute site URL, so set [`deployment.site`](/docs/deployment); Blume then injects a `<link rel="alternate">` tag on every page so readers discover it automatically.
@@ -255,7 +255,7 @@ Passing `siteUrl` (and `ogEnabled`) derives the page's `canonical` and a generat
255
255
 
256
256
  Only this page changes — every other route keeps its generated card — so it's how you give the home page alone a bespoke share image. The generated card declares its size and alt text to crawlers on its own; for your own `ogImage`, pass `ogImageAlt` and `ogImageSize` alongside so the share card gets the same treatment.
257
257
 
258
- The page also emits schema.org JSON-LD — the same `WebSite` graph the docs pages carry, so the home page (usually a custom page) isn't the one URL without structured data. Pass `structuredDataEnabled={config.structuredData}` to keep it in sync with the [`structuredData`](/docs/discoverability/structured-data) config, or `structuredDataEnabled={false}` to turn it off for one page.
258
+ The page also emits schema.org JSON-LD — the same graph the docs pages carry, with a `WebPage` node on the home page — so the home page (usually a custom page) isn't the one URL without structured data. A custom page has no content file to date, so pass `lastModified` (a date string or `Date`) to emit its `dateModified`; without a machine-readable date, search engines can take any other date the page shows as the page's own. Pass `structuredDataEnabled={config.structuredData}` to keep it in sync with the [`structuredData`](/docs/discoverability/structured-data) config, or `structuredDataEnabled={false}` to turn it off for one page.
259
259
 
260
260
  `page.title` is used verbatim as the document title (no `- siteTitle` suffix), since marketing pages usually set their own. To give a custom page the full docs chrome instead — sidebar, TOC, and all — wrap it in `RootLayout`, the layout the generated pages use. Pull the required props straight from `blume:data`:
261
261
 
@@ -19,7 +19,7 @@ blume doctor
19
19
  - **Navigation that leads nowhere**: a tab, selector item, featured link, header action, or call to action whose path matches no page, custom `.astro` page, generated route, or reference: a warning (`BLUME_NAV_MISSING_PAGE`), the same one `blume dev` and `blume build` print.
20
20
  - **[`<Component>`](/docs/content/components#component) examples that don't exist**: a `path` that names no file under `examples`, which the page would show as a "No example found" box: a warning (`BLUME_EXAMPLE_NOT_FOUND`) at its line, as `blume dev` and `blume build` print it. A `components.ts` override or island named `Component` replaces the built-in, and isn't checked.
21
21
  - **Features that need a server** on a site configured for static output — the assistant, the MCP server, the Try it playground's built-in proxy, or server-mode search (Mixedbread): an error naming the feature, with the deployment adapter to switch to (or, when a host adapter is set to `output: "static"`, telling you to drop that option).
22
- - **Packages your config needs** that aren't installed — the SDK a search, content source, or assistant adapter imports, a deployment adapter's `@astrojs/*` package, or the renderer for Vue or Svelte islands: an error naming each package, with the install command for your package manager. `blume build` stops on the same check.
22
+ - **Packages your config needs** that aren't installed — the SDK a search, content source, assistant, or narration adapter imports, a deployment adapter's `@astrojs/*` package, or the renderer for Vue or Svelte islands: an error naming each package, with the install command for your package manager. `blume build` stops on the same check.
23
23
  - **`components.ts` overrides** Blume can't plan — an inline or computed entry, or an import whose file doesn't exist: an error for each, at its line.
24
24
  - **A version-shaped folder** (`v1.0/`) on a site with no `versions` configured, which would otherwise build as ordinary content: a warning pointing at [`blume version`](/docs/cli/version).
25
25
  - **Secrets an enabled feature reads** that aren't set, such as `MIXEDBREAD_API_KEY` or `OPENROUTER_API_KEY`: a warning naming the variable. Doctor loads `.env` and `.env.local` first, like `blume dev` and `blume build`, and checks before any remote content source fetches, so the warning still prints when a source then fails without its token.
@@ -39,6 +39,7 @@ Every identifier below — a project key, a measurement ID, a site token — is
39
39
  | `amplitude()` | [Amplitude](#amplitude) | `key` |
40
40
  | `clarity()` | [Microsoft Clarity](#microsoft-clarity) | `id` |
41
41
  | `clearbit()` | [Clearbit](#clearbit) | `key` |
42
+ | `clics()` | [Clics](#clics) | `projectId` |
42
43
  | `cloudflare()` | [Cloudflare Web Analytics](#cloudflare-web-analytics) | `token` |
43
44
  | `databuddy()` | [Databuddy](#databuddy) | `clientId` |
44
45
  | `fathom()` | [Fathom](#fathom) | `site` |
@@ -149,6 +150,20 @@ analytics: [
149
150
 
150
151
  `domain` becomes `data-domain` and `host` picks where `/js/script.js` loads from. Any other option becomes its own `data-` attribute on the tag (`api: "/api/event"` → `data-api`). Script variants with extra behavior baked into the filename (`script.outbound-links.js`, …) are a different tag; load those with [`script()`](#custom-scripts).
151
152
 
153
+ ## Clics
154
+
155
+ Pass the **project ID** from Configure → Tracking in the [Clics dashboard](https://platform.clics.dev) to `clics()`. Blume adds the [Clics browser tracker](https://docs.clics.dev/installation-guides) with the required `data-project-id` attribute.
156
+
157
+ ```ts blume.config.ts lineNumbers
158
+ import { clics } from "blume/analytics";
159
+
160
+ analytics: [clics({ projectId: "YOUR_PROJECT_ID" })],
161
+ ```
162
+
163
+ The tracker counts page loads and client-side navigations automatically, including Blume's client router, so don't add a second pageview hook. It also tracks outbound links by default. Set `disableOutboundLinks: true` to turn those events off. For a production build previewed on localhost, set `allowLocalhost: true`; ordinary `blume dev` does not load analytics.
164
+
165
+ Blume's [custom events](#custom-events) don't reach Clics: its browser tracker has no global event function to forward them to. To send your own events, call the [`track()` helper](https://docs.clics.dev/custom-events) from `@clicsdev/tracker` in your own client code.
166
+
152
167
  ## Fathom
153
168
 
154
169
  Pass your **site ID** (from the site's script settings in [Fathom](https://usefathom.com)) to `fathom()`.
@@ -387,6 +402,9 @@ analytics: [
387
402
  | `amplitude()` | anything else | `autocapture: true`, `fetchRemoteConfig: true` | Merged into the `amplitude.init` options verbatim. |
388
403
  | `clarity()` | `id` | — | Clarity project ID. Required. |
389
404
  | `clearbit()` | `key` | — | Clearbit publishable API key. Required. |
405
+ | `clics()` | `projectId` | — | Public Clics project ID. Required. |
406
+ | `clics()` | `allowLocalhost` | `false` | Emit `data-allow-localhost` for local previews of a production build. |
407
+ | `clics()` | `disableOutboundLinks` | `false` | Emit `data-disable-outbound-links` to stop outbound-link tracking. |
390
408
  | `cloudflare()` | `token` | — | Cloudflare Web Analytics site token (manual setup). Required. |
391
409
  | `cloudflare()` | anything else | — | Forwarded in the beacon's `data-cf-beacon` JSON verbatim. |
392
410
  | `databuddy()` | `clientId` | — | Databuddy client ID. Required. |
@@ -431,4 +449,4 @@ analytics: [
431
449
 
432
450
  ## Custom events
433
451
 
434
- Blume's page feedback (`feedback`, and `feedback_comment` when [written feedback](/docs/configuration#written-feedback) is on), [search](/docs/configuration/search#search-analytics) (`search`, `search_select`), [assistant](/docs/configuration/assistant#analytics) (`ask`, `ask_answer`, `ask_error`), and [narration](/docs/configuration/narration#analytics) player (`narration_play`, `narration_complete`) send custom events through every configured adapter that has a client API — PostHog, Mixpanel, Heap, Segment, Hightouch, Amplitude, LogRocket, Adobe, Google Analytics, Google Tag Manager (as a `{ event }` push on `window.dataLayer`), Plausible, Databuddy, Fathom, OneDollarStats (with each property value as a string), Pirsch, Clarity, Hotjar, and Vercel. Fathom, Clarity, and Hotjar take an event's name alone, so they count each event without its properties. Cloudflare and Clearbit have no event API. Every event also fires as a `blume:track` CustomEvent on `window` with `{ event, props }` in `detail`, so a `script()` adapter can forward it anywhere else. The assistant never sends a question's text to a provider, and search sends its queries as typed unless you set [`search.analytics.queries`](/docs/configuration/search#search-analytics) to `false`, which sends each query's length instead; either way the text still reaches the `blume:track` event, for a listener you write to forward. For deploying your built site, see [Deployment](/docs/deployment).
452
+ Blume's page feedback (`feedback`, and `feedback_comment` when [written feedback](/docs/configuration#written-feedback) is on), [search](/docs/configuration/search#search-analytics) (`search`, `search_select`), [assistant](/docs/configuration/assistant#analytics) (`ask`, `ask_answer`, `ask_error`), and [narration](/docs/configuration/narration#analytics) player (`narration_play`, `narration_complete`) send custom events through every configured adapter that has a client API — PostHog, Mixpanel, Heap, Segment, Hightouch, Amplitude, LogRocket, Adobe, Google Analytics, Google Tag Manager (as a `{ event }` push on `window.dataLayer`), Plausible, Databuddy, Fathom, OneDollarStats (with each property value as a string), Pirsch, Clarity, Hotjar, and Vercel. Fathom, Clarity, and Hotjar take an event's name alone, so they count each event without its properties. Cloudflare and Clearbit have no event API, and Clics has no global one for Blume to call. Every event also fires as a `blume:track` CustomEvent on `window` with `{ event, props }` in `detail`, so a `script()` adapter can forward it anywhere else. The assistant never sends a question's text to a provider, and search sends its queries as typed unless you set [`search.analytics.queries`](/docs/configuration/search#search-analytics) to `false`, which sends each query's length instead; either way the text still reaches the `blume:track` event, for a listener you write to forward. For deploying your built site, see [Deployment](/docs/deployment).
@@ -496,6 +496,7 @@ Each of these has its own guide. The config field is the entry point:
496
496
  | `seo` | Metadata, OG images, feeds, structured data, sitemap, robots | [SEO and GEO](/docs/discoverability) |
497
497
  | `deployment` | A host adapter from `blume/deploy`, or `{ site, base }` for a static build | [Deployment](/docs/deployment) |
498
498
  | `redirects` | Permanent and temporary redirects | [Deployment](/docs/deployment#redirects) |
499
+ | `poweredBy` | The `X-Powered-By: Blume` response header (default `true`) | [Deployment](/docs/deployment#powered-by-header) |
499
500
  | `integrations` | Astro integrations appended after Blume's built-ins | [Customization](/docs/configuration/customization#astro-integrations) |
500
501
  | `basePath` | A path every generated route mounts under (e.g. `/docs`), invisible to the sidebar | [Deployment](/docs/deployment#mount-the-docs-under-a-path) |
501
502
  | `i18n` | Locales, the default locale, and translated UI strings | [Internationalization](/docs/content/i18n) |
@@ -44,7 +44,7 @@ This needs nothing from you and costs nothing, but how it sounds depends on the
44
44
 
45
45
  ## Generated voices
46
46
 
47
- Pass a `provider` to generate audio with a neural speech model at build time. The provider is the same [`gateway()`](/docs/configuration/assistant#adapters) adapter the assistant uses, pointed at a speech model:
47
+ Pass a `provider` to generate audio with a neural speech model at build time. The provider is one of the assistant's [adapters](/docs/configuration/assistant#adapters), pointed at a speech model: `gateway()` for the speech models on the Vercel AI Gateway, or [`openai()`](#openai-and-self-hosted-voices) for OpenAI's own speech models or a speech server you run yourself.
48
48
 
49
49
  ```ts blume.config.ts lineNumbers
50
50
  import { defineConfig } from "blume";
@@ -60,7 +60,7 @@ export default defineConfig({
60
60
  });
61
61
  ```
62
62
 
63
- `blume build` reads each built page exactly as the player will, splits it into sentences, and generates one clip per sentence through the [Vercel AI Gateway](https://vercel.com/docs/ai-gateway). The clips and a small manifest per page are written into your build as static files under `/blume-narration/`, so replays cost nothing and nothing runs on a server.
63
+ `blume build` reads each built page exactly as the player will, splits it into sentences, and generates one clip per sentence through the provider, here the [Vercel AI Gateway](https://vercel.com/docs/ai-gateway). The clips and a small manifest per page are written into your build as static files under `/blume-narration/`, so replays cost nothing and nothing runs on a server.
64
64
 
65
65
  Before it generates anything, the build logs how many new clips it needs and how many characters they cover, so the cost is visible up front:
66
66
 
@@ -68,6 +68,8 @@ Before it generates anything, the build logs how many new clips it needs and how
68
68
  Generating narration: 214 new clip(s), 15,880 characters, with openai/tts-1-hd
69
69
  ```
70
70
 
71
+ `gateway()` takes:
72
+
71
73
  | Option | Default | Description |
72
74
  | --- | --- | --- |
73
75
  | `model` | `openai/tts-1-hd` | A gateway speech model: `openai/tts-1`, `openai/tts-1-hd`, `fish-audio/s2.1-pro`, `spacexai/grok-tts`, and others the gateway lists. |
@@ -77,21 +79,56 @@ Generating narration: 214 new clip(s), 15,880 characters, with openai/tts-1-hd
77
79
  | `headers` | | Static headers sent with every request. |
78
80
  | `providerOptions` | | Passed to the AI SDK's `generateSpeech` as-is, for model settings Blume doesn't name. |
79
81
 
82
+ ### OpenAI and self-hosted voices
83
+
84
+ `openai()` generates the clips with OpenAI's speech API instead: OpenAI's own models with your `OPENAI_API_KEY`, or, with a `baseUrl`, any server that serves the same `POST /audio/speech` endpoint, such as [Kokoro-FastAPI](https://github.com/remsky/Kokoro-FastAPI), [Speaches](https://speaches.ai), or a [LiteLLM](https://docs.litellm.ai) proxy with a speech model. A site whose build can't reach a hosted service gets generated voices this way:
85
+
86
+ ```ts blume.config.ts lineNumbers
87
+ import { defineConfig } from "blume";
88
+ import { openai } from "blume/ai";
89
+
90
+ export default defineConfig({
91
+ narration: {
92
+ provider: openai({
93
+ baseUrl: "http://localhost:8880/v1",
94
+ model: "kokoro",
95
+ voice: "af_heart",
96
+ }),
97
+ },
98
+ });
99
+ ```
100
+
101
+ A server that takes a key, like a LiteLLM proxy, gets the one you name in `apiKeyEnv` (`openai({ baseUrl, apiKeyEnv: "LITELLM_API_KEY", model })`). Without `apiKeyEnv`, a `baseUrl` server is called with no key, so your `OPENAI_API_KEY` never leaves for another server. A response that isn't MP3, like a sign-in page from a proxy in front of the server or WAV from a server that ignores the format it was asked for, counts as a failed clip rather than being cached.
102
+
103
+ It speaks through the AI SDK's OpenAI provider, with or without a `baseUrl`, so add `@ai-sdk/openai` to your project (`npm install @ai-sdk/openai`). If it's missing, `blume build` stops before Vite runs and names the package.
104
+
105
+ | Option | Default | Description |
106
+ | --- | --- | --- |
107
+ | `model` | | Required. A speech model: `gpt-4o-mini-tts`, `tts-1`, or `tts-1-hd` on OpenAI, or the id your server serves. |
108
+ | `baseUrl` | | The server's OpenAI-compatible base URL, including the `/v1`. Without it, the clips come from OpenAI, or from the server `OPENAI_BASE_URL` names when that's set, as with OpenAI's own SDKs. |
109
+ | `voice` | `alloy` | The model's voice. |
110
+ | `instructions` | | How the voice should sound, for models that take instructions (`gpt-4o-mini-tts`). |
111
+ | `apiKeyEnv` | `OPENAI_API_KEY` without a `baseUrl` | The env var holding the key, sent as a bearer token. With a `baseUrl`, only a key you name here is sent, so your `OPENAI_API_KEY` never reaches another server; name none and requests go without a bearer token (an `Authorization` you set in `headers` is still sent). |
112
+ | `headers` | | Static headers sent with every request. |
113
+ | `providerOptions` | | Passed to the AI SDK's `generateSpeech` as-is. OpenAI's speech API reads `speed` from it: `{ openai: { speed: 1.1 } }`. |
114
+
115
+ OpenAI's speech API takes no language, so `openai()` doesn't send the page's, and a sentence that appears in several languages is generated once. OpenAI's models read the language off the text, but many self-hosted servers go by the voice, so pick one that speaks your content's language.
116
+
80
117
  ### Caching
81
118
 
82
- Clips are cached in `node_modules/.cache/blume/narration` by what decides their sound: the sentence, its language, the model, the voice, and the instructions. A rebuild only pays for sentences that changed, and a sentence shared by many pages is generated once. Vercel and Netlify restore `node_modules` from their build caches, so a deploy there regenerates only what changed. On other CI, cache that directory between runs.
119
+ Clips are cached in `node_modules/.cache/blume/narration` by what decides their sound: the sentence, its language (for `gateway()`, which sends it), the model, the voice, the instructions, the `providerOptions`, and, for `openai()`, the server it calls. Pointing `baseUrl` at another server regenerates every clip, even with the same model and voice. A rebuild only pays for sentences that changed, and a sentence shared by many pages is generated once. Vercel and Netlify restore `node_modules` from their build caches, so a deploy there regenerates only what changed. On other CI, cache that directory between runs.
83
120
 
84
121
  ### Fallbacks
85
122
 
86
123
  Generated voices fall back to browser voices wherever no clips exist:
87
124
 
88
125
  - In `blume dev`, which never generates audio. Run `blume build` and `blume preview` to hear the generated voice locally.
89
- - When the key isn't set at build time. The build warns and skips generation.
90
- - When generation fails. The build stops at the first failed clip, since the AI SDK has already retried it, and keeps every clip it has cached.
126
+ - When the key isn't set at build time. The build warns and generates nothing new, but pages whose clips are all cached still get them. `openai()` with a `baseUrl` and no `apiKeyEnv` sends no key, so it goes ahead.
127
+ - When generation fails, a server the build can't reach included. Generation stops at the first failed clip, since the AI SDK has already retried it, and the build carries on with a warning, keeping every clip it has cached.
91
128
 
92
129
  ## Languages
93
130
 
94
- Narration reads each page in its content language, so on an [internationalized](/docs/content/i18n) site every locale is read in its own voice. The spoken cues and the player's labels are translated in every built-in UI language. Override them per locale under `narration` in [`i18n.ui`](/docs/content/i18n#translated-ui).
131
+ Narration reads each page in its content language, so on an [internationalized](/docs/content/i18n) site every locale is read in its own voice. With `openai()`, that depends on the model and voice: see [OpenAI and self-hosted voices](#openai-and-self-hosted-voices). The spoken cues and the player's labels are translated in every built-in UI language. Override them per locale under `narration` in [`i18n.ui`](/docs/content/i18n#translated-ui).
95
132
 
96
133
  ## Turning it off for a page
97
134
 
@@ -64,7 +64,7 @@ navigation: {
64
64
 
65
65
  - **`flat`** (default) — a non-collapsible header with its pages listed beneath. Pages that aren't in any group always list first, above the group sections, so they can't be mistaken for a group's children.
66
66
  - **`group`** — a collapsible `<details>` disclosure per group. Groups start collapsed by default; a group containing the current page always starts open, so only the section you're in is expanded. Set `collapsed: false` in [folder meta](/docs/content/meta) to force a group open regardless.
67
- - **`page`** — each group is a single row that, when clicked, slides the sidebar into a sub-panel showing only that group's items, with a back arrow at the top. The panel is route-aware, so landing directly on a page inside the group opens straight to it.
67
+ - **`page`** — each group is a single row whose chevron slides the sidebar into a sub-panel showing only that group's items, with a back arrow at the top. A group with an index page links the row's name to that page, and a group without one slides the panel in from anywhere on the row. The panel is route-aware, so landing directly on a page inside the group opens straight to it. After the back arrow, the row of the section you're in stays highlighted.
68
68
 
69
69
  :::tip
70
70
  `page` mode keeps deep sections tidy — reach for it when groups have many children and you'd rather drill into them than scroll past them.
@@ -100,7 +100,7 @@ A generated group's effective mode resolves highest priority first:
100
100
  3. The global `navigation.sidebar.display`
101
101
  4. The Blume default (`flat`)
102
102
 
103
- A group's `display` applies to that group only — nested subgroups resolve their own value through the same chain. A `page`-mode group with an index page still drills into its sub-panel: the index page lists as the panel's first item, and landing on its URL opens the panel directly.
103
+ A group's `display` applies to that group only — nested subgroups resolve their own value through the same chain. A `page`-mode group with an index page links its row's name to that page, so clicking the name opens the landing page with its sub-panel already open, while the chevron beside it slides the panel in without leaving the current page. The panel's title links back to the landing page, and the index page lists as the panel's first item unless it's [hidden](#hidden-pages). A `page`-mode group without an index page has nothing to open, so its whole row slides the panel in.
104
104
 
105
105
  `sidebar.display` means nothing anywhere else — on a non-index page, on the content root's own `index` page (the root isn't a group; use `navigation.sidebar.display`), or on any page when an [explicit sidebar](#explicit-sidebar) is configured (its items own each group's mode) — so Blume reports a `BLUME_SIDEBAR_DISPLAY_IGNORED` warning instead of silently dropping it. `collapsed` remains specific to `group` mode; it's inert when a group resolves to `flat` or `page`.
106
106
 
@@ -304,6 +304,8 @@ navigation: {
304
304
 
305
305
  Like tab labels, a `label` here can be a map of locale code to text on a [multilingual](/docs/content/i18n#per-locale-navigation) site.
306
306
 
307
+ An action is only a link, so a changelog linked this way still lists its entries in the sidebar. Link it with a [tab](#tabs) to [keep them out](/docs/advanced/changelog#keep-entries-out-of-the-sidebar).
308
+
307
309
  `cta` is singular on purpose — a docs header has room for exactly one thing a reader is being asked to do, and a row of buttons asks for nothing. Secondary links belong in `actions`, or in [`featured`](#featured-links) if they should sit with the sidebar instead.
308
310
 
309
311
  An `http(s)` or protocol-relative href opens in a new tab; a route stays in the tab, and is validated against your pages at build time like a `featured` link — so a page served by another app on the same host (`/signup` on the product, say) should be written as an absolute URL. `actions` are hidden below the `sm` breakpoint, where the header has room for the logo and the navigation toggle and nothing else. `cta` hides there too, except on a page with no navigation toggle — a landing page on `PageLayout` with no tabs — where it stays, since nothing else can surface it on a phone.
@@ -33,3 +33,5 @@ export default defineConfig({
33
33
  ```
34
34
 
35
35
  The Delivery API token comes from the `CONTENTFUL_ACCESS_TOKEN` environment variable, which the adapter declares. Without it, the source stops before its first request with a `BLUME_MISSING_SECRET` error rather than Contentful's `401`, unless it has a [cached snapshot](/docs/content/sources#caching-and-offline-builds) to serve instead. Under `--preview` the adapter reads drafts through the Preview API with `CONTENTFUL_PREVIEW_TOKEN`. The Preview API rejects delivery tokens, so `--preview` without a preview token fails with a clear error rather than falling back to `CONTENTFUL_ACCESS_TOKEN`. A space with EU data residency is served from other hosts: set `host` to `cdn.eu.contentful.com` and `previewHost` to `preview.eu.contentful.com`. Map `fields.order` to a number field to set each page's sidebar order; it has no default. Assets are referenced from Contentful's CDN rather than downloaded — their URLs are stable. A link to another entry of the source's `contentType` points at that entry's page, at the route the page is built at (its slug under the source's `prefix`). A link to an entry the source doesn't turn into a page, one of another content type or one your `params` filter out, keeps only its text. The Delivery API leaves an unpublished, archived, or deleted asset or entry out of its response, so a page that embeds or links one renders without it (a link keeps its text) and the source warns with `BLUME_SOURCE_UNRESOLVED_LINK`. An embedded entry maps to a Blume component through the engine's `serializers` option, keyed by content type id, when you construct `contentfulSource` directly and pass it to [`custom()`](/docs/content/sources/custom); setting `serializers` writes the source's pages as MDX so the returned components render, and an embedded entry without a serializer is noted in a comment.
36
+
37
+ [Publish developer documentation from Contentful](/guides/contentful-documentation-website) walks through it end to end: a content model for docs pages and code blocks, drafts reviewed with a preview token, and a static site that rebuilds whenever an editor publishes.
@@ -37,3 +37,5 @@ changelog: {
37
37
  ```
38
38
 
39
39
  A private repo authenticates with the `GITHUB_TOKEN` environment variable — the same token the other GitHub features use, never inlined into your config; the adapter declares it, so a build without it warns. For a repository on GitHub Enterprise Server, set `baseUrl` to its REST API (`https://<host>/api/v3`); `GITHUB_TOKEN` is sent there, so it must be a token for that server. Like [every remote source](/docs/content/sources#caching-and-offline-builds) it's cached under `.blume/cache/<source>/` and served offline if the API is unreachable. Because a changelog is supplementary, a fetch failure with no cache (say a CI build without a token) degrades to an empty changelog with a warning rather than failing the build — set `GITHUB_TOKEN` in your CI and deploy environments to populate it.
40
+
41
+ [Turn GitHub releases into a product changelog](/guides/github-releases-changelog-website) walks through it end to end: a changelog timeline, a page per release, and an RSS feed, rebuilt every time you publish a release.
@@ -3,7 +3,7 @@ title: Notion
3
3
  description: Import a Notion database with the notion() source — rows become pages, properties become frontmatter, and blocks become Blume components.
4
4
  ---
5
5
 
6
- The built-in `notion()` adapter 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, and the text you type in Notion renders as written: a `{`, `<`, or Markdown character in a page is escaped rather than read as MDX, JSX, or formatting. 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 build warning, and its `<video>` player keeps pointing at the page, which it can't play — link to that video from the text instead. The adapter declares `@notionhq/client` (v5 or later) as its runtime dependency — an optional peer; Blume reads the database through its first data source.
6
+ The built-in `notion()` adapter 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, and the text you type in Notion renders as written: a `{`, `<`, or Markdown character in a page is escaped rather than read as MDX, JSX, or formatting, unless you [opt in to MDX](#writing-mdx-in-notion). 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 build warning, and its `<video>` player keeps pointing at the page, which it can't play — link to that video from the text instead. The adapter declares `@notionhq/client` (v5 or later) as its runtime dependency — an optional peer; Blume reads the database through its first data source.
7
7
 
8
8
  ```ts blume.config.ts
9
9
  import { defineConfig } from "blume";
@@ -30,3 +30,18 @@ The integration token comes from the `NOTION_TOKEN` environment variable (share
30
30
  The `Status` property is a publish gate by default. A page whose Status (a status or select property) holds any value other than `publishedValue`, which defaults to `Published`, imports with `draft: true`, and production builds drop drafts. A page with no Status value is published, and a database without the property publishes every page. Notion's default status options are Not started, In progress, and Done, so a database that uses them has no `Published` value and publishes nothing until you set `publishedValue: "Done"` (or whichever option means published). `properties.status` names a differently named property; to import every page whatever its status, point it at a property the database doesn't have.
31
31
 
32
32
  **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. Only a file the server reports as an image or video is saved (or, when the response doesn't say, one whose URL names an image or video extension); anything else keeps its original URL with a build warning, so nothing but media is ever served from your site's origin. 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.
33
+
34
+ [Turn a Notion database into a docs site](/guides/notion-documentation-website) walks through it end to end, with a Status property that decides what goes live. [Combine Markdown and Notion in one documentation site](/guides/markdown-notion-documentation) gives Notion editors a section of their own beside the Markdown in Git.
35
+
36
+ ## Writing MDX in Notion
37
+
38
+ Notion has no block for most Blume components. To use one in a Notion page (a `<CardGroup>` of `<Card>`s, say), type its MDX as text and set `mdx: true`: the source then reads the text of paragraphs, headings, list items, quotes, and callouts as MDX, so components, `{expressions}`, and Markdown typed in Notion render alongside Notion's own bold, italic, links, and inline code. Code blocks, toggle titles, image and video captions, and properties stay plain text.
39
+
40
+ ```ts blume.config.ts
41
+ notion({
42
+ database: "8f2c1e0a4b7d4f3c9e6a5d2b1c0f9e8d",
43
+ mdx: true,
44
+ });
45
+ ```
46
+
47
+ The text is then held to MDX's rules, like an `.mdx` file: a stray `{` or `<` fails the page, and a paragraph starting with `import` reads as an import statement. **MDX runs code when the site builds**, so anyone who can edit the database can run code on your build machine and read its environment variables, `NOTION_TOKEN` included. Turn `mdx` on only for a database whose editors you'd trust with the site's code.
@@ -35,3 +35,5 @@ A link to an `index` note lands on its folder's route rather than a phantom `/in
35
35
  Dot-folders are skipped, including Obsidian's own `.obsidian` config directory and `.trash` — which the dev watcher also ignores, so moving a pane in the app doesn't rebuild your site. Editing a note does, and so does deleting one to the trash, since the note leaves the vault. The directories no content scan reads (`node_modules`, `dist`, `.git`, …) are skipped too, so a vault rooted at the project itself doesn't publish dependency READMEs. A note with `#` or `?` in its path is left out with an error, the way a [content file](/docs/content#files-and-routes) is: Astro can't load its copy, so rename it. Symlinks inside the vault are followed, the way the filesystem source follows them, so a shared folder linked into the vault publishes with it. A vault that sits inside the filesystem source's root must be excluded from it (`filesystem({ root: "docs", exclude: ["**/_*", "**/.*", "vault/**"] })` — an `exclude` replaces the default `["**/_*", "**/.*"]` rather than adding to it, so keep those two to leave `_`-prefixed partials and dot-files unpublished); [`blume version <id>`](/docs/cli/version) then leaves it out of the snapshot, since the vault keeps publishing its own notes as current.
36
36
 
37
37
  Not yet lowered: callouts (`> [!note]`) render as plain blockquotes, embeds (`![[image.png]]`) pass through untouched, multi-line `%%comments%%` are left in place, and there is no backlink graph.
38
+
39
+ [Publish Obsidian notes as a docs site](/guides/publish-obsidian-documentation) walks through it end to end, from a vault to a deployed site.
@@ -29,3 +29,5 @@ export default defineConfig({
29
29
  ```
30
30
 
31
31
  The API key comes from the `PAYLOAD_API_KEY` environment variable and is sent as `users API-Key <key>`; set `authCollection` when the key belongs to another auth-enabled collection. Only published documents are imported — `--preview` requests drafts and stages them with `draft: true`. Documents are fetched with `depth: 1` so uploads carry their URLs, and a relative upload path (`/media/x.png`) resolves against `url`. Uploads are referenced at that URL rather than downloaded, since it doesn't expire the way Notion's do, so readers' browsers load them from your Payload server or its storage; to have Blume download and optimize them at build instead, authorize the host in [`image.domains`](/docs/configuration#images). A `block` or `inlineBlock` node maps to a Blume component through the engine's `serializers` option, keyed by `blockType`, when you construct `payloadSource` directly and pass it to [`custom()`](/docs/content/sources/custom). Setting `serializers` writes the source's pages as MDX so the returned components render; a body held in a Markdown text field stays Markdown. A node with no Markdown equivalent, or a block with no serializer, is left out as a comment, and the source warns with `BLUME_SOURCE_UNSUPPORTED_NODE` naming it and the page.
32
+
33
+ [Build a documentation website with Payload CMS](/guides/payload-cms-documentation-website) walks through it end to end: a collection published as a searchable docs site, with draft previews and a rebuild whenever an editor publishes.
@@ -42,3 +42,5 @@ A private repo's token is read from the `GITHUB_TOKEN` environment variable —
42
42
  Remote pages are rendered with full MDX-plus-component fidelity: their bodies are materialized into a hidden staging directory and rendered through Astro alongside your local docs, so callouts, tabs, and every other Blume component keep working.
43
43
 
44
44
  Like every remote source, fetched pages are cached for offline builds and refreshed with `blume sync` — see [Caching and offline builds](/docs/content/sources#caching-and-offline-builds).
45
+
46
+ [Build one docs portal from multiple GitHub repositories](/guides/documentation-multiple-repositories) uses it to pull each repository's docs folder into one site, pinned to a release that moves only when you change it.
@@ -27,3 +27,5 @@ export default defineConfig({
27
27
  ```
28
28
 
29
29
  A read token for a private dataset comes from the `SANITY_TOKEN` environment variable. The adapter declares it, so `blume dev` and `blume build` warn when it's unset; a public dataset works without it. Sanity answers a private dataset's query without a token with no documents rather than an error, so when a query finds nothing and the token is unset, the source adds a `BLUME_MISSING_SECRET` warning saying so. Under `--preview` the adapter reads drafts with the `previewDrafts` perspective, which `@sanity/client` 8 reports as renamed to `drafts`; the old name still works, and `@sanity/client` 6 accepts only the old one. Custom Portable Text block types map to Blume components through the engine's `serializers` option, available when you construct `sanitySource` directly and pass it to [`custom()`](/docs/content/sources/custom). Setting `serializers` writes the source's pages as MDX, so a component or directive a serializer returns renders.
30
+
31
+ [Use Sanity as a CMS for developer documentation](/guides/sanity-documentation-website) walks through it end to end: editors writing in Sanity Studio while developers keep Markdown in Git, with drafts only in protected previews.
@@ -30,3 +30,5 @@ export default defineConfig({
30
30
  ```
31
31
 
32
32
  The API token comes from the `STRAPI_API_TOKEN` environment variable, sent as a bearer token. Entries are fetched with `populate=*` so images carry their URLs (set `populate` to narrow it), and a relative upload path (`/uploads/x.png`) in a Blocks field resolves against `url`. A Markdown field passes through as written, so a relative path there stays relative to your docs site and breaks: use absolute image URLs in Markdown fields, as Strapi's Markdown editor inserts them. Only published entries are imported — `--preview` requests drafts (`status=draft` on Strapi 5, `publicationState=preview` on Strapi 4) and stages each document that has never been published with `draft: true`; a published document previews its latest draft.
33
+
34
+ [Use Strapi to manage a documentation website](/guides/strapi-documentation-website) walks through it end to end: editors publishing in Strapi, a static site rebuilt on every publish, and drafts kept off production.
@@ -23,6 +23,8 @@ agents: {
23
23
 
24
24
  If a content or custom page already owns the route, the server isn't generated: the build warns, and `llms.txt` and the other [discovery documents](/docs/discoverability/agent-discovery) leave it out.
25
25
 
26
+ [Add an MCP server to your docs](/guides/mcp-server-for-documentation) walks through it from turning it on to deploying the server and connecting a client.
27
+
26
28
  ## Tools and resources
27
29
 
28
30
  The server exposes read-only tools — `search_docs`, `get_page`, `list_pages`, and `get_navigation` — and every page as an MCP resource (`resources/list` enumerates the pages at their served URLs with a `text/markdown` type, leaving out i18n fallback copies of untranslated pages as `list_pages` does; `resources/read` returns the page's [agent Markdown](/docs/discoverability/markdown), the same output as `get_page`), so clients that attach context by URI can browse the docs without calling a tool. It publishes discovery documents at `/.well-known/mcp.json` and `/.well-known/mcp/server-card.json`. The server card follows the [SEP-2127](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2127) Server Card extension schema (reverse-DNS `name`, `remotes` transport endpoints), with initialize-shaped compat fields (`serverInfo`, `capabilities`, `transports`) for scanners built against the proposal's earlier revision. Each page's **Connect to MCP** menu offers copy-and-go install for Claude Code, Cursor, VS Code, and Codex (shown once [`deployment.site`](/docs/deployment) is set).