blume 1.6.6 → 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 (86) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/dist/cli/{chunk-nyqzjdhj.js → chunk-0qhq7b8q.js} +5 -5
  3. package/dist/cli/{chunk-cnvm6k3e.js → chunk-18tjv4f7.js} +11 -11
  4. package/dist/cli/{chunk-62qsssnh.js → chunk-5d4q7121.js} +401 -145
  5. package/dist/cli/chunk-5d4q7121.js.map +40 -0
  6. package/dist/cli/{chunk-ag1zyr5x.js → chunk-9qs6acpw.js} +11 -11
  7. package/dist/cli/{chunk-aerwpe14.js → chunk-agy5rzxy.js} +98 -15
  8. package/dist/cli/chunk-agy5rzxy.js.map +15 -0
  9. package/dist/cli/{chunk-x1vrdjyk.js → chunk-cfw6x4rm.js} +5 -5
  10. package/dist/cli/{chunk-bawgnt8x.js → chunk-ckh3a410.js} +3 -3
  11. package/dist/cli/{chunk-j00ezcg5.js → chunk-drke6t0h.js} +9 -9
  12. package/dist/cli/{chunk-3k0kzs6d.js → chunk-j6pxe0dt.js} +2 -2
  13. package/dist/cli/{chunk-n0y172hf.js → chunk-jk1zwka1.js} +4 -4
  14. package/dist/cli/{chunk-f75cqye8.js → chunk-jxkxjsc1.js} +10 -10
  15. package/dist/cli/{chunk-s4k1pnvf.js → chunk-kwx90v78.js} +11 -11
  16. package/dist/cli/{chunk-9sh49q0h.js → chunk-n0nyat6g.js} +2 -2
  17. package/dist/cli/{chunk-wkq5tbtq.js → chunk-qq9nm3qd.js} +3 -3
  18. package/dist/cli/{chunk-etsqspj6.js → chunk-s102bysw.js} +2 -2
  19. package/dist/cli/{chunk-wb067mv3.js → chunk-s5dsk8bj.js} +18 -7
  20. package/dist/cli/chunk-s5dsk8bj.js.map +13 -0
  21. package/dist/cli/{chunk-m3p3wahd.js → chunk-tnskyrej.js} +4 -4
  22. package/dist/cli/{chunk-vv237fp3.js → chunk-v2ymm99c.js} +26 -12
  23. package/dist/cli/{chunk-vv237fp3.js.map → chunk-v2ymm99c.js.map} +3 -3
  24. package/dist/cli/{chunk-5yvt556e.js → chunk-v5mm027v.js} +2 -2
  25. package/dist/cli/{chunk-vv3f8mb6.js → chunk-xv91q4nm.js} +24 -24
  26. package/dist/cli/{chunk-vv3f8mb6.js.map → chunk-xv91q4nm.js.map} +3 -3
  27. package/dist/cli/{chunk-0ewz4trd.js → chunk-y3g15rvv.js} +6 -6
  28. package/dist/cli/{chunk-tc89yh2r.js → chunk-ye9zdkgv.js} +2 -2
  29. package/dist/cli/{chunk-n4qjabmt.js → chunk-ynacq3ev.js} +4 -4
  30. package/dist/cli/{chunk-s4jn7f1q.js → chunk-zr3ygrq3.js} +2 -2
  31. package/dist/cli/index.js +13 -13
  32. package/dist/types/components/layout/nav-utils.d.ts +33 -1
  33. package/dist/types/theme/fonts.d.ts +22 -22
  34. package/docs/02-deployment.mdx +21 -0
  35. package/docs/content/navigation.mdx +2 -0
  36. package/docs/content/syntax.mdx +1 -1
  37. package/docs/discoverability/open-graph.mdx +4 -0
  38. package/package.json +1 -1
  39. package/src/astro/generate.ts +141 -5
  40. package/src/astro/integration.ts +12 -1
  41. package/src/astro/module-types.ts +9 -0
  42. package/src/astro/templates.ts +171 -11
  43. package/src/cli/commands/build.ts +28 -0
  44. package/src/components/Icon.astro +24 -0
  45. package/src/components/icon-sprite-middleware.ts +41 -0
  46. package/src/components/icon-sprite.ts +93 -0
  47. package/src/components/layout/IconSprite.astro +11 -0
  48. package/src/components/layout/NavTree.astro +156 -188
  49. package/src/components/layout/NavTreeCache.astro +45 -0
  50. package/src/components/layout/NavTreeScript.astro +256 -0
  51. package/src/components/layout/PageActions.astro +11 -5
  52. package/src/components/layout/PageLayout.astro +7 -0
  53. package/src/components/layout/ReferenceLayout.astro +7 -0
  54. package/src/components/layout/RootLayout.astro +30 -2
  55. package/src/components/layout/nav-cache.ts +49 -0
  56. package/src/components/layout/nav-utils.ts +69 -1
  57. package/src/markdown/language-icon.ts +64 -20
  58. package/src/markdown/mermaid.ts +11 -0
  59. package/src/og/cache.ts +236 -0
  60. package/src/og/card.ts +12 -4
  61. package/src/og/index.ts +8 -1
  62. package/src/registry/eject.ts +23 -8
  63. package/src/theme/entry.ts +41 -7
  64. package/src/theme/fonts.ts +30 -23
  65. package/dist/cli/chunk-62qsssnh.js.map +0 -36
  66. package/dist/cli/chunk-aerwpe14.js.map +0 -15
  67. package/dist/cli/chunk-wb067mv3.js.map +0 -13
  68. /package/dist/cli/{chunk-nyqzjdhj.js.map → chunk-0qhq7b8q.js.map} +0 -0
  69. /package/dist/cli/{chunk-cnvm6k3e.js.map → chunk-18tjv4f7.js.map} +0 -0
  70. /package/dist/cli/{chunk-ag1zyr5x.js.map → chunk-9qs6acpw.js.map} +0 -0
  71. /package/dist/cli/{chunk-x1vrdjyk.js.map → chunk-cfw6x4rm.js.map} +0 -0
  72. /package/dist/cli/{chunk-bawgnt8x.js.map → chunk-ckh3a410.js.map} +0 -0
  73. /package/dist/cli/{chunk-j00ezcg5.js.map → chunk-drke6t0h.js.map} +0 -0
  74. /package/dist/cli/{chunk-3k0kzs6d.js.map → chunk-j6pxe0dt.js.map} +0 -0
  75. /package/dist/cli/{chunk-n0y172hf.js.map → chunk-jk1zwka1.js.map} +0 -0
  76. /package/dist/cli/{chunk-f75cqye8.js.map → chunk-jxkxjsc1.js.map} +0 -0
  77. /package/dist/cli/{chunk-s4k1pnvf.js.map → chunk-kwx90v78.js.map} +0 -0
  78. /package/dist/cli/{chunk-9sh49q0h.js.map → chunk-n0nyat6g.js.map} +0 -0
  79. /package/dist/cli/{chunk-wkq5tbtq.js.map → chunk-qq9nm3qd.js.map} +0 -0
  80. /package/dist/cli/{chunk-etsqspj6.js.map → chunk-s102bysw.js.map} +0 -0
  81. /package/dist/cli/{chunk-m3p3wahd.js.map → chunk-tnskyrej.js.map} +0 -0
  82. /package/dist/cli/{chunk-5yvt556e.js.map → chunk-v5mm027v.js.map} +0 -0
  83. /package/dist/cli/{chunk-0ewz4trd.js.map → chunk-y3g15rvv.js.map} +0 -0
  84. /package/dist/cli/{chunk-tc89yh2r.js.map → chunk-ye9zdkgv.js.map} +0 -0
  85. /package/dist/cli/{chunk-n4qjabmt.js.map → chunk-ynacq3ev.js.map} +0 -0
  86. /package/dist/cli/{chunk-s4jn7f1q.js.map → chunk-zr3ygrq3.js.map} +0 -0
