@plentico/pattr 0.0.4

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/pattr.js ADDED
@@ -0,0 +1,997 @@
1
+ /*!
2
+ * Pattr v1.0.0
3
+ * A lightweight reactive framework
4
+ * https://github.com/plentico/pattr
5
+ * MIT License
6
+ */
7
+ window.Pattr = {
8
+ _templateScopeCounter: 0,
9
+
10
+ directives: {
11
+ 'p-text': (el, value, modifiers = {}) => {
12
+ let text = String(value);
13
+
14
+ // Apply trim modifier
15
+ if (modifiers.trim && modifiers.trim.length > 0) {
16
+ const maxLength = parseInt(modifiers.trim[0]) || 100;
17
+ if (text.length > maxLength) {
18
+ text = text.substring(0, maxLength) + '...';
19
+ }
20
+ }
21
+
22
+ el.innerText = text;
23
+ },
24
+ 'p-html': (el, value, modifiers = {}) => {
25
+ let html = value;
26
+
27
+ // Apply allow filter first (if present)
28
+ if (modifiers.allow && modifiers.allow.length > 0) {
29
+ const allowedTags = modifiers.allow;
30
+ const tempDiv = document.createElement('div');
31
+ tempDiv.innerHTML = html;
32
+
33
+ // Recursively filter elements
34
+ const filterNode = (node) => {
35
+ if (node.nodeType === Node.ELEMENT_NODE) {
36
+ const tagName = node.tagName.toLowerCase();
37
+ if (!allowedTags.includes(tagName)) {
38
+ // Replace with text content
39
+ return document.createTextNode(node.textContent);
40
+ }
41
+ // Keep element but filter children
42
+ const filtered = node.cloneNode(false);
43
+ Array.from(node.childNodes).forEach(child => {
44
+ const filteredChild = filterNode(child);
45
+ if (filteredChild) filtered.appendChild(filteredChild);
46
+ });
47
+ return filtered;
48
+ }
49
+ return node.cloneNode();
50
+ };
51
+
52
+ const filtered = document.createElement('div');
53
+ Array.from(tempDiv.childNodes).forEach(child => {
54
+ const filteredChild = filterNode(child);
55
+ if (filteredChild) filtered.appendChild(filteredChild);
56
+ });
57
+ html = filtered.innerHTML;
58
+ }
59
+
60
+ // Apply trim modifier (counts only text, preserves HTML tags)
61
+ if (modifiers.trim && modifiers.trim.length > 0) {
62
+ const maxLength = parseInt(modifiers.trim[0]) || 100;
63
+ const tempDiv = document.createElement('div');
64
+ tempDiv.innerHTML = html;
65
+
66
+ let charCount = 0;
67
+ let truncated = false;
68
+
69
+ // Recursively traverse and trim while preserving HTML structure
70
+ const trimNode = (node) => {
71
+ if (truncated) return null;
72
+
73
+ if (node.nodeType === Node.TEXT_NODE) {
74
+ const text = node.textContent;
75
+ const remaining = maxLength - charCount;
76
+
77
+ if (text.length <= remaining) {
78
+ charCount += text.length;
79
+ return node.cloneNode();
80
+ } else {
81
+ // This text node exceeds limit
82
+ truncated = true;
83
+ const trimmedText = text.substring(0, remaining) + '...';
84
+ return document.createTextNode(trimmedText);
85
+ }
86
+ } else if (node.nodeType === Node.ELEMENT_NODE) {
87
+ const cloned = node.cloneNode(false);
88
+ for (let child of node.childNodes) {
89
+ const trimmedChild = trimNode(child);
90
+ if (trimmedChild) {
91
+ cloned.appendChild(trimmedChild);
92
+ }
93
+ if (truncated) break;
94
+ }
95
+ return cloned;
96
+ }
97
+ return node.cloneNode();
98
+ };
99
+
100
+ const result = document.createElement('div');
101
+ for (let child of tempDiv.childNodes) {
102
+ const trimmedChild = trimNode(child);
103
+ if (trimmedChild) {
104
+ result.appendChild(trimmedChild);
105
+ }
106
+ if (truncated) break;
107
+ }
108
+
109
+ html = result.innerHTML;
110
+ }
111
+
112
+ el.innerHTML = html;
113
+ },
114
+ 'p-show': (el, value) => {
115
+ el.style.display = value ? '' : 'none'
116
+ },
117
+ 'p-style': (el, value) => {
118
+ if (typeof value === 'string') {
119
+ el.style.cssText = value;
120
+ } else if (typeof value === 'object' && value !== null) {
121
+ Object.assign(el.style, value);
122
+ }
123
+ },
124
+ 'p-class': (el, value) => {
125
+ if (typeof value === 'string') {
126
+ el.className = value;
127
+ } else if (Array.isArray(value)) {
128
+ el.className = value.join(' ');
129
+ } else if (typeof value === 'object' && value !== null) {
130
+ // Object format: { className: boolean, ... }
131
+ el.className = Object.keys(value)
132
+ .filter(key => value[key])
133
+ .join(' ');
134
+ }
135
+ },
136
+ 'p-model': (el, value) => {
137
+ el.value = value
138
+ },
139
+ 'p-attr': (el, value, modifiers = {}) => {
140
+ // Two usage modes:
141
+ // 1. Single attribute: p-attr:data-id="userId" or p-attr:href="link"
142
+ // 2. Multiple attributes: p-attr="{ 'data-id': userId, 'data-name': userName }"
143
+
144
+ // Check if this is single attribute mode (has modifier parts)
145
+ const modifierKeys = Object.keys(modifiers);
146
+ if (modifierKeys.length > 0) {
147
+ // Single attribute mode: p-attr:data-id="value"
148
+ // The attribute name is the first modifier
149
+ const attrName = modifierKeys[0];
150
+ if (value === null || value === undefined || value === false) {
151
+ el.removeAttribute(attrName);
152
+ } else {
153
+ el.setAttribute(attrName, String(value));
154
+ }
155
+ } else if (typeof value === 'object' && value !== null) {
156
+ // Multiple attributes mode: p-attr="{ 'data-id': userId }"
157
+ Object.keys(value).forEach(attrName => {
158
+ const attrValue = value[attrName];
159
+ if (attrValue === null || attrValue === undefined || attrValue === false) {
160
+ el.removeAttribute(attrName);
161
+ } else {
162
+ el.setAttribute(attrName, String(attrValue));
163
+ }
164
+ });
165
+ }
166
+ },
167
+ },
168
+
169
+ parseDirectiveModifiers(attrName) {
170
+ // Parse: p-html:trim.300:allow.p.h1.h2
171
+ // Returns: { directive: 'p-html', modifiers: { trim: ['300'], allow: ['p', 'h1', 'h2'] } }
172
+ const parts = attrName.split(':');
173
+ const directive = parts[0];
174
+ const modifiers = {};
175
+
176
+ // Parse each modifier group
177
+ for (let i = 1; i < parts.length; i++) {
178
+ const modParts = parts[i].split('.');
179
+ const modName = modParts[0];
180
+ const modValues = modParts.slice(1);
181
+ modifiers[modName] = modValues;
182
+ }
183
+
184
+ return { directive, modifiers };
185
+ },
186
+
187
+ // ==================== INITIALIZATION ====================
188
+
189
+ async start() {
190
+ this.root = document.documentElement;
191
+
192
+ // Load root data (props from CMS)
193
+ const rootDataJsonString = document.getElementById("p-root-data")?.textContent;
194
+ try {
195
+ this.rawData = JSON.parse(rootDataJsonString || '{}');
196
+ } catch (e) {
197
+ console.error("Error parsing root data JSON:", e);
198
+ }
199
+
200
+ this.buildScopeData(this.root, this.rawData);
201
+ this.data = this.observe(this.rawData);
202
+ this.walkDom(this.root, this.data, true);
203
+ this.refreshAllLoops();
204
+ },
205
+
206
+ buildScopeData(el, parentData) {
207
+ let currentData = parentData;
208
+ if (el.hasAttribute('p-scope')) {
209
+ const dataId = el.getAttribute('p-id') || 'missing_p-id';
210
+ if (!parentData._p_children) {
211
+ parentData._p_children = {};
212
+ }
213
+ if (!parentData._p_children[dataId]) {
214
+ parentData._p_children[dataId] = {};
215
+ }
216
+ currentData = parentData._p_children[dataId];
217
+ currentData._p_scope = el.getAttribute('p-scope');
218
+ }
219
+ let child = el.firstElementChild;
220
+ while (child) {
221
+ this.buildScopeData(child, currentData);
222
+ child = child.nextElementSibling;
223
+ }
224
+ },
225
+
226
+ // ==================== REACTIVE PROXY ====================
227
+
228
+ observe(data, parentScope) {
229
+ const localTarget = data;
230
+ let proxyTarget = localTarget;
231
+ if (parentScope) {
232
+ proxyTarget = Object.create(parentScope._p_target || parentScope);
233
+ Object.assign(proxyTarget, localTarget);
234
+ }
235
+ const proxy = new Proxy(proxyTarget, {
236
+ get: (target, key) => {
237
+ if (key === '_p_target') {
238
+ return target;
239
+ }
240
+ return target[key];
241
+ },
242
+ set: (target, key, value) => {
243
+ target[key] = value;
244
+ this.walkDom(this.root, this.data, false);
245
+ return true;
246
+ },
247
+ has: (target, key) => {
248
+ return key in target;
249
+ }
250
+ });
251
+ return proxy;
252
+ },
253
+
254
+ // ==================== SCOPE MANAGEMENT ====================
255
+
256
+ /**
257
+ * Creates a new scope for an element with p-scope during hydration
258
+ */
259
+ initScope(el, parentScope) {
260
+ const dataId = el.getAttribute('p-id');
261
+ const localRawData = parentScope._p_target._p_children[dataId];
262
+
263
+ // Create new inherited Proxy
264
+ const scope = this.observe(localRawData, parentScope);
265
+
266
+ // Execute p-scope assignments directly on target to avoid triggering setter
267
+ this.executePScopeStatements(scope, localRawData._p_scope);
268
+
269
+ // Initialize parent snapshot so first refresh doesn't think everything changed
270
+ const parentProto = Object.getPrototypeOf(scope._p_target);
271
+ el._parentSnapshot = {};
272
+ for (let key in parentProto) {
273
+ if (!key.startsWith('_p_')) {
274
+ el._parentSnapshot[key] = parentProto[key];
275
+ }
276
+ }
277
+
278
+ // Initialize local snapshot for tracking local variable changes
279
+ const target = scope._p_target;
280
+ el._localSnapshot = {};
281
+ for (let key of Object.keys(target)) {
282
+ if (!key.startsWith('_p_')) {
283
+ el._localSnapshot[key] = target[key];
284
+ }
285
+ }
286
+
287
+ return scope;
288
+ },
289
+
290
+ /**
291
+ * Gets the stored scope for an element during refresh, re-executing p-scope if parent changed
292
+ */
293
+ refreshScope(el, parentScope) {
294
+ let scope = el._scope;
295
+
296
+ if (!scope) {
297
+ return parentScope;
298
+ }
299
+
300
+ // Check if parent values changed - if so, selectively re-execute p-scope
301
+ const pScopeExpr = el.getAttribute('p-scope');
302
+ if (pScopeExpr && scope._p_target) {
303
+ this.updateScopeFromParent(el, scope, pScopeExpr);
304
+ }
305
+
306
+ return scope;
307
+ },
308
+
309
+ /**
310
+ * Parses and executes p-scope statements sequentially, setting values directly on target
311
+ * Each statement sees the results of previous statements
312
+ */
313
+ executePScopeStatements(scope, pScopeExpr) {
314
+ const statements = pScopeExpr.split(';').map(s => s.trim()).filter(s => s);
315
+ const target = scope._p_target;
316
+
317
+ // Create a sequential scope that always reads from target first (for updated values)
318
+ // then falls back to the parent scope for inherited values
319
+ const sequentialScope = new Proxy(target, {
320
+ get: (t, key) => {
321
+ // First check if we have a local value (possibly updated by previous statement)
322
+ if (Object.prototype.hasOwnProperty.call(t, key)) {
323
+ return t[key];
324
+ }
325
+ // Fall back to parent scope via prototype chain or original scope
326
+ return scope[key];
327
+ },
328
+ has: (t, key) => {
329
+ return Object.prototype.hasOwnProperty.call(t, key) || key in scope;
330
+ }
331
+ });
332
+
333
+ for (const stmt of statements) {
334
+ const match = stmt.match(/^(\w+)\s*=\s*(.+)$/);
335
+ if (match) {
336
+ const [, varName, expr] = match;
337
+ try {
338
+ const value = eval(`with (sequentialScope) { (${expr}) }`);
339
+ target[varName] = value;
340
+ } catch (e) {
341
+ console.error(`Error executing p-scope statement "${stmt}":`, e);
342
+ }
343
+ }
344
+ }
345
+ },
346
+
347
+ /**
348
+ * Re-executes p-scope statements that depend on changed variables (parent or local)
349
+ * Statements are executed sequentially so each sees results of previous ones
350
+ */
351
+ updateScopeFromParent(el, scope, pScopeExpr) {
352
+ const parentProto = Object.getPrototypeOf(scope._p_target);
353
+ const target = scope._p_target;
354
+
355
+ // Track which variables changed in PARENT vs LOCAL separately
356
+ const changedParentVars = new Set();
357
+ const changedLocalVars = new Set();
358
+
359
+ // Check parent variable changes
360
+ if (!el._parentSnapshot) {
361
+ el._parentSnapshot = {};
362
+ }
363
+ for (let key in parentProto) {
364
+ if (!key.startsWith('_p_')) {
365
+ if (el._parentSnapshot[key] !== parentProto[key]) {
366
+ changedParentVars.add(key);
367
+ }
368
+ el._parentSnapshot[key] = parentProto[key];
369
+ }
370
+ }
371
+
372
+ // Check local variable changes (variables that are OWN properties of target)
373
+ if (!el._localSnapshot) {
374
+ el._localSnapshot = {};
375
+ }
376
+ for (let key of Object.keys(target)) {
377
+ if (!key.startsWith('_p_')) {
378
+ if (el._localSnapshot[key] !== target[key]) {
379
+ changedLocalVars.add(key);
380
+ }
381
+ el._localSnapshot[key] = target[key];
382
+ }
383
+ }
384
+
385
+ // Combine for checking which statements to execute
386
+ const allChangedVars = new Set([...changedParentVars, ...changedLocalVars]);
387
+
388
+ if (allChangedVars.size === 0) return;
389
+
390
+ try {
391
+ const statements = pScopeExpr.split(';').map(s => s.trim()).filter(s => s);
392
+
393
+ // Identify OUTPUT variables (variables SET by p-scope statements)
394
+ // Local changes to these should NOT trigger re-execution
395
+ const outputVars = new Set();
396
+ statements.forEach(stmt => {
397
+ const match = stmt.match(/^(\w+)\s*=\s*(.+)$/);
398
+ if (match) {
399
+ outputVars.add(match[1]);
400
+ }
401
+ });
402
+
403
+ // Track variables set during THIS re-execution pass
404
+ const setInThisPass = new Set();
405
+
406
+ // Create a sequential scope that:
407
+ // - Uses values from this pass if already computed
408
+ // - For PARENT-changed vars: reads from parent (new value)
409
+ // - For LOCAL-changed vars: reads from local (new value)
410
+ // - Otherwise: reads from local if exists, else parent
411
+ const sequentialScope = new Proxy(target, {
412
+ get: (t, key) => {
413
+ if (key === '_p_target' || key === '_p_children' || key === '_p_scope') {
414
+ return t[key];
415
+ }
416
+ // If this variable was set by a previous statement in this pass, use that
417
+ if (setInThisPass.has(key)) {
418
+ return t[key];
419
+ }
420
+ // If this variable changed in PARENT, read from parent (new value)
421
+ if (changedParentVars.has(key)) {
422
+ return parentProto[key];
423
+ }
424
+ // If this variable changed LOCALLY, read from local (new value)
425
+ if (changedLocalVars.has(key)) {
426
+ return t[key];
427
+ }
428
+ // Otherwise, read current value (local if exists, else parent)
429
+ if (Object.prototype.hasOwnProperty.call(t, key)) {
430
+ return t[key];
431
+ }
432
+ return parentProto[key];
433
+ },
434
+ set: (t, key, value) => {
435
+ t[key] = value;
436
+ return true;
437
+ },
438
+ has: (t, key) => {
439
+ return setInThisPass.has(key) || Object.prototype.hasOwnProperty.call(t, key) || key in parentProto;
440
+ }
441
+ });
442
+
443
+ statements.forEach(stmt => {
444
+ let shouldExecute = false;
445
+ const parts = stmt.split('=');
446
+ if (parts.length <= 1) return;
447
+ const rhs = parts.slice(1).join('=');
448
+
449
+ // Execute if RHS contains a PARENT-changed variable
450
+ changedParentVars.forEach(varName => {
451
+ if (rhs.includes(varName)) {
452
+ shouldExecute = true;
453
+ }
454
+ });
455
+
456
+ // Execute if RHS contains a LOCAL-changed variable that is NOT an output var
457
+ // (Local changes to output vars are user modifications that should stick)
458
+ changedLocalVars.forEach(varName => {
459
+ if (!outputVars.has(varName) && rhs.includes(varName)) {
460
+ shouldExecute = true;
461
+ }
462
+ });
463
+
464
+ // Also execute if the statement depends on a variable set earlier in this pass
465
+ if (!shouldExecute) {
466
+ setInThisPass.forEach(varName => {
467
+ if (rhs.includes(varName)) {
468
+ shouldExecute = true;
469
+ }
470
+ });
471
+ }
472
+
473
+ if (shouldExecute) {
474
+ const match = stmt.match(/^(\w+)\s*=\s*(.+)$/);
475
+ if (match) {
476
+ const [, varName, expr] = match;
477
+ const value = eval(`with (sequentialScope) { (${expr}) }`);
478
+ target[varName] = value;
479
+ setInThisPass.add(varName);
480
+ }
481
+ }
482
+ });
483
+
484
+ // Update local snapshot with any newly computed values
485
+ for (let key of Object.keys(target)) {
486
+ if (!key.startsWith('_p_')) {
487
+ el._localSnapshot[key] = target[key];
488
+ }
489
+ }
490
+ } catch (e) {
491
+ console.error(`Error re-executing p-scope expression:`, e);
492
+ }
493
+ },
494
+
495
+ // ==================== EVENT HANDLING ====================
496
+
497
+ /**
498
+ * Registers p-on:* event listeners on an element
499
+ */
500
+ registerEventListeners(el) {
501
+ Array.from(el.attributes).forEach(attr => {
502
+ if (attr.name.startsWith('p-on:')) {
503
+ const event = attr.name.replace('p-on:', '');
504
+ el.addEventListener(event, () => {
505
+ eval(`with (el._scope) { ${attr.value} }`);
506
+ this.refreshAllLoops();
507
+ });
508
+ }
509
+ });
510
+ },
511
+
512
+ /**
513
+ * Sets up p-model two-way binding on an element
514
+ */
515
+ registerModelBinding(el) {
516
+ const modelAttr = el.getAttribute('p-model');
517
+ if (!modelAttr) return;
518
+
519
+ el.addEventListener('input', (e) => {
520
+ let value;
521
+ const type = e.target.type;
522
+
523
+ if (type === 'number' || type === 'range') {
524
+ value = e.target.value === '' ? null : Number(e.target.value);
525
+ } else if (type === 'checkbox') {
526
+ value = e.target.checked;
527
+ } else if (type === 'radio') {
528
+ value = e.target.checked ? e.target.value : undefined;
529
+ } else {
530
+ value = e.target.value;
531
+ }
532
+
533
+ if (value !== undefined) {
534
+ eval(`with (el._scope) { ${modelAttr} = value }`);
535
+ }
536
+ });
537
+
538
+ // For checkbox and radio, also listen to 'change' event
539
+ if (el.type === 'checkbox' || el.type === 'radio') {
540
+ el.addEventListener('change', (e) => {
541
+ let value;
542
+ if (el.type === 'checkbox') {
543
+ value = e.target.checked;
544
+ } else {
545
+ value = e.target.checked ? e.target.value : undefined;
546
+ }
547
+ if (value !== undefined) {
548
+ eval(`with (el._scope) { ${modelAttr} = value }`);
549
+ }
550
+ });
551
+ }
552
+ },
553
+
554
+ // ==================== DIRECTIVE EVALUATION ====================
555
+
556
+ /**
557
+ * Evaluates all directives on an element
558
+ */
559
+ evaluateDirectives(el, scope) {
560
+ Array.from(el.attributes).forEach(attr => {
561
+ const parsed = this.parseDirectiveModifiers(attr.name);
562
+ if (Object.keys(this.directives).includes(parsed.directive)) {
563
+ const evalScope = el._scope || scope;
564
+ const value = eval(`with (evalScope) { (${attr.value}) }`);
565
+ this.directives[parsed.directive](el, value, parsed.modifiers);
566
+ }
567
+ });
568
+ },
569
+
570
+ // ==================== LOOP HANDLING ====================
571
+
572
+ setForTemplateRecursive(element, template) {
573
+ element._forTemplate = template;
574
+ Array.from(element.children).forEach(child => {
575
+ this.setForTemplateRecursive(child, template);
576
+ });
577
+ },
578
+
579
+ refreshAllLoops(el = this.root, processedTemplates = new Set()) {
580
+ if (el.tagName === 'TEMPLATE' && el.hasAttribute('p-for')) {
581
+ // Skip if this template was already processed during this refresh cycle
582
+ // (e.g., as a nested template during an outer loop's refresh)
583
+ if (processedTemplates.has(el)) {
584
+ return;
585
+ }
586
+ processedTemplates.add(el);
587
+ this.handleFor(el, el._scope || this.data, false);
588
+ return;
589
+ }
590
+
591
+ let child = el.firstElementChild;
592
+ while (child) {
593
+ const nextChild = child.nextElementSibling; // Store before processing in case DOM changes
594
+ this.refreshAllLoops(child, processedTemplates);
595
+ child = nextChild;
596
+ }
597
+ },
598
+
599
+ handleFor(template, parentScope, isHydrating) {
600
+ const forExpr = template.getAttribute('p-for');
601
+ const match = forExpr.match(/^(?:const|let)?\s*(.+?)\s+(of|in)\s+(.+)$/);
602
+
603
+ if (!match) {
604
+ console.error(`Invalid p-for expression: ${forExpr}`);
605
+ return;
606
+ }
607
+
608
+ const [, varPattern, operator, iterableExpr] = match;
609
+
610
+ if (isHydrating) {
611
+ this.hydrateLoop(template, parentScope, varPattern, iterableExpr);
612
+ } else {
613
+ this.refreshLoop(template, parentScope, varPattern, iterableExpr);
614
+ }
615
+ },
616
+
617
+ /**
618
+ * Gets the scope prefix for a template based on its nesting level
619
+ * Checks ancestors and siblings for parent p-for templates to build hierarchical key
620
+ */
621
+ getTemplateScopePrefix(template) {
622
+ let ancestorKey = '';
623
+
624
+ // First, check if this template has _forTemplate set (meaning it was rendered by an outer loop)
625
+ // This is the most reliable indicator for nested templates
626
+ if (template._forTemplate) {
627
+ const parentForData = template._forTemplate._forData;
628
+ if (parentForData && parentForData.scopePrefix) {
629
+ // Find which iteration we're in by checking sibling elements
630
+ let sibling = template.previousElementSibling;
631
+ while (sibling) {
632
+ if (sibling.hasAttribute && sibling.hasAttribute('p-for-key')) {
633
+ const siblingKey = sibling.getAttribute('p-for-key');
634
+ ancestorKey = siblingKey + '-';
635
+ break;
636
+ }
637
+ sibling = sibling.previousElementSibling;
638
+ }
639
+ }
640
+ }
641
+
642
+ // If no _forTemplate, check previous siblings for p-for-key
643
+ // This handles the case where template is adjacent to loop-rendered elements
644
+ if (!ancestorKey) {
645
+ let sibling = template.previousElementSibling;
646
+ while (sibling) {
647
+ if (sibling.hasAttribute && sibling.hasAttribute('p-for-key')) {
648
+ // Check if this sibling shares the same parent template
649
+ if (sibling._forTemplate === template._forTemplate) {
650
+ const siblingKey = sibling.getAttribute('p-for-key');
651
+ ancestorKey = siblingKey + '-';
652
+ break;
653
+ }
654
+ }
655
+ sibling = sibling.previousElementSibling;
656
+ }
657
+ }
658
+
659
+ // Also check parent elements for p-for-key
660
+ if (!ancestorKey) {
661
+ let parent = template.parentElement;
662
+ while (parent) {
663
+ if (parent.hasAttribute && parent.hasAttribute('p-for-key')) {
664
+ const parentKey = parent.getAttribute('p-for-key');
665
+ ancestorKey = parentKey + '-';
666
+ break;
667
+ }
668
+ if (parent._forTemplate) {
669
+ const parentForData = parent._forTemplate._forData;
670
+ if (parentForData) {
671
+ const parentIndex = parentForData.renderedElements.indexOf(parent);
672
+ if (parentIndex >= 0) {
673
+ ancestorKey = (parentForData.scopePrefix || '') + parentIndex + '-';
674
+ break;
675
+ }
676
+ }
677
+ }
678
+ parent = parent.parentElement;
679
+ }
680
+ }
681
+
682
+ // Generate a unique scope ID for this template
683
+ if (!template._forScopeId) {
684
+ template._forScopeId = 's' + (this._templateScopeCounter++);
685
+ }
686
+
687
+ return ancestorKey + template._forScopeId + ':';
688
+ },
689
+
690
+ hydrateLoop(template, parentScope, varPattern, iterableExpr) {
691
+ template._scope = parentScope;
692
+
693
+ try {
694
+ const iterable = eval(`with (parentScope) { (${iterableExpr}) }`);
695
+
696
+ // Get the scope prefix for this template's keys
697
+ const scopePrefix = this.getTemplateScopePrefix(template);
698
+
699
+ template._forData = {
700
+ varPattern,
701
+ iterableExpr,
702
+ renderedElements: [],
703
+ scopePrefix
704
+ };
705
+
706
+ // Check for SSR-rendered elements - only match those with our scope prefix
707
+ const existingElementsByKey = {};
708
+ let sibling = template.nextElementSibling;
709
+ while (sibling && sibling.hasAttribute('p-for-key')) {
710
+ const fullKey = sibling.getAttribute('p-for-key');
711
+
712
+ // Check if this element belongs to this template (has our scope prefix)
713
+ // or is an old-style unscoped key (for backwards compatibility)
714
+ const isOurElement = fullKey.startsWith(scopePrefix) ||
715
+ (!fullKey.includes(':') && !fullKey.includes('-')); // unscoped legacy key
716
+
717
+ if (isOurElement) {
718
+ // Extract the index part from the key
719
+ let indexKey;
720
+ if (fullKey.startsWith(scopePrefix)) {
721
+ indexKey = fullKey.substring(scopePrefix.length);
722
+ } else {
723
+ indexKey = fullKey; // legacy unscoped key
724
+ }
725
+
726
+ if (!existingElementsByKey[indexKey]) {
727
+ existingElementsByKey[indexKey] = [];
728
+ }
729
+ existingElementsByKey[indexKey].push(sibling);
730
+ }
731
+ sibling = sibling.nextElementSibling;
732
+ }
733
+
734
+ let index = 0;
735
+ let lastInserted = template;
736
+
737
+ for (const item of iterable) {
738
+ const loopScope = this.createLoopScope(parentScope, varPattern, item);
739
+
740
+ if (existingElementsByKey[String(index)]) {
741
+ // Hydrate existing SSR elements
742
+ existingElementsByKey[String(index)].forEach(el => {
743
+ el._scope = loopScope;
744
+ this.setForTemplateRecursive(el, template);
745
+ // Update key to use scoped format
746
+ if (el.tagName !== 'TEMPLATE') {
747
+ el.setAttribute('p-for-key', scopePrefix + index);
748
+ }
749
+ this.walkDom(el, loopScope, true);
750
+ template._forData.renderedElements.push(el);
751
+ lastInserted = el;
752
+ });
753
+ } else {
754
+ // Render from template
755
+ const clone = template.content.cloneNode(true);
756
+ const elements = Array.from(clone.children);
757
+
758
+ elements.forEach(el => {
759
+ el._scope = loopScope;
760
+ this.setForTemplateRecursive(el, template);
761
+ // Only set p-for-key on non-template elements at this level
762
+ if (el.tagName !== 'TEMPLATE') {
763
+ el.setAttribute('p-for-key', scopePrefix + index);
764
+ }
765
+ this.walkDom(el, loopScope, true);
766
+ });
767
+
768
+ // Insert elements after the last inserted element
769
+ const fragment = document.createDocumentFragment();
770
+ elements.forEach(el => fragment.appendChild(el));
771
+ lastInserted.parentNode.insertBefore(fragment, lastInserted.nextSibling);
772
+
773
+ template._forData.renderedElements.push(...elements);
774
+ lastInserted = elements[elements.length - 1] || lastInserted;
775
+ }
776
+
777
+ index++;
778
+ }
779
+
780
+ // Remove extra SSR elements
781
+ Object.keys(existingElementsByKey).forEach(key => {
782
+ if (parseInt(key) >= index) {
783
+ existingElementsByKey[key].forEach(el => el.remove());
784
+ }
785
+ });
786
+ } catch (e) {
787
+ console.error(`Error in p-for hydration: ${iterableExpr}`, e);
788
+ }
789
+ },
790
+
791
+ /**
792
+ * Recursively removes elements and their nested loop contents
793
+ */
794
+ removeLoopElements(elements) {
795
+ elements.forEach(el => {
796
+ // If this element is a template with its own rendered elements, remove those first
797
+ if (el._forData && el._forData.renderedElements) {
798
+ this.removeLoopElements(el._forData.renderedElements);
799
+ el._forData.renderedElements = [];
800
+ }
801
+ // Also check children for templates with rendered elements
802
+ if (el.querySelectorAll) {
803
+ const nestedTemplates = el.querySelectorAll('template[p-for]');
804
+ nestedTemplates.forEach(tpl => {
805
+ if (tpl._forData && tpl._forData.renderedElements) {
806
+ this.removeLoopElements(tpl._forData.renderedElements);
807
+ tpl._forData.renderedElements = [];
808
+ }
809
+ });
810
+ }
811
+ el.remove();
812
+ });
813
+ },
814
+
815
+ refreshLoop(template, parentScope, varPattern, iterableExpr) {
816
+ const forData = template._forData;
817
+ if (!forData) return;
818
+
819
+ try {
820
+ const iterable = eval(`with (parentScope._p_target || parentScope) { (${iterableExpr}) }`);
821
+
822
+ // Use stored scope prefix or regenerate if needed
823
+ const scopePrefix = forData.scopePrefix || this.getTemplateScopePrefix(template);
824
+
825
+ // Remove all elements (including nested loop elements) and re-render
826
+ this.removeLoopElements(forData.renderedElements);
827
+ forData.renderedElements = [];
828
+
829
+ // Track the actual last inserted element (accounts for nested template output)
830
+ let lastInsertedElement = template;
831
+
832
+ let index = 0;
833
+ for (const item of iterable) {
834
+ const clone = template.content.cloneNode(true);
835
+ const loopScope = this.createLoopScope(parentScope, forData.varPattern, item);
836
+
837
+ const elements = Array.from(clone.children);
838
+
839
+ // First pass: set up scope and basic attributes (but don't process nested templates yet)
840
+ elements.forEach(el => {
841
+ el._scope = loopScope;
842
+ this.setForTemplateRecursive(el, template);
843
+ // Only set p-for-key on non-template elements
844
+ if (el.tagName !== 'TEMPLATE') {
845
+ el.setAttribute('p-for-key', scopePrefix + index);
846
+ }
847
+ });
848
+
849
+ // Insert elements into DOM after the last inserted element
850
+ const fragment = document.createDocumentFragment();
851
+ elements.forEach(el => fragment.appendChild(el));
852
+ lastInsertedElement.parentNode.insertBefore(fragment, lastInsertedElement.nextSibling);
853
+ forData.renderedElements.push(...elements);
854
+
855
+ // Second pass: now that elements are in DOM, process them (including nested templates)
856
+ elements.forEach(el => {
857
+ this.walkDom(el, loopScope, true);
858
+ });
859
+
860
+ // Update lastInsertedElement to the actual last element in the DOM from this iteration
861
+ // This accounts for nested template rendered elements
862
+ const lastElement = elements[elements.length - 1];
863
+ if (lastElement) {
864
+ lastInsertedElement = this.findLastRenderedSibling(lastElement);
865
+ }
866
+
867
+ index++;
868
+ }
869
+ } catch (e) {
870
+ console.error(`Error in p-for refresh: ${iterableExpr}`, e);
871
+ }
872
+ },
873
+
874
+ /**
875
+ * Finds the last element rendered by a template (including nested template output)
876
+ */
877
+ findLastRenderedSibling(element) {
878
+ // If this is a p-for template with rendered elements, get the last rendered element
879
+ if (element.tagName === 'TEMPLATE' && element._forData && element._forData.renderedElements.length > 0) {
880
+ const lastRendered = element._forData.renderedElements[element._forData.renderedElements.length - 1];
881
+ return this.findLastRenderedSibling(lastRendered);
882
+ }
883
+ return element;
884
+ },
885
+
886
+ createLoopScope(parentScope, varPattern, item) {
887
+ const loopData = {};
888
+ varPattern = varPattern.trim();
889
+
890
+ if (varPattern.startsWith('[')) {
891
+ // Array destructuring: [i, cat]
892
+ const vars = varPattern.slice(1, -1).split(',').map(v => v.trim());
893
+ if (Array.isArray(item)) {
894
+ vars.forEach((v, i) => loopData[v] = item[i]);
895
+ } else {
896
+ loopData[vars[0]] = item;
897
+ }
898
+ } else if (varPattern.startsWith('{')) {
899
+ // Object destructuring: {name, age}
900
+ const vars = varPattern.slice(1, -1).split(',').map(v => v.trim());
901
+ vars.forEach(v => {
902
+ const [key, alias] = v.split(':').map(s => s.trim());
903
+ loopData[alias || key] = item[key];
904
+ });
905
+ } else {
906
+ // Simple variable
907
+ loopData[varPattern] = item;
908
+ }
909
+
910
+ if (!parentScope) {
911
+ console.error('parentScope is undefined in createLoopScope');
912
+ return new Proxy({}, { get: () => undefined, set: () => false });
913
+ }
914
+
915
+ const parentTarget = parentScope._p_target || parentScope;
916
+ if (!parentTarget || typeof parentTarget !== 'object') {
917
+ console.error('Invalid parentTarget:', parentTarget);
918
+ return new Proxy(loopData, {
919
+ get: (target, key) => target[key],
920
+ set: (target, key, value) => { target[key] = value; return true; }
921
+ });
922
+ }
923
+
924
+ const loopTarget = Object.create(parentTarget);
925
+ Object.assign(loopTarget, loopData);
926
+
927
+ const proxy = new Proxy(loopTarget, {
928
+ get: (target, key) => target[key],
929
+ set: (target, key, value) => {
930
+ if (key in loopData) {
931
+ target[key] = value;
932
+ } else {
933
+ parentTarget[key] = value;
934
+ }
935
+ return true;
936
+ }
937
+ });
938
+ proxy._p_target = loopTarget;
939
+
940
+ return proxy;
941
+ },
942
+
943
+ // ==================== MAIN DOM WALKER ====================
944
+
945
+ /**
946
+ * Walks the DOM tree, processing scopes, event handlers, and directives
947
+ */
948
+ walkDom(el, parentScope, isHydrating = false) {
949
+ // Handle p-for templates separately
950
+ if (el.tagName === 'TEMPLATE' && el.hasAttribute('p-for')) {
951
+ // Skip nested templates during refresh (non-hydrating) - they will be
952
+ // re-created by their parent loop. Their scope variables come from the
953
+ // parent loop iteration, not the static scope chain.
954
+ if (!isHydrating && el._forTemplate) {
955
+ return;
956
+ }
957
+ this.handleFor(el, parentScope, isHydrating);
958
+ return;
959
+ }
960
+
961
+ // Determine the scope for this element
962
+ let currentScope = parentScope;
963
+
964
+ if (el.hasAttribute('p-scope')) {
965
+ if (isHydrating) {
966
+ currentScope = this.initScope(el, parentScope);
967
+ } else {
968
+ currentScope = this.refreshScope(el, parentScope);
969
+ }
970
+ }
971
+
972
+ // Store scope on element during hydration
973
+ if (isHydrating) {
974
+ el._scope = currentScope;
975
+ this.registerEventListeners(el);
976
+ this.registerModelBinding(el);
977
+ }
978
+
979
+ // Evaluate directives
980
+ if (currentScope) {
981
+ this.evaluateDirectives(el, currentScope);
982
+ }
983
+
984
+ // Recurse to children
985
+ // Use Array.from to capture a snapshot of children, avoiding issues where
986
+ // p-for templates insert new elements during the walk
987
+ const children = Array.from(el.children);
988
+ for (const child of children) {
989
+ this.walkDom(child, currentScope, isHydrating);
990
+ }
991
+ }
992
+
993
+
994
+ }
995
+
996
+ window.Pattr.start()
997
+