tina4-nodejs 3.0.0-rc.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (119) hide show
  1. package/BENCHMARK_REPORT.md +96 -0
  2. package/CARBONAH.md +140 -0
  3. package/CLAUDE.md +599 -0
  4. package/COMPARISON.md +194 -0
  5. package/README.md +595 -0
  6. package/package.json +59 -0
  7. package/packages/cli/src/bin.ts +110 -0
  8. package/packages/cli/src/commands/init.ts +194 -0
  9. package/packages/cli/src/commands/migrate.ts +96 -0
  10. package/packages/cli/src/commands/migrateCreate.ts +59 -0
  11. package/packages/cli/src/commands/routes.ts +61 -0
  12. package/packages/cli/src/commands/serve.ts +58 -0
  13. package/packages/cli/src/commands/test.ts +83 -0
  14. package/packages/core/gallery/auth/meta.json +1 -0
  15. package/packages/core/gallery/auth/src/routes/api/gallery/auth/login/post.ts +22 -0
  16. package/packages/core/gallery/auth/src/routes/api/gallery/auth/verify/get.ts +16 -0
  17. package/packages/core/gallery/auth/src/routes/gallery/auth/get.ts +97 -0
  18. package/packages/core/gallery/database/meta.json +1 -0
  19. package/packages/core/gallery/database/src/routes/api/gallery/db/notes/get.ts +13 -0
  20. package/packages/core/gallery/database/src/routes/api/gallery/db/notes/post.ts +17 -0
  21. package/packages/core/gallery/database/src/routes/api/gallery/db/tables/get.ts +23 -0
  22. package/packages/core/gallery/error-overlay/meta.json +1 -0
  23. package/packages/core/gallery/error-overlay/src/routes/api/gallery/crash/get.ts +17 -0
  24. package/packages/core/gallery/orm/meta.json +1 -0
  25. package/packages/core/gallery/orm/src/routes/api/gallery/products/get.ts +12 -0
  26. package/packages/core/gallery/orm/src/routes/api/gallery/products/post.ts +7 -0
  27. package/packages/core/gallery/queue/meta.json +1 -0
  28. package/packages/core/gallery/queue/src/routes/api/gallery/queue/produce/post.ts +16 -0
  29. package/packages/core/gallery/queue/src/routes/api/gallery/queue/status/get.ts +10 -0
  30. package/packages/core/gallery/rest-api/meta.json +1 -0
  31. package/packages/core/gallery/rest-api/src/routes/api/gallery/hello/get.ts +6 -0
  32. package/packages/core/gallery/rest-api/src/routes/api/gallery/hello/post.ts +7 -0
  33. package/packages/core/gallery/templates/meta.json +1 -0
  34. package/packages/core/gallery/templates/src/routes/gallery/page/get.ts +15 -0
  35. package/packages/core/gallery/templates/src/templates/gallery_page.twig +257 -0
  36. package/packages/core/public/css/tina4.css +2463 -0
  37. package/packages/core/public/css/tina4.min.css +1 -0
  38. package/packages/core/public/favicon.ico +0 -0
  39. package/packages/core/public/images/logo.svg +5 -0
  40. package/packages/core/public/images/tina4-logo-icon.webp +0 -0
  41. package/packages/core/public/js/frond.min.js +420 -0
  42. package/packages/core/public/js/tina4-dev-admin.min.js +327 -0
  43. package/packages/core/public/js/tina4.min.js +93 -0
  44. package/packages/core/public/swagger/index.html +90 -0
  45. package/packages/core/public/swagger/oauth2-redirect.html +63 -0
  46. package/packages/core/src/ai.ts +359 -0
  47. package/packages/core/src/api.ts +248 -0
  48. package/packages/core/src/auth.ts +287 -0
  49. package/packages/core/src/cache.ts +121 -0
  50. package/packages/core/src/constants.ts +48 -0
  51. package/packages/core/src/container.ts +90 -0
  52. package/packages/core/src/devAdmin.ts +2024 -0
  53. package/packages/core/src/devMailbox.ts +316 -0
  54. package/packages/core/src/dotenv.ts +172 -0
  55. package/packages/core/src/errorOverlay.test.ts +122 -0
  56. package/packages/core/src/errorOverlay.ts +278 -0
  57. package/packages/core/src/events.ts +112 -0
  58. package/packages/core/src/fakeData.ts +309 -0
  59. package/packages/core/src/graphql.ts +812 -0
  60. package/packages/core/src/health.ts +31 -0
  61. package/packages/core/src/htmlElement.ts +172 -0
  62. package/packages/core/src/i18n.ts +136 -0
  63. package/packages/core/src/index.ts +88 -0
  64. package/packages/core/src/logger.ts +226 -0
  65. package/packages/core/src/messenger.ts +822 -0
  66. package/packages/core/src/middleware.ts +138 -0
  67. package/packages/core/src/queue.ts +481 -0
  68. package/packages/core/src/queueBackends/kafkaBackend.ts +348 -0
  69. package/packages/core/src/queueBackends/rabbitmqBackend.ts +479 -0
  70. package/packages/core/src/rateLimiter.ts +107 -0
  71. package/packages/core/src/request.ts +189 -0
  72. package/packages/core/src/response.ts +146 -0
  73. package/packages/core/src/routeDiscovery.ts +87 -0
  74. package/packages/core/src/router.ts +398 -0
  75. package/packages/core/src/scss.ts +366 -0
  76. package/packages/core/src/server.ts +610 -0
  77. package/packages/core/src/service.ts +380 -0
  78. package/packages/core/src/session.ts +480 -0
  79. package/packages/core/src/sessionHandlers/mongoHandler.ts +286 -0
  80. package/packages/core/src/sessionHandlers/valkeyHandler.ts +184 -0
  81. package/packages/core/src/static.ts +58 -0
  82. package/packages/core/src/testing.ts +233 -0
  83. package/packages/core/src/types.ts +98 -0
  84. package/packages/core/src/watcher.ts +37 -0
  85. package/packages/core/src/websocket.ts +408 -0
  86. package/packages/core/src/wsdl.ts +546 -0
  87. package/packages/core/templates/errors/302.twig +14 -0
  88. package/packages/core/templates/errors/401.twig +9 -0
  89. package/packages/core/templates/errors/403.twig +29 -0
  90. package/packages/core/templates/errors/404.twig +29 -0
  91. package/packages/core/templates/errors/500.twig +38 -0
  92. package/packages/core/templates/errors/502.twig +9 -0
  93. package/packages/core/templates/errors/503.twig +12 -0
  94. package/packages/core/templates/errors/base.twig +37 -0
  95. package/packages/frond/src/engine.ts +1475 -0
  96. package/packages/frond/src/index.ts +2 -0
  97. package/packages/orm/src/adapters/firebird.ts +455 -0
  98. package/packages/orm/src/adapters/mssql.ts +440 -0
  99. package/packages/orm/src/adapters/mysql.ts +355 -0
  100. package/packages/orm/src/adapters/postgres.ts +362 -0
  101. package/packages/orm/src/adapters/sqlite.ts +270 -0
  102. package/packages/orm/src/autoCrud.ts +231 -0
  103. package/packages/orm/src/baseModel.ts +536 -0
  104. package/packages/orm/src/database.ts +321 -0
  105. package/packages/orm/src/fakeData.ts +118 -0
  106. package/packages/orm/src/index.ts +49 -0
  107. package/packages/orm/src/migration.ts +392 -0
  108. package/packages/orm/src/model.ts +56 -0
  109. package/packages/orm/src/query.ts +113 -0
  110. package/packages/orm/src/seeder.ts +120 -0
  111. package/packages/orm/src/sqlTranslation.ts +272 -0
  112. package/packages/orm/src/types.ts +110 -0
  113. package/packages/orm/src/validation.ts +93 -0
  114. package/packages/swagger/src/generator.ts +189 -0
  115. package/packages/swagger/src/index.ts +2 -0
  116. package/packages/swagger/src/ui.ts +48 -0
  117. package/skills/tina4-developer.skill +0 -0
  118. package/skills/tina4-js.skill +0 -0
  119. package/skills/tina4-maintainer.skill +0 -0
