@reidelsaltres/pureper 0.1.156 → 0.1.160

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.
Files changed (92) hide show
  1. package/out/foundation/api/Observer.d.ts +30 -0
  2. package/out/foundation/api/Observer.d.ts.map +1 -1
  3. package/out/foundation/api/Observer.js +48 -0
  4. package/out/foundation/api/Observer.js.map +1 -1
  5. package/out/foundation/component_api/Attribute.d.ts.map +1 -1
  6. package/out/foundation/component_api/Attribute.js +1 -0
  7. package/out/foundation/component_api/Attribute.js.map +1 -1
  8. package/out/foundation/engine/BalancedParser.d.ts +58 -0
  9. package/out/foundation/engine/BalancedParser.d.ts.map +1 -0
  10. package/out/foundation/engine/BalancedParser.js +301 -0
  11. package/out/foundation/engine/BalancedParser.js.map +1 -0
  12. package/out/foundation/engine/EscapeHandler.d.ts +27 -0
  13. package/out/foundation/engine/EscapeHandler.d.ts.map +1 -0
  14. package/out/foundation/engine/EscapeHandler.js +47 -0
  15. package/out/foundation/engine/EscapeHandler.js.map +1 -0
  16. package/out/foundation/engine/Expression.d.ts +83 -0
  17. package/out/foundation/engine/Expression.d.ts.map +1 -0
  18. package/out/foundation/engine/Expression.js +256 -0
  19. package/out/foundation/engine/Expression.js.map +1 -0
  20. package/out/foundation/engine/Rule.d.ts +83 -0
  21. package/out/foundation/engine/Rule.d.ts.map +1 -0
  22. package/out/foundation/engine/Rule.js +69 -0
  23. package/out/foundation/engine/Rule.js.map +1 -0
  24. package/out/foundation/engine/Scope.d.ts +57 -0
  25. package/out/foundation/engine/Scope.d.ts.map +1 -0
  26. package/out/foundation/engine/Scope.js +147 -0
  27. package/out/foundation/engine/Scope.js.map +1 -0
  28. package/out/foundation/engine/TemplateEngine.d.ts +79 -0
  29. package/out/foundation/engine/TemplateEngine.d.ts.map +1 -0
  30. package/out/foundation/engine/TemplateEngine.js +187 -0
  31. package/out/foundation/engine/TemplateEngine.js.map +1 -0
  32. package/out/foundation/engine/TemplateInstance.d.ts +121 -0
  33. package/out/foundation/engine/TemplateInstance.d.ts.map +1 -0
  34. package/out/foundation/engine/TemplateInstance.js +255 -0
  35. package/out/foundation/engine/TemplateInstance.js.map +1 -0
  36. package/out/foundation/engine/exceptions/TemplateExceptions.d.ts +21 -0
  37. package/out/foundation/engine/exceptions/TemplateExceptions.d.ts.map +1 -0
  38. package/out/foundation/engine/exceptions/TemplateExceptions.js +26 -0
  39. package/out/foundation/engine/exceptions/TemplateExceptions.js.map +1 -0
  40. package/out/foundation/engine/index.d.ts +18 -0
  41. package/out/foundation/engine/index.d.ts.map +1 -0
  42. package/out/foundation/engine/index.js +19 -0
  43. package/out/foundation/engine/index.js.map +1 -0
  44. package/out/foundation/engine/rules/attribute/EventRule.d.ts +22 -0
  45. package/out/foundation/engine/rules/attribute/EventRule.d.ts.map +1 -0
  46. package/out/foundation/engine/rules/attribute/EventRule.js +129 -0
  47. package/out/foundation/engine/rules/attribute/EventRule.js.map +1 -0
  48. package/out/foundation/engine/rules/attribute/InjectionRule.d.ts +20 -0
  49. package/out/foundation/engine/rules/attribute/InjectionRule.d.ts.map +1 -0
  50. package/out/foundation/engine/rules/attribute/InjectionRule.js +108 -0
  51. package/out/foundation/engine/rules/attribute/InjectionRule.js.map +1 -0
  52. package/out/foundation/engine/rules/attribute/RefRule.d.ts +23 -0
  53. package/out/foundation/engine/rules/attribute/RefRule.d.ts.map +1 -0
  54. package/out/foundation/engine/rules/attribute/RefRule.js +98 -0
  55. package/out/foundation/engine/rules/attribute/RefRule.js.map +1 -0
  56. package/out/foundation/engine/rules/syntax/ExpressionRule.d.ts +19 -0
  57. package/out/foundation/engine/rules/syntax/ExpressionRule.d.ts.map +1 -0
  58. package/out/foundation/engine/rules/syntax/ExpressionRule.js +82 -0
  59. package/out/foundation/engine/rules/syntax/ExpressionRule.js.map +1 -0
  60. package/out/foundation/engine/rules/syntax/ForRule.d.ts +19 -0
  61. package/out/foundation/engine/rules/syntax/ForRule.d.ts.map +1 -0
  62. package/out/foundation/engine/rules/syntax/ForRule.js +226 -0
  63. package/out/foundation/engine/rules/syntax/ForRule.js.map +1 -0
  64. package/out/foundation/engine/rules/syntax/IfRule.d.ts +17 -0
  65. package/out/foundation/engine/rules/syntax/IfRule.d.ts.map +1 -0
  66. package/out/foundation/engine/rules/syntax/IfRule.js +220 -0
  67. package/out/foundation/engine/rules/syntax/IfRule.js.map +1 -0
  68. package/out/foundation/worker/Router.d.ts.map +1 -1
  69. package/out/foundation/worker/Router.js.map +1 -1
  70. package/out/index.d.ts +3 -0
  71. package/out/index.d.ts.map +1 -1
  72. package/out/index.js +3 -0
  73. package/out/index.js.map +1 -1
  74. package/package.json +1 -1
  75. package/src/foundation/api/Observer.ts +60 -0
  76. package/src/foundation/component_api/Attribute.ts +1 -0
  77. package/src/foundation/engine/BalancedParser.ts +353 -0
  78. package/src/foundation/engine/EscapeHandler.ts +54 -0
  79. package/src/foundation/engine/Expression.ts +285 -0
  80. package/src/foundation/engine/Rule.ts +136 -0
  81. package/src/foundation/engine/Scope.ts +166 -0
  82. package/src/foundation/engine/TemplateEngine.ts +243 -0
  83. package/src/foundation/engine/TemplateInstance.ts +355 -0
  84. package/src/foundation/engine/exceptions/TemplateExceptions.ts +27 -0
  85. package/src/foundation/engine/rules/attribute/EventRule.ts +171 -0
  86. package/src/foundation/engine/rules/attribute/InjectionRule.ts +140 -0
  87. package/src/foundation/engine/rules/attribute/RefRule.ts +119 -0
  88. package/src/foundation/engine/rules/syntax/ExpressionRule.ts +102 -0
  89. package/src/foundation/engine/rules/syntax/ForRule.ts +267 -0
  90. package/src/foundation/engine/rules/syntax/IfRule.ts +261 -0
  91. package/src/foundation/worker/Router.ts +1 -1
  92. package/src/index.ts +9 -0
