jskelet 0.6.4 → 0.6.5

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 CHANGED
@@ -28,6 +28,11 @@ one is listed under a **Breaking** heading.
28
28
 
29
29
  ### Added
30
30
 
31
+ - `ogImage({ fonts })` registers each `ttf` or `otf` (`path` and `family`)
32
+ with fontconfig so card and raw-SVG text use the site's own files. A slim
33
+ image still needs the `fontconfig` package. `woff` / `woff2` are not drawn
34
+ by the rasterizer (empty boxes); they are embedded only when the response
35
+ is SVG.
31
36
  - `robots.txt` responses gain a trailing JSkelet note that disallows framework
32
37
  endpoints (`/_jskelet/`, `/__jskelet/`, `/_fragment/`, plus a custom admin,
33
38
  image, handoff, or dev path when it sits outside those prefixes). Every
@@ -582,6 +582,39 @@ app.get("/og/custom.png", async (req, res) => {
582
582
  });
583
583
  ```
584
584
 
585
+ ### Fontlar
586
+
587
+ `bookworm-slim` gibi ince imajlarda sistem fontu yoktur. Hazır kart
588
+ `system-ui` istediği için librsvg her sitede aynı boş kareleri basar.
589
+ `fonts`, `ttf` veya `otf` dosyasını fontconfig üzerinden librsvg'ye
590
+ bağlar. `family` kart metninin kullandığı addır; dosyanın içindeki adla
591
+ aynı olmak zorunda değildir. Ham `svg`'de kendi `font-family` değerinizi
592
+ bırakın ve aynı `family` adını buraya verin — `@font-face` bloğu yazmayın.
593
+ librsvg onu yok sayar.
594
+
595
+ ```js
596
+ return {
597
+ title: post.title,
598
+ description: post.excerpt,
599
+ siteName: "Blog",
600
+ fonts: [
601
+ { path: "fonts/Inter-Regular.ttf", family: "Inter" },
602
+ { path: "fonts/Inter-Bold.ttf", family: "Inter" },
603
+ ],
604
+ };
605
+ ```
606
+
607
+ `path` uygulama köküne görelidir; mutlak yol da geçerlidir. PNG `ttf` ve
608
+ `otf` okur. librsvg `@font-face`'i yok sayar; `woff` / `woff2` boş kare
609
+ basar (yanıt SVG ise bu dosyalar yine gömülür, tarayıcı kullanabilir).
610
+ `weight` opsiyoneldir; her dosya içindeki ağırlığını korur, başlık gerçekten
611
+ kalın olsun diye regular ve bold dosyalarını birlikte verin. Dosya yoksa
612
+ görsel yine üretilir, o yüz atlanır.
613
+
614
+ Çalışma imajında `fontconfig` paketi yine gerekir: sharp'ın SVG metin çizimi
615
+ onsuz yüzleri yüklemez. Sistem font paketi (`fonts-dejavu` gibi) gerekmez.
616
+ PNG için `sharp` çalışma bağımlılığıdır.
617
+
585
618
  Varsayılan başlıklar (süreler HTML ayarına bağlı değildir):
586
619
 
587
620
  ```
@@ -136,7 +136,10 @@ Notlar:
136
136
  optimizasyonu için. `--omit=dev` ile dışarıda kalır (devDependency olarak
137
137
  kurulmuşsa). **`images.remote` açıksa** sharp runtime bağımlılığıdır —
138
138
  production `dependencies`'e alın ya da runtime imajında ayrıca kurun; yoksa
139
- optimizer kaynak URL'ye 302 yönlendirir.
139
+ optimizer kaynak URL'ye 302 yönlendirir. **Dinamik OG PNG** de sharp ister
140
+ ve imajda `fontconfig` paketi gerekir (`apt-get install -y fontconfig`).
141
+ `ogImage({ fonts })` site fontunu gömer; sistem font paketi şart değildir.
142
+ `fontconfig` yoksa metin boş kare basar.
140
143
  - `jskelet start`ı `npx` olmadan çağırmak isterseniz
141
144
  `CMD ["node", "node_modules/jskelet/bin/jskelet.mjs", "start"]` de çalışır.
142
145
 
@@ -588,6 +588,39 @@ app.get("/og/custom.png", async (req, res) => {
588
588
  });