@@ -0,0 +1,1475 @@
1
+ /**
2
+ * Tina4 Frond Engine — Lexer, parser, and runtime.
3
+ * Zero-dependency Twig-like template engine.
4
+ * Supports: variables, filters, if/elseif/else/endif, for/else/endfor,
5
+ * extends/block, include, macro, set, comments, whitespace control, tests.
6
+ */
7
+ import { createHash, createHmac } from "node:crypto";
8
+ import { readFileSync, existsSync } from "node:fs";
9
+ import { join, resolve } from "node:path";
10
+
11
+ // ── Types ──────────────────────────────────────────────────────
12
+
13
+ export type FilterFn = (value: unknown, ...args: unknown[]) => unknown;
14
+ export type TestFn = (value: unknown) => boolean;
15
+
16
+ /** Marker class for strings that should not be auto-escaped. */
17
+ class SafeString {
18
+ constructor(public value: string) {}
19
+ toString() { return this.value; }
20
+ }
21
+
22
+ type TokenType = "TEXT" | "VAR" | "BLOCK" | "COMMENT";
23
+ type Token = [TokenType, string];
24
+
25
+ // ── Lexer ──────────────────────────────────────────────────────
26
+
27
+ const TOKEN_RE = /(\{%-?\s*[\s\S]*?\s*-?%\})|(\{\{-?\s*[\s\S]*?\s*-?\}\})|(\{#[\s\S]*?#\})/g;
28
+
29
+ // Regex to extract {% raw %}...{% endraw %} blocks before tokenizing
30
+ const RAW_BLOCK_RE = /\{%-?\s*raw\s*-?%\}([\s\S]*?)\{%-?\s*endraw\s*-?%\}/g;
31
+
32
+ function tokenize(source: string): Token[] {
33
+ // 1. Extract raw blocks and replace with placeholders
34
+ const rawBlocks: string[] = [];
35
+ source = source.replace(RAW_BLOCK_RE, (_match, content) => {
36
+ const idx = rawBlocks.length;
37
+ rawBlocks.push(content);
38
+ return `\x00RAW_${idx}\x00`;
39
+ });
40
+
41
+ // 2. Normal tokenization
42
+ const tokens: Token[] = [];
43
+ let pos = 0;
44
+
45
+ TOKEN_RE.lastIndex = 0;
46
+ let m: RegExpExecArray | null;
47
+ while ((m = TOKEN_RE.exec(source)) !== null) {
48
+ const start = m.index;
49
+ if (start > pos) {
50
+ tokens.push(["TEXT", source.slice(pos, start)]);
51
+ }
52
+
53
+ const raw = m[0];
54
+ if (raw.startsWith("{#")) {
55
+ tokens.push(["COMMENT", raw]);
56
+ } else if (raw.startsWith("{{")) {
57
+ tokens.push(["VAR", raw]);
58
+ } else if (raw.startsWith("{%")) {
59
+ tokens.push(["BLOCK", raw]);
60
+ }
61
+ pos = m.index + raw.length;
62
+ }
63
+
64
+ if (pos < source.length) {
65
+ tokens.push(["TEXT", source.slice(pos)]);
66
+ }
67
+
68
+ // 3. Restore raw block placeholders as literal TEXT
69
+ if (rawBlocks.length > 0) {
70
+ for (let i = 0; i < tokens.length; i++) {
71
+ if (tokens[i][0] === "TEXT" && tokens[i][1].includes("\x00RAW_")) {
72
+ let value = tokens[i][1];
73
+ for (let idx = 0; idx < rawBlocks.length; idx++) {
74
+ value = value.replace(`\x00RAW_${idx}\x00`, rawBlocks[idx]);
75
+ }
76
+ tokens[i] = ["TEXT", value];
77
+ }
78
+ }
79
+ }
80
+
81
+ return tokens;
82
+ }
83
+
84
+ function stripTag(raw: string): [string, boolean, boolean] {
85
+ let inner: string;
86
+ if (raw.startsWith("{{")) {
87
+ inner = raw.slice(2, -2);
88
+ } else if (raw.startsWith("{%")) {
89
+ inner = raw.slice(2, -2);
90
+ } else {
91
+ inner = raw.slice(2, -2);
92
+ }
93
+
94
+ let stripBefore = false;
95
+ let stripAfter = false;
96
+
97
+ if (inner.startsWith("-")) {
98
+ stripBefore = true;
99
+ inner = inner.slice(1);
100
+ }
101
+ if (inner.endsWith("-")) {
102
+ stripAfter = true;
103
+ inner = inner.slice(0, -1);
104
+ }
105
+
106
+ return [inner.trim(), stripBefore, stripAfter];
107
+ }
108
+
109
+ // ── Expression Evaluator ───────────────────────────────────────
110
+
111
+ function resolveVar(expr: string, context: Record<string, unknown>): unknown {
112
+ expr = expr.trim();
113
+
114
+ // String literal
115
+ if ((expr.startsWith('"') && expr.endsWith('"')) ||
116
+ (expr.startsWith("'") && expr.endsWith("'"))) {
117
+ return expr.slice(1, -1);
118
+ }
119
+
120
+ // Numeric literal
121
+ if (/^-?\d+(\.\d+)?$/.test(expr)) {
122
+ return expr.includes(".") ? parseFloat(expr) : parseInt(expr, 10);
123
+ }
124
+
125
+ // Boolean/null literals
126
+ if (expr === "true") return true;
127
+ if (expr === "false") return false;
128
+ if (expr === "null" || expr === "none" || expr === "None") return null;
129
+
130
+ // Array literal [...]
131
+ if (expr.startsWith("[") && expr.endsWith("]")) {
132
+ const inner = expr.slice(1, -1).trim();
133
+ if (inner === "") return [];
134
+ const items = splitArgs(inner);
135
+ return items.map(item => evalExpr(item.trim(), context));
136
+ }
137
+
138
+ // Dotted path with bracket access
139
+ const parts = expr.split(/\.|\[([^\]]+)\]/g).filter(p => p !== undefined && p !== "");
140
+
141
+ let value: unknown = context;
142
+ for (const part of parts) {
143
+ if (value === null || value === undefined) return null;
144
+
145
+ let key: string | number = part.replace(/^['"]|['"]$/g, "");
146
+ const asNum = parseInt(key, 10);
147
+ if (!isNaN(asNum) && String(asNum) === key) {
148
+ key = asNum;
149
+ }
150
+
151
+ if (typeof value === "object" && value !== null) {
152
+ if (Array.isArray(value) && typeof key === "number") {
153
+ value = (value as unknown[])[key];
154
+ } else if (key in (value as Record<string, unknown>)) {
155
+ const v = (value as Record<string, unknown>)[key as string];
156
+ value = typeof v === "function" ? v.call(value) : v;
157
+ } else {
158
+ return null;
159
+ }
160
+ } else {
161
+ return null;
162
+ }
163
+ }
164
+
165
+ return value;
166
+ }
167
+
168
+ function evalExpr(expr: string, context: Record<string, unknown>): unknown {
169
+ expr = expr.trim();
170
+
171
+ // Ternary: condition ? true_val : false_val
172
+ // Match carefully to handle nested ternaries
173
+ const ternaryIdx = findTernary(expr);
174
+ if (ternaryIdx !== -1) {
175
+ const condPart = expr.slice(0, ternaryIdx).trim();
176
+ const rest = expr.slice(ternaryIdx + 1);
177
+ const colonIdx = findColon(rest);
178
+ if (colonIdx !== -1) {
179
+ const truePart = rest.slice(0, colonIdx).trim();
180
+ const falsePart = rest.slice(colonIdx + 1).trim();
181
+ const cond = evalExpr(condPart, context);
182
+ return cond ? evalExpr(truePart, context) : evalExpr(falsePart, context);
183
+ }
184
+ }
185
+
186
+ // Null coalescing: value ?? "default"
187
+ const qqIdx = expr.indexOf("??");
188
+ if (qqIdx !== -1) {
189
+ const left = expr.slice(0, qqIdx).trim();
190
+ const right = expr.slice(qqIdx + 2).trim();
191
+ const val = evalExpr(left, context);
192
+ if (val === null || val === undefined) {
193
+ return evalExpr(right, context);
194
+ }
195
+ return val;
196
+ }
197
+
198
+ // String concatenation with ~
199
+ if (expr.includes("~")) {
200
+ const parts = splitOnTilde(expr);
201
+ if (parts.length > 1) {
202
+ return parts.map(p => {
203
+ const v = evalExpr(p.trim(), context);
204
+ return v === null || v === undefined ? "" : String(v);
205
+ }).join("");
206
+ }
207
+ }
208
+
209
+ // Check for comparison/logical operators
210
+ for (const op of [" not in ", " in ", " is not ", " is ", "!=", "==", ">=", "<=", ">", "<", " and ", " or ", " not "]) {
211
+ if (expr.includes(op)) {
212
+ return evalComparison(expr, context);
213
+ }
214
+ }
215
+
216
+ // Function call: name("arg1", "arg2")
217
+ const fnMatch = expr.match(/^(\w+)\s*\(([\s\S]*)?\)$/);
218
+ if (fnMatch) {
219
+ const fnName = fnMatch[1];
220
+ const rawArgs = fnMatch[2] || "";
221
+ const fn = context[fnName] ?? resolveVar(fnName, context);
222
+ if (typeof fn === "function") {
223
+ if (rawArgs.trim()) {
224
+ const parts = splitArgs(rawArgs);
225
+ const evalArgs = parts.map(a => evalExpr(a.trim(), context));
226
+ return fn(...evalArgs);
227
+ }
228
+ return fn();
229
+ }
230
+ }
231
+
232
+ return resolveVar(expr, context);
233
+ }
234
+
235
+ function findTernary(expr: string): number {
236
+ let depth = 0;
237
+ let inQuote: string | null = null;
238
+ for (let i = 0; i < expr.length; i++) {
239
+ const ch = expr[i];
240
+ if (inQuote) {
241
+ if (ch === inQuote) inQuote = null;
242
+ continue;
243
+ }
244
+ if (ch === '"' || ch === "'") { inQuote = ch; continue; }
245
+ if (ch === "(") { depth++; continue; }
246
+ if (ch === ")") { depth--; continue; }
247
+ if (ch === "?" && depth === 0 && expr[i + 1] !== "?") {
248
+ return i;
249
+ }
250
+ }
251
+ return -1;
252
+ }
253
+
254
+ function findColon(expr: string): number {
255
+ let depth = 0;
256
+ let inQuote: string | null = null;
257
+ for (let i = 0; i < expr.length; i++) {
258
+ const ch = expr[i];
259
+ if (inQuote) {
260
+ if (ch === inQuote) inQuote = null;
261
+ continue;
262
+ }
263
+ if (ch === '"' || ch === "'") { inQuote = ch; continue; }
264
+ if (ch === "(") { depth++; continue; }
265
+ if (ch === ")") { depth--; continue; }
266
+ if (ch === ":" && depth === 0) {
267
+ return i;
268
+ }
269
+ }
270
+ return -1;
271
+ }
272
+
273
+ function splitOnTilde(expr: string): string[] {
274
+ const parts: string[] = [];
275
+ let current = "";
276
+ let inQuote: string | null = null;
277
+ for (let i = 0; i < expr.length; i++) {
278
+ const ch = expr[i];
279
+ if (inQuote) {
280
+ current += ch;
281
+ if (ch === inQuote) inQuote = null;
282
+ continue;
283
+ }
284
+ if (ch === '"' || ch === "'") { inQuote = ch; current += ch; continue; }
285
+ if (ch === "~") {
286
+ parts.push(current);
287
+ current = "";
288
+ continue;
289
+ }
290
+ current += ch;
291
+ }
292
+ if (current) parts.push(current);
293
+ return parts;
294
+ }
295
+
296
+ function evalComparison(expr: string, context: Record<string, unknown>): boolean {
297
+ expr = expr.trim();
298
+
299
+ // Handle 'not' prefix
300
+ if (expr.startsWith("not ")) {
301
+ return !evalComparison(expr.slice(4), context);
302
+ }
303
+
304
+ // 'or' (lowest precedence)
305
+ const orParts = splitOnKeyword(expr, " or ");
306
+ if (orParts.length > 1) {
307
+ return orParts.some(p => evalComparison(p, context));
308
+ }
309
+
310
+ // 'and'
311
+ const andParts = splitOnKeyword(expr, " and ");
312
+ if (andParts.length > 1) {
313
+ return andParts.every(p => evalComparison(p, context));
314
+ }
315
+
316
+ // 'is not' test
317
+ let m = expr.match(/^(.+?)\s+is\s+not\s+(\w+)(.*)$/);
318
+ if (m) {
319
+ return !evalTest(m[1].trim(), m[2], m[3].trim(), context);
320
+ }
321
+
322
+ // 'is' test
323
+ m = expr.match(/^(.+?)\s+is\s+(\w+)(.*)$/);
324
+ if (m) {
325
+ return evalTest(m[1].trim(), m[2], m[3].trim(), context);
326
+ }
327
+
328
+ // 'not in'
329
+ m = expr.match(/^(.+?)\s+not\s+in\s+(.+)$/);
330
+ if (m) {
331
+ const val = evalExpr(m[1].trim(), context);
332
+ const collection = evalExpr(m[2].trim(), context);
333
+ if (Array.isArray(collection)) return !collection.includes(val);
334
+ if (typeof collection === "string") return !collection.includes(val as string);
335
+ return true;
336
+ }
337
+
338
+ // 'in'
339
+ m = expr.match(/^(.+?)\s+in\s+(.+)$/);
340
+ if (m) {
341
+ const val = evalExpr(m[1].trim(), context);
342
+ const collection = evalExpr(m[2].trim(), context);
343
+ if (Array.isArray(collection)) return collection.includes(val);
344
+ if (typeof collection === "string") return collection.includes(val as string);
345
+ return false;
346
+ }
347
+
348
+ // Binary operators
349
+ const ops: [string, (a: unknown, b: unknown) => boolean][] = [
350
+ ["!=", (a, b) => a !== b],
351
+ ["==", (a, b) => a == b], // intentional loose equality to match Python
352
+ [">=", (a, b) => (a as number) >= (b as number)],
353
+ ["<=", (a, b) => (a as number) <= (b as number)],
354
+ [">", (a, b) => (a as number) > (b as number)],
355
+ ["<", (a, b) => (a as number) < (b as number)],
356
+ ];
357
+
358
+ for (const [op, fn] of ops) {
359
+ const opIdx = expr.indexOf(op);
360
+ if (opIdx !== -1) {
361
+ const left = expr.slice(0, opIdx).trim();
362
+ const right = expr.slice(opIdx + op.length).trim();
363
+ const l = evalExpr(left, context);
364
+ const r = evalExpr(right, context);
365
+ try {
366
+ return fn(l, r);
367
+ } catch {
368
+ return false;
369
+ }
370
+ }
371
+ }
372
+
373
+ // Fall through to simple eval
374
+ const val = evalExpr(expr, context);
375
+ return val !== null && val !== undefined && val !== false && val !== 0 && val !== "";
376
+ }
377
+
378
+ function splitOnKeyword(expr: string, keyword: string): string[] {
379
+ const parts: string[] = [];
380
+ let current = "";
381
+ let inQuote: string | null = null;
382
+ let depth = 0;
383
+ let i = 0;
384
+
385
+ while (i < expr.length) {
386
+ const ch = expr[i];
387
+ if (inQuote) {
388
+ current += ch;
389
+ if (ch === inQuote) inQuote = null;
390
+ i++;
391
+ continue;
392
+ }
393
+ if (ch === '"' || ch === "'") {
394
+ inQuote = ch;
395
+ current += ch;
396
+ i++;
397
+ continue;
398
+ }
399
+ if (ch === "(") { depth++; current += ch; i++; continue; }
400
+ if (ch === ")") { depth--; current += ch; i++; continue; }
401
+
402
+ if (depth === 0 && expr.slice(i, i + keyword.length) === keyword) {
403
+ parts.push(current);
404
+ current = "";
405
+ i += keyword.length;
406
+ continue;
407
+ }
408
+ current += ch;
409
+ i++;
410
+ }
411
+ if (current) parts.push(current);
412
+ return parts;
413
+ }
414
+
415
+ function evalTest(
416
+ valueExpr: string,
417
+ testName: string,
418
+ args: string,
419
+ context: Record<string, unknown>,
420
+ ): boolean {
421
+ const val = evalExpr(valueExpr, context);
422
+
423
+ // Check custom tests first
424
+ const customTests = (context as { __frond_tests__?: Record<string, TestFn> }).__frond_tests__;
425
+ if (customTests && customTests[testName]) {
426
+ return customTests[testName](val);
427
+ }
428
+
429
+ const tests: Record<string, (v: unknown) => boolean> = {
430
+ defined: (v) => v !== null && v !== undefined,
431
+ empty: (v) => !v || (Array.isArray(v) && v.length === 0) || (typeof v === "object" && v !== null && Object.keys(v).length === 0),
432
+ null: (v) => v === null || v === undefined,
433
+ none: (v) => v === null || v === undefined,
434
+ even: (v) => typeof v === "number" && Number.isInteger(v) && v % 2 === 0,
435
+ odd: (v) => typeof v === "number" && Number.isInteger(v) && v % 2 !== 0,
436
+ iterable: (v) => Array.isArray(v) || (typeof v === "object" && v !== null),
437
+ string: (v) => typeof v === "string",
438
+ number: (v) => typeof v === "number",
439
+ boolean: (v) => typeof v === "boolean",
440
+ };
441
+
442
+ // 'divisible by(n)'
443
+ if (testName === "divisible") {
444
+ const dm = args.match(/\s*by\s*\(\s*(\d+)\s*\)/);
445
+ if (dm) {
446
+ const n = parseInt(dm[1], 10);
447
+ return typeof val === "number" && Number.isInteger(val) && val % n === 0;
448
+ }
449
+ return false;
450
+ }
451
+
452
+ if (testName in tests) {
453
+ return tests[testName](val);
454
+ }
455
+
456
+ return false;
457
+ }
458
+
459
+ // ── Filters ────────────────────────────────────────────────────
460
+
461
+ function parseFilterChain(expr: string): [string, [string, string[]][]] {
462
+ // Split on | but not inside strings or parentheses
463
+ const parts: string[] = [];
464
+ let current = "";
465
+ let inQuote: string | null = null;
466
+ let depth = 0;
467
+
468
+ for (let i = 0; i < expr.length; i++) {
469
+ const ch = expr[i];
470
+ if (inQuote) {
471
+ current += ch;
472
+ if (ch === inQuote) inQuote = null;
473
+ continue;
474
+ }
475
+ if (ch === '"' || ch === "'") {
476
+ inQuote = ch;
477
+ current += ch;
478
+ continue;
479
+ }
480
+ if (ch === "(") { depth++; current += ch; continue; }
481
+ if (ch === ")") { depth--; current += ch; continue; }
482
+ if (ch === "|" && depth === 0) {
483
+ parts.push(current);
484
+ current = "";
485
+ continue;
486
+ }
487
+ current += ch;
488
+ }
489
+ if (current) parts.push(current);
490
+
491
+ const variable = parts[0].trim();
492
+ const filters: [string, string[]][] = [];
493
+
494
+ for (let i = 1; i < parts.length; i++) {
495
+ const f = parts[i].trim();
496
+ const fm = f.match(/^(\w+)\s*\(([\s\S]*)\)$/);
497
+ if (fm) {
498
+ const name = fm[1];
499
+ const rawArgs = fm[2].trim();
500
+ const args = rawArgs ? parseArgs(rawArgs) : [];
501
+ filters.push([name, args]);
502
+ } else {
503
+ filters.push([f.trim(), []]);
504
+ }
505
+ }
506
+
507
+ return [variable, filters];
508
+ }
509
+
510
+ function parseArgs(raw: string): string[] {
511
+ const args: string[] = [];
512
+ let current = "";
513
+ let inQuote: string | null = null;
514
+ let wasQuoted = false;
515
+ let depth = 0;
516
+
517
+ for (const ch of raw) {
518
+ if (inQuote) {
519
+ if (ch === inQuote) {
520
+ inQuote = null;
521
+ } else {
522
+ current += ch;
523
+ }
524
+ continue;
525
+ }
526
+ if (ch === '"' || ch === "'") {
527
+ inQuote = ch;
528
+ wasQuoted = true;
529
+ // Discard any whitespace accumulated before the opening quote
530
+ if (current.trim() === "") current = "";
531
+ continue;
532
+ }
533
+ if (ch === "(") { depth++; current += ch; continue; }
534
+ if (ch === ")") { depth--; current += ch; continue; }
535
+ if (ch === "," && depth === 0) {
536
+ args.push(wasQuoted ? current : current.trim());
537
+ current = "";
538
+ wasQuoted = false;
539
+ continue;
540
+ }
541
+ current += ch;
542
+ }
543
+
544
+ const final = wasQuoted ? current : current.trim();
545
+ if (final !== "" || wasQuoted) {
546
+ args.push(final);
547
+ }
548
+
549
+ return args;
550
+ }
551
+
552
+ function splitArgs(raw: string): string[] {
553
+ const args: string[] = [];
554
+ let current = "";
555
+ let inQuote: string | null = null;
556
+ let depth = 0;
557
+
558
+ for (const ch of raw) {
559
+ if (inQuote) {
560
+ current += ch;
561
+ if (ch === inQuote) inQuote = null;
562
+ continue;
563
+ }
564
+ if (ch === '"' || ch === "'") {
565
+ inQuote = ch;
566
+ current += ch;
567
+ continue;
568
+ }
569
+ if (ch === "(" || ch === "[") { depth++; current += ch; continue; }
570
+ if (ch === ")" || ch === "]") { depth--; current += ch; continue; }
571
+ if (ch === "," && depth === 0) {
572
+ args.push(current.trim());
573
+ current = "";
574
+ continue;
575
+ }
576
+ current += ch;
577
+ }
578
+ if (current.trim()) args.push(current.trim());
579
+ return args;
580
+ }
581
+
582
+ function htmlEscape(str: string): string {
583
+ return str
584
+ .replace(/&/g, "&amp;")
585
+ .replace(/</g, "&lt;")
586
+ .replace(/>/g, "&gt;")
587
+ .replace(/"/g, "&quot;")
588
+ .replace(/'/g, "&#x27;");
589
+ }
590
+
591
+ function dateFilter(value: unknown, fmt: string): string {
592
+ let dt: Date;
593
+ if (value instanceof Date) {
594
+ dt = value;
595
+ } else if (typeof value === "string") {
596
+ dt = new Date(value);
597
+ if (isNaN(dt.getTime())) return String(value);
598
+ } else if (typeof value === "number") {
599
+ dt = new Date(value);
600
+ } else {
601
+ return String(value);
602
+ }
603
+
604
+ // Python strftime format to manual conversion
605
+ return fmt
606
+ .replace(/%Y/g, String(dt.getFullYear()))
607
+ .replace(/%m/g, String(dt.getMonth() + 1).padStart(2, "0"))
608
+ .replace(/%d/g, String(dt.getDate()).padStart(2, "0"))
609
+ .replace(/%H/g, String(dt.getHours()).padStart(2, "0"))
610
+ .replace(/%M/g, String(dt.getMinutes()).padStart(2, "0"))
611
+ .replace(/%S/g, String(dt.getSeconds()).padStart(2, "0"))
612
+ .replace(/%I/g, String(dt.getHours() % 12 || 12).padStart(2, "0"))
613
+ .replace(/%p/g, dt.getHours() >= 12 ? "PM" : "AM")
614
+ .replace(/%B/g, dt.toLocaleString("en-US", { month: "long" }))
615
+ .replace(/%b/g, dt.toLocaleString("en-US", { month: "short" }))
616
+ .replace(/%A/g, dt.toLocaleString("en-US", { weekday: "long" }))
617
+ .replace(/%a/g, dt.toLocaleString("en-US", { weekday: "short" }));
618
+ }
619
+
620
+ function wordwrap(text: string, width: number): string {
621
+ const words = text.split(/\s+/);
622
+ const lines: string[] = [];
623
+ let current = "";
624
+ for (const word of words) {
625
+ if (current && current.length + 1 + word.length > width) {
626
+ lines.push(current);
627
+ current = word;
628
+ } else {
629
+ current = current ? `${current} ${word}` : word;
630
+ }
631
+ }
632
+ if (current) lines.push(current);
633
+ return lines.join("\n");
634
+ }
635
+
636
+ function numberFormat(value: unknown, decimals: number): string {
637
+ const num = parseFloat(String(value));
638
+ const fixed = num.toFixed(decimals);
639
+ const [intPart, decPart] = fixed.split(".");
640
+ const formatted = intPart.replace(/\B(?=(\d{3})+(?!\d))/g, ",");
641
+ return decPart ? `${formatted}.${decPart}` : formatted;
642
+ }
643
+
644
+ const BUILTIN_FILTERS: Record<string, FilterFn> = {
645
+ upper: (v) => String(v).toUpperCase(),
646
+ lower: (v) => String(v).toLowerCase(),
647
+ capitalize: (v) => { const s = String(v); return s.charAt(0).toUpperCase() + s.slice(1).toLowerCase(); },
648
+ title: (v) => String(v).replace(/\b\w/g, c => c.toUpperCase()),
649
+ trim: (v) => String(v).trim(),
650
+ ltrim: (v) => String(v).replace(/^\s+/, ""),
651
+ rtrim: (v) => String(v).replace(/\s+$/, ""),
652
+ length: (v) => {
653
+ if (Array.isArray(v)) return v.length;
654
+ if (typeof v === "string") return v.length;
655
+ if (typeof v === "object" && v !== null) return Object.keys(v).length;
656
+ return 0;
657
+ },
658
+ reverse: (v) => Array.isArray(v) ? [...v].reverse() : String(v).split("").reverse().join(""),
659
+ sort: (v) => Array.isArray(v) ? [...v].sort() : v,
660
+ shuffle: (v) => {
661
+ if (!Array.isArray(v)) return v;
662
+ const arr = [...v];
663
+ for (let i = arr.length - 1; i > 0; i--) {
664
+ const j = Math.floor(Math.random() * (i + 1));
665
+ [arr[i], arr[j]] = [arr[j], arr[i]];
666
+ }
667
+ return arr;
668
+ },
669
+ first: (v) => Array.isArray(v) ? v[0] ?? null : null,
670
+ last: (v) => Array.isArray(v) ? v[v.length - 1] ?? null : null,
671
+ join: (v, sep) => Array.isArray(v) ? v.map(String).join(sep !== undefined ? String(sep) : ", ") : String(v),
672
+ split: (v, sep) => String(v).split(sep !== undefined ? String(sep) : " "),
673
+ replace: (v, from, to) => from !== undefined && to !== undefined ? String(v).split(String(from)).join(String(to)) : String(v),
674
+ default: (v, fallback) => (v !== null && v !== undefined && v !== "") ? v : (fallback !== undefined ? fallback : ""),
675
+ raw: (v) => v,
676
+ safe: (v) => v,
677
+ escape: (v) => htmlEscape(String(v)),
678
+ e: (v) => htmlEscape(String(v)),
679
+ striptags: (v) => String(v).replace(/<[^>]+>/g, ""),
680
+ nl2br: (v) => String(v).replace(/\n/g, "<br>\n"),
681
+ abs: (v) => typeof v === "number" ? Math.abs(v) : v,
682
+ round: (v, decimals) => {
683
+ const d = decimals !== undefined ? parseInt(String(decimals), 10) : 0;
684
+ return parseFloat(parseFloat(String(v)).toFixed(d));
685
+ },
686
+ int: (v) => v ? parseInt(String(v), 10) || 0 : 0,
687
+ float: (v) => v ? parseFloat(String(v)) || 0.0 : 0.0,
688
+ string: (v) => String(v),
689
+ json_encode: (v) => JSON.stringify(v),
690
+ json_decode: (v) => typeof v === "string" ? JSON.parse(v) : v,
691
+ keys: (v) => (typeof v === "object" && v !== null && !Array.isArray(v)) ? Object.keys(v) : [],
692
+ values: (v) => (typeof v === "object" && v !== null && !Array.isArray(v)) ? Object.values(v) : [],
693
+ merge: (v, other) => {
694
+ if (typeof v === "object" && v !== null && !Array.isArray(v) && typeof other === "object" && other !== null) {
695
+ return { ...(v as Record<string, unknown>), ...(other as Record<string, unknown>) };
696
+ }
697
+ return v;
698
+ },
699
+ slice: (v, start, end) => {
700
+ if (Array.isArray(v) || typeof v === "string") {
701
+ return v.slice(
702
+ start !== undefined ? parseInt(String(start), 10) : 0,
703
+ end !== undefined ? parseInt(String(end), 10) : undefined,
704
+ );
705
+ }
706
+ return v;
707
+ },
708
+ batch: (v, size) => {
709
+ if (!Array.isArray(v) || size === undefined) return [v];
710
+ const s = parseInt(String(size), 10);
711
+ const result: unknown[][] = [];
712
+ for (let i = 0; i < v.length; i += s) {
713
+ result.push(v.slice(i, i + s));
714
+ }
715
+ return result;
716
+ },
717
+ unique: (v) => {
718
+ if (!Array.isArray(v)) return v;
719
+ return [...new Set(v)];
720
+ },
721
+ map: (v, key) => {
722
+ if (!Array.isArray(v) || key === undefined) return v;
723
+ return v.map(item => {
724
+ if (typeof item === "object" && item !== null) {
725
+ return (item as Record<string, unknown>)[String(key)] ?? null;
726
+ }
727
+ return null;
728
+ });
729
+ },
730
+ filter: (v) => Array.isArray(v) ? v.filter(Boolean) : v,
731
+ column: (v, key) => {
732
+ if (!Array.isArray(v) || key === undefined) return v;
733
+ return v.map(row => {
734
+ if (typeof row === "object" && row !== null) {
735
+ return (row as Record<string, unknown>)[String(key)] ?? null;
736
+ }
737
+ return null;
738
+ });
739
+ },
740
+ number_format: (v, decimals) => numberFormat(v, decimals !== undefined ? parseInt(String(decimals), 10) : 0),
741
+ date: (v, fmt) => dateFilter(v, fmt !== undefined ? String(fmt) : "%Y-%m-%d"),
742
+ truncate: (v, length) => {
743
+ const s = String(v);
744
+ if (length !== undefined && s.length > parseInt(String(length), 10)) {
745
+ return s.slice(0, parseInt(String(length), 10)) + "...";
746
+ }
747
+ return s;
748
+ },
749
+ wordwrap: (v, width) => wordwrap(String(v), width !== undefined ? parseInt(String(width), 10) : 75),
750
+ slug: (v) => String(v).toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, ""),
751
+ md5: (v) => createHash("md5").update(String(v)).digest("hex"),
752
+ sha256: (v) => createHash("sha256").update(String(v)).digest("hex"),
753
+ base64_encode: (v) => Buffer.from(String(v)).toString("base64"),
754
+ base64_decode: (v) => Buffer.from(String(v), "base64").toString("utf-8"),
755
+ url_encode: (v) => encodeURIComponent(String(v)),
756
+ format: (v, ...args) => {
757
+ let s = String(v);
758
+ // Simple %s / %d replacement like Python's % operator
759
+ let idx = 0;
760
+ s = s.replace(/%[sd]/g, () => {
761
+ const val = idx < args.length ? String(args[idx]) : "";
762
+ idx++;
763
+ return val;
764
+ });
765
+ return s;
766
+ },
767
+ dump: (v) => JSON.stringify(v),
768
+ formToken: (v?: unknown) => _generateFormToken(v != null ? String(v) : ""),
769
+ form_token: (v?: unknown) => _generateFormToken(v != null ? String(v) : ""),
770
+ };
771
+
772
+ // ── Form Token ────────────────────────────────────────────────
773
+
774
+ function _b64url(data: Buffer): string {
775
+ return data.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
776
+ }
777
+
778
+ /**
779
+ * Generate a JWT form token and return a hidden input element.
780
+ *
781
+ * @param descriptor - Optional string to enrich the token payload.
782
+ * - Empty: payload is {"type":"form"}
783
+ * - "admin_panel": payload is {"type":"form","context":"admin_panel"}
784
+ * - "checkout|order_123": payload is {"type":"form","context":"checkout","ref":"order_123"}
785
+ *
786
+ * @returns `<input type="hidden" name="formToken" value="TOKEN">`
787
+ */
788
+ function _generateFormToken(descriptor: string = ""): SafeString {
789
+ const secret = process.env.SECRET || "tina4-default-secret";
790
+ const ttlMinutes = parseInt(process.env.TINA4_TOKEN_LIMIT || "30", 10);
791
+
792
+ const header = { alg: "HS256", typ: "JWT" };
793
+ const now = Math.floor(Date.now() / 1000);
794
+ const payload: Record<string, unknown> = { type: "form", iat: now, exp: now + ttlMinutes * 60 };
795
+
796
+ if (descriptor) {
797
+ if (descriptor.includes("|")) {
798
+ const [ctx, ref] = descriptor.split("|", 2);
799
+ payload.context = ctx;
800
+ payload.ref = ref;
801
+ } else {
802
+ payload.context = descriptor;
803
+ }
804
+ }
805
+
806
+ const h = _b64url(Buffer.from(JSON.stringify(header)));
807
+ const p = _b64url(Buffer.from(JSON.stringify(payload)));
808
+ const sigInput = `${h}.${p}`;
809
+ const sig = _b64url(createHmac("sha256", secret).update(sigInput).digest());
810
+
811
+ const token = `${h}.${p}.${sig}`;
812
+ const escaped = token.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
813
+ return new SafeString(`<input type="hidden" name="formToken" value="${escaped}">`);
814
+ }
815
+
816
+ // ── Frond Engine ───────────────────────────────────────────────
817
+
818
+ export class Frond {
819
+ private templateDir: string;
820
+ private filters: Record<string, FilterFn>;
821
+ private globals: Record<string, unknown>;
822
+ private tests: Record<string, TestFn>;
823
+ private _sandbox: boolean;
824
+ private _allowedFilters: Set<string> | null;
825
+ private _allowedTags: Set<string> | null;
826
+ private _allowedVars: Set<string> | null;
827
+ private fragmentCache: Map<string, [string, number]>;
828
+
829
+ constructor(templateDir: string = "src/templates") {
830
+ this.templateDir = resolve(templateDir);
831
+ this.filters = { ...BUILTIN_FILTERS };
832
+ this.globals = {};
833
+ this.tests = {};
834
+ this._sandbox = false;
835
+ this._allowedFilters = null;
836
+ this._allowedTags = null;
837
+ this._allowedVars = null;
838
+ this.fragmentCache = new Map();
839
+
840
+ // Built-in global functions
841
+ this.globals.formToken = (descriptor?: string) => _generateFormToken(descriptor || "");
842
+ this.globals.form_token = (descriptor?: string) => _generateFormToken(descriptor || "");
843
+ }
844
+
845
+ sandbox(filters?: string[], tags?: string[], vars?: string[]): Frond {
846
+ this._sandbox = true;
847
+ this._allowedFilters = filters ? new Set(filters) : null;
848
+ this._allowedTags = tags ? new Set(tags) : null;
849
+ this._allowedVars = vars ? new Set(vars) : null;
850
+ return this;
851
+ }
852
+
853
+ unsandbox(): Frond {
854
+ this._sandbox = false;
855
+ this._allowedFilters = null;
856
+ this._allowedTags = null;
857
+ this._allowedVars = null;
858
+ return this;
859
+ }
860
+
861
+ addFilter(name: string, fn: FilterFn): void {
862
+ this.filters[name] = fn;
863
+ }
864
+
865
+ addGlobal(name: string, value: unknown): void {
866
+ this.globals[name] = value;
867
+ }
868
+
869
+ addTest(name: string, fn: TestFn): void {
870
+ this.tests[name] = fn;
871
+ }
872
+
873
+ render(template: string, data?: Record<string, unknown>): string {
874
+ const context = { ...this.globals, ...(data || {}) };
875
+ const source = this.load(template);
876
+ return this.execute(source, context);
877
+ }
878
+
879
+ renderString(source: string, data?: Record<string, unknown>): string {
880
+ const context = { ...this.globals, ...(data || {}) };
881
+ return this.execute(source, context);
882
+ }
883
+
884
+ private load(name: string): string {
885
+ const filePath = join(this.templateDir, name);
886
+ if (!existsSync(filePath)) {
887
+ throw new Error(`Template not found: ${filePath}`);
888
+ }
889
+ return readFileSync(filePath, "utf-8");
890
+ }
891
+
892
+ private execute(source: string, context: Record<string, unknown>): string {
893
+ // Inject custom tests into context for evalTest to find
894
+ if (Object.keys(this.tests).length > 0) {
895
+ context.__frond_tests__ = this.tests;
896
+ }
897
+
898
+ // Handle extends first
899
+ const extendsMatch = source.match(/\{%[-\s]*extends\s+["'](.+?)["']\s*[-]?%\}/);
900
+ if (extendsMatch) {
901
+ const parentName = extendsMatch[1];
902
+ const parentSource = this.load(parentName);
903
+ const childBlocks = this.extractBlocks(source);
904
+ return this.renderWithBlocks(parentSource, context, childBlocks);
905
+ }
906
+
907
+ return this.renderTokens(tokenize(source), context);
908
+ }
909
+
910
+ private extractBlocks(source: string): Record<string, string> {
911
+ const blocks: Record<string, string> = {};
912
+ const pattern = /\{%[-\s]*block\s+(\w+)\s*[-]?%\}([\s\S]*?)\{%[-\s]*endblock\s*[-]?%\}/g;
913
+ let m: RegExpExecArray | null;
914
+ while ((m = pattern.exec(source)) !== null) {
915
+ blocks[m[1]] = m[2];
916
+ }
917
+ return blocks;
918
+ }
919
+
920
+ private renderWithBlocks(
921
+ parentSource: string,
922
+ context: Record<string, unknown>,
923
+ childBlocks: Record<string, string>,
924
+ ): string {
925
+ const pattern = /\{%[-\s]*block\s+(\w+)\s*[-]?%\}([\s\S]*?)\{%[-\s]*endblock\s*[-]?%\}/g;
926
+
927
+ const result = parentSource.replace(pattern, (_match, name: string, defaultContent: string) => {
928
+ const blockSource = childBlocks[name] ?? defaultContent;
929
+ return this.renderTokens(tokenize(blockSource), context);
930
+ });
931
+
932
+ return this.renderTokens(tokenize(result), context);
933
+ }
934
+
935
+ private renderTokens(tokens: Token[], context: Record<string, unknown>): string {
936
+ const output: string[] = [];
937
+ let i = 0;
938
+
939
+ while (i < tokens.length) {
940
+ const [ttype, raw] = tokens[i];
941
+
942
+ if (ttype === "TEXT") {
943
+ output.push(raw);
944
+ i++;
945
+ } else if (ttype === "COMMENT") {
946
+ i++;
947
+ } else if (ttype === "VAR") {
948
+ const [content, stripB, stripA] = stripTag(raw);
949
+ if (stripB && output.length > 0) {
950
+ output[output.length - 1] = output[output.length - 1].replace(/\s+$/, "");
951
+ }
952
+
953
+ const result = this.evalVar(content, context);
954
+ output.push(result !== null && result !== undefined ? String(result) : "");
955
+
956
+ if (stripA && i + 1 < tokens.length && tokens[i + 1][0] === "TEXT") {
957
+ tokens[i + 1] = ["TEXT", tokens[i + 1][1].replace(/^\s+/, "")];
958
+ }
959
+ i++;
960
+ } else if (ttype === "BLOCK") {
961
+ const [content, stripB, stripA] = stripTag(raw);
962
+ if (stripB && output.length > 0) {
963
+ output[output.length - 1] = output[output.length - 1].replace(/\s+$/, "");
964
+ }
965
+
966
+ const parts = content.split(/\s+/);
967
+ const tag = parts[0] || "";
968
+
969
+ // Apply stripA before handlers consume body tokens
970
+ if (stripA && i + 1 < tokens.length && tokens[i + 1][0] === "TEXT") {
971
+ tokens[i + 1] = ["TEXT", tokens[i + 1][1].replace(/^\s+/, "")];
972
+ }
973
+
974
+ if (tag === "if") {
975
+ // Sandbox check
976
+ if (this._sandbox && this._allowedTags !== null && !this._allowedTags.has("if")) {
977
+ const skip = this.skipBlock(tokens, i, "if", "endif");
978
+ i = skip;
979
+ } else {
980
+ const [result, skip] = this.handleIf(tokens, i, context);
981
+ output.push(result);
982
+ i = skip;
983
+ }
984
+ } else if (tag === "for") {
985
+ if (this._sandbox && this._allowedTags !== null && !this._allowedTags.has("for")) {
986
+ const skip = this.skipBlock(tokens, i, "for", "endfor");
987
+ i = skip;
988
+ } else {
989
+ const [result, skip] = this.handleFor(tokens, i, context);
990
+ output.push(result);
991
+ i = skip;
992
+ }
993
+ } else if (tag === "set") {
994
+ if (this._sandbox && this._allowedTags !== null && !this._allowedTags.has("set")) {
995
+ i++;
996
+ } else {
997
+ this.handleSet(content, context);
998
+ i++;
999
+ }
1000
+ } else if (tag === "include") {
1001
+ if (this._sandbox && this._allowedTags !== null && !this._allowedTags.has("include")) {
1002
+ i++;
1003
+ } else {
1004
+ const result = this.handleInclude(content, context);
1005
+ output.push(result);
1006
+ i++;
1007
+ }
1008
+ } else if (tag === "macro") {
1009
+ const skip = this.handleMacro(tokens, i, context);
1010
+ i = skip;
1011
+ } else if (tag === "from") {
1012
+ this.handleFromImport(content, context);
1013
+ i++;
1014
+ } else if (tag === "cache") {
1015
+ const [result, skip] = this.handleCache(tokens, i, context);
1016
+ output.push(result);
1017
+ i = skip;
1018
+ } else if (tag === "block" || tag === "endblock" || tag === "extends") {
1019
+ i++; // Already handled
1020
+ } else {
1021
+ i++;
1022
+ }
1023
+
1024
+ if (stripA && i < tokens.length && tokens[i][0] === "TEXT") {
1025
+ tokens[i] = ["TEXT", tokens[i][1].replace(/^\s+/, "")];
1026
+ }
1027
+ } else {
1028
+ i++;
1029
+ }
1030
+ }
1031
+
1032
+ return output.join("");
1033
+ }
1034
+
1035
+ private skipBlock(tokens: Token[], start: number, openTag: string, closeTag: string): number {
1036
+ let depth = 0;
1037
+ let i = start + 1;
1038
+ while (i < tokens.length) {
1039
+ if (tokens[i][0] === "BLOCK") {
1040
+ const [content] = stripTag(tokens[i][1]);
1041
+ const tag = content.split(/\s+/)[0] || "";
1042
+ if (tag === openTag) depth++;
1043
+ else if (tag === closeTag) {
1044
+ if (depth === 0) return i + 1;
1045
+ depth--;
1046
+ }
1047
+ }
1048
+ i++;
1049
+ }
1050
+ return i;
1051
+ }
1052
+
1053
+ private evalVar(expr: string, context: Record<string, unknown>): unknown {
1054
+ const [varName, filters] = parseFilterChain(expr);
1055
+
1056
+ // Sandbox: check variable access
1057
+ if (this._sandbox && this._allowedVars !== null) {
1058
+ const rootVar = varName.split(".")[0].split("[")[0].trim();
1059
+ if (rootVar && !this._allowedVars.has(rootVar) && rootVar !== "loop") {
1060
+ return ""; // Silently block
1061
+ }
1062
+ }
1063
+
1064
+ let value = evalExpr(varName, context);
1065
+
1066
+ let isSafe = false;
1067
+ for (const [fname, args] of filters) {
1068
+ if (fname === "raw" || fname === "safe") {
1069
+ isSafe = true;
1070
+ continue;
1071
+ }
1072
+ // escape/e filter marks output as safe (already escaped)
1073
+ if (fname === "escape" || fname === "e") {
1074
+ isSafe = true;
1075
+ }
1076
+
1077
+ // Sandbox: check filter access
1078
+ if (this._sandbox && this._allowedFilters !== null) {
1079
+ if (!this._allowedFilters.has(fname)) {
1080
+ continue; // Silently skip blocked filter
1081
+ }
1082
+ }
1083
+
1084
+ const fn = this.filters[fname];
1085
+ if (fn) {
1086
+ value = fn(value, ...args);
1087
+ }
1088
+ }
1089
+
1090
+ // SafeString instances are already rendered/safe
1091
+ if (value instanceof SafeString) {
1092
+ return value.value;
1093
+ }
1094
+
1095
+ // Auto-escape HTML unless marked safe
1096
+ if (!isSafe && typeof value === "string") {
1097
+ value = htmlEscape(value);
1098
+ }
1099
+
1100
+ return value;
1101
+ }
1102
+
1103
+ private handleIf(tokens: Token[], start: number, context: Record<string, unknown>): [string, number] {
1104
+ const [content] = stripTag(tokens[start][1]);
1105
+ const conditionExpr = content.slice(3).trim(); // Remove 'if '
1106
+
1107
+ // Collect branches: [condition, tokens][]
1108
+ const branches: [string | null, Token[]][] = [];
1109
+ let currentTokens: Token[] = [];
1110
+ let currentCond: string | null = conditionExpr;
1111
+ let depth = 0;
1112
+ let i = start + 1;
1113
+
1114
+ while (i < tokens.length) {
1115
+ const [ttype, raw] = tokens[i];
1116
+ if (ttype === "BLOCK") {
1117
+ const [tagContent, tagStripB, tagStripA] = stripTag(raw);
1118
+ const tag = tagContent.split(/\s+/)[0] || "";
1119
+
1120
+ if (tag === "if") {
1121
+ depth++;
1122
+ currentTokens.push(tokens[i]);
1123
+ } else if (tag === "endif" && depth > 0) {
1124
+ depth--;
1125
+ currentTokens.push(tokens[i]);
1126
+ } else if (tag === "endif" && depth === 0) {
1127
+ // Strip trailing whitespace from last body token if endif has strip_before
1128
+ if (tagStripB && currentTokens.length > 0 && currentTokens[currentTokens.length - 1][0] === "TEXT") {
1129
+ currentTokens[currentTokens.length - 1] = ["TEXT", currentTokens[currentTokens.length - 1][1].replace(/\s+$/, "")];
1130
+ }
1131
+ branches.push([currentCond, currentTokens]);
1132
+ // Apply stripA on token after endif
1133
+ if (tagStripA && i + 1 < tokens.length && tokens[i + 1][0] === "TEXT") {
1134
+ tokens[i + 1] = ["TEXT", tokens[i + 1][1].replace(/^\s+/, "")];
1135
+ }
1136
+ i++;
1137
+ break;
1138
+ } else if ((tag === "elseif" || tag === "elif") && depth === 0) {
1139
+ if (tagStripB && currentTokens.length > 0 && currentTokens[currentTokens.length - 1][0] === "TEXT") {
1140
+ currentTokens[currentTokens.length - 1] = ["TEXT", currentTokens[currentTokens.length - 1][1].replace(/\s+$/, "")];
1141
+ }
1142
+ branches.push([currentCond, currentTokens]);
1143
+ currentCond = tagContent.slice(tag.length).trim();
1144
+ currentTokens = [];
1145
+ } else if (tag === "else" && depth === 0) {
1146
+ if (tagStripB && currentTokens.length > 0 && currentTokens[currentTokens.length - 1][0] === "TEXT") {
1147
+ currentTokens[currentTokens.length - 1] = ["TEXT", currentTokens[currentTokens.length - 1][1].replace(/\s+$/, "")];
1148
+ }
1149
+ branches.push([currentCond, currentTokens]);
1150
+ currentCond = null; // else branch
1151
+ currentTokens = [];
1152
+ } else {
1153
+ currentTokens.push(tokens[i]);
1154
+ }
1155
+ } else {
1156
+ currentTokens.push(tokens[i]);
1157
+ }
1158
+ i++;
1159
+ }
1160
+
1161
+ // Evaluate branches
1162
+ for (const [cond, branchTokens] of branches) {
1163
+ if (cond === null || evalComparison(cond, context)) {
1164
+ return [this.renderTokens([...branchTokens], context), i];
1165
+ }
1166
+ }
1167
+
1168
+ return ["", i];
1169
+ }
1170
+
1171
+ private handleFor(tokens: Token[], start: number, context: Record<string, unknown>): [string, number] {
1172
+ const [content] = stripTag(tokens[start][1]);
1173
+ const forMatch = content.match(/^for\s+(\w+)(?:\s*,\s*(\w+))?\s+in\s+(.+)/);
1174
+ if (!forMatch) return ["", start + 1];
1175
+
1176
+ const var1 = forMatch[1];
1177
+ const var2 = forMatch[2] || null;
1178
+ const iterableExpr = forMatch[3].trim();
1179
+
1180
+ // Collect body and else tokens
1181
+ const bodyTokens: Token[] = [];
1182
+ const elseTokens: Token[] = [];
1183
+ let inElse = false;
1184
+ let forDepth = 0;
1185
+ let ifDepth = 0;
1186
+ let i = start + 1;
1187
+
1188
+ while (i < tokens.length) {
1189
+ const [ttype, raw] = tokens[i];
1190
+ if (ttype === "BLOCK") {
1191
+ const [tagContent] = stripTag(raw);
1192
+ const tag = tagContent.split(/\s+/)[0] || "";
1193
+
1194
+ if (tag === "for") {
1195
+ forDepth++;
1196
+ (inElse ? elseTokens : bodyTokens).push(tokens[i]);
1197
+ } else if (tag === "endfor" && forDepth > 0) {
1198
+ forDepth--;
1199
+ (inElse ? elseTokens : bodyTokens).push(tokens[i]);
1200
+ } else if (tag === "endfor" && forDepth === 0) {
1201
+ i++;
1202
+ break;
1203
+ } else if (tag === "if") {
1204
+ ifDepth++;
1205
+ (inElse ? elseTokens : bodyTokens).push(tokens[i]);
1206
+ } else if (tag === "endif") {
1207
+ ifDepth--;
1208
+ (inElse ? elseTokens : bodyTokens).push(tokens[i]);
1209
+ } else if (tag === "else" && forDepth === 0 && ifDepth === 0) {
1210
+ inElse = true;
1211
+ } else {
1212
+ (inElse ? elseTokens : bodyTokens).push(tokens[i]);
1213
+ }
1214
+ } else {
1215
+ (inElse ? elseTokens : bodyTokens).push(tokens[i]);
1216
+ }
1217
+ i++;
1218
+ }
1219
+
1220
+ // Evaluate iterable
1221
+ const iterable = evalExpr(iterableExpr, context);
1222
+
1223
+ if (!iterable || (Array.isArray(iterable) && iterable.length === 0) ||
1224
+ (typeof iterable === "object" && !Array.isArray(iterable) && Object.keys(iterable as object).length === 0)) {
1225
+ if (elseTokens.length > 0) {
1226
+ return [this.renderTokens([...elseTokens], context), i];
1227
+ }
1228
+ return ["", i];
1229
+ }
1230
+
1231
+ // Iterate
1232
+ const output: string[] = [];
1233
+ const isDict = typeof iterable === "object" && !Array.isArray(iterable);
1234
+ const items = isDict
1235
+ ? Object.entries(iterable as Record<string, unknown>)
1236
+ : Array.isArray(iterable) ? iterable : [];
1237
+ const total = items.length;
1238
+
1239
+ for (let idx = 0; idx < total; idx++) {
1240
+ const item = items[idx];
1241
+ const loopCtx: Record<string, unknown> = { ...context };
1242
+ loopCtx.loop = {
1243
+ index: idx + 1,
1244
+ index0: idx,
1245
+ first: idx === 0,
1246
+ last: idx === total - 1,
1247
+ length: total,
1248
+ revindex: total - idx,
1249
+ revindex0: total - idx - 1,
1250
+ even: (idx + 1) % 2 === 0,
1251
+ odd: (idx + 1) % 2 !== 0,
1252
+ };
1253
+
1254
+ if (isDict) {
1255
+ const [key, value] = item as [string, unknown];
1256
+ if (var2) {
1257
+ loopCtx[var1] = key;
1258
+ loopCtx[var2] = value;
1259
+ } else {
1260
+ loopCtx[var1] = key;
1261
+ }
1262
+ } else {
1263
+ if (var2) {
1264
+ loopCtx[var1] = idx;
1265
+ loopCtx[var2] = item;
1266
+ } else {
1267
+ loopCtx[var1] = item;
1268
+ }
1269
+ }
1270
+
1271
+ output.push(this.renderTokens([...bodyTokens], loopCtx));
1272
+ }
1273
+
1274
+ return [output.join(""), i];
1275
+ }
1276
+
1277
+ private handleSet(content: string, context: Record<string, unknown>): void {
1278
+ const m = content.match(/^set\s+(\w+)\s*=\s*([\s\S]+)/);
1279
+ if (m) {
1280
+ const name = m[1];
1281
+ const expr = m[2].trim();
1282
+ context[name] = evalExpr(expr, context);
1283
+ }
1284
+ }
1285
+
1286
+ private handleInclude(content: string, context: Record<string, unknown>): string {
1287
+ const ignoreMissing = content.includes("ignore missing");
1288
+ const cleanContent = content.replace("ignore missing", "").trim();
1289
+
1290
+ const m = cleanContent.match(/^include\s+["'](.+?)["'](?:\s+with\s+(.+))?/);
1291
+ if (!m) return "";
1292
+
1293
+ const filename = m[1];
1294
+ const withExpr = m[2];
1295
+
1296
+ let source: string;
1297
+ try {
1298
+ source = this.load(filename);
1299
+ } catch {
1300
+ if (ignoreMissing) return "";
1301
+ throw new Error(`Template not found: ${join(this.templateDir, filename)}`);
1302
+ }
1303
+
1304
+ const incContext = { ...context };
1305
+ if (withExpr) {
1306
+ const extra = evalExpr(withExpr, context);
1307
+ if (typeof extra === "object" && extra !== null) {
1308
+ Object.assign(incContext, extra);
1309
+ }
1310
+ }
1311
+
1312
+ return this.execute(source, incContext);
1313
+ }
1314
+
1315
+ private handleMacro(tokens: Token[], start: number, context: Record<string, unknown>): number {
1316
+ const [content] = stripTag(tokens[start][1]);
1317
+ const m = content.match(/^macro\s+(\w+)\s*\(([^)]*)\)/);
1318
+ if (!m) {
1319
+ // Skip to endmacro
1320
+ let i = start + 1;
1321
+ while (i < tokens.length) {
1322
+ if (tokens[i][0] === "BLOCK" && tokens[i][1].includes("endmacro")) {
1323
+ return i + 1;
1324
+ }
1325
+ i++;
1326
+ }
1327
+ return i;
1328
+ }
1329
+
1330
+ const macroName = m[1];
1331
+ const paramNames = m[2].split(",").map(p => p.trim()).filter(Boolean);
1332
+
1333
+ // Collect body tokens
1334
+ const bodyTokens: Token[] = [];
1335
+ let i = start + 1;
1336
+ while (i < tokens.length) {
1337
+ if (tokens[i][0] === "BLOCK" && tokens[i][1].includes("endmacro")) {
1338
+ i++;
1339
+ break;
1340
+ }
1341
+ bodyTokens.push(tokens[i]);
1342
+ i++;
1343
+ }
1344
+
1345
+ // Register macro as callable
1346
+ const engine = this;
1347
+ const capturedContext = { ...context };
1348
+ context[macroName] = (...args: unknown[]) => {
1349
+ const macroCtx: Record<string, unknown> = { ...capturedContext };
1350
+ for (let pi = 0; pi < paramNames.length; pi++) {
1351
+ macroCtx[paramNames[pi]] = pi < args.length ? args[pi] : null;
1352
+ }
1353
+ return new SafeString(engine.renderTokens([...bodyTokens], macroCtx));
1354
+ };
1355
+
1356
+ return i;
1357
+ }
1358
+
1359
+ private handleFromImport(content: string, context: Record<string, unknown>): void {
1360
+ const m = content.match(/^from\s+["'](.+?)["']\s+import\s+(.+)/);
1361
+ if (!m) return;
1362
+
1363
+ const filename = m[1];
1364
+ const names = m[2].split(",").map(n => n.trim()).filter(Boolean);
1365
+
1366
+ const source = this.load(filename);
1367
+ const tokens = tokenize(source);
1368
+
1369
+ let i = 0;
1370
+ while (i < tokens.length) {
1371
+ const [ttype, raw] = tokens[i];
1372
+ if (ttype === "BLOCK") {
1373
+ const [tagContent] = stripTag(raw);
1374
+ const tag = tagContent.split(/\s+/)[0] || "";
1375
+ if (tag === "macro") {
1376
+ const macroM = tagContent.match(/^macro\s+(\w+)\s*\(([^)]*)\)/);
1377
+ if (macroM && names.includes(macroM[1])) {
1378
+ const macroName = macroM[1];
1379
+ const paramNames = macroM[2].split(",").map(p => p.trim()).filter(Boolean);
1380
+
1381
+ const bodyTokens: Token[] = [];
1382
+ i++;
1383
+ while (i < tokens.length) {
1384
+ if (tokens[i][0] === "BLOCK" && tokens[i][1].includes("endmacro")) {
1385
+ i++;
1386
+ break;
1387
+ }
1388
+ bodyTokens.push(tokens[i]);
1389
+ i++;
1390
+ }
1391
+
1392
+ // Create closure with its own copy of captured values
1393
+ const capturedBody = [...bodyTokens];
1394
+ const capturedParams = [...paramNames];
1395
+ const capturedCtx = { ...context };
1396
+ const engine = this;
1397
+
1398
+ context[macroName] = (...args: unknown[]) => {
1399
+ const macroCtx: Record<string, unknown> = { ...capturedCtx };
1400
+ for (let pi = 0; pi < capturedParams.length; pi++) {
1401
+ macroCtx[capturedParams[pi]] = pi < args.length ? args[pi] : null;
1402
+ }
1403
+ return new SafeString(engine.renderTokens([...capturedBody], macroCtx));
1404
+ };
1405
+ continue;
1406
+ }
1407
+ }
1408
+ }
1409
+ i++;
1410
+ }
1411
+ }
1412
+
1413
+ private handleCache(tokens: Token[], start: number, context: Record<string, unknown>): [string, number] {
1414
+ const [content] = stripTag(tokens[start][1]);
1415
+ const m = content.match(/^cache\s+["'](.+?)["']\s*(\d+)?/);
1416
+ const cacheKey = m ? m[1] : "default";
1417
+ const ttl = m && m[2] ? parseInt(m[2], 10) : 60;
1418
+
1419
+ // Check cache
1420
+ const cached = this.fragmentCache.get(cacheKey);
1421
+ if (cached) {
1422
+ const [htmlContent, expiresAt] = cached;
1423
+ if (Date.now() < expiresAt) {
1424
+ // Skip to endcache
1425
+ let i = start + 1;
1426
+ let depth = 0;
1427
+ while (i < tokens.length) {
1428
+ if (tokens[i][0] === "BLOCK") {
1429
+ const [tagContent] = stripTag(tokens[i][1]);
1430
+ const tag = tagContent.split(/\s+/)[0] || "";
1431
+ if (tag === "cache") depth++;
1432
+ else if (tag === "endcache") {
1433
+ if (depth === 0) return [htmlContent, i + 1];
1434
+ depth--;
1435
+ }
1436
+ }
1437
+ i++;
1438
+ }
1439
+ return [htmlContent, i];
1440
+ }
1441
+ }
1442
+
1443
+ // Collect body tokens
1444
+ const bodyTokens: Token[] = [];
1445
+ let i = start + 1;
1446
+ let depth = 0;
1447
+ while (i < tokens.length) {
1448
+ if (tokens[i][0] === "BLOCK") {
1449
+ const [tagContent] = stripTag(tokens[i][1]);
1450
+ const tag = tagContent.split(/\s+/)[0] || "";
1451
+ if (tag === "cache") {
1452
+ depth++;
1453
+ bodyTokens.push(tokens[i]);
1454
+ } else if (tag === "endcache") {
1455
+ if (depth === 0) {
1456
+ i++;
1457
+ break;
1458
+ }
1459
+ depth--;
1460
+ bodyTokens.push(tokens[i]);
1461
+ } else {
1462
+ bodyTokens.push(tokens[i]);
1463
+ }
1464
+ } else {
1465
+ bodyTokens.push(tokens[i]);
1466
+ }
1467
+ i++;
1468
+ }
1469
+
1470
+ // Render and cache
1471
+ const rendered = this.renderTokens([...bodyTokens], context);
1472
+ this.fragmentCache.set(cacheKey, [rendered, Date.now() + ttl * 1000]);
1473
+ return [rendered, i];
1474
+ }
1475
+ }