jskelet 0.1.1 → 0.1.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.
Files changed (64) hide show
  1. package/AGENTS.md +5 -0
  2. package/CHANGELOG.md +63 -0
  3. package/README.md +21 -7
  4. package/bin/jskelet.mjs +6 -6
  5. package/docs/03-routing.md +48 -9
  6. package/docs/04-render-ve-sablonlar.md +2 -2
  7. package/docs/05-islands.md +59 -6
  8. package/docs/06-cache.md +39 -7
  9. package/docs/07-yapilandirma.md +51 -1
  10. package/docs/08-build.md +4 -4
  11. package/docs/09-dev-araclari.md +5 -0
  12. package/docs/12-panel-ve-oturum.md +384 -0
  13. package/docs/README.md +25 -2
  14. package/docs/en/01-getting-started.md +292 -0
  15. package/docs/en/02-architecture.md +305 -0
  16. package/docs/en/03-routing.md +493 -0
  17. package/docs/en/04-rendering.md +504 -0
  18. package/docs/en/05-islands.md +492 -0
  19. package/docs/en/06-caching.md +454 -0
  20. package/docs/en/07-configuration.md +736 -0
  21. package/docs/en/08-build.md +383 -0
  22. package/docs/en/09-dev-tools.md +314 -0
  23. package/docs/en/10-deployment.md +332 -0
  24. package/docs/en/11-migration.md +360 -0
  25. package/docs/en/12-dashboards-and-sessions.md +392 -0
  26. package/docs/en/README.md +112 -0
  27. package/package.json +4 -2
  28. package/src/build/build.mjs +1 -1
  29. package/src/build/tasks/client.mjs +2 -2
  30. package/src/build/tasks/fonts.mjs +3 -3
  31. package/src/build/tasks/icons.mjs +1 -1
  32. package/src/build/tasks/images.mjs +2 -2
  33. package/src/client/devtools/overlay.js +196 -164
  34. package/src/client/devtools/report.js +96 -96
  35. package/src/client/form.js +192 -0
  36. package/src/client/index.js +10 -1
  37. package/src/client/registry.js +78 -4
  38. package/src/client/swap.js +188 -0
  39. package/src/config/defaults.js +34 -0
  40. package/src/config/index.js +68 -13
  41. package/src/config/pattern.js +1 -1
  42. package/src/dev-server.mjs +1 -1
  43. package/src/http/control-flow.js +16 -1
  44. package/src/http/cookies.js +257 -0
  45. package/src/http/request-context.js +162 -0
  46. package/src/index.js +19 -2
  47. package/src/init.mjs +32 -31
  48. package/src/log.mjs +8 -2
  49. package/src/logo.png +0 -0
  50. package/src/runtime/alias-hooks.mjs +1 -1
  51. package/src/server/assets.js +1 -1
  52. package/src/server/create-app.js +12 -4
  53. package/src/server/dev/devtools.js +6 -2
  54. package/src/server/dev/version-check.mjs +139 -0
  55. package/src/server/head-hints.js +1 -1
  56. package/src/server/html-cache.js +10 -4
  57. package/src/server/middleware/csrf.js +134 -0
  58. package/src/server/prewarm.js +6 -6
  59. package/src/server/render.js +199 -16
  60. package/src/server/router.js +14 -7
  61. package/src/server/status-page.js +1 -1
  62. package/src/version.mjs +9 -4
  63. package/src/views/components/loader.js +1 -1
  64. package/src/views/helpers/tags.js +53 -1
@@ -21,6 +21,12 @@ import { matchPattern } from "../config/pattern.js";
21
21
  import { encodeText, negotiateEncoding } from "./middleware/compression.js";
22
22
  import { navigationHints, preconnectHints } from "./head-hints.js";
23
23
  import { withRequestCache } from "../http/request-cache.js";
