jskelet 0.2.5 → 0.3.0

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 (106) hide show
  1. package/AGENTS.md +132 -132
  2. package/CHANGELOG.md +403 -383
  3. package/LICENSE +21 -21
  4. package/bin/jskelet.mjs +103 -103
  5. package/docs/01-baslangic.md +285 -285
  6. package/docs/02-mimari.md +293 -287
  7. package/docs/03-routing.md +486 -480
  8. package/docs/04-render-ve-sablonlar.md +490 -490
  9. package/docs/05-islands.md +482 -482
  10. package/docs/06-cache.md +1231 -1209
  11. package/docs/07-yapilandirma.md +44 -21
  12. package/docs/08-build.md +366 -366
  13. package/docs/09-dev-araclari.md +335 -335
  14. package/docs/10-dagitim.md +329 -329
  15. package/docs/11-tasima.md +1 -0
  16. package/docs/12-panel-ve-oturum.md +384 -384
  17. package/docs/README.md +105 -105
  18. package/docs/en/01-getting-started.md +292 -292
  19. package/docs/en/02-architecture.md +311 -305
  20. package/docs/en/03-routing.md +503 -497
  21. package/docs/en/04-rendering.md +504 -504
  22. package/docs/en/05-islands.md +492 -492
  23. package/docs/en/06-caching.md +1197 -1198
  24. package/docs/en/07-configuration.md +1009 -986
  25. package/docs/en/08-build.md +383 -383
  26. package/docs/en/09-dev-tools.md +342 -342
  27. package/docs/en/10-deployment.md +332 -332
  28. package/docs/en/11-migration.md +360 -359
  29. package/docs/en/12-dashboards-and-sessions.md +392 -392
  30. package/docs/en/README.md +112 -112
  31. package/package.json +102 -102
  32. package/src/build/ensure-build.mjs +15 -15
  33. package/src/build/paths.mjs +143 -143
  34. package/src/build/resolve-peer.mjs +36 -36
  35. package/src/build/tasks/client.mjs +268 -268
  36. package/src/build/tasks/css.mjs +124 -124
  37. package/src/build/tasks/fonts.mjs +146 -146
  38. package/src/build/tasks/icons.mjs +224 -224
  39. package/src/build/tasks/images.mjs +244 -244
  40. package/src/build/tasks/precompress.mjs +78 -78
  41. package/src/client/{cache-panel → admin}/i18n.js +756 -670
  42. package/src/client/{cache-panel → admin}/login.html +74 -74
  43. package/src/client/{cache-panel → admin}/panel.css +804 -756
  44. package/src/client/admin/panel.html +486 -0
  45. package/src/client/{cache-panel → admin}/panel.js +1242 -915
  46. package/src/client/devtools/report.html +185 -185
  47. package/src/client/devtools/report.js +725 -725
  48. package/src/client/dom.js +95 -95
  49. package/src/client/form.js +192 -192
  50. package/src/client/index.js +35 -35
  51. package/src/client/registry.js +297 -297
  52. package/src/client/safe-image.js +91 -91
  53. package/src/client/store.js +36 -36
  54. package/src/client/swap.js +188 -188
  55. package/src/config/defaults.js +16 -7
  56. package/src/config/index.js +38 -22
  57. package/src/config/pattern.js +107 -107
  58. package/src/http/control-flow.js +71 -71
  59. package/src/http/cookies.js +257 -257
  60. package/src/http/request-cache.js +46 -46
  61. package/src/http/request-context.js +162 -162
  62. package/src/index.js +83 -83
  63. package/src/init.mjs +221 -221
  64. package/src/log.mjs +58 -0
  65. package/src/runtime/alias-hooks.mjs +119 -119
  66. package/src/runtime/register.mjs +4 -4
  67. package/src/server/admin/actions.js +229 -0
  68. package/src/server/admin/auth.js +125 -0
  69. package/src/server/admin/event-log.js +151 -0
  70. package/src/server/admin/gate.js +209 -0
  71. package/src/server/admin/inventory.js +188 -0
  72. package/src/server/admin/mount.js +56 -0
  73. package/src/server/admin/router.js +216 -0
  74. package/src/server/admin/snapshot.js +126 -0
  75. package/src/server/assets.js +147 -147
  76. package/src/server/cache-deps.js +42 -42
  77. package/src/server/cloudflare.js +607 -607
  78. package/src/server/create-app.js +295 -291
  79. package/src/server/data-cache.js +462 -462
  80. package/src/server/dev/report.js +369 -369
  81. package/src/server/dev/socket.js +170 -170
  82. package/src/server/dev/version-check.mjs +139 -139
  83. package/src/server/html-cache.js +817 -817
  84. package/src/server/metadata.js +102 -102
  85. package/src/server/middleware/compression.js +205 -205
  86. package/src/server/middleware/csrf.js +134 -134
  87. package/src/server/middleware/dev-gate.js +62 -62
  88. package/src/server/middleware/headers.js +37 -37
  89. package/src/server/middleware/redirects.js +32 -32
  90. package/src/server/middleware/static-precompressed.js +100 -100
  91. package/src/server/middleware/trailing-slash.js +53 -0
  92. package/src/server/middleware/upstream-proxy.js +141 -141
  93. package/src/server/prewarm.js +601 -601
  94. package/src/server/redis.js +569 -569
  95. package/src/server/router.js +128 -128
  96. package/src/server/status-page.js +164 -164
  97. package/src/server/upstream-limiter.js +376 -376
  98. package/src/server/upstream-tracking.js +166 -166
  99. package/src/start.mjs +7 -7
  100. package/src/templates/layout.ejs +44 -44
  101. package/src/version.mjs +31 -31
  102. package/src/views/components/loader.js +85 -85
  103. package/src/views/helpers/html.js +102 -102
  104. package/src/views/helpers/tags.js +245 -245
  105. package/src/client/cache-panel/panel.html +0 -308
  106. package/src/server/cache-panel.js +0 -759
