jskelet 0.5.1 → 0.5.3

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.
@@ -16,7 +16,9 @@
16
16
  * redirects() → [{ source, destination, permanent?, statusCode? }]
17
17
  * rewrites() → [{ source, destination }] | { beforeFiles?, afterFiles? }
18
18
  * cache() → { html?: { [source]: saniye },
19
- * query?: { [source]: string[] | true }, maxEntries?: number,
19
+ * query?: { [source]: string[] | true },
20
+ * vary?: { host?: boolean, headers?: string[], fn?: Function },
21
+ * maxEntries?: number,
20
22
  * data?: {...}, redis?: {...}, prewarm?: {...} }
21
23
  * admin() → { enabled?, basePath?, allowIps?, blockBots?, … }
22
24
  * logs → { console?, kinds?, file?, s3? }
@@ -107,6 +109,10 @@ const CONFIG_FILE = "jskelet.config.mjs";
107
109
  * @property {{ pattern: CompiledPattern, allow: true | string[] }[]} cacheQuery
108
110
  * Yol deseni başına, HTML cache anahtarına girmesine izin verilen query
109
111
  * parametreleri. Eşleşen kural yoksa query'li istek cache'lenmez.
112
+ * @property {{ host: boolean, headers: string[],
113
+ * fn: ((req: import('express').Request) => string | null | undefined) | null }} cacheVary
114
+ * Anahtara eklenen sabit parçalar (query allowlist'ten bağımsız). Host'tan
115
+ * locale üreten sitelerde `host: true` zorunlu.
110
116
  * @property {number} htmlMaxEntries HTML önbelleğinin girdi sınırı.
111
117
  * @property {Record<string, unknown>} data Upstream veri önbelleği ayarları.
112
118
  * @property {boolean} trackUpstream `fetch` sarılıp geçici hatalar otomatik bildirilsin mi.
@@ -633,10 +639,42 @@ function normalizeQueryRules(raw) {
633
639
  return out;
634
640
  }
635
641
 
642
+ /**
643
+ * `cache().vary` → HTML anahtarına host / header / özel fn parçası.
644
+ *
645
+ * @param {unknown} raw
646
+ * @returns {ResolvedConfig["cacheVary"]}
647
+ */
648
+ function normalizeVary(raw) {
649
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
650
+ return { host: false, headers: [], fn: null };
651
+ }
652
+
653
+ const source = /** @type {Record<string, unknown>} */ (raw);
654
+ const headers = asArray(source.headers, "cache().vary.headers")
655
+ .filter((name) => typeof name === "string" && name)
656
+ .map((name) => String(name).toLowerCase());
657
+
658
+ /** @type {ResolvedConfig["cacheVary"]["fn"]} */
659
+ let fn = null;
660
+ if (typeof source.fn === "function") {
661
+ fn = /** @type {ResolvedConfig["cacheVary"]["fn"]} */ (source.fn);
662
+ } else if (source.fn != null) {
663
+ console.warn("[config] cache().vary.fn must be a function, ignoring it");
664
+ }
665
+
666
+ return {
667
+ host: source.host === true,
668
+ headers,
669
+ fn,
670
+ };
671
+ }
672
+
636
673
  /**
637
674
  * @param {unknown} raw
638
675
  * @returns {{ html: ResolvedConfig["html"],
639
- * cacheQuery: ResolvedConfig["cacheQuery"], htmlMaxEntries: number,
676
+ * cacheQuery: ResolvedConfig["cacheQuery"],
677
+ * cacheVary: ResolvedConfig["cacheVary"], htmlMaxEntries: number,
640
678
  * data: Record<string, unknown>, trackUpstream: boolean,
641
679
  * trackDependencies: boolean,
642
680
  * transientRetry: { attempts: number, delayMs: number },
@@ -664,6 +702,7 @@ function normalizeCache(raw) {
664
702
  return {
665
703
  html,
666
704
  cacheQuery: queryRules,
705
+ cacheVary: normalizeVary(raw?.vary),
667
706
  htmlMaxEntries:
668
707
  Number.isFinite(maxEntries) && maxEntries > 0
669
708
  ? Math.floor(maxEntries)
@@ -775,6 +814,12 @@ function normalizePrewarm(raw) {
775
814
  // birleşir. `onVisit` anahtarı çözülmüş nesnede her zaman durur.
776
815
  const classic = { ...source };
777
816
  delete classic.onVisit;
817
+
818
+ const origins = asArray(classic.origins, "cache().prewarm.origins")
819
+ .filter((value) => typeof value === "string" && /^https?:\/\//i.test(value))
820
+ .map(String);
821
+ classic.origins = origins;
822
+
778
823
  return {
779
824
  ...DEFAULT_PREWARM,
780
825
  ...classic,
@@ -1074,6 +1119,7 @@ export async function loadConfig(options = {}) {
1074
1119
  const {
1075
1120
  html,
1076
1121
  cacheQuery,
1122
+ cacheVary,
1077
1123
  htmlMaxEntries,
1078
1124
  data,
1079
1125
  trackUpstream,
@@ -1097,6 +1143,7 @@ export async function loadConfig(options = {}) {
1097
1143
  rewrites: normalizeRewrites(rewrites),
1098
1144
  html,
1099
1145
  cacheQuery,
1146
+ cacheVary,
1100
1147
  htmlMaxEntries,
1101
1148
  data,
1102
1149
  trackUpstream,
package/src/index.js CHANGED
@@ -38,6 +38,16 @@ export {
38
38
  export { reportUpstreamFailure } from "./server/upstream-tracking.js";
39
39
  export { asset, hasAsset, optimizedImage, getSpriteIds } from "./server/assets.js";
40
40
  export { remoteImageUrl, parseAllowedRemoteUrl } from "./server/image-optimizer.js";
41
+ export {
42
+ ImageResponse,
43
+ OG_SIZE,
44
+ buildOgSvg,
45
+ escapeXml,
46
+ ogHandler,
47
+ ogImage,
48
+ sendOgImage,
49
+ wrapText,
50
+ } from "./server/og-image.js";
41
51
  export { headHints } from "./server/head-hints.js";
42
52
  export { renderHeadMeta } from "./server/metadata.js";
43
53
  export {
@@ -0,0 +1,113 @@
1
+ /**
2
+ * HTML cache anahtarına host / header / özel fn ile sabit vary parçası ekler.
3
+ *
4
+ * CDN zaten tam URL ile ayırır; asıl risk origin L1 ve Redis HTML anahtarı —
5
+ * host'tan locale üreten sitelerde `vary.host: true` olmadan ilk locale'in
6
+ * HTML'i diğer host'a servis edilir.
7
+ */
8
+ import { getConfig } from "../config/index.js";
9
+
10
+ /**
11
+ * Public Host: `x-forwarded-host` (ilk değer) yoksa `Host`. Lowercase, portsuz.
12
+ * IPv6 (`[::1]:3000`) köşeli parantezleri korur.
13
+ *
14
+ * @param {{ headers?: Record<string, unknown>, get?: (name: string) => string | undefined }} req
15
+ * @returns {string}
16
+ */
17
+ export function publicHost(req) {
18
+ const forwarded = headerValue(req, "x-forwarded-host");
19
+ const raw = forwarded || headerValue(req, "host") || "";
20
+ const first = raw.split(",")[0].trim().toLowerCase();
21
+ return stripPort(first);
22
+ }
23
+
24
+ /**
25
+ * @param {string} host
26
+ * @returns {string}
27
+ */
28
+ function stripPort(host) {
29
+ if (!host) return "";
30
+ if (host.startsWith("[")) {
31
+ const end = host.indexOf("]");
32
+ return end === -1 ? host : host.slice(0, end + 1);
33
+ }
34
+ // Birden fazla `:` → portsuz IPv6 (Host'ta nadir); tek `:` → host:port.
35
+ const colon = host.lastIndexOf(":");
36
+ if (colon === -1) return host;
37
+ if (host.indexOf(":") !== colon) return host;
38
+ return host.slice(0, colon);
39
+ }
40
+
41
+ /**
42
+ * @param {{ headers?: Record<string, unknown>, get?: (name: string) => string | undefined }} req
43
+ * @param {string} name
44
+ * @returns {string}
45
+ */
46
+ function headerValue(req, name) {
47
+ const viaGet = req.get?.(name);
48
+ if (typeof viaGet === "string" && viaGet) return viaGet;
49
+
50
+ const raw = req.headers?.[name.toLowerCase()];
51
+ if (Array.isArray(raw)) return raw[0] ? String(raw[0]) : "";
52
+ if (raw == null) return "";
53
+ return String(raw);
54
+ }
55
+
56
+ /**
57
+ * Anahtarın başına eklenen önek: `h=tr.example.com|` veya
58
+ * `h=…&x-locale=tr|`. Vary yoksa boş string.
59
+ *
60
+ * @param {{ headers?: Record<string, unknown>, get?: (name: string) => string | undefined }} [req]
61
+ * @returns {string}
62
+ */
63
+ export function buildVaryPrefix(req) {
64
+ if (!req) return "";
65
+
66
+ let vary;
67
+ try {
68
+ vary = getConfig().cacheVary;
69
+ } catch {
70
+ return "";
71
+ }
72
+
73
+ if (!vary || (!vary.host && !vary.headers.length && !vary.fn)) return "";
74
+
75
+ /** @type {string[]} */
76
+ const parts = [];
77
+
78
+ if (vary.host) {
79
+ const host = publicHost(req);
80
+ if (host) parts.push(`h=${host}`);
81
+ }
82
+
83
+ for (const name of vary.headers) {
84
+ const value = headerValue(req, name).trim();
85
+ if (value) parts.push(`${name}=${value}`);
86
+ }
87
+
88
+ if (typeof vary.fn === "function") {
89
+ try {
90
+ const custom = vary.fn(/** @type {import('express').Request} */ (req));
91
+ if (custom != null && custom !== "") parts.push(String(custom));
92
+ } catch (error) {
93
+ console.warn("[cache] cache().vary.fn threw, ignoring it", error);
94
+ }
95
+ }
96
+
97
+ return parts.length ? `${parts.join("&")}|` : "";
98
+ }
99
+
100
+ /**
101
+ * Anahtardan yol kısmını çıkarır (`[vary|]yol?query` → `yol`).
102
+ * Invalidation hedefleri `/…` ile başlar; vary öneki eşleşmeye karışmamalı.
103
+ *
104
+ * @param {string} key
105
+ * @returns {string}
106
+ */
107
+ export function pathOfCacheKey(key) {
108
+ const mark = key.indexOf("?");
109
+ const beforeQuery = mark === -1 ? key : key.slice(0, mark);
110
+ const sep = beforeQuery.indexOf("|/");
111
+ if (sep !== -1) return beforeQuery.slice(sep + 1);
112
+ return beforeQuery;
113
+ }
@@ -34,6 +34,7 @@ import { getConfig } from "../config/index.js";
34
34
  import { DEFAULT_HTML_CACHE_MAX_ENTRIES } from "../config/defaults.js";