24
+ import {
25
+ createRequestContext,
26
+ getRequestContext,
27
+ guardRequest,
28
+ withRequestContext,
29
+ } from "../http/request-context.js";
24
30
  import { getUpstreamFailures, withUpstreamTracking } from "./upstream-tracking.js";
25
31
  import { isNotFoundError, isRedirectError } from "../http/control-flow.js";
26
32
  import { renderHeadMeta } from "./metadata.js";
@@ -153,50 +159,114 @@ export async function renderPage(page) {
153
159
  );
154
160
  }
155
161
 
162
+ /**
163
+ * Kişiye özel yanıtın cache direktifi. Dinamik (cache'lenmeyen) her sayfa da
164
+ * bunu alır: bir yanıt hiçbir direktif taşımadığında HTTP onu "sezgisel olarak
165
+ * cache'lenebilir" sayar ve araya giren bir proxy ya da tarayıcının geri
166
+ * tuşu kullanıcıya özel HTML'i saklayabilir.
167
+ */
168
+ const PRIVATE_CACHE = "private, no-store";
169
+
156
170
  /**
157
171
  * Controller'ı çalıştırıp yanıtı yazar; notFound/redirect kontrol akışını,
158
172
  * HTML cache'ini ve hata yönetimini üstlenir.
159
173
  *
174
+ * `private: true` kişiye özel sayfaları public cache yolundan tamamen ayırır:
175
+ * HTML cache devre dışı kalır, config'in `cache.html` deseni bu kararı
176
+ * ezemez, yanıt `no-store` ile ve ETag'siz gider. Dashboard tipi sayfalarda
177
+ * bu bayrak olmadan çalışmak, bir kullanıcının HTML'inin bir başkasına
178
+ * servis edilmesi anlamına gelir.
179
+ *
160
180
  * @param {(ctx: { params: object, query: object, pathname: string,
161
181
  * req: import('express').Request }) => Promise<object>} controller
162
- * @param {{ revalidate?: number }} [options]
182
+ * @param {{ revalidate?: number, private?: boolean }} [options]
163
183
  * @returns {import('express').RequestHandler}
164
184
  */
