jskelet 0.6.2 → 0.6.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.
Files changed (155) hide show
  1. package/AGENTS.md +136 -136
  2. package/CHANGELOG.md +620 -596
  3. package/LICENSE +21 -21
  4. package/bin/jskelet.mjs +130 -130
  5. package/docs/01-baslangic.md +291 -291
  6. package/docs/02-mimari.md +310 -309
  7. package/docs/03-routing.md +515 -515
  8. package/docs/04-render-ve-sablonlar.md +661 -661
  9. package/docs/05-islands.md +486 -486
  10. package/docs/06-cache.md +1443 -1423
  11. package/docs/07-yapilandirma.md +12 -6
  12. package/docs/08-build.md +429 -428
  13. package/docs/09-dev-araclari.md +364 -364
  14. package/docs/10-dagitim.md +338 -338
  15. package/docs/12-panel-ve-oturum.md +478 -478
  16. package/docs/README.md +83 -83
  17. package/docs/en/01-getting-started.md +298 -298
  18. package/docs/en/02-architecture.md +329 -328
  19. package/docs/en/03-routing.md +531 -531
  20. package/docs/en/04-rendering.md +669 -669
  21. package/docs/en/05-islands.md +497 -497
  22. package/docs/en/06-caching.md +1453 -1431
  23. package/docs/en/07-configuration.md +1219 -1214
  24. package/docs/en/08-build.md +447 -446
  25. package/docs/en/09-dev-tools.md +373 -373
  26. package/docs/en/10-deployment.md +340 -340
  27. package/docs/en/11-migration.md +398 -398
  28. package/docs/en/12-dashboards-and-sessions.md +488 -488
  29. package/docs/en/README.md +87 -87
  30. package/package.json +137 -137
  31. package/src/build/ensure-build.mjs +19 -19
  32. package/src/build/paths.mjs +153 -153
  33. package/src/build/resolve-peer.mjs +36 -36
  34. package/src/build/tasks/client.mjs +349 -349
  35. package/src/build/tasks/css.mjs +235 -235
  36. package/src/build/tasks/fonts.mjs +146 -146
  37. package/src/build/tasks/icons.mjs +357 -357
  38. package/src/build/tasks/images.mjs +244 -244
  39. package/src/build/tasks/precompress.mjs +78 -78
  40. package/src/build/tasks/templates.mjs +20 -20
  41. package/src/client/admin/i18n.js +764 -764
  42. package/src/client/admin/login.html +74 -74
  43. package/src/client/admin/panel.css +809 -809
  44. package/src/client/admin/panel.html +495 -495
  45. package/src/client/admin/panel.js +1251 -1251
  46. package/src/client/devtools/report.html +185 -185
  47. package/src/client/devtools/report.js +745 -745
  48. package/src/client/devtools/seo.js +628 -628
  49. package/src/client/dom.js +95 -95
  50. package/src/client/form.js +192 -192
  51. package/src/client/index.js +45 -45
  52. package/src/client/registry.js +305 -305
  53. package/src/client/safe-image.js +91 -91
  54. package/src/client/shared-cookie.js +225 -225
  55. package/src/client/store.js +36 -36
  56. package/src/client/swap.js +188 -188
  57. package/src/compile/codegen.js +336 -336
  58. package/src/compile/compile-all.js +149 -149
  59. package/src/compile/errors.js +66 -66
  60. package/src/compile/expr.js +409 -409
  61. package/src/compile/index.js +17 -17
  62. package/src/compile/parse.js +541 -541
  63. package/src/compile/resolve.js +211 -211
  64. package/src/compile/scan-exports.js +51 -51
  65. package/src/config/defaults.js +17 -1
  66. package/src/config/index.js +13 -0
  67. package/src/config/pattern.js +107 -107
  68. package/src/generate.mjs +163 -163
  69. package/src/http/control-flow.js +71 -71
  70. package/src/http/cookies-entry.js +21 -21
  71. package/src/http/cookies.js +277 -277
  72. package/src/http/request-cache.js +46 -46
  73. package/src/http/request-context.js +165 -165
  74. package/src/http/shared-cookie.js +178 -178
  75. package/src/index.js +101 -101
  76. package/src/init.mjs +230 -230
  77. package/src/migrate/apply.mjs +262 -262
  78. package/src/migrate/babel.mjs +79 -79
  79. package/src/migrate/classify.mjs +155 -155
  80. package/src/migrate/config.mjs +126 -126
  81. package/src/migrate/fs-walk.mjs +191 -191
  82. package/src/migrate/parse.mjs +26 -26
  83. package/src/migrate/scan.mjs +177 -177
  84. package/src/migrate/transform/expr-source.mjs +168 -168
  85. package/src/migrate/transform/island.mjs +67 -67
  86. package/src/migrate/transform/jsx-to-component.mjs +302 -302
  87. package/src/migrate/transform/jsx-to-jsk.mjs +330 -330
  88. package/src/migrate/transform/page-split.mjs +435 -435
  89. package/src/migrate/write.mjs +81 -81
  90. package/src/migrate.mjs +171 -171
  91. package/src/runtime/alias-hooks.mjs +119 -119
  92. package/src/runtime/register.mjs +4 -4
  93. package/src/server/admin/actions.js +229 -229
  94. package/src/server/admin/auth.js +125 -125
  95. package/src/server/admin/event-log.js +151 -151
  96. package/src/server/admin/gate.js +209 -209
  97. package/src/server/admin/inventory.js +188 -188
  98. package/src/server/admin/mount.js +56 -56
  99. package/src/server/admin/router.js +216 -216
  100. package/src/server/admin/snapshot.js +241 -241
  101. package/src/server/assets.js +147 -147
  102. package/src/server/auth/handoff.js +309 -309
  103. package/src/server/cache-blob.js +70 -0
  104. package/src/server/cache-deps.js +42 -42
  105. package/src/server/cache-vary.js +113 -113
  106. package/src/server/cloudflare.js +607 -607
  107. package/src/server/create-app.js +366 -366
  108. package/src/server/data-cache.js +553 -462
  109. package/src/server/dev/report.js +485 -485
  110. package/src/server/dev/socket.js +170 -170
  111. package/src/server/dev/version-check.mjs +139 -139
  112. package/src/server/disk-cache.js +233 -0
  113. package/src/server/ejs-adapter.js +59 -59
  114. package/src/server/html-cache.js +1196 -1122
  115. package/src/server/image-optimizer.js +500 -407
  116. package/src/server/logs/access-middleware.js +66 -66
  117. package/src/server/logs/file-sink.js +193 -66
  118. package/src/server/logs/pipeline.js +165 -158
  119. package/src/server/logs/s3-put.js +214 -214
  120. package/src/server/logs/s3-sink.js +112 -112
  121. package/src/server/metadata.js +102 -102
  122. package/src/server/middleware/compression.js +205 -205
  123. package/src/server/middleware/csrf.js +134 -134
  124. package/src/server/middleware/dev-gate.js +75 -75
  125. package/src/server/middleware/headers.js +37 -37
  126. package/src/server/middleware/redirects.js +32 -32
  127. package/src/server/middleware/robots-txt.js +341 -341
  128. package/src/server/middleware/static-precompressed.js +121 -100
  129. package/src/server/middleware/trailing-slash.js +53 -53
  130. package/src/server/middleware/upstream-proxy.js +141 -141
  131. package/src/server/og-image.js +356 -356
  132. package/src/server/port-guard.js +255 -255
  133. package/src/server/prewarm.js +1082 -1058
  134. package/src/server/redis.js +588 -569
  135. package/src/server/render.js +4 -4
  136. package/src/server/router.js +157 -157
  137. package/src/server/status-page.js +265 -265
  138. package/src/server/upstream-limiter.js +376 -376
  139. package/src/server/upstream-tracking.js +166 -166
  140. package/src/shared/cookie-domain.js +66 -66
  141. package/src/start.mjs +22 -22
  142. package/src/templates/layout.ejs +30 -30
  143. package/src/templates/layout.jsk +30 -30
  144. package/src/version.mjs +31 -31
  145. package/src/views/components/loader.js +101 -101
  146. package/src/views/helpers/html.js +102 -102
  147. package/src/views/helpers/tags.js +375 -375
  148. package/types/config/defaults.d.ts +15 -1
  149. package/types/config/index.d.ts +8 -0
  150. package/types/server/cache-blob.d.ts +13 -0
  151. package/types/server/data-cache.d.ts +9 -0
  152. package/types/server/disk-cache.d.ts +36 -0
  153. package/types/server/html-cache.d.ts +26 -3
  154. package/types/server/logs/file-sink.d.ts +16 -5
  155. package/types/server/redis.d.ts +2 -1