589
589
  ```
590
590
 
591
+ ### Fonts
592
+
593
+ A slim image such as `bookworm-slim` has no system fonts. The built-in card
594
+ asks for `system-ui`, so librsvg draws the same empty boxes on every site.
595
+ `fonts` points librsvg at your `ttf` or `otf` through fontconfig. `family` is
596
+ the name the card text uses; it does not have to be the name stored in the
597
+ file. On a raw `svg`, keep your own `font-family` and pass the same `family`
598
+ here — do not write an `@font-face` block. librsvg ignores it.
599
+
600
+ ```js
601
+ return {
602
+ title: post.title,
603
+ description: post.excerpt,
604
+ siteName: "Blog",
605
+ fonts: [
606
+ { path: "fonts/Inter-Regular.ttf", family: "Inter" },
607
+ { path: "fonts/Inter-Bold.ttf", family: "Inter" },
608
+ ],
609
+ };
610
+ ```
611
+
612
+ `path` is relative to the application root; an absolute path is accepted too.
613
+ PNG rasterization reads `ttf` and `otf`. librsvg ignores `@font-face` and
614
+ draws empty boxes from `woff` / `woff2` (those files are still embedded when
615
+ the response is SVG, because the browser can use them). `weight` is optional;
616
+ each file keeps the weight stored inside it, so pass regular and bold files
617
+ if the title should be a real bold. A missing file is skipped and the image
618
+ is still produced.
619
+
620
+ The runtime image still needs the `fontconfig` package: sharp's SVG text
621
+ drawing will not load the faces without it. A system font package (such as
622
+ `fonts-dejavu`) is not required. PNG needs `sharp` as a runtime dependency.
623
+
591
624
  Default headers (the durations are not tied to the HTML setting):
592
625
 
593
626
  ```
@@ -138,6 +138,10 @@ Notes:
138
138
  as a devDependency). **If `images.remote` is enabled**, sharp is a runtime
139
139
  dependency — move it to production `dependencies` or install it in the
140
140
  runtime image; otherwise the optimizer 302-redirects to the source URL.
141
+ **Dynamic OG PNG** needs sharp too, and the image needs the `fontconfig`
142
+ package (`apt-get install -y fontconfig`). `ogImage({ fonts })` embeds the
143
+ site's own faces; a system font package is not required. Without
144
+ `fontconfig`, text renders as empty boxes.
141
145
  - If you would rather call `jskelet start` without `npx`,
142
146
  `CMD ["node", "node_modules/jskelet/bin/jskelet.mjs", "start"]` works too.
143
147
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jskelet",
3
- "version": "0.6.4",
3
+ "version": "0.6.5",
4
4
  "description": "A framework that feels like no framework: Express 5 + build-time .jsk SSR (optional EJS peer), vanilla JS islands, Tailwind v4 and an in-process HTML TTL cache.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -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 = {}) {
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Yüzlü OG PNG'si.
3
+ *
4
+ * fontconfig `FONTCONFIG_FILE`'ı süreç açılırken okur. Üst süreçte sonradan
5
+ * atanan ortam değişkeni (özellikle Windows CRT `getenv`) görünmez; bu yüzden
6
+ * çizim, dosyayı baştan gören bu süreçte yapılır.
7
+ */
8
+ const sharpMod = await import(process.env.JSKELET_SHARP);
9
+ const sharp = sharpMod.default ?? sharpMod;
10
+
11
+ /** @type {Buffer[]} */
12
+ const chunks = [];
13
+ for await (const chunk of process.stdin) chunks.push(chunk);
14
+
15
+ const width = Number(process.env.JSKELET_OG_WIDTH);
16
+ const height = Number(process.env.JSKELET_OG_HEIGHT);
17
+ const body = await sharp(Buffer.concat(chunks))
18
+ .resize(width, height, { fit: "fill" })
19
+ .png()
20
+ .toBuffer();
21
+ process.stdout.write(body);
@@ -24,12 +24,31 @@ export type OgCardOptions = {
24
24
  */
25
25
  accent?: string;
26
26
  };
27
+ export type OgFontFace = {
28
+ /**
29
+ * Font dosyası. PNG için `ttf` veya `otf`
30
+ * (librsvg `woff` / `woff2` dosyasından kare basar). Uygulama köküne
31
+ * göreli ya da mutlak.
32
+ */
33
+ path: string;
34
+ /**
35
+ * SVG `font-family` adı. Dosyanın içindeki adla
36
+ * aynı olmak zorunda değil.
37
+ */
38
+ family: string;
39
+ /**
40
+ * Tek ağırlık (`700`) ya da aralık (`"100 900"`).
41
+ */
42
+ weight?: number | string;
43
+ style?: 'normal' | 'italic' | 'oblique';
44
+ };
27
45
  export type OgImageOptions = OgCardOptions & {
28
46
  svg?: string;
29
47
  width?: number;
30
48
  height?: number;
31
49
  format?: 'png' | 'svg';
32
50
  cacheControl?: string;
51
+ fonts?: OgFontFace[];
33
52
  };
