jskelet 0.2.4 → 0.2.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.
- package/AGENTS.md +132 -132
- package/CHANGELOG.md +8 -0
- package/LICENSE +21 -21
- package/bin/jskelet.mjs +103 -103
- package/docs/01-baslangic.md +285 -285
- package/docs/02-mimari.md +287 -287
- package/docs/03-routing.md +480 -480
- package/docs/04-render-ve-sablonlar.md +490 -490
- package/docs/05-islands.md +482 -482
- package/docs/06-cache.md +1209 -1209
- package/docs/08-build.md +366 -366
- package/docs/09-dev-araclari.md +335 -335
- package/docs/10-dagitim.md +329 -329
- package/docs/12-panel-ve-oturum.md +384 -384
- package/docs/README.md +105 -105
- package/docs/en/01-getting-started.md +292 -292
- package/docs/en/02-architecture.md +305 -305
- package/docs/en/03-routing.md +497 -497
- package/docs/en/04-rendering.md +504 -504
- package/docs/en/05-islands.md +492 -492
- package/docs/en/06-caching.md +1239 -1239
- package/docs/en/07-configuration.md +986 -986
- package/docs/en/08-build.md +383 -383
- package/docs/en/09-dev-tools.md +342 -342
- package/docs/en/10-deployment.md +332 -332
- package/docs/en/11-migration.md +359 -359
- package/docs/en/12-dashboards-and-sessions.md +392 -392
- package/docs/en/README.md +112 -112
- package/package.json +102 -102
- package/src/build/ensure-build.mjs +15 -15
- package/src/build/paths.mjs +143 -143
- package/src/build/resolve-peer.mjs +36 -36
- package/src/build/tasks/client.mjs +268 -268
- package/src/build/tasks/css.mjs +124 -124
- package/src/build/tasks/fonts.mjs +146 -146
- package/src/build/tasks/icons.mjs +224 -224
- package/src/build/tasks/images.mjs +244 -244
- package/src/build/tasks/precompress.mjs +78 -78
- package/src/client/cache-panel/i18n.js +670 -670
- package/src/client/cache-panel/login.html +74 -74
- package/src/client/cache-panel/panel.css +756 -756
- package/src/client/cache-panel/panel.html +308 -308
- package/src/client/cache-panel/panel.js +915 -915
- package/src/client/devtools/report.html +185 -185
- package/src/client/devtools/report.js +725 -725
- package/src/client/dom.js +95 -95
- package/src/client/form.js +192 -192
- package/src/client/index.js +35 -35
- package/src/client/registry.js +297 -297
- package/src/client/safe-image.js +91 -91
- package/src/client/store.js +36 -36
- package/src/client/swap.js +188 -188
- package/src/config/pattern.js +107 -107
- package/src/http/control-flow.js +71 -71
- package/src/http/cookies.js +257 -257
- package/src/http/request-cache.js +46 -46
- package/src/http/request-context.js +162 -162
- package/src/index.js +83 -83
- package/src/init.mjs +221 -221
- package/src/runtime/alias-hooks.mjs +119 -119
- package/src/runtime/register.mjs +4 -4
- package/src/server/assets.js +147 -147
- package/src/server/cache-deps.js +42 -42
- package/src/server/cache-panel.js +759 -759
- package/src/server/cloudflare.js +607 -595
- package/src/server/create-app.js +291 -291
- package/src/server/data-cache.js +462 -462
- package/src/server/dev/report.js +369 -369
- package/src/server/dev/socket.js +170 -170
- package/src/server/dev/version-check.mjs +139 -139
- package/src/server/html-cache.js +817 -817
- package/src/server/metadata.js +102 -102
- package/src/server/middleware/compression.js +205 -205
- package/src/server/middleware/csrf.js +134 -134
- package/src/server/middleware/dev-gate.js +62 -62
- package/src/server/middleware/headers.js +37 -37
- package/src/server/middleware/redirects.js +32 -32
- package/src/server/middleware/static-precompressed.js +100 -100
- package/src/server/middleware/upstream-proxy.js +141 -141
- package/src/server/prewarm.js +601 -601
- package/src/server/redis.js +569 -569
- package/src/server/router.js +128 -128
- package/src/server/status-page.js +164 -164
- package/src/server/upstream-limiter.js +376 -376
- package/src/server/upstream-tracking.js +166 -166
- package/src/start.mjs +7 -7
- package/src/templates/layout.ejs +44 -44
- package/src/version.mjs +31 -31
- package/src/views/components/loader.js +85 -85
- package/src/views/helpers/html.js +102 -102
- package/src/views/helpers/tags.js +245 -245
package/src/client/dom.js
CHANGED
|
@@ -1,95 +1,95 @@
|
|
|
1
|
-
/** Island'ların paylaştığı küçük DOM yardımcıları. */
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* @param {ParentNode} root
|
|
5
|
-
* @param {string} selector
|
|
6
|
-
* @returns {HTMLElement | null}
|
|
7
|
-
*/
|
|
8
|
-
export function qs(root, selector) {
|
|
9
|
-
return /** @type {HTMLElement | null} */ (root.querySelector(selector));
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
/**
|
|
13
|
-
* @param {ParentNode} root
|
|
14
|
-
* @param {string} selector
|
|
15
|
-
* @returns {HTMLElement[]}
|
|
16
|
-
*/
|
|
17
|
-
export function qsa(root, selector) {
|
|
18
|
-
return /** @type {HTMLElement[]} */ ([...root.querySelectorAll(selector)]);
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
/**
|
|
22
|
-
* Otomatik temizlenebilir listener.
|
|
23
|
-
* @param {EventTarget} target
|
|
24
|
-
* @param {string} type
|
|
25
|
-
* @param {EventListenerOrEventListenerObject} handler
|
|
26
|
-
* @param {AddEventListenerOptions | boolean} [options]
|
|
27
|
-
* @returns {() => void}
|
|
28
|
-
*/
|
|
29
|
-
export function on(target, type, handler, options) {
|
|
30
|
-
target.addEventListener(type, handler, options);
|
|
31
|
-
return () => target.removeEventListener(type, handler, options);
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
/**
|
|
35
|
-
* `data-*` üzerinden delege edilmiş click.
|
|
36
|
-
* @param {HTMLElement} root
|
|
37
|
-
* @param {string} selector
|
|
38
|
-
* @param {(event: MouseEvent, target: HTMLElement) => void} handler
|
|
39
|
-
* @returns {() => void}
|
|
40
|
-
*/
|
|
41
|
-
export function onClick(root, selector, handler) {
|
|
42
|
-
return on(root, "click", (event) => {
|
|
43
|
-
const target = /** @type {HTMLElement} */ (event.target)?.closest(selector);
|
|
44
|
-
if (!target || !root.contains(target)) return;
|
|
45
|
-
handler(/** @type {MouseEvent} */ (event), /** @type {HTMLElement} */ (target));
|
|
46
|
-
});
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
/**
|
|
50
|
-
* @param {number} ms
|
|
51
|
-
* @param {(...args: any[]) => void} fn
|
|
52
|
-
* @returns {(...args: any[]) => void}
|
|
53
|
-
*/
|
|
54
|
-
export function debounce(ms, fn) {
|
|
55
|
-
let timer = null;
|
|
56
|
-
return (...args) => {
|
|
57
|
-
clearTimeout(timer);
|
|
58
|
-
timer = setTimeout(() => fn(...args), ms);
|
|
59
|
-
};
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
/**
|
|
63
|
-
* requestAnimationFrame ile birleştirilmiş çağrı.
|
|
64
|
-
* @param {() => void} fn
|
|
65
|
-
* @returns {() => void}
|
|
66
|
-
*/
|
|
67
|
-
export function raf(fn) {
|
|
68
|
-
let frame = 0;
|
|
69
|
-
return () => {
|
|
70
|
-
if (frame) return;
|
|
71
|
-
frame = requestAnimationFrame(() => {
|
|
72
|
-
frame = 0;
|
|
73
|
-
fn();
|
|
74
|
-
});
|
|
75
|
-
};
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
/**
|
|
79
|
-
* @param {HTMLElement} element
|
|
80
|
-
* @param {string} name
|
|
81
|
-
* @param {boolean} active
|
|
82
|
-
*/
|
|
83
|
-
export function toggleClass(element, name, active) {
|
|
84
|
-
element.classList.toggle(name, active);
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
/**
|
|
88
|
-
* Overlay portal hedefi: layout'ta `<div id="jskelet-overlays">` varsa
|
|
89
|
-
* modal/drawer içeriği oraya taşınır, yoksa `body`ye. Portal, `overflow`
|
|
90
|
-
* ya da `transform` taşıyan bir ata elementin `position: fixed` overlay'i
|
|
91
|
-
* kırpmasını engeller.
|
|
92
|
-
*/
|
|
93
|
-
export function getOverlayRoot() {
|
|
94
|
-
return document.getElementById("jskelet-overlays") ?? document.body;
|
|
95
|
-
}
|
|
1
|
+
/** Island'ların paylaştığı küçük DOM yardımcıları. */
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @param {ParentNode} root
|
|
5
|
+
* @param {string} selector
|
|
6
|
+
* @returns {HTMLElement | null}
|
|
7
|
+
*/
|
|
8
|
+
export function qs(root, selector) {
|
|
9
|
+
return /** @type {HTMLElement | null} */ (root.querySelector(selector));
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* @param {ParentNode} root
|
|
14
|
+
* @param {string} selector
|
|
15
|
+
* @returns {HTMLElement[]}
|
|
16
|
+
*/
|
|
17
|
+
export function qsa(root, selector) {
|
|
18
|
+
return /** @type {HTMLElement[]} */ ([...root.querySelectorAll(selector)]);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Otomatik temizlenebilir listener.
|
|
23
|
+
* @param {EventTarget} target
|
|
24
|
+
* @param {string} type
|
|
25
|
+
* @param {EventListenerOrEventListenerObject} handler
|
|
26
|
+
* @param {AddEventListenerOptions | boolean} [options]
|
|
27
|
+
* @returns {() => void}
|
|
28
|
+
*/
|
|
29
|
+
export function on(target, type, handler, options) {
|
|
30
|
+
target.addEventListener(type, handler, options);
|
|
31
|
+
return () => target.removeEventListener(type, handler, options);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* `data-*` üzerinden delege edilmiş click.
|
|
36
|
+
* @param {HTMLElement} root
|
|
37
|
+
* @param {string} selector
|
|
38
|
+
* @param {(event: MouseEvent, target: HTMLElement) => void} handler
|
|
39
|
+
* @returns {() => void}
|
|
40
|
+
*/
|
|
41
|
+
export function onClick(root, selector, handler) {
|
|
42
|
+
return on(root, "click", (event) => {
|
|
43
|
+
const target = /** @type {HTMLElement} */ (event.target)?.closest(selector);
|
|
44
|
+
if (!target || !root.contains(target)) return;
|
|
45
|
+
handler(/** @type {MouseEvent} */ (event), /** @type {HTMLElement} */ (target));
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* @param {number} ms
|
|
51
|
+
* @param {(...args: any[]) => void} fn
|
|
52
|
+
* @returns {(...args: any[]) => void}
|
|
53
|
+
*/
|
|
54
|
+
export function debounce(ms, fn) {
|
|
55
|
+
let timer = null;
|
|
56
|
+
return (...args) => {
|
|
57
|
+
clearTimeout(timer);
|
|
58
|
+
timer = setTimeout(() => fn(...args), ms);
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* requestAnimationFrame ile birleştirilmiş çağrı.
|
|
64
|
+
* @param {() => void} fn
|
|
65
|
+
* @returns {() => void}
|
|
66
|
+
*/
|
|
67
|
+
export function raf(fn) {
|
|
68
|
+
let frame = 0;
|
|
69
|
+
return () => {
|
|
70
|
+
if (frame) return;
|
|
71
|
+
frame = requestAnimationFrame(() => {
|
|
72
|
+
frame = 0;
|
|
73
|
+
fn();
|
|
74
|
+
});
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* @param {HTMLElement} element
|
|
80
|
+
* @param {string} name
|
|
81
|
+
* @param {boolean} active
|
|
82
|
+
*/
|
|
83
|
+
export function toggleClass(element, name, active) {
|
|
84
|
+
element.classList.toggle(name, active);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Overlay portal hedefi: layout'ta `<div id="jskelet-overlays">` varsa
|
|
89
|
+
* modal/drawer içeriği oraya taşınır, yoksa `body`ye. Portal, `overflow`
|
|
90
|
+
* ya da `transform` taşıyan bir ata elementin `position: fixed` overlay'i
|
|
91
|
+
* kırpmasını engeller.
|
|
92
|
+
*/
|
|
93
|
+
export function getOverlayRoot() {
|
|
94
|
+
return document.getElementById("jskelet-overlays") ?? document.body;
|
|
95
|
+
}
|
package/src/client/form.js
CHANGED
|
@@ -1,192 +1,192 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Form gönderimini sayfa yenilemeden yürütür — ama yenilemeyi de bozmadan.
|
|
3
|
-
*
|
|
4
|
-
* Sözleşme bilinçli olarak "progressive enhancement": form normal bir
|
|
5
|
-
* `<form method="post" action="…">`. JS yüklenmemişse, hata verdiyse ya da
|
|
6
|
-
* kullanıcı kapattıysa tarayıcı formu kendisi gönderir ve sunucu yönlendirme
|
|
7
|
-
* ile cevap verir. Bu dosya yalnızca aradaki tam sayfa turunu kaldırıyor.
|
|
8
|
-
*
|
|
9
|
-
* Sunucudan üç cevaptan biri beklenir:
|
|
10
|
-
* - yönlendirme → `location.assign` ile izlenir (başarılı mutasyon)
|
|
11
|
-
* - 4xx + parça → formun yerine takılır (alan bazlı doğrulama hataları)
|
|
12
|
-
* - 2xx + parça → hedef bölgeye takılır (güncellenen liste, kart)
|
|
13
|
-
*
|
|
14
|
-
* CSRF token'ı `csrfField()` tarafından basılan gizli alanda; `FormData`
|
|
15
|
-
* onu kendiliğinden taşıdığı için burada ek bir iş yok.
|
|
16
|
-
*/
|
|
17
|
-
import { hydrate, unmount } from "./registry.js";
|
|
18
|
-
|
|
19
|
-
/**
|
|
20
|
-
* @param {HTMLFormElement} form
|
|
21
|
-
* @param {boolean} busy
|
|
22
|
-
*/
|
|
23
|
-
function setBusy(form, busy) {
|
|
24
|
-
form.toggleAttribute("data-submitting", busy);
|
|
25
|
-
|
|
26
|
-
for (const element of form.elements) {
|
|
27
|
-
if (
|
|
28
|
-
element instanceof HTMLButtonElement ||
|
|
29
|
-
(element instanceof HTMLInputElement && element.type === "submit")
|
|
30
|
-
) {
|
|
31
|
-
element.disabled = busy;
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
/**
|
|
37
|
-
* @param {HTMLElement} element
|
|
38
|
-
* @param {string} html
|
|
39
|
-
*/
|
|
40
|
-
function replaceContent(element, html) {
|
|
41
|
-
unmount(element);
|
|
42
|
-
element.innerHTML = html;
|
|
43
|
-
hydrate(element);
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
/**
|
|
47
|
-
* @param {HTMLFormElement} form
|
|
48
|
-
* @param {string} html
|
|
49
|
-
*/
|
|
50
|
-
function replaceForm(form, html) {
|
|
51
|
-
const holder = document.createElement("div");
|
|
52
|
-
holder.innerHTML = html;
|
|
53
|
-
|
|
54
|
-
const replacement = holder.firstElementChild;
|
|
55
|
-
if (!replacement) return;
|
|
56
|
-
|
|
57
|
-
unmount(form);
|
|
58
|
-
form.replaceWith(replacement);
|
|
59
|
-
hydrate(replacement);
|
|
60
|
-
|
|
61
|
-
// Doğrulama hatasında odak ilk hatalı alana gitmeli; klavye kullanıcısı
|
|
62
|
-
// aksi hâlde hatanın nerede olduğunu bulmak için baştan geziyor.
|
|
63
|
-
const invalid = replacement.querySelector("[aria-invalid='true'], [data-field-error]");
|
|
64
|
-
if (invalid instanceof HTMLElement) invalid.focus();
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
/**
|
|
68
|
-
* @param {HTMLFormElement} form
|
|
69
|
-
* @param {HTMLElement | null} submitter
|
|
70
|
-
*/
|
|
71
|
-
async function submitForm(form, submitter) {
|
|
72
|
-
setBusy(form, true);
|
|
73
|
-
|
|
74
|
-
const method = (form.method || "post").toUpperCase();
|
|
75
|
-
const action = form.action || location.href;
|
|
76
|
-
const data = new FormData(form);
|
|
77
|
-
|
|
78
|
-
// Gönderen butonun `name`/`value`'su `FormData`'ya girmez; hangi eylemin
|
|
79
|
-
// tetiklendiğini ayırt eden formlar (kaydet / sil) buna güveniyor.
|
|
80
|
-
if (
|
|
81
|
-
(submitter instanceof HTMLButtonElement ||
|
|
82
|
-
submitter instanceof HTMLInputElement) &&
|
|
83
|
-
submitter.name
|
|
84
|
-
) {
|
|
85
|
-
data.append(submitter.name, submitter.value);
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
try {
|
|
89
|
-
const isGet = method === "GET";
|
|
90
|
-
const query = new URLSearchParams();
|
|
91
|
-
if (isGet) {
|
|
92
|
-
for (const [key, value] of data.entries()) {
|
|
93
|
-
if (typeof value === "string") query.append(key, value);
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
const response = await fetch(isGet ? `${action.split("?")[0]}?${query}` : action, {
|
|
98
|
-
method,
|
|
99
|
-
body: isGet ? null : data,
|
|
100
|
-
headers: { "X-Requested-With": "fragment" },
|
|
101
|
-
credentials: "same-origin",
|
|
102
|
-
});
|
|
103
|
-
|
|
104
|
-
if (response.redirected) {
|
|
105
|
-
location.assign(response.url);
|
|
106
|
-
return;
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
const html = await response.text();
|
|
110
|
-
const target = form.dataset.target
|
|
111
|
-
? document.querySelector(form.dataset.target)
|
|
112
|
-
: null;
|
|
113
|
-
|
|
114
|
-
if (!response.ok || !target) {
|
|
115
|
-
replaceForm(form, html);
|
|
116
|
-
} else {
|
|
117
|
-
replaceContent(/** @type {HTMLElement} */ (target), html);
|
|
118
|
-
form.reset();
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
form.dispatchEvent(
|
|
122
|
-
new CustomEvent("jskelet:submitted", {
|
|
123
|
-
bubbles: true,
|
|
124
|
-
detail: { ok: response.ok, status: response.status },
|
|
125
|
-
}),
|
|
126
|
-
);
|
|
127
|
-
} catch (error) {
|
|
128
|
-
console.error("[form] submit failed", error);
|
|
129
|
-
// Ağ hatasında formu kilitli bırakmıyoruz: kullanıcı tekrar denesin.
|
|
130
|
-
form.dispatchEvent(
|
|
131
|
-
new CustomEvent("jskelet:submit-error", { bubbles: true, detail: { error } }),
|
|
132
|
-
);
|
|
133
|
-
} finally {
|
|
134
|
-
setBusy(form, false);
|
|
135
|
-
}
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
/**
|
|
139
|
-
* @param {SubmitEvent} event
|
|
140
|
-
* @param {HTMLFormElement} form
|
|
141
|
-
*/
|
|
142
|
-
function onSubmit(event, form) {
|
|
143
|
-
// Tarayıcının kendi doğrulaması geçmediyse hiç ağa çıkmıyoruz; hata
|
|
144
|
-
// balonlarını da tarayıcı gösterir.
|
|
145
|
-
if (!form.checkValidity()) return;
|
|
146
|
-
if (form.hasAttribute("data-submitting")) {
|
|
147
|
-
event.preventDefault();
|
|
148
|
-
return;
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
event.preventDefault();
|
|
152
|
-
void submitForm(form, /** @type {HTMLElement | null} */ (event.submitter));
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
/**
|
|
156
|
-
* Tek bir formu bağlar.
|
|
157
|
-
*
|
|
158
|
-
* `data-target` verilirse başarılı yanıtın gövdesi o bölgeye takılır; yoksa
|
|
159
|
-
* yanıt formun kendisini değiştirir (doğrulama hatalarının geldiği yol).
|
|
160
|
-
*
|
|
161
|
-
* @param {HTMLFormElement} form
|
|
162
|
-
* @returns {() => void} Dinleyiciyi söken fonksiyon.
|
|
163
|
-
*/
|
|
164
|
-
export function enhanceForm(form) {
|
|
165
|
-
/** @param {Event} event */
|
|
166
|
-
const listener = (event) => onSubmit(/** @type {SubmitEvent} */ (event), form);
|
|
167
|
-
|
|
168
|
-
form.addEventListener("submit", listener);
|
|
169
|
-
return () => form.removeEventListener("submit", listener);
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
/**
|
|
173
|
-
* `data-enhance` taşıyan formları tek bir delege dinleyiciyle bağlar.
|
|
174
|
-
* `submit` olayı köpürdüğü için sonradan DOM'a giren (fragment ile gelen)
|
|
175
|
-
* formlar da kapsanır ve her takastan sonra yeniden bağlama gerekmez.
|
|
176
|
-
*
|
|
177
|
-
* @param {ParentNode} [root]
|
|
178
|
-
* @returns {() => void}
|
|
179
|
-
*/
|
|
180
|
-
export function startForms(root = document) {
|
|
181
|
-
/** @param {Event} event */
|
|
182
|
-
const listener = (event) => {
|
|
183
|
-
const form = event.target;
|
|
184
|
-
if (!(form instanceof HTMLFormElement)) return;
|
|
185
|
-
if (!("enhance" in form.dataset)) return;
|
|
186
|
-
|
|
187
|
-
onSubmit(/** @type {SubmitEvent} */ (event), form);
|
|
188
|
-
};
|
|
189
|
-
|
|
190
|
-
root.addEventListener("submit", listener);
|
|
191
|
-
return () => root.removeEventListener("submit", listener);
|
|
192
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Form gönderimini sayfa yenilemeden yürütür — ama yenilemeyi de bozmadan.
|
|
3
|
+
*
|
|
4
|
+
* Sözleşme bilinçli olarak "progressive enhancement": form normal bir
|
|
5
|
+
* `<form method="post" action="…">`. JS yüklenmemişse, hata verdiyse ya da
|
|
6
|
+
* kullanıcı kapattıysa tarayıcı formu kendisi gönderir ve sunucu yönlendirme
|
|
7
|
+
* ile cevap verir. Bu dosya yalnızca aradaki tam sayfa turunu kaldırıyor.
|
|
8
|
+
*
|
|
9
|
+
* Sunucudan üç cevaptan biri beklenir:
|
|
10
|
+
* - yönlendirme → `location.assign` ile izlenir (başarılı mutasyon)
|
|
11
|
+
* - 4xx + parça → formun yerine takılır (alan bazlı doğrulama hataları)
|
|
12
|
+
* - 2xx + parça → hedef bölgeye takılır (güncellenen liste, kart)
|
|
13
|
+
*
|
|
14
|
+
* CSRF token'ı `csrfField()` tarafından basılan gizli alanda; `FormData`
|
|
15
|
+
* onu kendiliğinden taşıdığı için burada ek bir iş yok.
|
|
16
|
+
*/
|
|
17
|
+
import { hydrate, unmount } from "./registry.js";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @param {HTMLFormElement} form
|
|
21
|
+
* @param {boolean} busy
|
|
22
|
+
*/
|
|
23
|
+
function setBusy(form, busy) {
|
|
24
|
+
form.toggleAttribute("data-submitting", busy);
|
|
25
|
+
|
|
26
|
+
for (const element of form.elements) {
|
|
27
|
+
if (
|
|
28
|
+
element instanceof HTMLButtonElement ||
|
|
29
|
+
(element instanceof HTMLInputElement && element.type === "submit")
|
|
30
|
+
) {
|
|
31
|
+
element.disabled = busy;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* @param {HTMLElement} element
|
|
38
|
+
* @param {string} html
|
|
39
|
+
*/
|
|
40
|
+
function replaceContent(element, html) {
|
|
41
|
+
unmount(element);
|
|
42
|
+
element.innerHTML = html;
|
|
43
|
+
hydrate(element);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* @param {HTMLFormElement} form
|
|
48
|
+
* @param {string} html
|
|
49
|
+
*/
|
|
50
|
+
function replaceForm(form, html) {
|
|
51
|
+
const holder = document.createElement("div");
|
|
52
|
+
holder.innerHTML = html;
|
|
53
|
+
|
|
54
|
+
const replacement = holder.firstElementChild;
|
|
55
|
+
if (!replacement) return;
|
|
56
|
+
|
|
57
|
+
unmount(form);
|
|
58
|
+
form.replaceWith(replacement);
|
|
59
|
+
hydrate(replacement);
|
|
60
|
+
|
|
61
|
+
// Doğrulama hatasında odak ilk hatalı alana gitmeli; klavye kullanıcısı
|
|
62
|
+
// aksi hâlde hatanın nerede olduğunu bulmak için baştan geziyor.
|
|
63
|
+
const invalid = replacement.querySelector("[aria-invalid='true'], [data-field-error]");
|
|
64
|
+
if (invalid instanceof HTMLElement) invalid.focus();
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* @param {HTMLFormElement} form
|
|
69
|
+
* @param {HTMLElement | null} submitter
|
|
70
|
+
*/
|
|
71
|
+
async function submitForm(form, submitter) {
|
|
72
|
+
setBusy(form, true);
|
|
73
|
+
|
|
74
|
+
const method = (form.method || "post").toUpperCase();
|
|
75
|
+
const action = form.action || location.href;
|
|
76
|
+
const data = new FormData(form);
|
|
77
|
+
|
|
78
|
+
// Gönderen butonun `name`/`value`'su `FormData`'ya girmez; hangi eylemin
|
|
79
|
+
// tetiklendiğini ayırt eden formlar (kaydet / sil) buna güveniyor.
|
|
80
|
+
if (
|
|
81
|
+
(submitter instanceof HTMLButtonElement ||
|
|
82
|
+
submitter instanceof HTMLInputElement) &&
|
|
83
|
+
submitter.name
|
|
84
|
+
) {
|
|
85
|
+
data.append(submitter.name, submitter.value);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
try {
|
|
89
|
+
const isGet = method === "GET";
|
|
90
|
+
const query = new URLSearchParams();
|
|
91
|
+
if (isGet) {
|
|
92
|
+
for (const [key, value] of data.entries()) {
|
|
93
|
+
if (typeof value === "string") query.append(key, value);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const response = await fetch(isGet ? `${action.split("?")[0]}?${query}` : action, {
|
|
98
|
+
method,
|
|
99
|
+
body: isGet ? null : data,
|
|
100
|
+
headers: { "X-Requested-With": "fragment" },
|
|
101
|
+
credentials: "same-origin",
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
if (response.redirected) {
|
|
105
|
+
location.assign(response.url);
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const html = await response.text();
|
|
110
|
+
const target = form.dataset.target
|
|
111
|
+
? document.querySelector(form.dataset.target)
|
|
112
|
+
: null;
|
|
113
|
+
|
|
114
|
+
if (!response.ok || !target) {
|
|
115
|
+
replaceForm(form, html);
|
|
116
|
+
} else {
|
|
117
|
+
replaceContent(/** @type {HTMLElement} */ (target), html);
|
|
118
|
+
form.reset();
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
form.dispatchEvent(
|
|
122
|
+
new CustomEvent("jskelet:submitted", {
|
|
123
|
+
bubbles: true,
|
|
124
|
+
detail: { ok: response.ok, status: response.status },
|
|
125
|
+
}),
|
|
126
|
+
);
|
|
127
|
+
} catch (error) {
|
|
128
|
+
console.error("[form] submit failed", error);
|
|
129
|
+
// Ağ hatasında formu kilitli bırakmıyoruz: kullanıcı tekrar denesin.
|
|
130
|
+
form.dispatchEvent(
|
|
131
|
+
new CustomEvent("jskelet:submit-error", { bubbles: true, detail: { error } }),
|
|
132
|
+
);
|
|
133
|
+
} finally {
|
|
134
|
+
setBusy(form, false);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* @param {SubmitEvent} event
|
|
140
|
+
* @param {HTMLFormElement} form
|
|
141
|
+
*/
|
|
142
|
+
function onSubmit(event, form) {
|
|
143
|
+
// Tarayıcının kendi doğrulaması geçmediyse hiç ağa çıkmıyoruz; hata
|
|
144
|
+
// balonlarını da tarayıcı gösterir.
|
|
145
|
+
if (!form.checkValidity()) return;
|
|
146
|
+
if (form.hasAttribute("data-submitting")) {
|
|
147
|
+
event.preventDefault();
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
event.preventDefault();
|
|
152
|
+
void submitForm(form, /** @type {HTMLElement | null} */ (event.submitter));
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Tek bir formu bağlar.
|
|
157
|
+
*
|
|
158
|
+
* `data-target` verilirse başarılı yanıtın gövdesi o bölgeye takılır; yoksa
|
|
159
|
+
* yanıt formun kendisini değiştirir (doğrulama hatalarının geldiği yol).
|
|
160
|
+
*
|
|
161
|
+
* @param {HTMLFormElement} form
|
|
162
|
+
* @returns {() => void} Dinleyiciyi söken fonksiyon.
|
|
163
|
+
*/
|
|
164
|
+
export function enhanceForm(form) {
|
|
165
|
+
/** @param {Event} event */
|
|
166
|
+
const listener = (event) => onSubmit(/** @type {SubmitEvent} */ (event), form);
|
|
167
|
+
|
|
168
|
+
form.addEventListener("submit", listener);
|
|
169
|
+
return () => form.removeEventListener("submit", listener);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* `data-enhance` taşıyan formları tek bir delege dinleyiciyle bağlar.
|
|
174
|
+
* `submit` olayı köpürdüğü için sonradan DOM'a giren (fragment ile gelen)
|
|
175
|
+
* formlar da kapsanır ve her takastan sonra yeniden bağlama gerekmez.
|
|
176
|
+
*
|
|
177
|
+
* @param {ParentNode} [root]
|
|
178
|
+
* @returns {() => void}
|
|
179
|
+
*/
|
|
180
|
+
export function startForms(root = document) {
|
|
181
|
+
/** @param {Event} event */
|
|
182
|
+
const listener = (event) => {
|
|
183
|
+
const form = event.target;
|
|
184
|
+
if (!(form instanceof HTMLFormElement)) return;
|
|
185
|
+
if (!("enhance" in form.dataset)) return;
|
|
186
|
+
|
|
187
|
+
onSubmit(/** @type {SubmitEvent} */ (event), form);
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
root.addEventListener("submit", listener);
|
|
191
|
+
return () => root.removeEventListener("submit", listener);
|
|
192
|
+
}
|
package/src/client/index.js
CHANGED
|
@@ -1,35 +1,35 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* JSkelet island runtime'ının tarayıcı tarafı API'si.
|
|
3
|
-
*
|
|
4
|
-
* `client/entries/main.js` içinde:
|
|
5
|
-
*
|
|
6
|
-
* import { registerAll, start } from "jskelet/client";
|
|
7
|
-
*
|
|
8
|
-
* registerAll({
|
|
9
|
-
* counter: () => import("../islands/counter.js"),
|
|
10
|
-
* });
|
|
11
|
-
*
|
|
12
|
-
* start();
|
|
13
|
-
*/
|
|
14
|
-
export {
|
|
15
|
-
register,
|
|
16
|
-
registerAll,
|
|
17
|
-
hydrate,
|
|
18
|
-
unmount,
|
|
19
|
-
observeDocument,
|
|
20
|
-
start,
|
|
21
|
-
} from "./registry.js";
|
|
22
|
-
export { swap, startSwapLinks } from "./swap.js";
|
|
23
|
-
export { enhanceForm, startForms } from "./form.js";
|
|
24
|
-
export { createStore } from "./store.js";
|
|
25
|
-
export {
|
|
26
|
-
debounce,
|
|
27
|
-
on,
|
|
28
|
-
onClick,
|
|
29
|
-
qs,
|
|
30
|
-
qsa,
|
|
31
|
-
raf,
|
|
32
|
-
toggleClass,
|
|
33
|
-
getOverlayRoot,
|
|
34
|
-
} from "./dom.js";
|
|
35
|
-
export { startSafeImages } from "./safe-image.js";
|
|
1
|
+
/**
|
|
2
|
+
* JSkelet island runtime'ının tarayıcı tarafı API'si.
|
|
3
|
+
*
|
|
4
|
+
* `client/entries/main.js` içinde:
|
|
5
|
+
*
|
|
6
|
+
* import { registerAll, start } from "jskelet/client";
|
|
7
|
+
*
|
|
8
|
+
* registerAll({
|
|
9
|
+
* counter: () => import("../islands/counter.js"),
|
|
10
|
+
* });
|
|
11
|
+
*
|
|
12
|
+
* start();
|
|
13
|
+
*/
|
|
14
|
+
export {
|
|
15
|
+
register,
|
|
16
|
+
registerAll,
|
|
17
|
+
hydrate,
|
|
18
|
+
unmount,
|
|
19
|
+
observeDocument,
|
|
20
|
+
start,
|
|
21
|
+
} from "./registry.js";
|
|
22
|
+
export { swap, startSwapLinks } from "./swap.js";
|
|
23
|
+
export { enhanceForm, startForms } from "./form.js";
|
|
24
|
+
export { createStore } from "./store.js";
|
|
25
|
+
export {
|
|
26
|
+
debounce,
|
|
27
|
+
on,
|
|
28
|
+
onClick,
|
|
29
|
+
qs,
|
|
30
|
+
qsa,
|
|
31
|
+
raf,
|
|
32
|
+
toggleClass,
|
|
33
|
+
getOverlayRoot,
|
|
34
|
+
} from "./dom.js";
|
|
35
|
+
export { startSafeImages } from "./safe-image.js";
|