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.
- package/CHANGELOG.md +39 -0
- package/bin/blume.mjs +3 -2
- package/dist/cli/chunk-0qhq7b8q.js +111 -0
- package/dist/cli/chunk-0qhq7b8q.js.map +11 -0
- package/dist/cli/chunk-18tjv4f7.js +96 -0
- package/dist/cli/chunk-18tjv4f7.js.map +10 -0
- package/dist/cli/chunk-27gtm2ym.js +69 -0
- package/dist/cli/chunk-27gtm2ym.js.map +11 -0
- package/dist/cli/chunk-2aj8ddew.js +72 -0
- package/dist/cli/chunk-2aj8ddew.js.map +10 -0
- package/dist/cli/chunk-3r94j3tc.js +221 -0
- package/dist/cli/chunk-3r94j3tc.js.map +10 -0
- package/dist/cli/chunk-4trphnvy.js +102 -0
- package/dist/cli/chunk-4trphnvy.js.map +11 -0
- package/dist/cli/chunk-4xyggvgf.js +21 -0
- package/dist/cli/chunk-4xyggvgf.js.map +10 -0
- package/dist/cli/chunk-5d4q7121.js +4064 -0
- package/dist/cli/chunk-5d4q7121.js.map +40 -0
- package/dist/cli/chunk-5hs6gb7n.js +32 -0
- package/dist/cli/chunk-5hs6gb7n.js.map +10 -0
- package/dist/cli/chunk-6kzzpsx8.js +26 -0
- package/dist/cli/chunk-6kzzpsx8.js.map +10 -0
- package/dist/cli/chunk-8gnpdsn1.js +952 -0
- package/dist/cli/chunk-8gnpdsn1.js.map +12 -0
- package/dist/cli/chunk-9qs6acpw.js +176 -0
- package/dist/cli/chunk-9qs6acpw.js.map +10 -0
- package/dist/cli/chunk-agy5rzxy.js +2453 -0
- package/dist/cli/chunk-agy5rzxy.js.map +15 -0
- package/dist/cli/chunk-bcy492zc.js +16 -0
- package/dist/cli/chunk-bcy492zc.js.map +10 -0
- package/dist/cli/chunk-btfr9yvw.js +41 -0
- package/dist/cli/chunk-btfr9yvw.js.map +10 -0
- package/dist/cli/chunk-cbjnx4s8.js +73 -0
- package/dist/cli/chunk-cbjnx4s8.js.map +10 -0
- package/dist/cli/chunk-cfw6x4rm.js +1967 -0
- package/dist/cli/chunk-cfw6x4rm.js.map +34 -0
- package/dist/cli/chunk-ckh3a410.js +277 -0
- package/dist/cli/chunk-ckh3a410.js.map +11 -0
- package/dist/cli/chunk-drke6t0h.js +259 -0
- package/dist/cli/chunk-drke6t0h.js.map +11 -0
- package/dist/cli/chunk-ev67ycx0.js +15 -0
- package/dist/cli/chunk-ev67ycx0.js.map +10 -0
- package/dist/cli/chunk-ey89bjj1.js +209 -0
- package/dist/cli/chunk-ey89bjj1.js.map +11 -0
- package/dist/cli/chunk-j6pxe0dt.js +69 -0
- package/dist/cli/chunk-j6pxe0dt.js.map +11 -0
- package/dist/cli/chunk-jk1zwka1.js +387 -0
- package/dist/cli/chunk-jk1zwka1.js.map +12 -0
- package/dist/cli/chunk-jtb45atp.js +467 -0
- package/dist/cli/chunk-jtb45atp.js.map +14 -0
- package/dist/cli/chunk-jxkxjsc1.js +76 -0
- package/dist/cli/chunk-jxkxjsc1.js.map +10 -0
- package/dist/cli/chunk-kwx90v78.js +81 -0
- package/dist/cli/chunk-kwx90v78.js.map +10 -0
- package/dist/cli/chunk-n0nyat6g.js +30 -0
- package/dist/cli/chunk-n0nyat6g.js.map +10 -0
- package/dist/cli/chunk-pxj10x8y.js +35 -0
- package/dist/cli/chunk-pxj10x8y.js.map +10 -0
- package/dist/cli/chunk-qq9nm3qd.js +1141 -0
- package/dist/cli/chunk-qq9nm3qd.js.map +19 -0
- package/dist/cli/chunk-s102bysw.js +5170 -0
- package/dist/cli/chunk-s102bysw.js.map +47 -0
- package/dist/cli/chunk-s5dsk8bj.js +769 -0
- package/dist/cli/chunk-s5dsk8bj.js.map +13 -0
- package/dist/cli/chunk-s5e5jt53.js +227 -0
- package/dist/cli/chunk-s5e5jt53.js.map +11 -0
- package/dist/cli/chunk-sbdqrjbb.js +81 -0
- package/dist/cli/chunk-sbdqrjbb.js.map +10 -0
- package/dist/cli/chunk-tnskyrej.js +117 -0
- package/dist/cli/chunk-tnskyrej.js.map +10 -0
- package/dist/cli/chunk-v2ymm99c.js +1016 -0
- package/dist/cli/chunk-v2ymm99c.js.map +13 -0
- package/dist/cli/chunk-v5mm027v.js +185 -0
- package/dist/cli/chunk-v5mm027v.js.map +11 -0
- package/dist/cli/chunk-vt8fgygt.js +23 -0
- package/dist/cli/chunk-vt8fgygt.js.map +10 -0
- package/dist/cli/chunk-vxv4x1n8.js +17 -0
- package/dist/cli/chunk-vxv4x1n8.js.map +10 -0
- package/dist/cli/chunk-wd27zjcz.js +60 -0
- package/dist/cli/chunk-wd27zjcz.js.map +10 -0
- package/dist/cli/chunk-x66c5yjn.js +23 -0
- package/dist/cli/chunk-x66c5yjn.js.map +10 -0
- package/dist/cli/chunk-xv91q4nm.js +5314 -0
- package/dist/cli/chunk-xv91q4nm.js.map +58 -0
- package/dist/cli/chunk-y3g15rvv.js +679 -0
- package/dist/cli/chunk-y3g15rvv.js.map +15 -0
- package/dist/cli/chunk-ye9zdkgv.js +136 -0
- package/dist/cli/chunk-ye9zdkgv.js.map +10 -0
- package/dist/cli/chunk-ynacq3ev.js +1062 -0
- package/dist/cli/chunk-ynacq3ev.js.map +25 -0
- package/dist/cli/chunk-zr3ygrq3.js +54 -0
- package/dist/cli/chunk-zr3ygrq3.js.map +10 -0
- package/dist/cli/index.js +55 -27597
- package/dist/cli/index.js.map +5 -243
- package/dist/types/ai/ask-context.d.ts +26 -0
- package/dist/types/components/layout/nav-utils.d.ts +33 -1
- package/dist/types/core/code-fences.d.ts +11 -0
- package/dist/types/core/package-root.d.ts +1 -1
- package/dist/types/core/schema.d.ts +70 -0
- package/dist/types/theme/fonts.d.ts +22 -22
- package/docs/02-deployment.mdx +22 -1
- package/docs/configuration/ask-ai.mdx +1 -1
- package/docs/configuration/customization.mdx +2 -9
- package/docs/content/navigation.mdx +2 -0
- package/docs/content/syntax.mdx +1 -1
- package/docs/discoverability/open-graph.mdx +4 -0
- package/docs/reference/cli.mdx +1 -1
- package/package.json +4 -2
- package/src/ai/api/handlers.ts +4 -7
- package/src/ai/api/paths.ts +8 -0
- package/src/ai/api/spec.ts +2 -1
- package/src/ai/ask-context.ts +378 -22
- package/src/astro/generate.ts +161 -28
- package/src/astro/include-hmr.ts +10 -13
- package/src/astro/include-refresh.ts +0 -0
- package/src/astro/index.ts +6 -1
- package/src/astro/integration.ts +280 -53
- package/src/astro/module-types.ts +83 -0
- package/src/astro/templates.ts +256 -108
- package/src/audit/image-size.ts +10 -8
- package/src/cli/command-meta.ts +77 -0
- package/src/cli/commands/add.ts +2 -4
- package/src/cli/commands/audit.ts +2 -4
- package/src/cli/commands/build.ts +70 -346
- package/src/cli/commands/check.ts +2 -4
- package/src/cli/commands/dev.ts +31 -42
- package/src/cli/commands/doctor.ts +2 -4
- package/src/cli/commands/eject.ts +3 -41
- package/src/cli/commands/eval.ts +2 -5
- package/src/cli/commands/init.ts +2 -4
- package/src/cli/commands/mcp-stdio.ts +2 -5
- package/src/cli/commands/preview.ts +3 -5
- package/src/cli/commands/sync.ts +2 -4
- package/src/cli/commands/translate.ts +2 -5
- package/src/cli/commands/validate.ts +2 -4
- package/src/cli/commands/version.ts +2 -4
- package/src/cli/eject-scripts.ts +0 -45
- package/src/cli/host-args.ts +16 -0
- package/src/cli/index.ts +84 -35
- package/src/cli/lazy-command.ts +47 -0
- package/src/components/Icon.astro +24 -0
- package/src/components/content/GithubInfo.astro +4 -1
- package/src/components/icon-sprite-middleware.ts +41 -0
- package/src/components/icon-sprite.ts +93 -0
- package/src/components/layout/IconSprite.astro +11 -0
- package/src/components/layout/NavTree.astro +156 -188
- package/src/components/layout/NavTreeCache.astro +45 -0
- package/src/components/layout/NavTreeScript.astro +256 -0
- package/src/components/layout/PageActions.astro +11 -5
- package/src/components/layout/PageLayout.astro +21 -3
- package/src/components/layout/ReferenceLayout.astro +21 -4
- package/src/components/layout/RootLayout.astro +44 -6
- package/src/components/layout/nav-cache.ts +49 -0
- package/src/components/layout/nav-utils.ts +69 -1
- package/src/components/layout/page-locale.ts +29 -0
- package/src/core/api-name.ts +18 -0
- package/src/core/code-fences.ts +48 -0
- package/src/core/content-assets.ts +3 -7
- package/src/core/includes.ts +3 -7
- package/src/core/package-root.ts +1 -1
- package/src/core/schema.ts +19 -0
- package/src/core/sources/normalize.ts +2 -37
- package/src/core/sources/obsidian.ts +3 -2
- package/src/core/svg-dimensions.ts +97 -0
- package/src/core/version-cut.ts +2 -2
- package/src/deploy/artifacts.ts +370 -0
- package/src/deploy/cloudflare-negotiation.ts +97 -32
- package/src/deploy/function-bundle.ts +66 -20
- package/src/deploy/sitemap.ts +6 -0
- package/src/deploy/vercel-negotiation.ts +8 -30
- package/src/markdown/language-icon.ts +64 -20
- package/src/markdown/mermaid.ts +11 -0
- package/src/og/cache.ts +236 -0
- package/src/og/card.ts +18 -16
- package/src/og/index.ts +8 -1
- package/src/openapi/render-mdx.ts +9 -5
- package/src/registry/eject.ts +23 -10
- package/src/theme/entry.ts +41 -7
- 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
|
-
*
|
|
69
|
-
* "x"`
|
|
70
|
-
*
|
|
71
|
-
*
|
|
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*
|
|
75
|
+
/(?:^|[;\s}])(?:import\s*["'](?<bare>[^"'\n]+)["']|(?:import|export)\s*[\w$*{},\s]*?\s*from\s*["'](?<from>[^"'\n]+)["'])/gu;
|
|
77
76
|
|
|
78
|
-
/**
|
|
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
|
-
/**
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/deploy/sitemap.ts
CHANGED
|
@@ -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
|
|
289
|
-
*
|
|
290
|
-
*
|
|
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.
|
|
346
|
-
|
|
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
|
|
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
|
-
|
|
175
|
-
|
|
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");
|
package/src/markdown/mermaid.ts
CHANGED
|
@@ -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") {
|
package/src/og/cache.ts
ADDED
|
@@ -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).
|
|
238
|
-
*
|
|
239
|
-
*
|
|
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
|
-
|
|
245
|
-
|
|
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 {
|
|
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,
|