blume 1.6.5 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (179) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/bin/blume.mjs +3 -2
  3. package/dist/cli/chunk-0qhq7b8q.js +111 -0
  4. package/dist/cli/chunk-0qhq7b8q.js.map +11 -0
  5. package/dist/cli/chunk-18tjv4f7.js +96 -0
  6. package/dist/cli/chunk-18tjv4f7.js.map +10 -0
  7. package/dist/cli/chunk-27gtm2ym.js +69 -0
  8. package/dist/cli/chunk-27gtm2ym.js.map +11 -0
  9. package/dist/cli/chunk-2aj8ddew.js +72 -0
  10. package/dist/cli/chunk-2aj8ddew.js.map +10 -0
  11. package/dist/cli/chunk-3r94j3tc.js +221 -0
  12. package/dist/cli/chunk-3r94j3tc.js.map +10 -0
  13. package/dist/cli/chunk-4trphnvy.js +102 -0
  14. package/dist/cli/chunk-4trphnvy.js.map +11 -0
  15. package/dist/cli/chunk-4xyggvgf.js +21 -0
  16. package/dist/cli/chunk-4xyggvgf.js.map +10 -0
  17. package/dist/cli/chunk-5d4q7121.js +4064 -0
  18. package/dist/cli/chunk-5d4q7121.js.map +40 -0
  19. package/dist/cli/chunk-5hs6gb7n.js +32 -0
  20. package/dist/cli/chunk-5hs6gb7n.js.map +10 -0
  21. package/dist/cli/chunk-6kzzpsx8.js +26 -0
  22. package/dist/cli/chunk-6kzzpsx8.js.map +10 -0
  23. package/dist/cli/chunk-8gnpdsn1.js +952 -0
  24. package/dist/cli/chunk-8gnpdsn1.js.map +12 -0
  25. package/dist/cli/chunk-9qs6acpw.js +176 -0
  26. package/dist/cli/chunk-9qs6acpw.js.map +10 -0
  27. package/dist/cli/chunk-agy5rzxy.js +2453 -0
  28. package/dist/cli/chunk-agy5rzxy.js.map +15 -0
  29. package/dist/cli/chunk-bcy492zc.js +16 -0
  30. package/dist/cli/chunk-bcy492zc.js.map +10 -0
  31. package/dist/cli/chunk-btfr9yvw.js +41 -0
  32. package/dist/cli/chunk-btfr9yvw.js.map +10 -0
  33. package/dist/cli/chunk-cbjnx4s8.js +73 -0
  34. package/dist/cli/chunk-cbjnx4s8.js.map +10 -0
  35. package/dist/cli/chunk-cfw6x4rm.js +1967 -0
  36. package/dist/cli/chunk-cfw6x4rm.js.map +34 -0
  37. package/dist/cli/chunk-ckh3a410.js +277 -0
  38. package/dist/cli/chunk-ckh3a410.js.map +11 -0
  39. package/dist/cli/chunk-drke6t0h.js +259 -0
  40. package/dist/cli/chunk-drke6t0h.js.map +11 -0
  41. package/dist/cli/chunk-ev67ycx0.js +15 -0
  42. package/dist/cli/chunk-ev67ycx0.js.map +10 -0
  43. package/dist/cli/chunk-ey89bjj1.js +209 -0
  44. package/dist/cli/chunk-ey89bjj1.js.map +11 -0
  45. package/dist/cli/chunk-j6pxe0dt.js +69 -0
  46. package/dist/cli/chunk-j6pxe0dt.js.map +11 -0
  47. package/dist/cli/chunk-jk1zwka1.js +387 -0
  48. package/dist/cli/chunk-jk1zwka1.js.map +12 -0
  49. package/dist/cli/chunk-jtb45atp.js +467 -0
  50. package/dist/cli/chunk-jtb45atp.js.map +14 -0
  51. package/dist/cli/chunk-jxkxjsc1.js +76 -0
  52. package/dist/cli/chunk-jxkxjsc1.js.map +10 -0
  53. package/dist/cli/chunk-kwx90v78.js +81 -0
  54. package/dist/cli/chunk-kwx90v78.js.map +10 -0
  55. package/dist/cli/chunk-n0nyat6g.js +30 -0
  56. package/dist/cli/chunk-n0nyat6g.js.map +10 -0
  57. package/dist/cli/chunk-pxj10x8y.js +35 -0
  58. package/dist/cli/chunk-pxj10x8y.js.map +10 -0
  59. package/dist/cli/chunk-qq9nm3qd.js +1141 -0
  60. package/dist/cli/chunk-qq9nm3qd.js.map +19 -0
  61. package/dist/cli/chunk-s102bysw.js +5170 -0
  62. package/dist/cli/chunk-s102bysw.js.map +47 -0
  63. package/dist/cli/chunk-s5dsk8bj.js +769 -0
  64. package/dist/cli/chunk-s5dsk8bj.js.map +13 -0
  65. package/dist/cli/chunk-s5e5jt53.js +227 -0
  66. package/dist/cli/chunk-s5e5jt53.js.map +11 -0
  67. package/dist/cli/chunk-sbdqrjbb.js +81 -0
  68. package/dist/cli/chunk-sbdqrjbb.js.map +10 -0
  69. package/dist/cli/chunk-tnskyrej.js +117 -0
  70. package/dist/cli/chunk-tnskyrej.js.map +10 -0
  71. package/dist/cli/chunk-v2ymm99c.js +1016 -0
  72. package/dist/cli/chunk-v2ymm99c.js.map +13 -0
  73. package/dist/cli/chunk-v5mm027v.js +185 -0
  74. package/dist/cli/chunk-v5mm027v.js.map +11 -0
  75. package/dist/cli/chunk-vt8fgygt.js +23 -0
  76. package/dist/cli/chunk-vt8fgygt.js.map +10 -0
  77. package/dist/cli/chunk-vxv4x1n8.js +17 -0
  78. package/dist/cli/chunk-vxv4x1n8.js.map +10 -0
  79. package/dist/cli/chunk-wd27zjcz.js +60 -0
  80. package/dist/cli/chunk-wd27zjcz.js.map +10 -0
  81. package/dist/cli/chunk-x66c5yjn.js +23 -0
  82. package/dist/cli/chunk-x66c5yjn.js.map +10 -0
  83. package/dist/cli/chunk-xv91q4nm.js +5314 -0
  84. package/dist/cli/chunk-xv91q4nm.js.map +58 -0
  85. package/dist/cli/chunk-y3g15rvv.js +679 -0
  86. package/dist/cli/chunk-y3g15rvv.js.map +15 -0
  87. package/dist/cli/chunk-ye9zdkgv.js +136 -0
  88. package/dist/cli/chunk-ye9zdkgv.js.map +10 -0
  89. package/dist/cli/chunk-ynacq3ev.js +1062 -0
  90. package/dist/cli/chunk-ynacq3ev.js.map +25 -0
  91. package/dist/cli/chunk-zr3ygrq3.js +54 -0
  92. package/dist/cli/chunk-zr3ygrq3.js.map +10 -0
  93. package/dist/cli/index.js +55 -27597
  94. package/dist/cli/index.js.map +5 -243
  95. package/dist/types/ai/ask-context.d.ts +26 -0
  96. package/dist/types/components/layout/nav-utils.d.ts +33 -1
  97. package/dist/types/core/code-fences.d.ts +11 -0
  98. package/dist/types/core/package-root.d.ts +1 -1
  99. package/dist/types/core/schema.d.ts +70 -0
  100. package/dist/types/theme/fonts.d.ts +22 -22
  101. package/docs/02-deployment.mdx +22 -1
  102. package/docs/configuration/ask-ai.mdx +1 -1
  103. package/docs/configuration/customization.mdx +2 -9
  104. package/docs/content/navigation.mdx +2 -0
  105. package/docs/content/syntax.mdx +1 -1
  106. package/docs/discoverability/open-graph.mdx +4 -0
  107. package/docs/reference/cli.mdx +1 -1
  108. package/package.json +4 -2
  109. package/src/ai/api/handlers.ts +4 -7
  110. package/src/ai/api/paths.ts +8 -0
  111. package/src/ai/api/spec.ts +2 -1
  112. package/src/ai/ask-context.ts +378 -22
  113. package/src/astro/generate.ts +161 -28
  114. package/src/astro/include-hmr.ts +10 -13
  115. package/src/astro/include-refresh.ts +0 -0
  116. package/src/astro/index.ts +6 -1
  117. package/src/astro/integration.ts +280 -53
  118. package/src/astro/module-types.ts +83 -0
  119. package/src/astro/templates.ts +256 -108
  120. package/src/audit/image-size.ts +10 -8
  121. package/src/cli/command-meta.ts +77 -0
  122. package/src/cli/commands/add.ts +2 -4
  123. package/src/cli/commands/audit.ts +2 -4
  124. package/src/cli/commands/build.ts +70 -346
  125. package/src/cli/commands/check.ts +2 -4
  126. package/src/cli/commands/dev.ts +31 -42
  127. package/src/cli/commands/doctor.ts +2 -4
  128. package/src/cli/commands/eject.ts +3 -41
  129. package/src/cli/commands/eval.ts +2 -5
  130. package/src/cli/commands/init.ts +2 -4
  131. package/src/cli/commands/mcp-stdio.ts +2 -5
  132. package/src/cli/commands/preview.ts +3 -5
  133. package/src/cli/commands/sync.ts +2 -4
  134. package/src/cli/commands/translate.ts +2 -5
  135. package/src/cli/commands/validate.ts +2 -4
  136. package/src/cli/commands/version.ts +2 -4
  137. package/src/cli/eject-scripts.ts +0 -45
  138. package/src/cli/host-args.ts +16 -0
  139. package/src/cli/index.ts +84 -35
  140. package/src/cli/lazy-command.ts +47 -0
  141. package/src/components/Icon.astro +24 -0
  142. package/src/components/content/GithubInfo.astro +4 -1
  143. package/src/components/icon-sprite-middleware.ts +41 -0
  144. package/src/components/icon-sprite.ts +93 -0
  145. package/src/components/layout/IconSprite.astro +11 -0
  146. package/src/components/layout/NavTree.astro +156 -188
  147. package/src/components/layout/NavTreeCache.astro +45 -0
  148. package/src/components/layout/NavTreeScript.astro +256 -0
  149. package/src/components/layout/PageActions.astro +11 -5
  150. package/src/components/layout/PageLayout.astro +21 -3
  151. package/src/components/layout/ReferenceLayout.astro +21 -4
  152. package/src/components/layout/RootLayout.astro +44 -6
  153. package/src/components/layout/nav-cache.ts +49 -0
  154. package/src/components/layout/nav-utils.ts +69 -1
  155. package/src/components/layout/page-locale.ts +29 -0
  156. package/src/core/api-name.ts +18 -0
  157. package/src/core/code-fences.ts +48 -0
  158. package/src/core/content-assets.ts +3 -7
  159. package/src/core/includes.ts +3 -7
  160. package/src/core/package-root.ts +1 -1
  161. package/src/core/schema.ts +19 -0
  162. package/src/core/sources/normalize.ts +2 -37
  163. package/src/core/sources/obsidian.ts +3 -2
  164. package/src/core/svg-dimensions.ts +97 -0
  165. package/src/core/version-cut.ts +2 -2
  166. package/src/deploy/artifacts.ts +370 -0
  167. package/src/deploy/cloudflare-negotiation.ts +97 -32
  168. package/src/deploy/function-bundle.ts +66 -20
  169. package/src/deploy/sitemap.ts +6 -0
  170. package/src/deploy/vercel-negotiation.ts +8 -30
  171. package/src/markdown/language-icon.ts +64 -20
  172. package/src/markdown/mermaid.ts +11 -0
  173. package/src/og/cache.ts +236 -0
  174. package/src/og/card.ts +18 -16
  175. package/src/og/index.ts +8 -1
  176. package/src/openapi/render-mdx.ts +9 -5
  177. package/src/registry/eject.ts +23 -10
  178. package/src/theme/entry.ts +41 -7
  179. package/src/theme/fonts.ts +30 -23