165
185
  export function route(controller, options = {}) {
186
+ const isPrivate = options.private === true;
187
+
166
188
  return async (req, res, next) => {
189
+ const context = createRequestContext({ private: isPrivate, res });
167
190
  const ctx = {
168
191
  params: req.params ?? {},
169
192
  query: req.query ?? {},
170
193
  pathname: req.path,
171
- req,
194
+ // Cookie/Authorization okunursa çıktı kullanıcıya bağlıdır; cache'e
195
+ // yazılmaması için işaretlenmesi gerekiyor.
196
+ req: guardRequest(req),
172
197
  };
173
198
 
174
- const revalidate = resolveRevalidate(req.path, options.revalidate);
175
- const cacheable = req.method === "GET" && Boolean(revalidate);
199
+ // Private route'ta desen taraması hiç yapılmaz: `cache.html` altındaki
200
+ // geniş bir kural (`/**` gibi) bu sayfayı cache'lenebilir hâle
201
+ // getirmesin. Kilit tek yönlü — route "özel" dediyse config açamaz.
202
+ const revalidate = isPrivate
203
+ ? undefined
204
+ : resolveRevalidate(req.path, options.revalidate);
205
+ const cacheable = !isPrivate && req.method === "GET" && Boolean(revalidate);
176
206
  const cacheKey = `${req.path}?${new URLSearchParams(
177
207
  Object.entries(ctx.query).map(([k, v]) => [k, String(v)]),
178
208
  ).toString()}`;
179
209
 
180
210
  try {
181
- const result = await withHtmlCache(cacheKey, cacheable ? revalidate : 0, () =>
182
- withUpstreamTracking(() => withRequestCache(() => produce(controller, ctx))),
211
+ const result = await withRequestContext(context, () =>
212
+ withHtmlCache(cacheKey, cacheable ? revalidate : 0, () =>
213
+ withUpstreamTracking(() => withRequestCache(() => produce(controller, ctx))),
214
+ ),
183
215
  );
184
216
 
217
+ // Cache'lenebilir bir route kimliğe dokunduysa yanıt yine gider ama
218
+ // saklanmaz (`produce` bunu `storable: false` ile bildirdi) ve public
219
+ // direktif yazılmaz.
220
+ const leaked = cacheable && context.tainted;
221
+ if (leaked && isDev) {
222
+ throw new Error(
223
+ `[render] ${req.path} is a cacheable route but read identity-bound data ` +
224
+ `(${context.taintReasons.join(", ")}). This page must be registered with ` +
225
+ `'route(fn, { private: true })'; otherwise one user's HTML is served to another.`,
226
+ );
227
+ }
228
+
229
+ const publicCache = cacheable && !leaked;
230
+
185
231
  res.status(result.status);
186
232
  res.setHeader("Content-Type", "text/html; charset=utf-8");
187
- if (cacheable) {
233
+
234
+ if (publicCache) {
188
235
  res.setHeader(
189
236
  "Cache-Control",
190
237
  `public, max-age=0, s-maxage=${revalidate}, stale-while-revalidate=60`,
191
238
  );
239
+ res.setHeader(
240
+ getConfig().brand.cacheHeader,
241
+ result.cached ? (result.stale ? "STALE" : "HIT") : "MISS",
242
+ );
243
+ } else {
244
+ res.setHeader("Cache-Control", PRIVATE_CACHE);
245
+ // Anahtarında cookie olmayan bir cache'in bu yanıtı paylaşmasını
246
+ // engeller; `no-store`'a uymayan bir katman için ikinci savunma.
247
+ res.setHeader("Vary", "Cookie");
248
+
249
+ if (leaked) {
250
+ console.warn(
251
+ `[render] ${req.path} read identity-bound data (${context.taintReasons.join(", ")}), ` +
252
+ `not cached. The route should be registered with 'private: true'.`,
253
+ );
254
+ }
192
255
  }
193
- res.setHeader(
194
- getConfig().brand.cacheHeader,
195
- result.cached ? (result.stale ? "STALE" : "HIT") : "MISS",
196
- );
197
- await sendHtml(req, res, result.html, result.encoded);
256
+
257
+ // ETag kişiye özel HTML için kullanıcıya özgü bir doğrulayıcıdır ve
258
+ // `no-store` ile birlikte hiçbir işe yaramaz; üretilmesi engellenir.
259
+ await sendHtml(req, res, result.html, result.encoded, { etag: publicCache });
198
260
  } catch (error) {
199
261
  if (isRedirectError(error)) {
262
+ // Oturuma bağlı bir yönlendirme de kişiye özeldir: "giriş yapmalısın"
263
+ // kararının cache'lenmesi, oturum açmış kullanıcıyı da login sayfasına
264
+ // atan türde hatalara yol açıyor.
265
+ if (isPrivate || context.tainted) {
266
+ res.setHeader("Cache-Control", PRIVATE_CACHE);
267
+ res.setHeader("Vary", "Cookie");
268
+ }
269
+
200
270
  res.redirect(error.statusCode, error.location);
201
271
  return;
202
272
  }
@@ -205,6 +275,105 @@ export function route(controller, options = {}) {
205
275
  };
206
276
  }
207
277
 
278
+ /**
279
+ * Layout'suz, asla cache'lenmeyen parça yanıtı.
280
+ *
281
+ * Fragment uçları (tablo sayfası, sekme paneli, canlı tazelenen kart) her
282
+ * projede elle yazılıyor ve `no-store` yazmayı unutmak sessiz bir sızıntıya
283
+ * dönüşüyor. Burada politika sabit: HTML cache'e hiç uğramaz, `no-store` ile
284
+ * ve ETag'siz gider.
285
+ *
286
+ * Hata durumunda tüm sayfa yerine küçük bir hata parçası döner: takas edilen
287
+ * bölge bir hata sayfasının tamamını içine almasın.
288
+ *
289
+ * @param {(ctx: { params: object, query: object, pathname: string,
290
+ * req: import('express').Request }) => Promise<{ view: string, data?: object,
291
+ * status?: number } | string>} controller
292
+ * @returns {import('express').RequestHandler}
293
+ */
294
+ export function fragment(controller) {
295
+ return async (req, res, next) => {
296
+ const context = createRequestContext({ private: true, res });
297
+ const ctx = {
298
+ params: req.params ?? {},
299
+ query: req.query ?? {},
300
+ pathname: req.path,
301
+ req: guardRequest(req),
302
+ };
303
+
304
+ try {
305
+ const result = await withRequestContext(context, () =>
306
+ withRequestCache(async () => {
307
+ const value = await controller(ctx);
308
+ if (typeof value === "string") return { html: value, status: 200 };
309
+
310
+ return {
311
+ html: await renderView(value.view, value.data ?? {}),
312
+ status: value.status ?? 200,
313
+ };
314
+ }),
315
+ );
316
+
317
+ res.status(result.status);
318
+ res.setHeader("Content-Type", "text/html; charset=utf-8");
319
+ res.setHeader("Cache-Control", PRIVATE_CACHE);
320
+ res.setHeader("Vary", "Cookie");
321
+ await sendHtml(req, res, result.html, undefined, { etag: false });
322
+ } catch (error) {
323
+ if (isRedirectError(error)) {
324
+ res.setHeader("Cache-Control", PRIVATE_CACHE);
325
+ res.redirect(error.statusCode, error.location);
326
+ return;
327
+ }
328
+
329
+ if (isNotFoundError(error)) {
330
+ res.status(404).setHeader("Cache-Control", PRIVATE_CACHE);
331
+ res.type("html").send(fragmentError("notFound"));
332
+ return;
333
+ }
334
+
335
+ console.error(`[fragment] ${req.method} ${req.originalUrl}`, error);
336
+
337
+ if (res.headersSent) {
338
+ next(error);
339
+ return;
340
+ }
341
+
342
+ res.status(500).setHeader("Cache-Control", PRIVATE_CACHE);
343
+ res.type("html").send(fragmentError("failed"));
344
+ }
345
+ };
346
+ }
347
+
348
+ /**
349
+ * Ziyaretçiye görünen parça hatası metinleri. Framework'ün kendi logları
350
+ * İngilizce ama bu satırlar ekranda okunuyor, bu yüzden durum sayfalarıyla
351
+ * aynı kuralı izliyorlar: dil `brand.lang`.
352
+ */
353
+ const FRAGMENT_MESSAGES = {
354
+ tr: { notFound: "Bu içerik bulunamadı.", failed: "Bu bölüm yüklenemedi." },
355
+ en: { notFound: "This content was not found.", failed: "This section could not be loaded." },
356
+ };
357
+
358
+ /**
359
+ * Fragment hatası için minimal işaretleme. Şablona bağlı olmaması bilinçli:
360
+ * hata yolu, hatanın kaynağı olabilecek render katmanına geri dönmemeli.
361
+ *
362
+ * @param {"notFound" | "failed"} kind
363
+ * @returns {string}
364
+ */
365
+ function fragmentError(kind) {
366
+ let lang = "en";
367
+ try {
368
+ lang = getConfig().brand.lang ?? "en";
369
+ } catch {
370
+ /* config yüklenmemişse İngilizce kalır */
371
+ }
372
+
373
+ const table = FRAGMENT_MESSAGES[lang.slice(0, 2).toLowerCase()] ?? FRAGMENT_MESSAGES.en;
374
+ return `<div role="alert" data-fragment-error>${html.esc(table[kind])}</div>`;
375
+ }
376
+
208
377
  /**
209
378
  * `jskelet.config.mjs` → `cache().html` route'un kendi `revalidate`'ini ezer.
210
379
  * Sonuç yol başına hatırlanır: her istekte desen taraması yapılmaz.
@@ -253,13 +422,23 @@ function resolveRevalidate(pathname, fallback) {
253
422
  * @param {import('express').Response} res
254
423
  * @param {string} body
255
424
  * @param {Map<string, Buffer>} [encoded]
425
+ * @param {{ etag?: boolean }} [options] `etag: false` → `res.send()` atlanır,
426
+ * böylece Express kullanıcıya özel gövde için doğrulayıcı üretmez.
256
427
  * @returns {Promise<void>}
257
428
  */
258
- async function sendHtml(req, res, body, encoded) {
429
+ async function sendHtml(req, res, body, encoded, options = {}) {
259
430
  const encoding =
260
431
  req.method === "HEAD" ? null : negotiateEncoding(req.headers["accept-encoding"]);
261
432
 
262
433
  if (!encoding || !encoded) {
434
+ if (options.etag === false) {
435
+ // Sıkıştırma middleware'i devreye girerse Content-Length'i kendisi
436
+ // kaldırır; girmezse doğru uzunlukla gider.
437
+ res.setHeader("Content-Length", String(Buffer.byteLength(body)));
438
+ res.end(req.method === "HEAD" ? undefined : body);
439
+ return;
440
+ }
441
+
263
442
  res.send(body);
264
443
  return;
265
444
  }
@@ -279,7 +458,8 @@ async function sendHtml(req, res, body, encoded) {
279
458
  /**
280
459
  * @param {Function} controller
281
460
  * @param {{ pathname: string }} ctx
282
- * @returns {Promise<{ html: string, status: number, degraded?: boolean }>}
461
+ * @returns {Promise<{ html: string, status: number, degraded?: boolean,
462
+ * storable?: boolean }>}
283
463
  */
284
464
  async function produce(controller, ctx) {
285
465
  try {
@@ -289,6 +469,9 @@ async function produce(controller, ctx) {
289
469
  html: rendered,
290
470
  status: page.status ?? 200,
291
471
  degraded: hasUpstreamFailures(ctx.pathname),
472
+ // Kimliğe bağlı çıktı önbelleğe yazılmaz. Karar burada verilmeli:
473
+ // `withHtmlCache` yazma anında controller'ın ne okuduğunu bilemez.
474
+ storable: getRequestContext()?.tainted !== true,
292
475
  };
293
476
  } catch (error) {
294
477
  if (isNotFoundError(error)) {
@@ -332,14 +515,14 @@ function hasUpstreamFailures(pathname) {
332
515
 
333
516
  if (permanent.length) {
334
517
  console.warn(
335
- `[render] ${pathname} eksik veriyle üretildi, upstream kalıcı hata veriyor (${summarize(permanent)})`,
518
+ `[render] ${pathname} was produced with missing data, upstream is failing permanently (${summarize(permanent)})`,
336
519
  );
337
520
  }
338
521
 
339
522
  if (!transient.length) return false;
340
523
 
341
524
  console.warn(
342
- `[render] ${pathname} eksik veriyle üretildi, önbelleğe alınmıyor (${summarize(transient)})`,
525
+ `[render] ${pathname} was produced with missing data, not caching it (${summarize(transient)})`,
343
526
  );
344
527
 
345
528
  return true;
@@ -14,16 +14,21 @@
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
- * `renderView`, `renderPage` ve `notFound`/`redirect` hazır gelir, böylece
18
- * route dosyaları framework'ten tek tek import yapmak zorunda kalmaz.
17
+ * `fragment`, `renderView`, `renderPage` ve `notFound`/`redirect` hazır gelir,
18
+ * böylece route dosyaları framework'ten tek tek import yapmak zorunda kalmaz.
19
19
  */
20
20
  import fs from "node:fs";
21
21
  import path from "node:path";
22
22
  import process from "node:process";
23
23
  import { pathToFileURL } from "node:url";
24
24
  import { getConfig } from "../config/index.js";
25
- import { renderPage, renderView, route } from "./render.js";
26
- import { notFound, permanentRedirect, redirect } from "../http/control-flow.js";
25
+ import { fragment, renderPage, renderView, route } from "./render.js";
26
+ import {
27
+ notFound,
28
+ permanentRedirect,
29
+ redirect,
30
+ seeOther,
31
+ } from "../http/control-flow.js";
27
32
 
28
33
  const isDev = process.env.NODE_ENV === "development";
29
34
 
@@ -61,11 +66,13 @@ function discover(dir, out = []) {
61
66
  */
62
67
  const api = {
63
68
  route,
69
+ fragment,
64
70
  renderView,
65
71
  renderPage,
66
72
  notFound,
67
73
  redirect,
68
74
  permanentRedirect,
75
+ seeOther,
69
76
  };
70
77
 
71
78
  /**
@@ -81,7 +88,7 @@ export async function registerRoutes(app) {
81
88
 
82
89
  if (!files.length) {
83
90
  console.warn(
84
- `[router] hiç route modülü bulunamadı — ${path.relative(config.root, config.dirs.routes)}/ boş mu?`,
91
+ `[router] no route modules found — is ${path.relative(config.root, config.dirs.routes)}/ empty?`,
85
92
  );
86
93
  return 0;
87
94
  }
@@ -99,7 +106,7 @@ export async function registerRoutes(app) {
99
106
  // et. Üretimde fırlat — yarım route tablosuyla yayına çıkmak,
100
107
  // sessizce 404 dönen sayfalar demek.
101
108
  if (isDev) {
102
- console.warn(`[router] ${path.basename(file)} yüklenemedi, atlandı`, error);
109
+ console.warn(`[router] ${path.basename(file)} failed to load, skipped`, error);
103
110
  continue;
104
111
  }
105
112
  throw error;
@@ -108,7 +115,7 @@ export async function registerRoutes(app) {
108
115
  const register = module.default ?? module.register;
109
116
  if (typeof register !== "function") {
110
117
  console.warn(
111
- `[router] ${path.basename(file)} default ya da 'register' fonksiyonu dışa açmıyor, atlandı`,
118
+ `[router] ${path.basename(file)} exports neither a default nor a 'register' function, skipped`,
112
119
  );
113
120
  continue;
114
121
  }
@@ -41,7 +41,7 @@ export async function renderStatusPage(status, options = {}) {
41
41
  const { renderPage } = await import("./render.js");
42
42
  return await renderPage({ pathname: `/${status}`, ...page });
43
43
  } catch (error) {
44
- console.error(`[render] ${status} sayfası render edilemedi`, error);
44
+ console.error(`[render] failed to render the ${status} page`, error);
45
45
  return fallbackPage(status);
46
46
  }
47
47
  }
package/src/version.mjs CHANGED
@@ -6,12 +6,17 @@ import fs from "node:fs";
6
6
  import path from "node:path";
7
7
  import { FRAMEWORK_ROOT } from "./config/index.js";
8
8
 
9
- /** @type {string} */
10
- export const FRAMEWORK_VERSION = (() => {
9
+ const manifest = (() => {
11
10
  try {
12
11
  const file = path.join(FRAMEWORK_ROOT, "package.json");
13
- return JSON.parse(fs.readFileSync(file, "utf8")).version ?? "0.0.0";
12
+ return JSON.parse(fs.readFileSync(file, "utf8"));
14
13
  } catch {
15
- return "0.0.0";
14
+ return {};
16
15
  }
17
16
  })();
17
+
18
+ /** @type {string} */
19
+ export const FRAMEWORK_VERSION = manifest.version ?? "0.0.0";
20
+
21
+ /** Paket adı: sürüm kontrolü hangi kayıt defteri girdisine bakacağını buradan bilir. */
22
+ export const FRAMEWORK_PACKAGE = manifest.name ?? "jskelet";
@@ -72,7 +72,7 @@ export async function loadComponents(dir) {
72
72
  const previous = origin.get(name);
73
73
  if (previous && previous !== BARREL && previous !== relative) {
74
74
  console.warn(
75
- `[components] '${name}' iki kez tanımlı: ${previous} ve ${relative} — ikincisi kazanıyor.`,
75
+ `[components] '${name}' is defined twice: ${previous} and ${relative} — the second one wins.`,
76
76
  );
77
77
  }
78
78
 
@@ -4,6 +4,9 @@
4
4
  */
5
5
  import { attrs, esc, cn } from "./html.js";
6
6
  import { asset, getSpriteIds, optimizedImage } from "../../server/assets.js";
7
+ import { getRequestContext, markTainted } from "../../http/request-context.js";
8
+ import { getSignedCookie, randomToken, setSignedCookie } from "../../http/cookies.js";
9
+ import { getConfig } from "../../config/index.js";
7
10
 
8
11
  const isDev = process.env.NODE_ENV === "development";
9
12
 
@@ -160,7 +163,7 @@ export function icon(props) {
160
163
  if (ids.size && !ids.has(id)) {
161
164
  warnedIcons.add(id);
162
165
  console.warn(
163
- `[icon] sprite'ta yok: ${id} — adı sabit yazın ya da build/tasks/icons.mjs taramasına ekleyin.`,
166
+ `[icon] missing from sprite: ${id} — write the name as a literal or add it to the build/tasks/icons.mjs scan.`,
164
167
  );
165
168
  }
166
169
  }
@@ -179,6 +182,55 @@ export function icon(props) {
179
182
  return `<svg${attributes}><use href="${esc(spritePath)}#${esc(id)}"></use></svg>`;
180
183
  }
181
184
 
185
+ /**
186
+ * Formun içine CSRF token'ını gizli alan olarak basar.
187
+ *
188
+ * Token **burada** üretilir ve imzalı cookie olarak yazılır: bir sayfada
189
+ * token gerçekten gerekiyorsa o sayfa zaten kişiye özeldir. Bu yüzden çağrı
190
+ * render'ı işaretler (`tainted`) ve sayfa public HTML cache'ine giremez —
191
+ * aksi hâlde tüm ziyaretçiler cache'ten aynı token'ı alırdı ve çift gönderim
192
+ * kontrolü hiçbir şey doğrulamazdı.
193
+ *
194
+ * `security.csrf.token` kapalıysa boş string döner; şablon her koşulda
195
+ * render edilebilmeli, korumanın açık olması config'in kararı.
196
+ *
197
+ * @returns {string}
198
+ */
199
+ export function csrfField() {
200
+ const context = getRequestContext();
201
+ if (!context?.res) return "";
202
+
203
+ const { csrf } = getConfig().security;
204
+ if (!csrf.token) return "";
205
+
206
+ if (!context.csrfToken) {
207
+ const existing = getSignedCookie(context.res.req, csrf.cookieName);
208
+ const token = existing ?? randomToken(24);
209
+
210
+ if (!existing) {
211
+ try {
212
+ setSignedCookie(context.res, csrf.cookieName, token, {
213
+ // Token'ı istemci cookie'den değil, basılan gizli alandan okur;
214
+ // HttpOnly kalması XSS durumunda bir katman daha demek.
215
+ httpOnly: true,
216
+ });
217
+ } catch (error) {
218
+ console.warn(
219
+ "[csrf] could not emit the token: no secret for signed cookies",
220
+ error instanceof Error ? error.message : error,
221
+ );
222
+ return "";
223
+ }
224
+ }
225
+
226
+ context.csrfToken = token;
227
+ }
228
+
229
+ markTainted("csrfField()");
230
+
231
+ return `<input type="hidden" name="${esc(csrf.fieldName)}" value="${esc(context.csrfToken)}">`;
232
+ }
233
+
182
234
  /**
183
235
  * `ArrowRightIcon` / `ArrowRight` → `arrow-right`
184
236
  * @param {string} name