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
@@ -13,6 +13,17 @@ interface CodeNode extends MdastNode {
13
13
  * rendered on the client (Mermaid needs a DOM), so the source rides on a string
14
14
  * attribute rather than as child text (which MDX would try to parse).
15
15
  */
16
+ /**
17
+ * A ```mermaid (or ~~~mermaid) fence opener at the start of a line. Used to
18
+ * decide, at generation time, whether the site needs the Mermaid client
19
+ * library at all — see `featuresTemplate`.
20
+ */
21
+ const MERMAID_FENCE = /^[ \t]*(?:`{3,}|~{3,})[ \t]*mermaid\b/mu;
22
+
23
+ /** Whether a page's Markdown/MDX source contains a mermaid fence. */
24
+ export const hasMermaidFence = (text: string): boolean =>
25
+ MERMAID_FENCE.test(text);
26
+
16
27
  export const mermaidPlugin = () => ({
17
28
  code(node: CodeNode, ctx: MdastVisitorContext) {
18
29
  if (node.lang !== "mermaid") {
@@ -0,0 +1,236 @@
1
+ import { createHash } from "node:crypto";
2
+ import { existsSync } from "node:fs";
3
+ import {
4
+ mkdir,
5
+ readdir,
6
+ readFile,
7
+ rename,
8
+ rm,
9
+ writeFile,
10
+ } from "node:fs/promises";
11
+
12
+ import { dirname, join } from "pathe";
13
+
14
+ import type { ProjectContext } from "../core/types.ts";
15
+ import type { OgCardOptions, OgFont } from "./card.ts";
16
+
17
+ /**
18
+ * Where a build keeps rendered OG cards between runs (see {@link cardCacheKey}
19
+ * for what invalidates one). Baked into the generated OG endpoint alongside
20
+ * the Blume version that renders the cards.
21
+ */
22
+ export interface OgCache {
23
+ /** Absolute directory holding `<key>.png` files. */
24
+ dir: string;
25
+ /** The Blume version rendering the cards; part of every key. */
26
+ version: string;
27
+ }
28
+
29
+ /**
30
+ * The card cache directory for a project: `node_modules/.cache/blume/og`,
31
+ * the conventional build-cache location. Vercel and Netlify restore
32
+ * `node_modules` from their build caches, so a deploy there re-renders only
33
+ * the cards whose inputs changed; Cloudflare Workers Builds keeps only
34
+ * package-manager caches (and `node_modules/.astro` for a detected Astro
35
+ * project), and a self-managed runner needs a cache step for the directory.
36
+ * A project with no `node_modules` of its own falls back to the runtime's
37
+ * cache dir next to Astro's and Vite's.
38
+ */
39
+ export const ogCacheDir = (
40
+ context: Pick<ProjectContext, "outDir" | "root">
41
+ ): string =>
42
+ existsSync(join(context.root, "node_modules"))
43
+ ? join(context.root, "node_modules", ".cache", "blume", "og")
44
+ : join(context.outDir, ".cache", "og");
45
+
46
+ /**
47
+ * Per-process tally of cache hits and misses plus the keys this build asked
48
+ * for, read back by the CLI after `build()` for the summary line and the
49
+ * prune. On `globalThis` for the same reason as the integration registry: the
50
+ * endpoint renders in the copy of this module Vite bundled for the prerender,
51
+ * while the CLI reads from its own bundled copy.
52
+ */
53
+ interface OgCacheRegistry {
54
+ hits: number;
55
+ misses: number;
56
+ used: Set<string>;
57
+ }
58
+
59
+ const REGISTRY_KEY = Symbol.for("blume.og-cache");
60
+
61
+ type RegistryHost = typeof globalThis & { [REGISTRY_KEY]?: OgCacheRegistry };
62
+
63
+ const registry = (): OgCacheRegistry => {
64
+ // SAFETY: the registry is stashed on globalThis under a well-known symbol so
65
+ // every copy of this module in the process shares it; the intersection only
66
+ // names that slot.
67
+ const host = globalThis as RegistryHost;
68
+ host[REGISTRY_KEY] ??= { hits: 0, misses: 0, used: new Set() };
69
+ return host[REGISTRY_KEY];
70
+ };
71
+
72
+ /** Type guard: is this OG font a local file entry? */
73
+ export const isLocalOgFont = (
74
+ font: OgFont
75
+ ): font is Extract<OgFont, { src: string }> =>
76
+ typeof font !== "string" && "src" in font;
77
+
78
+ // A local font file's contents digest, computed once per path per process: the
79
+ // key must follow the file's bytes, not its mtime (a fresh CI checkout resets
80
+ // every mtime, which would miss the whole cache on each build).
81
+ const localFontDigests = new Map<string, Promise<string>>();
82
+
83
+ const digestFile = async (path: string): Promise<string> =>
84
+ createHash("sha256")
85
+ .update(await readFile(path))
86
+ .digest("hex");
87
+
88
+ const localFontDigest = (path: string): Promise<string> => {
89
+ let digest = localFontDigests.get(path);
90
+ if (!digest) {
91
+ digest = digestFile(path);
92
+ localFontDigests.set(path, digest);
93
+ }
94
+ return digest;
95
+ };
96
+
97
+ /**
98
+ * The cache key of a card: a digest of everything that decides its pixels —
99
+ * the card options (title, description, brand, logo markup, palette, footer
100
+ * text, font families), the fonts (a local file by its contents, a Google
101
+ * family by its request), and the Blume version, since the layout and the
102
+ * renderer it pins ship with the package. Pre-fetched `images` are left out:
103
+ * the endpoint never passes them.
104
+ */
105
+ export const cardCacheKey = async (
106
+ version: string,
107
+ options: OgCardOptions
108
+ ): Promise<string> => {
109
+ const fonts = await Promise.all(
110
+ (options.fonts ?? []).map(async (font) =>
111
+ isLocalOgFont(font)
112
+ ? { ...font, digest: await localFontDigest(font.src) }
113
+ : font
114
+ )
115
+ );
116
+ const card = { ...options, fonts: undefined, images: undefined };
117
+ return createHash("sha256")
118
+ .update(JSON.stringify({ card, fonts, version }))
119
+ .digest("hex");
120
+ };
121
+
122
+ const cardPath = (cache: OgCache, key: string): string =>
123
+ join(cache.dir, `${key}.png`);
124
+
125
+ /** A cached card's bytes, or `null` when there is none. */
126
+ const readCard = async (file: string): Promise<Uint8Array | null> => {
127
+ try {
128
+ return await readFile(file);
129
+ } catch {
130
+ return null;
131
+ }
132
+ };
133
+
134
+ // Written to a sibling temp file and renamed into place: concurrent page
135
+ // renders may store the same key, and a reader must never see a half-written
136
+ // card.
137
+ const storeCard = async (file: string, png: Uint8Array): Promise<void> => {
138
+ await mkdir(dirname(file), { recursive: true });
139
+ const tmp = `${file}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
140
+ await writeFile(tmp, png);
141
+ await rename(tmp, file);
142
+ };
143
+
144
+ /** The in-flight renders of this process, so duplicate titles render once. */
145
+ const inflight = new Map<string, Promise<Uint8Array>>();
146
+
147
+ const renderAndStore = async (
148
+ key: string,
149
+ file: string,
150
+ options: OgCardOptions,
151
+ render: (options: OgCardOptions) => Promise<Uint8Array>
152
+ ): Promise<Uint8Array> => {
153
+ try {
154
+ const png = await render(options);
155
+ try {
156
+ await storeCard(file, png);
157
+ } catch {
158
+ // An unwritable cache (a read-only workspace) never fails the build.
159
+ }
160
+ return png;
161
+ } finally {
162
+ inflight.delete(key);
163
+ }
164
+ };
165
+
166
+ /**
167
+ * Serve a card from `cache`, rendering it with `render` on a miss and storing
168
+ * the result for the next build. Without a cache every card renders; the
169
+ * cache is only ever a shortcut.
170
+ */
171
+ export const throughCardCache = async (
172
+ cache: OgCache | undefined,
173
+ options: OgCardOptions,
174
+ render: (options: OgCardOptions) => Promise<Uint8Array>
175
+ ): Promise<Uint8Array> => {
176
+ if (!cache) {
177
+ return render(options);
178
+ }
179
+ const key = await cardCacheKey(cache.version, options);
180
+ const state = registry();
181
+ state.used.add(key);
182
+ const file = cardPath(cache, key);
183
+ const hit = await readCard(file);
184
+ if (hit) {
185
+ state.hits += 1;
186
+ return hit;
187
+ }
188
+ let pending = inflight.get(key);
189
+ if (!pending) {
190
+ state.misses += 1;
191
+ pending = renderAndStore(key, file, options, render);
192
+ inflight.set(key, pending);
193
+ }
194
+ return pending;
195
+ };
196
+
197
+ /**
198
+ * Remove the cards this build never asked for — a renamed page, a changed
199
+ * description, a previous Blume version — plus any temp file a crashed build
200
+ * left behind, so a persisted cache holds exactly the current site's cards.
201
+ * Returns how many files were removed; a missing directory removes nothing.
202
+ */
203
+ export const pruneCardCache = async (dir: string): Promise<number> => {
204
+ const { used } = registry();
205
+ let entries: string[];
206
+ try {
207
+ entries = await readdir(dir);
208
+ } catch {
209
+ return 0;
210
+ }
211
+ const stale = entries.filter(
212
+ (name) =>
213
+ name.endsWith(".tmp") ||
214
+ (name.endsWith(".png") && !used.has(name.slice(0, -".png".length)))
215
+ );
216
+ await Promise.all(stale.map((name) => rm(join(dir, name), { force: true })));
217
+ return stale.length;
218
+ };
219
+
220
+ /**
221
+ * This process's card cache tally — how many cards a build reused and how many
222
+ * it rendered — or `null` when no card was requested (OG cards off, or an
223
+ * endpoint-free build).
224
+ */
225
+ export const cardCacheTally = (): { hits: number; misses: number } | null => {
226
+ const { hits, misses } = registry();
227
+ return hits + misses === 0 ? null : { hits, misses };
228
+ };
229
+
230
+ /** Reset the tally and the used-key set (tests). */
231
+ export const resetCardCacheTally = (): void => {
232
+ const state = registry();
233
+ state.hits = 0;
234
+ state.misses = 0;
235
+ state.used.clear();
236
+ };
package/src/og/card.ts CHANGED
@@ -7,6 +7,8 @@ import type { FontSubset, GoogleFontFamily, Node } from "takumi-js/helpers";
7
7
 
8
8
  import { svgDimensions } from "../core/svg-dimensions.ts";
9
9
  import { ACCENTS, isAccentPreset } from "../theme/palette.ts";
10
+ import { isLocalOgFont, throughCardCache } from "./cache.ts";
11
+ import type { OgCache } from "./cache.ts";
10
12
  import { OG_IMAGE_HEIGHT, OG_IMAGE_WIDTH } from "./dimensions.ts";
11
13
 
12
14
  /** A local font file registered with the OG card renderer, read at build. */
@@ -40,10 +42,6 @@ export type OgFont =
40
42
  }
