@genkaok/fenom-js 1.0.13
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/compiler/compile-ast.d.ts +2 -0
- package/compiler/compile-node.d.ts +2 -0
- package/compiler/compiler.d.ts +2 -0
- package/compiler/evaluate.d.ts +2 -0
- package/compiler/functions.d.ts +9 -0
- package/engine/loader.d.ts +2 -0
- package/engine/renderer.d.ts +8 -0
- package/fenom-js.cjs +1815 -0
- package/fenom-js.cjs.map +1 -0
- package/fenom-js.mjs +1810 -0
- package/fenom-js.mjs.map +1 -0
- package/filters/filters.d.ts +173 -0
- package/index.d.ts +5 -0
- package/lexer/patterns.d.ts +14 -0
- package/lexer/tokenize.d.ts +2 -0
- package/loader/loader.d.ts +2 -0
- package/node.cjs +1838 -0
- package/node.cjs.map +1 -0
- package/node.d.ts +4 -0
- package/node.mjs +1832 -0
- package/node.mjs.map +1 -0
- package/package.json +38 -0
- package/parser/parse-block.d.ts +6 -0
- package/parser/parse-expression.d.ts +2 -0
- package/parser/parse-extends.d.ts +6 -0
- package/parser/parse-for.d.ts +6 -0
- package/parser/parse-if.d.ts +5 -0
- package/parser/parse-switch.d.ts +5 -0
- package/parser/parser.d.ts +3 -0
- package/readme.md +130 -0
- package/types/common.d.ts +18 -0
- package/types/compiler/compile-ast.d.ts +2 -0
- package/types/compiler/compile-node.d.ts +2 -0
- package/types/compiler/compiler.d.ts +2 -0
- package/types/compiler/evaluate.d.ts +2 -0
- package/types/compiler/functions.d.ts +9 -0
- package/types/engine/loader.d.ts +2 -0
- package/types/engine/renderer.d.ts +8 -0
- package/types/expression.d.ts +27 -0
- package/types/filters/filters.d.ts +173 -0
- package/types/index.d.ts +5 -0
- package/types/lexer/patterns.d.ts +14 -0
- package/types/lexer/tokenize.d.ts +2 -0
- package/types/loader/loader.d.ts +2 -0
- package/types/node.d.ts +4 -0
- package/types/parser/parse-block.d.ts +6 -0
- package/types/parser/parse-expression.d.ts +2 -0
- package/types/parser/parse-extends.d.ts +6 -0
- package/types/parser/parse-for.d.ts +6 -0
- package/types/parser/parse-if.d.ts +5 -0
- package/types/parser/parse-switch.d.ts +5 -0
- package/types/parser/parser.d.ts +3 -0
- package/types/token.d.ts +9 -0
- package/types/types/common.d.ts +18 -0
- package/types/types/expression.d.ts +27 -0
- package/types/types/token.d.ts +9 -0
package/fenom-js.mjs
ADDED
|
@@ -0,0 +1,1810 @@
|
|
|
1
|
+
// --- ГРУППА: Переменные и присвоение ---
|
|
2
|
+
const SET_PATTERNS = [
|
|
3
|
+
// 1. {set $var = {...} или [...]}
|
|
4
|
+
{
|
|
5
|
+
type: 'set',
|
|
6
|
+
regex: /^\{set\s+\$(\w+)\s*=\s*(\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}|\[[^\[\]]*(?:\[[^\[\]]*\][^\[\]]*)*\])\}/,
|
|
7
|
+
process(match) {
|
|
8
|
+
return {
|
|
9
|
+
variable: match[1], // без $
|
|
10
|
+
value: match[2].trim() // строка: "{a:1}" или "[1,2]"
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
// 2. {set $var = "строка" или 'строка'}
|
|
15
|
+
{
|
|
16
|
+
type: 'set',
|
|
17
|
+
regex: /^\{set\s+\$(\w+)\s*=\s*(['"])(.*?)\2\}/,
|
|
18
|
+
process(match) {
|
|
19
|
+
return {
|
|
20
|
+
variable: match[1],
|
|
21
|
+
value: match[3] // содержимое внутри кавычек
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
// 3. {set $var = 123 / true / $other / $a + 1}
|
|
26
|
+
{
|
|
27
|
+
type: 'set',
|
|
28
|
+
regex: /^\{set\s+\$(\w+)\s*=\s*([^{][^}]*)\}/,
|
|
29
|
+
process(match) {
|
|
30
|
+
return {
|
|
31
|
+
variable: match[1],
|
|
32
|
+
value: match[2].trim() // любое выражение: 100, $x, $count + 1 и т.д.
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
// 4. {add $var ++}
|
|
37
|
+
{
|
|
38
|
+
type: 'add',
|
|
39
|
+
regex: /^\{add\s+\$(\w+)\s*\+\+\}/,
|
|
40
|
+
process(match) {
|
|
41
|
+
return {
|
|
42
|
+
variable: match[1]
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
},
|
|
46
|
+
// 5. {var $var = "значение"} — аналог set, но может инициализировать
|
|
47
|
+
{
|
|
48
|
+
type: 'var',
|
|
49
|
+
regex: /^\{var\s+\$(\w+)\s*=\s*(['"])(.*?)\2\}/,
|
|
50
|
+
process(match) {
|
|
51
|
+
return {
|
|
52
|
+
variable: match[1],
|
|
53
|
+
value: match[3]
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
];
|
|
58
|
+
// --- ГРУППА: Условия ---
|
|
59
|
+
const IF_PATTERNS = [
|
|
60
|
+
{
|
|
61
|
+
type: 'if',
|
|
62
|
+
regex: /^\{if\s+(.+?)\}/,
|
|
63
|
+
process(match) {
|
|
64
|
+
return { condition: match[1].trim() };
|
|
65
|
+
}
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
type: 'elseif',
|
|
69
|
+
regex: /^\{elseif\s+(.+?)\}/,
|
|
70
|
+
process(match) {
|
|
71
|
+
return { condition: match[1].trim() };
|
|
72
|
+
}
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
type: 'else',
|
|
76
|
+
regex: /^\{else\}/
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
type: 'endif',
|
|
80
|
+
regex: /^\{\/if\}/
|
|
81
|
+
}
|
|
82
|
+
];
|
|
83
|
+
// --- ГРУППА: Циклы ---
|
|
84
|
+
const FOREACH_PATTERNS = [
|
|
85
|
+
// 1. for i..j
|
|
86
|
+
{
|
|
87
|
+
type: 'for_range',
|
|
88
|
+
regex: /^\{(for|foreach)\s+(\d+)\.\.(\d+)\s+as\s*\$(\w+)(?:\s*\|\s*reverse)?\s*\}/,
|
|
89
|
+
process(match) {
|
|
90
|
+
return {
|
|
91
|
+
start: parseInt(match[2], 10),
|
|
92
|
+
end: parseInt(match[3], 10),
|
|
93
|
+
item: match[4],
|
|
94
|
+
reverse: match[0].includes('| reverse')
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
},
|
|
98
|
+
// 2. {foreach $path.as.array as $item}
|
|
99
|
+
{
|
|
100
|
+
type: 'for',
|
|
101
|
+
regex: /^\{(for|foreach)\s*\$([^\s}]+?)\s+as\s*\$(\w+)(\s*\|\s*reverse)?\s*\}/,
|
|
102
|
+
process: (match) => ({
|
|
103
|
+
collection: `$${match[2]}`,
|
|
104
|
+
item: match[3],
|
|
105
|
+
key: null,
|
|
106
|
+
reverse: !!match[4]
|
|
107
|
+
})
|
|
108
|
+
},
|
|
109
|
+
// 3. {foreach $path as $key => $item}
|
|
110
|
+
{
|
|
111
|
+
type: 'for',
|
|
112
|
+
regex: /^\{(for|foreach)\s*\$([^\s}]+?)\s+as\s*\$(\w+)\s*=>\s*\$(\w+)(\s*\|\s*reverse)?\s*\}/,
|
|
113
|
+
process: (match) => ({
|
|
114
|
+
collection: `$${match[2]}`,
|
|
115
|
+
key: match[3],
|
|
116
|
+
item: match[4],
|
|
117
|
+
reverse: !!match[5]
|
|
118
|
+
})
|
|
119
|
+
},
|
|
120
|
+
// 3. {foreach $path as $key => $item}
|
|
121
|
+
{
|
|
122
|
+
type: 'for',
|
|
123
|
+
regex: /^\{(for|foreach)\s*\$([^\s}]+?)\s+as\s*\$(\w+)\s*=>\s*\$(\w+)(?:\s*\|\s*reverse)?\s*\}/,
|
|
124
|
+
process: (match) => ({
|
|
125
|
+
collection: `$${match[2]}`,
|
|
126
|
+
key: match[3],
|
|
127
|
+
item: match[4],
|
|
128
|
+
reverse: match[0].includes('| reverse')
|
|
129
|
+
})
|
|
130
|
+
},
|
|
131
|
+
// 4. endfor
|
|
132
|
+
{
|
|
133
|
+
type: 'endfor',
|
|
134
|
+
regex: /^\{\/(?:for|foreach)\}/
|
|
135
|
+
},
|
|
136
|
+
// 5. foreachelse
|
|
137
|
+
{
|
|
138
|
+
type: 'foreachelse',
|
|
139
|
+
regex: /^\{foreachelse\}/
|
|
140
|
+
},
|
|
141
|
+
// 6. break / continue
|
|
142
|
+
{
|
|
143
|
+
type: 'break',
|
|
144
|
+
regex: /^\{break\}/i
|
|
145
|
+
},
|
|
146
|
+
{
|
|
147
|
+
type: 'continue',
|
|
148
|
+
regex: /^\{continue\}/i
|
|
149
|
+
}
|
|
150
|
+
];
|
|
151
|
+
// --- ГРУППА: Switch ---
|
|
152
|
+
const SWITCH_PATTERNS = [
|
|
153
|
+
{
|
|
154
|
+
type: 'switch',
|
|
155
|
+
regex: /^\{switch\s+(.+?)\}/,
|
|
156
|
+
process(match) {
|
|
157
|
+
return { value: match[1].trim() };
|
|
158
|
+
}
|
|
159
|
+
},
|
|
160
|
+
{
|
|
161
|
+
type: 'case',
|
|
162
|
+
regex: /^\{case\s+(.+?)\}/,
|
|
163
|
+
process(match) {
|
|
164
|
+
return { value: match[1].trim() };
|
|
165
|
+
}
|
|
166
|
+
},
|
|
167
|
+
{
|
|
168
|
+
type: 'default',
|
|
169
|
+
regex: /^\{default\}/
|
|
170
|
+
},
|
|
171
|
+
{
|
|
172
|
+
type: 'endswitch',
|
|
173
|
+
regex: /^\{\/switch\}/
|
|
174
|
+
}
|
|
175
|
+
];
|
|
176
|
+
// --- ГРУППА: Cycle ---
|
|
177
|
+
const CYCLE_PATTERNS = [
|
|
178
|
+
{
|
|
179
|
+
type: 'cycle',
|
|
180
|
+
regex: /^\{cycle\s+(.+?)\}/,
|
|
181
|
+
process(match) {
|
|
182
|
+
return { values: match[1] }; // например: "'red','blue'"
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
];
|
|
186
|
+
// --- ГРУППА: Включение шаблонов ---
|
|
187
|
+
const INCLUDE_PATTERNS = [
|
|
188
|
+
{
|
|
189
|
+
type: 'include',
|
|
190
|
+
// Поддерживает: {include 'file:...' key="value" key='value' key=$var key=word}
|
|
191
|
+
regex: /^\{include\s+['"]file:([^'"]+)['"](?:\s+((?:\s*\w+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s}]+))+))?\s*\}/,
|
|
192
|
+
process: (match) => {
|
|
193
|
+
const file = match[1];
|
|
194
|
+
const paramsPart = match[2]; // 'title="Тест" user=$currentUser'
|
|
195
|
+
const params = {};
|
|
196
|
+
if (paramsPart) {
|
|
197
|
+
// Извлекаем все `ключ=значение` через регулярку
|
|
198
|
+
const paramRegex = /(\w+)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s}]+))/g;
|
|
199
|
+
let paramMatch;
|
|
200
|
+
while ((paramMatch = paramRegex.exec(paramsPart)) !== null) {
|
|
201
|
+
const key = paramMatch[1];
|
|
202
|
+
const value = paramMatch[2] || paramMatch[3] || paramMatch[4] || '';
|
|
203
|
+
params[key] = value;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
return { file, params };
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
];
|
|
210
|
+
// --- ГРУППА: Наследование ---
|
|
211
|
+
const EXTENDS_PATTERNS = [
|
|
212
|
+
// {extends 'file:...'}
|
|
213
|
+
{
|
|
214
|
+
type: 'extends',
|
|
215
|
+
regex: /^\{extends\s+['"]file:([^'"]+)['"]\s*\}/,
|
|
216
|
+
process: (match) => ({ file: match[1] }),
|
|
217
|
+
},
|
|
218
|
+
// {block "name"} → открывается
|
|
219
|
+
{
|
|
220
|
+
type: 'block_open',
|
|
221
|
+
regex: /^\{block\s+(['"])(.*?)\1\s*\}/,
|
|
222
|
+
process(match) {
|
|
223
|
+
return { name: match[2] };
|
|
224
|
+
}
|
|
225
|
+
},
|
|
226
|
+
// {/block} → закрывается
|
|
227
|
+
{
|
|
228
|
+
type: 'block_close',
|
|
229
|
+
regex: /^\{\/block\}/,
|
|
230
|
+
},
|
|
231
|
+
// {parent} — вставляет родительский контент блока
|
|
232
|
+
{
|
|
233
|
+
type: 'parent',
|
|
234
|
+
regex: /^\{parent\}/
|
|
235
|
+
},
|
|
236
|
+
// {paste "blockName"} — вставка другого блока (Fenom-фича)
|
|
237
|
+
{
|
|
238
|
+
type: 'paste',
|
|
239
|
+
regex: /^\{paste\s+(['"])(.*?)\1\}/,
|
|
240
|
+
process(match) {
|
|
241
|
+
return { name: match[2] };
|
|
242
|
+
}
|
|
243
|
+
},
|
|
244
|
+
// {use 'file:...'} — импорт макросов
|
|
245
|
+
{
|
|
246
|
+
type: 'use',
|
|
247
|
+
regex: /^\{use\s+(['"])(.*?)\1\}/,
|
|
248
|
+
process(match) {
|
|
249
|
+
return { file: match[2] };
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
];
|
|
253
|
+
// --- ГРУППА: Фильтры и экранирование ---
|
|
254
|
+
const FILTER_PATTERNS = [
|
|
255
|
+
{
|
|
256
|
+
type: 'filter',
|
|
257
|
+
regex: /^\{filter\s+(.+?)\}/,
|
|
258
|
+
process(match) {
|
|
259
|
+
return { filter: match[1].trim() };
|
|
260
|
+
}
|
|
261
|
+
},
|
|
262
|
+
{
|
|
263
|
+
type: 'endfilter',
|
|
264
|
+
regex: /^\{\/filter\}/
|
|
265
|
+
},
|
|
266
|
+
{
|
|
267
|
+
type: 'raw',
|
|
268
|
+
regex: /^\{raw\}/
|
|
269
|
+
},
|
|
270
|
+
{
|
|
271
|
+
type: 'endraw',
|
|
272
|
+
regex: /^\{\/raw\}/
|
|
273
|
+
},
|
|
274
|
+
{
|
|
275
|
+
type: 'autoescape',
|
|
276
|
+
regex: /^\{autoescape\}/
|
|
277
|
+
},
|
|
278
|
+
{
|
|
279
|
+
type: 'endautoescape',
|
|
280
|
+
regex: /^\{\/autoescape\}/
|
|
281
|
+
}
|
|
282
|
+
];
|
|
283
|
+
// --- ГРУППА: Макросы и импорт ---
|
|
284
|
+
const MACRO_PATTERNS = [
|
|
285
|
+
{
|
|
286
|
+
type: 'macro',
|
|
287
|
+
regex: /^\{macro\s+(\w+)(?:\s*\((.*?)\))?\}/,
|
|
288
|
+
process(match) {
|
|
289
|
+
const args = match[2] ? match[2].split(',').map(s => s.trim()) : [];
|
|
290
|
+
return { name: match[1], args };
|
|
291
|
+
}
|
|
292
|
+
},
|
|
293
|
+
{
|
|
294
|
+
type: 'endmacro',
|
|
295
|
+
regex: /^\{\/macro\}/
|
|
296
|
+
},
|
|
297
|
+
{
|
|
298
|
+
type: 'import',
|
|
299
|
+
regex: /^\{import\s+(['"])(.*?)\1\s+as\s+(\w+)\}/,
|
|
300
|
+
process(match) {
|
|
301
|
+
return { file: match[2], alias: match[3] };
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
];
|
|
305
|
+
// --- ГРУППА: Игнор ---
|
|
306
|
+
const IGNORE_PATTERN = [
|
|
307
|
+
{
|
|
308
|
+
type: 'ignore_block',
|
|
309
|
+
regex: /^\{ignore\}([\s\S]*?)\{\/ignore\}/,
|
|
310
|
+
process: (match) => ({
|
|
311
|
+
content: match[1] // содержимое между {ignore} и {/ignore}
|
|
312
|
+
})
|
|
313
|
+
}
|
|
314
|
+
];
|
|
315
|
+
// --- ГРУППА: Прочее ---
|
|
316
|
+
const MISC_PATTERNS = [
|
|
317
|
+
{
|
|
318
|
+
type: 'unset',
|
|
319
|
+
regex: /^\{unset\s+\$([^\s}]+)\}/,
|
|
320
|
+
process(match) {
|
|
321
|
+
return { variable: '$' + match[1] };
|
|
322
|
+
}
|
|
323
|
+
},
|
|
324
|
+
{
|
|
325
|
+
type: 'comment',
|
|
326
|
+
regex: /^\{\*\s*([\s\S]*?)\s*\*\}/,
|
|
327
|
+
// не нужно process — мы просто пропустим этот блок
|
|
328
|
+
}
|
|
329
|
+
];
|
|
330
|
+
// --- ГРУППА: Вывод переменных с модификаторами ---
|
|
331
|
+
const OUTPUT_PATTERN = [
|
|
332
|
+
// 1. {output name="title"}
|
|
333
|
+
{
|
|
334
|
+
type: 'output',
|
|
335
|
+
regex: /^\{output\s+name\s*=\s*(['"])(.*?)\1\s*\}/,
|
|
336
|
+
process: (match) => ({
|
|
337
|
+
name: match[2],
|
|
338
|
+
filters: []
|
|
339
|
+
})
|
|
340
|
+
},
|
|
341
|
+
// 2. {output "$title"} или {output $title}
|
|
342
|
+
{
|
|
343
|
+
type: 'output',
|
|
344
|
+
regex: /^\{output\s+(['"])(.*?)\1\s*\}/,
|
|
345
|
+
process: (match) => ({
|
|
346
|
+
name: match[2],
|
|
347
|
+
filters: []
|
|
348
|
+
})
|
|
349
|
+
},
|
|
350
|
+
{
|
|
351
|
+
type: 'output',
|
|
352
|
+
regex: /^\{output\s+([^\s}]+)\s*\}/,
|
|
353
|
+
process: (match) => ({
|
|
354
|
+
name: match[1],
|
|
355
|
+
filters: []
|
|
356
|
+
})
|
|
357
|
+
},
|
|
358
|
+
// 3. Выражения: {output $user.age + 18}
|
|
359
|
+
{
|
|
360
|
+
type: 'output',
|
|
361
|
+
regex: /^\{output\s+(\$?[^}]+)\}/,
|
|
362
|
+
process: (match) => ({
|
|
363
|
+
name: match[1].trim(),
|
|
364
|
+
filters: []
|
|
365
|
+
})
|
|
366
|
+
},
|
|
367
|
+
// 🔥 4. ОСНОВНОЙ случай: {$var}, {$var|filter}, {$var|filter:"arg"}
|
|
368
|
+
{
|
|
369
|
+
type: 'output',
|
|
370
|
+
regex: /^\{\$(.+?)\}/, // ← нежадный — ловит всё внутри
|
|
371
|
+
process: (match) => {
|
|
372
|
+
const content = match[1].trim();
|
|
373
|
+
const parts = content.split('|').map(s => s.trim());
|
|
374
|
+
const variable = parts[0];
|
|
375
|
+
const filters = parts.slice(1);
|
|
376
|
+
return {
|
|
377
|
+
name: `$${variable}`, // → '$arr'
|
|
378
|
+
filters // → ['length']
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
},
|
|
382
|
+
// Любое выражение в { ... }, даже без $
|
|
383
|
+
{
|
|
384
|
+
type: 'output',
|
|
385
|
+
regex: /^\{([^$].+?)\}/,
|
|
386
|
+
process: (match) => {
|
|
387
|
+
const content = match[1].trim();
|
|
388
|
+
return {
|
|
389
|
+
name: content, // → '"Привет" ~ " " ~ "мир"'
|
|
390
|
+
filters: []
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
];
|
|
395
|
+
// Поддержка ++, --, +=, -=, *= и т.д.
|
|
396
|
+
const OPERATOR_PATTERN = [
|
|
397
|
+
{
|
|
398
|
+
type: 'operator',
|
|
399
|
+
regex: /^\{\$([^\s}]+)\s*(\+\+|--|\+=|-=|\*=|\/=|\%=)\s*([^}]+)?\}/,
|
|
400
|
+
process: (match) => {
|
|
401
|
+
var _a;
|
|
402
|
+
const variable = match[1];
|
|
403
|
+
const operator = match[2];
|
|
404
|
+
const value = ((_a = match[3]) === null || _a === void 0 ? void 0 : _a.trim()) || '1';
|
|
405
|
+
return { variable: '$' + variable, operator, value };
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
];
|
|
409
|
+
|
|
410
|
+
// Объединяем все паттерны
|
|
411
|
+
const ALL_PATTERNS = [
|
|
412
|
+
...EXTENDS_PATTERNS,
|
|
413
|
+
...INCLUDE_PATTERNS,
|
|
414
|
+
...FOREACH_PATTERNS,
|
|
415
|
+
...SWITCH_PATTERNS,
|
|
416
|
+
...OPERATOR_PATTERN,
|
|
417
|
+
...IF_PATTERNS,
|
|
418
|
+
...IGNORE_PATTERN,
|
|
419
|
+
...SET_PATTERNS,
|
|
420
|
+
...MISC_PATTERNS,
|
|
421
|
+
...CYCLE_PATTERNS,
|
|
422
|
+
...FILTER_PATTERNS,
|
|
423
|
+
...MACRO_PATTERNS,
|
|
424
|
+
...OUTPUT_PATTERN,
|
|
425
|
+
];
|
|
426
|
+
function tokenize(input) {
|
|
427
|
+
const tokens = [];
|
|
428
|
+
let pos = 0;
|
|
429
|
+
while (pos < input.length) {
|
|
430
|
+
let matched = false;
|
|
431
|
+
if (input.slice(pos).startsWith('{ignore}')) {
|
|
432
|
+
let depth = 1;
|
|
433
|
+
let i = pos + 8; // длина '{ignore}'
|
|
434
|
+
while (i < input.length) {
|
|
435
|
+
if (input.length - i >= 8 && input.startsWith('{ignore}', i)) {
|
|
436
|
+
depth++;
|
|
437
|
+
i += 8;
|
|
438
|
+
continue;
|
|
439
|
+
}
|
|
440
|
+
if (input.length - i >= 9 && input.startsWith('{/ignore}', i)) {
|
|
441
|
+
depth--;
|
|
442
|
+
i += 9;
|
|
443
|
+
if (depth === 0) {
|
|
444
|
+
const content = input.slice(pos + 8, i - 9);
|
|
445
|
+
tokens.push({ type: 'ignore_block', content });
|
|
446
|
+
pos = i;
|
|
447
|
+
matched = true;
|
|
448
|
+
break;
|
|
449
|
+
}
|
|
450
|
+
continue;
|
|
451
|
+
}
|
|
452
|
+
i++;
|
|
453
|
+
}
|
|
454
|
+
if (!matched) {
|
|
455
|
+
tokens.push({ type: 'text', value: '{ignore}' });
|
|
456
|
+
pos += 8;
|
|
457
|
+
}
|
|
458
|
+
continue;
|
|
459
|
+
}
|
|
460
|
+
if (input[pos] !== '{') {
|
|
461
|
+
const nextBrace = input.indexOf('{', pos);
|
|
462
|
+
if (nextBrace === -1) {
|
|
463
|
+
tokens.push({ type: 'text', value: input.slice(pos) });
|
|
464
|
+
break;
|
|
465
|
+
}
|
|
466
|
+
if (nextBrace > pos) {
|
|
467
|
+
tokens.push({ type: 'text', value: input.slice(pos, nextBrace) });
|
|
468
|
+
}
|
|
469
|
+
pos = nextBrace;
|
|
470
|
+
}
|
|
471
|
+
for (const pattern of ALL_PATTERNS) {
|
|
472
|
+
const substr = input.slice(pos);
|
|
473
|
+
const match = substr.match(pattern.regex);
|
|
474
|
+
if (match) {
|
|
475
|
+
if (pattern.type === 'comment') {
|
|
476
|
+
tokens.push({ type: 'comment' });
|
|
477
|
+
pos += match[0].length;
|
|
478
|
+
matched = true;
|
|
479
|
+
break;
|
|
480
|
+
}
|
|
481
|
+
const token = {
|
|
482
|
+
type: pattern.type,
|
|
483
|
+
value: match[0] // ← добавляем оригинальный текст токена
|
|
484
|
+
};
|
|
485
|
+
if (pattern.process) {
|
|
486
|
+
Object.assign(token, pattern.process(match));
|
|
487
|
+
}
|
|
488
|
+
tokens.push(token);
|
|
489
|
+
pos += match[0].length;
|
|
490
|
+
matched = true;
|
|
491
|
+
break;
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
if (!matched) {
|
|
495
|
+
const context = input.slice(pos, pos + 30).replace(/\n/g, '↵');
|
|
496
|
+
console.warn(`Skip unknown tag at ${pos}: "${context}"`);
|
|
497
|
+
pos++;
|
|
498
|
+
}
|
|
499
|
+
// console.log('[TOKEN] matched:', tokens);
|
|
500
|
+
}
|
|
501
|
+
return tokens;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
function parseIf(tokens, index) {
|
|
505
|
+
const ifToken = tokens[index];
|
|
506
|
+
const node = {
|
|
507
|
+
type: 'if',
|
|
508
|
+
condition: ifToken.condition,
|
|
509
|
+
body: [],
|
|
510
|
+
elseIfs: [],
|
|
511
|
+
elseBody: []
|
|
512
|
+
};
|
|
513
|
+
let i = index + 1;
|
|
514
|
+
let depth = 0;
|
|
515
|
+
// Собираем токены для каждой ветки
|
|
516
|
+
const bodyTokens = [];
|
|
517
|
+
const elseIfs = [];
|
|
518
|
+
const elseTokens = [];
|
|
519
|
+
let currentElseIf = null;
|
|
520
|
+
let inElseBranch = false;
|
|
521
|
+
while (i < tokens.length) {
|
|
522
|
+
const token = tokens[i];
|
|
523
|
+
// Увеличиваем глубину для всех вложенных блоков
|
|
524
|
+
if (token.type === 'if') {
|
|
525
|
+
depth++;
|
|
526
|
+
}
|
|
527
|
+
if (token.type === 'for' || token.type === 'foreach' || token.type === 'for_range') {
|
|
528
|
+
depth++;
|
|
529
|
+
}
|
|
530
|
+
if (depth > 0) {
|
|
531
|
+
// Внутри вложенного блока — собираем токены и обновляем глубину
|
|
532
|
+
if (!currentElseIf && !inElseBranch) {
|
|
533
|
+
bodyTokens.push(token);
|
|
534
|
+
}
|
|
535
|
+
else if (currentElseIf) {
|
|
536
|
+
currentElseIf.tokens.push(token);
|
|
537
|
+
}
|
|
538
|
+
else if (inElseBranch) {
|
|
539
|
+
elseTokens.push(token);
|
|
540
|
+
}
|
|
541
|
+
// Уменьшаем глубину для закрывающих тегов
|
|
542
|
+
if (token.type === 'endif') {
|
|
543
|
+
depth--;
|
|
544
|
+
}
|
|
545
|
+
if (token.type === 'endfor' || token.type === 'endforeach') {
|
|
546
|
+
depth--;
|
|
547
|
+
}
|
|
548
|
+
i++;
|
|
549
|
+
continue;
|
|
550
|
+
}
|
|
551
|
+
// Обработка веток
|
|
552
|
+
if (token.type === 'elseif') {
|
|
553
|
+
if (!currentElseIf && !inElseBranch) {
|
|
554
|
+
currentElseIf = {
|
|
555
|
+
condition: token.condition,
|
|
556
|
+
tokens: []
|
|
557
|
+
};
|
|
558
|
+
elseIfs.push(currentElseIf);
|
|
559
|
+
}
|
|
560
|
+
else if (currentElseIf) {
|
|
561
|
+
currentElseIf.tokens.push(token);
|
|
562
|
+
}
|
|
563
|
+
else if (inElseBranch) {
|
|
564
|
+
elseTokens.push(token);
|
|
565
|
+
}
|
|
566
|
+
i++;
|
|
567
|
+
continue;
|
|
568
|
+
}
|
|
569
|
+
if (token.type === 'else') {
|
|
570
|
+
// 🔴 Завершаем текущий elseif
|
|
571
|
+
currentElseIf = null;
|
|
572
|
+
inElseBranch = true;
|
|
573
|
+
i++;
|
|
574
|
+
continue;
|
|
575
|
+
}
|
|
576
|
+
if (token.type === 'endif') {
|
|
577
|
+
break;
|
|
578
|
+
}
|
|
579
|
+
// Собираем токены
|
|
580
|
+
if (!currentElseIf && !inElseBranch) {
|
|
581
|
+
bodyTokens.push(token);
|
|
582
|
+
}
|
|
583
|
+
else if (currentElseIf) {
|
|
584
|
+
currentElseIf.tokens.push(token);
|
|
585
|
+
}
|
|
586
|
+
else if (inElseBranch) {
|
|
587
|
+
elseTokens.push(token);
|
|
588
|
+
}
|
|
589
|
+
i++;
|
|
590
|
+
}
|
|
591
|
+
// 🔥 ПАРСИМ собранные токены → в AST
|
|
592
|
+
node.body = parse(bodyTokens);
|
|
593
|
+
node.elseIfs = elseIfs.map(elif => ({
|
|
594
|
+
condition: elif.condition,
|
|
595
|
+
body: parse(elif.tokens)
|
|
596
|
+
}));
|
|
597
|
+
node.elseBody = parse(elseTokens);
|
|
598
|
+
// Возвращаем следующий индекс после {/if}
|
|
599
|
+
return {
|
|
600
|
+
node,
|
|
601
|
+
nextIndex: i + 1 // пропускаем {/if}
|
|
602
|
+
};
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
function parseFor(tokens, index) {
|
|
606
|
+
const token = tokens[index];
|
|
607
|
+
let node;
|
|
608
|
+
// 🔥 Обработка for_range
|
|
609
|
+
if (token.type === 'for_range') {
|
|
610
|
+
node = {
|
|
611
|
+
type: 'for_range',
|
|
612
|
+
start: token.start,
|
|
613
|
+
end: token.end,
|
|
614
|
+
item: token.item,
|
|
615
|
+
reverse: Boolean(token.reverse),
|
|
616
|
+
body: [],
|
|
617
|
+
elseBody: []
|
|
618
|
+
};
|
|
619
|
+
}
|
|
620
|
+
// Обычный for / foreach
|
|
621
|
+
else if (token.type === 'for' || token.type === 'foreach') {
|
|
622
|
+
node = {
|
|
623
|
+
type: 'for',
|
|
624
|
+
collection: token.collection,
|
|
625
|
+
item: token.item,
|
|
626
|
+
key: token.key || null,
|
|
627
|
+
reverse: Boolean(token.reverse),
|
|
628
|
+
body: [],
|
|
629
|
+
elseBody: []
|
|
630
|
+
};
|
|
631
|
+
}
|
|
632
|
+
// Неизвестный токен
|
|
633
|
+
else {
|
|
634
|
+
throw new Error(`Invalid for token at ${index}: ${token.type}`);
|
|
635
|
+
}
|
|
636
|
+
let i = index + 1;
|
|
637
|
+
let depth = 0;
|
|
638
|
+
let inElseBranch = false;
|
|
639
|
+
const bodyTokens = [];
|
|
640
|
+
const elseTokens = [];
|
|
641
|
+
while (i < tokens.length) {
|
|
642
|
+
const currentToken = tokens[i];
|
|
643
|
+
// Увеличиваем глубину для вложенных блоков (циклы и условия)
|
|
644
|
+
if (currentToken.type === 'for' || currentToken.type === 'foreach' || currentToken.type === 'for_range') {
|
|
645
|
+
depth++;
|
|
646
|
+
}
|
|
647
|
+
if (currentToken.type === 'if') {
|
|
648
|
+
depth++;
|
|
649
|
+
}
|
|
650
|
+
// Закрывающие теги
|
|
651
|
+
if (currentToken.type === 'endfor' || currentToken.type === 'endforeach') {
|
|
652
|
+
if (depth > 0) {
|
|
653
|
+
depth--;
|
|
654
|
+
}
|
|
655
|
+
else {
|
|
656
|
+
break; // выходим — нашли конец текущего цикла
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
if (currentToken.type === 'endif') {
|
|
660
|
+
if (depth > 0) {
|
|
661
|
+
depth--;
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
// Поддержка {foreachelse}
|
|
665
|
+
if (currentToken.type === 'foreachelse') {
|
|
666
|
+
if (depth === 0) {
|
|
667
|
+
inElseBranch = true;
|
|
668
|
+
i++;
|
|
669
|
+
continue;
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
// Собираем токены в нужную ветку
|
|
673
|
+
if (!inElseBranch) {
|
|
674
|
+
bodyTokens.push(currentToken);
|
|
675
|
+
}
|
|
676
|
+
else {
|
|
677
|
+
elseTokens.push(currentToken);
|
|
678
|
+
}
|
|
679
|
+
i++;
|
|
680
|
+
}
|
|
681
|
+
if (i >= tokens.length) {
|
|
682
|
+
throw new Error('Unclosed for loop: expected {/for}');
|
|
683
|
+
}
|
|
684
|
+
// Рекурсивно парсим
|
|
685
|
+
node.body = parse(bodyTokens);
|
|
686
|
+
if (elseTokens.length > 0) {
|
|
687
|
+
node.elseBody = parse(elseTokens);
|
|
688
|
+
}
|
|
689
|
+
return {
|
|
690
|
+
node,
|
|
691
|
+
nextIndex: i + 1 // пропускаем {/for}
|
|
692
|
+
};
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
function parseSwitch(tokens, index) {
|
|
696
|
+
const switchToken = tokens[index];
|
|
697
|
+
const node = {
|
|
698
|
+
type: 'switch',
|
|
699
|
+
value: switchToken.value,
|
|
700
|
+
cases: [],
|
|
701
|
+
defaultBody: []
|
|
702
|
+
};
|
|
703
|
+
let i = index + 1;
|
|
704
|
+
let depth = 0;
|
|
705
|
+
let currentCase = null;
|
|
706
|
+
let hasDefault = false;
|
|
707
|
+
while (i < tokens.length) {
|
|
708
|
+
const token = tokens[i];
|
|
709
|
+
if (token.type === 'switch') {
|
|
710
|
+
depth++;
|
|
711
|
+
}
|
|
712
|
+
if (token.type === 'endswitch') {
|
|
713
|
+
if (depth > 0) {
|
|
714
|
+
depth--;
|
|
715
|
+
if (currentCase) {
|
|
716
|
+
currentCase.body.push(token);
|
|
717
|
+
}
|
|
718
|
+
i++;
|
|
719
|
+
continue;
|
|
720
|
+
}
|
|
721
|
+
return {
|
|
722
|
+
node,
|
|
723
|
+
nextIndex: i + 1
|
|
724
|
+
};
|
|
725
|
+
}
|
|
726
|
+
if (depth > 0) {
|
|
727
|
+
if (currentCase) {
|
|
728
|
+
currentCase.body.push(token);
|
|
729
|
+
}
|
|
730
|
+
i++;
|
|
731
|
+
continue;
|
|
732
|
+
}
|
|
733
|
+
if (token.type === 'case') {
|
|
734
|
+
currentCase = {
|
|
735
|
+
value: token.value,
|
|
736
|
+
body: []
|
|
737
|
+
};
|
|
738
|
+
node.cases.push(currentCase);
|
|
739
|
+
i++;
|
|
740
|
+
continue;
|
|
741
|
+
}
|
|
742
|
+
if (token.type === 'default') {
|
|
743
|
+
if (hasDefault) {
|
|
744
|
+
throw new Error('Duplicate {default} in switch');
|
|
745
|
+
}
|
|
746
|
+
hasDefault = true;
|
|
747
|
+
currentCase = null; // после этого идёт defaultBody
|
|
748
|
+
i++;
|
|
749
|
+
continue;
|
|
750
|
+
}
|
|
751
|
+
// Добавляем токены в нужное место
|
|
752
|
+
if (currentCase) {
|
|
753
|
+
currentCase.body.push(token);
|
|
754
|
+
}
|
|
755
|
+
else if (hasDefault) {
|
|
756
|
+
node.defaultBody.push(token);
|
|
757
|
+
}
|
|
758
|
+
else {
|
|
759
|
+
// Пока нет default — все токены до него относятся к последнему case
|
|
760
|
+
if (node.cases.length > 0) {
|
|
761
|
+
node.cases[node.cases.length - 1].body.push(token);
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
i++;
|
|
765
|
+
}
|
|
766
|
+
throw new Error('Unclosed switch: expected {/switch}');
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
// parser/parser.ts
|
|
770
|
+
function parse(tokens) {
|
|
771
|
+
const ast = [];
|
|
772
|
+
let i = 0;
|
|
773
|
+
while (i < tokens.length) {
|
|
774
|
+
const token = tokens[i];
|
|
775
|
+
// --- Обработка block ---
|
|
776
|
+
if (token.type === 'block_open') {
|
|
777
|
+
const blockName = token.name;
|
|
778
|
+
i++; // пропускаем {block ...}
|
|
779
|
+
const blockTokens = [];
|
|
780
|
+
let depth = 0;
|
|
781
|
+
while (i < tokens.length) {
|
|
782
|
+
const current = tokens[i];
|
|
783
|
+
if (current.type === 'block_open') {
|
|
784
|
+
depth++;
|
|
785
|
+
}
|
|
786
|
+
if (current.type === 'block_close') {
|
|
787
|
+
if (depth === 0)
|
|
788
|
+
break;
|
|
789
|
+
depth--;
|
|
790
|
+
}
|
|
791
|
+
blockTokens.push(current);
|
|
792
|
+
i++;
|
|
793
|
+
}
|
|
794
|
+
const body = parse(blockTokens); // ← рекурсивно парсим содержимое
|
|
795
|
+
ast.push({
|
|
796
|
+
type: 'block',
|
|
797
|
+
name: blockName,
|
|
798
|
+
body,
|
|
799
|
+
});
|
|
800
|
+
i++; // пропускаем {/block}
|
|
801
|
+
continue;
|
|
802
|
+
}
|
|
803
|
+
// --- Обработка extends ---
|
|
804
|
+
if (token.type === 'extends') {
|
|
805
|
+
ast.push({ ...token });
|
|
806
|
+
i++;
|
|
807
|
+
continue;
|
|
808
|
+
}
|
|
809
|
+
// --- Обработка include ---
|
|
810
|
+
if (token.type === 'include') {
|
|
811
|
+
ast.push({ ...token });
|
|
812
|
+
i++;
|
|
813
|
+
continue;
|
|
814
|
+
}
|
|
815
|
+
// --- Остальные теги ---
|
|
816
|
+
if (['set', 'var', 'add'].includes(token.type)) {
|
|
817
|
+
ast.push({ ...token });
|
|
818
|
+
i++;
|
|
819
|
+
continue;
|
|
820
|
+
}
|
|
821
|
+
if (token.type === 'if') {
|
|
822
|
+
const { node, nextIndex } = parseIf(tokens, i);
|
|
823
|
+
ast.push(node);
|
|
824
|
+
i = nextIndex; // ← сначала обновляем
|
|
825
|
+
continue;
|
|
826
|
+
}
|
|
827
|
+
if (['for', 'foreach', 'for_range'].includes(token.type)) {
|
|
828
|
+
const { node, nextIndex } = parseFor(tokens, i);
|
|
829
|
+
ast.push(node);
|
|
830
|
+
i = nextIndex;
|
|
831
|
+
continue;
|
|
832
|
+
}
|
|
833
|
+
if (token.type === 'switch') {
|
|
834
|
+
const { node, nextIndex } = parseSwitch(tokens, i);
|
|
835
|
+
ast.push(node);
|
|
836
|
+
i = nextIndex;
|
|
837
|
+
continue;
|
|
838
|
+
}
|
|
839
|
+
if (token.type === 'output') {
|
|
840
|
+
const match = token.value.match(/^\{\$(.+)\}$/);
|
|
841
|
+
if (!match) {
|
|
842
|
+
ast.push({ type: 'text', value: token.value });
|
|
843
|
+
i++;
|
|
844
|
+
continue;
|
|
845
|
+
}
|
|
846
|
+
const content = match[1].trim();
|
|
847
|
+
const parts = content.split('|');
|
|
848
|
+
const variable = parts[0];
|
|
849
|
+
const filters = parts.slice(1);
|
|
850
|
+
ast.push({
|
|
851
|
+
type: 'output',
|
|
852
|
+
name: `$${variable}`,
|
|
853
|
+
filters
|
|
854
|
+
});
|
|
855
|
+
i++;
|
|
856
|
+
continue;
|
|
857
|
+
}
|
|
858
|
+
if (['elseif', 'else', 'endif', 'endfor', 'endforeach', 'endswitch'].includes(token.type)) {
|
|
859
|
+
i++;
|
|
860
|
+
continue;
|
|
861
|
+
}
|
|
862
|
+
// Простые токены
|
|
863
|
+
ast.push({ ...token });
|
|
864
|
+
i++;
|
|
865
|
+
}
|
|
866
|
+
return ast;
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
function tokenizeExpression(expr) {
|
|
870
|
+
const tokens = [];
|
|
871
|
+
const re = /(\s+|[$a-zA-Z_]\w*(?:\.\w+)*|"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|\d+(?:\.\d+)?|[-+*/%=<>!&|?:(){}[\]~]+|[\w-]+:)/g;
|
|
872
|
+
let match;
|
|
873
|
+
const isOperator = (val) => {
|
|
874
|
+
return [
|
|
875
|
+
'+', '-', '*', '/', '%',
|
|
876
|
+
'==', '!=', '!==', '<', '<=', '>', '>=',
|
|
877
|
+
'&&', '||', '!', '(', ')', '?', ':',
|
|
878
|
+
'~' // ← добавили
|
|
879
|
+
].includes(val);
|
|
880
|
+
};
|
|
881
|
+
while ((match = re.exec(expr)) !== null) {
|
|
882
|
+
const val = match[0].trim();
|
|
883
|
+
if (!val)
|
|
884
|
+
continue;
|
|
885
|
+
if (isOperator(val)) {
|
|
886
|
+
tokens.push({ type: 'op', value: val });
|
|
887
|
+
}
|
|
888
|
+
else if (val === '|') {
|
|
889
|
+
tokens.push({ type: 'filter', value: val });
|
|
890
|
+
}
|
|
891
|
+
else if (val.startsWith('$')) {
|
|
892
|
+
tokens.push({ type: 'var', value: val });
|
|
893
|
+
}
|
|
894
|
+
else if (/^["']/.test(val)) {
|
|
895
|
+
const str = val.slice(1, -1).replace(/\\(.)/g, '$1');
|
|
896
|
+
tokens.push({ type: 'str', value: str });
|
|
897
|
+
}
|
|
898
|
+
else if (!isNaN(+val)) {
|
|
899
|
+
tokens.push({ type: 'num', value: val });
|
|
900
|
+
}
|
|
901
|
+
else {
|
|
902
|
+
tokens.push({ type: 'str', value: val });
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
return tokens;
|
|
906
|
+
}
|
|
907
|
+
// Основная функция — только один раз
|
|
908
|
+
function parseExpression(expr) {
|
|
909
|
+
const tokens = tokenizeExpression(expr);
|
|
910
|
+
let pos = 0;
|
|
911
|
+
function parseTernary() {
|
|
912
|
+
const test = parseLogical();
|
|
913
|
+
if (pos < tokens.length && tokens[pos].value === '?') {
|
|
914
|
+
pos++;
|
|
915
|
+
const consequent = parseExpressionInternal(); // ✅ Используем внутреннюю
|
|
916
|
+
if (pos < tokens.length && tokens[pos].value === ':')
|
|
917
|
+
pos++;
|
|
918
|
+
const alternate = parseTernary();
|
|
919
|
+
return { type: 'conditional', test, consequent, alternate };
|
|
920
|
+
}
|
|
921
|
+
return test;
|
|
922
|
+
}
|
|
923
|
+
function parseLogical() {
|
|
924
|
+
return parseBinary(() => parseEquality(), ['||', '&&']);
|
|
925
|
+
}
|
|
926
|
+
function parseEquality() {
|
|
927
|
+
return parseBinary(() => parseRelational(), ['==', '!=', '!==']);
|
|
928
|
+
}
|
|
929
|
+
function parseRelational() {
|
|
930
|
+
return parseBinary(() => parseAdditive(), ['<', '<=', '>', '>=']);
|
|
931
|
+
}
|
|
932
|
+
function parseAdditive() {
|
|
933
|
+
return parseBinary(() => parseMultiplicative(), ['+', '-', '~']);
|
|
934
|
+
}
|
|
935
|
+
function parseMultiplicative() {
|
|
936
|
+
return parseBinary(() => parseUnary(), ['*', '/', '%']);
|
|
937
|
+
}
|
|
938
|
+
function parseUnary() {
|
|
939
|
+
if (pos < tokens.length && ['!', '+', '-'].includes(tokens[pos].value)) {
|
|
940
|
+
const op = tokens[pos].value;
|
|
941
|
+
pos++;
|
|
942
|
+
return { type: 'unary', operator: op, argument: parseUnary() };
|
|
943
|
+
}
|
|
944
|
+
return parsePrimary();
|
|
945
|
+
}
|
|
946
|
+
function parsePrimary() {
|
|
947
|
+
const token = tokens[pos];
|
|
948
|
+
if (!token)
|
|
949
|
+
throw new Error('Unexpected end of expression');
|
|
950
|
+
if (token.type === 'num') {
|
|
951
|
+
pos++;
|
|
952
|
+
return { type: 'literal', value: +token.value };
|
|
953
|
+
}
|
|
954
|
+
if (token.type === 'str') {
|
|
955
|
+
pos++;
|
|
956
|
+
return { type: 'literal', value: token.value };
|
|
957
|
+
}
|
|
958
|
+
if (token.type === 'var') {
|
|
959
|
+
pos++;
|
|
960
|
+
return { type: 'variable', path: token.value.slice(1) };
|
|
961
|
+
}
|
|
962
|
+
if (token.value === '(') {
|
|
963
|
+
pos++;
|
|
964
|
+
const expr = parseTernary();
|
|
965
|
+
if (pos >= tokens.length || tokens[pos].value !== ')') {
|
|
966
|
+
throw new Error('Expected )');
|
|
967
|
+
}
|
|
968
|
+
pos++;
|
|
969
|
+
return expr;
|
|
970
|
+
}
|
|
971
|
+
throw new Error(`Unexpected token: ${token.value}`);
|
|
972
|
+
}
|
|
973
|
+
function parseBinary(parseLeft, operators) {
|
|
974
|
+
let left = parseLeft();
|
|
975
|
+
while (pos < tokens.length && operators.includes(tokens[pos].value)) {
|
|
976
|
+
const op = tokens[pos].value;
|
|
977
|
+
pos++;
|
|
978
|
+
const right = parseLeft();
|
|
979
|
+
left = { type: 'binary', operator: op, left, right };
|
|
980
|
+
}
|
|
981
|
+
return left;
|
|
982
|
+
}
|
|
983
|
+
function parseExpressionInternal() {
|
|
984
|
+
return parseTernary();
|
|
985
|
+
}
|
|
986
|
+
return parseTernary();
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
function parseValue(value) {
|
|
990
|
+
// Убираем пробелы
|
|
991
|
+
value = value.trim();
|
|
992
|
+
// Булевы
|
|
993
|
+
if (value === 'true')
|
|
994
|
+
return true;
|
|
995
|
+
if (value === 'false')
|
|
996
|
+
return false;
|
|
997
|
+
if (value === 'null')
|
|
998
|
+
return null;
|
|
999
|
+
if (value === 'undefined')
|
|
1000
|
+
return undefined;
|
|
1001
|
+
// Число
|
|
1002
|
+
if (!isNaN(+value) && !value.includes(' ')) {
|
|
1003
|
+
return +value;
|
|
1004
|
+
}
|
|
1005
|
+
// Строка в кавычках
|
|
1006
|
+
if (/^["'](.*)["']$/.test(value)) {
|
|
1007
|
+
return value.slice(1, -1);
|
|
1008
|
+
}
|
|
1009
|
+
// Массив: ['a', 'b'] → только простые
|
|
1010
|
+
if (value.startsWith('[') && value.endsWith(']')) {
|
|
1011
|
+
try {
|
|
1012
|
+
const items = value.slice(1, -1).split(',').map(s => s.trim());
|
|
1013
|
+
return items.map(parseValue);
|
|
1014
|
+
}
|
|
1015
|
+
catch (_a) { }
|
|
1016
|
+
}
|
|
1017
|
+
// Объект: {a:1} → упрощённо
|
|
1018
|
+
if (value.startsWith('{') && value.endsWith('}')) {
|
|
1019
|
+
try {
|
|
1020
|
+
return JSON.parse(value
|
|
1021
|
+
.replace(/(\w+):/g, '"$1":')
|
|
1022
|
+
.replace(/'/g, '"'));
|
|
1023
|
+
}
|
|
1024
|
+
catch (_b) { }
|
|
1025
|
+
}
|
|
1026
|
+
// Переменная? Нет — возвращаем как строку
|
|
1027
|
+
// ❌ Не разрешаем $ здесь — это делает parseExpression
|
|
1028
|
+
return value;
|
|
1029
|
+
}
|
|
1030
|
+
function warnFilter(name, expected, fn) {
|
|
1031
|
+
return (...args) => {
|
|
1032
|
+
const value = args[0];
|
|
1033
|
+
let isValid = false;
|
|
1034
|
+
switch (expected) {
|
|
1035
|
+
case 'array':
|
|
1036
|
+
isValid = Array.isArray(value) || typeof value === 'string';
|
|
1037
|
+
break;
|
|
1038
|
+
case 'string':
|
|
1039
|
+
isValid = typeof value === 'string';
|
|
1040
|
+
break;
|
|
1041
|
+
case 'object':
|
|
1042
|
+
isValid = value && typeof value === 'object' && !Array.isArray(value);
|
|
1043
|
+
break;
|
|
1044
|
+
}
|
|
1045
|
+
if (!isValid) {
|
|
1046
|
+
console.warn(`[Fenom] filter '${name}' should be used with ${expected}, but got ${typeof value}`);
|
|
1047
|
+
}
|
|
1048
|
+
return fn(...args);
|
|
1049
|
+
};
|
|
1050
|
+
}
|
|
1051
|
+
function minifyHTML(html) {
|
|
1052
|
+
return html
|
|
1053
|
+
.replace(/>\s+</g, '><') // > < → ><
|
|
1054
|
+
.replace(/\s{2,}/g, ' ') // множественные пробелы
|
|
1055
|
+
.replace(/(<!--.*?-->)\s+/g, '$1') // пробелы после комментариев
|
|
1056
|
+
.trim();
|
|
1057
|
+
}
|
|
1058
|
+
function getFromContext(path, context) {
|
|
1059
|
+
if (!path || typeof path !== 'string')
|
|
1060
|
+
return undefined;
|
|
1061
|
+
const cleanPath = path.startsWith('$') ? path.slice(1) : path;
|
|
1062
|
+
const tokens = [];
|
|
1063
|
+
let current = '';
|
|
1064
|
+
let inBracket = false;
|
|
1065
|
+
for (const char of cleanPath) {
|
|
1066
|
+
if (char === '.' && !inBracket) {
|
|
1067
|
+
if (current) {
|
|
1068
|
+
tokens.push(current);
|
|
1069
|
+
current = '';
|
|
1070
|
+
}
|
|
1071
|
+
}
|
|
1072
|
+
else if (char === '[') {
|
|
1073
|
+
if (current) {
|
|
1074
|
+
tokens.push(current);
|
|
1075
|
+
current = '';
|
|
1076
|
+
}
|
|
1077
|
+
current = '[';
|
|
1078
|
+
inBracket = true;
|
|
1079
|
+
}
|
|
1080
|
+
else if (char === ']') {
|
|
1081
|
+
current += ']';
|
|
1082
|
+
tokens.push(current);
|
|
1083
|
+
current = '';
|
|
1084
|
+
inBracket = false;
|
|
1085
|
+
}
|
|
1086
|
+
else {
|
|
1087
|
+
current += char;
|
|
1088
|
+
}
|
|
1089
|
+
}
|
|
1090
|
+
if (current) {
|
|
1091
|
+
tokens.push(current);
|
|
1092
|
+
}
|
|
1093
|
+
let value = context;
|
|
1094
|
+
for (const token of tokens) {
|
|
1095
|
+
if (token.startsWith('[')) {
|
|
1096
|
+
const match = token.match(/^\[(?:'(.+)'|"(.+)"|(\d+))\]$/);
|
|
1097
|
+
if (match) {
|
|
1098
|
+
const key = match[1] || match[2] || parseInt(match[3], 10);
|
|
1099
|
+
value = value === null || value === void 0 ? void 0 : value[key];
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
else {
|
|
1103
|
+
value = value === null || value === void 0 ? void 0 : value[token];
|
|
1104
|
+
}
|
|
1105
|
+
}
|
|
1106
|
+
return value;
|
|
1107
|
+
}
|
|
1108
|
+
function applyFilters(value, filterList, context, filters) {
|
|
1109
|
+
let result = value;
|
|
1110
|
+
for (const filter of filterList) {
|
|
1111
|
+
const [name, ...args] = filter.split(':').map(s => s.trim());
|
|
1112
|
+
const filterFn = filters[name];
|
|
1113
|
+
if (typeof filterFn === 'function') {
|
|
1114
|
+
const argValues = args.map(arg => {
|
|
1115
|
+
var _a;
|
|
1116
|
+
if (/^["'].*["']$/.test(arg))
|
|
1117
|
+
return arg.slice(1, -1);
|
|
1118
|
+
if (!isNaN(+arg))
|
|
1119
|
+
return +arg;
|
|
1120
|
+
if (arg.startsWith('$'))
|
|
1121
|
+
return (_a = getFromContext(arg.slice(1), context)) !== null && _a !== void 0 ? _a : '';
|
|
1122
|
+
return arg;
|
|
1123
|
+
});
|
|
1124
|
+
try {
|
|
1125
|
+
result = filterFn(result, ...argValues);
|
|
1126
|
+
}
|
|
1127
|
+
catch (e) {
|
|
1128
|
+
result = '';
|
|
1129
|
+
}
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1132
|
+
return result;
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
function evaluate(node, context, filters) {
|
|
1136
|
+
var _a;
|
|
1137
|
+
switch (node.type) {
|
|
1138
|
+
case 'literal':
|
|
1139
|
+
return node.value;
|
|
1140
|
+
case 'variable':
|
|
1141
|
+
return (_a = getFromContext(node.path, context)) !== null && _a !== void 0 ? _a : '';
|
|
1142
|
+
case 'unary':
|
|
1143
|
+
const arg = evaluate(node.argument, context, filters);
|
|
1144
|
+
switch (node.operator) {
|
|
1145
|
+
case '!': return !arg;
|
|
1146
|
+
case '+': return +arg;
|
|
1147
|
+
case '-': return -arg;
|
|
1148
|
+
}
|
|
1149
|
+
break;
|
|
1150
|
+
case 'binary': {
|
|
1151
|
+
const left = evaluate(node.left, context, filters);
|
|
1152
|
+
const right = evaluate(node.right, context, filters);
|
|
1153
|
+
switch (node.operator) {
|
|
1154
|
+
case '+': return left + right;
|
|
1155
|
+
case '-': return left - right;
|
|
1156
|
+
case '*': return left * right;
|
|
1157
|
+
case '/': return left / right;
|
|
1158
|
+
case '%': return left % right;
|
|
1159
|
+
case '==': return left == right;
|
|
1160
|
+
case '!=': return left != right;
|
|
1161
|
+
case '!==': return left !== right;
|
|
1162
|
+
case '<': return left < right;
|
|
1163
|
+
case '<=': return left <= right;
|
|
1164
|
+
case '>': return left > right;
|
|
1165
|
+
case '>=': return left >= right;
|
|
1166
|
+
case '&&': return left && right;
|
|
1167
|
+
case '||': return left || right;
|
|
1168
|
+
case '~': return String(left) + String(right);
|
|
1169
|
+
}
|
|
1170
|
+
break;
|
|
1171
|
+
}
|
|
1172
|
+
case 'conditional':
|
|
1173
|
+
const test = evaluate(node.test, context, filters);
|
|
1174
|
+
return test
|
|
1175
|
+
? evaluate(node.consequent, context, filters)
|
|
1176
|
+
: evaluate(node.alternate, context, filters);
|
|
1177
|
+
case 'filter': {
|
|
1178
|
+
const input = evaluate(node.expression, context, filters);
|
|
1179
|
+
const args = node.args.map(arg => evaluate(arg, context, filters));
|
|
1180
|
+
const filterFn = filters[node.filter];
|
|
1181
|
+
if (typeof filterFn === 'function') {
|
|
1182
|
+
return filterFn(input, ...args);
|
|
1183
|
+
}
|
|
1184
|
+
return input;
|
|
1185
|
+
}
|
|
1186
|
+
}
|
|
1187
|
+
return '';
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
async function compileAST(ast, loader, context = {}, filters = {}) {
|
|
1191
|
+
var _a, _b, _c, _d, _e, _f, _g;
|
|
1192
|
+
let result = '';
|
|
1193
|
+
for (const node of ast) {
|
|
1194
|
+
switch (node.type) {
|
|
1195
|
+
case 'text':
|
|
1196
|
+
result += node.value;
|
|
1197
|
+
break;
|
|
1198
|
+
case 'ignore_block':
|
|
1199
|
+
result += node.content || '';
|
|
1200
|
+
break;
|
|
1201
|
+
case 'comment':
|
|
1202
|
+
break;
|
|
1203
|
+
case 'output': {
|
|
1204
|
+
try {
|
|
1205
|
+
const exp = node.name.trim();
|
|
1206
|
+
let evaluated;
|
|
1207
|
+
if (/[+\-*/%<>!=&|?:~]/.test(exp)) {
|
|
1208
|
+
const ast = parseExpression(exp);
|
|
1209
|
+
evaluated = evaluate(ast, context, filters);
|
|
1210
|
+
}
|
|
1211
|
+
else {
|
|
1212
|
+
evaluated = (_a = getFromContext(exp, context)) !== null && _a !== void 0 ? _a : '';
|
|
1213
|
+
}
|
|
1214
|
+
const filtered = applyFilters(evaluated, node.filters, context, filters);
|
|
1215
|
+
result += String(filtered);
|
|
1216
|
+
}
|
|
1217
|
+
catch (e) {
|
|
1218
|
+
console.warn(`Eval error: ${node.name}`, e);
|
|
1219
|
+
result += '';
|
|
1220
|
+
}
|
|
1221
|
+
break;
|
|
1222
|
+
}
|
|
1223
|
+
case 'set': {
|
|
1224
|
+
try {
|
|
1225
|
+
const valueStr = node.value.trim();
|
|
1226
|
+
if (/[+\-*/%~]/.test(valueStr)) {
|
|
1227
|
+
const ast = parseExpression(valueStr);
|
|
1228
|
+
context[node.variable] = evaluate(ast, context, filters);
|
|
1229
|
+
}
|
|
1230
|
+
else if (valueStr.startsWith('$')) {
|
|
1231
|
+
context[node.variable] = (_b = getFromContext(valueStr, context)) !== null && _b !== void 0 ? _b : '';
|
|
1232
|
+
}
|
|
1233
|
+
else {
|
|
1234
|
+
context[node.variable] = parseValue(valueStr);
|
|
1235
|
+
}
|
|
1236
|
+
}
|
|
1237
|
+
catch (e) {
|
|
1238
|
+
context[node.variable] = '';
|
|
1239
|
+
}
|
|
1240
|
+
break;
|
|
1241
|
+
}
|
|
1242
|
+
case 'var':
|
|
1243
|
+
if (context[node.variable] === undefined) {
|
|
1244
|
+
context[node.variable] = parseValue(node.value);
|
|
1245
|
+
}
|
|
1246
|
+
break;
|
|
1247
|
+
case 'add':
|
|
1248
|
+
context[node.variable] = (context[node.variable] || 0) + 1;
|
|
1249
|
+
break;
|
|
1250
|
+
case 'if': {
|
|
1251
|
+
let cond = false;
|
|
1252
|
+
try {
|
|
1253
|
+
const ast = parseExpression(node.condition);
|
|
1254
|
+
cond = !!evaluate(ast, context, filters);
|
|
1255
|
+
}
|
|
1256
|
+
catch (e) {
|
|
1257
|
+
console.warn(`Condition error: ${node.condition}`, e);
|
|
1258
|
+
}
|
|
1259
|
+
const body = cond
|
|
1260
|
+
? node.body
|
|
1261
|
+
: (((_d = (_c = node.elseIfs) === null || _c === void 0 ? void 0 : _c.find((elseIf) => {
|
|
1262
|
+
try {
|
|
1263
|
+
const ast = parseExpression(elseIf.condition);
|
|
1264
|
+
return !!evaluate(ast, context, filters);
|
|
1265
|
+
}
|
|
1266
|
+
catch (_a) {
|
|
1267
|
+
return false;
|
|
1268
|
+
}
|
|
1269
|
+
})) === null || _d === void 0 ? void 0 : _d.body) || node.elseBody || []);
|
|
1270
|
+
result += await compileAST(body, loader, context, filters);
|
|
1271
|
+
break;
|
|
1272
|
+
}
|
|
1273
|
+
case 'for_range': {
|
|
1274
|
+
const { start, end, item, reverse, body, elseBody } = node;
|
|
1275
|
+
const range = [];
|
|
1276
|
+
for (let i = start; i <= end; i++) {
|
|
1277
|
+
range.push(i);
|
|
1278
|
+
}
|
|
1279
|
+
if (reverse)
|
|
1280
|
+
range.reverse();
|
|
1281
|
+
let innerResult = '';
|
|
1282
|
+
if (range.length === 0 && elseBody) {
|
|
1283
|
+
innerResult += await compileAST(elseBody, loader, context, filters);
|
|
1284
|
+
}
|
|
1285
|
+
else {
|
|
1286
|
+
for (const value of range) {
|
|
1287
|
+
const newContext = { ...context };
|
|
1288
|
+
newContext[item] = value;
|
|
1289
|
+
newContext.loop = {
|
|
1290
|
+
index: value - start + 1,
|
|
1291
|
+
first: value === (reverse ? end : start),
|
|
1292
|
+
last: value === (reverse ? start : end),
|
|
1293
|
+
key: value - start
|
|
1294
|
+
};
|
|
1295
|
+
innerResult += await compileAST(body, loader, newContext, filters);
|
|
1296
|
+
}
|
|
1297
|
+
}
|
|
1298
|
+
result += innerResult;
|
|
1299
|
+
break;
|
|
1300
|
+
}
|
|
1301
|
+
case 'for': {
|
|
1302
|
+
const collectionPath = node.collection.startsWith('$') ? node.collection.slice(1) : node.collection;
|
|
1303
|
+
const collection = getFromContext(collectionPath, context);
|
|
1304
|
+
let items = [];
|
|
1305
|
+
if (Array.isArray(collection)) {
|
|
1306
|
+
items = Array.from(collection.entries());
|
|
1307
|
+
}
|
|
1308
|
+
else if (collection && typeof collection === 'object') {
|
|
1309
|
+
items = Object.entries(collection);
|
|
1310
|
+
}
|
|
1311
|
+
if (node.reverse)
|
|
1312
|
+
items = items.reverse();
|
|
1313
|
+
if (items.length === 0 && node.elseBody) {
|
|
1314
|
+
result += await compileAST(node.elseBody, loader, context, filters);
|
|
1315
|
+
}
|
|
1316
|
+
else {
|
|
1317
|
+
for (const [index, itemValue] of items) {
|
|
1318
|
+
const newContext = { ...context };
|
|
1319
|
+
newContext[node.item] = itemValue;
|
|
1320
|
+
if (node.key)
|
|
1321
|
+
newContext[node.key] = index;
|
|
1322
|
+
newContext.loop = {
|
|
1323
|
+
index: index + 1,
|
|
1324
|
+
first: index === 0,
|
|
1325
|
+
last: index === items.length - 1,
|
|
1326
|
+
key: index,
|
|
1327
|
+
};
|
|
1328
|
+
result += await compileAST(node.body, loader, newContext, filters);
|
|
1329
|
+
}
|
|
1330
|
+
}
|
|
1331
|
+
break;
|
|
1332
|
+
}
|
|
1333
|
+
case 'switch': {
|
|
1334
|
+
const rawValue = node.value.startsWith('$') ? node.value.slice(1) : node.value;
|
|
1335
|
+
const value = (_e = getFromContext(rawValue, context)) !== null && _e !== void 0 ? _e : rawValue;
|
|
1336
|
+
let matched = false;
|
|
1337
|
+
for (const c of node.cases) {
|
|
1338
|
+
// Case values can be strings like "a" or variables
|
|
1339
|
+
const caseValue = c.value.startsWith('$')
|
|
1340
|
+
? getFromContext(c.value.slice(1), context)
|
|
1341
|
+
: c.value.replace(/^["']|["']$/g, '');
|
|
1342
|
+
if (caseValue === value) {
|
|
1343
|
+
result += await compileAST(c.body, loader, context, filters);
|
|
1344
|
+
matched = true;
|
|
1345
|
+
break;
|
|
1346
|
+
}
|
|
1347
|
+
}
|
|
1348
|
+
if (!matched && node.defaultBody) {
|
|
1349
|
+
result += await compileAST(node.defaultBody, loader, context, filters);
|
|
1350
|
+
}
|
|
1351
|
+
break;
|
|
1352
|
+
}
|
|
1353
|
+
case 'operator': {
|
|
1354
|
+
const { variable, operator, value } = node;
|
|
1355
|
+
const varName = variable.startsWith('$') ? variable.slice(1) : variable;
|
|
1356
|
+
let numValue = isNaN(+value) ? (_f = getFromContext(value, context)) !== null && _f !== void 0 ? _f : 0 : +value;
|
|
1357
|
+
const currentValue = (_g = getFromContext(variable, context)) !== null && _g !== void 0 ? _g : 0;
|
|
1358
|
+
switch (operator) {
|
|
1359
|
+
case '++':
|
|
1360
|
+
context[varName] = currentValue + 1;
|
|
1361
|
+
break;
|
|
1362
|
+
case '--':
|
|
1363
|
+
context[varName] = currentValue - 1;
|
|
1364
|
+
break;
|
|
1365
|
+
case '+=':
|
|
1366
|
+
context[varName] = currentValue + numValue;
|
|
1367
|
+
break;
|
|
1368
|
+
case '-=':
|
|
1369
|
+
context[varName] = currentValue - numValue;
|
|
1370
|
+
break;
|
|
1371
|
+
case '*=':
|
|
1372
|
+
context[varName] = currentValue * numValue;
|
|
1373
|
+
break;
|
|
1374
|
+
case '/=':
|
|
1375
|
+
context[varName] = currentValue / numValue;
|
|
1376
|
+
break;
|
|
1377
|
+
case '%=':
|
|
1378
|
+
context[varName] = currentValue % numValue;
|
|
1379
|
+
break;
|
|
1380
|
+
}
|
|
1381
|
+
break;
|
|
1382
|
+
}
|
|
1383
|
+
case 'block':
|
|
1384
|
+
if (typeof context.block === 'function') {
|
|
1385
|
+
result += await context.block(node.name);
|
|
1386
|
+
}
|
|
1387
|
+
break;
|
|
1388
|
+
case 'include': {
|
|
1389
|
+
if (!loader) {
|
|
1390
|
+
console.warn(`{include '${node.file}'} ignored: no loader`);
|
|
1391
|
+
break;
|
|
1392
|
+
}
|
|
1393
|
+
try {
|
|
1394
|
+
const includedTemplate = await loader(node.file);
|
|
1395
|
+
const tokens = tokenize(includedTemplate);
|
|
1396
|
+
const includedAST = parse(tokens);
|
|
1397
|
+
const html = await compileAST(includedAST, loader, { ...context, ...node.params }, filters);
|
|
1398
|
+
result += html;
|
|
1399
|
+
}
|
|
1400
|
+
catch (err) {
|
|
1401
|
+
result += `<span style="color:red">[Include error: ${err.message}]</span>`;
|
|
1402
|
+
}
|
|
1403
|
+
break;
|
|
1404
|
+
}
|
|
1405
|
+
case 'extends':
|
|
1406
|
+
break;
|
|
1407
|
+
default:
|
|
1408
|
+
console.warn(`Unknown node type: ${node.type}`);
|
|
1409
|
+
}
|
|
1410
|
+
}
|
|
1411
|
+
return result;
|
|
1412
|
+
}
|
|
1413
|
+
|
|
1414
|
+
function compile(ast, loader) {
|
|
1415
|
+
const blocks = {};
|
|
1416
|
+
let parentFile = null;
|
|
1417
|
+
for (const node of ast) {
|
|
1418
|
+
if (node.type === 'extends') {
|
|
1419
|
+
parentFile = node.file;
|
|
1420
|
+
}
|
|
1421
|
+
else if (node.type === 'block') {
|
|
1422
|
+
blocks[node.name] = node.body;
|
|
1423
|
+
}
|
|
1424
|
+
}
|
|
1425
|
+
if (parentFile) {
|
|
1426
|
+
return async function (context, filters) {
|
|
1427
|
+
if (!loader) {
|
|
1428
|
+
throw new Error(`Template uses {extends '${parentFile}'}, but no loader provided`);
|
|
1429
|
+
}
|
|
1430
|
+
try {
|
|
1431
|
+
const template = await loader(parentFile);
|
|
1432
|
+
const tokens = tokenize(template);
|
|
1433
|
+
const parentAst = parse(tokens);
|
|
1434
|
+
const parentBlocks = {};
|
|
1435
|
+
for (const node of parentAst) {
|
|
1436
|
+
if (node.type === 'block') {
|
|
1437
|
+
parentBlocks[node.name] = node.body;
|
|
1438
|
+
}
|
|
1439
|
+
}
|
|
1440
|
+
const finalBlocks = { ...parentBlocks, ...blocks };
|
|
1441
|
+
const blockContent = {};
|
|
1442
|
+
context.block = async (name) => {
|
|
1443
|
+
if (blockContent[name] !== undefined)
|
|
1444
|
+
return blockContent[name];
|
|
1445
|
+
const blockAst = finalBlocks[name];
|
|
1446
|
+
if (!blockAst) {
|
|
1447
|
+
blockContent[name] = '';
|
|
1448
|
+
return '';
|
|
1449
|
+
}
|
|
1450
|
+
blockContent[name] = await compileAST(blockAst, loader, context, filters);
|
|
1451
|
+
return blockContent[name];
|
|
1452
|
+
};
|
|
1453
|
+
return await compileAST(parentAst, loader, context, filters);
|
|
1454
|
+
}
|
|
1455
|
+
catch (err) {
|
|
1456
|
+
return `[Render error: ${err.message}]`;
|
|
1457
|
+
}
|
|
1458
|
+
};
|
|
1459
|
+
}
|
|
1460
|
+
else {
|
|
1461
|
+
return async function (context, filters) {
|
|
1462
|
+
return await compileAST(ast, loader, context, filters);
|
|
1463
|
+
};
|
|
1464
|
+
}
|
|
1465
|
+
}
|
|
1466
|
+
|
|
1467
|
+
const filters = {
|
|
1468
|
+
// ——— Строковые фильтры ————————————————————————
|
|
1469
|
+
/**
|
|
1470
|
+
* Преобразует строку в верхний регистр
|
|
1471
|
+
*/
|
|
1472
|
+
upper: (s) => String(s).toUpperCase(),
|
|
1473
|
+
/**
|
|
1474
|
+
* Преобразует строку в нижний регистр
|
|
1475
|
+
*/
|
|
1476
|
+
lower: (s) => String(s).toLowerCase(),
|
|
1477
|
+
/**
|
|
1478
|
+
* Делает первую букву строки заглавной, остальные — строчными
|
|
1479
|
+
* 'аННА' → 'Анна'
|
|
1480
|
+
*/
|
|
1481
|
+
capitalize: (s) => {
|
|
1482
|
+
const str = String(s).trim();
|
|
1483
|
+
if (str.length === 0)
|
|
1484
|
+
return '';
|
|
1485
|
+
return str.charAt(0).toUpperCase() + str.slice(1).toLowerCase();
|
|
1486
|
+
},
|
|
1487
|
+
/**
|
|
1488
|
+
* Аналог capitalize (для совместимости)
|
|
1489
|
+
*/
|
|
1490
|
+
ucfirst: (s) => filters.capitalize(s),
|
|
1491
|
+
/**
|
|
1492
|
+
* Делает первую букву каждого слова заглавной
|
|
1493
|
+
* 'hello world' → 'Hello World'
|
|
1494
|
+
*/
|
|
1495
|
+
ucwords: (s) => {
|
|
1496
|
+
const str = String(s).trim();
|
|
1497
|
+
return str.replace(/\b\w/g, (match) => match.toUpperCase());
|
|
1498
|
+
},
|
|
1499
|
+
/**
|
|
1500
|
+
* Делает первую букву строки строчной
|
|
1501
|
+
* 'Hello' → 'hello'
|
|
1502
|
+
*/
|
|
1503
|
+
lcfirst: (s) => {
|
|
1504
|
+
const str = String(s).trim();
|
|
1505
|
+
if (str.length === 0)
|
|
1506
|
+
return '';
|
|
1507
|
+
return str.charAt(0).toLowerCase() + str.slice(1);
|
|
1508
|
+
},
|
|
1509
|
+
/**
|
|
1510
|
+
* Удаляет пробелы с краёв строки
|
|
1511
|
+
*/
|
|
1512
|
+
trim: (s) => String(s).trim(),
|
|
1513
|
+
/**
|
|
1514
|
+
* Удаляет пробелы слева
|
|
1515
|
+
*/
|
|
1516
|
+
ltrim: (s) => String(s).replace(/^\s+/, ''),
|
|
1517
|
+
/**
|
|
1518
|
+
* Удаляет пробелы справа
|
|
1519
|
+
*/
|
|
1520
|
+
rtrim: (s) => String(s).replace(/\s+$/, ''),
|
|
1521
|
+
/**
|
|
1522
|
+
* Преобразует \n → <br>
|
|
1523
|
+
*/
|
|
1524
|
+
nl2br: (s) => String(s).replace(/\n/g, '<br>'),
|
|
1525
|
+
/**
|
|
1526
|
+
* Заменяет подстроку
|
|
1527
|
+
* {$str|replace:'old':'new'}
|
|
1528
|
+
*/
|
|
1529
|
+
replace: (s, search, replace) => {
|
|
1530
|
+
const str = String(s);
|
|
1531
|
+
return str.split(String(search)).join(String(replace));
|
|
1532
|
+
},
|
|
1533
|
+
/**
|
|
1534
|
+
* Обрезает строку
|
|
1535
|
+
* {$str|substr:0:5}
|
|
1536
|
+
*/
|
|
1537
|
+
substr: warnFilter('substr', 'string', (s, start, length) => {
|
|
1538
|
+
const str = String(s);
|
|
1539
|
+
return length === undefined ? str.slice(start) : str.slice(start, start + length);
|
|
1540
|
+
}),
|
|
1541
|
+
/**
|
|
1542
|
+
* Кодирует строку в URL
|
|
1543
|
+
*/
|
|
1544
|
+
urlencode: (s) => encodeURIComponent(String(s)),
|
|
1545
|
+
/**
|
|
1546
|
+
* Декодирует URL
|
|
1547
|
+
*/
|
|
1548
|
+
urldecode: (s) => decodeURIComponent(String(s)),
|
|
1549
|
+
/**
|
|
1550
|
+
* Экранирует HTML-символы
|
|
1551
|
+
*/
|
|
1552
|
+
escape: warnFilter('escape', 'string', (s) => {
|
|
1553
|
+
const str = String(s);
|
|
1554
|
+
return str
|
|
1555
|
+
.replace(/&/g, '&')
|
|
1556
|
+
.replace(/</g, '<')
|
|
1557
|
+
.replace(/>/g, '>')
|
|
1558
|
+
.replace(/"/g, '"')
|
|
1559
|
+
.replace(/'/g, ''');
|
|
1560
|
+
}),
|
|
1561
|
+
/**
|
|
1562
|
+
* Синоним escape
|
|
1563
|
+
*/
|
|
1564
|
+
e: (s) => filters.escape(s),
|
|
1565
|
+
// ——— Работа с массивами ——————————————————————
|
|
1566
|
+
/**
|
|
1567
|
+
* Первый элемент массива/объекта
|
|
1568
|
+
*/
|
|
1569
|
+
first: warnFilter('first', 'array', (arr) => {
|
|
1570
|
+
if (Array.isArray(arr))
|
|
1571
|
+
return arr[0];
|
|
1572
|
+
if (arr && typeof arr === 'object')
|
|
1573
|
+
return Object.values(arr)[0];
|
|
1574
|
+
return '';
|
|
1575
|
+
}),
|
|
1576
|
+
/**
|
|
1577
|
+
* Последний элемент массива/объекта
|
|
1578
|
+
*/
|
|
1579
|
+
last: warnFilter('last', 'array', (arr) => {
|
|
1580
|
+
if (Array.isArray(arr))
|
|
1581
|
+
return arr[arr.length - 1];
|
|
1582
|
+
if (arr && typeof arr === 'object') {
|
|
1583
|
+
const values = Object.values(arr);
|
|
1584
|
+
return values[values.length - 1];
|
|
1585
|
+
}
|
|
1586
|
+
return '';
|
|
1587
|
+
}),
|
|
1588
|
+
/**
|
|
1589
|
+
* Объединяет массив в строку
|
|
1590
|
+
* {$arr|join:', '}
|
|
1591
|
+
*/
|
|
1592
|
+
join: warnFilter('join', 'array', (arr, separator = ',') => {
|
|
1593
|
+
if (Array.isArray(arr))
|
|
1594
|
+
return arr.join(separator);
|
|
1595
|
+
if (arr && typeof arr === 'object')
|
|
1596
|
+
return Object.values(arr).join(separator);
|
|
1597
|
+
return String(arr);
|
|
1598
|
+
}),
|
|
1599
|
+
/**
|
|
1600
|
+
* Переворачивает массив или строку
|
|
1601
|
+
*/
|
|
1602
|
+
reverse: warnFilter('reverse', 'array', (arr) => {
|
|
1603
|
+
return Array.isArray(arr) ? [...arr].reverse() : arr;
|
|
1604
|
+
}),
|
|
1605
|
+
/**
|
|
1606
|
+
* Сортирует массив по значениям
|
|
1607
|
+
*/
|
|
1608
|
+
sort: warnFilter('sort', 'array', (arr) => {
|
|
1609
|
+
if (Array.isArray(arr))
|
|
1610
|
+
return [...arr].sort();
|
|
1611
|
+
return arr;
|
|
1612
|
+
}),
|
|
1613
|
+
/**
|
|
1614
|
+
* Сортирует массив по ключам
|
|
1615
|
+
*/
|
|
1616
|
+
ksort: warnFilter('ksort', 'object', (obj) => {
|
|
1617
|
+
if (obj && typeof obj === 'object') {
|
|
1618
|
+
const sorted = {};
|
|
1619
|
+
Object.keys(obj).sort().forEach(key => {
|
|
1620
|
+
sorted[key] = obj[key];
|
|
1621
|
+
});
|
|
1622
|
+
return sorted;
|
|
1623
|
+
}
|
|
1624
|
+
return obj;
|
|
1625
|
+
}),
|
|
1626
|
+
/**
|
|
1627
|
+
* Возвращает только уникальные значения
|
|
1628
|
+
*/
|
|
1629
|
+
unique: warnFilter('unique', 'array', (arr) => {
|
|
1630
|
+
if (Array.isArray(arr))
|
|
1631
|
+
return [...new Set(arr)];
|
|
1632
|
+
return arr;
|
|
1633
|
+
}),
|
|
1634
|
+
/**
|
|
1635
|
+
* Перемешивает массив
|
|
1636
|
+
*/
|
|
1637
|
+
shuffle: warnFilter('shuffle', 'array', (arr) => {
|
|
1638
|
+
if (!Array.isArray(arr))
|
|
1639
|
+
return arr;
|
|
1640
|
+
const newArr = [...arr];
|
|
1641
|
+
for (let i = newArr.length - 1; i > 0; i--) {
|
|
1642
|
+
const j = Math.floor(Math.random() * (i + 1));
|
|
1643
|
+
[newArr[i], newArr[j]] = [newArr[j], newArr[i]];
|
|
1644
|
+
}
|
|
1645
|
+
return newArr;
|
|
1646
|
+
}),
|
|
1647
|
+
/**
|
|
1648
|
+
* Возвращает срез массива/строки
|
|
1649
|
+
* {$arr|slice:0:2}
|
|
1650
|
+
*/
|
|
1651
|
+
slice: (arr, start, length) => {
|
|
1652
|
+
if (Array.isArray(arr) || typeof arr === 'string') {
|
|
1653
|
+
return length === undefined ? arr.slice(start) : arr.slice(start, start + length);
|
|
1654
|
+
}
|
|
1655
|
+
return arr;
|
|
1656
|
+
},
|
|
1657
|
+
/**
|
|
1658
|
+
* Объединяет два массива
|
|
1659
|
+
* {$arr1|merge:$arr2}
|
|
1660
|
+
*/
|
|
1661
|
+
merge: (arr1, arr2) => {
|
|
1662
|
+
if (Array.isArray(arr1) && Array.isArray(arr2)) {
|
|
1663
|
+
return [...arr1, ...arr2];
|
|
1664
|
+
}
|
|
1665
|
+
return arr1;
|
|
1666
|
+
},
|
|
1667
|
+
/**
|
|
1668
|
+
* Разбивает массив на группы
|
|
1669
|
+
* {$items|batch:3}
|
|
1670
|
+
*/
|
|
1671
|
+
batch: (arr, size) => {
|
|
1672
|
+
if (!Array.isArray(arr))
|
|
1673
|
+
return arr;
|
|
1674
|
+
const result = [];
|
|
1675
|
+
for (let i = 0; i < arr.length; i += size) {
|
|
1676
|
+
result.push(arr.slice(i, i + size));
|
|
1677
|
+
}
|
|
1678
|
+
return result;
|
|
1679
|
+
},
|
|
1680
|
+
/**
|
|
1681
|
+
* Возвращает ключи массива/объекта
|
|
1682
|
+
*/
|
|
1683
|
+
keys: (obj) => {
|
|
1684
|
+
return obj && typeof obj === 'object' ? Object.keys(obj) : [];
|
|
1685
|
+
},
|
|
1686
|
+
/**
|
|
1687
|
+
* Возвращает значения массива/объекта
|
|
1688
|
+
*/
|
|
1689
|
+
values: (obj) => (obj && typeof obj === 'object' ? Object.values(obj) : []),
|
|
1690
|
+
/**
|
|
1691
|
+
* Длина строки или количество элементов
|
|
1692
|
+
*/
|
|
1693
|
+
length: (arr) => {
|
|
1694
|
+
if (Array.isArray(arr))
|
|
1695
|
+
return arr.length;
|
|
1696
|
+
if (arr && typeof arr === 'object')
|
|
1697
|
+
return Object.keys(arr).length;
|
|
1698
|
+
return String(arr).length;
|
|
1699
|
+
},
|
|
1700
|
+
// ——— Форматирование и числа ——————————————————
|
|
1701
|
+
/**
|
|
1702
|
+
* Форматирует число
|
|
1703
|
+
* {$price|number_format:2:'.':','}
|
|
1704
|
+
*/
|
|
1705
|
+
number_format: (num, decimals = 0, decPoint = '.', thousandsSep = ',') => {
|
|
1706
|
+
const n = Number(num);
|
|
1707
|
+
if (isNaN(n))
|
|
1708
|
+
return '';
|
|
1709
|
+
return n.toLocaleString('en-US', {
|
|
1710
|
+
minimumFractionDigits: decimals,
|
|
1711
|
+
maximumFractionDigits: decimals,
|
|
1712
|
+
useGrouping: true
|
|
1713
|
+
}).replace(/,/g, thousandsSep).replace(/\./g, decPoint);
|
|
1714
|
+
},
|
|
1715
|
+
/**
|
|
1716
|
+
* Абсолютное значение
|
|
1717
|
+
*/
|
|
1718
|
+
abs: (n) => Math.abs(Number(n) || 0),
|
|
1719
|
+
/**
|
|
1720
|
+
* Округляет число
|
|
1721
|
+
*/
|
|
1722
|
+
round: (n, precision = 0) => {
|
|
1723
|
+
const factor = 10 ** precision;
|
|
1724
|
+
return Math.round((Number(n) || 0) * factor) / factor;
|
|
1725
|
+
},
|
|
1726
|
+
// ——— JSON ————————————————————————————————
|
|
1727
|
+
/**
|
|
1728
|
+
* Кодирует в JSON
|
|
1729
|
+
*/
|
|
1730
|
+
json_encode: (data) => JSON.stringify(data),
|
|
1731
|
+
/**
|
|
1732
|
+
* Декодирует из JSON
|
|
1733
|
+
*/
|
|
1734
|
+
json_decode: (str) => {
|
|
1735
|
+
try {
|
|
1736
|
+
return JSON.parse(String(str));
|
|
1737
|
+
}
|
|
1738
|
+
catch (_a) {
|
|
1739
|
+
return null;
|
|
1740
|
+
}
|
|
1741
|
+
},
|
|
1742
|
+
// ——— Дата ————————————————————————————————
|
|
1743
|
+
/**
|
|
1744
|
+
* Форматирует timestamp
|
|
1745
|
+
* Формат: d.m.Y H:i:s
|
|
1746
|
+
*/
|
|
1747
|
+
date: (timestamp, format = 'd.m.Y') => {
|
|
1748
|
+
const n = Number(timestamp);
|
|
1749
|
+
const d = new Date(isNaN(n) ? timestamp : n * 1000);
|
|
1750
|
+
if (isNaN(d.getTime())) {
|
|
1751
|
+
console.warn(`[Fenom] filter 'date' received invalid timestamp: ${timestamp}`);
|
|
1752
|
+
return '';
|
|
1753
|
+
}
|
|
1754
|
+
const pad = (n) => n.toString().padStart(2, '0');
|
|
1755
|
+
return format
|
|
1756
|
+
.replace(/d/g, pad(d.getDate()))
|
|
1757
|
+
.replace(/m/g, pad(d.getMonth() + 1))
|
|
1758
|
+
.replace(/Y/g, d.getFullYear().toString())
|
|
1759
|
+
.replace(/H/g, pad(d.getHours()))
|
|
1760
|
+
.replace(/i/g, pad(d.getMinutes()))
|
|
1761
|
+
.replace(/s/g, pad(d.getSeconds()));
|
|
1762
|
+
},
|
|
1763
|
+
// ——— Прочее ——————————————————————————————
|
|
1764
|
+
/**
|
|
1765
|
+
* Возвращает значение по умолчанию, если пусто
|
|
1766
|
+
*/
|
|
1767
|
+
default: (s, def) => {
|
|
1768
|
+
return (s == null || s === '' || (typeof s === 'object' && Object.keys(s).length === 0))
|
|
1769
|
+
? def
|
|
1770
|
+
: s;
|
|
1771
|
+
},
|
|
1772
|
+
/**
|
|
1773
|
+
* Вывод без изменений
|
|
1774
|
+
*/
|
|
1775
|
+
raw: (s) => s,
|
|
1776
|
+
/**
|
|
1777
|
+
* Отладка: вывод структуры
|
|
1778
|
+
*/
|
|
1779
|
+
var_dump: (data) => {
|
|
1780
|
+
return `<pre>${JSON.stringify(data, null, 2)}</pre>`;
|
|
1781
|
+
},
|
|
1782
|
+
/**
|
|
1783
|
+
* Отладка: красивый вывод
|
|
1784
|
+
*/
|
|
1785
|
+
print_r: (data) => {
|
|
1786
|
+
return `<pre>${data instanceof Object ? JSON.stringify(data, null, 2) : String(data)}</pre>`;
|
|
1787
|
+
}
|
|
1788
|
+
};
|
|
1789
|
+
|
|
1790
|
+
async function FenomJs(template, options) {
|
|
1791
|
+
const { context = {}, loader, minify = false, debug = false } = options || {};
|
|
1792
|
+
try {
|
|
1793
|
+
const tokens = tokenize(template);
|
|
1794
|
+
if (debug)
|
|
1795
|
+
console.log('tokens', tokens);
|
|
1796
|
+
const ast = parse(tokens);
|
|
1797
|
+
if (debug)
|
|
1798
|
+
console.log('ast', ast);
|
|
1799
|
+
const compiled = compile(ast, loader);
|
|
1800
|
+
const html = await compiled(context, filters);
|
|
1801
|
+
return minify ? minifyHTML(html) : html;
|
|
1802
|
+
}
|
|
1803
|
+
catch (err) {
|
|
1804
|
+
console.error('Template error:', err);
|
|
1805
|
+
return `<span style="color:red">[Ошибка шаблона: ${err.message}]</span>`;
|
|
1806
|
+
}
|
|
1807
|
+
}
|
|
1808
|
+
|
|
1809
|
+
export { FenomJs, compile, parse, tokenize };
|
|
1810
|
+
//# sourceMappingURL=fenom-js.mjs.map
|