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.
- package/BENCHMARK_REPORT.md +96 -0
- package/CARBONAH.md +140 -0
- package/CLAUDE.md +599 -0
- package/COMPARISON.md +194 -0
- package/README.md +595 -0
- package/package.json +59 -0
- package/packages/cli/src/bin.ts +110 -0
- package/packages/cli/src/commands/init.ts +194 -0
- package/packages/cli/src/commands/migrate.ts +96 -0
- package/packages/cli/src/commands/migrateCreate.ts +59 -0
- package/packages/cli/src/commands/routes.ts +61 -0
- package/packages/cli/src/commands/serve.ts +58 -0
- package/packages/cli/src/commands/test.ts +83 -0
- package/packages/core/gallery/auth/meta.json +1 -0
- package/packages/core/gallery/auth/src/routes/api/gallery/auth/login/post.ts +22 -0
- package/packages/core/gallery/auth/src/routes/api/gallery/auth/verify/get.ts +16 -0
- package/packages/core/gallery/auth/src/routes/gallery/auth/get.ts +97 -0
- package/packages/core/gallery/database/meta.json +1 -0
- package/packages/core/gallery/database/src/routes/api/gallery/db/notes/get.ts +13 -0
- package/packages/core/gallery/database/src/routes/api/gallery/db/notes/post.ts +17 -0
- package/packages/core/gallery/database/src/routes/api/gallery/db/tables/get.ts +23 -0
- package/packages/core/gallery/error-overlay/meta.json +1 -0
- package/packages/core/gallery/error-overlay/src/routes/api/gallery/crash/get.ts +17 -0
- package/packages/core/gallery/orm/meta.json +1 -0
- package/packages/core/gallery/orm/src/routes/api/gallery/products/get.ts +12 -0
- package/packages/core/gallery/orm/src/routes/api/gallery/products/post.ts +7 -0
- package/packages/core/gallery/queue/meta.json +1 -0
- package/packages/core/gallery/queue/src/routes/api/gallery/queue/produce/post.ts +16 -0
- package/packages/core/gallery/queue/src/routes/api/gallery/queue/status/get.ts +10 -0
- package/packages/core/gallery/rest-api/meta.json +1 -0
- package/packages/core/gallery/rest-api/src/routes/api/gallery/hello/get.ts +6 -0
- package/packages/core/gallery/rest-api/src/routes/api/gallery/hello/post.ts +7 -0
- package/packages/core/gallery/templates/meta.json +1 -0
- package/packages/core/gallery/templates/src/routes/gallery/page/get.ts +15 -0
- package/packages/core/gallery/templates/src/templates/gallery_page.twig +257 -0
- package/packages/core/public/css/tina4.css +2463 -0
- package/packages/core/public/css/tina4.min.css +1 -0
- package/packages/core/public/favicon.ico +0 -0
- package/packages/core/public/images/logo.svg +5 -0
- package/packages/core/public/images/tina4-logo-icon.webp +0 -0
- package/packages/core/public/js/frond.min.js +420 -0
- package/packages/core/public/js/tina4-dev-admin.min.js +327 -0
- package/packages/core/public/js/tina4.min.js +93 -0
- package/packages/core/public/swagger/index.html +90 -0
- package/packages/core/public/swagger/oauth2-redirect.html +63 -0
- package/packages/core/src/ai.ts +359 -0
- package/packages/core/src/api.ts +248 -0
- package/packages/core/src/auth.ts +287 -0
- package/packages/core/src/cache.ts +121 -0
- package/packages/core/src/constants.ts +48 -0
- package/packages/core/src/container.ts +90 -0
- package/packages/core/src/devAdmin.ts +2024 -0
- package/packages/core/src/devMailbox.ts +316 -0
- package/packages/core/src/dotenv.ts +172 -0
- package/packages/core/src/errorOverlay.test.ts +122 -0
- package/packages/core/src/errorOverlay.ts +278 -0
- package/packages/core/src/events.ts +112 -0
- package/packages/core/src/fakeData.ts +309 -0
- package/packages/core/src/graphql.ts +812 -0
- package/packages/core/src/health.ts +31 -0
- package/packages/core/src/htmlElement.ts +172 -0
- package/packages/core/src/i18n.ts +136 -0
- package/packages/core/src/index.ts +88 -0
- package/packages/core/src/logger.ts +226 -0
- package/packages/core/src/messenger.ts +822 -0
- package/packages/core/src/middleware.ts +138 -0
- package/packages/core/src/queue.ts +481 -0
- package/packages/core/src/queueBackends/kafkaBackend.ts +348 -0
- package/packages/core/src/queueBackends/rabbitmqBackend.ts +479 -0
- package/packages/core/src/rateLimiter.ts +107 -0
- package/packages/core/src/request.ts +189 -0
- package/packages/core/src/response.ts +146 -0
- package/packages/core/src/routeDiscovery.ts +87 -0
- package/packages/core/src/router.ts +398 -0
- package/packages/core/src/scss.ts +366 -0
- package/packages/core/src/server.ts +610 -0
- package/packages/core/src/service.ts +380 -0
- package/packages/core/src/session.ts +480 -0
- package/packages/core/src/sessionHandlers/mongoHandler.ts +286 -0
- package/packages/core/src/sessionHandlers/valkeyHandler.ts +184 -0
- package/packages/core/src/static.ts +58 -0
- package/packages/core/src/testing.ts +233 -0
- package/packages/core/src/types.ts +98 -0
- package/packages/core/src/watcher.ts +37 -0
- package/packages/core/src/websocket.ts +408 -0
- package/packages/core/src/wsdl.ts +546 -0
- package/packages/core/templates/errors/302.twig +14 -0
- package/packages/core/templates/errors/401.twig +9 -0
- package/packages/core/templates/errors/403.twig +29 -0
- package/packages/core/templates/errors/404.twig +29 -0
- package/packages/core/templates/errors/500.twig +38 -0
- package/packages/core/templates/errors/502.twig +9 -0
- package/packages/core/templates/errors/503.twig +12 -0
- package/packages/core/templates/errors/base.twig +37 -0
- package/packages/frond/src/engine.ts +1475 -0
- package/packages/frond/src/index.ts +2 -0
- package/packages/orm/src/adapters/firebird.ts +455 -0
- package/packages/orm/src/adapters/mssql.ts +440 -0
- package/packages/orm/src/adapters/mysql.ts +355 -0
- package/packages/orm/src/adapters/postgres.ts +362 -0
- package/packages/orm/src/adapters/sqlite.ts +270 -0
- package/packages/orm/src/autoCrud.ts +231 -0
- package/packages/orm/src/baseModel.ts +536 -0
- package/packages/orm/src/database.ts +321 -0
- package/packages/orm/src/fakeData.ts +118 -0
- package/packages/orm/src/index.ts +49 -0
- package/packages/orm/src/migration.ts +392 -0
- package/packages/orm/src/model.ts +56 -0
- package/packages/orm/src/query.ts +113 -0
- package/packages/orm/src/seeder.ts +120 -0
- package/packages/orm/src/sqlTranslation.ts +272 -0
- package/packages/orm/src/types.ts +110 -0
- package/packages/orm/src/validation.ts +93 -0
- package/packages/swagger/src/generator.ts +189 -0
- package/packages/swagger/src/index.ts +2 -0
- package/packages/swagger/src/ui.ts +48 -0
- package/skills/tina4-developer.skill +0 -0
- package/skills/tina4-js.skill +0 -0
- 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, "&")
|
|
585
|
+
.replace(/</g, "<")
|
|
586
|
+
.replace(/>/g, ">")
|
|
587
|
+
.replace(/"/g, """)
|
|
588
|
+
.replace(/'/g, "'");
|
|
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, "&").replace(/"/g, """).replace(/</g, "<").replace(/>/g, ">");
|
|
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
|
+
}
|