blume 1.6.4 → 1.6.6

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 (178) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/bin/blume.mjs +3 -2
  3. package/dist/cli/chunk-0ewz4trd.js +679 -0
  4. package/dist/cli/chunk-0ewz4trd.js.map +15 -0
  5. package/dist/cli/chunk-27gtm2ym.js +69 -0
  6. package/dist/cli/chunk-27gtm2ym.js.map +11 -0
  7. package/dist/cli/chunk-2aj8ddew.js +72 -0
  8. package/dist/cli/chunk-2aj8ddew.js.map +10 -0
  9. package/dist/cli/chunk-3k0kzs6d.js +69 -0
  10. package/dist/cli/chunk-3k0kzs6d.js.map +11 -0
  11. package/dist/cli/chunk-3r94j3tc.js +221 -0
  12. package/dist/cli/chunk-3r94j3tc.js.map +10 -0
  13. package/dist/cli/chunk-4trphnvy.js +102 -0
  14. package/dist/cli/chunk-4trphnvy.js.map +11 -0
  15. package/dist/cli/chunk-4xyggvgf.js +21 -0
  16. package/dist/cli/chunk-4xyggvgf.js.map +10 -0
  17. package/dist/cli/chunk-5hs6gb7n.js +32 -0
  18. package/dist/cli/chunk-5hs6gb7n.js.map +10 -0
  19. package/dist/cli/chunk-5yvt556e.js +185 -0
  20. package/dist/cli/chunk-5yvt556e.js.map +11 -0
  21. package/dist/cli/chunk-62qsssnh.js +3808 -0
  22. package/dist/cli/chunk-62qsssnh.js.map +36 -0
  23. package/dist/cli/chunk-6kzzpsx8.js +26 -0
  24. package/dist/cli/chunk-6kzzpsx8.js.map +10 -0
  25. package/dist/cli/chunk-8gnpdsn1.js +952 -0
  26. package/dist/cli/chunk-8gnpdsn1.js.map +12 -0
  27. package/dist/cli/chunk-9sh49q0h.js +30 -0
  28. package/dist/cli/chunk-9sh49q0h.js.map +10 -0
  29. package/dist/cli/chunk-aerwpe14.js +2370 -0
  30. package/dist/cli/chunk-aerwpe14.js.map +15 -0
  31. package/dist/cli/chunk-ag1zyr5x.js +176 -0
  32. package/dist/cli/chunk-ag1zyr5x.js.map +10 -0
  33. package/dist/cli/chunk-bawgnt8x.js +277 -0
  34. package/dist/cli/chunk-bawgnt8x.js.map +11 -0
  35. package/dist/cli/chunk-bcy492zc.js +16 -0
  36. package/dist/cli/chunk-bcy492zc.js.map +10 -0
  37. package/dist/cli/chunk-btfr9yvw.js +41 -0
  38. package/dist/cli/chunk-btfr9yvw.js.map +10 -0
  39. package/dist/cli/chunk-cbjnx4s8.js +73 -0
  40. package/dist/cli/chunk-cbjnx4s8.js.map +10 -0
  41. package/dist/cli/chunk-cnvm6k3e.js +96 -0
  42. package/dist/cli/chunk-cnvm6k3e.js.map +10 -0
  43. package/dist/cli/chunk-etsqspj6.js +5170 -0
  44. package/dist/cli/chunk-etsqspj6.js.map +47 -0
  45. package/dist/cli/chunk-ev67ycx0.js +15 -0
  46. package/dist/cli/chunk-ev67ycx0.js.map +10 -0
  47. package/dist/cli/chunk-ey89bjj1.js +209 -0
  48. package/dist/cli/chunk-ey89bjj1.js.map +11 -0
  49. package/dist/cli/chunk-f75cqye8.js +76 -0
  50. package/dist/cli/chunk-f75cqye8.js.map +10 -0
  51. package/dist/cli/chunk-j00ezcg5.js +259 -0
  52. package/dist/cli/chunk-j00ezcg5.js.map +11 -0
  53. package/dist/cli/chunk-jtb45atp.js +467 -0
  54. package/dist/cli/chunk-jtb45atp.js.map +14 -0
  55. package/dist/cli/chunk-m3p3wahd.js +117 -0
  56. package/dist/cli/chunk-m3p3wahd.js.map +10 -0
  57. package/dist/cli/chunk-n0y172hf.js +387 -0
  58. package/dist/cli/chunk-n0y172hf.js.map +12 -0
  59. package/dist/cli/chunk-n4qjabmt.js +1062 -0
  60. package/dist/cli/chunk-n4qjabmt.js.map +25 -0
  61. package/dist/cli/chunk-nyqzjdhj.js +111 -0
  62. package/dist/cli/chunk-nyqzjdhj.js.map +11 -0
  63. package/dist/cli/chunk-pxj10x8y.js +35 -0
  64. package/dist/cli/chunk-pxj10x8y.js.map +10 -0
  65. package/dist/cli/chunk-s4jn7f1q.js +54 -0
  66. package/dist/cli/chunk-s4jn7f1q.js.map +10 -0
  67. package/dist/cli/chunk-s4k1pnvf.js +81 -0
  68. package/dist/cli/chunk-s4k1pnvf.js.map +10 -0
  69. package/dist/cli/chunk-s5e5jt53.js +227 -0
  70. package/dist/cli/chunk-s5e5jt53.js.map +11 -0
  71. package/dist/cli/chunk-sbdqrjbb.js +81 -0
  72. package/dist/cli/chunk-sbdqrjbb.js.map +10 -0
  73. package/dist/cli/chunk-tc89yh2r.js +136 -0
  74. package/dist/cli/chunk-tc89yh2r.js.map +10 -0
  75. package/dist/cli/chunk-vt8fgygt.js +23 -0
  76. package/dist/cli/chunk-vt8fgygt.js.map +10 -0
  77. package/dist/cli/chunk-vv237fp3.js +1002 -0
  78. package/dist/cli/chunk-vv237fp3.js.map +13 -0
  79. package/dist/cli/chunk-vv3f8mb6.js +5314 -0
  80. package/dist/cli/chunk-vv3f8mb6.js.map +58 -0
  81. package/dist/cli/chunk-vxv4x1n8.js +17 -0
  82. package/dist/cli/chunk-vxv4x1n8.js.map +10 -0
  83. package/dist/cli/chunk-wb067mv3.js +758 -0
  84. package/dist/cli/chunk-wb067mv3.js.map +13 -0
  85. package/dist/cli/chunk-wd27zjcz.js +60 -0
  86. package/dist/cli/chunk-wd27zjcz.js.map +10 -0
  87. package/dist/cli/chunk-wkq5tbtq.js +1141 -0
  88. package/dist/cli/chunk-wkq5tbtq.js.map +19 -0
  89. package/dist/cli/chunk-x1vrdjyk.js +1967 -0
  90. package/dist/cli/chunk-x1vrdjyk.js.map +34 -0
  91. package/dist/cli/chunk-x66c5yjn.js +23 -0
  92. package/dist/cli/chunk-x66c5yjn.js.map +10 -0
  93. package/dist/cli/index.js +55 -27587
  94. package/dist/cli/index.js.map +5 -243
  95. package/dist/types/ai/ask-context.d.ts +26 -0
  96. package/dist/types/core/code-fences.d.ts +11 -0
  97. package/dist/types/core/config-input.d.ts +10 -0
  98. package/dist/types/core/package-root.d.ts +1 -1
  99. package/dist/types/core/schema.d.ts +74 -1
  100. package/docs/02-deployment.mdx +1 -1
  101. package/docs/configuration/analytics.mdx +21 -2
  102. package/docs/configuration/ask-ai.mdx +1 -1
  103. package/docs/configuration/customization.mdx +2 -9
  104. package/docs/content/syntax.mdx +1 -1
  105. package/docs/reference/cli.mdx +1 -1
  106. package/package.json +16 -14
  107. package/src/ai/api/handlers.ts +4 -7
  108. package/src/ai/api/paths.ts +8 -0
  109. package/src/ai/api/spec.ts +2 -1
  110. package/src/ai/ask-context.ts +378 -22
  111. package/src/astro/generate.ts +25 -29
  112. package/src/astro/include-hmr.ts +10 -13
  113. package/src/astro/include-refresh.ts +0 -0
  114. package/src/astro/index.ts +6 -1
  115. package/src/astro/integration.ts +269 -53
  116. package/src/astro/module-types.ts +74 -0
  117. package/src/astro/templates.ts +85 -97
  118. package/src/audit/image-size.ts +10 -8
  119. package/src/cli/command-meta.ts +77 -0
  120. package/src/cli/commands/add.ts +2 -4
  121. package/src/cli/commands/audit.ts +2 -4
  122. package/src/cli/commands/build.ts +42 -346
  123. package/src/cli/commands/check.ts +2 -4
  124. package/src/cli/commands/dev.ts +31 -42
  125. package/src/cli/commands/doctor.ts +2 -4
  126. package/src/cli/commands/eject.ts +3 -41
  127. package/src/cli/commands/eval.ts +2 -5
  128. package/src/cli/commands/init.ts +2 -4
  129. package/src/cli/commands/mcp-stdio.ts +2 -5
  130. package/src/cli/commands/preview.ts +3 -5
  131. package/src/cli/commands/sync.ts +2 -4
  132. package/src/cli/commands/translate.ts +2 -5
  133. package/src/cli/commands/validate.ts +2 -4
  134. package/src/cli/commands/version.ts +2 -4
  135. package/src/cli/eject-scripts.ts +0 -45
  136. package/src/cli/host-args.ts +16 -0
  137. package/src/cli/index.ts +84 -35
  138. package/src/cli/lazy-command.ts +47 -0
  139. package/src/components/content/GithubInfo.astro +4 -1
  140. package/src/components/content/mermaid-element.ts +8 -0
  141. package/src/components/layout/Analytics.astro +20 -1
  142. package/src/components/layout/PageLayout.astro +14 -3
  143. package/src/components/layout/ReferenceLayout.astro +15 -4
  144. package/src/components/layout/RootLayout.astro +15 -4
  145. package/src/components/layout/analytics-client.ts +2 -1
  146. package/src/components/layout/page-locale.ts +29 -0
  147. package/src/components/openapi/AsyncApiOperation.astro +5 -3
  148. package/src/components/openapi/Authorization.astro +4 -6
  149. package/src/components/openapi/Bindings.astro +2 -2
  150. package/src/components/openapi/Description.astro +109 -0
  151. package/src/components/openapi/GraphqlFieldsTable.astro +5 -7
  152. package/src/components/openapi/GraphqlOperation.astro +4 -3
  153. package/src/components/openapi/GraphqlType.astro +4 -6
  154. package/src/components/openapi/ParametersTable.astro +5 -7
  155. package/src/components/openapi/RequestBody.astro +2 -4
  156. package/src/components/openapi/Responses.astro +4 -3
  157. package/src/components/openapi/SchemaProperty.astro +11 -6
  158. package/src/components/openapi/description.ts +91 -0
  159. package/src/core/api-name.ts +18 -0
  160. package/src/core/code-fences.ts +48 -0
  161. package/src/core/config-input.ts +10 -0
  162. package/src/core/content-assets.ts +3 -7
  163. package/src/core/includes.ts +3 -7
  164. package/src/core/package-root.ts +1 -1
  165. package/src/core/schema.ts +27 -0
  166. package/src/core/sources/normalize.ts +2 -37
  167. package/src/core/sources/obsidian.ts +3 -2
  168. package/src/core/svg-dimensions.ts +97 -0
  169. package/src/core/version-cut.ts +2 -2
  170. package/src/deploy/artifacts.ts +370 -0
  171. package/src/deploy/cloudflare-negotiation.ts +97 -32
  172. package/src/deploy/function-bundle.ts +66 -20
  173. package/src/deploy/sitemap.ts +6 -0
  174. package/src/deploy/vercel-negotiation.ts +8 -30
  175. package/src/og/card.ts +6 -12
  176. package/src/openapi/render-mdx.ts +9 -5
  177. package/src/registry/eject.ts +0 -2
  178. package/src/theme/entry.ts +9 -2
