jskelet 0.4.3 → 0.4.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -37,6 +37,7 @@ import {
37
37
  DEFAULT_DEV_GATE_BYPASS,
38
38
  DEFAULT_DIRS,
39
39
  DEFAULT_HTML_CACHE_MAX_ENTRIES,
40
+ DEFAULT_IMAGES,
40
41
  DEFAULT_LOGS,
41
42
  DEFAULT_NAVIGATION,
42
43
  DEFAULT_NAVIGATION_EXCLUDE,
@@ -130,10 +131,29 @@ const CONFIG_FILE = "jskelet.config.mjs";
130
131
  * @property {string[]} watch Dev sunucusunun izlediği ek dizinler.
131
132
  * @property {{ family: string, slug?: string, weights: number[] }[]} fonts
132
133
  * @property {{ scan?: string[] } | false} icons
133
- * @property {{ widths?: number[], quality?: number, skip?: string[] } | false} images
134
+ * @property {ImagesConfig | false} images
134
135
  * @property {string[]} clientEnv Client bundle'a gömülecek env anahtarları.
135
136
  */
136
137
 
138
+ /**
139
+ * @typedef {object} ImagesRemoteConfig
140
+ * @property {boolean} enabled
141
+ * @property {string[]} allowHosts
142
+ * @property {string} path
143
+ * @property {number} maxWidth
144
+ * @property {number} cacheMaxAge
145
+ * @property {number} fetchTimeoutMs
146
+ * @property {number} maxBytes
147
+ */
148
+
149
+ /**
150
+ * @typedef {object} ImagesConfig
151
+ * @property {number[]} widths
152
+ * @property {number} quality
153
+ * @property {string[]} skip
154
+ * @property {ImagesRemoteConfig | false} remote
155
+ */
156
+
137
157
  /** @type {ResolvedConfig | null} */
138
158
  let config = null;
139
159
 
@@ -772,6 +792,83 @@ function normalizeSecurity(raw) {
772
792
  };
773
793
  }
774
794
 
