jskelet 0.5.1 → 0.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -10,6 +10,10 @@ one is listed under a **Breaking** heading.
10
10
 
11
11
  ### Added
12
12
 
13
+ - Dynamic Open Graph images (Next.js `ImageResponse` / `opengraph-image`):
14
+ `ogImage`, `sendOgImage`, `ogHandler`, and `ImageResponse` turn card fields or
15
+ raw SVG into PNG when `sharp` is installed (SVG fallback otherwise). Wired in
16
+ `examples/blog` as `/og/blog/:slug.png` and `metadata.openGraph.image`.
13
17
  - Early HTML cache refresh before TTL expiry: the last successful produce time
14
18
  (`produceMs`) sets a lead window (`min(max(produceMs×2, 250ms), ttl/2)`). A
15
19
  still-fresh `HIT` in that window revalidates in the background; idle entries
@@ -35,6 +35,10 @@ gerekmez:
35
35
  | `redirect` | `jskelet` → `redirect` |
36
36
  | `permanentRedirect` | `jskelet` → `permanentRedirect` |
37
37
  | `seeOther` | `jskelet` → `seeOther` |
38
+ | `ogHandler` | `jskelet` → `ogHandler` |
39
+ | `ogImage` | `jskelet` → `ogImage` |
40
+ | `sendOgImage` | `jskelet` → `sendOgImage` |
41
+ | `ImageResponse` | `jskelet` → `ImageResponse` |
38
42
 
39
43
  İstersen doğrudan import da edebilirsin; `api` yalnızca kolaylık:
40
44
 