@@ -66,6 +66,15 @@ declare module "blume:openapi" {
66
66
  export default specs;
67
67
  }
68
68
 
69
+ declare module "blume:features" {
70
+ /** Registers the <blume-mermaid> element; null when no page has a mermaid fence. */
71
+ export const loadMermaid: (() => Promise<unknown>) | null;
72
+ /** The EPUB generator's browser bundle; null when export.epub is off. */
73
+ export const loadEpub:
74
+ | (() => Promise<typeof import("epub-gen-memory/bundle")>)
75
+ | null;
76
+ }
77
+
69
78
  declare module "blume:search-client" {
70
79
  export const createSearch: () =>
71
80
  | import("blume/components/layout/search/types.ts").SearchFn
@@ -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
  },
@@ -1289,6 +1335,34 @@ const hostedSearchOptions = (
1289
1335
  }
1290
1336
  };
1291
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
+
1292
1366
  /**
1293
1367
  * Generate `.blume/src/generated/search-client.ts` — the provider-specific
1294
1368
  * loader the `<Search>` component lazy-imports via the `blume:search-client`
@@ -1610,10 +1684,77 @@ export function GET({ props }: { props: { section: string } }) {
1610
1684
  }
1611
1685
  `;
1612
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
+
1613
1749
  /** Generate the OG image endpoint (`.blume/src/pages/og/[...slug].png.ts`). */
