blume 0.6.7 → 0.8.0

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 (211) hide show
  1. package/CHANGELOG.md +618 -0
  2. package/LICENSE +21 -0
  3. package/README.md +107 -0
  4. package/dist/cli/index.js +2609 -1041
  5. package/dist/cli/index.js.map +110 -103
  6. package/dist/types/ai/component-markdown.d.ts +34 -0
  7. package/dist/types/components/content/youtube.d.ts +18 -0
  8. package/dist/types/core/base-path.d.ts +47 -0
  9. package/dist/types/core/config-input.d.ts +110 -12
  10. package/dist/types/core/config.d.ts +6 -4
  11. package/dist/types/core/data.d.ts +4 -0
  12. package/dist/types/core/i18n-ui.d.ts +477 -135
  13. package/dist/types/core/schema.d.ts +309 -195
  14. package/dist/types/core/sources/types.d.ts +2 -0
  15. package/dist/types/core/types.d.ts +6 -1
  16. package/dist/types/index.d.ts +1 -0
  17. package/dist/types/openapi/references.d.ts +60 -0
  18. package/docs/01-quickstart.mdx +5 -2
  19. package/docs/02-deployment.mdx +24 -9
  20. package/docs/03-faq.mdx +46 -16
  21. package/docs/advanced/custom-pages.mdx +1 -1
  22. package/docs/advanced/skills.mdx +1 -1
  23. package/docs/configuration/ai.mdx +49 -10
  24. package/docs/configuration/customization.mdx +11 -0
  25. package/docs/configuration/index.mdx +33 -3
  26. package/docs/configuration/seo.mdx +2 -2
  27. package/docs/content/components.mdx +30 -3
  28. package/docs/content/i18n.mdx +1 -1
  29. package/docs/content/islands.mdx +8 -0
  30. package/docs/content/navigation.mdx +3 -3
  31. package/docs/content/sources.mdx +1 -1
  32. package/docs/content/syntax.mdx +17 -2
  33. package/docs/index.mdx +2 -2
  34. package/docs/reference/cli.mdx +8 -6
  35. package/package.json +15 -4
  36. package/skills/blume/SKILL.md +5 -3
  37. package/skills/blume-update-docs/SKILL.md +3 -2
  38. package/src/ai/agent-readability.ts +11 -5
  39. package/src/ai/ask-context.ts +7 -2
  40. package/src/ai/ask-data.ts +3 -0
  41. package/src/ai/ask.ts +12 -7
  42. package/src/ai/component-markdown.ts +461 -0
  43. package/src/ai/llms.ts +143 -23
  44. package/src/ai/markdown.ts +35 -6
  45. package/src/ai/mcp/data.ts +33 -8
  46. package/src/ai/mcp/discovery.ts +10 -3
  47. package/src/ai/mcp/server.ts +24 -7
  48. package/src/ai/visibility.ts +74 -0
  49. package/src/astro/component-slots.ts +16 -4
  50. package/src/astro/examples.ts +12 -7
  51. package/src/astro/generate.ts +393 -189
  52. package/src/astro/index.ts +5 -1
  53. package/src/astro/integration.ts +9 -5
  54. package/src/astro/islands.ts +11 -5
  55. package/src/astro/markdown-negotiation.ts +2 -2
  56. package/src/astro/pages.ts +89 -22
  57. package/src/astro/templates.ts +259 -25
  58. package/src/blume-modules.d.ts +8 -0
  59. package/src/cli/commands/build.ts +131 -38
  60. package/src/cli/commands/check.ts +1 -1
  61. package/src/cli/commands/dev.ts +71 -17
  62. package/src/cli/commands/doctor.ts +2 -2
  63. package/src/cli/commands/eject.ts +47 -19
  64. package/src/cli/commands/init.ts +120 -180
  65. package/src/cli/commands/preview.ts +4 -1
  66. package/src/cli/commands/validate.ts +44 -2
  67. package/src/cli/dev-lock.ts +34 -19
  68. package/src/cli/eject-scripts.ts +72 -0
  69. package/src/cli/env.ts +15 -5
  70. package/src/cli/init/questions.ts +158 -0
  71. package/src/cli/init/scaffold.ts +380 -0
  72. package/src/cli/required-secrets.ts +2 -1
  73. package/src/components/content/AccordionItem.astro +23 -4
  74. package/src/components/content/Badge.astro +3 -1
  75. package/src/components/content/Card.astro +4 -2
  76. package/src/components/content/CodeBlock.astro +3 -0
  77. package/src/components/content/Component.astro +30 -16
  78. package/src/components/content/Diff.astro +3 -1
  79. package/src/components/content/Step.astro +10 -1
  80. package/src/components/content/Tabs.astro +15 -3
  81. package/src/components/content/Tile.astro +2 -1
  82. package/src/components/content/Tooltip.astro +3 -1
  83. package/src/components/content/Update.astro +9 -2
  84. package/src/components/content/auto-type-table.ts +25 -9
  85. package/src/components/content/base-href.ts +33 -0
  86. package/src/components/content/changelog-element.ts +9 -2
  87. package/src/components/content/diff.ts +12 -6
  88. package/src/components/content/mermaid-element.ts +10 -2
  89. package/src/components/index.ts +23 -1
  90. package/src/components/islands/AskAI.astro +5 -2
  91. package/src/components/islands/ask-ai.tsx +68 -12
  92. package/src/components/islands/base-path.ts +28 -0
  93. package/src/components/islands/hooks.ts +44 -9
  94. package/src/components/layout/Banner.astro +12 -3
  95. package/src/components/layout/Breadcrumbs.astro +2 -1
  96. package/src/components/layout/Favicon.astro +3 -2
  97. package/src/components/layout/Header.astro +15 -5
  98. package/src/components/layout/LanguageSwitcher.astro +2 -1
  99. package/src/components/layout/Logo.astro +13 -4
  100. package/src/components/layout/NavSelector.astro +2 -1
  101. package/src/components/layout/NavTree.astro +22 -7
  102. package/src/components/layout/PageActions.astro +25 -10
  103. package/src/components/layout/PageFeedback.astro +4 -1
  104. package/src/components/layout/PageLayout.astro +51 -9
  105. package/src/components/layout/Pagination.astro +3 -2
  106. package/src/components/layout/ReferenceLayout.astro +8 -1
  107. package/src/components/layout/RootLayout.astro +74 -13
  108. package/src/components/layout/Search.astro +107 -27
  109. package/src/components/layout/nav-utils.ts +18 -10
  110. package/src/components/layout/search/algolia.ts +11 -2
  111. package/src/components/layout/search/endpoint.ts +11 -5
  112. package/src/components/layout/search/orama-cloud.ts +8 -2
  113. package/src/components/layout/search/pagefind.ts +3 -0
  114. package/src/components/layout/search/types.ts +5 -1
  115. package/src/components/layout/search/typesense.ts +4 -1
  116. package/src/components/layout/toc-element.ts +8 -2
  117. package/src/components/openapi/ApiTagOperations.astro +2 -1
  118. package/src/components/openapi/Operation.astro +47 -40
  119. package/src/components/openapi/RequestPanel.astro +8 -2
  120. package/src/components/openapi/helpers.ts +71 -3
  121. package/src/components/openapi/panel.ts +1 -1
  122. package/src/components/openapi/snippets.ts +25 -11
  123. package/src/core/base-path.ts +94 -0
  124. package/src/core/builtin-tags.ts +2 -0
  125. package/src/core/component-overrides.ts +103 -74
  126. package/src/core/config-input.ts +118 -17
  127. package/src/core/config.ts +8 -5
  128. package/src/core/content.ts +2 -0
  129. package/src/core/data.ts +4 -0
  130. package/src/core/diagnostics.ts +54 -34
  131. package/src/core/gitignore.ts +4 -1
  132. package/src/core/graph.ts +166 -88
  133. package/src/core/i18n-ui.ts +63 -3
  134. package/src/core/last-modified.ts +15 -6
  135. package/src/core/links.ts +69 -25
  136. package/src/core/manifest.ts +62 -45
  137. package/src/core/nav-diagnostics.ts +1 -1
  138. package/src/core/navigation.ts +144 -58
  139. package/src/core/package-json.ts +17 -2
  140. package/src/core/project-graph.ts +25 -15
  141. package/src/core/schema.ts +605 -620
  142. package/src/core/sources/assets.ts +6 -1
  143. package/src/core/sources/filesystem.ts +4 -0
  144. package/src/core/sources/github-releases.ts +2 -1
  145. package/src/core/sources/mdx-remote.ts +76 -63
  146. package/src/core/sources/normalize.ts +236 -91
  147. package/src/core/sources/notion.ts +27 -18
  148. package/src/core/sources/types.ts +2 -0
  149. package/src/core/tsconfig-aliases.ts +59 -30
  150. package/src/core/types.ts +6 -1
  151. package/src/core/ui-packs/ar.ts +1 -0
  152. package/src/core/ui-packs/bg.ts +1 -0
  153. package/src/core/ui-packs/bn.ts +1 -0
  154. package/src/core/ui-packs/ca.ts +1 -0
  155. package/src/core/ui-packs/cs.ts +1 -0
  156. package/src/core/ui-packs/da.ts +1 -0
  157. package/src/core/ui-packs/de.ts +1 -0
  158. package/src/core/ui-packs/el.ts +1 -0
  159. package/src/core/ui-packs/es.ts +1 -0
  160. package/src/core/ui-packs/fa.ts +1 -0
  161. package/src/core/ui-packs/fi.ts +1 -0
  162. package/src/core/ui-packs/fr.ts +2 -1
  163. package/src/core/ui-packs/he.ts +1 -0
  164. package/src/core/ui-packs/hi.ts +1 -0
  165. package/src/core/ui-packs/hr.ts +1 -0
  166. package/src/core/ui-packs/hu.ts +1 -0
  167. package/src/core/ui-packs/id.ts +1 -0
  168. package/src/core/ui-packs/it.ts +1 -0
  169. package/src/core/ui-packs/ja.ts +1 -0
  170. package/src/core/ui-packs/ko.ts +1 -0
  171. package/src/core/ui-packs/nl.ts +1 -0
  172. package/src/core/ui-packs/no.ts +1 -0
  173. package/src/core/ui-packs/pl.ts +1 -0
  174. package/src/core/ui-packs/pt-br.ts +1 -0
  175. package/src/core/ui-packs/pt.ts +1 -0
  176. package/src/core/ui-packs/ro.ts +1 -0
  177. package/src/core/ui-packs/ru.ts +1 -0
  178. package/src/core/ui-packs/sk.ts +1 -0
  179. package/src/core/ui-packs/sr.ts +1 -0
  180. package/src/core/ui-packs/sv.ts +1 -0
  181. package/src/core/ui-packs/th.ts +1 -0
  182. package/src/core/ui-packs/tr.ts +1 -0
  183. package/src/core/ui-packs/uk.ts +1 -0
  184. package/src/core/ui-packs/vi.ts +1 -0
  185. package/src/core/ui-packs/zh-tw.ts +1 -0
  186. package/src/core/ui-packs/zh.ts +1 -0
  187. package/src/deploy/adapter-output.ts +18 -8
  188. package/src/deploy/redirects.ts +25 -2
  189. package/src/deploy/robots.ts +6 -1
  190. package/src/deploy/rss.ts +10 -3
  191. package/src/deploy/sitemap.ts +59 -13
  192. package/src/index.ts +5 -0
  193. package/src/markdown/base-links.ts +60 -0
  194. package/src/markdown/code-title.ts +11 -14
  195. package/src/markdown/index.ts +46 -9
  196. package/src/markdown/inline-code.ts +14 -4
  197. package/src/markdown/package-commands.ts +10 -4
  198. package/src/markdown/themes.ts +24 -0
  199. package/src/openapi/model.ts +15 -5
  200. package/src/openapi/parse.ts +21 -0
  201. package/src/openapi/references.ts +75 -21
  202. package/src/openapi/render-mdx.ts +11 -6
  203. package/src/openapi/scalar.ts +32 -16
  204. package/src/openapi/source.ts +59 -10
  205. package/src/registry/eject.ts +247 -19
  206. package/src/registry/registry.ts +0 -3
  207. package/src/search/build.ts +3 -0
  208. package/src/search/documents.ts +36 -4
  209. package/src/search/sync/typesense.ts +6 -4
  210. package/src/seo/jsonld.ts +28 -17
  211. package/src/theme/entry.ts +85 -20
