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.
@@ -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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
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, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#x27;");
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, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
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
+ };