blume 1.6.5 → 1.7.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 (179) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/bin/blume.mjs +3 -2
  3. package/dist/cli/chunk-0qhq7b8q.js +111 -0
  4. package/dist/cli/chunk-0qhq7b8q.js.map +11 -0
  5. package/dist/cli/chunk-18tjv4f7.js +96 -0
  6. package/dist/cli/chunk-18tjv4f7.js.map +10 -0
  7. package/dist/cli/chunk-27gtm2ym.js +69 -0
  8. package/dist/cli/chunk-27gtm2ym.js.map +11 -0
  9. package/dist/cli/chunk-2aj8ddew.js +72 -0
  10. package/dist/cli/chunk-2aj8ddew.js.map +10 -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-5d4q7121.js +4064 -0
  18. package/dist/cli/chunk-5d4q7121.js.map +40 -0
  19. package/dist/cli/chunk-5hs6gb7n.js +32 -0
  20. package/dist/cli/chunk-5hs6gb7n.js.map +10 -0
  21. package/dist/cli/chunk-6kzzpsx8.js +26 -0
  22. package/dist/cli/chunk-6kzzpsx8.js.map +10 -0
  23. package/dist/cli/chunk-8gnpdsn1.js +952 -0
  24. package/dist/cli/chunk-8gnpdsn1.js.map +12 -0
  25. package/dist/cli/chunk-9qs6acpw.js +176 -0
  26. package/dist/cli/chunk-9qs6acpw.js.map +10 -0
  27. package/dist/cli/chunk-agy5rzxy.js +2453 -0
  28. package/dist/cli/chunk-agy5rzxy.js.map +15 -0
  29. package/dist/cli/chunk-bcy492zc.js +16 -0
  30. package/dist/cli/chunk-bcy492zc.js.map +10 -0
  31. package/dist/cli/chunk-btfr9yvw.js +41 -0
  32. package/dist/cli/chunk-btfr9yvw.js.map +10 -0
  33. package/dist/cli/chunk-cbjnx4s8.js +73 -0
  34. package/dist/cli/chunk-cbjnx4s8.js.map +10 -0
  35. package/dist/cli/chunk-cfw6x4rm.js +1967 -0
  36. package/dist/cli/chunk-cfw6x4rm.js.map +34 -0
  37. package/dist/cli/chunk-ckh3a410.js +277 -0
  38. package/dist/cli/chunk-ckh3a410.js.map +11 -0
  39. package/dist/cli/chunk-drke6t0h.js +259 -0
  40. package/dist/cli/chunk-drke6t0h.js.map +11 -0
  41. package/dist/cli/chunk-ev67ycx0.js +15 -0
  42. package/dist/cli/chunk-ev67ycx0.js.map +10 -0
  43. package/dist/cli/chunk-ey89bjj1.js +209 -0
  44. package/dist/cli/chunk-ey89bjj1.js.map +11 -0
  45. package/dist/cli/chunk-j6pxe0dt.js +69 -0
  46. package/dist/cli/chunk-j6pxe0dt.js.map +11 -0
  47. package/dist/cli/chunk-jk1zwka1.js +387 -0
  48. package/dist/cli/chunk-jk1zwka1.js.map +12 -0
  49. package/dist/cli/chunk-jtb45atp.js +467 -0
  50. package/dist/cli/chunk-jtb45atp.js.map +14 -0
  51. package/dist/cli/chunk-jxkxjsc1.js +76 -0
  52. package/dist/cli/chunk-jxkxjsc1.js.map +10 -0
  53. package/dist/cli/chunk-kwx90v78.js +81 -0
  54. package/dist/cli/chunk-kwx90v78.js.map +10 -0
  55. package/dist/cli/chunk-n0nyat6g.js +30 -0
  56. package/dist/cli/chunk-n0nyat6g.js.map +10 -0
  57. package/dist/cli/chunk-pxj10x8y.js +35 -0
  58. package/dist/cli/chunk-pxj10x8y.js.map +10 -0
  59. package/dist/cli/chunk-qq9nm3qd.js +1141 -0
  60. package/dist/cli/chunk-qq9nm3qd.js.map +19 -0
  61. package/dist/cli/chunk-s102bysw.js +5170 -0
  62. package/dist/cli/chunk-s102bysw.js.map +47 -0
  63. package/dist/cli/chunk-s5dsk8bj.js +769 -0
  64. package/dist/cli/chunk-s5dsk8bj.js.map +13 -0
  65. package/dist/cli/chunk-s5e5jt53.js +227 -0
  66. package/dist/cli/chunk-s5e5jt53.js.map +11 -0
  67. package/dist/cli/chunk-sbdqrjbb.js +81 -0
  68. package/dist/cli/chunk-sbdqrjbb.js.map +10 -0
  69. package/dist/cli/chunk-tnskyrej.js +117 -0
  70. package/dist/cli/chunk-tnskyrej.js.map +10 -0
  71. package/dist/cli/chunk-v2ymm99c.js +1016 -0
  72. package/dist/cli/chunk-v2ymm99c.js.map +13 -0
  73. package/dist/cli/chunk-v5mm027v.js +185 -0
  74. package/dist/cli/chunk-v5mm027v.js.map +11 -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-vxv4x1n8.js +17 -0
  78. package/dist/cli/chunk-vxv4x1n8.js.map +10 -0
  79. package/dist/cli/chunk-wd27zjcz.js +60 -0
  80. package/dist/cli/chunk-wd27zjcz.js.map +10 -0
  81. package/dist/cli/chunk-x66c5yjn.js +23 -0
  82. package/dist/cli/chunk-x66c5yjn.js.map +10 -0
  83. package/dist/cli/chunk-xv91q4nm.js +5314 -0
  84. package/dist/cli/chunk-xv91q4nm.js.map +58 -0
  85. package/dist/cli/chunk-y3g15rvv.js +679 -0
  86. package/dist/cli/chunk-y3g15rvv.js.map +15 -0
  87. package/dist/cli/chunk-ye9zdkgv.js +136 -0
  88. package/dist/cli/chunk-ye9zdkgv.js.map +10 -0
  89. package/dist/cli/chunk-ynacq3ev.js +1062 -0
  90. package/dist/cli/chunk-ynacq3ev.js.map +25 -0
  91. package/dist/cli/chunk-zr3ygrq3.js +54 -0
  92. package/dist/cli/chunk-zr3ygrq3.js.map +10 -0
  93. package/dist/cli/index.js +55 -27597
  94. package/dist/cli/index.js.map +5 -243
  95. package/dist/types/ai/ask-context.d.ts +26 -0
  96. package/dist/types/components/layout/nav-utils.d.ts +33 -1
  97. package/dist/types/core/code-fences.d.ts +11 -0
  98. package/dist/types/core/package-root.d.ts +1 -1
  99. package/dist/types/core/schema.d.ts +70 -0
  100. package/dist/types/theme/fonts.d.ts +22 -22
  101. package/docs/02-deployment.mdx +22 -1
  102. package/docs/configuration/ask-ai.mdx +1 -1
  103. package/docs/configuration/customization.mdx +2 -9
  104. package/docs/content/navigation.mdx +2 -0
  105. package/docs/content/syntax.mdx +1 -1
  106. package/docs/discoverability/open-graph.mdx +4 -0
  107. package/docs/reference/cli.mdx +1 -1
  108. package/package.json +4 -2
  109. package/src/ai/api/handlers.ts +4 -7
  110. package/src/ai/api/paths.ts +8 -0
  111. package/src/ai/api/spec.ts +2 -1
  112. package/src/ai/ask-context.ts +378 -22
  113. package/src/astro/generate.ts +161 -28
  114. package/src/astro/include-hmr.ts +10 -13
  115. package/src/astro/include-refresh.ts +0 -0
  116. package/src/astro/index.ts +6 -1
  117. package/src/astro/integration.ts +280 -53
  118. package/src/astro/module-types.ts +83 -0
  119. package/src/astro/templates.ts +256 -108
  120. package/src/audit/image-size.ts +10 -8
  121. package/src/cli/command-meta.ts +77 -0
  122. package/src/cli/commands/add.ts +2 -4
  123. package/src/cli/commands/audit.ts +2 -4
  124. package/src/cli/commands/build.ts +70 -346
  125. package/src/cli/commands/check.ts +2 -4
  126. package/src/cli/commands/dev.ts +31 -42
  127. package/src/cli/commands/doctor.ts +2 -4
  128. package/src/cli/commands/eject.ts +3 -41
  129. package/src/cli/commands/eval.ts +2 -5
  130. package/src/cli/commands/init.ts +2 -4
  131. package/src/cli/commands/mcp-stdio.ts +2 -5
  132. package/src/cli/commands/preview.ts +3 -5
  133. package/src/cli/commands/sync.ts +2 -4
  134. package/src/cli/commands/translate.ts +2 -5
  135. package/src/cli/commands/validate.ts +2 -4
  136. package/src/cli/commands/version.ts +2 -4
  137. package/src/cli/eject-scripts.ts +0 -45
  138. package/src/cli/host-args.ts +16 -0
  139. package/src/cli/index.ts +84 -35
  140. package/src/cli/lazy-command.ts +47 -0
  141. package/src/components/Icon.astro +24 -0
  142. package/src/components/content/GithubInfo.astro +4 -1
  143. package/src/components/icon-sprite-middleware.ts +41 -0
  144. package/src/components/icon-sprite.ts +93 -0
  145. package/src/components/layout/IconSprite.astro +11 -0
  146. package/src/components/layout/NavTree.astro +156 -188
  147. package/src/components/layout/NavTreeCache.astro +45 -0
  148. package/src/components/layout/NavTreeScript.astro +256 -0
  149. package/src/components/layout/PageActions.astro +11 -5
  150. package/src/components/layout/PageLayout.astro +21 -3
  151. package/src/components/layout/ReferenceLayout.astro +21 -4
  152. package/src/components/layout/RootLayout.astro +44 -6
  153. package/src/components/layout/nav-cache.ts +49 -0
  154. package/src/components/layout/nav-utils.ts +69 -1
  155. package/src/components/layout/page-locale.ts +29 -0
  156. package/src/core/api-name.ts +18 -0
  157. package/src/core/code-fences.ts +48 -0
  158. package/src/core/content-assets.ts +3 -7
  159. package/src/core/includes.ts +3 -7
  160. package/src/core/package-root.ts +1 -1
  161. package/src/core/schema.ts +19 -0
  162. package/src/core/sources/normalize.ts +2 -37
  163. package/src/core/sources/obsidian.ts +3 -2
  164. package/src/core/svg-dimensions.ts +97 -0
  165. package/src/core/version-cut.ts +2 -2
  166. package/src/deploy/artifacts.ts +370 -0
  167. package/src/deploy/cloudflare-negotiation.ts +97 -32
  168. package/src/deploy/function-bundle.ts +66 -20
  169. package/src/deploy/sitemap.ts +6 -0
  170. package/src/deploy/vercel-negotiation.ts +8 -30
  171. package/src/markdown/language-icon.ts +64 -20
  172. package/src/markdown/mermaid.ts +11 -0
  173. package/src/og/cache.ts +236 -0
  174. package/src/og/card.ts +18 -16
  175. package/src/og/index.ts +8 -1
  176. package/src/openapi/render-mdx.ts +9 -5
  177. package/src/registry/eject.ts +23 -10
  178. package/src/theme/entry.ts +41 -7
  179. package/src/theme/fonts.ts +30 -23
