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 +4 -0
- package/docs/03-routing.md +4 -0
- package/docs/04-render-ve-sablonlar.md +58 -0
- package/docs/11-tasima.md +2 -0
- package/docs/README.md +3 -2
- package/docs/en/03-routing.md +4 -0
- package/docs/en/04-rendering.md +58 -0
- package/docs/en/11-migration.md +2 -0
- package/docs/en/README.md +2 -2
- package/package.json +1 -1
- package/src/index.js +10 -0
- package/src/server/og-image.js +356 -0
- package/src/server/router.js +8 -2
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
|
package/docs/03-routing.md
CHANGED
|
@@ -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,
|
|
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
|
package/docs/en/03-routing.md
CHANGED
|
@@ -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
|
|
package/docs/en/04-rendering.md
CHANGED
|
@@ -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
|
package/docs/en/11-migration.md
CHANGED
|
@@ -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
|
|
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.
|
|
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, "&")
|
|
61
|
+
.replace(/</g, "<")
|
|
62
|
+
.replace(/>/g, ">")
|
|
63
|
+
.replace(/"/g, """)
|
|
64
|
+
.replace(/'/g, "'");
|
|
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
|
+
}
|
package/src/server/router.js
CHANGED
|
@@ -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
|
|
18
|
-
* böylece route dosyaları framework'ten tek tek
|
|
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
|
/**
|