@@ -2,6 +2,7 @@ import { existsSync, readFileSync } from "node:fs";
2
2
  import { readdir, readFile } from "node:fs/promises";
3
3
  import { builtinModules } from "node:module";
4
4
 
5
+ import { init, parse } from "es-module-lexer";
5
6
  import { dirname, join, relative } from "pathe";
6
7
  import { z } from "zod";
7
8
 
@@ -65,32 +66,76 @@ export const packageName = (specifier: string): string | null => {
65
66
  };
66
67
 
67
68
  /**
68
- * Static specifiers: a side-effect `import "x"` or an `import`/`export … from
69
- * "x"` clause. Only `import` takes the bare-string form — `export "x"` is not
70
- * syntax, so a runtime message quoting `export 'ALL'` must not match. The
71
- * specifier quote must not be escaped either: a backslash-quoted `from \"zod\"`
72
- * is a code sample serialized into a string (the MCP snapshot carries page
73
- * Markdown), not module syntax.
69
+ * Fallback static specifiers for a module the lexer rejects: a side-effect
70
+ * `import "x"` or an `import`/`export … from "x"` clause. Only `import` takes
71
+ * the bare-string form — `export "x"` is not syntax, so a runtime message
72
+ * quoting `export 'ALL'` must not match.
74
73
  */