795
+ /**
796
+ * Build + runtime görsel ayarları. `false` → her iki yüzey de kapalı.
797
+ * `remote.allowHosts` boşsa remote kapalı kalır (açık proxy olmasın).
798
+ *
799
+ * @param {unknown} raw
800
+ * @returns {ImagesConfig | false}
801
+ */
802
+ function normalizeImages(raw) {
803
+ if (raw === false) return false;
804
+
805
+ const source = /** @type {Record<string, any>} */ (raw ?? {});
806
+ const widths = asArray(source.widths ?? DEFAULT_IMAGES.widths, "images.widths")
807
+ .map((entry) => Number(entry))
808
+ .filter((entry) => Number.isFinite(entry) && entry > 0)
809
+ .map((entry) => Math.round(entry));
810
+
811
+ const quality = Number(source.quality ?? DEFAULT_IMAGES.quality);
812
+ const skip = asArray(source.skip ?? DEFAULT_IMAGES.skip, "images.skip")
813
+ .filter((entry) => typeof entry === "string")
814
+ .map(String);
815
+
816
+ /** @type {ImagesRemoteConfig | false} */
817
+ let remote = false;
818
+ if (source.remote !== false && source.remote != null) {
819
+ const rem = /** @type {Record<string, any>} */ (
820
+ source.remote === true ? {} : source.remote
821
+ );
822
+ const allowHosts = asArray(
823
+ rem.allowHosts ?? DEFAULT_IMAGES.remote.allowHosts,
824
+ "images.remote.allowHosts",
825
+ )
826
+ .filter((entry) => typeof entry === "string" && entry.trim())
827
+ .map((entry) => String(entry).trim().toLowerCase());
828
+
829
+ if (allowHosts.length === 0) {
830
+ if (source.remote === true || rem.allowHosts != null) {
831
+ console.warn(
832
+ "[config] images.remote needs a non-empty allowHosts list; remote optimizer disabled",
833
+ );
834
+ }
835
+ } else {
836
+ remote = {
837
+ enabled: true,
838
+ allowHosts,
839
+ path: String(rem.path ?? DEFAULT_IMAGES.remote.path),
840
+ maxWidth: Math.max(
841
+ 1,
842
+ Number(rem.maxWidth ?? DEFAULT_IMAGES.remote.maxWidth) ||
843
+ DEFAULT_IMAGES.remote.maxWidth,
844
+ ),
845
+ cacheMaxAge: Math.max(
846
+ 0,
847
+ Number(rem.cacheMaxAge ?? DEFAULT_IMAGES.remote.cacheMaxAge) ||
848
+ DEFAULT_IMAGES.remote.cacheMaxAge,
849
+ ),
850
+ fetchTimeoutMs: Math.max(
851
+ 1000,
852
+ Number(rem.fetchTimeoutMs ?? DEFAULT_IMAGES.remote.fetchTimeoutMs) ||
853
+ DEFAULT_IMAGES.remote.fetchTimeoutMs,
854
+ ),
855
+ maxBytes: Math.max(
856
+ 1024,
857
+ Number(rem.maxBytes ?? DEFAULT_IMAGES.remote.maxBytes) ||
858
+ DEFAULT_IMAGES.remote.maxBytes,
859
+ ),
860
+ };
861
+ }
862
+ }
863
+
864
+ return {
865
+ widths: widths.length ? widths : [...DEFAULT_IMAGES.widths],
866
+ quality: Number.isFinite(quality) && quality > 0 ? quality : DEFAULT_IMAGES.quality,
867
+ skip,
868
+ remote,
869
+ };
870
+ }
871
+
775
872
  /**
776
873
  * Dizin adlarını mutlak yola çevirir. `styles` bir dosya yolu olduğu için
777
874
  * de aynı çözümlemeden geçer; ayrı bir alan tutmaya değmez.
@@ -942,7 +1039,7 @@ export async function loadConfig(options = {}) {
942
1039
  // olsun diye aynı yerden geçer.
943
1040
  fonts: source.fonts ?? [],
944
1041
  icons: source.icons ?? {},
945
- images: source.images ?? {},
1042
+ images: normalizeImages(source.images),
946
1043
  clientEnv: source.clientEnv ?? [],
947
1044
  };
948
1045
 
package/src/index.js CHANGED
@@ -37,6 +37,7 @@ export {
37
37
  } from "./http/cookies.js";
38
38
  export { reportUpstreamFailure } from "./server/upstream-tracking.js";
39
39
  export { asset, hasAsset, optimizedImage, getSpriteIds } from "./server/assets.js";
40
+ export { remoteImageUrl, parseAllowedRemoteUrl } from "./server/image-optimizer.js";
40
41
  export { headHints } from "./server/head-hints.js";
41
42
  export { renderHeadMeta } from "./server/metadata.js";
42
43
  export {
@@ -17,6 +17,8 @@
17
17
  * static'e düşer ve middleware anında sıkıştırır (kalite 5).
18
18
  * 4b. admin paneli (açıksa) — statikten sonra, route'lardan önce: kendi
19
19
  * gövde ayrıştırıcısını taşır ve uygulama yolunu gölgeleyemez.
20
+ * 4c. image optimizer (images.remote) — uzak görselleri webp'ye çevirir;
21
+ * body parser'dan önce, admin ile aynı katmanda.
20
22
  * 5. body parser'lar — statikten sonra: görsel isteklerinde gövde ayrıştırma
21
23
  * maliyeti ödenmesin.
22
24
  * 6. csrf — body parser'lardan sonra olmalı: token form alanından okunuyor.
@@ -137,6 +139,13 @@ export async function createApp(options = {}) {
137
139
  mountAdmin(app);
138
140
  }
139
141
 
142
+ // Uzak görsel proxy: allowHosts doluysa mount. Statikten sonra, body
143
+ // parser'dan önce — görsel GET'lerinde gövde ayrıştırma maliyeti ödenmesin.
144
+ if (config.images && config.images !== false && config.images.remote) {
145
+ const { mountImageOptimizer } = await import("./image-optimizer.js");
146
+ await mountImageOptimizer(app);
147
+ }
148
+
140
149
  app.use(express.urlencoded({ extended: false, limit: "64kb" }));
141
150
  app.use(express.json({ limit: "256kb" }));
142
151
 
@@ -339,6 +339,13 @@ function router() {
339
339
  fs.createReadStream(OVERLAY_FILE).pipe(res);
340
340
  });
341
341
 
342
+ // Overlay'in SEO taraması; native ESM import ile yüklenir.
343
+ api.get("/seo.js", (req, res) => {
344
+ res.type("application/javascript");
345
+ res.setHeader("Cache-Control", "no-store");
346
+ fs.createReadStream(path.join(DEVTOOLS_DIR, "seo.js")).pipe(res);
347
+ });
348
+
342
349
  api.get("/logo.png", (req, res) => {
343
350
  res.type("image/png");
344
351
  // Logo geliştirme sırasında değişmiyor; her gezinmede yeniden indirmesin.
@@ -0,0 +1,338 @@
1
+ /**
2
+ * Runtime uzak görsel proxy: allowlist'teki host'lardan çeker, sharp ile
3
+ * webp'ye çevirir, diske yazar ve uzun Cache-Control ile servis eder.
4
+ *
5
+ * Next.js `/_next/image` karşılığı. Build zamanı `images.mjs` yalnızca
6
+ * `public/` altındaki yerel dosyaları kapsar; CMS / CDN kapakları için bu uç
7
+ * gerekir. Kapalıyken (allowHosts yok) router hiç mount edilmez.
8
+ *
9
+ * sharp yoksa 302 ile orijinale yönlendirilir — sayfa bozulmaz, tasarruf
10
+ * olmaz. Deployment notu: remote açıksa sharp runtime bağımlılığıdır.
11
+ */
12
+ import crypto from "node:crypto";
13
+ import fs from "node:fs";
14
+ import path from "node:path";
15
+ import process from "node:process";
16
+ import { tryImportFromApp } from "../build/resolve-peer.mjs";
17
+ import { getConfig } from "../config/index.js";
18
+
19
+ /** @type {typeof import('sharp') | null | undefined} */
20
+ let sharpModule;
21
+
22
+ /** @type {string | null} */
23
+ let cacheDir = null;
24
+
25
+ /**
26
+ * @returns {import('../config/index.js').ImagesRemoteConfig | null}
27
+ */
28
+ export function getRemoteImages() {
29
+ const images = getConfig().images;
30
+ if (!images || images === false || !images.remote || !images.remote.enabled) {
31
+ return null;
32
+ }
33
+ return images.remote;
34
+ }
35
+
36
+ /**
37
+ * Mutlak http(s) URL mi ve allowHosts'ta mı.
38
+ * @param {string} src
39
+ * @returns {URL | null}
40
+ */
41
+ export function parseAllowedRemoteUrl(src) {
42
+ const remote = getRemoteImages();
43
+ if (!remote || typeof src !== "string") return null;
44
+
45
+ let url;
46
+ try {
47
+ url = new URL(src);
48
+ } catch {
49
+ return null;
50
+ }
51
+
52
+ if (url.protocol !== "http:" && url.protocol !== "https:") return null;
53
+ if (!isHostAllowed(url.hostname, remote.allowHosts)) return null;
54
+ if (isBlockedAddress(url.hostname)) return null;
55
+
56
+ return url;
57
+ }
58
+
59
+ /**
60
+ * @param {string} hostname
61
+ * @param {string[]} allowHosts
62
+ * @returns {boolean}
63
+ */
64
+ export function isHostAllowed(hostname, allowHosts) {
65
+ const host = hostname.toLowerCase();
66
+ return allowHosts.some((entry) => {
67
+ const pattern = entry.toLowerCase();
68
+ if (pattern.startsWith("*.")) {
69
+ const suffix = pattern.slice(1); // ".example.com"
70
+ return host.endsWith(suffix) || host === pattern.slice(2);
71
+ }
72
+ return host === pattern;
73
+ });
74
+ }
75
+
76
+ /**
77
+ * Literal private / link-local / loopback host'ları reddet (SSRF).
78
+ * Allowlist asıl koruma; bu ek bir savunma katmanı.
79
+ * @param {string} hostname
80
+ * @returns {boolean}
81
+ */
82
+ export function isBlockedAddress(hostname) {
83
+ const host = hostname.toLowerCase().replace(/^\[|\]$/g, "");
84
+ if (host === "localhost" || host === "0.0.0.0" || host.endsWith(".localhost")) {
85
+ return true;
86
+ }
87
+ if (host === "::1" || host === "0:0:0:0:0:0:0:1") return true;
88
+
89
+ // IPv4
90
+ const ipv4 = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(host);
91
+ if (ipv4) {
92
+ const parts = ipv4.slice(1).map(Number);
93
+ if (parts.some((n) => n > 255)) return true;
94
+ const [a, b] = parts;
95
+ if (a === 10 || a === 127 || a === 0) return true;
96
+ if (a === 169 && b === 254) return true;
97
+ if (a === 172 && b >= 16 && b <= 31) return true;
98
+ if (a === 192 && b === 168) return true;
99
+ if (a === 100 && b >= 64 && b <= 127) return true; // CGNAT
100
+ return false;
101
+ }
102
+
103
+ // Ham IPv6 private / ULA — basit önek kontrolü
104
+ if (host.includes(":")) {
105
+ if (host.startsWith("fc") || host.startsWith("fd") || host.startsWith("fe80")) {
106
+ return true;
107
+ }
108
+ }
109
+
110
+ return false;
111
+ }
112
+
113
+ /**
114
+ * Optimizer URL'si üret. `image()` ve elle URL kuran uygulamalar için.
115
+ * @param {string} src Uzak görsel URL'si
116
+ * @param {{ width: number, quality?: number }} options
117
+ * @returns {string | null} Allowlist dışıysa null
118
+ */
119
+ export function remoteImageUrl(src, options) {
120
+ const remote = getRemoteImages();
121
+ const allowed = parseAllowedRemoteUrl(src);
122
+ if (!remote || !allowed) return null;
123
+
124
+ const images = getConfig().images;
125
+ const quality =
126
+ options.quality ??
127
+ (images && images !== false ? images.quality : 78) ??
128
+ 78;
129
+ const width = clampWidth(options.width, remote.maxWidth);
130
+
131
+ const params = new URLSearchParams({
132
+ url: allowed.href,
133
+ w: String(width),
134
+ q: String(quality),
135
+ });
136
+ return `${remote.path}?${params}`;
137
+ }
138
+
139
+ /**
140
+ * @param {number} width
141
+ * @param {number} maxWidth
142
+ * @returns {number}
143
+ */
144
+ export function clampWidth(width, maxWidth) {
145
+ const n = Math.round(Number(width));
146
+ if (!Number.isFinite(n) || n < 1) return Math.min(640, maxWidth);
147
+ return Math.min(n, maxWidth);
148
+ }
149
+
150
+ /**
151
+ * Görüntülenen genişliğe göre srcset adayları (1x + 2x + config widths).
152
+ * @param {number} displayWidth
153
+ * @param {number[]} widths
154
+ * @param {number} maxWidth
155
+ * @returns {number[]}
156
+ */
157
+ export function srcsetWidths(displayWidth, widths, maxWidth) {
158
+ const base = Math.max(1, Math.round(displayWidth));
159
+ const candidates = new Set([
160
+ base,
161
+ Math.min(base * 2, maxWidth),
162
+ ...widths.filter((w) => w >= base && w <= maxWidth),
163
+ ]);
164
+ return [...candidates].sort((a, b) => a - b);
165
+ }
166
+
167
+ /**
168
+ * @param {import('express').Express} app
169
+ * @returns {Promise<void>}
170
+ */
171
+ export async function mountImageOptimizer(app) {
172
+ const remote = getRemoteImages();
173
+ if (!remote) return;
174
+
175
+ const config = getConfig();
176
+ cacheDir = path.join(config.dirs.generated, "image-cache");
177
+ fs.mkdirSync(cacheDir, { recursive: true });
178
+
179
+ sharpModule = await tryImportFromApp(config.root, "sharp");
180
+ if (!sharpModule) {
181
+ console.warn(
182
+ "[images.remote] sharp not installed; optimizer will redirect to the source URL",
183
+ );
184
+ }
185
+
186
+ app.get(remote.path, (req, res) => {
187
+ void handleOptimize(req, res, remote);
188
+ });
189
+ }
190
+
191
+ /**
192
+ * @param {import('express').Request} req
193
+ * @param {import('express').Response} res
194
+ * @param {import('../config/index.js').ImagesRemoteConfig} remote
195
+ */
196
+ async function handleOptimize(req, res, remote) {
197
+ const rawUrl = typeof req.query.url === "string" ? req.query.url : "";
198
+ const allowed = parseAllowedRemoteUrl(rawUrl);
199
+ if (!allowed) {
200
+ res.status(400).type("text").send("Invalid or disallowed image url");
201
+ return;
202
+ }
203
+
204
+ const images = getConfig().images;
205
+ const defaultQ = images && images !== false ? images.quality : 78;
206
+ const quality = clampQuality(
207
+ typeof req.query.q === "string" ? req.query.q : defaultQ,
208
+ defaultQ,
209
+ );
210
+ const width = clampWidth(
211
+ typeof req.query.w === "string" ? req.query.w : 640,
212
+ remote.maxWidth,
213
+ );
214
+
215
+ if (!sharpModule) {
216
+ res.redirect(302, allowed.href);
217
+ return;
218
+ }
219
+
220
+ const key = cacheKey(allowed.href, width, quality);
221
+ const filePath = path.join(/** @type {string} */ (cacheDir), `${key}.webp`);
222
+
223
+ try {
224
+ if (fs.existsSync(filePath)) {
225
+ sendCached(res, filePath, remote.cacheMaxAge);
226
+ return;
227
+ }
228
+
229
+ const upstream = await fetchUpstream(allowed.href, remote);
230
+ if (!upstream.ok) {
231
+ res.status(502).type("text").send("Upstream image fetch failed");
232
+ return;
233
+ }
234
+
235
+ const sharp = sharpModule.default;
236
+ const buffer = await sharp(upstream.buffer, { failOn: "none" })
237
+ .rotate()
238
+ .resize({
239
+ width,
240
+ withoutEnlargement: true,
241
+ fit: "inside",
242
+ })
243
+ .webp({ quality, effort: 4 })
244
+ .toBuffer();
245
+
246
+ // Atomik yaz: yarım dosya immutable cache'e düşmesin.
247
+ const tmp = `${filePath}.${process.pid}.tmp`;
248
+ await fs.promises.writeFile(tmp, buffer);
249
+ await fs.promises.rename(tmp, filePath);
250
+
251
+ sendCached(res, filePath, remote.cacheMaxAge);
252
+ } catch (error) {
253
+ console.warn("[images.remote] optimize failed:", error);
254
+ // Bozuk kaynakta sayfa boş kalmasın: orijinale düş.
255
+ if (!res.headersSent) res.redirect(302, allowed.href);
256
+ }
257
+ }
258
+
259
+ /**
260
+ * @param {string} href
261
+ * @param {import('../config/index.js').ImagesRemoteConfig} remote
262
+ * @returns {Promise<{ ok: true, buffer: Buffer } | { ok: false }>}
263
+ */
264
+ async function fetchUpstream(href, remote) {
265
+ const controller = new AbortController();
266
+ const timer = setTimeout(() => controller.abort(), remote.fetchTimeoutMs);
267
+
268
+ try {
269
+ const response = await fetch(href, {
270
+ signal: controller.signal,
271
+ redirect: "follow",
272
+ headers: {
273
+ // Bazı CDN'ler bot UA reddeder; tarayıcıya yakın tut.
274
+ Accept: "image/avif,image/webp,image/*,*/*;q=0.8",
275
+ "User-Agent": "jskelet-image-optimizer/1",
276
+ },
277
+ });
278
+
279
+ if (!response.ok) return { ok: false };
280
+
281
+ const type = response.headers.get("content-type") ?? "";
282
+ if (type && !type.startsWith("image/") && !type.includes("octet-stream")) {
283
+ return { ok: false };
284
+ }
285
+
286
+ const length = Number(response.headers.get("content-length") ?? 0);
287
+ if (length > remote.maxBytes) return { ok: false };
288
+
289
+ const buffer = Buffer.from(await response.arrayBuffer());
290
+ if (buffer.byteLength > remote.maxBytes) return { ok: false };
291
+
292
+ return { ok: true, buffer };
293
+ } catch {
294
+ return { ok: false };
295
+ } finally {
296
+ clearTimeout(timer);
297
+ }
298
+ }
299
+
300
+ /**
301
+ * @param {import('express').Response} res
302
+ * @param {string} filePath
303
+ * @param {number} maxAge
304
+ */
305
+ function sendCached(res, filePath, maxAge) {
306
+ res.setHeader("Content-Type", "image/webp");
307
+ res.setHeader(
308
+ "Cache-Control",
309
+ `public, max-age=${maxAge}, stale-while-revalidate=${Math.min(maxAge, 86400)}`,
310
+ );
311
+ res.setHeader("Vary", "Accept");
312
+ res.sendFile(path.resolve(filePath));
313
+ }
314
+
315
+ /**
316
+ * @param {string} href
317
+ * @param {number} width
318
+ * @param {number} quality
319
+ * @returns {string}
320
+ */
321
+ function cacheKey(href, width, quality) {
322
+ return crypto
323
+ .createHash("sha256")
324
+ .update(`webp-q${quality}-e4:${width}:${href}`)
325
+ .digest("hex")
326
+ .slice(0, 32);
327
+ }
328
+
329
+ /**
330
+ * @param {unknown} value
331
+ * @param {number} fallback
332
+ * @returns {number}
333
+ */
334
+ function clampQuality(value, fallback) {
335
+ const n = Math.round(Number(value));
336
+ if (!Number.isFinite(n)) return fallback;
337
+ return Math.min(100, Math.max(1, n));
338
+ }
@@ -4,6 +4,11 @@
4
4
  */
