jskelet 0.6.4 → 0.6.6

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.
@@ -7,7 +7,20 @@
7
7
  * döner. Sosyal kazıyıcıların çoğu PNG beklediği için prod'da sharp önerilir.
8
8
  *
9
9
  * Domain bilgisi taşınmaz — metin, renk ve SVG uygulama tarafındandır.
10
+ *
11
+ * Slim imajda `system-ui` karşılığı yoktur; librsvg her sitede aynı boş
12
+ * kareleri basar. `@font-face` (data URI dahil) bu çizici tarafından yok
13
+ * sayılır, `woff2` de kare basar. `fonts` içindeki `ttf` / `otf` dosyaları
14
+ * fontconfig'e eklenir. Paket imajda yine gerekir; sistem font paketi gerekmez.
10
15
  */
16
+ import crypto from "node:crypto";
17
+ import { spawn } from "node:child_process";
18
+ import { existsSync } from "node:fs";
19
+ import fs from "node:fs/promises";
20
+ import { createRequire } from "node:module";
21
+ import os from "node:os";
22
+ import path from "node:path";
23
+ import { fileURLToPath, pathToFileURL } from "node:url";
11
24
  import { tryImportFromApp } from "../build/resolve-peer.mjs";
12
25
  import { getConfig } from "../config/index.js";
13
26
  import { isNotFoundError } from "../http/control-flow.js";
@@ -19,6 +32,12 @@ let sharpModule;
19
32
  /** Sosyal kartlar için yaygın boyut (Facebook / X / LinkedIn). */
20
33
  export const OG_SIZE = Object.freeze({ width: 1200, height: 630 });
21
34
 
35
+ /**
36
+ * Kartın yedeği. Slim imajda bu yığın boş kare basar; `fonts` verilince
37
+ * uygulamanın yüzü öne alınır.
38
+ */
39
+ const SYSTEM_FONT_STACK = "system-ui, -apple-system, Segoe UI, sans-serif";
40
+
22
41
  /**
23
42
  * OG süreleri HTML TTL'ye bağlanmaz. Yalnızca `s-maxage` kalkar; edge
24
43
  * `CDN-Cache-Control` üzerinde aynı pencereyi görür.
@@ -37,6 +56,20 @@ const OG_EDGE_STALE = 604800;
37
56
  * @property {string} [accent] Sol şerit rengi
38
57
  */
39
58
 
59
+ /**
60
+ * OG metnine gömülen yüz. `path` + `family` yeter. PNG, dosyanın içindeki
61
+ * ağırlığı kullanır; `weight` yalnızca SVG yanıtındaki `@font-face` içindir.
62
+ *
63
+ * @typedef {object} OgFontFace
64
+ * @property {string} path Font dosyası. PNG için `ttf` veya `otf`
65
+ * (librsvg `woff` / `woff2` dosyasından kare basar). Uygulama köküne
66
+ * göreli ya da mutlak.
67
+ * @property {string} family SVG `font-family` adı. Dosyanın içindeki adla
68
+ * aynı olmak zorunda değil.
69
+ * @property {number | string} [weight] Tek ağırlık (`700`) ya da aralık (`"100 900"`).
70
+ * @property {'normal' | 'italic' | 'oblique'} [style]
71
+ */
72
+
40
73
  /**
41
74
  * @typedef {OgCardOptions & {
42
75
  * svg?: string,
@@ -44,6 +77,7 @@ const OG_EDGE_STALE = 604800;
44
77
  * height?: number,
45
78
  * format?: 'png' | 'svg',
46
79
  * cacheControl?: string,
80
+ * fonts?: OgFontFace[],
47
81
  * }} OgImageOptions
48
82
  */
49
83
 