@@ -0,0 +1,355 @@
1
+ import Scope from './Scope.js';
2
+ import Observable, { MutationObserver } from '../api/Observer.js';
3
+ import Rule, { RuleMatch, RuleResult } from './Rule.js';
4
+
5
+ /**
6
+ * TemplateChangeEvent - событие изменения шаблона
7
+ */
8
+ export interface TemplateChangeEvent {
9
+ oldValue: any;
10
+ newValue: any;
11
+ oldTemplate: string;
12
+ newTemplate: string;
13
+ }
14
+
15
+ /**
16
+ * TemplateSection - секция шаблона, связанная с Rule
17
+ */
18
+ export interface TemplateSection {
19
+ /** Rule который создал эту секцию */
20
+ rule: Rule;
21
+ /** Оригинальный match */
22
+ match: RuleMatch;
23
+ /** Текущий результат */
24
+ result: RuleResult;
25
+ /** Исходный шаблон секции (для пересоздания) */
26
+ sourceTemplate: string;
27
+ /** Дочерние секции */
28
+ children: TemplateSection[];
29
+ /** Observable подписки для отслеживания */
30
+ subscriptions: Array<{ observable: Observable<any>; unsubscribe: () => void }>;
31
+ }
32
+
33
+ /**
34
+ * ObservableTracking - отслеживание Observable и связанных секций
35
+ */
36
+ interface ObservableTracking {
37
+ observable: Observable<any>;
38
+ sections: Array<{
39
+ section: TemplateSection;
40
+ rebuild: (section: TemplateSection) => RuleResult;
41
+ }>;
42
+ unsubscribe: () => void;
43
+ }
44
+
45
+ /**
46
+ * PageTemplate - динамический шаблон страницы.
47
+ * Хранит обработанные Rule и поддерживает реактивное обновление.
48
+ *
49
+ * При изменении Observable все зависимые секции обновляются
50
+ * одновременно в одном событии onTemplateChange.
51
+ */
52
+ export default class TemplateInstance {
53
+ private template: string;
54
+ private scope: Scope;
55
+ private sections: TemplateSection[] = [];
56
+ private fragment: DocumentFragment | null = null;
57
+
58
+ /** Observers for template changes */
59
+ private changeObserver = new MutationObserver<TemplateChangeEvent>();
60
+
61
+ /** Группировка секций по Observable */
62
+ private observableTrackings = new Map<Observable<any>, ObservableTracking>();
63
+
64
+ constructor(template: string, scope: Scope) {
65
+ this.template = template;
66
+ this.scope = scope;
67
+ }
68
+
69
+ /**
70
+ * Получить текущий шаблон
71
+ */
72
+ public getTemplate(): string {
73
+ return this.template;
74
+ }
75
+
76
+ /**
77
+ * Установить новый шаблон (вызывает событие изменения)
78
+ */
79
+ public setTemplate(newTemplate: string): void {
80
+ const oldTemplate = this.template;
81
+ this.template = newTemplate;
82
+
83
+ this.changeObserver.notify(
84
+ { oldValue: null, newValue: null, oldTemplate, newTemplate },
85
+ { oldValue: null, newValue: null, oldTemplate, newTemplate }
86
+ );
87
+ }
88
+
89
+ /**
90
+ * Получить Scope
91
+ */
92
+ public getScope(): Scope {
93
+ return this.scope;
94
+ }
95
+
96
+ /**
97
+ * Подписаться на изменения шаблона
98
+ */
99
+ public onTemplateChange(
100
+ listener: (oldValue: any, newValue: any, oldTemplate: string, newTemplate: string) => void
101
+ ): () => void {
102
+ const wrapper = (oldEvent: TemplateChangeEvent, newEvent: TemplateChangeEvent) => {
103
+ listener(newEvent.oldValue, newEvent.newValue, newEvent.oldTemplate, newEvent.newTemplate);
104
+ };
105
+ this.changeObserver.subscribe(wrapper);
106
+ return () => this.changeObserver.unsubscribe(wrapper);
107
+ }
108
+
109
+ /**
110
+ * Добавить секцию шаблона
111
+ */
112
+ public addSection(section: TemplateSection): void {
113
+ this.sections.push(section);
114
+ }
115
+
116
+ /**
117
+ * Получить все секции
118
+ */
119
+ public getSections(): TemplateSection[] {
120
+ return this.sections;
121
+ }
122
+
123
+ /**
124
+ * Подписаться на Observable и автоматически пересоздавать секцию.
125
+ * Все секции, зависящие от одного Observable, обновляются разом.
126
+ */
127
+ public trackObservable(
128
+ observable: Observable<any>,
129
+ section: TemplateSection,
130
+ rebuild: (section: TemplateSection) => RuleResult
131
+ ): () => void {
132
+ // Проверяем, есть ли уже отслеживание для этого Observable
133
+ let tracking = this.observableTrackings.get(observable);
134
+
135
+ if (!tracking) {
136
+ // Создаём новое отслеживание
137
+ const listener = (newValue: any) => {
138
+ this.rebuildAllSectionsForObservable(observable, newValue);
139
+ };
140
+
141
+ observable.subscribe(listener);
142
+
143
+ tracking = {
144
+ observable,
145
+ sections: [],
146
+ unsubscribe: () => observable.unsubscribe(listener)
147
+ };
148
+
149
+ this.observableTrackings.set(observable, tracking);
150
+ }
151
+
152
+ // Добавляем секцию в отслеживание
153
+ tracking.sections.push({ section, rebuild });
154
+
155
+ // Возвращаем функцию отписки для этой конкретной секции
156
+ return () => {
157
+ if (tracking) {
158
+ tracking.sections = tracking.sections.filter(s => s.section !== section);
159
+
160
+ // Если больше нет секций, отписываемся от Observable
161
+ if (tracking.sections.length === 0) {
162
+ tracking.unsubscribe();
163
+ this.observableTrackings.delete(observable);
164
+ }
165
+ }
166
+ };
167
+ }
168
+
169
+ /**
170
+ * Перестроить все секции, зависящие от Observable, за один раз
171
+ */
172
+ private rebuildAllSectionsForObservable(observable: Observable<any>, newValue: any): void {
173
+ const tracking = this.observableTrackings.get(observable);
174
+ if (!tracking || tracking.sections.length === 0) return;
175
+
176
+ const oldTemplate = this.template;
177
+ let currentTemplate = this.template;
178
+
179
+ // Собираем все замены: старый output -> новый output
180
+ // Сортируем по позиции в шаблоне (с конца), чтобы замены не сбивали индексы
181
+ const replacements: Array<{ oldOutput: string; newOutput: string; section: TemplateSection }> = [];
182
+
183
+ for (const { section, rebuild } of tracking.sections) {
184
+ // Unsubscribe old nested observables
185
+ this.unsubscribeSectionNested(section);
186
+
187
+ // Rebuild section
188
+ const newResult = rebuild(section);
189
+ const oldOutput = section.result.output;
190
+
191
+ replacements.push({
192
+ oldOutput,
193
+ newOutput: newResult.output,
194
+ section
195
+ });
196
+
197
+ section.result = newResult;
198
+ }
199
+
200
+ // Применяем все замены
201
+ for (const { oldOutput, newOutput } of replacements) {
202
+ currentTemplate = currentTemplate.replace(oldOutput, newOutput);
203
+ }
204
+
205
+ this.template = currentTemplate;
206
+
207
+ // Одно событие для всех изменений
208
+ this.changeObserver.notify(
209
+ { oldValue: null, newValue, oldTemplate, newTemplate: this.template },
210
+ { oldValue: null, newValue, oldTemplate, newTemplate: this.template }
211
+ );
212
+ }
213
+
214
+ /**
215
+ * Отписаться от вложенных Observable в секции (но не от главного)
216
+ */
217
+ private unsubscribeSectionNested(section: TemplateSection): void {
218
+ // Отписываемся только от подписок, сохранённых непосредственно в секции
219
+ for (const sub of section.subscriptions) {
220
+ sub.unsubscribe();
221
+ }
222
+ section.subscriptions = [];
223
+
224
+ // Recursively unsubscribe children
225
+ for (const child of section.children) {
226
+ this.unsubscribeSectionNested(child);
227
+ }
228
+ }
229
+
230
+ /**
231
+ * Отписаться от всех Observable в секции
232
+ */
233
+ private unsubscribeSection(section: TemplateSection): void {
234
+ for (const sub of section.subscriptions) {
235
+ sub.unsubscribe();
236
+ }
237
+ section.subscriptions = [];
238
+
239
+ // Recursively unsubscribe children
240
+ for (const child of section.children) {
241
+ this.unsubscribeSection(child);
242
+ }
243
+ }
244
+
245
+ /**
246
+ * Создать DocumentFragment из текущего шаблона
247
+ */
248
+ public createFragment(): DocumentFragment {
249
+ if (typeof document === 'undefined') {
250
+ throw new Error('PageTemplate.createFragment() requires DOM environment');
251
+ }
252
+
253
+ const template = document.createElement('template');
254
+ template.innerHTML = this.template;
255
+ this.fragment = template.content.cloneNode(true) as DocumentFragment;
256
+
257
+ return this.fragment;
258
+ }
259
+
260
+ /**
261
+ * Получить кэшированный DocumentFragment
262
+ */
263
+ public getFragment(): DocumentFragment | null {
264
+ return this.fragment;
265
+ }
266
+
267
+ /**
268
+ * Пересоздать fragment
269
+ */
270
+ public rebuildFragment(): DocumentFragment {
271
+ this.fragment = null;
272
+ return this.createFragment();
273
+ }
274
+
275
+ /**
276
+ * Очистить все подписки
277
+ */
278
+ public dispose(): void {
279
+ // Отписываемся от всех Observable
280
+ for (const tracking of this.observableTrackings.values()) {
281
+ tracking.unsubscribe();
282
+ }
283
+ this.observableTrackings.clear();
284
+
285
+ // Очищаем секции
286
+ for (const section of this.sections) {
287
+ this.unsubscribeSection(section);
288
+ }
289
+ this.sections = [];
290
+ this.fragment = null;
291
+ }
292
+
293
+ /**
294
+ * Привязать refs после вставки в DOM
295
+ */
296
+ public bindRefs(root: Element | DocumentFragment): void {
297
+ const refElements = root.querySelectorAll('[data-ref]');
298
+
299
+ for (const element of Array.from(refElements)) {
300
+ const refName = element.getAttribute('data-ref');
301
+ if (refName) {
302
+ this.scope.set(refName, element);
303
+ }
304
+ }
305
+ }
306
+
307
+ /**
308
+ * Обработать инжекции (@injection[head/tail])
309
+ * Должен вызываться после bindRefs
310
+ */
311
+ public processInjections(root: Element | DocumentFragment): void {
312
+ // Find all elements with injection attributes
313
+ const injectElements = root.querySelectorAll('[data-injection-type][data-injection-target]');
314
+
315
+ for (const element of Array.from(injectElements)) {
316
+ const type = element.getAttribute('data-injection-type') as 'head' | 'tail';
317
+ const targetRefName = decodeURIComponent(element.getAttribute('data-injection-target') || '');
318
+
319
+ if (!targetRefName) continue;
320
+
321
+ // Get target element from scope
322
+ const targetElement = this.scope.get(targetRefName);
323
+
324
+ if (!targetElement || !(targetElement instanceof Element)) {
325
+ console.warn(`[PageTemplate] Injection target "${targetRefName}" not found in scope or is not an Element`);
326
+ continue;
327
+ }
328
+
329
+ // Remove injection attributes
330
+ element.removeAttribute('data-injection-type');
331
+ element.removeAttribute('data-injection-target');
332
+
333
+ // Perform injection
334
+ if (type === 'head') {
335
+ targetElement.prepend(element);
336
+ } else {
337
+ targetElement.append(element);
338
+ }
339
+ }
340
+ }
341
+
342
+ /**
343
+ * Отвязать refs (установить null)
344
+ */
345
+ public unbindRefs(): void {
346
+ for (const section of this.sections) {
347
+ if (section.rule.name === 'ref' && section.match.data?.expression) {
348
+ const refName = section.match.data.expression;
349
+ if (this.scope.has(refName)) {
350
+ this.scope.set(refName, null);
351
+ }
352
+ }
353
+ }
354
+ }
355
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * InvalidDynamicRuleUsage - исключение при использовании Observable
3
+ * в правилах, которые не поддерживают динамическое обновление.
4
+ * Например: @[ref], @injection
5
+ */
6
+ export class InvalidDynamicRuleUsage extends Error {
7
+ constructor(ruleName: string, message?: string) {
8
+ super(message ?? `Rule "${ruleName}" does not support Observable values. Use a static value instead.`);
9
+ this.name = 'InvalidDynamicRuleUsage';
10
+ }
11
+ }
12
+
13
+ /**
14
+ * InvalidTemplateEngineSyntaxException - исключение при синтаксических ошибках
15
+ * в шаблоне. Например: @if без boolean, @for с неверным типом.
16
+ */
17
+ export class InvalidTemplateEngineSyntaxException extends Error {
18
+ public readonly line?: number;
19
+ public readonly column?: number;
20
+
21
+ constructor(message: string, options?: { line?: number; column?: number }) {
22
+ super(message);
23
+ this.name = 'InvalidTemplateEngineSyntaxException';
24
+ this.line = options?.line;
25
+ this.column = options?.column;
26
+ }
27
+ }
@@ -0,0 +1,171 @@
1
+ import { AttributeRule } from '../../Rule.js';
2
+ import type { RuleMatch, RuleResult } from '../../Rule.js';
3
+ import Scope from '../../Scope.js';
4
+ import Expression from '../../Expression.js';
5
+
6
+ interface EventMatch extends RuleMatch {
7
+ data: {
8
+ eventName: string;
9
+ expression: string;
10
+ attributeMatch: string;
11
+ };
12
+ }
13
+
14
+ /**
15
+ * EventRule - обработка @on[eventName]="expression"
16
+ * Подписывает элемент на событие.
17
+ */
18
+ export default class EventRule extends AttributeRule {
19
+ public readonly name = 'event';
20
+ public readonly priority = 30;
21
+
22
+ public find(template: string): RuleMatch[] {
23
+ const results: RuleMatch[] = [];
24
+ // Match @on[eventName]="
25
+ const pattern = /@on\[([a-zA-Z]+)\]\s*=/gi;
26
+ let match: RegExpExecArray | null;
27
+
28
+ while ((match = pattern.exec(template)) !== null) {
29
+ const idx = match.index;
30
+
31
+ // Check for @@ escape
32
+ if (idx > 0 && template[idx - 1] === '@') {
33
+ continue;
34
+ }
35
+
36
+ const eventName = match[1].toLowerCase();
37
+
38
+ // Find quote char after =
39
+ let pos = idx + match[0].length;
40
+ while (pos < template.length && /\s/.test(template[pos])) {
41
+ pos++;
42
+ }
43
+
44
+ const quoteChar = template[pos];
45
+ if (quoteChar !== '"' && quoteChar !== "'") {
46
+ continue;
47
+ }
48
+
49
+ // Find matching closing quote
50
+ const contentStart = pos + 1;
51
+ pos++;
52
+ while (pos < template.length && template[pos] !== quoteChar) {
53
+ pos++;
54
+ }
55
+
56
+ if (pos >= template.length) continue;
57
+
58
+ const content = template.slice(contentStart, pos);
59
+ const fullMatch = template.slice(idx, pos + 1);
60
+
61
+ results.push({
62
+ fullMatch,
63
+ start: idx,
64
+ end: pos + 1,
65
+ data: {
66
+ eventName,
67
+ expression: content,
68
+ attributeMatch: fullMatch
69
+ }
70
+ });
71
+ }
72
+
73
+ return results;
74
+ }
75
+
76
+ public execute(match: RuleMatch, scope: Scope): RuleResult {
77
+ const data = (match as EventMatch).data;
78
+
79
+ // Generate data attribute for later DOM binding
80
+ // Actual event binding happens in postprocessing
81
+ const encodedExpr = encodeURIComponent(data.expression);
82
+
83
+ return {
84
+ output: `data-event-${data.eventName}="${encodedExpr}"`,
85
+ observables: []
86
+ };
87
+ }
88
+
89
+ /**
90
+ * Постобработка: привязать события к элементам
91
+ */
92
+ public static bindEvents(element: Element, scope: Scope): (() => void)[] {
93
+ const unbinders: (() => void)[] = [];
94
+
95
+ // Find all data-event-* attributes
96
+ const attributes = Array.from(element.attributes);
97
+
98
+ for (const attr of attributes) {
99
+ if (attr.name.startsWith('data-event-')) {
100
+ const eventName = attr.name.slice('data-event-'.length);
101
+ const exprCode = decodeURIComponent(attr.value);
102
+
103
+ const handler = (event: Event) => {
104
+ // Create local scope with event
105
+ const localScope = scope.createChild({ event });
106
+ const expr = new Expression(exprCode);
107
+
108
+ try {
109
+ expr.execute(localScope);
110
+ } catch (error) {
111
+ console.error(`[EventRule] Error executing handler for ${eventName}:`, error);
112
+ }
113
+ };
114
+
115
+ element.addEventListener(eventName, handler);
116
+ unbinders.push(() => element.removeEventListener(eventName, handler));
117
+
118
+ // Optionally remove the data attribute
119
+ // element.removeAttribute(attr.name);
120
+ }
121
+ }
122
+
123
+ return unbinders;
124
+ }
125
+
126
+ /**
127
+ * Привязать событие с поддержкой Observable
128
+ */
129
+ public static bindEventWithObservable(
130
+ element: Element,
131
+ eventName: string,
132
+ exprCode: string,
133
+ scope: Scope
134
+ ): () => void {
135
+ let currentHandler: ((event: Event) => void) | null = null;
136
+
137
+ const setupHandler = () => {
138
+ // Remove old handler if exists
139
+ if (currentHandler) {
140
+ element.removeEventListener(eventName, currentHandler);
141
+ }
142
+
143
+ currentHandler = (event: Event) => {
144
+ const localScope = scope.createChild({ event });
145
+ const expr = new Expression(exprCode);
146
+
147
+ try {
148
+ const result = expr.execute(localScope);
149
+
150
+ // If result is Observable, handle it
151
+ if (result && typeof result === 'object' && typeof result.subscribe === 'function') {
152
+ // Re-setup handler when Observable changes
153
+ result.subscribe(() => setupHandler());
154
+ }
155
+ } catch (error) {
156
+ console.error(`[EventRule] Error executing handler for ${eventName}:`, error);
157
+ }
158
+ };
159
+
160
+ element.addEventListener(eventName, currentHandler);
161
+ };
162
+
163
+ setupHandler();
164
+
165
+ return () => {
166
+ if (currentHandler) {
167
+ element.removeEventListener(eventName, currentHandler);
168
+ }
169
+ };
170
+ }
171
+ }
@@ -0,0 +1,140 @@
1
+ import { AttributeRule } from '../../Rule.js';
2
+ import type { RuleMatch, RuleResult } from '../../Rule.js';
3
+ import Scope from '../../Scope.js';
4
+ import Expression from '../../Expression.js';
5
+ import { InvalidDynamicRuleUsage } from '../../exceptions/TemplateExceptions.js';
6
+
7
+ interface InjectionMatch extends RuleMatch {
8
+ data: {
9
+ type: 'head' | 'tail';
10
+ expression: string;
11
+ attributeMatch: string;
12
+ };
13
+ }
14
+
15
+ /**
16
+ * InjectionRule - обработка @injection[type]="expression"
17
+ * Инжектирует элемент в целевой элемент (найденный по @[ref]).
18
+ * type: 'head' = prepend, 'tail' = append
19
+ */
20
+ export default class InjectionRule extends AttributeRule {
21
+ public readonly name = 'injection';
22
+ public readonly priority = 200; // Выполняется в самом конце
23
+
24
+ public find(template: string): RuleMatch[] {
25
+ const results: RuleMatch[] = [];
26
+ // Match @injection[head] or @injection[tail]
27
+ const pattern = /@injection\[(head|tail)\]\s*=/gi;
28
+ let match: RegExpExecArray | null;
29
+
30
+ while ((match = pattern.exec(template)) !== null) {
31
+ const idx = match.index;
32
+
33
+ // Check for @@ escape
34
+ if (idx > 0 && template[idx - 1] === '@') {
35
+ continue;
36
+ }
37
+
38
+ const injectionType = match[1].toLowerCase() as 'head' | 'tail';
39
+
40
+ // Find quote char after =
41
+ let pos = idx + match[0].length;
42
+ while (pos < template.length && /\s/.test(template[pos])) {
43
+ pos++;
44
+ }
45
+
46
+ const quoteChar = template[pos];
47
+ if (quoteChar !== '"' && quoteChar !== "'") {
48
+ continue;
49
+ }
50
+
51
+ // Find matching closing quote
52
+ const contentStart = pos + 1;
53
+ pos++;
54
+ while (pos < template.length && template[pos] !== quoteChar) {
55
+ pos++;
56
+ }
57
+
58
+ if (pos >= template.length) continue;
59
+
60
+ const content = template.slice(contentStart, pos);
61
+ const fullMatch = template.slice(idx, pos + 1);
62
+
63
+ results.push({
64
+ fullMatch,
65
+ start: idx,
66
+ end: pos + 1,
67
+ data: {
68
+ type: injectionType,
69
+ expression: content,
70
+ attributeMatch: fullMatch
71
+ }
72
+ });
73
+ }
74
+
75
+ return results;
76
+ }
77
+
78
+ public execute(match: RuleMatch, scope: Scope): RuleResult {
79
+ const data = (match as InjectionMatch).data;
80
+ const expr = new Expression(data.expression);
81
+ const targetRefName = expr.execute(scope);
82
+
83
+ // Check if Observable - not allowed for @injection
84
+ if (targetRefName && typeof targetRefName === 'object' && typeof targetRefName.subscribe === 'function') {
85
+ throw new InvalidDynamicRuleUsage('@injection',
86
+ '@injection does not support Observable values. The target reference must be static.');
87
+ }
88
+
89
+ if (typeof targetRefName !== 'string') {
90
+ console.error(`[InjectionRule] Expression must return a string (reference name), got: ${typeof targetRefName}`);
91
+ return { output: '' };
92
+ }
93
+
94
+ // Store injection info for postprocessing
95
+ const encodedTarget = encodeURIComponent(targetRefName);
96
+
97
+ return {
98
+ output: `data-injection-type="${data.type}" data-injection-target="${encodedTarget}"`,
99
+ observables: []
100
+ };
101
+ }
102
+
103
+ public supportsObservable(): boolean {
104
+ return false;
105
+ }
106
+
107
+ /**
108
+ * Постобработка: выполнить инжекцию элементов
109
+ */
110
+ public static processInjections(root: Element | DocumentFragment, scope: Scope): void {
111
+ // Find all elements with injection attributes
112
+ const injectElements = root.querySelectorAll('[data-injection-type][data-injection-target]');
113
+
114
+ for (const element of Array.from(injectElements)) {
115
+ const type = element.getAttribute('data-injection-type') as 'head' | 'tail';
116
+ const targetRefName = decodeURIComponent(element.getAttribute('data-injection-target') || '');
117
+
118
+ if (!targetRefName) continue;
119
+
120
+ // Get target element from scope
121
+ const targetElement = scope.get(targetRefName);
122
+
123
+ if (!targetElement || !(targetElement instanceof Element)) {
124
+ console.warn(`[InjectionRule] Target element "${targetRefName}" not found in scope or is not an Element`);
125
+ continue;
126
+ }
127
+
128
+ // Remove injection attributes
129
+ element.removeAttribute('data-injection-type');
130
+ element.removeAttribute('data-injection-target');
131
+
132
+ // Perform injection
133
+ if (type === 'head') {
134
+ targetElement.prepend(element);
135
+ } else {
136
+ targetElement.append(element);
137
+ }
138
+ }
139
+ }
140
+ }