@@ -19,7 +19,7 @@ import { hasScalarReferences } from "../openapi/references.ts";
19
19
  import { searchProviderMeta } from "../search/providers.ts";
20
20
  import { buildFontEntries, fontLocaleCodes } from "../theme/fonts.ts";
21
21
  import type { ExampleSpec } from "./examples.ts";
22
- import type { BlumePageRoute } from "./integration.ts";
22
+ import type { BlumeIntegrationOptions, BlumePageRoute } from "./integration.ts";
23
23
  import type { IslandSpec } from "./islands.ts";
24
24
  import type { OgCustomRoute } from "./pages.ts";
25
25
  import { RUNTIME_MODULE_FILES } from "./runtime-modules.ts";
@@ -125,30 +125,24 @@ const resolveCloudflareAdapterArgs = (context: ProjectContext): string => {
125
125
  * Without a configured driver, `@astrojs/cloudflare` force-enables KV-backed
126
126
  * sessions and declares a `SESSION` kv_namespaces entry in the generated
127
127
  * wrangler config — which `wrangler deploy` then requires a real KV namespace
128
- * for, even though Blume never reads `Astro.session`. An explicit in-memory
129
- * driver keeps the binding out. Swap for Astro's session opt-out once
130
- * withastro/astro#16871 ships in the supported range.
128
+ * for, even though Blume never reads `Astro.session`. Astro's `session: false`
129
+ * opts the project out, and the adapter checks for it before adding the
130
+ * binding. (An in-memory driver used to stand in before the opt-out existed.)
131
131
  */
132
132
  const resolveSessionOption = (deployment: {
133
133
  adapter: string | null;
134
134
  output: string;
135
135
  }): string =>
136
136
  deployment.output === "server" && deployment.adapter === "cloudflare"
137
- ? "\n session: { driver: sessionDrivers.memory() },"
137
+ ? "\n session: false,"
138
138
  : "";
139
139
 
140
140
  /** The named imports the generated config pulls from `astro/config`. */
141
- const astroConfigImportLine = (options: {
142
- hasFonts: boolean;
143
- hasSession: boolean;
144
- }): string => {
141
+ const astroConfigImportLine = (options: { hasFonts: boolean }): string => {
145
142
  const names = ["defineConfig"];
146
143
  if (options.hasFonts) {
147
144
  names.push("fontProviders");
148
145
  }
149
- if (options.hasSession) {
150
- names.push("sessionDrivers");
151
- }
152
146
  return `import { ${names.join(", ")} } from "astro/config";`;
153
147
  };
154
148
 
@@ -458,6 +452,33 @@ const renderRuntimeModuleWiring = (
458
452
  return { aliasLines, imports: [], pluginEntry: "" };
459
453
  };
460
454
 
455
+ /**
456
+ * The options baked into the generated config's `blumeIntegration(...)` call.
457
+ * The hidden runtime gets the content routes and homepage `Link` header from
458
+ * the CLI in memory (`publishDevNegotiation`, republished on every
459
+ * regeneration), so a content-route change never rewrites the config — Astro
460
+ * restarts the dev server in place on a config change — and its scanned
461
+ * project from `blume build` for the deploy artifacts. An ejected project has
462
+ * no CLI, so the negotiation inputs are baked in and `astro:build:done` scans
463
+ * the project root (the Astro root, after eject) for the artifacts.
464
+ */
465
+ const blumeIntegrationOptions = (options: {
466
+ config: ResolvedConfig;
467
+ contentRoutes: string[];
468
+ ejected: boolean;
469
+ pages: BlumePageRoute[];
470
+ }): BlumeIntegrationOptions =>
471
+ options.ejected
472
+ ? {
473
+ buildArtifactsRoot: ".",
474
+ contentRoutes: options.contentRoutes,
475
+ homeLinkHeader:
476
+ buildHomeLinkHeader(options.config, options.contentRoutes) ??
477
+ undefined,
478
+ pages: options.pages,
479
+ }
480
+ : { pages: options.pages };
481
+
461
482
  export const astroConfigTemplate = (options: {
462
483
  context: ProjectContext;
463
484
  config: ResolvedConfig;
@@ -567,7 +588,9 @@ export const astroConfigTemplate = (options: {
567
588
  : "";
568
589
  const imageOption = renderImageOption(config);
569
590
 
570
- // Astro's native i18n gives locale-aware helpers + `<html lang>` correctness.
591
+ // Astro's native i18n resolves `Astro.currentLocale` from the URL, which the
592
+ // document shells fall back to for `<html lang>`/`dir` on pages the content
593
+ // catch-all doesn't drive (custom pages, the 404, the reference shell).
571
594
  // Blume owns getStaticPaths and materializes fallback routes in the manifest,
572
595
  // so we deliberately omit Astro's `fallback` to keep one source of routing.
573
596
  const i18nOption = config.i18n
@@ -653,7 +676,6 @@ export const astroConfigTemplate = (options: {
653
676
  : "";
654
677
  const defineConfigImport = astroConfigImportLine({
655
678
  hasFonts: fontEntries.length > 0,
656
- hasSession: sessionOption.length > 0,
657
679
  });
658
680
 
659
681
  // Framework renderers are only wired in when an island (or Ask AI, for React)
@@ -710,12 +732,14 @@ export const astroConfigTemplate = (options: {
710
732
  // up dev-server `Accept: text/markdown` negotiation over the content routes,
711
733
  // plus the homepage agent-discovery `Link` header.
712
734
  integrations.push(
713
- `blumeIntegration(${JSON.stringify({
714
- base: deployment.base,
715
- contentRoutes,
716
- homeLinkHeader: buildHomeLinkHeader(config, contentRoutes) ?? undefined,
717
- pages,
718
- })})`
735
+ `blumeIntegration(${JSON.stringify(
736
+ blumeIntegrationOptions({
737
+ config,
738
+ contentRoutes,
739
+ ejected: generatedModulesDir !== undefined,
740
+ pages,
741
+ })
742
+ )})`
719
743
  );
720
744
 
721
745
  const {
@@ -758,6 +782,14 @@ ${userConfigSetup}export default defineConfig({
758
782
  },
759
783
  },
760
784
  devToolbar: { enabled: false },
785
+ // One canonical URL per page: canonicals, the sitemap, and hreflang all use
786
+ // the slashless form, so the slashed spelling is not a second address. Astro
787
+ // applies this itself — its dev server answers a slashed URL with a 404 that
788
+ // names the setting, an on-demand route redirects — and the Vercel adapter
789
+ // turns it into the platform's 308 route, so the Build Output config needs
790
+ // no hand-spliced redirect (see deploy/vercel-negotiation.ts). Static hosts
791
+ // serve the \`index.html\` directory layout as they always did.
792
+ trailingSlash: "never",
761
793
  // The layouts render Astro's <ClientRouter />, and its in-place swaps read
762
794
  // from the prefetch cache — fetching every link on hover/viewport hides the
763
795
  // request latency behind the user's intent, so most navigations swap
@@ -924,6 +956,7 @@ const staged = defineCollection({
924
956
  base: ${JSON.stringify(astroGlobBase(stagedBase))},
925
957
  generateId: ({ entry }) => entry,
926
958
  }),
959
+ schema: pageCollectionSchema,
927
960
  });
928
961
  `
929
962
  : "";
@@ -932,16 +965,22 @@ const staged = defineCollection({
932
965
  import { defineCollection } from "astro:content";
933
966
  import { glob } from "astro/loaders";
934
967
  import { withIncludeRefresh } from "blume/astro";
968
+ import { pageCollectionSchema } from "blume/core/schema.ts";
935
969
 
936
970
  // withIncludeRefresh keeps <include>-bearing pages fresh: plain .md entries
937
971
  // are rendered at sync time and digest-cached on the page file alone, so a
938
972
  // partial edit (or a warm-cache rebuild after one) would serve stale HTML.
973
+ //
974
+ // pageCollectionSchema is the scan's page front-matter schema, so entry.data
975
+ // is typed and normalized the same way; it passes custom keys through and
976
+ // falls back to defaults for a page the scan already dropped as invalid.
939
977
  const docs = defineCollection({
940
978
  loader: withIncludeRefresh(glob({
941
979
  pattern: ${JSON.stringify(docsPattern)},
942
980
  base: ${JSON.stringify(astroGlobBase(collectionBase))},
943
981
  generateId: ({ entry }) => entry,
944
982
  }), ${JSON.stringify(`${context.outDir}/src/generated/includes.json`)}),
983
+ schema: pageCollectionSchema,
945
984
  });
946
985
  ${stagedBlock}
947
986
  export const collections = { docs${options.staged ? ", staged" : ""} };
@@ -980,26 +1019,38 @@ export const askEndpointTemplate = (
980
1019
  const fallbackPrompt = instructions
981
1020
  ? `${ASK_FALLBACK_PROMPT}\n\n${instructions}`
982
1021
  : ASK_FALLBACK_PROMPT;
1022
+ // Secrets go through Astro's `getSecret` rather than `process.env`, so each
1023
+ // adapter supplies them its own way (Cloudflare from the Worker's bindings,
1024
+ // Node and Vercel from the environment).
983
1025
  const imports = [
984
1026
  'import type { APIRoute } from "astro";',
985
- 'import { streamText } from "ai";',
1027
+ 'import { getSecret } from "astro:env/server";',
1028
+ // The gateway provider reads the key (or Vercel's OIDC token) from the
1029
+ // environment itself; passing the key explicitly lets a binding-backed
1030
+ // secret store reach it too.
1031
+ backend.kind === "gateway"
1032
+ ? 'import { createGateway, streamText } from "ai";'
1033
+ : 'import { streamText } from "ai";',
986
1034
  ];
987
1035
  let setup = "";
988
1036
  let modelExpr = JSON.stringify(backend.model);
989
- if (backend.kind === "openrouter") {
1037
+ if (backend.kind === "gateway") {
1038
+ setup = `\nconst gateway = createGateway({ apiKey: getSecret("AI_GATEWAY_API_KEY") });\n`;
1039
+ modelExpr = `gateway(${JSON.stringify(backend.model)})`;
1040
+ } else if (backend.kind === "openrouter") {
990
1041
  imports.push(
991
1042
  'import { createOpenRouter } from "@openrouter/ai-sdk-provider";'
992
1043
  );
993
- setup = `\nconst openrouter = createOpenRouter({ apiKey: process.env[${JSON.stringify(
1044
+ setup = `\nconst openrouter = createOpenRouter({ apiKey: getSecret(${JSON.stringify(
994
1045
  backend.apiKeyEnv
995
- )}] });\n`;
1046
+ )}) });\n`;
996
1047
  modelExpr = `openrouter(${JSON.stringify(backend.model)})`;
997
1048
  } else if (backend.kind === "openai-compatible") {
998
1049
  imports.push(
999
1050
  'import { createOpenAICompatible } from "@ai-sdk/openai-compatible";'
1000
1051
  );
1001
1052
  setup = `\nconst provider = createOpenAICompatible({
1002
- apiKey: process.env[${JSON.stringify(backend.apiKeyEnv)}],
1053
+ apiKey: getSecret(${JSON.stringify(backend.apiKeyEnv)}),
1003
1054
  baseURL: ${JSON.stringify(backend.baseUrl)},
1004
1055
  name: ${JSON.stringify(backend.name)},
1005
1056
  });\n`;
@@ -1062,13 +1113,13 @@ export const askEndpointTemplate = (
1062
1113
  const keyCheck =
1063
1114
  backend.kind === "gateway"
1064
1115
  ? ` // The AI Gateway authenticates with an API key or Vercel's OIDC token.
1065
- if (!(process.env.AI_GATEWAY_API_KEY || process.env.VERCEL_OIDC_TOKEN)) {
1116
+ if (!(getSecret("AI_GATEWAY_API_KEY") || getSecret("VERCEL_OIDC_TOKEN"))) {
1066
1117
  return new Response(
1067
1118
  "Ask AI is not configured: set AI_GATEWAY_API_KEY (or deploy on Vercel with OIDC).",
1068
1119
  { status: 500 }
1069
1120
  );
1070
1121
  }`
1071
- : ` if (!process.env[${JSON.stringify(backend.apiKeyEnv)}]) {
1122
+ : ` if (!getSecret(${JSON.stringify(backend.apiKeyEnv)})) {
1072
1123
  return new Response(
1073
1124
  ${JSON.stringify(`Ask AI is not configured: set ${backend.apiKeyEnv}.`)},
1074
1125
  { status: 500 }
@@ -1176,8 +1227,9 @@ const searchClientImport = (module: string): string =>
1176
1227
  `import { createSearch as create } from "blume/components/layout/search/${module}.ts";\n`;
1177
1228
 
1178
1229
  // Joins a base-relative path onto BASE_URL, which arrives with or without a
1179
- // trailing slash (Astro's default trailingSlash: "ignore" passes `/docs`
1180
- // through bare — naive concatenation would yield `/docsblume-search.json`).
1230
+ // trailing slash (Astro normalizes `base` by `trailingSlash`, which the
1231
+ // generated config pins to "never" but an owned config may set either way —
1232
+ // naive concatenation of a bare `/docs` would yield `/docsblume-search.json`).
1181
1233
  const SEARCH_BASE_IMPORT =
1182
1234
  'import { joinBase } from "blume/components/islands/base-path.ts";\n';
1183
1235
 
@@ -1291,11 +1343,12 @@ export const createSearch = () => create({ url });
1291
1343
  export const mixedbreadSearchEndpointTemplate = (storeId: string): string =>
1292
1344
  `// Generated by Blume. Do not edit.
1293
1345
  import type { APIRoute } from "astro";
1346
+ import { getSecret } from "astro:env/server";
1294
1347
  import Mixedbread from "@mixedbread/sdk";
1295
1348
 
1296
1349
  export const prerender = false;
1297
1350
 
1298
- const client = new Mixedbread({ apiKey: process.env.MIXEDBREAD_API_KEY ?? "" });
1351
+ const client = new Mixedbread({ apiKey: getSecret("MIXEDBREAD_API_KEY") ?? "" });
1299
1352
  const STORE_ID = ${JSON.stringify(storeId)};
1300
1353
 
1301
1354
  export const POST: APIRoute = async ({ request }) => {
@@ -1883,9 +1936,9 @@ const headings =
1883
1936
  tocHidden.size > 0
1884
1937
  ? allHeadings.filter((heading) => !tocHidden.has(heading.slug))
1885
1938
  : allHeadings;
1886
- const frontmatter = entry.data ?? {};
1939
+ const frontmatter = entry.data;
1887
1940
 
1888
- const seo = frontmatter.seo ?? {};
1941
+ const seo = frontmatter.seo;
1889
1942
  const base = data.config.site ? data.config.site.replace(/\\/$/, "") : null;
1890
1943
 
1891
1944
  // Percent-encode the route-derived path (the sitemap convention): a Unicode
@@ -3037,71 +3090,6 @@ const Example = entry.Component;
3037
3090
  </html>
3038
3091
  `;
3039
3092
 
3040
- /** Generate `.blume/src/env.d.ts`. */
3041
- export const envTemplate =
3042
- (): string => `/// <reference path="../.astro/types.d.ts" />
3043
- /// <reference types="astro/client" />
3044
-
3045
- declare module "blume:ask" {
3046
- const Ask: typeof import("blume/components/islands/AskAI.astro").default;
3047
- export default Ask;
3048
- }
3049
-
3050
- declare module "blume:data" {
3051
- const data: import("blume").BlumeData;
3052
- export default data;
3053
- }
3054
-
3055
- declare module "blume:ask-data" {
3056
- const askData: import("blume/ai/ask-context.ts").AskData;
3057
- export default askData;
3058
- }
3059
-
3060
- declare module "blume:content-assets" {
3061
- const assets: Record<string, string>;
3062
- export default assets;
3063
- }
3064
-
3065
- declare module "blume:mcp-data" {
3066
- const data: import("blume/ai/mcp/data.ts").McpData;
3067
- export default data;
3068
- }
3069
-
3070
- declare module "blume:raw-markdown" {
3071
- const raw: Record<string, import("blume/ai/markdown.ts").RawMarkdownEntry>;
3072
- export default raw;
3073
- }
3074
-
3075
- declare module "blume:rss" {
3076
- const feeds: Record<string, string>;
3077
- export default feeds;
3078
- }
3079
-
3080
- declare module "blume:search-index" {
3081
- const documents: import("blume/search/documents.ts").SearchDocument[];
3082
- export default documents;
3083
- }
3084
-
3085
- declare module "blume:examples" {
3086
- type Examples = typeof import("./generated/examples.ts").examples;
3087
- export const examples: Record<string, Examples[keyof Examples]>;
3088
- export const examplesBase: string;
3089
- }
3090
-
3091
- declare module "blume:examples-theme";
3092
-
3093
- declare module "blume:openapi" {
3094
- const specs: import("blume/openapi/model.ts").OpenApiData;
3095
- export default specs;
3096
- }
3097
-
3098
- declare module "blume:search-client" {
3099
- export const createSearch: () =>
3100
- | import("blume/components/layout/search/types.ts").SearchFn
3101
- | Promise<import("blume/components/layout/search/types.ts").SearchFn>;
3102
- }
3103
- `;
3104
-
3105
3093
  /** Generate `.blume/package.json`. */
3106
3094
  export const runtimePackageTemplate = (dependencies: string[] = []): string =>
3107
3095
  `${JSON.stringify(
@@ -1,11 +1,13 @@
1
- import { imageSize as measureImage } from "image-size";
1
+ import sharp from "sharp";
2
2
 
3
3
  /**
4
- * Pixel dimensions read from an image header via the image-size package,
5
- * which covers the formats a modern pipeline actually emits — WebP and AVIF
6
- * included, where the previous hand parser (PNG/JPEG/GIF only) went silent
7
- * and the dimension checks never ran. An unrecognized or truncated buffer
8
- * yields null and its checks simply don't run.
4
+ * Pixel dimensions read from an image header via sharp, which is already the
5
+ * build's image optimizer and so covers every format the pipeline can emit —
6
+ * WebP and AVIF included. libvips reads only the header and rejects malformed
7
+ * input with an error rather than looping on it, which is why the previous
8
+ * pure-JS parser was retired: it could be driven into an infinite loop by a
9
+ * crafted ICNS, HEIF, or JXL file and hang the build. An unrecognized or
10
+ * truncated buffer yields null and its checks simply don't run.
9
11
  */
10
12
  export interface ImageSize {
11
13
  width: number;
@@ -13,9 +15,9 @@ export interface ImageSize {
13
15
  }
14
16
 
15
17
  /** The image's pixel dimensions, or null when the format isn't recognized. */
16
- export const imageSize = (bytes: Buffer): ImageSize | null => {
18
+ export const imageSize = async (bytes: Buffer): Promise<ImageSize | null> => {
17
19
  try {
18
- const { width, height } = measureImage(bytes);
20
+ const { width, height } = await sharp(bytes).metadata();
19
21
  return width > 0 && height > 0 ? { height, width } : null;
20
22
  } catch {
21
23
  return null;
@@ -0,0 +1,77 @@
1
+ import type { CommandMeta } from "citty";
2
+
3
+ /**
4
+ * Every command's `meta`, held apart from the command modules themselves.
5
+ *
6
+ * The CLI entry loads each command lazily (see `lazy-command.ts`), but citty
7
+ * still reads every subcommand's `meta` to render `blume --help` and to match
8
+ * an unknown name against aliases. Keeping that table here lets those paths
9
+ * run without importing a single command module — `dev` alone drags in Astro,
10
+ * `mcp-stdio` the MCP SDK. The command modules read their `meta` from this
11
+ * table too, so the entry and the command can't drift.
12
+ */
13
+ export const commandMeta = {
14
+ add: {
15
+ description: "Install a source component or template from the registry.",
16
+ name: "add",
17
+ },
18
+ audit: {
19
+ description: "Audit the built site for SEO and site-health issues.",
20
+ name: "audit",
21
+ },
22
+ build: {
23
+ description: "Build the docs site for production.",
24
+ name: "build",
25
+ },
26
+ check: {
27
+ description: "Type-check the docs site with astro check.",
28
+ name: "check",
29
+ },
30
+ dev: {
31
+ description: "Start the Blume development server.",
32
+ name: "dev",
33
+ },
34
+ doctor: {
35
+ description: "Diagnose common configuration and content problems.",
36
+ name: "doctor",
37
+ },
38
+ eject: {
39
+ description: "Promote the generated runtime into an owned Astro project.",
40
+ name: "eject",
41
+ },
42
+ eval: {
43
+ description:
44
+ "Test the docs: an agent answers your questions using only the documentation.",
45
+ name: "eval",
46
+ },
47
+ init: {
48
+ description: "Scaffold a minimal Blume project.",
49
+ name: "init",
50
+ },
51
+ "mcp-stdio": {
52
+ description:
53
+ "Serve an MCP data snapshot over stdio (internal, used by `blume eval`).",
54
+ name: "mcp-stdio",
55
+ },
56
+ preview: {
57
+ description: "Preview the last production build.",
58
+ name: "preview",
59
+ },
60
+ sync: {
61
+ description: "Re-fetch remote content sources and regenerate the runtime.",
62
+ name: "sync",
63
+ },
64
+ translate: {
65
+ description:
66
+ "Translate docs into the configured locales with a local agent CLI.",
67
+ name: "translate",
68
+ },
69
+ validate: {
70
+ description: "Validate internal, anchor, asset, and external links.",
71
+ name: "validate",
72
+ },
73
+ version: {
74
+ description: "Freeze the current docs as an archived version.",
75
+ name: "version",
76
+ },
77
+ } satisfies Record<string, CommandMeta>;
@@ -6,6 +6,7 @@ import { dirname, join } from "pathe";
6
6
 
7
7
  import { findItem, packageSrc, registry } from "../../registry/registry.ts";
8
8
  import { rewriteImports } from "../../registry/rewrite-imports.ts";
9
+ import { commandMeta } from "../command-meta.ts";
9
10
  import { logger } from "../log.ts";
10
11
 
11
12
  export const addCommand = defineCommand({
@@ -17,10 +18,7 @@ export const addCommand = defineCommand({
17
18
  type: "positional",
18
19
  },
19
20
  },
20
- meta: {
21
- description: "Install a source component or template from the registry.",
22
- name: "add",
23
- },
21
+ meta: commandMeta.add,
24
22
  async run({ args }) {
25
23
  const root = process.cwd();
26
24
 
@@ -13,6 +13,7 @@ import type { AuditResult } from "../../audit/run.ts";
13
13
  import { BlumeError } from "../../core/diagnostics.ts";
14
14
  import { scanProject } from "../../core/project-graph.ts";
15
15
  import type { DiagnosticSeverity } from "../../core/types.ts";
16
+ import { commandMeta } from "../command-meta.ts";
16
17
  import { reportInternalError } from "../internal-error.ts";
17
18
  import { flushStdout, logger } from "../log.ts";
18
19
 
@@ -115,10 +116,7 @@ export const auditCommand = defineCommand({
115
116
  type: "boolean",
116
117
  },
117
118
  },
118
- meta: {
119
- description: "Audit the built site for SEO and site-health issues.",
120
- name: "audit",
121
- },
119
+ meta: commandMeta.audit,
122
120
  async run({ args }) {
123
121
  if (args["list-checks"]) {
124
122
  process.stdout.write(formatCatalog());