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.
- package/CLAUDE.md +3 -3
- package/README.md +2 -2
- package/package.json +1 -1
- package/packages/cli/dist/bin.js +3181 -3051
- package/packages/cli/src/commands/generate.ts +33 -22
- package/packages/cli/src/commands/lint.ts +77 -111
- package/packages/core/dist/index.js +3090 -2952
- package/packages/core/src/.tina4-metrics.json +15004 -0
- package/packages/core/src/aiClient.ts +199 -161
- package/packages/core/src/dispatchPipeline.ts +65 -67
- package/packages/core/src/docs.ts +52 -544
- package/packages/core/src/docsParser.ts +270 -0
- package/packages/core/src/docsScanner.ts +121 -0
- package/packages/core/src/docsSignatures.ts +165 -0
- package/packages/core/src/index.ts +2 -0
- package/packages/core/src/logger.ts +68 -82
- package/packages/core/src/mcp.ts +32 -60
- package/packages/core/src/messenger.ts +136 -157
- package/packages/core/src/middleware.ts +56 -60
- package/packages/core/src/plan.ts +78 -70
- package/packages/core/src/projectIndex.ts +15 -288
- package/packages/core/src/projectIndexExtractors.ts +126 -0
- package/packages/core/src/projectIndexStorage.ts +122 -0
- package/packages/core/src/push.ts +281 -0
- package/packages/core/src/server.ts +182 -183
- package/packages/frond/dist/index.js +607 -770
- package/packages/frond/src/engine.ts +670 -818
- package/packages/orm/dist/index.js +3100 -2965
- package/packages/orm/src/adapters/mongodb.ts +99 -144
- package/packages/orm/src/baseModel.ts +429 -515
- package/packages/orm/src/fakeData.ts +73 -61
- package/packages/orm/src/migration.ts +96 -126
- package/packages/orm/src/seeder.ts +6 -238
- package/packages/orm/src/seederTable.ts +101 -0
- package/packages/orm/src/seederTypes.ts +14 -0
- package/packages/orm/src/validation.ts +97 -80
- package/types/core/src/aiClient.d.ts +5 -0
- package/types/core/src/docsParser.d.ts +28 -0
- package/types/core/src/docsScanner.d.ts +1 -0
- package/types/core/src/docsSignatures.d.ts +11 -0
- package/types/core/src/index.d.ts +2 -0
- package/types/core/src/messenger.d.ts +8 -0
- package/types/core/src/projectIndexExtractors.d.ts +3 -0
- package/types/core/src/projectIndexStorage.d.ts +13 -0
- package/types/core/src/push.d.ts +45 -0
- package/types/frond/src/engine.d.ts +25 -0
- package/types/orm/src/fakeData.d.ts +3 -0
- package/types/orm/src/seeder.d.ts +3 -89
- package/types/orm/src/seederTable.d.ts +9 -0
- 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
|
-
|
|
171
|
-
if (
|
|
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
|
-
|
|
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
|
|
512
|
-
|
|
524
|
+
function parsePath(expr: string): [string[], boolean[]] {
|
|
525
|
+
const cachedPath = pathParseCache.get(expr);
|
|
526
|
+
if (cachedPath) return cachedPath;
|
|
513
527
|
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
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
|
-
|
|
521
|
-
|
|
522
|
-
|
|
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
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
if (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
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
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
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
|
|
590
|
-
|
|
591
|
-
|
|
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
|
|
2361
|
+
const [ttype] = tokens[i];
|
|
2255
2362
|
|
|
2256
2363
|
if (ttype === "TEXT") {
|
|
2257
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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 (
|
|
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
|
-
|
|
2594
|
-
|
|
2595
|
-
|
|
2596
|
-
|
|
2597
|
-
|
|
2598
|
-
|
|
2599
|
-
|
|
2600
|
-
|
|
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
|
-
|
|
2605
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2632
|
-
|
|
2633
|
-
|
|
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 (!
|
|
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
|
|
2734
|
-
|
|
2735
|
-
|
|
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
|
-
|
|
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
|
|
2747
|
-
|
|
2748
|
-
|
|
2749
|
-
|
|
2750
|
-
|
|
2751
|
-
|
|
2752
|
-
|
|
2753
|
-
|
|
2754
|
-
|
|
2755
|
-
|
|
2756
|
-
|
|
2757
|
-
|
|
2758
|
-
|
|
2759
|
-
|
|
2760
|
-
|
|
2761
|
-
|
|
2762
|
-
|
|
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),
|
|
2704
|
+
return [this.renderTokens([...branchTokens], context), next];
|
|
2795
2705
|
}
|
|
2796
2706
|
}
|
|
2797
2707
|
|
|
2798
|
-
return ["",
|
|
2708
|
+
return ["", next];
|
|
2799
2709
|
}
|
|
2800
2710
|
|
|
2801
|
-
private
|
|
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
|
|
2820
|
-
if (
|
|
2821
|
-
|
|
2822
|
-
|
|
2823
|
-
|
|
2824
|
-
|
|
2825
|
-
|
|
2826
|
-
|
|
2827
|
-
|
|
2828
|
-
|
|
2829
|
-
|
|
2830
|
-
|
|
2831
|
-
|
|
2832
|
-
|
|
2833
|
-
|
|
2834
|
-
|
|
2835
|
-
|
|
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
|
-
|
|
2851
|
-
const
|
|
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
|
-
|
|
2854
|
-
|
|
2855
|
-
|
|
2856
|
-
|
|
2857
|
-
|
|
2858
|
-
|
|
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:
|
|
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 <
|
|
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 ===
|
|
2890
|
-
loopObj.revindex =
|
|
2891
|
-
loopObj.revindex0 =
|
|
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
|
-
|
|
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
|
-
|
|
2914
|
-
|
|
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:
|
|
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
|
|
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
|
|
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
|
-
|
|
3136
|
-
|
|
3137
|
-
|
|
3138
|
-
|
|
3139
|
-
|
|
3140
|
-
|
|
3141
|
-
|
|
3142
|
-
|
|
3143
|
-
|
|
3144
|
-
|
|
3145
|
-
|
|
3146
|
-
|
|
3147
|
-
|
|
3148
|
-
|
|
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
|
-
|
|
3157
|
-
|
|
3158
|
-
|
|
3159
|
-
|
|
3160
|
-
|
|
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
|
-
|
|
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
|
|
3286
|
-
|
|
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
|
|
3298
|
-
}
|
|
3299
|
-
|
|
3300
|
-
|
|
3301
|
-
const
|
|
3302
|
-
if (!
|
|
3303
|
-
|
|
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
|
-
|
|
3311
|
-
const
|
|
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]
|
|
3315
|
-
|
|
3316
|
-
|
|
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
|
-
|
|
3330
|
-
|
|
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
|
|
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) ──
|