@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.
- package/LICENSE +21 -0
- package/README.md +662 -0
- package/examples/basic-usage.js +217 -0
- package/examples/dist/basic-usage-result.html +31 -0
- package/examples/dist/layout-example-result.html +210 -0
- package/examples/dist/templates/layouts/main.aether +58 -0
- package/examples/dist/templates/pages/home.aether +116 -0
- package/examples/layout-example.js +404 -0
- package/examples/ssr-example.js +180 -0
- package/index.js +179 -0
- package/package.json +42 -0
- package/src/core/CacheManager.js +245 -0
- package/src/core/EngineRegistry.js +148 -0
- package/src/core/ModeManager.js +231 -0
- package/src/core/TemplateEngineFactory.js +373 -0
- package/src/engines/AetherEngine.js +582 -0
- package/src/engines/BaseEngine.js +101 -0
- package/src/engines/SSRModeEngine.js +139 -0
- package/src/engines/TemplateModeEngine.js +320 -0
- package/src/utils/ConfigLoader.js +279 -0
- package/src/utils/ErrorHandler.js +276 -0
|
@@ -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">« Prev</a>`;
|
|
115
|
+
} else {
|
|
116
|
+
html += `<span class="page-link disabled">« 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 »</a>`;
|
|
138
|
+
} else {
|
|
139
|
+
html += `<span class="page-link disabled">Next »</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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
|
|
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, '&')
|
|
94
|
+
.replace(/</g, '<')
|
|
95
|
+
.replace(/>/g, '>')
|
|
96
|
+
.replace(/"/g, '"')
|
|
97
|
+
.replace(/'/g, ''');
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export default BaseEngine;
|