asjs-express 1.1.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.
@@ -0,0 +1,989 @@
1
+ (function createASJSRouter() {
2
+ function nextFrame() {
3
+ return new Promise((resolve) => {
4
+ window.requestAnimationFrame(() => {
5
+ window.requestAnimationFrame(resolve);
6
+ });
7
+ });
8
+ }
9
+
10
+ function wait(duration) {
11
+ return new Promise((resolve) => {
12
+ window.setTimeout(resolve, duration);
13
+ });
14
+ }
15
+
16
+ function parseBooleanAttribute(value, fallback) {
17
+ if (value === undefined || value === null || value === '') {
18
+ return fallback;
19
+ }
20
+
21
+ return !['false', '0', 'off', 'no'].includes(String(value).toLowerCase());
22
+ }
23
+
24
+ function parseNumberAttribute(value, fallback) {
25
+ const normalized = Number.parseInt(value, 10);
26
+ return Number.isFinite(normalized) && normalized >= 0 ? normalized : fallback;
27
+ }
28
+
29
+ function normalizePathname(value) {
30
+ const normalized = String(value || '/').replace(/\/+$/, '');
31
+ return normalized || '/';
32
+ }
33
+
34
+ function isActivePath(targetPath, currentPath, mode) {
35
+ const normalizedTarget = normalizePathname(targetPath);
36
+ const normalizedCurrent = normalizePathname(currentPath);
37
+
38
+ if (mode === 'prefix') {
39
+ if (normalizedTarget === '/') {
40
+ return normalizedCurrent === '/';
41
+ }
42
+
43
+ return normalizedCurrent === normalizedTarget || normalizedCurrent.startsWith(`${normalizedTarget}/`);
44
+ }
45
+
46
+ return normalizedCurrent === normalizedTarget;
47
+ }
48
+
49
+ function normalizeUrl(url) {
50
+ const normalized = new URL(url, window.location.href);
51
+ normalized.hash = '';
52
+ return normalized.toString();
53
+ }
54
+
55
+ function copyAttributes(currentNode, nextNode) {
56
+ Array.from(currentNode.attributes).forEach((attribute) => {
57
+ if (!nextNode.hasAttribute(attribute.name)) {
58
+ currentNode.removeAttribute(attribute.name);
59
+ }
60
+ });
61
+
62
+ Array.from(nextNode.attributes).forEach((attribute) => {
63
+ currentNode.setAttribute(attribute.name, attribute.value);
64
+ });
65
+ }
66
+
67
+ function reviveScripts(scope) {
68
+ scope.querySelectorAll('script').forEach((oldScript) => {
69
+ const newScript = document.createElement('script');
70
+
71
+ Array.from(oldScript.attributes).forEach((attribute) => {
72
+ newScript.setAttribute(attribute.name, attribute.value);
73
+ });
74
+
75
+ newScript.textContent = oldScript.textContent;
76
+ oldScript.replaceWith(newScript);
77
+ });
78
+ }
79
+
80
+ function cloneManagedHeadNode(source) {
81
+ const node = document.createElement(source.tagName.toLowerCase());
82
+
83
+ Array.from(source.attributes).forEach((attribute) => {
84
+ node.setAttribute(attribute.name, attribute.value);
85
+ });
86
+
87
+ if (source.tagName === 'SCRIPT' || source.tagName === 'STYLE') {
88
+ node.textContent = source.textContent;
89
+ }
90
+
91
+ return node;
92
+ }
93
+
94
+ function syncManagedHead(parsedDocument) {
95
+ if (!parsedDocument.head) {
96
+ return;
97
+ }
98
+
99
+ const selector = '[data-asjs-head-key]';
100
+ const currentNodes = Array.from(document.head.querySelectorAll(selector));
101
+ const nextNodes = Array.from(parsedDocument.head.querySelectorAll(selector));
102
+ const currentMap = new Map();
103
+ const nextKeys = new Set();
104
+
105
+ currentNodes.forEach((node) => {
106
+ const key = node.getAttribute('data-asjs-head-key');
107
+
108
+ if (key) {
109
+ currentMap.set(key, node);
110
+ }
111
+ });
112
+
113
+ nextNodes.forEach((node) => {
114
+ const key = node.getAttribute('data-asjs-head-key');
115
+
116
+ if (!key) {
117
+ return;
118
+ }
119
+
120
+ nextKeys.add(key);
121
+
122
+ const currentNode = currentMap.get(key);
123
+ if (currentNode && currentNode.outerHTML === node.outerHTML) {
124
+ return;
125
+ }
126
+
127
+ const clone = cloneManagedHeadNode(node);
128
+
129
+ if (currentNode) {
130
+ currentNode.replaceWith(clone);
131
+ } else {
132
+ document.head.appendChild(clone);
133
+ }
134
+ });
135
+
136
+ currentNodes.forEach((node) => {
137
+ const key = node.getAttribute('data-asjs-head-key');
138
+
139
+ if (key && !nextKeys.has(key)) {
140
+ node.remove();
141
+ }
142
+ });
143
+ }
144
+
145
+ function syncBodyData(parsedDocument) {
146
+ const nextBody = parsedDocument.body;
147
+ if (!nextBody) {
148
+ return;
149
+ }
150
+
151
+ [
152
+ 'data-asjs-transition',
153
+ 'data-asjs-transition-duration',
154
+ 'data-asjs-prefetch',
155
+ 'data-asjs-prefetch-ttl',
156
+ 'data-asjs-loading-bar'
157
+ ].forEach((attributeName) => {
158
+ if (nextBody.hasAttribute(attributeName)) {
159
+ document.body.setAttribute(attributeName, nextBody.getAttribute(attributeName));
160
+ } else {
161
+ document.body.removeAttribute(attributeName);
162
+ }
163
+ });
164
+ }
165
+
166
+ function appendFormDataToSearchParams(searchParams, formData) {
167
+ formData.forEach((value, key) => {
168
+ searchParams.append(key, value);
169
+ });
170
+
171
+ return searchParams;
172
+ }
173
+
174
+ function formDataToSearchParams(formData) {
175
+ return appendFormDataToSearchParams(new URLSearchParams(), formData);
176
+ }
177
+
178
+ function formDataToJson(formData) {
179
+ const payload = {};
180
+
181
+ formData.forEach((value, key) => {
182
+ if (Object.prototype.hasOwnProperty.call(payload, key)) {
183
+ if (Array.isArray(payload[key])) {
184
+ payload[key].push(value);
185
+ return;
186
+ }
187
+
188
+ payload[key] = [payload[key], value];
189
+ return;
190
+ }
191
+
192
+ payload[key] = value;
193
+ });
194
+
195
+ return payload;
196
+ }
197
+
198
+ function isHtmlPayload(contentType, payload) {
199
+ if (contentType && contentType.includes('text/html')) {
200
+ return true;
201
+ }
202
+
203
+ return typeof payload === 'string' && payload.includes('<html');
204
+ }
205
+
206
+ function createInlineResponseMarkup(message, tone) {
207
+ const root = document.createElement('div');
208
+ root.className = `asjs-inline-response ${tone === 'error' ? 'is-error' : 'is-success'}`;
209
+ const text = document.createElement('p');
210
+ text.textContent = message;
211
+ root.appendChild(text);
212
+ return root.outerHTML;
213
+ }
214
+
215
+ function clearTransitionClasses(node) {
216
+ Array.from(node.classList).forEach((className) => {
217
+ if (
218
+ className.startsWith('asjs-transition-name--') ||
219
+ className.startsWith('asjs-transition-phase--')
220
+ ) {
221
+ node.classList.remove(className);
222
+ }
223
+ });
224
+ }
225
+
226
+ class ASJSNavigator {
227
+ constructor(options = {}) {
228
+ this.linkSelector = options.linkSelector || 'a[data-asjs-link]';
229
+ this.viewSelector = options.viewSelector || '[data-asjs-view]';
230
+ this.defaultFormSelector = options.formSelector || 'form[data-asjs-form], form[data-webas-submit]';
231
+ this.formSelector = this.defaultFormSelector;
232
+ this.activeClass = options.activeClass || 'is-active';
233
+ this.defaultPrefetch = options.prefetch !== false;
234
+ this.defaultPrefetchTtl = parseNumberAttribute(options.prefetchTtl, 30000);
235
+ this.defaultLoadingBar = options.loadingBar !== false;
236
+ this.defaultForms = options.forms !== false;
237
+ this.defaultFormMode = options.formMode || 'view';
238
+ this.defaultFormHistory = Boolean(options.formHistory);
239
+ this.defaultFormResetOnSuccess = Boolean(options.formResetOnSuccess);
240
+ this.defaultFormTarget = options.formTarget || '';
241
+ this.defaultFormSwap = options.formSwap || 'replace';
242
+ this.pageCache = new Map();
243
+ this.prefetchRequests = new Map();
244
+ this.isNavigating = false;
245
+ this.progressStartedAt = 0;
246
+ this.progressShownAt = 0;
247
+ this.progressShowTimer = null;
248
+ this.progressRoot = document.querySelector('[data-asjs-progress]');
249
+ this.progressBar = this.progressRoot ? this.progressRoot.querySelector('.asjs-progress-bar') : null;
250
+ this.progressRampTimer = null;
251
+ this.progressHideTimer = null;
252
+ this.applyDocumentSettings(document.body);
253
+ this.bindEvents();
254
+ this.syncActiveLinks(window.location.pathname);
255
+ }
256
+
257
+ applyDocumentSettings(root) {
258
+ this.prefetchEnabled = parseBooleanAttribute(root && root.dataset.asjsPrefetch, this.defaultPrefetch);
259
+ this.prefetchTtl = parseNumberAttribute(root && root.dataset.asjsPrefetchTtl, this.defaultPrefetchTtl);
260
+ this.loadingBarEnabled = parseBooleanAttribute(root && root.dataset.asjsLoadingBar, this.defaultLoadingBar);
261
+ this.formsEnabled = parseBooleanAttribute(root && root.dataset.asjsForms, this.defaultForms);
262
+ this.formSelector = (root && root.dataset.asjsFormSelector) || this.defaultFormSelector;
263
+ this.formMode = (root && root.dataset.asjsFormMode) || this.defaultFormMode;
264
+ this.formHistory = parseBooleanAttribute(root && root.dataset.asjsFormHistory, this.defaultFormHistory);
265
+ this.formResetOnSuccess = parseBooleanAttribute(root && root.dataset.asjsFormResetOnSuccess, this.defaultFormResetOnSuccess);
266
+ this.formTarget = (root && root.dataset.asjsFormTarget) || this.defaultFormTarget;
267
+ this.formSwap = (root && root.dataset.asjsFormSwap) || this.defaultFormSwap;
268
+ }
269
+
270
+ ensureProgressBar() {
271
+ if (!this.progressRoot) {
272
+ this.progressRoot = document.createElement('div');
273
+ this.progressRoot.className = 'asjs-progress';
274
+ this.progressRoot.dataset.asjsProgress = 'true';
275
+ this.progressRoot.innerHTML = '<span class="asjs-progress-bar"></span>';
276
+ document.body.prepend(this.progressRoot);
277
+ }
278
+
279
+ this.progressBar = this.progressRoot.querySelector('.asjs-progress-bar');
280
+ return Boolean(this.progressRoot && this.progressBar);
281
+ }
282
+
283
+ startProgress() {
284
+ if (!this.loadingBarEnabled || !this.ensureProgressBar()) {
285
+ return;
286
+ }
287
+
288
+ this.progressStartedAt = Date.now();
289
+ this.progressShownAt = 0;
290
+ window.clearTimeout(this.progressShowTimer);
291
+ window.clearTimeout(this.progressRampTimer);
292
+ window.clearTimeout(this.progressHideTimer);
293
+ this.progressRoot.classList.remove('is-active');
294
+ this.progressBar.style.opacity = '0';
295
+ this.progressBar.style.transform = 'scaleX(0)';
296
+ this.progressShowTimer = window.setTimeout(() => {
297
+ this.progressShownAt = Date.now();
298
+ this.progressRoot.classList.add('is-active');
299
+ this.progressBar.style.opacity = '1';
300
+ this.progressBar.style.transform = 'scaleX(0.18)';
301
+ this.progressRampTimer = window.setTimeout(() => {
302
+ this.progressBar.style.transform = 'scaleX(0.58)';
303
+ }, 90);
304
+ }, 110);
305
+ }
306
+
307
+ completeProgress() {
308
+ if (!this.loadingBarEnabled || !this.ensureProgressBar()) {
309
+ return;
310
+ }
311
+
312
+ window.clearTimeout(this.progressShowTimer);
313
+ window.clearTimeout(this.progressRampTimer);
314
+ window.clearTimeout(this.progressHideTimer);
315
+
316
+ if (!this.progressRoot.classList.contains('is-active')) {
317
+ this.progressBar.style.opacity = '0';
318
+ this.progressBar.style.transform = 'scaleX(0)';
319
+ return;
320
+ }
321
+
322
+ const elapsed = this.progressShownAt ? Date.now() - this.progressShownAt : 0;
323
+ const remainingVisibleTime = Math.max(0, 220 - elapsed);
324
+
325
+ this.progressBar.style.opacity = '1';
326
+ this.progressBar.style.transform = 'scaleX(1)';
327
+ this.progressHideTimer = window.setTimeout(() => {
328
+ this.progressRoot.classList.remove('is-active');
329
+ this.progressBar.style.opacity = '0';
330
+ this.progressBar.style.transform = 'scaleX(0)';
331
+ }, remainingVisibleTime + 180);
332
+ }
333
+
334
+ bindEvents() {
335
+ document.addEventListener('click', (event) => {
336
+ const link = event.target.closest(this.linkSelector);
337
+
338
+ if (!link || !this.shouldHandleClick(link, event)) {
339
+ return;
340
+ }
341
+
342
+ event.preventDefault();
343
+ this.visit(link.href, { pushState: true, trigger: link });
344
+ });
345
+
346
+ document.addEventListener('mouseover', (event) => {
347
+ const link = event.target.closest(this.linkSelector);
348
+
349
+ if (link) {
350
+ this.prefetchLink(link);
351
+ }
352
+ });
353
+
354
+ document.addEventListener('focusin', (event) => {
355
+ const link = event.target.closest(this.linkSelector);
356
+
357
+ if (link) {
358
+ this.prefetchLink(link);
359
+ }
360
+ });
361
+
362
+ document.addEventListener('touchstart', (event) => {
363
+ const link = event.target.closest(this.linkSelector);
364
+
365
+ if (link) {
366
+ this.prefetchLink(link);
367
+ }
368
+ }, { passive: true });
369
+
370
+ document.addEventListener('submit', (event) => {
371
+ const form = event.target.closest(this.formSelector);
372
+
373
+ if (!form || !this.shouldHandleForm(form, event)) {
374
+ return;
375
+ }
376
+
377
+ event.preventDefault();
378
+ this.submitForm(form, { submitter: event.submitter || null });
379
+ });
380
+
381
+ window.addEventListener('popstate', () => {
382
+ this.visit(window.location.href, { pushState: false, preserveScroll: true });
383
+ });
384
+ }
385
+
386
+ shouldHandleClick(link, event) {
387
+ if (
388
+ event.defaultPrevented ||
389
+ event.button !== 0 ||
390
+ event.metaKey ||
391
+ event.ctrlKey ||
392
+ event.shiftKey ||
393
+ event.altKey ||
394
+ link.target === '_blank' ||
395
+ link.hasAttribute('download')
396
+ ) {
397
+ return false;
398
+ }
399
+
400
+ const nextUrl = new URL(link.href, window.location.href);
401
+ const currentUrl = new URL(window.location.href);
402
+
403
+ if (nextUrl.origin !== currentUrl.origin) {
404
+ return false;
405
+ }
406
+
407
+ if (
408
+ nextUrl.pathname === currentUrl.pathname &&
409
+ nextUrl.search === currentUrl.search &&
410
+ nextUrl.hash
411
+ ) {
412
+ return false;
413
+ }
414
+
415
+ return true;
416
+ }
417
+
418
+ shouldHandleForm(form, event) {
419
+ if (event.defaultPrevented || !this.formsEnabled) {
420
+ return false;
421
+ }
422
+
423
+ if (form.dataset.asjsForm === 'false' || form.target === '_blank') {
424
+ return false;
425
+ }
426
+
427
+ const action = form.getAttribute('action') || window.location.href;
428
+ const actionUrl = new URL(action, window.location.href);
429
+ const currentUrl = new URL(window.location.href);
430
+
431
+ if (actionUrl.origin !== currentUrl.origin) {
432
+ return false;
433
+ }
434
+
435
+ return ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'].includes(
436
+ String(form.getAttribute('method') || 'GET').toUpperCase()
437
+ );
438
+ }
439
+
440
+ resolveFormOptions(form) {
441
+ return {
442
+ action: new URL(form.getAttribute('action') || window.location.href, window.location.href).toString(),
443
+ method: String(form.getAttribute('method') || 'GET').toUpperCase(),
444
+ mode: String(form.dataset.asjsFormMode || this.formMode || 'view').toLowerCase(),
445
+ history: parseBooleanAttribute(form.dataset.asjsFormHistory, this.formHistory),
446
+ resetOnSuccess: parseBooleanAttribute(form.dataset.asjsFormResetOnSuccess, this.formResetOnSuccess),
447
+ target: form.dataset.asjsFormTarget || this.formTarget || '',
448
+ swap: String(form.dataset.asjsFormSwap || this.formSwap || 'replace').toLowerCase(),
449
+ bodyMode: String(form.dataset.asjsFormBody || '').toLowerCase(),
450
+ preserveScroll: parseBooleanAttribute(form.dataset.asjsFormPreserveScroll, false),
451
+ transition: form.dataset.asjsTransition || null
452
+ };
453
+ }
454
+
455
+ createFormRequest(form, settings, submitter) {
456
+ const requestUrl = new URL(settings.action, window.location.href);
457
+ const formData = new FormData(form);
458
+ const headers = {
459
+ 'X-Requested-With': 'ASJS',
460
+ 'X-ASJS-Form': 'true',
461
+ 'X-ASJS-Form-Mode': settings.mode
462
+ };
463
+
464
+ if (submitter && submitter.name && !formData.has(submitter.name)) {
465
+ formData.append(submitter.name, submitter.value);
466
+ }
467
+
468
+ if (settings.method === 'GET') {
469
+ requestUrl.search = appendFormDataToSearchParams(new URLSearchParams(requestUrl.search), formData).toString();
470
+
471
+ return {
472
+ url: requestUrl.toString(),
473
+ options: {
474
+ method: 'GET',
475
+ headers,
476
+ redirect: 'follow'
477
+ }
478
+ };
479
+ }
480
+
481
+ if (settings.bodyMode === 'json' || String(form.enctype || '').toLowerCase() === 'application/json') {
482
+ headers['Content-Type'] = 'application/json';
483
+
484
+ return {
485
+ url: requestUrl.toString(),
486
+ options: {
487
+ method: settings.method,
488
+ headers,
489
+ body: JSON.stringify(formDataToJson(formData)),
490
+ redirect: 'follow'
491
+ }
492
+ };
493
+ }
494
+
495
+ if (String(form.enctype || '').toLowerCase() === 'multipart/form-data') {
496
+ return {
497
+ url: requestUrl.toString(),
498
+ options: {
499
+ method: settings.method,
500
+ headers,
501
+ body: formData,
502
+ redirect: 'follow'
503
+ }
504
+ };
505
+ }
506
+
507
+ headers['Content-Type'] = 'application/x-www-form-urlencoded;charset=UTF-8';
508
+
509
+ return {
510
+ url: requestUrl.toString(),
511
+ options: {
512
+ method: settings.method,
513
+ headers,
514
+ body: formDataToSearchParams(formData),
515
+ redirect: 'follow'
516
+ }
517
+ };
518
+ }
519
+
520
+ applyTargetContent(targetSelector, content, swap) {
521
+ if (!targetSelector || !content) {
522
+ return false;
523
+ }
524
+
525
+ const target = document.querySelector(targetSelector);
526
+ if (!target) {
527
+ return false;
528
+ }
529
+
530
+ if (swap === 'append') {
531
+ target.insertAdjacentHTML('beforeend', content);
532
+ } else if (swap === 'prepend') {
533
+ target.insertAdjacentHTML('afterbegin', content);
534
+ } else {
535
+ target.innerHTML = content;
536
+ }
537
+
538
+ reviveScripts(target);
539
+ return true;
540
+ }
541
+
542
+ async swapViewFromHtml(html, url, options = {}) {
543
+ const currentView = document.querySelector(this.viewSelector);
544
+
545
+ if (!currentView) {
546
+ return false;
547
+ }
548
+
549
+ const parsedDocument = new DOMParser().parseFromString(html, 'text/html');
550
+ const nextView = parsedDocument.querySelector(this.viewSelector);
551
+
552
+ if (!nextView) {
553
+ return false;
554
+ }
555
+
556
+ const leaveTransition = this.resolveTransition({
557
+ trigger: options.trigger,
558
+ view: currentView,
559
+ root: document.body
560
+ });
561
+
562
+ const enterTransition = this.resolveTransition({
563
+ trigger: options.trigger,
564
+ view: nextView,
565
+ root: parsedDocument.body,
566
+ fallback: leaveTransition
567
+ });
568
+
569
+ await this.playTransition(currentView, leaveTransition, 'leave');
570
+ syncBodyData(parsedDocument);
571
+ syncManagedHead(parsedDocument);
572
+ this.applyDocumentSettings(document.body);
573
+ copyAttributes(currentView, nextView);
574
+ currentView.innerHTML = nextView.innerHTML;
575
+ reviveScripts(currentView);
576
+ document.dispatchEvent(new CustomEvent('asjs:content-replaced', {
577
+ detail: {
578
+ url,
579
+ title: parsedDocument.title || document.title
580
+ }
581
+ }));
582
+ await this.playTransition(currentView, enterTransition, 'enter');
583
+
584
+ document.title = parsedDocument.title || document.title;
585
+
586
+ if (options.pushState) {
587
+ window.history.pushState({}, '', url);
588
+ } else if (options.replaceState) {
589
+ window.history.replaceState({}, '', url);
590
+ }
591
+
592
+ this.syncActiveLinks(new URL(url, window.location.href).pathname);
593
+
594
+ if (!options.preserveScroll) {
595
+ window.scrollTo({ top: 0, behavior: 'smooth' });
596
+ }
597
+
598
+ document.dispatchEvent(new CustomEvent('asjs:navigated', {
599
+ detail: {
600
+ url,
601
+ fromCache: Boolean(options.fromCache)
602
+ }
603
+ }));
604
+
605
+ return true;
606
+ }
607
+
608
+ async handleHtmlFormResponse(form, response, html, settings) {
609
+ const finalUrl = response.url || settings.action;
610
+ const contentType = response.headers.get('content-type') || '';
611
+
612
+ if (isHtmlPayload(contentType, html)) {
613
+ const swapped = await this.swapViewFromHtml(html, finalUrl, {
614
+ trigger: form,
615
+ pushState: settings.history,
616
+ replaceState: response.redirected && !settings.history,
617
+ preserveScroll: settings.preserveScroll || response.status === 422
618
+ });
619
+
620
+ if (swapped) {
621
+ return true;
622
+ }
623
+ }
624
+
625
+ if (settings.target) {
626
+ return this.applyTargetContent(settings.target, html, settings.swap);
627
+ }
628
+
629
+ return false;
630
+ }
631
+
632
+ async handleJsonFormResponse(form, response, payload, settings) {
633
+ const detail = {
634
+ action: settings.action,
635
+ form,
636
+ method: settings.method,
637
+ payload,
638
+ status: response.status
639
+ };
640
+
641
+ document.dispatchEvent(new CustomEvent('asjs:form-response', { detail }));
642
+
643
+ let handled = false;
644
+ if (settings.target) {
645
+ if (payload && typeof payload.html === 'string') {
646
+ handled = this.applyTargetContent(settings.target, payload.html, settings.swap);
647
+ } else if (payload && typeof payload.message === 'string') {
648
+ handled = this.applyTargetContent(
649
+ settings.target,
650
+ createInlineResponseMarkup(payload.message, response.ok ? 'success' : 'error'),
651
+ settings.swap
652
+ );
653
+ }
654
+ }
655
+
656
+ if (response.ok && settings.resetOnSuccess) {
657
+ form.reset();
658
+ }
659
+
660
+ document.dispatchEvent(new CustomEvent(response.ok ? 'asjs:form-success' : 'asjs:form-error', {
661
+ detail
662
+ }));
663
+
664
+ return handled;
665
+ }
666
+
667
+ async submitForm(form, options = {}) {
668
+ if (this.isNavigating) {
669
+ return;
670
+ }
671
+
672
+ const settings = this.resolveFormOptions(form);
673
+ const request = this.createFormRequest(form, settings, options.submitter);
674
+
675
+ this.isNavigating = true;
676
+ form.classList.add('asjs-form-submitting');
677
+ document.documentElement.classList.add('asjs-loading');
678
+ document.dispatchEvent(new CustomEvent('asjs:before-submit', {
679
+ detail: {
680
+ action: settings.action,
681
+ form,
682
+ method: settings.method,
683
+ submitter: options.submitter || null
684
+ }
685
+ }));
686
+ this.startProgress();
687
+
688
+ try {
689
+ const response = await fetch(request.url, request.options);
690
+ const contentType = response.headers.get('content-type') || '';
691
+ const rawPayload = await response.text();
692
+ let handled = false;
693
+
694
+ if (settings.mode === 'json' || contentType.includes('application/json')) {
695
+ const payload = rawPayload ? JSON.parse(rawPayload) : null;
696
+ handled = await this.handleJsonFormResponse(form, response, payload, settings);
697
+ } else {
698
+ handled = await this.handleHtmlFormResponse(form, response, rawPayload, settings);
699
+ document.dispatchEvent(new CustomEvent(response.ok ? 'asjs:form-success' : 'asjs:form-error', {
700
+ detail: {
701
+ action: settings.action,
702
+ form,
703
+ method: settings.method,
704
+ status: response.status,
705
+ url: response.url || request.url
706
+ }
707
+ }));
708
+
709
+ if (response.ok && settings.resetOnSuccess) {
710
+ form.reset();
711
+ }
712
+ }
713
+
714
+ if (!handled && response.redirected) {
715
+ window.location.href = response.url;
716
+ return;
717
+ }
718
+
719
+ if (!handled && !response.ok) {
720
+ window.location.href = request.url;
721
+ }
722
+ } catch (error) {
723
+ console.error('ASJS form fetch hatasi:', error);
724
+ document.dispatchEvent(new CustomEvent('asjs:form-error', {
725
+ detail: {
726
+ action: settings.action,
727
+ error,
728
+ form,
729
+ method: settings.method
730
+ }
731
+ }));
732
+ } finally {
733
+ this.isNavigating = false;
734
+ form.classList.remove('asjs-form-submitting');
735
+ document.documentElement.classList.remove('asjs-loading');
736
+ this.completeProgress();
737
+ }
738
+ }
739
+
740
+ resolveActiveMode(link) {
741
+ return String(link.dataset.asjsActive || 'exact').toLowerCase();
742
+ }
743
+
744
+ isLinkActive(link, pathname) {
745
+ const linkUrl = new URL(link.href, window.location.href);
746
+ const matchPath = link.dataset.asjsMatchPath || linkUrl.pathname;
747
+ return isActivePath(matchPath, pathname, this.resolveActiveMode(link));
748
+ }
749
+
750
+ shouldPrefetch(link) {
751
+ const prefetchEnabled = parseBooleanAttribute(link.dataset.asjsPrefetch, this.prefetchEnabled);
752
+
753
+ if (!prefetchEnabled || link.dataset.asjsPrefetched === 'true') {
754
+ return false;
755
+ }
756
+
757
+ const targetUrl = new URL(link.href, window.location.href);
758
+ const currentUrl = new URL(window.location.href);
759
+
760
+ if (targetUrl.origin !== currentUrl.origin || link.target === '_blank' || link.hasAttribute('download')) {
761
+ return false;
762
+ }
763
+
764
+ return !(targetUrl.pathname === currentUrl.pathname && targetUrl.search === currentUrl.search);
765
+ }
766
+
767
+ resolvePrefetchTtl(trigger) {
768
+ return parseNumberAttribute(
769
+ trigger && trigger.dataset.asjsPrefetchTtl,
770
+ this.prefetchTtl
771
+ );
772
+ }
773
+
774
+ resolveTransition({ trigger, view, root, fallback } = {}) {
775
+ if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
776
+ return { name: 'none', duration: 0 };
777
+ }
778
+
779
+ const rawName =
780
+ (trigger && trigger.dataset.asjsTransition) ||
781
+ (view && view.dataset.asjsTransition) ||
782
+ (root && root.dataset.asjsTransition) ||
783
+ (fallback && fallback.name) ||
784
+ 'none';
785
+
786
+ const durationValue =
787
+ (trigger && trigger.dataset.asjsTransitionDuration) ||
788
+ (view && view.dataset.asjsTransitionDuration) ||
789
+ (root && root.dataset.asjsTransitionDuration) ||
790
+ (fallback && fallback.duration) ||
791
+ 0;
792
+
793
+ const duration = Math.max(0, Number.parseInt(durationValue, 10) || 0);
794
+ const name = rawName === 'false' || rawName === 'off' ? 'none' : rawName;
795
+
796
+ return {
797
+ name,
798
+ duration: name === 'none' ? 0 : duration
799
+ };
800
+ }
801
+
802
+ async playTransition(view, transition, phase) {
803
+ if (!view || !transition || !transition.name || transition.name === 'none' || transition.duration <= 0) {
804
+ return;
805
+ }
806
+
807
+ const nameClass = `asjs-transition-name--${transition.name}`;
808
+ const phaseClass = `asjs-transition-phase--${phase}`;
809
+
810
+ view.style.setProperty('--asjs-transition-duration', `${transition.duration}ms`);
811
+ clearTransitionClasses(view);
812
+
813
+ if (phase === 'enter') {
814
+ view.classList.add(nameClass, phaseClass);
815
+ await nextFrame();
816
+ await nextFrame();
817
+ view.classList.remove(phaseClass);
818
+ await wait(transition.duration);
819
+ clearTransitionClasses(view);
820
+ return;
821
+ }
822
+
823
+ view.classList.add(nameClass);
824
+ await nextFrame();
825
+ view.classList.add(phaseClass);
826
+ await nextFrame();
827
+ await wait(transition.duration);
828
+ }
829
+
830
+ getCachedPage(url) {
831
+ const cacheKey = normalizeUrl(url);
832
+ const cached = this.pageCache.get(cacheKey);
833
+
834
+ if (!cached) {
835
+ return null;
836
+ }
837
+
838
+ if (cached.expiresAt <= Date.now()) {
839
+ this.pageCache.delete(cacheKey);
840
+ return null;
841
+ }
842
+
843
+ return cached.html;
844
+ }
845
+
846
+ setCachedPage(url, html, ttl) {
847
+ this.pageCache.set(normalizeUrl(url), {
848
+ html,
849
+ expiresAt: Date.now() + ttl
850
+ });
851
+ }
852
+
853
+ async readPage(url, options = {}) {
854
+ const cacheKey = normalizeUrl(url);
855
+ const cached = options.useCache === false ? null : this.getCachedPage(url);
856
+
857
+ if (cached) {
858
+ return {
859
+ html: cached,
860
+ status: 200,
861
+ fromCache: true
862
+ };
863
+ }
864
+
865
+ if (this.prefetchRequests.has(cacheKey)) {
866
+ return this.prefetchRequests.get(cacheKey);
867
+ }
868
+
869
+ const requestPromise = fetch(url, {
870
+ headers: {
871
+ 'X-Requested-With': 'ASJS',
872
+ ...(options.prefetch ? { 'X-ASJS-Prefetch': 'true' } : {})
873
+ }
874
+ }).then(async (response) => {
875
+ const html = await response.text();
876
+
877
+ if (response.ok) {
878
+ this.setCachedPage(url, html, options.ttl || this.prefetchTtl);
879
+ }
880
+
881
+ return {
882
+ html,
883
+ status: response.status,
884
+ fromCache: false
885
+ };
886
+ }).finally(() => {
887
+ this.prefetchRequests.delete(cacheKey);
888
+ });
889
+
890
+ this.prefetchRequests.set(cacheKey, requestPromise);
891
+ return requestPromise;
892
+ }
893
+
894
+ async prefetchLink(link) {
895
+ if (!this.shouldPrefetch(link)) {
896
+ return;
897
+ }
898
+
899
+ try {
900
+ const result = await this.readPage(link.href, {
901
+ prefetch: true,
902
+ ttl: this.resolvePrefetchTtl(link)
903
+ });
904
+
905
+ if (result.status >= 200 && result.status < 300) {
906
+ link.dataset.asjsPrefetched = 'true';
907
+ document.dispatchEvent(new CustomEvent('asjs:prefetched', {
908
+ detail: { url: link.href }
909
+ }));
910
+ }
911
+ } catch (error) {
912
+ console.warn('ASJS prefetch hatasi:', error);
913
+ }
914
+ }
915
+
916
+ async visit(url, options = {}) {
917
+ if (this.isNavigating) {
918
+ return;
919
+ }
920
+
921
+ if (!document.querySelector(this.viewSelector)) {
922
+ window.location.href = url;
923
+ return;
924
+ }
925
+
926
+ this.isNavigating = true;
927
+ document.documentElement.classList.add('asjs-loading');
928
+ document.dispatchEvent(new CustomEvent('asjs:before-navigate', {
929
+ detail: { url, trigger: options.trigger || null }
930
+ }));
931
+ this.startProgress();
932
+
933
+ try {
934
+ const response = await this.readPage(url, {
935
+ ttl: this.resolvePrefetchTtl(options.trigger)
936
+ });
937
+
938
+ if (response.status < 200 || response.status >= 300) {
939
+ window.location.href = url;
940
+ return;
941
+ }
942
+
943
+ const swapped = await this.swapViewFromHtml(response.html, url, {
944
+ trigger: options.trigger,
945
+ pushState: options.pushState,
946
+ preserveScroll: options.preserveScroll,
947
+ fromCache: response.fromCache
948
+ });
949
+
950
+ if (!swapped) {
951
+ window.location.href = url;
952
+ return;
953
+ }
954
+ } catch (error) {
955
+ console.error('ASJS router fetch hatasi:', error);
956
+ document.dispatchEvent(new CustomEvent('asjs:navigation-error', {
957
+ detail: { url, error }
958
+ }));
959
+ window.location.href = url;
960
+ } finally {
961
+ this.isNavigating = false;
962
+ document.documentElement.classList.remove('asjs-loading');
963
+ this.completeProgress();
964
+ }
965
+ }
966
+
967
+ syncActiveLinks(pathname) {
968
+ document.querySelectorAll(this.linkSelector).forEach((link) => {
969
+ const isActive = this.isLinkActive(link, pathname);
970
+
971
+ link.classList.toggle(this.activeClass, isActive);
972
+
973
+ if (isActive) {
974
+ link.setAttribute('aria-current', 'page');
975
+ } else {
976
+ link.removeAttribute('aria-current');
977
+ }
978
+ });
979
+ }
980
+ }
981
+
982
+ window.ASJSNavigator = ASJSNavigator;
983
+
984
+ window.addEventListener('DOMContentLoaded', () => {
985
+ if (document.querySelector('[data-asjs-view]')) {
986
+ window.asjsNavigator = new ASJSNavigator();
987
+ }
988
+ });
989
+ })();