34
53
  export type OgImageResult = {
35
54
  body: Buffer;
@@ -47,6 +66,19 @@ export type OgImageResult = {
47
66
  * @property {string} [mutedColor] Açıklama / site adı
48
67
  * @property {string} [accent] Sol şerit rengi
49
68
  */
69
+ /**
70
+ * OG metnine gömülen yüz. `path` + `family` yeter. PNG, dosyanın içindeki
71
+ * ağırlığı kullanır; `weight` yalnızca SVG yanıtındaki `@font-face` içindir.
72
+ *
73
+ * @typedef {object} OgFontFace
74
+ * @property {string} path Font dosyası. PNG için `ttf` veya `otf`
75
+ * (librsvg `woff` / `woff2` dosyasından kare basar). Uygulama köküne
76
+ * göreli ya da mutlak.
77
+ * @property {string} family SVG `font-family` adı. Dosyanın içindeki adla
78
+ * aynı olmak zorunda değil.
79
+ * @property {number | string} [weight] Tek ağırlık (`700`) ya da aralık (`"100 900"`).
80
+ * @property {'normal' | 'italic' | 'oblique'} [style]
81
+ */
50
82
  /**
51
83
  * @typedef {OgCardOptions & {
52
84
  * svg?: string,
@@ -54,6 +86,7 @@ export type OgImageResult = {
54
86
  * height?: number,
55
87
  * format?: 'png' | 'svg',
56
88
  * cacheControl?: string,
89
+ * fonts?: OgFontFace[],
57
90
  * }} OgImageOptions
58
91
  */
59
92
  /**
@@ -79,15 +112,23 @@ export declare function escapeXml(value: unknown): string;
79
112
  export declare function wrapText(text: string, maxChars: number, maxLines: number): string[];
80
113
  /**
81
114
  * Hazır kart SVG'si. Uygulama kendi SVG'sini vermek isterse `svg` kullanır.
82
- * @param {OgCardOptions & { width?: number, height?: number }} options
115
+ * @param {OgCardOptions & { width?: number, height?: number, fontFamily?: string }} options
83
116
  * @returns {string}
84
117
  */
85
118
  export declare function buildOgSvg(options?: OgCardOptions & {
86
119
  width?: number;
87
120
  height?: number;
121
+ fontFamily?: string;
88
122
  }): string;
89
123
  /**
90
124
  * SVG veya kart alanlarından PNG/SVG gövde üretir.
125
+ *
126
+ * PNG'de `ttf` / `otf` fontconfig'e eklenir ve kart bu `family` adlarını
127
+ * kullanır. `woff` / `woff2` yalnızca sharp yokken (ya da `format: "svg"`)
128
+ * `@font-face` olarak gömülür; librsvg onlardan kare basar. Dosya okunamazsa
129
+ * görsel yine üretilir, o yüz atlanır. Çalışma imajında `fontconfig` paketi
130
+ * gerekir.
131
+ *
91
132
  * @param {OgImageOptions} [options]
92
133
  * @returns {Promise<OgImageResult>}
93
134
  */
@@ -135,10 +176,12 @@ export declare function ogHandler(factory: (ctx: {
135
176
  export declare class ImageResponse {
136
177
  #private;
137
178
  /**
138
- * @param {string | OgCardOptions} element
179
+ * @param {string | (OgCardOptions & { fonts?: OgFontFace[] })} element
139
180
  * @param {Omit<OgImageOptions, keyof OgCardOptions | 'svg'> & { width?: number, height?: number }} [init]
140
181
  */
141
- constructor(element: string | OgCardOptions, init?: Omit<OgImageOptions, keyof OgCardOptions | 'svg'> & {
182
+ constructor(element: string | (OgCardOptions & {
183
+ fonts?: OgFontFace[];
184
+ }), init?: Omit<OgImageOptions, keyof OgCardOptions | 'svg'> & {
142
185
  width?: number;
143
186
  height?: number;
144
187
  });