tina4-nodejs 3.13.133 → 3.13.134

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/CLAUDE.md +3 -3
  2. package/README.md +2 -2
  3. package/package.json +1 -1
  4. package/packages/cli/dist/bin.js +3181 -3051
  5. package/packages/cli/src/commands/generate.ts +33 -22
  6. package/packages/cli/src/commands/lint.ts +77 -111
  7. package/packages/core/dist/index.js +3090 -2952
  8. package/packages/core/src/.tina4-metrics.json +15004 -0
  9. package/packages/core/src/aiClient.ts +199 -161
  10. package/packages/core/src/dispatchPipeline.ts +65 -67
  11. package/packages/core/src/docs.ts +52 -544
  12. package/packages/core/src/docsParser.ts +270 -0
  13. package/packages/core/src/docsScanner.ts +121 -0
  14. package/packages/core/src/docsSignatures.ts +165 -0
  15. package/packages/core/src/index.ts +2 -0
  16. package/packages/core/src/logger.ts +68 -82
  17. package/packages/core/src/mcp.ts +32 -60
  18. package/packages/core/src/messenger.ts +136 -157
  19. package/packages/core/src/middleware.ts +56 -60
  20. package/packages/core/src/plan.ts +78 -70
  21. package/packages/core/src/projectIndex.ts +15 -288
  22. package/packages/core/src/projectIndexExtractors.ts +126 -0
  23. package/packages/core/src/projectIndexStorage.ts +122 -0
  24. package/packages/core/src/push.ts +281 -0
  25. package/packages/core/src/server.ts +182 -183
  26. package/packages/frond/dist/index.js +607 -770
  27. package/packages/frond/src/engine.ts +670 -818
  28. package/packages/orm/dist/index.js +3100 -2965
  29. package/packages/orm/src/adapters/mongodb.ts +99 -144
  30. package/packages/orm/src/baseModel.ts +429 -515
  31. package/packages/orm/src/fakeData.ts +73 -61
  32. package/packages/orm/src/migration.ts +96 -126
  33. package/packages/orm/src/seeder.ts +6 -238
  34. package/packages/orm/src/seederTable.ts +101 -0
  35. package/packages/orm/src/seederTypes.ts +14 -0
  36. package/packages/orm/src/validation.ts +97 -80
  37. package/types/core/src/aiClient.d.ts +5 -0
  38. package/types/core/src/docsParser.d.ts +28 -0
  39. package/types/core/src/docsScanner.d.ts +1 -0
  40. package/types/core/src/docsSignatures.d.ts +11 -0
  41. package/types/core/src/index.d.ts +2 -0
  42. package/types/core/src/messenger.d.ts +8 -0
  43. package/types/core/src/projectIndexExtractors.d.ts +3 -0
  44. package/types/core/src/projectIndexStorage.d.ts +13 -0
  45. package/types/core/src/push.d.ts +45 -0
  46. package/types/frond/src/engine.d.ts +25 -0
  47. package/types/orm/src/fakeData.d.ts +3 -0
  48. package/types/orm/src/seeder.d.ts +3 -89
  49. package/types/orm/src/seederTable.d.ts +9 -0
  50. package/types/orm/src/seederTypes.d.ts +16 -0
@@ -88,6 +88,7 @@ const BLOCK_TAG_ENDS: Record<string, string> = {
88
88
  spaceless: "endspaceless",
89
89
  };
90
90
 
91
+
91
92
  /**
92
93
  * Serialize a value to compact JSON text that is always valid JSON.
93
94
  *
@@ -166,83 +167,56 @@ function jsonSafe(value: unknown): SafeString {
166
167
  * Output is a plain string; callers wrap it in <pre> and mark it safe so
167
168
  * the template engine doesn't double-escape.
168
169
  */
