blume 0.7.0 → 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 (168) 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 +1487 -360
  5. package/dist/cli/index.js.map +91 -85
  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 +9 -0
  9. package/dist/types/core/config-input.d.ts +36 -2
  10. package/dist/types/core/config.d.ts +3 -2
  11. package/dist/types/core/data.d.ts +2 -0
  12. package/dist/types/core/i18n-ui.d.ts +476 -132
  13. package/dist/types/core/schema.d.ts +216 -145
  14. package/dist/types/index.d.ts +1 -0
  15. package/dist/types/openapi/references.d.ts +60 -0
  16. package/docs/01-quickstart.mdx +5 -2
  17. package/docs/02-deployment.mdx +8 -8
  18. package/docs/03-faq.mdx +46 -16
  19. package/docs/advanced/custom-pages.mdx +1 -1
  20. package/docs/advanced/skills.mdx +1 -1
  21. package/docs/configuration/ai.mdx +49 -10
  22. package/docs/configuration/customization.mdx +11 -0
  23. package/docs/configuration/index.mdx +27 -3
  24. package/docs/configuration/seo.mdx +2 -2
  25. package/docs/content/components.mdx +1 -1
  26. package/docs/content/i18n.mdx +1 -1
  27. package/docs/content/navigation.mdx +3 -3
  28. package/docs/content/sources.mdx +1 -1
  29. package/docs/content/syntax.mdx +4 -2
  30. package/docs/index.mdx +2 -2
  31. package/docs/reference/cli.mdx +8 -6
  32. package/package.json +14 -4
  33. package/skills/blume/SKILL.md +5 -3
  34. package/skills/blume-update-docs/SKILL.md +3 -2
  35. package/src/ai/agent-readability.ts +9 -8
  36. package/src/ai/ask-context.ts +7 -2
  37. package/src/ai/ask-data.ts +3 -0
  38. package/src/ai/component-markdown.ts +461 -0
  39. package/src/ai/llms.ts +135 -26
  40. package/src/ai/markdown.ts +35 -6
  41. package/src/ai/mcp/data.ts +25 -4
  42. package/src/ai/mcp/discovery.ts +10 -3
  43. package/src/ai/mcp/server.ts +21 -7
  44. package/src/ai/visibility.ts +74 -0
  45. package/src/astro/component-slots.ts +11 -1
  46. package/src/astro/generate.ts +76 -45
  47. package/src/astro/integration.ts +1 -1
  48. package/src/astro/markdown-negotiation.ts +1 -1
  49. package/src/astro/pages.ts +81 -19
  50. package/src/astro/templates.ts +99 -12
  51. package/src/blume-modules.d.ts +8 -0
  52. package/src/cli/commands/build.ts +99 -19
  53. package/src/cli/commands/check.ts +1 -1
  54. package/src/cli/commands/dev.ts +26 -5
  55. package/src/cli/commands/eject.ts +47 -19
  56. package/src/cli/commands/init.ts +120 -180
  57. package/src/cli/commands/preview.ts +4 -1
  58. package/src/cli/commands/validate.ts +43 -2
  59. package/src/cli/dev-lock.ts +8 -4
  60. package/src/cli/eject-scripts.ts +72 -0
  61. package/src/cli/env.ts +15 -5
  62. package/src/cli/init/questions.ts +158 -0
  63. package/src/cli/init/scaffold.ts +380 -0
  64. package/src/components/content/AccordionItem.astro +23 -4
  65. package/src/components/content/Badge.astro +3 -1
  66. package/src/components/content/Card.astro +4 -2
  67. package/src/components/content/Step.astro +10 -1
  68. package/src/components/content/Tabs.astro +15 -3
  69. package/src/components/content/Tile.astro +2 -1
  70. package/src/components/content/Tooltip.astro +3 -1
  71. package/src/components/content/Update.astro +9 -2
  72. package/src/components/content/auto-type-table.ts +7 -1
  73. package/src/components/content/base-href.ts +33 -0
  74. package/src/components/content/changelog-element.ts +9 -2
  75. package/src/components/content/mermaid-element.ts +7 -2
  76. package/src/components/islands/AskAI.astro +5 -2
  77. package/src/components/islands/ask-ai.tsx +56 -6
  78. package/src/components/islands/hooks.ts +28 -8
  79. package/src/components/layout/Banner.astro +10 -2
  80. package/src/components/layout/Header.astro +13 -4
  81. package/src/components/layout/Logo.astro +11 -3
  82. package/src/components/layout/NavTree.astro +17 -3
  83. package/src/components/layout/PageActions.astro +25 -10
  84. package/src/components/layout/PageLayout.astro +45 -8
  85. package/src/components/layout/ReferenceLayout.astro +8 -1
  86. package/src/components/layout/RootLayout.astro +67 -9
  87. package/src/components/layout/Search.astro +94 -22
  88. package/src/components/layout/search/algolia.ts +11 -2
  89. package/src/components/layout/search/endpoint.ts +11 -5
  90. package/src/components/layout/search/orama-cloud.ts +8 -2
  91. package/src/components/layout/search/types.ts +5 -1
  92. package/src/components/layout/search/typesense.ts +4 -1
  93. package/src/components/layout/toc-element.ts +1 -1
  94. package/src/components/openapi/ApiTagOperations.astro +2 -1
  95. package/src/components/openapi/Operation.astro +47 -40
  96. package/src/components/openapi/RequestPanel.astro +1 -1
  97. package/src/components/openapi/helpers.ts +71 -3
  98. package/src/components/openapi/panel.ts +1 -1
  99. package/src/core/base-path.ts +24 -0
  100. package/src/core/builtin-tags.ts +2 -0
  101. package/src/core/config-input.ts +37 -2
  102. package/src/core/config.ts +3 -2
  103. package/src/core/data.ts +2 -0
  104. package/src/core/graph.ts +15 -5
  105. package/src/core/i18n-ui.ts +45 -0
  106. package/src/core/last-modified.ts +13 -6
  107. package/src/core/links.ts +32 -8
  108. package/src/core/navigation.ts +29 -4
  109. package/src/core/package-json.ts +17 -2
  110. package/src/core/project-graph.ts +15 -6
  111. package/src/core/schema.ts +36 -2
  112. package/src/core/sources/assets.ts +6 -1
  113. package/src/core/sources/filesystem.ts +4 -0
  114. package/src/core/sources/mdx-remote.ts +23 -14
  115. package/src/core/sources/normalize.ts +152 -50
  116. package/src/core/sources/notion.ts +8 -8
  117. package/src/core/ui-packs/ar.ts +1 -0
  118. package/src/core/ui-packs/bg.ts +1 -0
  119. package/src/core/ui-packs/bn.ts +1 -0
  120. package/src/core/ui-packs/ca.ts +1 -0
  121. package/src/core/ui-packs/cs.ts +1 -0
  122. package/src/core/ui-packs/da.ts +1 -0
  123. package/src/core/ui-packs/de.ts +1 -0
  124. package/src/core/ui-packs/el.ts +1 -0
  125. package/src/core/ui-packs/es.ts +1 -0
  126. package/src/core/ui-packs/fa.ts +1 -0
  127. package/src/core/ui-packs/fi.ts +1 -0
  128. package/src/core/ui-packs/fr.ts +2 -1
  129. package/src/core/ui-packs/he.ts +1 -0
  130. package/src/core/ui-packs/hi.ts +1 -0
  131. package/src/core/ui-packs/hr.ts +1 -0
  132. package/src/core/ui-packs/hu.ts +1 -0
  133. package/src/core/ui-packs/id.ts +1 -0
  134. package/src/core/ui-packs/it.ts +1 -0
  135. package/src/core/ui-packs/ja.ts +1 -0
  136. package/src/core/ui-packs/ko.ts +1 -0
  137. package/src/core/ui-packs/nl.ts +1 -0
  138. package/src/core/ui-packs/no.ts +1 -0
  139. package/src/core/ui-packs/pl.ts +1 -0
  140. package/src/core/ui-packs/pt-br.ts +1 -0
  141. package/src/core/ui-packs/pt.ts +1 -0
  142. package/src/core/ui-packs/ro.ts +1 -0
  143. package/src/core/ui-packs/ru.ts +1 -0
  144. package/src/core/ui-packs/sk.ts +1 -0
  145. package/src/core/ui-packs/sr.ts +1 -0
  146. package/src/core/ui-packs/sv.ts +1 -0
  147. package/src/core/ui-packs/th.ts +1 -0
  148. package/src/core/ui-packs/tr.ts +1 -0
  149. package/src/core/ui-packs/uk.ts +1 -0
  150. package/src/core/ui-packs/vi.ts +1 -0
  151. package/src/core/ui-packs/zh-tw.ts +1 -0
  152. package/src/core/ui-packs/zh.ts +1 -0
  153. package/src/deploy/adapter-output.ts +18 -8
  154. package/src/deploy/redirects.ts +7 -2
  155. package/src/deploy/sitemap.ts +53 -11
  156. package/src/index.ts +5 -0
  157. package/src/markdown/base-links.ts +10 -8
  158. package/src/markdown/index.ts +15 -3
  159. package/src/markdown/inline-code.ts +7 -2
  160. package/src/markdown/package-commands.ts +10 -4
  161. package/src/openapi/model.ts +12 -4
  162. package/src/openapi/parse.ts +21 -0
  163. package/src/openapi/references.ts +38 -8
  164. package/src/openapi/source.ts +59 -10
  165. package/src/registry/eject.ts +184 -12
  166. package/src/registry/registry.ts +0 -3
  167. package/src/search/documents.ts +34 -2
  168. package/src/seo/jsonld.ts +13 -12
