@docubook/plugin-og-image 0.0.0-stage → 0.1.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/dist/index.js ADDED
@@ -0,0 +1,1008 @@
1
+ import { mkdir, readFile, rename, rmdir, stat, unlink, writeFile } from "node:fs/promises";
2
+ import { dirname, extname, isAbsolute, join, relative, resolve } from "node:path";
3
+ import { presetRegistry, resolveTheme } from "@docubook/themes-colors";
4
+ import { createHash } from "node:crypto";
5
+ import { existsSync, readFileSync, statSync } from "node:fs";
6
+ import matter from "@11ty/gray-matter";
7
+ // src/options.ts
8
+ /**
9
+ * Plugin options from `docu.json` and their validation. Pure: no file system
10
+ * and no flame imports, so a typo stops `flame build` and `flame dev` before
11
+ * anything else runs.
12
+ */
13
+ var LOG_PREFIX = "[plugin:og-image]";
14
+ var LAYOUTS = ["card", "minimal"];
15
+ var KNOWN_KEYS = /* @__PURE__ */ new Set([
16
+ "layout",
17
+ "logo",
18
+ "siteName",
19
+ "pages",
20
+ "fonts"
21
+ ]);
22
+ var PAGE_KEYS = /* @__PURE__ */ new Set([
23
+ "docs",
24
+ "index",
25
+ "home"
26
+ ]);
27
+ var EXAMPLE = `Example:
28
+ "plugins": [["@docubook/plugin-og-image", { "layout": "card" }]]`;
29
+ function fail(message) {
30
+ throw new Error(`${LOG_PREFIX} ${message}\n${EXAMPLE}`);
31
+ }
32
+ /**
33
+ * A value that names something on another host or inline data rather than a
34
+ * local file: `https://…`, `//cdn…`, `data:…`. Single-letter schemes are left
35
+ * alone so a Windows drive path (`C:\…`) is not mistaken for a URL.
36
+ */
37
+ function isRemoteReference(value) {
38
+ return /^[a-z][a-z\d+.-]+:/i.test(value) || value.startsWith("//");
39
+ }
40
+ function isPlainObject(value) {
41
+ return typeof value === "object" && value !== null && !Array.isArray(value);
42
+ }
43
+ function resolveOptions(input) {
44
+ if (input === void 0 || input === null) input = {};
45
+ if (!isPlainObject(input)) fail("options must be an object.");
46
+ for (const key of Object.keys(input)) if (!KNOWN_KEYS.has(key)) fail(`unknown option "${key}". Valid options: ${[...KNOWN_KEYS].join(", ")}.`);
47
+ const layout = input.layout ?? "card";
48
+ if (typeof layout !== "string" || !LAYOUTS.includes(layout)) fail(`"layout" must be one of ${LAYOUTS.map((l) => `"${l}"`).join(", ")}.`);
49
+ const logo = input.logo;
50
+ if (logo !== void 0 && logo !== false) {
51
+ if (typeof logo !== "string" || logo.trim() === "") fail(`"logo" must be a file path or false.`);
52
+ if (isRemoteReference(logo)) fail(`"logo" must be a local file, not "${logo}". The build never downloads files.`);
53
+ }
54
+ const siteName = input.siteName;
55
+ if (siteName !== void 0 && (typeof siteName !== "string" || siteName.trim() === "")) fail(`"siteName" must be a non-empty string.`);
56
+ const pages = {
57
+ docs: true,
58
+ index: true,
59
+ home: true
60
+ };
61
+ if (input.pages !== void 0) {
62
+ if (!isPlainObject(input.pages)) fail(`"pages" must be an object such as { "home": false }.`);
63
+ for (const [key, value] of Object.entries(input.pages)) {
64
+ if (!PAGE_KEYS.has(key)) fail(`unknown key "pages.${key}". Valid keys: docs, index, home.`);
65
+ if (typeof value !== "boolean") fail(`"pages.${key}" must be true or false.`);
66
+ pages[key] = value;
67
+ }
68
+ }
69
+ const fonts = [];
70
+ if (input.fonts !== void 0) {
71
+ if (!Array.isArray(input.fonts)) fail(`"fonts" must be an array of { "path": "…" }.`);
72
+ input.fonts.forEach((font, i) => {
73
+ if (!isPlainObject(font) || typeof font.path !== "string" || font.path.trim() === "") fail(`"fonts[${i}].path" must be a file path.`);
74
+ if (isRemoteReference(font.path)) fail(`"fonts[${i}].path" must be a local file, not "${font.path}".`);
75
+ if (font.weight !== void 0 && (typeof font.weight !== "number" || font.weight < 1 || font.weight > 1e3)) fail(`"fonts[${i}].weight" must be a number from 1 to 1000.`);
76
+ if (font.style !== void 0 && font.style !== "normal" && font.style !== "italic") fail(`"fonts[${i}].style" must be "normal" or "italic".`);
77
+ fonts.push({
78
+ path: font.path,
79
+ ...font.weight !== void 0 && { weight: font.weight },
80
+ ...font.style !== void 0 && { style: font.style }
81
+ });
82
+ });
83
+ }
84
+ return {
85
+ layout,
86
+ logo,
87
+ siteName,
88
+ pages,
89
+ fonts
90
+ };
91
+ }
92
+ // src/site.ts
93
+ /**
94
+ * What the plugin needs to know about the site, derived from `docu.json` the
95
+ * same way flame derives it. `@docubook/flame` has no runtime exports, so the
96
+ * base-path rules are mirrored here; `__tests__/site.test.ts` pins them to
97
+ * flame's own cases.
98
+ */
99
+ var DEFAULT_BASE_PATH = "/docs";
100
+ /** Output directory of the cards, below the docs output directory. */
101
+ var CARD_DIR = "assets/og";
102
+ /**
103
+ * Strip slashes from the end (and the start, unless `endOnly`) in one linear
104
+ * pass. A `/\/+$/` regex backtracks polynomially on long runs of `/`.
105
+ */
106
+ function trimSlashes(value, endOnly = false) {
107
+ let start = 0;
108
+ let end = value.length;
109
+ if (!endOnly) while (start < end && value[start] === "/") start++;
110
+ while (end > start && value[end - 1] === "/") end--;
111
+ return value.slice(start, end);
112
+ }
113
+ /** Walk up from `start` to the directory holding `docu.json`, as flame does. */
114
+ function findProjectRoot(start = process.cwd()) {
115
+ let dir = start;
116
+ for (let i = 0; i < 6; i++) {
117
+ if (existsSync(join(dir, "docu.json"))) return dir;
118
+ const parent = resolve(dir, "..");
119
+ if (parent === dir) break;
120
+ dir = parent;
121
+ }
122
+ return start;
123
+ }
124
+ /** Mirror of flame's `normalizeBasePath`. */
125
+ function normalizeBasePath(value) {
126
+ if (typeof value !== "string") return DEFAULT_BASE_PATH;
127
+ let path = value.trim();
128
+ if (path.length === 0 || path === "/") return "";
129
+ try {
130
+ if (/^[a-zA-Z][a-zA-Z\d+.-]*:\/\//.test(path)) path = new URL(path).pathname;
131
+ } catch {}
132
+ path = `/${trimSlashes(path)}`;
133
+ return path === "/" ? "" : path;
134
+ }
135
+ /** Mirror of flame's `canonicalBasePath`. */
136
+ function canonicalBasePath(value) {
137
+ const structural = normalizeBasePath(value);
138
+ if (structural === "") return "";
139
+ const cleaned = structural.replace(/\s+/g, "-").replace(/[^A-Za-z0-9\-._~/]+/g, "").split("/").filter((segment) => segment.length > 0 && segment !== "." && segment !== "..").join("/").replace(/-{2,}/g, "-").toLowerCase();
140
+ return cleaned.length > 0 ? `/${cleaned}` : "";
141
+ }
142
+ /** Mirror of flame's `resolveBasePath`. */
143
+ function resolveBasePath(config) {
144
+ const basePath = config.meta?.basePath;
145
+ if (typeof basePath !== "string") return DEFAULT_BASE_PATH;
146
+ if (normalizeBasePath(basePath) === "") return "";
147
+ return canonicalBasePath(basePath) || "/docs";
148
+ }
149
+ /** Mirror of flame's `resolveDeployPath`: the path part of `meta.baseURL`. */
150
+ function resolveDeployPath(config) {
151
+ const raw = config.meta?.baseURL;
152
+ if (typeof raw !== "string") return "";
153
+ try {
154
+ return normalizeBasePath(new URL(raw).pathname);
155
+ } catch {
156
+ return raw.startsWith("/") ? normalizeBasePath(raw) : "";
157
+ }
158
+ }
159
+ function domainOf(baseURL) {
160
+ try {
161
+ const url = new URL(baseURL);
162
+ return `${url.host}${trimSlashes(url.pathname, true)}`;
163
+ } catch {
164
+ return;
165
+ }
166
+ }
167
+ function resolveSite(config, options, root) {
168
+ const prefix = resolveBasePath(config);
169
+ const ownPages = config.experimental?.pages === true;
170
+ const baseURL = trimSlashes((config.meta?.baseURL ?? "").trim(), true);
171
+ const navbarName = config.navbar?.logoText?.trim();
172
+ return {
173
+ root,
174
+ docsDir: join(root, "docs"),
175
+ prefix,
176
+ baseURL,
177
+ domain: baseURL ? domainOf(baseURL) : void 0,
178
+ siteName: options.siteName ?? (navbarName || config.meta?.title?.trim() || "Docs"),
179
+ hasLanding: !ownPages,
180
+ hasDocsIndex: prefix !== "" || ownPages,
181
+ routes: Array.isArray(config.routes) ? config.routes : []
182
+ };
183
+ }
184
+ /**
185
+ * Title of the top-level sidebar group a docs page sits in, e.g. "Getting
186
+ * Started" for `getting-started/configuration`. Top-level pages have none.
187
+ */
188
+ function sectionFor(routes, slug) {
189
+ const segments = slug.split("/");
190
+ if (segments.length < 2) return void 0;
191
+ return routes.find((r) => typeof r.href === "string" && trimSlashes(r.href) === segments[0])?.title?.trim() || void 0;
192
+ }
193
+ /** Absolute directory flame writes the docs pages to. */
194
+ function docsOutputDir(outDir, prefix) {
195
+ return prefix === "" ? outDir : join(outDir, prefix.slice(1));
196
+ }
197
+ /**
198
+ * Absolute URL of a card, as flame's SEO resolver builds it for a relative
199
+ * `frontmatter.image`. Root-relative when `meta.baseURL` is unset.
200
+ */
201
+ function cardUrl(site, file) {
202
+ return `${site.baseURL}${site.prefix}/${CARD_DIR}/${file}`;
203
+ }
204
+ /** True when `file` is inside `root` (or is `root`). */
205
+ function isInside(root, file) {
206
+ const rel = relative(root, file);
207
+ return rel === "" || !rel.startsWith("..") && !isAbsolute(rel);
208
+ }
209
+ var LOGO_TYPES = {
210
+ ".svg": "image/svg+xml",
211
+ ".png": "image/png",
212
+ ".jpg": "image/jpeg",
213
+ ".jpeg": "image/jpeg",
214
+ ".webp": "image/webp"
215
+ };
216
+ /**
217
+ * Map a logo reference to a file in the project.
218
+ *
219
+ * `navbar.logo.src` is a site path (`/docs/assets/images/logo.svg`), while the
220
+ * `logo` option may also be a project path (`brand/logo.svg`). Site paths
221
+ * under the docs prefix, or under the default `/docs` spelling flame re-bases,
222
+ * map to `docs/`; anything else is tried relative to the project root, then
223
+ * to `docs/`.
224
+ */
225
+ function findLogoFile(value, site, deployPath) {
226
+ if (isRemoteReference(value)) return {
227
+ reason: "remote",
228
+ value
229
+ };
230
+ let path = value.split(/[?#]/)[0];
231
+ const candidates = [];
232
+ if (path.startsWith("/")) {
233
+ if (deployPath && path.startsWith(`${deployPath}/`)) path = path.slice(deployPath.length);
234
+ for (const prefix of [site.prefix, DEFAULT_BASE_PATH]) if (prefix && path.startsWith(`${prefix}/`)) candidates.push(join(site.docsDir, path.slice(prefix.length + 1)));
235
+ if (site.prefix === "") candidates.push(join(site.docsDir, path.slice(1)));
236
+ candidates.push(join(site.root, path.slice(1)));
237
+ } else candidates.push(resolve(site.root, path), resolve(site.docsDir, path));
238
+ let outside = false;
239
+ for (const candidate of candidates) {
240
+ if (!isInside(site.root, candidate)) {
241
+ outside = true;
242
+ continue;
243
+ }
244
+ try {
245
+ if (statSync(candidate).isFile()) {
246
+ if (!(extname(candidate).toLowerCase() in LOGO_TYPES)) return {
247
+ reason: "type",
248
+ value
249
+ };
250
+ return { file: candidate };
251
+ }
252
+ } catch {}
253
+ }
254
+ return {
255
+ reason: outside ? "outside" : "missing",
256
+ value
257
+ };
258
+ }
259
+ // src/cards.ts
260
+ /**
261
+ * Turning a page into a card: what text it shows, whether it is skipped, and
262
+ * the file name it is published under. Pure functions over frontmatter.
263
+ */
264
+ /** Frontmatter value as flame's schema coerces it: primitives become strings. */
265
+ function text(value) {
266
+ if (typeof value === "string") return value.trim();
267
+ if (typeof value === "number" || typeof value === "boolean") return String(value);
268
+ return "";
269
+ }
270
+ function overrides(frontmatter) {
271
+ const og = frontmatter.og;
272
+ return typeof og === "object" && og !== null && !Array.isArray(og) ? og : {};
273
+ }
274
+ /**
275
+ * Why a page gets no card, or `undefined` when it gets one. An image the
276
+ * author set always wins; `og: false` opts the page out.
277
+ */
278
+ function skipReason(kind, frontmatter, options) {
279
+ if (!options.pages[kind]) return "disabled";
280
+ if (frontmatter.og === false) return "opt-out";
281
+ if (text(frontmatter.image)) return "author-image";
282
+ }
283
+ /** Card text for a docs page or the docs index. Mirrors flame's title fallback. */
284
+ function docsCard(kind, slug, frontmatter, site) {
285
+ const og = overrides(frontmatter);
286
+ return {
287
+ kind,
288
+ slug,
289
+ title: text(og.title) || text(frontmatter.title) || slug || "Docs",
290
+ description: text(og.description) || text(frontmatter.description),
291
+ section: kind === "docs" ? sectionFor(site.routes, slug) : void 0
292
+ };
293
+ }
294
+ /** Card text for the landing page: the hero headline and the site description. */
295
+ function homeCard(config) {
296
+ const hero = config.home?.hero;
297
+ return {
298
+ kind: "home",
299
+ slug: "",
300
+ title: text(hero?.headline) || text(config.meta?.title) || "Docs",
301
+ description: text(config.meta?.description),
302
+ section: void 0
303
+ };
304
+ }
305
+ function sha256(...parts) {
306
+ const hash = createHash("sha256");
307
+ for (const part of parts) hash.update(part);
308
+ return hash.digest("hex");
309
+ }
310
+ /**
311
+ * Plan a card's file. The name hash covers only inputs that also invalidate
312
+ * flame's page cache (plugin version, `docu.json`, the page's source), so a
313
+ * page served from that cache always points at a file this build writes.
314
+ */
315
+ function planCard(card, site, options, version) {
316
+ const nameHash = sha256(JSON.stringify([
317
+ version,
318
+ options.layout,
319
+ options.logo ?? null,
320
+ options.fonts,
321
+ site.siteName,
322
+ site.domain ?? null,
323
+ card.kind,
324
+ card.title,
325
+ card.description,
326
+ card.section ?? null
327
+ ]));
328
+ return {
329
+ card,
330
+ file: `${card.kind === "docs" ? card.slug : card.kind}.${nameHash.slice(0, 8)}.png`,
331
+ nameHash
332
+ };
333
+ }
334
+ /** The value set as `frontmatter.image`; flame resolves it against the docs prefix. */
335
+ function imageRef(file) {
336
+ return `./${CARD_DIR}/${file}`;
337
+ }
338
+ /** Source file candidates for a slug, in the order flame's scanner would find them. */
339
+ function sourceCandidates(docsDir, slug) {
340
+ if (slug === "") return [join(docsDir, "index.mdx"), join(docsDir, "index.md")];
341
+ return [
342
+ join(docsDir, `${slug}.mdx`),
343
+ join(docsDir, `${slug}.md`),
344
+ join(docsDir, slug, "index.mdx"),
345
+ join(docsDir, slug, "index.md")
346
+ ];
347
+ }
348
+ /** Frontmatter of a source file. Parsed with the same library `@docubook/core` uses. */
349
+ function parseFrontmatter(raw) {
350
+ return matter(raw, {}).data;
351
+ }
352
+ /**
353
+ * Frontmatter of a docs page as written in its source file, or `undefined`
354
+ * when no source exists. Every card is planned from this, never from the
355
+ * frontmatter other plugins hand on, so the page phase and `onEnd` always
356
+ * agree on the file name.
357
+ */
358
+ async function readFrontmatter(docsDir, slug) {
359
+ for (const file of sourceCandidates(docsDir, slug)) {
360
+ let raw;
361
+ try {
362
+ raw = await readFile(file, "utf8");
363
+ } catch {
364
+ continue;
365
+ }
366
+ return parseFrontmatter(raw);
367
+ }
368
+ }
369
+ /** {@link readFrontmatter} for hooks that must return synchronously. */
370
+ function readFrontmatterSync(docsDir, slug) {
371
+ for (const file of sourceCandidates(docsDir, slug)) {
372
+ let raw;
373
+ try {
374
+ raw = readFileSync(file, "utf8");
375
+ } catch {
376
+ continue;
377
+ }
378
+ return parseFrontmatter(raw);
379
+ }
380
+ }
381
+ // src/layouts.ts
382
+ var WIDTH = 1200;
383
+ var hsl = (triplet, alpha) => alpha === void 0 ? `hsl(${triplet})` : `hsl(${triplet} / ${alpha})`;
384
+ var box = (style, children = []) => ({
385
+ type: "container",
386
+ style,
387
+ children
388
+ });
389
+ var label = (value, style) => ({
390
+ type: "text",
391
+ text: value,
392
+ style
393
+ });
394
+ /** Two lines at most, then an ellipsis. */
395
+ var clamp = {
396
+ lineClamp: 2,
397
+ textOverflow: "ellipsis",
398
+ overflow: "hidden"
399
+ };
400
+ function logoImage(brand, size, radius = 0) {
401
+ if (!brand.logo) return [];
402
+ return [{
403
+ type: "image",
404
+ src: brand.logo,
405
+ width: size,
406
+ height: size,
407
+ style: {
408
+ width: size,
409
+ height: size,
410
+ borderRadius: radius,
411
+ objectFit: "contain"
412
+ }
413
+ }];
414
+ }
415
+ function identityRow(card, brand, fontSize) {
416
+ const { palette } = brand;
417
+ return box({
418
+ display: "flex",
419
+ alignItems: "center",
420
+ gap: 18,
421
+ fontSize,
422
+ color: hsl(palette.muted)
423
+ }, [
424
+ ...logoImage(brand, 56, 14),
425
+ label(brand.siteName, {
426
+ fontWeight: 600,
427
+ color: hsl(palette.foreground)
428
+ }),
429
+ ...card.section ? [label("›", { opacity: .6 }), label(card.section, {})] : []
430
+ ]);
431
+ }
432
+ function textBlock(card, brand, maxWidth) {
433
+ return box({
434
+ display: "flex",
435
+ flexDirection: "column",
436
+ gap: 22,
437
+ maxWidth
438
+ }, [label(card.title, {
439
+ fontSize: 64,
440
+ fontWeight: 700,
441
+ lineHeight: 1.1,
442
+ letterSpacing: -1.5,
443
+ color: hsl(brand.palette.foreground),
444
+ ...clamp
445
+ }), ...card.description ? [label(card.description, {
446
+ fontSize: 30,
447
+ lineHeight: 1.35,
448
+ color: hsl(brand.palette.muted),
449
+ ...clamp
450
+ })] : []]);
451
+ }
452
+ /**
453
+ * Default layout: identity row, title, description, a page-kind chip with the
454
+ * domain, the logo in a tinted tile on the right, and a brand bar along the
455
+ * bottom edge over a faint grid.
456
+ */
457
+ function cardLayout(card, brand) {
458
+ const { palette } = brand;
459
+ const kind = card.kind === "home" ? "Home" : "Docs";
460
+ return box({
461
+ display: "flex",
462
+ flexDirection: "column",
463
+ position: "relative",
464
+ overflow: "hidden",
465
+ width: WIDTH,
466
+ height: 630,
467
+ backgroundColor: hsl(palette.background),
468
+ color: hsl(palette.foreground),
469
+ fontFamily: brand.fontFamily
470
+ }, [
471
+ box({
472
+ position: "absolute",
473
+ top: 0,
474
+ left: 0,
475
+ right: 0,
476
+ bottom: 0,
477
+ backgroundImage: `linear-gradient(${hsl(palette.border)} 1px, transparent 1px), linear-gradient(90deg, ${hsl(palette.border)} 1px, transparent 1px)`,
478
+ backgroundSize: "40px 40px",
479
+ opacity: .45
480
+ }),
481
+ box({
482
+ position: "absolute",
483
+ top: -220,
484
+ right: -160,
485
+ width: 720,
486
+ height: 720,
487
+ borderRadius: 9999,
488
+ backgroundImage: `radial-gradient(circle, ${hsl(palette.primary, .2)} 0%, ${hsl(palette.primary, 0)} 65%)`
489
+ }),
490
+ box({
491
+ display: "flex",
492
+ flexDirection: "column",
493
+ justifyContent: "space-between",
494
+ flexGrow: 1,
495
+ position: "relative",
496
+ padding: "72px 80px 60px 80px"
497
+ }, [
498
+ identityRow(card, brand, 28),
499
+ textBlock(card, brand, brand.logo ? 760 : 1040),
500
+ box({
501
+ display: "flex",
502
+ alignItems: "center",
503
+ gap: 20,
504
+ fontSize: 24,
505
+ color: hsl(palette.muted)
506
+ }, [box({
507
+ display: "flex",
508
+ alignItems: "center",
509
+ gap: 10,
510
+ padding: "8px 18px",
511
+ borderRadius: 9999,
512
+ backgroundColor: hsl(palette.primary, .1),
513
+ color: hsl(palette.primary),
514
+ fontWeight: 600
515
+ }, [box({
516
+ width: 10,
517
+ height: 10,
518
+ borderRadius: 9999,
519
+ backgroundColor: hsl(palette.primary)
520
+ }), label(kind, {})]), ...brand.domain ? [label(brand.domain, {})] : []])
521
+ ]),
522
+ ...brand.logo ? [box({
523
+ position: "absolute",
524
+ right: 80,
525
+ top: 200,
526
+ width: 220,
527
+ height: 220,
528
+ borderRadius: 48,
529
+ backgroundColor: hsl(palette.primary, .08),
530
+ display: "flex",
531
+ alignItems: "center",
532
+ justifyContent: "center"
533
+ }, logoImage(brand, 140))] : [],
534
+ box({
535
+ height: 12,
536
+ flexShrink: 0,
537
+ backgroundColor: hsl(palette.primary)
538
+ })
539
+ ]);
540
+ }
541
+ /** Plain layout: identity row, title, description, domain. Safe for square crops. */
542
+ function minimalLayout(card, brand) {
543
+ const { palette } = brand;
544
+ return box({
545
+ display: "flex",
546
+ flexDirection: "column",
547
+ justifyContent: "space-between",
548
+ width: WIDTH,
549
+ height: 630,
550
+ padding: 80,
551
+ backgroundColor: hsl(palette.background),
552
+ color: hsl(palette.foreground),
553
+ fontFamily: brand.fontFamily
554
+ }, [
555
+ box({
556
+ display: "flex",
557
+ alignItems: "center",
558
+ gap: 18,
559
+ fontSize: 28
560
+ }, [...logoImage(brand, 56, 14), label(brand.siteName, { fontWeight: 600 })]),
561
+ textBlock(card, brand, 980),
562
+ label(brand.domain ?? "", {
563
+ fontSize: 24,
564
+ color: hsl(palette.muted)
565
+ })
566
+ ]);
567
+ }
568
+ function buildTree(layout, card, brand) {
569
+ return layout === "minimal" ? minimalLayout(card, brand) : cardLayout(card, brand);
570
+ }
571
+ // src/renderer.ts
572
+ /** Family name Takumi gives its built-in font. */
573
+ var BUILTIN_FAMILY = "Geist";
574
+ async function loadTakumi() {
575
+ try {
576
+ return await import("@takumi-rs/core");
577
+ } catch (err) {
578
+ throw new Error(`${LOG_PREFIX} could not load the Takumi renderer (@takumi-rs/core). Its native binary is installed per platform: run the install again on the machine that builds the site.\nCause: ${err instanceof Error ? err.message : String(err)}`, { cause: err });
579
+ }
580
+ }
581
+ /** Width and height from a PNG's IHDR chunk, or `undefined` for anything else. */
582
+ function pngSize(bytes) {
583
+ if (bytes.length < 24 || [
584
+ 137,
585
+ 80,
586
+ 78,
587
+ 71,
588
+ 13,
589
+ 10,
590
+ 26,
591
+ 10
592
+ ].some((b, i) => bytes[i] !== b)) return void 0;
593
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
594
+ return {
595
+ width: view.getUint32(16),
596
+ height: view.getUint32(20)
597
+ };
598
+ }
599
+ /** A code point no font maps, so Takumi draws its "missing glyph" box. */
600
+ var NOTDEF_PROBE = "􏿽";
601
+ async function createEngine(fonts) {
602
+ const { Renderer } = await loadTakumi();
603
+ const renderer = new Renderer();
604
+ const families = [];
605
+ for (const font of fonts) {
606
+ const registered = await renderer.registerFont({
607
+ data: font.data,
608
+ ...font.weight !== void 0 && { weight: font.weight },
609
+ ...font.style !== void 0 && { style: font.style }
610
+ });
611
+ for (const family of registered) if (!families.includes(family.name)) families.push(family.name);
612
+ }
613
+ const fontFamily = [...families, BUILTIN_FAMILY].map((name) => JSON.stringify(name)).join(", ");
614
+ const glyphCache = /* @__PURE__ */ new Map();
615
+ let notdef;
616
+ const drawGlyph = (char) => renderer.render({
617
+ type: "container",
618
+ style: {
619
+ display: "flex",
620
+ width: 48,
621
+ height: 48,
622
+ backgroundColor: "#fff",
623
+ fontFamily
624
+ },
625
+ children: [{
626
+ type: "text",
627
+ text: char,
628
+ style: {
629
+ fontSize: 32,
630
+ color: "#000"
631
+ }
632
+ }]
633
+ }, {
634
+ width: 48,
635
+ height: 48,
636
+ format: "raw"
637
+ });
638
+ return {
639
+ fontFamily,
640
+ async render(tree) {
641
+ const png = await renderer.render(tree, {
642
+ width: WIDTH,
643
+ height: 630,
644
+ format: "png"
645
+ });
646
+ return new Uint8Array(png.buffer, png.byteOffset, png.byteLength);
647
+ },
648
+ async missingGlyphs(chars) {
649
+ const missing = [];
650
+ for (const char of chars) {
651
+ if (char.codePointAt(0) < 127 || /\s/u.test(char)) continue;
652
+ let isMissing = glyphCache.get(char);
653
+ if (isMissing === void 0) {
654
+ notdef ??= await drawGlyph(NOTDEF_PROBE);
655
+ isMissing = notdef.equals(await drawGlyph(char));
656
+ glyphCache.set(char, isMissing);
657
+ }
658
+ if (isMissing) missing.push(char);
659
+ }
660
+ return missing;
661
+ }
662
+ };
663
+ }
664
+ async function readManifest(path) {
665
+ try {
666
+ const parsed = JSON.parse(await readFile(path, "utf8"));
667
+ if (parsed?.version === 1 && typeof parsed.files === "object" && parsed.files) return parsed;
668
+ } catch {}
669
+ return {
670
+ version: 1,
671
+ files: {}
672
+ };
673
+ }
674
+ /** Write through a temp file so an interrupted build never leaves half a file. */
675
+ async function writeAtomic(path, data) {
676
+ await mkdir(dirname(path), { recursive: true });
677
+ const temp = `${path}.${process.pid}.tmp`;
678
+ try {
679
+ await writeFile(temp, data);
680
+ await rename(temp, path);
681
+ } catch (err) {
682
+ await unlink(temp).catch(() => {});
683
+ throw err;
684
+ }
685
+ }
686
+ async function exists(path) {
687
+ try {
688
+ return (await stat(path)).isFile();
689
+ } catch {
690
+ return false;
691
+ }
692
+ }
693
+ async function mapLimit(items, limit, fn) {
694
+ let next = 0;
695
+ const worker = async () => {
696
+ while (next < items.length) await fn(items[next++]);
697
+ };
698
+ await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
699
+ }
700
+ /** Remove now-empty parent directories of a removed card, up to `stop`. */
701
+ async function removeEmptyParents(file, stop) {
702
+ for (let dir = dirname(file); dir.startsWith(stop) && dir !== stop; dir = dirname(dir)) try {
703
+ await rmdir(dir);
704
+ } catch {
705
+ return;
706
+ }
707
+ }
708
+ /**
709
+ * Bring `cardDir` in line with `jobs`: render cards that are missing or out
710
+ * of date, delete files an earlier build wrote and this one no longer needs,
711
+ * and save the manifest. Only files listed in the previous manifest are ever
712
+ * deleted, so anything else in the directory is left alone.
713
+ */
714
+ async function publishCards(jobs, cardDir, manifestPath, render) {
715
+ const previous = await readManifest(manifestPath);
716
+ const next = {
717
+ version: 1,
718
+ files: {}
719
+ };
720
+ const result = {
721
+ rendered: [],
722
+ unchanged: [],
723
+ removed: [],
724
+ oversized: []
725
+ };
726
+ const pending = [];
727
+ for (const job of jobs) {
728
+ next.files[job.file] = job.renderHash;
729
+ if (previous.files[job.file] === job.renderHash && await exists(join(cardDir, job.file))) result.unchanged.push(job.file);
730
+ else pending.push(job);
731
+ }
732
+ await mapLimit(pending, 2, async (job) => {
733
+ const png = await render(job);
734
+ const size = pngSize(png);
735
+ if (!size || size.width !== 1200 || size.height !== 630) throw new Error(`card for "${job.file}" is ${size ? `${size.width}×${size.height}` : "not a PNG"}, expected ${WIDTH}×630`);
736
+ if (png.byteLength > 307200) result.oversized.push({
737
+ file: job.file,
738
+ bytes: png.byteLength
739
+ });
740
+ await writeAtomic(join(cardDir, job.file), png);
741
+ result.rendered.push(job.file);
742
+ });
743
+ for (const file of Object.keys(previous.files)) {
744
+ if (file in next.files) continue;
745
+ const path = join(cardDir, file);
746
+ if (!isInside(cardDir, path) || path === cardDir) continue;
747
+ try {
748
+ await unlink(path);
749
+ result.removed.push(file);
750
+ await removeEmptyParents(path, cardDir);
751
+ } catch (err) {
752
+ if (err.code !== "ENOENT") throw err;
753
+ }
754
+ }
755
+ await writeAtomic(manifestPath, `${JSON.stringify(next, null, 2)}\n`);
756
+ return result;
757
+ }
758
+ // src/index.ts
759
+ /**
760
+ * @docubook/plugin-og-image
761
+ *
762
+ * Gives every page a 1200×630 social preview card, rendered at build time
763
+ * from the page's frontmatter with Takumi. Authors write nothing extra:
764
+ *
765
+ * - `transformFrontmatter` points `frontmatter.image` at the page's card, and
766
+ * flame's own SEO code turns it into an absolute `og:image`;
767
+ * - `injectHead` adds the card's size, type and alt text, and an `og:image`
768
+ * on the landing page when `meta.ogImage` does not set one;
769
+ * - `onEnd` renders the cards into `<docs>/assets/og/`, skipping the ones
770
+ * whose inputs did not change;
771
+ * - `handleRequest` renders a card on demand during `flame dev`.
772
+ *
773
+ * An `image` in frontmatter always wins, and `og: false` opts a page out.
774
+ */
775
+ /** Logos above this size are skipped: a card shows them at 140 px. */
776
+ var MAX_LOGO_BYTES = 1048576;
777
+ /** Cards kept in memory by the dev server. */
778
+ var DEV_CACHE_SIZE = 50;
779
+ var consoleLogger = {
780
+ info: (message) => console.log(`${LOG_PREFIX} ${message}`),
781
+ warn: (message) => console.warn(`${LOG_PREFIX} ${message}`)
782
+ };
783
+ function escapeAttr(value) {
784
+ return value.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
785
+ }
786
+ function sizeTags(card) {
787
+ return [
788
+ `<meta property="og:image:width" content="1200">`,
789
+ `<meta property="og:image:height" content="630">`,
790
+ `<meta property="og:image:type" content="image/png">`,
791
+ `<meta property="og:image:alt" content="${escapeAttr(card.title)}">`
792
+ ];
793
+ }
794
+ /** Light-mode colours of the active theme, as flame resolves it. */
795
+ function resolvePalette(config) {
796
+ const themeConfig = process.env.FLAME_THEME || config.themes?.colors;
797
+ const vars = resolveTheme(themeConfig, presetRegistry).variables.root;
798
+ return {
799
+ background: vars.background ?? "0 0% 100%",
800
+ foreground: vars.foreground ?? "220 30% 15%",
801
+ primary: vars.primary ?? "210 81% 56%",
802
+ muted: vars["muted-foreground"] ?? "220 15% 38%",
803
+ border: vars["border-color"] ?? "210 20% 88%"
804
+ };
805
+ }
806
+ async function loadLogo(config, site, options, log) {
807
+ if (options.logo === false) return void 0;
808
+ const value = options.logo ?? config.navbar?.logo?.src;
809
+ if (!value) return void 0;
810
+ const source = options.logo ? `"logo" option` : `navbar.logo.src`;
811
+ const found = findLogoFile(value, site, resolveDeployPath(config));
812
+ if ("reason" in found) {
813
+ const why = {
814
+ remote: "is a URL, and the build never downloads files",
815
+ outside: "points outside the project",
816
+ missing: "was not found in the project",
817
+ type: "is not an SVG, PNG, JPEG or WebP file"
818
+ }[found.reason];
819
+ log.warn(`${source} "${value}" ${why}. Cards are rendered without a logo; set the "logo" option to a local file.`);
820
+ return;
821
+ }
822
+ const { size } = await stat(found.file);
823
+ if (size > 1048576) {
824
+ log.warn(`${source} "${value}" is ${Math.round(size / 1024)} KB, above the 1 MB limit. Cards are rendered without a logo.`);
825
+ return;
826
+ }
827
+ return new Uint8Array(await readFile(found.file));
828
+ }
829
+ async function loadRuntime(config, site, options, log, makeEngine) {
830
+ const fonts = [];
831
+ for (const font of options.fonts) {
832
+ const path = resolve(site.root, font.path);
833
+ let data;
834
+ try {
835
+ data = new Uint8Array(await readFile(path));
836
+ } catch (err) {
837
+ throw new Error(`${LOG_PREFIX} font "${font.path}" could not be read: ${err.message}`, { cause: err });
838
+ }
839
+ fonts.push({
840
+ ...font,
841
+ data
842
+ });
843
+ }
844
+ const logo = await loadLogo(config, site, options, log);
845
+ const engine = await makeEngine(fonts);
846
+ const palette = resolvePalette(config);
847
+ return {
848
+ engine,
849
+ brand: {
850
+ siteName: site.siteName,
851
+ domain: site.domain,
852
+ logo,
853
+ palette,
854
+ fontFamily: engine.fontFamily
855
+ },
856
+ fingerprint: sha256(JSON.stringify(palette), logo ? sha256(logo) : "no-logo", ...fonts.map((font) => sha256(font.data)))
857
+ };
858
+ }
859
+ function describe(list, limit = 5) {
860
+ const shown = list.slice(0, limit).join(", ");
861
+ return list.length > limit ? `${shown} (+${list.length - limit} more)` : shown;
862
+ }
863
+ function pluginOgImage(options, internals = {}) {
864
+ const resolved = resolveOptions(options);
865
+ const version = internals.version ?? "0.1.0";
866
+ const log = internals.log ?? consoleLogger;
867
+ return {
868
+ name: "og-image",
869
+ setup(build) {
870
+ const config = build.config;
871
+ const root = internals.root ?? findProjectRoot();
872
+ for (const [label, path] of [...typeof resolved.logo === "string" && !resolved.logo.startsWith("/") ? [["logo", resolved.logo]] : [], ...resolved.fonts.map((font, i) => [`fonts[${i}].path`, font.path])]) if (!isInside(root, resolve(root, path))) throw new Error(`${LOG_PREFIX} "${label}" must point inside the project, not "${path}".`);
873
+ const site = resolveSite(config, resolved, root);
874
+ const plan = (card) => planCard(card, site, resolved, version);
875
+ const wantsHome = () => site.hasLanding && resolved.pages.home && !config.meta?.ogImage;
876
+ let runtime;
877
+ const ready = () => runtime ??= loadRuntime(config, site, resolved, log, internals.createEngine ?? createEngine);
878
+ build.onStart(async () => {
879
+ if (!site.baseURL) log.warn(`meta.baseURL is not set, so og:image stays a relative URL that social sites ignore.`);
880
+ await ready();
881
+ });
882
+ const linked = /* @__PURE__ */ new Map();
883
+ build.transformFrontmatter((frontmatter, ctx) => {
884
+ linked.delete(ctx.slug);
885
+ const kind = ctx.slug === "" ? "index" : "docs";
886
+ if (kind === "index" && !site.hasDocsIndex) return;
887
+ if (skipReason(kind, frontmatter, resolved)) return;
888
+ let source;
889
+ try {
890
+ source = readFrontmatterSync(site.docsDir, ctx.slug);
891
+ } catch {
892
+ return;
893
+ }
894
+ if (!source || skipReason(kind, source, resolved)) return;
895
+ const planned = plan(docsCard(kind, ctx.slug, source, site));
896
+ linked.set(ctx.slug, planned);
897
+ return {
898
+ ...frontmatter,
899
+ image: imageRef(planned.file)
900
+ };
901
+ });
902
+ build.injectHead((ctx) => {
903
+ if (ctx.pageType === "docs") {
904
+ const planned = linked.get(ctx.slug);
905
+ return planned && ctx.frontmatter.image === imageRef(planned.file) ? sizeTags(planned.card) : [];
906
+ }
907
+ if (ctx.pageType === "home" && wantsHome()) {
908
+ const planned = plan(homeCard(config));
909
+ return [`<meta property="og:image" content="${escapeAttr(cardUrl(site, planned.file))}">`, ...sizeTags(planned.card)];
910
+ }
911
+ return [];
912
+ });
913
+ build.onEnd(async (_config, pages, { outDir }) => {
914
+ const started = performance.now();
915
+ const rt = await ready();
916
+ const cards = [];
917
+ let skipped = 0;
918
+ const addDocs = async (kind, slug) => {
919
+ let frontmatter;
920
+ try {
921
+ frontmatter = await readFrontmatter(site.docsDir, slug);
922
+ } catch (err) {
923
+ log.warn(`skipped "${slug || "index"}": its frontmatter could not be read (${err.message}).`);
924
+ return;
925
+ }
926
+ if (!frontmatter) return;
927
+ if (skipReason(kind, frontmatter, resolved)) {
928
+ skipped++;
929
+ return;
930
+ }
931
+ cards.push(plan(docsCard(kind, slug, frontmatter, site)));
932
+ };
933
+ if (resolved.pages.docs) for (const page of pages) await addDocs("docs", page.slug);
934
+ if (site.hasDocsIndex && resolved.pages.index) await addDocs("index", "");
935
+ if (wantsHome()) cards.push(plan(homeCard(config)));
936
+ const missing = await rt.engine.missingGlyphs(new Set(cards.flatMap(({ card }) => [...`${card.title}${card.description}${card.section ?? ""}`]).concat([...site.siteName])));
937
+ if (missing.length > 0) {
938
+ const affected = cards.filter(({ card }) => missing.some((char) => `${card.title}${card.description}${card.section ?? ""}`.includes(char))).map(({ card }) => card.kind === "docs" ? card.slug : card.kind);
939
+ log.warn(`no loaded font can draw ${describe(missing.map((char) => `"${char}"`), 10)}, so cards show boxes in their place. Add a font that covers them with the "fonts" option.` + (affected.length > 0 ? ` Pages: ${describe(affected)}.` : ""));
940
+ }
941
+ const jobs = cards.map((planned) => ({
942
+ ...planned,
943
+ renderHash: sha256(planned.nameHash, rt.fingerprint)
944
+ }));
945
+ const cardDir = join(docsOutputDir(outDir, site.prefix), CARD_DIR);
946
+ let result;
947
+ try {
948
+ result = await publishCards(jobs, cardDir, join(dirname(outDir), "og-image.json"), (job) => rt.engine.render(buildTree(resolved.layout, job.card, rt.brand)));
949
+ } catch (err) {
950
+ throw new Error(`${LOG_PREFIX} ${err.message}`, { cause: err });
951
+ }
952
+ for (const { file, bytes } of result.oversized) log.warn(`${file} is ${Math.round(bytes / 1024)} KB; some apps (WhatsApp) skip previews above 300 KB.`);
953
+ const parts = [`${result.rendered.length} rendered`, `${result.unchanged.length} unchanged`];
954
+ if (result.removed.length > 0) parts.push(`${result.removed.length} removed`);
955
+ if (skipped > 0) parts.push(`${skipped} pages use their own image`);
956
+ log.info(`${jobs.length} cards: ${parts.join(", ")} (${Math.round(performance.now() - started)}ms)`);
957
+ });
958
+ const devCache = /* @__PURE__ */ new Map();
959
+ const base = `${site.prefix}/${CARD_DIR}/`;
960
+ build.handleRequest(async (req) => {
961
+ const { pathname } = new URL(req.url);
962
+ if (!pathname.startsWith(base)) return;
963
+ const notFound = () => new Response("Not found", { status: 404 });
964
+ let relative;
965
+ try {
966
+ relative = decodeURIComponent(pathname.slice(base.length));
967
+ } catch {
968
+ return notFound();
969
+ }
970
+ const match = /^(.+)\.([0-9a-f]{8})\.png$/.exec(relative);
971
+ if (!match) return notFound();
972
+ const [, stem, hash] = match;
973
+ if (stem.split("/").some((s) => !/^[\w@-][\w.@-]*$/.test(s) || s.includes(".."))) return notFound();
974
+ try {
975
+ const candidates = [];
976
+ if (stem === "home" && wantsHome()) candidates.push(plan(homeCard(config)));
977
+ if (stem === "index" && site.hasDocsIndex && resolved.pages.index) {
978
+ const frontmatter = await readFrontmatter(site.docsDir, "");
979
+ if (frontmatter) candidates.push(plan(docsCard("index", "", frontmatter, site)));
980
+ }
981
+ if (resolved.pages.docs) {
982
+ const frontmatter = await readFrontmatter(site.docsDir, stem);
983
+ if (frontmatter) candidates.push(plan(docsCard("docs", stem, frontmatter, site)));
984
+ }
985
+ const chosen = candidates.find((c) => c.nameHash.startsWith(hash)) ?? candidates[0];
986
+ if (!chosen) return notFound();
987
+ const rt = await ready();
988
+ const key = sha256(chosen.nameHash, rt.fingerprint);
989
+ let png = devCache.get(key);
990
+ if (!png) {
991
+ png = await rt.engine.render(buildTree(resolved.layout, chosen.card, rt.brand));
992
+ devCache.set(key, png);
993
+ if (devCache.size > DEV_CACHE_SIZE) devCache.delete(devCache.keys().next().value);
994
+ }
995
+ return new Response(png, { headers: {
996
+ "Content-Type": "image/png",
997
+ "Cache-Control": "no-cache"
998
+ } });
999
+ } catch (err) {
1000
+ return new Response(`${LOG_PREFIX} ${err.message}`, { status: 500 });
1001
+ }
1002
+ });
1003
+ }
1004
+ };
1005
+ }
1006
+ export { MAX_LOGO_BYTES, pluginOgImage as default, resolvePalette };
1007
+
1008
+ //# sourceMappingURL=index.js.map