blume 1.6.6 → 1.7.1

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 (99) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/dist/cli/{chunk-etsqspj6.js → chunk-12dzsn9b.js} +140 -23
  3. package/dist/cli/{chunk-etsqspj6.js.map → chunk-12dzsn9b.js.map} +6 -5
  4. package/dist/cli/{chunk-aerwpe14.js → chunk-4ae4f395.js} +164 -51
  5. package/dist/cli/chunk-4ae4f395.js.map +15 -0
  6. package/dist/cli/{chunk-m3p3wahd.js → chunk-52cwcqvp.js} +4 -4
  7. package/dist/cli/{chunk-j00ezcg5.js → chunk-5gfw0q4j.js} +9 -9
  8. package/dist/cli/{chunk-bawgnt8x.js → chunk-6mq7qkve.js} +3 -3
  9. package/dist/cli/{chunk-nyqzjdhj.js → chunk-82atea4k.js} +5 -5
  10. package/dist/cli/{chunk-tc89yh2r.js → chunk-8p3xe5jv.js} +2 -2
  11. package/dist/cli/{chunk-s4k1pnvf.js → chunk-90pdhkpm.js} +11 -11
  12. package/dist/cli/{chunk-n0y172hf.js → chunk-aqjvpd03.js} +4 -4
  13. package/dist/cli/{chunk-x1vrdjyk.js → chunk-h9ekmtz7.js} +5 -5
  14. package/dist/cli/{chunk-f75cqye8.js → chunk-he2zfgah.js} +10 -10
  15. package/dist/cli/{chunk-s4jn7f1q.js → chunk-j5f2wrj5.js} +2 -2
  16. package/dist/cli/{chunk-wkq5tbtq.js → chunk-k0v1f8bb.js} +3 -3
  17. package/dist/cli/{chunk-n4qjabmt.js → chunk-ka5k7cz9.js} +6 -19
  18. package/dist/cli/{chunk-n4qjabmt.js.map → chunk-ka5k7cz9.js.map} +3 -4
  19. package/dist/cli/{chunk-5yvt556e.js → chunk-kmx2mydj.js} +2 -2
  20. package/dist/cli/{chunk-ag1zyr5x.js → chunk-mfm4sjwx.js} +11 -11
  21. package/dist/cli/{chunk-0ewz4trd.js → chunk-np8dmfb0.js} +6 -6
  22. package/dist/cli/{chunk-cnvm6k3e.js → chunk-pdwg3q9g.js} +11 -11
  23. package/dist/cli/{chunk-vv237fp3.js → chunk-q56730e0.js} +26 -12
  24. package/dist/cli/{chunk-vv237fp3.js.map → chunk-q56730e0.js.map} +3 -3
  25. package/dist/cli/{chunk-3k0kzs6d.js → chunk-qvvpnwaz.js} +2 -2
  26. package/dist/cli/{chunk-vv3f8mb6.js → chunk-r99hynxh.js} +25 -24
  27. package/dist/cli/{chunk-vv3f8mb6.js.map → chunk-r99hynxh.js.map} +4 -4
  28. package/dist/cli/{chunk-wb067mv3.js → chunk-vyqj481z.js} +19 -7
  29. package/dist/cli/chunk-vyqj481z.js.map +13 -0
  30. package/dist/cli/{chunk-62qsssnh.js → chunk-x1wvw7a8.js} +411 -145
  31. package/dist/cli/chunk-x1wvw7a8.js.map +40 -0
  32. package/dist/cli/{chunk-9sh49q0h.js → chunk-ywn7t0pb.js} +2 -2
  33. package/dist/cli/index.js +13 -13
  34. package/dist/types/components/layout/nav-utils.d.ts +46 -1
  35. package/dist/types/core/config-input.d.ts +7 -0
  36. package/dist/types/core/schema.d.ts +2 -0
  37. package/dist/types/theme/fonts.d.ts +22 -22
  38. package/docs/02-deployment.mdx +21 -0
  39. package/docs/08-faq.mdx +21 -0
  40. package/docs/configuration/ask-ai.mdx +16 -0
  41. package/docs/content/navigation.mdx +2 -0
  42. package/docs/content/sources.mdx +2 -2
  43. package/docs/content/syntax.mdx +1 -1
  44. package/docs/discoverability/open-graph.mdx +4 -0
  45. package/package.json +1 -1
  46. package/src/ai/ask.ts +31 -4
  47. package/src/astro/generate.ts +142 -5
  48. package/src/astro/integration.ts +12 -1
  49. package/src/astro/module-types.ts +9 -0
  50. package/src/astro/templates.ts +260 -56
  51. package/src/cli/commands/build.ts +28 -0
  52. package/src/components/Icon.astro +24 -0
  53. package/src/components/content/YouTube.astro +1 -1
  54. package/src/components/icon-sprite-middleware.ts +41 -0
  55. package/src/components/icon-sprite.ts +93 -0
  56. package/src/components/layout/Header.astro +1 -1
  57. package/src/components/layout/IconSprite.astro +11 -0
  58. package/src/components/layout/NavTree.astro +156 -188
  59. package/src/components/layout/NavTreeCache.astro +45 -0
  60. package/src/components/layout/NavTreeScript.astro +256 -0
  61. package/src/components/layout/PageActions.astro +11 -5
  62. package/src/components/layout/PageLayout.astro +7 -0
  63. package/src/components/layout/ReferenceLayout.astro +7 -0
  64. package/src/components/layout/RootLayout.astro +30 -2
  65. package/src/components/layout/Search.astro +11 -0
  66. package/src/components/layout/nav-cache.ts +49 -0
  67. package/src/components/layout/nav-utils.ts +87 -1
  68. package/src/core/config-input.ts +7 -0
  69. package/src/core/schema.ts +5 -0
  70. package/src/core/sources/assets.ts +162 -26
  71. package/src/core/sources/notion.ts +60 -1
  72. package/src/markdown/language-icon.ts +64 -20
  73. package/src/markdown/mermaid.ts +11 -0
  74. package/src/og/cache.ts +236 -0
  75. package/src/og/card.ts +12 -4
  76. package/src/og/index.ts +8 -1
  77. package/src/registry/eject.ts +24 -8
  78. package/src/theme/entry.ts +50 -7
  79. package/src/theme/fonts.ts +30 -23
  80. package/dist/cli/chunk-62qsssnh.js.map +0 -36
  81. package/dist/cli/chunk-aerwpe14.js.map +0 -15
  82. package/dist/cli/chunk-wb067mv3.js.map +0 -13
  83. /package/dist/cli/{chunk-m3p3wahd.js.map → chunk-52cwcqvp.js.map} +0 -0
  84. /package/dist/cli/{chunk-j00ezcg5.js.map → chunk-5gfw0q4j.js.map} +0 -0
  85. /package/dist/cli/{chunk-bawgnt8x.js.map → chunk-6mq7qkve.js.map} +0 -0
  86. /package/dist/cli/{chunk-nyqzjdhj.js.map → chunk-82atea4k.js.map} +0 -0
  87. /package/dist/cli/{chunk-tc89yh2r.js.map → chunk-8p3xe5jv.js.map} +0 -0
  88. /package/dist/cli/{chunk-s4k1pnvf.js.map → chunk-90pdhkpm.js.map} +0 -0
  89. /package/dist/cli/{chunk-n0y172hf.js.map → chunk-aqjvpd03.js.map} +0 -0
  90. /package/dist/cli/{chunk-x1vrdjyk.js.map → chunk-h9ekmtz7.js.map} +0 -0
  91. /package/dist/cli/{chunk-f75cqye8.js.map → chunk-he2zfgah.js.map} +0 -0
  92. /package/dist/cli/{chunk-s4jn7f1q.js.map → chunk-j5f2wrj5.js.map} +0 -0
  93. /package/dist/cli/{chunk-wkq5tbtq.js.map → chunk-k0v1f8bb.js.map} +0 -0
  94. /package/dist/cli/{chunk-5yvt556e.js.map → chunk-kmx2mydj.js.map} +0 -0
  95. /package/dist/cli/{chunk-ag1zyr5x.js.map → chunk-mfm4sjwx.js.map} +0 -0
  96. /package/dist/cli/{chunk-0ewz4trd.js.map → chunk-np8dmfb0.js.map} +0 -0
  97. /package/dist/cli/{chunk-cnvm6k3e.js.map → chunk-pdwg3q9g.js.map} +0 -0
  98. /package/dist/cli/{chunk-3k0kzs6d.js.map → chunk-qvvpnwaz.js.map} +0 -0
  99. /package/dist/cli/{chunk-9sh49q0h.js.map → chunk-ywn7t0pb.js.map} +0 -0
