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.
- package/CHANGELOG.md +45 -0
- package/dist/cli/index.js +1715 -539
- package/dist/cli/index.js.map +40 -31
- package/dist/types/core/config-input.d.ts +131 -11
- package/dist/types/core/config.d.ts +9 -1
- package/dist/types/core/data.d.ts +24 -5
- package/dist/types/core/i18n-ui.d.ts +58 -799
- package/dist/types/core/schema.d.ts +534 -3305
- package/dist/types/theme/fonts.d.ts +55 -11
- package/docs/02-deployment.mdx +2 -0
- package/docs/07-faq.mdx +14 -14
- package/docs/advanced/skills.mdx +2 -2
- package/docs/configuration/ai.mdx +126 -2
- package/docs/configuration/index.mdx +19 -1
- package/docs/configuration/search.mdx +17 -0
- package/docs/configuration/seo.mdx +26 -3
- package/docs/configuration/theming.mdx +44 -2
- package/docs/content/syntax.mdx +18 -2
- package/docs/reference/cli.mdx +3 -3
- package/package.json +9 -8
- package/skills/blume/SKILL.md +6 -4
- package/skills/blume-migrate/SKILL.md +5 -3
- package/skills/blume-migrate/references/mintlify.md +5 -5
- package/skills/blume-migrate/references/monorepo.md +2 -1
- package/src/ai/agent-readability.ts +31 -1
- package/src/ai/api-catalog.ts +81 -0
- package/src/ai/ask-context.ts +7 -1
- package/src/ai/ask-data.ts +1 -0
- package/src/ai/link-headers.ts +52 -0
- package/src/ai/llms.ts +12 -1
- package/src/ai/markdown.ts +15 -2
- package/src/ai/mcp/data.ts +7 -0
- package/src/ai/mcp/discovery.ts +70 -15
- package/src/ai/mcp/server.ts +14 -8
- package/src/ai/mcp/stdio.ts +4 -1
- package/src/ai/skills.ts +193 -0
- package/src/ai/tar.ts +104 -0
- package/src/ai/web-bot-auth.ts +30 -0
- package/src/astro/generate.ts +116 -6
- package/src/astro/integration.ts +52 -14
- package/src/astro/templates.ts +191 -37
- package/src/audit/catalog.ts +20 -0
- package/src/audit/checks/dns-aid.ts +190 -0
- package/src/audit/report.ts +5 -0
- package/src/audit/run.ts +2 -0
- package/src/cli/commands/build.ts +178 -9
- package/src/cli/init/scaffold.ts +1 -1
- package/src/components/islands/ask-ai.tsx +4 -1
- package/src/components/islands/webmcp.ts +203 -0
- package/src/components/layout/NavTree.astro +4 -4
- package/src/components/layout/PageLayout.astro +2 -0
- package/src/components/layout/ReferenceLayout.astro +2 -0
- package/src/components/layout/RootLayout.astro +63 -11
- package/src/components/layout/Search.astro +2 -2
- package/src/components/layout/WebMcp.astro +49 -0
- package/src/components/layout/search/orama.ts +5 -2
- package/src/core/config-input.ts +143 -11
- package/src/core/config.ts +17 -1
- package/src/core/content-assets.ts +199 -0
- package/src/core/data.ts +21 -5
- package/src/core/diagnostics.ts +6 -5
- package/src/core/i18n-ui.ts +19 -28
- package/src/core/project-graph.ts +6 -0
- package/src/core/schema.ts +224 -71
- package/src/core/sources/normalize.ts +5 -5
- package/src/deploy/headers.ts +45 -3
- package/src/deploy/vercel-negotiation.ts +233 -0
- package/src/markdown/mermaid.ts +7 -1
- package/src/markdown/table-wrap.ts +33 -1
- package/src/og/card.ts +91 -22
- package/src/og/derive.ts +200 -0
- package/src/og/index.ts +6 -1
- package/src/search/orama-index.ts +151 -7
- package/src/theme/entry.ts +34 -13
- package/src/theme/fonts.ts +183 -30
- package/dist/types/og/card.d.ts +0 -63
- package/dist/types/og/dimensions.d.ts +0 -12
package/src/deploy/headers.ts
CHANGED
|
@@ -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 = (
|
|
71
|
+
export const buildNetlifyHeaders = (
|
|
72
|
+
config: ResolvedConfig,
|
|
73
|
+
homeLinkHeader?: string | null
|
|
74
|
+
): string => {
|
|
59
75
|
const deployBase = normalizeBasePath(config.deployment.base);
|
|
60
|
-
|
|
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
|
-
})
|
|
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
|
+
};
|
package/src/markdown/mermaid.ts
CHANGED
|
@@ -25,7 +25,13 @@ export const mermaidPlugin = () => ({
|
|
|
25
25
|
[
|
|
26
26
|
jsxAttribute(
|
|
27
27
|
"class",
|
|
28
|
-
|
|
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: [
|
|
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
|
|
10
|
-
* family name (weight 400, normal style); the object form pins
|
|
11
|
-
*
|
|
12
|
-
* from Google Fonts at build and returns per-glyph
|
|
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
|
-
*
|
|
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
|
-
*
|
|
79
|
-
* only Latin, so a CJK (etc.) title renders as tofu without a family
|
|
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 = (
|
|
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
|
|
329
|
-
const fontSubsets =
|
|
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:
|
|
401
|
+
fonts: cardFonts.length ? cardFonts : undefined,
|
|
333
402
|
format: "png",
|
|
334
403
|
height: HEIGHT,
|
|
335
404
|
images: resolveImages(options.images),
|