@reidelsaltres/pureper 0.1.154 → 0.1.156

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.
@@ -1,990 +0,0 @@
1
- import Observable from './api/Observer.js';
2
- import Context from './hmle/Context.js';
3
-
4
- // Helper: encode expression for HTML attribute
5
- function encodeAttr(s: string): string {
6
- return s.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
7
- }
8
-
9
- // Helper: decode expression from HTML attribute
10
- function decodeAttr(s: string): string {
11
- return s.replace(/&gt;/g, '>').replace(/&lt;/g, '<').replace(/&quot;/g, '"').replace(/&amp;/g, '&');
12
- }
13
-
14
- // Helper: find all Observable variable names used in an expression
15
- // Also considers dynamicVars as "Observable-like" for template generation
16
- function findObservablesInExpr(expr: string, scope: Record<string, any>, dynamicVars?: Set<string>): string[] {
17
- const result: string[] = [];
18
- // Extract all identifier-like tokens from expression
19
- const identifiers = expr.match(/[A-Za-z_$][A-Za-z0-9_$]*/g) || [];
20
- for (const id of identifiers) {
21
- // Check if it's an actual Observable in scope
22
- if (scope[id] instanceof Observable && !result.includes(id)) {
23
- result.push(id);
24
- }
25
- // Check if it's a dynamic variable (from @for loop)
26
- else if (dynamicVars?.has(id) && !result.includes(id)) {
27
- result.push(id);
28
- }
29
- }
30
- return result;
31
- }
32
-
33
- // Helper: Find the index of the matching closing brace '}' ignoring braces inside quotes/comments
34
- function findMatchingClosingBrace(content: string, openIndex: number): number {
35
- let i = openIndex + 1;
36
- let depth = 1;
37
- let inSingle = false;
38
- let inDouble = false;
39
- let inBacktick = false;
40
- let inLineComment = false;
41
- let inBlockComment = false;
42
- let prevChar = '';
43
-
44
- while (i < content.length && depth > 0) {
45
- const ch = content[i];
46
-
47
- // handle comment states
48
- if (inLineComment) {
49
- if (ch === '\n') inLineComment = false;
50
- prevChar = ch;
51
- i++;
52
- continue;
53
- }
54
- if (inBlockComment) {
55
- if (prevChar === '*' && ch === '/') inBlockComment = false;
56
- prevChar = ch;
57
- i++;
58
- continue;
59
- }
60
-
61
- // handle string/template states, allow escaping
62
- if (inSingle) {
63
- if (ch === '\\' && prevChar !== '\\') { prevChar = ch; i++; continue; }
64
- if (ch === "'" && prevChar !== '\\') inSingle = false;
65
- prevChar = ch;
66
- i++; continue;
67
- }
68
- if (inDouble) {
69
- if (ch === '\\' && prevChar !== '\\') { prevChar = ch; i++; continue; }
70
- if (ch === '"' && prevChar !== '\\') inDouble = false;
71
- prevChar = ch;
72
- i++; continue;
73
- }
74
- if (inBacktick) {
75
- if (ch === '\\' && prevChar !== '\\') { prevChar = ch; i++; continue; }
76
- if (ch === '`' && prevChar !== '\\') inBacktick = false;
77
- prevChar = ch;
78
- i++; continue;
79
- }
80
-
81
- // Not inside quotes or comments
82
- // Start comments
83
- if (prevChar === '/' && ch === '/') { inLineComment = true; prevChar = ''; i++; continue; }
84
- if (prevChar === '/' && ch === '*') { inBlockComment = true; prevChar = ''; i++; continue; }
85
-
86
- // Start quotes
87
- if (ch === "'") { inSingle = true; prevChar = ch; i++; continue; }
88
- if (ch === '"') { inDouble = true; prevChar = ch; i++; continue; }
89
- if (ch === '`') { inBacktick = true; prevChar = ch; i++; continue; }
90
-
91
- // handle braces
92
- if (ch === '{') depth++;
93
- else if (ch === '}') depth--;
94
-
95
- prevChar = ch;
96
- i++;
97
- }
98
-
99
- // i is index just after the closing brace (since we increment after reading '}' )
100
- return i;
101
- }
102
-
103
- /**
104
- * Rule — represents a single parsing rule with its own check and execution logic.
105
- * Rules can apply to any of the 3 parsing stages or to specific ones.
106
- */
107
- export interface Rule {
108
- name: string;
109
- /** Stage 1: text parsing — returns transformed string or null if rule doesn't apply */
110
- parseText?: (parser: HMLEParserReborn, content: string, scope?: Record<string, any>, dynamicVars?: Set<string>) => string | null;
111
- /** Stage 3: hydration — processes template elements in DOM */
112
- hydrate?: (parser: HMLEParserReborn, template: HTMLTemplateElement, scope?: Record<string, any>) => void;
113
- /** Optional: element-level hydration (stage 2/DOM parse) */
114
- elementHydrate?: (parser: HMLEParserReborn, el: Element, scope?: Record<string, any>) => void;
115
- }
116
-
117
- export default class HMLEParserReborn {
118
- private rules: Rule[] = [];
119
- public variables: Record<string, any> = {};
120
-
121
- constructor() {
122
- // Register default rules — order matters!
123
- // forRule must come before expRule so that @for creates local scope first
124
- this.rules.push(forRule);
125
- // element attribute rules
126
- this.rules.push(refRule);
127
- this.rules.push(onRule);
128
- this.rules.push(expRule);
129
- }
130
-
131
- /**
132
- * Add a custom rule to the parser
133
- */
134
- public addRule(rule: Rule): this {
135
- this.rules.push(rule);
136
- return this;
137
- }
138
-
139
- /**
140
- * Get registered rules
141
- */
142
- public getRules(): Rule[] {
143
- return this.rules;
144
- }
145
-
146
- public static isIdentifier(s: string): boolean {
147
- return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test((s || '').trim());
148
- }
149
-
150
- private buildContext(scope?: Record<string, any>): Record<string, any> {
151
- return Context.build(this.variables, scope);
152
- }
153
-
154
- public evaluate(expr: string, scope?: Record<string, any>): any {
155
- const ctx = this.buildContext(scope);
156
- try {
157
- const fn = new Function('with(this){ return (' + expr + '); }');
158
- return fn.call(ctx);
159
- } catch (e) {
160
- return null;
161
- }
162
- }
163
-
164
- public stringify(v: any): string {
165
- if (v == null) return '';
166
- if (typeof v === 'string') return v;
167
- return String(v);
168
- }
169
-
170
- /**
171
- * Stage 1: Parsing — parse HMLE as text, execute STATIC RULES.
172
- * Observable values are left as <template ...> placeholders for Stage 3.
173
- */
174
- public parse(content: string, scope?: Record<string, any>): string {
175
- let working = content || '';
176
-
177
- // Build dynamic var list from ref attributes in raw content so expressions referencing
178
- // DOM-created variables (like @[ref]="name") are not evaluated at parse time.
179
- const dynamicVars = new Set<string>();
180
- // Match patterns like @[ref]="name" or @[ref]='name'
181
- const refAttrRe = /@\[\s*ref\s*\]\s*=\s*"([A-Za-z_$][A-Za-z0-9_$]*)"|@\[\s*ref\s*\]\s*=\s*'([A-Za-z_$][A-Za-z0-9_$]*)'/g;
182
- let rm: RegExpExecArray | null;
183
- while ((rm = refAttrRe.exec(working)) !== null) {
184
- const name = rm[1] || rm[2];
185
- if (name) dynamicVars.add(name);
186
- }
187
-
188
- // Apply parseText rules in order, passing dynamicVars so rules like expRule can
189
- // treat references to these variables as dynamic
190
- for (const rule of this.rules) {
191
- if (rule.parseText) {
192
- const result = rule.parseText(this, working, scope, dynamicVars);
193
- if (result !== null) {
194
- working = result;
195
- }
196
- }
197
- }
198
-
199
- return working;
200
- }
201
-
202
- /**
203
- * Stage 2: DOM Parsing — parse HMLE text to DOM, create <template> for dynamic rules.
204
- * Created templates are preserved for reuse when Observable updates.
205
- */
206
- public parseToDOM(content: string, scope?: Record<string, any>): DocumentFragment {
207
- const html = this.parse(content, scope);
208
- const template = document.createElement('template');
209
- template.innerHTML = html.trim();
210
- return template.content;
211
- }
212
-
213
- /**
214
- * Stage 3: Hydration — remove templates and execute dynamic rules.
215
- */
216
- public hydrate(fragment: DocumentFragment | Element, scope?: Record<string, any>): void {
217
- let root: any;
218
- if (typeof DocumentFragment !== 'undefined' && fragment instanceof DocumentFragment) root = fragment;
219
- else if ((fragment as any).nodeType === 11) root = fragment;
220
- else root = fragment;
221
-
222
- // Stage 2-ish: Process element-level rules (like @[ref] and @[on-event]) before templates are hydrated
223
- const allElements = Array.from(root.querySelectorAll('*')) as Element[];
224
- for (const rule of this.rules) {
225
- if (rule.elementHydrate) {
226
- for (const el of allElements) {
227
- rule.elementHydrate(this, el, scope);
228
- }
229
- }
230
- }
231
-
232
- // Process each rule's template hydrate method (stage 3)
233
- for (const rule of this.rules) {
234
- if (rule.hydrate) {
235
- const selector = `template[${rule.name}]`;
236
- const templates = Array.from(root.querySelectorAll(selector)) as HTMLTemplateElement[];
237
- for (const t of templates) {
238
- rule.hydrate(this, t, scope);
239
- }
240
- }
241
- }
242
-
243
- // Process {{EXP:...}} placeholders in attributes
244
- this.hydrateAttributeExpressions(root, scope);
245
- }
246
-
247
- /**
248
- * Process {{EXP:expr}} placeholders in element attributes
249
- */
250
- private hydrateAttributeExpressions(root: Element | DocumentFragment, scope?: Record<string, any>): void {
251
- const expPattern = /\{\{EXP:([^}]+)\}\}/g;
252
-
253
- // Get all elements
254
- const elements = root.querySelectorAll('*');
255
- const allElements = [root, ...Array.from(elements)] as Element[];
256
-
257
- for (const el of allElements) {
258
- if (!el.attributes) continue;
259
-
260
- for (const attr of Array.from(el.attributes)) {
261
- if (!expPattern.test(attr.value)) continue;
262
-
263
- // Reset regex
264
- expPattern.lastIndex = 0;
265
-
266
- const originalValue = attr.value;
267
- const observablesInAttr: { expr: string, obs: Observable<any>[] }[] = [];
268
-
269
- // Find all expressions in this attribute
270
- let match;
271
- while ((match = expPattern.exec(originalValue)) !== null) {
272
- const expr = decodeAttr(match[1]);
273
- const obsNames = scope ? findObservablesInExpr(expr, scope) : [];
274
- const obs = obsNames.map(name => (scope as any)[name]).filter(o => o instanceof Observable);
275
- observablesInAttr.push({ expr, obs });
276
- }
277
-
278
- // Evaluate and replace
279
- const evalAttr = () => {
280
- let result = originalValue;
281
- expPattern.lastIndex = 0;
282
- result = result.replace(expPattern, (_, encodedExpr) => {
283
- const expr = decodeAttr(encodedExpr);
284
- // Build scope with Observable values
285
- const evalScope = Object.assign({}, scope);
286
- if (scope) {
287
- for (const name of findObservablesInExpr(expr, scope)) {
288
- const obs = (scope as any)[name];
289
- if (obs instanceof Observable) {
290
- evalScope[name] = obs.getObject ? obs.getObject() : undefined;
291
- }
292
- }
293
- }
294
- const val = this.evaluate(expr, evalScope);
295
- return this.stringify(val);
296
- });
297
- return result;
298
- };
299
-
300
- // Initial evaluation
301
- attr.value = evalAttr();
302
-
303
- // Subscribe to all Observables for updates
304
- for (const { obs } of observablesInAttr) {
305
- for (const o of obs) {
306
- o.subscribe(() => {
307
- attr.value = evalAttr();
308
- });
309
- }
310
- }
311
- }
312
- }
313
- }
314
- }
315
-
316
- /**
317
- * Helper: Parse content with specific variables treated as "dynamic" (Observable-like).
318
- * This is used inside Observable @for loops where index and value variables
319
- * should be treated as dynamic even though they're not in scope yet.
320
- */
321
- function parseWithDynamicVars(parser: HMLEParserReborn, content: string, scope?: Record<string, any>, dynamicVars?: Set<string>): string {
322
- let working = content;
323
-
324
- // Apply parseText rules in order with dynamicVars
325
- for (const rule of parser.getRules()) {
326
- if (rule.parseText) {
327
- const result = rule.parseText(parser, working, scope, dynamicVars);
328
- if (result !== null) {
329
- working = result;
330
- }
331
- }
332
- }
333
-
334
- return working;
335
- }
336
-
337
- // ==========================================
338
- // Rule: exp — @(expression or variable)
339
- // ==========================================
340
- const expRule: Rule = {
341
- name: 'exp',
342
-
343
- parseText(parser, content, scope, dynamicVars) {
344
- let working = content;
345
- let out = '';
346
- let pos = 0;
347
-
348
- // Track if we're inside a <template for> tag
349
- let templateDepth = 0;
350
-
351
- while (pos < working.length) {
352
- // Check for <template for opening
353
- const templateForStart = working.indexOf('<template for', pos);
354
- // Check for </template> closing
355
- const templateEnd = working.indexOf('</template>', pos);
356
- // Check for @(
357
- const atIdx = working.indexOf('@(', pos);
358
-
359
- // Find the earliest marker
360
- const markers = [
361
- { type: 'start', idx: templateForStart },
362
- { type: 'end', idx: templateEnd },
363
- { type: 'expr', idx: atIdx }
364
- ].filter(m => m.idx !== -1).sort((a, b) => a.idx - b.idx);
365
-
366
- if (markers.length === 0) {
367
- // No more markers, append rest and break
368
- out += working.slice(pos);
369
- break;
370
- }
371
-
372
- const first = markers[0];
373
-
374
- if (first.type === 'start') {
375
- // Found <template for — copy up to it and increase depth
376
- const endOfTag = working.indexOf('>', first.idx);
377
- if (endOfTag === -1) {
378
- out += working.slice(pos);
379
- break;
380
- }
381
- out += working.slice(pos, endOfTag + 1);
382
- pos = endOfTag + 1;
383
- templateDepth++;
384
- continue;
385
- }
386
-
387
- if (first.type === 'end') {
388
- // Found </template> — copy up to it (including) and decrease depth
389
- const closeEnd = first.idx + '</template>'.length;
390
- out += working.slice(pos, closeEnd);
391
- pos = closeEnd;
392
- if (templateDepth > 0) templateDepth--;
393
- continue;
394
- }
395
-
396
- if (first.type === 'expr') {
397
- // Found @( — if inside template, skip it
398
- if (templateDepth > 0) {
399
- // Copy including @( and move on
400
- out += working.slice(pos, first.idx + 2);
401
- pos = first.idx + 2;
402
- continue;
403
- }
404
-
405
- // Not inside template — process the expression
406
- out += working.slice(pos, first.idx);
407
-
408
- // Find balanced parentheses
409
- let j = first.idx + 2;
410
- let depth = 1;
411
- while (j < working.length && depth > 0) {
412
- if (working[j] === '(') depth++;
413
- else if (working[j] === ')') depth--;
414
- j++;
415
- }
416
-
417
- if (depth !== 0) {
418
- out += working.slice(first.idx, first.idx + 2);
419
- pos = first.idx + 2;
420
- continue;
421
- }
422
-
423
- const innerExpr = working.slice(first.idx + 2, j - 1).trim();
424
-
425
- // Check if expression references any Observable in scope or dynamic vars
426
- const observablesUsed = scope ? findObservablesInExpr(innerExpr, scope, dynamicVars) :
427
- (dynamicVars ? findObservablesInExpr(innerExpr, {}, dynamicVars) : []);
428
-
429
- if (observablesUsed.length > 0) {
430
- // Expression uses Observable or dynamic var — make it dynamic
431
- // Check if we're inside an HTML attribute (look back for ="
432
- const beforeMatch = out.slice(-50);
433
- const inAttribute = /=["'][^"']*$/.test(beforeMatch);
434
-
435
- if (inAttribute) {
436
- out += `{{EXP:${encodeAttr(innerExpr)}}}`;
437
- } else {
438
- // For dynamic vars from @for, include the var name
439
- const dynamicVarsUsed = dynamicVars ? observablesUsed.filter(v => dynamicVars.has(v)) : [];
440
-
441
- if (dynamicVarsUsed.length > 0) {
442
- // Expression uses dynamic loop variables
443
- out += `<template exp var="${dynamicVarsUsed.join(',')}" expr="${encodeAttr(innerExpr)}"></template>`;
444
- } else if (HMLEParserReborn.isIdentifier(innerExpr)) {
445
- out += `<template exp var="${innerExpr}"></template>`;
446
- } else {
447
- out += `<template exp expr="${encodeAttr(innerExpr)}"></template>`;
448
- }
449
- }
450
- pos = j;
451
- continue;
452
- }
453
-
454
- // Evaluate expression
455
- const res = parser.evaluate(innerExpr, scope);
456
-
457
- if (res instanceof Observable) {
458
- out += `<template exp></template>`;
459
- } else if (typeof res === 'undefined') {
460
- // void: nothing displayed
461
- } else {
462
- out += parser.stringify(res);
463
- }
464
-
465
- pos = j;
466
- }
467
- }
468
-
469
- return out;
470
- },
471
-
472
- hydrate(parser, template, scope) {
473
- const varAttr = template.getAttribute('var') ?? null;
474
- const exprAttr = template.getAttribute('expr');
475
- const expr = exprAttr ? decodeAttr(exprAttr) : null;
476
-
477
- // Case 1: var="varName" without expr — simple variable reference @(obs)
478
- if (varAttr && !expr && scope) {
479
- // Single variable that should be Observable in scope
480
- if ((scope as any)[varAttr] instanceof Observable) {
481
- const obs = (scope as any)[varAttr] as Observable<any>;
482
- const value = obs.getObject ? obs.getObject() : undefined;
483
- const textNode = document.createTextNode(parser.stringify(value));
484
-
485
- template.parentNode?.replaceChild(textNode, template);
486
-
487
- // Subscribe for updates
488
- obs.subscribe((v: any) => {
489
- textNode.textContent = parser.stringify(v);
490
- });
491
- return;
492
- }
493
- }
494
-
495
- // Case 2: var="i,v" with expr="..." — expression using dynamic variables
496
- // The var attribute contains comma-separated list of dynamic vars that should be Observable in scope
497
- if (varAttr && expr && scope) {
498
- const dynamicVarNames = varAttr.split(',').map(s => s.trim());
499
-
500
- // Collect all Observable variables used in the expression
501
- const observablesUsed: { name: string, obs: Observable<any> }[] = [];
502
-
503
- // Add dynamic vars from var attribute
504
- for (const name of dynamicVarNames) {
505
- if ((scope as any)[name] instanceof Observable) {
506
- observablesUsed.push({ name, obs: (scope as any)[name] });
507
- }
508
- }
509
-
510
- // Also find any other Observable variables used in expression
511
- const identifiers = expr.match(/[A-Za-z_$][A-Za-z0-9_$]*/g) || [];
512
- for (const id of identifiers) {
513
- if ((scope as any)[id] instanceof Observable && !observablesUsed.some(o => o.name === id)) {
514
- observablesUsed.push({ name: id, obs: (scope as any)[id] });
515
- }
516
- }
517
-
518
- // Evaluate with current Observable values
519
- const evalWithScope = () => {
520
- const evalScope = Object.assign({}, scope);
521
- for (const { name, obs } of observablesUsed) {
522
- evalScope[name] = obs.getObject ? obs.getObject() : undefined;
523
- }
524
- return parser.evaluate(expr, evalScope);
525
- };
526
-
527
- const value = evalWithScope();
528
- const textNode = document.createTextNode(parser.stringify(value));
529
- template.parentNode?.replaceChild(textNode, template);
530
-
531
- // Subscribe to all Observables
532
- for (const { obs } of observablesUsed) {
533
- obs.subscribe(() => {
534
- textNode.textContent = parser.stringify(evalWithScope());
535
- });
536
- }
537
- return;
538
- }
539
-
540
- // Case 3: Expression without var attribute that uses Observables @(action(greeting))
541
- if (expr && scope) {
542
- const observables = findObservablesInExpr(expr, scope);
543
-
544
- // Evaluate with current Observable values
545
- const evalWithScope = () => {
546
- const evalScope = Object.assign({}, scope);
547
- // Get current values from Observables
548
- for (const name of observables) {
549
- const obs = (scope as any)[name];
550
- if (obs instanceof Observable) {
551
- evalScope[name] = obs.getObject ? obs.getObject() : undefined;
552
- }
553
- }
554
- return parser.evaluate(expr, evalScope);
555
- };
556
-
557
- const value = evalWithScope();
558
- const textNode = document.createTextNode(parser.stringify(value));
559
- template.parentNode?.replaceChild(textNode, template);
560
-
561
- // Subscribe to all Observables used in expression
562
- for (const name of observables) {
563
- const obs = (scope as any)[name];
564
- if (obs instanceof Observable) {
565
- obs.subscribe(() => {
566
- textNode.textContent = parser.stringify(evalWithScope());
567
- });
568
- }
569
- }
570
- return;
571
- }
572
-
573
- // Fallback: replace with comment
574
- const comment = document.createComment('exp');
575
- template.parentNode?.replaceChild(comment, template);
576
- }
577
- };
578
-
579
- // ==========================================
580
- // Rule: for — @for (index, value in values) { ... }
581
- // ==========================================
582
- const forRule: Rule = {
583
- name: 'for',
584
-
585
- parseText(parser, content, scope, dynamicVars?: Set<string>) {
586
- let working = content;
587
- const forRe = /@for\s*\(\s*([A-Za-z_$][A-Za-z0-9_$]*)(?:\s*,\s*([A-Za-z_$][A-Za-z0-9_$]*))?\s+in\s+([^\)\s]+)\s*\)\s*\{/g;
588
-
589
- let out = '';
590
- let lastIndex = 0;
591
- forRe.lastIndex = 0;
592
- let m: RegExpExecArray | null;
593
-
594
- while ((m = forRe.exec(working)) !== null) {
595
- out += working.slice(lastIndex, m.index);
596
-
597
- const a = m[1];
598
- const b = m[2];
599
- const iterable = m[3];
600
-
601
- const blockStart = m.index + m[0].length - 1; // position of '{'
602
-
603
- // Extract balanced brace block using robust finder that ignores braces inside strings/comments
604
- const i = findMatchingClosingBrace(working, blockStart);
605
-
606
- if (i > working.length || i <= blockStart) {
607
- // Unable to find matching closing brace — fallback to leaving original match as-is
608
- out += working.slice(m.index, forRe.lastIndex);
609
- lastIndex = forRe.lastIndex;
610
- continue;
611
- }
612
-
613
- const inner = working.slice(blockStart + 1, i - 1);
614
-
615
- // Determine if this @for is dynamic (iterates over Observable or uses dynamic vars)
616
- const indexName = b ? a : 'index';
617
- const varName = b ? b : a;
618
-
619
- let isObservable = false;
620
- let values: any[] = [];
621
-
622
- if (/^\d+$/.test(iterable)) {
623
- // Numeric literal: @for (i in 5) { } — static
624
- const n = parseInt(iterable, 10);
625
- values = Array.from({ length: Math.max(0, n) }, (_, k) => k);
626
- } else {
627
- // Variable or dotted path
628
- const rootName = iterable.split('.')[0];
629
-
630
- // Check if iterable references Observable or dynamic variable
631
- const val = scope ? (scope as any)[rootName] : undefined;
632
- const isDynamicIterable = dynamicVars?.has(rootName);
633
-
634
- if (val instanceof Observable || isDynamicIterable) {
635
- isObservable = true;
636
- } else {
637
- // Evaluate expression for non-Observable
638
- const resolved = parser.evaluate(iterable, scope);
639
- if (Array.isArray(resolved)) values = resolved;
640
- else if (typeof resolved === 'number' && isFinite(resolved)) {
641
- values = Array.from({ length: Math.max(0, Math.floor(resolved)) }, (_, k) => k);
642
- } else {
643
- values = [];
644
- }
645
- }
646
- }
647
-
648
- if (isObservable) {
649
- // DYNAMIC @for — create template placeholder
650
- // Parse inner content with index and value marked as dynamic variables
651
- const innerDynamicVars = new Set(dynamicVars ?? []);
652
- innerDynamicVars.add(indexName);
653
- innerDynamicVars.add(varName);
654
-
655
- // Parse inner content — @() using dynamic vars will create <template exp>
656
- const parsedInner = parseWithDynamicVars(parser, inner, scope, innerDynamicVars);
657
-
658
- out += `<template for index="${indexName}" var="${varName}" in="${iterable}">${parsedInner}</template>`;
659
- lastIndex = i;
660
- forRe.lastIndex = i;
661
- continue;
662
- }
663
-
664
- // STATIC expansion for non-Observable values
665
- for (let idx = 0; idx < values.length; idx++) {
666
- const item = values[idx];
667
- const localScope = Object.assign({}, scope ?? {});
668
- if (b) {
669
- localScope[a] = idx;
670
- localScope[b] = item;
671
- } else {
672
- localScope[a] = item;
673
- }
674
- out += parser.parse(inner, localScope);
675
- }
676
- lastIndex = i;
677
- forRe.lastIndex = i;
678
- }
679
-
680
- out += working.slice(lastIndex);
681
- return out;
682
- },
683
-
684
- hydrate(parser, template, scope) {
685
- const inExpr = template.getAttribute('in') ?? '';
686
- const varName = template.getAttribute('var') ?? 'item';
687
- const indexName = template.getAttribute('index') ?? 'i';
688
-
689
- // Get the root variable name and any nested path
690
- const rootName = inExpr.split('.')[0];
691
- const isNestedPath = inExpr.includes('.');
692
- const pathAfterRoot = isNestedPath ? inExpr.slice(rootName.length + 1) : '';
693
-
694
- const parent = template.parentNode;
695
- if (!parent) return;
696
-
697
- // Save insertion point (element after template, or null if at end)
698
- const insertionPoint = template.nextSibling;
699
- const innerContent = template.innerHTML;
700
-
701
- // Track rendered nodes for cleanup
702
- let rendered: { nodes: Node[], observables: { idx: Observable<number>, val: Observable<any> } }[] = [];
703
-
704
- // Helper: get array value from expression, unwrapping Observables
705
- const resolveArray = (): any[] => {
706
- if (isNestedPath) {
707
- // For nested paths like category.items, unwrap Observables in scope
708
- const unwrappedScope = Object.assign({}, scope);
709
- for (const key in unwrappedScope) {
710
- if (unwrappedScope[key] instanceof Observable) {
711
- unwrappedScope[key] = unwrappedScope[key].getObject();
712
- }
713
- }
714
- const resolved = parser.evaluate(inExpr, unwrappedScope);
715
- return Array.isArray(resolved) ? resolved : [];
716
- } else {
717
- const val = scope ? (scope as any)[rootName] : undefined;
718
- if (val instanceof Observable) {
719
- const v = val.getObject();
720
- return Array.isArray(v) ? v : [];
721
- } else if (Array.isArray(val)) {
722
- return val;
723
- } else if (typeof val === 'number') {
724
- return Array.from({ length: Math.max(0, Math.floor(val)) }, (_, k) => k);
725
- }
726
- return [];
727
- }
728
- };
729
-
730
- // Check if this loop depends on any Observable
731
- const rootVal = scope ? (scope as any)[rootName] : undefined;
732
- const isObservableLoop = rootVal instanceof Observable;
733
-
734
- // Render a single item and return its nodes
735
- const renderItem = (idx: number, item: any, insertBefore: Node | null): { nodes: Node[], observables: { idx: Observable<number>, val: Observable<any> } } => {
736
- const localScope = Object.assign({}, scope ?? {});
737
-
738
- // Create Observables for index and value (so inner templates can subscribe)
739
- const idxObs = new Observable(idx);
740
- const valObs = new Observable(item);
741
-
742
- // Always put Observables in scope for Observable loops
743
- if (isObservableLoop) {
744
- localScope[indexName] = idxObs;
745
- localScope[varName] = valObs;
746
- } else {
747
- localScope[indexName] = idx;
748
- localScope[varName] = item;
749
- }
750
-
751
- const tmp = document.createElement('template');
752
- tmp.innerHTML = innerContent;
753
-
754
- // Hydrate nested templates with Observable scope
755
- parser.hydrate(tmp.content, localScope);
756
-
757
- const nodes: Node[] = [];
758
- while (tmp.content.firstChild) {
759
- nodes.push(tmp.content.firstChild);
760
- parent.insertBefore(tmp.content.firstChild, insertBefore);
761
- }
762
-
763
- return { nodes, observables: { idx: idxObs, val: valObs } };
764
- };
765
-
766
- // Remove all rendered nodes
767
- const clearRendered = () => {
768
- for (const r of rendered) {
769
- for (const node of r.nodes) {
770
- if (node.parentNode) {
771
- node.parentNode.removeChild(node);
772
- }
773
- }
774
- }
775
- rendered = [];
776
- };
777
-
778
- // Full re-render: clear old nodes and render new array
779
- const fullRerender = (insertBefore: Node | null) => {
780
- clearRendered();
781
- const arr = resolveArray();
782
- for (let idx = 0; idx < arr.length; idx++) {
783
- const result = renderItem(idx, arr[idx], insertBefore);
784
- rendered.push(result);
785
- }
786
- };
787
-
788
- // Smart update: update existing, add/remove as needed
789
- const smartUpdate = (newArr: any[]) => {
790
- const oldLen = rendered.length;
791
- const newLen = newArr.length;
792
-
793
- // Update existing items (just update Observable values)
794
- for (let i = 0; i < Math.min(oldLen, newLen); i++) {
795
- rendered[i].observables.idx.setObject(i);
796
- rendered[i].observables.val.setObject(newArr[i]);
797
- }
798
-
799
- // If new array is longer, add new items
800
- if (newLen > oldLen) {
801
- let insertBeforeNode: Node | null = null;
802
- if (rendered.length > 0) {
803
- const lastNodes = rendered[rendered.length - 1].nodes;
804
- if (lastNodes.length > 0) {
805
- insertBeforeNode = lastNodes[lastNodes.length - 1].nextSibling;
806
- }
807
- } else {
808
- insertBeforeNode = insertionPoint;
809
- }
810
-
811
- for (let i = oldLen; i < newLen; i++) {
812
- const result = renderItem(i, newArr[i], insertBeforeNode);
813
- rendered.push(result);
814
- }
815
- }
816
-
817
- // If new array is shorter, remove extra items
818
- if (newLen < oldLen) {
819
- for (let i = oldLen - 1; i >= newLen; i--) {
820
- for (const node of rendered[i].nodes) {
821
- if (node.parentNode) {
822
- node.parentNode.removeChild(node);
823
- }
824
- }
825
- }
826
- rendered.splice(newLen);
827
- }
828
- };
829
-
830
- // Initial render
831
- const arr = resolveArray();
832
- for (let idx = 0; idx < arr.length; idx++) {
833
- const result = renderItem(idx, arr[idx], template);
834
- rendered.push(result);
835
- }
836
-
837
- // Remove the template element
838
- parent.removeChild(template);
839
-
840
- // Subscribe to Observable updates
841
- if (isObservableLoop) {
842
- if (isNestedPath) {
843
- // For nested paths like category.items, we need to re-render when the root Observable changes
844
- // because the nested structure might have changed entirely
845
- (rootVal as Observable<any>).subscribe(() => {
846
- // Find current insertion point (after last rendered node, or original point)
847
- let insertBeforeNode: Node | null = insertionPoint;
848
- if (rendered.length > 0) {
849
- const lastNodes = rendered[rendered.length - 1].nodes;
850
- if (lastNodes.length > 0) {
851
- insertBeforeNode = lastNodes[lastNodes.length - 1].nextSibling;
852
- }
853
- }
854
- fullRerender(insertBeforeNode);
855
- });
856
- } else {
857
- // Direct Observable - use smart update
858
- (rootVal as Observable<any>).subscribe((newArr: any) => {
859
- const items = Array.isArray(newArr) ? newArr : [];
860
- smartUpdate(items);
861
- });
862
- }
863
- }
864
- }
865
- };
866
-
867
- // ==========================================
868
- // Rule: ref — @[ref]="name"
869
- // Create a variable in scope referencing the element
870
- // ==========================================
871
- const refRule: Rule = {
872
- name: 'ref',
873
- elementHydrate(parser, el, scope) {
874
- // attribute name is '@[ref]'
875
- if (!el.hasAttribute('@[ref]')) return;
876
- const raw = el.getAttribute('@[ref]')?.trim();
877
- if (!raw) return;
878
-
879
- // Build evaluation context so prototype methods are available
880
- const ctx = Context.build(parser.variables, scope);
881
-
882
- // If attribute contains attribute-expression placeholders like {{EXP:...}},
883
- // replace them by evaluating inner expressions. Otherwise try to evaluate the whole
884
- // attribute as an expression. Fallback to literal if evaluation fails.
885
- let refName: string | undefined;
886
- const expPattern = /\{\{EXP:([^}]+)\}\}/g;
887
- if (expPattern.test(raw)) {
888
- // Replace each placeholder with evaluated string
889
- let tmp = raw;
890
- expPattern.lastIndex = 0;
891
- tmp = tmp.replace(expPattern, (_m, enc) => {
892
- const expr = decodeAttr(enc);
893
- // Build evaluation scope with unwrapped Observable values used in expr
894
- const evalScope = Object.assign({}, ctx);
895
- if (scope) {
896
- const obsNames = findObservablesInExpr(expr, scope);
897
- for (const name of obsNames) {
898
- const o = (scope as any)[name];
899
- if (o instanceof Observable) evalScope[name] = o.getObject ? o.getObject() : undefined;
900
- }
901
- }
902
- const val = parser.evaluate(expr, evalScope);
903
- return val == null ? '' : String(val);
904
- });
905
- refName = tmp;
906
- } else {
907
- // Try evaluating full attribute as JS expression
908
- try {
909
- const evalScope = Object.assign({}, ctx);
910
- if (scope) {
911
- const obsNames = findObservablesInExpr(raw, scope);
912
- for (const name of obsNames) {
913
- const o = (scope as any)[name];
914
- if (o instanceof Observable) evalScope[name] = o.getObject ? o.getObject() : undefined;
915
- }
916
- }
917
- const evaluated = parser.evaluate(raw, evalScope);
918
- if (typeof evaluated === 'string') refName = evaluated;
919
- else if (evaluated != null) refName = String(evaluated);
920
- } catch (e) {
921
- // ignore and fallback to raw
922
- }
923
- }
924
-
925
- if (!refName) refName = raw;
926
-
927
- if (refName) {
928
- if (scope) {
929
- (scope as any)[refName] = el;
930
- } else {
931
- (parser as any).variables[refName] = el;
932
- }
933
- }
934
- }
935
- };
936
-
937
- // ==========================================
938
- // Rule: on — @[onclick]="expression"; binds events to elements
939
- // ==========================================
940
- const onRule: Rule = {
941
- name: 'on',
942
- elementHydrate(parser, el, scope) {
943
- // Ensure we don't attach duplicate handlers when hydration runs multiple times
944
- const anyEl = el as any;
945
- anyEl.__hmle_on_handlers = anyEl.__hmle_on_handlers || {};
946
- const HMLE_ON_HANDLERS = anyEl.__hmle_on_handlers as Record<string, EventListener>;
947
- for (const attr of Array.from(el.attributes)) {
948
- const an = attr.name;
949
- if (!an.startsWith('@[on') || !an.endsWith(']')) continue;
950
- // Example: '@[onclick]' -> 'onclick' -> event 'click'
951
- let eventName = an.slice(2, an.length - 1); // removes '@[' and ']'
952
- if (eventName.startsWith('on')) eventName = eventName.slice(2);
953
- const expr = attr.value;
954
- // Skip if we already attached a handler for this event on this element
955
- if (HMLE_ON_HANDLERS[eventName]) {
956
- el.removeAttribute(an);
957
- continue;
958
- }
959
-
960
- // Add event listener
961
- const handler = (ev: Event) => {
962
- // Build evaluation scope with unwrapped Observables while preserving
963
- // the prototype of the original scope so prototype methods remain bound.
964
- let evalScope: Record<string, any> = Object.create(scope ?? null);
965
-
966
- // Ensure Context.bindPrototypeMethods binds to the real scope instance,
967
- // not to this wrapper object (otherwise `this` inside methods is not HTMLElement).
968
- (evalScope as any).__hmle_this = scope ?? null;
969
-
970
- // Unwrap Observables referenced in expression into own-properties
971
- if (scope) {
972
- const observed = findObservablesInExpr(expr, scope);
973
- for (const name of observed) {
974
- const o = (scope as any)[name];
975
- if (o instanceof Observable) (evalScope as any)[name] = o.getObject ? o.getObject() : undefined;
976
- }
977
- }
978
-
979
- // event and element are added to the scope as own-properties
980
- (evalScope as any)['event'] = ev;
981
- (evalScope as any)['element'] = el;
982
-
983
- parser.evaluate(expr, evalScope);
984
- };
985
- el.addEventListener(eventName, handler);
986
- HMLE_ON_HANDLERS[eventName] = handler;
987
- el.removeAttribute(an);
988
- }
989
- }
990
- };