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