@@ -1,91 +1,91 @@
1
- /**
2
- * Yüklenemeyen görseller için tek bir belge dinleyicisi.
3
- *
4
- * Bu iş bilinçli olarak **island değil**. Görsel ağırlıklı bir sayfada 80+
5
- * `<img>` olabiliyor ve her birine ayrı island bağlamak (gözlemci + dinamik
6
- * import + mount) sırf hata ihtimali için ciddi bir hidrasyon yükü. `error`
7
- * olayı kabarmaz ama yakalama fazında görülebilir, bu yüzden tek dinleyici
8
- * hepsini karşılar ve sonradan DOM'a eklenen görseller de kendiliğinden
9
- * kapsanır.
10
- *
11
- * Kullanım: görsele `data-safe-image` ekleyin. Kendi hata görünümünüzü
12
- * vermek için görseli `data-safe-image-host` taşıyan bir sarmalayıcıya alın
13
- * ve içine `<template data-safe-image-fallback>` koyun — framework hiçbir
14
- * stil dayatmaz, yalnızca değiştirme işini yapar.
15
- */
16
-
17
- const MARKER = "data-safe-image";
18
-
19
- /**
20
- * Şablon verilmemişse yerine geçen minimal blok: görselin ölçülerini korur
21
- * ki değiştirme sırasında düzen kaymasın (CLS).
22
- *
23
- * @param {HTMLImageElement} img
24
- * @returns {HTMLElement}
25
- */
26
- function buildFallback(img) {
27
- const element = document.createElement("div");
28
- const label = img.dataset.fallbackLabel || img.getAttribute("alt") || "";
29
-
30
- element.className = [img.className, img.dataset.fallbackClass ?? ""]
31
- .filter(Boolean)
32
- .join(" ");
33
- element.dataset.safeImageFallback = "";
34
-
35
- if (img.width && img.height) {
36
- element.style.width = `${img.width}px`;
37
- element.style.height = `${img.height}px`;
38
- }
39
-
40
- element.setAttribute("role", "img");
41
- if (label) element.setAttribute("aria-label", label);
42
-
43
- return element;
44
- }
45
-
46
- /**
47
- * @param {HTMLImageElement} img
48
- */
49
- function replace(img) {
50
- if (!img.isConnected) return;
51
-
52
- const host = img.closest("[data-safe-image-host]");
53
- const template = host?.querySelector("template[data-safe-image-fallback]");
54
-
55
- if (host && template) {
56
- host.replaceWith(template.content.cloneNode(true));
57
- return;
58
- }
59
-
60
- img.replaceWith(buildFallback(img));
61
- }
62
-
63
- /**
64
- * @returns {void}
65
- */
66
- export function startSafeImages() {
67
- document.addEventListener(
68
- "error",
69
- (event) => {
70
- const target = /** @type {HTMLElement} */ (event.target);
71
- if (target?.tagName !== "IMG" || !target.hasAttribute(MARKER)) return;
72
- replace(/** @type {HTMLImageElement} */ (target));
73
- },
74
- // `error` kabarmaz; yalnızca yakalama fazında görülür.
75
- true,
76
- );
77
-
78
- // JS çalışmadan önce başarısız olmuş görseller olay üretmez; bir kez taranır.
79
- const sweep = () => {
80
- for (const img of document.querySelectorAll(`img[${MARKER}]`)) {
81
- const image = /** @type {HTMLImageElement} */ (img);
82
- if (image.complete && image.naturalWidth === 0) replace(image);
83
- }
84
- };
85
-
86
- if (typeof requestIdleCallback === "function") {
87
- requestIdleCallback(sweep, { timeout: 2000 });
88
- } else {
89
- setTimeout(sweep, 0);
90
- }
91
- }
1
+ /**
2
+ * Yüklenemeyen görseller için tek bir belge dinleyicisi.
3
+ *
4
+ * Bu iş bilinçli olarak **island değil**. Görsel ağırlıklı bir sayfada 80+
5
+ * `<img>` olabiliyor ve her birine ayrı island bağlamak (gözlemci + dinamik
6
+ * import + mount) sırf hata ihtimali için ciddi bir hidrasyon yükü. `error`
7
+ * olayı kabarmaz ama yakalama fazında görülebilir, bu yüzden tek dinleyici
8
+ * hepsini karşılar ve sonradan DOM'a eklenen görseller de kendiliğinden
9
+ * kapsanır.
10
+ *
11
+ * Kullanım: görsele `data-safe-image` ekleyin. Kendi hata görünümünüzü
12
+ * vermek için görseli `data-safe-image-host` taşıyan bir sarmalayıcıya alın
13
+ * ve içine `<template data-safe-image-fallback>` koyun — framework hiçbir
14
+ * stil dayatmaz, yalnızca değiştirme işini yapar.
15
+ */
16
+
17
+ const MARKER = "data-safe-image";
18
+
19
+ /**
20
+ * Şablon verilmemişse yerine geçen minimal blok: görselin ölçülerini korur
21
+ * ki değiştirme sırasında düzen kaymasın (CLS).
22
+ *
23
+ * @param {HTMLImageElement} img
24
+ * @returns {HTMLElement}
25
+ */
26
+ function buildFallback(img) {
27
+ const element = document.createElement("div");
28
+ const label = img.dataset.fallbackLabel || img.getAttribute("alt") || "";
29
+
30
+ element.className = [img.className, img.dataset.fallbackClass ?? ""]
31
+ .filter(Boolean)
32
+ .join(" ");
33
+ element.dataset.safeImageFallback = "";
34
+
35
+ if (img.width && img.height) {
36
+ element.style.width = `${img.width}px`;
37
+ element.style.height = `${img.height}px`;
38
+ }
39
+
40
+ element.setAttribute("role", "img");
41
+ if (label) element.setAttribute("aria-label", label);
42
+
43
+ return element;
44
+ }
45
+
46
+ /**
47
+ * @param {HTMLImageElement} img
48
+ */
49
+ function replace(img) {
50
+ if (!img.isConnected) return;
51
+
52
+ const host = img.closest("[data-safe-image-host]");
53
+ const template = host?.querySelector("template[data-safe-image-fallback]");
54
+
55
+ if (host && template) {
56
+ host.replaceWith(template.content.cloneNode(true));
57
+ return;
58
+ }
59
+
60
+ img.replaceWith(buildFallback(img));
61
+ }
62
+
63
+ /**
64
+ * @returns {void}
65
+ */
66
+ export function startSafeImages() {
67
+ document.addEventListener(
68
+ "error",
69
+ (event) => {
70
+ const target = /** @type {HTMLElement} */ (event.target);
71
+ if (target?.tagName !== "IMG" || !target.hasAttribute(MARKER)) return;
72
+ replace(/** @type {HTMLImageElement} */ (target));
73
+ },
74
+ // `error` kabarmaz; yalnızca yakalama fazında görülür.
75
+ true,
76
+ );
77
+
78
+ // JS çalışmadan önce başarısız olmuş görseller olay üretmez; bir kez taranır.
79
+ const sweep = () => {
80
+ for (const img of document.querySelectorAll(`img[${MARKER}]`)) {
81
+ const image = /** @type {HTMLImageElement} */ (img);
82
+ if (image.complete && image.naturalWidth === 0) replace(image);
83
+ }
84
+ };
85
+
86
+ if (typeof requestIdleCallback === "function") {
87
+ requestIdleCallback(sweep, { timeout: 2000 });
88
+ } else {
89
+ setTimeout(sweep, 0);
90
+ }
91
+ }
@@ -1,36 +1,36 @@
1
- /**
2
- * React Context yerine kullanılan minimal pub/sub store.
3
- * `useSyncExternalStore` köprüsünün yerini alır: doğrudan `subscribe`.
4
- */
5
-
6
- /**
7
- * @param {T} initial
8
- * @returns {{
9
- * get: () => T,
10
- * set: (next: T | ((prev: T) => T)) => void,
11
- * subscribe: (listener: (value: T) => void) => () => void,
12
- * }}
13
- * @template T
14
- */
15
- export function createStore(initial) {
16
- let value = initial;
17
- /** @type {Set<(value: T) => void>} */
18
- const listeners = new Set();
19
-
20
- return {
21
- get: () => value,
22
-
23
- set(next) {
24
- const resolved =
25
- typeof next === "function" ? /** @type {Function} */ (next)(value) : next;
26
- if (resolved === value) return;
27
- value = resolved;
28
- for (const listener of listeners) listener(value);
29
- },
30
-
31
- subscribe(listener) {
32
- listeners.add(listener);
33
- return () => listeners.delete(listener);
34
- },
35
- };
36
- }
1
+ /**
2
+ * React Context yerine kullanılan minimal pub/sub store.
3
+ * `useSyncExternalStore` köprüsünün yerini alır: doğrudan `subscribe`.
4
+ */
5
+
6
+ /**
7
+ * @param {T} initial
8
+ * @returns {{
9
+ * get: () => T,
10
+ * set: (next: T | ((prev: T) => T)) => void,
11
+ * subscribe: (listener: (value: T) => void) => () => void,
12
+ * }}
13
+ * @template T
14
+ */
15
+ export function createStore(initial) {
16
+ let value = initial;
17
+ /** @type {Set<(value: T) => void>} */
18
+ const listeners = new Set();
19
+
20
+ return {
21
+ get: () => value,
22
+
23
+ set(next) {
24
+ const resolved =
25
+ typeof next === "function" ? /** @type {Function} */ (next)(value) : next;
26
+ if (resolved === value) return;
27
+ value = resolved;
28
+ for (const listener of listeners) listener(value);
29
+ },
30
+
31
+ subscribe(listener) {
32
+ listeners.add(listener);
33
+ return () => listeners.delete(listener);
34
+ },
35
+ };
36
+ }
@@ -1,188 +1,188 @@
1
- /**
2
- * Sunucudan gelen bir parçayı DOM'a takar.
3
- *
4
- * Dashboard'un can damarı bu: tablo sayfası, filtre sonucu, yeniden hesaplanan
5
- * kart — hepsi sunucuda render edilip yerine konur, böylece işaretlemenin tek
6
- * kaynağı sunucuda kalır ve istemci ikinci bir şablon taşımaz.
7
- *
8
- * Taşıma katmanından bağımsız. Verinin ne zaman değiştiğini nasıl öğrendiğin
9
- * (SSE, WebSocket, aralıklı sorgu, kullanıcı tıklaması) uygulamanın kararı;
10
- * burada yalnızca "şu parçayı tazele" adımı var.
11
- *
12
- * Sırası önemli olan üç iş yapıyor:
13
- * 1. eski alt ağacın island'larını söker (yoksa dinleyici sızar),
14
- * 2. içeriği değiştirir,
15
- * 3. yeni alt ağacı hidre eder ve odağı kaybolmuşsa geri getirir.
16
- */
17
- import { hydrate, unmount } from "./registry.js";
18
-
19
- /**
20
- * @param {Element | string} target
21
- * @returns {HTMLElement | null}
22
- */
23
- function resolveTarget(target) {
24
- if (typeof target === "string") {
25
- return /** @type {HTMLElement | null} */ (document.querySelector(target));
26
- }
27
- return /** @type {HTMLElement} */ (target);
28
- }
29
-
30
- /**
31
- * Odak takas edilen bölgenin içindeyse kaybolur ve klavye kullanıcısı
32
- * sayfanın başına düşer. Yeni içerikte aynı `id`'li bir element varsa oraya,
33
- * yoksa bölgenin kendisine dönüyoruz.
34
- *
35
- * @param {HTMLElement} element
36
- * @param {string | null} previousId
37
- */
38
- function restoreFocus(element, previousId) {
39
- if (previousId) {
40
- const next = element.querySelector(`#${CSS.escape(previousId)}`);
41
- if (next instanceof HTMLElement) {
42
- next.focus();
43
- return;
44
- }
45
- }
46
-
47
- if (!element.hasAttribute("tabindex")) {
48
- element.setAttribute("tabindex", "-1");
49
- }
50
- element.focus({ preventScroll: true });
51
- }
52
-
53
- /**
54
- * @typedef {object} SwapOptions
55
- * @property {string} [method] Varsayılan `GET`.
56
- * @property {BodyInit | null} [body]
57
- * @property {Record<string, string>} [headers]
58
- * @property {AbortSignal} [signal]
59
- * @property {boolean} [history] `true` → adres çubuğu `url` ile güncellenir.
60
- * @property {"replace" | "append"} [mode] Varsayılan `replace`.
61
- */
62
-
63
- /**
64
- * @param {Element | string} target Takas edilecek bölge.
65
- * @param {string} url Parçayı döndüren uç.
66
- * @param {SwapOptions} [options]
67
- * @returns {Promise<boolean>} Başarılıysa `true`; hata durumunda mevcut
68
- * içerik korunur ve `false` döner.
69
- */
70
- export async function swap(target, url, options = {}) {
71
- const element = resolveTarget(target);
72
- if (!element) {
73
- console.warn(`[swap] target not found: ${String(target)}`);
74
- return false;
75
- }
76
-
77
- const activeId =
78
- document.activeElement instanceof HTMLElement &&
79
- element.contains(document.activeElement)
80
- ? document.activeElement.id || null
81
- : null;
82
-
83
- element.setAttribute("aria-busy", "true");
84
-
85
- try {
86
- const response = await fetch(url, {
87
- method: options.method ?? "GET",
88
- body: options.body ?? null,
89
- headers: {
90
- // Sunucu tam sayfa yerine parça döndürmeyi seçebilsin.
91
- "X-Requested-With": "fragment",
92
- ...(options.headers ?? {}),
93
- },
94
- // Oturum cookie'si olmadan kişiye özel parça gelmez.
95
- credentials: "same-origin",
96
- signal: options.signal,
97
- });
98
-
99
- // Sunucu yönlendirdiyse (ör. oturum düştü, login'e gidiliyor) parça değil
100
- // sayfa beklenir; alt ağaca bir login formu takmak anlamsız olurdu.
101
- if (response.redirected && response.url !== new URL(url, location.href).href) {
102
- location.assign(response.url);
103
- return false;
104
- }
105
-
106
- if (!response.ok) {
107
- element.dispatchEvent(
108
- new CustomEvent("jskelet:swap-error", {
109
- bubbles: true,
110
- detail: { url, status: response.status },
111
- }),
112
- );
113
- return false;
114
- }
115
-
116
- const html = await response.text();
117
-
118
- if (options.mode === "append") {
119
- const holder = document.createElement("div");
120
- holder.innerHTML = html;
121
- const added = [...holder.childNodes];
122
- element.append(...added);
123
- for (const node of added) {
124
- if (node instanceof HTMLElement) hydrate(node);
125
- }
126
- } else {
127
- unmount(element);
128
- element.innerHTML = html;
129
- hydrate(element);
130
- restoreFocus(element, activeId);
131
- }
132
-
133
- if (options.history) {
134
- history.pushState({}, "", url);
135
- }
136
-
137
- element.dispatchEvent(
138
- new CustomEvent("jskelet:swap", { bubbles: true, detail: { url } }),
139
- );
140
-
141
- return true;
142
- } catch (error) {
143
- // İptal edilen istek hata değil: kullanıcı hızlı yazıyor ya da sayfadan
144
- // ayrıldı.
145
- if (!(error instanceof DOMException && error.name === "AbortError")) {
146
- console.error(`[swap] ${url}`, error);
147
- element.dispatchEvent(
148
- new CustomEvent("jskelet:swap-error", {
149
- bubbles: true,
150
- detail: { url, error },
151
- }),
152
- );
153
- }
154
- return false;
155
- } finally {
156
- element.removeAttribute("aria-busy");
157
- }
158
- }
159
-
160
- /**
161
- * `data-swap` taşıyan bağlantı ve butonları otomatik bağlar.
162
- *
163
- * <a href="/_fragment/rows?page=2" data-swap="#rows" data-swap-history>…</a>
164
- *
165
- * Tek bir delege dinleyici: sonradan DOM'a giren bağlantılar da çalışır ve
166
- * her takastan sonra yeniden bağlama gerekmez.
167
- *
168
- * @param {ParentNode} [root]
169
- * @returns {() => void} Dinleyiciyi söken fonksiyon.
170
- */
171
- export function startSwapLinks(root = document) {
172
- /** @param {Event} event */
173
- const onClick = (event) => {
174
- const origin = /** @type {HTMLElement | null} */ (event.target);
175
- const trigger = origin?.closest?.("[data-swap]");
176
- if (!(trigger instanceof HTMLElement)) return;
177
-
178
- const selector = trigger.dataset.swap;
179
- const url = trigger.getAttribute("href") ?? trigger.dataset.swapUrl;
180
- if (!selector || !url) return;
181
-
182
- event.preventDefault();
183
- void swap(selector, url, { history: "swapHistory" in trigger.dataset });
184
- };
185
-
186
- root.addEventListener("click", onClick);
187
- return () => root.removeEventListener("click", onClick);
188
- }
1
+ /**
2
+ * Sunucudan gelen bir parçayı DOM'a takar.
3
+ *
4
+ * Dashboard'un can damarı bu: tablo sayfası, filtre sonucu, yeniden hesaplanan
5
+ * kart — hepsi sunucuda render edilip yerine konur, böylece işaretlemenin tek
6
+ * kaynağı sunucuda kalır ve istemci ikinci bir şablon taşımaz.
7
+ *
8
+ * Taşıma katmanından bağımsız. Verinin ne zaman değiştiğini nasıl öğrendiğin
9
+ * (SSE, WebSocket, aralıklı sorgu, kullanıcı tıklaması) uygulamanın kararı;
10
+ * burada yalnızca "şu parçayı tazele" adımı var.
11
+ *
12
+ * Sırası önemli olan üç iş yapıyor:
13
+ * 1. eski alt ağacın island'larını söker (yoksa dinleyici sızar),
14
+ * 2. içeriği değiştirir,
15
+ * 3. yeni alt ağacı hidre eder ve odağı kaybolmuşsa geri getirir.
16
+ */
17
+ import { hydrate, unmount } from "./registry.js";
18
+
19
+ /**
20
+ * @param {Element | string} target
21
+ * @returns {HTMLElement | null}
22
+ */
23
+ function resolveTarget(target) {
24
+ if (typeof target === "string") {
25
+ return /** @type {HTMLElement | null} */ (document.querySelector(target));
26
+ }
27
+ return /** @type {HTMLElement} */ (target);
28
+ }
29
+
30
+ /**
31
+ * Odak takas edilen bölgenin içindeyse kaybolur ve klavye kullanıcısı
32
+ * sayfanın başına düşer. Yeni içerikte aynı `id`'li bir element varsa oraya,
33
+ * yoksa bölgenin kendisine dönüyoruz.
34
+ *
35
+ * @param {HTMLElement} element
36
+ * @param {string | null} previousId
37
+ */
38
+ function restoreFocus(element, previousId) {
39
+ if (previousId) {
40
+ const next = element.querySelector(`#${CSS.escape(previousId)}`);
41
+ if (next instanceof HTMLElement) {
42
+ next.focus();
43
+ return;
44
+ }
45
+ }
46
+
47
+ if (!element.hasAttribute("tabindex")) {
48
+ element.setAttribute("tabindex", "-1");
49
+ }
50
+ element.focus({ preventScroll: true });
51
+ }
52
+
53
+ /**
54
+ * @typedef {object} SwapOptions
55
+ * @property {string} [method] Varsayılan `GET`.
56
+ * @property {BodyInit | null} [body]
57
+ * @property {Record<string, string>} [headers]
58
+ * @property {AbortSignal} [signal]
59
+ * @property {boolean} [history] `true` → adres çubuğu `url` ile güncellenir.
60
+ * @property {"replace" | "append"} [mode] Varsayılan `replace`.
61
+ */
62
+
63
+ /**
64
+ * @param {Element | string} target Takas edilecek bölge.
65
+ * @param {string} url Parçayı döndüren uç.
66
+ * @param {SwapOptions} [options]
67
+ * @returns {Promise<boolean>} Başarılıysa `true`; hata durumunda mevcut
68
+ * içerik korunur ve `false` döner.
69
+ */
70
+ export async function swap(target, url, options = {}) {
71
+ const element = resolveTarget(target);
72
+ if (!element) {
73
+ console.warn(`[swap] target not found: ${String(target)}`);
74
+ return false;
75
+ }
76
+
77
+ const activeId =
78
+ document.activeElement instanceof HTMLElement &&
79
+ element.contains(document.activeElement)
80
+ ? document.activeElement.id || null
81
+ : null;
82
+
83
+ element.setAttribute("aria-busy", "true");
84
+
85
+ try {
86
+ const response = await fetch(url, {
87
+ method: options.method ?? "GET",
88
+ body: options.body ?? null,
89
+ headers: {
90
+ // Sunucu tam sayfa yerine parça döndürmeyi seçebilsin.
91
+ "X-Requested-With": "fragment",
92
+ ...(options.headers ?? {}),
93
+ },
94
+ // Oturum cookie'si olmadan kişiye özel parça gelmez.
95
+ credentials: "same-origin",
96
+ signal: options.signal,
97
+ });
98
+
99
+ // Sunucu yönlendirdiyse (ör. oturum düştü, login'e gidiliyor) parça değil
100
+ // sayfa beklenir; alt ağaca bir login formu takmak anlamsız olurdu.
101
+ if (response.redirected && response.url !== new URL(url, location.href).href) {
102
+ location.assign(response.url);
103
+ return false;
104
+ }
105
+
106
+ if (!response.ok) {
107
+ element.dispatchEvent(
108
+ new CustomEvent("jskelet:swap-error", {
109
+ bubbles: true,
110
+ detail: { url, status: response.status },
111
+ }),
112
+ );
113
+ return false;
114
+ }
115
+
116
+ const html = await response.text();
117
+
118
+ if (options.mode === "append") {
119
+ const holder = document.createElement("div");
120
+ holder.innerHTML = html;
121
+ const added = [...holder.childNodes];
122
+ element.append(...added);
123
+ for (const node of added) {
124
+ if (node instanceof HTMLElement) hydrate(node);
125
+ }
126
+ } else {
127
+ unmount(element);
128
+ element.innerHTML = html;
129
+ hydrate(element);
130
+ restoreFocus(element, activeId);
131
+ }
132
+
133
+ if (options.history) {
134
+ history.pushState({}, "", url);
135
+ }
136
+
137
+ element.dispatchEvent(
138
+ new CustomEvent("jskelet:swap", { bubbles: true, detail: { url } }),
139
+ );
140
+
141
+ return true;
142
+ } catch (error) {
143
+ // İptal edilen istek hata değil: kullanıcı hızlı yazıyor ya da sayfadan
144
+ // ayrıldı.
145
+ if (!(error instanceof DOMException && error.name === "AbortError")) {
146
+ console.error(`[swap] ${url}`, error);
147
+ element.dispatchEvent(
148
+ new CustomEvent("jskelet:swap-error", {
149
+ bubbles: true,
150
+ detail: { url, error },
151
+ }),
152
+ );
153
+ }
154
+ return false;
155
+ } finally {
156
+ element.removeAttribute("aria-busy");
157
+ }
158
+ }
159
+
160
+ /**
161
+ * `data-swap` taşıyan bağlantı ve butonları otomatik bağlar.
162
+ *
163
+ * <a href="/_fragment/rows?page=2" data-swap="#rows" data-swap-history>…</a>
164
+ *
165
+ * Tek bir delege dinleyici: sonradan DOM'a giren bağlantılar da çalışır ve
166
+ * her takastan sonra yeniden bağlama gerekmez.
167
+ *
168
+ * @param {ParentNode} [root]
169
+ * @returns {() => void} Dinleyiciyi söken fonksiyon.
170
+ */
171
+ export function startSwapLinks(root = document) {
172
+ /** @param {Event} event */
173
+ const onClick = (event) => {
174
+ const origin = /** @type {HTMLElement | null} */ (event.target);
175
+ const trigger = origin?.closest?.("[data-swap]");
176
+ if (!(trigger instanceof HTMLElement)) return;
177
+
178
+ const selector = trigger.dataset.swap;
179
+ const url = trigger.getAttribute("href") ?? trigger.dataset.swapUrl;
180
+ if (!selector || !url) return;
181
+
182
+ event.preventDefault();
183
+ void swap(selector, url, { history: "swapHistory" in trigger.dataset });
184
+ };
185
+
186
+ root.addEventListener("click", onClick);
187
+ return () => root.removeEventListener("click", onClick);
188
+ }
@@ -192,27 +192,36 @@ export const DEFAULT_REDIS = {
192
192
  };