75
74
  const STATIC_IMPORT =
76
- /(?:^|[;\s}])(?:import\s*(?<!\\)["'](?<bare>[^"'\n]+)["']|(?:import|export)\s*[\w$*{},\s]*?\s*from\s*(?<!\\)["'](?<from>[^"'\n]+)["'])/gu;
75
+ /(?:^|[;\s}])(?:import\s*["'](?<bare>[^"'\n]+)["']|(?:import|export)\s*[\w$*{},\s]*?\s*from\s*["'](?<from>[^"'\n]+)["'])/gu;
77
76
 
78
- /** Dynamic `import("…")` specifiers, same escaped-quote rule. */
79
- const DYNAMIC_IMPORT =
80
- /\bimport\(\s*(?<!\\)["'](?<dynamic>[^"'\n]+)["']\s*\)/gu;
77
+ /** Fallback dynamic `import("…")` specifiers. */
78
+ const DYNAMIC_IMPORT = /\bimport\(\s*["'](?<dynamic>[^"'\n]+)["']\s*\)/gu;
81
79
 
82
- /** Every bare package name a module's source imports. */
83
- export const importedPackages = (source: string): string[] => {
84
- const names = new Set<string>();
80
+ /**
81
+ * Textual best effort for a module `es-module-lexer` cannot parse: every
82
+ * quoted specifier in import position, string contents included.
83
+ */
84
+ const scannedSpecifiers = (source: string): string[] => {
85
+ const specifiers: string[] = [];
85
86
  for (const pattern of [STATIC_IMPORT, DYNAMIC_IMPORT]) {
86
87
  for (const match of source.matchAll(pattern)) {
87
88
  const groups = match.groups ?? {};
88
- const name = packageName(
89
- groups.bare ?? groups.from ?? groups.dynamic ?? ""
90
- );
91
- if (name) {
92
- names.add(name);
93
- }
89
+ specifiers.push(groups.bare ?? groups.from ?? groups.dynamic ?? "");
90
+ }
91
+ }
92
+ return specifiers;
93
+ };
94
+
95
+ /**
96
+ * Every specifier a module's syntax imports, read with `es-module-lexer` so
97
+ * text inside string literals never counts. That matters for Blume's data
98
+ * chunks: the MCP snapshot is `JSON.parse("…")` over every page's Markdown,
99
+ * and a code sample there reading `import { config } from 'dotenv'` is prose
100
+ * to a bundle audit, not a module the function needs. `import.meta` carries no
101
+ * specifier and a template-literal `import(\`pkg/${x}\`)` is a glob the
102
+ * bundler already resolved, so neither names a package.
103
+ */
104
+ const lexedSpecifiers = (source: string, name: string): string[] => {
105
+ const [imports] = parse(source, name);
106
+ const specifiers: string[] = [];
107
+ for (const entry of imports) {
108
+ if (entry.type === "dynamic" && entry.glob) {
109
+ continue;
110
+ }
111
+ if (entry.specifier) {
112
+ specifiers.push(entry.specifier);
113
+ }
114
+ }
115
+ return specifiers;
116
+ };
117
+
118
+ /**
119
+ * Every bare package name a module's source imports. A module the lexer
120
+ * rejects (an unterminated string, an invalid escape in a specifier) falls
121
+ * back to the textual scan rather than going unaudited.
122
+ */
123
+ export const importedPackages = async (
124
+ source: string,
125
+ name = "module"
126
+ ): Promise<string[]> => {
127
+ await init();
128
+ let specifiers: string[];
129
+ try {
130
+ specifiers = lexedSpecifiers(source, name);
131
+ } catch {
132
+ specifiers = scannedSpecifiers(source);
133
+ }
134
+ const names = new Set<string>();
135
+ for (const specifier of specifiers) {
136
+ const packageId = packageName(specifier);
137
+ if (packageId) {
138
+ names.add(packageId);
94
139
  }
95
140
  }
96
141
  return [...names];
@@ -164,7 +209,8 @@ export const auditFunctionBundle = async (
164
209
  for (const file of await listModules(serverDir)) {
165
210
  // oxlint-disable-next-line no-await-in-loop -- sequential read keeps the importer lists ordered
166
211
  const source = await readFile(file, "utf-8");
167
- for (const name of importedPackages(source)) {
212
+ // oxlint-disable-next-line no-await-in-loop -- the lexer runs per file, in the same order
213
+ for (const name of await importedPackages(source, file)) {
168
214
  if (resolvable(name, dirname(file), funcDir)) {
169
215
  continue;
170
216
  }
@@ -36,6 +36,12 @@ export interface SitemapFile {
36
36
  xml: string;
37
37
  }
38
38
 
39
+ /** The build log line for an emitted sitemap set: one file, or an index. */
40
+ export const describeSitemapFiles = (files: readonly SitemapFile[]): string =>
41
+ files.length === 1
42
+ ? "Generated sitemap.xml"
43
+ : `Generated sitemap.xml (index of ${files.length - 1} sitemap files)`;
44
+
39
45
  /**
40
46
  * The sitemaps.org cap on `<url>` entries in a single file. Beyond it,
41
47
  * `sitemap.xml` becomes a sitemap index pointing at numbered chunk files —
@@ -235,21 +235,6 @@ export const buildNegotiationRoutes = (
235
235
  /** The `src` of the injected homepage `Link` header route. */
236
236
  const HOME_SRC = "^/$";
237
237
 
238
- /**
239
- * Permanent redirect from any trailing-slash URL to its slashless twin, so
240
- * `/docs/` and `/docs` don't serve as duplicate URLs (canonicals, sitemap, and
241
- * hreflang all use the slashless form; the root `/` is untouched — `.+`
242
- * requires a non-empty path). Spliced into the main phase before `handle:
243
- * "filesystem"`, after the Markdown rewrites, so an agent's `Accept:
244
- * text/markdown` request on a slashed URL still rewrites without the extra
245
- * hop. Vercel carries the query string over to the `Location` target itself.
246
- */
247
- export const TRAILING_SLASH_REDIRECT: VercelRoute = {
248
- headers: { Location: "/$1" },
249
- src: "^/(.+)/$",
250
- status: 308,
251
- };
252
-
253
238
  /**
254
239
  * Whether a route is one this module previously injected, so re-injection
255
240
  * replaces rather than duplicates. Rewrites are identified by their `accept`
@@ -273,9 +258,7 @@ const isNegotiationRoute = (route: VercelRoute): boolean =>
273
258
  (route.continue === true &&
274
259
  isString(route.headers?.link) &&
275
260
  route.src === HOME_SRC &&
276
- Object.keys(route).length === 3) ||
277
- (route.status === TRAILING_SLASH_REDIRECT.status &&
278
- route.src === TRAILING_SLASH_REDIRECT.src);
261
+ Object.keys(route).length === 3);
279
262
 
280
263
  /**
281
264
  * Splice the negotiation routes into a Build Output `config.json`, plus — when
@@ -285,9 +268,11 @@ const isNegotiationRoute = (route: VercelRoute): boolean =>
285
268
  * rides on the prerendered homepage response. `contentTypeOverrides` maps static-dir
286
269
  * relative paths to media types via the Build Output `overrides` field — the
287
270
  * platform's mechanism for extensionless static files (e.g. the Web Bot Auth
288
- * signature directory). The trailing-slash 308 redirect is always spliced in
289
- * alongside, so slashed duplicates of every page collapse onto the canonical
290
- * slashless URL. For each 404 twin the build emitted (`notFound.markdown` for
271
+ * signature directory). The trailing-slash redirect that collapses `/docs/`
272
+ * onto `/docs` is not spliced here: the generated config sets Astro's
273
+ * `trailingSlash: "never"`, which the adapter turns into the platform's own
274
+ * 308 route ahead of everything below (so a slashed Markdown request takes
275
+ * that hop first, then negotiates). For each 404 twin the build emitted (`notFound.markdown` for
291
276
  * `404.md`, `notFound.json` for `404.json`), its routes go into the miss
292
277
  * phase right before the adapter's `/404.html` fallback — and nowhere when
293
278
  * that fallback is absent, since a `dest` with no file behind it would serve
@@ -342,15 +327,8 @@ export const injectNegotiationRoutes = (
342
327
  }
343
328
  // Headers first: `continue` routes accumulate, so a request the rewrite
344
329
  // route then terminates (Markdown negotiation on the homepage) still carries
345
- // the Link header. The trailing-slash redirect goes last so a slashed URL's
346
- // Markdown negotiation still rewrites directly instead of bouncing.
347
- routes.splice(
348
- filesystemIndex,
349
- 0,
350
- ...headerRoutes,
351
- ...rewriteRoutes,
352
- TRAILING_SLASH_REDIRECT
353
- );
330
+ // the Link header.
331
+ routes.splice(filesystemIndex, 0, ...headerRoutes, ...rewriteRoutes);
354
332
  const notFoundRoutes = [
355
333
  ...(notFound.markdown ? NOT_FOUND_MARKDOWN_ROUTES : []),
356
334
  ...(notFound.json ? NOT_FOUND_JSON_ROUTES : []),
@@ -49,9 +49,10 @@ import {
49
49
  siYaml,
50
50
  } from "simple-icons";
51
51
 
52
- /** The slice of a `simple-icons` icon Blume reads (the SVG path data). */
52
+ /** The slice of a `simple-icons` icon Blume reads: its slug and path data. */
53
53
  interface SimpleIcon {
54
54
  path: string;
55
+ slug: string;
55
56
  }
56
57
 
57
58
  /** Fence language (and common aliases) → icon. Unmapped languages get none. */
@@ -146,23 +147,6 @@ export interface LanguageIconTransformer {
146
147
  pre: (this: IconContext, node: IconPreNode) => void;
147
148
  }
148
149
 
149
- /** Build an inline SVG hast node from a simple-icons path. */
150
- const iconNode = (path: string): HastNode => ({
151
- children: [
152
- { children: [], properties: { d: path }, tagName: "path", type: "element" },
153
- ],
154
- properties: {
155
- ariaHidden: "true",
156
- className: ["blume-lang-icon"],
157
- fill: "currentColor",
158
- height: 14,
159
- viewBox: "0 0 24 24",
160
- width: 14,
161
- },
162
- tagName: "svg",
163
- type: "element",
164
- });
165
-
166
150
  /** Build the transformer. Runs after Shiki's built-in `data-language` hook. */
167
151
  export const languageIconTransformer = (): LanguageIconTransformer => ({
168
152
  name: "blume:language-icon",
@@ -171,7 +155,67 @@ export const languageIconTransformer = (): LanguageIconTransformer => ({
171
155
  if (!icon) {
172
156
  return;
173
157
  }
174
- node.children.unshift(iconNode(icon.path));
175
- node.properties.dataIcon = "";
158
+ // The icon itself is CSS: the theme paints `pre[data-icon="<slug>"]::after`
159
+ // with the brand path as a mask (see `languageIconCss`), so a block
160
+ // carries a short attribute instead of ~1 kB of SVG — on a reference page
161
+ // with twenty TypeScript blocks, the difference is most of the page.
162
+ node.properties.dataIcon = icon.slug;
176
163
  },
177
164
  });
165
+
166
+ /** The icon slug for a fence language, or null for an unmapped language. */
167
+ export const languageIconSlug = (language: string): string | null =>
168
+ LANGUAGE_ICONS[language.toLowerCase()]?.slug ?? null;
169
+
170
+ // Fence openers (```ts, ~~~tsx) and the `lang`/`language` props of code
171
+ // components (<CodeBlock lang="ts">), which highlight through the same
172
+ // transformer. Word characters plus the few punctuation marks languages use.
173
+ const FENCE_LANGUAGE = /^[ \t]*(?:`{3,}|~{3,})[ \t]*(?<lang>[\w+#.-]+)/gmu;
174
+ const PROP_LANGUAGE = /\blang(?:uage)?=["'](?<lang>[\w+#.-]+)["']/gu;
175
+
176
+ /**
177
+ * The icon slugs a site's Markdown uses, sorted and deduped, so the theme
178
+ * carries a mask rule for each of them and none for the other thirty.
179
+ */
180
+ export const languageIconSlugsIn = (markdown: string): string[] => {
181
+ const slugs = new Set<string>();
182
+ for (const pattern of [FENCE_LANGUAGE, PROP_LANGUAGE]) {
183
+ for (const match of markdown.matchAll(pattern)) {
184
+ const slug = languageIconSlug(match.groups?.lang ?? "");
185
+ if (slug) {
186
+ slugs.add(slug);
187
+ }
188
+ }
189
+ }
190
+ return [...slugs].toSorted();
191
+ };
192
+
193
+ const iconBySlug = (slug: string): SimpleIcon | undefined =>
194
+ Object.values(LANGUAGE_ICONS).find((icon) => icon.slug === slug);
195
+
196
+ /** A simple-icons path as a `mask-image` data URI (24×24 viewBox). */
197
+ const maskUri = (path: string): string =>
198
+ `url("data:image/svg+xml,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="${path}"/></svg>`)}")`;
199
+
200
+ /**
201
+ * The per-language rules that paint a code block's icon: each gives the
202
+ * block's `::after` (positioned by the theme) the brand path as a mask over
203
+ * the muted foreground. Only the listed slugs get a rule, so an unmapped or
204
+ * unused language paints nothing rather than a blank square.
205
+ */
206
+ export const languageIconCss = (slugs: string[]): string =>
207
+ slugs
208
+ .map((slug) => {
209
+ const icon = iconBySlug(slug);
210
+ if (!icon) {
211
+ return "";
212
+ }
213
+ const mask = maskUri(icon.path);
214
+ return `.prose > :where(pre[data-language][data-icon="${slug}"])::after {
215
+ background-color: var(--blume-muted-foreground);
216
+ -webkit-mask-image: ${mask};
217
+ mask-image: ${mask};
218
+ }`;
219
+ })
220
+ .filter((rule) => rule !== "")
221
+ .join("\n");
@@ -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
@@ -1,12 +1,14 @@
1
1
  import { readFile } from "node:fs/promises";
2
2
 
3
- import { imageSize } from "image-size";
4
3
  import { render } from "takumi-js";
5
4
  import type { RenderOptions } from "takumi-js";
6
5
  import { container, googleFonts, image, text } from "takumi-js/helpers";
7
6
  import type { FontSubset, GoogleFontFamily, Node } from "takumi-js/helpers";
8
7
 
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
@@ -234,19 +232,13 @@ const MARK_HEIGHT = 32;
234
232
  const MARK_MAX_WIDTH = 240;
235
233
  /**
236
234
  * The SVG's aspect ratio (w/h), or null when no usable dimensions exist (the
237
- * caller falls back to a square mark). image-size (already a dependency)
238
- * reads explicit width/height and falls back to the viewBox, tolerating the
239
- * quote/whitespace/attribute spellings the old regex silently missed —
240
- * `viewBox = "…"`, newline-separated values — which shipped visibly-squashed
241
- * marks instead of failing loudly.
235
+ * caller falls back to a square mark). The shared root-tag parser reads
236
+ * explicit width/height and falls back to the viewBox, so the card and the
237
+ * header measure one logo the same way.
242
238
  */
243
239
  const logoAspect = (svg: string): number | null => {
244
- try {
245
- const { height, width } = imageSize(Buffer.from(svg));
246
- return width && height ? width / height : null;
247
- } catch {
248
- return null;
249
- }
240
+ const size = svgDimensions(svg);
241
+ return size ? size.width / size.height : null;
250
242
  };
251
243
 
252
244
  // Render the configured logo as the brand mark. A `currentColor` logo carries
@@ -424,3 +416,13 @@ export const renderOgImage = async (
424
416
  width: WIDTH,
425
417
  });
426
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,