5
5
  import { attrs, esc, cn } from "./html.js";
6
6
  import { asset, getSpriteIds, optimizedImage } from "../../server/assets.js";
7
+ import {
8
+ parseAllowedRemoteUrl,
9
+ remoteImageUrl,
10
+ srcsetWidths,
11
+ } from "../../server/image-optimizer.js";
7
12
  import { getRequestContext, markTainted } from "../../http/request-context.js";
8
13
  import { getSignedCookie, randomToken, setSignedCookie } from "../../http/cookies.js";
9
14
  import { getConfig } from "../../config/index.js";
@@ -53,7 +58,11 @@ export function link(props) {
53
58
  * `next/image` karşılığı. `public/` altındaki yerel görseller için build'de
54
59
  * üretilen webp varyantları (`build/tasks/images.mjs`) otomatik olarak
55
60
  * `srcset` + intrinsic `width`/`height` olarak eklenir; manifest'te olmayan
56
- * ya da uzak görseller olduğu gibi basılır.
61
+ * yerel yollar olduğu gibi basılır.
62
+ *
63
+ * `images.remote.allowHosts` açıksa uzak http(s) URL'leri `/_jskelet/image`
64
+ * proxy'sine çevrilir (webp + `w`). `unoptimized` veya elle `srcset` bunu
65
+ * atlar.
57
66
  *
58
67
  * `priority` LCP görselleri için `fetchpriority=high` + eager yükleme yapar.
59
68
  * @param {{ src: string, alt: string, width?: number, height?: number,
@@ -79,20 +88,27 @@ export function image(props) {
79
88
  } = props;
80
89
 
81
90
  const optimized = unoptimized || srcset ? undefined : optimizedImage(src);
91
+ const remote = unoptimized || srcset ? null : remoteResponsive(src, width);
82
92
  const largest = optimized?.variants.at(-1);
83
93
  // Tek varyant üretilmişse (kaynak zaten küçükse) srcset/sizes gürültüden ibaret.
84
- const responsive = optimized && optimized.variants.length > 1;
94
+ const responsive =
95
+ (optimized && optimized.variants.length > 1) ||
96
+ (remote && remote.srcset);
85
97
 
86
98
  const attributes = attrs({
87
- src: largest?.url ?? src,
99
+ src: largest?.url ?? remote?.src ?? src,
88
100
  alt: alt ?? "",
89
101
  width: fill ? undefined : (width ?? optimized?.width),
90
102
  height: fill ? undefined : (height ?? optimized?.height),
91
103
  class: fill
92
104
  ? cn("absolute inset-0 h-full w-full object-cover", className)
93
105
  : className,
94
- sizes: responsive ? (sizes ?? defaultSizes(optimized)) : sizes,
95
- srcset: responsive ? toSrcSet(optimized) : srcset,
106
+ sizes: responsive
107
+ ? (sizes ?? (optimized ? defaultSizes(optimized) : remote?.sizes))
108
+ : sizes,
109
+ srcset: responsive
110
+ ? (optimized ? toSrcSet(optimized) : remote?.srcset)
111
+ : srcset,
96
112
  loading: loading ?? (priority ? "eager" : "lazy"),
97
113
  decoding: priority ? "sync" : "async",
98
114
  fetchpriority: priority ? "high" : undefined,
@@ -102,6 +118,37 @@ export function image(props) {
102
118
  return `<img${attributes}>`;
103
119
  }
104
120
 
121
+ /**
122
+ * Uzak URL → optimizer `src` / `srcset`. Allowlist dışıysa null.
123
+ * @param {string} src
124
+ * @param {number | undefined} width
125
+ * @returns {{ src: string, srcset?: string, sizes?: string } | null}
126
+ */
127
+ function remoteResponsive(src, width) {
128
+ if (!parseAllowedRemoteUrl(src)) return null;
129
+
130
+ const images = getConfig().images;
131
+ if (!images || images === false || !images.remote) return null;
132
+
133
+ const display = width && width > 0 ? width : 640;
134
+ const widths = srcsetWidths(display, images.widths, images.remote.maxWidth);
135
+ const urls = widths
136
+ .map((w) => {
137
+ const href = remoteImageUrl(src, { width: w });
138
+ return href ? `${href} ${w}w` : null;
139
+ })
140
+ .filter(Boolean);
141
+
142
+ const primary = remoteImageUrl(src, { width: display });
143
+ if (!primary) return null;
144
+
145
+ return {
146
+ src: primary,
147
+ srcset: urls.length > 1 ? urls.join(", ") : undefined,
148
+ sizes: `(max-width: ${display}px) 100vw, ${display}px`,
149
+ };
150
+ }
151
+
105
152
  /**
106
153
  * @param {import("../../server/assets.js").OptimizedImage} optimized
107
154
  * @returns {string}