35
35
  import { collectDependencies } from "./cache-deps.js";
36
36
  import { compilePattern, matchPattern } from "../config/pattern.js";
37
+ import { pathOfCacheKey } from "./cache-vary.js";
37
38
  import {
38
39
  cacheKey,
39
40
  onCacheEvent,
@@ -662,8 +663,9 @@ function invalidateKey(key, hard) {
662
663
  * arkada ve anahtar başına tek seferde koşar. `hard: true` yalnızca eski
663
664
  * HTML'in gerçekten geçersiz olduğu durumlar için.
664
665
  *
665
- * Anahtar `yol?query` olduğundan eşleştirme **yol kısmına** yapılır: bir
666
- * yolun bütün query varyantları (`?utm_source=…` dahil) tek çağrıyla düşer.
666
+ * Anahtar `yol?query` (isteğe bağlı `vary|` önekiyle) olduğundan eşleştirme
667
+ * **yol kısmına** yapılır: bir yolun bütün query / host varyantları tek
668
+ * çağrıyla düşer.
667
669
  *
668
670
  * @param {string | RegExp | (string | RegExp)[]} target
669
671
  * @param {{ hard?: boolean }} [options]
@@ -733,14 +735,14 @@ function compileMatchers(targets) {
733
735
  }
734
736
 
735
737
  /**
736
- * Anahtar `yol?query`; eşleştirme **yol kısmına** yapılır.
738
+ * Anahtar `[vary|]yol?query`; eşleştirme **yol** kısmına yapılır.
739
+ * Vary öneki (`h=…|`) invalidation hedefiyle karışmasın.
737
740
  *
738
741
  * @param {string} key
739
742
  * @returns {string}
740
743
  */
741
744
  function pathOf(key) {
742
- const mark = key.indexOf("?");
743
- return mark === -1 ? key : key.slice(0, mark);
745
+ return pathOfCacheKey(key);
744
746
  }
745
747
 
746
748
  /**
@@ -879,6 +881,9 @@ function dropLocalKey(key) {
879
881
  * boşaltır. Isıtma turu bunları başa alır; iki tur aynı yolu tekrar
880
882
  * ısıtmasın diye okuma yıkıcıdır.
881
883
  *
884
+ * Vary öneki (`h=…|`) düşülür — HTTP ısıtması yalnızca yolu ister; host
885
+ * ayrımı `prewarm.origins` / istek Host'u ile yapılır.
886
+ *
882
887
  * @returns {string[]}
883
888
  */
884
889
  export function takeInvalidatedPaths() {
@@ -886,8 +891,13 @@ export function takeInvalidatedPaths() {
886
891
 
887
892
  const paths = [...invalidated];
888
893
  invalidated.clear();
889
- // Anahtar `yol?query`; query boşsa sondaki `?` atılır.
890
- return paths.map((key) => (key.endsWith("?") ? key.slice(0, -1) : key));
894
+ return paths.map((key) => {
895
+ const pathname = pathOfCacheKey(key);
896
+ const q = key.indexOf("?");
897
+ if (q === -1) return pathname;
898
+ const query = key.slice(q + 1);
899
+ return query ? `${pathname}?${query}` : pathname;
900
+ });
891
901
  }
892
902
 
893
903
  /**
@@ -0,0 +1,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
+ 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, "&amp;")
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
+ }