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 +5 -0
- package/docs/04-render-ve-sablonlar.md +33 -0
- package/docs/10-dagitim.md +4 -1
- package/docs/en/04-rendering.md +33 -0
- package/docs/en/10-deployment.md +4 -0
- package/package.json +1 -1
- package/src/server/og-image.js +621 -25
- package/src/server/og-raster.mjs +21 -0
- package/types/server/og-image.d.ts +46 -3
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
|
```
|
package/docs/10-dagitim.md
CHANGED
|
@@ -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
|
|
package/docs/en/04-rendering.md
CHANGED
|
@@ -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
|
```
|
package/docs/en/10-deployment.md
CHANGED
|
@@ -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.
|
|
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",
|
package/src/server/og-image.js
CHANGED
|
@@ -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="
|
|
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="
|
|
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="
|
|
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
|
-
*
|
|
225
|
-
* @
|
|
226
|
-
* @returns {Promise<OgImageResult>}
|
|
259
|
+
* Aynı eksik dosya her OG isteğinde yeniden uyarılmasın.
|
|
260
|
+
* @type {Set<string>}
|
|
227
261
|
*/
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
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, "&")
|
|
472
|
+
.replace(/</g, "<")
|
|
473
|
+
.replace(/>/g, ">")
|
|
474
|
+
.replace(/"/g, """);
|
|
475
|
+
}
|
|
235
476
|
|
|
236
|
-
|
|
237
|
-
|
|
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
|
-
|
|
242
|
-
|
|
243
|
-
|
|
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
|
-
|
|
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/
|
|
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
|
|
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
|
});
|