xhtmlx 0.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.
package/xhtmlx.js ADDED
@@ -0,0 +1,2825 @@
1
+ /**
2
+ * xhtmlx.js — Declarative HTML attributes for REST API driven UIs.
3
+ *
4
+ * Like htmx, but the server returns JSON and xhtmlx renders UI client-side
5
+ * using templates.
6
+ *
7
+ * Zero dependencies. Single file. IIFE pattern.
8
+ *
9
+ * trigger → request → receive JSON → render template with data → swap into DOM
10
+ */
11
+ (function () {
12
+ "use strict";
13
+
14
+ // ---------------------------------------------------------------------------
15
+ // Configuration
16
+ // ---------------------------------------------------------------------------
17
+ var config = {
18
+ debug: false,
19
+ defaultSwapMode: "innerHTML",
20
+ indicatorClass: "xh-indicator",
21
+ requestClass: "xh-request",
22
+ errorClass: "xh-error",
23
+ batchThreshold: 100, // xh-each arrays above this size use rAF batching
24
+ defaultErrorTemplate: null, // Global fallback error template URL
25
+ defaultErrorTarget: null, // Global fallback error target CSS selector
26
+ templatePrefix: "", // Prefix prepended to all xh-template URLs
27
+ apiPrefix: "", // Prefix prepended to all REST verb URLs
28
+ uiVersion: null // Current UI version identifier (any string)
29
+ };
30
+
31
+ // ---------------------------------------------------------------------------
32
+ // Internal state
33
+ // ---------------------------------------------------------------------------
34
+
35
+ /** WeakMap<Element, ElementState> — per-element bookkeeping */
36
+ var elementStates = new WeakMap();
37
+
38
+ /** Map<string, Promise<string>> — external template cache (URL → HTML) */
39
+ var templateCache = new Map();
40
+
41
+ /** Set<string> — currently resolving template URLs (circular detection) */
42
+ // (per-chain stacks are passed as arrays instead of a global set)
43
+
44
+ /** Generation counter per element for discarding stale responses */
45
+ var generationMap = new WeakMap();
46
+
47
+ /** Map<string, {data: string, timestamp: number}> — response cache (verb:url → body) */
48
+ var responseCache = new Map();
49
+
50
+ // ---------------------------------------------------------------------------
51
+ // Default CSS injection
52
+ // ---------------------------------------------------------------------------
53
+ function injectDefaultCSS() {
54
+ var id = "xhtmlx-default-css";
55
+ if (document.getElementById(id)) return;
56
+ var style = document.createElement("style");
57
+ style.id = id;
58
+ style.textContent =
59
+ ".xh-indicator { opacity: 0; transition: opacity 200ms ease-in; }\n" +
60
+ ".xh-request .xh-indicator, .xh-request.xh-indicator { opacity: 1; }\n" +
61
+ ".xh-added { }\n" +
62
+ ".xh-settled { }\n" +
63
+ ".xh-invalid { border-color: #ef4444; }\n";
64
+ document.head.appendChild(style);
65
+ }
66
+
67
+ // ---------------------------------------------------------------------------
68
+ // DataContext
69
+ // ---------------------------------------------------------------------------
70
+
71
+ /**
72
+ * Holds the JSON data for the current rendering scope and a reference to its
73
+ * parent context so that templates can walk up the chain.
74
+ *
75
+ * @param {*} data – The JSON payload for this level.
76
+ * @param {DataContext} parent – Enclosing context (null at root).
77
+ * @param {number|null} index – Current iteration index for xh-each.
78
+ */
79
+ function DataContext(data, parent, index) {
80
+ this.data = data != null ? data : {};
81
+ this.parent = parent || null;
82
+ this.index = index != null ? index : null;
83
+ }
84
+
85
+ /**
86
+ * Resolve a dotted path against this context.
87
+ *
88
+ * Special variables:
89
+ * $index – iteration index
90
+ * $parent – jump to parent context, continue resolving remainder
91
+ * $root – jump to root context, continue resolving remainder
92
+ *
93
+ * If the key is not found locally, we walk up the parent chain.
94
+ *
95
+ * @param {string} path – e.g. "user.name", "$parent.title", "$index"
96
+ * @returns {*} resolved value or undefined
97
+ */
98
+ DataContext.prototype.resolve = function (path) {
99
+ if (path == null || path === "") return undefined;
100
+
101
+ // -- transform pipe support: "price | currency" --------------------------
102
+ if (path.indexOf(" | ") !== -1) {
103
+ var pipeParts = path.split(" | ");
104
+ var rawValue = this.resolve(pipeParts[0].trim());
105
+ for (var p = 1; p < pipeParts.length; p++) {
106
+ var transformName = pipeParts[p].trim();
107
+ if (transforms[transformName]) {
108
+ rawValue = transforms[transformName](rawValue);
109
+ }
110
+ }
111
+ return rawValue;
112
+ }
113
+
114
+ var parts = path.split(".");
115
+
116
+ // --- special variables ---------------------------------------------------
117
+ if (parts[0] === "$index") {
118
+ if (parts.length === 1) return this.index;
119
+ // $index doesn't have sub-properties
120
+ return undefined;
121
+ }
122
+
123
+ if (parts[0] === "$parent") {
124
+ if (!this.parent) return undefined;
125
+ if (parts.length === 1) return this.parent.data;
126
+ return this.parent.resolve(parts.slice(1).join("."));
127
+ }
128
+
129
+ if (parts[0] === "$root") {
130
+ var root = this;
131
+ while (root.parent) root = root.parent;
132
+ if (parts.length === 1) return root.data;
133
+ return root.resolve(parts.slice(1).join("."));
134
+ }
135
+
136
+ // --- local lookup --------------------------------------------------------
137
+ var value = resolveDot(this.data, parts);
138
+ if (value !== undefined) return value;
139
+
140
+ // --- walk parent chain ---------------------------------------------------
141
+ if (this.parent) return this.parent.resolve(path);
142
+
143
+ return undefined;
144
+ };
145
+
146
+ /**
147
+ * Resolve a dotted path against a plain object.
148
+ * @param {Object} obj
149
+ * @param {string[]} parts
150
+ * @returns {*}
151
+ */
152
+ function resolveDot(obj, parts) {
153
+ var cur = obj;
154
+ for (var i = 0; i < parts.length; i++) {
155
+ if (cur == null || typeof cur !== "object") return undefined;
156
+ if (!(parts[i] in cur)) return undefined;
157
+ cur = cur[parts[i]];
158
+ }
159
+ return cur;
160
+ }
161
+
162
+ // ---------------------------------------------------------------------------
163
+ // MutableDataContext — reactive wrapper around DataContext
164
+ // ---------------------------------------------------------------------------
165
+
166
+ /**
167
+ * A DataContext subclass that supports live reactivity.
168
+ * When a field is changed via set(), all subscribers for that path are notified.
169
+ *
170
+ * @param {*} data – The JSON payload for this level.
171
+ * @param {DataContext} parent – Enclosing context (null at root).
172
+ * @param {number|null} index – Current iteration index for xh-each.
173
+ */
174
+ function MutableDataContext(data, parent, index) {
175
+ DataContext.call(this, data, parent, index);
176
+ this._subscribers = {}; // path -> [callback]
177
+ }
178
+ MutableDataContext.prototype = Object.create(DataContext.prototype);
179
+ MutableDataContext.prototype.constructor = MutableDataContext;
180
+
181
+ /**
182
+ * Set a value at the given dotted path, creating intermediate objects as needed.
183
+ * Notifies all subscribers for the given path.
184
+ *
185
+ * @param {string} path – e.g. "user.name"
186
+ * @param {*} value – The new value.
187
+ */
188
+ MutableDataContext.prototype.set = function(path, value) {
189
+ var parts = path.split(".");
190
+ var obj = this.data;
191
+ for (var i = 0; i < parts.length - 1; i++) {
192
+ if (obj[parts[i]] == null) obj[parts[i]] = {};
193
+ obj = obj[parts[i]];
194
+ }
195
+ obj[parts[parts.length - 1]] = value;
196
+ this._notify(path);
197
+ };
198
+
199
+ /**
200
+ * Subscribe to changes on a given path.
201
+ *
202
+ * @param {string} path – The dotted path to watch.
203
+ * @param {Function} callback – Called when the value at path changes.
204
+ */
205
+ MutableDataContext.prototype.subscribe = function(path, callback) {
206
+ if (!this._subscribers[path]) this._subscribers[path] = [];
207
+ this._subscribers[path].push(callback);
208
+ };
209
+
210
+ /**
211
+ * Notify all subscribers for a given path.
212
+ * @param {string} path
213
+ */
214
+ MutableDataContext.prototype._notify = function(path) {
215
+ var subs = this._subscribers[path];
216
+ if (subs) {
217
+ for (var i = 0; i < subs.length; i++) subs[i]();
218
+ }
219
+ };
220
+
221
+ // ---------------------------------------------------------------------------
222
+ // Interpolation — {{field}} replacement
223
+ // ---------------------------------------------------------------------------
224
+
225
+ var INTERP_RE = /\{\{([^}]+)\}\}/g;
226
+
227
+ /**
228
+ * Replace all {{field}} tokens in a string using the given DataContext.
229
+ *
230
+ * @param {string} str – Source string.
231
+ * @param {DataContext} ctx – Data context for resolution.
232
+ * @param {boolean} uriEnc – If true, URI-encode each substituted value.
233
+ * @returns {string}
234
+ */
235
+ function interpolate(str, ctx, uriEnc) {
236
+ return str.replace(INTERP_RE, function (_match, path) {
237
+ var val = ctx.resolve(path.trim());
238
+ if (val === undefined) {
239
+ if (config.debug) {
240
+ console.warn("[xhtmlx] unresolved interpolation: {{" + path + "}}");
241
+ }
242
+ return "";
243
+ }
244
+ var s = String(val);
245
+ return uriEnc ? encodeURIComponent(s) : s;
246
+ });
247
+ }
248
+
249
+ /**
250
+ * Walk a DOM tree and interpolate {{field}} tokens in text nodes and
251
+ * non-xh-* attributes. Leaves xh-* attribute values untouched so they
252
+ * can be processed later with the correct data context (e.g. per-item
253
+ * context inside xh-each).
254
+ *
255
+ * @param {Element} root
256
+ * @param {DataContext} ctx
257
+ */
258
+ function interpolateDOM(root, ctx) {
259
+ var walker = document.createTreeWalker(root, 4 /* NodeFilter.SHOW_TEXT */);
260
+ var textNodes = [];
261
+ while (walker.nextNode()) {
262
+ textNodes.push(walker.currentNode);
263
+ }
264
+ for (var t = 0; t < textNodes.length; t++) {
265
+ var node = textNodes[t];
266
+ if (INTERP_RE.test(node.nodeValue)) {
267
+ node.nodeValue = interpolate(node.nodeValue, ctx, false);
268
+ }
269
+ }
270
+
271
+ // Interpolate non-xh-* attributes on all elements
272
+ var elements = root.querySelectorAll("*");
273
+ for (var e = 0; e < elements.length; e++) {
274
+ var attrs = elements[e].attributes;
275
+ for (var a = 0; a < attrs.length; a++) {
276
+ var name = attrs[a].name;
277
+ // Skip xh-* attributes — they are processed by directives/executeRequest
278
+ if (name.indexOf("xh-") === 0) continue;
279
+ if (INTERP_RE.test(attrs[a].value)) {
280
+ attrs[a].value = interpolate(attrs[a].value, ctx, false);
281
+ }
282
+ }
283
+ }
284
+ }
285
+
286
+ // ---------------------------------------------------------------------------
287
+ // Template loading (external fetch + cache, inline <template>, self)
288
+ // ---------------------------------------------------------------------------
289
+
290
+ /**
291
+ * Fetch an external template by URL, with deduplication and caching.
292
+ * @param {string} url
293
+ * @returns {Promise<string>}
294
+ */
295
+ function fetchTemplate(url) {
296
+ // Prepend template prefix (for UI versioning)
297
+ var fetchUrl = config.templatePrefix ? config.templatePrefix + url : url;
298
+ // Cache by prefixed URL so version switches get fresh templates
299
+ if (templateCache.has(fetchUrl)) return templateCache.get(fetchUrl);
300
+ var promise = fetch(fetchUrl).then(function (res) {
301
+ if (!res.ok) throw new Error("Template fetch failed: " + fetchUrl + " (" + res.status + ")");
302
+ return res.text();
303
+ });
304
+ templateCache.set(fetchUrl, promise);
305
+ return promise;
306
+ }
307
+
308
+ /**
309
+ * Resolve the template HTML for an element.
310
+ *
311
+ * Priority:
312
+ * 1. xh-template attribute (external URL)
313
+ * 2. Inline <template> child
314
+ * 3. null (self-binding — element itself is the target)
315
+ *
316
+ * @param {Element} el
317
+ * @param {string[]} templateStack – URLs already being resolved (circular check)
318
+ * @returns {Promise<{html: string|null, isExternal: boolean}>}
319
+ */
320
+ function resolveTemplate(el, templateStack) {
321
+ var url = el.getAttribute("xh-template");
322
+
323
+ // -- external template ----------------------------------------------------
324
+ if (url) {
325
+ if (templateStack.indexOf(url) !== -1) {
326
+ console.error("[xhtmlx] circular template reference detected: " + url);
327
+ return Promise.reject(new Error("Circular template: " + url));
328
+ }
329
+ var newStack = templateStack.concat(url);
330
+ return fetchTemplate(url).then(function (html) {
331
+ return { html: html, isExternal: true, templateStack: newStack };
332
+ });
333
+ }
334
+
335
+ // -- inline <template> ----------------------------------------------------
336
+ var tpl = el.querySelector(":scope > template");
337
+ if (tpl) {
338
+ return Promise.resolve({
339
+ html: tpl.innerHTML,
340
+ isExternal: false,
341
+ templateStack: templateStack
342
+ });
343
+ }
344
+
345
+ // -- self-binding ---------------------------------------------------------
346
+ return Promise.resolve({
347
+ html: null,
348
+ isExternal: false,
349
+ templateStack: templateStack
350
+ });
351
+ }
352
+
353
+ // ---------------------------------------------------------------------------
354
+ // Directive processing
355
+ // ---------------------------------------------------------------------------
356
+
357
+ var REST_VERBS = ["xh-get", "xh-post", "xh-put", "xh-delete", "xh-patch"];
358
+
359
+ /**
360
+ * Returns the REST verb attribute on an element, if any.
361
+ * @param {Element} el
362
+ * @returns {{verb: string, url: string}|null}
363
+ */
364
+ function getRestVerb(el) {
365
+ for (var i = 0; i < REST_VERBS.length; i++) {
366
+ var url = el.getAttribute(REST_VERBS[i]);
367
+ if (url != null) {
368
+ var method = REST_VERBS[i].replace("xh-", "").toUpperCase();
369
+ return { verb: method, url: url };
370
+ }
371
+ }
372
+ return null;
373
+ }
374
+
375
+ /**
376
+ * Apply data-binding directives to a single element (xh-text, xh-html,
377
+ * xh-attr-*, xh-if, xh-unless). Does NOT handle xh-each — that is done
378
+ * separately because it clones elements.
379
+ *
380
+ * @param {Element} el
381
+ * @param {DataContext} ctx
382
+ * @returns {boolean} false if the element was removed by xh-if/xh-unless
383
+ */
384
+ function applyBindings(el, ctx) {
385
+ // -- xh-show ----------------------------------------------------------------
386
+ var showAttr = el.getAttribute("xh-show");
387
+ if (showAttr != null) {
388
+ var sval = ctx.resolve(showAttr);
389
+ el.style.display = sval ? "" : "none";
390
+ if (ctx instanceof MutableDataContext) {
391
+ (function(field, element, context) {
392
+ context.subscribe(field, function() {
393
+ var newVal = context.resolve(field);
394
+ element.style.display = newVal ? "" : "none";
395
+ });
396
+ })(showAttr, el, ctx);
397
+ }
398
+ }
399
+
400
+ // -- xh-hide ----------------------------------------------------------------
401
+ var hideAttr = el.getAttribute("xh-hide");
402
+ if (hideAttr != null) {
403
+ var hdval = ctx.resolve(hideAttr);
404
+ el.style.display = hdval ? "none" : "";
405
+ if (ctx instanceof MutableDataContext) {
406
+ (function(field, element, context) {
407
+ context.subscribe(field, function() {
408
+ var newVal = context.resolve(field);
409
+ element.style.display = newVal ? "none" : "";
410
+ });
411
+ })(hideAttr, el, ctx);
412
+ }
413
+ }
414
+
415
+ // -- xh-if ----------------------------------------------------------------
416
+ var ifAttr = el.getAttribute("xh-if");
417
+ if (ifAttr != null) {
418
+ var val = ctx.resolve(ifAttr);
419
+ if (!val) {
420
+ el.remove();
421
+ return false;
422
+ }
423
+ }
424
+
425
+ // -- xh-unless ------------------------------------------------------------
426
+ var unlessAttr = el.getAttribute("xh-unless");
427
+ if (unlessAttr != null) {
428
+ var uval = ctx.resolve(unlessAttr);
429
+ if (uval) {
430
+ el.remove();
431
+ return false;
432
+ }
433
+ }
434
+
435
+ // -- xh-text --------------------------------------------------------------
436
+ var textAttr = el.getAttribute("xh-text");
437
+ if (textAttr != null) {
438
+ var tv = ctx.resolve(textAttr);
439
+ el.textContent = tv != null ? String(tv) : "";
440
+ if (ctx instanceof MutableDataContext) {
441
+ (function(field, element, context) {
442
+ context.subscribe(field, function() {
443
+ var newVal = context.resolve(field);
444
+ element.textContent = newVal != null ? String(newVal) : "";
445
+ });
446
+ })(textAttr, el, ctx);
447
+ }
448
+ }
449
+
450
+ // -- xh-html --------------------------------------------------------------
451
+ var htmlAttr = el.getAttribute("xh-html");
452
+ if (htmlAttr != null) {
453
+ var hv = ctx.resolve(htmlAttr);
454
+ el.innerHTML = hv != null ? String(hv) : "";
455
+ if (ctx instanceof MutableDataContext) {
456
+ (function(field, element, context) {
457
+ context.subscribe(field, function() {
458
+ var newVal = context.resolve(field);
459
+ element.innerHTML = newVal != null ? String(newVal) : "";
460
+ });
461
+ })(htmlAttr, el, ctx);
462
+ }
463
+ }
464
+
465
+ // -- xh-attr-* ------------------------------------------------------------
466
+ var attrs = el.attributes;
467
+ for (var i = attrs.length - 1; i >= 0; i--) {
468
+ var aName = attrs[i].name;
469
+ if (aName.indexOf("xh-attr-") === 0) {
470
+ var targetAttr = aName.slice(8); // after "xh-attr-"
471
+ var aval = ctx.resolve(attrs[i].value);
472
+ if (aval != null) {
473
+ el.setAttribute(targetAttr, String(aval));
474
+ }
475
+ if (ctx instanceof MutableDataContext) {
476
+ (function(field, tAttr, element, context) {
477
+ context.subscribe(field, function() {
478
+ var newVal = context.resolve(field);
479
+ if (newVal != null) {
480
+ element.setAttribute(tAttr, String(newVal));
481
+ }
482
+ });
483
+ })(attrs[i].value, targetAttr, el, ctx);
484
+ }
485
+ }
486
+ }
487
+
488
+ // -- xh-model ---------------------------------------------------------------
489
+ var modelAttr = el.getAttribute("xh-model");
490
+ if (modelAttr != null) {
491
+ var mv = ctx.resolve(modelAttr);
492
+ var tag = el.tagName.toLowerCase();
493
+ var type = (el.getAttribute("type") || "").toLowerCase();
494
+
495
+ if (tag === "select") {
496
+ // Set the matching option as selected
497
+ var options = el.options;
498
+ for (var s = 0; s < options.length; s++) {
499
+ options[s].selected = (options[s].value === mv);
500
+ }
501
+ } else if (type === "checkbox") {
502
+ el.checked = !!mv;
503
+ } else if (type === "radio") {
504
+ el.checked = (el.value === mv);
505
+ } else if (tag === "textarea") {
506
+ el.value = mv != null ? String(mv) : "";
507
+ } else {
508
+ // text, email, number, hidden, etc.
509
+ el.value = mv != null ? String(mv) : "";
510
+ }
511
+
512
+ // Live reactivity: when the user edits an xh-model input, call ctx.set()
513
+ if (ctx instanceof MutableDataContext) {
514
+ (function(field, element, context) {
515
+ var eventName = (type === "checkbox" || type === "radio" || tag === "select") ? "change" : "input";
516
+ element.addEventListener(eventName, function() {
517
+ var newValue;
518
+ if (type === "checkbox") {
519
+ newValue = element.checked;
520
+ } else {
521
+ newValue = element.value;
522
+ }
523
+ context.set(field, newValue);
524
+ });
525
+ })(modelAttr, el, ctx);
526
+ }
527
+ }
528
+
529
+ // -- xh-class-* -------------------------------------------------------------
530
+ for (var c = attrs.length - 1; c >= 0; c--) {
531
+ var cName = attrs[c].name;
532
+ if (cName.indexOf("xh-class-") === 0) {
533
+ var className = cName.slice(9); // after "xh-class-"
534
+ var cval = ctx.resolve(attrs[c].value);
535
+ if (cval) {
536
+ el.classList.add(className);
537
+ } else {
538
+ el.classList.remove(className);
539
+ }
540
+ if (ctx instanceof MutableDataContext) {
541
+ (function(field, clsName, element, context) {
542
+ context.subscribe(field, function() {
543
+ var newVal = context.resolve(field);
544
+ if (newVal) {
545
+ element.classList.add(clsName);
546
+ } else {
547
+ element.classList.remove(clsName);
548
+ }
549
+ });
550
+ })(attrs[c].value, className, el, ctx);
551
+ }
552
+ }
553
+ }
554
+
555
+ // -- custom directives -------------------------------------------------------
556
+ for (var cd = 0; cd < customDirectives.length; cd++) {
557
+ var directive = customDirectives[cd];
558
+ var cdVal = el.getAttribute(directive.name);
559
+ if (cdVal != null) {
560
+ directive.handler(el, cdVal, ctx);
561
+ }
562
+ }
563
+
564
+ return true;
565
+ }
566
+
567
+ /**
568
+ * Process xh-each on an element. Clones the element for each item in the
569
+ * array, applies bindings, and recursively processes each clone.
570
+ *
571
+ * @param {Element} el
572
+ * @param {DataContext} ctx
573
+ */
574
+ function processEach(el, ctx) {
575
+ var eachAttr = el.getAttribute("xh-each");
576
+ if (eachAttr == null) return false;
577
+
578
+ var arr = ctx.resolve(eachAttr);
579
+ if (!Array.isArray(arr)) {
580
+ if (config.debug) {
581
+ console.warn("[xhtmlx] xh-each: '" + eachAttr + "' did not resolve to an array");
582
+ }
583
+ el.remove();
584
+ return true;
585
+ }
586
+
587
+ var parent = el.parentNode;
588
+ if (!parent) return true;
589
+
590
+ // Remove the xh-each attribute from the template element so clones don't
591
+ // re-trigger iteration.
592
+ el.removeAttribute("xh-each");
593
+
594
+ var fragment = document.createDocumentFragment();
595
+
596
+ var renderItem = function (item, idx) {
597
+ var clone = el.cloneNode(true);
598
+ // Mark clone so renderTemplate's second pass doesn't rebind with wrong context
599
+ clone.setAttribute("data-xh-each-item", "");
600
+ var ItemCtxClass = (ctx instanceof MutableDataContext) ? MutableDataContext : DataContext;
601
+ var itemCtx = new ItemCtxClass(item, ctx, idx);
602
+ applyBindings(clone, itemCtx);
603
+ // Process children bindings
604
+ processBindingsInTree(clone, itemCtx);
605
+ // Recursively process for nested REST verb elements
606
+ processNode(clone, itemCtx);
607
+ fragment.appendChild(clone);
608
+ };
609
+
610
+ if (arr.length > config.batchThreshold) {
611
+ // For large arrays we still render synchronously for simplicity but
612
+ // could be enhanced with rAF batching in a future version.
613
+ for (var i = 0; i < arr.length; i++) {
614
+ renderItem(arr[i], i);
615
+ }
616
+ } else {
617
+ for (var j = 0; j < arr.length; j++) {
618
+ renderItem(arr[j], j);
619
+ }
620
+ }
621
+
622
+ parent.insertBefore(fragment, el);
623
+ parent.removeChild(el);
624
+ return true;
625
+ }
626
+
627
+ /**
628
+ * Walk a subtree applying bindings (xh-text, xh-html, xh-attr-*, xh-if,
629
+ * xh-unless, xh-each) to every element. This is used after template
630
+ * rendering.
631
+ *
632
+ * @param {Element} root
633
+ * @param {DataContext} ctx
634
+ */
635
+ function processBindingsInTree(root, ctx) {
636
+ // Collect elements that need processing. We snapshot because DOM
637
+ // mutations (xh-each, xh-if) can modify the live list.
638
+ var elements = Array.prototype.slice.call(root.querySelectorAll("*"));
639
+ for (var i = 0; i < elements.length; i++) {
640
+ var el = elements[i];
641
+ // Skip if already detached from DOM
642
+ if (!el.parentNode) continue;
643
+
644
+ // xh-each must be handled before other bindings on the same element
645
+ if (el.hasAttribute("xh-each")) {
646
+ processEach(el, ctx);
647
+ continue;
648
+ }
649
+
650
+ // Apply simple bindings
651
+ applyBindings(el, ctx);
652
+ }
653
+ }
654
+
655
+ // ---------------------------------------------------------------------------
656
+ // Trigger parsing
657
+ // ---------------------------------------------------------------------------
658
+
659
+ /**
660
+ * Determine the default trigger event for an element.
661
+ * @param {Element} el
662
+ * @returns {string}
663
+ */
664
+ function defaultTrigger(el) {
665
+ var tag = el.tagName.toLowerCase();
666
+ if (tag === "form") return "submit";
667
+ if (tag === "input" || tag === "select" || tag === "textarea") return "change";
668
+ return "click"; // buttons, links, divs, etc.
669
+ }
670
+
671
+ /**
672
+ * Parse the xh-trigger attribute into an array of trigger spec objects.
673
+ *
674
+ * Syntax examples:
675
+ * "click"
676
+ * "click once"
677
+ * "keyup changed delay:300ms"
678
+ * "load"
679
+ * "every 5s"
680
+ * "revealed"
681
+ * "click from:#other-button"
682
+ *
683
+ * @param {string} raw
684
+ * @returns {Object[]}
685
+ */
686
+ function parseTrigger(raw) {
687
+ if (!raw || !raw.trim()) return [];
688
+
689
+ // Multiple triggers can be comma-separated
690
+ var parts = raw.split(",");
691
+ var specs = [];
692
+
693
+ for (var p = 0; p < parts.length; p++) {
694
+ var tokens = parts[p].trim().split(/\s+/);
695
+ if (tokens.length === 0 || tokens[0] === "") continue;
696
+
697
+ var spec = {
698
+ event: tokens[0],
699
+ delay: 0,
700
+ throttle: 0,
701
+ once: false,
702
+ changed: false,
703
+ from: null,
704
+ interval: 0 // for "every Ns"
705
+ };
706
+
707
+ // Handle "every Ns"
708
+ if (spec.event === "every" && tokens.length >= 2) {
709
+ var match = tokens[1].match(/^(\d+)(s|ms)$/);
710
+ if (match) {
711
+ spec.interval = match[2] === "s" ? parseInt(match[1]) * 1000 : parseInt(match[1]);
712
+ spec.event = "every";
713
+ // Continue parsing modifiers after the interval token
714
+ tokens = tokens.slice(2);
715
+ }
716
+ } else {
717
+ tokens = tokens.slice(1);
718
+ }
719
+
720
+ // Parse modifiers
721
+ for (var t = 0; t < tokens.length; t++) {
722
+ var tok = tokens[t];
723
+ if (tok === "once") {
724
+ spec.once = true;
725
+ } else if (tok === "changed") {
726
+ spec.changed = true;
727
+ } else if (tok.indexOf("delay:") === 0) {
728
+ spec.delay = parseTimeValue(tok.slice(6));
729
+ } else if (tok.indexOf("throttle:") === 0) {
730
+ spec.throttle = parseTimeValue(tok.slice(9));
731
+ } else if (tok.indexOf("from:") === 0) {
732
+ spec.from = tok.slice(5);
733
+ }
734
+ }
735
+
736
+ specs.push(spec);
737
+ }
738
+ return specs;
739
+ }
740
+
741
+ /**
742
+ * Parse a time value like "300ms" or "2s" into milliseconds.
743
+ * @param {string} val
744
+ * @returns {number}
745
+ */
746
+ function parseTimeValue(val) {
747
+ var m = val.match(/^(\d+)(ms|s)$/);
748
+ if (!m) return 0;
749
+ return m[2] === "s" ? parseInt(m[1]) * 1000 : parseInt(m[1]);
750
+ }
751
+
752
+ // ---------------------------------------------------------------------------
753
+ // Request handling
754
+ // ---------------------------------------------------------------------------
755
+
756
+ /**
757
+ * Build the request body for a POST/PUT/PATCH request.
758
+ *
759
+ * @param {Element} el – The element that triggered the request.
760
+ * @param {DataContext} ctx – Current data context.
761
+ * @returns {string|null}
762
+ */
763
+ function buildRequestBody(el, ctx) {
764
+ var body = {};
765
+
766
+ // If the trigger element is (or is inside) a form, serialize the form
767
+ var form = el.tagName.toLowerCase() === "form" ? el : el.closest("form");
768
+ if (form) {
769
+ var formData = new FormData(form);
770
+ formData.forEach(function (value, key) {
771
+ body[key] = value;
772
+ });
773
+ }
774
+
775
+ // Merge xh-vals
776
+ var valsAttr = el.getAttribute("xh-vals");
777
+ if (valsAttr) {
778
+ try {
779
+ var valsInterpolated = interpolate(valsAttr, ctx, false);
780
+ var vals = JSON.parse(valsInterpolated);
781
+ for (var k in vals) {
782
+ if (vals.hasOwnProperty(k)) {
783
+ body[k] = vals[k];
784
+ }
785
+ }
786
+ } catch (e) {
787
+ console.error("[xhtmlx] invalid JSON in xh-vals:", valsAttr, e);
788
+ }
789
+ }
790
+
791
+ // Collect xh-model values from the element's scope
792
+ var scope = form || el.closest("[xh-get],[xh-post],[xh-put],[xh-patch],[xh-delete]") || el;
793
+ var modelInputs = scope.querySelectorAll("[xh-model]");
794
+ for (var m = 0; m < modelInputs.length; m++) {
795
+ var mEl = modelInputs[m];
796
+ var field = mEl.getAttribute("xh-model");
797
+ var mTag = mEl.tagName.toLowerCase();
798
+ var mType = (mEl.getAttribute("type") || "").toLowerCase();
799
+
800
+ if (mType === "checkbox") {
801
+ body[field] = mEl.checked;
802
+ } else if (mType === "radio") {
803
+ if (mEl.checked) body[field] = mEl.value;
804
+ } else if (mTag === "select") {
805
+ body[field] = mEl.value;
806
+ } else {
807
+ body[field] = mEl.value;
808
+ }
809
+ }
810
+
811
+ return JSON.stringify(body);
812
+ }
813
+
814
+ /**
815
+ * Parse the xh-headers attribute.
816
+ * @param {Element} el
817
+ * @param {DataContext} ctx
818
+ * @returns {Object}
819
+ */
820
+ function parseHeaders(el, ctx) {
821
+ var hdrs = {};
822
+ var raw = el.getAttribute("xh-headers");
823
+ if (raw) {
824
+ try {
825
+ var interpolated = interpolate(raw, ctx, false);
826
+ hdrs = JSON.parse(interpolated);
827
+ } catch (e) {
828
+ console.error("[xhtmlx] invalid JSON in xh-headers:", raw, e);
829
+ }
830
+ }
831
+ return hdrs;
832
+ }
833
+
834
+ // ---------------------------------------------------------------------------
835
+ // Indicator helpers
836
+ // ---------------------------------------------------------------------------
837
+
838
+ function showIndicator(el) {
839
+ var sel = el.getAttribute("xh-indicator");
840
+ if (!sel) return;
841
+ var ind = document.querySelector(sel);
842
+ if (ind) ind.classList.add(config.requestClass);
843
+ }
844
+
845
+ function hideIndicator(el) {
846
+ var sel = el.getAttribute("xh-indicator");
847
+ if (!sel) return;
848
+ var ind = document.querySelector(sel);
849
+ if (ind) ind.classList.remove(config.requestClass);
850
+ }
851
+
852
+ // ---------------------------------------------------------------------------
853
+ // Custom DOM events
854
+ // ---------------------------------------------------------------------------
855
+
856
+ /**
857
+ * Dispatch a custom event on an element.
858
+ * @param {Element} el
859
+ * @param {string} name – Event name (e.g. "xh:beforeRequest")
860
+ * @param {Object} detail – Event detail data
861
+ * @param {boolean} cancelable
862
+ * @returns {boolean} false if event was preventDefault()'d
863
+ */
864
+ function emitEvent(el, name, detail, cancelable) {
865
+ var evt = new CustomEvent(name, {
866
+ bubbles: true,
867
+ cancelable: !!cancelable,
868
+ detail: detail || {}
869
+ });
870
+ return el.dispatchEvent(evt);
871
+ }
872
+
873
+ // ---------------------------------------------------------------------------
874
+ // Error template resolution
875
+ // ---------------------------------------------------------------------------
876
+
877
+ /**
878
+ * Determine which error template URL to use for a given HTTP status code.
879
+ *
880
+ * Resolution order:
881
+ * 1. xh-error-template-{exact code} on the element
882
+ * 2. xh-error-template-{class} on the element (e.g. 4xx)
883
+ * 3. xh-error-template on the element (generic)
884
+ * 4. nearest ancestor xh-error-boundary with template/target
885
+ * 5. config.defaultErrorTemplate global fallback
886
+ * 6. null just add CSS class
887
+ *
888
+ * @param {Element} el
889
+ * @param {number} status
890
+ * @returns {string|null}
891
+ */
892
+ function resolveErrorTemplate(el, status) {
893
+ // 1. exact code on element
894
+ var exact = el.getAttribute("xh-error-template-" + status);
895
+ if (exact) return exact;
896
+
897
+ // 2. class (4xx, 5xx) on element
898
+ var cls = Math.floor(status / 100) + "xx";
899
+ var classAttr = el.getAttribute("xh-error-template-" + cls);
900
+ if (classAttr) return classAttr;
901
+
902
+ // 3. generic on element
903
+ var generic = el.getAttribute("xh-error-template");
904
+ if (generic) return generic;
905
+
906
+ // 4. nearest ancestor xh-error-boundary
907
+ var boundary = findErrorBoundary(el, status);
908
+ if (boundary) return boundary.template;
909
+
910
+ // 5. global config
911
+ if (config.defaultErrorTemplate) return config.defaultErrorTemplate;
912
+
913
+ return null;
914
+ }
915
+
916
+ /**
917
+ * Walk up the DOM to find the nearest xh-error-boundary ancestor.
918
+ * An error boundary can have its own xh-error-template[-code] attributes.
919
+ *
920
+ * @param {Element} el
921
+ * @param {number} status
922
+ * @returns {{template: string, target: Element|null}|null}
923
+ */
924
+ function findErrorBoundary(el, status) {
925
+ var node = el.parentElement;
926
+ while (node) {
927
+ if (node.hasAttribute("xh-error-boundary")) {
928
+ // Check status-specific template on boundary
929
+ var cls = Math.floor(status / 100) + "xx";
930
+ var tmpl = node.getAttribute("xh-error-template-" + status) ||
931
+ node.getAttribute("xh-error-template-" + cls) ||
932
+ node.getAttribute("xh-error-template");
933
+ if (tmpl) {
934
+ return { template: tmpl, boundaryEl: node };
935
+ }
936
+ }
937
+ node = node.parentElement;
938
+ }
939
+ return null;
940
+ }
941
+
942
+ // ---------------------------------------------------------------------------
943
+ // Swap helpers
944
+ // ---------------------------------------------------------------------------
945
+
946
+ /**
947
+ * Get the target element for swapping content.
948
+ * @param {Element} el – The element that triggered the request.
949
+ * @param {boolean} isError – Whether this is an error swap.
950
+ * @returns {Element}
951
+ */
952
+ function getSwapTarget(el, isError, status) {
953
+ var sel;
954
+ if (isError) {
955
+ // 1. Element-level xh-error-target
956
+ sel = el.getAttribute("xh-error-target");
957
+ if (sel) {
958
+ var t = document.querySelector(sel);
959
+ if (t) return t;
960
+ }
961
+
962
+ // 2. Error boundary target
963
+ if (status) {
964
+ var boundary = findErrorBoundary(el, status);
965
+ if (boundary) {
966
+ var bTarget = boundary.boundaryEl.getAttribute("xh-error-target");
967
+ if (bTarget) {
968
+ var bt = document.querySelector(bTarget);
969
+ if (bt) return bt;
970
+ }
971
+ // If boundary has no xh-error-target, swap into the boundary itself
972
+ return boundary.boundaryEl;
973
+ }
974
+ }
975
+
976
+ // 3. Global config error target
977
+ if (config.defaultErrorTarget) {
978
+ var gt = document.querySelector(config.defaultErrorTarget);
979
+ if (gt) return gt;
980
+ }
981
+
982
+ // 4. Fall back to xh-target or self
983
+ sel = el.getAttribute("xh-target");
984
+ if (sel) {
985
+ var xt = document.querySelector(sel);
986
+ if (xt) return xt;
987
+ }
988
+ } else {
989
+ sel = el.getAttribute("xh-target");
990
+ if (sel) {
991
+ var target = document.querySelector(sel);
992
+ if (target) return target;
993
+ if (config.debug) console.warn("[xhtmlx] target not found:", sel);
994
+ }
995
+ }
996
+ return el;
997
+ }
998
+
999
+ /**
1000
+ * Clean up any intervals or observers associated with elements that are
1001
+ * about to be removed from the DOM.
1002
+ * @param {Element} container
1003
+ */
1004
+ function cleanupBeforeSwap(container, includeContainer) {
1005
+ // Clean up elements inside the container
1006
+ var all = container.querySelectorAll("*");
1007
+ for (var i = 0; i < all.length; i++) {
1008
+ cleanupElement(all[i]);
1009
+ }
1010
+ // Only clean up the container itself when it will be removed from the DOM
1011
+ // (outerHTML, delete). For innerHTML, the container survives and should
1012
+ // keep its own intervals/observers.
1013
+ if (includeContainer) {
1014
+ cleanupElement(container);
1015
+ }
1016
+ }
1017
+
1018
+ /**
1019
+ * Clean up a single element's intervals and state.
1020
+ * @param {Element} el
1021
+ */
1022
+ function cleanupElement(el) {
1023
+ var state = elementStates.get(el);
1024
+ if (!state) return;
1025
+ if (state.intervalIds) {
1026
+ for (var j = 0; j < state.intervalIds.length; j++) {
1027
+ clearInterval(state.intervalIds[j]);
1028
+ }
1029
+ }
1030
+ if (state.observers) {
1031
+ for (var k = 0; k < state.observers.length; k++) {
1032
+ state.observers[k].disconnect();
1033
+ }
1034
+ }
1035
+ if (state.ws) {
1036
+ state.ws.close(1000);
1037
+ state.ws = null;
1038
+ }
1039
+ }
1040
+
1041
+ /**
1042
+ * Perform the DOM swap.
1043
+ *
1044
+ * @param {Element} target – The target element.
1045
+ * @param {DocumentFragment} fragment – Rendered content.
1046
+ * @param {string} mode – Swap mode.
1047
+ * @returns {Element|null} The element that should be processed recursively,
1048
+ * or null for "none"/"delete".
1049
+ */
1050
+ function performSwap(target, fragment, mode) {
1051
+ switch (mode) {
1052
+ case "innerHTML":
1053
+ cleanupBeforeSwap(target, false);
1054
+ target.innerHTML = "";
1055
+ target.appendChild(fragment);
1056
+ return target;
1057
+
1058
+ case "outerHTML":
1059
+ cleanupBeforeSwap(target, true);
1060
+ var placeholder = document.createComment("xhtmlx-swap");
1061
+ target.parentNode.insertBefore(placeholder, target);
1062
+ target.parentNode.removeChild(target);
1063
+ // Insert all children of fragment before the placeholder
1064
+ var wrapper = document.createElement("div");
1065
+ wrapper.appendChild(fragment);
1066
+ var children = Array.prototype.slice.call(wrapper.childNodes);
1067
+ for (var i = 0; i < children.length; i++) {
1068
+ placeholder.parentNode.insertBefore(children[i], placeholder);
1069
+ }
1070
+ placeholder.parentNode.removeChild(placeholder);
1071
+ // Return the parent so we process the new nodes
1072
+ return children.length === 1 && children[0].nodeType === 1 ? children[0] : null;
1073
+
1074
+ case "beforeend":
1075
+ target.appendChild(fragment);
1076
+ return target;
1077
+
1078
+ case "afterbegin":
1079
+ target.insertBefore(fragment, target.firstChild);
1080
+ return target;
1081
+
1082
+ case "beforebegin":
1083
+ target.parentNode.insertBefore(fragment, target);
1084
+ return target.parentNode;
1085
+
1086
+ case "afterend":
1087
+ target.parentNode.insertBefore(fragment, target.nextSibling);
1088
+ return target.parentNode;
1089
+
1090
+ case "delete":
1091
+ cleanupBeforeSwap(target, true);
1092
+ target.remove();
1093
+ return null;
1094
+
1095
+ case "none":
1096
+ return null;
1097
+
1098
+ default:
1099
+ console.warn("[xhtmlx] unknown swap mode:", mode);
1100
+ cleanupBeforeSwap(target, false);
1101
+ target.innerHTML = "";
1102
+ target.appendChild(fragment);
1103
+ return target;
1104
+ }
1105
+ }
1106
+
1107
+ // ---------------------------------------------------------------------------
1108
+ // Rendering pipeline
1109
+ // ---------------------------------------------------------------------------
1110
+
1111
+ /**
1112
+ * Render a template HTML string with a DataContext, producing a
1113
+ * DocumentFragment ready for DOM insertion.
1114
+ *
1115
+ * Steps:
1116
+ * 1. Interpolate {{field}} tokens
1117
+ * 2. Parse into a DocumentFragment
1118
+ * 3. Process directives (xh-each, xh-if, xh-unless, xh-text, xh-html, xh-attr-*)
1119
+ *
1120
+ * @param {string} html
1121
+ * @param {DataContext} ctx
1122
+ * @returns {DocumentFragment}
1123
+ */
1124
+ function renderTemplate(html, ctx) {
1125
+ // 1. Parse into fragment (no global interpolation — that would replace
1126
+ // {{field}} inside xh-* attributes with the wrong context)
1127
+ var tpl = document.createElement("template");
1128
+ tpl.innerHTML = html;
1129
+ var fragment = document.importNode(tpl.content, true);
1130
+
1131
+ // 2. Process directives in the fragment
1132
+ // We need a temporary container because DocumentFragment doesn't support
1133
+ // querySelectorAll with :scope or certain traversals in all browsers.
1134
+ var container = document.createElement("div");
1135
+ container.appendChild(fragment);
1136
+
1137
+ // 2a. Interpolate {{field}} in text nodes and non-xh-* attributes only.
1138
+ // This leaves xh-get URLs, xh-text values, etc. for later processing
1139
+ // with the correct per-item data context.
1140
+ interpolateDOM(container, ctx);
1141
+
1142
+ // Process xh-each first (top-level only, they handle their own children)
1143
+ var eachEls = Array.prototype.slice.call(container.querySelectorAll("[xh-each]"));
1144
+ for (var i = 0; i < eachEls.length; i++) {
1145
+ if (!eachEls[i].parentNode) continue;
1146
+ // Only process top-level xh-each (not nested inside another xh-each)
1147
+ var isNested = false;
1148
+ var check = eachEls[i].parentNode;
1149
+ while (check && check !== container) {
1150
+ if (check.hasAttribute && check.hasAttribute("xh-each")) {
1151
+ isNested = true;
1152
+ break;
1153
+ }
1154
+ check = check.parentNode;
1155
+ }
1156
+ if (!isNested) {
1157
+ processEach(eachEls[i], ctx);
1158
+ }
1159
+ }
1160
+
1161
+ // Process other bindings (skip elements already handled by xh-each)
1162
+ var allEls = Array.prototype.slice.call(container.querySelectorAll("*"));
1163
+ for (var j = 0; j < allEls.length; j++) {
1164
+ if (!allEls[j].parentNode) continue;
1165
+ // Skip elements that still have xh-each (shouldn't happen, but guard)
1166
+ if (allEls[j].hasAttribute("xh-each")) continue;
1167
+ // Skip elements with REST verbs — they will be processed by processNode
1168
+ if (getRestVerb(allEls[j])) continue;
1169
+ // Skip elements created by xh-each — they were already bound with the
1170
+ // correct per-item context inside processEach
1171
+ if (allEls[j].hasAttribute("data-xh-each-item") ||
1172
+ allEls[j].closest("[data-xh-each-item]")) continue;
1173
+ applyBindings(allEls[j], ctx);
1174
+ }
1175
+
1176
+ // Move children back into a new fragment
1177
+ var resultFragment = document.createDocumentFragment();
1178
+ while (container.firstChild) {
1179
+ resultFragment.appendChild(container.firstChild);
1180
+ }
1181
+ return resultFragment;
1182
+ }
1183
+
1184
+ // ---------------------------------------------------------------------------
1185
+ // Settle class helpers
1186
+ // ---------------------------------------------------------------------------
1187
+
1188
+ /**
1189
+ * Apply settle classes to newly inserted elements.
1190
+ * Adds "xh-added" immediately, then swaps to "xh-settled" after two
1191
+ * animation frames so that CSS transitions can react.
1192
+ *
1193
+ * @param {Element} processTarget – The container of new elements.
1194
+ */
1195
+ function applySettleClasses(processTarget) {
1196
+ if (!processTarget) return;
1197
+ var newEls = processTarget.querySelectorAll ? Array.prototype.slice.call(processTarget.querySelectorAll("*")) : [];
1198
+ if (processTarget.classList) newEls.unshift(processTarget);
1199
+
1200
+ for (var se = 0; se < newEls.length; se++) {
1201
+ if (newEls[se].classList) newEls[se].classList.add("xh-added");
1202
+ }
1203
+
1204
+ requestAnimationFrame(function () {
1205
+ requestAnimationFrame(function () {
1206
+ for (var sf = 0; sf < newEls.length; sf++) {
1207
+ if (newEls[sf].classList) {
1208
+ newEls[sf].classList.remove("xh-added");
1209
+ newEls[sf].classList.add("xh-settled");
1210
+ }
1211
+ }
1212
+ });
1213
+ });
1214
+ }
1215
+
1216
+ // ---------------------------------------------------------------------------
1217
+ // Retry with backoff helper
1218
+ // ---------------------------------------------------------------------------
1219
+
1220
+ /**
1221
+ * Fetch with automatic retry on 5xx errors and network failures.
1222
+ *
1223
+ * @param {string} url – Request URL.
1224
+ * @param {Object} opts – Fetch options.
1225
+ * @param {number} retries – Maximum number of retries.
1226
+ * @param {number} delay – Base delay in ms (doubled each attempt).
1227
+ * @param {number} attempt – Current attempt (0-based).
1228
+ * @param {Element} el – Element for emitting events.
1229
+ * @returns {Promise<Response>}
1230
+ */
1231
+ function fetchWithRetry(url, opts, retries, delay, attempt, el) {
1232
+ return fetch(url, opts).then(function (response) {
1233
+ if (!response.ok && response.status >= 500 && attempt < retries) {
1234
+ emitEvent(el, "xh:retry", { attempt: attempt + 1, maxRetries: retries, status: response.status }, false);
1235
+ return new Promise(function (resolve) {
1236
+ setTimeout(function () {
1237
+ resolve(fetchWithRetry(url, opts, retries, delay, attempt + 1, el));
1238
+ }, delay * Math.pow(2, attempt));
1239
+ });
1240
+ }
1241
+ return response;
1242
+ }).catch(function (err) {
1243
+ if (attempt < retries) {
1244
+ emitEvent(el, "xh:retry", { attempt: attempt + 1, maxRetries: retries, error: err.message }, false);
1245
+ return new Promise(function (resolve) {
1246
+ setTimeout(function () {
1247
+ resolve(fetchWithRetry(url, opts, retries, delay, attempt + 1, el));
1248
+ }, delay * Math.pow(2, attempt));
1249
+ });
1250
+ }
1251
+ throw err;
1252
+ });
1253
+ }
1254
+
1255
+ // ---------------------------------------------------------------------------
1256
+ // Validation
1257
+ // ---------------------------------------------------------------------------
1258
+
1259
+ /**
1260
+ * Validate fields within the scope of an element.
1261
+ * Looks for [xh-validate] elements in the form or element scope and checks
1262
+ * rules: required, pattern, min/max, minlength/maxlength.
1263
+ *
1264
+ * @param {Element} el – The element that triggered the request.
1265
+ * @returns {boolean} true if all valid, false if errors found.
1266
+ */
1267
+ function validateElement(el) {
1268
+ var scope = el.tagName.toLowerCase() === "form" ? el : el.closest("form") || el;
1269
+ var fields = scope.querySelectorAll("[xh-validate]");
1270
+ var errors = [];
1271
+
1272
+ for (var i = 0; i < fields.length; i++) {
1273
+ var field = fields[i];
1274
+ var rules = field.getAttribute("xh-validate").split(" ");
1275
+ var value = field.value || "";
1276
+ var fieldName = field.getAttribute("name") || field.getAttribute("xh-model") || "field";
1277
+ var customMsg = field.getAttribute("xh-validate-message");
1278
+ var errorClass = field.getAttribute("xh-validate-class") || "xh-invalid";
1279
+ var errorTarget = field.getAttribute("xh-validate-target");
1280
+ var error = null;
1281
+
1282
+ for (var r = 0; r < rules.length; r++) {
1283
+ var rule = rules[r];
1284
+ if (rule === "required" && !value.trim()) {
1285
+ error = customMsg || fieldName + " is required";
1286
+ }
1287
+ }
1288
+
1289
+ // xh-validate-pattern
1290
+ var pattern = field.getAttribute("xh-validate-pattern");
1291
+ if (pattern && value && !new RegExp(pattern).test(value)) {
1292
+ error = customMsg || fieldName + " format is invalid";
1293
+ }
1294
+
1295
+ // xh-validate-min / xh-validate-max
1296
+ var min = field.getAttribute("xh-validate-min");
1297
+ var max = field.getAttribute("xh-validate-max");
1298
+ if (min != null && Number(value) < Number(min)) {
1299
+ error = customMsg || fieldName + " must be at least " + min;
1300
+ }
1301
+ if (max != null && Number(value) > Number(max)) {
1302
+ error = customMsg || fieldName + " must be at most " + max;
1303
+ }
1304
+
1305
+ // xh-validate-minlength / xh-validate-maxlength
1306
+ var minlen = field.getAttribute("xh-validate-minlength");
1307
+ var maxlen = field.getAttribute("xh-validate-maxlength");
1308
+ if (minlen != null && value.length < Number(minlen)) {
1309
+ error = customMsg || fieldName + " must be at least " + minlen + " characters";
1310
+ }
1311
+ if (maxlen != null && value.length > Number(maxlen)) {
1312
+ error = customMsg || fieldName + " must be at most " + maxlen + " characters";
1313
+ }
1314
+
1315
+ if (error) {
1316
+ field.classList.add(errorClass);
1317
+ if (errorTarget) {
1318
+ var tgt = document.querySelector(errorTarget);
1319
+ if (tgt) tgt.textContent = error;
1320
+ }
1321
+ errors.push({ field: fieldName, message: error, element: field });
1322
+ } else {
1323
+ field.classList.remove(errorClass);
1324
+ if (errorTarget) {
1325
+ var tgt2 = document.querySelector(errorTarget);
1326
+ if (tgt2) tgt2.textContent = "";
1327
+ }
1328
+ }
1329
+ }
1330
+
1331
+ if (errors.length > 0) {
1332
+ emitEvent(el, "xh:validationError", { errors: errors }, false);
1333
+ return false;
1334
+ }
1335
+ return true;
1336
+ }
1337
+
1338
+ // ---------------------------------------------------------------------------
1339
+ // Main request handler
1340
+ // ---------------------------------------------------------------------------
1341
+
1342
+ /**
1343
+ * Execute a REST request triggered by an element.
1344
+ *
1345
+ * @param {Element} el
1346
+ * @param {DataContext} ctx
1347
+ * @param {string[]} templateStack – For circular template detection.
1348
+ */
1349
+ function executeRequest(el, ctx, templateStack) {
1350
+ var restInfo = getRestVerb(el);
1351
+ if (!restInfo) return;
1352
+
1353
+ // Increment generation counter to handle stale responses
1354
+ var gen = (generationMap.get(el) || 0) + 1;
1355
+ generationMap.set(el, gen);
1356
+
1357
+ // -- Request deduplication: skip if already in-flight ---------------------
1358
+ var state = elementStates.get(el);
1359
+ if (state && state.requestInFlight) {
1360
+ if (config.debug) console.warn("[xhtmlx] request already in-flight, skipping");
1361
+ return;
1362
+ }
1363
+
1364
+ // Mark request in-flight
1365
+ if (state) state.requestInFlight = true;
1366
+
1367
+ // Interpolate URL with URI encoding, prepend API prefix for versioning
1368
+ var url = interpolate(restInfo.url, ctx, true);
1369
+ if (config.apiPrefix && url.indexOf("://") === -1) {
1370
+ url = config.apiPrefix + url;
1371
+ }
1372
+
1373
+ // Build fetch options
1374
+ var fetchOpts = { method: restInfo.verb, headers: {} };
1375
+
1376
+ // Custom headers
1377
+ var customHeaders = parseHeaders(el, ctx);
1378
+ for (var h in customHeaders) {
1379
+ if (customHeaders.hasOwnProperty(h)) {
1380
+ fetchOpts.headers[h] = customHeaders[h];
1381
+ }
1382
+ }
1383
+
1384
+ // Request body for POST/PUT/PATCH
1385
+ if (restInfo.verb === "POST" || restInfo.verb === "PUT" || restInfo.verb === "PATCH") {
1386
+ fetchOpts.headers["Content-Type"] = fetchOpts.headers["Content-Type"] || "application/json";
1387
+ fetchOpts.body = buildRequestBody(el, ctx);
1388
+ }
1389
+
1390
+ // Run global plugin hooks before request
1391
+ var hookAllowed = runHooks("beforeRequest", {
1392
+ url: url, method: restInfo.verb, headers: fetchOpts.headers, element: el
1393
+ });
1394
+ if (!hookAllowed) {
1395
+ if (state) state.requestInFlight = false;
1396
+ return;
1397
+ }
1398
+
1399
+ // Emit xh:beforeRequest (cancelable)
1400
+ var allowed = emitEvent(el, "xh:beforeRequest", {
1401
+ url: url,
1402
+ method: restInfo.verb,
1403
+ headers: fetchOpts.headers
1404
+ }, true);
1405
+ if (!allowed) {
1406
+ if (state) state.requestInFlight = false;
1407
+ return;
1408
+ }
1409
+
1410
+ // Validate before sending
1411
+ if (!validateElement(el)) {
1412
+ if (state) state.requestInFlight = false;
1413
+ return;
1414
+ }
1415
+
1416
+ // Show indicator
1417
+ showIndicator(el);
1418
+
1419
+ // Accessibility: mark target as busy
1420
+ var ariaTarget = getSwapTarget(el, false);
1421
+ if (ariaTarget) ariaTarget.setAttribute("aria-busy", "true");
1422
+
1423
+ // xh-disabled-class: add CSS class while request is in-flight
1424
+ var disabledClass = el.getAttribute("xh-disabled-class");
1425
+ if (disabledClass) {
1426
+ el.classList.add(disabledClass);
1427
+ el.setAttribute("aria-disabled", "true");
1428
+ }
1429
+
1430
+ // -- Response caching (xh-cache) ------------------------------------------
1431
+ var cacheAttr = el.getAttribute("xh-cache");
1432
+ var cacheKey = restInfo.verb + ":" + url;
1433
+
1434
+ if (cacheAttr && restInfo.verb === "GET") {
1435
+ var cached = responseCache.get(cacheKey);
1436
+ if (cached) {
1437
+ var age = Date.now() - cached.timestamp;
1438
+ var ttl = cacheAttr === "forever" ? Infinity : parseInt(cacheAttr, 10) * 1000;
1439
+ if (age < ttl) {
1440
+ // Use cached response — create a fake Response-like object
1441
+ var fakeResponse = {
1442
+ ok: true, status: 200, statusText: "OK (cached)",
1443
+ text: function () { return Promise.resolve(cached.data); }
1444
+ };
1445
+ processFetchResponse(fakeResponse);
1446
+ return;
1447
+ }
1448
+ responseCache.delete(cacheKey);
1449
+ }
1450
+ }
1451
+
1452
+ // -- Retry with backoff (xh-retry) ----------------------------------------
1453
+ var retryAttr = el.getAttribute("xh-retry");
1454
+ var retryCount = retryAttr ? parseInt(retryAttr, 10) : 0;
1455
+ var retryDelayAttr = el.getAttribute("xh-retry-delay");
1456
+ var retryDelay = retryDelayAttr ? parseInt(retryDelayAttr, 10) : 1000;
1457
+
1458
+ var fetchPromise = retryCount > 0
1459
+ ? fetchWithRetry(url, fetchOpts, retryCount, retryDelay, 0, el)
1460
+ : fetch(url, fetchOpts);
1461
+
1462
+ fetchPromise
1463
+ .then(processFetchResponse)
1464
+ .catch(function (err) {
1465
+ console.error("[xhtmlx] request failed:", url, err);
1466
+ handleError(el, ctx, 0, "Network Error", err.message, templateStack);
1467
+ })
1468
+ .finally(function () {
1469
+ hideIndicator(el);
1470
+ if (ariaTarget) ariaTarget.removeAttribute("aria-busy");
1471
+ if (disabledClass) {
1472
+ el.classList.remove(disabledClass);
1473
+ el.removeAttribute("aria-disabled");
1474
+ }
1475
+ if (state) state.requestInFlight = false;
1476
+ });
1477
+
1478
+ function processFetchResponse(response) {
1479
+ // Emit xh:afterRequest
1480
+ emitEvent(el, "xh:afterRequest", { url: url, status: response.status }, false);
1481
+
1482
+ // Stale response check
1483
+ if (generationMap.get(el) !== gen) {
1484
+ if (config.debug) console.warn("[xhtmlx] discarding stale response for", url);
1485
+ return;
1486
+ }
1487
+
1488
+ if (!response.ok) {
1489
+ // Error path
1490
+ return response.text().then(function (bodyText) {
1491
+ var errorBody;
1492
+ try {
1493
+ errorBody = JSON.parse(bodyText);
1494
+ } catch (_) {
1495
+ errorBody = bodyText;
1496
+ }
1497
+ handleError(el, ctx, response.status, response.statusText, errorBody, templateStack);
1498
+ });
1499
+ }
1500
+
1501
+ // Success path — parse JSON
1502
+ return response.text().then(function (bodyText) {
1503
+ var jsonData;
1504
+ if (bodyText.trim() === "") {
1505
+ jsonData = {};
1506
+ } else {
1507
+ try {
1508
+ jsonData = JSON.parse(bodyText);
1509
+ } catch (e) {
1510
+ console.error("[xhtmlx] invalid JSON response from", url, e);
1511
+ handleError(el, ctx, response.status, "Invalid JSON", bodyText, templateStack);
1512
+ return;
1513
+ }
1514
+ }
1515
+
1516
+ // Cache the response if xh-cache is set
1517
+ if (cacheAttr && restInfo.verb === "GET" && bodyText) {
1518
+ responseCache.set(cacheKey, { data: bodyText, timestamp: Date.now() });
1519
+ }
1520
+
1521
+ var childCtx = new MutableDataContext(jsonData, ctx);
1522
+
1523
+ // Resolve and render template
1524
+ return resolveTemplate(el, templateStack).then(function (tmpl) {
1525
+ var swapMode = el.getAttribute("xh-swap") || config.defaultSwapMode;
1526
+ var target = getSwapTarget(el, false);
1527
+
1528
+ if (tmpl.html !== null) {
1529
+ // Render from template HTML
1530
+ var fragment = renderTemplate(tmpl.html, childCtx);
1531
+
1532
+ // Emit xh:beforeSwap (cancelable)
1533
+ var swapAllowed = emitEvent(el, "xh:beforeSwap", {
1534
+ target: target,
1535
+ fragment: fragment,
1536
+ swapMode: swapMode
1537
+ }, true);
1538
+ if (!swapAllowed) return;
1539
+
1540
+ var processTarget = performSwap(target, fragment, swapMode);
1541
+
1542
+ // Apply settle classes to newly added elements
1543
+ applySettleClasses(processTarget);
1544
+
1545
+ // Recursively process new content
1546
+ if (processTarget) {
1547
+ processNode(processTarget, childCtx, tmpl.templateStack);
1548
+ }
1549
+
1550
+ // Emit xh:afterSwap
1551
+ emitEvent(el, "xh:afterSwap", { target: target }, false);
1552
+
1553
+ // -- xh-focus: focus management after swap ----------------------------
1554
+ var focusEl = el.getAttribute("xh-focus");
1555
+ if (focusEl && focusEl !== "auto") {
1556
+ var toFocus = document.querySelector(focusEl);
1557
+ if (toFocus) toFocus.focus();
1558
+ } else if (focusEl === "auto" && processTarget) {
1559
+ var focusable = processTarget.querySelector("a, button, input, select, textarea, [tabindex]");
1560
+ if (focusable) focusable.focus();
1561
+ }
1562
+
1563
+ } else {
1564
+ // Self-binding: apply bindings directly to the element
1565
+ applyBindings(el, childCtx);
1566
+
1567
+ // Also process children for bindings
1568
+ processBindingsInTree(el, childCtx);
1569
+
1570
+ // Emit swap events
1571
+ emitEvent(el, "xh:afterSwap", { target: el }, false);
1572
+ }
1573
+
1574
+ // -- xh-push-url -------------------------------------------------------
1575
+ var pushUrl = el.getAttribute("xh-push-url");
1576
+ if (pushUrl) {
1577
+ var historyUrl = pushUrl === "true" ? url : interpolate(pushUrl, childCtx, false);
1578
+ var historyState = {
1579
+ xhtmlx: true,
1580
+ url: restInfo.url,
1581
+ verb: restInfo.verb,
1582
+ targetSel: el.getAttribute("xh-target"),
1583
+ templateUrl: el.getAttribute("xh-template")
1584
+ };
1585
+ history.pushState(historyState, "", historyUrl);
1586
+ }
1587
+
1588
+ // -- xh-replace-url ----------------------------------------------------
1589
+ var replaceUrl = el.getAttribute("xh-replace-url");
1590
+ if (replaceUrl) {
1591
+ var rUrl = replaceUrl === "true" ? url : interpolate(replaceUrl, childCtx, false);
1592
+ history.replaceState({ xhtmlx: true }, "", rUrl);
1593
+ }
1594
+
1595
+ // Store data context and URLs for reload/versioning
1596
+ if (state) {
1597
+ state.dataContext = childCtx;
1598
+ state.templateUrl = el.getAttribute("xh-template");
1599
+ state.apiUrl = restInfo.url;
1600
+ state.apiVerb = restInfo.verb;
1601
+ }
1602
+ });
1603
+ });
1604
+ }
1605
+ }
1606
+
1607
+ /**
1608
+ * Handle an error response: resolve error template, render, and swap.
1609
+ *
1610
+ * @param {Element} el
1611
+ * @param {DataContext} ctx
1612
+ * @param {number} status
1613
+ * @param {string} statusText
1614
+ * @param {*} body
1615
+ * @param {string[]} templateStack
1616
+ */
1617
+ function handleError(el, ctx, status, statusText, body, templateStack) {
1618
+ var errorData = { status: status, statusText: statusText, body: body };
1619
+ var errorCtx = new DataContext(errorData, ctx);
1620
+
1621
+ // Emit xh:responseError
1622
+ emitEvent(el, "xh:responseError", errorData, false);
1623
+
1624
+ // Resolve error template
1625
+ var errorTemplateUrl = resolveErrorTemplate(el, status);
1626
+
1627
+ if (!errorTemplateUrl) {
1628
+ // No error template — just add error class
1629
+ el.classList.add(config.errorClass);
1630
+ return;
1631
+ }
1632
+
1633
+ // Fetch and render error template
1634
+ fetchTemplate(errorTemplateUrl)
1635
+ .then(function (html) {
1636
+ var fragment = renderTemplate(html, errorCtx);
1637
+ var errorTarget = getSwapTarget(el, true, status);
1638
+ var swapMode = el.getAttribute("xh-swap") || config.defaultSwapMode;
1639
+
1640
+ var swapAllowed = emitEvent(el, "xh:beforeSwap", {
1641
+ target: errorTarget,
1642
+ fragment: fragment,
1643
+ swapMode: swapMode,
1644
+ isError: true
1645
+ }, true);
1646
+ if (!swapAllowed) return;
1647
+
1648
+ var processTarget = performSwap(errorTarget, fragment, swapMode);
1649
+
1650
+ // Apply settle classes to newly added error elements
1651
+ applySettleClasses(processTarget);
1652
+
1653
+ if (processTarget) {
1654
+ processNode(processTarget, errorCtx, templateStack);
1655
+ }
1656
+
1657
+ // Accessibility: mark error container with role="alert"
1658
+ errorTarget.setAttribute("role", "alert");
1659
+
1660
+ el.classList.add(config.errorClass);
1661
+ emitEvent(el, "xh:afterSwap", { target: errorTarget, isError: true }, false);
1662
+ })
1663
+ .catch(function (err) {
1664
+ console.error("[xhtmlx] error template fetch failed:", err);
1665
+ el.classList.add(config.errorClass);
1666
+ });
1667
+ }
1668
+
1669
+ // ---------------------------------------------------------------------------
1670
+ // Trigger attachment
1671
+ // ---------------------------------------------------------------------------
1672
+
1673
+ /**
1674
+ * Attach trigger listeners to an element.
1675
+ *
1676
+ * @param {Element} el
1677
+ * @param {DataContext} ctx
1678
+ * @param {string[]} templateStack
1679
+ */
1680
+ function attachTriggers(el, ctx, templateStack) {
1681
+ var triggerAttr = el.getAttribute("xh-trigger");
1682
+ var specs;
1683
+
1684
+ if (triggerAttr) {
1685
+ specs = parseTrigger(triggerAttr);
1686
+ } else {
1687
+ // Use default trigger
1688
+ specs = [{ event: defaultTrigger(el), delay: 0, throttle: 0, once: false, changed: false, from: null, interval: 0 }];
1689
+ }
1690
+
1691
+ var state = elementStates.get(el) || {};
1692
+ state.triggerSpecs = specs;
1693
+ state.intervalIds = state.intervalIds || [];
1694
+ state.observers = state.observers || [];
1695
+ state.processed = true;
1696
+ elementStates.set(el, state);
1697
+
1698
+ for (var i = 0; i < specs.length; i++) {
1699
+ attachSingleTrigger(el, ctx, templateStack, specs[i], state);
1700
+ }
1701
+ }
1702
+
1703
+ /**
1704
+ * Attach a single trigger spec to an element.
1705
+ */
1706
+ function attachSingleTrigger(el, ctx, templateStack, spec, state) {
1707
+ // --- "load" trigger: fire immediately -------------------------------------
1708
+ if (spec.event === "load") {
1709
+ executeRequest(el, ctx, templateStack);
1710
+ return;
1711
+ }
1712
+
1713
+ // --- "every Ns" trigger: set an interval ---------------------------------
1714
+ if (spec.event === "every" && spec.interval > 0) {
1715
+ var intervalId = setInterval(function () {
1716
+ executeRequest(el, ctx, templateStack);
1717
+ }, spec.interval);
1718
+ state.intervalIds.push(intervalId);
1719
+ return;
1720
+ }
1721
+
1722
+ // --- "revealed" trigger: IntersectionObserver -----------------------------
1723
+ if (spec.event === "revealed") {
1724
+ var observer = new IntersectionObserver(function (entries) {
1725
+ for (var e = 0; e < entries.length; e++) {
1726
+ if (entries[e].isIntersecting) {
1727
+ executeRequest(el, ctx, templateStack);
1728
+ if (spec.once) observer.disconnect();
1729
+ }
1730
+ }
1731
+ }, { threshold: 0.1 });
1732
+ observer.observe(el);
1733
+ state.observers.push(observer);
1734
+ return;
1735
+ }
1736
+
1737
+ // --- Standard DOM event trigger ------------------------------------------
1738
+ var listenTarget = el;
1739
+ if (spec.from) {
1740
+ var fromEl = document.querySelector(spec.from);
1741
+ if (fromEl) {
1742
+ listenTarget = fromEl;
1743
+ } else if (config.debug) {
1744
+ console.warn("[xhtmlx] from: selector not found:", spec.from);
1745
+ }
1746
+ }
1747
+
1748
+ // Build the handler with optional modifiers
1749
+ var handler = buildHandler(el, ctx, templateStack, spec);
1750
+
1751
+ var eventOptions = spec.once ? { once: true } : false;
1752
+ listenTarget.addEventListener(spec.event, handler, eventOptions);
1753
+ }
1754
+
1755
+ /**
1756
+ * Build an event handler function incorporating delay, throttle, and changed
1757
+ * modifiers.
1758
+ */
1759
+ function buildHandler(el, ctx, templateStack, spec) {
1760
+ var lastValue = undefined;
1761
+ var delayTimer = null;
1762
+ var throttleTimer = null;
1763
+ var throttlePending = false;
1764
+
1765
+ return function (evt) {
1766
+ // Prevent default for forms
1767
+ if (spec.event === "submit") {
1768
+ evt.preventDefault();
1769
+ }
1770
+
1771
+ // "changed" modifier — only fire if value changed
1772
+ if (spec.changed) {
1773
+ var currentValue;
1774
+ var source = evt.target || el;
1775
+ if ("value" in source) {
1776
+ currentValue = source.value;
1777
+ } else {
1778
+ currentValue = source.textContent;
1779
+ }
1780
+ if (currentValue === lastValue) return;
1781
+ lastValue = currentValue;
1782
+ }
1783
+
1784
+ var fire = function () {
1785
+ executeRequest(el, ctx, templateStack);
1786
+ };
1787
+
1788
+ // "delay" modifier — debounce
1789
+ if (spec.delay > 0) {
1790
+ if (delayTimer) clearTimeout(delayTimer);
1791
+ delayTimer = setTimeout(fire, spec.delay);
1792
+ return;
1793
+ }
1794
+
1795
+ // "throttle" modifier
1796
+ if (spec.throttle > 0) {
1797
+ if (throttleTimer) {
1798
+ throttlePending = true;
1799
+ return;
1800
+ }
1801
+ fire();
1802
+ throttleTimer = setTimeout(function () {
1803
+ throttleTimer = null;
1804
+ if (throttlePending) {
1805
+ throttlePending = false;
1806
+ fire();
1807
+ }
1808
+ }, spec.throttle);
1809
+ return;
1810
+ }
1811
+
1812
+ fire();
1813
+ };
1814
+ }
1815
+
1816
+ // ---------------------------------------------------------------------------
1817
+ // WebSocket support
1818
+ // ---------------------------------------------------------------------------
1819
+
1820
+ function setupWebSocket(el, ctx, templateStack) {
1821
+ var wsUrl = el.getAttribute("xh-ws");
1822
+ if (!wsUrl) return;
1823
+
1824
+ var ws;
1825
+ try {
1826
+ ws = new WebSocket(wsUrl);
1827
+ } catch (e) {
1828
+ console.error("[xhtmlx] WebSocket connection failed:", wsUrl, e);
1829
+ return;
1830
+ }
1831
+
1832
+ var state = elementStates.get(el) || {};
1833
+ state.ws = ws;
1834
+ elementStates.set(el, state);
1835
+
1836
+ ws.addEventListener("message", function(event) {
1837
+ var jsonData;
1838
+ try {
1839
+ jsonData = JSON.parse(event.data);
1840
+ } catch (e) {
1841
+ if (config.debug) console.warn("[xhtmlx] WebSocket message is not JSON:", event.data);
1842
+ return;
1843
+ }
1844
+
1845
+ var childCtx = new DataContext(jsonData, ctx);
1846
+
1847
+ resolveTemplate(el, templateStack).then(function(tmpl) {
1848
+ if (tmpl.html !== null) {
1849
+ var swapMode = el.getAttribute("xh-swap") || config.defaultSwapMode;
1850
+ var target = getSwapTarget(el, false);
1851
+ var fragment = renderTemplate(tmpl.html, childCtx);
1852
+
1853
+ var swapAllowed = emitEvent(el, "xh:beforeSwap", {
1854
+ target: target, fragment: fragment, swapMode: swapMode
1855
+ }, true);
1856
+ if (!swapAllowed) return;
1857
+
1858
+ var processTarget = performSwap(target, fragment, swapMode);
1859
+ if (processTarget) {
1860
+ processNode(processTarget, childCtx, tmpl.templateStack);
1861
+ }
1862
+ emitEvent(el, "xh:afterSwap", { target: target }, false);
1863
+ } else {
1864
+ applyBindings(el, childCtx);
1865
+ processBindingsInTree(el, childCtx);
1866
+ }
1867
+ });
1868
+ });
1869
+
1870
+ ws.addEventListener("open", function() {
1871
+ emitEvent(el, "xh:wsOpen", { url: wsUrl }, false);
1872
+ });
1873
+
1874
+ ws.addEventListener("close", function(event) {
1875
+ emitEvent(el, "xh:wsClose", { code: event.code, reason: event.reason }, false);
1876
+ // Auto-reconnect after 3 seconds if not deliberately closed
1877
+ if (event.code !== 1000) {
1878
+ setTimeout(function() {
1879
+ if (el.parentNode) setupWebSocket(el, ctx, templateStack);
1880
+ }, 3000);
1881
+ }
1882
+ });
1883
+
1884
+ ws.addEventListener("error", function() {
1885
+ emitEvent(el, "xh:wsError", { url: wsUrl }, false);
1886
+ });
1887
+ }
1888
+
1889
+ // For sending messages
1890
+ function setupWsSend(el) {
1891
+ var sendTarget = el.getAttribute("xh-ws-send");
1892
+ if (!sendTarget) return;
1893
+
1894
+ el.addEventListener(el.tagName.toLowerCase() === "form" ? "submit" : "click", function(evt) {
1895
+ evt.preventDefault();
1896
+ var wsEl = document.querySelector(sendTarget);
1897
+ if (!wsEl) return;
1898
+ var wsState = elementStates.get(wsEl);
1899
+ if (!wsState || !wsState.ws || wsState.ws.readyState !== 1) return;
1900
+
1901
+ var data = {};
1902
+ var form = el.tagName.toLowerCase() === "form" ? el : el.closest("form");
1903
+ if (form) {
1904
+ new FormData(form).forEach(function(v, k) { data[k] = v; });
1905
+ }
1906
+ var vals = el.getAttribute("xh-vals");
1907
+ if (vals) {
1908
+ try {
1909
+ var parsed = JSON.parse(vals);
1910
+ for (var k in parsed) {
1911
+ if (parsed.hasOwnProperty(k)) data[k] = parsed[k];
1912
+ }
1913
+ } catch(e) {
1914
+ // ignore invalid JSON
1915
+ }
1916
+ }
1917
+ wsState.ws.send(JSON.stringify(data));
1918
+ });
1919
+ }
1920
+
1921
+ // ---------------------------------------------------------------------------
1922
+ // xh-boost — enhance regular links and forms
1923
+ // ---------------------------------------------------------------------------
1924
+
1925
+ function boostElement(container, ctx) {
1926
+ // Boost links
1927
+ var links = container.querySelectorAll("a[href]");
1928
+ for (var i = 0; i < links.length; i++) {
1929
+ if (links[i].hasAttribute("xh-get") || links[i].hasAttribute("data-xh-boosted")) continue;
1930
+ boostLink(links[i], ctx);
1931
+ }
1932
+
1933
+ // Boost forms
1934
+ var forms = container.querySelectorAll("form[action]");
1935
+ for (var j = 0; j < forms.length; j++) {
1936
+ if (getRestVerb(forms[j]) || forms[j].hasAttribute("data-xh-boosted")) continue;
1937
+ boostForm(forms[j], ctx);
1938
+ }
1939
+ }
1940
+
1941
+ function boostLink(link, ctx) {
1942
+ var href = link.getAttribute("href");
1943
+ if (!href || href.indexOf("#") === 0 || href.indexOf("javascript:") === 0 || href.indexOf("mailto:") === 0 || link.getAttribute("target") === "_blank") return;
1944
+
1945
+ link.setAttribute("data-xh-boosted", "");
1946
+ link.addEventListener("click", function(e) {
1947
+ e.preventDefault();
1948
+ var boostContainer = link.closest("[xh-boost]");
1949
+ var boostTarget = boostContainer.getAttribute("xh-boost-target") || "#xh-boost-content";
1950
+ var target = document.querySelector(boostTarget);
1951
+ if (!target) target = document.body;
1952
+
1953
+ showIndicator(boostContainer);
1954
+
1955
+ fetch(href).then(function(response) {
1956
+ return response.text();
1957
+ }).then(function(text) {
1958
+ var jsonData;
1959
+ try {
1960
+ jsonData = JSON.parse(text);
1961
+ } catch(e) {
1962
+ // If not JSON, treat as HTML and swap directly
1963
+ target.innerHTML = text;
1964
+ processNode(target, ctx, []);
1965
+ return;
1966
+ }
1967
+ var childCtx = new DataContext(jsonData, ctx);
1968
+ var templateUrl = boostContainer.getAttribute("xh-boost-template");
1969
+ if (templateUrl) {
1970
+ fetchTemplate(templateUrl).then(function(html) {
1971
+ var fragment = renderTemplate(html, childCtx);
1972
+ target.innerHTML = "";
1973
+ target.appendChild(fragment);
1974
+ processNode(target, childCtx, []);
1975
+ });
1976
+ } else {
1977
+ // Self-bind
1978
+ applyBindings(target, childCtx);
1979
+ processBindingsInTree(target, childCtx);
1980
+ }
1981
+ }).finally(function() {
1982
+ hideIndicator(boostContainer);
1983
+ });
1984
+
1985
+ // Push URL
1986
+ if (typeof history !== "undefined" && history.pushState) {
1987
+ history.pushState({ xhtmlx: true, url: href }, "", href);
1988
+ }
1989
+ });
1990
+ }
1991
+
1992
+ function boostForm(form, ctx) {
1993
+ var action = form.getAttribute("action");
1994
+ var method = (form.getAttribute("method") || "GET").toUpperCase();
1995
+ if (!action) return;
1996
+
1997
+ form.setAttribute("data-xh-boosted", "");
1998
+ form.addEventListener("submit", function(e) {
1999
+ e.preventDefault();
2000
+ var body = {};
2001
+ new FormData(form).forEach(function(v, k) { body[k] = v; });
2002
+
2003
+ var fetchOpts = { method: method, headers: {} };
2004
+ if (method !== "GET") {
2005
+ fetchOpts.headers["Content-Type"] = "application/json";
2006
+ fetchOpts.body = JSON.stringify(body);
2007
+ }
2008
+
2009
+ var boostContainer = form.closest("[xh-boost]");
2010
+ var boostTarget = boostContainer.getAttribute("xh-boost-target") || "#xh-boost-content";
2011
+ var target = document.querySelector(boostTarget);
2012
+ if (!target) target = form;
2013
+
2014
+ fetch(action, fetchOpts).then(function(response) {
2015
+ return response.text();
2016
+ }).then(function(text) {
2017
+ var jsonData;
2018
+ try { jsonData = JSON.parse(text); } catch(e) { return; }
2019
+ var childCtx = new DataContext(jsonData, ctx);
2020
+ var templateUrl = boostContainer.getAttribute("xh-boost-template");
2021
+ if (templateUrl) {
2022
+ fetchTemplate(templateUrl).then(function(html) {
2023
+ var fragment = renderTemplate(html, childCtx);
2024
+ target.innerHTML = "";
2025
+ target.appendChild(fragment);
2026
+ processNode(target, childCtx, []);
2027
+ });
2028
+ }
2029
+ });
2030
+ });
2031
+ }
2032
+
2033
+ // ---------------------------------------------------------------------------
2034
+ // Plugin / Extension API
2035
+ // ---------------------------------------------------------------------------
2036
+
2037
+ var customDirectives = []; // [{name, handler}]
2038
+ var globalHooks = {}; // event -> [handler]
2039
+ var transforms = {}; // name -> function
2040
+
2041
+ function registerDirective(name, handler) {
2042
+ customDirectives.push({ name: name, handler: handler });
2043
+ }
2044
+
2045
+ function registerHook(event, handler) {
2046
+ if (!globalHooks[event]) globalHooks[event] = [];
2047
+ globalHooks[event].push(handler);
2048
+ }
2049
+
2050
+ function registerTransform(name, fn) {
2051
+ transforms[name] = fn;
2052
+ }
2053
+
2054
+ function runHooks(event, detail) {
2055
+ var hooks = globalHooks[event];
2056
+ if (!hooks) return true;
2057
+ for (var i = 0; i < hooks.length; i++) {
2058
+ var result = hooks[i](detail);
2059
+ if (result === false) return false;
2060
+ }
2061
+ return true;
2062
+ }
2063
+
2064
+ // ---------------------------------------------------------------------------
2065
+ // Core processing loop
2066
+ // ---------------------------------------------------------------------------
2067
+
2068
+ /**
2069
+ * Process a DOM node: find all descendants with xh-* attributes, attach
2070
+ * triggers for REST verb elements, and apply bindings for binding-only
2071
+ * elements.
2072
+ *
2073
+ * @param {Element} root
2074
+ * @param {DataContext} ctx
2075
+ * @param {string[]} templateStack – For circular template detection.
2076
+ */
2077
+ function processNode(root, ctx, templateStack) {
2078
+ templateStack = templateStack || [];
2079
+
2080
+ if (!root || root.nodeType !== 1) return;
2081
+
2082
+ // Process the root element itself if it has xh-* attributes
2083
+ processElement(root, ctx, templateStack);
2084
+
2085
+ // Find all descendant elements with any xh-* attribute
2086
+ // Use a broad selector that catches all xh- prefixed attributes
2087
+ var candidates = gatherXhElements(root);
2088
+
2089
+ for (var i = 0; i < candidates.length; i++) {
2090
+ var el = candidates[i];
2091
+ // Skip already processed elements
2092
+ var existingState = elementStates.get(el);
2093
+ if (existingState && existingState.processed) continue;
2094
+ // Skip detached elements
2095
+ if (!el.parentNode) continue;
2096
+
2097
+ processElement(el, ctx, templateStack);
2098
+ }
2099
+ }
2100
+
2101
+ /**
2102
+ * Gather elements that have xh-* attributes within a root node.
2103
+ * @param {Element} root
2104
+ * @returns {Element[]}
2105
+ */
2106
+ function gatherXhElements(root) {
2107
+ // We need a comprehensive selector for any element with xh-* attributes
2108
+ var selectors = [
2109
+ "[xh-get]", "[xh-post]", "[xh-put]", "[xh-delete]", "[xh-patch]",
2110
+ "[xh-text]", "[xh-html]", "[xh-each]", "[xh-if]", "[xh-unless]",
2111
+ "[xh-trigger]", "[xh-template]", "[xh-target]", "[xh-swap]",
2112
+ "[xh-indicator]", "[xh-vals]", "[xh-headers]",
2113
+ "[xh-error-template]", "[xh-error-target]",
2114
+ "[xh-model]", "[xh-show]", "[xh-hide]",
2115
+ "[xh-disabled-class]",
2116
+ "[xh-push-url]", "[xh-replace-url]",
2117
+ "[xh-cache]",
2118
+ "[xh-retry]",
2119
+ "[xh-ws]", "[xh-ws-send]",
2120
+ "[xh-boost]",
2121
+ "[xh-validate]",
2122
+ "[xh-i18n]",
2123
+ "[xh-router]", "[xh-route]",
2124
+ "[xh-focus]", "[xh-aria-live]"
2125
+ ];
2126
+
2127
+ // Also match xh-attr-* and xh-error-template-*
2128
+ var results = [];
2129
+ var all;
2130
+ try {
2131
+ all = root.querySelectorAll(selectors.join(","));
2132
+ } catch (_) {
2133
+ all = root.querySelectorAll("*");
2134
+ }
2135
+
2136
+ var seen = new Set();
2137
+ for (var i = 0; i < all.length; i++) {
2138
+ if (!seen.has(all[i])) {
2139
+ seen.add(all[i]);
2140
+ results.push(all[i]);
2141
+ }
2142
+ }
2143
+
2144
+ // Also check for xh-attr-* elements (they won't match the fixed selectors)
2145
+ var allEls = root.querySelectorAll("*");
2146
+ for (var j = 0; j < allEls.length; j++) {
2147
+ if (seen.has(allEls[j])) continue;
2148
+ var attrs = allEls[j].attributes;
2149
+ for (var k = 0; k < attrs.length; k++) {
2150
+ if (attrs[k].name.indexOf("xh-attr-") === 0 ||
2151
+ attrs[k].name.indexOf("xh-error-template-") === 0 ||
2152
+ attrs[k].name.indexOf("xh-class-") === 0 ||
2153
+ attrs[k].name.indexOf("xh-on-") === 0 ||
2154
+ attrs[k].name.indexOf("xh-i18n-") === 0) {
2155
+ results.push(allEls[j]);
2156
+ seen.add(allEls[j]);
2157
+ break;
2158
+ }
2159
+ }
2160
+ }
2161
+
2162
+ return results;
2163
+ }
2164
+
2165
+ // ---------------------------------------------------------------------------
2166
+ // xh-on-* event handler helper
2167
+ // ---------------------------------------------------------------------------
2168
+
2169
+ /**
2170
+ * Attach a declarative event handler for xh-on-{event} directives.
2171
+ *
2172
+ * @param {Element} el – The element to attach the handler to.
2173
+ * @param {string} event – The DOM event name (e.g. "click", "dblclick").
2174
+ * @param {string} actionStr – The action string (e.g. "toggleClass:active").
2175
+ */
2176
+ function attachOnHandler(el, event, actionStr) {
2177
+ el.addEventListener(event, function(_evt) {
2178
+ var parts = actionStr.split(":");
2179
+ var action = parts[0];
2180
+ var arg = parts.slice(1).join(":");
2181
+
2182
+ switch (action) {
2183
+ case "toggleClass":
2184
+ el.classList.toggle(arg);
2185
+ break;
2186
+ case "addClass":
2187
+ el.classList.add(arg);
2188
+ break;
2189
+ case "removeClass":
2190
+ el.classList.remove(arg);
2191
+ break;
2192
+ case "remove":
2193
+ el.remove();
2194
+ break;
2195
+ case "toggle":
2196
+ var target = document.querySelector(arg);
2197
+ if (target) {
2198
+ target.style.display = target.style.display === "none" ? "" : "none";
2199
+ }
2200
+ break;
2201
+ case "dispatch":
2202
+ el.dispatchEvent(new CustomEvent(arg, { bubbles: true, detail: {} }));
2203
+ break;
2204
+ default:
2205
+ if (config.debug) console.warn("[xhtmlx] unknown xh-on action:", action);
2206
+ }
2207
+ });
2208
+ }
2209
+
2210
+ /**
2211
+ * Process a single element: either attach REST triggers or apply
2212
+ * binding-only directives.
2213
+ *
2214
+ * @param {Element} el
2215
+ * @param {DataContext} ctx
2216
+ * @param {string[]} templateStack
2217
+ */
2218
+ function processElement(el, ctx, templateStack) {
2219
+ // Skip already-processed elements to prevent double processing
2220
+ // (e.g. MutationObserver + explicit process() call)
2221
+ var existing = elementStates.get(el);
2222
+ if (existing && existing.processed) return;
2223
+
2224
+ // -- xh-on-* event handlers -----------------------------------------------
2225
+ var onAttrs = [];
2226
+ for (var oa = 0; oa < el.attributes.length; oa++) {
2227
+ if (el.attributes[oa].name.indexOf("xh-on-") === 0) {
2228
+ onAttrs.push({
2229
+ event: el.attributes[oa].name.slice(6),
2230
+ action: el.attributes[oa].value
2231
+ });
2232
+ }
2233
+ }
2234
+ for (var ob = 0; ob < onAttrs.length; ob++) {
2235
+ attachOnHandler(el, onAttrs[ob].event, onAttrs[ob].action);
2236
+ }
2237
+
2238
+ // -- i18n support -----------------------------------------------------------
2239
+ if (el.hasAttribute("xh-i18n") || checkElementForI18nAttr(el)) {
2240
+ applyI18n(el);
2241
+ }
2242
+
2243
+ if (el.hasAttribute("xh-ws")) {
2244
+ setupWebSocket(el, ctx, templateStack);
2245
+ var wState = elementStates.get(el) || {};
2246
+ wState.processed = true;
2247
+ elementStates.set(el, wState);
2248
+ }
2249
+ if (el.hasAttribute("xh-ws-send")) {
2250
+ setupWsSend(el);
2251
+ }
2252
+
2253
+ if (el.hasAttribute("xh-boost")) {
2254
+ boostElement(el, ctx);
2255
+ var boostState = elementStates.get(el) || {};
2256
+ boostState.processed = true;
2257
+ elementStates.set(el, boostState);
2258
+ }
2259
+
2260
+ // Accessibility: auto-set aria-live on xh-target elements
2261
+ var targetSel = el.getAttribute("xh-target");
2262
+ if (targetSel) {
2263
+ var ariaLiveTarget = document.querySelector(targetSel);
2264
+ if (ariaLiveTarget && !ariaLiveTarget.hasAttribute("aria-live")) {
2265
+ var ariaLive = el.getAttribute("xh-aria-live") || "polite";
2266
+ ariaLiveTarget.setAttribute("aria-live", ariaLive);
2267
+ }
2268
+ }
2269
+
2270
+ var restInfo = getRestVerb(el);
2271
+
2272
+ if (restInfo) {
2273
+ // Initialize element state
2274
+ var state = existing || {};
2275
+ state.dataContext = ctx;
2276
+ state.requestInFlight = false;
2277
+ state.intervalIds = state.intervalIds || [];
2278
+ state.observers = state.observers || [];
2279
+ elementStates.set(el, state);
2280
+
2281
+ // Attach triggers
2282
+ attachTriggers(el, ctx, templateStack);
2283
+ } else {
2284
+ // Binding-only element — apply immediately
2285
+ // Note: xh-each elements should have been handled in renderTemplate
2286
+ // This handles binding-only elements found directly in the document
2287
+ if (el.hasAttribute("xh-each")) {
2288
+ processEach(el, ctx);
2289
+ } else {
2290
+ var kept = applyBindings(el, ctx);
2291
+ if (kept) {
2292
+ // Mark as processed
2293
+ var bState = elementStates.get(el) || {};
2294
+ bState.processed = true;
2295
+ elementStates.set(el, bState);
2296
+ }
2297
+ }
2298
+ }
2299
+ }
2300
+
2301
+ // ---------------------------------------------------------------------------
2302
+ // MutationObserver — auto-process dynamically added elements
2303
+ // ---------------------------------------------------------------------------
2304
+
2305
+ var mutationObserver = null;
2306
+
2307
+ function setupMutationObserver(ctx) {
2308
+ if (mutationObserver) return;
2309
+
2310
+ mutationObserver = new MutationObserver(function (mutations) {
2311
+ for (var m = 0; m < mutations.length; m++) {
2312
+ var added = mutations[m].addedNodes;
2313
+ for (var n = 0; n < added.length; n++) {
2314
+ var node = added[n];
2315
+ if (node.nodeType !== 1) continue; // Element nodes only
2316
+
2317
+ // Check if this node or any descendant has xh-* attributes
2318
+ var hasXh = hasXhAttributes(node);
2319
+ if (hasXh) {
2320
+ // Use the root data context
2321
+ processNode(node, ctx, []);
2322
+ }
2323
+ }
2324
+ }
2325
+ });
2326
+
2327
+ mutationObserver.observe(document.body, {
2328
+ childList: true,
2329
+ subtree: true
2330
+ });
2331
+ }
2332
+
2333
+ /**
2334
+ * Check whether an element or any of its descendants have xh-* attributes.
2335
+ * @param {Element} el
2336
+ * @returns {boolean}
2337
+ */
2338
+ function hasXhAttributes(el) {
2339
+ // Check the element itself
2340
+ if (checkElementForXh(el)) return true;
2341
+ // Check descendants
2342
+ var all = el.querySelectorAll ? el.querySelectorAll("*") : [];
2343
+ for (var i = 0; i < all.length; i++) {
2344
+ if (checkElementForXh(all[i])) return true;
2345
+ }
2346
+ return false;
2347
+ }
2348
+
2349
+ /**
2350
+ * Check if a single element has any xh-* attribute.
2351
+ * @param {Element} el
2352
+ * @returns {boolean}
2353
+ */
2354
+ function checkElementForXh(el) {
2355
+ if (!el.attributes) return false;
2356
+ for (var i = 0; i < el.attributes.length; i++) {
2357
+ if (el.attributes[i].name.indexOf("xh-") === 0) return true;
2358
+ }
2359
+ return false;
2360
+ }
2361
+
2362
+ /**
2363
+ * Check if an element has any xh-i18n-{attr} attribute (not xh-i18n-vars).
2364
+ * @param {Element} el
2365
+ * @returns {boolean}
2366
+ */
2367
+ function checkElementForI18nAttr(el) {
2368
+ if (!el.attributes) return false;
2369
+ for (var i = 0; i < el.attributes.length; i++) {
2370
+ var name = el.attributes[i].name;
2371
+ if (name.indexOf("xh-i18n-") === 0 && name !== "xh-i18n-vars") return true;
2372
+ }
2373
+ return false;
2374
+ }
2375
+
2376
+ // ---------------------------------------------------------------------------
2377
+ // i18n — Internationalization support
2378
+ // ---------------------------------------------------------------------------
2379
+
2380
+ var i18n = {
2381
+ _locales: {},
2382
+ _locale: null,
2383
+ _fallback: "en",
2384
+
2385
+ load: function(locale, translations) {
2386
+ i18n._locales[locale] = i18n._locales[locale] || {};
2387
+ for (var k in translations) {
2388
+ if (translations.hasOwnProperty(k)) {
2389
+ i18n._locales[locale][k] = translations[k];
2390
+ }
2391
+ }
2392
+ },
2393
+
2394
+ get locale() { return i18n._locale || i18n._fallback; },
2395
+ set locale(val) {
2396
+ i18n._locale = val;
2397
+ if (typeof document !== "undefined") {
2398
+ applyI18n(document.body);
2399
+ emitEvent(document.body, "xh:localeChanged", { locale: val }, false);
2400
+ }
2401
+ },
2402
+
2403
+ t: function(key, vars) {
2404
+ var locales = [i18n._locale, i18n._fallback];
2405
+ for (var l = 0; l < locales.length; l++) {
2406
+ if (!locales[l]) continue;
2407
+ var dict = i18n._locales[locales[l]];
2408
+ if (dict && dict[key] != null) {
2409
+ var text = String(dict[key]);
2410
+ if (vars) {
2411
+ for (var v in vars) {
2412
+ if (vars.hasOwnProperty(v)) {
2413
+ text = text.replace(new RegExp("\\{" + v + "\\}", "g"), vars[v]);
2414
+ }
2415
+ }
2416
+ }
2417
+ return text;
2418
+ }
2419
+ }
2420
+ return key; // fallback to key itself
2421
+ }
2422
+ };
2423
+
2424
+ /**
2425
+ * Apply i18n translations to elements with xh-i18n and xh-i18n-{attr} attributes.
2426
+ *
2427
+ * @param {Element} root – The root element to scan.
2428
+ */
2429
+ function applyI18n(root) {
2430
+ var els = root.querySelectorAll("[xh-i18n]");
2431
+ for (var i = 0; i < els.length; i++) {
2432
+ var key = els[i].getAttribute("xh-i18n");
2433
+ var vars = els[i].getAttribute("xh-i18n-vars");
2434
+ var parsedVars = null;
2435
+ if (vars) {
2436
+ try { parsedVars = JSON.parse(vars); } catch(e) { /* ignore */ }
2437
+ }
2438
+ els[i].textContent = i18n.t(key, parsedVars);
2439
+ }
2440
+
2441
+ // xh-i18n-{attr} for attribute translations
2442
+ var all = root.querySelectorAll("*");
2443
+ for (var j = 0; j < all.length; j++) {
2444
+ var attrs = all[j].attributes;
2445
+ for (var a = 0; a < attrs.length; a++) {
2446
+ var name = attrs[a].name;
2447
+ if (name.indexOf("xh-i18n-") === 0 && name !== "xh-i18n-vars") {
2448
+ var targetAttr = name.slice(8);
2449
+ var attrKey = attrs[a].value;
2450
+ all[j].setAttribute(targetAttr, i18n.t(attrKey));
2451
+ }
2452
+ }
2453
+ }
2454
+ }
2455
+
2456
+ // ---------------------------------------------------------------------------
2457
+ // SPA Router
2458
+ // ---------------------------------------------------------------------------
2459
+
2460
+ var router = {
2461
+ _routes: [],
2462
+ _outlet: null,
2463
+ _activeLink: null,
2464
+ _activeClass: "xh-route-active",
2465
+ _notFoundTemplate: null,
2466
+
2467
+ _init: function() {
2468
+ // Scan for xh-router containers
2469
+ if (typeof document === "undefined") return;
2470
+ var containers = document.querySelectorAll("[xh-router]");
2471
+ for (var c = 0; c < containers.length; c++) {
2472
+ var outlet = containers[c].getAttribute("xh-router-outlet") || "#router-outlet";
2473
+ router._outlet = document.querySelector(outlet);
2474
+
2475
+ var links = containers[c].querySelectorAll("[xh-route]");
2476
+ for (var l = 0; l < links.length; l++) {
2477
+ var link = links[l];
2478
+ var route = {
2479
+ path: link.getAttribute("xh-route"),
2480
+ template: link.getAttribute("xh-template"),
2481
+ api: link.getAttribute("xh-get"),
2482
+ element: link,
2483
+ regex: null,
2484
+ paramNames: []
2485
+ };
2486
+
2487
+ // Convert path pattern to regex: /users/:id -> /users/([^/]+)
2488
+ var paramNames = [];
2489
+ var regexStr = route.path.replace(/:([^/]+)/g, function(_, name) {
2490
+ paramNames.push(name);
2491
+ return "([^/]+)";
2492
+ });
2493
+ route.regex = new RegExp("^" + regexStr + "$");
2494
+ route.paramNames = paramNames;
2495
+ router._routes.push(route);
2496
+
2497
+ // Click handler
2498
+ (function(r) {
2499
+ r.element.addEventListener("click", function(e) {
2500
+ e.preventDefault();
2501
+ router.navigate(r.path);
2502
+ });
2503
+ })(route);
2504
+ }
2505
+
2506
+ // 404 fallback
2507
+ var notFound = containers[c].getAttribute("xh-router-404");
2508
+ if (notFound) router._notFoundTemplate = notFound;
2509
+ }
2510
+
2511
+ // Handle popstate for back/forward
2512
+ window.addEventListener("popstate", function() {
2513
+ router._resolve(window.location.pathname);
2514
+ });
2515
+
2516
+ // Resolve current URL on init
2517
+ if (router._routes.length > 0) {
2518
+ router._resolve(window.location.pathname);
2519
+ }
2520
+ },
2521
+
2522
+ navigate: function(path) {
2523
+ history.pushState({ xhtmlx: true, route: path }, "", path);
2524
+ router._resolve(path);
2525
+ },
2526
+
2527
+ _resolve: function(path) {
2528
+ if (!router._outlet) return;
2529
+
2530
+ for (var i = 0; i < router._routes.length; i++) {
2531
+ var route = router._routes[i];
2532
+ var match = path.match(route.regex);
2533
+ if (match) {
2534
+ // Extract params
2535
+ var params = {};
2536
+ for (var p = 0; p < route.paramNames.length; p++) {
2537
+ params[route.paramNames[p]] = match[p + 1];
2538
+ }
2539
+
2540
+ // Update active class
2541
+ if (router._activeLink) {
2542
+ router._activeLink.classList.remove(router._activeClass);
2543
+ }
2544
+ route.element.classList.add(router._activeClass);
2545
+ router._activeLink = route.element;
2546
+
2547
+ // Fetch data and render
2548
+ var ctx = new DataContext(params);
2549
+
2550
+ if (route.api) {
2551
+ var url = interpolate(route.api, ctx, true);
2552
+ fetch(url).then(function(r) { return r.text(); }).then(function(text) {
2553
+ var data;
2554
+ try { data = JSON.parse(text); } catch(e) { data = {}; }
2555
+ var childCtx = new DataContext(data, ctx);
2556
+
2557
+ if (route.template) {
2558
+ fetchTemplate(route.template).then(function(html) {
2559
+ var fragment = renderTemplate(html, childCtx);
2560
+ router._outlet.innerHTML = "";
2561
+ router._outlet.appendChild(fragment);
2562
+ processNode(router._outlet, childCtx, []);
2563
+ });
2564
+ }
2565
+ });
2566
+ } else if (route.template) {
2567
+ fetchTemplate(route.template).then(function(html) {
2568
+ var fragment = renderTemplate(html, ctx);
2569
+ router._outlet.innerHTML = "";
2570
+ router._outlet.appendChild(fragment);
2571
+ processNode(router._outlet, ctx, []);
2572
+ });
2573
+ }
2574
+
2575
+ emitEvent(router._outlet, "xh:routeChanged", { path: path, params: params }, false);
2576
+ return;
2577
+ }
2578
+ }
2579
+
2580
+ // 404
2581
+ if (router._notFoundTemplate) {
2582
+ var ctx404 = new DataContext({ path: path });
2583
+ fetchTemplate(router._notFoundTemplate).then(function(html) {
2584
+ var fragment = renderTemplate(html, ctx404);
2585
+ router._outlet.innerHTML = "";
2586
+ router._outlet.appendChild(fragment);
2587
+ });
2588
+ }
2589
+
2590
+ emitEvent(document.body, "xh:routeNotFound", { path: path }, false);
2591
+ }
2592
+ };
2593
+
2594
+ // ---------------------------------------------------------------------------
2595
+ // Public API
2596
+ // ---------------------------------------------------------------------------
2597
+
2598
+ var xhtmlx = {
2599
+ /** Library configuration */
2600
+ config: config,
2601
+
2602
+ /**
2603
+ * Manually process a DOM node and its descendants.
2604
+ * @param {Element} root – Element to process.
2605
+ * @param {DataContext} ctx – Optional data context.
2606
+ */
2607
+ process: function (root, ctx) {
2608
+ processNode(root || document.body, ctx || new DataContext({}), []);
2609
+ },
2610
+
2611
+ /**
2612
+ * Create a DataContext for programmatic use.
2613
+ * @param {*} data
2614
+ * @param {DataContext} parent
2615
+ * @param {number} index
2616
+ * @returns {DataContext}
2617
+ */
2618
+ createContext: function (data, parent, index) {
2619
+ return new DataContext(data, parent, index);
2620
+ },
2621
+
2622
+ /**
2623
+ * Clear the template cache.
2624
+ */
2625
+ clearTemplateCache: function () {
2626
+ templateCache.clear();
2627
+ },
2628
+
2629
+ /**
2630
+ * Clear the response cache.
2631
+ */
2632
+ clearResponseCache: function () {
2633
+ responseCache.clear();
2634
+ },
2635
+
2636
+ /**
2637
+ * Interpolate a string using a data context.
2638
+ * @param {string} str
2639
+ * @param {DataContext} ctx
2640
+ * @param {boolean} uriEncode
2641
+ * @returns {string}
2642
+ */
2643
+ interpolate: function (str, ctx, uriEncode) {
2644
+ return interpolate(str, ctx, !!uriEncode);
2645
+ },
2646
+
2647
+ /** Register a custom directive processed in applyBindings. */
2648
+ directive: registerDirective,
2649
+
2650
+ /** Register a global hook (e.g. "beforeRequest"). */
2651
+ hook: registerHook,
2652
+
2653
+ /** Register a named transform for pipe syntax in bindings. */
2654
+ transform: registerTransform,
2655
+
2656
+ /**
2657
+ * Switch UI version. Sets template and API prefixes, clears all caches.
2658
+ * Version can be any string: "v2", "abc123", "20260315", a git SHA, etc.
2659
+ *
2660
+ * @param {string} version – Version identifier.
2661
+ * @param {Object} [opts] – Options.
2662
+ * @param {string} [opts.templatePrefix] – Template prefix. Defaults to "/ui/{version}".
2663
+ * @param {string} [opts.apiPrefix] – API prefix. Defaults to "" (unchanged).
2664
+ * @param {boolean} [opts.reload] – Re-render all active widgets. Defaults to true.
2665
+ */
2666
+ switchVersion: function (version, opts) {
2667
+ opts = opts || {};
2668
+ config.uiVersion = version;
2669
+ config.templatePrefix = opts.templatePrefix != null ? opts.templatePrefix : "/ui/" + version;
2670
+ config.apiPrefix = opts.apiPrefix != null ? opts.apiPrefix : config.apiPrefix;
2671
+ templateCache.clear();
2672
+ responseCache.clear();
2673
+
2674
+ if (typeof document !== "undefined") {
2675
+ emitEvent(document.body, "xh:versionChanged", {
2676
+ version: version,
2677
+ templatePrefix: config.templatePrefix,
2678
+ apiPrefix: config.apiPrefix
2679
+ }, false);
2680
+ }
2681
+
2682
+ if (opts.reload !== false) {
2683
+ this.reload();
2684
+ }
2685
+ },
2686
+
2687
+ /**
2688
+ * Re-render all active widgets, or only those using a specific template.
2689
+ * Re-fetches data from API and re-renders with (possibly new) templates.
2690
+ *
2691
+ * @param {string} [templateUrl] – If provided, only reload widgets using this template.
2692
+ */
2693
+ reload: function (templateUrl) {
2694
+ if (typeof document === "undefined") return;
2695
+ var allEls = gatherXhElements(document.body);
2696
+ for (var i = 0; i < allEls.length; i++) {
2697
+ var el = allEls[i];
2698
+ var st = elementStates.get(el);
2699
+ if (!st || !st.apiUrl) continue;
2700
+ if (templateUrl && st.templateUrl !== templateUrl) continue;
2701
+ // Reset processed flag so element can be re-triggered
2702
+ st.processed = false;
2703
+ elementStates.set(el, st);
2704
+ // Re-execute the request
2705
+ var restInfo = getRestVerb(el);
2706
+ if (restInfo) {
2707
+ var ctx = st.dataContext || new DataContext({});
2708
+ executeRequest(el, ctx, []);
2709
+ }
2710
+ }
2711
+ },
2712
+
2713
+ /** i18n module for internationalization support */
2714
+ i18n: i18n,
2715
+
2716
+ /** SPA Router */
2717
+ router: router,
2718
+
2719
+ /** Internal version string */
2720
+ version: "0.2.0",
2721
+
2722
+ // --- Internals exposed for testing (not part of the public API) ----------
2723
+ _internals: {
2724
+ DataContext: DataContext,
2725
+ MutableDataContext: MutableDataContext,
2726
+ interpolate: interpolate,
2727
+ parseTrigger: parseTrigger,
2728
+ parseTimeValue: parseTimeValue,
2729
+ renderTemplate: renderTemplate,
2730
+ applyBindings: applyBindings,
2731
+ processEach: processEach,
2732
+ processBindingsInTree: processBindingsInTree,
2733
+ processElement: processElement,
2734
+ attachOnHandler: attachOnHandler,
2735
+ executeRequest: executeRequest,
2736
+ resolveErrorTemplate: resolveErrorTemplate,
2737
+ findErrorBoundary: findErrorBoundary,
2738
+ getRestVerb: getRestVerb,
2739
+ performSwap: performSwap,
2740
+ buildRequestBody: buildRequestBody,
2741
+ fetchTemplate: fetchTemplate,
2742
+ resolveTemplate: resolveTemplate,
2743
+ getSwapTarget: getSwapTarget,
2744
+ defaultTrigger: defaultTrigger,
2745
+ resolveDot: resolveDot,
2746
+ templateCache: templateCache,
2747
+ responseCache: responseCache,
2748
+ elementStates: elementStates,
2749
+ generationMap: generationMap,
2750
+ fetchWithRetry: fetchWithRetry,
2751
+ applySettleClasses: applySettleClasses,
2752
+ setupWebSocket: setupWebSocket,
2753
+ setupWsSend: setupWsSend,
2754
+ boostElement: boostElement,
2755
+ boostLink: boostLink,
2756
+ boostForm: boostForm,
2757
+ customDirectives: customDirectives,
2758
+ globalHooks: globalHooks,
2759
+ transforms: transforms,
2760
+ runHooks: runHooks,
2761
+ registerDirective: registerDirective,
2762
+ registerHook: registerHook,
2763
+ registerTransform: registerTransform,
2764
+ config: config,
2765
+ validateElement: validateElement,
2766
+ applyI18n: applyI18n,
2767
+ i18n: i18n,
2768
+ router: router
2769
+ }
2770
+ };
2771
+
2772
+ // Expose globally (browser) or as module (Node/test)
2773
+ if (typeof window !== "undefined") {
2774
+ window.xhtmlx = xhtmlx;
2775
+ }
2776
+ if (typeof module !== "undefined" && module.exports) {
2777
+ module.exports = xhtmlx;
2778
+ }
2779
+
2780
+ // ---------------------------------------------------------------------------
2781
+ // Auto-init on DOMContentLoaded (browser only)
2782
+ // ---------------------------------------------------------------------------
2783
+
2784
+ if (typeof document !== "undefined" && document.addEventListener) {
2785
+ document.addEventListener("DOMContentLoaded", function () {
2786
+ injectDefaultCSS();
2787
+ var rootCtx = new DataContext({});
2788
+ processNode(document.body, rootCtx, []);
2789
+ setupMutationObserver(rootCtx);
2790
+ router._init();
2791
+ });
2792
+ }
2793
+
2794
+ // ---------------------------------------------------------------------------
2795
+ // popstate listener — browser history back/forward (xh-push-url support)
2796
+ // ---------------------------------------------------------------------------
2797
+
2798
+ if (typeof window !== "undefined") {
2799
+ window.addEventListener("popstate", function (e) {
2800
+ if (e.state && e.state.xhtmlx && e.state.url) {
2801
+ var target = e.state.targetSel ? document.querySelector(e.state.targetSel) : document.body;
2802
+ if (target) {
2803
+ fetch(e.state.url).then(function (r) { return r.text(); }).then(function (text) {
2804
+ var data;
2805
+ try {
2806
+ data = JSON.parse(text);
2807
+ } catch (_) {
2808
+ return;
2809
+ }
2810
+ var ctx = new DataContext(data);
2811
+ if (e.state.templateUrl) {
2812
+ fetchTemplate(e.state.templateUrl).then(function (html) {
2813
+ var fragment = renderTemplate(html, ctx);
2814
+ target.innerHTML = "";
2815
+ target.appendChild(fragment);
2816
+ processNode(target, ctx, []);
2817
+ });
2818
+ }
2819
+ }).catch(function () {});
2820
+ }
2821
+ }
2822
+ });
2823
+ }
2824
+
2825
+ })();