tina4-nodejs 3.13.87 → 3.13.89
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/package.json +1 -1
- package/packages/cli/dist/bin.js +119 -6
- package/packages/core/dist/index.js +119 -6
- package/packages/frond/dist/index.js +118 -5
- package/packages/frond/src/engine.ts +159 -5
- package/packages/orm/dist/index.js +119 -6
package/CLAUDE.md
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
# CLAUDE.md - AI Developer Guide for tina4-nodejs (v3.13.
|
|
1
|
+
# CLAUDE.md - AI Developer Guide for tina4-nodejs (v3.13.89)
|
|
2
2
|
|
|
3
3
|
> This file helps AI assistants (Claude, Copilot, Cursor, etc.) understand and work on this codebase effectively.
|
|
4
4
|
|
|
5
5
|
## What This Project Is
|
|
6
6
|
|
|
7
|
-
Tina4 for Node.js/TypeScript v3.13.
|
|
7
|
+
Tina4 for Node.js/TypeScript v3.13.89 - The Intelligent Native Application 4ramework. A convention-over-configuration structural paradigm. The developer writes TypeScript; Tina4 is invisible infrastructure.
|
|
8
8
|
|
|
9
9
|
The philosophy: zero ceremony, batteries included, file system as source of truth.
|
|
10
10
|
|
|
@@ -1244,7 +1244,7 @@ When adding new features, add a corresponding `test/<feature>.test.ts` file.
|
|
|
1244
1244
|
## v3 Features Summary
|
|
1245
1245
|
|
|
1246
1246
|
- **98 built-in features**, zero third-party dependencies
|
|
1247
|
-
- **5,
|
|
1247
|
+
- **5,916 tests** passing across 189 files (build + typecheck green)
|
|
1248
1248
|
- **Race-safe `getNextId()`** with atomic sequence table (`tina4_sequences`) for SQLite/MySQL/MSSQL; PostgreSQL auto-creates sequences
|
|
1249
1249
|
- **Frond template engine optimizations**: pre-compiled regexes, lazy loop context (copy-on-write), filter chain caching, path split caching, inline common filters (11-15% speedup)
|
|
1250
1250
|
- **Production server auto-detect**: `npx tina4nodejs serve --production` auto-uses cluster mode
|
package/package.json
CHANGED
package/packages/cli/dist/bin.js
CHANGED
|
@@ -12786,6 +12786,26 @@ __export(engine_exports, {
|
|
|
12786
12786
|
import { createHash as createHash2, createHmac as createHmac3, randomBytes as randomBytes4 } from "node:crypto";
|
|
12787
12787
|
import { readFileSync as readFileSync6, existsSync as existsSync9, statSync as statSync6 } from "node:fs";
|
|
12788
12788
|
import { join as join11, resolve as resolve7 } from "node:path";
|
|
12789
|
+
function jsonText(value) {
|
|
12790
|
+
try {
|
|
12791
|
+
const text = JSON.stringify(value);
|
|
12792
|
+
return text === void 0 ? "null" : text;
|
|
12793
|
+
} catch {
|
|
12794
|
+
const seen = /* @__PURE__ */ new WeakSet();
|
|
12795
|
+
const text = JSON.stringify(value, (_key, v) => {
|
|
12796
|
+
if (typeof v === "bigint") return v.toString();
|
|
12797
|
+
if (typeof v === "object" && v !== null) {
|
|
12798
|
+
if (seen.has(v)) return null;
|
|
12799
|
+
seen.add(v);
|
|
12800
|
+
}
|
|
12801
|
+
return v;
|
|
12802
|
+
});
|
|
12803
|
+
return text === void 0 ? "null" : text;
|
|
12804
|
+
}
|
|
12805
|
+
}
|
|
12806
|
+
function jsonSafe(value) {
|
|
12807
|
+
return new SafeString(jsonText(value).replace(JSON_UNSAFE_RE, (c) => JSON_UNSAFE_MAP[c]));
|
|
12808
|
+
}
|
|
12789
12809
|
function inspectValue(value, seen = /* @__PURE__ */ new WeakSet(), depth = 0) {
|
|
12790
12810
|
if (value === null) return "null";
|
|
12791
12811
|
if (value === void 0) return "undefined";
|
|
@@ -13773,7 +13793,7 @@ function _generateFormToken(descriptor = "") {
|
|
|
13773
13793
|
function _generateFormTokenValue(descriptor = "") {
|
|
13774
13794
|
return new SafeString(_buildFormTokenJwt(descriptor));
|
|
13775
13795
|
}
|
|
13776
|
-
var SafeString, NUMERIC_RE, METHOD_CALL_RE, FN_CALL_RE, IS_NOT_RE, IS_RE, NOT_IN_RE, IN_RE, DIVISIBLE_BY_RE, FILTER_WITH_ARGS_RE, FILTER_COMPARISON_RE, TITLE_WORD_RE, STRIP_TAGS_RE, FORMAT_RE, LEADING_WS_RE, TRAILING_WS_RE, THOUSANDS_RE, LIVE_RE, LIVE_WS_RE, LIVE_SRC_RE, filterChainCache, pathParseCache, TEMPLATE_CACHE_MAX, TOKEN_RE, RAW_BLOCK_RE, VarRef, BUILTIN_FILTERS, _formTokenSessionId, Frond;
|
|
13796
|
+
var SafeString, KNOWN_TAGS, TERMINATOR_TAGS, JSON_UNSAFE_RE, JSON_UNSAFE_MAP, NUMERIC_RE, METHOD_CALL_RE, FN_CALL_RE, IS_NOT_RE, IS_RE, NOT_IN_RE, IN_RE, DIVISIBLE_BY_RE, FILTER_WITH_ARGS_RE, FILTER_COMPARISON_RE, TITLE_WORD_RE, STRIP_TAGS_RE, FORMAT_RE, LEADING_WS_RE, TRAILING_WS_RE, THOUSANDS_RE, LIVE_RE, LIVE_WS_RE, LIVE_SRC_RE, filterChainCache, pathParseCache, TEMPLATE_CACHE_MAX, TOKEN_RE, RAW_BLOCK_RE, VarRef, BUILTIN_FILTERS, _formTokenSessionId, Frond;
|
|
13777
13797
|
var init_engine = __esm({
|
|
13778
13798
|
"../frond/src/engine.ts"() {
|
|
13779
13799
|
"use strict";
|
|
@@ -13785,6 +13805,46 @@ var init_engine = __esm({
|
|
|
13785
13805
|
return this.value;
|
|
13786
13806
|
}
|
|
13787
13807
|
};
|
|
13808
|
+
KNOWN_TAGS = /* @__PURE__ */ new Set([
|
|
13809
|
+
"autoescape",
|
|
13810
|
+
"block",
|
|
13811
|
+
"cache",
|
|
13812
|
+
"extends",
|
|
13813
|
+
"for",
|
|
13814
|
+
"from",
|
|
13815
|
+
"if",
|
|
13816
|
+
"import",
|
|
13817
|
+
"include",
|
|
13818
|
+
"live",
|
|
13819
|
+
"macro",
|
|
13820
|
+
"raw",
|
|
13821
|
+
"set",
|
|
13822
|
+
"spaceless"
|
|
13823
|
+
]);
|
|
13824
|
+
TERMINATOR_TAGS = /* @__PURE__ */ new Set([
|
|
13825
|
+
"elif",
|
|
13826
|
+
"else",
|
|
13827
|
+
"elseif",
|
|
13828
|
+
"endautoescape",
|
|
13829
|
+
"endblock",
|
|
13830
|
+
"endcache",
|
|
13831
|
+
"endfor",
|
|
13832
|
+
"endif",
|
|
13833
|
+
"endlive",
|
|
13834
|
+
"endmacro",
|
|
13835
|
+
"endraw",
|
|
13836
|
+
"endset",
|
|
13837
|
+
"endspaceless"
|
|
13838
|
+
]);
|
|
13839
|
+
JSON_UNSAFE_RE = /[<>&'\u2028\u2029]/g;
|
|
13840
|
+
JSON_UNSAFE_MAP = {
|
|
13841
|
+
"<": "\\u003c",
|
|
13842
|
+
">": "\\u003e",
|
|
13843
|
+
"&": "\\u0026",
|
|
13844
|
+
"'": "\\u0027",
|
|
13845
|
+
"\u2028": "\\u2028",
|
|
13846
|
+
"\u2029": "\\u2029"
|
|
13847
|
+
};
|
|
13788
13848
|
NUMERIC_RE = /^-?\d+(\.\d+)?$/;
|
|
13789
13849
|
METHOD_CALL_RE = /^(\w+)\s*\(([\s\S]*)?\)$/;
|
|
13790
13850
|
FN_CALL_RE = /^([\w.]+)\s*\(([\s\S]*)?\)$/;
|
|
@@ -13875,7 +13935,7 @@ var init_engine = __esm({
|
|
|
13875
13935
|
int: (v) => v ? parseInt(String(v), 10) || 0 : 0,
|
|
13876
13936
|
float: (v) => v ? parseFloat(String(v)) || 0 : 0,
|
|
13877
13937
|
string: (v) => String(v),
|
|
13878
|
-
json_encode: (v) =>
|
|
13938
|
+
json_encode: (v) => jsonSafe(v),
|
|
13879
13939
|
json_decode: (v) => typeof v === "string" ? JSON.parse(v) : v,
|
|
13880
13940
|
keys: (v) => typeof v === "object" && v !== null && !Array.isArray(v) ? Object.keys(v) : [],
|
|
13881
13941
|
values: (v) => typeof v === "object" && v !== null && !Array.isArray(v) ? Object.values(v) : [],
|
|
@@ -14020,8 +14080,12 @@ var init_engine = __esm({
|
|
|
14020
14080
|
form_token: (v) => _generateFormToken(v != null ? String(v) : ""),
|
|
14021
14081
|
formTokenValue: (v) => _generateFormTokenValue(v != null ? String(v) : ""),
|
|
14022
14082
|
form_token_value: (v) => _generateFormTokenValue(v != null ? String(v) : ""),
|
|
14023
|
-
|
|
14024
|
-
|
|
14083
|
+
// Same serializer as json_encode -- the three names are one behaviour. The
|
|
14084
|
+
// old indent argument is gone: PHP cannot honour an arbitrary indent
|
|
14085
|
+
// (JSON_PRETTY_PRINT is fixed at four spaces), so honouring it here alone
|
|
14086
|
+
// broke byte-parity for the one filter whose whole job is a wire format.
|
|
14087
|
+
tojson: (v) => jsonSafe(v),
|
|
14088
|
+
to_json: (v) => jsonSafe(v),
|
|
14025
14089
|
js_escape: (v) => new SafeString(String(v).replace(/\\/g, "\\\\").replace(/'/g, "\\'").replace(/"/g, '\\"').replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"))
|
|
14026
14090
|
};
|
|
14027
14091
|
_formTokenSessionId = "";
|
|
@@ -14426,8 +14490,11 @@ var init_engine = __esm({
|
|
|
14426
14490
|
i = skip;
|
|
14427
14491
|
}
|
|
14428
14492
|
} else if (tag === "set") {
|
|
14493
|
+
const isBlockSet = !content.includes("=");
|
|
14429
14494
|
if (this._sandbox && this._allowedTags !== null && !this._allowedTags.has("set")) {
|
|
14430
|
-
i
|
|
14495
|
+
i = isBlockSet ? this.skipBlock(tokens, i, "set", "endset") : i + 1;
|
|
14496
|
+
} else if (isBlockSet) {
|
|
14497
|
+
i = this.handleSetBlock(tokens, i, context);
|
|
14431
14498
|
} else {
|
|
14432
14499
|
this.handleSet(content, context);
|
|
14433
14500
|
i++;
|
|
@@ -14469,6 +14536,11 @@ var init_engine = __esm({
|
|
|
14469
14536
|
i++;
|
|
14470
14537
|
} else {
|
|
14471
14538
|
i++;
|
|
14539
|
+
if (tag !== "" && !TERMINATOR_TAGS.has(tag)) {
|
|
14540
|
+
throw new Error(
|
|
14541
|
+
`Frond: unknown tag "${tag}" -- known tags are: ${[...KNOWN_TAGS].sort().join(", ")}`
|
|
14542
|
+
);
|
|
14543
|
+
}
|
|
14472
14544
|
}
|
|
14473
14545
|
if (stripA && i < tokens.length && tokens[i][0] === "TEXT") {
|
|
14474
14546
|
tokens[i] = ["TEXT", tokens[i][1].replace(LEADING_WS_RE, "")];
|
|
@@ -14761,7 +14833,7 @@ var init_engine = __esm({
|
|
|
14761
14833
|
value = typeof value === "object" && value !== null && !Array.isArray(value) ? Object.values(value) : [];
|
|
14762
14834
|
continue;
|
|
14763
14835
|
case "json_encode":
|
|
14764
|
-
value =
|
|
14836
|
+
value = jsonSafe(value);
|
|
14765
14837
|
continue;
|
|
14766
14838
|
case "dump":
|
|
14767
14839
|
value = renderDump(value);
|
|
@@ -15365,6 +15437,47 @@ var init_engine = __esm({
|
|
|
15365
15437
|
}
|
|
15366
15438
|
return html;
|
|
15367
15439
|
}
|
|
15440
|
+
/**
|
|
15441
|
+
* {% set name %}...{% endset %} -- render the body and bind it.
|
|
15442
|
+
*
|
|
15443
|
+
* Emits nothing itself. The captured value is a SafeString because it is
|
|
15444
|
+
* template output that has already been escaped on the way in; re-escaping it
|
|
15445
|
+
* at {{ name }} would double-encode every entity. Twig and Jinja2 both mark the
|
|
15446
|
+
* capture safe. Returns the index just past {% endset %}.
|
|
15447
|
+
*/
|
|
15448
|
+
handleSetBlock(tokens, start2, context) {
|
|
15449
|
+
const [content] = stripTag(tokens[start2][1]);
|
|
15450
|
+
const name = (content.split(/\s+/)[1] || "").trim();
|
|
15451
|
+
const bodyTokens = [];
|
|
15452
|
+
let i = start2 + 1;
|
|
15453
|
+
let depth = 0;
|
|
15454
|
+
while (i < tokens.length) {
|
|
15455
|
+
if (tokens[i][0] === "BLOCK") {
|
|
15456
|
+
const [tagContent] = stripTag(tokens[i][1]);
|
|
15457
|
+
const tag = tagContent.split(/\s+/)[0] || "";
|
|
15458
|
+
if (tag === "set" && !tagContent.includes("=")) {
|
|
15459
|
+
depth++;
|
|
15460
|
+
bodyTokens.push(tokens[i]);
|
|
15461
|
+
} else if (tag === "endset") {
|
|
15462
|
+
if (depth === 0) {
|
|
15463
|
+
i++;
|
|
15464
|
+
break;
|
|
15465
|
+
}
|
|
15466
|
+
depth--;
|
|
15467
|
+
bodyTokens.push(tokens[i]);
|
|
15468
|
+
} else {
|
|
15469
|
+
bodyTokens.push(tokens[i]);
|
|
15470
|
+
}
|
|
15471
|
+
} else {
|
|
15472
|
+
bodyTokens.push(tokens[i]);
|
|
15473
|
+
}
|
|
15474
|
+
i++;
|
|
15475
|
+
}
|
|
15476
|
+
if (name) {
|
|
15477
|
+
context[name] = new SafeString(this.renderTokens([...bodyTokens], context));
|
|
15478
|
+
}
|
|
15479
|
+
return i;
|
|
15480
|
+
}
|
|
15368
15481
|
handleSpaceless(tokens, start2, context) {
|
|
15369
15482
|
const bodyTokens = [];
|
|
15370
15483
|
let i = start2 + 1;
|
|
@@ -12785,6 +12785,26 @@ __export(engine_exports, {
|
|
|
12785
12785
|
import { createHash as createHash2, createHmac as createHmac3, randomBytes as randomBytes4 } from "node:crypto";
|
|
12786
12786
|
import { readFileSync as readFileSync6, existsSync as existsSync8, statSync as statSync6 } from "node:fs";
|
|
12787
12787
|
import { join as join10, resolve as resolve6 } from "node:path";
|
|
12788
|
+
function jsonText(value) {
|
|
12789
|
+
try {
|
|
12790
|
+
const text = JSON.stringify(value);
|
|
12791
|
+
return text === void 0 ? "null" : text;
|
|
12792
|
+
} catch {
|
|
12793
|
+
const seen = /* @__PURE__ */ new WeakSet();
|
|
12794
|
+
const text = JSON.stringify(value, (_key, v) => {
|
|
12795
|
+
if (typeof v === "bigint") return v.toString();
|
|
12796
|
+
if (typeof v === "object" && v !== null) {
|
|
12797
|
+
if (seen.has(v)) return null;
|
|
12798
|
+
seen.add(v);
|
|
12799
|
+
}
|
|
12800
|
+
return v;
|
|
12801
|
+
});
|
|
12802
|
+
return text === void 0 ? "null" : text;
|
|
12803
|
+
}
|
|
12804
|
+
}
|
|
12805
|
+
function jsonSafe(value) {
|
|
12806
|
+
return new SafeString(jsonText(value).replace(JSON_UNSAFE_RE, (c) => JSON_UNSAFE_MAP[c]));
|
|
12807
|
+
}
|
|
12788
12808
|
function inspectValue(value, seen = /* @__PURE__ */ new WeakSet(), depth = 0) {
|
|
12789
12809
|
if (value === null) return "null";
|
|
12790
12810
|
if (value === void 0) return "undefined";
|
|
@@ -13772,7 +13792,7 @@ function _generateFormToken(descriptor = "") {
|
|
|
13772
13792
|
function _generateFormTokenValue(descriptor = "") {
|
|
13773
13793
|
return new SafeString(_buildFormTokenJwt(descriptor));
|
|
13774
13794
|
}
|
|
13775
|
-
var SafeString, NUMERIC_RE, METHOD_CALL_RE, FN_CALL_RE, IS_NOT_RE, IS_RE, NOT_IN_RE, IN_RE, DIVISIBLE_BY_RE, FILTER_WITH_ARGS_RE, FILTER_COMPARISON_RE, TITLE_WORD_RE, STRIP_TAGS_RE, FORMAT_RE, LEADING_WS_RE, TRAILING_WS_RE, THOUSANDS_RE, LIVE_RE, LIVE_WS_RE, LIVE_SRC_RE, filterChainCache, pathParseCache, TEMPLATE_CACHE_MAX, TOKEN_RE, RAW_BLOCK_RE, VarRef, BUILTIN_FILTERS, _formTokenSessionId, Frond;
|
|
13795
|
+
var SafeString, KNOWN_TAGS, TERMINATOR_TAGS, JSON_UNSAFE_RE, JSON_UNSAFE_MAP, NUMERIC_RE, METHOD_CALL_RE, FN_CALL_RE, IS_NOT_RE, IS_RE, NOT_IN_RE, IN_RE, DIVISIBLE_BY_RE, FILTER_WITH_ARGS_RE, FILTER_COMPARISON_RE, TITLE_WORD_RE, STRIP_TAGS_RE, FORMAT_RE, LEADING_WS_RE, TRAILING_WS_RE, THOUSANDS_RE, LIVE_RE, LIVE_WS_RE, LIVE_SRC_RE, filterChainCache, pathParseCache, TEMPLATE_CACHE_MAX, TOKEN_RE, RAW_BLOCK_RE, VarRef, BUILTIN_FILTERS, _formTokenSessionId, Frond;
|
|
13776
13796
|
var init_engine = __esm({
|
|
13777
13797
|
"../frond/src/engine.ts"() {
|
|
13778
13798
|
"use strict";
|
|
@@ -13784,6 +13804,46 @@ var init_engine = __esm({
|
|
|
13784
13804
|
return this.value;
|
|
13785
13805
|
}
|
|
13786
13806
|
};
|
|
13807
|
+
KNOWN_TAGS = /* @__PURE__ */ new Set([
|
|
13808
|
+
"autoescape",
|
|
13809
|
+
"block",
|
|
13810
|
+
"cache",
|
|
13811
|
+
"extends",
|
|
13812
|
+
"for",
|
|
13813
|
+
"from",
|
|
13814
|
+
"if",
|
|
13815
|
+
"import",
|
|
13816
|
+
"include",
|
|
13817
|
+
"live",
|
|
13818
|
+
"macro",
|
|
13819
|
+
"raw",
|
|
13820
|
+
"set",
|
|
13821
|
+
"spaceless"
|
|
13822
|
+
]);
|
|
13823
|
+
TERMINATOR_TAGS = /* @__PURE__ */ new Set([
|
|
13824
|
+
"elif",
|
|
13825
|
+
"else",
|
|
13826
|
+
"elseif",
|
|
13827
|
+
"endautoescape",
|
|
13828
|
+
"endblock",
|
|
13829
|
+
"endcache",
|
|
13830
|
+
"endfor",
|
|
13831
|
+
"endif",
|
|
13832
|
+
"endlive",
|
|
13833
|
+
"endmacro",
|
|
13834
|
+
"endraw",
|
|
13835
|
+
"endset",
|
|
13836
|
+
"endspaceless"
|
|
13837
|
+
]);
|
|
13838
|
+
JSON_UNSAFE_RE = /[<>&'\u2028\u2029]/g;
|
|
13839
|
+
JSON_UNSAFE_MAP = {
|
|
13840
|
+
"<": "\\u003c",
|
|
13841
|
+
">": "\\u003e",
|
|
13842
|
+
"&": "\\u0026",
|
|
13843
|
+
"'": "\\u0027",
|
|
13844
|
+
"\u2028": "\\u2028",
|
|
13845
|
+
"\u2029": "\\u2029"
|
|
13846
|
+
};
|
|
13787
13847
|
NUMERIC_RE = /^-?\d+(\.\d+)?$/;
|
|
13788
13848
|
METHOD_CALL_RE = /^(\w+)\s*\(([\s\S]*)?\)$/;
|
|
13789
13849
|
FN_CALL_RE = /^([\w.]+)\s*\(([\s\S]*)?\)$/;
|
|
@@ -13874,7 +13934,7 @@ var init_engine = __esm({
|
|
|
13874
13934
|
int: (v) => v ? parseInt(String(v), 10) || 0 : 0,
|
|
13875
13935
|
float: (v) => v ? parseFloat(String(v)) || 0 : 0,
|
|
13876
13936
|
string: (v) => String(v),
|
|
13877
|
-
json_encode: (v) =>
|
|
13937
|
+
json_encode: (v) => jsonSafe(v),
|
|
13878
13938
|
json_decode: (v) => typeof v === "string" ? JSON.parse(v) : v,
|
|
13879
13939
|
keys: (v) => typeof v === "object" && v !== null && !Array.isArray(v) ? Object.keys(v) : [],
|
|
13880
13940
|
values: (v) => typeof v === "object" && v !== null && !Array.isArray(v) ? Object.values(v) : [],
|
|
@@ -14019,8 +14079,12 @@ var init_engine = __esm({
|
|
|
14019
14079
|
form_token: (v) => _generateFormToken(v != null ? String(v) : ""),
|
|
14020
14080
|
formTokenValue: (v) => _generateFormTokenValue(v != null ? String(v) : ""),
|
|
14021
14081
|
form_token_value: (v) => _generateFormTokenValue(v != null ? String(v) : ""),
|
|
14022
|
-
|
|
14023
|
-
|
|
14082
|
+
// Same serializer as json_encode -- the three names are one behaviour. The
|
|
14083
|
+
// old indent argument is gone: PHP cannot honour an arbitrary indent
|
|
14084
|
+
// (JSON_PRETTY_PRINT is fixed at four spaces), so honouring it here alone
|
|
14085
|
+
// broke byte-parity for the one filter whose whole job is a wire format.
|
|
14086
|
+
tojson: (v) => jsonSafe(v),
|
|
14087
|
+
to_json: (v) => jsonSafe(v),
|
|
14024
14088
|
js_escape: (v) => new SafeString(String(v).replace(/\\/g, "\\\\").replace(/'/g, "\\'").replace(/"/g, '\\"').replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"))
|
|
14025
14089
|
};
|
|
14026
14090
|
_formTokenSessionId = "";
|
|
@@ -14425,8 +14489,11 @@ var init_engine = __esm({
|
|
|
14425
14489
|
i = skip;
|
|
14426
14490
|
}
|
|
14427
14491
|
} else if (tag === "set") {
|
|
14492
|
+
const isBlockSet = !content.includes("=");
|
|
14428
14493
|
if (this._sandbox && this._allowedTags !== null && !this._allowedTags.has("set")) {
|
|
14429
|
-
i
|
|
14494
|
+
i = isBlockSet ? this.skipBlock(tokens, i, "set", "endset") : i + 1;
|
|
14495
|
+
} else if (isBlockSet) {
|
|
14496
|
+
i = this.handleSetBlock(tokens, i, context);
|
|
14430
14497
|
} else {
|
|
14431
14498
|
this.handleSet(content, context);
|
|
14432
14499
|
i++;
|
|
@@ -14468,6 +14535,11 @@ var init_engine = __esm({
|
|
|
14468
14535
|
i++;
|
|
14469
14536
|
} else {
|
|
14470
14537
|
i++;
|
|
14538
|
+
if (tag !== "" && !TERMINATOR_TAGS.has(tag)) {
|
|
14539
|
+
throw new Error(
|
|
14540
|
+
`Frond: unknown tag "${tag}" -- known tags are: ${[...KNOWN_TAGS].sort().join(", ")}`
|
|
14541
|
+
);
|
|
14542
|
+
}
|
|
14471
14543
|
}
|
|
14472
14544
|
if (stripA && i < tokens.length && tokens[i][0] === "TEXT") {
|
|
14473
14545
|
tokens[i] = ["TEXT", tokens[i][1].replace(LEADING_WS_RE, "")];
|
|
@@ -14760,7 +14832,7 @@ var init_engine = __esm({
|
|
|
14760
14832
|
value = typeof value === "object" && value !== null && !Array.isArray(value) ? Object.values(value) : [];
|
|
14761
14833
|
continue;
|
|
14762
14834
|
case "json_encode":
|
|
14763
|
-
value =
|
|
14835
|
+
value = jsonSafe(value);
|
|
14764
14836
|
continue;
|
|
14765
14837
|
case "dump":
|
|
14766
14838
|
value = renderDump(value);
|
|
@@ -15364,6 +15436,47 @@ var init_engine = __esm({
|
|
|
15364
15436
|
}
|
|
15365
15437
|
return html;
|
|
15366
15438
|
}
|
|
15439
|
+
/**
|
|
15440
|
+
* {% set name %}...{% endset %} -- render the body and bind it.
|
|
15441
|
+
*
|
|
15442
|
+
* Emits nothing itself. The captured value is a SafeString because it is
|
|
15443
|
+
* template output that has already been escaped on the way in; re-escaping it
|
|
15444
|
+
* at {{ name }} would double-encode every entity. Twig and Jinja2 both mark the
|
|
15445
|
+
* capture safe. Returns the index just past {% endset %}.
|
|
15446
|
+
*/
|
|
15447
|
+
handleSetBlock(tokens, start2, context) {
|
|
15448
|
+
const [content] = stripTag(tokens[start2][1]);
|
|
15449
|
+
const name = (content.split(/\s+/)[1] || "").trim();
|
|
15450
|
+
const bodyTokens = [];
|
|
15451
|
+
let i = start2 + 1;
|
|
15452
|
+
let depth = 0;
|
|
15453
|
+
while (i < tokens.length) {
|
|
15454
|
+
if (tokens[i][0] === "BLOCK") {
|
|
15455
|
+
const [tagContent] = stripTag(tokens[i][1]);
|
|
15456
|
+
const tag = tagContent.split(/\s+/)[0] || "";
|
|
15457
|
+
if (tag === "set" && !tagContent.includes("=")) {
|
|
15458
|
+
depth++;
|
|
15459
|
+
bodyTokens.push(tokens[i]);
|
|
15460
|
+
} else if (tag === "endset") {
|
|
15461
|
+
if (depth === 0) {
|
|
15462
|
+
i++;
|
|
15463
|
+
break;
|
|
15464
|
+
}
|
|
15465
|
+
depth--;
|
|
15466
|
+
bodyTokens.push(tokens[i]);
|
|
15467
|
+
} else {
|
|
15468
|
+
bodyTokens.push(tokens[i]);
|
|
15469
|
+
}
|
|
15470
|
+
} else {
|
|
15471
|
+
bodyTokens.push(tokens[i]);
|
|
15472
|
+
}
|
|
15473
|
+
i++;
|
|
15474
|
+
}
|
|
15475
|
+
if (name) {
|
|
15476
|
+
context[name] = new SafeString(this.renderTokens([...bodyTokens], context));
|
|
15477
|
+
}
|
|
15478
|
+
return i;
|
|
15479
|
+
}
|
|
15367
15480
|
handleSpaceless(tokens, start2, context) {
|
|
15368
15481
|
const bodyTokens = [];
|
|
15369
15482
|
let i = start2 + 1;
|
|
@@ -10,6 +10,66 @@ var SafeString = class {
|
|
|
10
10
|
return this.value;
|
|
11
11
|
}
|
|
12
12
|
};
|
|
13
|
+
var KNOWN_TAGS = /* @__PURE__ */ new Set([
|
|
14
|
+
"autoescape",
|
|
15
|
+
"block",
|
|
16
|
+
"cache",
|
|
17
|
+
"extends",
|
|
18
|
+
"for",
|
|
19
|
+
"from",
|
|
20
|
+
"if",
|
|
21
|
+
"import",
|
|
22
|
+
"include",
|
|
23
|
+
"live",
|
|
24
|
+
"macro",
|
|
25
|
+
"raw",
|
|
26
|
+
"set",
|
|
27
|
+
"spaceless"
|
|
28
|
+
]);
|
|
29
|
+
var TERMINATOR_TAGS = /* @__PURE__ */ new Set([
|
|
30
|
+
"elif",
|
|
31
|
+
"else",
|
|
32
|
+
"elseif",
|
|
33
|
+
"endautoescape",
|
|
34
|
+
"endblock",
|
|
35
|
+
"endcache",
|
|
36
|
+
"endfor",
|
|
37
|
+
"endif",
|
|
38
|
+
"endlive",
|
|
39
|
+
"endmacro",
|
|
40
|
+
"endraw",
|
|
41
|
+
"endset",
|
|
42
|
+
"endspaceless"
|
|
43
|
+
]);
|
|
44
|
+
function jsonText(value) {
|
|
45
|
+
try {
|
|
46
|
+
const text = JSON.stringify(value);
|
|
47
|
+
return text === void 0 ? "null" : text;
|
|
48
|
+
} catch {
|
|
49
|
+
const seen = /* @__PURE__ */ new WeakSet();
|
|
50
|
+
const text = JSON.stringify(value, (_key, v) => {
|
|
51
|
+
if (typeof v === "bigint") return v.toString();
|
|
52
|
+
if (typeof v === "object" && v !== null) {
|
|
53
|
+
if (seen.has(v)) return null;
|
|
54
|
+
seen.add(v);
|
|
55
|
+
}
|
|
56
|
+
return v;
|
|
57
|
+
});
|
|
58
|
+
return text === void 0 ? "null" : text;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
var JSON_UNSAFE_RE = /[<>&'\u2028\u2029]/g;
|
|
62
|
+
var JSON_UNSAFE_MAP = {
|
|
63
|
+
"<": "\\u003c",
|
|
64
|
+
">": "\\u003e",
|
|
65
|
+
"&": "\\u0026",
|
|
66
|
+
"'": "\\u0027",
|
|
67
|
+
"\u2028": "\\u2028",
|
|
68
|
+
"\u2029": "\\u2029"
|
|
69
|
+
};
|
|
70
|
+
function jsonSafe(value) {
|
|
71
|
+
return new SafeString(jsonText(value).replace(JSON_UNSAFE_RE, (c) => JSON_UNSAFE_MAP[c]));
|
|
72
|
+
}
|
|
13
73
|
function inspectValue(value, seen = /* @__PURE__ */ new WeakSet(), depth = 0) {
|
|
14
74
|
if (value === null) return "null";
|
|
15
75
|
if (value === void 0) return "undefined";
|
|
@@ -1049,7 +1109,7 @@ var BUILTIN_FILTERS = {
|
|
|
1049
1109
|
int: (v) => v ? parseInt(String(v), 10) || 0 : 0,
|
|
1050
1110
|
float: (v) => v ? parseFloat(String(v)) || 0 : 0,
|
|
1051
1111
|
string: (v) => String(v),
|
|
1052
|
-
json_encode: (v) =>
|
|
1112
|
+
json_encode: (v) => jsonSafe(v),
|
|
1053
1113
|
json_decode: (v) => typeof v === "string" ? JSON.parse(v) : v,
|
|
1054
1114
|
keys: (v) => typeof v === "object" && v !== null && !Array.isArray(v) ? Object.keys(v) : [],
|
|
1055
1115
|
values: (v) => typeof v === "object" && v !== null && !Array.isArray(v) ? Object.values(v) : [],
|
|
@@ -1194,8 +1254,12 @@ var BUILTIN_FILTERS = {
|
|
|
1194
1254
|
form_token: (v) => _generateFormToken(v != null ? String(v) : ""),
|
|
1195
1255
|
formTokenValue: (v) => _generateFormTokenValue(v != null ? String(v) : ""),
|
|
1196
1256
|
form_token_value: (v) => _generateFormTokenValue(v != null ? String(v) : ""),
|
|
1197
|
-
|
|
1198
|
-
|
|
1257
|
+
// Same serializer as json_encode -- the three names are one behaviour. The
|
|
1258
|
+
// old indent argument is gone: PHP cannot honour an arbitrary indent
|
|
1259
|
+
// (JSON_PRETTY_PRINT is fixed at four spaces), so honouring it here alone
|
|
1260
|
+
// broke byte-parity for the one filter whose whole job is a wire format.
|
|
1261
|
+
tojson: (v) => jsonSafe(v),
|
|
1262
|
+
to_json: (v) => jsonSafe(v),
|
|
1199
1263
|
js_escape: (v) => new SafeString(String(v).replace(/\\/g, "\\\\").replace(/'/g, "\\'").replace(/"/g, '\\"').replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"))
|
|
1200
1264
|
};
|
|
1201
1265
|
function _b64url(data) {
|
|
@@ -1635,8 +1699,11 @@ var Frond = class _Frond {
|
|
|
1635
1699
|
i = skip;
|
|
1636
1700
|
}
|
|
1637
1701
|
} else if (tag === "set") {
|
|
1702
|
+
const isBlockSet = !content.includes("=");
|
|
1638
1703
|
if (this._sandbox && this._allowedTags !== null && !this._allowedTags.has("set")) {
|
|
1639
|
-
i
|
|
1704
|
+
i = isBlockSet ? this.skipBlock(tokens, i, "set", "endset") : i + 1;
|
|
1705
|
+
} else if (isBlockSet) {
|
|
1706
|
+
i = this.handleSetBlock(tokens, i, context);
|
|
1640
1707
|
} else {
|
|
1641
1708
|
this.handleSet(content, context);
|
|
1642
1709
|
i++;
|
|
@@ -1678,6 +1745,11 @@ var Frond = class _Frond {
|
|
|
1678
1745
|
i++;
|
|
1679
1746
|
} else {
|
|
1680
1747
|
i++;
|
|
1748
|
+
if (tag !== "" && !TERMINATOR_TAGS.has(tag)) {
|
|
1749
|
+
throw new Error(
|
|
1750
|
+
`Frond: unknown tag "${tag}" -- known tags are: ${[...KNOWN_TAGS].sort().join(", ")}`
|
|
1751
|
+
);
|
|
1752
|
+
}
|
|
1681
1753
|
}
|
|
1682
1754
|
if (stripA && i < tokens.length && tokens[i][0] === "TEXT") {
|
|
1683
1755
|
tokens[i] = ["TEXT", tokens[i][1].replace(LEADING_WS_RE, "")];
|
|
@@ -1970,7 +2042,7 @@ var Frond = class _Frond {
|
|
|
1970
2042
|
value = typeof value === "object" && value !== null && !Array.isArray(value) ? Object.values(value) : [];
|
|
1971
2043
|
continue;
|
|
1972
2044
|
case "json_encode":
|
|
1973
|
-
value =
|
|
2045
|
+
value = jsonSafe(value);
|
|
1974
2046
|
continue;
|
|
1975
2047
|
case "dump":
|
|
1976
2048
|
value = renderDump(value);
|
|
@@ -2574,6 +2646,47 @@ var Frond = class _Frond {
|
|
|
2574
2646
|
}
|
|
2575
2647
|
return html;
|
|
2576
2648
|
}
|
|
2649
|
+
/**
|
|
2650
|
+
* {% set name %}...{% endset %} -- render the body and bind it.
|
|
2651
|
+
*
|
|
2652
|
+
* Emits nothing itself. The captured value is a SafeString because it is
|
|
2653
|
+
* template output that has already been escaped on the way in; re-escaping it
|
|
2654
|
+
* at {{ name }} would double-encode every entity. Twig and Jinja2 both mark the
|
|
2655
|
+
* capture safe. Returns the index just past {% endset %}.
|
|
2656
|
+
*/
|
|
2657
|
+
handleSetBlock(tokens, start, context) {
|
|
2658
|
+
const [content] = stripTag(tokens[start][1]);
|
|
2659
|
+
const name = (content.split(/\s+/)[1] || "").trim();
|
|
2660
|
+
const bodyTokens = [];
|
|
2661
|
+
let i = start + 1;
|
|
2662
|
+
let depth = 0;
|
|
2663
|
+
while (i < tokens.length) {
|
|
2664
|
+
if (tokens[i][0] === "BLOCK") {
|
|
2665
|
+
const [tagContent] = stripTag(tokens[i][1]);
|
|
2666
|
+
const tag = tagContent.split(/\s+/)[0] || "";
|
|
2667
|
+
if (tag === "set" && !tagContent.includes("=")) {
|
|
2668
|
+
depth++;
|
|
2669
|
+
bodyTokens.push(tokens[i]);
|
|
2670
|
+
} else if (tag === "endset") {
|
|
2671
|
+
if (depth === 0) {
|
|
2672
|
+
i++;
|
|
2673
|
+
break;
|
|
2674
|
+
}
|
|
2675
|
+
depth--;
|
|
2676
|
+
bodyTokens.push(tokens[i]);
|
|
2677
|
+
} else {
|
|
2678
|
+
bodyTokens.push(tokens[i]);
|
|
2679
|
+
}
|
|
2680
|
+
} else {
|
|
2681
|
+
bodyTokens.push(tokens[i]);
|
|
2682
|
+
}
|
|
2683
|
+
i++;
|
|
2684
|
+
}
|
|
2685
|
+
if (name) {
|
|
2686
|
+
context[name] = new SafeString(this.renderTokens([...bodyTokens], context));
|
|
2687
|
+
}
|
|
2688
|
+
return i;
|
|
2689
|
+
}
|
|
2577
2690
|
handleSpaceless(tokens, start, context) {
|
|
2578
2691
|
const bodyTokens = [];
|
|
2579
2692
|
let i = start + 1;
|
|
@@ -34,6 +34,94 @@ class SafeString {
|
|
|
34
34
|
toString() { return this.value; }
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
+
/**
|
|
38
|
+
* Every tag that OPENS a construct.
|
|
39
|
+
*
|
|
40
|
+
* An unknown tag is a typo, and 3.13.89 makes it throw rather than render its
|
|
41
|
+
* body: a mistyped guard -- {% iff is_admin %} instead of {% if is_admin %} --
|
|
42
|
+
* used to render the gated content UNCONDITIONALLY, so a reviewer saw a guard
|
|
43
|
+
* that was not there. Twig and Jinja2 both raise on an unknown tag; Frond now
|
|
44
|
+
* does too. There is no user-extension point for tags in any of the four
|
|
45
|
+
* frameworks, so an unknown name is always a mistake, never a plugin.
|
|
46
|
+
*/
|
|
47
|
+
const KNOWN_TAGS = new Set([
|
|
48
|
+
"autoescape", "block", "cache", "extends", "for", "from", "if", "import",
|
|
49
|
+
"include", "live", "macro", "raw", "set", "spaceless",
|
|
50
|
+
]);
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Terminators and branch keywords. These reach the tag dispatch only when stray
|
|
54
|
+
* (their own collector consumes them in the normal case), and a stray one keeps
|
|
55
|
+
* the old render-nothing behaviour -- see the comment at the throw.
|
|
56
|
+
*/
|
|
57
|
+
const TERMINATOR_TAGS = new Set([
|
|
58
|
+
"elif", "else", "elseif", "endautoescape", "endblock", "endcache", "endfor",
|
|
59
|
+
"endif", "endlive", "endmacro", "endraw", "endset", "endspaceless",
|
|
60
|
+
]);
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Serialize a value to compact JSON text that is always valid JSON.
|
|
64
|
+
*
|
|
65
|
+
* Never throws and never returns an empty string. JSON.stringify already maps a
|
|
66
|
+
* non-finite number to null, but it returns the VALUE `undefined` for undefined,
|
|
67
|
+
* a function, or a symbol, and it throws on a BigInt or a circular structure --
|
|
68
|
+
* all four of which would otherwise reach the page as nothing or as a crash.
|
|
69
|
+
*/
|
|
70
|
+
function jsonText(value: unknown): string {
|
|
71
|
+
try {
|
|
72
|
+
const text = JSON.stringify(value);
|
|
73
|
+
return text === undefined ? "null" : text;
|
|
74
|
+
} catch {
|
|
75
|
+
// Only reached when the happy path threw, so a well-formed payload never
|
|
76
|
+
// pays for the walk.
|
|
77
|
+
const seen = new WeakSet<object>();
|
|
78
|
+
const text = JSON.stringify(value, (_key, v) => {
|
|
79
|
+
if (typeof v === "bigint") return v.toString();
|
|
80
|
+
if (typeof v === "object" && v !== null) {
|
|
81
|
+
if (seen.has(v)) return null;
|
|
82
|
+
seen.add(v);
|
|
83
|
+
}
|
|
84
|
+
return v;
|
|
85
|
+
});
|
|
86
|
+
return text === undefined ? "null" : text;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const JSON_UNSAFE_RE = /[<>&'\u2028\u2029]/g;
|
|
91
|
+
const JSON_UNSAFE_MAP: Record<string, string> = {
|
|
92
|
+
"<": "\\u003c", ">": "\\u003e", "&": "\\u0026", "'": "\\u0027",
|
|
93
|
+
"\u2028": "\\u2028", "\u2029": "\\u2029",
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Serialize to JSON that is valid JSON, valid JavaScript, and safe in HTML.
|
|
98
|
+
*
|
|
99
|
+
* THE cross-framework contract for json_encode / to_json / tojson. Keep the four
|
|
100
|
+
* implementations byte-identical; frond_expression_corpus.txt locks it.
|
|
101
|
+
*
|
|
102
|
+
* Three things this must never do, each of which was a real bug in one of the
|
|
103
|
+
* four engines:
|
|
104
|
+
*
|
|
105
|
+
* 1. Never emit a non-finite literal. Python wrote a bare `Infinity`, PHP
|
|
106
|
+
* returned false, Ruby fell back to inspect output -- none of them parse.
|
|
107
|
+
* Node was the one that already got this right. Reported as tina4-php#184 by
|
|
108
|
+
* justin-k-bruce.
|
|
109
|
+
* 2. Never emit nothing, and never emit something that still parses and means
|
|
110
|
+
* something else. "var ROWS = ;" is at least a loud SyntaxError.
|
|
111
|
+
* 3. Never HTML-escape it. Entity-encoding JSON produces {"a":1}, a
|
|
112
|
+
* SyntaxError inside <script>, which is the filter's whole point. Escape only
|
|
113
|
+
* the dangerous characters, as JSON \uXXXX escapes: the result stays valid
|
|
114
|
+
* JSON AND valid JavaScript, </script> cannot terminate the block, and it is
|
|
115
|
+
* safe inside a single-quoted attribute. This is what Jinja2's tojson does,
|
|
116
|
+
* and it is why the result is a SafeString.
|
|
117
|
+
*
|
|
118
|
+
* U+2028 and U+2029 join that escape set. Both are legal inside a JSON string and
|
|
119
|
+
* both were illegal inside a JavaScript string literal before ES2019.
|
|
120
|
+
*/
|
|
121
|
+
function jsonSafe(value: unknown): SafeString {
|
|
122
|
+
return new SafeString(jsonText(value).replace(JSON_UNSAFE_RE, (c) => JSON_UNSAFE_MAP[c]));
|
|
123
|
+
}
|
|
124
|
+
|
|
37
125
|
/**
|
|
38
126
|
* Produce a human-readable, debugger-friendly inspection of any value.
|
|
39
127
|
*
|
|
@@ -1307,7 +1395,7 @@ const BUILTIN_FILTERS: Record<string, FilterFn> = {
|
|
|
1307
1395
|
int: (v) => v ? parseInt(String(v), 10) || 0 : 0,
|
|
1308
1396
|
float: (v) => v ? parseFloat(String(v)) || 0.0 : 0.0,
|
|
1309
1397
|
string: (v) => String(v),
|
|
1310
|
-
json_encode: (v) =>
|
|
1398
|
+
json_encode: (v) => jsonSafe(v),
|
|
1311
1399
|
json_decode: (v) => typeof v === "string" ? JSON.parse(v) : v,
|
|
1312
1400
|
keys: (v) => (typeof v === "object" && v !== null && !Array.isArray(v)) ? Object.keys(v) : [],
|
|
1313
1401
|
values: (v) => (typeof v === "object" && v !== null && !Array.isArray(v)) ? Object.values(v) : [],
|
|
@@ -1431,8 +1519,12 @@ const BUILTIN_FILTERS: Record<string, FilterFn> = {
|
|
|
1431
1519
|
form_token: (v?: unknown) => _generateFormToken(v != null ? String(v) : ""),
|
|
1432
1520
|
formTokenValue: (v?: unknown) => _generateFormTokenValue(v != null ? String(v) : ""),
|
|
1433
1521
|
form_token_value: (v?: unknown) => _generateFormTokenValue(v != null ? String(v) : ""),
|
|
1434
|
-
|
|
1435
|
-
|
|
1522
|
+
// Same serializer as json_encode -- the three names are one behaviour. The
|
|
1523
|
+
// old indent argument is gone: PHP cannot honour an arbitrary indent
|
|
1524
|
+
// (JSON_PRETTY_PRINT is fixed at four spaces), so honouring it here alone
|
|
1525
|
+
// broke byte-parity for the one filter whose whole job is a wire format.
|
|
1526
|
+
tojson: (v) => jsonSafe(v),
|
|
1527
|
+
to_json: (v) => jsonSafe(v),
|
|
1436
1528
|
js_escape: (v) => new SafeString(String(v).replace(/\\/g, "\\\\").replace(/'/g, "\\'").replace(/"/g, '\\"').replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t")),
|
|
1437
1529
|
};
|
|
1438
1530
|
|
|
@@ -2010,8 +2102,16 @@ export class Frond {
|
|
|
2010
2102
|
i = skip;
|
|
2011
2103
|
}
|
|
2012
2104
|
} else if (tag === "set") {
|
|
2105
|
+
// An assignment has an "="; without one this is the BLOCK form,
|
|
2106
|
+
// {% set name %}...{% endset %}, which captures its rendered body. A
|
|
2107
|
+
// bare includes() is exact here, not a shortcut: the block form's tag
|
|
2108
|
+
// content is only ever "set <name>", so an "=" anywhere -- even inside
|
|
2109
|
+
// a quoted value like {% set m = "a = b" %} -- means assignment.
|
|
2110
|
+
const isBlockSet = !content.includes("=");
|
|
2013
2111
|
if (this._sandbox && this._allowedTags !== null && !this._allowedTags.has("set")) {
|
|
2014
|
-
i
|
|
2112
|
+
i = isBlockSet ? this.skipBlock(tokens, i, "set", "endset") : i + 1;
|
|
2113
|
+
} else if (isBlockSet) {
|
|
2114
|
+
i = this.handleSetBlock(tokens, i, context);
|
|
2015
2115
|
} else {
|
|
2016
2116
|
this.handleSet(content, context);
|
|
2017
2117
|
i++;
|
|
@@ -2053,6 +2153,16 @@ export class Frond {
|
|
|
2053
2153
|
i++; // Already handled
|
|
2054
2154
|
} else {
|
|
2055
2155
|
i++;
|
|
2156
|
+
if (tag !== "" && !TERMINATOR_TAGS.has(tag)) {
|
|
2157
|
+
throw new Error(
|
|
2158
|
+
`Frond: unknown tag "${tag}" -- known tags are: ${[...KNOWN_TAGS].sort().join(", ")}`,
|
|
2159
|
+
);
|
|
2160
|
+
}
|
|
2161
|
+
// An empty tag ({% %}) or a stray terminator (an {% endif %} with
|
|
2162
|
+
// no {% if %}): no output.
|
|
2163
|
+
// Malformed, but it has always rendered nothing, and nothing is the
|
|
2164
|
+
// safe answer -- unlike an unknown tag it cannot expose content that
|
|
2165
|
+
// was meant to be gated.
|
|
2056
2166
|
}
|
|
2057
2167
|
|
|
2058
2168
|
if (stripA && i < tokens.length && tokens[i][0] === "TEXT") {
|
|
@@ -2346,7 +2456,7 @@ export class Frond {
|
|
|
2346
2456
|
case "last": value = Array.isArray(value) ? value[value.length - 1] ?? null : null; continue;
|
|
2347
2457
|
case "keys": value = (typeof value === "object" && value !== null && !Array.isArray(value)) ? Object.keys(value) : []; continue;
|
|
2348
2458
|
case "values": value = (typeof value === "object" && value !== null && !Array.isArray(value)) ? Object.values(value) : []; continue;
|
|
2349
|
-
case "json_encode": value =
|
|
2459
|
+
case "json_encode": value = jsonSafe(value); continue;
|
|
2350
2460
|
case "dump":
|
|
2351
2461
|
// Delegates to renderDump(), which is gated on TINA4_DEBUG.
|
|
2352
2462
|
// In production this emits an empty SafeString (no leaked state).
|
|
@@ -3054,6 +3164,50 @@ export class Frond {
|
|
|
3054
3164
|
return html;
|
|
3055
3165
|
}
|
|
3056
3166
|
|
|
3167
|
+
/**
|
|
3168
|
+
* {% set name %}...{% endset %} -- render the body and bind it.
|
|
3169
|
+
*
|
|
3170
|
+
* Emits nothing itself. The captured value is a SafeString because it is
|
|
3171
|
+
* template output that has already been escaped on the way in; re-escaping it
|
|
3172
|
+
* at {{ name }} would double-encode every entity. Twig and Jinja2 both mark the
|
|
3173
|
+
* capture safe. Returns the index just past {% endset %}.
|
|
3174
|
+
*/
|
|
3175
|
+
private handleSetBlock(tokens: Token[], start: number, context: Record<string, unknown>): number {
|
|
3176
|
+
const [content] = stripTag(tokens[start][1]);
|
|
3177
|
+
const name = (content.split(/\s+/)[1] || "").trim();
|
|
3178
|
+
|
|
3179
|
+
const bodyTokens: Token[] = [];
|
|
3180
|
+
let i = start + 1;
|
|
3181
|
+
let depth = 0;
|
|
3182
|
+
while (i < tokens.length) {
|
|
3183
|
+
if (tokens[i][0] === "BLOCK") {
|
|
3184
|
+
const [tagContent] = stripTag(tokens[i][1]);
|
|
3185
|
+
const tag = tagContent.split(/\s+/)[0] || "";
|
|
3186
|
+
if (tag === "set" && !tagContent.includes("=")) {
|
|
3187
|
+
depth++;
|
|
3188
|
+
bodyTokens.push(tokens[i]);
|
|
3189
|
+
} else if (tag === "endset") {
|
|
3190
|
+
if (depth === 0) {
|
|
3191
|
+
i++;
|
|
3192
|
+
break;
|
|
3193
|
+
}
|
|
3194
|
+
depth--;
|
|
3195
|
+
bodyTokens.push(tokens[i]);
|
|
3196
|
+
} else {
|
|
3197
|
+
bodyTokens.push(tokens[i]);
|
|
3198
|
+
}
|
|
3199
|
+
} else {
|
|
3200
|
+
bodyTokens.push(tokens[i]);
|
|
3201
|
+
}
|
|
3202
|
+
i++;
|
|
3203
|
+
}
|
|
3204
|
+
|
|
3205
|
+
if (name) {
|
|
3206
|
+
context[name] = new SafeString(this.renderTokens([...bodyTokens], context));
|
|
3207
|
+
}
|
|
3208
|
+
return i;
|
|
3209
|
+
}
|
|
3210
|
+
|
|
3057
3211
|
private handleSpaceless(tokens: Token[], start: number, context: Record<string, unknown>): [string, number] {
|
|
3058
3212
|
const bodyTokens: Token[] = [];
|
|
3059
3213
|
let i = start + 1;
|
|
@@ -3421,6 +3421,26 @@ __export(engine_exports, {
|
|
|
3421
3421
|
import { createHash as createHash2, createHmac as createHmac2, randomBytes as randomBytes2 } from "node:crypto";
|
|
3422
3422
|
import { readFileSync as readFileSync4, existsSync as existsSync6, statSync as statSync4 } from "node:fs";
|
|
3423
3423
|
import { join as join5, resolve as resolve2 } from "node:path";
|
|
3424
|
+
function jsonText(value) {
|
|
3425
|
+
try {
|
|
3426
|
+
const text = JSON.stringify(value);
|
|
3427
|
+
return text === void 0 ? "null" : text;
|
|
3428
|
+
} catch {
|
|
3429
|
+
const seen = /* @__PURE__ */ new WeakSet();
|
|
3430
|
+
const text = JSON.stringify(value, (_key, v) => {
|
|
3431
|
+
if (typeof v === "bigint") return v.toString();
|
|
3432
|
+
if (typeof v === "object" && v !== null) {
|
|
3433
|
+
if (seen.has(v)) return null;
|
|
3434
|
+
seen.add(v);
|
|
3435
|
+
}
|
|
3436
|
+
return v;
|
|
3437
|
+
});
|
|
3438
|
+
return text === void 0 ? "null" : text;
|
|
3439
|
+
}
|
|
3440
|
+
}
|
|
3441
|
+
function jsonSafe(value) {
|
|
3442
|
+
return new SafeString(jsonText(value).replace(JSON_UNSAFE_RE, (c) => JSON_UNSAFE_MAP[c]));
|
|
3443
|
+
}
|
|
3424
3444
|
function inspectValue(value, seen = /* @__PURE__ */ new WeakSet(), depth = 0) {
|
|
3425
3445
|
if (value === null) return "null";
|
|
3426
3446
|
if (value === void 0) return "undefined";
|
|
@@ -4408,7 +4428,7 @@ function _generateFormToken(descriptor = "") {
|
|
|
4408
4428
|
function _generateFormTokenValue(descriptor = "") {
|
|
4409
4429
|
return new SafeString(_buildFormTokenJwt(descriptor));
|
|
4410
4430
|
}
|
|
4411
|
-
var SafeString, NUMERIC_RE, METHOD_CALL_RE, FN_CALL_RE, IS_NOT_RE, IS_RE, NOT_IN_RE, IN_RE, DIVISIBLE_BY_RE, FILTER_WITH_ARGS_RE, FILTER_COMPARISON_RE, TITLE_WORD_RE, STRIP_TAGS_RE, FORMAT_RE, LEADING_WS_RE, TRAILING_WS_RE, THOUSANDS_RE, LIVE_RE, LIVE_WS_RE, LIVE_SRC_RE, filterChainCache, pathParseCache, TEMPLATE_CACHE_MAX, TOKEN_RE, RAW_BLOCK_RE, VarRef, BUILTIN_FILTERS, _formTokenSessionId, Frond;
|
|
4431
|
+
var SafeString, KNOWN_TAGS, TERMINATOR_TAGS, JSON_UNSAFE_RE, JSON_UNSAFE_MAP, NUMERIC_RE, METHOD_CALL_RE, FN_CALL_RE, IS_NOT_RE, IS_RE, NOT_IN_RE, IN_RE, DIVISIBLE_BY_RE, FILTER_WITH_ARGS_RE, FILTER_COMPARISON_RE, TITLE_WORD_RE, STRIP_TAGS_RE, FORMAT_RE, LEADING_WS_RE, TRAILING_WS_RE, THOUSANDS_RE, LIVE_RE, LIVE_WS_RE, LIVE_SRC_RE, filterChainCache, pathParseCache, TEMPLATE_CACHE_MAX, TOKEN_RE, RAW_BLOCK_RE, VarRef, BUILTIN_FILTERS, _formTokenSessionId, Frond;
|
|
4412
4432
|
var init_engine = __esm({
|
|
4413
4433
|
"../frond/src/engine.ts"() {
|
|
4414
4434
|
"use strict";
|
|
@@ -4420,6 +4440,46 @@ var init_engine = __esm({
|
|
|
4420
4440
|
return this.value;
|
|
4421
4441
|
}
|
|
4422
4442
|
};
|
|
4443
|
+
KNOWN_TAGS = /* @__PURE__ */ new Set([
|
|
4444
|
+
"autoescape",
|
|
4445
|
+
"block",
|
|
4446
|
+
"cache",
|
|
4447
|
+
"extends",
|
|
4448
|
+
"for",
|
|
4449
|
+
"from",
|
|
4450
|
+
"if",
|
|
4451
|
+
"import",
|
|
4452
|
+
"include",
|
|
4453
|
+
"live",
|
|
4454
|
+
"macro",
|
|
4455
|
+
"raw",
|
|
4456
|
+
"set",
|
|
4457
|
+
"spaceless"
|
|
4458
|
+
]);
|
|
4459
|
+
TERMINATOR_TAGS = /* @__PURE__ */ new Set([
|
|
4460
|
+
"elif",
|
|
4461
|
+
"else",
|
|
4462
|
+
"elseif",
|
|
4463
|
+
"endautoescape",
|
|
4464
|
+
"endblock",
|
|
4465
|
+
"endcache",
|
|
4466
|
+
"endfor",
|
|
4467
|
+
"endif",
|
|
4468
|
+
"endlive",
|
|
4469
|
+
"endmacro",
|
|
4470
|
+
"endraw",
|
|
4471
|
+
"endset",
|
|
4472
|
+
"endspaceless"
|
|
4473
|
+
]);
|
|
4474
|
+
JSON_UNSAFE_RE = /[<>&'\u2028\u2029]/g;
|
|
4475
|
+
JSON_UNSAFE_MAP = {
|
|
4476
|
+
"<": "\\u003c",
|
|
4477
|
+
">": "\\u003e",
|
|
4478
|
+
"&": "\\u0026",
|
|
4479
|
+
"'": "\\u0027",
|
|
4480
|
+
"\u2028": "\\u2028",
|
|
4481
|
+
"\u2029": "\\u2029"
|
|
4482
|
+
};
|
|
4423
4483
|
NUMERIC_RE = /^-?\d+(\.\d+)?$/;
|
|
4424
4484
|
METHOD_CALL_RE = /^(\w+)\s*\(([\s\S]*)?\)$/;
|
|
4425
4485
|
FN_CALL_RE = /^([\w.]+)\s*\(([\s\S]*)?\)$/;
|
|
@@ -4510,7 +4570,7 @@ var init_engine = __esm({
|
|
|
4510
4570
|
int: (v) => v ? parseInt(String(v), 10) || 0 : 0,
|
|
4511
4571
|
float: (v) => v ? parseFloat(String(v)) || 0 : 0,
|
|
4512
4572
|
string: (v) => String(v),
|
|
4513
|
-
json_encode: (v) =>
|
|
4573
|
+
json_encode: (v) => jsonSafe(v),
|
|
4514
4574
|
json_decode: (v) => typeof v === "string" ? JSON.parse(v) : v,
|
|
4515
4575
|
keys: (v) => typeof v === "object" && v !== null && !Array.isArray(v) ? Object.keys(v) : [],
|
|
4516
4576
|
values: (v) => typeof v === "object" && v !== null && !Array.isArray(v) ? Object.values(v) : [],
|
|
@@ -4655,8 +4715,12 @@ var init_engine = __esm({
|
|
|
4655
4715
|
form_token: (v) => _generateFormToken(v != null ? String(v) : ""),
|
|
4656
4716
|
formTokenValue: (v) => _generateFormTokenValue(v != null ? String(v) : ""),
|
|
4657
4717
|
form_token_value: (v) => _generateFormTokenValue(v != null ? String(v) : ""),
|
|
4658
|
-
|
|
4659
|
-
|
|
4718
|
+
// Same serializer as json_encode -- the three names are one behaviour. The
|
|
4719
|
+
// old indent argument is gone: PHP cannot honour an arbitrary indent
|
|
4720
|
+
// (JSON_PRETTY_PRINT is fixed at four spaces), so honouring it here alone
|
|
4721
|
+
// broke byte-parity for the one filter whose whole job is a wire format.
|
|
4722
|
+
tojson: (v) => jsonSafe(v),
|
|
4723
|
+
to_json: (v) => jsonSafe(v),
|
|
4660
4724
|
js_escape: (v) => new SafeString(String(v).replace(/\\/g, "\\\\").replace(/'/g, "\\'").replace(/"/g, '\\"').replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"))
|
|
4661
4725
|
};
|
|
4662
4726
|
_formTokenSessionId = "";
|
|
@@ -5061,8 +5125,11 @@ var init_engine = __esm({
|
|
|
5061
5125
|
i = skip;
|
|
5062
5126
|
}
|
|
5063
5127
|
} else if (tag === "set") {
|
|
5128
|
+
const isBlockSet = !content.includes("=");
|
|
5064
5129
|
if (this._sandbox && this._allowedTags !== null && !this._allowedTags.has("set")) {
|
|
5065
|
-
i
|
|
5130
|
+
i = isBlockSet ? this.skipBlock(tokens, i, "set", "endset") : i + 1;
|
|
5131
|
+
} else if (isBlockSet) {
|
|
5132
|
+
i = this.handleSetBlock(tokens, i, context);
|
|
5066
5133
|
} else {
|
|
5067
5134
|
this.handleSet(content, context);
|
|
5068
5135
|
i++;
|
|
@@ -5104,6 +5171,11 @@ var init_engine = __esm({
|
|
|
5104
5171
|
i++;
|
|
5105
5172
|
} else {
|
|
5106
5173
|
i++;
|
|
5174
|
+
if (tag !== "" && !TERMINATOR_TAGS.has(tag)) {
|
|
5175
|
+
throw new Error(
|
|
5176
|
+
`Frond: unknown tag "${tag}" -- known tags are: ${[...KNOWN_TAGS].sort().join(", ")}`
|
|
5177
|
+
);
|
|
5178
|
+
}
|
|
5107
5179
|
}
|
|
5108
5180
|
if (stripA && i < tokens.length && tokens[i][0] === "TEXT") {
|
|
5109
5181
|
tokens[i] = ["TEXT", tokens[i][1].replace(LEADING_WS_RE, "")];
|
|
@@ -5396,7 +5468,7 @@ var init_engine = __esm({
|
|
|
5396
5468
|
value = typeof value === "object" && value !== null && !Array.isArray(value) ? Object.values(value) : [];
|
|
5397
5469
|
continue;
|
|
5398
5470
|
case "json_encode":
|
|
5399
|
-
value =
|
|
5471
|
+
value = jsonSafe(value);
|
|
5400
5472
|
continue;
|
|
5401
5473
|
case "dump":
|
|
5402
5474
|
value = renderDump(value);
|
|
@@ -6000,6 +6072,47 @@ var init_engine = __esm({
|
|
|
6000
6072
|
}
|
|
6001
6073
|
return html;
|
|
6002
6074
|
}
|
|
6075
|
+
/**
|
|
6076
|
+
* {% set name %}...{% endset %} -- render the body and bind it.
|
|
6077
|
+
*
|
|
6078
|
+
* Emits nothing itself. The captured value is a SafeString because it is
|
|
6079
|
+
* template output that has already been escaped on the way in; re-escaping it
|
|
6080
|
+
* at {{ name }} would double-encode every entity. Twig and Jinja2 both mark the
|
|
6081
|
+
* capture safe. Returns the index just past {% endset %}.
|
|
6082
|
+
*/
|
|
6083
|
+
handleSetBlock(tokens, start2, context) {
|
|
6084
|
+
const [content] = stripTag(tokens[start2][1]);
|
|
6085
|
+
const name = (content.split(/\s+/)[1] || "").trim();
|
|
6086
|
+
const bodyTokens = [];
|
|
6087
|
+
let i = start2 + 1;
|
|
6088
|
+
let depth = 0;
|
|
6089
|
+
while (i < tokens.length) {
|
|
6090
|
+
if (tokens[i][0] === "BLOCK") {
|
|
6091
|
+
const [tagContent] = stripTag(tokens[i][1]);
|
|
6092
|
+
const tag = tagContent.split(/\s+/)[0] || "";
|
|
6093
|
+
if (tag === "set" && !tagContent.includes("=")) {
|
|
6094
|
+
depth++;
|
|
6095
|
+
bodyTokens.push(tokens[i]);
|
|
6096
|
+
} else if (tag === "endset") {
|
|
6097
|
+
if (depth === 0) {
|
|
6098
|
+
i++;
|
|
6099
|
+
break;
|
|
6100
|
+
}
|
|
6101
|
+
depth--;
|
|
6102
|
+
bodyTokens.push(tokens[i]);
|
|
6103
|
+
} else {
|
|
6104
|
+
bodyTokens.push(tokens[i]);
|
|
6105
|
+
}
|
|
6106
|
+
} else {
|
|
6107
|
+
bodyTokens.push(tokens[i]);
|
|
6108
|
+
}
|
|
6109
|
+
i++;
|
|
6110
|
+
}
|
|
6111
|
+
if (name) {
|
|
6112
|
+
context[name] = new SafeString(this.renderTokens([...bodyTokens], context));
|
|
6113
|
+
}
|
|
6114
|
+
return i;
|
|
6115
|
+
}
|
|
6003
6116
|
handleSpaceless(tokens, start2, context) {
|
|
6004
6117
|
const bodyTokens = [];
|
|
6005
6118
|
let i = start2 + 1;
|