blume 1.2.0 → 1.3.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 (77) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/dist/cli/index.js +1715 -539
  3. package/dist/cli/index.js.map +40 -31
  4. package/dist/types/core/config-input.d.ts +131 -11
  5. package/dist/types/core/config.d.ts +9 -1
  6. package/dist/types/core/data.d.ts +24 -5
  7. package/dist/types/core/i18n-ui.d.ts +58 -799
  8. package/dist/types/core/schema.d.ts +534 -3305
  9. package/dist/types/theme/fonts.d.ts +55 -11
  10. package/docs/02-deployment.mdx +2 -0
  11. package/docs/07-faq.mdx +14 -14
  12. package/docs/advanced/skills.mdx +2 -2
  13. package/docs/configuration/ai.mdx +126 -2
  14. package/docs/configuration/index.mdx +19 -1
  15. package/docs/configuration/search.mdx +17 -0
  16. package/docs/configuration/seo.mdx +26 -3
  17. package/docs/configuration/theming.mdx +44 -2
  18. package/docs/content/syntax.mdx +18 -2
  19. package/docs/reference/cli.mdx +3 -3
  20. package/package.json +9 -8
  21. package/skills/blume/SKILL.md +6 -4
  22. package/skills/blume-migrate/SKILL.md +5 -3
  23. package/skills/blume-migrate/references/mintlify.md +5 -5
  24. package/skills/blume-migrate/references/monorepo.md +2 -1
  25. package/src/ai/agent-readability.ts +31 -1
  26. package/src/ai/api-catalog.ts +81 -0
  27. package/src/ai/ask-context.ts +7 -1
  28. package/src/ai/ask-data.ts +1 -0
  29. package/src/ai/link-headers.ts +52 -0
  30. package/src/ai/llms.ts +12 -1
  31. package/src/ai/markdown.ts +15 -2
  32. package/src/ai/mcp/data.ts +7 -0
  33. package/src/ai/mcp/discovery.ts +70 -15
  34. package/src/ai/mcp/server.ts +14 -8
  35. package/src/ai/mcp/stdio.ts +4 -1
  36. package/src/ai/skills.ts +193 -0
  37. package/src/ai/tar.ts +104 -0
  38. package/src/ai/web-bot-auth.ts +30 -0
  39. package/src/astro/generate.ts +116 -6
  40. package/src/astro/integration.ts +52 -14
  41. package/src/astro/templates.ts +191 -37
  42. package/src/audit/catalog.ts +20 -0
  43. package/src/audit/checks/dns-aid.ts +190 -0
  44. package/src/audit/report.ts +5 -0
  45. package/src/audit/run.ts +2 -0
  46. package/src/cli/commands/build.ts +178 -9
  47. package/src/cli/init/scaffold.ts +1 -1
  48. package/src/components/islands/ask-ai.tsx +4 -1
  49. package/src/components/islands/webmcp.ts +203 -0
  50. package/src/components/layout/NavTree.astro +4 -4
  51. package/src/components/layout/PageLayout.astro +2 -0
  52. package/src/components/layout/ReferenceLayout.astro +2 -0
  53. package/src/components/layout/RootLayout.astro +63 -11
  54. package/src/components/layout/Search.astro +2 -2
  55. package/src/components/layout/WebMcp.astro +49 -0
  56. package/src/components/layout/search/orama.ts +5 -2
  57. package/src/core/config-input.ts +143 -11
  58. package/src/core/config.ts +17 -1
  59. package/src/core/content-assets.ts +199 -0
  60. package/src/core/data.ts +21 -5
  61. package/src/core/diagnostics.ts +6 -5
  62. package/src/core/i18n-ui.ts +19 -28
  63. package/src/core/project-graph.ts +6 -0
  64. package/src/core/schema.ts +224 -71
  65. package/src/core/sources/normalize.ts +5 -5
  66. package/src/deploy/headers.ts +45 -3
  67. package/src/deploy/vercel-negotiation.ts +233 -0
  68. package/src/markdown/mermaid.ts +7 -1
  69. package/src/markdown/table-wrap.ts +33 -1
  70. package/src/og/card.ts +91 -22
  71. package/src/og/derive.ts +200 -0
  72. package/src/og/index.ts +6 -1
  73. package/src/search/orama-index.ts +151 -7
  74. package/src/theme/entry.ts +34 -13
  75. package/src/theme/fonts.ts +183 -30
  76. package/dist/types/og/card.d.ts +0 -63
  77. package/dist/types/og/dimensions.d.ts +0 -12
