tina4-nodejs 3.13.149 → 3.13.151
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +2 -2
- package/package.json +5 -9
- package/packages/cli/dist/bin.js +1074 -521
- package/packages/core/dist/index.js +1071 -516
- package/packages/core/public/bundled-component-provenance.json +2 -2
- package/packages/core/public/js/tina4-dev-admin-split.html +202 -0
- package/packages/core/public/js/tina4-dev-admin.min.js +147 -138
- package/packages/core/src/aiClient.ts +2 -1
- package/packages/core/src/api.ts +2 -1
- package/packages/core/src/auth.ts +1 -1
- package/packages/core/src/cache.ts +6 -1
- package/packages/core/src/devAdmin.ts +209 -123
- package/packages/core/src/dispatchPipeline.ts +4 -0
- package/packages/core/src/docs.ts +5 -3
- package/packages/core/src/errorOverlay.ts +18 -9
- package/packages/core/src/index.ts +2 -1
- package/packages/core/src/mcp.ts +53 -16
- package/packages/core/src/messenger.ts +22 -30
- package/packages/core/src/metrics.ts +5 -1
- package/packages/core/src/middleware.ts +8 -1
- package/packages/core/src/optionalPackage.ts +2 -2
- package/packages/core/src/plan.ts +4 -4
- package/packages/core/src/push.ts +1 -1
- package/packages/core/src/request.ts +185 -25
- package/packages/core/src/router.ts +3 -2
- package/packages/core/src/server.ts +32 -10
- package/packages/core/src/session.ts +44 -0
- package/packages/core/src/slashTrim.ts +39 -0
- package/packages/core/src/wsdl.ts +66 -48
- package/packages/core/templates/crud/form.twig +5 -5
- package/packages/core/templates/crud/table.twig +2 -2
- package/packages/core/templates/errors/302.twig +4 -3
- package/packages/frond/dist/index.js +206 -64
- package/packages/frond/src/engine.ts +278 -69
- package/packages/frond/src/index.ts +1 -1
- package/packages/orm/dist/index.js +1055 -502
- package/packages/orm/src/crudRender.ts +5 -5
- package/packages/orm/src/crudTable.ts +1 -1
- package/packages/orm/src/database.ts +15 -29
- package/packages/orm/src/databaseResult.ts +11 -1
- package/packages/orm/src/realtime/index.ts +1 -0
- package/packages/orm/src/realtime/realtime.ts +2 -1
- package/packages/orm/src/realtime/s3Client.ts +276 -0
- package/packages/orm/src/realtime/storage.ts +19 -60
- package/packages/orm/src/sqlTranslator.ts +58 -42
- package/packages/swagger/dist/index.js +25 -4
- package/packages/swagger/src/generator.ts +7 -4
- package/packages/swagger/src/ui.ts +2 -1
- package/types/core/src/devAdmin.d.ts +40 -0
- package/types/core/src/index.d.ts +2 -1
- package/types/core/src/middleware.d.ts +1 -0
- package/types/core/src/optionalPackage.d.ts +2 -2
- package/types/core/src/request.d.ts +5 -1
- package/types/core/src/session.d.ts +25 -0
- package/types/core/src/slashTrim.d.ts +6 -0
- package/types/frond/src/engine.d.ts +11 -0
- package/types/frond/src/index.d.ts +1 -1
- package/types/orm/src/realtime/index.d.ts +1 -0
- package/types/orm/src/realtime/s3Client.d.ts +48 -0
- package/types/orm/src/realtime/storage.d.ts +9 -7
|
@@ -283,6 +283,15 @@ const FILTER_WITH_ARGS_RE = /^(\w+)\s*\(([\s\S]*)\)$/;
|
|
|
283
283
|
const FILTER_COMPARISON_RE = /^(\w+)\s*(!=|==|>=|<=|>|<)\s*(.+)$/;
|
|
284
284
|
const TITLE_WORD_RE = /\b\w/g;
|
|
285
285
|
const STRIP_TAGS_RE = /<[^>]+>/g;
|
|
286
|
+
|
|
287
|
+
// Strip tags to a fixed point: a single pass leaves a tag that only appears once
|
|
288
|
+
// the surrounding one is removed (`<<b>b>` -> `<b>`), which CodeQL flags as
|
|
289
|
+
// incomplete multi-character sanitization. Repeat until nothing more matches.
|
|
290
|
+
function stripTags(input: string): string {
|
|
291
|
+
let s = input, prev: string;
|
|
292
|
+
do { prev = s; s = s.replace(STRIP_TAGS_RE, ""); } while (s !== prev);
|
|
293
|
+
return s;
|
|
294
|
+
}
|
|
286
295
|
const FAST_FILTERS: Record<string, (value: unknown) => unknown> = {
|
|
287
296
|
upper: (value) => String(value).toUpperCase(),
|
|
288
297
|
lower: (value) => String(value).toLowerCase(),
|
|
@@ -296,7 +305,7 @@ const FAST_FILTERS: Record<string, (value: unknown) => unknown> = {
|
|
|
296
305
|
int: (value) => value ? parseInt(String(value), 10) || 0 : 0,
|
|
297
306
|
float: (value) => value ? parseFloat(String(value)) || 0.0 : 0.0,
|
|
298
307
|
abs: (value) => typeof value === "number" ? Math.abs(value) : value,
|
|
299
|
-
striptags: (value) => String(value)
|
|
308
|
+
striptags: (value) => stripTags(String(value)),
|
|
300
309
|
first: (value) => Array.isArray(value) ? value[0] ?? null : null,
|
|
301
310
|
last: (value) => Array.isArray(value) ? value[value.length - 1] ?? null : null,
|
|
302
311
|
keys: (value) => typeof value === "object" && value !== null && !Array.isArray(value) ? Object.keys(value) : [],
|
|
@@ -313,17 +322,20 @@ const FAST_FILTERS: Record<string, (value: unknown) => unknown> = {
|
|
|
313
322
|
// common types. Matches PHP sprintf / Python % / Ruby format so the `format`
|
|
314
323
|
// filter renders e.g. `{{ '%.2f' | format(n) }}` as "3.14" across all engines.
|
|
315
324
|
const FORMAT_RE = /%%|%([-+ 0]*)(\d+)?(?:\.(\d+))?([sdifFeEgGxXob])/g;
|
|
316
|
-
const LEADING_WS_RE = /^\s+/;
|
|
317
|
-
const TRAILING_WS_RE = /\s+$/;
|
|
318
325
|
const THOUSANDS_RE = /\B(?=(\d{3})+(?!\d))/g;
|
|
319
326
|
// {% live "name" poll N | sse | ws "path" [src "url"] %}
|
|
320
327
|
const LIVE_RE = /^live\s+["']([^"']+)["']([\s\S]*)$/;
|
|
321
328
|
const LIVE_WS_RE = /ws\s+["']([^"']+)["']/;
|
|
322
329
|
const LIVE_SRC_RE = /src\s+["']([^"']+)["']/;
|
|
323
|
-
|
|
330
|
+
// The captured path is a single negated class ([^"']+), not .+?, for the same
|
|
331
|
+
// reason LIVE_RE above uses one: V8 backtracks a lazy dot catastrophically when
|
|
332
|
+
// the closing quote never arrives, so `{% extends "` + 100KB of junk would hang
|
|
333
|
+
// the render (js/polynomial-redos). A template path never carries a quote, so
|
|
334
|
+
// the class loses nothing and the match is linear.
|
|
335
|
+
const EXTENDS_RE = /\{%[-\s]*extends\s+["']([^"']+)["']\s*[-]?%\}/;
|
|
324
336
|
// Global-flag twin of EXTENDS_RE purely for counting every occurrence (a
|
|
325
337
|
// non-global RegExp's .exec()/.match() only ever reports the first match).
|
|
326
|
-
const EXTENDS_RE_GLOBAL = /\{%[-\s]*extends\s+["'](
|
|
338
|
+
const EXTENDS_RE_GLOBAL = /\{%[-\s]*extends\s+["']([^"']+)["']\s*[-]?%\}/g;
|
|
327
339
|
|
|
328
340
|
/** Escape a value for a live-marker HTML attribute. Byte-identical order to
|
|
329
341
|
* the Python master / PHP liveAttr / Ruby live_attr so the emitted marker
|
|
@@ -420,47 +432,90 @@ function sweepExpiredCache(cache: Map<string, [string, number]>): void {
|
|
|
420
432
|
}
|
|
421
433
|
|
|
422
434
|
// ── Lexer ──────────────────────────────────────────────────────
|
|
435
|
+
//
|
|
436
|
+
// ReDoS note: this lexer is an INDEX-BASED forward scan, not a backtracking
|
|
437
|
+
// regex. The old `/( \{%…[\s\S]*?…%\} )|…/g` + `/\{%…raw…([\s\S]*?)…endraw…/g`
|
|
438
|
+
// pair ran O(n^2) on V8 for a crafted "many openers, no closer" input (a lazy
|
|
439
|
+
// body re-scanned from every opener), so an ~8 KB template value could hang the
|
|
440
|
+
// event loop for tens of seconds. `indexOf` never backtracks; the per-kind
|
|
441
|
+
// "no more closers" cache below keeps even the pathological case linear.
|
|
442
|
+
|
|
443
|
+
// Raw-block delimiters. These are BOUNDED (fixed keyword, no lazy body), so they
|
|
444
|
+
// cannot backtrack — the ReDoS lived only in the `[\s\S]*?` body, which is gone.
|
|
445
|
+
const RAW_OPEN_RE = /\{%-?\s*raw\s*-?%\}/g;
|
|
446
|
+
const RAW_CLOSE_RE = /\{%-?\s*endraw\s*-?%\}/g;
|
|
447
|
+
|
|
448
|
+
// Extract {% raw %}…{% endraw %} blocks, replacing each with a NUL-wrapped
|
|
449
|
+
// placeholder so its body is never tokenized. First opener → first endraw after
|
|
450
|
+
// it (matches the old non-greedy body). An opener with no endraw anywhere is left
|
|
451
|
+
// in place (it tokenizes as an ordinary {% raw %} BLOCK, exactly as before).
|
|
452
|
+
function extractRawBlocks(source: string): { source: string; rawBlocks: string[] } {
|
|
453
|
+
const rawBlocks: string[] = [];
|
|
454
|
+
let out = "";
|
|
455
|
+
let pos = 0;
|
|
456
|
+
RAW_OPEN_RE.lastIndex = 0;
|
|
457
|
+
let om: RegExpExecArray | null;
|
|
458
|
+
while ((om = RAW_OPEN_RE.exec(source)) !== null) {
|
|
459
|
+
const openStart = om.index;
|
|
460
|
+
const openEnd = RAW_OPEN_RE.lastIndex;
|
|
461
|
+
RAW_CLOSE_RE.lastIndex = openEnd;
|
|
462
|
+
const cm = RAW_CLOSE_RE.exec(source);
|
|
463
|
+
if (cm === null) break; // no endraw after this opener → none after any later one
|
|
464
|
+
const idx = rawBlocks.length;
|
|
465
|
+
rawBlocks.push(source.slice(openEnd, cm.index));
|
|
466
|
+
out += source.slice(pos, openStart) + `\x00RAW_${idx}\x00`;
|
|
467
|
+
pos = RAW_CLOSE_RE.lastIndex;
|
|
468
|
+
RAW_OPEN_RE.lastIndex = pos; // continue after the endraw
|
|
469
|
+
}
|
|
470
|
+
out += source.slice(pos);
|
|
471
|
+
return { source: out, rawBlocks };
|
|
472
|
+
}
|
|
423
473
|
|
|
424
|
-
const
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
474
|
+
const TAG_KINDS: Record<string, { close: string; type: TokenType }> = {
|
|
475
|
+
"%": { close: "%}", type: "BLOCK" },
|
|
476
|
+
"{": { close: "}}", type: "VAR" },
|
|
477
|
+
"#": { close: "#}", type: "COMMENT" },
|
|
478
|
+
};
|
|
428
479
|
|
|
429
|
-
function tokenize(
|
|
480
|
+
function tokenize(rawSource: string): Token[] {
|
|
430
481
|
// 1. Extract raw blocks and replace with placeholders
|
|
431
|
-
const rawBlocks
|
|
432
|
-
source = source.replace(RAW_BLOCK_RE, (_match, content) => {
|
|
433
|
-
const idx = rawBlocks.length;
|
|
434
|
-
rawBlocks.push(content);
|
|
435
|
-
return `\x00RAW_${idx}\x00`;
|
|
436
|
-
});
|
|
482
|
+
const { source, rawBlocks } = extractRawBlocks(rawSource);
|
|
437
483
|
|
|
438
|
-
// 2.
|
|
484
|
+
// 2. Forward index scan — find each '{', classify it, jump to its close.
|
|
439
485
|
const tokens: Token[] = [];
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
486
|
+
const n = source.length;
|
|
487
|
+
let pos = 0; // start of the current TEXT run
|
|
488
|
+
let scan = 0; // where to look for the next '{'
|
|
489
|
+
// Once a close delimiter has no occurrence beyond some point it never will
|
|
490
|
+
// (indexOf is monotonic as `scan` only advances), so remember it and skip that
|
|
491
|
+
// kind in O(1) instead of re-scanning to EOF for every dead opener.
|
|
492
|
+
const exhausted: Record<string, boolean> = { "%}": false, "}}": false, "#}": false };
|
|
493
|
+
|
|
494
|
+
while (scan < n) {
|
|
495
|
+
const brace = source.indexOf("{", scan);
|
|
496
|
+
if (brace === -1) break;
|
|
497
|
+
const kind = TAG_KINDS[source[brace + 1]];
|
|
498
|
+
if (!kind) {
|
|
499
|
+
scan = brace + 1; // a '{' that starts no tag — part of TEXT
|
|
500
|
+
continue;
|
|
448
501
|
}
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
502
|
+
if (exhausted[kind.close]) {
|
|
503
|
+
scan = brace + 1; // no matching close remains — treat as TEXT, keep scanning
|
|
504
|
+
continue;
|
|
505
|
+
}
|
|
506
|
+
const close = source.indexOf(kind.close, brace + 2);
|
|
507
|
+
if (close === -1) {
|
|
508
|
+
exhausted[kind.close] = true;
|
|
509
|
+
scan = brace + 1;
|
|
510
|
+
continue;
|
|
457
511
|
}
|
|
458
|
-
pos
|
|
512
|
+
if (brace > pos) tokens.push(["TEXT", source.slice(pos, brace)]);
|
|
513
|
+
tokens.push([kind.type, source.slice(brace, close + 2)]);
|
|
514
|
+
pos = close + 2;
|
|
515
|
+
scan = close + 2;
|
|
459
516
|
}
|
|
460
517
|
|
|
461
|
-
if (pos < source.
|
|
462
|
-
tokens.push(["TEXT", source.slice(pos)]);
|
|
463
|
-
}
|
|
518
|
+
if (pos < n) tokens.push(["TEXT", source.slice(pos)]);
|
|
464
519
|
|
|
465
520
|
// 3. Restore raw block placeholders as literal TEXT
|
|
466
521
|
if (rawBlocks.length > 0) {
|
|
@@ -844,26 +899,163 @@ const ARITHMETIC_OPERATIONS: Record<string, (left: number, right: number) => num
|
|
|
844
899
|
"**": (left, right) => left ** right,
|
|
845
900
|
};
|
|
846
901
|
|
|
902
|
+
/** Arithmetic precedence levels, loosest first. `**` is right-associative; the rest are left-associative. */
|
|
903
|
+
const ARITHMETIC_LEVELS: string[][] = [["+", "-"], ["*", "//", "/", "%"], ["**"]];
|
|
904
|
+
|
|
905
|
+
/** Every position in expr outside quotes and brackets. */
|
|
906
|
+
function topLevelPositions(expr: string): number[] {
|
|
907
|
+
const positions: number[] = [];
|
|
908
|
+
let inQuote: string | null = null;
|
|
909
|
+
let depth = 0;
|
|
910
|
+
for (let pos = 0; pos < expr.length; pos++) {
|
|
911
|
+
const ch = expr[pos];
|
|
912
|
+
if (inQuote) {
|
|
913
|
+
if (ch === inQuote) inQuote = null;
|
|
914
|
+
} else if (ch === '"' || ch === "'") {
|
|
915
|
+
inQuote = ch;
|
|
916
|
+
} else if ("([{".includes(ch)) {
|
|
917
|
+
depth++;
|
|
918
|
+
} else if (")]}".includes(ch)) {
|
|
919
|
+
depth--;
|
|
920
|
+
} else if (depth === 0) {
|
|
921
|
+
positions.push(pos);
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
return positions;
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
/** True when the text before pos ends in an operand, so an operator there is binary; the `-` in `n * -1` or `-7` is a sign. */
|
|
928
|
+
function followsOperand(expr: string, pos: number): boolean {
|
|
929
|
+
return /[\w)\]'"]$/.test(expr.slice(0, pos).trimEnd());
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
/** [position, operator] for every binary arithmetic operator outside quotes and brackets (`**` and `//` are two characters). */
|
|
933
|
+
function topLevelArithmeticOperators(expr: string): [number, string][] {
|
|
934
|
+
const operators: [number, string][] = [];
|
|
935
|
+
let skipUntil = -1;
|
|
936
|
+
for (const pos of topLevelPositions(expr)) {
|
|
937
|
+
if (pos < skipUntil || !"+-*/%".includes(expr[pos])) continue;
|
|
938
|
+
const pair = expr.slice(pos, pos + 2);
|
|
939
|
+
const op = pair === "**" || pair === "//" ? pair : expr[pos];
|
|
940
|
+
skipUntil = pos + op.length;
|
|
941
|
+
if (followsOperand(expr, pos)) operators.push([pos, op]);
|
|
942
|
+
}
|
|
943
|
+
return operators;
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
/**
|
|
947
|
+
* Pick the operator that binds loosest, so it is evaluated last. Within a
|
|
948
|
+
* left-associative level the RIGHTMOST operator is the split point
|
|
949
|
+
* (`10 - 2 - 3` is `(10 - 2) - 3`); `**` splits at the leftmost.
|
|
950
|
+
*/
|
|
951
|
+
function splitArithmetic(expr: string): [number, string] | null {
|
|
952
|
+
const operators = topLevelArithmeticOperators(expr);
|
|
953
|
+
for (const level of ARITHMETIC_LEVELS) {
|
|
954
|
+
const matches = operators.filter(([, op]) => level.includes(op));
|
|
955
|
+
if (matches.length > 0) return level[0] === "**" ? matches[0] : matches[matches.length - 1];
|
|
956
|
+
}
|
|
957
|
+
return null;
|
|
958
|
+
}
|
|
959
|
+
|
|
847
960
|
function evalArithmeticExpression(expr: string, context: Record<string, unknown>): ExprResult {
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
961
|
+
const split = splitArithmetic(expr);
|
|
962
|
+
if (!split) return EXPR_NOT_MATCHED;
|
|
963
|
+
const [pos, op] = split;
|
|
964
|
+
const lVal = evalExpr(expr.slice(0, pos).trim(), context);
|
|
965
|
+
const rVal = evalExpr(expr.slice(pos + op.length).trim(), context);
|
|
966
|
+
let lNum = lVal != null ? Number(lVal) : 0;
|
|
967
|
+
let rNum = rVal != null ? Number(rVal) : 0;
|
|
968
|
+
if (isNaN(lNum)) lNum = 0;
|
|
969
|
+
if (isNaN(rNum)) rNum = 0;
|
|
970
|
+
return ARITHMETIC_OPERATIONS[op](lNum, rNum);
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
/**
|
|
974
|
+
* Integers from start to end inclusive, counting down when start is greater
|
|
975
|
+
* than end. Backs both range() and `a..b`; [] for a bound that is not a
|
|
976
|
+
* number or a zero step.
|
|
977
|
+
*/
|
|
978
|
+
export function frondRange(start: unknown, end: unknown, step: unknown = 1): number[] {
|
|
979
|
+
const numeric = (value: unknown) => typeof value === "number" || (typeof value === "string" && NUMERIC_RE.test(value));
|
|
980
|
+
if (!numeric(start) || !numeric(end) || !numeric(step)) return [];
|
|
981
|
+
const first = Math.trunc(Number(start));
|
|
982
|
+
const last = Math.trunc(Number(end));
|
|
983
|
+
const stride = Math.abs(Math.trunc(Number(step)));
|
|
984
|
+
if (stride === 0) return [];
|
|
985
|
+
const values: number[] = [];
|
|
986
|
+
if (first <= last) for (let value = first; value <= last; value += stride) values.push(value);
|
|
987
|
+
else for (let value = first; value >= last; value -= stride) values.push(value);
|
|
988
|
+
return values;
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
/**
|
|
992
|
+
* Position of a top-level `..` (not part of `...`), or -1. A pipe after the
|
|
993
|
+
* range (`1..4|join(",")`) applies to the whole range, so it is left to the
|
|
994
|
+
* filter step.
|
|
995
|
+
*/
|
|
996
|
+
function findRangeOperator(expr: string): number {
|
|
997
|
+
let pos = findOutsideQuotes(expr, "..");
|
|
998
|
+
while (pos >= 0) {
|
|
999
|
+
const partOfEllipsis = expr.startsWith("...", pos) || (pos > 0 && expr[pos - 1] === ".");
|
|
1000
|
+
if (pos > 0 && !partOfEllipsis) return findOutsideQuotes(expr.slice(pos + 2), "|") >= 0 ? -1 : pos;
|
|
1001
|
+
const next = findOutsideQuotes(expr.slice(pos + 2), "..");
|
|
1002
|
+
pos = next >= 0 ? pos + 2 + next : -1;
|
|
1003
|
+
}
|
|
1004
|
+
return -1;
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
function evalRangeExpression(expr: string, context: Record<string, unknown>): ExprResult {
|
|
1008
|
+
const pos = findRangeOperator(expr);
|
|
1009
|
+
if (pos < 0) return EXPR_NOT_MATCHED;
|
|
1010
|
+
return frondRange(evalExpr(expr.slice(0, pos).trim(), context), evalExpr(expr.slice(pos + 2).trim(), context));
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
/** Offset of the bracket closing the one that opens expr, skipping quotes; -1 when unbalanced. */
|
|
1014
|
+
function closingBracketIndex(expr: string): number {
|
|
1015
|
+
let depth = 0;
|
|
1016
|
+
let inQuote: string | null = null;
|
|
1017
|
+
for (let i = 0; i < expr.length; i++) {
|
|
1018
|
+
const ch = expr[i];
|
|
1019
|
+
if (inQuote) {
|
|
1020
|
+
if (ch === inQuote) inQuote = null;
|
|
1021
|
+
} else if (ch === '"' || ch === "'") {
|
|
1022
|
+
inQuote = ch;
|
|
1023
|
+
} else if ("([{".includes(ch)) {
|
|
1024
|
+
depth++;
|
|
1025
|
+
} else if (")]}".includes(ch) && --depth === 0) {
|
|
1026
|
+
return i;
|
|
864
1027
|
}
|
|
865
1028
|
}
|
|
866
|
-
return
|
|
1029
|
+
return -1;
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
const BARE_KEY_RE = /^[A-Za-z_]\w*$/;
|
|
1033
|
+
|
|
1034
|
+
/** A bare map key is a string, as in Twig; a quoted key is its text; a parenthesised key is evaluated: `{(name): 1}`. */
|
|
1035
|
+
function mapLiteralKey(rawKey: string, context: Record<string, unknown>): string {
|
|
1036
|
+
const quoted = rawKey.length >= 2 && (rawKey[0] === '"' || rawKey[0] === "'") && rawKey.endsWith(rawKey[0]);
|
|
1037
|
+
if (quoted) return rawKey.slice(1, -1);
|
|
1038
|
+
return BARE_KEY_RE.test(rawKey) ? rawKey : String(evalExpr(rawKey, context));
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
function evalMapLiteral(inner: string, context: Record<string, unknown>): Record<string, unknown> {
|
|
1042
|
+
const map: Record<string, unknown> = {};
|
|
1043
|
+
for (const pair of inner === "" ? [] : splitArgs(inner)) {
|
|
1044
|
+
const colon = findOutsideQuotes(pair, ":");
|
|
1045
|
+
if (colon > 0) map[mapLiteralKey(pair.slice(0, colon).trim(), context)] = evalExpr(pair.slice(colon + 1).trim(), context);
|
|
1046
|
+
}
|
|
1047
|
+
return map;
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
/**
|
|
1051
|
+
* A whole `[...]` or `{...}` literal is one value, so operators inside its
|
|
1052
|
+
* members (`{a: "x" ~ name}`) never split it.
|
|
1053
|
+
*/
|
|
1054
|
+
function evalCollectionLiteral(expr: string, context: Record<string, unknown>): ExprResult {
|
|
1055
|
+
if ((expr[0] !== "[" && expr[0] !== "{") || closingBracketIndex(expr) !== expr.length - 1) return EXPR_NOT_MATCHED;
|
|
1056
|
+
const inner = expr.slice(1, -1).trim();
|
|
1057
|
+
if (expr[0] === "{") return evalMapLiteral(inner, context);
|
|
1058
|
+
return inner === "" ? [] : splitArgs(inner).map(item => evalExpr(item.trim(), context));
|
|
867
1059
|
}
|
|
868
1060
|
|
|
869
1061
|
function evalFilterExpression(expr: string, context: Record<string, unknown>): ExprResult {
|
|
@@ -928,9 +1120,11 @@ function evalFunctionExpression(expr: string, context: Record<string, unknown>):
|
|
|
928
1120
|
}
|
|
929
1121
|
|
|
930
1122
|
const EXPR_EVALUATORS = [
|
|
1123
|
+
evalCollectionLiteral,
|
|
931
1124
|
evalPrimary,
|
|
932
1125
|
evalConditional,
|
|
933
1126
|
evalConcatOrComparison,
|
|
1127
|
+
evalRangeExpression,
|
|
934
1128
|
evalArithmeticExpression,
|
|
935
1129
|
evalFilterExpression,
|
|
936
1130
|
evalFunctionExpression,
|
|
@@ -1357,8 +1551,8 @@ function splitArgs(raw: string): string[] {
|
|
|
1357
1551
|
current += ch;
|
|
1358
1552
|
continue;
|
|
1359
1553
|
}
|
|
1360
|
-
if (ch === "(" || ch === "[") { depth++; current += ch; continue; }
|
|
1361
|
-
if (ch === ")" || ch === "]") { depth--; current += ch; continue; }
|
|
1554
|
+
if (ch === "(" || ch === "[" || ch === "{") { depth++; current += ch; continue; }
|
|
1555
|
+
if (ch === ")" || ch === "]" || ch === "}") { depth--; current += ch; continue; }
|
|
1362
1556
|
if (ch === "," && depth === 0) {
|
|
1363
1557
|
args.push(current.trim());
|
|
1364
1558
|
current = "";
|
|
@@ -1610,8 +1804,8 @@ const BUILTIN_FILTERS: Record<string, FilterFn> = {
|
|
|
1610
1804
|
capitalize: (v) => { const s = String(v); return s.charAt(0).toUpperCase() + s.slice(1).toLowerCase(); },
|
|
1611
1805
|
title: (v) => String(v).replace(TITLE_WORD_RE, c => c.toUpperCase()),
|
|
1612
1806
|
trim: (v) => String(v).trim(),
|
|
1613
|
-
ltrim: (v) => String(v).
|
|
1614
|
-
rtrim: (v) => String(v).
|
|
1807
|
+
ltrim: (v) => String(v).trimStart(),
|
|
1808
|
+
rtrim: (v) => String(v).trimEnd(),
|
|
1615
1809
|
length: (v) => {
|
|
1616
1810
|
if (Array.isArray(v)) return v.length;
|
|
1617
1811
|
if (typeof v === "string") return v.length;
|
|
@@ -1652,7 +1846,7 @@ const BUILTIN_FILTERS: Record<string, FilterFn> = {
|
|
|
1652
1846
|
safe: (v) => v,
|
|
1653
1847
|
escape: (v, ...a) => escapeStrategy(v, (a[0] as string) ?? "html"),
|
|
1654
1848
|
e: (v, ...a) => escapeStrategy(v, (a[0] as string) ?? "html"),
|
|
1655
|
-
striptags: (v) => String(v)
|
|
1849
|
+
striptags: (v) => stripTags(String(v)),
|
|
1656
1850
|
nl2br: (v) => new SafeString(htmlEscape(String(v)).replace(/\n/g, "<br />\n")),
|
|
1657
1851
|
abs: (v) => typeof v === "number" ? Math.abs(v) : v,
|
|
1658
1852
|
round: (v, decimals) => {
|
|
@@ -1768,7 +1962,7 @@ const BUILTIN_FILTERS: Record<string, FilterFn> = {
|
|
|
1768
1962
|
// ── Form Token ────────────────────────────────────────────────
|
|
1769
1963
|
|
|
1770
1964
|
function _b64url(data: Buffer): string {
|
|
1771
|
-
return data.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(
|
|
1965
|
+
return data.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/={1,2}$/, "");
|
|
1772
1966
|
}
|
|
1773
1967
|
|
|
1774
1968
|
/**
|
|
@@ -1794,6 +1988,19 @@ export function setFormTokenSessionId(sessionId: string): void {
|
|
|
1794
1988
|
_formTokenSessionId = sessionId || "";
|
|
1795
1989
|
}
|
|
1796
1990
|
|
|
1991
|
+
// Form-token session provider. Core registers its request-scoped session id
|
|
1992
|
+
// source here, for the same reason as the CSP nonce provider below. Default
|
|
1993
|
+
// returns "" for standalone Frond use.
|
|
1994
|
+
let _formTokenSessionProvider: () => string = () => "";
|
|
1995
|
+
|
|
1996
|
+
/**
|
|
1997
|
+
* Register the function that gives form_token() the current request's session
|
|
1998
|
+
* id, so each token is bound to the session that rendered it (CSRF).
|
|
1999
|
+
*/
|
|
2000
|
+
export function setFormTokenSessionProvider(fn: () => string): void {
|
|
2001
|
+
_formTokenSessionProvider = fn;
|
|
2002
|
+
}
|
|
2003
|
+
|
|
1797
2004
|
// CSP nonce provider (ADR-0088). Frond never imports @tina4/core (that would be
|
|
1798
2005
|
// circular — core depends on frond), so core injects the per-response nonce
|
|
1799
2006
|
// source through this setter. Default returns "" for standalone Frond use.
|
|
@@ -1844,8 +2051,9 @@ function _buildFormTokenJwt(descriptor: string = ""): string {
|
|
|
1844
2051
|
}
|
|
1845
2052
|
|
|
1846
2053
|
// Include session_id for CSRF session binding
|
|
1847
|
-
|
|
1848
|
-
|
|
2054
|
+
const sessionId = _formTokenSessionProvider() || _formTokenSessionId;
|
|
2055
|
+
if (sessionId) {
|
|
2056
|
+
payload.session_id = sessionId;
|
|
1849
2057
|
}
|
|
1850
2058
|
|
|
1851
2059
|
const h = _b64url(Buffer.from(JSON.stringify(header)));
|
|
@@ -2032,6 +2240,7 @@ export class Frond {
|
|
|
2032
2240
|
this.globals.form_token = (descriptor?: string) => _generateFormToken(descriptor || "");
|
|
2033
2241
|
this.globals.formTokenValue = (descriptor?: string) => _generateFormTokenValue(descriptor || "");
|
|
2034
2242
|
this.globals.form_token_value = (descriptor?: string) => _generateFormTokenValue(descriptor || "");
|
|
2243
|
+
this.globals.range = frondRange;
|
|
2035
2244
|
|
|
2036
2245
|
// Debug helper: {{ dump(x) }} — gated on TINA4_DEBUG, see renderDump().
|
|
2037
2246
|
// Available alongside the |dump filter so both styles work:
|
|
@@ -2479,21 +2688,21 @@ export class Frond {
|
|
|
2479
2688
|
|
|
2480
2689
|
private renderVarToken(tokens: Token[], index: number, context: Record<string, unknown>, output: string[]): number {
|
|
2481
2690
|
const [content, stripB, stripA] = stripTag(tokens[index][1]);
|
|
2482
|
-
if (stripB && output.length > 0) output[output.length - 1] = output[output.length - 1].
|
|
2691
|
+
if (stripB && output.length > 0) output[output.length - 1] = output[output.length - 1].trimEnd();
|
|
2483
2692
|
const result = this.evalVar(content, context);
|
|
2484
2693
|
output.push(result !== null && result !== undefined ? String(result) : "");
|
|
2485
2694
|
if (stripA && index + 1 < tokens.length && tokens[index + 1][0] === "TEXT") {
|
|
2486
|
-
tokens[index + 1] = ["TEXT", tokens[index + 1][1].
|
|
2695
|
+
tokens[index + 1] = ["TEXT", tokens[index + 1][1].trimStart()];
|
|
2487
2696
|
}
|
|
2488
2697
|
return index + 1;
|
|
2489
2698
|
}
|
|
2490
2699
|
|
|
2491
2700
|
private renderBlockToken(tokens: Token[], index: number, context: Record<string, unknown>, output: string[]): number {
|
|
2492
2701
|
const [content, stripB, stripA] = stripTag(tokens[index][1]);
|
|
2493
|
-
if (stripB && output.length > 0) output[output.length - 1] = output[output.length - 1].
|
|
2702
|
+
if (stripB && output.length > 0) output[output.length - 1] = output[output.length - 1].trimEnd();
|
|
2494
2703
|
const tag = content.split(/\s+/)[0] || "";
|
|
2495
2704
|
if (stripA && index + 1 < tokens.length && tokens[index + 1][0] === "TEXT") {
|
|
2496
|
-
tokens[index + 1] = ["TEXT", tokens[index + 1][1].
|
|
2705
|
+
tokens[index + 1] = ["TEXT", tokens[index + 1][1].trimStart()];
|
|
2497
2706
|
}
|
|
2498
2707
|
let next: number;
|
|
2499
2708
|
if (tag === "if" && this.tagPermitted(tag)) {
|
|
@@ -2510,7 +2719,7 @@ export class Frond {
|
|
|
2510
2719
|
next = block.next;
|
|
2511
2720
|
}
|
|
2512
2721
|
if (stripA && next < tokens.length && tokens[next][0] === "TEXT") {
|
|
2513
|
-
tokens[next] = ["TEXT", tokens[next][1].
|
|
2722
|
+
tokens[next] = ["TEXT", tokens[next][1].trimStart()];
|
|
2514
2723
|
}
|
|
2515
2724
|
return next;
|
|
2516
2725
|
}
|
|
@@ -2839,7 +3048,7 @@ export class Frond {
|
|
|
2839
3048
|
): void {
|
|
2840
3049
|
if (stripBefore && branchTokens.length > 0 && branchTokens[branchTokens.length - 1][0] === "TEXT") {
|
|
2841
3050
|
const last = branchTokens[branchTokens.length - 1];
|
|
2842
|
-
branchTokens[branchTokens.length - 1] = ["TEXT", last[1].
|
|
3051
|
+
branchTokens[branchTokens.length - 1] = ["TEXT", last[1].trimEnd()];
|
|
2843
3052
|
}
|
|
2844
3053
|
branches.push([condition, branchTokens]);
|
|
2845
3054
|
}
|
|
@@ -2872,7 +3081,7 @@ export class Frond {
|
|
|
2872
3081
|
} else if (tag === "endif") {
|
|
2873
3082
|
this.pushIfBranch(branches, currentCond, currentTokens, tagStripB);
|
|
2874
3083
|
if (tagStripA && i + 1 < tokens.length && tokens[i + 1][0] === "TEXT") {
|
|
2875
|
-
tokens[i + 1] = ["TEXT", tokens[i + 1][1].
|
|
3084
|
+
tokens[i + 1] = ["TEXT", tokens[i + 1][1].trimStart()];
|
|
2876
3085
|
}
|
|
2877
3086
|
return { branches, next: i + 1 };
|
|
2878
3087
|
} else if ((tag === "elseif" || tag === "elif") && depth === 0) {
|
|
@@ -6,5 +6,5 @@ License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
|
6
6
|
file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
export { Frond, escapeHtml, setCspNonceProvider } from "./engine.js";
|
|
9
|
+
export { Frond, escapeHtml, setCspNonceProvider, setFormTokenSessionProvider } from "./engine.js";
|
|
10
10
|
export type { FilterFn, TestFn, LiveProvider, LiveRequest, LiveResponse, LiveBroadcaster } from "./engine.js";
|