@@ -511,6 +511,64 @@ return {
511
511
  `renderHeadMeta(metadata)` fonksiyonu dışa açıktır; layout dışında (ör. bir
512
512
  fragment ya da e-posta) aynı etiketleri üretmek gerekirse kullanılabilir.
513
513
 
514
+ ## Dinamik OG görselleri
515
+
516
+ Next.js `ImageResponse` / `opengraph-image.tsx` karşılığı. JSX yok: kart
517
+ alanları (`title`, `description`, `siteName`, renkler) ya da ham `svg` verilir.
518
+ `sharp` (opsiyonel peer) kuruluysa PNG, yoksa SVG döner. Sosyal kazıyıcıların
519
+ çoğu PNG beklediği için prod'da `sharp` önerilir.
520
+
521
+ HTML değil görsel döndüğü için `route()` kullanılmaz — `ogHandler` düz bir
522
+ Express handler üretir. `notFound()` ve `null` dönüşü 404 olur.
523
+
524
+ ```js
525
+ // routes/35-og.mjs
526
+ export default function register(app, { ogHandler, notFound }) {
527
+ app.get(
528
+ "/og/blog/:slug.png",
529
+ ogHandler(async ({ params }) => {
530
+ const post = getPost(params.slug);
531
+ if (!post) notFound();
532
+ return {
533
+ title: post.title,
534
+ description: post.excerpt,
535
+ siteName: "Blog",
536
+ };
537
+ }),
538
+ );
539
+ }
540
+ ```
541
+
542
+ Sayfa metadata'sında mutlak URL ve boyut verin:
543
+
544
+ ```js
545
+ openGraph: {
546
+ type: "article",
547
+ image: `${SITE_URL}/og/blog/${post.slug}.png`,
548
+ imageWidth: 1200,
549
+ imageHeight: 630,
550
+ },
551
+ ```
552
+
553
+ Ham SVG veya Next benzeri sınıf:
554
+
555
+ ```js
556
+ import { ImageResponse, sendOgImage, OG_SIZE } from "jskelet";
557
+
558
+ app.get("/og/custom.png", async (req, res) => {
559
+ const image = new ImageResponse(
560
+ `<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="630">…</svg>`,
561
+ OG_SIZE,
562
+ );
563
+ await image.send(res);
564
+ // veya: await sendOgImage(res, { title: "…", format: "svg" });
565
+ });
566
+ ```
567
+
568
+ Varsayılan `Cache-Control`:
569
+ `public, max-age=0, s-maxage=86400, stale-while-revalidate=604800`.
570
+ `cacheControl` seçeneğiyle ezilir. Çalışan örnek: `examples/blog/routes/35-og.mjs`.
571
+
514
572
  ## Hook'lar
515
573
 
516
574
  Hook'lar `jskelet.config.mjs` → `hooks` altında tanımlanır. Hepsi opsiyonel,
package/docs/11-tasima.md CHANGED
@@ -40,6 +40,7 @@ alt kümesine benzetildi — `next.config` sözdizimi, Metadata API, `notFound()
40
40
  | `loading.js` / Suspense | — | Sunucu HTML'i tam; iskelet gerekmiyor |
41
41
  | Streaming SSR | — | Yanıt tek parça |
42
42
  | `generateMetadata()` | Controller `metadata` + `hooks.metadata()` | Aynı alan adları ([04](./04-render-ve-sablonlar.md)) |
43
+ | `opengraph-image.tsx` / `ImageResponse` | `ogHandler` + `ImageResponse` / `sendOgImage` | SVG veya kart alanları → PNG (`sharp`); [04](./04-render-ve-sablonlar.md) |
43
44
  | `generateStaticParams()` | `hooks.prewarmPaths()` | Build zamanı değil, açılış zamanı ısıtma |
44
45
  | Route Handlers (`route.js`) | Düz Express handler'ı | `app.get/post(...)` |
45
46
  | Middleware (`middleware.ts`) | Express middleware + config `rewrites`/`headers`/`redirects` | `app.use(...)` |
@@ -68,6 +69,7 @@ başlığı elle okuyun.
68
69
  | `next/link` | `link({ href, text })` — `jskelet/tags` | `title` otomatik, dış bağlantıya `rel`/`target` otomatik |
69
70
  | `next/link` prefetch'i | `navigation: { prefetch, prerender }` | Speculation Rules; client runtime'ı yok ([07](./07-yapilandirma.md)) |
70
71
  | `next/image` | `image({ src, alt, priority })` — `jskelet/tags` | `srcset` build manifest'inden |
72
+ | `next/og` `ImageResponse` | `ImageResponse` / `ogHandler` — `jskelet` | JSX yok; SVG veya `title`/`description` kartı |
71
73
  | `next/font/google` | `fonts: [{ family, weights }]` | Self-host woff2, commit edilir |
72
74
  | `@phosphor-icons/react` | `icon({ name, weight })` — `jskelet/tags` | Build zamanı SVG sprite |
73
75
  | `react-dom` preconnect/preload | `preconnect: [...]` + `headHints()` | ([04](./04-render-ve-sablonlar.md)) |
package/docs/README.md CHANGED
@@ -57,8 +57,9 @@ npm --prefix examples/minimal run dev
57
57
 
58
58
  **`examples/blog/`** — dinamik route (`/blog/:slug`), etiket sayfaları,
59
59
  `redirects`/`rewrites`/`headers`/`cache` yapılandırmasının tamamı, fragment ile
60
- gelen sekme panelleri, form gönderimi, prewarm, `robots.txt`/`sitemap.xml`/`rss.xml`
61
- ve dört island (tema, sekme, arama, form).
60
+ gelen sekme panelleri, form gönderimi, prewarm, `robots.txt`/`sitemap.xml`/`rss.xml`,
61
+ dinamik OG görselleri (`/og/blog/:slug.png`) ve dört island (tema, sekme, arama,
62
+ form).
62
63
 
63
64
  ```bash
64
65
  npm --prefix examples/blog install
@@ -36,6 +36,10 @@ don't have to import things one by one in every file:
36
36
  | `redirect` | `jskelet` → `redirect` |
37
37
  | `permanentRedirect` | `jskelet` → `permanentRedirect` |
38
38
  | `seeOther` | `jskelet` → `seeOther` |
39
+ | `ogHandler` | `jskelet` → `ogHandler` |
40
+ | `ogImage` | `jskelet` → `ogImage` |
41
+ | `sendOgImage` | `jskelet` → `sendOgImage` |
42
+ | `ImageResponse` | `jskelet` → `ImageResponse` |
39
43
 
40
44
  You can also import directly if you prefer; `api` is only a convenience:
41
45
 
@@ -522,6 +522,64 @@ The `renderHeadMeta(metadata)` function is exported; it can be used when you
522
522
  need to produce the same tags outside the layout (for example in a fragment or
523
523
  an email).
524
524
 
525
+ ## Dynamic OG images
526
+
527
+ Counterpart to Next.js `ImageResponse` / `opengraph-image.tsx`. There is no JSX:
528
+ pass card fields (`title`, `description`, `siteName`, colours) or a raw `svg`.
529
+ With the optional `sharp` peer installed the response is PNG; otherwise SVG.
530
+ Most social scrapers expect PNG, so install `sharp` in production.
531
+
532
+ Because the response is an image, not HTML, do not use `route()` — `ogHandler`
533
+ returns a plain Express handler. `notFound()` and a `null` return yield 404.
534
+
535
+ ```js
536
+ // routes/35-og.mjs
537
+ export default function register(app, { ogHandler, notFound }) {
538
+ app.get(
539
+ "/og/blog/:slug.png",
540
+ ogHandler(async ({ params }) => {
541
+ const post = getPost(params.slug);
542
+ if (!post) notFound();
543
+ return {
544
+ title: post.title,
545
+ description: post.excerpt,
546
+ siteName: "Blog",
547
+ };
548
+ }),
549
+ );
550
+ }
551
+ ```
552
+
553
+ Point page metadata at the absolute URL and size:
554
+
555
+ ```js
556
+ openGraph: {
557
+ type: "article",
558
+ image: `${SITE_URL}/og/blog/${post.slug}.png`,
559
+ imageWidth: 1200,
560
+ imageHeight: 630,
561
+ },
562
+ ```
563
+
564
+ Raw SVG or a Next-like class:
565
+
566
+ ```js
567
+ import { ImageResponse, sendOgImage, OG_SIZE } from "jskelet";
568
+
569
+ app.get("/og/custom.png", async (req, res) => {
570
+ const image = new ImageResponse(
571
+ `<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="630">…</svg>`,
572
+ OG_SIZE,
573
+ );
574
+ await image.send(res);
575
+ // or: await sendOgImage(res, { title: "…", format: "svg" });
576
+ });
577
+ ```
578
+
579
+ Default `Cache-Control`:
580
+ `public, max-age=0, s-maxage=86400, stale-while-revalidate=604800`.
581
+ Override with `cacheControl`. Working example: `examples/blog/routes/35-og.mjs`.
582
+
525
583
  ## Hooks
526
584
 
527
585
  Hooks are defined in `jskelet.config.mjs` under `hooks`. They are all optional
@@ -41,6 +41,7 @@ will feel familiar. The *reasons* behind the differences are in
41
41
  | `loading.js` / Suspense | — | The server HTML is complete; no skeleton needed |
42
42
  | Streaming SSR | — | The response is a single chunk |
43
43
  | `generateMetadata()` | Controller `metadata` + `hooks.metadata()` | Same field names ([04](./04-rendering.md)) |
44
+ | `opengraph-image.tsx` / `ImageResponse` | `ogHandler` + `ImageResponse` / `sendOgImage` | SVG or card fields → PNG (`sharp`); [04](./04-rendering.md) |
44
45
  | `generateStaticParams()` | `hooks.prewarmPaths()` | Warming at startup time, not build time |
45
46
  | Route Handlers (`route.js`) | A plain Express handler | `app.get/post(...)` |
46
47
  | Middleware (`middleware.ts`) | Express middleware + config `rewrites`/`headers`/`redirects` | `app.use(...)` |
@@ -69,6 +70,7 @@ header manually.
69
70
  | `next/link` | `link({ href, text })` — `jskelet/tags` | `title` automatic, `rel`/`target` automatic for external links |
70
71
  | `next/link` prefetching | `navigation: { prefetch, prerender }` | Speculation Rules; no client runtime ([07](./07-configuration.md)) |
71
72
  | `next/image` | `image({ src, alt, priority })` — `jskelet/tags` | `srcset` from the build manifest |
73
+ | `next/og` `ImageResponse` | `ImageResponse` / `ogHandler` — `jskelet` | No JSX; SVG or `title`/`description` card |
72
74
  | `next/font/google` | `fonts: [{ family, weights }]` | Self-hosted woff2, committed |
73
75
  | `@phosphor-icons/react` | `icon({ name, weight })` — `jskelet/tags` | Build-time SVG sprite |
74
76
  | `react-dom` preconnect/preload | `preconnect: [...]` + `headHints()` | ([04](./04-rendering.md)) |
package/docs/en/README.md CHANGED
@@ -62,8 +62,8 @@ npm --prefix examples/minimal run dev
62
62
  **`examples/blog/`** — a dynamic route (`/blog/:slug`), tag pages, the whole of
63
63
  the `redirects`/`rewrites`/`headers`/`cache` configuration, tab panels arriving
64
64
  as fragments, form submission, prewarm,
65
- `robots.txt`/`sitemap.xml`/`rss.xml` and four islands (theme, tabs, search,
66
- form).
65
+ `robots.txt`/`sitemap.xml`/`rss.xml`, dynamic OG images (`/og/blog/:slug.png`)
66
+ and four islands (theme, tabs, search, form).
67
67
 
68
68
  ```bash
69
69
  npm --prefix examples/blog install
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jskelet",
3
- "version": "0.5.1",
3
+ "version": "0.5.2",
4
4
  "description": "A framework that feels like no framework: Express 5 + build-time .jsk (or EJS) SSR, vanilla JS islands, Tailwind v4 and an in-process HTML TTL cache.",
5
5
  "type": "module",
6
6
  "license": "MIT",
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,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
+ }
@@ -14,8 +14,9 @@
14
14
  *
15
15
  * Modül sözleşmesi: default export ya da `register` adlı named export,
16
16
  * `(app, api) => void | Promise<void>` imzasıyla. `api` içinde `route`,
17
- * `fragment`, `renderView`, `renderPage` ve `notFound`/`redirect` hazır gelir,
18
- * böylece route dosyaları framework'ten tek tek import yapmak zorunda kalmaz.
17
+ * `fragment`, `renderView`, `renderPage`, `notFound`/`redirect` ve
18
+ * `ogHandler` hazır gelir, böylece route dosyaları framework'ten tek tek
19
+ * import yapmak zorunda kalmaz.
19
20
  */
20
21
  import fs from "node:fs";
21
22
  import path from "node:path";
@@ -29,6 +30,7 @@ import {
29
30
  redirect,
30
31
  seeOther,
31
32
  } from "../http/control-flow.js";
33
+ import { ImageResponse, ogHandler, ogImage, sendOgImage } from "./og-image.js";
32
34
 
33
35
  const isDev = process.env.NODE_ENV === "development";
34
36
 
@@ -96,6 +98,10 @@ const api = {
96
98
  redirect,
97
99
  permanentRedirect,
98
100
  seeOther,
101
+ ogHandler,
102
+ ogImage,
103
+ sendOgImage,
104
+ ImageResponse,
99
105
  };
100
106
 
101
107
  /**