@@ -1,3 +1,12 @@
1
+ import {
2
+ API_CATALOG_PATH,
3
+ API_CATALOG_TYPE,
4
+ hasApiCatalog,
5
+ } from "../ai/api-catalog.ts";
6
+ import {
7
+ SIGNATURES_DIRECTORY_PATH,
8
+ SIGNATURES_DIRECTORY_TYPE,
9
+ } from "../ai/web-bot-auth.ts";
1
10
  import { normalizeBasePath } from "../core/base-path.ts";
2
11
  import type { ResolvedConfig } from "../core/schema.ts";
3
12
 
@@ -54,13 +63,46 @@ const HEADER_RULES: readonly {
54
63
  * still match once the site is mounted under a subpath (`/docs/*.md`); the
55
64
  * wildcard spans path segments, so a nested route like `/docs/ja/intro.md`
56
65
  * matches too.
66
+ *
67
+ * When a homepage `Link` header is provided (see `ai/link-headers.ts`), an
68
+ * exact-path rule for the root page advertises the agent-discovery resources —
69
+ * the static-host counterpart of the Vercel routing-config injection.
57
70
  */
58
- export const buildNetlifyHeaders = (config: ResolvedConfig): string => {
71
+ export const buildNetlifyHeaders = (
72
+ config: ResolvedConfig,
73
+ homeLinkHeader?: string | null
74
+ ): string => {
59
75
  const deployBase = normalizeBasePath(config.deployment.base);
60
- return `${HEADER_RULES.map((rule) => {
76
+ const rules = HEADER_RULES.map((rule) => {
61
77
  const prefix = rule.underBasePath
62
78
  ? `${deployBase}${config.basePath}`
63
79
  : deployBase;
64
80
  return `${prefix}/*.${rule.ext}\n Content-Type: ${rule.contentType}`;
65
- }).join("\n")}\n`;
81
+ });
82
+ if (homeLinkHeader) {
83
+ rules.push(`${deployBase}/\n Link: ${homeLinkHeader}`);
84
+ }
85
+ // The well-known discovery files are extensionless, so without an explicit
86
+ // rule a static host serves their registered media types as octet-stream or
87
+ // text/plain.
88
+ if (hasApiCatalog(config)) {
89
+ rules.push(
90
+ `${deployBase}${API_CATALOG_PATH}\n Content-Type: ${API_CATALOG_TYPE}`
91
+ );
92
+ }
93
+ if (config.ai.webBotAuth.keys.length > 0) {
94
+ rules.push(
95
+ `${deployBase}${SIGNATURES_DIRECTORY_PATH}\n Content-Type: ${SIGNATURES_DIRECTORY_TYPE}`
96
+ );
97
+ }
98
+ // Published skills live at the deployment base, outside `basePath` — the
99
+ // `.md` charset rule above misses them whenever a basePath is set, and the
100
+ // RFC wants archives served as application/gzip explicitly.
101
+ if (config.ai.skills) {
102
+ rules.push(
103
+ `${deployBase}/.well-known/agent-skills/*.md\n Content-Type: text/markdown; charset=utf-8`,
104
+ `${deployBase}/.well-known/agent-skills/*.tar.gz\n Content-Type: application/gzip`
105
+ );
106
+ }
107
+ return `${rules.join("\n")}\n`;
66
108
  };
@@ -0,0 +1,233 @@
1
+ /**
2
+ * `Accept: text/markdown` content negotiation for Vercel server builds.
3
+ *
4
+ * Blume prerenders every content page — even under `deployment.output:
5
+ * "server"` — so a page request never reaches Astro middleware: Vercel serves
6
+ * the prerendered HTML straight from its static layer. Request-time negotiation
7
+ * therefore has to live in the platform's routing config. The Vercel adapter
8
+ * emits a Build Output API `config.json`; these helpers splice extra routes
9
+ * into it so a content-page request that prefers `text/markdown` is rewritten
10
+ * (not redirected) to the page's prerendered `.md` mirror — the deployed
11
+ * counterpart of the dev-server rewrite in `astro/markdown-negotiation.ts`.
12
+ */
13
+
14
+ /**
15
+ * Regex for the `accept` header condition. Written to hold under both matching
16
+ * semantics a router may apply — full-string and substring — by anchoring the
17
+ * end and letting `(.*,)?` absorb any earlier list entries: it requires a
18
+ * `text/markdown` or `text/x-markdown` entry terminated by `;`, `,`, or the end
19
+ * of the header. Kept lookaround-free so it stays valid in RE2, and lowercase
20
+ * only — real agents send lowercase media types, and q-values are not compared
21
+ * (a client sending `text/markdown` at `q=0` is pathological). Browsers never
22
+ * send `text/markdown`, so ordinary page requests are unaffected.
23
+ */
24
+ export const ACCEPT_MARKDOWN_HEADER_VALUE =
25
+ "(.*,)?\\s*text/(x-)?markdown(\\s*[;,].*)?$";
26
+
27
+ /** A Build Output API route — the subset these helpers read and write. */
28
+ export interface VercelRoute {
29
+ continue?: boolean;
30
+ dest?: string;
31
+ handle?: string;
32
+ has?: { key?: string; type: string; value?: string }[];
33
+ headers?: Record<string, string>;
34
+ src?: string;
35
+ [key: string]: unknown;
36
+ }
37
+
38
+ const ACCEPT_MARKDOWN_CONDITION: VercelRoute["has"] = [
39
+ { key: "accept", type: "header", value: ACCEPT_MARKDOWN_HEADER_VALUE },
40
+ ];
41
+
42
+ const VARY_ACCEPT = { vary: "Accept" };
43
+
44
+ /**
45
+ * Vercel rejects route `src` patterns longer than 4096 characters, so route
46
+ * alternations are split across as many route entries as needed. The budget
47
+ * leaves headroom for the `^(` … `)/?$` wrapper.
48
+ */
49
+ const MAX_ALTERNATION_LENGTH = 3900;
50
+
51
+ const REGEX_SPECIALS = /[$()*+.?[\]^{|}\\]/gu;
52
+
53
+ /**
54
+ * A route path as it appears on the wire (percent-encoded, matching the layout
55
+ * of the prerendered files), escaped for literal use inside the alternation.
56
+ * Escaping runs after encoding; the `%` an encode introduces is not a regex
57
+ * metacharacter.
58
+ */
59
+ const routePattern = (route: string): string =>
60
+ encodeURI(route).replace(REGEX_SPECIALS, "\\$&");
61
+
62
+ /** Group patterns so each group's alternation stays under the `src` limit. */
63
+ const chunkPatterns = (patterns: readonly string[]): string[][] => {
64
+ const chunks: string[][] = [];
65
+ let current: string[] = [];
66
+ let length = 0;
67
+ for (const pattern of patterns) {
68
+ if (
69
+ current.length > 0 &&
70
+ length + pattern.length + 1 > MAX_ALTERNATION_LENGTH
71
+ ) {
72
+ chunks.push(current);
73
+ current = [];
74
+ length = 0;
75
+ }
76
+ current.push(pattern);
77
+ length += pattern.length + 1;
78
+ }
79
+ if (current.length > 0) {
80
+ chunks.push(current);
81
+ }
82
+ return chunks;
83
+ };
84
+
85
+ export interface NegotiationRoutes {
86
+ /**
87
+ * `Vary: Accept` for the plain-HTML side of every negotiated URL, so shared
88
+ * caches keep the two variants apart. Spliced *after* `handle: "filesystem"`
89
+ * — routes there run against filesystem matches (the same slot the Vercel
90
+ * adapter uses for its `_astro` cache headers).
91
+ */
92
+ headerRoutes: VercelRoute[];
93
+ /**
94
+ * The negotiation itself: header-conditional rewrites to the `.md` mirror.
95
+ * Spliced *before* `handle: "filesystem"` so they run ahead of static-file
96
+ * matching; the rewritten path then resolves to the prerendered `.md` file.
97
+ */
98
+ rewriteRoutes: VercelRoute[];
99
+ }
100
+
101
+ /**
102
+ * Build the routes for the given content-route paths (the routes that have a
103
+ * raw-Markdown mirror, straight from the manifest). Paths are matched with an
104
+ * optional trailing slash and rewritten `/{route}` → `/{route}.md`; the home
105
+ * page's mirror lives at `/index.md`.
106
+ */
107
+ export const buildNegotiationRoutes = (
108
+ routePaths: readonly string[]
109
+ ): NegotiationRoutes => {
110
+ const home = routePaths.includes("/");
111
+ const rest = routePaths
112
+ .filter((path) => path !== "/")
113
+ .map((path) => routePattern(path));
114
+ const chunks = chunkPatterns(rest);
115
+
116
+ const rewriteRoutes: VercelRoute[] = home
117
+ ? [
118
+ {
119
+ dest: "/index.md",
120
+ has: ACCEPT_MARKDOWN_CONDITION,
121
+ headers: VARY_ACCEPT,
122
+ src: "^/$",
123
+ },
124
+ ]
125
+ : [];
126
+ for (const chunk of chunks) {
127
+ rewriteRoutes.push({
128
+ dest: "$1.md",
129
+ has: ACCEPT_MARKDOWN_CONDITION,
130
+ headers: VARY_ACCEPT,
131
+ src: `^(${chunk.join("|")})/?$`,
132
+ });
133
+ }
134
+
135
+ const headerChunks = chunkPatterns(home ? ["/", ...rest] : rest);
136
+ const headerRoutes: VercelRoute[] = headerChunks.map((chunk) => ({
137
+ continue: true,
138
+ headers: VARY_ACCEPT,
139
+ src: `^(?:${chunk.join("|")})/?$`,
140
+ }));
141
+
142
+ return { headerRoutes, rewriteRoutes };
143
+ };
144
+
145
+ /** The `src` of the injected homepage `Link` header route. */
146
+ const HOME_SRC = "^/$";
147
+
148
+ /**
149
+ * Whether a route is one this module previously injected, so re-injection
150
+ * replaces rather than duplicates. Rewrites are identified by their `accept`
151
+ * condition; the `Vary` routes by their exact three-field shape (a
152
+ * user-authored route of that identical shape would be semantically equal to
153
+ * the one re-added); the homepage `Link` route by its three-field
154
+ * continue-with-link shape (the Build Output config is adapter-generated, so
155
+ * no user-authored route competes in this file).
156
+ */
157
+ const isNegotiationRoute = (route: VercelRoute): boolean =>
158
+ route.has?.some(
159
+ (condition) => condition.value === ACCEPT_MARKDOWN_HEADER_VALUE
160
+ ) === true ||
161
+ (route.continue === true &&
162
+ route.headers?.vary === "Accept" &&
163
+ typeof route.src === "string" &&
164
+ Object.keys(route).length === 3) ||
165
+ (route.continue === true &&
166
+ typeof route.headers?.link === "string" &&
167
+ route.src === HOME_SRC &&
168
+ Object.keys(route).length === 3);
169
+
170
+ /**
171
+ * Splice the negotiation routes into a Build Output `config.json`, plus — when
172
+ * given — a homepage `Link` header route for agent discovery (see
173
+ * `ai/link-headers.ts`), applied the same way the `Vary` routes are: after
174
+ * `handle: "filesystem"` with `continue`, so the header rides on the
175
+ * prerendered homepage response. `contentTypeOverrides` maps static-dir
176
+ * relative paths to media types via the Build Output `overrides` field — the
177
+ * platform's mechanism for extensionless static files (e.g. the Web Bot Auth
178
+ * signature directory). Returns the updated JSON text (tab-indented, like the
179
+ * adapter's own output), or `null` when there is nothing to do or nowhere
180
+ * safe to do it: nothing to inject, an unparsable config, no `routes` array,
181
+ * or no `handle: "filesystem"` marker to anchor the splice.
182
+ */
183
+ export const injectNegotiationRoutes = (
184
+ configText: string,
185
+ routePaths: readonly string[],
186
+ homeLinkHeader?: string | null,
187
+ contentTypeOverrides?: Record<string, string>
188
+ ): string | null => {
189
+ const overrideEntries = Object.entries(contentTypeOverrides ?? {});
190
+ if (
191
+ routePaths.length === 0 &&
192
+ !homeLinkHeader &&
193
+ overrideEntries.length === 0
194
+ ) {
195
+ return null;
196
+ }
197
+ let config: {
198
+ overrides?: Record<string, { contentType?: string; path?: string }>;
199
+ routes?: VercelRoute[];
200
+ };
201
+ try {
202
+ config = JSON.parse(configText);
203
+ } catch {
204
+ return null;
205
+ }
206
+ if (!Array.isArray(config.routes)) {
207
+ return null;
208
+ }
209
+ for (const [path, contentType] of overrideEntries) {
210
+ // Keyed assignment, so re-injection replaces rather than duplicates and a
211
+ // user's own override of the same path is simply refreshed.
212
+ config.overrides = { ...config.overrides, [path]: { contentType } };
213
+ }
214
+ const routes = config.routes.filter((route) => !isNegotiationRoute(route));
215
+ const filesystemIndex = routes.findIndex(
216
+ (route) => route.handle === "filesystem"
217
+ );
218
+ if (filesystemIndex === -1) {
219
+ return null;
220
+ }
221
+ const { headerRoutes, rewriteRoutes } = buildNegotiationRoutes(routePaths);
222
+ if (homeLinkHeader) {
223
+ headerRoutes.push({
224
+ continue: true,
225
+ headers: { link: homeLinkHeader },
226
+ src: HOME_SRC,
227
+ });
228
+ }
229
+ routes.splice(filesystemIndex + 1, 0, ...headerRoutes);
230
+ routes.splice(filesystemIndex, 0, ...rewriteRoutes);
231
+ config.routes = routes;
232
+ return `${JSON.stringify(config, null, "\t")}\n`;
233
+ };
@@ -25,7 +25,13 @@ export const mermaidPlugin = () => ({
25
25
  [
26
26
  jsxAttribute(
27
27
  "class",
28
- "not-prose my-6 flex justify-center overflow-x-auto"
28
+ // Mermaid's SVG has width:100% + a viewBox but no intrinsic width,
29
+ // so a shrink-wrapped flex item collapses to the 300px replaced-
30
+ // element fallback and the viewBox scales the drawing down into it.
31
+ // The child must fill the column (w-full); the svg centers itself
32
+ // via auto margins, and diagrams that opt out of useMaxWidth keep
33
+ // a fixed pixel width and scroll via overflow-x-auto.
34
+ "not-prose my-6 flex overflow-x-auto [&>div]:w-full [&>div>svg]:mx-auto [&>div>svg]:block"
29
35
  ),
30
36
  jsxAttribute("data-source", node.value),
31
37
  ],
@@ -7,6 +7,12 @@
7
7
  * `scrollable-region-focusable`). It's added unconditionally — whether a given
8
8
  * table overflows isn't known at build time — which costs a tab stop on tables
9
9
  * that happen to fit; no ARIA label is set to avoid an untranslated string.
10
+ *
11
+ * GFM has no headerless-table syntax — the delimiter row is mandatory, so a
12
+ * table that doesn't want headers is authored with an empty first row
13
+ * (`| | |`). That parses to a `<thead>` of blank cells, which renders as a
14
+ * dead band above the body; such a header row is dropped here. A header cell
15
+ * containing any non-text content (an image, an icon) counts as non-empty.
10
16
  */
11
17
 
12
18
  /** A minimal hast node (avoids a hast type dependency). */
@@ -27,12 +33,38 @@ export interface TableWrapPlugin {
27
33
  };
28
34
  }
29
35
 
36
+ /** Structural elements whose presence alone doesn't make a header non-empty. */
37
+ const HEADER_STRUCTURE = new Set(["thead", "tr", "th"]);
38
+
39
+ /** Whether a header subtree contains anything that would render visibly. */
40
+ const hasVisibleContent = (node: HastNode): boolean => {
41
+ if (node.type === "text") {
42
+ return (node.value ?? "").trim() !== "";
43
+ }
44
+ if (node.tagName && !HEADER_STRUCTURE.has(node.tagName)) {
45
+ return true;
46
+ }
47
+ return (node.children ?? []).some(hasVisibleContent);
48
+ };
49
+
50
+ const isEmptyThead = (node: HastNode): boolean =>
51
+ node.tagName === "thead" && !hasVisibleContent(node);
52
+
30
53
  export const tableWrapPlugin = (): TableWrapPlugin => ({
31
54
  element: {
32
55
  filter: ["table"],
33
56
  visit(node) {
57
+ // Satteri serializes an unchanged node by identity, so stripping the
58
+ // header must produce a new table object — mutating `node.children`
59
+ // in place has no effect on the output.
60
+ const table = node.children?.some(isEmptyThead)
61
+ ? {
62
+ ...node,
63
+ children: node.children.filter((child) => !isEmptyThead(child)),
64
+ }
65
+ : node;
34
66
  return {
35
- children: [node],
67
+ children: [table],
36
68
  properties: { className: ["blume-table-scroll"], tabIndex: 0 },
37
69
  tagName: "div",
38
70
  type: "element",
package/src/og/card.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import { readFile } from "node:fs/promises";
2
+
1
3
  import { render } from "takumi-js";
2
4
  import type { RenderOptions } from "takumi-js";
3
5
  import { container, googleFonts, image, text } from "takumi-js/helpers";
@@ -5,11 +7,24 @@ import type { FontSubset, GoogleFontFamily, Node } from "takumi-js/helpers";
5
7
 
6
8
  import { OG_IMAGE_HEIGHT, OG_IMAGE_WIDTH } from "./dimensions.ts";
7
9
 
10
+ /** A local font file registered with the OG card renderer, read at build. */
11
+ export interface OgLocalFont {
12
+ /** Family name the file's face registers under. */
13
+ name: string;
14
+ /** Absolute path to the font file. */
15
+ src: string;
16
+ /** Face weight; read from the file when omitted. */
17
+ weight?: number;
18
+ /** Face style; read from the file when omitted. */
19
+ style?: "normal" | "italic";
20
+ }
21
+
8
22
  /**
9
- * A Google Font family to load into the OG card renderer. A bare string is the
10
- * family name (weight 400, normal style); the object form pins weight and style.
11
- * Handed straight to Takumi's `googleFonts` helper, which fetches the family
12
- * from Google Fonts at build and returns per-glyph coverage subsets.
23
+ * A font to load into the OG card renderer. A bare string is a Google Fonts
24
+ * family name (weight 400, normal style); the name-only object form pins
25
+ * weight and style. Both are handed to Takumi's `googleFonts` helper, which
26
+ * fetches the families from Google Fonts at build and returns per-glyph
27
+ * coverage subsets. The `src` form reads a local font file instead.
13
28
  */
14
29
  export type OgFont =
15
30
  | string
@@ -20,7 +35,24 @@ export type OgFont =
20
35
  weight?: number | number[] | string;
21
36
  /** `"normal"`, `"italic"`, or both. */
22
37
  style?: "normal" | "italic" | ("normal" | "italic")[];
23
- };
38
+ }
39
+ | OgLocalFont;
40
+
41
+ /** Type guard: is this OG font a local file entry? */
42
+ const isLocalOgFont = (font: OgFont): font is OgLocalFont =>
43
+ typeof font !== "string" && "src" in font;
44
+
45
+ /**
46
+ * Which loaded family each card role renders in. Takumi still falls back
47
+ * across every loaded font per glyph, so a family that misses a script
48
+ * degrades to the rest of the chain instead of tofu.
49
+ */
50
+ export interface OgFontFamilies {
51
+ /** Family for the description and footer text. */
52
+ body?: string;
53
+ /** Family for the headline. */
54
+ title?: string;
55
+ }
24
56
 
25
57
  const ACCENT_HEX: Record<string, string> = {
26
58
  blue: "#3b82f6",
@@ -58,10 +90,11 @@ export interface OgCardOptions {
58
90
  /** Muted subtitle under the headline (usually the site description). */
59
91
  description?: string;
60
92
  /**
61
- * Inlined SVG markup of the configured logo, painted into
62
- * the brand lockup. Falls back to an accent mark when absent.
93
+ * Inlined SVG markup of the configured logo, painted into the brand
94
+ * lockup. Falls back to an accent mark when absent; `false` renders the
95
+ * card without any brand mark.
63
96
  */
64
- logo?: string;
97
+ logo?: string | false;
65
98
  /** Optional colors for the generated card. */
66
99
  palette?: OgCardPalette;
67
100
  /** Footer-left repository slug, e.g. `owner/repo`. */
@@ -75,11 +108,14 @@ export interface OgCardOptions {
75
108
  */
76
109
  images?: RenderOptions["images"];
77
110
  /**
78
- * Google Font families for non-Latin titles. Takumi's built-in font covers
79
- * only Latin, so a CJK (etc.) title renders as tofu without a family that
80
- * covers its script — see {@link loadFonts}.
111
+ * Fonts for non-Latin titles and card branding. Takumi's built-in font
112
+ * covers only Latin, so a CJK (etc.) title renders as tofu without a family
113
+ * that covers its script — see {@link loadFonts}. Local entries are read
114
+ * from disk instead of Google Fonts.
81
115
  */
82
116
  fonts?: OgFont[];
117
+ /** Per-role families from the loaded fonts (title vs body text). */
118
+ families?: OgFontFamilies;
83
119
  }
84
120
 
85
121
  const WIDTH = OG_IMAGE_WIDTH;
@@ -117,7 +153,9 @@ const fontSubsetCache = new Map<string, Promise<FontSubset[]>>();
117
153
  * fetch failure rejects, failing the build with the cause rather than silently
118
154
  * shipping tofu — the same fail-fast the OG accent relies on.
119
155
  */
120
- const loadFonts = (fonts: OgFont[]): Promise<FontSubset[]> => {
156
+ const loadFonts = (
157
+ fonts: Exclude<OgFont, OgLocalFont>[]
158
+ ): Promise<FontSubset[]> => {
121
159
  const key = JSON.stringify(fonts);
122
160
  let pending = fontSubsetCache.get(key);
123
161
  if (!pending) {
@@ -127,6 +165,20 @@ const loadFonts = (fonts: OgFont[]): Promise<FontSubset[]> => {
127
165
  return pending;
128
166
  };
129
167
 
168
+ /**
169
+ * A lazy loader for a local font file, matching the shape `render` accepts
170
+ * alongside Google subsets. Keyed by path so the shared renderer reads and
171
+ * registers each file once across a build's per-page renders; a missing file
172
+ * rejects at first use, failing the build with the path in the cause.
173
+ */
174
+ const localFontLoader = (font: OgLocalFont) => ({
175
+ data: () => readFile(font.src),
176
+ key: font.src,
177
+ name: font.name,
178
+ ...(font.weight === undefined ? {} : { weight: font.weight }),
179
+ ...(font.style === undefined ? {} : { style: font.style }),
180
+ });
181
+
130
182
  // Light neutral scale mirrored from the docs homepage theme tokens:
131
183
  // FOREGROUND = --foreground, MUTED = --muted-foreground, FAINT = that lighter,
132
184
  // BORDER = --border.
@@ -229,6 +281,10 @@ const titleSize = (title: string): number => {
229
281
  return 76;
230
282
  };
231
283
 
284
+ /** A spreadable `fontFamily` style, empty when no family is configured. */
285
+ const familyStyle = (family?: string): { fontFamily?: string } =>
286
+ family ? { fontFamily: family } : {};
287
+
232
288
  /** Render a 1200x630 Open Graph card to a PNG buffer. */
233
289
  export const renderOgImage = async (
234
290
  options: OgCardOptions
@@ -236,22 +292,28 @@ export const renderOgImage = async (
236
292
  const { accent, background, border, faint, foreground, muted } =
237
293
  resolvePalette(options);
238
294
  const brand = options.brand?.trim();
239
- const logo = options.logo?.trim();
295
+ const logo = options.logo === false ? false : options.logo?.trim();
240
296
  // Slice by code point, not code unit — `charAt(0)` would split a leading
241
297
  // surrogate pair (an emoji brand initial) into a lone half that renders blank.
242
298
  const initial = brand ? ([...brand][0]?.toUpperCase() ?? "") : "";
243
299
  const description = options.description?.trim();
244
300
  const repo = options.repo?.trim();
245
301
  const site = options.site?.trim();
302
+ const titleFamily = familyStyle(options.families?.title);
303
+ const bodyFamily = familyStyle(options.families?.body);
246
304
 
247
305
  // Logo only — no brand-name label beside it. A wordmark logo already spells
248
306
  // the name, and rendering the site title next to it duplicated the brand
249
307
  // ("Ultracite Ultracite"). Without a logo, the accent tile with the brand
250
- // initial stands in.
308
+ // initial stands in; `logo: false` opts out of any mark.
309
+ const mark = (): Node[] => {
310
+ if (logo === false) {
311
+ return [];
312
+ }
313
+ return [logo ? logoMark(logo, foreground) : initialMark(accent, initial)];
314
+ };
251
315
  const header = container({
252
- children: [
253
- logo ? logoMark(logo, foreground) : initialMark(accent, initial),
254
- ],
316
+ children: mark(),
255
317
  style: { alignItems: "center", display: "flex" },
256
318
  });
257
319
 
@@ -265,6 +327,7 @@ export const renderOgImage = async (
265
327
  lineHeight: 1.05,
266
328
  maxWidth: 1010,
267
329
  textWrap: "balance",
330
+ ...titleFamily,
268
331
  }),
269
332
  description
270
333
  ? text(truncate(description, 140), {
@@ -274,6 +337,7 @@ export const renderOgImage = async (
274
337
  marginTop: 28,
275
338
  maxWidth: 900,
276
339
  textWrap: "balance",
340
+ ...bodyFamily,
277
341
  })
278
342
  : container({}),
279
343
  ],
@@ -290,10 +354,10 @@ export const renderOgImage = async (
290
354
  container({
291
355
  children: [
292
356
  repo
293
- ? text(repo, { color: muted, fontSize: 22 })
357
+ ? text(repo, { color: muted, fontSize: 22, ...bodyFamily })
294
358
  : container({}),
295
359
  site
296
- ? text(site, { color: faint, fontSize: 22 })
360
+ ? text(site, { color: faint, fontSize: 22, ...bodyFamily })
297
361
  : container({}),
298
362
  ],
299
363
  style: {
@@ -324,12 +388,17 @@ export const renderOgImage = async (
324
388
  });
325
389
 
326
390
  const fonts = options.fonts ?? [];
391
+ const googleFamilies = fonts.filter((font) => !isLocalOgFont(font));
392
+ const localFonts = fonts.filter((font) => isLocalOgFont(font));
327
393
  // `render` registers only the subsets a card's text uses and skips files it
328
- // has already loaded, so passing the full family list per page is cheap.
329
- const fontSubsets = fonts.length ? await loadFonts(fonts) : undefined;
394
+ // has already loaded, so passing the full font list per page is cheap.
395
+ const fontSubsets = googleFamilies.length
396
+ ? await loadFonts(googleFamilies)
397
+ : [];
398
+ const cardFonts = [...fontSubsets, ...localFonts.map(localFontLoader)];
330
399
 
331
400
  return render(node, {
332
- fonts: fontSubsets,
401
+ fonts: cardFonts.length ? cardFonts : undefined,
333
402
  format: "png",
334
403
  height: HEIGHT,
335
404
  images: resolveImages(options.images),