@@ -14,6 +14,7 @@ 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";
@@ -137,6 +138,18 @@ const resolveSessionOption = (deployment: {
137
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
154
  const astroConfigImportLine = (options: { hasFonts: boolean }): string => {
142
155
  const names = ["defineConfig"];
@@ -389,13 +402,31 @@ interface OptimizeDepsConfig {
389
402
  * absolute path (see `reactIntegration`), which that check never matches. See
390
403
  * the optimizeDeps comment in the generated config for the failure this prevents.
391
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
+
392
422
  const resolveOptimizeDeps = (options: {
393
423
  aliases: Record<string, string> | undefined;
394
424
  context: ProjectContext;
425
+ features: ClientFeatures;
395
426
  needsReact: boolean;
396
427
  reactCompilerPath: string | null | undefined;
397
428
  }): OptimizeDepsConfig => {
398
- const { context } = options;
429
+ const { context, features } = options;
399
430
  const optimizeDepsEntries = [
400
431
  ...(context.pagesRoot ? [`${context.pagesRoot}/**/*.astro`] : []),
401
432
  `${context.root}/islands/**/*.{jsx,svelte,tsx,vue}`,
@@ -404,8 +435,10 @@ const resolveOptimizeDeps = (options: {
404
435
  .map((dir) => `${dir}/**/*.{astro,jsx,svelte,tsx,vue}`),
405
436
  ];
406
437
  const optimizeDepsInclude = [
407
- "blume > mermaid",
408
- "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"] : []),
409
442
  // Astro's own client-router/prefetch virtual modules are deliberately NOT
410
443
  // forced in here: they read Vite `define`-injected constants
411
444
  // (__PREFETCH_PREFETCH_ALL__ and friends) that a pre-bundled copy loses,
@@ -494,6 +527,10 @@ export const astroConfigTemplate = (options: {
494
527
  examplesThemePath: string;
495
528
  themePath: string;
496
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;
497
534
  /**
498
535
  * Where the runtime data modules (`blume:data`, the search index, …) live as
499
536
  * JSON files, for a project with no CLI to publish them in memory (eject):
@@ -528,6 +565,8 @@ export const astroConfigTemplate = (options: {
528
565
  contentRoutes,
529
566
  examplesPath,
530
567
  examplesThemePath,
568
+ features = ALL_CLIENT_FEATURES,
569
+ featuresPath,
531
570
  generatedModulesDir,
532
571
  needsSvelte,
533
572
  needsVue,
@@ -549,6 +588,7 @@ export const astroConfigTemplate = (options: {
549
588
  const { optimizeDepsEntries, optimizeDepsInclude } = resolveOptimizeDeps({
550
589
  aliases: options.aliases,
551
590
  context,
591
+ features,
552
592
  needsReact,
553
593
  reactCompilerPath: options.reactCompilerPath,
554
594
  });
@@ -666,9 +706,7 @@ export const astroConfigTemplate = (options: {
666
706
  font.name
667
707
  )}, cssVariable: ${JSON.stringify(
668
708
  font.cssVariable
669
- )}, weights: ${JSON.stringify(
670
- font.weights
671
- )}, subsets: ${JSON.stringify(
709
+ )}, weights: ${astroFontWeights(font.weights)}, subsets: ${JSON.stringify(
672
710
  font.subsets
673
711
  )}, fallbacks: ${JSON.stringify(font.fallbacks)} }`
674
712
  )
@@ -750,7 +788,8 @@ export const astroConfigTemplate = (options: {
750
788
  } = renderIntegrationBridge(options.integrationBridge);
751
789
 
752
790
  return `// Generated by Blume. Do not edit; this file is recreated on each run.
753
- ${configSourceMarker}${userConfigImports}${defineConfigImport}
791
+ ${configSourceMarker}${userConfigImports}import { availableParallelism } from "node:os";
792
+ ${defineConfigImport}
754
793
  import mdx from "@astrojs/mdx";
755
794
  import tailwindcss from "@tailwindcss/vite";
756
795
  import { blumeMarkdownProcessor, blumeMdxProcessor, blumeShikiTransformers, blumeTwoslashTransformer } from "blume/markdown";
@@ -795,6 +834,12 @@ ${userConfigSetup}export default defineConfig({
795
834
  // request latency behind the user's intent, so most navigations swap
796
835
  // instantly.
797
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()) },
798
843
  vite: {${viteCacheOption}
799
844
  plugins: [${runtimeModulesPluginEntry}tailwindcss(), includeHmrPlugin(${JSON.stringify(
800
845
  `${context.outDir}/src/generated/includes.json`
@@ -847,6 +892,7 @@ ${userConfigSetup}export default defineConfig({
847
892
  "blume:ask": ${JSON.stringify(askPath)},
848
893
  "blume:examples": ${JSON.stringify(examplesPath)},
849
894
  "blume:examples-theme": ${JSON.stringify(examplesThemePath)},
895
+ "blume:features": ${JSON.stringify(featuresPath)},
850
896
  "blume:search-client": ${JSON.stringify(searchClientPath)},
851
897
  "blume:theme": ${JSON.stringify(themePath)},${runtimeModuleAliasLines}${userAliasLines}
852
898
  },
@@ -1034,16 +1080,23 @@ export const askEndpointTemplate = (
1034
1080
  ];
1035
1081
  let setup = "";
1036
1082
  let modelExpr = JSON.stringify(backend.model);
1083
+ // `ai.ask.headers`, inlined as literals: every provider factory below takes
1084
+ // the same `headers` option, so one line serves all three.
1085
+ const headersField = backend.headers
1086
+ ? `\n headers: ${JSON.stringify(backend.headers)},`
1087
+ : "";
1037
1088
  if (backend.kind === "gateway") {
1038
- setup = `\nconst gateway = createGateway({ apiKey: getSecret("AI_GATEWAY_API_KEY") });\n`;
1089
+ setup = `\nconst gateway = createGateway({
1090
+ apiKey: getSecret("AI_GATEWAY_API_KEY"),${headersField}
1091
+ });\n`;
1039
1092
  modelExpr = `gateway(${JSON.stringify(backend.model)})`;
1040
1093
  } else if (backend.kind === "openrouter") {
1041
1094
  imports.push(
1042
1095
  'import { createOpenRouter } from "@openrouter/ai-sdk-provider";'
1043
1096
  );
1044
- setup = `\nconst openrouter = createOpenRouter({ apiKey: getSecret(${JSON.stringify(
1045
- backend.apiKeyEnv
1046
- )}) });\n`;
1097
+ setup = `\nconst openrouter = createOpenRouter({
1098
+ apiKey: getSecret(${JSON.stringify(backend.apiKeyEnv)}),${headersField}
1099
+ });\n`;
1047
1100
  modelExpr = `openrouter(${JSON.stringify(backend.model)})`;
1048
1101
  } else if (backend.kind === "openai-compatible") {
1049
1102
  imports.push(
@@ -1051,7 +1104,7 @@ export const askEndpointTemplate = (
1051
1104
  );
1052
1105
  setup = `\nconst provider = createOpenAICompatible({
1053
1106
  apiKey: getSecret(${JSON.stringify(backend.apiKeyEnv)}),
1054
- baseURL: ${JSON.stringify(backend.baseUrl)},
1107
+ baseURL: ${JSON.stringify(backend.baseUrl)},${headersField}
1055
1108
  name: ${JSON.stringify(backend.name)},
1056
1109
  });\n`;
1057
1110
  modelExpr = `provider(${JSON.stringify(backend.model)})`;
@@ -1289,6 +1342,34 @@ const hostedSearchOptions = (
1289
1342
  }
1290
1343
  };
1291
1344
 
1345
+ /**
1346
+ * Generate `.blume/src/generated/features.ts` — the client-feature loaders
1347
+ * behind the `blume:features` alias. Each loader is a dynamic import when the
1348
+ * site uses the feature and `null` otherwise, so an unused library is absent
1349
+ * from the module graph (never bundled, never pre-bundled in dev) rather than
1350
+ * merely lazy. Regenerated on every pass: a page that gains a mermaid fence
1351
+ * turns the loader on.
1352
+ */
1353
+ export const featuresTemplate = (features: ClientFeatures): string =>
1354
+ `// Generated by Blume. Do not edit.
1355
+ //
1356
+ // The client features this site uses. A feature no page needs stays out of
1357
+ // the module graph entirely: its loader is null and its library never enters
1358
+ // the client bundle.
1359
+
1360
+ /** Registers the <blume-mermaid> element; null when no page has a mermaid fence. */
1361
+ export const loadMermaid: (() => Promise<unknown>) | null = ${
1362
+ features.mermaid
1363
+ ? '() => import("blume/components/content/mermaid-element.ts")'
1364
+ : "null"
1365
+ };
1366
+
1367
+ /** The EPUB generator's browser bundle; null when export.epub is off. */
1368
+ export const loadEpub:
1369
+ | (() => Promise<typeof import("epub-gen-memory/bundle")>)
1370
+ | null = ${features.epub ? '() => import("epub-gen-memory/bundle")' : "null"};
1371
+ `;
1372
+
1292
1373
  /**
1293
1374
  * Generate `.blume/src/generated/search-client.ts` — the provider-specific
1294
1375
  * loader the `<Search>` component lazy-imports via the `blume:search-client`
@@ -1610,10 +1691,85 @@ export function GET({ props }: { props: { section: string } }) {
1610
1691
  }
1611
1692
  `;
1612
1693
 
1694
+ /**
1695
+ * Generate the deferred sidebar fragments page
1696
+ * (`.blume/src/pages/blume-nav/[version]/[locale]/[id].astro`): one
1697
+ * prerendered partial per collapsible or drill-in group, holding just that
1698
+ * group's rows, which the sidebar fetches on first open instead of carrying
1699
+ * every collapsed section on every page. Ids are `navGroupIds` over the full
1700
+ * tree, the same ids the pages' sidebars use; the `version`/`locale`
1701
+ * segments pick the navigation variant (`current`/`default` for the
1702
+ * unversioned, unlocalized tree, and `default` again for the default locale
1703
+ * while its URL prefix is hidden: Astro's i18n routing 404s a page URL that
1704
+ * carries the default locale's code as a segment). Rendered with no current
1705
+ * route — a section that isn't on the active path has no active row by
1706
+ * definition.
1707
+ */
1708
+ export const navFragmentTemplate = (): string =>
1709
+ `---
1710
+ // Generated by Blume. Do not edit.
1711
+ import NavTree from "blume/components/layout/NavTree.astro";
1712
+ import { withBase } from "blume/components/islands/base-path.ts";
1713
+ import {
1714
+ hiddenDefaultLocale,
1715
+ navGroupIds,
1716
+ navVariants,
1717
+ } from "blume/components/layout/nav-utils.ts";
1718
+ import type { NavNode } from "blume/core/types.ts";
1719
+ import data from "blume:data";
1720
+
1721
+ export const prerender = true;
1722
+ // A partial: no doctype, head, or script/style injection — the rows only.
1723
+ export const partial = true;
1724
+
1725
+ // Only imports are in scope here (Astro hoists getStaticPaths), which is why
1726
+ // the variant walk lives in nav-utils.
1727
+ export function getStaticPaths() {
1728
+ return navVariants(data, hiddenDefaultLocale(data.config.i18n)).flatMap(
1729
+ ({ locale, navigation, version }) =>
1730
+ [...navGroupIds(navigation.sidebar)]
1731
+ .filter(([node]) => (node.display ?? "flat") !== "flat")
1732
+ .map(([, id]) => ({ params: { id, locale, version } }))
1733
+ );
1734
+ }
1735
+
1736
+ const { id, locale, version } = Astro.params;
1737
+ const variant = navVariants(data, hiddenDefaultLocale(data.config.i18n)).find(
1738
+ (entry) => entry.version === version && entry.locale === locale
1739
+ );
1740
+ const ids = variant
1741
+ ? navGroupIds(variant.navigation.sidebar)
1742
+ : new Map<NavNode, string>();
1743
+ const node = [...ids].find(([, groupId]) => groupId === id)?.[0];
1744
+ if (!(variant && node && node.kind === "group")) {
1745
+ return new Response(null, { status: 404 });
1746
+ }
1747
+ const strings =
1748
+ locale === "default" ? data.ui.nav : (data.uiByLocale[locale] ?? data.ui).nav;
1749
+ const fragmentBase = withBase(\`/blume-nav/\${version}/\${locale}\`);
1750
+ ---
1751
+
1752
+ <NavTree
1753
+ currentRoute=""
1754
+ depth={1}
1755
+ fragmentBase={fragmentBase}
1756
+ idPrefix={id}
1757
+ ids={ids}
1758
+ items={node.children}
1759
+ root={false}
1760
+ strings={strings}
1761
+ />
1762
+ `;
1763
+
1613
1764
  /** Generate the OG image endpoint (`.blume/src/pages/og/[...slug].png.ts`). */
1614
1765
  export const ogEndpointTemplate = (
1615
1766
  customRoutes: OgCustomRoute[] = [],
1616
1767
  og: {
1768
+ /**
1769
+ * The on-disk card cache (directory plus the rendering Blume version).
1770
+ * Absent, every card renders on every build.
1771
+ */
1772
+ cache?: OgCache;
1617
1773
  families?: OgFontFamilies;
1618
1774
  fonts?: OgFont[];
1619
1775
  /**
@@ -1626,8 +1782,8 @@ export const ogEndpointTemplate = (
1626
1782
  includeChangelog = false
1627
1783
  ): string =>
1628
1784
  `// Generated by Blume. Do not edit.
1629
- import { renderOgImage } from "blume/og";
1630
- import type { OgFont, OgFontFamilies } from "blume/og";
1785
+ import { cachedOgImage } from "blume/og";
1786
+ import type { OgCache, OgFont, OgFontFamilies } from "blume/og";
1631
1787
  import data from "blume:data";
1632
1788
 
1633
1789
  export const prerender = true;
@@ -1646,6 +1802,14 @@ const families: OgFontFamilies | undefined = ${
1646
1802
  og.families ? JSON.stringify(og.families) : "undefined"
1647
1803
  };
1648
1804
 
1805
+ // Rendered cards are kept on disk between builds, keyed by everything that
1806
+ // decides their pixels, so a rebuild renders only the cards whose title,
1807
+ // description, branding, or fonts changed. The directory is a build-machine
1808
+ // path, baked in for the same reason as the local font paths above.
1809
+ const cache: OgCache | undefined = ${
1810
+ og.cache ? JSON.stringify(og.cache) : "undefined"
1811
+ };
1812
+
1649
1813
  // A page's own description (its \`seo.description\`, else \`description\`) is
1650
1814
  // the card subtitle, so the image says what the page's og:description says.
1651
1815
  // Pages without one fall back to the site-wide subtitle at render time.
@@ -1705,7 +1869,7 @@ const repoSlug = data.config.github
1705
1869
  : undefined;
1706
1870
 
1707
1871
  export async function GET({ props }: { props: CardProps }) {
1708
- const png = await renderOgImage({
1872
+ const png = await cachedOgImage(cache, {
1709
1873
  accent: data.config.og.palette?.accent ?? data.config.theme.accent.light,
1710
1874
  brand: data.config.title,
1711
1875
  description: props.description ?? data.config.og.description,
@@ -1784,31 +1948,22 @@ const htmlLang = i18n ? i18n.defaultLocale : "en";
1784
1948
  </ReferenceLayout>
1785
1949
  `;
1786
1950
 
1787
- export const catchAllPageTemplate = (options: {
1788
- exportEpub: boolean;
1789
- exportPdf: boolean;
1790
- mathEnabled: boolean;
1791
- /** Serialize the island-hooks snapshot; only needed when React is enabled. */
1792
- needsReact: boolean;
1793
- }): string => {
1794
- const mathImport = options.mathEnabled
1951
+ /**
1952
+ * The component map every rendered entry body receives via
1953
+ * `<Content components={...} />`: Blume's content components, `<Math>` when
1954
+ * math is enabled, the island wrappers, and the user's `mdxComponents`
1955
+ * overrides. Shared by the catch-all page and the changelog index so a
1956
+ * `:::` directive (compiled to `<Callout>`) or an explicit `<Steps>` renders
1957
+ * wherever an entry is rendered — an MDX body throws "Expected component
1958
+ * `X` to be defined" the moment a page renders it without this map.
1959
+ */
1960
+ const contentComponentsSource = (mathEnabled: boolean) => {
1961
+ const mathImport = mathEnabled
1795
1962
  ? 'import Math from "blume/components/content/Math.astro";\n'
1796
1963
  : "";
1797
- const mathEntry = options.mathEnabled ? "Math,\n " : "";
1798
- // The island-hooks snapshot (config + navigation + page) for `blume/hooks`.
1799
- const clientData = options.needsReact
1800
- ? "\n clientData={{ config: data.config, navigation, page: { route, title: seo.title ?? title } }}"
1801
- : "";
1802
-
1803
- return `---
1804
- // Generated by Blume. Do not edit.
1805
- import { getEntry, render } from "astro:content";
1806
- import type { CollectionKey } from "astro:content";
1807
- import RootLayout from "blume/components/layout/RootLayout.astro";
1808
- import { withBase } from "blume/components/islands/base-path.ts";
1809
- import { stripBasePath, withBasePath } from "blume/core/base-path.ts";
1810
- import { resolveSlot } from "blume/components/layout/overrides.ts";
1811
- import Accordion from "blume/components/content/Accordion.astro";
1964
+ const mathEntry = mathEnabled ? "Math,\n " : "";
1965
+ return {
1966
+ imports: `import Accordion from "blume/components/content/Accordion.astro";
1812
1967
  import AccordionItem from "blume/components/content/AccordionItem.astro";
1813
1968
  import AutoTypeTable from "blume/components/content/AutoTypeTable.astro";
1814
1969
  import Badge from "blume/components/content/Badge.astro";
@@ -1848,19 +2003,8 @@ import ApiOverview from "blume/components/openapi/ApiOverview.astro";
1848
2003
  import ApiTagOperations from "blume/components/openapi/ApiTagOperations.astro";
1849
2004
  import Operation from "blume/components/openapi/Operation.astro";
1850
2005
  ${mathImport}import { mdxComponents as userMdx, layoutOverrides } from "../generated/components.ts";
1851
- import { islandComponents } from "../generated/islands.ts";
1852
- import data from "blume:data";
1853
-
1854
- const Color = Object.assign(ColorRoot, { Item: ColorItem, Row: ColorRow });
1855
- const Tree = Object.assign(TreeRoot, { File: TreeFile, Folder: TreeFolder });
1856
-
1857
- // Docs content is file-based and always prerendered, even in server output
1858
- // (where only endpoints like /api/ask render on demand). Without this, server
1859
- // builds would render this route on demand and ignore getStaticPaths, leaving
1860
- // the entry id undefined.
1861
- export const prerender = true;
1862
-
1863
- const components = {
2006
+ import { islandComponents } from "../generated/islands.ts";`,
2007
+ map: `{
1864
2008
  Accordion,
1865
2009
  AccordionItem,
1866
2010
  ApiOverview,
@@ -1897,8 +2041,52 @@ const components = {
1897
2041
  YouTube,
1898
2042
  ${mathEntry}...islandComponents,
1899
2043
  ...userMdx,
2044
+ }`,
2045
+ };
1900
2046
  };
1901
2047
 
2048
+ export const catchAllPageTemplate = (options: {
2049
+ exportEpub: boolean;
2050
+ exportPdf: boolean;
2051
+ mathEnabled: boolean;
2052
+ /**
2053
+ * Whether the sidebar defers collapsed sections to prerendered fragments
2054
+ * (see `navFragmentTemplate`); true when some group renders as a
2055
+ * disclosure or a drill-in panel.
2056
+ */
2057
+ navFragments?: boolean;
2058
+ /** Serialize the island-hooks snapshot; only needed when React is enabled. */
2059
+ needsReact: boolean;
2060
+ }): string => {
2061
+ const { imports: componentImports, map: componentMap } =
2062
+ contentComponentsSource(options.mathEnabled);
2063
+ // The island-hooks snapshot (config + navigation + page) for `blume/hooks`.
2064
+ const clientData = options.needsReact
2065
+ ? "\n clientData={{ config: data.config, navigation, page: { route, title: seo.title ?? title } }}"
2066
+ : "";
2067
+
2068
+ return `---
2069
+ // Generated by Blume. Do not edit.
2070
+ import { getEntry, render } from "astro:content";
2071
+ import type { CollectionKey } from "astro:content";
2072
+ import RootLayout from "blume/components/layout/RootLayout.astro";
2073
+ import { withBase } from "blume/components/islands/base-path.ts";
2074
+ import { stripBasePath, withBasePath } from "blume/core/base-path.ts";
2075
+ import { resolveSlot } from "blume/components/layout/overrides.ts";
2076
+ ${componentImports}
2077
+ import data from "blume:data";
2078
+
2079
+ const Color = Object.assign(ColorRoot, { Item: ColorItem, Row: ColorRow });
2080
+ const Tree = Object.assign(TreeRoot, { File: TreeFile, Folder: TreeFolder });
2081
+
2082
+ // Docs content is file-based and always prerendered, even in server output
2083
+ // (where only endpoints like /api/ask render on demand). Without this, server
2084
+ // builds would render this route on demand and ignore getStaticPaths, leaving
2085
+ // the entry id undefined.
2086
+ export const prerender = true;
2087
+
2088
+ const components = ${componentMap};
2089
+
1902
2090
  export function getStaticPaths() {
1903
2091
  return data.routes.map((route) => ({
1904
2092
  params: { slug: route.path === "/" ? undefined : route.path.slice(1) },
@@ -2166,7 +2354,12 @@ const LayoutComponent = resolveSlot(layoutOverrides.Layout, RootLayout);
2166
2354
  editUrl={editUrl}
2167
2355
  feedback={data.config.feedback}
2168
2356
  exportPdf={${options.exportPdf}}
2169
- exportEpub={${options.exportEpub}}
2357
+ exportEpub={${options.exportEpub}}${
2358
+ options.navFragments
2359
+ ? `
2360
+ navFragmentBase={withBase(\`/blume-nav/\${version || "current"}/\${i18n && localePrefix(locale) ? locale : "default"}\`)}`
2361
+ : ""
2362
+ }
2170
2363
  openInChat={data.config.openInChat}
2171
2364
  feeds={data.feeds}
2172
2365
  discovery={data.config.discovery}
@@ -2195,6 +2388,7 @@ const LayoutComponent = resolveSlot(layoutOverrides.Layout, RootLayout);
2195
2388
  export const changelogIndexTemplate = (options: {
2196
2389
  exportEpub: boolean;
2197
2390
  exportPdf: boolean;
2391
+ mathEnabled: boolean;
2198
2392
  /** Serialize the island-hooks snapshot; only needed when React is enabled. */
2199
2393
  needsReact: boolean;
2200
2394
  /** Whether a `staged` collection exists (non-filesystem changelog sources). */
@@ -2208,6 +2402,8 @@ export const changelogIndexTemplate = (options: {
2208
2402
  const stagedSpread = options.staged
2209
2403
  ? '\n ...(await getCollection("staged")),'
2210
2404
  : "";
2405
+ const { imports: componentImports, map: componentMap } =
2406
+ contentComponentsSource(options.mathEnabled);
2211
2407
 
2212
2408
  return `---
2213
2409
  // Generated by Blume. Do not edit.
@@ -2217,11 +2413,19 @@ import Update from "blume/components/content/Update.astro";
2217
2413
  import { withBase } from "blume/components/islands/base-path.ts";
2218
2414
  import { resolveSlot } from "blume/components/layout/overrides.ts";
2219
2415
  import { resolveDateFormatOptions } from "blume/core/date-format.ts";
2220
- import { layoutOverrides } from "../generated/components.ts";
2416
+ ${componentImports}
2221
2417
  import data from "blume:data";
2222
2418
 
2419
+ const Color = Object.assign(ColorRoot, { Item: ColorItem, Row: ColorRow });
2420
+ const Tree = Object.assign(TreeRoot, { File: TreeFile, Folder: TreeFolder });
2421
+
2223
2422
  export const prerender = true;
2224
2423
 
2424
+ // Entry bodies are MDX rendered outside the catch-all, so they need the same
2425
+ // component map: a \`:::\` callout or \`<Steps>\` in a release note otherwise
2426
+ // throws "Expected component ... to be defined" at build time.
2427
+ const components = ${componentMap};
2428
+
2225
2429
  const entryDate = (entry: {
2226
2430
  data: { date?: string | null; changelog?: { date?: string | null } | null };
2227
2431
  }) => entry.data.date ?? entry.data.changelog?.date ?? null;
@@ -2420,7 +2624,7 @@ const LayoutComponent = resolveSlot(layoutOverrides.Layout, RootLayout);
2420
2624
  >
2421
2625
  {majorGroups[0].items.map(({ Content, href, id, label, date, tags }) => (
2422
2626
  <Update description={date} href={href} id={id} label={label} tags={tags}>
2423
- <Content />
2627
+ <Content components={components} />
2424
2628
  </Update>
2425
2629
  ))}
2426
2630
  {majorGroups.slice(1).map((group) => (
@@ -2431,7 +2635,7 @@ const LayoutComponent = resolveSlot(layoutOverrides.Layout, RootLayout);
2431
2635
  >
2432
2636
  {group.items.map(({ Content, href, id, label, date, tags }) => (
2433
2637
  <Update description={date} href={href} id={id} label={label} tags={tags}>
2434
- <Content />
2638
+ <Content components={components} />
2435
2639
  </Update>
2436
2640
  ))}
2437
2641
  </section>
@@ -2451,7 +2655,7 @@ const LayoutComponent = resolveSlot(layoutOverrides.Layout, RootLayout);
2451
2655
  <div class="not-prose mt-8">
2452
2656
  {items.map(({ Content, href, id, label, date, tags }) => (
2453
2657
  <Update description={date} href={href} id={id} label={label} tags={tags}>
2454
- <Content />
2658
+ <Content components={components} />
2455
2659
  </Update>
2456
2660
  ))}
2457
2661
  </div>
@@ -45,6 +45,7 @@ import {
45
45
  } from "../../deploy/function-bundle.ts";
46
46
  import { platformRedirects } from "../../deploy/redirects.ts";
47
47
  import { injectNegotiationRoutes } from "../../deploy/vercel-negotiation.ts";
48
+ import { cardCacheTally, ogCacheDir, pruneCardCache } from "../../og/cache.ts";
48
49
  import { commandMeta } from "../command-meta.ts";
49
50
  import { refuseIfDevRunning } from "../dev-lock.ts";
50
51
  import { logger } from "../log.ts";
@@ -248,6 +249,29 @@ const emitCloudflareNegotiation = async (
248
249
  );
249
250
  };
250
251
 
252
+ /**
253
+ * Report how many OG cards the build read back from the on-disk cache against
254
+ * how many it rendered (the endpoint tallies both), so a warm rebuild shows
255
+ * where the time went. With `prune`, also drop the cached cards this build
256
+ * never asked for — renamed pages, edited descriptions, cards from an earlier
257
+ * Blume version — so a persisted cache holds exactly the current site's cards.
258
+ */
259
+ const reportCardCache = async (
260
+ project: BlumeProject,
261
+ prune: boolean
262
+ ): Promise<void> => {
263
+ const cards = cardCacheTally();
264
+ if (!cards) {
265
+ return;
266
+ }
267
+ logger.info(
268
+ `OG cards: ${cards.hits} reused from the cache, ${cards.misses} rendered`
269
+ );
270
+ if (prune) {
271
+ await pruneCardCache(ogCacheDir(project.context));
272
+ }
273
+ };
274
+
251
275
  const formatBytes = (bytes: number): string => {
252
276
  if (bytes < 1024) {
253
277
  return `${bytes} B`;
@@ -557,6 +581,10 @@ export const buildCommand = defineCommand({
557
581
  root: project.context.outDir,
558
582
  });
559
583
 
584
+ // A real build also prunes the cache; an isolated verify must not evict
585
+ // cards a live dev server is still serving.
586
+ await reportCardCache(project, !runtimeDir);
587
+
560
588
  // The bundle report and budget gate still run for an isolated build —
561
589
  // `blume build --isolated --budget-js 100` exiting 0 without measuring
562
590
  // anything would be a silent false pass in CI.
@@ -1,6 +1,7 @@
1
1
  ---
2
2
  import { isImageIcon, isInlineSvg } from "../theme/icon-kind.ts";
3
3
  import { resolveIcon } from "../theme/icons.ts";
4
+ import { iconSpriteFor, registerIconSymbol } from "./icon-sprite.ts";
4
5
  import { withBase } from "./islands/base-path.ts";
5
6
 
6
7
  interface Props {
@@ -32,6 +33,17 @@ const rawSvg = iconName && isInlineSvg(iconName) ? iconName.trim() : null;
32
33
  const imageSrc = iconName && !rawSvg && isImageIcon(iconName) ? iconName : null;
33
34
  const resolvedIcon =
34
35
  iconName && !(imageSrc || rawSvg) ? resolveIcon(iconName) : null;
36
+ // A set icon joins the page's sprite when the shell keeps one (see
37
+ // icon-sprite.ts): the paths render once, in the sprite, and this use is a
38
+ // <use> reference. Without a sprite the icon inlines its paths as before.
39
+ const sprite = resolvedIcon ? iconSpriteFor(Astro.locals) : undefined;
40
+ const symbolId =
41
+ sprite && resolvedIcon
42
+ ? registerIconSymbol(sprite, resolvedIcon.name, {
43
+ body: resolvedIcon.body,
44
+ viewBox: resolvedIcon.viewBox,
45
+ })
46
+ : null;
35
47
  const resolvedClass = astroClass ?? className;
36
48
  const customStyle = `display:inline-flex;width:${size}px;height:${size}px;${
37
49
  color ? `color:${color}` : ""
@@ -68,6 +80,18 @@ const style = color ? `color:${color}` : undefined;
68
80
  src={withBase(imageSrc)}
69
81
  width={size}
70
82
  />
83
+ ) : symbolId ? (
84
+ <svg
85
+ aria-hidden={label ? undefined : "true"}
86
+ aria-label={label}
87
+ class={resolvedClass}
88
+ height={size}
89
+ role={label ? "img" : undefined}
90
+ style={style}
91
+ width={size}
92
+ >
93
+ <use href={`#${symbolId}`} />
94
+ </svg>
71
95
  ) : (
72
96
  resolvedIcon && (
73
97
  <svg
@@ -20,7 +20,7 @@ const src = videoId ? youtubeEmbedUrl(videoId, { start }) : null;
20
20
 
21
21
  {
22
22
  src && (
23
- <div class="not-prose my-6 aspect-video overflow-hidden rounded-blume border border-border bg-muted/30">
23
+ <div class="not-prose my-6 aspect-video w-full overflow-hidden rounded-blume border border-border bg-muted/30">
24
24
  <iframe
25
25
  allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
26
26
  allowfullscreen
@@ -0,0 +1,41 @@
1
+ import type { MiddlewareHandler } from "astro";
2
+
3
+ import { iconSpriteFor, renderIconSprite } from "./icon-sprite.ts";
4
+ import type { IconSprite } from "./icon-sprite.ts";
5
+
6
+ /**
7
+ * The placeholder a shell renders where its icon sprite goes (the end of the
8
+ * body); {@link onRequest} replaces it once the page has fully rendered.
9
+ * Rendering the sprite from a component instead wouldn't work: Astro walks a
10
+ * template's own markup before its nested components render, so a sprite
11
+ * component at the end of the body runs before the header's search button or
12
+ * the page content's callouts have registered their icons.
13
+ */
14
+ export const ICON_SPRITE_SLOT = "<!--blume-icon-sprite-->";
15
+
16
+ /** The page HTML with its placeholder replaced by the finished sprite. */
17
+ export const spliceIconSprite = (html: string, sprite?: IconSprite): string =>
18
+ html.includes(ICON_SPRITE_SLOT)
19
+ ? html.replace(ICON_SPRITE_SLOT, sprite ? renderIconSprite(sprite) : "")
20
+ : html;
21
+
22
+ /**
23
+ * Blume's icon-sprite middleware (wired by the integration). Draining the
24
+ * response renders the whole page, after which the request's registry holds
25
+ * every icon it used; the sprite is spliced in where the shell left the slot.
26
+ * Requests whose shell kept no registry — a user layout override, a partial,
27
+ * an endpoint — pass through untouched, streaming and all.
28
+ */
29
+ export const onRequest: MiddlewareHandler = async (context, render) => {
30
+ const response = await render();
31
+ const sprite = iconSpriteFor(context.locals);
32
+ if (!sprite) {
33
+ return response;
34
+ }
35
+ const html = await response.text();
36
+ return new Response(spliceIconSprite(html, sprite), {
37
+ headers: response.headers,
38
+ status: response.status,
39
+ statusText: response.statusText,
40
+ });
41
+ };