@@ -1,12 +1,15 @@
1
1
  import { existsSync, readFileSync } from "node:fs";
2
+ import { pathToFileURL } from "node:url";
2
3
 
3
4
  import { dirname, isAbsolute, join, relative } from "pathe";
4
5
 
5
6
  import { askBackendRuntimeDep } from "../ai/ask.ts";
6
7
  import type { AskBackend } from "../ai/ask.ts";
8
+ import { normalizeBasePath } from "../core/base-path.ts";
7
9
  import type { ResolvedConfig } from "../core/schema.ts";
8
10
  import { BLUME_IGNORE_DIRS } from "../core/sources/watch.ts";
9
11
  import type { ProjectContext } from "../core/types.ts";
12
+ import { applyBaseToRedirects } from "../deploy/redirects.ts";
10
13
  import { hasScalarReferences } from "../openapi/references.ts";
11
14
  import { searchProviderMeta } from "../search/providers.ts";
12
15
  import { buildFontEntries } from "../theme/fonts.ts";
@@ -184,6 +187,19 @@ const renderUserAliases = (
184
187
  const astroOutDir = (context: ProjectContext): string =>
185
188
  context.distDir ?? `${context.root}/dist`;
186
189
 
190
+ /**
191
+ * The `react()` integration call. When `compilerPath` is set (the resolved
192
+ * absolute path to `babel-plugin-react-compiler`), react() carries the compiler
193
+ * as the first babel plugin — an absolute path, because @vitejs/plugin-react
194
+ * resolves babel plugins from the *project* root, not `.blume/`, so a bare
195
+ * specifier wouldn't resolve in a user project. `target: "19"` matches Blume's
196
+ * React pin. `null`/`undefined` (compiler off or unresolvable) emits bare react().
197
+ */
198
+ const reactIntegration = (compilerPath: string | null | undefined): string =>
199
+ compilerPath
200
+ ? `react({ babel: { plugins: [[${JSON.stringify(compilerPath)}, { target: "19" }]] } })`
201
+ : "react()";
202
+
187
203
  export const astroConfigTemplate = (options: {
188
204
  context: ProjectContext;
189
205
  config: ResolvedConfig;
@@ -194,9 +210,17 @@ export const astroConfigTemplate = (options: {
194
210
  contentRoutes: string[];
195
211
  dataPath: string;
196
212
  examplesPath: string;
213
+ /** The example-preview Tailwind entry (`blume:examples-theme`). */
214
+ examplesThemePath: string;
197
215
  themePath: string;
198
216
  searchClientPath: string;
199
217
  openapiPath: string;
218
+ /**
219
+ * Absolute path to `babel-plugin-react-compiler` when the React Compiler is
220
+ * enabled (resolved from Blume's package root by the caller); null/absent
221
+ * disables the compiler and emits a bare `react()`.
222
+ */
223
+ reactCompilerPath?: string | null;
200
224
  /** Project tsconfig path aliases (`find` -> absolute dir), e.g. `@` -> src. */
201
225
  aliases?: Record<string, string>;
202
226
  }): string => {
@@ -204,6 +228,7 @@ export const astroConfigTemplate = (options: {
204
228
  const {
205
229
  contentRoutes,
206
230
  examplesPath,
231
+ examplesThemePath,
207
232
  needsSvelte,
208
233
  needsVue,
209
234
  openapiPath,
@@ -248,11 +273,17 @@ export const astroConfigTemplate = (options: {
248
273
  })},`
249
274
  : "";
250
275
 
276
+ // Base the redirect paths the same way routes are based, so a redirect lands
277
+ // under `basePath` too. Astro layers its own `base` (deployment.base) on top.
278
+ const basedRedirects = applyBaseToRedirects(
279
+ config.redirects,
280
+ config.basePath
281
+ );
251
282
  const redirectsOption =
252
- config.redirects.length > 0
283
+ basedRedirects.length > 0
253
284
  ? `\n redirects: ${JSON.stringify(
254
285
  Object.fromEntries(
255
- config.redirects.map((redirect) => [
286
+ basedRedirects.map((redirect) => [
256
287
  redirect.from,
257
288
  { destination: redirect.to, status: redirect.status },
258
289
  ])
@@ -288,7 +319,7 @@ export const astroConfigTemplate = (options: {
288
319
  const svelteImport = needsSvelte
289
320
  ? `import svelte from "@astrojs/svelte";\n`
290
321
  : "";
291
- const blumeImport = `import { blumeIntegration, prerenderDepsPlugin } from "blume/astro";\n`;
322
+ const blumeImport = `import { blumeIntegration, prerenderDepsPlugin, serverAppResolvePlugin } from "blume/astro";\n`;
292
323
 
293
324
  // Twoslash runs first, before the always-on transformers, but only on fences
294
325
  // with the `twoslash` meta (explicitTrigger) — so it's opt-in per block with
@@ -297,13 +328,24 @@ export const astroConfigTemplate = (options: {
297
328
  const twoslashTransformer =
298
329
  "transformerTwoslash({ explicitTrigger: true }), ";
299
330
 
331
+ // Content links are rewritten to their real served URL: the `deployment.base`
332
+ // subdirectory (Astro doesn't rewrite `<a href>`) layered over the site-wide
333
+ // `basePath` baked into routes. The layers are passed separately so a
334
+ // hand-written `basePath` link (`/docs/x`) isn't double-prefixed (see
335
+ // `withComposedBasePath`). The link checker validates the base-less authored
336
+ // path against `basePath` routes separately.
337
+ const deployBase = normalizeBasePath(deployment.base);
338
+
300
339
  const integrations = [
301
340
  `mdx({ processor: blumeMdxProcessor(${JSON.stringify({
341
+ basePath: config.basePath,
342
+ codeThemes: config.markdown.codeBlocks.theme,
343
+ deployBase,
302
344
  headingAnchors: config.markdown.headingAnchors,
303
345
  })}) })`,
304
346
  ];
305
347
  if (needsReact) {
306
- integrations.push("react()");
348
+ integrations.push(reactIntegration(options.reactCompilerPath));
307
349
  }
308
350
  if (needsVue) {
309
351
  integrations.push("vue()");
@@ -332,12 +374,15 @@ export default defineConfig({
332
374
  integrations: [${integrations.join(", ")}],
333
375
  markdown: {
334
376
  processor: blumeMarkdownProcessor(${JSON.stringify({
377
+ basePath: config.basePath,
378
+ codeThemes: config.markdown.codeBlocks.theme,
379
+ deployBase,
335
380
  headingAnchors: config.markdown.headingAnchors,
336
381
  })}),
337
382
  shikiConfig: {
338
383
  themes: {
339
- light: "github-light",
340
- dark: "github-dark",
384
+ light: ${JSON.stringify(config.markdown.codeBlocks.theme.light)},
385
+ dark: ${JSON.stringify(config.markdown.codeBlocks.theme.dark)},
341
386
  },
342
387
  defaultColor: false,
343
388
  transformers: [${twoslashTransformer}...blumeShikiTransformers(${JSON.stringify(
@@ -347,7 +392,7 @@ export default defineConfig({
347
392
  },
348
393
  devToolbar: { enabled: false },
349
394
  vite: {
350
- plugins: [tailwindcss(), prerenderDepsPlugin()],
395
+ plugins: [tailwindcss(), prerenderDepsPlugin(), serverAppResolvePlugin()],
351
396
  // Blume's render-time deps are forced external on both build environments so
352
397
  // native bindings resolve at runtime and isolated linkers don't bundle
353
398
  // symlinked store copies (which would surface their children as unresolvable
@@ -368,6 +413,7 @@ export default defineConfig({
368
413
  alias: {
369
414
  "blume:data": ${JSON.stringify(dataPath)},
370
415
  "blume:examples": ${JSON.stringify(examplesPath)},
416
+ "blume:examples-theme": ${JSON.stringify(examplesThemePath)},
371
417
  "blume:openapi": ${JSON.stringify(openapiPath)},
372
418
  "blume:search-client": ${JSON.stringify(searchClientPath)},
373
419
  "blume:theme": ${JSON.stringify(themePath)},${userAliasLines}
@@ -396,6 +442,17 @@ export default defineConfig({
396
442
  export const stagedContentDir = (outDir: string): string =>
397
443
  join(outDir, "content");
398
444
 
445
+ /**
446
+ * Astro's glob loader resolves `base` with `new URL(base, config.root)`. On
447
+ * Windows an absolute path like `C:\\docs\\content` makes `new URL` parse the
448
+ * drive letter as a URL scheme, so the result isn't a `file:` URL and Astro's
449
+ * subsequent `fileURLToPath` throws "The URL must be of scheme file". Emit an
450
+ * absolute base as a proper `file://` URL so the drive letter can't be mistaken
451
+ * for a scheme; relative bases resolve against `config.root` unchanged.
452
+ */
453
+ const astroGlobBase = (base: string): string =>
454
+ isAbsolute(base) ? pathToFileURL(base).href : base;
455
+
399
456
  /** Generate `.blume/src/content.config.ts`. */
400
457
  export const contentConfigTemplate = (options: {
401
458
  context: ProjectContext;
@@ -454,8 +511,8 @@ export const contentConfigTemplate = (options: {
454
511
  // e.g. a prior `dist/*.mdx` render — and crash the content-module graph.
455
512
  // The runtime dir (`.blume`, or a custom distDir) is excluded precisely
456
513
  // by `outDirIgnore` instead, so it's left out of this baseline.
457
- ...BLUME_IGNORE_DIRS.filter((dir) => dir !== ".blume").map(
458
- (dir) => `!**/${dir}/**`
514
+ ...BLUME_IGNORE_DIRS.flatMap((dir) =>
515
+ dir === ".blume" ? [] : [`!**/${dir}/**`]
459
516
  ),
460
517
  ...outDirIgnore,
461
518
  ]
@@ -468,7 +525,7 @@ export const contentConfigTemplate = (options: {
468
525
  const staged = defineCollection({
469
526
  loader: glob({
470
527
  pattern: ["**/*.{md,mdx}"],
471
- base: ${JSON.stringify(stagedBase)},
528
+ base: ${JSON.stringify(astroGlobBase(stagedBase))},
472
529
  generateId: ({ entry }) => entry,
473
530
  }),
474
531
  });
@@ -482,7 +539,7 @@ import { glob } from "astro/loaders";
482
539
  const docs = defineCollection({
483
540
  loader: glob({
484
541
  pattern: ${JSON.stringify(docsPattern)},
485
- base: ${JSON.stringify(collectionBase)},
542
+ base: ${JSON.stringify(astroGlobBase(collectionBase))},
486
543
  generateId: ({ entry }) => entry,
487
544
  }),
488
545
  });
@@ -562,6 +619,31 @@ export const askEndpointTemplate = (
562
619
  content: m.content,
563
620
  role: m.role,
564
621
  }));`;
622
+ // `streamText` returns synchronously and defers provider/auth/network errors
623
+ // to stream consumption, so the handler's try/catch never sees them: without
624
+ // these the client gets a 200 whose stream aborts mid-flight and nothing is
625
+ // logged server-side. A missing credential is rejected up front as a real
626
+ // 500; everything else is at least logged via `onError`.
627
+ const keyCheck =
628
+ backend.kind === "gateway"
629
+ ? ` // The AI Gateway authenticates with an API key or Vercel's OIDC token.
630
+ if (!(process.env.AI_GATEWAY_API_KEY || process.env.VERCEL_OIDC_TOKEN)) {
631
+ return new Response(
632
+ "Ask AI is not configured: set AI_GATEWAY_API_KEY (or deploy on Vercel with OIDC).",
633
+ { status: 500 }
634
+ );
635
+ }`
636
+ : ` if (!process.env[${JSON.stringify(backend.apiKeyEnv)}]) {
637
+ return new Response(
638
+ ${JSON.stringify(`Ask AI is not configured: set ${backend.apiKeyEnv}.`)},
639
+ { status: 500 }
640
+ );
641
+ }`;
642
+ // Provider errors surface mid-stream, after the 200 is committed; this is
643
+ // the only place they can be observed server-side.
644
+ const onError = ` onError({ error }) {
645
+ console.error("Ask AI provider error:", error);
646
+ },`;
565
647
  const stream = grounded
566
648
  ? ` const system =
567
649
  (await ground(messages, body.page)) ??
@@ -570,15 +652,18 @@ export const askEndpointTemplate = (
570
652
  model: ${modelExpr},
571
653
  system,
572
654
  messages,
655
+ ${onError}
573
656
  });`
574
657
  : ` const result = streamText({
575
658
  model: ${modelExpr},
576
659
  system:
577
660
  "You are a helpful documentation assistant. Answer using the project's documentation.",
578
661
  messages,
662
+ ${onError}
579
663
  });`;
580
664
  const handler = `export const POST: APIRoute = async ({ request }) => {
581
665
  ${validate}
666
+ ${keyCheck}
582
667
  try {
583
668
  ${stream}
584
669
  return result.toTextStreamResponse();
@@ -751,9 +836,13 @@ export const POST: APIRoute = async ({ request }) => {
751
836
 
752
837
  /**
753
838
  * Generate the raw-Markdown endpoints (`[...slug].md.ts` and `[...slug].mdx.ts`).
754
- * Each route's source is served verbatim so `/<route>.md` returns plain Markdown.
839
+ * Both read `raw-markdown.json`, whose entries hold the verbatim source (`mdx`)
840
+ * plus a component-downleveled variant (`md`) when the page uses components:
841
+ * `/<route>.mdx` serves the source exactly as written, `/<route>.md` serves
842
+ * plain Markdown with `<TypeTable>`-style components converted for consumers
843
+ * that can't interpret JSX.
755
844
  */
756
- export const rawMarkdownEndpointTemplate = (): string =>
845
+ export const rawMarkdownEndpointTemplate = (kind: "md" | "mdx"): string =>
757
846
  `// Generated by Blume. Do not edit.
758
847
  import raw from "../generated/raw-markdown.json";
759
848
 
@@ -767,7 +856,10 @@ export function getStaticPaths() {
767
856
  }
768
857
 
769
858
  export function GET({ props }) {
770
- return new Response(raw[props.route] ?? "", {
859
+ const entry = raw[props.route];
860
+ return new Response(entry ? ${
861
+ kind === "md" ? "(entry.md ?? entry.mdx)" : "entry.mdx"
862
+ } : "", {
771
863
  headers: { "Content-Type": "text/markdown; charset=utf-8" },
772
864
  });
773
865
  }
@@ -946,6 +1038,7 @@ const configuration = ${JSON.stringify(options.configuration, null, 2)};
946
1038
  searchEnabled={data.config.search.enabled}
947
1039
  site={{ title: data.config.title, description: data.config.description }}
948
1040
  themeMode={data.config.theme.mode}
1041
+ ui={data.ui}
949
1042
  >
950
1043
  <ScalarComponent configuration={configuration} renderMode="client" />
951
1044
  </ReferenceLayout>
@@ -978,6 +1071,7 @@ export const catchAllPageTemplate = (options: {
978
1071
  // Generated by Blume. Do not edit.
979
1072
  import { getEntry, render } from "astro:content";
980
1073
  import RootLayout from "blume/components/layout/RootLayout.astro";
1074
+ import { withBase } from "blume/components/islands/base-path.ts";
981
1075
  import { resolveSlot } from "blume/components/layout/overrides.ts";
982
1076
  ${askImport}
983
1077
  import Accordion from "blume/components/content/Accordion.astro";
@@ -1103,13 +1197,16 @@ const ogPath = data.config.og.enabled
1103
1197
  ? \`/og/\${route === "/" ? "index" : route.slice(1)}.png\`
1104
1198
  : null;
1105
1199
  const ogRel = seo.image ?? ogPath;
1106
- // Only absolutize root-relative paths: \`seo.image\` may be an external URL,
1107
- // which must pass through verbatim (mirrors PageLayout's absolutizeOgImage).
1200
+ // Absolute URLs also carry the deployment base (the page is served under it):
1201
+ // \`site + base + path\`. Only absolutize root-relative paths: \`seo.image\` may be
1202
+ // an external URL, which passes through verbatim (mirrors PageLayout).
1108
1203
  const ogImage =
1109
- ogRel && base && ogRel.startsWith("/") ? \`\${base}\${ogRel}\` : ogRel;
1204
+ ogRel && base && ogRel.startsWith("/") ? \`\${base}\${withBase(ogRel)}\` : ogRel;
1110
1205
 
1206
+ const basedRoute = withBase(route);
1111
1207
  const canonical =
1112
- seo.canonical ?? (base ? \`\${base}\${route === "/" ? "" : route}\` : null);
1208
+ seo.canonical ??
1209
+ (base ? \`\${base}\${basedRoute === "/" ? "" : basedRoute}\` : null);
1113
1210
 
1114
1211
  // Locale resolution. With i18n on, pick the active locale's nav + dictionary,
1115
1212
  // build hreflang alternates, and derive the language-switcher targets.
@@ -1142,7 +1239,10 @@ const contentLocale =
1142
1239
  const contentDir = i18n
1143
1240
  ? (i18n.locales.find((l) => l.code === contentLocale)?.dir ?? "ltr")
1144
1241
  : "ltr";
1145
- const absolute = (path) => base + (path === "/" ? "" : path);
1242
+ const absolute = (path) => {
1243
+ const p = withBase(path);
1244
+ return base + (p === "/" ? "" : p);
1245
+ };
1146
1246
 
1147
1247
  const localeAlternates =
1148
1248
  i18n && base
@@ -1254,6 +1354,7 @@ export const changelogIndexTemplate = (options: {
1254
1354
  import { getCollection, render } from "astro:content";
1255
1355
  import RootLayout from "blume/components/layout/RootLayout.astro";
1256
1356
  import Update from "blume/components/content/Update.astro";
1357
+ import { withBase } from "blume/components/islands/base-path.ts";
1257
1358
  import { resolveSlot } from "blume/components/layout/overrides.ts";
1258
1359
  import { layoutOverrides } from "../generated/components.ts";
1259
1360
  ${askImport}import data from "../generated/data.json";
@@ -1334,6 +1435,20 @@ const items = await Promise.all(
1334
1435
  })
1335
1436
  );
1336
1437
 
1438
+ // Repeated labels slug to the same id (e.g. two entries with neither a title
1439
+ // nor a version both falling back to "update"); suffix the later ones -2, -3,
1440
+ // ... so every heading deep-links to its own entry. The first keeps the plain
1441
+ // slug, and the rendered ids stay in lockstep with the \`headings\` list below.
1442
+ const seenIds = new Set();
1443
+ for (const item of items) {
1444
+ let uniqueId = item.id;
1445
+ for (let n = 2; seenIds.has(uniqueId); n += 1) {
1446
+ uniqueId = item.id + "-" + n;
1447
+ }
1448
+ seenIds.add(uniqueId);
1449
+ item.id = uniqueId;
1450
+ }
1451
+
1337
1452
  // A changelog is semver-paginated only when every visible release parses as
1338
1453
  // semver and they span more than one major line. Older majors then collapse
1339
1454
  // into groups the reader reveals one at a time; otherwise the timeline is flat.
@@ -1354,7 +1469,20 @@ const headings = items.map((item) => ({
1354
1469
  }));
1355
1470
 
1356
1471
  const base = data.config.site ? data.config.site.replace(/\\/$/, "") : null;
1357
- const canonical = base ? base + "/changelog" : null;
1472
+ // The canonical URL carries the deployment base (the page is served under it),
1473
+ // matching how the catch-all canonicalizes via \`withBase(route)\`.
1474
+ const basedRoute = withBase("/changelog");
1475
+ const canonical = base ? base + basedRoute : null;
1476
+
1477
+ // The changelog is an unlocalized route, so its chrome renders in the default
1478
+ // locale's dictionary and direction (\`data.ui\` is the default locale's resolved
1479
+ // dictionary), mirroring the catch-all's locale wiring.
1480
+ const i18n = data.config.i18n;
1481
+ const localeMeta = i18n
1482
+ ? i18n.locales.find((l) => l.code === i18n.defaultLocale)
1483
+ : null;
1484
+ const dir = localeMeta?.dir ?? "ltr";
1485
+ const htmlLang = i18n ? i18n.defaultLocale : "en";
1358
1486
 
1359
1487
  const LayoutComponent = resolveSlot(layoutOverrides.Layout, RootLayout);
1360
1488
  ---
@@ -1371,6 +1499,9 @@ const LayoutComponent = resolveSlot(layoutOverrides.Layout, RootLayout);
1371
1499
  imageZoom={data.config.imageZoom}
1372
1500
  codeWrap={data.config.codeWrap}
1373
1501
  navigation={data.navigation}
1502
+ locale={htmlLang}
1503
+ dir={dir}
1504
+ ui={data.ui}
1374
1505
  page={{
1375
1506
  title: data.config.title + " changelog",
1376
1507
  description: "Product updates and release notes.",
@@ -1398,7 +1529,10 @@ const LayoutComponent = resolveSlot(layoutOverrides.Layout, RootLayout);
1398
1529
  items.length === 0 ? (
1399
1530
  <p>No changelog entries yet.</p>
1400
1531
  ) : paginate ? (
1401
- <blume-changelog class="not-prose mt-8 block">
1532
+ <blume-changelog
1533
+ class="not-prose mt-8 block"
1534
+ data-i18n-more={data.ui.changelog?.showReleases}
1535
+ >
1402
1536
  {majorGroups[0].items.map(({ Content, href, id, label, date, tags }) => (
1403
1537
  <Update description={date} href={href} id={id} label={label} tags={tags}>
1404
1538
  <Content />
@@ -1457,11 +1591,22 @@ const LayoutComponent = resolveSlot(layoutOverrides.Layout, RootLayout);
1457
1591
  export const notFoundPageTemplate = (): string => `---
1458
1592
  // Generated by Blume. Do not edit. Override by adding \`pages/404.astro\`.
1459
1593
  import PageLayout from "blume/components/layout/PageLayout.astro";
1594
+ import { withBase } from "blume/components/islands/base-path.ts";
1460
1595
  import data from "../generated/data.json";
1461
1596
 
1462
1597
  export const prerender = true;
1463
1598
 
1464
1599
  const nf = data.ui.notFound;
1600
+
1601
+ // The 404 page is an unlocalized route, so its chrome renders in the default
1602
+ // locale's dictionary and direction (\`data.ui\` is the default locale's resolved
1603
+ // dictionary), mirroring the catch-all's locale wiring.
1604
+ const i18n = data.config.i18n;
1605
+ const localeMeta = i18n
1606
+ ? i18n.locales.find((l) => l.code === i18n.defaultLocale)
1607
+ : null;
1608
+ const dir = localeMeta?.dir ?? "ltr";
1609
+ const htmlLang = i18n ? i18n.defaultLocale : "en";
1465
1610
  ---
1466
1611
 
1467
1612
  <PageLayout
@@ -1476,6 +1621,8 @@ const nf = data.ui.notFound;
1476
1621
  themeMode={data.config.theme.mode}
1477
1622
  fontCssVars={data.fontCssVars}
1478
1623
  searchEnabled={data.config.search.enabled}
1624
+ locale={htmlLang}
1625
+ dir={dir}
1479
1626
  ui={data.ui}
1480
1627
  noindex={true}
1481
1628
  >
@@ -1487,7 +1634,7 @@ const nf = data.ui.notFound;
1487
1634
  <p class="text-muted-foreground">{nf.description}</p>
1488
1635
  <a
1489
1636
  class="mt-2 rounded-md bg-accent px-4 py-2 text-sm font-medium text-accent-foreground"
1490
- href="/">{nf.home}</a
1637
+ href={withBase("/")}>{nf.home}</a
1491
1638
  >
1492
1639
  </div>
1493
1640
  </PageLayout>
@@ -1575,15 +1722,31 @@ import Example from ${JSON.stringify(spec.file)};
1575
1722
  <Example ${exampleDirective(spec)}{...Astro.props}><slot /></Example>
1576
1723
  `;
1577
1724
 
1725
+ /**
1726
+ * The route prefix `<Component />` preview frames are served under:
1727
+ * `{basePath}/blume-examples/<example path>`. `deployment.base` is layered on
1728
+ * top by Astro (components apply it with `withBase`).
1729
+ */
1730
+ export const examplesRouteBase = (basePath: string): string =>
1731
+ `${basePath}/blume-examples`;
1732
+
1578
1733
  /**
1579
1734
  * Generate `.blume/src/generated/examples.ts` — a map of example path to its live
1580
- * wrapper component plus raw source and language for the code tab. Reached by the
1581
- * shipped `Component.astro` via the `blume:examples` alias. Always written (an
1735
+ * wrapper component plus raw source and language for the code tab, and the route
1736
+ * base preview iframes point at. Reached by the shipped `Component.astro` and the
1737
+ * generated preview page via the `blume:examples` alias. Always written (an
1582
1738
  * empty object when there are no examples) so the alias resolves.
1583
1739
  */
1584
- export const exampleMapTemplate = (specs: ExampleSpec[]): string => {
1740
+ export const exampleMapTemplate = (
1741
+ specs: ExampleSpec[],
1742
+ basePath: string
1743
+ ): string => {
1744
+ const base = `export const examplesBase = ${JSON.stringify(
1745
+ examplesRouteBase(basePath)
1746
+ )};`;
1585
1747
  if (specs.length === 0) {
1586
1748
  return `// Generated by Blume. Do not edit.
1749
+ ${base}
1587
1750
  export const examples = {};
1588
1751
  `;
1589
1752
  }
@@ -1603,12 +1766,83 @@ export const examples = {};
1603
1766
  .join("\n");
1604
1767
  return `// Generated by Blume. Do not edit.
1605
1768
  ${imports}
1769
+ ${base}
1606
1770
  export const examples = {
1607
1771
  ${entries}
1608
1772
  };
1609
1773
  `;
1610
1774
  };
1611
1775
 
1776
+ /**
1777
+ * Generate the `<Component />` preview page — one prerendered route per
1778
+ * example under `{basePath}/blume-examples/`, rendered as a bare document
1779
+ * (no layout) that an iframe in the docs page embeds. The iframe boundary is
1780
+ * what isolates examples from the docs CSS: the only stylesheet here is the
1781
+ * example entry (`blume:examples-theme` — Tailwind, the Blume tokens, and the
1782
+ * user's configured examples css), so users can preview components styled by
1783
+ * their own design system (e.g. shadcn) with no prose styles bleeding in.
1784
+ *
1785
+ * The inline script mirrors the docs theme before first paint — same-document
1786
+ * reads of the parent's `data-theme` (same origin) with a MutationObserver for
1787
+ * live toggles — and sets both `data-theme` and a `dark` class so either
1788
+ * dark-mode convention works in user CSS. When the page is opened directly
1789
+ * (no parent), it falls back to the stored preference, then the OS setting.
1790
+ */
1791
+ export const examplesPageTemplate = (): string =>
1792
+ `---
1793
+ // Generated by Blume. Do not edit.
1794
+ import { examples } from "blume:examples";
1795
+ import "blume:examples-theme";
1796
+
1797
+ // Prerendered even in server output, like docs content.
1798
+ export const prerender = true;
1799
+
1800
+ export const getStaticPaths = () =>
1801
+ Object.keys(examples).map((path) => ({ params: { path } }));
1802
+
1803
+ const { path } = Astro.params;
1804
+ const entry = examples[path];
1805
+ const Example = entry.Component;
1806
+ ---
1807
+
1808
+ <html lang="en">
1809
+ <head>
1810
+ <meta charset="utf-8" />
1811
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
1812
+ <meta name="robots" content="noindex" />
1813
+ <title>{path}</title>
1814
+ <script is:inline>
1815
+ (() => {
1816
+ const root = document.documentElement;
1817
+ const apply = (theme) => {
1818
+ root.dataset.theme = theme;
1819
+ root.classList.toggle("dark", theme === "dark");
1820
+ };
1821
+ const stored = () =>
1822
+ localStorage.getItem("blume-theme") ??
1823
+ (matchMedia("(prefers-color-scheme: dark)").matches
1824
+ ? "dark"
1825
+ : "light");
1826
+ try {
1827
+ const host = window.parent.document.documentElement;
1828
+ apply(host.dataset.theme ?? stored());
1829
+ new MutationObserver(() => {
1830
+ apply(host.dataset.theme ?? stored());
1831
+ }).observe(host, { attributeFilter: ["data-theme"] });
1832
+ } catch {
1833
+ apply(stored());
1834
+ }
1835
+ })();
1836
+ </script>
1837
+ </head>
1838
+ <!-- Flex + margin:auto centers the example and, unlike place-items, keeps
1839
+ the top edge reachable when the example outgrows the frame. -->
1840
+ <body style="display:flex;min-height:100svh;padding:1.5rem">
1841
+ <div style="margin:auto"><Example /></div>
1842
+ </body>
1843
+ </html>
1844
+ `;
1845
+
1612
1846
  /** Generate `.blume/src/env.d.ts`. */
1613
1847
  export const envTemplate =
1614
1848
  (): string => `/// <reference path="../.astro/types.d.ts" />
@@ -15,6 +15,14 @@ declare module "blume:search-client" {
15
15
  export const createSearch: () => Fn | Promise<Fn>;
16
16
  }
17
17
 
18
+ declare module "blume:data" {
19
+ /** The generated per-project data snapshot (see `core/data.ts`). */
20
+ // biome-ignore lint/style/useImportType: ambient module must stay a global script
21
+ // oxlint-disable-next-line typescript/consistent-type-imports
22
+ const data: import("./core/data.ts").BlumeData;
23
+ export default data;
24
+ }
25
+
18
26
  // Package-only shim so `components/props.ts` can extract `.astro` prop types with
19
27
  // `ComponentProps<typeof import("./X.astro").default>` under the package's own
20
28
  // `tsc` (where the Astro TS plugin isn't active). Not shipped in `dist/types`, so