170
+ function inspectPrimitive(value: unknown): { handled: boolean; output: string } {
171
+ if (value === null) return { handled: true, output: "null" };
172
+ if (value === undefined) return { handled: true, output: "undefined" };
173
+ if (typeof value === "string") return { handled: true, output: JSON.stringify(value) };
174
+ if (typeof value === "number" || typeof value === "boolean") return { handled: true, output: String(value) };
175
+ if (typeof value === "bigint") return { handled: true, output: `${value.toString()}n` };
176
+ if (typeof value === "symbol") return { handled: true, output: value.toString() };
177
+ if (typeof value === "function") return { handled: true, output: `[Function: ${value.name || "(anonymous)"}]` };
178
+ return { handled: false, output: "" };
179
+ }
180
+
181
+ function inspectCollection(obj: object, seen: WeakSet<object>, depth: number): string | null {
182
+ if (obj instanceof Map) {
183
+ if (obj.size === 0) return "Map(0) {}";
184
+ const entries = [...obj].map(([key, value]) => `${inspectValue(key, seen, depth + 1)} => ${inspectValue(value, seen, depth + 1)}`);
185
+ return `Map(${obj.size}) { ${entries.join(", ")} }`;
186
+ }
187
+ if (obj instanceof Set) {
188
+ if (obj.size === 0) return "Set(0) {}";
189
+ return `Set(${obj.size}) { ${[...obj].map((value) => inspectValue(value, seen, depth + 1)).join(", ")} }`;
190
+ }
191
+ if (Array.isArray(obj)) {
192
+ if (obj.length === 0) return "[]";
193
+ return `[${obj.map((value) => inspectValue(value, seen, depth + 1)).join(", ")}]`;
194
+ }
195
+ return null;
196
+ }
197
+
198
+ function inspectObject(obj: object, seen: WeakSet<object>, depth: number): string {
199
+ if (obj instanceof Date) return `Date(${obj.toISOString()})`;
200
+ if (obj instanceof RegExp) return obj.toString();
201
+ if (obj instanceof Error) return `${obj.constructor.name}(${JSON.stringify(obj.message)})`;
202
+ const collection = inspectCollection(obj, seen, depth);
203
+ if (collection !== null) return collection;
204
+ const keys = Object.keys(obj);
205
+ const className = obj.constructor && obj.constructor.name !== "Object" ? `${obj.constructor.name} ` : "";
206
+ if (keys.length === 0) return `${className}{}`;
207
+ const props = keys.map((key) => `${key}: ${inspectValue((obj as Record<string, unknown>)[key], seen, depth + 1)}`);
208
+ return `${className}{ ${props.join(", ")} }`;
209
+ }
210
+
169
211
  function inspectValue(value: unknown, seen: WeakSet<object> = new WeakSet(), depth = 0): string {
170
- // Primitives
171
- if (value === null) return "null";
172
- if (value === undefined) return "undefined";
173
- if (typeof value === "string") return JSON.stringify(value);
174
- if (typeof value === "number" || typeof value === "boolean") return String(value);
175
- if (typeof value === "bigint") return `${value.toString()}n`;
176
- if (typeof value === "symbol") return value.toString();
177
- if (typeof value === "function") {
178
- const name = value.name || "(anonymous)";
179
- return `[Function: ${name}]`;
180
- }
181
-
182
- // value is now object (including arrays, Date, Map, Set, etc.)
212
+ const primitive = inspectPrimitive(value);
213
+ if (primitive.handled) return primitive.output;
183
214
  const obj = value as object;
184
-
185
- // Cycle detection
186
215
  if (seen.has(obj)) return "[Circular]";
187
216
  seen.add(obj);
188
-
189
- // Depth cap — prevents runaway recursion on enormous graphs
190
217
  if (depth > 8) return "[...]";
191
-
192
218
  try {
193
- // Date
194
- if (obj instanceof Date) {
195
- return `Date(${obj.toISOString()})`;
196
- }
197
-
198
- // RegExp
199
- if (obj instanceof RegExp) {
200
- return obj.toString();
201
- }
202
-
203
- // Error
204
- if (obj instanceof Error) {
205
- return `${obj.constructor.name}(${JSON.stringify(obj.message)})`;
206
- }
207
-
208
- // Map
209
- if (obj instanceof Map) {
210
- if (obj.size === 0) return "Map(0) {}";
211
- const entries: string[] = [];
212
- for (const [k, v] of obj) {
213
- entries.push(`${inspectValue(k, seen, depth + 1)} => ${inspectValue(v, seen, depth + 1)}`);
214
- }
215
- return `Map(${obj.size}) { ${entries.join(", ")} }`;
216
- }
217
-
218
- // Set
219
- if (obj instanceof Set) {
220
- if (obj.size === 0) return "Set(0) {}";
221
- const items: string[] = [];
222
- for (const v of obj) {
223
- items.push(inspectValue(v, seen, depth + 1));
224
- }
225
- return `Set(${obj.size}) { ${items.join(", ")} }`;
226
- }
227
-
228
- // Array
229
- if (Array.isArray(obj)) {
230
- if (obj.length === 0) return "[]";
231
- const items = obj.map((v) => inspectValue(v, seen, depth + 1));
232
- return `[${items.join(", ")}]`;
233
- }
234
-
235
- // Plain object or class instance
236
- const keys = Object.keys(obj);
237
- const className = obj.constructor && obj.constructor.name !== "Object"
238
- ? `${obj.constructor.name} `
239
- : "";
240
- if (keys.length === 0) return `${className}{}`;
241
- const props = keys.map((k) => {
242
- const v = (obj as Record<string, unknown>)[k];
243
- return `${k}: ${inspectValue(v, seen, depth + 1)}`;
244
- });
245
- return `${className}{ ${props.join(", ")} }`;
219
+ return inspectObject(obj, seen, depth);
246
220
  } finally {
247
221
  seen.delete(obj);
248
222
  }
@@ -274,6 +248,19 @@ function renderDump(value: unknown): SafeString {
274
248
  type TokenType = "TEXT" | "VAR" | "BLOCK" | "COMMENT";
275
249
  type Token = [TokenType, string];
276
250
 
251
+ type MacroDefinition = {
252
+ name: string;
253
+ params: Array<[string, string | null]>;
254
+ bodyTokens: Token[];
255
+ };
256
+ type BlockHandlerResult = { next: number; output?: string };
257
+ type BlockHandler = (
258
+ tokens: Token[],
259
+ start: number,
260
+ content: string,
261
+ context: Record<string, unknown>,
262
+ ) => BlockHandlerResult;
263
+
277
264
  // ── Pre-compiled Regexes (module level) ────────────────────────
278
265
 
279
266
  const NUMERIC_RE = /^-?\d+(\.\d+)?$/;
@@ -288,6 +275,32 @@ const FILTER_WITH_ARGS_RE = /^(\w+)\s*\(([\s\S]*)\)$/;
288
275
  const FILTER_COMPARISON_RE = /^(\w+)\s*(!=|==|>=|<=|>|<)\s*(.+)$/;
289
276
  const TITLE_WORD_RE = /\b\w/g;
290
277
  const STRIP_TAGS_RE = /<[^>]+>/g;
278
+ const FAST_FILTERS: Record<string, (value: unknown) => unknown> = {
279
+ upper: (value) => String(value).toUpperCase(),
280
+ lower: (value) => String(value).toLowerCase(),
281
+ trim: (value) => String(value).trim(),
282
+ length: (value) => Array.isArray(value) ? value.length
283
+ : typeof value === "string" ? value.length
284
+ : typeof value === "object" && value !== null ? Object.keys(value).length : 0,
285
+ capitalize: (value) => { const s = String(value); return s.charAt(0).toUpperCase() + s.slice(1).toLowerCase(); },
286
+ title: (value) => String(value).replace(TITLE_WORD_RE, c => c.toUpperCase()),
287
+ string: (value) => String(value),
288
+ int: (value) => value ? parseInt(String(value), 10) || 0 : 0,
289
+ float: (value) => value ? parseFloat(String(value)) || 0.0 : 0.0,
290
+ abs: (value) => typeof value === "number" ? Math.abs(value) : value,
291
+ striptags: (value) => String(value).replace(STRIP_TAGS_RE, ""),
292
+ first: (value) => Array.isArray(value) ? value[0] ?? null : null,
293
+ last: (value) => Array.isArray(value) ? value[value.length - 1] ?? null : null,
294
+ keys: (value) => typeof value === "object" && value !== null && !Array.isArray(value) ? Object.keys(value) : [],
295
+ values: (value) => typeof value === "object" && value !== null && !Array.isArray(value) ? Object.values(value) : [],
296
+ json_encode: (value) => jsonSafe(value),
297
+ dump: (value) => renderDump(value),
298
+ nl2br: (value) => new SafeString(htmlEscape(String(value)).replace(/\n/g, "<br />\n")),
299
+ unique: (value) => Array.isArray(value) ? [...new Set(value)] : value,
300
+ sort: (value) => Array.isArray(value) ? [...value].sort() : value,
301
+ reverse: (value) => Array.isArray(value) ? [...value].reverse() : String(value).split("").reverse().join(""),
302
+ filter: (value) => Array.isArray(value) ? value.filter(Boolean) : value,
303
+ };
291
304
  // printf-style conversions: %%, plus %[flags][width][.precision]type for the
292
305
  // common types. Matches PHP sprintf / Python % / Ruby format so the `format`
293
306
  // filter renders e.g. `{{ '%.2f' | format(n) }}` as "3.14" across all engines.
@@ -508,159 +521,130 @@ function extendsTarget(source: string): string {
508
521
 
509
522
  // ── Expression Evaluator ───────────────────────────────────────
510
523
 
511
- function resolveVar(expr: string, context: Record<string, unknown>): unknown {
512
- expr = expr.trim();
524
+ function parsePath(expr: string): [string[], boolean[]] {
525
+ const cachedPath = pathParseCache.get(expr);
526
+ if (cachedPath) return cachedPath;
513
527
 
514
- // String literal
515
- if ((expr.startsWith('"') && expr.endsWith('"')) ||
516
- (expr.startsWith("'") && expr.endsWith("'"))) {
517
- return expr.slice(1, -1);
528
+ const parts: string[] = [];
529
+ const fromBracket: boolean[] = [];
530
+ let current = "";
531
+ let depth = 0;
532
+ let inQuote: string | null = null;
533
+ for (let i = 0; i < expr.length; i++) {
534
+ const ch = expr[i];
535
+ if (inQuote) {
536
+ current += ch;
537
+ if (ch === inQuote) inQuote = null;
538
+ continue;
539
+ }
540
+ if (ch === '"' || ch === "'") { inQuote = ch; current += ch; continue; }
541
+ if (ch === '(') { depth++; current += ch; continue; }
542
+ if (ch === ')') { depth--; current += ch; continue; }
543
+ if (ch === '.' && depth === 0) {
544
+ if (current) { parts.push(current); fromBracket.push(false); }
545
+ current = "";
546
+ continue;
547
+ }
548
+ if (ch === '[' && depth === 0) {
549
+ if (current) { parts.push(current); fromBracket.push(false); }
550
+ current = "";
551
+ const end = expr.indexOf(']', i + 1);
552
+ if (end !== -1) {
553
+ parts.push(expr.slice(i + 1, end));
554
+ fromBracket.push(true);
555
+ i = end;
556
+ }
557
+ continue;
558
+ }
559
+ current += ch;
518
560
  }
561
+ if (current) { parts.push(current); fromBracket.push(false); }
562
+ const parsed: [string[], boolean[]] = [parts, fromBracket];
563
+ capCache(pathParseCache, MEMO_CACHE_MAX);
564
+ pathParseCache.set(expr, parsed);
565
+ return parsed;
566
+ }
519
567
 
520
- // Numeric literal
521
- if (NUMERIC_RE.test(expr)) {
522
- return expr.includes(".") ? parseFloat(expr) : parseInt(expr, 10);
523
- }
568
+ function resolveMethodPart(value: unknown, part: string, context: Record<string, unknown>): { matched: boolean; value: unknown } {
569
+ const methodMatch = part.match(METHOD_CALL_RE);
570
+ if (!methodMatch || typeof value !== "object" || value === null) return { matched: false, value };
571
+ const methodName = methodMatch[1];
572
+ const rawArgs = methodMatch[2] || "";
573
+ const fn = (value as Record<string, unknown>)[methodName];
574
+ if (typeof fn !== "function") return { matched: false, value };
575
+ const args = rawArgs.trim() ? splitArgs(rawArgs).map(a => evalExpr(a.trim(), context)) : [];
576
+ return { matched: true, value: fn.apply(value, args) };
577
+ }
524
578
 
525
- // Boolean/null literals
526
- if (expr === "true") return true;
527
- if (expr === "false") return false;
528
- if (expr === "null" || expr === "none" || expr === "None") return null;
579
+ function resolvePathKey(part: string, isBracket: boolean, context: Record<string, unknown>): string | number {
580
+ const isQuotedPart = (part.startsWith('"') && part.endsWith('"')) ||
581
+ (part.startsWith("'") && part.endsWith("'"));
582
+ if (isQuotedPart) return part.slice(1, -1);
583
+ const asNum = parseInt(part, 10);
584
+ if (!isNaN(asNum) && String(asNum) === part) return asNum;
585
+ if (isBracket) {
586
+ const resolved = evalExpr(part, context);
587
+ return resolved !== undefined ? String(resolved) : part;
588
+ }
589
+ return part;
590
+ }
529
591
 
530
- // Array literal [...]
592
+ function resolvePathPart(value: unknown, key: string | number): { found: boolean; value: unknown } {
593
+ if (typeof value !== "object" || value === null) return { found: false, value: null };
594
+ if (Array.isArray(value) && typeof key === "number") return { found: true, value: value[key] };
595
+ if (!(key in (value as Record<string, unknown>))) return { found: false, value: null };
596
+ const member = (value as Record<string, unknown>)[key as string];
597
+ return { found: true, value: typeof member === "function" ? member.call(value) : member };
598
+ }
599
+
600
+ function resolveLiteral(expr: string, context: Record<string, unknown>): { handled: boolean; value: unknown } {
601
+ if ((expr.startsWith('"') && expr.endsWith('"')) || (expr.startsWith("'") && expr.endsWith("'"))) {
602
+ return { handled: true, value: expr.slice(1, -1) };
603
+ }
604
+ if (NUMERIC_RE.test(expr)) return { handled: true, value: expr.includes(".") ? parseFloat(expr) : parseInt(expr, 10) };
605
+ if (expr === "true") return { handled: true, value: true };
606
+ if (expr === "false") return { handled: true, value: false };
607
+ if (expr === "null" || expr === "none" || expr === "None") return { handled: true, value: null };
531
608
  if (expr.startsWith("[") && expr.endsWith("]")) {
532
609
  const inner = expr.slice(1, -1).trim();
533
- if (inner === "") return [];
534
- const items = splitArgs(inner);
535
- return items.map(item => evalExpr(item.trim(), context));
610
+ return { handled: true, value: inner === "" ? [] : splitArgs(inner).map(item => evalExpr(item.trim(), context)) };
536
611
  }
612
+ return { handled: false, value: undefined };
613
+ }
537
614
 
538
- // Dotted path with bracket access — split on . and [...] but not . inside parentheses
539
- // Track which parts came from bracket access (need variable resolution)
540
- let parts: string[];
541
- let fromBracket: boolean[];
615
+ function resolvePathSegment(
616
+ value: unknown,
617
+ part: string,
618
+ isBracket: boolean,
619
+ context: Record<string, unknown>,
620
+ ): { found: boolean; value: unknown } {
621
+ if (value === null || value === undefined) return { found: false, value: null };
622
+ const method = resolveMethodPart(value, part, context);
623
+ if (part.match(METHOD_CALL_RE)) return method.matched ? { found: true, value: method.value } : { found: false, value: null };
624
+ const isQuotedPart = (part.startsWith('"') && part.endsWith('"')) || (part.startsWith("'") && part.endsWith("'"));
625
+ if (isBracket && part.includes(":") && !isQuotedPart) {
626
+ const [rawStart, rawEnd] = part.split(":", 2);
627
+ const start = rawStart.trim() ? parseInt(String(evalExpr(rawStart.trim(), context)), 10) : undefined;
628
+ const end = rawEnd.trim() ? parseInt(String(evalExpr(rawEnd.trim(), context)), 10) : undefined;
629
+ if (Array.isArray(value)) return { found: true, value: value.slice(start ?? 0, end) };
630
+ if (typeof value === "string") return { found: true, value: value.slice(start ?? 0, end) };
631
+ return { found: false, value: null };
632
+ }
633
+ return resolvePathPart(value, resolvePathKey(part, isBracket, context));
634
+ }
542
635
 
543
- const cachedPath = pathParseCache.get(expr);
544
- if (cachedPath) {
545
- [parts, fromBracket] = cachedPath;
546
- } else {
547
- parts = [];
548
- fromBracket = [];
549
- {
550
- let current = "";
551
- let depth = 0;
552
- let inQuote: string | null = null;
553
- for (let i = 0; i < expr.length; i++) {
554
- const ch = expr[i];
555
- if (inQuote) {
556
- current += ch;
557
- if (ch === inQuote) inQuote = null;
558
- continue;
559
- }
560
- if (ch === '"' || ch === "'") { inQuote = ch; current += ch; continue; }
561
- if (ch === '(') { depth++; current += ch; continue; }
562
- if (ch === ')') { depth--; current += ch; continue; }
563
- if (ch === '.' && depth === 0) {
564
- if (current) { parts.push(current); fromBracket.push(false); }
565
- current = "";
566
- continue;
567
- }
568
- if (ch === '[' && depth === 0) {
569
- if (current) { parts.push(current); fromBracket.push(false); }
570
- current = "";
571
- const end = expr.indexOf(']', i + 1);
572
- if (end !== -1) {
573
- parts.push(expr.slice(i + 1, end));
574
- fromBracket.push(true);
575
- i = end;
576
- }
577
- continue;
578
- }
579
- current += ch;
580
- }
581
- if (current) { parts.push(current); fromBracket.push(false); }
582
- }
583
- capCache(pathParseCache, MEMO_CACHE_MAX);
584
- pathParseCache.set(expr, [parts, fromBracket]);
585
- }
636
+ function resolveVar(expr: string, context: Record<string, unknown>): unknown {
637
+ expr = expr.trim();
638
+ const literal = resolveLiteral(expr, context);
639
+ if (literal.handled) return literal.value;
586
640
 
641
+ const [parts, fromBracket] = parsePath(expr);
587
642
  let value: unknown = context;
588
643
  for (let pi = 0; pi < parts.length; pi++) {
589
- const part = parts[pi];
590
- const isBracket = fromBracket[pi];
591
- if (value === null || value === undefined) return null;
592
-
593
- // Check for method call: name(args)
594
- const methodMatch = part.match(METHOD_CALL_RE);
595
- if (methodMatch) {
596
- const methodName = methodMatch[1];
597
- const rawArgs = methodMatch[2] || "";
598
- if (typeof value === "object" && value !== null && methodName in (value as Record<string, unknown>)) {
599
- const fn = (value as Record<string, unknown>)[methodName];
600
- if (typeof fn === "function") {
601
- if (rawArgs.trim()) {
602
- const argParts = splitArgs(rawArgs);
603
- const evalArgs = argParts.map(a => evalExpr(a.trim(), context));
604
- value = fn.apply(value, evalArgs);
605
- } else {
606
- value = fn.call(value);
607
- }
608
- continue;
609
- }
610
- }
611
- return null;
612
- }
613
-
614
- // Slice syntax: value[1:5], value[:10], value[start:end]
615
- const isQuotedPart = (part.startsWith('"') && part.endsWith('"')) ||
616
- (part.startsWith("'") && part.endsWith("'"));
617
- if (isBracket && part.includes(":") && !isQuotedPart) {
618
- const sliceParts = part.split(":", 2);
619
- const sStart = sliceParts[0].trim() ? parseInt(String(evalExpr(sliceParts[0].trim(), context)), 10) : undefined;
620
- const sEnd = sliceParts[1].trim() ? parseInt(String(evalExpr(sliceParts[1].trim(), context)), 10) : undefined;
621
- if (Array.isArray(value)) {
622
- value = (value as unknown[]).slice(sStart ?? 0, sEnd);
623
- } else if (typeof value === "string") {
624
- value = (value as string).slice(sStart ?? 0, sEnd);
625
- } else {
626
- return null;
627
- }
628
- continue;
629
- }
630
-
631
- let key: string | number;
632
- // Check if this part came from bracket access and needs variable resolution
633
- if (isQuotedPart) {
634
- // Quoted string literal — strip quotes
635
- key = part.slice(1, -1);
636
- } else {
637
- const asNum = parseInt(part, 10);
638
- if (!isNaN(asNum) && String(asNum) === part) {
639
- key = asNum;
640
- } else if (isBracket) {
641
- // Only resolve as a variable from context for bracket-derived parts
642
- const resolved = evalExpr(part, context);
643
- key = resolved !== undefined ? String(resolved) : part;
644
- } else {
645
- // Dot-derived parts or root — use the part name directly as the key
646
- key = part;
647
- }
648
- }
649
-
650
- if (typeof value === "object" && value !== null) {
651
- if (Array.isArray(value) && typeof key === "number") {
652
- value = (value as unknown[])[key];
653
- } else if (key in (value as Record<string, unknown>)) {
654
- const v = (value as Record<string, unknown>)[key as string];
655
- value = typeof v === "function" ? v.call(value) : v;
656
- } else {
657
- return null;
658
- }
659
- } else {
660
- return null;
661
- }
644
+ const result = resolvePathSegment(value, parts[pi], fromBracket[pi], context);
645
+ if (!result.found) return null;
646
+ value = result.value;
662
647
  }
663
-
664
648
  return value;
665
649
  }
666
650
 
@@ -1435,6 +1419,55 @@ function numberFormat(
1435
1419
  return decPart ? `${formatted}${decimalPoint}${decPart}` : formatted;
1436
1420
  }
1437
1421
 
1422
+ function formatInteger(type: string, arg: unknown): string {
1423
+ const integer = Math.trunc(Number(arg) || 0);
1424
+ if (type === "d" || type === "i") return String(integer);
1425
+ if (type === "x") return integer.toString(16);
1426
+ if (type === "X") return integer.toString(16).toUpperCase();
1427
+ if (type === "o") return integer.toString(8);
1428
+ return integer.toString(2);
1429
+ }
1430
+
1431
+ function formatDecimal(type: string, precision: number | undefined, arg: unknown): string {
1432
+ const places = precision !== undefined ? precision : 6;
1433
+ if (type === "f" || type === "F") return Number(arg).toFixed(places);
1434
+ if (type === "e" || type === "E") {
1435
+ const out = Number(arg).toExponential(places);
1436
+ return type === "E" ? out.toUpperCase() : out;
1437
+ }
1438
+ return String(Number(arg));
1439
+ }
1440
+
1441
+ function formatValue(type: string, precision: number | undefined, arg: unknown): string {
1442
+ if (type === "s") return String(arg ?? "");
1443
+ if ("dixXob".includes(type)) return formatInteger(type, arg);
1444
+ if ("fFeEgG".includes(type)) return formatDecimal(type, precision, arg);
1445
+ return String(arg ?? "");
1446
+ }
1447
+
1448
+ function padFormattedValue(value: string, flags: string | undefined, width: string | undefined): string {
1449
+ if (!width) return value;
1450
+ const targetWidth = parseInt(width, 10);
1451
+ if (value.length >= targetWidth) return value;
1452
+ const padFlags = flags || "";
1453
+ return padFlags.includes("-")
1454
+ ? value.padEnd(targetWidth, " ")
1455
+ : value.padStart(targetWidth, padFlags.includes("0") ? "0" : " ");
1456
+ }
1457
+
1458
+ function formatMatch(
1459
+ match: string,
1460
+ flags: string | undefined,
1461
+ width: string | undefined,
1462
+ prec: string | undefined,
1463
+ type: string,
1464
+ arg: unknown,
1465
+ ): string {
1466
+ if (match === "%%") return "%";
1467
+ const precision = prec !== undefined ? parseInt(prec, 10) : undefined;
1468
+ return padFormattedValue(formatValue(type, precision, arg), flags, width);
1469
+ }
1470
+
1438
1471
  const BUILTIN_FILTERS: Record<string, FilterFn> = {
1439
1472
  upper: (v) => String(v).toUpperCase(),
1440
1473
  lower: (v) => String(v).toLowerCase(),
@@ -1580,37 +1613,7 @@ const BUILTIN_FILTERS: Record<string, FilterFn> = {
1580
1613
  url_encode: (v) => encodeURIComponent(String(v)),
1581
1614
  format: (v, ...args) => {
1582
1615
  let idx = 0;
1583
- return String(v).replace(FORMAT_RE, (m, flags, width, prec, type) => {
1584
- if (m === "%%") return "%";
1585
- const arg = args[idx++];
1586
- const p = prec !== undefined ? parseInt(String(prec), 10) : undefined;
1587
- let out: string;
1588
- switch (type) {
1589
- case "s": out = String(arg ?? ""); break;
1590
- case "d": case "i": out = String(Math.trunc(Number(arg) || 0)); break;
1591
- case "f": case "F": out = Number(arg).toFixed(p !== undefined ? p : 6); break;
1592
- case "e": case "E": {
1593
- out = Number(arg).toExponential(p !== undefined ? p : 6);
1594
- if (type === "E") out = out.toUpperCase();
1595
- break;
1596
- }
1597
- case "g": case "G": out = String(Number(arg)); break;
1598
- case "x": out = Math.trunc(Number(arg) || 0).toString(16); break;
1599
- case "X": out = Math.trunc(Number(arg) || 0).toString(16).toUpperCase(); break;
1600
- case "o": out = Math.trunc(Number(arg) || 0).toString(8); break;
1601
- case "b": out = Math.trunc(Number(arg) || 0).toString(2); break;
1602
- default: out = String(arg ?? "");
1603
- }
1604
- if (width) {
1605
- const w = parseInt(String(width), 10);
1606
- if (out.length < w) {
1607
- const f = String(flags || "");
1608
- if (f.includes("-")) out = out.padEnd(w, " ");
1609
- else out = out.padStart(w, f.includes("0") ? "0" : " ");
1610
- }
1611
- }
1612
- return out;
1613
- });
1616
+ return String(v).replace(FORMAT_RE, (m, flags, width, prec, type) => formatMatch(m, flags, width, prec, type, args[idx++]));
1614
1617
  },
1615
1618
  dump: (v) => JSON.stringify(v),
1616
1619
  formToken: (v?: unknown) => _generateFormToken(v != null ? String(v) : ""),
@@ -1790,6 +1793,7 @@ export class Frond {
1790
1793
  private _allowedVars: Set<string> | null;
1791
1794
  private fragmentCache: Map<string, [string, number]>;
1792
1795
  private _autoEscape: boolean;
1796
+ private readonly blockHandlers: Record<string, BlockHandler>;
1793
1797
  /**
1794
1798
  * Token pre-compilation cache for file templates.
1795
1799
  *
@@ -1821,6 +1825,47 @@ export class Frond {
1821
1825
  this._allowedVars = null;
1822
1826
  this.fragmentCache = new Map();
1823
1827
  this._autoEscape = true;
1828
+ this.blockHandlers = {
1829
+ if: (tokens, start, _content, context) => {
1830
+ const [output, next] = this.handleIf(tokens, start, context);
1831
+ return { output, next };
1832
+ },
1833
+ for: (tokens, start, _content, context) => {
1834
+ const [output, next] = this.handleFor(tokens, start, context);
1835
+ return { output, next };
1836
+ },
1837
+ set: (tokens, start, content, context) => {
1838
+ if (!content.includes("=")) return { next: this.handleSetBlock(tokens, start, context) };
1839
+ this.handleSet(content, context);
1840
+ return { next: start + 1 };
1841
+ },
1842
+ include: (_tokens, start, content, context) => ({ output: this.handleInclude(content, context), next: start + 1 }),
1843
+ macro: (tokens, start, _content, context) => ({ next: this.handleMacro(tokens, start, context) }),
1844
+ import: (_tokens, start, content, context) => {
1845
+ this.handleImportAs(content, context);
1846
+ return { next: start + 1 };
1847
+ },
1848
+ from: (_tokens, start, content, context) => {
1849
+ this.handleFromImport(content, context);
1850
+ return { next: start + 1 };
1851
+ },
1852
+ cache: (tokens, start, _content, context) => {
1853
+ const [output, next] = this.handleCache(tokens, start, context);
1854
+ return { output, next };
1855
+ },
1856
+ live: (tokens, start, _content, context) => {
1857
+ const [output, next] = this.handleLive(tokens, start, context);
1858
+ return { output, next };
1859
+ },
1860
+ spaceless: (tokens, start, _content, context) => {
1861
+ const [output, next] = this.handleSpaceless(tokens, start, context);
1862
+ return { output, next };
1863
+ },
1864
+ autoescape: (tokens, start, _content, context) => {
1865
+ const [output, next] = this.handleAutoescape(tokens, start, context);
1866
+ return { output, next };
1867
+ },
1868
+ };
1824
1869
 
1825
1870
  // Built-in global functions
1826
1871
  this.globals.formToken = (descriptor?: string) => _generateFormToken(descriptor || "");
@@ -2240,6 +2285,68 @@ export class Frond {
2240
2285
  return this.renderTokens(tokenize(result), context);
2241
2286
  }
2242
2287
 
2288
+ private dispatchBlock(
2289
+ tokens: Token[],
2290
+ start: number,
2291
+ content: string,
2292
+ tag: string,
2293
+ context: Record<string, unknown>,
2294
+ ): BlockHandlerResult {
2295
+ if (!this.tagPermitted(tag)) return { next: this.skipDeniedTag(tokens, start, tag, content) };
2296
+ const handler = this.blockHandlers[tag];
2297
+ if (handler) return handler(tokens, start, content, context);
2298
+ if (tag === "block" || tag === "endblock" || tag === "extends") return { next: start + 1 };
2299
+ if (tag !== "" && !TERMINATOR_TAGS.has(tag)) {
2300
+ throw new Error(
2301
+ `Frond: unknown tag "${tag}" -- known tags are: ${[...KNOWN_TAGS].sort().join(", ")}`,
2302
+ );
2303
+ }
2304
+ return { next: start + 1 };
2305
+ }
2306
+
2307
+ private renderTextToken(tokens: Token[], index: number, output: string[]): number {
2308
+ output.push(tokens[index][1]);
2309
+ return index + 1;
2310
+ }
2311
+
2312
+ private renderVarToken(tokens: Token[], index: number, context: Record<string, unknown>, output: string[]): number {
2313
+ const [content, stripB, stripA] = stripTag(tokens[index][1]);
2314
+ if (stripB && output.length > 0) output[output.length - 1] = output[output.length - 1].replace(TRAILING_WS_RE, "");
2315
+ const result = this.evalVar(content, context);
2316
+ output.push(result !== null && result !== undefined ? String(result) : "");
2317
+ if (stripA && index + 1 < tokens.length && tokens[index + 1][0] === "TEXT") {
2318
+ tokens[index + 1] = ["TEXT", tokens[index + 1][1].replace(LEADING_WS_RE, "")];
2319
+ }
2320
+ return index + 1;
2321
+ }
2322
+
2323
+ private renderBlockToken(tokens: Token[], index: number, context: Record<string, unknown>, output: string[]): number {
2324
+ const [content, stripB, stripA] = stripTag(tokens[index][1]);
2325
+ if (stripB && output.length > 0) output[output.length - 1] = output[output.length - 1].replace(TRAILING_WS_RE, "");
2326
+ const tag = content.split(/\s+/)[0] || "";
2327
+ if (stripA && index + 1 < tokens.length && tokens[index + 1][0] === "TEXT") {
2328
+ tokens[index + 1] = ["TEXT", tokens[index + 1][1].replace(LEADING_WS_RE, "")];
2329
+ }
2330
+ let next: number;
2331
+ if (tag === "if" && this.tagPermitted(tag)) {
2332
+ const [result, after] = this.handleIf(tokens, index, context);
2333
+ output.push(result);
2334
+ next = after;
2335
+ } else if (tag === "for" && this.tagPermitted(tag)) {
2336
+ const [result, after] = this.handleFor(tokens, index, context);
2337
+ output.push(result);
2338
+ next = after;
2339
+ } else {
2340
+ const block = this.dispatchBlock(tokens, index, content, tag, context);
2341
+ if (block.output !== undefined) output.push(block.output);
2342
+ next = block.next;
2343
+ }
2344
+ if (stripA && next < tokens.length && tokens[next][0] === "TEXT") {
2345
+ tokens[next] = ["TEXT", tokens[next][1].replace(LEADING_WS_RE, "")];
2346
+ }
2347
+ return next;
2348
+ }
2349
+
2243
2350
  private renderTokens(tokens: Token[], context: Record<string, unknown>): string {
2244
2351
  // Expose this instance's filter engine to the module-level evalExpr so a
2245
2352
  // filter pipe resolves at any expression depth with the right (custom)
@@ -2251,114 +2358,16 @@ export class Frond {
2251
2358
  let i = 0;
2252
2359
 
2253
2360
  while (i < tokens.length) {
2254
- const [ttype, raw] = tokens[i];
2361
+ const [ttype] = tokens[i];
2255
2362
 
2256
2363
  if (ttype === "TEXT") {
2257
- output.push(raw);
2258
- i++;
2364
+ i = this.renderTextToken(tokens, i, output);
2259
2365
  } else if (ttype === "COMMENT") {
2260
2366
  i++;
2261
2367
  } else if (ttype === "VAR") {
2262
- const [content, stripB, stripA] = stripTag(raw);
2263
- if (stripB && output.length > 0) {
2264
- output[output.length - 1] = output[output.length - 1].replace(TRAILING_WS_RE, "");
2265
- }
2266
-
2267
- const result = this.evalVar(content, context);
2268
- output.push(result !== null && result !== undefined ? String(result) : "");
2269
-
2270
- if (stripA && i + 1 < tokens.length && tokens[i + 1][0] === "TEXT") {
2271
- tokens[i + 1] = ["TEXT", tokens[i + 1][1].replace(LEADING_WS_RE, "")];
2272
- }
2273
- i++;
2368
+ i = this.renderVarToken(tokens, i, context, output);
2274
2369
  } else if (ttype === "BLOCK") {
2275
- const [content, stripB, stripA] = stripTag(raw);
2276
- if (stripB && output.length > 0) {
2277
- output[output.length - 1] = output[output.length - 1].replace(TRAILING_WS_RE, "");
2278
- }
2279
-
2280
- const parts = content.split(/\s+/);
2281
- const tag = parts[0] || "";
2282
-
2283
- // Apply stripA before handlers consume body tokens
2284
- if (stripA && i + 1 < tokens.length && tokens[i + 1][0] === "TEXT") {
2285
- tokens[i + 1] = ["TEXT", tokens[i + 1][1].replace(LEADING_WS_RE, "")];
2286
- }
2287
-
2288
- if (!this.tagPermitted(tag)) {
2289
- // ONE sandbox gate for the whole tag vocabulary. Previously only if, for,
2290
- // set and include were checked, so every other tag ignored the allow-list
2291
- // -- {% autoescape false %} could switch escaping off from inside a
2292
- // sandbox whose tags were restricted to something else entirely.
2293
- i = this.skipDeniedTag(tokens, i, tag, content);
2294
- } else if (tag === "if") {
2295
- const [result, skip] = this.handleIf(tokens, i, context);
2296
- output.push(result);
2297
- i = skip;
2298
- } else if (tag === "for") {
2299
- const [result, skip] = this.handleFor(tokens, i, context);
2300
- output.push(result);
2301
- i = skip;
2302
- } else if (tag === "set") {
2303
- // An assignment has an "="; without one this is the BLOCK form,
2304
- // {% set name %}...{% endset %}, which captures its rendered body. A
2305
- // bare includes() is exact here, not a shortcut: the block form's tag
2306
- // content is only ever "set <name>", so an "=" anywhere -- even inside
2307
- // a quoted value like {% set m = "a = b" %} -- means assignment.
2308
- if (!content.includes("=")) {
2309
- i = this.handleSetBlock(tokens, i, context);
2310
- } else {
2311
- this.handleSet(content, context);
2312
- i++;
2313
- }
2314
- } else if (tag === "include") {
2315
- const result = this.handleInclude(content, context);
2316
- output.push(result);
2317
- i++;
2318
- } else if (tag === "macro") {
2319
- const skip = this.handleMacro(tokens, i, context);
2320
- i = skip;
2321
- } else if (tag === "import") {
2322
- this.handleImportAs(content, context);
2323
- i++;
2324
- } else if (tag === "from") {
2325
- this.handleFromImport(content, context);
2326
- i++;
2327
- } else if (tag === "cache") {
2328
- const [result, skip] = this.handleCache(tokens, i, context);
2329
- output.push(result);
2330
- i = skip;
2331
- } else if (tag === "live") {
2332
- const [result, skip] = this.handleLive(tokens, i, context);
2333
- output.push(result);
2334
- i = skip;
2335
- } else if (tag === "spaceless") {
2336
- const [result, skip] = this.handleSpaceless(tokens, i, context);
2337
- output.push(result);
2338
- i = skip;
2339
- } else if (tag === "autoescape") {
2340
- const [result, skip] = this.handleAutoescape(tokens, i, context);
2341
- output.push(result);
2342
- i = skip;
2343
- } else if (tag === "block" || tag === "endblock" || tag === "extends") {
2344
- i++; // Already handled
2345
- } else {
2346
- i++;
2347
- if (tag !== "" && !TERMINATOR_TAGS.has(tag)) {
2348
- throw new Error(
2349
- `Frond: unknown tag "${tag}" -- known tags are: ${[...KNOWN_TAGS].sort().join(", ")}`,
2350
- );
2351
- }
2352
- // An empty tag ({% %}) or a stray terminator (an {% endif %} with
2353
- // no {% if %}): no output.
2354
- // Malformed, but it has always rendered nothing, and nothing is the
2355
- // safe answer -- unlike an unknown tag it cannot expose content that
2356
- // was meant to be gated.
2357
- }
2358
-
2359
- if (stripA && i < tokens.length && tokens[i][0] === "TEXT") {
2360
- tokens[i] = ["TEXT", tokens[i][1].replace(LEADING_WS_RE, "")];
2361
- }
2370
+ i = this.renderBlockToken(tokens, i, context, output);
2362
2371
  } else {
2363
2372
  i++;
2364
2373
  }
@@ -2393,6 +2402,46 @@ export class Frond {
2393
2402
  return this._allowedTags.has(tag);
2394
2403
  }
2395
2404
 
2405
+ private applyFilterValue(
2406
+ value: unknown,
2407
+ fname: string,
2408
+ args: unknown[],
2409
+ context: Record<string, unknown>,
2410
+ ): unknown {
2411
+ const [realFname, tailPath] = splitFilterNameAndPath(fname);
2412
+ if (tailPath) {
2413
+ let applied = false;
2414
+ if (realFname === "first") {
2415
+ value = Array.isArray(value) ? value[0] ?? null : null;
2416
+ applied = true;
2417
+ } else if (realFname === "last") {
2418
+ value = Array.isArray(value) ? value[value.length - 1] ?? null : null;
2419
+ applied = true;
2420
+ } else if (this.filters[realFname]) {
2421
+ value = this.filters[realFname](value, ...args);
2422
+ applied = true;
2423
+ }
2424
+ if (applied) return evalExpr("__frondFilterTmp." + tailPath, { __frondFilterTmp: value });
2425
+ }
2426
+
2427
+ const fn = this.filters[fname];
2428
+ if (fn) return fn(value, ...args);
2429
+ const comparison = fname.match(FILTER_COMPARISON_RE);
2430
+ if (!comparison) return evalExpr(fname, context);
2431
+ const comparisonFn = this.filters[comparison[1]];
2432
+ if (comparisonFn) value = comparisonFn(value, ...args);
2433
+ const right = evalExpr(comparison[3].trim(), context);
2434
+ switch (comparison[2]) {
2435
+ case "!=": return value !== right;
2436
+ case "==": return value === right;
2437
+ case ">=": return (value as number) >= (right as number);
2438
+ case "<=": return (value as number) <= (right as number);
2439
+ case ">": return (value as number) > (right as number);
2440
+ case "<": return (value as number) < (right as number);
2441
+ default: return value;
2442
+ }
2443
+ }
2444
+
2396
2445
  /**
2397
2446
  * Consume a denied tag WITHOUT running it, returning the index past its body.
2398
2447
  *
@@ -2451,50 +2500,8 @@ export class Frond {
2451
2500
  // unchanged) — same gate as evalVarInner. applyFilters is reached by the
2452
2501
  // folded filter pipe in evalExpr (`x|f ~ y`, #171), so without this gate a
2453
2502
  // non-allow-listed filter would run in sandbox mode.
2454
- if (this._sandbox && this._allowedFilters !== null && !this._allowedFilters.has(fname)) continue;
2455
-
2456
- const [realFname, tailPath] = splitFilterNameAndPath(fname);
2457
- if (tailPath) {
2458
- let applied = false;
2459
- if (realFname === "first") {
2460
- value = Array.isArray(value) ? value[0] ?? null : null;
2461
- applied = true;
2462
- } else if (realFname === "last") {
2463
- value = Array.isArray(value) ? value[value.length - 1] ?? null : null;
2464
- applied = true;
2465
- } else if (this.filters[realFname]) {
2466
- value = this.filters[realFname](value, ...args);
2467
- applied = true;
2468
- }
2469
- if (applied) {
2470
- value = evalExpr("__frondFilterTmp." + tailPath, { __frondFilterTmp: value });
2471
- continue;
2472
- }
2473
- }
2474
-
2475
- const fn = this.filters[fname];
2476
- if (fn) {
2477
- value = fn(value, ...args);
2478
- } else {
2479
- // The filter name may carry a trailing comparison operator, e.g.
2480
- // "length != 1" — apply the real filter, then evaluate the comparison.
2481
- const m = fname.match(FILTER_COMPARISON_RE);
2482
- if (m) {
2483
- const fn2 = this.filters[m[1]];
2484
- if (fn2) value = fn2(value, ...args);
2485
- const right = evalExpr(m[3].trim(), context);
2486
- switch (m[2]) {
2487
- case "!=": value = value !== right; break;
2488
- case "==": value = value === right; break;
2489
- case ">=": value = (value as number) >= (right as number); break;
2490
- case "<=": value = (value as number) <= (right as number); break;
2491
- case ">": value = (value as number) > (right as number); break;
2492
- case "<": value = (value as number) < (right as number); break;
2493
- }
2494
- } else {
2495
- value = evalExpr(fname, context);
2496
- }
2497
- }
2503
+ if (!this.filterPermitted(fname)) continue;
2504
+ value = this.applyFilterValue(value, fname, args, context);
2498
2505
  }
2499
2506
  return value;
2500
2507
  }
@@ -2529,193 +2536,89 @@ export class Frond {
2529
2536
  // unchanged) — same gate as evalVarInner. evalVarRaw is reached by a
2530
2537
  // ternary condition (`x|f ? a : b`), evalComparison, and set, none of
2531
2538
  // which gated filters before, so a non-allow-listed filter could run.
2532
- if (this._sandbox && this._allowedFilters !== null && !this._allowedFilters.has(fname)) continue;
2533
-
2534
- // Filter + property-access chain: `first.groupSummary` — apply
2535
- // the filter, then traverse the path on the result via a
2536
- // synthetic context so evalExpr's dotted resolution does the
2537
- // work. Parity with tina4-python + tina4-php. `first` and
2538
- // `last` are inlined because they're in the fast-path switch
2539
- // rather than `this.filters`.
2540
- const [realFname, tailPath] = splitFilterNameAndPath(fname);
2541
- if (tailPath) {
2542
- let applied = false;
2543
- if (realFname === "first") {
2544
- value = Array.isArray(value) ? value[0] ?? null : null;
2545
- applied = true;
2546
- } else if (realFname === "last") {
2547
- value = Array.isArray(value) ? value[value.length - 1] ?? null : null;
2548
- applied = true;
2549
- } else if (this.filters[realFname]) {
2550
- value = this.filters[realFname](value, ...args);
2551
- applied = true;
2552
- }
2553
- if (applied) {
2554
- value = evalExpr("__frondFilterTmp." + tailPath,
2555
- { __frondFilterTmp: value });
2556
- continue;
2557
- }
2558
- }
2559
-
2560
- const fn = this.filters[fname];
2561
- if (fn) {
2562
- value = fn(value, ...args);
2563
- } else {
2564
- // The filter name may include a trailing comparison operator,
2565
- // e.g. "length != 1". Extract the real filter name and the
2566
- // comparison suffix, apply the filter, then evaluate the comparison.
2567
- const m = fname.match(FILTER_COMPARISON_RE);
2568
- if (m) {
2569
- const realFilter = m[1];
2570
- const op = m[2];
2571
- const rightExpr = m[3].trim();
2572
- const fn2 = this.filters[realFilter];
2573
- if (fn2) {
2574
- value = fn2(value, ...args);
2575
- }
2576
- const right = evalExpr(rightExpr, context);
2577
- switch (op) {
2578
- case "!=": value = value !== right; break;
2579
- case "==": value = value === right; break;
2580
- case ">=": value = (value as number) >= (right as number); break;
2581
- case "<=": value = (value as number) <= (right as number); break;
2582
- case ">": value = (value as number) > (right as number); break;
2583
- case "<": value = (value as number) < (right as number); break;
2584
- }
2585
- } else {
2586
- value = evalExpr(fname, context);
2587
- }
2588
- }
2539
+ if (!this.filterPermitted(fname)) continue;
2540
+ value = this.applyFilterValue(value, fname, args, context);
2589
2541
  }
2590
2542
  return value;
2591
2543
  }
2592
2544
 
2593
- private evalVarInner(expr: string, context: Record<string, unknown>): unknown {
2594
- const [varName, filters] = parseFilterChain(expr);
2595
-
2596
- // Sandbox: check variable access
2597
- if (this._sandbox && this._allowedVars !== null) {
2598
- const rootVar = varName.split(".")[0].split("[")[0].trim();
2599
- if (rootVar && !this._allowedVars.has(rootVar) && rootVar !== "loop") {
2600
- return ""; // Silently block
2545
+ /**
2546
+ * Apply the no-argument filters that are common enough to avoid generic
2547
+ * dispatch. Keeping this table separate from evalVarInner makes the
2548
+ * expression pipeline easier to audit without changing filter order.
2549
+ */
2550
+ private applyFastFilter(name: string, value: unknown): { handled: boolean; value: unknown } {
2551
+ const handler = FAST_FILTERS[name];
2552
+ return handler ? { handled: true, value: handler(value) } : { handled: false, value };
2553
+ }
2554
+
2555
+ private applyRenderedFilter(value: unknown, fname: string, args: unknown[]): unknown {
2556
+ const [realFname, tailPath] = splitFilterNameAndPath(fname);
2557
+ if (tailPath) {
2558
+ let applied = false;
2559
+ if (realFname === "first") {
2560
+ value = Array.isArray(value) ? value[0] ?? null : null;
2561
+ applied = true;
2562
+ } else if (realFname === "last") {
2563
+ value = Array.isArray(value) ? value[value.length - 1] ?? null : null;
2564
+ applied = true;
2565
+ } else if (this.filters[realFname]) {
2566
+ value = this.filters[realFname](value, ...args);
2567
+ applied = true;
2601
2568
  }
2569
+ if (applied) return evalExpr("__frondFilterTmp." + tailPath, { __frondFilterTmp: value });
2602
2570
  }
2603
-
2604
- // Concat precedence (#171): a top-level `~` means the whole expression is a
2605
- // concatenation, and `|` binds TIGHTER than `~`. parseFilterChain above
2606
- // wrongly glued the trailing `~ ...` onto the last filter, so evaluate the
2607
- // WHOLE expression through evalExpr (which resolves the filter pipe at the
2608
- // correct precedence, at any depth) and only auto-escape the result here.
2609
- if (findOutsideQuotes(expr, "~") >= 0) {
2610
- let concatValue = evalExpr(expr, context);
2611
- if (concatValue instanceof SafeString) return concatValue.value;
2612
- if (this._autoEscape && typeof concatValue === "string") {
2613
- concatValue = htmlEscape(concatValue);
2614
- }
2615
- return concatValue;
2571
+ if (args.length === 0) {
2572
+ const fast = this.applyFastFilter(fname, value);
2573
+ if (fast.handled) return fast.value;
2616
2574
  }
2575
+ const fn = this.filters[fname];
2576
+ return fn ? fn(value, ...args) : value;
2577
+ }
2617
2578
 
2618
- let value = evalExpr(varName, context);
2579
+ private variablePermitted(varName: string): boolean {
2580
+ if (!this._sandbox || this._allowedVars === null) return true;
2581
+ const rootVar = varName.split(".")[0].split("[")[0].trim();
2582
+ return !rootVar || rootVar === "loop" || this._allowedVars.has(rootVar);
2583
+ }
2584
+
2585
+ private resolveConcatenation(
2586
+ expr: string,
2587
+ context: Record<string, unknown>,
2588
+ ): { handled: boolean; value: unknown } {
2589
+ if (findOutsideQuotes(expr, "~") < 0) return { handled: false, value: undefined };
2590
+ let value = evalExpr(expr, context);
2591
+ if (value instanceof SafeString) return { handled: true, value: value.value };
2592
+ if (this._autoEscape && typeof value === "string") value = htmlEscape(value);
2593
+ return { handled: true, value };
2594
+ }
2619
2595
 
2620
- let isSafe = false;
2596
+ private applyRenderedFilters(
2597
+ value: unknown,
2598
+ filters: [string, unknown[]][],
2599
+ context: Record<string, unknown>,
2600
+ ): { value: unknown; safe: boolean } {
2601
+ let safe = false;
2621
2602
  for (const [fname, rawArgs] of filters) {
2622
2603
  const args = rawArgs.map((a) => (a instanceof VarRef ? evalExpr(a.name, context) : a));
2623
2604
  if (fname === "raw" || fname === "safe") {
2624
- // Decide from what was permitted to RUN, not from what the source asked
2625
- // for. Marking the value safe here regardless meant a DENIED raw produced
2626
- // byte-identical output to an allowed one -- the allow-list entry that
2627
- // governs XSS escaping did nothing at all.
2628
- if (this.filterPermitted(fname)) isSafe = true;
2605
+ if (this.filterPermitted(fname)) safe = true;
2629
2606
  continue;
2630
2607
  }
2631
- // escape/e filter marks output as safe (already escaped) -- but ONLY when it
2632
- // is permitted to run. Node's escape returns a plain string, so this flag is
2633
- // what suppresses auto-escaping; setting it for a DENIED escape emitted the
2634
- // value unescaped, having never escaped it.
2635
- if (fname === "escape" || fname === "e") {
2636
- if (this.filterPermitted(fname)) isSafe = true;
2637
- }
2638
-
2639
- // Sandbox: check filter access
2640
- if (this._sandbox && this._allowedFilters !== null) {
2641
- if (!this._allowedFilters.has(fname)) {
2642
- continue; // Silently skip blocked filter
2643
- }
2644
- }
2645
-
2646
- // Filter + property-access chain: `first.groupSummary` — apply
2647
- // the filter, then traverse the path on the result via evalExpr.
2648
- // Done BEFORE the inline fast-path so `items|first.name` works
2649
- // whether or not `first` is in the fast-path list.
2650
- //
2651
- // We inline `first` and `last` here because they're defined by
2652
- // the fast-path switch below, not in `this.filters` — without
2653
- // this explicit branch, the chain would fall through and we'd
2654
- // return the unfiltered array. All other registered filters
2655
- // route through `this.filters[realFname]`.
2656
- const [realFname, tailPath] = splitFilterNameAndPath(fname);
2657
- if (tailPath) {
2658
- let applied = false;
2659
- if (realFname === "first") {
2660
- value = Array.isArray(value) ? value[0] ?? null : null;
2661
- applied = true;
2662
- } else if (realFname === "last") {
2663
- value = Array.isArray(value) ? value[value.length - 1] ?? null : null;
2664
- applied = true;
2665
- } else if (this.filters[realFname]) {
2666
- value = this.filters[realFname](value, ...args);
2667
- applied = true;
2668
- }
2669
- if (applied) {
2670
- value = evalExpr("__frondFilterTmp." + tailPath,
2671
- { __frondFilterTmp: value });
2672
- continue;
2673
- }
2674
- }
2675
-
2676
- // Inline fast-path for common no-arg filters — avoids generic dispatch
2677
- if (args.length === 0) {
2678
- switch (fname) {
2679
- case "upper": value = String(value).toUpperCase(); continue;
2680
- case "lower": value = String(value).toLowerCase(); continue;
2681
- case "trim": value = String(value).trim(); continue;
2682
- case "length":
2683
- if (Array.isArray(value)) { value = value.length; }
2684
- else if (typeof value === "string") { value = value.length; }
2685
- else if (typeof value === "object" && value !== null) { value = Object.keys(value).length; }
2686
- else { value = 0; }
2687
- continue;
2688
- case "capitalize": { const s = String(value); value = s.charAt(0).toUpperCase() + s.slice(1).toLowerCase(); continue; }
2689
- case "title": value = String(value).replace(TITLE_WORD_RE, c => c.toUpperCase()); continue;
2690
- case "string": value = String(value); continue;
2691
- case "int": value = value ? parseInt(String(value), 10) || 0 : 0; continue;
2692
- case "float": value = value ? parseFloat(String(value)) || 0.0 : 0.0; continue;
2693
- case "abs": value = typeof value === "number" ? Math.abs(value) : value; continue;
2694
- case "striptags": value = String(value).replace(STRIP_TAGS_RE, ""); continue;
2695
- case "first": value = Array.isArray(value) ? value[0] ?? null : null; continue;
2696
- case "last": value = Array.isArray(value) ? value[value.length - 1] ?? null : null; continue;
2697
- case "keys": value = (typeof value === "object" && value !== null && !Array.isArray(value)) ? Object.keys(value) : []; continue;
2698
- case "values": value = (typeof value === "object" && value !== null && !Array.isArray(value)) ? Object.values(value) : []; continue;
2699
- case "json_encode": value = jsonSafe(value); continue;
2700
- case "dump":
2701
- // Delegates to renderDump(), which is gated on TINA4_DEBUG.
2702
- // In production this emits an empty SafeString (no leaked state).
2703
- value = renderDump(value);
2704
- continue;
2705
- case "nl2br": value = new SafeString(htmlEscape(String(value)).replace(/\n/g, "<br />\n")); continue;
2706
- case "unique": value = Array.isArray(value) ? [...new Set(value)] : value; continue;
2707
- case "sort": value = Array.isArray(value) ? [...value].sort() : value; continue;
2708
- case "reverse": value = Array.isArray(value) ? [...value].reverse() : String(value).split("").reverse().join(""); continue;
2709
- case "filter": value = Array.isArray(value) ? value.filter(Boolean) : value; continue;
2710
- // Not a fast-path filter — fall through to generic dispatch
2711
- }
2712
- }
2713
-
2714
- const fn = this.filters[fname];
2715
- if (fn) {
2716
- value = fn(value, ...args);
2717
- }
2608
+ if ((fname === "escape" || fname === "e") && this.filterPermitted(fname)) safe = true;
2609
+ if (!this.filterPermitted(fname)) continue;
2610
+ value = this.applyRenderedFilter(value, fname, args);
2718
2611
  }
2612
+ return { value, safe };
2613
+ }
2614
+
2615
+ private evalVarInner(expr: string, context: Record<string, unknown>): unknown {
2616
+ const [varName, filters] = parseFilterChain(expr);
2617
+ if (!this.variablePermitted(varName)) return "";
2618
+ const concatenated = this.resolveConcatenation(expr, context);
2619
+ if (concatenated.handled) return concatenated.value;
2620
+ const applied = this.applyRenderedFilters(evalExpr(varName, context), filters, context);
2621
+ let value = applied.value;
2719
2622
 
2720
2623
  // SafeString instances are already rendered/safe
2721
2624
  if (value instanceof SafeString) {
@@ -2723,213 +2626,187 @@ export class Frond {
2723
2626
  }
2724
2627
 
2725
2628
  // Auto-escape HTML unless marked safe or auto-escape is disabled
2726
- if (!isSafe && this._autoEscape && typeof value === "string") {
2629
+ if (!applied.safe && this._autoEscape && typeof value === "string") {
2727
2630
  value = htmlEscape(value);
2728
2631
  }
2729
2632
 
2730
2633
  return value;
2731
2634
  }
2732
2635
 
2733
- private handleIf(tokens: Token[], start: number, context: Record<string, unknown>): [string, number] {
2734
- const [content] = stripTag(tokens[start][1]);
2735
- const conditionExpr = content.slice(3).trim(); // Remove 'if '
2636
+ private pushIfBranch(
2637
+ branches: [string | null, Token[]][],
2638
+ condition: string | null,
2639
+ branchTokens: Token[],
2640
+ stripBefore: boolean,
2641
+ ): void {
2642
+ if (stripBefore && branchTokens.length > 0 && branchTokens[branchTokens.length - 1][0] === "TEXT") {
2643
+ const last = branchTokens[branchTokens.length - 1];
2644
+ branchTokens[branchTokens.length - 1] = ["TEXT", last[1].replace(TRAILING_WS_RE, "")];
2645
+ }
2646
+ branches.push([condition, branchTokens]);
2647
+ }
2736
2648
 
2737
- // Collect branches: [condition, tokens][]
2649
+ private collectIfBranches(
2650
+ tokens: Token[],
2651
+ start: number,
2652
+ conditionExpr: string,
2653
+ ): { branches: [string | null, Token[]][]; next: number } {
2738
2654
  const branches: [string | null, Token[]][] = [];
2739
2655
  let currentTokens: Token[] = [];
2740
2656
  let currentCond: string | null = conditionExpr;
2741
2657
  let depth = 0;
2742
2658
  let i = start + 1;
2743
-
2744
2659
  while (i < tokens.length) {
2745
2660
  const [ttype, raw] = tokens[i];
2746
- if (ttype === "BLOCK") {
2747
- const [tagContent, tagStripB, tagStripA] = stripTag(raw);
2748
- const tag = tagContent.split(/\s+/)[0] || "";
2749
-
2750
- if (tag === "if") {
2751
- depth++;
2752
- currentTokens.push(tokens[i]);
2753
- } else if (tag === "endif" && depth > 0) {
2754
- depth--;
2755
- currentTokens.push(tokens[i]);
2756
- } else if (tag === "endif" && depth === 0) {
2757
- // Strip trailing whitespace from last body token if endif has strip_before
2758
- if (tagStripB && currentTokens.length > 0 && currentTokens[currentTokens.length - 1][0] === "TEXT") {
2759
- currentTokens[currentTokens.length - 1] = ["TEXT", currentTokens[currentTokens.length - 1][1].replace(TRAILING_WS_RE, "")];
2760
- }
2761
- branches.push([currentCond, currentTokens]);
2762
- // Apply stripA on token after endif
2763
- if (tagStripA && i + 1 < tokens.length && tokens[i + 1][0] === "TEXT") {
2764
- tokens[i + 1] = ["TEXT", tokens[i + 1][1].replace(LEADING_WS_RE, "")];
2765
- }
2766
- i++;
2767
- break;
2768
- } else if ((tag === "elseif" || tag === "elif") && depth === 0) {
2769
- if (tagStripB && currentTokens.length > 0 && currentTokens[currentTokens.length - 1][0] === "TEXT") {
2770
- currentTokens[currentTokens.length - 1] = ["TEXT", currentTokens[currentTokens.length - 1][1].replace(TRAILING_WS_RE, "")];
2771
- }
2772
- branches.push([currentCond, currentTokens]);
2773
- currentCond = tagContent.slice(tag.length).trim();
2774
- currentTokens = [];
2775
- } else if (tag === "else" && depth === 0) {
2776
- if (tagStripB && currentTokens.length > 0 && currentTokens[currentTokens.length - 1][0] === "TEXT") {
2777
- currentTokens[currentTokens.length - 1] = ["TEXT", currentTokens[currentTokens.length - 1][1].replace(TRAILING_WS_RE, "")];
2778
- }
2779
- branches.push([currentCond, currentTokens]);
2780
- currentCond = null; // else branch
2781
- currentTokens = [];
2782
- } else {
2783
- currentTokens.push(tokens[i]);
2661
+ if (ttype !== "BLOCK") {
2662
+ currentTokens.push(tokens[i]);
2663
+ i++;
2664
+ continue;
2665
+ }
2666
+ const [tagContent, tagStripB, tagStripA] = stripTag(raw);
2667
+ const tag = tagContent.split(/\s+/)[0] || "";
2668
+ if (tag === "if") {
2669
+ depth++;
2670
+ currentTokens.push(tokens[i]);
2671
+ } else if (tag === "endif" && depth > 0) {
2672
+ depth--;
2673
+ currentTokens.push(tokens[i]);
2674
+ } else if (tag === "endif") {
2675
+ this.pushIfBranch(branches, currentCond, currentTokens, tagStripB);
2676
+ if (tagStripA && i + 1 < tokens.length && tokens[i + 1][0] === "TEXT") {
2677
+ tokens[i + 1] = ["TEXT", tokens[i + 1][1].replace(LEADING_WS_RE, "")];
2784
2678
  }
2679
+ return { branches, next: i + 1 };
2680
+ } else if ((tag === "elseif" || tag === "elif") && depth === 0) {
2681
+ this.pushIfBranch(branches, currentCond, currentTokens, tagStripB);
2682
+ currentCond = tagContent.slice(tag.length).trim();
2683
+ currentTokens = [];
2684
+ } else if (tag === "else" && depth === 0) {
2685
+ this.pushIfBranch(branches, currentCond, currentTokens, tagStripB);
2686
+ currentCond = null;
2687
+ currentTokens = [];
2785
2688
  } else {
2786
2689
  currentTokens.push(tokens[i]);
2787
2690
  }
2788
2691
  i++;
2789
2692
  }
2693
+ return { branches, next: i };
2694
+ }
2695
+
2696
+ private handleIf(tokens: Token[], start: number, context: Record<string, unknown>): [string, number] {
2697
+ const [content] = stripTag(tokens[start][1]);
2698
+ const conditionExpr = content.slice(3).trim(); // Remove 'if '
2699
+ const { branches, next } = this.collectIfBranches(tokens, start, conditionExpr);
2790
2700
 
2791
2701
  // Evaluate branches
2792
2702
  for (const [cond, branchTokens] of branches) {
2793
2703
  if (cond === null || evalComparison(cond, context, this.evalVarRaw.bind(this))) {
2794
- return [this.renderTokens([...branchTokens], context), i];
2704
+ return [this.renderTokens([...branchTokens], context), next];
2795
2705
  }
2796
2706
  }
2797
2707
 
2798
- return ["", i];
2708
+ return ["", next];
2799
2709
  }
2800
2710
 
2801
- private handleFor(tokens: Token[], start: number, context: Record<string, unknown>): [string, number] {
2802
- const [content] = stripTag(tokens[start][1]);
2803
- const forMatch = content.match(/^for\s+(\w+)(?:\s*,\s*(\w+))?\s+in\s+(.+)/);
2804
- if (!forMatch) return ["", start + 1];
2805
-
2806
- const var1 = forMatch[1];
2807
- const var2 = forMatch[2] || null;
2808
- const iterableExpr = forMatch[3].trim();
2809
-
2810
- // Collect body and else tokens
2711
+ private collectForTokens(tokens: Token[], start: number): { bodyTokens: Token[]; elseTokens: Token[]; next: number } {
2811
2712
  const bodyTokens: Token[] = [];
2812
2713
  const elseTokens: Token[] = [];
2813
2714
  let inElse = false;
2814
2715
  let forDepth = 0;
2815
2716
  let ifDepth = 0;
2816
2717
  let i = start + 1;
2817
-
2818
2718
  while (i < tokens.length) {
2819
- const [ttype, raw] = tokens[i];
2820
- if (ttype === "BLOCK") {
2821
- const [tagContent] = stripTag(raw);
2822
- const tag = tagContent.split(/\s+/)[0] || "";
2823
-
2824
- if (tag === "for") {
2825
- forDepth++;
2826
- (inElse ? elseTokens : bodyTokens).push(tokens[i]);
2827
- } else if (tag === "endfor" && forDepth > 0) {
2828
- forDepth--;
2829
- (inElse ? elseTokens : bodyTokens).push(tokens[i]);
2830
- } else if (tag === "endfor" && forDepth === 0) {
2831
- i++;
2832
- break;
2833
- } else if (tag === "if") {
2834
- ifDepth++;
2835
- (inElse ? elseTokens : bodyTokens).push(tokens[i]);
2836
- } else if (tag === "endif") {
2837
- ifDepth--;
2838
- (inElse ? elseTokens : bodyTokens).push(tokens[i]);
2839
- } else if (tag === "else" && forDepth === 0 && ifDepth === 0) {
2840
- inElse = true;
2841
- } else {
2842
- (inElse ? elseTokens : bodyTokens).push(tokens[i]);
2843
- }
2844
- } else {
2845
- (inElse ? elseTokens : bodyTokens).push(tokens[i]);
2719
+ const token = tokens[i];
2720
+ if (token[0] !== "BLOCK") {
2721
+ (inElse ? elseTokens : bodyTokens).push(token);
2722
+ i++;
2723
+ continue;
2724
+ }
2725
+ const [tagContent] = stripTag(token[1]);
2726
+ const tag = tagContent.split(/\s+/)[0] || "";
2727
+ if (tag === "for") forDepth++;
2728
+ else if (tag === "endfor" && forDepth > 0) forDepth--;
2729
+ else if (tag === "endfor") return { bodyTokens, elseTokens, next: i + 1 };
2730
+ else if (tag === "if") ifDepth++;
2731
+ else if (tag === "endif") ifDepth--;
2732
+ else if (tag === "else" && forDepth === 0 && ifDepth === 0) {
2733
+ inElse = true;
2734
+ i++;
2735
+ continue;
2846
2736
  }
2737
+ (inElse ? elseTokens : bodyTokens).push(token);
2847
2738
  i++;
2848
2739
  }
2740
+ return { bodyTokens, elseTokens, next: i };
2741
+ }
2849
2742
 
2850
- // Evaluate iterable
2851
- const iterable = evalExpr(iterableExpr, context);
2743
+ private forItems(iterable: unknown): { items: unknown[]; isDict: boolean } {
2744
+ const isDict = typeof iterable === "object" && iterable !== null && !Array.isArray(iterable);
2745
+ if (isDict) return { items: Object.entries(iterable as Record<string, unknown>), isDict: true };
2746
+ return { items: Array.isArray(iterable) ? iterable : [], isDict: false };
2747
+ }
2852
2748
 
2853
- if (!iterable || (Array.isArray(iterable) && iterable.length === 0) ||
2854
- (typeof iterable === "object" && !Array.isArray(iterable) && Object.keys(iterable as object).length === 0)) {
2855
- if (elseTokens.length > 0) {
2856
- return [this.renderTokens([...elseTokens], context), i];
2857
- }
2858
- return ["", i];
2859
- }
2749
+ private handleFor(tokens: Token[], start: number, context: Record<string, unknown>): [string, number] {
2750
+ const [content] = stripTag(tokens[start][1]);
2751
+ const forMatch = content.match(/^for\s+(\w+)(?:\s*,\s*(\w+))?\s+in\s+(.+)/);
2752
+ if (!forMatch) return ["", start + 1];
2753
+
2754
+ const var1 = forMatch[1];
2755
+ const var2 = forMatch[2] || null;
2756
+ const { bodyTokens, elseTokens, next: i } = this.collectForTokens(tokens, start);
2757
+
2758
+ // Evaluate iterable
2759
+ const iterable = evalExpr(forMatch[3].trim(), context);
2760
+ const { items, isDict } = this.forItems(iterable);
2761
+ if (items.length === 0) return [elseTokens.length ? this.renderTokens([...elseTokens], context) : "", i];
2860
2762
 
2861
- // Iterate
2862
2763
  const output: string[] = [];
2863
- const isDict = typeof iterable === "object" && !Array.isArray(iterable);
2864
- const items = isDict
2865
- ? Object.entries(iterable as Record<string, unknown>)
2866
- : Array.isArray(iterable) ? iterable : [];
2867
- const total = items.length;
2868
2764
 
2869
2765
  // Reusable loop object — mutated each iteration to avoid allocation
2870
- const loopObj = {
2766
+ const loopObj: Record<string, unknown> = {
2871
2767
  index: 0,
2872
2768
  index0: 0,
2873
2769
  first: false,
2874
2770
  last: false,
2875
- length: total,
2771
+ length: items.length,
2876
2772
  revindex: 0,
2877
2773
  revindex0: 0,
2878
2774
  even: false,
2879
2775
  odd: false,
2880
2776
  };
2881
2777
 
2882
- for (let idx = 0; idx < total; idx++) {
2778
+ for (let idx = 0; idx < items.length; idx++) {
2883
2779
  const item = items[idx];
2884
-
2885
- // Update loop object in-place
2886
2780
  loopObj.index = idx + 1;
2887
2781
  loopObj.index0 = idx;
2888
2782
  loopObj.first = idx === 0;
2889
- loopObj.last = idx === total - 1;
2890
- loopObj.revindex = total - idx;
2891
- loopObj.revindex0 = total - idx - 1;
2783
+ loopObj.last = idx === items.length - 1;
2784
+ loopObj.revindex = items.length - idx;
2785
+ loopObj.revindex0 = items.length - idx - 1;
2892
2786
  loopObj.even = (idx + 1) % 2 === 0;
2893
2787
  loopObj.odd = (idx + 1) % 2 !== 0;
2894
-
2895
- // Lazy overlay context: reads from local overrides first, then parent
2896
2788
  const locals: Record<string, unknown> = { loop: loopObj };
2897
-
2898
2789
  if (isDict) {
2899
2790
  const [key, value] = item as [string, unknown];
2900
2791
  locals[var1] = key;
2901
2792
  if (var2) locals[var2] = value;
2793
+ } else if (var2) {
2794
+ locals[var1] = idx;
2795
+ locals[var2] = item;
2902
2796
  } else {
2903
- if (var2) {
2904
- locals[var1] = idx;
2905
- locals[var2] = item;
2906
- } else {
2907
- locals[var1] = item;
2908
- }
2797
+ locals[var1] = item;
2909
2798
  }
2910
-
2911
2799
  const loopCtx = new Proxy(locals, {
2912
- get(target, prop: string) {
2913
- if (prop in target) return target[prop];
2914
- return (context as Record<string, unknown>)[prop];
2915
- },
2916
- set(target, prop: string, value) {
2917
- target[prop] = value;
2918
- return true;
2919
- },
2920
- has(target, prop: string) {
2921
- return prop in target || prop in context;
2922
- },
2923
- ownKeys() {
2924
- return [...new Set([...Object.keys(locals), ...Object.keys(context)])];
2925
- },
2800
+ get(target, prop: string) { return prop in target ? target[prop] : context[prop]; },
2801
+ set(target, prop: string, value) { target[prop] = value; return true; },
2802
+ has(target, prop: string) { return prop in target || prop in context; },
2803
+ ownKeys() { return [...new Set([...Object.keys(locals), ...Object.keys(context)])]; },
2926
2804
  getOwnPropertyDescriptor(target, prop: string) {
2927
2805
  if (prop in target) return { configurable: true, enumerable: true, value: target[prop] };
2928
- if (prop in context) return { configurable: true, enumerable: true, value: (context as Record<string, unknown>)[prop] };
2806
+ if (prop in context) return { configurable: true, enumerable: true, value: context[prop] };
2929
2807
  return undefined;
2930
2808
  },
2931
2809
  }) as Record<string, unknown>;
2932
-
2933
2810
  output.push(this.renderTokens([...bodyTokens], loopCtx));
2934
2811
  }
2935
2812
 
@@ -3072,49 +2949,8 @@ export class Frond {
3072
2949
  const namespace: Record<string, unknown> = {};
3073
2950
 
3074
2951
  const source = this.load(filename);
3075
- const tokens = tokenize(source);
3076
-
3077
- let i = 0;
3078
- while (i < tokens.length) {
3079
- const [ttype, raw] = tokens[i];
3080
- if (ttype === "BLOCK") {
3081
- const [tagContent] = stripTag(raw);
3082
- if ((tagContent.split(/\s+/)[0] || "") === "macro") {
3083
- const macroM = tagContent.match(/^macro\s+(\w+)\s*\(([^)]*)\)/);
3084
- if (macroM) {
3085
- const macroName = macroM[1];
3086
- const params = Frond.parseMacroParams(macroM[2]);
3087
-
3088
- const bodyTokens: Token[] = [];
3089
- i++;
3090
- while (i < tokens.length) {
3091
- if (tokens[i][0] === "BLOCK" && tokens[i][1].includes("endmacro")) {
3092
- i++;
3093
- break;
3094
- }
3095
- bodyTokens.push(tokens[i]);
3096
- i++;
3097
- }
3098
-
3099
- // Own copies per macro — avoids closure-over-loop-variable sharing.
3100
- const capturedBody = [...bodyTokens];
3101
- const capturedParams = [...params];
3102
- const capturedCtx = { ...context };
3103
- const engine = this;
3104
-
3105
- namespace[macroName] = (...args: unknown[]) => {
3106
- const macroCtx: Record<string, unknown> = { ...capturedCtx };
3107
- for (let pi = 0; pi < capturedParams.length; pi++) {
3108
- const [pname, pdefault] = capturedParams[pi];
3109
- macroCtx[pname] = pi < args.length ? args[pi] : pdefault;
3110
- }
3111
- return new SafeString(engine.renderTokens([...capturedBody], macroCtx));
3112
- };
3113
- continue;
3114
- }
3115
- }
3116
- }
3117
- i++;
2952
+ for (const definition of this.collectMacroDefinitions(tokenize(source))) {
2953
+ namespace[definition.name] = this.createMacro(definition, context);
3118
2954
  }
3119
2955
 
3120
2956
  context[alias] = namespace;
@@ -3128,51 +2964,66 @@ export class Frond {
3128
2964
  const names = m[2].split(",").map(n => n.trim()).filter(Boolean);
3129
2965
 
3130
2966
  const source = this.load(filename);
3131
- const tokens = tokenize(source);
2967
+ for (const definition of this.collectMacroDefinitions(tokenize(source))) {
2968
+ if (names.includes(definition.name)) {
2969
+ // Add each selected macro before capturing the next one, preserving the
2970
+ // historical ability for a later macro to call an earlier macro.
2971
+ context[definition.name] = this.createMacro(definition, context);
2972
+ }
2973
+ }
2974
+ }
3132
2975
 
2976
+ private collectMacroDefinitions(tokens: Token[]): MacroDefinition[] {
2977
+ const definitions: MacroDefinition[] = [];
3133
2978
  let i = 0;
3134
2979
  while (i < tokens.length) {
3135
- const [ttype, raw] = tokens[i];
3136
- if (ttype === "BLOCK") {
3137
- const [tagContent] = stripTag(raw);
3138
- const tag = tagContent.split(/\s+/)[0] || "";
3139
- if (tag === "macro") {
3140
- const macroM = tagContent.match(/^macro\s+(\w+)\s*\(([^)]*)\)/);
3141
- if (macroM && names.includes(macroM[1])) {
3142
- const macroName = macroM[1];
3143
- const paramNames = Frond.parseMacroParams(macroM[2]);
3144
-
3145
- const bodyTokens: Token[] = [];
3146
- i++;
3147
- while (i < tokens.length) {
3148
- if (tokens[i][0] === "BLOCK" && tokens[i][1].includes("endmacro")) {
3149
- i++;
3150
- break;
3151
- }
3152
- bodyTokens.push(tokens[i]);
3153
- i++;
3154
- }
2980
+ if (tokens[i][0] !== "BLOCK") {
2981
+ i++;
2982
+ continue;
2983
+ }
2984
+ const [tagContent] = stripTag(tokens[i][1]);
2985
+ if ((tagContent.split(/\s+/)[0] || "") !== "macro") {
2986
+ i++;
2987
+ continue;
2988
+ }
2989
+ const macroMatch = tagContent.match(/^macro\s+(\w+)\s*\(([^)]*)\)/);
2990
+ if (!macroMatch) {
2991
+ i++;
2992
+ continue;
2993
+ }
3155
2994
 
3156
- // Create closure with its own copy of captured values
3157
- const capturedBody = [...bodyTokens];
3158
- const capturedParams = [...paramNames];
3159
- const capturedCtx = { ...context };
3160
- const engine = this;
3161
-
3162
- context[macroName] = (...args: unknown[]) => {
3163
- const macroCtx: Record<string, unknown> = { ...capturedCtx };
3164
- for (let pi = 0; pi < capturedParams.length; pi++) {
3165
- const [pname, pdefault] = capturedParams[pi];
3166
- macroCtx[pname] = pi < args.length ? args[pi] : pdefault;
3167
- }
3168
- return new SafeString(engine.renderTokens([...capturedBody], macroCtx));
3169
- };
3170
- continue;
3171
- }
2995
+ const bodyTokens: Token[] = [];
2996
+ i++;
2997
+ while (i < tokens.length) {
2998
+ if (tokens[i][0] === "BLOCK" && tokens[i][1].includes("endmacro")) {
2999
+ i++;
3000
+ break;
3172
3001
  }
3002
+ bodyTokens.push(tokens[i]);
3003
+ i++;
3173
3004
  }
3174
- i++;
3005
+ definitions.push({
3006
+ name: macroMatch[1],
3007
+ params: Frond.parseMacroParams(macroMatch[2]),
3008
+ bodyTokens,
3009
+ });
3175
3010
  }
3011
+ return definitions;
3012
+ }
3013
+
3014
+ private createMacro(definition: MacroDefinition, context: Record<string, unknown>): (...args: unknown[]) => SafeString {
3015
+ const capturedBody = [...definition.bodyTokens];
3016
+ const capturedParams = [...definition.params];
3017
+ const capturedCtx = { ...context };
3018
+ const engine = this;
3019
+ return (...args: unknown[]) => {
3020
+ const macroCtx: Record<string, unknown> = { ...capturedCtx };
3021
+ for (let pi = 0; pi < capturedParams.length; pi++) {
3022
+ const [pname, pdefault] = capturedParams[pi];
3023
+ macroCtx[pname] = pi < args.length ? args[pi] : pdefault;
3024
+ }
3025
+ return new SafeString(engine.renderTokens([...capturedBody], macroCtx));
3026
+ };
3176
3027
  }
3177
3028
 
3178
3029
  /**
@@ -3279,69 +3130,70 @@ export class Frond {
3279
3130
  }
3280
3131
  const name = m[1];
3281
3132
  const rest = (m[2] || "").trim();
3133
+ const options = this.parseLiveOptions(rest);
3134
+ const [bodyTokens, i] = this.collectLiveBody(tokens, start);
3135
+
3136
+ // Register the raw body source so the auto endpoint can re-render it.
3137
+ Frond.liveFragments.set(name, bodyTokens.map((t) => t[1]).join(""));
3138
+
3139
+ const attrs = this.liveAttributes(name, options);
3140
+
3141
+ const firstPaint = this.renderTokens([...bodyTokens], context);
3142
+ return [`<div ${attrs.join(" ")}>${firstPaint}</div>`, i];
3143
+ }
3144
+
3145
+ private parseLiveOptions(rest: string): { mode: string; src: string | null; interval: number | null; wsPath: string | null } {
3282
3146
  const parts = rest.split(/\s+/).filter(Boolean);
3283
3147
  const mode = parts[0] || "";
3284
-
3285
- const sm = rest.match(LIVE_SRC_RE);
3286
- const src = sm ? sm[1] : null;
3287
- if (src && (src.startsWith("http://") || src.startsWith("https://") || src.startsWith("//"))) {
3148
+ const sourceMatch = rest.match(LIVE_SRC_RE);
3149
+ const src = sourceMatch ? sourceMatch[1] : null;
3150
+ if (src && /^(?:https?:)?\/\//.test(src)) {
3288
3151
  throw new Error("live: src must be a same-origin path, not an absolute URL");
3289
3152
  }
3290
-
3291
- let interval: number | null = null;
3292
- let wsPath: string | null = null;
3293
3153
  if (mode === "poll") {
3294
3154
  if (!parts[1] || !/^\d+$/.test(parts[1])) {
3295
3155
  throw new Error('live: poll requires seconds, e.g. {% live "x" poll 5 %}');
3296
3156
  }
3297
- interval = parseInt(parts[1], 10);
3298
- } else if (mode === "sse") {
3299
- // no extra config
3300
- } else if (mode === "ws") {
3301
- const wm = rest.match(LIVE_WS_RE);
3302
- if (!wm) {
3303
- throw new Error('live: ws requires a path, e.g. {% live "x" ws "/ws/x" %}');
3304
- }
3305
- wsPath = wm[1];
3306
- } else {
3307
- throw new Error(`live: unknown transport "${mode}" (use poll N, sse, or ws "path")`);
3157
+ return { mode, src, interval: parseInt(parts[1], 10), wsPath: null };
3158
+ }
3159
+ if (mode === "sse") return { mode, src, interval: null, wsPath: null };
3160
+ if (mode === "ws") {
3161
+ const wsMatch = rest.match(LIVE_WS_RE);
3162
+ if (!wsMatch) throw new Error('live: ws requires a path, e.g. {% live "x" ws "/ws/x" %}');
3163
+ return { mode, src, interval: null, wsPath: wsMatch[1] };
3308
3164
  }
3165
+ throw new Error(`live: unknown transport "${mode}" (use poll N, sse, or ws "path")`);
3166
+ }
3309
3167
 
3310
- // Collect body tokens up to {% endlive %}. Nested live is unsupported.
3311
- const bodyTokens: Token[] = [];
3168
+ private collectLiveBody(tokens: Token[], start: number): [Token[], number] {
3169
+ const body: Token[] = [];
3312
3170
  let i = start + 1;
3313
3171
  while (i < tokens.length) {
3314
- if (tokens[i][0] === "BLOCK") {
3315
- const [tagContent] = stripTag(tokens[i][1]);
3316
- const tag = tagContent.split(/\s+/)[0] || "";
3317
- if (tag === "live") throw new Error("live: nested live blocks are not supported");
3318
- if (tag === "endlive") {
3319
- i++;
3320
- break;
3321
- }
3322
- bodyTokens.push(tokens[i]);
3323
- } else {
3324
- bodyTokens.push(tokens[i]);
3172
+ if (tokens[i][0] !== "BLOCK") {
3173
+ body.push(tokens[i++]);
3174
+ continue;
3325
3175
  }
3326
- i++;
3176
+ const [tagContent] = stripTag(tokens[i][1]);
3177
+ const tag = tagContent.split(/\s+/)[0] || "";
3178
+ if (tag === "live") throw new Error("live: nested live blocks are not supported");
3179
+ if (tag === "endlive") return [body, i + 1];
3180
+ body.push(tokens[i++]);
3327
3181
  }
3182
+ return [body, i];
3183
+ }
3328
3184
 
3329
- // Register the raw body source so the auto endpoint can re-render it.
3330
- Frond.liveFragments.set(name, bodyTokens.map((t) => t[1]).join(""));
3331
-
3332
- const endpoint = src || `/__frond/live/${name}`;
3185
+ private liveAttributes(name: string, options: { mode: string; src: string | null; interval: number | null; wsPath: string | null }): string[] {
3186
+ const endpoint = options.src || `/__frond/live/${name}`;
3333
3187
  const attrs = [`data-frond-live="${liveAttr(name)}"`, `id="live-${liveAttr(name)}"`];
3334
- if (mode === "poll") {
3335
- attrs.push('data-mode="poll"', `data-interval="${interval}"`, `data-src="${liveAttr(endpoint)}"`);
3336
- } else if (mode === "sse") {
3188
+ if (options.mode === "poll") {
3189
+ attrs.push('data-mode="poll"', `data-interval="${options.interval}"`, `data-src="${liveAttr(endpoint)}"`);
3190
+ } else if (options.mode === "sse") {
3337
3191
  attrs.push('data-mode="sse"', `data-src="${liveAttr(endpoint)}"`);
3338
- } else if (mode === "ws") {
3339
- Frond.liveWsPaths.set(name, wsPath as string);
3340
- attrs.push('data-mode="ws"', `data-ws="${liveAttr(wsPath)}"`);
3192
+ } else {
3193
+ Frond.liveWsPaths.set(name, options.wsPath as string);
3194
+ attrs.push('data-mode="ws"', `data-ws="${liveAttr(options.wsPath)}"`);
3341
3195
  }
3342
-
3343
- const firstPaint = this.renderTokens([...bodyTokens], context);
3344
- return [`<div ${attrs.join(" ")}>${firstPaint}</div>`, i];
3196
+ return attrs;
3345
3197
  }
3346
3198
 
3347
3199
  // ── Live-block class API (mirrors Python master + PHP/Ruby facades) ──