@@ -1,375 +1,375 @@
1
- /**
2
- * `next/link`, `next/image` ve `@phosphor-icons/react` karşılıkları.
3
- * Hepsi HTML string döndürür; şablonlarda PascalCase etiket veya
4
- * (legacy EJS) `<%- %>` ile basılır.
5
- */
6
- import { attrs, esc, cn, jsonScript } from "./html.js";
7
- import {
8
- asset,
9
- hasAsset,
10
- getSpriteIds,
11
- optimizedImage,
12
- } from "../../server/assets.js";
13
- import {
14
- parseAllowedRemoteUrl,
15
- remoteImageUrl,
16
- srcsetWidths,
17
- } from "../../server/image-optimizer.js";
18
- import { getRequestContext, markTainted } from "../../http/request-context.js";
19
- import { getSignedCookie, randomToken, setSignedCookie } from "../../http/cookies.js";
20
- import { getConfig } from "../../config/index.js";
21
-
22
- const isDev = process.env.NODE_ENV === "development";
23
-
24
- /** Aynı eksik ikon için tek uyarı. */
25
- const warnedIcons = new Set();
26
-
27
- /**
28
- * `SeoLink` karşılığı: title otomatik doldurulur.
29
- * @param {{ href: string, text?: string, html?: string, class?: string,
30
- * title?: string, ariaLabel?: string, target?: string, rel?: string,
31
- * attrs?: Record<string, unknown> }} props
32
- * @returns {string}
33
- */
34
- export function link(props) {
35
- const {
36
- href,
37
- text,
38
- html,
39
- class: className,
40
- title,
41
- ariaLabel,
42
- target,
43
- rel,
44
- attrs: extra = {},
45
- } = props;
46
-
47
- const resolvedTitle = title ?? ariaLabel ?? text ?? href;
48
- const isExternal = /^https?:\/\//.test(String(href ?? ""));
49
-
50
- const attributes = attrs({
51
- href,
52
- class: className,
53
- title: resolvedTitle,
54
- "aria-label": ariaLabel,
55
- target: target ?? (isExternal ? "_blank" : undefined),
56
- rel: rel ?? (isExternal ? "noopener noreferrer" : undefined),
57
- ...extra,
58
- });
59
-
60
- return `<a${attributes}>${html ?? esc(text ?? "")}</a>`;
61
- }
62
-
63
- /**
64
- * `next/image` karşılığı. `public/` altındaki yerel görseller için build'de
65
- * üretilen webp varyantları (`build/tasks/images.mjs`) otomatik olarak
66
- * `srcset` + intrinsic `width`/`height` olarak eklenir; manifest'te olmayan
67
- * yerel yollar olduğu gibi basılır.
68
- *
69
- * `images.remote.allowHosts` açıksa uzak http(s) URL'leri `/_jskelet/image`
70
- * proxy'sine çevrilir (webp + `w`). `unoptimized` veya elle `srcset` bunu
71
- * atlar.
72
- *
73
- * `priority` LCP görselleri için `fetchpriority=high` + eager yükleme yapar.
74
- * @param {{ src: string, alt: string, width?: number, height?: number,
75
- * class?: string, sizes?: string, srcset?: string, priority?: boolean,
76
- * fill?: boolean, loading?: 'lazy' | 'eager', unoptimized?: boolean,
77
- * attrs?: Record<string, unknown> }} props
78
- * @returns {string}
79
- */
80
- export function image(props) {
81
- const {
82
- src,
83
- alt,
84
- width,
85
- height,
86
- class: className,
87
- sizes,
88
- srcset,
89
- priority = false,
90
- fill = false,
91
- loading,
92
- unoptimized = false,
93
- attrs: extra = {},
94
- } = props;
95
-
96
- const optimized = unoptimized || srcset ? undefined : optimizedImage(src);
97
- const remote = unoptimized || srcset ? null : remoteResponsive(src, width);
98
- const largest = optimized?.variants.at(-1);
99
- // Tek varyant üretilmişse (kaynak zaten küçükse) srcset/sizes gürültüden ibaret.
100
- const responsive =
101
- (optimized && optimized.variants.length > 1) ||
102
- (remote && remote.srcset);
103
-
104
- const attributes = attrs({
105
- src: largest?.url ?? remote?.src ?? src,
106
- alt: alt ?? "",
107
- width: fill ? undefined : (width ?? optimized?.width),
108
- height: fill ? undefined : (height ?? optimized?.height),
109
- class: fill
110
- ? cn("absolute inset-0 h-full w-full object-cover", className)
111
- : className,
112
- sizes: responsive
113
- ? (sizes ?? (optimized ? defaultSizes(optimized) : remote?.sizes))
114
- : sizes,
115
- srcset: responsive
116
- ? (optimized ? toSrcSet(optimized) : remote?.srcset)
117
- : srcset,
118
- loading: loading ?? (priority ? "eager" : "lazy"),
119
- decoding: priority ? "sync" : "async",
120
- fetchpriority: priority ? "high" : undefined,
121
- ...extra,
122
- });
123
-
124
- return `<img${attributes}>`;
125
- }
126
-
127
- /**
128
- * Uzak URL → optimizer `src` / `srcset`. Allowlist dışıysa null.
129
- * @param {string} src
130
- * @param {number | undefined} width
131
- * @returns {{ src: string, srcset?: string, sizes?: string } | null}
132
- */
133
- function remoteResponsive(src, width) {
134
- if (!parseAllowedRemoteUrl(src)) return null;
135
-
136
- const images = getConfig().images;
137
- if (!images || images === false || !images.remote) return null;
138
-
139
- const display = width && width > 0 ? width : 640;
140
- const widths = srcsetWidths(display, images.widths, images.remote.maxWidth);
141
- const urls = widths
142
- .map((w) => {
143
- const href = remoteImageUrl(src, { width: w });
144
- return href ? `${href} ${w}w` : null;
145
- })
146
- .filter(Boolean);
147
-
148
- const primary = remoteImageUrl(src, { width: display });
149
- if (!primary) return null;
150
-
151
- return {
152
- src: primary,
153
- srcset: urls.length > 1 ? urls.join(", ") : undefined,
154
- sizes: `(max-width: ${display}px) 100vw, ${display}px`,
155
- };
156
- }
157
-
158
- /**
159
- * @param {import("../../server/assets.js").OptimizedImage} optimized
160
- * @returns {string}
161
- */
162
- function toSrcSet(optimized) {
163
- return optimized.variants
164
- .map((variant) => `${variant.url} ${variant.width}w`)
165
- .join(", ");
166
- }
167
-
168
- /**
169
- * Çağıran `sizes` vermediyse makul bir varsayılan: görsel kendi intrinsic
170
- * genişliğinden büyütülmez, dar ekranlarda viewport'u kaplar.
171
- * @param {import("../../server/assets.js").OptimizedImage} optimized
172
- * @returns {string}
173
- */
174
- function defaultSizes(optimized) {
175
- const max = optimized.variants.at(-1)?.width ?? optimized.width;
176
- return `(max-width: ${max}px) 100vw, ${max}px`;
177
- }
178
-
179
- /**
180
- * LCP görselleri için `<head>` preload etiketi.
181
- * @param {{ href: string, imagesrcset?: string, imagesizes?: string }} props
182
- * @returns {string}
183
- */
184
- export function preloadImage({ href, imagesrcset, imagesizes }) {
185
- return `<link${attrs({
186
- rel: "preload",
187
- as: "image",
188
- href,
189
- imagesrcset,
190
- imagesizes,
191
- fetchpriority: "high",
192
- })}>`;
193
- }
194
-
195
- /**
196
- * Phosphor ikonları — build zamanı üretilen SVG sprite'tan `<use>`.
197
- * `weight` sprite id'sine dahildir (regular/bold/fill).
198
- * @param {{ name: string, size?: number | string, weight?: string,
199
- * class?: string, attrs?: Record<string, unknown> }} props
200
- * @returns {string}
201
- */
202
- export function icon(props) {
203
- const {
204
- name,
205
- size = 24,
206
- weight = "regular",
207
- class: className,
208
- attrs: extra = {},
209
- } = props;
210
-
211
- const spritePath = asset("sprite.svg");
212
- const id = `${toKebab(name)}-${weight}`;
213
-
214
- if (isDev && !warnedIcons.has(id)) {
215
- const ids = getSpriteIds();
216
- if (ids.size && !ids.has(id)) {
217
- warnedIcons.add(id);
218
- console.warn(
219
- `[icon] missing from sprite: ${id} — write the name as a literal or add it to the build/tasks/icons.mjs scan.`,
220
- );
221
- }
222
- }
223
-
224
- const attributes = attrs({
225
- width: size,
226
- height: size,
227
- class: className,
228
- "aria-hidden": "true",
229
- focusable: "false",
230
- fill: "currentColor",
231
- viewBox: "0 0 256 256",
232
- ...extra,
233
- });
234
-
235
- return `<svg${attributes}><use href="${esc(spritePath)}#${esc(id)}"></use></svg>`;
236
- }
237
-
238
- /**
239
- * Formun içine CSRF token'ını gizli alan olarak basar.
240
- *
241
- * Token **burada** üretilir ve imzalı cookie olarak yazılır: bir sayfada
242
- * token gerçekten gerekiyorsa o sayfa zaten kişiye özeldir. Bu yüzden çağrı
243
- * render'ı işaretler (`tainted`) ve sayfa public HTML cache'ine giremez —
244
- * aksi hâlde tüm ziyaretçiler cache'ten aynı token'ı alırdı ve çift gönderim
245
- * kontrolü hiçbir şey doğrulamazdı.
246
- *
247
- * `security.csrf.token` kapalıysa boş string döner; şablon her koşulda
248
- * render edilebilmeli, korumanın açık olması config'in kararı.
249
- *
250
- * @returns {string}
251
- */
252
- export function csrfField() {
253
- const context = getRequestContext();
254
- if (!context?.res) return "";
255
-
256
- const { csrf } = getConfig().security;
257
- if (!csrf.token) return "";
258
-
259
- if (!context.csrfToken) {
260
- const existing = getSignedCookie(context.res.req, csrf.cookieName);
261
- const token = existing ?? randomToken(24);
262
-
263
- if (!existing) {
264
- try {
265
- setSignedCookie(context.res, csrf.cookieName, token, {
266
- // Token'ı istemci cookie'den değil, basılan gizli alandan okur;
267
- // HttpOnly kalması XSS durumunda bir katman daha demek.
268
- httpOnly: true,
269
- });
270
- } catch (error) {
271
- console.warn(
272
- "[csrf] could not emit the token: no secret for signed cookies",
273
- error instanceof Error ? error.message : error,
274
- );
275
- return "";
276
- }
277
- }
278
-
279
- context.csrfToken = token;
280
- }
281
-
282
- markTainted("csrfField()");
283
-
284
- return `<input type="hidden" name="${esc(csrf.fieldName)}" value="${esc(context.csrfToken)}">`;
285
- }
286
-
287
- /**
288
- * `ArrowRightIcon` / `ArrowRight` → `arrow-right`
289
- * @param {string} name
290
- * @returns {string}
291
- */
292
- export function toKebab(name) {
293
- return String(name)
294
- .replace(/Icon$/, "")
295
- .replace(/([a-z0-9])([A-Z])/g, "$1-$2")
296
- .replace(/([A-Z]+)([A-Z][a-z])/g, "$1-$2")
297
- .toLowerCase();
298
- }
299
-
300
- /**
301
- * Layout `<head>` stylesheet'leri. `hasAsset` false olanları basmaz —
302
- * build yokken 404 link'i istememek için. `.jsk` ifade dilinde `asset()` /
303
- * `hasAsset()` çağrılamadığı için layout bu etiketi kullanır.
304
- *
305
- * @param {{ styles?: string[] }} [props]
306
- * @returns {string}
307
- */
308
- export function stylesheets(props = {}) {
309
- const styles = Array.isArray(props.styles) ? props.styles : [];
310
- /** @type {string[]} */
311
- const parts = [];
312
-
313
- if (hasAsset("app.css")) {
314
- parts.push(
315
- `<link rel="stylesheet" href="${esc(asset("app.css"))}" data-jskelet-css="app.css">`,
316
- );
317
- }
318
-
319
- for (const sheet of styles) {
320
- if (!sheet || !hasAsset(sheet)) continue;
321
- parts.push(
322
- `<link rel="stylesheet" href="${esc(asset(sheet))}" data-jskelet-css="${esc(sheet)}">`,
323
- );
324
- }
325
-
326
- return parts.join("\n ");
327
- }
328
-
329
- /**
330
- * Layout gövde sonu script'leri: `main.js`, controller `entries`, isteğe
331
- * bağlı dev overlay.
332
- *
333
- * @param {{ entries?: string[], devtools?: boolean, devBasePath?: string }} [props]
334
- * @returns {string}
335
- */
336
- export function bodyScripts(props = {}) {
337
- const entries = Array.isArray(props.entries) ? props.entries : [];
338
- const devBasePath = props.devBasePath ?? "/__jskelet/dev";
339
- /** @type {string[]} */
340
- const parts = [];
341
-
342
- if (hasAsset("main.js")) {
343
- parts.push(`<script type="module" src="${esc(asset("main.js"))}"></script>`);
344
- }
345
-
346
- for (const entry of entries) {
347
- if (!entry) continue;
348
- parts.push(`<script type="module" src="${esc(asset(entry))}"></script>`);
349
- }
350
-
351
- if (props.devtools) {
352
- parts.push(
353
- `<script type="module" src="${esc(devBasePath)}/overlay.js"></script>`,
354
- );
355
- }
356
-
357
- return parts.join("\n ");
358
- }
359
-
360
- /**
361
- * JSON-LD script etiketleri. `structuredData` dizisindeki her öğe için bir
362
- * `<script type="application/ld+json">`.
363
- *
364
- * @param {{ items?: unknown[] }} [props]
365
- * @returns {string}
366
- */
367
- export function jsonLd(props = {}) {
368
- const items = Array.isArray(props.items) ? props.items : [];
369
- return items
370
- .map(
371
- (item) =>
372
- `<script type="application/ld+json">${jsonScript(item)}</script>`,
373
- )
374
- .join("\n ");
375
- }
1
+ /**
2
+ * `next/link`, `next/image` ve `@phosphor-icons/react` karşılıkları.
3
+ * Hepsi HTML string döndürür; şablonlarda PascalCase etiket veya
4
+ * (legacy EJS) `<%- %>` ile basılır.
5
+ */
6
+ import { attrs, esc, cn, jsonScript } from "./html.js";
7
+ import {
8
+ asset,
9
+ hasAsset,
10
+ getSpriteIds,
11
+ optimizedImage,
12
+ } from "../../server/assets.js";
13
+ import {
14
+ parseAllowedRemoteUrl,
15
+ remoteImageUrl,
16
+ srcsetWidths,
17
+ } from "../../server/image-optimizer.js";
18
+ import { getRequestContext, markTainted } from "../../http/request-context.js";
19
+ import { getSignedCookie, randomToken, setSignedCookie } from "../../http/cookies.js";
20
+ import { getConfig } from "../../config/index.js";
21
+
22
+ const isDev = process.env.NODE_ENV === "development";
23
+
24
+ /** Aynı eksik ikon için tek uyarı. */
25
+ const warnedIcons = new Set();
26
+
27
+ /**
28
+ * `SeoLink` karşılığı: title otomatik doldurulur.
29
+ * @param {{ href: string, text?: string, html?: string, class?: string,
30
+ * title?: string, ariaLabel?: string, target?: string, rel?: string,
31
+ * attrs?: Record<string, unknown> }} props
32
+ * @returns {string}
33
+ */
34
+ export function link(props) {
35
+ const {
36
+ href,
37
+ text,
38
+ html,
39
+ class: className,
40
+ title,
41
+ ariaLabel,
42
+ target,
43
+ rel,
44
+ attrs: extra = {},
45
+ } = props;
46
+
47
+ const resolvedTitle = title ?? ariaLabel ?? text ?? href;
48
+ const isExternal = /^https?:\/\//.test(String(href ?? ""));
49
+
50
+ const attributes = attrs({
51
+ href,
52
+ class: className,
53
+ title: resolvedTitle,
54
+ "aria-label": ariaLabel,
55
+ target: target ?? (isExternal ? "_blank" : undefined),
56
+ rel: rel ?? (isExternal ? "noopener noreferrer" : undefined),
57
+ ...extra,
58
+ });
59
+
60
+ return `<a${attributes}>${html ?? esc(text ?? "")}</a>`;
61
+ }
62
+
63
+ /**
64
+ * `next/image` karşılığı. `public/` altındaki yerel görseller için build'de
65
+ * üretilen webp varyantları (`build/tasks/images.mjs`) otomatik olarak
66
+ * `srcset` + intrinsic `width`/`height` olarak eklenir; manifest'te olmayan
67
+ * yerel yollar olduğu gibi basılır.
68
+ *
69
+ * `images.remote.allowHosts` açıksa uzak http(s) URL'leri `/_jskelet/image`
70
+ * proxy'sine çevrilir (webp + `w`). `unoptimized` veya elle `srcset` bunu
71
+ * atlar.
72
+ *
73
+ * `priority` LCP görselleri için `fetchpriority=high` + eager yükleme yapar.
74
+ * @param {{ src: string, alt: string, width?: number, height?: number,
75
+ * class?: string, sizes?: string, srcset?: string, priority?: boolean,
76
+ * fill?: boolean, loading?: 'lazy' | 'eager', unoptimized?: boolean,
77
+ * attrs?: Record<string, unknown> }} props
78
+ * @returns {string}
79
+ */
80
+ export function image(props) {
81
+ const {
82
+ src,
83
+ alt,
84
+ width,
85
+ height,
86
+ class: className,
87
+ sizes,
88
+ srcset,
89
+ priority = false,
90
+ fill = false,
91
+ loading,
92
+ unoptimized = false,
93
+ attrs: extra = {},
94
+ } = props;
95
+
96
+ const optimized = unoptimized || srcset ? undefined : optimizedImage(src);
97
+ const remote = unoptimized || srcset ? null : remoteResponsive(src, width);
98
+ const largest = optimized?.variants.at(-1);
99
+ // Tek varyant üretilmişse (kaynak zaten küçükse) srcset/sizes gürültüden ibaret.
100
+ const responsive =
101
+ (optimized && optimized.variants.length > 1) ||
102
+ (remote && remote.srcset);
103
+
104
+ const attributes = attrs({
105
+ src: largest?.url ?? remote?.src ?? src,
106
+ alt: alt ?? "",
107
+ width: fill ? undefined : (width ?? optimized?.width),
108
+ height: fill ? undefined : (height ?? optimized?.height),
109
+ class: fill
110
+ ? cn("absolute inset-0 h-full w-full object-cover", className)
111
+ : className,
112
+ sizes: responsive
113
+ ? (sizes ?? (optimized ? defaultSizes(optimized) : remote?.sizes))
114
+ : sizes,
115
+ srcset: responsive
116
+ ? (optimized ? toSrcSet(optimized) : remote?.srcset)
117
+ : srcset,
118
+ loading: loading ?? (priority ? "eager" : "lazy"),
119
+ decoding: priority ? "sync" : "async",
120
+ fetchpriority: priority ? "high" : undefined,
121
+ ...extra,
122
+ });
123
+
124
+ return `<img${attributes}>`;
125
+ }
126
+
127
+ /**
128
+ * Uzak URL → optimizer `src` / `srcset`. Allowlist dışıysa null.
129
+ * @param {string} src
130
+ * @param {number | undefined} width
131
+ * @returns {{ src: string, srcset?: string, sizes?: string } | null}
132
+ */
133
+ function remoteResponsive(src, width) {
134
+ if (!parseAllowedRemoteUrl(src)) return null;
135
+
136
+ const images = getConfig().images;
137
+ if (!images || images === false || !images.remote) return null;
138
+
139
+ const display = width && width > 0 ? width : 640;
140
+ const widths = srcsetWidths(display, images.widths, images.remote.maxWidth);
141
+ const urls = widths
142
+ .map((w) => {
143
+ const href = remoteImageUrl(src, { width: w });
144
+ return href ? `${href} ${w}w` : null;
145
+ })
146
+ .filter(Boolean);
147
+
148
+ const primary = remoteImageUrl(src, { width: display });
149
+ if (!primary) return null;
150
+
151
+ return {
152
+ src: primary,
153
+ srcset: urls.length > 1 ? urls.join(", ") : undefined,
154
+ sizes: `(max-width: ${display}px) 100vw, ${display}px`,
155
+ };
156
+ }
157
+
158
+ /**
159
+ * @param {import("../../server/assets.js").OptimizedImage} optimized
160
+ * @returns {string}
161
+ */
162
+ function toSrcSet(optimized) {
163
+ return optimized.variants
164
+ .map((variant) => `${variant.url} ${variant.width}w`)
165
+ .join(", ");
166
+ }
167
+
168
+ /**
169
+ * Çağıran `sizes` vermediyse makul bir varsayılan: görsel kendi intrinsic
170
+ * genişliğinden büyütülmez, dar ekranlarda viewport'u kaplar.
171
+ * @param {import("../../server/assets.js").OptimizedImage} optimized
172
+ * @returns {string}
173
+ */
174
+ function defaultSizes(optimized) {
175
+ const max = optimized.variants.at(-1)?.width ?? optimized.width;
176
+ return `(max-width: ${max}px) 100vw, ${max}px`;
177
+ }
178
+
179
+ /**
180
+ * LCP görselleri için `<head>` preload etiketi.
181
+ * @param {{ href: string, imagesrcset?: string, imagesizes?: string }} props
182
+ * @returns {string}
183
+ */
184
+ export function preloadImage({ href, imagesrcset, imagesizes }) {
185
+ return `<link${attrs({
186
+ rel: "preload",
187
+ as: "image",
188
+ href,
189
+ imagesrcset,
190
+ imagesizes,
191
+ fetchpriority: "high",
192
+ })}>`;
193
+ }
194
+
195
+ /**
196
+ * Phosphor ikonları — build zamanı üretilen SVG sprite'tan `<use>`.
197
+ * `weight` sprite id'sine dahildir (regular/bold/fill).
198
+ * @param {{ name: string, size?: number | string, weight?: string,
199
+ * class?: string, attrs?: Record<string, unknown> }} props
200
+ * @returns {string}
201
+ */
202
+ export function icon(props) {
203
+ const {
204
+ name,
205
+ size = 24,
206
+ weight = "regular",
207
+ class: className,
208
+ attrs: extra = {},
209
+ } = props;
210
+
211
+ const spritePath = asset("sprite.svg");
212
+ const id = `${toKebab(name)}-${weight}`;
213
+
214
+ if (isDev && !warnedIcons.has(id)) {
215
+ const ids = getSpriteIds();
216
+ if (ids.size && !ids.has(id)) {
217
+ warnedIcons.add(id);
218
+ console.warn(
219
+ `[icon] missing from sprite: ${id} — write the name as a literal or add it to the build/tasks/icons.mjs scan.`,
220
+ );
221
+ }
222
+ }
223
+
224
+ const attributes = attrs({
225
+ width: size,
226
+ height: size,
227
+ class: className,
228
+ "aria-hidden": "true",
229
+ focusable: "false",
230
+ fill: "currentColor",
231
+ viewBox: "0 0 256 256",
232
+ ...extra,
233
+ });
234
+
235
+ return `<svg${attributes}><use href="${esc(spritePath)}#${esc(id)}"></use></svg>`;
236
+ }
237
+
238
+ /**
239
+ * Formun içine CSRF token'ını gizli alan olarak basar.
240
+ *
241
+ * Token **burada** üretilir ve imzalı cookie olarak yazılır: bir sayfada
242
+ * token gerçekten gerekiyorsa o sayfa zaten kişiye özeldir. Bu yüzden çağrı
243
+ * render'ı işaretler (`tainted`) ve sayfa public HTML cache'ine giremez —
244
+ * aksi hâlde tüm ziyaretçiler cache'ten aynı token'ı alırdı ve çift gönderim
245
+ * kontrolü hiçbir şey doğrulamazdı.
246
+ *
247
+ * `security.csrf.token` kapalıysa boş string döner; şablon her koşulda
248
+ * render edilebilmeli, korumanın açık olması config'in kararı.
249
+ *
250
+ * @returns {string}
251
+ */
252
+ export function csrfField() {
253
+ const context = getRequestContext();
254
+ if (!context?.res) return "";
255
+
256
+ const { csrf } = getConfig().security;
257
+ if (!csrf.token) return "";
258
+
259
+ if (!context.csrfToken) {
260
+ const existing = getSignedCookie(context.res.req, csrf.cookieName);
261
+ const token = existing ?? randomToken(24);
262
+
263
+ if (!existing) {
264
+ try {
265
+ setSignedCookie(context.res, csrf.cookieName, token, {
266
+ // Token'ı istemci cookie'den değil, basılan gizli alandan okur;
267
+ // HttpOnly kalması XSS durumunda bir katman daha demek.
268
+ httpOnly: true,
269
+ });
270
+ } catch (error) {
271
+ console.warn(
272
+ "[csrf] could not emit the token: no secret for signed cookies",
273
+ error instanceof Error ? error.message : error,
274
+ );
275
+ return "";
276
+ }
277
+ }
278
+
279
+ context.csrfToken = token;
280
+ }
281
+
282
+ markTainted("csrfField()");
283
+
284
+ return `<input type="hidden" name="${esc(csrf.fieldName)}" value="${esc(context.csrfToken)}">`;
285
+ }
286
+
287
+ /**
288
+ * `ArrowRightIcon` / `ArrowRight` → `arrow-right`
289
+ * @param {string} name
290
+ * @returns {string}
291
+ */
292
+ export function toKebab(name) {
293
+ return String(name)
294
+ .replace(/Icon$/, "")
295
+ .replace(/([a-z0-9])([A-Z])/g, "$1-$2")
296
+ .replace(/([A-Z]+)([A-Z][a-z])/g, "$1-$2")
297
+ .toLowerCase();
298
+ }
299
+
300
+ /**
301
+ * Layout `<head>` stylesheet'leri. `hasAsset` false olanları basmaz —
302
+ * build yokken 404 link'i istememek için. `.jsk` ifade dilinde `asset()` /
303
+ * `hasAsset()` çağrılamadığı için layout bu etiketi kullanır.
304
+ *
305
+ * @param {{ styles?: string[] }} [props]
306
+ * @returns {string}
307
+ */
308
+ export function stylesheets(props = {}) {
309
+ const styles = Array.isArray(props.styles) ? props.styles : [];
310
+ /** @type {string[]} */
311
+ const parts = [];
312
+
313
+ if (hasAsset("app.css")) {
314
+ parts.push(
315
+ `<link rel="stylesheet" href="${esc(asset("app.css"))}" data-jskelet-css="app.css">`,
316
+ );
317
+ }
318
+
319
+ for (const sheet of styles) {
320
+ if (!sheet || !hasAsset(sheet)) continue;
321
+ parts.push(
322
+ `<link rel="stylesheet" href="${esc(asset(sheet))}" data-jskelet-css="${esc(sheet)}">`,
323
+ );
324
+ }
325
+
326
+ return parts.join("\n ");
327
+ }
328
+
329
+ /**
330
+ * Layout gövde sonu script'leri: `main.js`, controller `entries`, isteğe
331
+ * bağlı dev overlay.
332
+ *
333
+ * @param {{ entries?: string[], devtools?: boolean, devBasePath?: string }} [props]
334
+ * @returns {string}
335
+ */
336
+ export function bodyScripts(props = {}) {
337
+ const entries = Array.isArray(props.entries) ? props.entries : [];
338
+ const devBasePath = props.devBasePath ?? "/__jskelet/dev";
339
+ /** @type {string[]} */
340
+ const parts = [];
341
+
342
+ if (hasAsset("main.js")) {
343
+ parts.push(`<script type="module" src="${esc(asset("main.js"))}"></script>`);
344
+ }
345
+
346
+ for (const entry of entries) {
347
+ if (!entry) continue;
348
+ parts.push(`<script type="module" src="${esc(asset(entry))}"></script>`);
349
+ }
350
+
351
+ if (props.devtools) {
352
+ parts.push(
353
+ `<script type="module" src="${esc(devBasePath)}/overlay.js"></script>`,
354
+ );
355
+ }
356
+
357
+ return parts.join("\n ");
358
+ }
359
+
360
+ /**
361
+ * JSON-LD script etiketleri. `structuredData` dizisindeki her öğe için bir
362
+ * `<script type="application/ld+json">`.
363
+ *
364
+ * @param {{ items?: unknown[] }} [props]
365
+ * @returns {string}
366
+ */
367
+ export function jsonLd(props = {}) {
368
+ const items = Array.isArray(props.items) ? props.items : [];
369
+ return items
370
+ .map(
371
+ (item) =>
372
+ `<script type="application/ld+json">${jsonScript(item)}</script>`,
373
+ )
374
+ .join("\n ");
375
+ }