@@ -54,7 +54,12 @@ import { planComponentSlots } from "./component-slots.ts";
54
54
  import type { ComponentSlotPlan } from "./component-slots.ts";
55
55
  import { discoverExamples } from "./examples.ts";
56
56
  import { discoverIslands } from "./islands.ts";
57
- import { customOgRoutes, discoverPages, routeIsTaken } from "./pages.ts";
57
+ import {
58
+ customOgRoutes,
59
+ discoverPages,
60
+ hasGeneratedChangelog,
61
+ routeIsTaken,
62
+ } from "./pages.ts";
58
63
  import {
59
64
  askEndpointTemplate,
60
65
  astroConfigTemplate,
@@ -377,6 +382,16 @@ const ISLAND_FRAMEWORK_DEPS: Record<string, string> = {
377
382
  vue: "@astrojs/vue",
378
383
  };
379
384
 
385
+ /**
386
+ * Adapter package the project must install itself for each deployment
387
+ * platform whose adapter Blume doesn't ship. Node and Vercel ship with Blume,
388
+ * so they never need this.
389
+ */
390
+ const DEPLOYMENT_ADAPTER_DEPS: Record<string, string> = {
391
+ cloudflare: "@astrojs/cloudflare",
392
+ netlify: "@astrojs/netlify",
393
+ };
394
+
380
395
  /**
381
396
  * Warn when a Vue/Svelte island is present but its Astro integration isn't
382
397
  * installed — Vite would otherwise fail opaquely on the generated config import.
@@ -398,6 +413,34 @@ const islandFrameworkWarnings = (
398
413
  return warnings;
399
414
  };
400
415
 
416
+ /**
417
+ * Warn when the resolved server-output adapter is one the project must install
418
+ * itself (Netlify/Cloudflare; Node and Vercel ship with Blume). The generated
419
+ * astro.config.mjs imports the adapter package directly — and on those
420
+ * platforms the adapter is even auto-selected from env vars — so warn early
421
+ * rather than let the build die with an opaque ERR_MODULE_NOT_FOUND from the
422
+ * hidden generated config. Availability mirrors the search-provider check: a
423
+ * dep resolves from the project root or from the Blume package itself.
424
+ */
425
+ const deploymentAdapterWarnings = (
426
+ deployment: ResolvedConfig["deployment"],
427
+ root: string
428
+ ): string[] => {
429
+ const dep =
430
+ deployment.output === "server" && deployment.adapter
431
+ ? DEPLOYMENT_ADAPTER_DEPS[deployment.adapter]
432
+ : undefined;
433
+ if (
434
+ dep &&
435
+ !(canResolveFrom(root, dep) || canResolveFrom(packageRoot(), dep))
436
+ ) {
437
+ return [
438
+ `Deployment adapter "${deployment.adapter}" needs "${dep}", which isn't installed. Run \`npm install ${dep}\` (or your package manager's equivalent).`,
439
+ ];
440
+ }
441
+ return [];
442
+ };
443
+
401
444
  /** Absolute path to the configured `examples.css`, or null when unset. */
402
445
  const examplesCssFile = (root: string, config: ResolvedConfig): string | null =>
403
446
  config.examples.css ? join(root, config.examples.css) : null;
@@ -461,16 +504,24 @@ export const detectNeedsReact = async (root: string): Promise<boolean> => {
461
504
  return matches.length > 0;
462
505
  };
463
506
 
507
+ /** Block math (`$$…$$`) or an explicitly authored `<Math …>` component. */
508
+ const containsMath = (content: string): boolean =>
509
+ content.includes("$$") || content.includes("<Math");
510
+
464
511
  /**
465
- * Detect whether the project authors block math (`$$…$$`) in any `.mdx`. Drives
466
- * whether the generated runtime imports the `<Math>` component and KaTeX's
467
- * stylesheet, so a math-free site ships no KaTeX CSS. Math parsing itself is
468
- * always on but block-only, so a literal `$$` in source is a necessary
469
- * condition — no false negatives. A stray `$$` (e.g. inside a code fence) merely
470
- * over-includes the idempotent import, which is harmless.
512
+ * Detect whether the project can render math: block math (`$$…$$`) or an
513
+ * explicit `<Math>` tag in any local `.md`/`.mdx`, or in staged (non-filesystem)
514
+ * source bodies. Drives whether the generated runtime imports the `<Math>`
515
+ * component and KaTeX's stylesheet, so a math-free site ships no KaTeX CSS.
516
+ * Math parsing itself is always on but block-only, so one of those literals is
517
+ * a necessary condition — no false negatives. A stray `$$` (e.g. inside a code
518
+ * fence) merely over-includes the idempotent import, which is harmless.
471
519
  */
472
- export const detectUsesMath = async (root: string): Promise<boolean> => {
473
- const files = await glob(["**/*.mdx"], {
520
+ export const detectUsesMath = async (
521
+ root: string,
522
+ staged: Iterable<string> = []
523
+ ): Promise<boolean> => {
524
+ const files = await glob(["**/*.{md,mdx}"], {
474
525
  cwd: root,
475
526
  ignore: ["**/node_modules/**", "**/.blume/**", "**/dist/**"],
476
527
  onlyFiles: true,
@@ -478,7 +529,7 @@ export const detectUsesMath = async (root: string): Promise<boolean> => {
478
529
  const contents = await Promise.all(
479
530
  files.map((file) => readOptional(join(root, file)))
480
531
  );
481
- return contents.some((content) => content.includes("$$"));
532
+ return [...contents, ...staged].some(containsMath);
482
533
  };
483
534
 
484
535
  const writeIfChanged = async (
@@ -792,6 +843,7 @@ export const buildRuntimeData = (project: BlumeProject): string => {
792
843
  ? { suggestions: config.ai.ask.suggestions }
793
844
  : null,
794
845
  banner: resolveBanner(config),
846
+ basePath: config.basePath,
795
847
  codeThemes: config.markdown.codeBlocks.theme,
796
848
  codeWrap: config.markdown.code.wrap,
797
849
  description: config.description,
@@ -934,6 +986,7 @@ const writeMcpFiles = async (
934
986
  }
935
987
  const data = await buildMcpData(project);
936
988
  const discoveryInput = {
989
+ base: data.base,
937
990
  name: data.name,
938
991
  route: plan.route,
939
992
  site: data.site,
@@ -1012,32 +1065,6 @@ export interface GenerateResult {
1012
1065
  warnings: string[];
1013
1066
  }
1014
1067
 
1015
- /**
1016
- * Whether to generate the default `/changelog` index. Written when there are
1017
- * `type: changelog` entries — or when a release-backed changelog source is
1018
- * configured, so its route (and any nav tab pointing at it) still resolves to an
1019
- * empty timeline on a build where the source could not be fetched (e.g. CI
1020
- * without a token). Skipped when a user content page or a custom `.astro` page
1021
- * already owns `/changelog`.
1022
- */
1023
- const shouldGenerateChangelog = (
1024
- project: BlumeProject,
1025
- userPages: { pattern: string }[]
1026
- ): boolean => {
1027
- const hasChangelog = project.graph.pages.some(
1028
- (page) =>
1029
- page.contentType === "changelog" &&
1030
- !(page.meta.draft || page.meta.sidebar.hidden)
1031
- );
1032
- const hasChangelogSource = (project.config.content.sources ?? []).some(
1033
- (source) => source.type === "github-releases"
1034
- );
1035
- return (
1036
- (hasChangelog || hasChangelogSource) &&
1037
- !routeIsTaken(userPages, project.graph.pages, "/changelog")
1038
- );
1039
- };
1040
-
1041
1068
  /**
1042
1069
  * Statically analyze the user's `components.ts` (never executing it) and plan the
1043
1070
  * generated `components.ts` module plus any hydration wrappers. Returns the plan
@@ -1097,6 +1124,10 @@ export const generateRuntime = async (
1097
1124
  const askEnabled = config.ai.ask?.enabled ?? false;
1098
1125
  const exportPdf = config.export.pdf;
1099
1126
  const exportEpub = config.export.epub;
1127
+ // Staged (non-filesystem) sources materialize into `.blume/content`; keyed by
1128
+ // entryId so i18n duplicates of one entry write a single file. Collected here
1129
+ // so math detection also sees staged bodies (they never live under root).
1130
+ const staged = collectStaged(project);
1100
1131
  // Statically analyze `components.ts` overrides (never executed): drives the
1101
1132
  // `islands` group, hydration on layout/mdx overrides, string-path resolution,
1102
1133
  // and the "framework component with no client mode" diagnostic. Independent of
@@ -1113,7 +1144,7 @@ export const generateRuntime = async (
1113
1144
  ] = await Promise.all([
1114
1145
  context.pagesRoot ? discoverPages(context.pagesRoot) : Promise.resolve([]),
1115
1146
  detectNeedsReact(context.root),
1116
- detectUsesMath(context.root),
1147
+ detectUsesMath(context.root, staged.values()),
1117
1148
  readOptional(context.themeFile),
1118
1149
  readOptional(examplesCssFile(context.root, config)),
1119
1150
  discoverIslands(context.root),
@@ -1156,9 +1187,6 @@ export const generateRuntime = async (
1156
1187
  const mcp = planMcp(project, srcDir, pages);
1157
1188
  pages.push(...mcp.discoveryPages);
1158
1189
 
1159
- // Staged (non-filesystem) sources materialize into `.blume/content`; keyed by
1160
- // entryId so i18n duplicates of one entry write a single file.
1161
- const staged = collectStaged(project);
1162
1190
  const hasStaged = staged.size > 0;
1163
1191
  // Only emit a project-scanning `docs` collection when a filesystem source
1164
1192
  // actually feeds it. An all-staged project (openapi/notion/…) has only staged
@@ -1303,7 +1331,7 @@ export const generateRuntime = async (
1303
1331
  }
1304
1332
 
1305
1333
  // Changelog index (`/changelog`), rendered through the Update timeline layout.
1306
- if (shouldGenerateChangelog(project, pages)) {
1334
+ if (hasGeneratedChangelog(project, pages)) {
1307
1335
  await write(
1308
1336
  join(srcDir, "pages", "changelog.astro"),
1309
1337
  changelogIndexTemplate({
@@ -1363,11 +1391,11 @@ export const generateRuntime = async (
1363
1391
  ),
1364
1392
  write(
1365
1393
  join(srcDir, "pages", "[...slug].md.ts"),
1366
- rawMarkdownEndpointTemplate()
1394
+ rawMarkdownEndpointTemplate("md")
1367
1395
  ),
1368
1396
  write(
1369
1397
  join(srcDir, "pages", "[...slug].mdx.ts"),
1370
- rawMarkdownEndpointTemplate()
1398
+ rawMarkdownEndpointTemplate("mdx")
1371
1399
  ),
1372
1400
  ]);
1373
1401
 
@@ -1410,7 +1438,7 @@ export const generateRuntime = async (
1410
1438
  ...pages.map((page) => page.pattern),
1411
1439
  ...referenceTabs(config).map((tab) => tab.path),
1412
1440
  ]);
1413
- if (shouldGenerateChangelog(project, pages)) {
1441
+ if (hasGeneratedChangelog(project, pages)) {
1414
1442
  navTargetRoutes.add("/changelog");
1415
1443
  }
1416
1444
  warnings.push(
@@ -1461,7 +1489,10 @@ export const generateRuntime = async (
1461
1489
 
1462
1490
  // React ships with Blume; Vue/Svelte islands need their Astro integration
1463
1491
  // installed by the project. Warn early rather than let Vite fail to resolve it.
1464
- warnings.push(...islandFrameworkWarnings(frameworks, context.root));
1492
+ warnings.push(
1493
+ ...deploymentAdapterWarnings(config.deployment, context.root),
1494
+ ...islandFrameworkWarnings(frameworks, context.root)
1495
+ );
1465
1496
  if (hasScalarReferences(config)) {
1466
1497
  const references = await buildReferenceFiles({
1467
1498
  config,
@@ -108,7 +108,7 @@ const negotiateMarkdown =
108
108
  * Blume's Astro integration. Mounts user-authored pages from `pages/` into the
109
109
  * generated runtime via `injectRoute`, keeping each file in its original
110
110
  * location so relative imports and `getStaticPaths` keep working, and teaches
111
- * the dev server to honour `Accept: text/markdown`.
111
+ * the dev server to honor `Accept: text/markdown`.
112
112
  */
113
113
  export const blumeIntegration = (
114
114
  options: BlumeIntegrationOptions
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * HTTP content negotiation for the raw-Markdown variants. The `<route>.md`
3
3
  * endpoints already serve a page's source verbatim; these helpers let the dev
4
- * server honour `Accept: text/markdown` by transparently rewriting a page
4
+ * server honor `Accept: text/markdown` by transparently rewriting a page
5
5
  * request to its `.md` variant.
6
6
  */
7
7
 
@@ -1,23 +1,14 @@
1
1
  import { extname, relative } from "pathe";
2
- import { glob } from "tinyglobby";
2
+ import { glob, globSync } from "tinyglobby";
3
3
 
4
+ import type { BlumeProject } from "../core/project-graph.ts";
4
5
  import type { BlumePageRoute } from "./integration.ts";
5
6
 
6
- /**
7
- * Discover user `.astro` pages and map them to route patterns. Files keep their
8
- * original location; only the route pattern is derived (index -> parent,
9
- * dynamic `[param]` segments preserved).
10
- */
11
- export const discoverPages = async (
12
- pagesRoot: string
13
- ): Promise<BlumePageRoute[]> => {
14
- const files = await glob(["**/*.astro"], {
15
- absolute: true,
16
- cwd: pagesRoot,
17
- onlyFiles: true,
18
- });
19
- files.sort();
7
+ const PAGE_GLOB = ["**/*.astro"];
20
8
 
9
+ /** Map discovered page files to routes; shared by the async/sync discoverers. */
10
+ const toPageRoutes = (pagesRoot: string, files: string[]): BlumePageRoute[] => {
11
+ files.sort();
21
12
  return files.map((file) => {
22
13
  const rel = relative(pagesRoot, file);
23
14
  const withoutExt = rel.slice(0, rel.length - extname(rel).length);
@@ -32,6 +23,26 @@ export const discoverPages = async (
32
23
  });
33
24
  };
34
25
 
26
+ /**
27
+ * Discover user `.astro` pages and map them to route patterns. Files keep their
28
+ * original location; only the route pattern is derived (index -> parent,
29
+ * dynamic `[param]` segments preserved).
30
+ */
31
+ export const discoverPages = async (
32
+ pagesRoot: string
33
+ ): Promise<BlumePageRoute[]> =>
34
+ toPageRoutes(
35
+ pagesRoot,
36
+ await glob(PAGE_GLOB, { absolute: true, cwd: pagesRoot, onlyFiles: true })
37
+ );
38
+
39
+ /** {@link discoverPages} for synchronous callers (e.g. the sitemap builder). */
40
+ export const discoverPagesSync = (pagesRoot: string): BlumePageRoute[] =>
41
+ toPageRoutes(
42
+ pagesRoot,
43
+ globSync(PAGE_GLOB, { absolute: true, cwd: pagesRoot, onlyFiles: true })
44
+ );
45
+
35
46
  /**
36
47
  * Whether the project already owns `route` — through a custom `.astro` page
37
48
  * (injected, so matched on `pattern`) or a content page (matched on `route`).
@@ -57,6 +68,59 @@ export interface OgCustomRoute {
57
68
  /** Skip private (`_partial`, `.well-known`) and Astro dynamic (`[param]`) parts. */
58
69
  const PRIVATE_SEGMENT = /^[._]/u;
59
70
 
71
+ /** Segments of a static, shareable page pattern; null for dynamic/private ones. */
72
+ const staticSegments = (pattern: string): string[] | null => {
73
+ const segments = pattern.split("/").filter(Boolean);
74
+ return segments.some(
75
+ (part) => PRIVATE_SEGMENT.test(part) || part.includes("[")
76
+ )
77
+ ? null
78
+ : segments;
79
+ };
80
+
81
+ /**
82
+ * The static routes served by custom `.astro` pages — the same filtering as
83
+ * {@link customOgRoutes}, but yielding the routes themselves. Feeds the route
84
+ * sets that must know every servable page beyond the content graph (the link
85
+ * checker, the sitemap); dynamic (`[param]`) and private segments are skipped
86
+ * because their concrete URLs can't be enumerated statically.
87
+ */
88
+ export const customStaticRoutes = (pages: { pattern: string }[]): string[] => {
89
+ const routes = new Set<string>();
90
+ for (const { pattern } of pages) {
91
+ const segments = staticSegments(pattern);
92
+ if (segments !== null) {
93
+ routes.add(segments.length === 0 ? "/" : `/${segments.join("/")}`);
94
+ }
95
+ }
96
+ return [...routes];
97
+ };
98
+
99
+ /**
100
+ * Whether the generated `/changelog` index route exists for this project —
101
+ * `generate.ts` (which writes the page) and the sitemap/link validator all
102
+ * share this check: there are visible `type: changelog` entries — or a
103
+ * release-backed changelog source, whose route must resolve even when a fetch
104
+ * fails — and no user content or custom page already owns `/changelog`.
105
+ */
106
+ export const hasGeneratedChangelog = (
107
+ project: BlumeProject,
108
+ userPages: { pattern: string }[]
109
+ ): boolean => {
110
+ const hasChangelog = project.graph.pages.some(
111
+ (page) =>
112
+ page.contentType === "changelog" &&
113
+ !(page.meta.draft || page.meta.sidebar.hidden)
114
+ );
115
+ const hasChangelogSource = (project.config.content.sources ?? []).some(
116
+ (source) => source.type === "github-releases"
117
+ );
118
+ return (
119
+ (hasChangelog || hasChangelogSource) &&
120
+ !routeIsTaken(userPages, project.graph.pages, "/changelog")
121
+ );
122
+ };
123
+
60
124
  const humanizeSegment = (segment: string): string =>
61
125
  segment
62
126
  .split(/[-_]/u)
@@ -83,10 +147,8 @@ export const customOgRoutes = (
83
147
  // Extracted so the skip paths become early `return`s (one `continue` budget
84
148
  // per loop under the lint rule) instead of `continue` statements.
85
149
  const collectRoute = (pattern: string): void => {
86
- const segments = pattern.split("/").filter(Boolean);
87
- if (
88
- segments.some((part) => PRIVATE_SEGMENT.test(part) || part.includes("["))
89
- ) {
150
+ const segments = staticSegments(pattern);
151
+ if (segments === null) {
90
152
  return;
91
153
  }
92
154
  const slug = segments.length === 0 ? "index" : segments.join("/");
@@ -329,15 +329,18 @@ export const astroConfigTemplate = (options: {
329
329
  "transformerTwoslash({ explicitTrigger: true }), ";
330
330
 
331
331
  // Content links are rewritten to their real served URL: the `deployment.base`
332
- // subdirectory (Astro doesn't rewrite `<a href>`) plus the site-wide
333
- // `basePath` baked into routes. The link checker validates the base-less
334
- // authored path against `basePath` routes separately.
335
- const contentLinkBase = normalizeBasePath(deployment.base) + config.basePath;
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);
336
338
 
337
339
  const integrations = [
338
340
  `mdx({ processor: blumeMdxProcessor(${JSON.stringify({
339
- basePath: contentLinkBase,
341
+ basePath: config.basePath,
340
342
  codeThemes: config.markdown.codeBlocks.theme,
343
+ deployBase,
341
344
  headingAnchors: config.markdown.headingAnchors,
342
345
  })}) })`,
343
346
  ];
@@ -371,8 +374,9 @@ export default defineConfig({
371
374
  integrations: [${integrations.join(", ")}],
372
375
  markdown: {
373
376
  processor: blumeMarkdownProcessor(${JSON.stringify({
374
- basePath: contentLinkBase,
377
+ basePath: config.basePath,
375
378
  codeThemes: config.markdown.codeBlocks.theme,
379
+ deployBase,
376
380
  headingAnchors: config.markdown.headingAnchors,
377
381
  })}),
378
382
  shikiConfig: {
@@ -615,6 +619,31 @@ export const askEndpointTemplate = (
615
619
  content: m.content,
616
620
  role: m.role,
617
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
+ },`;
618
647
  const stream = grounded
619
648
  ? ` const system =
620
649
  (await ground(messages, body.page)) ??
@@ -623,15 +652,18 @@ export const askEndpointTemplate = (
623
652
  model: ${modelExpr},
624
653
  system,
625
654
  messages,
655
+ ${onError}
626
656
  });`
627
657
  : ` const result = streamText({
628
658
  model: ${modelExpr},
629
659
  system:
630
660
  "You are a helpful documentation assistant. Answer using the project's documentation.",
631
661
  messages,
662
+ ${onError}
632
663
  });`;
633
664
  const handler = `export const POST: APIRoute = async ({ request }) => {
634
665
  ${validate}
666
+ ${keyCheck}
635
667
  try {
636
668
  ${stream}
637
669
  return result.toTextStreamResponse();
@@ -804,9 +836,13 @@ export const POST: APIRoute = async ({ request }) => {
804
836
 
805
837
  /**
806
838
  * Generate the raw-Markdown endpoints (`[...slug].md.ts` and `[...slug].mdx.ts`).
807
- * 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.
808
844
  */
809
- export const rawMarkdownEndpointTemplate = (): string =>
845
+ export const rawMarkdownEndpointTemplate = (kind: "md" | "mdx"): string =>
810
846
  `// Generated by Blume. Do not edit.
811
847
  import raw from "../generated/raw-markdown.json";
812
848
 
@@ -820,7 +856,10 @@ export function getStaticPaths() {
820
856
  }
821
857
 
822
858
  export function GET({ props }) {
823
- 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
+ } : "", {
824
863
  headers: { "Content-Type": "text/markdown; charset=utf-8" },
825
864
  });
826
865
  }
@@ -999,6 +1038,7 @@ const configuration = ${JSON.stringify(options.configuration, null, 2)};
999
1038
  searchEnabled={data.config.search.enabled}
1000
1039
  site={{ title: data.config.title, description: data.config.description }}
1001
1040
  themeMode={data.config.theme.mode}
1041
+ ui={data.ui}
1002
1042
  >
1003
1043
  <ScalarComponent configuration={configuration} renderMode="client" />
1004
1044
  </ReferenceLayout>
@@ -1314,6 +1354,7 @@ export const changelogIndexTemplate = (options: {
1314
1354
  import { getCollection, render } from "astro:content";
1315
1355
  import RootLayout from "blume/components/layout/RootLayout.astro";
1316
1356
  import Update from "blume/components/content/Update.astro";
1357
+ import { withBase } from "blume/components/islands/base-path.ts";
1317
1358
  import { resolveSlot } from "blume/components/layout/overrides.ts";
1318
1359
  import { layoutOverrides } from "../generated/components.ts";
1319
1360
  ${askImport}import data from "../generated/data.json";
@@ -1394,6 +1435,20 @@ const items = await Promise.all(
1394
1435
  })
1395
1436
  );
1396
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
+
1397
1452
  // A changelog is semver-paginated only when every visible release parses as
1398
1453
  // semver and they span more than one major line. Older majors then collapse
1399
1454
  // into groups the reader reveals one at a time; otherwise the timeline is flat.
@@ -1414,7 +1469,20 @@ const headings = items.map((item) => ({
1414
1469
  }));
1415
1470
 
1416
1471
  const base = data.config.site ? data.config.site.replace(/\\/$/, "") : null;
1417
- 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";
1418
1486
 
1419
1487
  const LayoutComponent = resolveSlot(layoutOverrides.Layout, RootLayout);
1420
1488
  ---
@@ -1431,6 +1499,9 @@ const LayoutComponent = resolveSlot(layoutOverrides.Layout, RootLayout);
1431
1499
  imageZoom={data.config.imageZoom}
1432
1500
  codeWrap={data.config.codeWrap}
1433
1501
  navigation={data.navigation}
1502
+ locale={htmlLang}
1503
+ dir={dir}
1504
+ ui={data.ui}
1434
1505
  page={{
1435
1506
  title: data.config.title + " changelog",
1436
1507
  description: "Product updates and release notes.",
@@ -1458,7 +1529,10 @@ const LayoutComponent = resolveSlot(layoutOverrides.Layout, RootLayout);
1458
1529
  items.length === 0 ? (
1459
1530
  <p>No changelog entries yet.</p>
1460
1531
  ) : paginate ? (
1461
- <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
+ >
1462
1536
  {majorGroups[0].items.map(({ Content, href, id, label, date, tags }) => (
1463
1537
  <Update description={date} href={href} id={id} label={label} tags={tags}>
1464
1538
  <Content />
@@ -1517,11 +1591,22 @@ const LayoutComponent = resolveSlot(layoutOverrides.Layout, RootLayout);
1517
1591
  export const notFoundPageTemplate = (): string => `---
1518
1592
  // Generated by Blume. Do not edit. Override by adding \`pages/404.astro\`.
1519
1593
  import PageLayout from "blume/components/layout/PageLayout.astro";
1594
+ import { withBase } from "blume/components/islands/base-path.ts";
1520
1595
  import data from "../generated/data.json";
1521
1596
 
1522
1597
  export const prerender = true;
1523
1598
 
1524
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";
1525
1610
  ---
1526
1611
 
1527
1612
  <PageLayout
@@ -1536,6 +1621,8 @@ const nf = data.ui.notFound;
1536
1621
  themeMode={data.config.theme.mode}
1537
1622
  fontCssVars={data.fontCssVars}
1538
1623
  searchEnabled={data.config.search.enabled}
1624
+ locale={htmlLang}
1625
+ dir={dir}
1539
1626
  ui={data.ui}
1540
1627
  noindex={true}
1541
1628
  >
@@ -1547,7 +1634,7 @@ const nf = data.ui.notFound;
1547
1634
  <p class="text-muted-foreground">{nf.description}</p>
1548
1635
  <a
1549
1636
  class="mt-2 rounded-md bg-accent px-4 py-2 text-sm font-medium text-accent-foreground"
1550
- href="/">{nf.home}</a
1637
+ href={withBase("/")}>{nf.home}</a
1551
1638
  >
1552
1639
  </div>
1553
1640
  </PageLayout>
@@ -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