@@ -148,7 +182,7 @@ export function wrapText(text, maxChars, maxLines) {
148
182
 
149
183
  /**
150
184
  * Hazır kart SVG'si. Uygulama kendi SVG'sini vermek isterse `svg` kullanır.
151
- * @param {OgCardOptions & { width?: number, height?: number }} options
185
+ * @param {OgCardOptions & { width?: number, height?: number, fontFamily?: string }} options
152
186
  * @returns {string}
153
187
  */
154
188
  export function buildOgSvg(options = {}) {
@@ -158,6 +192,7 @@ export function buildOgSvg(options = {}) {
158
192
  const color = options.color ?? "#f8fafc";
159
193
  const muted = options.mutedColor ?? "#94a3b8";
160
194
  const accent = options.accent ?? "#38bdf8";
195
+ const fontFamily = options.fontFamily || SYSTEM_FONT_STACK;
161
196
 
162
197
  const titleLines = wrapText(options.title ?? "", 28, 3);
163
198
  const descLines = wrapText(options.description ?? "", 52, 2);
@@ -185,13 +220,13 @@ export function buildOgSvg(options = {}) {
185
220
  `<rect width="100%" height="100%" fill="${escapeXml(background)}"/>` +
186
221
  `<rect x="0" y="0" width="14" height="${height}" fill="${escapeXml(accent)}"/>` +
187
222
  (titleTs
188
- ? `<text x="80" y="${titleY}" font-family="system-ui, -apple-system, Segoe UI, sans-serif" font-size="64" font-weight="700" fill="${escapeXml(color)}">${titleTs}</text>`
223
+ ? `<text x="80" y="${titleY}" font-family="${escapeXml(fontFamily)}" font-size="64" font-weight="700" fill="${escapeXml(color)}">${titleTs}</text>`
189
224
  : "") +
190
225
  (descTs
191
- ? `<text x="80" y="${descY}" font-family="system-ui, -apple-system, Segoe UI, sans-serif" font-size="30" font-weight="400" fill="${escapeXml(muted)}">${descTs}</text>`
226
+ ? `<text x="80" y="${descY}" font-family="${escapeXml(fontFamily)}" font-size="30" font-weight="400" fill="${escapeXml(muted)}">${descTs}</text>`
192
227
  : "") +
193
228
  (siteName
194
- ? `<text x="80" y="${height - 64}" font-family="system-ui, -apple-system, Segoe UI, sans-serif" font-size="24" font-weight="600" fill="${escapeXml(muted)}">${siteName}</text>`
229
+ ? `<text x="80" y="${height - 64}" font-family="${escapeXml(fontFamily)}" font-size="24" font-weight="600" fill="${escapeXml(muted)}">${siteName}</text>`
195
230
  : "") +
196
231
  `</svg>`
197
232
  );
@@ -221,38 +256,599 @@ async function loadSharp() {
221
256
  }
222
257
 
223
258
  /**
224
- * SVG veya kart alanlarından PNG/SVG gövde üretir.
225
- * @param {OgImageOptions} [options]
226
- * @returns {Promise<OgImageResult>}
259
+ * Aynı eksik dosya her OG isteğinde yeniden uyarılmasın.
260
+ * @type {Set<string>}
227
261
  */
228
- export async function ogImage(options = {}) {
229
- const width = options.width ?? OG_SIZE.width;
230
- const height = options.height ?? OG_SIZE.height;
231
- const svg =
232
- typeof options.svg === "string" && options.svg.trim()
233
- ? options.svg
234
- : buildOgSvg({ ...options, width, height });
262
+ const warnedFonts = new Set();
263
+
264
+ /**
265
+ * Dosya baytı. Ağırlık ve family istekten isteme değişebilir; URI dosyaya bağlı.
266
+ * @type {Map<string, { mtimeMs: number, size: number, dataUri: string }>}
267
+ */
268
+ const fontDataCache = new Map();
269
+
270
+ /**
271
+ * @param {string} key
272
+ * @param {string} message
273
+ */
274
+ function warnFont(key, message) {
275
+ if (warnedFonts.has(key)) return;
276
+ warnedFonts.add(key);
277
+ console.warn(message);
278
+ }
279
+
280
+ /**
281
+ * Config yoksa (birim test) süreç dizinine düşülür.
282
+ * @returns {string}
283
+ */
284
+ function appRoot() {
285
+ try {
286
+ return getConfig().root;
287
+ } catch {
288
+ return process.cwd();
289
+ }
290
+ }
291
+
292
+ /**
293
+ * @param {unknown} filePath
294
+ * @returns {string}
295
+ */
296
+ function resolveFontPath(filePath) {
297
+ const raw = String(filePath ?? "").trim();
298
+ if (!raw) return "";
299
+ return path.isAbsolute(raw) ? raw : path.resolve(appRoot(), raw);
300
+ }
301
+
302
+ /**
303
+ * CSS string'ine ve SVG'ye sızmasın diye tırnak ve işaretler düşer.
304
+ * @param {unknown} value
305
+ * @returns {string}
306
+ */
307
+ function sanitizeFamily(value) {
308
+ return String(value ?? "")
309
+ .replace(/["\\\n\r<>]/g, "")
310
+ .trim();
311
+ }
312
+
313
+ /**
314
+ * @param {unknown} weight
315
+ * @returns {string}
316
+ */
317
+ function fontWeightValue(weight) {
318
+ if (weight == null || weight === "") return "100 900";
319
+ if (typeof weight === "number") {
320
+ if (!Number.isInteger(weight) || weight < 1 || weight > 1000) return "100 900";
321
+ return String(weight);
322
+ }
323
+ const text = String(weight).trim();
324
+ if (/^[1-9]\d{0,2}(\s+[1-9]\d{0,2})?$/.test(text)) return text;
325
+ return "100 900";
326
+ }
327
+
328
+ /**
329
+ * @param {unknown} style
330
+ * @returns {'normal' | 'italic' | 'oblique'}
331
+ */
332
+ function fontStyleValue(style) {
333
+ if (style === "italic" || style === "oblique") return style;
334
+ return "normal";
335
+ }
336
+
337
+ /**
338
+ * @param {string} filePath
339
+ * @returns {{ mime: string, format: string } | null}
340
+ */
341
+ function fontSource(filePath) {
342
+ switch (path.extname(filePath).toLowerCase()) {
343
+ case ".woff2":
344
+ return { mime: "font/woff2", format: "woff2" };
345
+ case ".woff":
346
+ return { mime: "font/woff", format: "woff" };
347
+ case ".otf":
348
+ return { mime: "font/otf", format: "opentype" };
349
+ case ".ttc":
350
+ return { mime: "font/collection", format: "collection" };
351
+ case ".ttf":
352
+ return { mime: "font/ttf", format: "truetype" };
353
+ default:
354
+ return null;
355
+ }
356
+ }
357
+
358
+ /**
359
+ * @param {string} filePath
360
+ * @param {string} mime
361
+ * @returns {Promise<string>}
362
+ */
363
+ async function fontDataUri(filePath, mime) {
364
+ const stat = await fs.stat(filePath);
365
+ const cached = fontDataCache.get(filePath);
366
+ if (cached && cached.mtimeMs === stat.mtimeMs && cached.size === stat.size) {
367
+ return cached.dataUri;
368
+ }
369
+ const buf = await fs.readFile(filePath);
370
+ const dataUri = `data:${mime};base64,${buf.toString("base64")}`;
371
+ fontDataCache.set(filePath, { mtimeMs: stat.mtimeMs, size: stat.size, dataUri });
372
+ return dataUri;
373
+ }
374
+
375
+ /**
376
+ * @param {OgFontFace} font
377
+ * @returns {Promise<string>}
378
+ */
379
+ async function fontFaceRule(font) {
380
+ const family = sanitizeFamily(font?.family);
381
+ if (!family) {
382
+ warnFont("family", "[og] fonts[].family is empty, skipping");
383
+ return "";
384
+ }
385
+ const filePath = resolveFontPath(font?.path);
386
+ if (!filePath) {
387
+ warnFont("path", "[og] fonts[].path is empty, skipping");
388
+ return "";
389
+ }
390
+ const source = fontSource(filePath);
391
+ if (!source) {
392
+ warnFont(filePath, `[og] unsupported font extension, skipping: ${filePath}`);
393
+ return "";
394
+ }
395
+
396
+ let dataUri;
397
+ try {
398
+ dataUri = await fontDataUri(filePath, source.mime);
399
+ } catch (error) {
400
+ const missing =
401
+ error && typeof error === "object" && "code" in error && error.code === "ENOENT";
402
+ warnFont(
403
+ filePath,
404
+ missing
405
+ ? `[og] font file not found, skipping: ${filePath}`
406
+ : `[og] could not read font, skipping: ${filePath}`,
407
+ );
408
+ return "";
409
+ }
410
+
411
+ return (
412
+ `@font-face {\n` +
413
+ ` font-family: "${family}";\n` +
414
+ ` font-style: ${fontStyleValue(font.style)};\n` +
415
+ ` font-weight: ${fontWeightValue(font.weight)};\n` +
416
+ ` src: url("${dataUri}") format("${source.format}");\n` +
417
+ `}`
418
+ );
419
+ }
420
+
421
+ /**
422
+ * Ham SVG'deki `font-family` uygulamanın işi. Burada yalnızca yüz tanımı eklenir,
423
+ * böylece uygulama kendi `@font-face` bloğunu yazmaz.
424
+ *
425
+ * @param {string} svg
426
+ * @param {string} css
427
+ * @returns {string}
428
+ */
429
+ function injectFontFaces(svg, css) {
430
+ const style = `<style><![CDATA[\n${css}\n]]></style>`;
431
+ if (/<defs[\s>]/i.test(svg)) {
432
+ return svg.replace(/<defs(\s[^>]*)?>/i, (open) => `${open}${style}`);
433
+ }
434
+ return svg.replace(/<svg\b[^>]*>/i, (open) => `${open}<defs>${style}</defs>`);
435
+ }
436
+
437
+ /**
438
+ * Kayıtlar çakışmasın diye sıraya girer. Çizim kuyruğun dışında kalır.
439
+ * @type {Promise<void>}
440
+ */
441
+ let fontQueue = Promise.resolve();
442
+
443
+ const fontconfigRoot = path.join(os.tmpdir(), "jskelet-og-fontconfig");
444
+
445
+ /**
446
+ * Kaynak yol → kullanıcının family adı ve dosyanın içindeki ad.
447
+ * @type {Map<string, { userFamily: string, internalFamily: string }>}
448
+ */
449
+ const rasterFonts = new Map();
450
+
451
+ /**
452
+ * @template T
453
+ * @param {() => Promise<T>} task
454
+ * @returns {Promise<T>}
455
+ */
456
+ function enqueueFont(task) {
457
+ const run = fontQueue.then(task, task);
458
+ fontQueue = run.then(
459
+ () => undefined,
460
+ () => undefined,
461
+ );
462
+ return run;
463
+ }
464
+
465
+ /**
466
+ * @param {string} value
467
+ * @returns {string}
468
+ */
469
+ function xmlText(value) {
470
+ return String(value)
471
+ .replace(/&/g, "&amp;")
472
+ .replace(/</g, "&lt;")
473
+ .replace(/>/g, "&gt;")
474
+ .replace(/"/g, "&quot;");
475
+ }
235
476
 
236
- const preferSvg = options.format === "svg";
237
- const sharp = preferSvg ? null : await loadSharp();
477
+ /**
478
+ * @param {Buffer} buf
479
+ * @returns {boolean}
480
+ */
481
+ function isSfnt(buf) {
482
+ if (buf.length < 12) return false;
483
+ const tag = buf.readUInt32BE(0);
484
+ // 0x00010000 ttf, OTTO otf, true (eski trueType)
485
+ return tag === 0x00010000 || tag === 0x4f54544f || tag === 0x74727565;
486
+ }
487
+
488
+ /**
489
+ * name tablosundan typographic family (nameID 16), yoksa font family (1).
490
+ * Ayrıştırılamazsa `null` — site düşmez, verilen ad denenir.
491
+ * @param {Buffer} buf
492
+ * @returns {string | null}
493
+ */
494
+ function readInternalFamily(buf) {
495
+ try {
496
+ if (!isSfnt(buf)) return null;
497
+ const numTables = buf.readUInt16BE(4);
498
+ let nameOffset = -1;
499
+ let nameLength = 0;
500
+ for (let i = 0; i < numTables; i += 1) {
501
+ const rec = 12 + i * 16;
502
+ if (rec + 16 > buf.length) return null;
503
+ const tag = buf.toString("latin1", rec, rec + 4);
504
+ if (tag !== "name") continue;
505
+ nameOffset = buf.readUInt32BE(rec + 8);
506
+ nameLength = buf.readUInt32BE(rec + 12);
507
+ break;
508
+ }
509
+ if (nameOffset < 0 || nameOffset + nameLength > buf.length) return null;
510
+ const table = buf.subarray(nameOffset, nameOffset + nameLength);
511
+ const count = table.readUInt16BE(2);
512
+ const stringOffset = table.readUInt16BE(4);
513
+ let best = "";
514
+ let bestScore = -1;
515
+ for (let i = 0; i < count; i += 1) {
516
+ const rec = 6 + i * 12;
517
+ if (rec + 12 > table.length) break;
518
+ const platformID = table.readUInt16BE(rec);
519
+ const nameID = table.readUInt16BE(rec + 6);
520
+ const length = table.readUInt16BE(rec + 8);
521
+ const offset = table.readUInt16BE(rec + 10);
522
+ if (nameID !== 16 && nameID !== 1) continue;
523
+ const start = stringOffset + offset;
524
+ if (start + length > table.length) continue;
525
+ const bytes = table.subarray(start, start + length);
526
+ let text = "";
527
+ if (platformID === 3 || platformID === 0) {
528
+ const swapped = Buffer.alloc(bytes.length - (bytes.length % 2));
529
+ for (let b = 0; b + 1 < bytes.length; b += 2) {
530
+ swapped[b] = bytes[b + 1];
531
+ swapped[b + 1] = bytes[b];
532
+ }
533
+ text = swapped.toString("utf16le").replace(/\0/g, "").trim();
534
+ } else {
535
+ text = bytes.toString("latin1").trim();
536
+ }
537
+ if (!text) continue;
538
+ const score = (nameID === 16 ? 10 : 0) + (platformID === 3 ? 2 : platformID === 0 ? 1 : 0);
539
+ if (score > bestScore) {
540
+ best = text;
541
+ bestScore = score;
542
+ }
543
+ }
544
+ return best || null;
545
+ } catch {
546
+ return null;
547
+ }
548
+ }
549
+
550
+ /**
551
+ * FONTCONFIG_FILE varsayılan listeyi değiştirir; sistem dizinlerini geri koy.
552
+ * @returns {string[]}
553
+ */
554
+ function systemFontDirs() {
555
+ /** @type {string[]} */
556
+ const dirs = [];
557
+ const windir = process.env.WINDIR || process.env.windir;
558
+ if (windir) dirs.push(path.join(windir, "Fonts"));
559
+ dirs.push("/usr/share/fonts", "/usr/local/share/fonts", path.join(os.homedir(), ".local/share/fonts"));
560
+ return dirs.filter((dir) => existsSync(dir));
561
+ }
562
+
563
+ /**
564
+ * @param {string} conf
565
+ * @param {string} fontsDir
566
+ * @param {string} cacheDir
567
+ * @returns {Promise<void>}
568
+ */
569
+ async function writeFontconfigFile(conf, fontsDir, cacheDir) {
570
+
571
+ /** @type {Map<string, Set<string>>} */
572
+ const aliases = new Map();
573
+ for (const face of rasterFonts.values()) {
574
+ const targets = aliases.get(face.userFamily) ?? new Set();
575
+ targets.add(face.internalFamily);
576
+ aliases.set(face.userFamily, targets);
577
+ }
578
+
579
+ const dirs = [fontsDir, ...systemFontDirs()]
580
+ .map((dir) => ` <dir>${xmlText(dir.split(path.sep).join("/"))}</dir>`)
581
+ .join("\n");
582
+ const aliasXml = [...aliases.entries()]
583
+ .map(([userFamily, targets]) => {
584
+ const prefer = [...targets].map((name) => ` <family>${xmlText(name)}</family>`).join("\n");
585
+ return (
586
+ ` <alias binding="strong">\n` +
587
+ ` <family>${xmlText(userFamily)}</family>\n` +
588
+ ` <prefer>\n${prefer}\n </prefer>\n` +
589
+ ` </alias>`
590
+ );
591
+ })
592
+ .join("\n");
593
+
594
+ const xml =
595
+ `<?xml version="1.0"?>\n` +
596
+ `<fontconfig>\n` +
597
+ ` <include ignore_missing="yes">/etc/fonts/fonts.conf</include>\n` +
598
+ `${dirs}\n` +
599
+ ` <cachedir>${xmlText(cacheDir.split(path.sep).join("/"))}</cachedir>\n` +
600
+ `${aliasXml}\n` +
601
+ `</fontconfig>\n`;
602
+ await fs.writeFile(conf, xml);
603
+ }
604
+
605
+ /**
606
+ * @param {string} filePath
607
+ * @param {string} userFamily
608
+ * @returns {Promise<boolean>}
609
+ */
610
+ async function registerRasterFont(filePath, userFamily) {
611
+ const ext = path.extname(filePath).toLowerCase();
612
+ if (ext === ".woff" || ext === ".woff2") {
613
+ if (existsSync(filePath)) {
614
+ warnFont(
615
+ `web:${filePath}`,
616
+ `[og] woff/woff2 is not drawn by librsvg (empty boxes). Pass a ttf or otf: ${filePath}`,
617
+ );
618
+ }
619
+ return false;
620
+ }
621
+ if (ext !== ".ttf" && ext !== ".otf" && ext !== ".ttc") return false;
622
+
623
+ let buf;
624
+ try {
625
+ buf = await fs.readFile(filePath);
626
+ } catch (error) {
627
+ const missing = error && typeof error === "object" && "code" in error && error.code === "ENOENT";
628
+ warnFont(
629
+ filePath,
630
+ missing
631
+ ? `[og] font file not found, skipping: ${filePath}`
632
+ : `[og] could not read font, skipping: ${filePath}`,
633
+ );
634
+ return false;
635
+ }
636
+
637
+ if (ext !== ".ttc" && !isSfnt(buf)) {
638
+ warnFont(filePath, `[og] font file is not a ttf/otf, skipping raster face: ${filePath}`);
639
+ return false;
640
+ }
641
+
642
+ const parsed = readInternalFamily(buf);
643
+ const internalFamily = parsed || userFamily;
644
+ if (!parsed) {
645
+ warnFont(
646
+ filePath,
647
+ `[og] could not read the name inside the font, using the given family: ${filePath}`,
648
+ );
649
+ }
650
+
651
+ const fontsDir = path.join(fontconfigRoot, "fonts");
652
+ await fs.mkdir(fontsDir, { recursive: true });
653
+ const destName = `${crypto.createHash("sha256").update(filePath).digest("hex").slice(0, 16)}${ext}`;
654
+ await fs.copyFile(filePath, path.join(fontsDir, destName));
655
+ rasterFonts.set(filePath, { userFamily, internalFamily });
656
+ return true;
657
+ }
658
+
659
+ /**
660
+ * @param {OgFontFace[] | undefined} fonts
661
+ * @returns {Promise<{ css: string, family: string | null, rasterFamily: string | null }>}
662
+ */
663
+ async function loadOgFonts(fonts) {
664
+ if (!Array.isArray(fonts) || fonts.length === 0) {
665
+ return { css: "", family: null, rasterFamily: null };
666
+ }
667
+
668
+ return enqueueFont(async () => {
669
+ /** @type {string[]} */
670
+ const rules = [];
671
+ /** @type {string[]} */
672
+ const families = [];
673
+ /** @type {string[]} */
674
+ const rasterFamilies = [];
675
+
676
+ for (const font of fonts) {
677
+ if (!font || typeof font !== "object") continue;
678
+ const family = sanitizeFamily(font.family);
679
+ const rule = await fontFaceRule(font);
680
+ if (rule && family) {
681
+ rules.push(rule);
682
+ if (!families.includes(family)) families.push(family);
683
+ }
684
+ if (!family) continue;
685
+ const filePath = resolveFontPath(font.path);
686
+ if (!filePath) continue;
687
+ const registered = await registerRasterFont(filePath, family);
688
+ if (registered && !rasterFamilies.includes(family)) rasterFamilies.push(family);
689
+ }
690
+
691
+ const stack = (names) =>
692
+ names.length ? `${names.map((name) => `"${name}"`).join(", ")}, ${SYSTEM_FONT_STACK}` : null;
238
693
 
239
- if (!sharp) {
240
694
  return {
241
- body: Buffer.from(svg, "utf8"),
242
- contentType: "image/svg+xml; charset=utf-8",
243
- width,
244
- height,
695
+ css: rules.join("\n"),
696
+ family: stack(families),
697
+ rasterFamily: stack(rasterFamilies),
245
698
  };
699
+ });
700
+ }
701
+
702
+ /**
703
+ * fontconfig `FONTCONFIG_FILE`'ı süreç açılırken okur. Windows'ta çalışan
704
+ * sürecin `process.env` ataması CRT `getenv`'ine düşmez; yüzlü PNG'yi bu
705
+ * dosyayı baştan gören bir alt süreç çizer. Üst sürecin fontconfig'i
706
+ * (görsel optimizer) değişmez.
707
+ *
708
+ * @param {string} svg
709
+ * @param {number} width
710
+ * @param {number} height
711
+ * @returns {Promise<Buffer | null>}
712
+ */
713
+ async function rasterizeWithFonts(svg, width, height) {
714
+ const sharpUrl = sharpModuleUrl();
715
+ if (!sharpUrl) return null;
716
+
717
+ const fontsDir = path.join(fontconfigRoot, "fonts");
718
+ const cacheDir = path.join(fontconfigRoot, "cache");
719
+ await fs.mkdir(fontsDir, { recursive: true });
720
+ await fs.mkdir(cacheDir, { recursive: true });
721
+ const conf = path.join(fontconfigRoot, `fonts-${crypto.randomBytes(8).toString("hex")}.conf`);
722
+ await writeFontconfigFile(conf, fontsDir, cacheDir);
723
+
724
+ const script = fileURLToPath(new URL("./og-raster.mjs", import.meta.url));
725
+ try {
726
+ return await new Promise((resolve, reject) => {
727
+ const child = spawn(process.execPath, [script], {
728
+ env: {
729
+ ...process.env,
730
+ FONTCONFIG_FILE: conf,
731
+ JSKELET_SHARP: sharpUrl,
732
+ JSKELET_OG_WIDTH: String(width),
733
+ JSKELET_OG_HEIGHT: String(height),
734
+ },
735
+ stdio: ["pipe", "pipe", "pipe"],
736
+ windowsHide: true,
737
+ });
738
+ /** @type {Buffer[]} */
739
+ const out = [];
740
+ /** @type {Buffer[]} */
741
+ const err = [];
742
+ child.stdout.on("data", (chunk) => out.push(chunk));
743
+ child.stderr.on("data", (chunk) => err.push(chunk));
744
+ child.on("error", reject);
745
+ child.on("close", (code) => {
746
+ if (code !== 0) {
747
+ const detail = Buffer.concat(err).toString("utf8").trim();
748
+ reject(new Error(`[og] raster failed (${code})${detail ? `: ${detail}` : ""}`));
749
+ return;
750
+ }
751
+ resolve(Buffer.concat(out));
752
+ });
753
+ child.stdin.end(svg);
754
+ });
755
+ } finally {
756
+ await fs.rm(conf, { force: true });
757
+ }
758
+ }
759
+
760
+ /**
761
+ * Uygulamanın sharp'ı. Framework `node_modules` içindeyken kendi paketinden
762
+ * çözülmez.
763
+ * @returns {string | null}
764
+ */
765
+ function sharpModuleUrl() {
766
+ /** @type {string[]} */
767
+ const roots = [appRoot()];
768
+ for (const root of roots) {
769
+ try {
770
+ const require = createRequire(path.join(root, "package.json"));
771
+ return pathToFileURL(require.resolve("sharp")).href;
772
+ } catch {
773
+ // framework kopyasına düş
774
+ }
775
+ }
776
+ try {
777
+ return pathToFileURL(createRequire(import.meta.url).resolve("sharp")).href;
778
+ } catch {
779
+ return null;
246
780
  }
781
+ }
247
782
 
248
- const body = await sharp(Buffer.from(svg, "utf8"))
783
+ /**
784
+ * @param {string} svg
785
+ * @param {number} width
786
+ * @param {number} height
787
+ * @param {boolean} withFonts
788
+ * @returns {Promise<Buffer | null>}
789
+ */
790
+ async function rasterizeOg(svg, width, height, withFonts) {
791
+ if (withFonts) return rasterizeWithFonts(svg, width, height);
792
+ const sharp = await loadSharp();
793
+ if (!sharp) return null;
794
+ return sharp(Buffer.from(svg, "utf8"))
249
795
  .resize(width, height, { fit: "fill" })
250
796
  .png()
251
797
  .toBuffer();
798
+ }
799
+
800
+ /**
801
+ * SVG veya kart alanlarından PNG/SVG gövde üretir.
802
+ *
803
+ * PNG'de `ttf` / `otf` fontconfig'e eklenir ve kart bu `family` adlarını
804
+ * kullanır. `woff` / `woff2` yalnızca sharp yokken (ya da `format: "svg"`)
805
+ * `@font-face` olarak gömülür; librsvg onlardan kare basar. Dosya okunamazsa
806
+ * görsel yine üretilir, o yüz atlanır. Çalışma imajında `fontconfig` paketi
807
+ * gerekir.
808
+ *
809
+ * @param {OgImageOptions} [options]
810
+ * @returns {Promise<OgImageResult>}
811
+ */
812
+ export async function ogImage(options = {}) {
813
+ const width = options.width ?? OG_SIZE.width;
814
+ const height = options.height ?? OG_SIZE.height;
815
+ const loaded = await loadOgFonts(options.fonts);
816
+ const raw = typeof options.svg === "string" && options.svg.trim() ? options.svg : null;
817
+
818
+ /**
819
+ * @param {boolean} raster
820
+ * @returns {string}
821
+ */
822
+ const makeSvg = (raster) => {
823
+ const family = raster ? loaded.rasterFamily : loaded.family;
824
+ let svg =
825
+ raw ??
826
+ buildOgSvg({
827
+ ...options,
828
+ width,
829
+ height,
830
+ fontFamily: family ?? undefined,
831
+ });
832
+ // librsvg gömülü yüzü çizmez; data URI yalnızca tarayıcıya giden SVG'de durur.
833
+ if (!raster && loaded.css) svg = injectFontFaces(svg, loaded.css);
834
+ return svg;
835
+ };
836
+
837
+ if (options.format !== "svg") {
838
+ const body = await rasterizeOg(makeSvg(true), width, height, Boolean(loaded.rasterFamily));
839
+ if (body) {
840
+ return {
841
+ body,
842
+ contentType: "image/png",
843
+ width,
844
+ height,
845
+ };
846
+ }
847
+ }
252
848
 
253
849
  return {
254
- body,
255
- contentType: "image/png",
850
+ body: Buffer.from(makeSvg(false), "utf8"),
851
+ contentType: "image/svg+xml; charset=utf-8",
256
852
  width,
257
853
  height,
258
854
  };
@@ -338,7 +934,7 @@ export class ImageResponse {
338
934
  #options;
339
935
 
340
936
  /**
341
- * @param {string | OgCardOptions} element
937
+ * @param {string | (OgCardOptions & { fonts?: OgFontFace[] })} element
342
938
  * @param {Omit<OgImageOptions, keyof OgCardOptions | 'svg'> & { width?: number, height?: number }} [init]
343
939
  */
344
940
  constructor(element, init = {}) {