jskelet 0.6.3 → 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/AGENTS.md +136 -136
- package/CHANGELOG.md +633 -620
- package/LICENSE +21 -21
- package/README.md +2 -0
- package/bin/jskelet.mjs +130 -130
- package/docs/01-baslangic.md +291 -291
- package/docs/02-mimari.md +310 -310
- package/docs/03-routing.md +515 -515
- package/docs/04-render-ve-sablonlar.md +700 -661
- package/docs/05-islands.md +486 -486
- package/docs/06-cache.md +1467 -1443
- package/docs/07-yapilandirma.md +1208 -1197
- package/docs/08-build.md +429 -429
- package/docs/09-dev-araclari.md +364 -364
- package/docs/10-dagitim.md +351 -338
- package/docs/12-panel-ve-oturum.md +479 -478
- package/docs/README.md +83 -83
- package/docs/en/01-getting-started.md +298 -298
- package/docs/en/02-architecture.md +329 -329
- package/docs/en/03-routing.md +531 -531
- package/docs/en/04-rendering.md +708 -669
- package/docs/en/05-islands.md +497 -497
- package/docs/en/06-caching.md +1476 -1453
- package/docs/en/07-configuration.md +1229 -1219
- package/docs/en/08-build.md +447 -447
- package/docs/en/09-dev-tools.md +373 -373
- package/docs/en/10-deployment.md +355 -340
- package/docs/en/11-migration.md +398 -398
- package/docs/en/12-dashboards-and-sessions.md +489 -488
- package/docs/en/README.md +87 -87
- package/package.json +137 -137
- package/src/build/ensure-build.mjs +19 -19
- package/src/build/paths.mjs +153 -153
- package/src/build/resolve-peer.mjs +36 -36
- package/src/build/tasks/client.mjs +349 -349
- package/src/build/tasks/css.mjs +235 -235
- package/src/build/tasks/fonts.mjs +146 -146
- package/src/build/tasks/icons.mjs +357 -357
- package/src/build/tasks/images.mjs +244 -244
- package/src/build/tasks/precompress.mjs +78 -78
- package/src/build/tasks/templates.mjs +20 -20
- package/src/client/admin/i18n.js +764 -764
- package/src/client/admin/login.html +74 -74
- package/src/client/admin/panel.css +809 -809
- package/src/client/admin/panel.html +495 -495
- package/src/client/admin/panel.js +1251 -1251
- package/src/client/devtools/report.html +185 -185
- package/src/client/devtools/report.js +745 -745
- package/src/client/devtools/seo.js +628 -628
- package/src/client/dom.js +95 -95
- package/src/client/form.js +192 -192
- package/src/client/index.js +45 -45
- package/src/client/registry.js +305 -305
- package/src/client/safe-image.js +91 -91
- package/src/client/shared-cookie.js +225 -225
- package/src/client/store.js +36 -36
- package/src/client/swap.js +188 -188
- package/src/compile/codegen.js +336 -336
- package/src/compile/compile-all.js +149 -149
- package/src/compile/errors.js +66 -66
- package/src/compile/expr.js +409 -409
- package/src/compile/index.js +17 -17
- package/src/compile/parse.js +541 -541
- package/src/compile/resolve.js +211 -211
- package/src/compile/scan-exports.js +51 -51
- package/src/config/defaults.js +541 -534
- package/src/config/index.js +1500 -1469
- package/src/config/pattern.js +107 -107
- package/src/generate.mjs +163 -163
- package/src/http/control-flow.js +71 -71
- package/src/http/cookies-entry.js +21 -21
- package/src/http/cookies.js +277 -277
- package/src/http/request-cache.js +46 -46
- package/src/http/request-context.js +165 -165
- package/src/http/shared-cookie.js +178 -178
- package/src/index.js +101 -101
- package/src/init.mjs +232 -230
- package/src/migrate/apply.mjs +262 -262
- package/src/migrate/babel.mjs +79 -79
- package/src/migrate/classify.mjs +155 -155
- package/src/migrate/config.mjs +126 -126
- package/src/migrate/fs-walk.mjs +191 -191
- package/src/migrate/parse.mjs +26 -26
- package/src/migrate/scan.mjs +177 -177
- package/src/migrate/transform/expr-source.mjs +168 -168
- package/src/migrate/transform/island.mjs +67 -67
- package/src/migrate/transform/jsx-to-component.mjs +302 -302
- package/src/migrate/transform/jsx-to-jsk.mjs +330 -330
- package/src/migrate/transform/page-split.mjs +435 -435
- package/src/migrate/write.mjs +81 -81
- package/src/migrate.mjs +171 -171
- package/src/runtime/alias-hooks.mjs +119 -119
- package/src/runtime/register.mjs +4 -4
- package/src/server/admin/actions.js +229 -229
- package/src/server/admin/auth.js +125 -125
- package/src/server/admin/event-log.js +151 -151
- package/src/server/admin/gate.js +209 -209
- package/src/server/admin/inventory.js +188 -188
- package/src/server/admin/mount.js +56 -56
- package/src/server/admin/router.js +216 -216
- package/src/server/admin/snapshot.js +241 -241
- package/src/server/assets.js +147 -147
- package/src/server/auth/handoff.js +309 -309
- package/src/server/cache-blob.js +70 -70
- package/src/server/cache-control.js +45 -0
- package/src/server/cache-deps.js +42 -42
- package/src/server/cache-vary.js +113 -113
- package/src/server/cloudflare.js +607 -607
- package/src/server/create-app.js +366 -366
- package/src/server/data-cache.js +553 -553
- package/src/server/dev/report.js +485 -485
- package/src/server/dev/socket.js +170 -170
- package/src/server/dev/version-check.mjs +139 -139
- package/src/server/disk-cache.js +233 -233
- package/src/server/ejs-adapter.js +59 -59
- package/src/server/html-cache.js +1196 -1196
- package/src/server/image-optimizer.js +500 -500
- package/src/server/logs/access-middleware.js +66 -66
- package/src/server/logs/file-sink.js +193 -193
- package/src/server/logs/pipeline.js +165 -165
- package/src/server/logs/s3-put.js +214 -214
- package/src/server/logs/s3-sink.js +112 -112
- package/src/server/metadata.js +102 -102
- package/src/server/middleware/compression.js +205 -205
- package/src/server/middleware/csrf.js +134 -134
- package/src/server/middleware/dev-gate.js +75 -75
- package/src/server/middleware/headers.js +37 -37
- package/src/server/middleware/redirects.js +32 -32
- package/src/server/middleware/robots-txt.js +341 -341
- package/src/server/middleware/static-precompressed.js +121 -121
- package/src/server/middleware/trailing-slash.js +53 -53
- package/src/server/middleware/upstream-proxy.js +141 -141
- package/src/server/og-image.js +965 -356
- package/src/server/og-raster.mjs +21 -0
- package/src/server/port-guard.js +255 -255
- package/src/server/prewarm.js +1082 -1082
- package/src/server/redis.js +588 -588
- package/src/server/render.js +910 -910
- package/src/server/router.js +157 -157
- package/src/server/status-page.js +265 -265
- package/src/server/upstream-limiter.js +376 -376
- package/src/server/upstream-tracking.js +166 -166
- package/src/shared/cookie-domain.js +66 -66
- package/src/start.mjs +22 -22
- package/src/templates/layout.ejs +30 -30
- package/src/templates/layout.jsk +30 -30
- package/src/version.mjs +31 -31
- package/src/views/components/loader.js +101 -101
- package/src/views/helpers/html.js +102 -102
- package/src/views/helpers/tags.js +375 -375
- package/types/config/defaults.d.ts +6 -0
- package/types/config/index.d.ts +6 -0
- package/types/server/cache-control.d.ts +28 -0
- package/types/server/og-image.d.ts +51 -3
package/src/server/og-image.js
CHANGED
|
@@ -1,356 +1,965 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Dinamik Open Graph görselleri — Next.js `ImageResponse` /
|
|
3
|
-
* `opengraph-image.tsx` karşılığı.
|
|
4
|
-
*
|
|
5
|
-
* JSX yok: ya hazır kart alanları (`title`, `description`, `siteName`) ya da
|
|
6
|
-
* ham `svg` verilir. sharp (opsiyonel peer) varsa PNG üretilir; yoksa SVG
|
|
7
|
-
* döner. Sosyal kazıyıcıların çoğu PNG beklediği için prod'da sharp önerilir.
|
|
8
|
-
*
|
|
9
|
-
* Domain bilgisi taşınmaz — metin, renk ve SVG uygulama tarafındandır.
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
/**
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
*
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
* @
|
|
50
|
-
* @property {
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
*
|
|
55
|
-
* @
|
|
56
|
-
* @
|
|
57
|
-
*/
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
*
|
|
69
|
-
* @
|
|
70
|
-
* @
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
const
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
*
|
|
260
|
-
* @
|
|
261
|
-
*/
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
*
|
|
282
|
-
* @
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
* @
|
|
315
|
-
*
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
}
|
|
356
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Dinamik Open Graph görselleri — Next.js `ImageResponse` /
|
|
3
|
+
* `opengraph-image.tsx` karşılığı.
|
|
4
|
+
*
|
|
5
|
+
* JSX yok: ya hazır kart alanları (`title`, `description`, `siteName`) ya da
|
|
6
|
+
* ham `svg` verilir. sharp (opsiyonel peer) varsa PNG üretilir; yoksa SVG
|
|
7
|
+
* döner. Sosyal kazıyıcıların çoğu PNG beklediği için prod'da sharp önerilir.
|
|
8
|
+
*
|
|
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.
|
|
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";
|
|
24
|
+
import { tryImportFromApp } from "../build/resolve-peer.mjs";
|
|
25
|
+
import { getConfig } from "../config/index.js";
|
|
26
|
+
import { isNotFoundError } from "../http/control-flow.js";
|
|
27
|
+
import { setEdgeCacheHeaders } from "./cache-control.js";
|
|
28
|
+
|
|
29
|
+
/** @type {((input: Buffer, opts?: object) => import('sharp').Sharp) | null | undefined} */
|
|
30
|
+
let sharpModule;
|
|
31
|
+
|
|
32
|
+
/** Sosyal kartlar için yaygın boyut (Facebook / X / LinkedIn). */
|
|
33
|
+
export const OG_SIZE = Object.freeze({ width: 1200, height: 630 });
|
|
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
|
+
|
|
41
|
+
/**
|
|
42
|
+
* OG süreleri HTML TTL'ye bağlanmaz. Yalnızca `s-maxage` kalkar; edge
|
|
43
|
+
* `CDN-Cache-Control` üzerinde aynı pencereyi görür.
|
|
44
|
+
*/
|
|
45
|
+
const OG_EDGE_MAX_AGE = 86400;
|
|
46
|
+
const OG_EDGE_STALE = 604800;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* @typedef {object} OgCardOptions
|
|
50
|
+
* @property {string} [title]
|
|
51
|
+
* @property {string} [description]
|
|
52
|
+
* @property {string} [siteName]
|
|
53
|
+
* @property {string} [background] Düz SVG rengi (`#0f172a`)
|
|
54
|
+
* @property {string} [color] Ana metin rengi
|
|
55
|
+
* @property {string} [mutedColor] Açıklama / site adı
|
|
56
|
+
* @property {string} [accent] Sol şerit rengi
|
|
57
|
+
*/
|
|
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
|
+
|
|
73
|
+
/**
|
|
74
|
+
* @typedef {OgCardOptions & {
|
|
75
|
+
* svg?: string,
|
|
76
|
+
* width?: number,
|
|
77
|
+
* height?: number,
|
|
78
|
+
* format?: 'png' | 'svg',
|
|
79
|
+
* cacheControl?: string,
|
|
80
|
+
* fonts?: OgFontFace[],
|
|
81
|
+
* }} OgImageOptions
|
|
82
|
+
*/
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* @typedef {object} OgImageResult
|
|
86
|
+
* @property {Buffer} body
|
|
87
|
+
* @property {string} contentType
|
|
88
|
+
* @property {number} width
|
|
89
|
+
* @property {number} height
|
|
90
|
+
*/
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* XML metin kaçışı — kullanıcı başlığı SVG'ye gömülür.
|
|
94
|
+
* @param {unknown} value
|
|
95
|
+
* @returns {string}
|
|
96
|
+
*/
|
|
97
|
+
export function escapeXml(value) {
|
|
98
|
+
return String(value ?? "")
|
|
99
|
+
.replace(/&/g, "&")
|
|
100
|
+
.replace(/</g, "<")
|
|
101
|
+
.replace(/>/g, ">")
|
|
102
|
+
.replace(/"/g, """)
|
|
103
|
+
.replace(/'/g, "'");
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Kelime sınırında satır kır. Uzun kelime kesilir; taşan içerik son satırda `…`.
|
|
108
|
+
* @param {string} text
|
|
109
|
+
* @param {number} maxChars
|
|
110
|
+
* @param {number} maxLines
|
|
111
|
+
* @returns {string[]}
|
|
112
|
+
*/
|
|
113
|
+
export function wrapText(text, maxChars, maxLines) {
|
|
114
|
+
const words = String(text ?? "")
|
|
115
|
+
.trim()
|
|
116
|
+
.split(/\s+/)
|
|
117
|
+
.filter(Boolean);
|
|
118
|
+
if (!words.length || maxLines < 1 || maxChars < 1) return [];
|
|
119
|
+
|
|
120
|
+
/** @type {string[]} */
|
|
121
|
+
const lines = [];
|
|
122
|
+
let current = "";
|
|
123
|
+
let overflow = false;
|
|
124
|
+
|
|
125
|
+
const pushCurrent = () => {
|
|
126
|
+
if (!current) return;
|
|
127
|
+
lines.push(current);
|
|
128
|
+
current = "";
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
for (const word of words) {
|
|
132
|
+
if (lines.length >= maxLines) {
|
|
133
|
+
overflow = true;
|
|
134
|
+
break;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const candidate = current ? `${current} ${word}` : word;
|
|
138
|
+
if (candidate.length <= maxChars) {
|
|
139
|
+
current = candidate;
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
pushCurrent();
|
|
144
|
+
if (lines.length >= maxLines) {
|
|
145
|
+
overflow = true;
|
|
146
|
+
break;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (word.length <= maxChars) {
|
|
150
|
+
current = word;
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
let rest = word;
|
|
155
|
+
while (rest.length > maxChars) {
|
|
156
|
+
if (lines.length >= maxLines) {
|
|
157
|
+
overflow = true;
|
|
158
|
+
rest = "";
|
|
159
|
+
break;
|
|
160
|
+
}
|
|
161
|
+
lines.push(rest.slice(0, maxChars));
|
|
162
|
+
rest = rest.slice(maxChars);
|
|
163
|
+
}
|
|
164
|
+
current = rest;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (current && lines.length < maxLines) {
|
|
168
|
+
lines.push(current);
|
|
169
|
+
} else if (current) {
|
|
170
|
+
overflow = true;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (overflow && lines.length) {
|
|
174
|
+
const last = lines[lines.length - 1];
|
|
175
|
+
const base = last.endsWith("…") ? last.slice(0, -1) : last;
|
|
176
|
+
const trimmed = base.slice(0, Math.max(1, maxChars - 1));
|
|
177
|
+
lines[lines.length - 1] = `${trimmed}…`;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return lines;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Hazır kart SVG'si. Uygulama kendi SVG'sini vermek isterse `svg` kullanır.
|
|
185
|
+
* @param {OgCardOptions & { width?: number, height?: number, fontFamily?: string }} options
|
|
186
|
+
* @returns {string}
|
|
187
|
+
*/
|
|
188
|
+
export function buildOgSvg(options = {}) {
|
|
189
|
+
const width = options.width ?? OG_SIZE.width;
|
|
190
|
+
const height = options.height ?? OG_SIZE.height;
|
|
191
|
+
const background = options.background ?? "#0f172a";
|
|
192
|
+
const color = options.color ?? "#f8fafc";
|
|
193
|
+
const muted = options.mutedColor ?? "#94a3b8";
|
|
194
|
+
const accent = options.accent ?? "#38bdf8";
|
|
195
|
+
const fontFamily = options.fontFamily || SYSTEM_FONT_STACK;
|
|
196
|
+
|
|
197
|
+
const titleLines = wrapText(options.title ?? "", 28, 3);
|
|
198
|
+
const descLines = wrapText(options.description ?? "", 52, 2);
|
|
199
|
+
const siteName = options.siteName ? escapeXml(options.siteName) : "";
|
|
200
|
+
|
|
201
|
+
const titleTs = titleLines
|
|
202
|
+
.map((line, i) => {
|
|
203
|
+
const dy = i === 0 ? 0 : 72;
|
|
204
|
+
return `<tspan x="80" dy="${dy}">${escapeXml(line)}</tspan>`;
|
|
205
|
+
})
|
|
206
|
+
.join("");
|
|
207
|
+
|
|
208
|
+
const descTs = descLines
|
|
209
|
+
.map((line, i) => {
|
|
210
|
+
const dy = i === 0 ? 0 : 40;
|
|
211
|
+
return `<tspan x="80" dy="${dy}">${escapeXml(line)}</tspan>`;
|
|
212
|
+
})
|
|
213
|
+
.join("");
|
|
214
|
+
|
|
215
|
+
const titleY = 200;
|
|
216
|
+
const descY = titleY + Math.max(titleLines.length, 1) * 72 + 36;
|
|
217
|
+
|
|
218
|
+
return (
|
|
219
|
+
`<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">` +
|
|
220
|
+
`<rect width="100%" height="100%" fill="${escapeXml(background)}"/>` +
|
|
221
|
+
`<rect x="0" y="0" width="14" height="${height}" fill="${escapeXml(accent)}"/>` +
|
|
222
|
+
(titleTs
|
|
223
|
+
? `<text x="80" y="${titleY}" font-family="${escapeXml(fontFamily)}" font-size="64" font-weight="700" fill="${escapeXml(color)}">${titleTs}</text>`
|
|
224
|
+
: "") +
|
|
225
|
+
(descTs
|
|
226
|
+
? `<text x="80" y="${descY}" font-family="${escapeXml(fontFamily)}" font-size="30" font-weight="400" fill="${escapeXml(muted)}">${descTs}</text>`
|
|
227
|
+
: "") +
|
|
228
|
+
(siteName
|
|
229
|
+
? `<text x="80" y="${height - 64}" font-family="${escapeXml(fontFamily)}" font-size="24" font-weight="600" fill="${escapeXml(muted)}">${siteName}</text>`
|
|
230
|
+
: "") +
|
|
231
|
+
`</svg>`
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* @returns {Promise<((input: Buffer, opts?: object) => import('sharp').Sharp) | null>}
|
|
237
|
+
*/
|
|
238
|
+
async function loadSharp() {
|
|
239
|
+
if (sharpModule !== undefined) return sharpModule;
|
|
240
|
+
|
|
241
|
+
/** @type {any} */
|
|
242
|
+
let mod = null;
|
|
243
|
+
try {
|
|
244
|
+
mod = await tryImportFromApp(getConfig().root, "sharp");
|
|
245
|
+
} catch {
|
|
246
|
+
// Config yoksa (birim test) doğrudan çözümle.
|
|
247
|
+
try {
|
|
248
|
+
mod = await import("sharp");
|
|
249
|
+
} catch {
|
|
250
|
+
mod = null;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
sharpModule = mod?.default ?? mod ?? null;
|
|
255
|
+
return sharpModule;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Aynı eksik dosya her OG isteğinde yeniden uyarılmasın.
|
|
260
|
+
* @type {Set<string>}
|
|
261
|
+
*/
|
|
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
|
+
}
|
|
476
|
+
|
|
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;
|
|
693
|
+
|
|
694
|
+
return {
|
|
695
|
+
css: rules.join("\n"),
|
|
696
|
+
family: stack(families),
|
|
697
|
+
rasterFamily: stack(rasterFamilies),
|
|
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;
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
|
|
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"))
|
|
795
|
+
.resize(width, height, { fit: "fill" })
|
|
796
|
+
.png()
|
|
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
|
+
}
|
|
848
|
+
|
|
849
|
+
return {
|
|
850
|
+
body: Buffer.from(makeSvg(false), "utf8"),
|
|
851
|
+
contentType: "image/svg+xml; charset=utf-8",
|
|
852
|
+
width,
|
|
853
|
+
height,
|
|
854
|
+
};
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
/**
|
|
858
|
+
* Express yanıtına OG görseli basar.
|
|
859
|
+
*
|
|
860
|
+
* Varsayılan edge penceresi 86400 / 604800'tür ve HTML TTL'ye bağlı değildir.
|
|
861
|
+
* `cacheControl` verilirse yalnızca `Cache-Control` yazılır;
|
|
862
|
+
* `CDN-Cache-Control` basılmaz.
|
|
863
|
+
*
|
|
864
|
+
* @param {import('express').Response} res
|
|
865
|
+
* @param {OgImageOptions} [options]
|
|
866
|
+
* @returns {Promise<OgImageResult>}
|
|
867
|
+
*/
|
|
868
|
+
export async function sendOgImage(res, options = {}) {
|
|
869
|
+
const result = await ogImage(options);
|
|
870
|
+
|
|
871
|
+
res.status(200);
|
|
872
|
+
res.setHeader("Content-Type", result.contentType);
|
|
873
|
+
if (options.cacheControl != null) {
|
|
874
|
+
res.setHeader("Cache-Control", options.cacheControl);
|
|
875
|
+
} else {
|
|
876
|
+
setEdgeCacheHeaders(res, OG_EDGE_MAX_AGE, OG_EDGE_STALE);
|
|
877
|
+
}
|
|
878
|
+
res.setHeader("Content-Length", String(result.body.length));
|
|
879
|
+
// Kazıyıcılar ve CDN'ler için boyut ipucu (meta ile de verilir).
|
|
880
|
+
res.setHeader("X-Og-Width", String(result.width));
|
|
881
|
+
res.setHeader("X-Og-Height", String(result.height));
|
|
882
|
+
res.end(result.body);
|
|
883
|
+
return result;
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
/**
|
|
887
|
+
* Next `opengraph-image` route handler'ına yakın Express sarmalayıcı.
|
|
888
|
+
*
|
|
889
|
+
* Factory `null` dönerse veya `notFound()` fırlatırsa 404.
|
|
890
|
+
*
|
|
891
|
+
* @param {(ctx: { params: Record<string, string>, query: import('express').Request['query'], req: import('express').Request }) =>
|
|
892
|
+
* OgImageOptions | null | Promise<OgImageOptions | null>} factory
|
|
893
|
+
* @param {OgImageOptions} [defaults] Her istekte birleşen varsayılanlar
|
|
894
|
+
* @returns {import('express').RequestHandler}
|
|
895
|
+
*/
|
|
896
|
+
export function ogHandler(factory, defaults = {}) {
|
|
897
|
+
return async (req, res, next) => {
|
|
898
|
+
try {
|
|
899
|
+
const result = await factory({
|
|
900
|
+
params: req.params ?? {},
|
|
901
|
+
query: req.query,
|
|
902
|
+
req,
|
|
903
|
+
});
|
|
904
|
+
if (result == null) {
|
|
905
|
+
res.status(404).end();
|
|
906
|
+
return;
|
|
907
|
+
}
|
|
908
|
+
await sendOgImage(res, { ...defaults, ...result });
|
|
909
|
+
} catch (error) {
|
|
910
|
+
if (isNotFoundError(error)) {
|
|
911
|
+
res.status(404).end();
|
|
912
|
+
return;
|
|
913
|
+
}
|
|
914
|
+
next(error);
|
|
915
|
+
}
|
|
916
|
+
};
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
/**
|
|
920
|
+
* Next.js `new ImageResponse(...)` DX'si. JSX yok — ilk argüman SVG string
|
|
921
|
+
* veya kart alanları nesnesi.
|
|
922
|
+
*
|
|
923
|
+
* @example
|
|
924
|
+
* ```js
|
|
925
|
+
* return new ImageResponse(
|
|
926
|
+
* { title: post.title, description: post.excerpt, siteName: "Blog" },
|
|
927
|
+
* { width: 1200, height: 630 },
|
|
928
|
+
* );
|
|
929
|
+
* // handler içinde: await image.send(res)
|
|
930
|
+
* ```
|
|
931
|
+
*/
|
|
932
|
+
export class ImageResponse {
|
|
933
|
+
/** @type {OgImageOptions} */
|
|
934
|
+
#options;
|
|
935
|
+
|
|
936
|
+
/**
|
|
937
|
+
* @param {string | (OgCardOptions & { fonts?: OgFontFace[] })} element
|
|
938
|
+
* @param {Omit<OgImageOptions, keyof OgCardOptions | 'svg'> & { width?: number, height?: number }} [init]
|
|
939
|
+
*/
|
|
940
|
+
constructor(element, init = {}) {
|
|
941
|
+
if (typeof element === "string") {
|
|
942
|
+
this.#options = { ...init, svg: element };
|
|
943
|
+
} else {
|
|
944
|
+
this.#options = { ...element, ...init };
|
|
945
|
+
}
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
/** @returns {OgImageOptions} */
|
|
949
|
+
get options() {
|
|
950
|
+
return this.#options;
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
/** @returns {Promise<OgImageResult>} */
|
|
954
|
+
async buffer() {
|
|
955
|
+
return ogImage(this.#options);
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
/**
|
|
959
|
+
* @param {import('express').Response} res
|
|
960
|
+
* @returns {Promise<OgImageResult>}
|
|
961
|
+
*/
|
|
962
|
+
async send(res) {
|
|
963
|
+
return sendOgImage(res, this.#options);
|
|
964
|
+
}
|
|
965
|
+
}
|