@@ -14,12 +14,13 @@ import { BLUME_IGNORE_DIRS } from "../core/sources/watch.ts";
14
14
  import { trimChar } from "../core/trim.ts";
15
15
  import type { ProjectContext } from "../core/types.ts";
16
16
  import { applyBaseToAstroRedirects } from "../deploy/redirects.ts";
17
+ import type { OgCache } from "../og/cache.ts";
17
18
  import type { OgFont, OgFontFamilies } from "../og/card.ts";
18
19
  import { hasScalarReferences } from "../openapi/references.ts";
19
20
  import { searchProviderMeta } from "../search/providers.ts";
20
21
  import { buildFontEntries, fontLocaleCodes } from "../theme/fonts.ts";
21
22
  import type { ExampleSpec } from "./examples.ts";
22
- import type { BlumePageRoute } from "./integration.ts";
23
+ import type { BlumeIntegrationOptions, BlumePageRoute } from "./integration.ts";
23
24
  import type { IslandSpec } from "./islands.ts";
24
25
  import type { OgCustomRoute } from "./pages.ts";
25
26
  import { RUNTIME_MODULE_FILES } from "./runtime-modules.ts";
@@ -125,30 +126,36 @@ const resolveCloudflareAdapterArgs = (context: ProjectContext): string => {
125
126
  * Without a configured driver, `@astrojs/cloudflare` force-enables KV-backed
126
127
  * sessions and declares a `SESSION` kv_namespaces entry in the generated
127
128
  * 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.
129
+ * for, even though Blume never reads `Astro.session`. Astro's `session: false`
130
+ * opts the project out, and the adapter checks for it before adding the
131
+ * binding. (An in-memory driver used to stand in before the opt-out existed.)
131
132
  */
132
133
  const resolveSessionOption = (deployment: {
133
134
  adapter: string | null;
134
135
  output: string;
135
136
  }): string =>
136
137
  deployment.output === "server" && deployment.adapter === "cloudflare"
137
- ? "\n session: { driver: sessionDrivers.memory() },"
138
+ ? "\n session: false,"
138
139
  : "";
139
140
 
141
+ /**
142
+ * A font weight as Astro's Fonts API spells it: a variable range is
143
+ * `"100 900"` there, where Blume's config (and Takumi's Google Fonts helper,
144
+ * which the OG cards use) write `"100..900"`. Astro treats the dotted form as
145
+ * an unknown discrete weight and loads nothing for it.
146
+ */
147
+ const astroFontWeights = (weights: (number | string)[]): string =>
148
+ JSON.stringify(weights).replaceAll(
149
+ /(?<min>\d+)\.\.(?<max>\d+)/gu,
150
+ "$<min> $<max>"
151
+ );
152
+
140
153
  /** The named imports the generated config pulls from `astro/config`. */
141
- const astroConfigImportLine = (options: {
142
- hasFonts: boolean;
143
- hasSession: boolean;
144
- }): string => {
154
+ const astroConfigImportLine = (options: { hasFonts: boolean }): string => {
145
155
  const names = ["defineConfig"];
146
156
  if (options.hasFonts) {
147
157
  names.push("fontProviders");
148
158
  }
149
- if (options.hasSession) {
150
- names.push("sessionDrivers");
151
- }
152
159
  return `import { ${names.join(", ")} } from "astro/config";`;
153
160
  };
154
161
 
@@ -395,13 +402,31 @@ interface OptimizeDepsConfig {
395
402
  * absolute path (see `reactIntegration`), which that check never matches. See
396
403
  * the optimizeDeps comment in the generated config for the failure this prevents.
397
404
  */
405
+ /**
406
+ * The client-side libraries a site needs, decided at generation time. A
407
+ * feature no page uses is left out of the module graph entirely (see
408
+ * {@link featuresTemplate}), so its library never enters the client bundle —
409
+ * Mermaid alone is over 3 MB of chunks (ELK, Cytoscape, KaTeX, every diagram
410
+ * type) and most of the build's client-bundle memory.
411
+ */
412
+ export interface ClientFeatures {
413
+ /** `export.epub` is on: the EPUB generator's browser bundle is needed. */
414
+ epub: boolean;
415
+ /** Some page has a mermaid fence: the `<blume-mermaid>` element is needed. */
416
+ mermaid: boolean;
417
+ }
418
+
419
+ /** Every feature on: what a checkout that predates the detection shipped. */
420
+ const ALL_CLIENT_FEATURES: ClientFeatures = { epub: true, mermaid: true };
421
+
398
422
  const resolveOptimizeDeps = (options: {
399
423
  aliases: Record<string, string> | undefined;
400
424
  context: ProjectContext;
425
+ features: ClientFeatures;
401
426
  needsReact: boolean;
402
427
  reactCompilerPath: string | null | undefined;
403
428
  }): OptimizeDepsConfig => {
404
- const { context } = options;
429
+ const { context, features } = options;
405
430
  const optimizeDepsEntries = [
406
431
  ...(context.pagesRoot ? [`${context.pagesRoot}/**/*.astro`] : []),
407
432
  `${context.root}/islands/**/*.{jsx,svelte,tsx,vue}`,
@@ -410,8 +435,10 @@ const resolveOptimizeDeps = (options: {
410
435
  .map((dir) => `${dir}/**/*.{astro,jsx,svelte,tsx,vue}`),
411
436
  ];
412
437
  const optimizeDepsInclude = [
413
- "blume > mermaid",
414
- "blume > epub-gen-memory/bundle",
438
+ // Only the libraries the site's features actually import: pre-bundling
439
+ // Mermaid costs the dev server seconds at startup for nothing otherwise.
440
+ ...(features.mermaid ? ["blume > mermaid"] : []),
441
+ ...(features.epub ? ["blume > epub-gen-memory/bundle"] : []),
415
442
  // Astro's own client-router/prefetch virtual modules are deliberately NOT
416
443
  // forced in here: they read Vite `define`-injected constants
417
444
  // (__PREFETCH_PREFETCH_ALL__ and friends) that a pre-bundled copy loses,
@@ -458,6 +485,33 @@ const renderRuntimeModuleWiring = (
458
485
  return { aliasLines, imports: [], pluginEntry: "" };
459
486
  };
460
487
 
488
+ /**
489
+ * The options baked into the generated config's `blumeIntegration(...)` call.
490
+ * The hidden runtime gets the content routes and homepage `Link` header from
491
+ * the CLI in memory (`publishDevNegotiation`, republished on every
492
+ * regeneration), so a content-route change never rewrites the config — Astro
493
+ * restarts the dev server in place on a config change — and its scanned
494
+ * project from `blume build` for the deploy artifacts. An ejected project has
495
+ * no CLI, so the negotiation inputs are baked in and `astro:build:done` scans
496
+ * the project root (the Astro root, after eject) for the artifacts.
497
+ */
498
+ const blumeIntegrationOptions = (options: {
499
+ config: ResolvedConfig;
500
+ contentRoutes: string[];
501
+ ejected: boolean;
502
+ pages: BlumePageRoute[];
503
+ }): BlumeIntegrationOptions =>
504
+ options.ejected
505
+ ? {
506
+ buildArtifactsRoot: ".",
507
+ contentRoutes: options.contentRoutes,
508
+ homeLinkHeader:
509
+ buildHomeLinkHeader(options.config, options.contentRoutes) ??
510
+ undefined,
511
+ pages: options.pages,
512
+ }
513
+ : { pages: options.pages };
514
+
461
515
  export const astroConfigTemplate = (options: {
462
516
  context: ProjectContext;
463
517
  config: ResolvedConfig;
@@ -473,6 +527,10 @@ export const astroConfigTemplate = (options: {
473
527
  examplesThemePath: string;
474
528
  themePath: string;
475
529
  searchClientPath: string;
530
+ /** The generated client-feature loaders (`blume:features`). */
531
+ featuresPath: string;
532
+ /** Which client features the site uses; every feature on when omitted. */
533
+ features?: ClientFeatures;
476
534
  /**
477
535
  * Where the runtime data modules (`blume:data`, the search index, …) live as
478
536
  * JSON files, for a project with no CLI to publish them in memory (eject):
@@ -507,6 +565,8 @@ export const astroConfigTemplate = (options: {
507
565
  contentRoutes,
508
566
  examplesPath,
509
567
  examplesThemePath,
568
+ features = ALL_CLIENT_FEATURES,
569
+ featuresPath,
510
570
  generatedModulesDir,
511
571
  needsSvelte,
512
572
  needsVue,
@@ -528,6 +588,7 @@ export const astroConfigTemplate = (options: {
528
588
  const { optimizeDepsEntries, optimizeDepsInclude } = resolveOptimizeDeps({
529
589
  aliases: options.aliases,
530
590
  context,
591
+ features,
531
592
  needsReact,
532
593
  reactCompilerPath: options.reactCompilerPath,
533
594
  });
@@ -567,7 +628,9 @@ export const astroConfigTemplate = (options: {
567
628
  : "";
568
629
  const imageOption = renderImageOption(config);
569
630
 
570
- // Astro's native i18n gives locale-aware helpers + `<html lang>` correctness.
631
+ // Astro's native i18n resolves `Astro.currentLocale` from the URL, which the
632
+ // document shells fall back to for `<html lang>`/`dir` on pages the content
633
+ // catch-all doesn't drive (custom pages, the 404, the reference shell).
571
634
  // Blume owns getStaticPaths and materializes fallback routes in the manifest,
572
635
  // so we deliberately omit Astro's `fallback` to keep one source of routing.
573
636
  const i18nOption = config.i18n
@@ -643,9 +706,7 @@ export const astroConfigTemplate = (options: {
643
706
  font.name
644
707
  )}, cssVariable: ${JSON.stringify(
645
708
  font.cssVariable
646
- )}, weights: ${JSON.stringify(
647
- font.weights
648
- )}, subsets: ${JSON.stringify(
709
+ )}, weights: ${astroFontWeights(font.weights)}, subsets: ${JSON.stringify(
649
710
  font.subsets
650
711
  )}, fallbacks: ${JSON.stringify(font.fallbacks)} }`
651
712
  )
@@ -653,7 +714,6 @@ export const astroConfigTemplate = (options: {
653
714
  : "";
654
715
  const defineConfigImport = astroConfigImportLine({
655
716
  hasFonts: fontEntries.length > 0,
656
- hasSession: sessionOption.length > 0,
657
717
  });
658
718
 
659
719
  // Framework renderers are only wired in when an island (or Ask AI, for React)
@@ -710,12 +770,14 @@ export const astroConfigTemplate = (options: {
710
770
  // up dev-server `Accept: text/markdown` negotiation over the content routes,
711
771
  // plus the homepage agent-discovery `Link` header.
712
772
  integrations.push(
713
- `blumeIntegration(${JSON.stringify({
714
- base: deployment.base,
715
- contentRoutes,
716
- homeLinkHeader: buildHomeLinkHeader(config, contentRoutes) ?? undefined,
717
- pages,
718
- })})`
773
+ `blumeIntegration(${JSON.stringify(
774
+ blumeIntegrationOptions({
775
+ config,
776
+ contentRoutes,
777
+ ejected: generatedModulesDir !== undefined,
778
+ pages,
779
+ })
780
+ )})`
719
781
  );
720
782
 
721
783
  const {
@@ -726,7 +788,8 @@ export const astroConfigTemplate = (options: {
726
788
  } = renderIntegrationBridge(options.integrationBridge);
727
789
 
728
790
  return `// Generated by Blume. Do not edit; this file is recreated on each run.
729
- ${configSourceMarker}${userConfigImports}${defineConfigImport}
791
+ ${configSourceMarker}${userConfigImports}import { availableParallelism } from "node:os";
792
+ ${defineConfigImport}
730
793
  import mdx from "@astrojs/mdx";
731
794
  import tailwindcss from "@tailwindcss/vite";
732
795
  import { blumeMarkdownProcessor, blumeMdxProcessor, blumeShikiTransformers, blumeTwoslashTransformer } from "blume/markdown";
@@ -758,11 +821,25 @@ ${userConfigSetup}export default defineConfig({
758
821
  },
759
822
  },
760
823
  devToolbar: { enabled: false },
824
+ // One canonical URL per page: canonicals, the sitemap, and hreflang all use
825
+ // the slashless form, so the slashed spelling is not a second address. Astro
826
+ // applies this itself — its dev server answers a slashed URL with a 404 that
827
+ // names the setting, an on-demand route redirects — and the Vercel adapter
828
+ // turns it into the platform's 308 route, so the Build Output config needs
829
+ // no hand-spliced redirect (see deploy/vercel-negotiation.ts). Static hosts
830
+ // serve the \`index.html\` directory layout as they always did.
831
+ trailingSlash: "never",
761
832
  // The layouts render Astro's <ClientRouter />, and its in-place swaps read
762
833
  // from the prefetch cache — fetching every link on hover/viewport hides the
763
834
  // request latency behind the user's intent, so most navigations swap
764
835
  // instantly.
765
836
  prefetch: { prefetchAll: true },
837
+ // Prerender several pages at once. Rendering is single-threaded, but a
838
+ // page's OG card renders on a native thread and its HTML is written
839
+ // asynchronously, so the main thread would otherwise idle behind each
840
+ // page's off-thread work. Capped at 8: the gain flattens there and beyond it
841
+ // the overlap only adds memory.
842
+ build: { concurrency: Math.min(8, availableParallelism()) },
766
843
  vite: {${viteCacheOption}
767
844
  plugins: [${runtimeModulesPluginEntry}tailwindcss(), includeHmrPlugin(${JSON.stringify(
768
845
  `${context.outDir}/src/generated/includes.json`
@@ -815,6 +892,7 @@ ${userConfigSetup}export default defineConfig({
815
892
  "blume:ask": ${JSON.stringify(askPath)},
816
893
  "blume:examples": ${JSON.stringify(examplesPath)},
817
894
  "blume:examples-theme": ${JSON.stringify(examplesThemePath)},
895
+ "blume:features": ${JSON.stringify(featuresPath)},
818
896
  "blume:search-client": ${JSON.stringify(searchClientPath)},
819
897
  "blume:theme": ${JSON.stringify(themePath)},${runtimeModuleAliasLines}${userAliasLines}
820
898
  },
@@ -924,6 +1002,7 @@ const staged = defineCollection({
924
1002
  base: ${JSON.stringify(astroGlobBase(stagedBase))},
925
1003
  generateId: ({ entry }) => entry,
926
1004
  }),
1005
+ schema: pageCollectionSchema,
927
1006
  });
928
1007
  `
929
1008
  : "";
@@ -932,16 +1011,22 @@ const staged = defineCollection({
932
1011
  import { defineCollection } from "astro:content";
933
1012
  import { glob } from "astro/loaders";
934
1013
  import { withIncludeRefresh } from "blume/astro";
1014
+ import { pageCollectionSchema } from "blume/core/schema.ts";
935
1015
 
936
1016
  // withIncludeRefresh keeps <include>-bearing pages fresh: plain .md entries
937
1017
  // are rendered at sync time and digest-cached on the page file alone, so a
938
1018
  // partial edit (or a warm-cache rebuild after one) would serve stale HTML.
1019
+ //
1020
+ // pageCollectionSchema is the scan's page front-matter schema, so entry.data
1021
+ // is typed and normalized the same way; it passes custom keys through and
1022
+ // falls back to defaults for a page the scan already dropped as invalid.
939
1023
  const docs = defineCollection({
940
1024
  loader: withIncludeRefresh(glob({
941
1025
  pattern: ${JSON.stringify(docsPattern)},
942
1026
  base: ${JSON.stringify(astroGlobBase(collectionBase))},
943
1027
  generateId: ({ entry }) => entry,
944
1028
  }), ${JSON.stringify(`${context.outDir}/src/generated/includes.json`)}),
1029
+ schema: pageCollectionSchema,
945
1030
  });
946
1031
  ${stagedBlock}
947
1032
  export const collections = { docs${options.staged ? ", staged" : ""} };
@@ -980,26 +1065,38 @@ export const askEndpointTemplate = (
980
1065
  const fallbackPrompt = instructions
981
1066
  ? `${ASK_FALLBACK_PROMPT}\n\n${instructions}`
982
1067
  : ASK_FALLBACK_PROMPT;
1068
+ // Secrets go through Astro's `getSecret` rather than `process.env`, so each
1069
+ // adapter supplies them its own way (Cloudflare from the Worker's bindings,
1070
+ // Node and Vercel from the environment).
983
1071
  const imports = [
984
1072
  'import type { APIRoute } from "astro";',
985
- 'import { streamText } from "ai";',
1073
+ 'import { getSecret } from "astro:env/server";',
1074
+ // The gateway provider reads the key (or Vercel's OIDC token) from the
1075
+ // environment itself; passing the key explicitly lets a binding-backed
1076
+ // secret store reach it too.
1077
+ backend.kind === "gateway"
1078
+ ? 'import { createGateway, streamText } from "ai";'
1079
+ : 'import { streamText } from "ai";',
986
1080
  ];
987
1081
  let setup = "";
988
1082
  let modelExpr = JSON.stringify(backend.model);
989
- if (backend.kind === "openrouter") {
1083
+ if (backend.kind === "gateway") {
1084
+ setup = `\nconst gateway = createGateway({ apiKey: getSecret("AI_GATEWAY_API_KEY") });\n`;
1085
+ modelExpr = `gateway(${JSON.stringify(backend.model)})`;
1086
+ } else if (backend.kind === "openrouter") {
990
1087
  imports.push(
991
1088
  'import { createOpenRouter } from "@openrouter/ai-sdk-provider";'
992
1089
  );
993
- setup = `\nconst openrouter = createOpenRouter({ apiKey: process.env[${JSON.stringify(
1090
+ setup = `\nconst openrouter = createOpenRouter({ apiKey: getSecret(${JSON.stringify(
994
1091
  backend.apiKeyEnv
995
- )}] });\n`;
1092
+ )}) });\n`;
996
1093
  modelExpr = `openrouter(${JSON.stringify(backend.model)})`;
997
1094
  } else if (backend.kind === "openai-compatible") {
998
1095
  imports.push(
999
1096
  'import { createOpenAICompatible } from "@ai-sdk/openai-compatible";'
1000
1097
  );
1001
1098
  setup = `\nconst provider = createOpenAICompatible({
1002
- apiKey: process.env[${JSON.stringify(backend.apiKeyEnv)}],
1099
+ apiKey: getSecret(${JSON.stringify(backend.apiKeyEnv)}),
1003
1100
  baseURL: ${JSON.stringify(backend.baseUrl)},
1004
1101
  name: ${JSON.stringify(backend.name)},
1005
1102
  });\n`;
@@ -1062,13 +1159,13 @@ export const askEndpointTemplate = (
1062
1159
  const keyCheck =
1063
1160
  backend.kind === "gateway"
1064
1161
  ? ` // 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)) {
1162
+ if (!(getSecret("AI_GATEWAY_API_KEY") || getSecret("VERCEL_OIDC_TOKEN"))) {
1066
1163
  return new Response(
1067
1164
  "Ask AI is not configured: set AI_GATEWAY_API_KEY (or deploy on Vercel with OIDC).",
1068
1165
  { status: 500 }
1069
1166
  );
1070
1167
  }`
1071
- : ` if (!process.env[${JSON.stringify(backend.apiKeyEnv)}]) {
1168
+ : ` if (!getSecret(${JSON.stringify(backend.apiKeyEnv)})) {
1072
1169
  return new Response(
1073
1170
  ${JSON.stringify(`Ask AI is not configured: set ${backend.apiKeyEnv}.`)},
1074
1171
  { status: 500 }
@@ -1176,8 +1273,9 @@ const searchClientImport = (module: string): string =>
1176
1273
  `import { createSearch as create } from "blume/components/layout/search/${module}.ts";\n`;
1177
1274
 
1178
1275
  // 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`).
1276
+ // trailing slash (Astro normalizes `base` by `trailingSlash`, which the
1277
+ // generated config pins to "never" but an owned config may set either way —
1278
+ // naive concatenation of a bare `/docs` would yield `/docsblume-search.json`).
1181
1279
  const SEARCH_BASE_IMPORT =
1182
1280
  'import { joinBase } from "blume/components/islands/base-path.ts";\n';
1183
1281
 
@@ -1237,6 +1335,34 @@ const hostedSearchOptions = (
1237
1335
  }
1238
1336
  };
1239
1337
 
1338
+ /**
1339
+ * Generate `.blume/src/generated/features.ts` — the client-feature loaders
1340
+ * behind the `blume:features` alias. Each loader is a dynamic import when the
1341
+ * site uses the feature and `null` otherwise, so an unused library is absent
1342
+ * from the module graph (never bundled, never pre-bundled in dev) rather than
1343
+ * merely lazy. Regenerated on every pass: a page that gains a mermaid fence
1344
+ * turns the loader on.
1345
+ */
1346
+ export const featuresTemplate = (features: ClientFeatures): string =>
1347
+ `// Generated by Blume. Do not edit.
1348
+ //
1349
+ // The client features this site uses. A feature no page needs stays out of
1350
+ // the module graph entirely: its loader is null and its library never enters
1351
+ // the client bundle.
1352
+
1353
+ /** Registers the <blume-mermaid> element; null when no page has a mermaid fence. */
1354
+ export const loadMermaid: (() => Promise<unknown>) | null = ${
1355
+ features.mermaid
1356
+ ? '() => import("blume/components/content/mermaid-element.ts")'
1357
+ : "null"
1358
+ };
1359
+
1360
+ /** The EPUB generator's browser bundle; null when export.epub is off. */
1361
+ export const loadEpub:
1362
+ | (() => Promise<typeof import("epub-gen-memory/bundle")>)
1363
+ | null = ${features.epub ? '() => import("epub-gen-memory/bundle")' : "null"};
1364
+ `;
1365
+
1240
1366
  /**
1241
1367
  * Generate `.blume/src/generated/search-client.ts` — the provider-specific
1242
1368
  * loader the `<Search>` component lazy-imports via the `blume:search-client`
@@ -1291,11 +1417,12 @@ export const createSearch = () => create({ url });
1291
1417
  export const mixedbreadSearchEndpointTemplate = (storeId: string): string =>
1292
1418
  `// Generated by Blume. Do not edit.
1293
1419
  import type { APIRoute } from "astro";
1420
+ import { getSecret } from "astro:env/server";
1294
1421
  import Mixedbread from "@mixedbread/sdk";
1295
1422
 
1296
1423
  export const prerender = false;
1297
1424
 
1298
- const client = new Mixedbread({ apiKey: process.env.MIXEDBREAD_API_KEY ?? "" });
1425
+ const client = new Mixedbread({ apiKey: getSecret("MIXEDBREAD_API_KEY") ?? "" });
1299
1426
  const STORE_ID = ${JSON.stringify(storeId)};
1300
1427
 
1301
1428
  export const POST: APIRoute = async ({ request }) => {
@@ -1557,10 +1684,77 @@ export function GET({ props }: { props: { section: string } }) {
1557
1684
  }
1558
1685
  `;
1559
1686
 
1687
+ /**
1688
+ * Generate the deferred sidebar fragments page
1689
+ * (`.blume/src/pages/blume-nav/[version]/[locale]/[id].astro`): one
1690
+ * prerendered partial per collapsible or drill-in group, holding just that
1691
+ * group's rows, which the sidebar fetches on first open instead of carrying
1692
+ * every collapsed section on every page. Ids are `navGroupIds` over the full
1693
+ * tree, the same ids the pages' sidebars use; the `version`/`locale`
1694
+ * segments pick the navigation variant (`current`/`default` for the
1695
+ * unversioned, unlocalized tree). Rendered with no current route — a
1696
+ * section that isn't on the active path has no active row by definition.
1697
+ */
1698
+ export const navFragmentTemplate = (): string =>
1699
+ `---
1700
+ // Generated by Blume. Do not edit.
1701
+ import NavTree from "blume/components/layout/NavTree.astro";
1702
+ import { withBase } from "blume/components/islands/base-path.ts";
1703
+ import { navGroupIds, navVariants } from "blume/components/layout/nav-utils.ts";
1704
+ import type { NavNode } from "blume/core/types.ts";
1705
+ import data from "blume:data";
1706
+
1707
+ export const prerender = true;
1708
+ // A partial: no doctype, head, or script/style injection — the rows only.
1709
+ export const partial = true;
1710
+
1711
+ // Only imports are in scope here (Astro hoists getStaticPaths), which is why
1712
+ // the variant walk lives in nav-utils.
1713
+ export function getStaticPaths() {
1714
+ return navVariants(data).flatMap(({ locale, navigation, version }) =>
1715
+ [...navGroupIds(navigation.sidebar)]
1716
+ .filter(([node]) => (node.display ?? "flat") !== "flat")
1717
+ .map(([, id]) => ({ params: { id, locale, version } }))
1718
+ );
1719
+ }
1720
+
1721
+ const { id, locale, version } = Astro.params;
1722
+ const variant = navVariants(data).find(
1723
+ (entry) => entry.version === version && entry.locale === locale
1724
+ );
1725
+ const ids = variant
1726
+ ? navGroupIds(variant.navigation.sidebar)
1727
+ : new Map<NavNode, string>();
1728
+ const node = [...ids].find(([, groupId]) => groupId === id)?.[0];
1729
+ if (!(variant && node && node.kind === "group")) {
1730
+ return new Response(null, { status: 404 });
1731
+ }
1732
+ const strings =
1733
+ locale === "default" ? data.ui.nav : (data.uiByLocale[locale] ?? data.ui).nav;
1734
+ const fragmentBase = withBase(\`/blume-nav/\${version}/\${locale}\`);
1735
+ ---
1736
+
1737
+ <NavTree
1738
+ currentRoute=""
1739
+ depth={1}
1740
+ fragmentBase={fragmentBase}
1741
+ idPrefix={id}
1742
+ ids={ids}
1743
+ items={node.children}
1744
+ root={false}
1745
+ strings={strings}
1746
+ />
1747
+ `;
1748
+
1560
1749
  /** Generate the OG image endpoint (`.blume/src/pages/og/[...slug].png.ts`). */
1561
1750
  export const ogEndpointTemplate = (
1562
1751
  customRoutes: OgCustomRoute[] = [],
1563
1752
  og: {
1753
+ /**
1754
+ * The on-disk card cache (directory plus the rendering Blume version).
1755
+ * Absent, every card renders on every build.
1756
+ */
1757
+ cache?: OgCache;
1564
1758
  families?: OgFontFamilies;
1565
1759
  fonts?: OgFont[];
1566
1760
  /**
@@ -1573,8 +1767,8 @@ export const ogEndpointTemplate = (
1573
1767
  includeChangelog = false
1574
1768
  ): string =>
1575
1769
  `// Generated by Blume. Do not edit.
1576
- import { renderOgImage } from "blume/og";
1577
- import type { OgFont, OgFontFamilies } from "blume/og";
1770
+ import { cachedOgImage } from "blume/og";
1771
+ import type { OgCache, OgFont, OgFontFamilies } from "blume/og";
1578
1772
  import data from "blume:data";
1579
1773
 
1580
1774
  export const prerender = true;
@@ -1593,6 +1787,14 @@ const families: OgFontFamilies | undefined = ${
1593
1787
  og.families ? JSON.stringify(og.families) : "undefined"
1594
1788
  };
1595
1789
 
1790
+ // Rendered cards are kept on disk between builds, keyed by everything that
1791
+ // decides their pixels, so a rebuild renders only the cards whose title,
1792
+ // description, branding, or fonts changed. The directory is a build-machine
1793
+ // path, baked in for the same reason as the local font paths above.
1794
+ const cache: OgCache | undefined = ${
1795
+ og.cache ? JSON.stringify(og.cache) : "undefined"
1796
+ };
1797
+
1596
1798
  // A page's own description (its \`seo.description\`, else \`description\`) is
1597
1799
  // the card subtitle, so the image says what the page's og:description says.
1598
1800
  // Pages without one fall back to the site-wide subtitle at render time.
@@ -1652,7 +1854,7 @@ const repoSlug = data.config.github
1652
1854
  : undefined;
1653
1855
 
1654
1856
  export async function GET({ props }: { props: CardProps }) {
1655
- const png = await renderOgImage({
1857
+ const png = await cachedOgImage(cache, {
1656
1858
  accent: data.config.og.palette?.accent ?? data.config.theme.accent.light,
1657
1859
  brand: data.config.title,
1658
1860
  description: props.description ?? data.config.og.description,
@@ -1735,6 +1937,12 @@ export const catchAllPageTemplate = (options: {
1735
1937
  exportEpub: boolean;
1736
1938
  exportPdf: boolean;
1737
1939
  mathEnabled: boolean;
1940
+ /**
1941
+ * Whether the sidebar defers collapsed sections to prerendered fragments
1942
+ * (see `navFragmentTemplate`); true when some group renders as a
1943
+ * disclosure or a drill-in panel.
1944
+ */
1945
+ navFragments?: boolean;
1738
1946
  /** Serialize the island-hooks snapshot; only needed when React is enabled. */
1739
1947
  needsReact: boolean;
1740
1948
  }): string => {
@@ -1883,9 +2091,9 @@ const headings =
1883
2091
  tocHidden.size > 0
1884
2092
  ? allHeadings.filter((heading) => !tocHidden.has(heading.slug))
1885
2093
  : allHeadings;
1886
- const frontmatter = entry.data ?? {};
2094
+ const frontmatter = entry.data;
1887
2095
 
1888
- const seo = frontmatter.seo ?? {};
2096
+ const seo = frontmatter.seo;
1889
2097
  const base = data.config.site ? data.config.site.replace(/\\/$/, "") : null;
1890
2098
 
1891
2099
  // Percent-encode the route-derived path (the sitemap convention): a Unicode
@@ -2113,7 +2321,12 @@ const LayoutComponent = resolveSlot(layoutOverrides.Layout, RootLayout);
2113
2321
  editUrl={editUrl}
2114
2322
  feedback={data.config.feedback}
2115
2323
  exportPdf={${options.exportPdf}}
2116
- exportEpub={${options.exportEpub}}
2324
+ exportEpub={${options.exportEpub}}${
2325
+ options.navFragments
2326
+ ? `
2327
+ navFragmentBase={withBase(\`/blume-nav/\${version || "current"}/\${i18n ? locale : "default"}\`)}`
2328
+ : ""
2329
+ }
2117
2330
  openInChat={data.config.openInChat}
2118
2331
  feeds={data.feeds}
2119
2332
  discovery={data.config.discovery}
@@ -3037,71 +3250,6 @@ const Example = entry.Component;
3037
3250
  </html>
3038
3251
  `;
3039
3252
 
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
3253
  /** Generate `.blume/package.json`. */
3106
3254
  export const runtimePackageTemplate = (dependencies: string[] = []): string =>
3107
3255
  `${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;