41
43
  | OgLocalFont;
42
44
 
43
- /** Type guard: is this OG font a local file entry? */
44
- const isLocalOgFont = (font: OgFont): font is OgLocalFont =>
45
- typeof font !== "string" && "src" in font;
46
-
47
45
  /**
48
46
  * Which loaded family each card role renders in. Takumi still falls back
49
47
  * across every loaded font per glyph, so a family that misses a script
@@ -418,3 +416,13 @@ export const renderOgImage = async (
418
416
  width: WIDTH,
419
417
  });
420
418
  };
419
+
420
+ /**
421
+ * {@link renderOgImage} through the on-disk card cache: a card whose inputs
422
+ * match one rendered by a previous build (or an earlier page of this one) is
423
+ * read back instead of rendered. `cache` undefined renders every card.
424
+ */
425
+ export const cachedOgImage = (
426
+ cache: OgCache | undefined,
427
+ options: OgCardOptions
428
+ ): Promise<Uint8Array> => throughCardCache(cache, options, renderOgImage);
package/src/og/index.ts CHANGED
@@ -1,4 +1,11 @@
1
- export { renderOgImage } from "./card.ts";
1
+ export {
2
+ cardCacheKey,
3
+ cardCacheTally,
4
+ ogCacheDir,
5
+ pruneCardCache,
6
+ } from "./cache.ts";
7
+ export type { OgCache } from "./cache.ts";
8
+ export { cachedOgImage, renderOgImage } from "./card.ts";
2
9
  export type {
3
10
  OgCardOptions,
4
11
  OgCardPalette,
@@ -17,23 +17,26 @@ import {
17
17
  } from "../astro/examples.ts";
18
18
  import {
19
19
  buildRuntimeData,
20
+ clientFeaturesFor,
20
21
  collectStaged,
21
22
  detectNeedsReact,
22
23
  detectUsesMath,
24
+ languageIconCssFor,
23
25
  } from "../astro/generate.ts";
24
26
  import { discoverIslands } from "../astro/islands.ts";
25
27
  import { customOgRoutes, discoverPages, routeIsTaken } from "../astro/pages.ts";
26
28
  import {
27
- askEndpointTemplate,
28
29
  askComponentTemplate,
30
+ askEndpointTemplate,
29
31
  astroConfigTemplate,
30
32
  catchAllPageTemplate,
31
33
  changelogIndexTemplate,
32
34
  contentConfigTemplate,
33
35
  exampleMapTemplate,
34
- exampleWrapperTemplate,
35
- examplesPageTemplate,
36
36
  exampleSlug,
37
+ examplesPageTemplate,
38
+ exampleWrapperTemplate,
39
+ featuresTemplate,
37
40
  islandMapTemplate,
38
41
  islandWrapperTemplate,
39
42
  mcpEndpointTemplate,
@@ -387,6 +390,8 @@ export const eject = async (
387
390
  const staged = collectStaged(project);
388
391
  const hasStaged = staged.size > 0;
389
392
  const stagedDir = "blume-staged";
393
+ const features = await clientFeaturesFor(project);
394
+ const languageIcons = await languageIconCssFor(project);
390
395
 
391
396
  const files: {
392
397
  path: string;
@@ -403,6 +408,8 @@ export const eject = async (
403
408
  context: relContext,
404
409
  examplesPath: "./src/generated/examples.ts",
405
410
  examplesThemePath: "./src/generated/examples.css",
411
+ features,
412
+ featuresPath: "./src/generated/features.ts",
406
413
  // No CLI publishes the runtime data modules in memory after eject, so
407
414
  // the config aliases each to the JSON snapshot written below.
408
415
  generatedModulesDir: "./src/generated",
@@ -476,6 +483,7 @@ export const eject = async (
476
483
  {
477
484
  content: tailwindEntryTemplate({
478
485
  configTokens: buildThemeCss(config.theme),
486
+ languageIcons,
479
487
  // Relative paths from src/generated/app.css keep the ejected app
480
488
  // portable; the blume glob resolves the real install location when
481
489
  // the package is hoisted out of the project's own node_modules.
@@ -558,11 +566,18 @@ export const eject = async (
558
566
  });
559
567
  }
560
568
 
561
- // The provider-specific client loader behind the `blume:search-client` alias.
562
- files.push({
563
- content: searchClientTemplate(config),
564
- path: join(genDir, "search-client.ts"),
565
- });
569
+ // The client-feature loaders behind the `blume:features` alias, and the
570
+ // provider-specific client loader behind `blume:search-client`.
571
+ files.push(
572
+ {
573
+ content: featuresTemplate(features),
574
+ path: join(genDir, "features.ts"),
575
+ },
576
+ {
577
+ content: searchClientTemplate(config),
578
+ path: join(genDir, "search-client.ts"),
579
+ }
580
+ );
566
581
 
567
582
  if (servesStaticIndex(config.search.provider)) {
568
583
  const documents = await buildSearchDocuments(project);
@@ -20,6 +20,8 @@ interface TailwindEntryOptions {
20
20
  userTheme: string;
21
21
  /** Twoslash rich-renderer styles (for fences with the `twoslash` meta). */
22
22
  twoslashCss?: string;
23
+ /** The code-block language icon rules (`languageIconCss`), if any. */
24
+ languageIcons?: string;
23
25
  }
24
26
 
25
27
  /** Dark mode is driven by `data-theme` on the root element (both sheets). */
@@ -244,6 +246,31 @@ ${THEME_MAPPING}
244
246
  unicode-bidi: isolate;
245
247
  }
246
248
 
249
+ /* The sidebar's rows. A docs site renders every sidebar row on every page,
250
+ so a large site's HTML is mostly this markup — one utility per row kind
251
+ instead of the dozen classes each expands to keeps every page (and the
252
+ build's HTML output) small. Kept in sync with NavTree.astro: a page row,
253
+ a page-mode group's drill-in button, a collapsible group's summary and
254
+ the link inside it, and a flat group's heading and the link inside it. */
255
+ @utility blume-nav-link {
256
+ @apply block rounded-[0.65rem] px-2.5 py-1.5 text-muted-foreground text-sm transition-colors hover:bg-muted hover:text-foreground aria-[current=page]:bg-muted aria-[current=page]:font-medium aria-[current=page]:text-foreground;
257
+ }
258
+ @utility blume-nav-drill {
259
+ @apply flex w-full items-center gap-2 rounded-[0.65rem] px-2.5 py-1.5 text-left font-medium text-foreground text-sm transition-colors hover:bg-muted;
260
+ }
261
+ @utility blume-nav-summary {
262
+ @apply flex cursor-pointer list-none items-center gap-1.5 rounded-[0.65rem] px-2.5 py-1.5 text-muted-foreground text-sm transition-colors hover:bg-muted hover:text-foreground [&::-webkit-details-marker]:hidden;
263
+ }
264
+ @utility blume-nav-summary-link {
265
+ @apply -my-1 flex flex-1 items-center gap-1.5 rounded py-1 transition-colors aria-[current=page]:text-foreground aria-[current=page]:font-semibold;
266
+ }
267
+ @utility blume-nav-heading {
268
+ @apply mb-2 flex items-center gap-1.5 px-2.5 font-medium text-foreground text-sm;
269
+ }
270
+ @utility blume-nav-heading-link {
271
+ @apply -ml-1 flex flex-1 items-center gap-1.5 rounded-[0.65rem] px-1 py-0.5 text-foreground transition-colors hover:bg-muted aria-[current=page]:bg-muted;
272
+ }
273
+
247
274
  /* Theme Tailwind Typography (prose) with Blume tokens. */
248
275
  .prose {
249
276
  --tw-prose-body: var(--blume-foreground);
@@ -459,15 +486,17 @@ blume-diff {
459
486
  CodeBlock (no title) never gets data-language, so without this gate the
460
487
  absolutely-positioned icon would overlap the first code line. Fenced code
461
488
  and titled blocks always have a header, so the icon shows there. */
462
- .blume-lang-icon {
463
- display: none;
464
- }
465
-
466
- .prose > :where(pre[data-language][data-icon]) > .blume-lang-icon {
467
- color: var(--blume-muted-foreground);
468
- display: block;
489
+ .prose > :where(pre[data-language][data-icon])::after {
490
+ content: "";
469
491
  height: 0.875rem;
470
492
  left: 1rem;
493
+ -webkit-mask-position: center;
494
+ -webkit-mask-repeat: no-repeat;
495
+ -webkit-mask-size: contain;
496
+ mask-position: center;
497
+ mask-repeat: no-repeat;
498
+ mask-size: contain;
499
+ pointer-events: none;
471
500
  position: absolute;
472
501
  top: 0.875rem;
473
502
  width: 0.875rem;
@@ -477,6 +506,11 @@ blume-diff {
477
506
  padding-left: 2.5rem;
478
507
  }
479
508
 
509
+ /* One mask rule per language the site's Markdown uses (see
510
+ markdown/language-icon.ts); the ::after above only gets a color and a mask
511
+ from these, so a block whose language has no rule paints nothing. */
512
+ ${options.languageIcons ?? ""}
513
+
480
514
  .prose :where(pre code) {
481
515
  background: transparent;
482
516
  border-radius: 0;
@@ -63,7 +63,14 @@ export type FontValue = string | RemoteFontConfig | LocalFontConfig;
63
63
  interface FontDef {
64
64
  category: FontCategory;
65
65
  family: string;
66
- weights: number[];
66
+ /**
67
+ * Discrete weights, or one `"min..max"` range for a family Google Fonts
68
+ * serves as a variable font: the range loads the same variable file the
69
+ * discrete weights did, declared as one `@font-face` per style instead of
70
+ * one per weight (16 rules down to 4 for Inter on every page). The three
71
+ * static families in the table keep discrete weights.
72
+ */
73
+ weights: (number | string)[];
67
74
  }
68
75
 
69
76
  /** Resolved theme fonts (a validated value per role, all optional). */
@@ -96,22 +103,22 @@ const FALLBACKS = {
96
103
 
97
104
  /** Slug -> Google family + weights + fallback category. Keep keys alphabetical. */
98
105
  export const GOOGLE_FONTS = {
99
- "dm-sans": { category: "sans", family: "DM Sans", weights: [400, 500, 700] },
106
+ "dm-sans": { category: "sans", family: "DM Sans", weights: ["400..700"] },
100
107
  figtree: {
101
108
  category: "sans",
102
109
  family: "Figtree",
103
- weights: [400, 500, 600, 700],
110
+ weights: ["400..700"],
104
111
  },
105
112
  "fira-code": {
106
113
  category: "mono",
107
114
  family: "Fira Code",
108
- weights: [400, 500, 700],
115
+ weights: ["400..700"],
109
116
  },
110
- geist: { category: "sans", family: "Geist", weights: [400, 500, 600, 700] },
117
+ geist: { category: "sans", family: "Geist", weights: ["400..700"] },
111
118
  "geist-mono": {
112
119
  category: "mono",
113
120
  family: "Geist Mono",
114
- weights: [400, 500, 600],
121
+ weights: ["400..600"],
115
122
  },
116
123
  "ibm-plex-mono": {
117
124
  category: "mono",
@@ -121,81 +128,81 @@ export const GOOGLE_FONTS = {
121
128
  "ibm-plex-sans": {
122
129
  category: "sans",
123
130
  family: "IBM Plex Sans",
124
- weights: [400, 500, 600, 700],
131
+ weights: ["400..700"],
125
132
  },
126
133
  "ibm-plex-serif": {
127
134
  category: "serif",
128
135
  family: "IBM Plex Serif",
129
136
  weights: [400, 500, 600],
130
137
  },
131
- inter: { category: "sans", family: "Inter", weights: [400, 500, 600, 700] },
138
+ inter: { category: "sans", family: "Inter", weights: ["400..700"] },
132
139
  "inter-tight": {
133
140
  category: "sans",
134
141
  family: "Inter Tight",
135
- weights: [400, 500, 600, 700],
142
+ weights: ["400..700"],
136
143
  },
137
144
  "jetbrains-mono": {
138
145
  category: "mono",
139
146
  family: "JetBrains Mono",
140
- weights: [400, 500, 700],
147
+ weights: ["400..700"],
141
148
  },
142
- lora: { category: "serif", family: "Lora", weights: [400, 500, 600, 700] },
149
+ lora: { category: "serif", family: "Lora", weights: ["400..700"] },
143
150
  manrope: {
144
151
  category: "sans",
145
152
  family: "Manrope",
146
- weights: [400, 500, 600, 700],
153
+ weights: ["400..700"],
147
154
  },
148
155
  merriweather: {
149
156
  category: "serif",
150
157
  family: "Merriweather",
151
- weights: [400, 700],
158
+ weights: ["400..700"],
152
159
  },
153
160
  "open-sans": {
154
161
  category: "sans",
155
162
  family: "Open Sans",
156
- weights: [400, 600, 700],
163
+ weights: ["400..700"],
157
164
  },
158
165
  "playfair-display": {
159
166
  category: "serif",
160
167
  family: "Playfair Display",
161
- weights: [400, 500, 700],
168
+ weights: ["400..700"],
162
169
  },
163
170
  "plus-jakarta-sans": {
164
171
  category: "sans",
165
172
  family: "Plus Jakarta Sans",
166
- weights: [400, 500, 600, 700],
173
+ weights: ["400..700"],
167
174
  },
168
- roboto: { category: "sans", family: "Roboto", weights: [400, 500, 700] },
175
+ roboto: { category: "sans", family: "Roboto", weights: ["400..700"] },
169
176
  "roboto-mono": {
170
177
  category: "mono",
171
178
  family: "Roboto Mono",
172
- weights: [400, 500, 700],
179
+ weights: ["400..700"],
173
180
  },
174
181
  "source-code-pro": {
175
182
  category: "mono",
176
183
  family: "Source Code Pro",
177
- weights: [400, 500, 600],
184
+ weights: ["400..600"],
178
185
  },
179
186
  "source-sans-3": {
180
187
  category: "sans",
181
188
  family: "Source Sans 3",
182
- weights: [400, 600, 700],
189
+ weights: ["400..700"],
183
190
  },
184
191
  "source-serif-4": {
185
192
  category: "serif",
186
193
  family: "Source Serif 4",
187
- weights: [400, 600, 700],
194
+ weights: ["400..700"],
188
195
  },
189
196
  "space-grotesk": {
190
197
  category: "sans",
191
198
  family: "Space Grotesk",
192
- weights: [400, 500, 700],
199
+ weights: ["400..700"],
193
200
  },
194
201
  "space-mono": { category: "mono", family: "Space Mono", weights: [400, 700] },
195
202
  "work-sans": {
196
203
  category: "sans",
197
204
  family: "Work Sans",
198
- weights: [400, 500, 600],
205
+ weights: ["400..600"],
199
206
  },
200
207
  } satisfies Record<string, FontDef>;
201
208