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.
- package/CHANGELOG.md +19 -0
- package/docs/03-routing.md +4 -0
- package/docs/04-render-ve-sablonlar.md +58 -0
- package/docs/06-cache.md +62 -11
- package/docs/07-yapilandirma.md +29 -2
- 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/06-caching.md +63 -12
- package/docs/en/07-configuration.md +30 -2
- package/docs/en/11-migration.md +2 -0
- package/docs/en/README.md +2 -2
- package/package.json +1 -1
- package/src/config/defaults.js +8 -0
- package/src/config/index.js +49 -2
- package/src/index.js +10 -0
- package/src/server/cache-vary.js +113 -0
- package/src/server/html-cache.js +17 -7
- package/src/server/og-image.js +356 -0
- package/src/server/prewarm.js +78 -10
- package/src/server/render.js +16 -7
- package/src/server/router.js +8 -2
package/src/config/index.js
CHANGED
|
@@ -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 },
|
|
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"],
|
|
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
|
+
}
|
package/src/server/html-cache.js
CHANGED
|
@@ -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`
|
|
666
|
-
* yolun bütün query
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
890
|
-
|
|
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, "&")
|
|
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
|
+
}
|