@aetherframework/template-engine 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,582 @@
1
+ /**
2
+ * @license MIT
3
+ * Copyright (c) 2026-present AetherFramework Contributors.
4
+ * SPDX-License-Identifier: MIT
5
+ * @module @aetherframework/template-engine/src/engines/AetherEngine
6
+ */
7
+
8
+ /**
9
+ * Aether Template Engine - Custom template engine with Blade-like syntax
10
+ * Supports: @yield, @section, @extends, @include, {{variable}}, {!! raw !!}, @if, @foreach, @forelse,
11
+ * @push/@stack, @csrf, pagination(), route(), auth().user.name
12
+ */
13
+ import fs from 'fs-extra';
14
+ import path from 'path';
15
+ import crypto from 'crypto';
16
+
17
+ class AetherEngine {
18
+ constructor(options = {}) {
19
+ this.name = 'aether';
20
+ this.version = '1.2.0';
21
+ this.initialized = false;
22
+
23
+ this.options = {
24
+ templateDir: options.templateDir || './templates',
25
+ cacheEnabled: options.cacheEnabled !== false,
26
+ cacheTTL: options.cacheTTL || 3600,
27
+ compileCache: new Map(),
28
+ templateCache: new Map(),
29
+ debug: options.debug || false,
30
+ csrfToken: options.csrfToken || '', // CSRF token support
31
+ ...options
32
+ };
33
+
34
+ this.filters = new Map();
35
+ this.functions = new Map();
36
+ this.layouts = new Map();
37
+ this.routes = {};
38
+
39
+ this.ensureTemplateDir();
40
+ this.registerDefaultFilters();
41
+ this.registerDefaultFunctions();
42
+ }
43
+
44
+ initialize() {
45
+ if (this.initialized) return;
46
+ console.log(`Aether Engine initialized (v${this.version})`);
47
+ this.initialized = true;
48
+ }
49
+
50
+ ensureTemplateDir() {
51
+ const dirs = [
52
+ this.options.templateDir,
53
+ path.join(this.options.templateDir, 'layouts'),
54
+ path.join(this.options.templateDir, 'components'),
55
+ path.join(this.options.templateDir, 'pages'),
56
+ path.join(this.options.templateDir, 'partials'),
57
+ ];
58
+ dirs.forEach((dir) => { if (!fs.existsSync(dir)) fs.ensureDirSync(dir); });
59
+ }
60
+
61
+ registerDefaultFilters() {
62
+ this.filter('upper', (value) => String(value).toUpperCase());
63
+ this.filter('lower', (value) => String(value).toLowerCase());
64
+ this.filter('escape', (value) => this.escapeHtml(value));
65
+ this.filter('json', (value) => JSON.stringify(value));
66
+ this.filter('date', (value) => new Date(value).toISOString().split('T')[0]);
67
+ // NEW: raw filter to bypass escaping
68
+ this.filter('raw', (value) => value);
69
+ }
70
+
71
+ registerDefaultFunctions() {
72
+ // Dynamic route function
73
+ this.function('route', (name, params = {}) => {
74
+ let url = this.routes[name] || `/${name}`;
75
+ if (params && typeof params === 'object') {
76
+ for (const [key, value] of Object.entries(params)) {
77
+ url = url.replace(new RegExp(`[:{]${key}[}]?`, 'g'), value);
78
+ }
79
+ }
80
+ return url;
81
+ });
82
+
83
+ this.function('asset', (assetPath) => `${process.env.ASSET_URL || '/assets'}/${assetPath.replace(/^\/+/, '')}`);
84
+ this.function('auth', () => ({ check: () => true, user: { name: 'John Doe', email: 'john@example.com', id: 1 } }));
85
+ this.function('env', (key, defaultValue = '') => process.env[key] || defaultValue);
86
+ this.function('url', (urlPath) => `${process.env.BASE_URL || 'http://localhost:3000'}${urlPath.startsWith('/') ? urlPath : '/' + urlPath}`);
87
+
88
+ // NEW: CSRF Token generator
89
+ this.function('csrf', () => `<input type="hidden" name="_csrf" value="${this.options.csrfToken}">`);
90
+
91
+ // NEW: Old input retrieval (for form validation fallbacks)
92
+ this.function('old', (field, defaultValue = '') => {
93
+ return `__get('old.${field}') || ${JSON.stringify(defaultValue)}`; // Handled dynamically in parser
94
+ });
95
+
96
+ // NEW: Dynamic Class builder (e.g., @class(['btn', 'active' => isActive]))
97
+ this.function('class', (classes) => {
98
+ if (Array.isArray(classes)) {
99
+ return classes.filter(c => {
100
+ if (typeof c === 'string') return true;
101
+ return false; // Object conditions are handled at runtime via JS
102
+ }).join(' ');
103
+ }
104
+ return '';
105
+ });
106
+
107
+ // NEW: Pagination HTML Generator
108
+ this.function('pagination', (pager) => {
109
+ if (!pager || !pager.total || pager.total <= 1) return '';
110
+ let html = '<nav class="aether-pagination" aria-label="Pagination">';
111
+
112
+ // Prev
113
+ if (pager.current > 1) {
114
+ html += `<a href="${pager.baseUrl}?page=${pager.current - 1}" class="page-link prev">&laquo; Prev</a>`;
115
+ } else {
116
+ html += `<span class="page-link disabled">&laquo; Prev</span>`;
117
+ }
118
+
119
+ // Pages (Simple windowing logic)
120
+ const start = Math.max(1, pager.current - 2);
121
+ const end = Math.min(pager.total, pager.current + 2);
122
+
123
+ if (start > 1) html += `<a href="${pager.baseUrl}?page=1" class="page-link">1</a><span class="dots">...</span>`;
124
+
125
+ for (let i = start; i <= end; i++) {
126
+ if (i === pager.current) {
127
+ html += `<span class="page-link active">${i}</span>`;
128
+ } else {
129
+ html += `<a href="${pager.baseUrl}?page=${i}" class="page-link">${i}</a>`;
130
+ }
131
+ }
132
+
133
+ if (end < pager.total) html += `<span class="dots">...</span><a href="${pager.baseUrl}?page=${pager.total}" class="page-link">${pager.total}</a>`;
134
+
135
+ // Next
136
+ if (pager.current < pager.total) {
137
+ html += `<a href="${pager.baseUrl}?page=${pager.current + 1}" class="page-link next">Next &raquo;</a>`;
138
+ } else {
139
+ html += `<span class="page-link disabled">Next &raquo;</span>`;
140
+ }
141
+
142
+ html += '</nav>';
143
+ return html;
144
+ });
145
+ }
146
+
147
+ filter(name, handler) { this.filters.set(name, handler); return this; }
148
+ function(name, handler) { this.functions.set(name, handler); return this; }
149
+ registerLayout(name, content) { this.layouts.set(name, content); return this; }
150
+ setRoutes(routes) { this.routes = { ...this.routes, ...routes }; return this; }
151
+ setCsrfToken(token) { this.options.csrfToken = token; return this; }
152
+
153
+ escapeHtml(str) {
154
+ if (str === undefined || str === null) return '';
155
+ if (typeof str !== 'string') str = String(str);
156
+ return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#039;');
157
+ }
158
+
159
+ enhancedConvertToJsCode(template) {
160
+ let jsCode = '';
161
+ let cursor = 0;
162
+
163
+ const parenRegex = '\\(((?:[^()]+|\\([^()]*\\))*)\\)';
164
+ // Added {!! !!}, @push, @endpush, @stack, @csrf
165
+ const tokenRegex = new RegExp(
166
+ `(\\{!!.*?!!\\}|\\{\\{.*?\\}\\}|@if\\s*${parenRegex}|@elseif\\s*${parenRegex}|@else\\b|@endif\\b|@foreach\\s*${parenRegex}|@endforeach\\b|@forelse\\s*${parenRegex}|@empty\\b|@endforelse\\b|@yield\\s*${parenRegex}|@section\\s*${parenRegex}|@endsection\\b|@extends\\s*${parenRegex}|@include\\s*${parenRegex}|@push\\s*${parenRegex}|@endpush\\b|@stack\\s*${parenRegex}|@csrf\\b)`,
167
+ 'gs'
168
+ );
169
+
170
+ let inSectionBlock = false;
171
+ let currentSectionName = '';
172
+ let sectionContent = '';
173
+
174
+ let inStackBlock = false;
175
+ let currentStackName = '';
176
+ let stackContent = '';
177
+
178
+ let match;
179
+ while ((match = tokenRegex.exec(template)) !== null) {
180
+ const text = template.slice(cursor, match.index);
181
+ if (text) {
182
+ if (inSectionBlock) sectionContent += text;
183
+ else if (inStackBlock) stackContent += text;
184
+ else jsCode += `__output.push(${JSON.stringify(text)});\n`;
185
+ }
186
+
187
+ cursor = match.index + match[0].length;
188
+ const token = match[0].trim();
189
+
190
+ // 1. Raw Output {!! ... !!}
191
+ if (token.startsWith('{!!') && token.endsWith('!!}')) {
192
+ const expr = token.slice(3, -3).trim();
193
+ if (inSectionBlock) sectionContent += token;
194
+ else if (inStackBlock) stackContent += token;
195
+ else jsCode += `__output.push(${this.parseExpression(expr, true)});\n`; // true = isRaw
196
+ }
197
+ // 2. Escaped Output {{ ... }}
198
+ else if (token.startsWith('{{') && token.endsWith('}}')) {
199
+ const expr = token.slice(2, -2).trim();
200
+ if (inSectionBlock) sectionContent += token;
201
+ else if (inStackBlock) stackContent += token;
202
+ else jsCode += `__output.push(${this.parseExpression(expr, false)});\n`;
203
+ }
204
+ else if (token.startsWith('@if')) {
205
+ const condMatch = token.match(new RegExp(`@if\\s*${parenRegex}`, 's'));
206
+ if (condMatch) {
207
+ if (inSectionBlock) sectionContent += token;
208
+ else if (inStackBlock) stackContent += token;
209
+ else jsCode += `if (${this.parseCondition(condMatch[1])}) {\n`;
210
+ }
211
+ }
212
+ else if (token.startsWith('@elseif')) {
213
+ const condMatch = token.match(new RegExp(`@elseif\\s*${parenRegex}`, 's'));
214
+ if (condMatch) {
215
+ if (inSectionBlock) sectionContent += token;
216
+ else if (inStackBlock) stackContent += token;
217
+ else jsCode += `} else if (${this.parseCondition(condMatch[1])}) {\n`;
218
+ }
219
+ }
220
+ else if (token === '@else') {
221
+ if (inSectionBlock) sectionContent += token;
222
+ else if (inStackBlock) stackContent += token;
223
+ else jsCode += `} else {\n`;
224
+ }
225
+ else if (token === '@endif') {
226
+ if (inSectionBlock) sectionContent += token;
227
+ else if (inStackBlock) stackContent += token;
228
+ else jsCode += `}\n`;
229
+ }
230
+ else if (token.startsWith('@foreach') || token.startsWith('@forelse')) {
231
+ const isForelse = token.startsWith('@forelse');
232
+ const loopMatch = token.match(new RegExp(`@(?:foreach|forelse)\\s*${parenRegex}`, 's'));
233
+ if (loopMatch) {
234
+ const parsedLoop = this.parseForeachExpression(loopMatch[1]);
235
+ if (inSectionBlock) sectionContent += token;
236
+ else if (inStackBlock) stackContent += token;
237
+ else {
238
+ jsCode += `{
239
+ const __collection = ${parsedLoop.collection} || [];
240
+ const __parentScope = __scope;
241
+ const __isArray = Array.isArray(__collection);
242
+ const __entries = __isArray ? __collection.map((v, i) => [i, v]) : Object.entries(__collection);
243
+ const __count = __entries.length;
244
+ `;
245
+ if (isForelse) jsCode += `if (__count > 0) {\n`;
246
+ jsCode += `for (let __i = 0; __i < __count; __i++) {
247
+ const [__key, __val] = __entries[__i];
248
+ __scope = Object.create(__parentScope);
249
+ __scope['${parsedLoop.item}'] = __val;
250
+ ${parsedLoop.key ? `__scope['${parsedLoop.key}'] = __key;` : ''}
251
+ __scope['loop'] = {
252
+ index: __i, iteration: __i + 1, remaining: __count - __i - 1, count: __count,
253
+ first: __i === 0, last: __i === __count - 1,
254
+ depth: (__parentScope.loop ? __parentScope.loop.depth + 1 : 1),
255
+ parent: __parentScope.loop || null
256
+ };
257
+ `;
258
+ }
259
+ }
260
+ }
261
+ else if (token === '@endforeach') {
262
+ if (inSectionBlock) sectionContent += token;
263
+ else if (inStackBlock) stackContent += token;
264
+ else jsCode += `}\n__scope = __parentScope;\n}\n`;
265
+ }
266
+ else if (token === '@empty') {
267
+ if (inSectionBlock) sectionContent += token;
268
+ else if (inStackBlock) stackContent += token;
269
+ else jsCode += `}\n} else {\n`;
270
+ }
271
+ else if (token === '@endforelse') {
272
+ if (inSectionBlock) sectionContent += token;
273
+ else if (inStackBlock) stackContent += token;
274
+ else jsCode += `}\n__scope = __parentScope;\n}\n`;
275
+ }
276
+ // 3. Stacks (@push / @endpush / @stack)
277
+ else if (token.startsWith('@push')) {
278
+ const pushMatch = token.match(/@push\s*\(\s*'([^']+)'\s*\)/);
279
+ if (pushMatch) {
280
+ currentStackName = pushMatch[1];
281
+ inStackBlock = true;
282
+ stackContent = '';
283
+ }
284
+ }
285
+ else if (token === '@endpush') {
286
+ if (inStackBlock && currentStackName) {
287
+ const stackJsCode = this.enhancedConvertToJsCode(stackContent);
288
+ jsCode += `if(!__stacks['${currentStackName}']) __stacks['${currentStackName}'] = [];
289
+ __stacks['${currentStackName}'].push(function(__parentScope, helpers) {
290
+ let __scope = __parentScope;
291
+ const __output = [];
292
+ const __escape = helpers.filters.get('escape') || function(s){ return s; };
293
+ const __filters = helpers.filters; const __functions = helpers.functions;
294
+ ${this.getHelperFunctionsString()}
295
+ try { ${stackJsCode} } catch (e) { throw new Error("Runtime error in stack: " + e.message); }
296
+ return __output.join('');
297
+ });\n`;
298
+ inStackBlock = false; currentStackName = ''; stackContent = '';
299
+ }
300
+ }
301
+ else if (token.startsWith('@stack')) {
302
+ const stackMatch = token.match(/@stack\s*\(\s*'([^']+)'\s*\)/);
303
+ if (stackMatch) {
304
+ if (inSectionBlock) sectionContent += token;
305
+ else jsCode += `if(__stacks['${stackMatch[1]}']) { __stacks['${stackMatch[1]}'].forEach(fn => __output.push(fn(__scope, helpers))); }\n`;
306
+ }
307
+ }
308
+ // 4. CSRF
309
+ else if (token === '@csrf') {
310
+ if (inSectionBlock) sectionContent += token;
311
+ else if (inStackBlock) stackContent += token;
312
+ else jsCode += `__output.push(__functions.get('csrf')());\n`;
313
+ }
314
+ // 5. Yields & Sections
315
+ else if (token.startsWith('@yield')) {
316
+ const yieldMatch = token.match(/@yield\s*\(\s*'([^']+)'(?:\s*,\s*'([^']*)')?\s*\)/);
317
+ if (yieldMatch) {
318
+ const name = yieldMatch[1], def = yieldMatch[2] || '';
319
+ if (inSectionBlock) sectionContent += token;
320
+ else jsCode += `__output.push(typeof __sections['${name}'] === 'function' ? __sections['${name}'](__scope, helpers) : (__sections['${name}'] !== undefined ? __sections['${name}'] : ${JSON.stringify(def)}));\n`;
321
+ }
322
+ }
323
+ else if (token.startsWith('@section') && token.includes(',')) {
324
+ const sectionMatch = token.match(/@section\s*\(\s*'([^']+)'\s*,\s*'([^']*)'\s*\)/);
325
+ if (sectionMatch) jsCode += `__sections['${sectionMatch[1]}'] = ${JSON.stringify(sectionMatch[2])};\n`;
326
+ }
327
+ else if (token.startsWith('@section') && !token.includes(',')) {
328
+ const sectionMatch = token.match(/@section\s*\(\s*'([^']+)'\s*\)/);
329
+ if (sectionMatch) { currentSectionName = sectionMatch[1]; inSectionBlock = true; sectionContent = ''; }
330
+ }
331
+ else if (token === '@endsection') {
332
+ if (inSectionBlock && currentSectionName) {
333
+ const sectionJsCode = this.enhancedConvertToJsCode(sectionContent);
334
+ jsCode += `__sections['${currentSectionName}'] = function(__parentScope, helpers) {
335
+ let __scope = __parentScope;
336
+ const __output = []; const __sections = {};
337
+ const __escape = helpers.filters.get('escape') || function(s){ return s; };
338
+ const __filters = helpers.filters; const __functions = helpers.functions;
339
+ ${this.getHelperFunctionsString()}
340
+ try { ${sectionJsCode} } catch (e) { throw new Error("Runtime error in section: " + e.message); }
341
+ return __output.join('');
342
+ };\n`;
343
+ inSectionBlock = false; currentSectionName = ''; sectionContent = '';
344
+ }
345
+ }
346
+ else if (token.startsWith('@include')) {
347
+ const includeMatch = token.match(/@include\s*\(\s*'([^']+)'\s*\)/);
348
+ if (includeMatch) {
349
+ if (inSectionBlock) sectionContent += token;
350
+ else if (inStackBlock) stackContent += token;
351
+ else jsCode += `__output.push(__include('${includeMatch[1]}'));\n`;
352
+ }
353
+ }
354
+ else if (token.startsWith('@extends')) {
355
+ const extendsMatch = token.match(/@extends\s*\(\s*'([^']+)'\s*\)/);
356
+ if (extendsMatch) jsCode += `const __layout = '${extendsMatch[1]}';\n`;
357
+ }
358
+ }
359
+
360
+ const remainingText = template.slice(cursor);
361
+ if (remainingText) {
362
+ if (inSectionBlock) {
363
+ sectionContent += remainingText;
364
+ const sectionJsCode = this.enhancedConvertToJsCode(sectionContent);
365
+ jsCode += `__sections['${currentSectionName}'] = function(__parentScope, helpers) {
366
+ let __scope = __parentScope; const __output = []; const __sections = {};
367
+ const __escape = helpers.filters.get('escape') || function(s){ return s; };
368
+ const __filters = helpers.filters; const __functions = helpers.functions;
369
+ ${this.getHelperFunctionsString()}
370
+ try { ${sectionJsCode} } catch (e) { throw new Error("Runtime error in section: " + e.message); }
371
+ return __output.join('');
372
+ };\n`;
373
+ } else if (inStackBlock) {
374
+ // Handle unclosed push block gracefully
375
+ stackContent += remainingText;
376
+ } else {
377
+ jsCode += `__output.push(${JSON.stringify(remainingText)});\n`;
378
+ }
379
+ }
380
+ return jsCode;
381
+ }
382
+
383
+ getHelperFunctionsString() {
384
+ return `
385
+ function __get(name) {
386
+ if (typeof __scope[name] !== 'undefined') return __scope[name];
387
+ const parts = name.split('.');
388
+ let value = __scope;
389
+ for (const part of parts) {
390
+ if (value && typeof value === 'object' && part in value) value = value[part];
391
+ else return undefined;
392
+ }
393
+ return value;
394
+ }
395
+ function __include(name) {
396
+ if (helpers.includes && helpers.includes[name]) return helpers.includes[name](__scope, helpers);
397
+ return '';
398
+ }
399
+ `;
400
+ }
401
+
402
+ parseExpression(expr, isRaw = false) {
403
+ if (!expr) return "''";
404
+ if (expr.includes('|')) {
405
+ const parts = expr.split('|').map(p => p.trim());
406
+ let baseExpr = this.parseExpression(parts[0], isRaw);
407
+ for (let i = 1; i < parts.length; i++) {
408
+ const filterPart = parts[i];
409
+ if (filterPart.includes(':')) {
410
+ const [filterName, ...args] = filterPart.split(':');
411
+ const parsedArgs = args.map(a => `'${a.trim()}'`).join(', ');
412
+ baseExpr = `__filters.get('${filterName.trim()}')(${baseExpr}, ${parsedArgs})`;
413
+ } else {
414
+ baseExpr = `__filters.get('${filterPart}')(${baseExpr})`;
415
+ if (filterPart === 'raw') isRaw = true;
416
+ }
417
+ }
418
+ return baseExpr;
419
+ }
420
+
421
+ const keywords = ['true', 'false', 'null', 'undefined'];
422
+ const tokenRegex = /(['"])(?:(?!\1|\\)[^\\]|\\.)*\1|(?<![.\w$])\b([a-zA-Z_$][a-zA-Z0-9_$]*)\b(?:\s*\(((?:[^()]+|\([^()]*\))*)\))?/g;
423
+
424
+ let parsed = expr.replace(tokenRegex, (match, quote, identifier, args) => {
425
+ if (quote) return match;
426
+ if (keywords.includes(identifier)) return match;
427
+ if (args !== undefined) {
428
+ const parsedArgs = args.trim() ? this.parseFunctionArguments(args.trim()) : '';
429
+ return `((typeof __get('${identifier}') === 'function') ? __get('${identifier}')(${parsedArgs}) : __functions.get('${identifier}')(${parsedArgs}))`;
430
+ }
431
+ return `__get('${identifier}')`;
432
+ });
433
+
434
+ // Apply escaping only if not raw
435
+ if (!isRaw) {
436
+ if (/^__get\('[^']+'\)$/.test(parsed)) return `__escape(${parsed})`;
437
+ if (!parsed.includes('__functions') && !parsed.includes('__filters') && !parsed.includes('__get(')) return `__escape(${parsed})`;
438
+ }
439
+
440
+ return parsed;
441
+ }
442
+
443
+ // ... (parseFunctionArguments, parseCondition, parseForeachExpression remain exactly the same) ...
444
+ parseFunctionArguments(argsString) {
445
+ const args = []; let currentArg = '', inString = false, stringChar = '';
446
+ for (let i = 0; i < argsString.length; i++) {
447
+ const char = argsString[i];
448
+ if ((char === "'" || char === '"') && (i === 0 || argsString[i-1] !== '\\')) {
449
+ if (!inString) { inString = true; stringChar = char; }
450
+ else if (char === stringChar) inString = false;
451
+ }
452
+ if (char === ',' && !inString) { args.push(currentArg.trim()); currentArg = ''; }
453
+ else currentArg += char;
454
+ }
455
+ if (currentArg.trim()) args.push(currentArg.trim());
456
+
457
+ return args.map(arg => {
458
+ if ((arg.startsWith("'") && arg.endsWith("'")) || (arg.startsWith('"') && arg.endsWith('"'))) return arg;
459
+ if (/^[a-zA-Z_$][a-zA-Z0-9_$.]*$/.test(arg)) return `__get('${arg}')`;
460
+ if (!isNaN(arg) || arg === 'true' || arg === 'false' || arg === 'null') return arg;
461
+ return `'${arg}'`;
462
+ }).join(', ');
463
+ }
464
+
465
+ parseCondition(condition) {
466
+ let parsed = condition || '';
467
+ const keywords = ['true', 'false', 'null', 'undefined', 'typeof', 'instanceof', 'new', 'this'];
468
+ const tokenRegex = /(['"])(?:(?!\1|\\)[^\\]|\\.)*\1|(?<![.\w$])\b([a-zA-Z_$][a-zA-Z0-9_$]*)\b(?:\s*\(((?:[^()]+|\([^()]*\))*)\))?/g;
469
+ parsed = parsed.replace(tokenRegex, (match, quote, identifier, args) => {
470
+ if (quote) return match;
471
+ if (keywords.includes(identifier)) return match;
472
+ if (args !== undefined) {
473
+ const parsedArgs = args.trim() ? this.parseFunctionArguments(args.trim()) : '';
474
+ return `((typeof __get('${identifier}') === 'function') ? __get('${identifier}')(${parsedArgs}) : __functions.get('${identifier}')(${parsedArgs}))`;
475
+ }
476
+ return `__get('${identifier}')`;
477
+ });
478
+ return parsed;
479
+ }
480
+
481
+ parseForeachExpression(expression) {
482
+ let match = expression.match(/([a-zA-Z_$][a-zA-Z0-9_$.]*)\s+as\s+([a-zA-Z_$][a-zA-Z0-9_$]*)\s*=>\s*([a-zA-Z_$][a-zA-Z0-9_$]*)/);
483
+ if (match) return { collection: `__get('${match[1]}')`, key: match[2], item: match[3] };
484
+ match = expression.match(/([a-zA-Z_$][a-zA-Z0-9_$.]*)\s+as\s+([a-zA-Z_$][a-zA-Z0-9_$]*)\s*,\s*([a-zA-Z_$][a-zA-Z0-9_$]*)/);
485
+ if (match) return { collection: `__get('${match[1]}')`, key: match[2], item: match[3] };
486
+ match = expression.match(/([a-zA-Z_$][a-zA-Z0-9_$.]*)\s+as\s+([a-zA-Z_$][a-zA-Z0-9_$]*)/);
487
+ if (match) return { collection: `__get('${match[1]}')`, key: null, item: match[2] };
488
+ match = expression.match(/([a-zA-Z_$][a-zA-Z0-9_$]*)\s+in\s+([a-zA-Z_$][a-zA-Z0-9_$.]*)/);
489
+ if (match) return { collection: `__get('${match[2]}')`, key: null, item: match[1] };
490
+ return { collection: `__get('${expression.trim()}')`, key: null, item: 'item' };
491
+ }
492
+
493
+ compile(templateContent) {
494
+ const cacheKey = crypto.createHash('md5').update(templateContent).digest('hex');
495
+ if (this.options.cacheEnabled && this.options.compileCache.has(cacheKey)) return this.options.compileCache.get(cacheKey);
496
+
497
+ let jsCode = '';
498
+ try {
499
+ jsCode = this.enhancedConvertToJsCode(templateContent);
500
+ if (this.options.debug) console.log('\n--- Generated JS Code ---\n' + jsCode + '\n-------------------------\n');
501
+
502
+ const renderFunc = new Function('data', 'helpers', `
503
+ const __output = [];
504
+ const __sections = {};
505
+ const __stacks = {}; // NEW: Stack storage for @push/@stack
506
+ const __escape = helpers.filters.get('escape') || function(s){ return s; };
507
+ const __filters = helpers.filters;
508
+ const __functions = helpers.functions;
509
+ let __scope = Object.create(data || {});
510
+ ${this.getHelperFunctionsString()}
511
+ try { ${jsCode} } catch (e) { console.error('Template runtime error:', e); throw new Error("Runtime error in template: " + e.message); }
512
+ return __output.join('');
513
+ `);
514
+
515
+ if (this.options.cacheEnabled) this.options.compileCache.set(cacheKey, renderFunc);
516
+ return renderFunc;
517
+ } catch (error) {
518
+ console.error('\n--- FAILED JS CODE ---\n' + (jsCode || 'Code generation failed.') + '\n----------------------\n');
519
+ throw new Error(`Template compilation failed: ${error.message}`);
520
+ }
521
+ }
522
+
523
+ async render(templateName, data = {}, options = {}) {
524
+ if (!this.initialized) this.initialize();
525
+ let templateContent = templateName;
526
+
527
+ const isFilePath = typeof templateName === 'string' && !templateName.includes('\n') && !templateName.includes('<') && (templateName.endsWith('.aether') || templateName.endsWith('.html') || templateName.includes('/') || templateName.includes('\\'));
528
+ if (isFilePath) templateContent = await this.loadTemplate(templateName);
529
+
530
+ const extendsMatch = templateContent.match(/@extends\s*\(\s*'([^']+)'\s*\)/);
531
+ if (extendsMatch) {
532
+ const layoutName = extendsMatch[1];
533
+ try {
534
+ const layoutContent = await this.loadTemplate(layoutName);
535
+ const sections = {};
536
+ const sectionRegex = /@section\s*\(\s*'([^']+)'\s*\)([\s\S]*?)@endsection/g;
537
+ let sectionMatch;
538
+ while ((sectionMatch = sectionRegex.exec(templateContent)) !== null) sections[sectionMatch[1]] = sectionMatch[2].trim();
539
+
540
+ const inlineSectionRegex = /@section\s*\(\s*'([^']+)'\s*,\s*'([^']*)'\s*\)/g;
541
+ let inlineMatch;
542
+ while ((inlineMatch = inlineSectionRegex.exec(templateContent)) !== null) sections[inlineMatch[1]] = inlineMatch[2];
543
+
544
+ const yieldRegex = /@yield\s*\(\s*'([^']+)'(?:\s*,\s*'([^']*)')?\s*\)/g;
545
+ templateContent = layoutContent.replace(yieldRegex, (match, name, defaultValue) => sections[name] !== undefined ? sections[name] : (defaultValue || ''));
546
+ } catch (error) { console.warn(`Warning: Could not load layout '${layoutName}':`, error.message); }
547
+ }
548
+
549
+ const renderFunc = this.compile(templateContent);
550
+ return renderFunc(data, { filters: this.filters, functions: this.functions, includes: options.includes || {} });
551
+ }
552
+
553
+ async loadTemplate(templateName) {
554
+ if (this.options.cacheEnabled && this.options.templateCache.has(templateName)) return this.options.templateCache.get(templateName);
555
+ const possiblePaths = [
556
+ path.join(this.options.templateDir, 'pages', `${templateName}.aether`),
557
+ path.join(this.options.templateDir, `${templateName}.aether`),
558
+ path.join(this.options.templateDir, templateName),
559
+ templateName
560
+ ];
561
+ for (const templatePath of possiblePaths) {
562
+ try {
563
+ const content = await fs.readFile(templatePath, 'utf-8');
564
+ if (this.options.cacheEnabled) this.options.templateCache.set(templateName, content);
565
+ return content;
566
+ } catch (error) {}
567
+ }
568
+ throw new Error(`Template not found: ${templateName}`);
569
+ }
570
+
571
+ clearCache() { this.options.compileCache.clear(); this.options.templateCache.clear(); }
572
+
573
+ getMetadata() {
574
+ return {
575
+ name: this.name, version: this.version, initialized: this.initialized,
576
+ filters: Array.from(this.filters.keys()), functions: Array.from(this.functions.keys()),
577
+ layouts: Array.from(this.layouts.keys()), cacheEnabled: this.options.cacheEnabled, templateDir: this.options.templateDir
578
+ };
579
+ }
580
+ }
581
+
582
+ export default AetherEngine;
@@ -0,0 +1,101 @@
1
+ /**
2
+ * @license MIT
3
+ * Copyright (c) 2026-present AetherFramework Contributors.
4
+ * SPDX-License-Identifier: MIT
5
+ * @module @aetherframework/template-engine/src/engines/BaseEngine
6
+ */
7
+
8
+ /**
9
+ * Base Engine - Abstract base class for all template engines
10
+ * Provides common interface and default implementations
11
+ */
12
+ class BaseEngine {
13
+ constructor(options = {}) {
14
+ this.name = 'base';
15
+ this.version = '1.0.0';
16
+ this.initialized = false;
17
+ this.options = {
18
+ cacheEnabled: options.cacheEnabled !== false,
19
+ debug: options.debug || false,
20
+ ...options
21
+ };
22
+ }
23
+
24
+ /**
25
+ * Initialize the engine
26
+ * @abstract
27
+ */
28
+ async initialize() {
29
+ this.initialized = true;
30
+ return this;
31
+ }
32
+
33
+ /**
34
+ * Render template with data
35
+ * @param {string} template - Template content
36
+ * @param {Object} data - Template data
37
+ * @param {Object} options - Render options
38
+ * @returns {Promise<string>} Rendered HTML
39
+ * @abstract
40
+ */
41
+ async render(template, data = {}, options = {}) {
42
+ throw new Error('render() method must be implemented by subclass');
43
+ }
44
+
45
+ /**
46
+ * Compile template for reuse
47
+ * @param {string} template - Template content
48
+ * @param {Object} options - Compile options
49
+ * @returns {Function} Compiled function
50
+ * @abstract
51
+ */
52
+ compile(template, options = {}) {
53
+ throw new Error('compile() method must be implemented by subclass');
54
+ }
55
+
56
+ /**
57
+ * Clear engine cache
58
+ */
59
+ clearCache() {
60
+ // Default implementation does nothing
61
+ }
62
+
63
+ /**
64
+ * Get engine metadata
65
+ * @returns {Object} Engine information
66
+ */
67
+ getMetadata() {
68
+ return {
69
+ name: this.name,
70
+ version: this.version,
71
+ initialized: this.initialized,
72
+ options: this.options
73
+ };
74
+ }
75
+
76
+ /**
77
+ * Validate template syntax
78
+ * @param {string} template - Template content
79
+ * @returns {boolean} True if valid
80
+ */
81
+ validate(template) {
82
+ return typeof template === 'string' && template.length > 0;
83
+ }
84
+
85
+ /**
86
+ * Escape HTML special characters
87
+ * @param {string} str - String to escape
88
+ * @returns {string} Escaped string
89
+ */
90
+ escapeHtml(str) {
91
+ if (typeof str !== 'string') return str;
92
+ return str
93
+ .replace(/&/g, '&amp;')
94
+ .replace(/</g, '&lt;')
95
+ .replace(/>/g, '&gt;')
96
+ .replace(/"/g, '&quot;')
97
+ .replace(/'/g, '&#039;');
98
+ }
99
+ }
100
+
101
+ export default BaseEngine;