tina4-nodejs 3.13.83 → 3.13.84
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/CLAUDE.md +2 -2
- package/package.json +11 -4
- package/packages/cli/dist/bin.js +40866 -0
- package/packages/cli/src/bin.ts +66 -10
- package/packages/cli/src/commands/init.ts +1 -1
- package/packages/core/dist/index.js +38006 -0
- package/packages/frond/dist/index.js +2543 -0
- package/packages/orm/dist/index.js +37850 -0
- package/packages/swagger/dist/index.js +445 -0
- package/packages/cli/bin/tina4nodejs +0 -10
|
@@ -0,0 +1,2543 @@
|
|
|
1
|
+
// src/engine.ts
|
|
2
|
+
import { createHash, createHmac, randomBytes } from "node:crypto";
|
|
3
|
+
import { readFileSync, existsSync, statSync } from "node:fs";
|
|
4
|
+
import { join, resolve } from "node:path";
|
|
5
|
+
var SafeString = class {
|
|
6
|
+
constructor(value) {
|
|
7
|
+
this.value = value;
|
|
8
|
+
}
|
|
9
|
+
toString() {
|
|
10
|
+
return this.value;
|
|
11
|
+
}
|
|
12
|
+
};
|
|
13
|
+
function inspectValue(value, seen = /* @__PURE__ */ new WeakSet(), depth = 0) {
|
|
14
|
+
if (value === null) return "null";
|
|
15
|
+
if (value === void 0) return "undefined";
|
|
16
|
+
if (typeof value === "string") return JSON.stringify(value);
|
|
17
|
+
if (typeof value === "number" || typeof value === "boolean") return String(value);
|
|
18
|
+
if (typeof value === "bigint") return `${value.toString()}n`;
|
|
19
|
+
if (typeof value === "symbol") return value.toString();
|
|
20
|
+
if (typeof value === "function") {
|
|
21
|
+
const name = value.name || "(anonymous)";
|
|
22
|
+
return `[Function: ${name}]`;
|
|
23
|
+
}
|
|
24
|
+
const obj = value;
|
|
25
|
+
if (seen.has(obj)) return "[Circular]";
|
|
26
|
+
seen.add(obj);
|
|
27
|
+
if (depth > 8) return "[...]";
|
|
28
|
+
try {
|
|
29
|
+
if (obj instanceof Date) {
|
|
30
|
+
return `Date(${obj.toISOString()})`;
|
|
31
|
+
}
|
|
32
|
+
if (obj instanceof RegExp) {
|
|
33
|
+
return obj.toString();
|
|
34
|
+
}
|
|
35
|
+
if (obj instanceof Error) {
|
|
36
|
+
return `${obj.constructor.name}(${JSON.stringify(obj.message)})`;
|
|
37
|
+
}
|
|
38
|
+
if (obj instanceof Map) {
|
|
39
|
+
if (obj.size === 0) return "Map(0) {}";
|
|
40
|
+
const entries = [];
|
|
41
|
+
for (const [k, v] of obj) {
|
|
42
|
+
entries.push(`${inspectValue(k, seen, depth + 1)} => ${inspectValue(v, seen, depth + 1)}`);
|
|
43
|
+
}
|
|
44
|
+
return `Map(${obj.size}) { ${entries.join(", ")} }`;
|
|
45
|
+
}
|
|
46
|
+
if (obj instanceof Set) {
|
|
47
|
+
if (obj.size === 0) return "Set(0) {}";
|
|
48
|
+
const items = [];
|
|
49
|
+
for (const v of obj) {
|
|
50
|
+
items.push(inspectValue(v, seen, depth + 1));
|
|
51
|
+
}
|
|
52
|
+
return `Set(${obj.size}) { ${items.join(", ")} }`;
|
|
53
|
+
}
|
|
54
|
+
if (Array.isArray(obj)) {
|
|
55
|
+
if (obj.length === 0) return "[]";
|
|
56
|
+
const items = obj.map((v) => inspectValue(v, seen, depth + 1));
|
|
57
|
+
return `[${items.join(", ")}]`;
|
|
58
|
+
}
|
|
59
|
+
const keys = Object.keys(obj);
|
|
60
|
+
const className = obj.constructor && obj.constructor.name !== "Object" ? `${obj.constructor.name} ` : "";
|
|
61
|
+
if (keys.length === 0) return `${className}{}`;
|
|
62
|
+
const props = keys.map((k) => {
|
|
63
|
+
const v = obj[k];
|
|
64
|
+
return `${k}: ${inspectValue(v, seen, depth + 1)}`;
|
|
65
|
+
});
|
|
66
|
+
return `${className}{ ${props.join(", ")} }`;
|
|
67
|
+
} finally {
|
|
68
|
+
seen.delete(obj);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
function renderDump(value) {
|
|
72
|
+
const debugMode = (process.env.TINA4_DEBUG ?? "").toLowerCase() === "true";
|
|
73
|
+
if (!debugMode) {
|
|
74
|
+
return new SafeString("");
|
|
75
|
+
}
|
|
76
|
+
const dumped = inspectValue(value);
|
|
77
|
+
const escaped = dumped.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
78
|
+
return new SafeString(`<pre>${escaped}</pre>`);
|
|
79
|
+
}
|
|
80
|
+
var NUMERIC_RE = /^-?\d+(\.\d+)?$/;
|
|
81
|
+
var METHOD_CALL_RE = /^(\w+)\s*\(([\s\S]*)?\)$/;
|
|
82
|
+
var FN_CALL_RE = /^([\w.]+)\s*\(([\s\S]*)?\)$/;
|
|
83
|
+
var IS_NOT_RE = /^(.+?)\s+is\s+not\s+(\w+)(.*)$/;
|
|
84
|
+
var IS_RE = /^(.+?)\s+is\s+(\w+)(.*)$/;
|
|
85
|
+
var NOT_IN_RE = /^(.+?)\s+not\s+in\s+(.+)$/;
|
|
86
|
+
var IN_RE = /^(.+?)\s+in\s+(.+)$/;
|
|
87
|
+
var DIVISIBLE_BY_RE = /\s*by\s*\(\s*(\d+)\s*\)/;
|
|
88
|
+
var FILTER_WITH_ARGS_RE = /^(\w+)\s*\(([\s\S]*)\)$/;
|
|
89
|
+
var FILTER_COMPARISON_RE = /^(\w+)\s*(!=|==|>=|<=|>|<)\s*(.+)$/;
|
|
90
|
+
var TITLE_WORD_RE = /\b\w/g;
|
|
91
|
+
var STRIP_TAGS_RE = /<[^>]+>/g;
|
|
92
|
+
var FORMAT_RE = /%%|%([-+ 0]*)(\d+)?(?:\.(\d+))?([sdifFeEgGxXob])/g;
|
|
93
|
+
var LEADING_WS_RE = /^\s+/;
|
|
94
|
+
var TRAILING_WS_RE = /\s+$/;
|
|
95
|
+
var THOUSANDS_RE = /\B(?=(\d{3})+(?!\d))/g;
|
|
96
|
+
var LIVE_RE = /^live\s+["']([^"']+)["']([\s\S]*)$/;
|
|
97
|
+
var LIVE_WS_RE = /ws\s+["']([^"']+)["']/;
|
|
98
|
+
var LIVE_SRC_RE = /src\s+["']([^"']+)["']/;
|
|
99
|
+
function liveAttr(value) {
|
|
100
|
+
return String(value).replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<").replace(/>/g, ">");
|
|
101
|
+
}
|
|
102
|
+
var filterChainCache = /* @__PURE__ */ new Map();
|
|
103
|
+
var pathParseCache = /* @__PURE__ */ new Map();
|
|
104
|
+
var TOKEN_RE = /(\{%-?\s*[\s\S]*?\s*-?%\})|(\{\{-?\s*[\s\S]*?\s*-?\}\})|(\{#[\s\S]*?#\})/g;
|
|
105
|
+
var RAW_BLOCK_RE = /\{%-?\s*raw\s*-?%\}([\s\S]*?)\{%-?\s*endraw\s*-?%\}/g;
|
|
106
|
+
function tokenize(source) {
|
|
107
|
+
const rawBlocks = [];
|
|
108
|
+
source = source.replace(RAW_BLOCK_RE, (_match, content) => {
|
|
109
|
+
const idx = rawBlocks.length;
|
|
110
|
+
rawBlocks.push(content);
|
|
111
|
+
return `\0RAW_${idx}\0`;
|
|
112
|
+
});
|
|
113
|
+
const tokens = [];
|
|
114
|
+
let pos = 0;
|
|
115
|
+
TOKEN_RE.lastIndex = 0;
|
|
116
|
+
let m;
|
|
117
|
+
while ((m = TOKEN_RE.exec(source)) !== null) {
|
|
118
|
+
const start = m.index;
|
|
119
|
+
if (start > pos) {
|
|
120
|
+
tokens.push(["TEXT", source.slice(pos, start)]);
|
|
121
|
+
}
|
|
122
|
+
const raw = m[0];
|
|
123
|
+
if (raw.startsWith("{#")) {
|
|
124
|
+
tokens.push(["COMMENT", raw]);
|
|
125
|
+
} else if (raw.startsWith("{{")) {
|
|
126
|
+
tokens.push(["VAR", raw]);
|
|
127
|
+
} else if (raw.startsWith("{%")) {
|
|
128
|
+
tokens.push(["BLOCK", raw]);
|
|
129
|
+
}
|
|
130
|
+
pos = m.index + raw.length;
|
|
131
|
+
}
|
|
132
|
+
if (pos < source.length) {
|
|
133
|
+
tokens.push(["TEXT", source.slice(pos)]);
|
|
134
|
+
}
|
|
135
|
+
if (rawBlocks.length > 0) {
|
|
136
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
137
|
+
if (tokens[i][0] === "TEXT" && tokens[i][1].includes("\0RAW_")) {
|
|
138
|
+
let value = tokens[i][1];
|
|
139
|
+
for (let idx = 0; idx < rawBlocks.length; idx++) {
|
|
140
|
+
value = value.replace(`\0RAW_${idx}\0`, rawBlocks[idx]);
|
|
141
|
+
}
|
|
142
|
+
tokens[i] = ["TEXT", value];
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return tokens;
|
|
147
|
+
}
|
|
148
|
+
function stripTag(raw) {
|
|
149
|
+
let inner;
|
|
150
|
+
if (raw.startsWith("{{")) {
|
|
151
|
+
inner = raw.slice(2, -2);
|
|
152
|
+
} else if (raw.startsWith("{%")) {
|
|
153
|
+
inner = raw.slice(2, -2);
|
|
154
|
+
} else {
|
|
155
|
+
inner = raw.slice(2, -2);
|
|
156
|
+
}
|
|
157
|
+
let stripBefore = false;
|
|
158
|
+
let stripAfter = false;
|
|
159
|
+
if (inner.startsWith("-")) {
|
|
160
|
+
stripBefore = true;
|
|
161
|
+
inner = inner.slice(1);
|
|
162
|
+
}
|
|
163
|
+
if (inner.endsWith("-")) {
|
|
164
|
+
stripAfter = true;
|
|
165
|
+
inner = inner.slice(0, -1);
|
|
166
|
+
}
|
|
167
|
+
return [inner.trim(), stripBefore, stripAfter];
|
|
168
|
+
}
|
|
169
|
+
function resolveVar(expr, context) {
|
|
170
|
+
expr = expr.trim();
|
|
171
|
+
if (expr.startsWith('"') && expr.endsWith('"') || expr.startsWith("'") && expr.endsWith("'")) {
|
|
172
|
+
return expr.slice(1, -1);
|
|
173
|
+
}
|
|
174
|
+
if (NUMERIC_RE.test(expr)) {
|
|
175
|
+
return expr.includes(".") ? parseFloat(expr) : parseInt(expr, 10);
|
|
176
|
+
}
|
|
177
|
+
if (expr === "true") return true;
|
|
178
|
+
if (expr === "false") return false;
|
|
179
|
+
if (expr === "null" || expr === "none" || expr === "None") return null;
|
|
180
|
+
if (expr.startsWith("[") && expr.endsWith("]")) {
|
|
181
|
+
const inner = expr.slice(1, -1).trim();
|
|
182
|
+
if (inner === "") return [];
|
|
183
|
+
const items = splitArgs(inner);
|
|
184
|
+
return items.map((item) => evalExpr(item.trim(), context));
|
|
185
|
+
}
|
|
186
|
+
let parts;
|
|
187
|
+
let fromBracket;
|
|
188
|
+
const cachedPath = pathParseCache.get(expr);
|
|
189
|
+
if (cachedPath) {
|
|
190
|
+
[parts, fromBracket] = cachedPath;
|
|
191
|
+
} else {
|
|
192
|
+
parts = [];
|
|
193
|
+
fromBracket = [];
|
|
194
|
+
{
|
|
195
|
+
let current = "";
|
|
196
|
+
let depth = 0;
|
|
197
|
+
let inQuote = null;
|
|
198
|
+
for (let i = 0; i < expr.length; i++) {
|
|
199
|
+
const ch = expr[i];
|
|
200
|
+
if (inQuote) {
|
|
201
|
+
current += ch;
|
|
202
|
+
if (ch === inQuote) inQuote = null;
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
if (ch === '"' || ch === "'") {
|
|
206
|
+
inQuote = ch;
|
|
207
|
+
current += ch;
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
if (ch === "(") {
|
|
211
|
+
depth++;
|
|
212
|
+
current += ch;
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
if (ch === ")") {
|
|
216
|
+
depth--;
|
|
217
|
+
current += ch;
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
if (ch === "." && depth === 0) {
|
|
221
|
+
if (current) {
|
|
222
|
+
parts.push(current);
|
|
223
|
+
fromBracket.push(false);
|
|
224
|
+
}
|
|
225
|
+
current = "";
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
if (ch === "[" && depth === 0) {
|
|
229
|
+
if (current) {
|
|
230
|
+
parts.push(current);
|
|
231
|
+
fromBracket.push(false);
|
|
232
|
+
}
|
|
233
|
+
current = "";
|
|
234
|
+
const end = expr.indexOf("]", i + 1);
|
|
235
|
+
if (end !== -1) {
|
|
236
|
+
parts.push(expr.slice(i + 1, end));
|
|
237
|
+
fromBracket.push(true);
|
|
238
|
+
i = end;
|
|
239
|
+
}
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
current += ch;
|
|
243
|
+
}
|
|
244
|
+
if (current) {
|
|
245
|
+
parts.push(current);
|
|
246
|
+
fromBracket.push(false);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
pathParseCache.set(expr, [parts, fromBracket]);
|
|
250
|
+
}
|
|
251
|
+
let value = context;
|
|
252
|
+
for (let pi = 0; pi < parts.length; pi++) {
|
|
253
|
+
const part = parts[pi];
|
|
254
|
+
const isBracket = fromBracket[pi];
|
|
255
|
+
if (value === null || value === void 0) return null;
|
|
256
|
+
const methodMatch = part.match(METHOD_CALL_RE);
|
|
257
|
+
if (methodMatch) {
|
|
258
|
+
const methodName = methodMatch[1];
|
|
259
|
+
const rawArgs = methodMatch[2] || "";
|
|
260
|
+
if (typeof value === "object" && value !== null && methodName in value) {
|
|
261
|
+
const fn = value[methodName];
|
|
262
|
+
if (typeof fn === "function") {
|
|
263
|
+
if (rawArgs.trim()) {
|
|
264
|
+
const argParts = splitArgs(rawArgs);
|
|
265
|
+
const evalArgs = argParts.map((a) => evalExpr(a.trim(), context));
|
|
266
|
+
value = fn.apply(value, evalArgs);
|
|
267
|
+
} else {
|
|
268
|
+
value = fn.call(value);
|
|
269
|
+
}
|
|
270
|
+
continue;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
return null;
|
|
274
|
+
}
|
|
275
|
+
const isQuotedPart = part.startsWith('"') && part.endsWith('"') || part.startsWith("'") && part.endsWith("'");
|
|
276
|
+
if (isBracket && part.includes(":") && !isQuotedPart) {
|
|
277
|
+
const sliceParts = part.split(":", 2);
|
|
278
|
+
const sStart = sliceParts[0].trim() ? parseInt(String(evalExpr(sliceParts[0].trim(), context)), 10) : void 0;
|
|
279
|
+
const sEnd = sliceParts[1].trim() ? parseInt(String(evalExpr(sliceParts[1].trim(), context)), 10) : void 0;
|
|
280
|
+
if (Array.isArray(value)) {
|
|
281
|
+
value = value.slice(sStart ?? 0, sEnd);
|
|
282
|
+
} else if (typeof value === "string") {
|
|
283
|
+
value = value.slice(sStart ?? 0, sEnd);
|
|
284
|
+
} else {
|
|
285
|
+
return null;
|
|
286
|
+
}
|
|
287
|
+
continue;
|
|
288
|
+
}
|
|
289
|
+
let key;
|
|
290
|
+
if (isQuotedPart) {
|
|
291
|
+
key = part.slice(1, -1);
|
|
292
|
+
} else {
|
|
293
|
+
const asNum = parseInt(part, 10);
|
|
294
|
+
if (!isNaN(asNum) && String(asNum) === part) {
|
|
295
|
+
key = asNum;
|
|
296
|
+
} else if (isBracket) {
|
|
297
|
+
const resolved = evalExpr(part, context);
|
|
298
|
+
key = resolved !== void 0 ? String(resolved) : part;
|
|
299
|
+
} else {
|
|
300
|
+
key = part;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
if (typeof value === "object" && value !== null) {
|
|
304
|
+
if (Array.isArray(value) && typeof key === "number") {
|
|
305
|
+
value = value[key];
|
|
306
|
+
} else if (key in value) {
|
|
307
|
+
const v = value[key];
|
|
308
|
+
value = typeof v === "function" ? v.call(value) : v;
|
|
309
|
+
} else {
|
|
310
|
+
return null;
|
|
311
|
+
}
|
|
312
|
+
} else {
|
|
313
|
+
return null;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
return value;
|
|
317
|
+
}
|
|
318
|
+
function findOutsideQuotes(expr, needle) {
|
|
319
|
+
let inQuote = null;
|
|
320
|
+
let depth = 0;
|
|
321
|
+
let bracketDepth = 0;
|
|
322
|
+
let i = 0;
|
|
323
|
+
while (i <= expr.length - needle.length) {
|
|
324
|
+
const ch = expr[i];
|
|
325
|
+
if ((ch === '"' || ch === "'") && depth === 0 && bracketDepth === 0) {
|
|
326
|
+
if (inQuote === null) {
|
|
327
|
+
inQuote = ch;
|
|
328
|
+
} else if (ch === inQuote) {
|
|
329
|
+
inQuote = null;
|
|
330
|
+
}
|
|
331
|
+
i++;
|
|
332
|
+
continue;
|
|
333
|
+
}
|
|
334
|
+
if (inQuote) {
|
|
335
|
+
i++;
|
|
336
|
+
continue;
|
|
337
|
+
}
|
|
338
|
+
if (ch === "(") depth++;
|
|
339
|
+
else if (ch === ")") depth--;
|
|
340
|
+
else if (ch === "[") bracketDepth++;
|
|
341
|
+
else if (ch === "]") bracketDepth--;
|
|
342
|
+
if (depth === 0 && bracketDepth === 0 && expr.slice(i, i + needle.length) === needle) {
|
|
343
|
+
return i;
|
|
344
|
+
}
|
|
345
|
+
i++;
|
|
346
|
+
}
|
|
347
|
+
return -1;
|
|
348
|
+
}
|
|
349
|
+
function splitOutsideQuotes(expr, sep) {
|
|
350
|
+
const parts = [];
|
|
351
|
+
let currentStart = 0;
|
|
352
|
+
let inQuote = null;
|
|
353
|
+
let depth = 0;
|
|
354
|
+
let bracketDepth = 0;
|
|
355
|
+
let i = 0;
|
|
356
|
+
while (i <= expr.length - sep.length) {
|
|
357
|
+
const ch = expr[i];
|
|
358
|
+
if ((ch === '"' || ch === "'") && depth === 0 && bracketDepth === 0) {
|
|
359
|
+
if (inQuote === null) {
|
|
360
|
+
inQuote = ch;
|
|
361
|
+
} else if (ch === inQuote) {
|
|
362
|
+
inQuote = null;
|
|
363
|
+
}
|
|
364
|
+
i++;
|
|
365
|
+
continue;
|
|
366
|
+
}
|
|
367
|
+
if (inQuote) {
|
|
368
|
+
i++;
|
|
369
|
+
continue;
|
|
370
|
+
}
|
|
371
|
+
if (ch === "(") depth++;
|
|
372
|
+
else if (ch === ")") depth--;
|
|
373
|
+
else if (ch === "[") bracketDepth++;
|
|
374
|
+
else if (ch === "]") bracketDepth--;
|
|
375
|
+
if (depth === 0 && bracketDepth === 0 && expr.slice(i, i + sep.length) === sep) {
|
|
376
|
+
parts.push(expr.slice(currentStart, i));
|
|
377
|
+
i += sep.length;
|
|
378
|
+
currentStart = i;
|
|
379
|
+
continue;
|
|
380
|
+
}
|
|
381
|
+
i++;
|
|
382
|
+
}
|
|
383
|
+
parts.push(expr.slice(currentStart));
|
|
384
|
+
return parts;
|
|
385
|
+
}
|
|
386
|
+
function evalExpr(expr, context) {
|
|
387
|
+
expr = expr.trim();
|
|
388
|
+
if (expr.length >= 2) {
|
|
389
|
+
const q = expr[0];
|
|
390
|
+
if ((q === '"' || q === "'") && expr.endsWith(q) && !expr.slice(1, -1).includes(q)) {
|
|
391
|
+
return expr.slice(1, -1);
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
if (expr.length >= 2 && expr[0] === "(" && expr.endsWith(")")) {
|
|
395
|
+
let depth = 0;
|
|
396
|
+
let matched = true;
|
|
397
|
+
for (let pi = 0; pi < expr.length; pi++) {
|
|
398
|
+
if (expr[pi] === "(") depth++;
|
|
399
|
+
else if (expr[pi] === ")") depth--;
|
|
400
|
+
if (depth === 0 && pi < expr.length - 1) {
|
|
401
|
+
matched = false;
|
|
402
|
+
break;
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
if (matched) {
|
|
406
|
+
return evalExpr(expr.slice(1, -1), context);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
const ternaryIdx = findTernary(expr);
|
|
410
|
+
if (ternaryIdx !== -1) {
|
|
411
|
+
const condPart = expr.slice(0, ternaryIdx).trim();
|
|
412
|
+
const rest = expr.slice(ternaryIdx + 1);
|
|
413
|
+
const colonIdx = findColon(rest);
|
|
414
|
+
if (colonIdx !== -1) {
|
|
415
|
+
const truePart = rest.slice(0, colonIdx).trim();
|
|
416
|
+
const falsePart = rest.slice(colonIdx + 1).trim();
|
|
417
|
+
const cond = evalExpr(condPart, context);
|
|
418
|
+
return cond ? evalExpr(truePart, context) : evalExpr(falsePart, context);
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
const ifIdx = findOutsideQuotes(expr, " if ");
|
|
422
|
+
if (ifIdx >= 0) {
|
|
423
|
+
const elseIdx = findOutsideQuotes(expr, " else ");
|
|
424
|
+
if (elseIdx >= 0 && elseIdx > ifIdx) {
|
|
425
|
+
const valuePart = expr.slice(0, ifIdx).trim();
|
|
426
|
+
const condPart = expr.slice(ifIdx + 4, elseIdx).trim();
|
|
427
|
+
const elsePart = expr.slice(elseIdx + 6).trim();
|
|
428
|
+
const cond = evalExpr(condPart, context);
|
|
429
|
+
return cond ? evalExpr(valuePart, context) : evalExpr(elsePart, context);
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
const qqIdx = findOutsideQuotes(expr, "??");
|
|
433
|
+
if (qqIdx !== -1) {
|
|
434
|
+
const left = expr.slice(0, qqIdx).trim();
|
|
435
|
+
const right = expr.slice(qqIdx + 2).trim();
|
|
436
|
+
const val = evalExpr(left, context);
|
|
437
|
+
if (val === null || val === void 0) {
|
|
438
|
+
return evalExpr(right, context);
|
|
439
|
+
}
|
|
440
|
+
return val;
|
|
441
|
+
}
|
|
442
|
+
if (findOutsideQuotes(expr, "~") >= 0) {
|
|
443
|
+
const parts = splitOutsideQuotes(expr, "~");
|
|
444
|
+
if (parts.length > 1) {
|
|
445
|
+
return parts.map((p) => {
|
|
446
|
+
const v = evalExpr(p.trim(), context);
|
|
447
|
+
return v === null || v === void 0 ? "" : String(v);
|
|
448
|
+
}).join("");
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
for (const op of [" not in ", " in ", " is not ", " is ", "!=", "==", ">=", "<=", ">", "<", " and ", " or ", " not "]) {
|
|
452
|
+
if (findOutsideQuotes(expr, op) >= 0) {
|
|
453
|
+
return evalComparison(expr, context);
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
for (const op of [" + ", " - ", " * ", " // ", " / ", " % ", " ** "]) {
|
|
457
|
+
const pos = findOutsideQuotes(expr, op);
|
|
458
|
+
if (pos >= 0) {
|
|
459
|
+
const left = expr.slice(0, pos).trim();
|
|
460
|
+
const right = expr.slice(pos + op.length).trim();
|
|
461
|
+
const lVal = evalExpr(left, context);
|
|
462
|
+
const rVal = evalExpr(right, context);
|
|
463
|
+
try {
|
|
464
|
+
let lNum = lVal != null ? Number(lVal) : 0;
|
|
465
|
+
let rNum = rVal != null ? Number(rVal) : 0;
|
|
466
|
+
if (isNaN(lNum)) lNum = 0;
|
|
467
|
+
if (isNaN(rNum)) rNum = 0;
|
|
468
|
+
const opS = op.trim();
|
|
469
|
+
const bothInt = Number.isInteger(lNum) && Number.isInteger(rNum) && opS !== "/";
|
|
470
|
+
let result;
|
|
471
|
+
switch (opS) {
|
|
472
|
+
case "+":
|
|
473
|
+
result = lNum + rNum;
|
|
474
|
+
break;
|
|
475
|
+
case "-":
|
|
476
|
+
result = lNum - rNum;
|
|
477
|
+
break;
|
|
478
|
+
case "*":
|
|
479
|
+
result = lNum * rNum;
|
|
480
|
+
break;
|
|
481
|
+
case "//":
|
|
482
|
+
result = rNum !== 0 ? Math.floor(lNum / rNum) : 0;
|
|
483
|
+
break;
|
|
484
|
+
case "/":
|
|
485
|
+
result = rNum !== 0 ? lNum / rNum : 0;
|
|
486
|
+
break;
|
|
487
|
+
case "%":
|
|
488
|
+
result = rNum !== 0 ? lNum % rNum : 0;
|
|
489
|
+
break;
|
|
490
|
+
case "**":
|
|
491
|
+
result = lNum ** rNum;
|
|
492
|
+
break;
|
|
493
|
+
default:
|
|
494
|
+
result = 0;
|
|
495
|
+
}
|
|
496
|
+
return bothInt && Number.isInteger(result) ? result : result;
|
|
497
|
+
} catch {
|
|
498
|
+
return null;
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
if (findOutsideQuotes(expr, "|") >= 0) {
|
|
503
|
+
const [baseExpr, filters] = parseFilterChain(expr);
|
|
504
|
+
if (filters.length > 0) {
|
|
505
|
+
const baseValue = evalExpr(baseExpr, context);
|
|
506
|
+
const applier = context.__frond_apply_filters__;
|
|
507
|
+
if (applier) {
|
|
508
|
+
return applier(baseValue, filters, context);
|
|
509
|
+
}
|
|
510
|
+
let value = baseValue;
|
|
511
|
+
for (const [fname, rawArgs] of filters) {
|
|
512
|
+
if (fname === "raw" || fname === "safe") continue;
|
|
513
|
+
const args = rawArgs.map((a) => a instanceof VarRef ? evalExpr(a.name, context) : a);
|
|
514
|
+
const fn = BUILTIN_FILTERS[fname];
|
|
515
|
+
if (fn) value = fn(value, ...args);
|
|
516
|
+
}
|
|
517
|
+
return value;
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
const fnMatch = expr.match(FN_CALL_RE);
|
|
521
|
+
if (fnMatch) {
|
|
522
|
+
const fnName = fnMatch[1];
|
|
523
|
+
const rawArgs = fnMatch[2] || "";
|
|
524
|
+
if (fnName.includes(".")) {
|
|
525
|
+
const lastDot = fnName.lastIndexOf(".");
|
|
526
|
+
const objPath = fnName.slice(0, lastDot);
|
|
527
|
+
const methodName = fnName.slice(lastDot + 1);
|
|
528
|
+
const obj = resolveVar(objPath, context);
|
|
529
|
+
if (obj && typeof obj === "object" && methodName in obj) {
|
|
530
|
+
const method = obj[methodName];
|
|
531
|
+
if (typeof method === "function") {
|
|
532
|
+
if (rawArgs.trim()) {
|
|
533
|
+
const parts = splitArgs(rawArgs);
|
|
534
|
+
const evalArgs = parts.map((a) => evalExpr(a.trim(), context));
|
|
535
|
+
return method.apply(obj, evalArgs);
|
|
536
|
+
}
|
|
537
|
+
return method.call(obj);
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
} else {
|
|
541
|
+
const fn = context[fnName] ?? resolveVar(fnName, context);
|
|
542
|
+
if (typeof fn === "function") {
|
|
543
|
+
if (rawArgs.trim()) {
|
|
544
|
+
const parts = splitArgs(rawArgs);
|
|
545
|
+
const evalArgs = parts.map((a) => evalExpr(a.trim(), context));
|
|
546
|
+
return fn(...evalArgs);
|
|
547
|
+
}
|
|
548
|
+
return fn();
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
return resolveVar(expr, context);
|
|
553
|
+
}
|
|
554
|
+
function findTernary(expr) {
|
|
555
|
+
let depth = 0;
|
|
556
|
+
let inQuote = null;
|
|
557
|
+
for (let i = 0; i < expr.length; i++) {
|
|
558
|
+
const ch = expr[i];
|
|
559
|
+
if (inQuote) {
|
|
560
|
+
if (ch === inQuote) inQuote = null;
|
|
561
|
+
continue;
|
|
562
|
+
}
|
|
563
|
+
if (ch === '"' || ch === "'") {
|
|
564
|
+
inQuote = ch;
|
|
565
|
+
continue;
|
|
566
|
+
}
|
|
567
|
+
if (ch === "(") {
|
|
568
|
+
depth++;
|
|
569
|
+
continue;
|
|
570
|
+
}
|
|
571
|
+
if (ch === ")") {
|
|
572
|
+
depth--;
|
|
573
|
+
continue;
|
|
574
|
+
}
|
|
575
|
+
if (ch === "?" && depth === 0 && expr[i + 1] !== "?") {
|
|
576
|
+
return i;
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
return -1;
|
|
580
|
+
}
|
|
581
|
+
function findColon(expr) {
|
|
582
|
+
let depth = 0;
|
|
583
|
+
let inQuote = null;
|
|
584
|
+
for (let i = 0; i < expr.length; i++) {
|
|
585
|
+
const ch = expr[i];
|
|
586
|
+
if (inQuote) {
|
|
587
|
+
if (ch === inQuote) inQuote = null;
|
|
588
|
+
continue;
|
|
589
|
+
}
|
|
590
|
+
if (ch === '"' || ch === "'") {
|
|
591
|
+
inQuote = ch;
|
|
592
|
+
continue;
|
|
593
|
+
}
|
|
594
|
+
if (ch === "(") {
|
|
595
|
+
depth++;
|
|
596
|
+
continue;
|
|
597
|
+
}
|
|
598
|
+
if (ch === ")") {
|
|
599
|
+
depth--;
|
|
600
|
+
continue;
|
|
601
|
+
}
|
|
602
|
+
if (ch === ":" && depth === 0) {
|
|
603
|
+
return i;
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
return -1;
|
|
607
|
+
}
|
|
608
|
+
function evalComparison(expr, context, evalFn) {
|
|
609
|
+
const ev = evalFn ?? evalExpr;
|
|
610
|
+
expr = expr.trim();
|
|
611
|
+
if (expr.startsWith("not ")) {
|
|
612
|
+
return !evalComparison(expr.slice(4), context, evalFn);
|
|
613
|
+
}
|
|
614
|
+
const orParts = splitOnKeyword(expr, " or ");
|
|
615
|
+
if (orParts.length > 1) {
|
|
616
|
+
return orParts.some((p) => evalComparison(p, context, evalFn));
|
|
617
|
+
}
|
|
618
|
+
const andParts = splitOnKeyword(expr, " and ");
|
|
619
|
+
if (andParts.length > 1) {
|
|
620
|
+
return andParts.every((p) => evalComparison(p, context, evalFn));
|
|
621
|
+
}
|
|
622
|
+
let m = expr.match(IS_NOT_RE);
|
|
623
|
+
if (m) {
|
|
624
|
+
return !evalTest(m[1].trim(), m[2], m[3].trim(), context, evalFn);
|
|
625
|
+
}
|
|
626
|
+
m = expr.match(IS_RE);
|
|
627
|
+
if (m) {
|
|
628
|
+
return evalTest(m[1].trim(), m[2], m[3].trim(), context, evalFn);
|
|
629
|
+
}
|
|
630
|
+
m = expr.match(NOT_IN_RE);
|
|
631
|
+
if (m) {
|
|
632
|
+
const val2 = ev(m[1].trim(), context);
|
|
633
|
+
const collection = ev(m[2].trim(), context);
|
|
634
|
+
if (Array.isArray(collection)) return !collection.includes(val2);
|
|
635
|
+
if (typeof collection === "string") return !collection.includes(val2);
|
|
636
|
+
return true;
|
|
637
|
+
}
|
|
638
|
+
m = expr.match(IN_RE);
|
|
639
|
+
if (m) {
|
|
640
|
+
const val2 = ev(m[1].trim(), context);
|
|
641
|
+
const collection = ev(m[2].trim(), context);
|
|
642
|
+
if (Array.isArray(collection)) return collection.includes(val2);
|
|
643
|
+
if (typeof collection === "string") return collection.includes(val2);
|
|
644
|
+
return false;
|
|
645
|
+
}
|
|
646
|
+
const ops = [
|
|
647
|
+
["!=", (a, b) => a !== b],
|
|
648
|
+
["==", (a, b) => a == b],
|
|
649
|
+
// intentional loose equality to match Python
|
|
650
|
+
[">=", (a, b) => a >= b],
|
|
651
|
+
["<=", (a, b) => a <= b],
|
|
652
|
+
[">", (a, b) => a > b],
|
|
653
|
+
["<", (a, b) => a < b]
|
|
654
|
+
];
|
|
655
|
+
for (const [op, fn] of ops) {
|
|
656
|
+
const opIdx = expr.indexOf(op);
|
|
657
|
+
if (opIdx !== -1) {
|
|
658
|
+
const left = expr.slice(0, opIdx).trim();
|
|
659
|
+
const right = expr.slice(opIdx + op.length).trim();
|
|
660
|
+
const l = ev(left, context);
|
|
661
|
+
const r = ev(right, context);
|
|
662
|
+
try {
|
|
663
|
+
return fn(l, r);
|
|
664
|
+
} catch {
|
|
665
|
+
return false;
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
const val = ev(expr, context);
|
|
670
|
+
return val !== null && val !== void 0 && val !== false && val !== 0 && val !== "";
|
|
671
|
+
}
|
|
672
|
+
function splitOnKeyword(expr, keyword) {
|
|
673
|
+
const parts = [];
|
|
674
|
+
let current = "";
|
|
675
|
+
let inQuote = null;
|
|
676
|
+
let depth = 0;
|
|
677
|
+
let i = 0;
|
|
678
|
+
while (i < expr.length) {
|
|
679
|
+
const ch = expr[i];
|
|
680
|
+
if (inQuote) {
|
|
681
|
+
current += ch;
|
|
682
|
+
if (ch === inQuote) inQuote = null;
|
|
683
|
+
i++;
|
|
684
|
+
continue;
|
|
685
|
+
}
|
|
686
|
+
if (ch === '"' || ch === "'") {
|
|
687
|
+
inQuote = ch;
|
|
688
|
+
current += ch;
|
|
689
|
+
i++;
|
|
690
|
+
continue;
|
|
691
|
+
}
|
|
692
|
+
if (ch === "(") {
|
|
693
|
+
depth++;
|
|
694
|
+
current += ch;
|
|
695
|
+
i++;
|
|
696
|
+
continue;
|
|
697
|
+
}
|
|
698
|
+
if (ch === ")") {
|
|
699
|
+
depth--;
|
|
700
|
+
current += ch;
|
|
701
|
+
i++;
|
|
702
|
+
continue;
|
|
703
|
+
}
|
|
704
|
+
if (depth === 0 && expr.slice(i, i + keyword.length) === keyword) {
|
|
705
|
+
parts.push(current);
|
|
706
|
+
current = "";
|
|
707
|
+
i += keyword.length;
|
|
708
|
+
continue;
|
|
709
|
+
}
|
|
710
|
+
current += ch;
|
|
711
|
+
i++;
|
|
712
|
+
}
|
|
713
|
+
if (current) parts.push(current);
|
|
714
|
+
return parts;
|
|
715
|
+
}
|
|
716
|
+
function evalTest(valueExpr, testName, args, context, evalFn) {
|
|
717
|
+
const ev = evalFn ?? evalExpr;
|
|
718
|
+
const val = ev(valueExpr, context);
|
|
719
|
+
const customTests = context.__frond_tests__;
|
|
720
|
+
if (customTests && customTests[testName]) {
|
|
721
|
+
return customTests[testName](val);
|
|
722
|
+
}
|
|
723
|
+
const tests = {
|
|
724
|
+
defined: (v) => v !== null && v !== void 0,
|
|
725
|
+
empty: (v) => !v || Array.isArray(v) && v.length === 0 || typeof v === "object" && v !== null && Object.keys(v).length === 0,
|
|
726
|
+
null: (v) => v === null || v === void 0,
|
|
727
|
+
none: (v) => v === null || v === void 0,
|
|
728
|
+
even: (v) => typeof v === "number" && Number.isInteger(v) && v % 2 === 0,
|
|
729
|
+
odd: (v) => typeof v === "number" && Number.isInteger(v) && v % 2 !== 0,
|
|
730
|
+
iterable: (v) => Array.isArray(v) || typeof v === "object" && v !== null,
|
|
731
|
+
string: (v) => typeof v === "string",
|
|
732
|
+
number: (v) => typeof v === "number",
|
|
733
|
+
boolean: (v) => typeof v === "boolean"
|
|
734
|
+
};
|
|
735
|
+
if (testName === "divisible") {
|
|
736
|
+
const dm = args.match(DIVISIBLE_BY_RE);
|
|
737
|
+
if (dm) {
|
|
738
|
+
const n = parseInt(dm[1], 10);
|
|
739
|
+
return typeof val === "number" && Number.isInteger(val) && val % n === 0;
|
|
740
|
+
}
|
|
741
|
+
return false;
|
|
742
|
+
}
|
|
743
|
+
if (testName in tests) {
|
|
744
|
+
return tests[testName](val);
|
|
745
|
+
}
|
|
746
|
+
return false;
|
|
747
|
+
}
|
|
748
|
+
function splitFilterNameAndPath(fname) {
|
|
749
|
+
let depth = 0;
|
|
750
|
+
let inQ = null;
|
|
751
|
+
for (let i = 0; i < fname.length; i++) {
|
|
752
|
+
const ch = fname[i];
|
|
753
|
+
if (inQ !== null) {
|
|
754
|
+
if (ch === inQ && (i === 0 || fname[i - 1] !== "\\")) inQ = null;
|
|
755
|
+
continue;
|
|
756
|
+
}
|
|
757
|
+
if (ch === '"' || ch === "'") {
|
|
758
|
+
inQ = ch;
|
|
759
|
+
continue;
|
|
760
|
+
}
|
|
761
|
+
if (ch === "(" || ch === "[" || ch === "{") {
|
|
762
|
+
depth++;
|
|
763
|
+
continue;
|
|
764
|
+
}
|
|
765
|
+
if (ch === ")" || ch === "]" || ch === "}") {
|
|
766
|
+
depth--;
|
|
767
|
+
continue;
|
|
768
|
+
}
|
|
769
|
+
if (ch === "." && depth === 0) {
|
|
770
|
+
return [fname.slice(0, i), fname.slice(i + 1)];
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
return [fname, ""];
|
|
774
|
+
}
|
|
775
|
+
function parseFilterChain(expr) {
|
|
776
|
+
const cached = filterChainCache.get(expr);
|
|
777
|
+
if (cached) return cached;
|
|
778
|
+
const parts = [];
|
|
779
|
+
let current = "";
|
|
780
|
+
let inQuote = null;
|
|
781
|
+
let depth = 0;
|
|
782
|
+
for (let i = 0; i < expr.length; i++) {
|
|
783
|
+
const ch = expr[i];
|
|
784
|
+
if (inQuote) {
|
|
785
|
+
current += ch;
|
|
786
|
+
if (ch === inQuote) inQuote = null;
|
|
787
|
+
continue;
|
|
788
|
+
}
|
|
789
|
+
if (ch === '"' || ch === "'") {
|
|
790
|
+
inQuote = ch;
|
|
791
|
+
current += ch;
|
|
792
|
+
continue;
|
|
793
|
+
}
|
|
794
|
+
if (ch === "(") {
|
|
795
|
+
depth++;
|
|
796
|
+
current += ch;
|
|
797
|
+
continue;
|
|
798
|
+
}
|
|
799
|
+
if (ch === ")") {
|
|
800
|
+
depth--;
|
|
801
|
+
current += ch;
|
|
802
|
+
continue;
|
|
803
|
+
}
|
|
804
|
+
if (ch === "|" && depth === 0) {
|
|
805
|
+
parts.push(current);
|
|
806
|
+
current = "";
|
|
807
|
+
continue;
|
|
808
|
+
}
|
|
809
|
+
current += ch;
|
|
810
|
+
}
|
|
811
|
+
if (current) parts.push(current);
|
|
812
|
+
const variable = parts[0].trim();
|
|
813
|
+
const filters = [];
|
|
814
|
+
for (let i = 1; i < parts.length; i++) {
|
|
815
|
+
const f = parts[i].trim();
|
|
816
|
+
const fm = f.match(FILTER_WITH_ARGS_RE);
|
|
817
|
+
if (fm) {
|
|
818
|
+
const name = fm[1];
|
|
819
|
+
const rawArgs = fm[2].trim();
|
|
820
|
+
const args = rawArgs ? parseArgs(rawArgs) : [];
|
|
821
|
+
filters.push([name, args]);
|
|
822
|
+
} else {
|
|
823
|
+
filters.push([f.trim(), []]);
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
const result = [variable, filters];
|
|
827
|
+
filterChainCache.set(expr, result);
|
|
828
|
+
return result;
|
|
829
|
+
}
|
|
830
|
+
var VarRef = class {
|
|
831
|
+
constructor(name) {
|
|
832
|
+
this.name = name;
|
|
833
|
+
}
|
|
834
|
+
};
|
|
835
|
+
function coerceArg(t) {
|
|
836
|
+
if (/^-?\d+$/.test(t)) return parseInt(t, 10);
|
|
837
|
+
if (/^-?\d*\.\d+$/.test(t)) return parseFloat(t);
|
|
838
|
+
if (t === "true") return true;
|
|
839
|
+
if (t === "false") return false;
|
|
840
|
+
if (t === "null" || t === "none" || t === "nil") return null;
|
|
841
|
+
if (t.startsWith("{") && t.endsWith("}") || t.startsWith("[") && t.endsWith("]")) {
|
|
842
|
+
try {
|
|
843
|
+
return JSON.parse(t);
|
|
844
|
+
} catch {
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
return new VarRef(t);
|
|
848
|
+
}
|
|
849
|
+
function parseArgs(raw) {
|
|
850
|
+
const args = [];
|
|
851
|
+
let current = "";
|
|
852
|
+
let inQuote = null;
|
|
853
|
+
let wasQuoted = false;
|
|
854
|
+
let depth = 0;
|
|
855
|
+
const flush = () => {
|
|
856
|
+
if (wasQuoted) args.push(current);
|
|
857
|
+
else {
|
|
858
|
+
const t = current.trim();
|
|
859
|
+
if (t !== "") args.push(coerceArg(t));
|
|
860
|
+
}
|
|
861
|
+
current = "";
|
|
862
|
+
wasQuoted = false;
|
|
863
|
+
};
|
|
864
|
+
for (const ch of raw) {
|
|
865
|
+
if (inQuote) {
|
|
866
|
+
if (ch === inQuote) {
|
|
867
|
+
inQuote = null;
|
|
868
|
+
} else {
|
|
869
|
+
current += ch;
|
|
870
|
+
}
|
|
871
|
+
continue;
|
|
872
|
+
}
|
|
873
|
+
if (ch === '"' || ch === "'") {
|
|
874
|
+
inQuote = ch;
|
|
875
|
+
wasQuoted = true;
|
|
876
|
+
if (current.trim() === "") current = "";
|
|
877
|
+
continue;
|
|
878
|
+
}
|
|
879
|
+
if (ch === "(") {
|
|
880
|
+
depth++;
|
|
881
|
+
current += ch;
|
|
882
|
+
continue;
|
|
883
|
+
}
|
|
884
|
+
if (ch === ")") {
|
|
885
|
+
depth--;
|
|
886
|
+
current += ch;
|
|
887
|
+
continue;
|
|
888
|
+
}
|
|
889
|
+
if (ch === "," && depth === 0) {
|
|
890
|
+
flush();
|
|
891
|
+
continue;
|
|
892
|
+
}
|
|
893
|
+
current += ch;
|
|
894
|
+
}
|
|
895
|
+
flush();
|
|
896
|
+
return args;
|
|
897
|
+
}
|
|
898
|
+
function splitArgs(raw) {
|
|
899
|
+
const args = [];
|
|
900
|
+
let current = "";
|
|
901
|
+
let inQuote = null;
|
|
902
|
+
let depth = 0;
|
|
903
|
+
for (const ch of raw) {
|
|
904
|
+
if (inQuote) {
|
|
905
|
+
current += ch;
|
|
906
|
+
if (ch === inQuote) inQuote = null;
|
|
907
|
+
continue;
|
|
908
|
+
}
|
|
909
|
+
if (ch === '"' || ch === "'") {
|
|
910
|
+
inQuote = ch;
|
|
911
|
+
current += ch;
|
|
912
|
+
continue;
|
|
913
|
+
}
|
|
914
|
+
if (ch === "(" || ch === "[") {
|
|
915
|
+
depth++;
|
|
916
|
+
current += ch;
|
|
917
|
+
continue;
|
|
918
|
+
}
|
|
919
|
+
if (ch === ")" || ch === "]") {
|
|
920
|
+
depth--;
|
|
921
|
+
current += ch;
|
|
922
|
+
continue;
|
|
923
|
+
}
|
|
924
|
+
if (ch === "," && depth === 0) {
|
|
925
|
+
args.push(current.trim());
|
|
926
|
+
current = "";
|
|
927
|
+
continue;
|
|
928
|
+
}
|
|
929
|
+
current += ch;
|
|
930
|
+
}
|
|
931
|
+
if (current.trim()) args.push(current.trim());
|
|
932
|
+
return args;
|
|
933
|
+
}
|
|
934
|
+
function htmlEscape(str) {
|
|
935
|
+
return str.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
936
|
+
}
|
|
937
|
+
function dateFilter(value, fmt) {
|
|
938
|
+
let dt;
|
|
939
|
+
if (value instanceof Date) {
|
|
940
|
+
dt = value;
|
|
941
|
+
} else if (typeof value === "string") {
|
|
942
|
+
dt = new Date(value);
|
|
943
|
+
if (isNaN(dt.getTime())) return String(value);
|
|
944
|
+
} else if (typeof value === "number") {
|
|
945
|
+
dt = new Date(value);
|
|
946
|
+
} else {
|
|
947
|
+
return String(value);
|
|
948
|
+
}
|
|
949
|
+
return fmt.replace(/%Y/g, String(dt.getFullYear())).replace(/%m/g, String(dt.getMonth() + 1).padStart(2, "0")).replace(/%d/g, String(dt.getDate()).padStart(2, "0")).replace(/%H/g, String(dt.getHours()).padStart(2, "0")).replace(/%M/g, String(dt.getMinutes()).padStart(2, "0")).replace(/%S/g, String(dt.getSeconds()).padStart(2, "0")).replace(/%I/g, String(dt.getHours() % 12 || 12).padStart(2, "0")).replace(/%p/g, dt.getHours() >= 12 ? "PM" : "AM").replace(/%B/g, dt.toLocaleString("en-US", { month: "long" })).replace(/%b/g, dt.toLocaleString("en-US", { month: "short" })).replace(/%A/g, dt.toLocaleString("en-US", { weekday: "long" })).replace(/%a/g, dt.toLocaleString("en-US", { weekday: "short" }));
|
|
950
|
+
}
|
|
951
|
+
function wordwrap(text, width) {
|
|
952
|
+
const words = text.split(/\s+/);
|
|
953
|
+
const lines = [];
|
|
954
|
+
let current = "";
|
|
955
|
+
for (const word of words) {
|
|
956
|
+
if (current && current.length + 1 + word.length > width) {
|
|
957
|
+
lines.push(current);
|
|
958
|
+
current = word;
|
|
959
|
+
} else {
|
|
960
|
+
current = current ? `${current} ${word}` : word;
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
if (current) lines.push(current);
|
|
964
|
+
return lines.join("\n");
|
|
965
|
+
}
|
|
966
|
+
function numberFormat(value, decimals, decimalPoint = ".", thousandsSep = ",") {
|
|
967
|
+
const num = parseFloat(String(value));
|
|
968
|
+
const fixed = num.toFixed(decimals);
|
|
969
|
+
const [intPart, decPart] = fixed.split(".");
|
|
970
|
+
const formatted = intPart.replace(THOUSANDS_RE, thousandsSep);
|
|
971
|
+
return decPart ? `${formatted}${decimalPoint}${decPart}` : formatted;
|
|
972
|
+
}
|
|
973
|
+
var BUILTIN_FILTERS = {
|
|
974
|
+
upper: (v) => String(v).toUpperCase(),
|
|
975
|
+
lower: (v) => String(v).toLowerCase(),
|
|
976
|
+
capitalize: (v) => {
|
|
977
|
+
const s = String(v);
|
|
978
|
+
return s.charAt(0).toUpperCase() + s.slice(1).toLowerCase();
|
|
979
|
+
},
|
|
980
|
+
title: (v) => String(v).replace(TITLE_WORD_RE, (c) => c.toUpperCase()),
|
|
981
|
+
trim: (v) => String(v).trim(),
|
|
982
|
+
ltrim: (v) => String(v).replace(LEADING_WS_RE, ""),
|
|
983
|
+
rtrim: (v) => String(v).replace(TRAILING_WS_RE, ""),
|
|
984
|
+
length: (v) => {
|
|
985
|
+
if (Array.isArray(v)) return v.length;
|
|
986
|
+
if (typeof v === "string") return v.length;
|
|
987
|
+
if (typeof v === "object" && v !== null) return Object.keys(v).length;
|
|
988
|
+
return 0;
|
|
989
|
+
},
|
|
990
|
+
reverse: (v) => Array.isArray(v) ? [...v].reverse() : String(v).split("").reverse().join(""),
|
|
991
|
+
sort: (v) => Array.isArray(v) ? [...v].sort() : v,
|
|
992
|
+
shuffle: (v) => {
|
|
993
|
+
if (!Array.isArray(v)) return v;
|
|
994
|
+
const arr = [...v];
|
|
995
|
+
for (let i = arr.length - 1; i > 0; i--) {
|
|
996
|
+
const j = Math.floor(Math.random() * (i + 1));
|
|
997
|
+
[arr[i], arr[j]] = [arr[j], arr[i]];
|
|
998
|
+
}
|
|
999
|
+
return arr;
|
|
1000
|
+
},
|
|
1001
|
+
first: (v) => Array.isArray(v) ? v[0] ?? null : null,
|
|
1002
|
+
last: (v) => Array.isArray(v) ? v[v.length - 1] ?? null : null,
|
|
1003
|
+
join: (v, sep) => Array.isArray(v) ? v.map(String).join(sep !== void 0 ? String(sep) : ", ") : String(v),
|
|
1004
|
+
split: (v, sep) => String(v).split(sep !== void 0 ? String(sep) : " "),
|
|
1005
|
+
replace: (v, from, to) => {
|
|
1006
|
+
const s = String(v);
|
|
1007
|
+
if (from !== void 0 && typeof from === "object" && from !== null && !Array.isArray(from)) {
|
|
1008
|
+
let result = s;
|
|
1009
|
+
for (const [old, newVal] of Object.entries(from)) {
|
|
1010
|
+
result = result.split(old).join(String(newVal));
|
|
1011
|
+
}
|
|
1012
|
+
return result;
|
|
1013
|
+
}
|
|
1014
|
+
if (from !== void 0 && to !== void 0) {
|
|
1015
|
+
return s.split(String(from)).join(String(to));
|
|
1016
|
+
}
|
|
1017
|
+
return s;
|
|
1018
|
+
},
|
|
1019
|
+
default: (v, fallback) => v !== null && v !== void 0 && v !== "" ? v : fallback !== void 0 ? fallback : "",
|
|
1020
|
+
raw: (v) => v,
|
|
1021
|
+
safe: (v) => v,
|
|
1022
|
+
escape: (v) => htmlEscape(String(v)),
|
|
1023
|
+
e: (v) => htmlEscape(String(v)),
|
|
1024
|
+
striptags: (v) => String(v).replace(STRIP_TAGS_RE, ""),
|
|
1025
|
+
nl2br: (v) => new SafeString(htmlEscape(String(v)).replace(/\n/g, "<br />\n")),
|
|
1026
|
+
abs: (v) => typeof v === "number" ? Math.abs(v) : v,
|
|
1027
|
+
round: (v, decimals) => {
|
|
1028
|
+
const d = decimals !== void 0 ? parseInt(String(decimals), 10) : 0;
|
|
1029
|
+
return parseFloat(parseFloat(String(v)).toFixed(d));
|
|
1030
|
+
},
|
|
1031
|
+
int: (v) => v ? parseInt(String(v), 10) || 0 : 0,
|
|
1032
|
+
float: (v) => v ? parseFloat(String(v)) || 0 : 0,
|
|
1033
|
+
string: (v) => String(v),
|
|
1034
|
+
json_encode: (v) => JSON.stringify(v),
|
|
1035
|
+
json_decode: (v) => typeof v === "string" ? JSON.parse(v) : v,
|
|
1036
|
+
keys: (v) => typeof v === "object" && v !== null && !Array.isArray(v) ? Object.keys(v) : [],
|
|
1037
|
+
values: (v) => typeof v === "object" && v !== null && !Array.isArray(v) ? Object.values(v) : [],
|
|
1038
|
+
merge: (v, other) => {
|
|
1039
|
+
if (typeof v === "object" && v !== null && !Array.isArray(v) && typeof other === "object" && other !== null) {
|
|
1040
|
+
return { ...v, ...other };
|
|
1041
|
+
}
|
|
1042
|
+
return v;
|
|
1043
|
+
},
|
|
1044
|
+
slice: (v, start, end) => {
|
|
1045
|
+
if (Array.isArray(v) || typeof v === "string") {
|
|
1046
|
+
return v.slice(
|
|
1047
|
+
start !== void 0 ? parseInt(String(start), 10) : 0,
|
|
1048
|
+
end !== void 0 ? parseInt(String(end), 10) : void 0
|
|
1049
|
+
);
|
|
1050
|
+
}
|
|
1051
|
+
return v;
|
|
1052
|
+
},
|
|
1053
|
+
batch: (v, size) => {
|
|
1054
|
+
if (!Array.isArray(v) || size === void 0) return [v];
|
|
1055
|
+
const s = parseInt(String(size), 10);
|
|
1056
|
+
const result = [];
|
|
1057
|
+
for (let i = 0; i < v.length; i += s) {
|
|
1058
|
+
result.push(v.slice(i, i + s));
|
|
1059
|
+
}
|
|
1060
|
+
return result;
|
|
1061
|
+
},
|
|
1062
|
+
unique: (v) => {
|
|
1063
|
+
if (!Array.isArray(v)) return v;
|
|
1064
|
+
return [...new Set(v)];
|
|
1065
|
+
},
|
|
1066
|
+
map: (v, key) => {
|
|
1067
|
+
if (!Array.isArray(v) || key === void 0) return v;
|
|
1068
|
+
return v.map((item) => {
|
|
1069
|
+
if (typeof item === "object" && item !== null) {
|
|
1070
|
+
return item[String(key)] ?? null;
|
|
1071
|
+
}
|
|
1072
|
+
return null;
|
|
1073
|
+
});
|
|
1074
|
+
},
|
|
1075
|
+
filter: (v) => Array.isArray(v) ? v.filter(Boolean) : v,
|
|
1076
|
+
column: (v, key) => {
|
|
1077
|
+
if (!Array.isArray(v) || key === void 0) return v;
|
|
1078
|
+
return v.map((row) => {
|
|
1079
|
+
if (typeof row === "object" && row !== null) {
|
|
1080
|
+
return row[String(key)] ?? null;
|
|
1081
|
+
}
|
|
1082
|
+
return null;
|
|
1083
|
+
});
|
|
1084
|
+
},
|
|
1085
|
+
// Twig signature: number_format(decimals=0, decimalPoint='.', thousandsSep=',').
|
|
1086
|
+
// 1-arg (or no-arg) calls keep the original output; args 2/3 enable localized
|
|
1087
|
+
// formats like `1.234,50`. (#170)
|
|
1088
|
+
number_format: (v, decimals, decimalPoint, thousandsSep) => numberFormat(
|
|
1089
|
+
v,
|
|
1090
|
+
decimals !== void 0 ? parseInt(String(decimals), 10) : 0,
|
|
1091
|
+
decimalPoint !== void 0 ? String(decimalPoint) : ".",
|
|
1092
|
+
thousandsSep !== void 0 ? String(thousandsSep) : ","
|
|
1093
|
+
),
|
|
1094
|
+
date: (v, fmt) => dateFilter(v, fmt !== void 0 ? String(fmt) : "%Y-%m-%d"),
|
|
1095
|
+
truncate: (v, length) => {
|
|
1096
|
+
const s = String(v);
|
|
1097
|
+
if (length !== void 0 && s.length > parseInt(String(length), 10)) {
|
|
1098
|
+
return s.slice(0, parseInt(String(length), 10)) + "...";
|
|
1099
|
+
}
|
|
1100
|
+
return s;
|
|
1101
|
+
},
|
|
1102
|
+
wordwrap: (v, width) => wordwrap(String(v), width !== void 0 ? parseInt(String(width), 10) : 75),
|
|
1103
|
+
slug: (v) => String(v).toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, ""),
|
|
1104
|
+
md5: (v) => createHash("md5").update(String(v)).digest("hex"),
|
|
1105
|
+
sha256: (v) => createHash("sha256").update(String(v)).digest("hex"),
|
|
1106
|
+
base64_encode: (v) => Buffer.isBuffer(v) ? v.toString("base64") : Buffer.from(String(v)).toString("base64"),
|
|
1107
|
+
base64encode: (v) => Buffer.isBuffer(v) ? v.toString("base64") : Buffer.from(String(v)).toString("base64"),
|
|
1108
|
+
base64_decode: (v) => Buffer.from(String(v), "base64").toString("utf-8"),
|
|
1109
|
+
base64decode: (v) => Buffer.from(String(v), "base64").toString("utf-8"),
|
|
1110
|
+
data_uri: (v) => {
|
|
1111
|
+
if (v && typeof v === "object" && "content" in v) {
|
|
1112
|
+
const ct = v.type ?? "application/octet-stream";
|
|
1113
|
+
const raw = Buffer.isBuffer(v.content) ? v.content : Buffer.from(String(v.content));
|
|
1114
|
+
return `data:${ct};base64,${raw.toString("base64")}`;
|
|
1115
|
+
}
|
|
1116
|
+
return String(v);
|
|
1117
|
+
},
|
|
1118
|
+
url_encode: (v) => encodeURIComponent(String(v)),
|
|
1119
|
+
format: (v, ...args) => {
|
|
1120
|
+
let idx = 0;
|
|
1121
|
+
return String(v).replace(FORMAT_RE, (m, flags, width, prec, type) => {
|
|
1122
|
+
if (m === "%%") return "%";
|
|
1123
|
+
const arg = args[idx++];
|
|
1124
|
+
const p = prec !== void 0 ? parseInt(String(prec), 10) : void 0;
|
|
1125
|
+
let out;
|
|
1126
|
+
switch (type) {
|
|
1127
|
+
case "s":
|
|
1128
|
+
out = String(arg ?? "");
|
|
1129
|
+
break;
|
|
1130
|
+
case "d":
|
|
1131
|
+
case "i":
|
|
1132
|
+
out = String(Math.trunc(Number(arg) || 0));
|
|
1133
|
+
break;
|
|
1134
|
+
case "f":
|
|
1135
|
+
case "F":
|
|
1136
|
+
out = Number(arg).toFixed(p !== void 0 ? p : 6);
|
|
1137
|
+
break;
|
|
1138
|
+
case "e":
|
|
1139
|
+
case "E": {
|
|
1140
|
+
out = Number(arg).toExponential(p !== void 0 ? p : 6);
|
|
1141
|
+
if (type === "E") out = out.toUpperCase();
|
|
1142
|
+
break;
|
|
1143
|
+
}
|
|
1144
|
+
case "g":
|
|
1145
|
+
case "G":
|
|
1146
|
+
out = String(Number(arg));
|
|
1147
|
+
break;
|
|
1148
|
+
case "x":
|
|
1149
|
+
out = Math.trunc(Number(arg) || 0).toString(16);
|
|
1150
|
+
break;
|
|
1151
|
+
case "X":
|
|
1152
|
+
out = Math.trunc(Number(arg) || 0).toString(16).toUpperCase();
|
|
1153
|
+
break;
|
|
1154
|
+
case "o":
|
|
1155
|
+
out = Math.trunc(Number(arg) || 0).toString(8);
|
|
1156
|
+
break;
|
|
1157
|
+
case "b":
|
|
1158
|
+
out = Math.trunc(Number(arg) || 0).toString(2);
|
|
1159
|
+
break;
|
|
1160
|
+
default:
|
|
1161
|
+
out = String(arg ?? "");
|
|
1162
|
+
}
|
|
1163
|
+
if (width) {
|
|
1164
|
+
const w = parseInt(String(width), 10);
|
|
1165
|
+
if (out.length < w) {
|
|
1166
|
+
const f = String(flags || "");
|
|
1167
|
+
if (f.includes("-")) out = out.padEnd(w, " ");
|
|
1168
|
+
else out = out.padStart(w, f.includes("0") ? "0" : " ");
|
|
1169
|
+
}
|
|
1170
|
+
}
|
|
1171
|
+
return out;
|
|
1172
|
+
});
|
|
1173
|
+
},
|
|
1174
|
+
dump: (v) => JSON.stringify(v),
|
|
1175
|
+
formToken: (v) => _generateFormToken(v != null ? String(v) : ""),
|
|
1176
|
+
form_token: (v) => _generateFormToken(v != null ? String(v) : ""),
|
|
1177
|
+
formTokenValue: (v) => _generateFormTokenValue(v != null ? String(v) : ""),
|
|
1178
|
+
form_token_value: (v) => _generateFormTokenValue(v != null ? String(v) : ""),
|
|
1179
|
+
tojson: (v, indent) => new SafeString(indent !== void 0 ? JSON.stringify(v, null, parseInt(String(indent), 10)) : JSON.stringify(v)),
|
|
1180
|
+
to_json: (v, indent) => new SafeString(indent !== void 0 ? JSON.stringify(v, null, parseInt(String(indent), 10)) : JSON.stringify(v)),
|
|
1181
|
+
js_escape: (v) => new SafeString(String(v).replace(/\\/g, "\\\\").replace(/'/g, "\\'").replace(/"/g, '\\"').replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"))
|
|
1182
|
+
};
|
|
1183
|
+
function _b64url(data) {
|
|
1184
|
+
return data.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
1185
|
+
}
|
|
1186
|
+
var _formTokenSessionId = "";
|
|
1187
|
+
function _buildFormTokenJwt(descriptor = "") {
|
|
1188
|
+
const secret = process.env.TINA4_SECRET || "tina4-default-secret";
|
|
1189
|
+
const ttlMinutes = parseInt(process.env.TINA4_TOKEN_LIMIT || "60", 10);
|
|
1190
|
+
const header = { alg: "HS256", typ: "JWT" };
|
|
1191
|
+
const now = Math.floor(Date.now() / 1e3);
|
|
1192
|
+
const payload = { type: "form", nonce: randomBytes(8).toString("hex"), iat: now, exp: now + ttlMinutes * 60 };
|
|
1193
|
+
if (descriptor) {
|
|
1194
|
+
if (descriptor.includes("|")) {
|
|
1195
|
+
const [ctx, ref] = descriptor.split("|", 2);
|
|
1196
|
+
payload.context = ctx;
|
|
1197
|
+
payload.ref = ref;
|
|
1198
|
+
} else {
|
|
1199
|
+
payload.context = descriptor;
|
|
1200
|
+
}
|
|
1201
|
+
}
|
|
1202
|
+
if (_formTokenSessionId) {
|
|
1203
|
+
payload.session_id = _formTokenSessionId;
|
|
1204
|
+
}
|
|
1205
|
+
const h = _b64url(Buffer.from(JSON.stringify(header)));
|
|
1206
|
+
const p = _b64url(Buffer.from(JSON.stringify(payload)));
|
|
1207
|
+
const sigInput = `${h}.${p}`;
|
|
1208
|
+
const sig = _b64url(createHmac("sha256", secret).update(sigInput).digest());
|
|
1209
|
+
return `${h}.${p}.${sig}`;
|
|
1210
|
+
}
|
|
1211
|
+
function _generateFormToken(descriptor = "") {
|
|
1212
|
+
const token = _buildFormTokenJwt(descriptor);
|
|
1213
|
+
const escaped = token.replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<").replace(/>/g, ">");
|
|
1214
|
+
return new SafeString(`<input type="hidden" name="formToken" value="${escaped}">`);
|
|
1215
|
+
}
|
|
1216
|
+
function _generateFormTokenValue(descriptor = "") {
|
|
1217
|
+
return new SafeString(_buildFormTokenJwt(descriptor));
|
|
1218
|
+
}
|
|
1219
|
+
var Frond = class _Frond {
|
|
1220
|
+
// ── Class-level registries ──────────────────────────────────
|
|
1221
|
+
// Persist globals, filters, and tests across hot-reloads and at
|
|
1222
|
+
// app-startup before any instance exists. When app.ts calls
|
|
1223
|
+
// ``Frond.addFilter("money", fn)`` once, the class remembers it
|
|
1224
|
+
// and every future ``new Frond()`` inherits it automatically.
|
|
1225
|
+
// Mirrors Python's _class_globals / _class_filters / _class_tests.
|
|
1226
|
+
static classFilters = /* @__PURE__ */ new Map();
|
|
1227
|
+
static classGlobals = /* @__PURE__ */ new Map();
|
|
1228
|
+
static classTests = /* @__PURE__ */ new Map();
|
|
1229
|
+
// ── Live-block registries (server-rendered {% live %} regions) ──
|
|
1230
|
+
// A {% live %} block registers three things when its page first renders:
|
|
1231
|
+
// liveFragments[name] -> the raw body source, re-rendered on every
|
|
1232
|
+
// refresh by GET /__frond/live/<name> or pushLive
|
|
1233
|
+
// liveSources[name] -> an optional data provider (liveSource) that
|
|
1234
|
+
// re-runs with the LIVE request each refresh, so
|
|
1235
|
+
// auth re-applies (IDOR guard)
|
|
1236
|
+
// liveWsPaths[name] -> the ws path a `ws "path"` block declared, the
|
|
1237
|
+
// pushLive broadcast target
|
|
1238
|
+
// Static so they persist across requests in the long-lived server. Mirrors
|
|
1239
|
+
// the Python master's class-level dicts and PHP/Ruby static registries.
|
|
1240
|
+
static liveFragments = /* @__PURE__ */ new Map();
|
|
1241
|
+
static liveSources = /* @__PURE__ */ new Map();
|
|
1242
|
+
static liveWsPaths = /* @__PURE__ */ new Map();
|
|
1243
|
+
// Best-effort WebSocket broadcaster wired by @tina4/core at boot (frond is a
|
|
1244
|
+
// zero-dep leaf package and cannot import core). pushLive calls it if set.
|
|
1245
|
+
static liveBroadcaster = null;
|
|
1246
|
+
/**
|
|
1247
|
+
* Register a custom filter at the class level — available to every
|
|
1248
|
+
* future ``new Frond()`` instance. Callable as ``Frond.addFilter()``
|
|
1249
|
+
* (static) or ``frond.addFilter()`` (instance). See instance method
|
|
1250
|
+
* below for the dual-call semantics.
|
|
1251
|
+
*/
|
|
1252
|
+
static addFilter(name, fn) {
|
|
1253
|
+
_Frond.classFilters.set(name, fn);
|
|
1254
|
+
}
|
|
1255
|
+
/**
|
|
1256
|
+
* Register a global variable available in all templates of every
|
|
1257
|
+
* future instance. Callable as ``Frond.addGlobal()`` (static) or
|
|
1258
|
+
* ``frond.addGlobal()`` (instance).
|
|
1259
|
+
*/
|
|
1260
|
+
static addGlobal(name, value) {
|
|
1261
|
+
_Frond.classGlobals.set(name, value);
|
|
1262
|
+
}
|
|
1263
|
+
/**
|
|
1264
|
+
* Register a custom test (``{% if x is positive %}``) at the class
|
|
1265
|
+
* level. Callable as ``Frond.addTest()`` (static) or
|
|
1266
|
+
* ``frond.addTest()`` (instance).
|
|
1267
|
+
*/
|
|
1268
|
+
static addTest(name, fn) {
|
|
1269
|
+
_Frond.classTests.set(name, fn);
|
|
1270
|
+
}
|
|
1271
|
+
/**
|
|
1272
|
+
* Clear the class-level globals/filters/tests registries.
|
|
1273
|
+
* Useful in test fixtures to prevent leaking state between tests.
|
|
1274
|
+
* Does NOT affect built-in filters or globals — only user-registered
|
|
1275
|
+
* ones via Frond.addFilter / addGlobal / addTest.
|
|
1276
|
+
*/
|
|
1277
|
+
static clearRegistry() {
|
|
1278
|
+
_Frond.classFilters.clear();
|
|
1279
|
+
_Frond.classGlobals.clear();
|
|
1280
|
+
_Frond.classTests.clear();
|
|
1281
|
+
_Frond.liveFragments.clear();
|
|
1282
|
+
_Frond.liveSources.clear();
|
|
1283
|
+
_Frond.liveWsPaths.clear();
|
|
1284
|
+
}
|
|
1285
|
+
templateDir;
|
|
1286
|
+
filters;
|
|
1287
|
+
globals;
|
|
1288
|
+
tests;
|
|
1289
|
+
_sandbox;
|
|
1290
|
+
_allowedFilters;
|
|
1291
|
+
_allowedTags;
|
|
1292
|
+
_allowedVars;
|
|
1293
|
+
fragmentCache;
|
|
1294
|
+
_autoEscape;
|
|
1295
|
+
/**
|
|
1296
|
+
* Token pre-compilation cache for file templates.
|
|
1297
|
+
*
|
|
1298
|
+
* `cachedAt` is captured so the TINA4_TEMPLATE_CACHE_TTL env var can
|
|
1299
|
+
* force re-compilation after N seconds even in production. TTL of 0
|
|
1300
|
+
* means "no time-based invalidation" — entries live forever.
|
|
1301
|
+
*/
|
|
1302
|
+
compiled = /* @__PURE__ */ new Map();
|
|
1303
|
+
/** Token pre-compilation cache for string templates */
|
|
1304
|
+
compiledStrings = /* @__PURE__ */ new Map();
|
|
1305
|
+
/**
|
|
1306
|
+
* Bound reference to `applyFilters`, stashed into the render context as
|
|
1307
|
+
* `__frond_apply_filters__` so the module-level `evalExpr` can resolve a
|
|
1308
|
+
* filter pipe using THIS instance's registered filters. Bound once. (#171)
|
|
1309
|
+
*/
|
|
1310
|
+
_applyFiltersBound = this.applyFilters.bind(this);
|
|
1311
|
+
getTemplateDir() {
|
|
1312
|
+
return this.templateDir;
|
|
1313
|
+
}
|
|
1314
|
+
constructor(templateDir = "src/templates") {
|
|
1315
|
+
this.templateDir = resolve(templateDir);
|
|
1316
|
+
this.filters = { ...BUILTIN_FILTERS };
|
|
1317
|
+
this.globals = {};
|
|
1318
|
+
this.tests = {};
|
|
1319
|
+
this._sandbox = false;
|
|
1320
|
+
this._allowedFilters = null;
|
|
1321
|
+
this._allowedTags = null;
|
|
1322
|
+
this._allowedVars = null;
|
|
1323
|
+
this.fragmentCache = /* @__PURE__ */ new Map();
|
|
1324
|
+
this._autoEscape = true;
|
|
1325
|
+
this.globals.formToken = (descriptor) => _generateFormToken(descriptor || "");
|
|
1326
|
+
this.globals.form_token = (descriptor) => _generateFormToken(descriptor || "");
|
|
1327
|
+
this.globals.formTokenValue = (descriptor) => _generateFormTokenValue(descriptor || "");
|
|
1328
|
+
this.globals.form_token_value = (descriptor) => _generateFormTokenValue(descriptor || "");
|
|
1329
|
+
this.globals.dump = (value) => renderDump(value);
|
|
1330
|
+
for (const [k, v] of _Frond.classFilters) this.filters[k] = v;
|
|
1331
|
+
for (const [k, v] of _Frond.classGlobals) this.globals[k] = v;
|
|
1332
|
+
for (const [k, v] of _Frond.classTests) this.tests[k] = v;
|
|
1333
|
+
}
|
|
1334
|
+
sandbox(filters, tags, vars) {
|
|
1335
|
+
this._sandbox = true;
|
|
1336
|
+
this._allowedFilters = filters ? new Set(filters) : null;
|
|
1337
|
+
this._allowedTags = tags ? new Set(tags) : null;
|
|
1338
|
+
this._allowedVars = vars ? new Set(vars) : null;
|
|
1339
|
+
return this;
|
|
1340
|
+
}
|
|
1341
|
+
unsandbox() {
|
|
1342
|
+
this._sandbox = false;
|
|
1343
|
+
this._allowedFilters = null;
|
|
1344
|
+
this._allowedTags = null;
|
|
1345
|
+
this._allowedVars = null;
|
|
1346
|
+
return this;
|
|
1347
|
+
}
|
|
1348
|
+
/**
|
|
1349
|
+
* Register a custom filter. The filter is persisted at class level
|
|
1350
|
+
* so new instances created by hot-reload inherit it automatically;
|
|
1351
|
+
* the live instance's local filter map also receives the addition
|
|
1352
|
+
* immediately. Mirrors Python's _ClassOrInstanceMethod dual-call.
|
|
1353
|
+
*/
|
|
1354
|
+
addFilter(name, fn) {
|
|
1355
|
+
_Frond.classFilters.set(name, fn);
|
|
1356
|
+
this.filters[name] = fn;
|
|
1357
|
+
}
|
|
1358
|
+
/**
|
|
1359
|
+
* Register a global variable available in all templates. Persisted
|
|
1360
|
+
* at class level — see ``addFilter`` for the dual-call semantics.
|
|
1361
|
+
*/
|
|
1362
|
+
addGlobal(name, value) {
|
|
1363
|
+
_Frond.classGlobals.set(name, value);
|
|
1364
|
+
this.globals[name] = value;
|
|
1365
|
+
}
|
|
1366
|
+
/**
|
|
1367
|
+
* Register a custom test. Persisted at class level — see
|
|
1368
|
+
* ``addFilter`` for the dual-call semantics.
|
|
1369
|
+
*/
|
|
1370
|
+
addTest(name, fn) {
|
|
1371
|
+
_Frond.classTests.set(name, fn);
|
|
1372
|
+
this.tests[name] = fn;
|
|
1373
|
+
}
|
|
1374
|
+
/**
|
|
1375
|
+
* Read the cache TTL in seconds. `TINA4_TEMPLATE_CACHE_TTL=0` (the
|
|
1376
|
+
* default) keeps the existing "cache forever in prod" behaviour — any
|
|
1377
|
+
* positive value invalidates compiled tokens after N seconds, useful
|
|
1378
|
+
* when running long-lived servers behind a slow file sync where mtime
|
|
1379
|
+
* isn't a reliable freshness signal.
|
|
1380
|
+
*/
|
|
1381
|
+
cacheTtlSeconds() {
|
|
1382
|
+
const raw = process.env.TINA4_TEMPLATE_CACHE_TTL;
|
|
1383
|
+
if (raw === void 0) return 0;
|
|
1384
|
+
const n = parseInt(raw, 10);
|
|
1385
|
+
return isNaN(n) || n < 0 ? 0 : n;
|
|
1386
|
+
}
|
|
1387
|
+
render(template, data) {
|
|
1388
|
+
const context = { ...this.globals, ...data || {} };
|
|
1389
|
+
const filePath = join(this.templateDir, template);
|
|
1390
|
+
if (!existsSync(filePath)) {
|
|
1391
|
+
throw new Error(`Template not found: ${filePath}`);
|
|
1392
|
+
}
|
|
1393
|
+
const debugMode = (process.env.TINA4_DEBUG || "").toLowerCase() === "true";
|
|
1394
|
+
const ttlMs = this.cacheTtlSeconds() * 1e3;
|
|
1395
|
+
if (!debugMode) {
|
|
1396
|
+
const cached = this.compiled.get(template);
|
|
1397
|
+
if (cached) {
|
|
1398
|
+
if (ttlMs === 0 || Date.now() - cached.cachedAt < ttlMs) {
|
|
1399
|
+
return this.executeCached(cached.tokens, context);
|
|
1400
|
+
}
|
|
1401
|
+
}
|
|
1402
|
+
}
|
|
1403
|
+
const source = readFileSync(filePath, "utf-8");
|
|
1404
|
+
const mtime = statSync(filePath).mtimeMs;
|
|
1405
|
+
const tokens = tokenize(source);
|
|
1406
|
+
this.compiled.set(template, { tokens, mtime, cachedAt: Date.now() });
|
|
1407
|
+
return this.executeWithSource(source, tokens, context);
|
|
1408
|
+
}
|
|
1409
|
+
renderString(source, data) {
|
|
1410
|
+
const context = { ...this.globals, ...data || {} };
|
|
1411
|
+
const key = createHash("md5").update(source).digest("hex");
|
|
1412
|
+
const ttlMs = this.cacheTtlSeconds() * 1e3;
|
|
1413
|
+
const cached = this.compiledStrings.get(key);
|
|
1414
|
+
if (cached) {
|
|
1415
|
+
if (ttlMs === 0 || Date.now() - cached.cachedAt < ttlMs) {
|
|
1416
|
+
return this.executeCached(cached.tokens, context);
|
|
1417
|
+
}
|
|
1418
|
+
}
|
|
1419
|
+
const tokens = tokenize(source);
|
|
1420
|
+
this.compiledStrings.set(key, { tokens, cachedAt: Date.now() });
|
|
1421
|
+
return this.executeCached(tokens, context);
|
|
1422
|
+
}
|
|
1423
|
+
/** Clear all compiled template caches. */
|
|
1424
|
+
clearCache() {
|
|
1425
|
+
this.compiled.clear();
|
|
1426
|
+
this.compiledStrings.clear();
|
|
1427
|
+
}
|
|
1428
|
+
/** Render a debug dump of a value as HTML — parity with PHP/Ruby/Python.
|
|
1429
|
+
* Gated on TINA4_DEBUG=true. Returns empty string in production. */
|
|
1430
|
+
renderDump(value) {
|
|
1431
|
+
return renderDump(value).toString();
|
|
1432
|
+
}
|
|
1433
|
+
load(name) {
|
|
1434
|
+
const filePath = join(this.templateDir, name);
|
|
1435
|
+
if (!existsSync(filePath)) {
|
|
1436
|
+
throw new Error(`Template not found: ${filePath}`);
|
|
1437
|
+
}
|
|
1438
|
+
return readFileSync(filePath, "utf-8");
|
|
1439
|
+
}
|
|
1440
|
+
/** Execute pre-tokenized template against context. */
|
|
1441
|
+
executeCached(tokens, context) {
|
|
1442
|
+
if (Object.keys(this.tests).length > 0) {
|
|
1443
|
+
context.__frond_tests__ = this.tests;
|
|
1444
|
+
}
|
|
1445
|
+
for (const [ttype, raw] of tokens) {
|
|
1446
|
+
if (ttype === "TEXT") {
|
|
1447
|
+
if (raw.trim()) break;
|
|
1448
|
+
continue;
|
|
1449
|
+
}
|
|
1450
|
+
if (ttype === "BLOCK") {
|
|
1451
|
+
const [content] = stripTag(raw);
|
|
1452
|
+
if (content.startsWith("extends ")) {
|
|
1453
|
+
const source = tokens.map(([, v]) => v).join("");
|
|
1454
|
+
return this.execute(source, context);
|
|
1455
|
+
}
|
|
1456
|
+
}
|
|
1457
|
+
break;
|
|
1458
|
+
}
|
|
1459
|
+
return this.renderTokens(tokens, context);
|
|
1460
|
+
}
|
|
1461
|
+
/** Execute with both source and pre-tokenized tokens available. */
|
|
1462
|
+
executeWithSource(source, tokens, context) {
|
|
1463
|
+
if (Object.keys(this.tests).length > 0) {
|
|
1464
|
+
context.__frond_tests__ = this.tests;
|
|
1465
|
+
}
|
|
1466
|
+
const extendsMatch = source.match(/\{%[-\s]*extends\s+["'](.+?)["']\s*[-]?%\}/);
|
|
1467
|
+
if (extendsMatch) {
|
|
1468
|
+
const parentName = extendsMatch[1];
|
|
1469
|
+
const parentSource = this.load(parentName);
|
|
1470
|
+
const childBlocks = this.extractBlocks(source);
|
|
1471
|
+
return this.renderWithBlocks(parentSource, context, childBlocks);
|
|
1472
|
+
}
|
|
1473
|
+
return this.renderTokens(tokens, context);
|
|
1474
|
+
}
|
|
1475
|
+
execute(source, context) {
|
|
1476
|
+
if (Object.keys(this.tests).length > 0) {
|
|
1477
|
+
context.__frond_tests__ = this.tests;
|
|
1478
|
+
}
|
|
1479
|
+
const extendsMatch = source.match(/\{%[-\s]*extends\s+["'](.+?)["']\s*[-]?%\}/);
|
|
1480
|
+
if (extendsMatch) {
|
|
1481
|
+
const parentName = extendsMatch[1];
|
|
1482
|
+
const parentSource = this.load(parentName);
|
|
1483
|
+
const childBlocks = this.extractBlocks(source);
|
|
1484
|
+
return this.renderWithBlocks(parentSource, context, childBlocks);
|
|
1485
|
+
}
|
|
1486
|
+
return this.renderTokens(tokenize(source), context);
|
|
1487
|
+
}
|
|
1488
|
+
extractBlocks(source) {
|
|
1489
|
+
const blocks = {};
|
|
1490
|
+
const blockOpen = /\{%[-\s]*block\s+(\w+)\s*[-]?%\}/g;
|
|
1491
|
+
const blockClose = /\{%[-\s]*endblock\s*[-]?%\}/g;
|
|
1492
|
+
let pos = 0;
|
|
1493
|
+
while (pos < source.length) {
|
|
1494
|
+
blockOpen.lastIndex = pos;
|
|
1495
|
+
const mOpen = blockOpen.exec(source);
|
|
1496
|
+
if (!mOpen) break;
|
|
1497
|
+
const name = mOpen[1];
|
|
1498
|
+
const contentStart = mOpen.index + mOpen[0].length;
|
|
1499
|
+
let depth = 1;
|
|
1500
|
+
let scan = contentStart;
|
|
1501
|
+
while (depth > 0 && scan < source.length) {
|
|
1502
|
+
blockOpen.lastIndex = scan;
|
|
1503
|
+
blockClose.lastIndex = scan;
|
|
1504
|
+
const nextOpen = blockOpen.exec(source);
|
|
1505
|
+
const nextClose = blockClose.exec(source);
|
|
1506
|
+
if (!nextClose) break;
|
|
1507
|
+
if (nextOpen && nextOpen.index < nextClose.index) {
|
|
1508
|
+
depth++;
|
|
1509
|
+
scan = nextOpen.index + nextOpen[0].length;
|
|
1510
|
+
} else {
|
|
1511
|
+
depth--;
|
|
1512
|
+
if (depth === 0) {
|
|
1513
|
+
blocks[name] = source.slice(contentStart, nextClose.index);
|
|
1514
|
+
pos = nextClose.index + nextClose[0].length;
|
|
1515
|
+
break;
|
|
1516
|
+
}
|
|
1517
|
+
scan = nextClose.index + nextClose[0].length;
|
|
1518
|
+
}
|
|
1519
|
+
}
|
|
1520
|
+
if (depth > 0) {
|
|
1521
|
+
pos = contentStart;
|
|
1522
|
+
}
|
|
1523
|
+
}
|
|
1524
|
+
return blocks;
|
|
1525
|
+
}
|
|
1526
|
+
renderWithBlocks(parentSource, context, childBlocks) {
|
|
1527
|
+
const extendsMatch = parentSource.trimStart().match(/\{%[-\s]*extends\s+["'](.+?)["']\s*[-]?%\}/);
|
|
1528
|
+
if (extendsMatch) {
|
|
1529
|
+
const grandparentName = extendsMatch[1];
|
|
1530
|
+
const grandparentSource = this.load(grandparentName);
|
|
1531
|
+
const parentBlocks = this.extractBlocks(parentSource);
|
|
1532
|
+
const mergedBlocks = { ...parentBlocks, ...childBlocks };
|
|
1533
|
+
const nestedBlockRe = /\{%[-\s]*block\s+(\w+)\s*[-]?%\}([\s\S]*?)\{%[-\s]*endblock\s*[-]?%\}/g;
|
|
1534
|
+
let changed = true;
|
|
1535
|
+
while (changed) {
|
|
1536
|
+
changed = false;
|
|
1537
|
+
for (const name of Object.keys(mergedBlocks)) {
|
|
1538
|
+
const resolved = mergedBlocks[name].replace(nestedBlockRe, (_m, innerName, innerDefault) => {
|
|
1539
|
+
return mergedBlocks[innerName] ?? innerDefault;
|
|
1540
|
+
});
|
|
1541
|
+
if (resolved !== mergedBlocks[name]) {
|
|
1542
|
+
mergedBlocks[name] = resolved;
|
|
1543
|
+
changed = true;
|
|
1544
|
+
}
|
|
1545
|
+
}
|
|
1546
|
+
}
|
|
1547
|
+
return this.renderWithBlocks(grandparentSource, context, mergedBlocks);
|
|
1548
|
+
}
|
|
1549
|
+
const pattern = /\{%[-\s]*block\s+(\w+)\s*[-]?%\}([\s\S]*?)\{%[-\s]*endblock\s*[-]?%\}/g;
|
|
1550
|
+
const engine = this;
|
|
1551
|
+
const result = parentSource.replace(pattern, (_match, name, parentContent) => {
|
|
1552
|
+
const blockSource = childBlocks[name] ?? parentContent;
|
|
1553
|
+
let renderedParent = null;
|
|
1554
|
+
const getParent = () => {
|
|
1555
|
+
if (renderedParent === null) {
|
|
1556
|
+
renderedParent = new SafeString(
|
|
1557
|
+
engine.renderTokens(tokenize(parentContent), context)
|
|
1558
|
+
);
|
|
1559
|
+
}
|
|
1560
|
+
return renderedParent;
|
|
1561
|
+
};
|
|
1562
|
+
const blockCtx = { ...context, parent: getParent, super: getParent };
|
|
1563
|
+
return this.renderTokens(tokenize(blockSource), blockCtx);
|
|
1564
|
+
});
|
|
1565
|
+
return this.renderTokens(tokenize(result), context);
|
|
1566
|
+
}
|
|
1567
|
+
renderTokens(tokens, context) {
|
|
1568
|
+
context.__frond_apply_filters__ = this._applyFiltersBound;
|
|
1569
|
+
const output = [];
|
|
1570
|
+
let i = 0;
|
|
1571
|
+
while (i < tokens.length) {
|
|
1572
|
+
const [ttype, raw] = tokens[i];
|
|
1573
|
+
if (ttype === "TEXT") {
|
|
1574
|
+
output.push(raw);
|
|
1575
|
+
i++;
|
|
1576
|
+
} else if (ttype === "COMMENT") {
|
|
1577
|
+
i++;
|
|
1578
|
+
} else if (ttype === "VAR") {
|
|
1579
|
+
const [content, stripB, stripA] = stripTag(raw);
|
|
1580
|
+
if (stripB && output.length > 0) {
|
|
1581
|
+
output[output.length - 1] = output[output.length - 1].replace(TRAILING_WS_RE, "");
|
|
1582
|
+
}
|
|
1583
|
+
const result = this.evalVar(content, context);
|
|
1584
|
+
output.push(result !== null && result !== void 0 ? String(result) : "");
|
|
1585
|
+
if (stripA && i + 1 < tokens.length && tokens[i + 1][0] === "TEXT") {
|
|
1586
|
+
tokens[i + 1] = ["TEXT", tokens[i + 1][1].replace(LEADING_WS_RE, "")];
|
|
1587
|
+
}
|
|
1588
|
+
i++;
|
|
1589
|
+
} else if (ttype === "BLOCK") {
|
|
1590
|
+
const [content, stripB, stripA] = stripTag(raw);
|
|
1591
|
+
if (stripB && output.length > 0) {
|
|
1592
|
+
output[output.length - 1] = output[output.length - 1].replace(TRAILING_WS_RE, "");
|
|
1593
|
+
}
|
|
1594
|
+
const parts = content.split(/\s+/);
|
|
1595
|
+
const tag = parts[0] || "";
|
|
1596
|
+
if (stripA && i + 1 < tokens.length && tokens[i + 1][0] === "TEXT") {
|
|
1597
|
+
tokens[i + 1] = ["TEXT", tokens[i + 1][1].replace(LEADING_WS_RE, "")];
|
|
1598
|
+
}
|
|
1599
|
+
if (tag === "if") {
|
|
1600
|
+
if (this._sandbox && this._allowedTags !== null && !this._allowedTags.has("if")) {
|
|
1601
|
+
const skip = this.skipBlock(tokens, i, "if", "endif");
|
|
1602
|
+
i = skip;
|
|
1603
|
+
} else {
|
|
1604
|
+
const [result, skip] = this.handleIf(tokens, i, context);
|
|
1605
|
+
output.push(result);
|
|
1606
|
+
i = skip;
|
|
1607
|
+
}
|
|
1608
|
+
} else if (tag === "for") {
|
|
1609
|
+
if (this._sandbox && this._allowedTags !== null && !this._allowedTags.has("for")) {
|
|
1610
|
+
const skip = this.skipBlock(tokens, i, "for", "endfor");
|
|
1611
|
+
i = skip;
|
|
1612
|
+
} else {
|
|
1613
|
+
const [result, skip] = this.handleFor(tokens, i, context);
|
|
1614
|
+
output.push(result);
|
|
1615
|
+
i = skip;
|
|
1616
|
+
}
|
|
1617
|
+
} else if (tag === "set") {
|
|
1618
|
+
if (this._sandbox && this._allowedTags !== null && !this._allowedTags.has("set")) {
|
|
1619
|
+
i++;
|
|
1620
|
+
} else {
|
|
1621
|
+
this.handleSet(content, context);
|
|
1622
|
+
i++;
|
|
1623
|
+
}
|
|
1624
|
+
} else if (tag === "include") {
|
|
1625
|
+
if (this._sandbox && this._allowedTags !== null && !this._allowedTags.has("include")) {
|
|
1626
|
+
i++;
|
|
1627
|
+
} else {
|
|
1628
|
+
const result = this.handleInclude(content, context);
|
|
1629
|
+
output.push(result);
|
|
1630
|
+
i++;
|
|
1631
|
+
}
|
|
1632
|
+
} else if (tag === "macro") {
|
|
1633
|
+
const skip = this.handleMacro(tokens, i, context);
|
|
1634
|
+
i = skip;
|
|
1635
|
+
} else if (tag === "from") {
|
|
1636
|
+
this.handleFromImport(content, context);
|
|
1637
|
+
i++;
|
|
1638
|
+
} else if (tag === "cache") {
|
|
1639
|
+
const [result, skip] = this.handleCache(tokens, i, context);
|
|
1640
|
+
output.push(result);
|
|
1641
|
+
i = skip;
|
|
1642
|
+
} else if (tag === "live") {
|
|
1643
|
+
const [result, skip] = this.handleLive(tokens, i, context);
|
|
1644
|
+
output.push(result);
|
|
1645
|
+
i = skip;
|
|
1646
|
+
} else if (tag === "spaceless") {
|
|
1647
|
+
const [result, skip] = this.handleSpaceless(tokens, i, context);
|
|
1648
|
+
output.push(result);
|
|
1649
|
+
i = skip;
|
|
1650
|
+
} else if (tag === "autoescape") {
|
|
1651
|
+
const [result, skip] = this.handleAutoescape(tokens, i, context);
|
|
1652
|
+
output.push(result);
|
|
1653
|
+
i = skip;
|
|
1654
|
+
} else if (tag === "block" || tag === "endblock" || tag === "extends") {
|
|
1655
|
+
i++;
|
|
1656
|
+
} else {
|
|
1657
|
+
i++;
|
|
1658
|
+
}
|
|
1659
|
+
if (stripA && i < tokens.length && tokens[i][0] === "TEXT") {
|
|
1660
|
+
tokens[i] = ["TEXT", tokens[i][1].replace(LEADING_WS_RE, "")];
|
|
1661
|
+
}
|
|
1662
|
+
} else {
|
|
1663
|
+
i++;
|
|
1664
|
+
}
|
|
1665
|
+
}
|
|
1666
|
+
return output.join("");
|
|
1667
|
+
}
|
|
1668
|
+
skipBlock(tokens, start, openTag, closeTag) {
|
|
1669
|
+
let depth = 0;
|
|
1670
|
+
let i = start + 1;
|
|
1671
|
+
while (i < tokens.length) {
|
|
1672
|
+
if (tokens[i][0] === "BLOCK") {
|
|
1673
|
+
const [content] = stripTag(tokens[i][1]);
|
|
1674
|
+
const tag = content.split(/\s+/)[0] || "";
|
|
1675
|
+
if (tag === openTag) depth++;
|
|
1676
|
+
else if (tag === closeTag) {
|
|
1677
|
+
if (depth === 0) return i + 1;
|
|
1678
|
+
depth--;
|
|
1679
|
+
}
|
|
1680
|
+
}
|
|
1681
|
+
i++;
|
|
1682
|
+
}
|
|
1683
|
+
return i;
|
|
1684
|
+
}
|
|
1685
|
+
/**
|
|
1686
|
+
* Apply a parsed filter chain to an already-evaluated value. This is the
|
|
1687
|
+
* instance-aware filter engine used by `evalExpr` (via the
|
|
1688
|
+
* `__frond_apply_filters__` hook in the render context) so filters resolve
|
|
1689
|
+
* with this Frond's registered/custom filters at ANY nesting depth — inside
|
|
1690
|
+
* concat operands, ternary branches, and parenthesised sub-expressions — not
|
|
1691
|
+
* only at the top-level {{ }} output. Mirrors the filter loop in
|
|
1692
|
+
* `evalVarRaw`: `first`/`last` tail-paths, registered `this.filters`, and the
|
|
1693
|
+
* trailing-comparison form (`length != 1`). Auto-escaping stays the caller's
|
|
1694
|
+
* concern (`evalVarInner`). (#171)
|
|
1695
|
+
*/
|
|
1696
|
+
applyFilters(value, filters, context) {
|
|
1697
|
+
for (const [fname, rawArgs] of filters) {
|
|
1698
|
+
const args = rawArgs.map((a) => a instanceof VarRef ? evalExpr(a.name, context) : a);
|
|
1699
|
+
if (fname === "raw" || fname === "safe") continue;
|
|
1700
|
+
if (this._sandbox && this._allowedFilters !== null && !this._allowedFilters.has(fname)) continue;
|
|
1701
|
+
const [realFname, tailPath] = splitFilterNameAndPath(fname);
|
|
1702
|
+
if (tailPath) {
|
|
1703
|
+
let applied = false;
|
|
1704
|
+
if (realFname === "first") {
|
|
1705
|
+
value = Array.isArray(value) ? value[0] ?? null : null;
|
|
1706
|
+
applied = true;
|
|
1707
|
+
} else if (realFname === "last") {
|
|
1708
|
+
value = Array.isArray(value) ? value[value.length - 1] ?? null : null;
|
|
1709
|
+
applied = true;
|
|
1710
|
+
} else if (this.filters[realFname]) {
|
|
1711
|
+
value = this.filters[realFname](value, ...args);
|
|
1712
|
+
applied = true;
|
|
1713
|
+
}
|
|
1714
|
+
if (applied) {
|
|
1715
|
+
value = evalExpr("__frondFilterTmp." + tailPath, { __frondFilterTmp: value });
|
|
1716
|
+
continue;
|
|
1717
|
+
}
|
|
1718
|
+
}
|
|
1719
|
+
const fn = this.filters[fname];
|
|
1720
|
+
if (fn) {
|
|
1721
|
+
value = fn(value, ...args);
|
|
1722
|
+
} else {
|
|
1723
|
+
const m = fname.match(FILTER_COMPARISON_RE);
|
|
1724
|
+
if (m) {
|
|
1725
|
+
const fn2 = this.filters[m[1]];
|
|
1726
|
+
if (fn2) value = fn2(value, ...args);
|
|
1727
|
+
const right = evalExpr(m[3].trim(), context);
|
|
1728
|
+
switch (m[2]) {
|
|
1729
|
+
case "!=":
|
|
1730
|
+
value = value !== right;
|
|
1731
|
+
break;
|
|
1732
|
+
case "==":
|
|
1733
|
+
value = value === right;
|
|
1734
|
+
break;
|
|
1735
|
+
case ">=":
|
|
1736
|
+
value = value >= right;
|
|
1737
|
+
break;
|
|
1738
|
+
case "<=":
|
|
1739
|
+
value = value <= right;
|
|
1740
|
+
break;
|
|
1741
|
+
case ">":
|
|
1742
|
+
value = value > right;
|
|
1743
|
+
break;
|
|
1744
|
+
case "<":
|
|
1745
|
+
value = value < right;
|
|
1746
|
+
break;
|
|
1747
|
+
}
|
|
1748
|
+
} else {
|
|
1749
|
+
value = evalExpr(fname, context);
|
|
1750
|
+
}
|
|
1751
|
+
}
|
|
1752
|
+
}
|
|
1753
|
+
return value;
|
|
1754
|
+
}
|
|
1755
|
+
evalVar(expr, context) {
|
|
1756
|
+
const ternaryIdx = findTernary(expr);
|
|
1757
|
+
if (ternaryIdx !== -1) {
|
|
1758
|
+
const condPart = expr.slice(0, ternaryIdx).trim();
|
|
1759
|
+
const rest = expr.slice(ternaryIdx + 1);
|
|
1760
|
+
const colonIdx = findColon(rest);
|
|
1761
|
+
if (colonIdx !== -1) {
|
|
1762
|
+
const truePart = rest.slice(0, colonIdx).trim();
|
|
1763
|
+
const falsePart = rest.slice(colonIdx + 1).trim();
|
|
1764
|
+
const cond = this.evalVarRaw(condPart, context);
|
|
1765
|
+
return cond ? this.evalVar(truePart, context) : this.evalVar(falsePart, context);
|
|
1766
|
+
}
|
|
1767
|
+
}
|
|
1768
|
+
return this.evalVarInner(expr, context);
|
|
1769
|
+
}
|
|
1770
|
+
evalVarRaw(expr, context) {
|
|
1771
|
+
const [varName, filters] = parseFilterChain(expr);
|
|
1772
|
+
let value = evalExpr(varName, context);
|
|
1773
|
+
for (const [fname, rawArgs] of filters) {
|
|
1774
|
+
const args = rawArgs.map((a) => a instanceof VarRef ? evalExpr(a.name, context) : a);
|
|
1775
|
+
if (fname === "raw" || fname === "safe") continue;
|
|
1776
|
+
if (this._sandbox && this._allowedFilters !== null && !this._allowedFilters.has(fname)) continue;
|
|
1777
|
+
const [realFname, tailPath] = splitFilterNameAndPath(fname);
|
|
1778
|
+
if (tailPath) {
|
|
1779
|
+
let applied = false;
|
|
1780
|
+
if (realFname === "first") {
|
|
1781
|
+
value = Array.isArray(value) ? value[0] ?? null : null;
|
|
1782
|
+
applied = true;
|
|
1783
|
+
} else if (realFname === "last") {
|
|
1784
|
+
value = Array.isArray(value) ? value[value.length - 1] ?? null : null;
|
|
1785
|
+
applied = true;
|
|
1786
|
+
} else if (this.filters[realFname]) {
|
|
1787
|
+
value = this.filters[realFname](value, ...args);
|
|
1788
|
+
applied = true;
|
|
1789
|
+
}
|
|
1790
|
+
if (applied) {
|
|
1791
|
+
value = evalExpr(
|
|
1792
|
+
"__frondFilterTmp." + tailPath,
|
|
1793
|
+
{ __frondFilterTmp: value }
|
|
1794
|
+
);
|
|
1795
|
+
continue;
|
|
1796
|
+
}
|
|
1797
|
+
}
|
|
1798
|
+
const fn = this.filters[fname];
|
|
1799
|
+
if (fn) {
|
|
1800
|
+
value = fn(value, ...args);
|
|
1801
|
+
} else {
|
|
1802
|
+
const m = fname.match(FILTER_COMPARISON_RE);
|
|
1803
|
+
if (m) {
|
|
1804
|
+
const realFilter = m[1];
|
|
1805
|
+
const op = m[2];
|
|
1806
|
+
const rightExpr = m[3].trim();
|
|
1807
|
+
const fn2 = this.filters[realFilter];
|
|
1808
|
+
if (fn2) {
|
|
1809
|
+
value = fn2(value, ...args);
|
|
1810
|
+
}
|
|
1811
|
+
const right = evalExpr(rightExpr, context);
|
|
1812
|
+
switch (op) {
|
|
1813
|
+
case "!=":
|
|
1814
|
+
value = value !== right;
|
|
1815
|
+
break;
|
|
1816
|
+
case "==":
|
|
1817
|
+
value = value === right;
|
|
1818
|
+
break;
|
|
1819
|
+
case ">=":
|
|
1820
|
+
value = value >= right;
|
|
1821
|
+
break;
|
|
1822
|
+
case "<=":
|
|
1823
|
+
value = value <= right;
|
|
1824
|
+
break;
|
|
1825
|
+
case ">":
|
|
1826
|
+
value = value > right;
|
|
1827
|
+
break;
|
|
1828
|
+
case "<":
|
|
1829
|
+
value = value < right;
|
|
1830
|
+
break;
|
|
1831
|
+
}
|
|
1832
|
+
} else {
|
|
1833
|
+
value = evalExpr(fname, context);
|
|
1834
|
+
}
|
|
1835
|
+
}
|
|
1836
|
+
}
|
|
1837
|
+
return value;
|
|
1838
|
+
}
|
|
1839
|
+
evalVarInner(expr, context) {
|
|
1840
|
+
const [varName, filters] = parseFilterChain(expr);
|
|
1841
|
+
if (this._sandbox && this._allowedVars !== null) {
|
|
1842
|
+
const rootVar = varName.split(".")[0].split("[")[0].trim();
|
|
1843
|
+
if (rootVar && !this._allowedVars.has(rootVar) && rootVar !== "loop") {
|
|
1844
|
+
return "";
|
|
1845
|
+
}
|
|
1846
|
+
}
|
|
1847
|
+
if (findOutsideQuotes(expr, "~") >= 0) {
|
|
1848
|
+
let concatValue = evalExpr(expr, context);
|
|
1849
|
+
if (concatValue instanceof SafeString) return concatValue.value;
|
|
1850
|
+
if (this._autoEscape && typeof concatValue === "string") {
|
|
1851
|
+
concatValue = htmlEscape(concatValue);
|
|
1852
|
+
}
|
|
1853
|
+
return concatValue;
|
|
1854
|
+
}
|
|
1855
|
+
let value = evalExpr(varName, context);
|
|
1856
|
+
let isSafe = false;
|
|
1857
|
+
for (const [fname, rawArgs] of filters) {
|
|
1858
|
+
const args = rawArgs.map((a) => a instanceof VarRef ? evalExpr(a.name, context) : a);
|
|
1859
|
+
if (fname === "raw" || fname === "safe") {
|
|
1860
|
+
isSafe = true;
|
|
1861
|
+
continue;
|
|
1862
|
+
}
|
|
1863
|
+
if (fname === "escape" || fname === "e") {
|
|
1864
|
+
isSafe = true;
|
|
1865
|
+
}
|
|
1866
|
+
if (this._sandbox && this._allowedFilters !== null) {
|
|
1867
|
+
if (!this._allowedFilters.has(fname)) {
|
|
1868
|
+
continue;
|
|
1869
|
+
}
|
|
1870
|
+
}
|
|
1871
|
+
const [realFname, tailPath] = splitFilterNameAndPath(fname);
|
|
1872
|
+
if (tailPath) {
|
|
1873
|
+
let applied = false;
|
|
1874
|
+
if (realFname === "first") {
|
|
1875
|
+
value = Array.isArray(value) ? value[0] ?? null : null;
|
|
1876
|
+
applied = true;
|
|
1877
|
+
} else if (realFname === "last") {
|
|
1878
|
+
value = Array.isArray(value) ? value[value.length - 1] ?? null : null;
|
|
1879
|
+
applied = true;
|
|
1880
|
+
} else if (this.filters[realFname]) {
|
|
1881
|
+
value = this.filters[realFname](value, ...args);
|
|
1882
|
+
applied = true;
|
|
1883
|
+
}
|
|
1884
|
+
if (applied) {
|
|
1885
|
+
value = evalExpr(
|
|
1886
|
+
"__frondFilterTmp." + tailPath,
|
|
1887
|
+
{ __frondFilterTmp: value }
|
|
1888
|
+
);
|
|
1889
|
+
continue;
|
|
1890
|
+
}
|
|
1891
|
+
}
|
|
1892
|
+
if (args.length === 0) {
|
|
1893
|
+
switch (fname) {
|
|
1894
|
+
case "upper":
|
|
1895
|
+
value = String(value).toUpperCase();
|
|
1896
|
+
continue;
|
|
1897
|
+
case "lower":
|
|
1898
|
+
value = String(value).toLowerCase();
|
|
1899
|
+
continue;
|
|
1900
|
+
case "trim":
|
|
1901
|
+
value = String(value).trim();
|
|
1902
|
+
continue;
|
|
1903
|
+
case "length":
|
|
1904
|
+
if (Array.isArray(value)) {
|
|
1905
|
+
value = value.length;
|
|
1906
|
+
} else if (typeof value === "string") {
|
|
1907
|
+
value = value.length;
|
|
1908
|
+
} else if (typeof value === "object" && value !== null) {
|
|
1909
|
+
value = Object.keys(value).length;
|
|
1910
|
+
} else {
|
|
1911
|
+
value = 0;
|
|
1912
|
+
}
|
|
1913
|
+
continue;
|
|
1914
|
+
case "capitalize": {
|
|
1915
|
+
const s = String(value);
|
|
1916
|
+
value = s.charAt(0).toUpperCase() + s.slice(1).toLowerCase();
|
|
1917
|
+
continue;
|
|
1918
|
+
}
|
|
1919
|
+
case "title":
|
|
1920
|
+
value = String(value).replace(TITLE_WORD_RE, (c) => c.toUpperCase());
|
|
1921
|
+
continue;
|
|
1922
|
+
case "string":
|
|
1923
|
+
value = String(value);
|
|
1924
|
+
continue;
|
|
1925
|
+
case "int":
|
|
1926
|
+
value = value ? parseInt(String(value), 10) || 0 : 0;
|
|
1927
|
+
continue;
|
|
1928
|
+
case "float":
|
|
1929
|
+
value = value ? parseFloat(String(value)) || 0 : 0;
|
|
1930
|
+
continue;
|
|
1931
|
+
case "abs":
|
|
1932
|
+
value = typeof value === "number" ? Math.abs(value) : value;
|
|
1933
|
+
continue;
|
|
1934
|
+
case "striptags":
|
|
1935
|
+
value = String(value).replace(STRIP_TAGS_RE, "");
|
|
1936
|
+
continue;
|
|
1937
|
+
case "first":
|
|
1938
|
+
value = Array.isArray(value) ? value[0] ?? null : null;
|
|
1939
|
+
continue;
|
|
1940
|
+
case "last":
|
|
1941
|
+
value = Array.isArray(value) ? value[value.length - 1] ?? null : null;
|
|
1942
|
+
continue;
|
|
1943
|
+
case "keys":
|
|
1944
|
+
value = typeof value === "object" && value !== null && !Array.isArray(value) ? Object.keys(value) : [];
|
|
1945
|
+
continue;
|
|
1946
|
+
case "values":
|
|
1947
|
+
value = typeof value === "object" && value !== null && !Array.isArray(value) ? Object.values(value) : [];
|
|
1948
|
+
continue;
|
|
1949
|
+
case "json_encode":
|
|
1950
|
+
value = JSON.stringify(value);
|
|
1951
|
+
continue;
|
|
1952
|
+
case "dump":
|
|
1953
|
+
value = renderDump(value);
|
|
1954
|
+
continue;
|
|
1955
|
+
case "nl2br":
|
|
1956
|
+
value = new SafeString(htmlEscape(String(value)).replace(/\n/g, "<br />\n"));
|
|
1957
|
+
continue;
|
|
1958
|
+
case "unique":
|
|
1959
|
+
value = Array.isArray(value) ? [...new Set(value)] : value;
|
|
1960
|
+
continue;
|
|
1961
|
+
case "sort":
|
|
1962
|
+
value = Array.isArray(value) ? [...value].sort() : value;
|
|
1963
|
+
continue;
|
|
1964
|
+
case "reverse":
|
|
1965
|
+
value = Array.isArray(value) ? [...value].reverse() : String(value).split("").reverse().join("");
|
|
1966
|
+
continue;
|
|
1967
|
+
case "filter":
|
|
1968
|
+
value = Array.isArray(value) ? value.filter(Boolean) : value;
|
|
1969
|
+
continue;
|
|
1970
|
+
}
|
|
1971
|
+
}
|
|
1972
|
+
const fn = this.filters[fname];
|
|
1973
|
+
if (fn) {
|
|
1974
|
+
value = fn(value, ...args);
|
|
1975
|
+
}
|
|
1976
|
+
}
|
|
1977
|
+
if (value instanceof SafeString) {
|
|
1978
|
+
return value.value;
|
|
1979
|
+
}
|
|
1980
|
+
if (!isSafe && this._autoEscape && typeof value === "string") {
|
|
1981
|
+
value = htmlEscape(value);
|
|
1982
|
+
}
|
|
1983
|
+
return value;
|
|
1984
|
+
}
|
|
1985
|
+
handleIf(tokens, start, context) {
|
|
1986
|
+
const [content] = stripTag(tokens[start][1]);
|
|
1987
|
+
const conditionExpr = content.slice(3).trim();
|
|
1988
|
+
const branches = [];
|
|
1989
|
+
let currentTokens = [];
|
|
1990
|
+
let currentCond = conditionExpr;
|
|
1991
|
+
let depth = 0;
|
|
1992
|
+
let i = start + 1;
|
|
1993
|
+
while (i < tokens.length) {
|
|
1994
|
+
const [ttype, raw] = tokens[i];
|
|
1995
|
+
if (ttype === "BLOCK") {
|
|
1996
|
+
const [tagContent, tagStripB, tagStripA] = stripTag(raw);
|
|
1997
|
+
const tag = tagContent.split(/\s+/)[0] || "";
|
|
1998
|
+
if (tag === "if") {
|
|
1999
|
+
depth++;
|
|
2000
|
+
currentTokens.push(tokens[i]);
|
|
2001
|
+
} else if (tag === "endif" && depth > 0) {
|
|
2002
|
+
depth--;
|
|
2003
|
+
currentTokens.push(tokens[i]);
|
|
2004
|
+
} else if (tag === "endif" && depth === 0) {
|
|
2005
|
+
if (tagStripB && currentTokens.length > 0 && currentTokens[currentTokens.length - 1][0] === "TEXT") {
|
|
2006
|
+
currentTokens[currentTokens.length - 1] = ["TEXT", currentTokens[currentTokens.length - 1][1].replace(TRAILING_WS_RE, "")];
|
|
2007
|
+
}
|
|
2008
|
+
branches.push([currentCond, currentTokens]);
|
|
2009
|
+
if (tagStripA && i + 1 < tokens.length && tokens[i + 1][0] === "TEXT") {
|
|
2010
|
+
tokens[i + 1] = ["TEXT", tokens[i + 1][1].replace(LEADING_WS_RE, "")];
|
|
2011
|
+
}
|
|
2012
|
+
i++;
|
|
2013
|
+
break;
|
|
2014
|
+
} else if ((tag === "elseif" || tag === "elif") && depth === 0) {
|
|
2015
|
+
if (tagStripB && currentTokens.length > 0 && currentTokens[currentTokens.length - 1][0] === "TEXT") {
|
|
2016
|
+
currentTokens[currentTokens.length - 1] = ["TEXT", currentTokens[currentTokens.length - 1][1].replace(TRAILING_WS_RE, "")];
|
|
2017
|
+
}
|
|
2018
|
+
branches.push([currentCond, currentTokens]);
|
|
2019
|
+
currentCond = tagContent.slice(tag.length).trim();
|
|
2020
|
+
currentTokens = [];
|
|
2021
|
+
} else if (tag === "else" && depth === 0) {
|
|
2022
|
+
if (tagStripB && currentTokens.length > 0 && currentTokens[currentTokens.length - 1][0] === "TEXT") {
|
|
2023
|
+
currentTokens[currentTokens.length - 1] = ["TEXT", currentTokens[currentTokens.length - 1][1].replace(TRAILING_WS_RE, "")];
|
|
2024
|
+
}
|
|
2025
|
+
branches.push([currentCond, currentTokens]);
|
|
2026
|
+
currentCond = null;
|
|
2027
|
+
currentTokens = [];
|
|
2028
|
+
} else {
|
|
2029
|
+
currentTokens.push(tokens[i]);
|
|
2030
|
+
}
|
|
2031
|
+
} else {
|
|
2032
|
+
currentTokens.push(tokens[i]);
|
|
2033
|
+
}
|
|
2034
|
+
i++;
|
|
2035
|
+
}
|
|
2036
|
+
for (const [cond, branchTokens] of branches) {
|
|
2037
|
+
if (cond === null || evalComparison(cond, context, this.evalVarRaw.bind(this))) {
|
|
2038
|
+
return [this.renderTokens([...branchTokens], context), i];
|
|
2039
|
+
}
|
|
2040
|
+
}
|
|
2041
|
+
return ["", i];
|
|
2042
|
+
}
|
|
2043
|
+
handleFor(tokens, start, context) {
|
|
2044
|
+
const [content] = stripTag(tokens[start][1]);
|
|
2045
|
+
const forMatch = content.match(/^for\s+(\w+)(?:\s*,\s*(\w+))?\s+in\s+(.+)/);
|
|
2046
|
+
if (!forMatch) return ["", start + 1];
|
|
2047
|
+
const var1 = forMatch[1];
|
|
2048
|
+
const var2 = forMatch[2] || null;
|
|
2049
|
+
const iterableExpr = forMatch[3].trim();
|
|
2050
|
+
const bodyTokens = [];
|
|
2051
|
+
const elseTokens = [];
|
|
2052
|
+
let inElse = false;
|
|
2053
|
+
let forDepth = 0;
|
|
2054
|
+
let ifDepth = 0;
|
|
2055
|
+
let i = start + 1;
|
|
2056
|
+
while (i < tokens.length) {
|
|
2057
|
+
const [ttype, raw] = tokens[i];
|
|
2058
|
+
if (ttype === "BLOCK") {
|
|
2059
|
+
const [tagContent] = stripTag(raw);
|
|
2060
|
+
const tag = tagContent.split(/\s+/)[0] || "";
|
|
2061
|
+
if (tag === "for") {
|
|
2062
|
+
forDepth++;
|
|
2063
|
+
(inElse ? elseTokens : bodyTokens).push(tokens[i]);
|
|
2064
|
+
} else if (tag === "endfor" && forDepth > 0) {
|
|
2065
|
+
forDepth--;
|
|
2066
|
+
(inElse ? elseTokens : bodyTokens).push(tokens[i]);
|
|
2067
|
+
} else if (tag === "endfor" && forDepth === 0) {
|
|
2068
|
+
i++;
|
|
2069
|
+
break;
|
|
2070
|
+
} else if (tag === "if") {
|
|
2071
|
+
ifDepth++;
|
|
2072
|
+
(inElse ? elseTokens : bodyTokens).push(tokens[i]);
|
|
2073
|
+
} else if (tag === "endif") {
|
|
2074
|
+
ifDepth--;
|
|
2075
|
+
(inElse ? elseTokens : bodyTokens).push(tokens[i]);
|
|
2076
|
+
} else if (tag === "else" && forDepth === 0 && ifDepth === 0) {
|
|
2077
|
+
inElse = true;
|
|
2078
|
+
} else {
|
|
2079
|
+
(inElse ? elseTokens : bodyTokens).push(tokens[i]);
|
|
2080
|
+
}
|
|
2081
|
+
} else {
|
|
2082
|
+
(inElse ? elseTokens : bodyTokens).push(tokens[i]);
|
|
2083
|
+
}
|
|
2084
|
+
i++;
|
|
2085
|
+
}
|
|
2086
|
+
const iterable = evalExpr(iterableExpr, context);
|
|
2087
|
+
if (!iterable || Array.isArray(iterable) && iterable.length === 0 || typeof iterable === "object" && !Array.isArray(iterable) && Object.keys(iterable).length === 0) {
|
|
2088
|
+
if (elseTokens.length > 0) {
|
|
2089
|
+
return [this.renderTokens([...elseTokens], context), i];
|
|
2090
|
+
}
|
|
2091
|
+
return ["", i];
|
|
2092
|
+
}
|
|
2093
|
+
const output = [];
|
|
2094
|
+
const isDict = typeof iterable === "object" && !Array.isArray(iterable);
|
|
2095
|
+
const items = isDict ? Object.entries(iterable) : Array.isArray(iterable) ? iterable : [];
|
|
2096
|
+
const total = items.length;
|
|
2097
|
+
const loopObj = {
|
|
2098
|
+
index: 0,
|
|
2099
|
+
index0: 0,
|
|
2100
|
+
first: false,
|
|
2101
|
+
last: false,
|
|
2102
|
+
length: total,
|
|
2103
|
+
revindex: 0,
|
|
2104
|
+
revindex0: 0,
|
|
2105
|
+
even: false,
|
|
2106
|
+
odd: false
|
|
2107
|
+
};
|
|
2108
|
+
for (let idx = 0; idx < total; idx++) {
|
|
2109
|
+
const item = items[idx];
|
|
2110
|
+
loopObj.index = idx + 1;
|
|
2111
|
+
loopObj.index0 = idx;
|
|
2112
|
+
loopObj.first = idx === 0;
|
|
2113
|
+
loopObj.last = idx === total - 1;
|
|
2114
|
+
loopObj.revindex = total - idx;
|
|
2115
|
+
loopObj.revindex0 = total - idx - 1;
|
|
2116
|
+
loopObj.even = (idx + 1) % 2 === 0;
|
|
2117
|
+
loopObj.odd = (idx + 1) % 2 !== 0;
|
|
2118
|
+
const locals = { loop: loopObj };
|
|
2119
|
+
if (isDict) {
|
|
2120
|
+
const [key, value] = item;
|
|
2121
|
+
locals[var1] = key;
|
|
2122
|
+
if (var2) locals[var2] = value;
|
|
2123
|
+
} else {
|
|
2124
|
+
if (var2) {
|
|
2125
|
+
locals[var1] = idx;
|
|
2126
|
+
locals[var2] = item;
|
|
2127
|
+
} else {
|
|
2128
|
+
locals[var1] = item;
|
|
2129
|
+
}
|
|
2130
|
+
}
|
|
2131
|
+
const loopCtx = new Proxy(locals, {
|
|
2132
|
+
get(target, prop) {
|
|
2133
|
+
if (prop in target) return target[prop];
|
|
2134
|
+
return context[prop];
|
|
2135
|
+
},
|
|
2136
|
+
set(target, prop, value) {
|
|
2137
|
+
target[prop] = value;
|
|
2138
|
+
return true;
|
|
2139
|
+
},
|
|
2140
|
+
has(target, prop) {
|
|
2141
|
+
return prop in target || prop in context;
|
|
2142
|
+
},
|
|
2143
|
+
ownKeys() {
|
|
2144
|
+
return [.../* @__PURE__ */ new Set([...Object.keys(locals), ...Object.keys(context)])];
|
|
2145
|
+
},
|
|
2146
|
+
getOwnPropertyDescriptor(target, prop) {
|
|
2147
|
+
if (prop in target) return { configurable: true, enumerable: true, value: target[prop] };
|
|
2148
|
+
if (prop in context) return { configurable: true, enumerable: true, value: context[prop] };
|
|
2149
|
+
return void 0;
|
|
2150
|
+
}
|
|
2151
|
+
});
|
|
2152
|
+
output.push(this.renderTokens([...bodyTokens], loopCtx));
|
|
2153
|
+
}
|
|
2154
|
+
return [output.join(""), i];
|
|
2155
|
+
}
|
|
2156
|
+
handleSet(content, context) {
|
|
2157
|
+
const m = content.match(/^set\s+(\w+)\s*=\s*([\s\S]+)/);
|
|
2158
|
+
if (m) {
|
|
2159
|
+
const name = m[1];
|
|
2160
|
+
const expr = m[2].trim();
|
|
2161
|
+
context[name] = this.evalVarRaw(expr, context);
|
|
2162
|
+
}
|
|
2163
|
+
}
|
|
2164
|
+
handleInclude(content, context) {
|
|
2165
|
+
const ignoreMissing = content.includes("ignore missing");
|
|
2166
|
+
const cleanContent = content.replace("ignore missing", "").trim();
|
|
2167
|
+
const m = cleanContent.match(/^include\s+["'](.+?)["'](?:\s+with\s+(.+))?/);
|
|
2168
|
+
if (!m) return "";
|
|
2169
|
+
const filename = m[1];
|
|
2170
|
+
const withExpr = m[2];
|
|
2171
|
+
let source;
|
|
2172
|
+
try {
|
|
2173
|
+
source = this.load(filename);
|
|
2174
|
+
} catch {
|
|
2175
|
+
if (ignoreMissing) return "";
|
|
2176
|
+
throw new Error(`Template not found: ${join(this.templateDir, filename)}`);
|
|
2177
|
+
}
|
|
2178
|
+
const incContext = { ...context };
|
|
2179
|
+
if (withExpr) {
|
|
2180
|
+
const extra = evalExpr(withExpr, context);
|
|
2181
|
+
if (typeof extra === "object" && extra !== null) {
|
|
2182
|
+
Object.assign(incContext, extra);
|
|
2183
|
+
}
|
|
2184
|
+
}
|
|
2185
|
+
return this.execute(source, incContext);
|
|
2186
|
+
}
|
|
2187
|
+
handleMacro(tokens, start, context) {
|
|
2188
|
+
const [content] = stripTag(tokens[start][1]);
|
|
2189
|
+
const m = content.match(/^macro\s+(\w+)\s*\(([^)]*)\)/);
|
|
2190
|
+
if (!m) {
|
|
2191
|
+
let i2 = start + 1;
|
|
2192
|
+
while (i2 < tokens.length) {
|
|
2193
|
+
if (tokens[i2][0] === "BLOCK" && tokens[i2][1].includes("endmacro")) {
|
|
2194
|
+
return i2 + 1;
|
|
2195
|
+
}
|
|
2196
|
+
i2++;
|
|
2197
|
+
}
|
|
2198
|
+
return i2;
|
|
2199
|
+
}
|
|
2200
|
+
const macroName = m[1];
|
|
2201
|
+
const paramNames = m[2].split(",").map((p) => p.trim()).filter(Boolean);
|
|
2202
|
+
const bodyTokens = [];
|
|
2203
|
+
let i = start + 1;
|
|
2204
|
+
while (i < tokens.length) {
|
|
2205
|
+
if (tokens[i][0] === "BLOCK" && tokens[i][1].includes("endmacro")) {
|
|
2206
|
+
i++;
|
|
2207
|
+
break;
|
|
2208
|
+
}
|
|
2209
|
+
bodyTokens.push(tokens[i]);
|
|
2210
|
+
i++;
|
|
2211
|
+
}
|
|
2212
|
+
const engine = this;
|
|
2213
|
+
const capturedContext = { ...context };
|
|
2214
|
+
context[macroName] = (...args) => {
|
|
2215
|
+
const macroCtx = { ...capturedContext };
|
|
2216
|
+
for (let pi = 0; pi < paramNames.length; pi++) {
|
|
2217
|
+
macroCtx[paramNames[pi]] = pi < args.length ? args[pi] : null;
|
|
2218
|
+
}
|
|
2219
|
+
return new SafeString(engine.renderTokens([...bodyTokens], macroCtx));
|
|
2220
|
+
};
|
|
2221
|
+
return i;
|
|
2222
|
+
}
|
|
2223
|
+
handleFromImport(content, context) {
|
|
2224
|
+
const m = content.match(/^from\s+["'](.+?)["']\s+import\s+(.+)/);
|
|
2225
|
+
if (!m) return;
|
|
2226
|
+
const filename = m[1];
|
|
2227
|
+
const names = m[2].split(",").map((n) => n.trim()).filter(Boolean);
|
|
2228
|
+
const source = this.load(filename);
|
|
2229
|
+
const tokens = tokenize(source);
|
|
2230
|
+
let i = 0;
|
|
2231
|
+
while (i < tokens.length) {
|
|
2232
|
+
const [ttype, raw] = tokens[i];
|
|
2233
|
+
if (ttype === "BLOCK") {
|
|
2234
|
+
const [tagContent] = stripTag(raw);
|
|
2235
|
+
const tag = tagContent.split(/\s+/)[0] || "";
|
|
2236
|
+
if (tag === "macro") {
|
|
2237
|
+
const macroM = tagContent.match(/^macro\s+(\w+)\s*\(([^)]*)\)/);
|
|
2238
|
+
if (macroM && names.includes(macroM[1])) {
|
|
2239
|
+
const macroName = macroM[1];
|
|
2240
|
+
const paramNames = macroM[2].split(",").map((p) => p.trim()).filter(Boolean);
|
|
2241
|
+
const bodyTokens = [];
|
|
2242
|
+
i++;
|
|
2243
|
+
while (i < tokens.length) {
|
|
2244
|
+
if (tokens[i][0] === "BLOCK" && tokens[i][1].includes("endmacro")) {
|
|
2245
|
+
i++;
|
|
2246
|
+
break;
|
|
2247
|
+
}
|
|
2248
|
+
bodyTokens.push(tokens[i]);
|
|
2249
|
+
i++;
|
|
2250
|
+
}
|
|
2251
|
+
const capturedBody = [...bodyTokens];
|
|
2252
|
+
const capturedParams = [...paramNames];
|
|
2253
|
+
const capturedCtx = { ...context };
|
|
2254
|
+
const engine = this;
|
|
2255
|
+
context[macroName] = (...args) => {
|
|
2256
|
+
const macroCtx = { ...capturedCtx };
|
|
2257
|
+
for (let pi = 0; pi < capturedParams.length; pi++) {
|
|
2258
|
+
macroCtx[capturedParams[pi]] = pi < args.length ? args[pi] : null;
|
|
2259
|
+
}
|
|
2260
|
+
return new SafeString(engine.renderTokens([...capturedBody], macroCtx));
|
|
2261
|
+
};
|
|
2262
|
+
continue;
|
|
2263
|
+
}
|
|
2264
|
+
}
|
|
2265
|
+
}
|
|
2266
|
+
i++;
|
|
2267
|
+
}
|
|
2268
|
+
}
|
|
2269
|
+
handleCache(tokens, start, context) {
|
|
2270
|
+
const [content] = stripTag(tokens[start][1]);
|
|
2271
|
+
const m = content.match(/^cache\s+["'](.+?)["']\s*(\d+)?/);
|
|
2272
|
+
const cacheKey = m ? m[1] : "default";
|
|
2273
|
+
const ttl = m && m[2] ? parseInt(m[2], 10) : 60;
|
|
2274
|
+
const cached = this.fragmentCache.get(cacheKey);
|
|
2275
|
+
if (cached) {
|
|
2276
|
+
const [htmlContent, expiresAt] = cached;
|
|
2277
|
+
if (Date.now() < expiresAt) {
|
|
2278
|
+
let i2 = start + 1;
|
|
2279
|
+
let depth2 = 0;
|
|
2280
|
+
while (i2 < tokens.length) {
|
|
2281
|
+
if (tokens[i2][0] === "BLOCK") {
|
|
2282
|
+
const [tagContent] = stripTag(tokens[i2][1]);
|
|
2283
|
+
const tag = tagContent.split(/\s+/)[0] || "";
|
|
2284
|
+
if (tag === "cache") depth2++;
|
|
2285
|
+
else if (tag === "endcache") {
|
|
2286
|
+
if (depth2 === 0) return [htmlContent, i2 + 1];
|
|
2287
|
+
depth2--;
|
|
2288
|
+
}
|
|
2289
|
+
}
|
|
2290
|
+
i2++;
|
|
2291
|
+
}
|
|
2292
|
+
return [htmlContent, i2];
|
|
2293
|
+
}
|
|
2294
|
+
}
|
|
2295
|
+
const bodyTokens = [];
|
|
2296
|
+
let i = start + 1;
|
|
2297
|
+
let depth = 0;
|
|
2298
|
+
while (i < tokens.length) {
|
|
2299
|
+
if (tokens[i][0] === "BLOCK") {
|
|
2300
|
+
const [tagContent] = stripTag(tokens[i][1]);
|
|
2301
|
+
const tag = tagContent.split(/\s+/)[0] || "";
|
|
2302
|
+
if (tag === "cache") {
|
|
2303
|
+
depth++;
|
|
2304
|
+
bodyTokens.push(tokens[i]);
|
|
2305
|
+
} else if (tag === "endcache") {
|
|
2306
|
+
if (depth === 0) {
|
|
2307
|
+
i++;
|
|
2308
|
+
break;
|
|
2309
|
+
}
|
|
2310
|
+
depth--;
|
|
2311
|
+
bodyTokens.push(tokens[i]);
|
|
2312
|
+
} else {
|
|
2313
|
+
bodyTokens.push(tokens[i]);
|
|
2314
|
+
}
|
|
2315
|
+
} else {
|
|
2316
|
+
bodyTokens.push(tokens[i]);
|
|
2317
|
+
}
|
|
2318
|
+
i++;
|
|
2319
|
+
}
|
|
2320
|
+
const rendered = this.renderTokens([...bodyTokens], context);
|
|
2321
|
+
this.fragmentCache.set(cacheKey, [rendered, Date.now() + ttl * 1e3]);
|
|
2322
|
+
return [rendered, i];
|
|
2323
|
+
}
|
|
2324
|
+
/**
|
|
2325
|
+
* Handle {% live "name" poll N | sse | ws "path" [src "url"] %}...{% endlive %}.
|
|
2326
|
+
*
|
|
2327
|
+
* Server-rendered live region. The body renders once for first paint, is
|
|
2328
|
+
* registered under <name> so GET /__frond/live/<name> (or a liveSource
|
|
2329
|
+
* provider) can re-render it, and is wrapped in a marker element that
|
|
2330
|
+
* frond.js wires to the chosen transport (poll / sse / ws). Mirrors the
|
|
2331
|
+
* Python master's _handle_live and PHP/Ruby handleLive.
|
|
2332
|
+
*/
|
|
2333
|
+
handleLive(tokens, start, context) {
|
|
2334
|
+
const [content] = stripTag(tokens[start][1]);
|
|
2335
|
+
const m = content.match(LIVE_RE);
|
|
2336
|
+
if (!m) {
|
|
2337
|
+
throw new Error('live: expected {% live "name" poll N | sse | ws "path" %}');
|
|
2338
|
+
}
|
|
2339
|
+
const name = m[1];
|
|
2340
|
+
const rest = (m[2] || "").trim();
|
|
2341
|
+
const parts = rest.split(/\s+/).filter(Boolean);
|
|
2342
|
+
const mode = parts[0] || "";
|
|
2343
|
+
const sm = rest.match(LIVE_SRC_RE);
|
|
2344
|
+
const src = sm ? sm[1] : null;
|
|
2345
|
+
if (src && (src.startsWith("http://") || src.startsWith("https://") || src.startsWith("//"))) {
|
|
2346
|
+
throw new Error("live: src must be a same-origin path, not an absolute URL");
|
|
2347
|
+
}
|
|
2348
|
+
let interval = null;
|
|
2349
|
+
let wsPath = null;
|
|
2350
|
+
if (mode === "poll") {
|
|
2351
|
+
if (!parts[1] || !/^\d+$/.test(parts[1])) {
|
|
2352
|
+
throw new Error('live: poll requires seconds, e.g. {% live "x" poll 5 %}');
|
|
2353
|
+
}
|
|
2354
|
+
interval = parseInt(parts[1], 10);
|
|
2355
|
+
} else if (mode === "sse") {
|
|
2356
|
+
} else if (mode === "ws") {
|
|
2357
|
+
const wm = rest.match(LIVE_WS_RE);
|
|
2358
|
+
if (!wm) {
|
|
2359
|
+
throw new Error('live: ws requires a path, e.g. {% live "x" ws "/ws/x" %}');
|
|
2360
|
+
}
|
|
2361
|
+
wsPath = wm[1];
|
|
2362
|
+
} else {
|
|
2363
|
+
throw new Error(`live: unknown transport "${mode}" (use poll N, sse, or ws "path")`);
|
|
2364
|
+
}
|
|
2365
|
+
const bodyTokens = [];
|
|
2366
|
+
let i = start + 1;
|
|
2367
|
+
while (i < tokens.length) {
|
|
2368
|
+
if (tokens[i][0] === "BLOCK") {
|
|
2369
|
+
const [tagContent] = stripTag(tokens[i][1]);
|
|
2370
|
+
const tag = tagContent.split(/\s+/)[0] || "";
|
|
2371
|
+
if (tag === "live") throw new Error("live: nested live blocks are not supported");
|
|
2372
|
+
if (tag === "endlive") {
|
|
2373
|
+
i++;
|
|
2374
|
+
break;
|
|
2375
|
+
}
|
|
2376
|
+
bodyTokens.push(tokens[i]);
|
|
2377
|
+
} else {
|
|
2378
|
+
bodyTokens.push(tokens[i]);
|
|
2379
|
+
}
|
|
2380
|
+
i++;
|
|
2381
|
+
}
|
|
2382
|
+
_Frond.liveFragments.set(name, bodyTokens.map((t) => t[1]).join(""));
|
|
2383
|
+
const endpoint = src || `/__frond/live/${name}`;
|
|
2384
|
+
const attrs = [`data-frond-live="${liveAttr(name)}"`, `id="live-${liveAttr(name)}"`];
|
|
2385
|
+
if (mode === "poll") {
|
|
2386
|
+
attrs.push('data-mode="poll"', `data-interval="${interval}"`, `data-src="${liveAttr(endpoint)}"`);
|
|
2387
|
+
} else if (mode === "sse") {
|
|
2388
|
+
attrs.push('data-mode="sse"', `data-src="${liveAttr(endpoint)}"`);
|
|
2389
|
+
} else if (mode === "ws") {
|
|
2390
|
+
_Frond.liveWsPaths.set(name, wsPath);
|
|
2391
|
+
attrs.push('data-mode="ws"', `data-ws="${liveAttr(wsPath)}"`);
|
|
2392
|
+
}
|
|
2393
|
+
const firstPaint = this.renderTokens([...bodyTokens], context);
|
|
2394
|
+
return [`<div ${attrs.join(" ")}>${firstPaint}</div>`, i];
|
|
2395
|
+
}
|
|
2396
|
+
// ── Live-block class API (mirrors Python master + PHP/Ruby facades) ──
|
|
2397
|
+
/**
|
|
2398
|
+
* Re-render a registered {% live %} fragment by name with fresh data.
|
|
2399
|
+
* Returns the rendered HTML, or null if no fragment is registered under that
|
|
2400
|
+
* name yet (its page has not rendered). GET /__frond/live/<name> calls this
|
|
2401
|
+
* after resolving the provider data.
|
|
2402
|
+
*/
|
|
2403
|
+
static renderLive(name, data) {
|
|
2404
|
+
const source = _Frond.liveFragments.get(name);
|
|
2405
|
+
if (source === void 0) return null;
|
|
2406
|
+
return new _Frond().renderString(source, data || {});
|
|
2407
|
+
}
|
|
2408
|
+
/** Register a data provider for a {% live %} block. Invoked with the live
|
|
2409
|
+
* request on every refresh so auth re-applies. Mirrors Python's @live_source. */
|
|
2410
|
+
static liveSource(name, fn) {
|
|
2411
|
+
_Frond.liveSources.set(name, fn);
|
|
2412
|
+
}
|
|
2413
|
+
/** The provider registered for a live block, or null. */
|
|
2414
|
+
static getLiveSource(name) {
|
|
2415
|
+
return _Frond.liveSources.get(name) ?? null;
|
|
2416
|
+
}
|
|
2417
|
+
/** Whether a live fragment has been registered (its page rendered). */
|
|
2418
|
+
static hasLiveFragment(name) {
|
|
2419
|
+
return _Frond.liveFragments.has(name);
|
|
2420
|
+
}
|
|
2421
|
+
/** The ws path a live block declared (data-ws), or null. */
|
|
2422
|
+
static getLiveWsPath(name) {
|
|
2423
|
+
return _Frond.liveWsPaths.get(name) ?? null;
|
|
2424
|
+
}
|
|
2425
|
+
/**
|
|
2426
|
+
* Resolve GET /__frond/live/{name}: run the provider with the live request
|
|
2427
|
+
* (auth re-applies), re-render the fragment, and return a pure {status, body}
|
|
2428
|
+
* descriptor the route handler applies to the response. 404 for an unknown
|
|
2429
|
+
* name / unrendered fragment. Mirrors Python's live_endpoint / PHP respondLive.
|
|
2430
|
+
*/
|
|
2431
|
+
static respondLive(req, name) {
|
|
2432
|
+
const provider = _Frond.liveSources.get(name);
|
|
2433
|
+
if (!_Frond.liveFragments.has(name) && provider === void 0) {
|
|
2434
|
+
return { status: 404, body: `live block not found: ${name}` };
|
|
2435
|
+
}
|
|
2436
|
+
let context = {};
|
|
2437
|
+
if (provider !== void 0) {
|
|
2438
|
+
const result = provider(req);
|
|
2439
|
+
context = result && typeof result === "object" ? result : {};
|
|
2440
|
+
}
|
|
2441
|
+
const html = _Frond.renderLive(name, context);
|
|
2442
|
+
if (html === null) {
|
|
2443
|
+
return { status: 404, body: `live fragment not registered yet: ${name}` };
|
|
2444
|
+
}
|
|
2445
|
+
return { status: 200, body: html };
|
|
2446
|
+
}
|
|
2447
|
+
/** Wire the WebSocket broadcaster used by pushLive. Called once by @tina4/core
|
|
2448
|
+
* at server boot (frond is a zero-dep leaf and cannot import core). */
|
|
2449
|
+
static setLiveBroadcaster(fn) {
|
|
2450
|
+
_Frond.liveBroadcaster = fn;
|
|
2451
|
+
}
|
|
2452
|
+
/**
|
|
2453
|
+
* Re-render the '<name>' live fragment and push it to connected clients.
|
|
2454
|
+
* Broadcasts a {type,name,html} envelope over WebSocket to the block's
|
|
2455
|
+
* declared data-ws path (else a room named <name>). Returns the rendered
|
|
2456
|
+
* HTML, or null if the fragment is not registered. Mirrors Python push_live
|
|
2457
|
+
* / PHP pushLive. The broadcast is best-effort — a missing/failed broadcaster
|
|
2458
|
+
* never throws into the caller.
|
|
2459
|
+
*/
|
|
2460
|
+
static pushLive(name, data) {
|
|
2461
|
+
const html = _Frond.renderLive(name, data);
|
|
2462
|
+
if (html === null) return null;
|
|
2463
|
+
if (_Frond.liveBroadcaster) {
|
|
2464
|
+
try {
|
|
2465
|
+
const envelope = JSON.stringify({ type: "live", name, html });
|
|
2466
|
+
_Frond.liveBroadcaster(_Frond.getLiveWsPath(name), name, envelope);
|
|
2467
|
+
} catch {
|
|
2468
|
+
}
|
|
2469
|
+
}
|
|
2470
|
+
return html;
|
|
2471
|
+
}
|
|
2472
|
+
handleSpaceless(tokens, start, context) {
|
|
2473
|
+
const bodyTokens = [];
|
|
2474
|
+
let i = start + 1;
|
|
2475
|
+
let depth = 0;
|
|
2476
|
+
while (i < tokens.length) {
|
|
2477
|
+
if (tokens[i][0] === "BLOCK") {
|
|
2478
|
+
const [tagContent] = stripTag(tokens[i][1]);
|
|
2479
|
+
const tag = tagContent.split(/\s+/)[0] || "";
|
|
2480
|
+
if (tag === "spaceless") {
|
|
2481
|
+
depth++;
|
|
2482
|
+
bodyTokens.push(tokens[i]);
|
|
2483
|
+
} else if (tag === "endspaceless") {
|
|
2484
|
+
if (depth === 0) {
|
|
2485
|
+
i++;
|
|
2486
|
+
break;
|
|
2487
|
+
}
|
|
2488
|
+
depth--;
|
|
2489
|
+
bodyTokens.push(tokens[i]);
|
|
2490
|
+
} else {
|
|
2491
|
+
bodyTokens.push(tokens[i]);
|
|
2492
|
+
}
|
|
2493
|
+
} else {
|
|
2494
|
+
bodyTokens.push(tokens[i]);
|
|
2495
|
+
}
|
|
2496
|
+
i++;
|
|
2497
|
+
}
|
|
2498
|
+
let rendered = this.renderTokens([...bodyTokens], context);
|
|
2499
|
+
rendered = rendered.replace(/>\s+</g, "><");
|
|
2500
|
+
return [rendered, i];
|
|
2501
|
+
}
|
|
2502
|
+
handleAutoescape(tokens, start, context) {
|
|
2503
|
+
const [content] = stripTag(tokens[start][1]);
|
|
2504
|
+
const modeMatch = content.match(/^autoescape\s+(false|true)/);
|
|
2505
|
+
const autoEscapeOn = !(modeMatch && modeMatch[1] === "false");
|
|
2506
|
+
const bodyTokens = [];
|
|
2507
|
+
let i = start + 1;
|
|
2508
|
+
let depth = 0;
|
|
2509
|
+
while (i < tokens.length) {
|
|
2510
|
+
if (tokens[i][0] === "BLOCK") {
|
|
2511
|
+
const [tagContent] = stripTag(tokens[i][1]);
|
|
2512
|
+
const tag = tagContent.split(/\s+/)[0] || "";
|
|
2513
|
+
if (tag === "autoescape") {
|
|
2514
|
+
depth++;
|
|
2515
|
+
bodyTokens.push(tokens[i]);
|
|
2516
|
+
} else if (tag === "endautoescape") {
|
|
2517
|
+
if (depth === 0) {
|
|
2518
|
+
i++;
|
|
2519
|
+
break;
|
|
2520
|
+
}
|
|
2521
|
+
depth--;
|
|
2522
|
+
bodyTokens.push(tokens[i]);
|
|
2523
|
+
} else {
|
|
2524
|
+
bodyTokens.push(tokens[i]);
|
|
2525
|
+
}
|
|
2526
|
+
} else {
|
|
2527
|
+
bodyTokens.push(tokens[i]);
|
|
2528
|
+
}
|
|
2529
|
+
i++;
|
|
2530
|
+
}
|
|
2531
|
+
if (!autoEscapeOn) {
|
|
2532
|
+
const oldAutoEscape = this._autoEscape;
|
|
2533
|
+
this._autoEscape = false;
|
|
2534
|
+
const rendered = this.renderTokens([...bodyTokens], context);
|
|
2535
|
+
this._autoEscape = oldAutoEscape;
|
|
2536
|
+
return [rendered, i];
|
|
2537
|
+
}
|
|
2538
|
+
return [this.renderTokens([...bodyTokens], context), i];
|
|
2539
|
+
}
|
|
2540
|
+
};
|
|
2541
|
+
export {
|
|
2542
|
+
Frond
|
|
2543
|
+
};
|