193
193
 
194
194
  /**
195
- * Önbellek yönetim paneli.
195
+ * Framework yönetim paneli (`/_jskelet/admin`).
196
196
  *
197
197
  * `enabled` varsayılan olarak **kapalı** ve ortama bakmaz: panel açıldığında
198
198
  * production'da da çalışır, ama açılması bilinçli bir karar olmalı. Kapalıyken
199
199
  * router hiç mount edilmez — yolun kendisi de yok, yani 404 dönen bir uç bile
200
200
  * ortaya çıkmaz.
201
201
  *
202
- * Şifre her süreç başlangıcında yeniden üretilir (bkz.
203
- * `src/server/cache-panel.js`): panelin ömrü sürecin ömrü kadardır ve bir
204
- * deploy eski erişimi otomatik olarak iptal eder. Bu yüzden config'te şifre
205
- * alanı yok.
202
+ * Şifre her süreç başlangıcında yeniden üretilir (bkz. `src/server/admin/`):
203
+ * panelin ömrü sürecin ömrü kadardır ve bir deploy eski erişimi otomatik
204
+ * olarak iptal eder. Bu yüzden config'te şifre alanı yok.
206
205
  */
207
- export const DEFAULT_CACHE_PANEL = {
206
+ export const DEFAULT_ADMIN = {
208
207
  enabled: false,
209
- basePath: "/_jskelet/cache",
208
+ basePath: "/_jskelet/admin",
209
+ /**
210
+ * Boş dizi = IP kısıtı yok. Exact IP veya CIDR (`10.0.0.0/8`); listede
211
+ * olmayan her istek 404 (login dahil).
212
+ * @type {string[]}
213
+ */
214
+ allowIps: [],
215
+ /** Bilinen crawler / bot UA'ları 404 ile reddedilsin mi. */
216
+ blockBots: true,
210
217
  /** Kaç başarısız denemeden sonra IP yasaklanır. */
211
218
  banAttempts: 3,
212
219
  /** Yasağın süresi. */
213
220
  banHours: 24,
214
221
  /** Oturumun ömrü; süreç yeniden başladığında zaten sıfırlanır. */
215
222
  sessionHours: 12,
223
+ /** Canlı log ring boyutu (HTTP + framework olayları). */
224
+ logSize: 500,
216
225
  };
217
226
 
218
227
  /**