1614
1750
  export const ogEndpointTemplate = (
1615
1751
  customRoutes: OgCustomRoute[] = [],
1616
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;
1617
1758
  families?: OgFontFamilies;
1618
1759
  fonts?: OgFont[];
1619
1760
  /**
@@ -1626,8 +1767,8 @@ export const ogEndpointTemplate = (
1626
1767
  includeChangelog = false
1627
1768
  ): string =>
1628
1769
  `// Generated by Blume. Do not edit.
1629
- import { renderOgImage } from "blume/og";
1630
- import type { OgFont, OgFontFamilies } from "blume/og";
1770
+ import { cachedOgImage } from "blume/og";
1771
+ import type { OgCache, OgFont, OgFontFamilies } from "blume/og";
1631
1772
  import data from "blume:data";
1632
1773
 
1633
1774
  export const prerender = true;
@@ -1646,6 +1787,14 @@ const families: OgFontFamilies | undefined = ${
1646
1787
  og.families ? JSON.stringify(og.families) : "undefined"
1647
1788
  };
1648
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
+
1649
1798
  // A page's own description (its \`seo.description\`, else \`description\`) is
1650
1799
  // the card subtitle, so the image says what the page's og:description says.
1651
1800
  // Pages without one fall back to the site-wide subtitle at render time.
@@ -1705,7 +1854,7 @@ const repoSlug = data.config.github
1705
1854
  : undefined;
1706
1855
 
1707
1856
  export async function GET({ props }: { props: CardProps }) {
1708
- const png = await renderOgImage({
1857
+ const png = await cachedOgImage(cache, {
1709
1858
  accent: data.config.og.palette?.accent ?? data.config.theme.accent.light,
1710
1859
  brand: data.config.title,
1711
1860
  description: props.description ?? data.config.og.description,
@@ -1788,6 +1937,12 @@ export const catchAllPageTemplate = (options: {
1788
1937
  exportEpub: boolean;
1789
1938
  exportPdf: boolean;
1790
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;
1791
1946
  /** Serialize the island-hooks snapshot; only needed when React is enabled. */
1792
1947
  needsReact: boolean;
1793
1948
  }): string => {
@@ -2166,7 +2321,12 @@ const LayoutComponent = resolveSlot(layoutOverrides.Layout, RootLayout);
2166
2321
  editUrl={editUrl}
2167
2322
  feedback={data.config.feedback}
2168
2323
  exportPdf={${options.exportPdf}}
2169
- exportEpub={${options.exportEpub}}
2324
+ exportEpub={${options.exportEpub}}${
2325
+ options.navFragments
2326
+ ? `
2327
+ navFragmentBase={withBase(\`/blume-nav/\${version || "current"}/\${i18n ? locale : "default"}\`)}`
2328
+ : ""
2329
+ }
2170
2330
  openInChat={data.config.openInChat}
2171
2331
  feeds={data.feeds}
2172
2332
  discovery={data.config.discovery}
@@ -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
@@ -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
+ };
@@ -0,0 +1,93 @@
1
+ /**
2
+ * A per-page SVG sprite for the icon sets. A page renders the same handful of
3
+ * icons many times over (every sidebar chevron, every code-block language
4
+ * icon, the page-action buttons); inlining each one repeats its paths on every
5
+ * use, which on a docs page is ~20 kB of the ~60 kB that isn't the sidebar.
6
+ * With the sprite, each use is a `<use href="#…">` reference and the paths
7
+ * appear once, in a hidden `<svg>` the layout renders at the end of the body.
8
+ *
9
+ * The registry lives on the request's `Astro.locals`, so every component in
10
+ * one page render — the layout, the MDX content, cached sidebar subtrees —
11
+ * shares it, and concurrent page renders never see each other's icons. A
12
+ * shell that creates no registry (a user's layout override, a partial) gets
13
+ * inline icons, exactly as before.
14
+ */
15
+
16
+ /** One symbol in the sprite: an icon set's inner markup and its viewBox. */
17
+ export interface IconSymbol {
18
+ body: string;
19
+ viewBox: string;
20
+ }
21
+
22
+ export interface IconSprite {
23
+ /** Symbol id → symbol, in first-use order. */
24
+ symbols: Map<string, IconSymbol>;
25
+ }
26
+
27
+ /** The request locals (`Astro.locals`) slot the registry lives in. */
28
+ export interface IconSpriteLocals {
29
+ blumeIconSprite?: IconSprite;
30
+ }
31
+
32
+ /** The request's sprite registry, when a shell created one. */
33
+ export const iconSpriteFor = (
34
+ locals: IconSpriteLocals
35
+ ): IconSprite | undefined => locals.blumeIconSprite;
36
+
37
+ /**
38
+ * The request's sprite registry, created on first call: a shell calls this
39
+ * up front. Idempotent, so a shell rendered inside another shell's request
40
+ * joins the existing registry instead of discarding its icons.
41
+ */
42
+ export const createIconSprite = (locals: IconSpriteLocals): IconSprite => {
43
+ locals.blumeIconSprite ??= { symbols: new Map() };
44
+ return locals.blumeIconSprite;
45
+ };
46
+
47
+ /**
48
+ * The sprite symbol id for an icon name. Names come from config and content
49
+ * (`lucide:arrow-right`, `simple-icons:github`), so anything outside the id
50
+ * charset is folded to a dash.
51
+ */
52
+ export const iconSymbolId = (name: string): string =>
53
+ `blume-i-${name.toLowerCase().replaceAll(/[^a-z0-9-]+/gu, "-")}`;
54
+
55
+ /** Register a symbol (idempotent) and return its id. */
56
+ export const registerIconSymbol = (
57
+ sprite: IconSprite,
58
+ name: string,
59
+ symbol: IconSymbol
60
+ ): string => {
61
+ const id = iconSymbolId(name);
62
+ if (!sprite.symbols.has(id)) {
63
+ sprite.symbols.set(id, symbol);
64
+ }
65
+ return id;
66
+ };
67
+
68
+ /** The ids of every sprite symbol a rendered HTML fragment references. */
69
+ export const referencedIconSymbols = (html: string): string[] => [
70
+ ...new Set(
71
+ [...html.matchAll(/href="#(?<id>blume-i-[^"]+)"/gu)].map(
72
+ (match) => match.groups?.id ?? ""
73
+ )
74
+ ),
75
+ ];
76
+
77
+ /**
78
+ * The hidden sprite `<svg>` holding every registered symbol, or an empty
79
+ * string when the page used no set icon. Symbol bodies are the icon sets'
80
+ * own markup (already SVG); the viewBox is the only attribute interpolated.
81
+ */
82
+ export const renderIconSprite = (sprite: IconSprite): string => {
83
+ if (sprite.symbols.size === 0) {
84
+ return "";
85
+ }
86
+ const symbols = [...sprite.symbols]
87
+ .map(
88
+ ([id, symbol]) =>
89
+ `<symbol id="${id}" viewBox="${symbol.viewBox}">${symbol.body}</symbol>`
90
+ )
91
+ .join("");
92
+ return `<svg aria-hidden="true" hidden xmlns="http://www.w3.org/2000/svg">${symbols}</svg>`;
93
+ };
@@ -0,0 +1,11 @@
1
+ ---
2
+ // Where the page's icon sprite goes: every set icon the render registered
3
+ // (see icon-sprite.ts), as one hidden <svg> of <symbol>s the icons' <use>
4
+ // references resolve against. This only leaves a placeholder at the end of
5
+ // the body; the icon-sprite middleware replaces it once the page has fully
6
+ // rendered, since a component here would run before nested components
7
+ // (the header's search button, the content's callouts) register their icons.
8
+ import { ICON_SPRITE_SLOT } from "../icon-sprite-middleware.ts";
9
+ ---
10
+
11
+ <Fragment set:html={ICON_SPRITE_SLOT} />