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 CHANGED
@@ -1,10 +1,10 @@
1
- # CLAUDE.md - AI Developer Guide for tina4-nodejs (v3.13.87)
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.87 - The Intelligent Native Application 4ramework. A convention-over-configuration structural paradigm. The developer writes TypeScript; Tina4 is invisible infrastructure.
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,879 tests** passing across 189 files (build + typecheck green)
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tina4-nodejs",
3
- "version": "3.13.87",
3
+ "version": "3.13.89",
4
4
  "type": "module",
5
5
  "description": "Tina4 for Node.js/TypeScript - 54 built-in features, zero dependencies",
6
6
  "keywords": [
@@ -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) => JSON.stringify(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
- tojson: (v, indent) => new SafeString(indent !== void 0 ? JSON.stringify(v, null, parseInt(String(indent), 10)) : JSON.stringify(v)),
14024
- to_json: (v, indent) => new SafeString(indent !== void 0 ? JSON.stringify(v, null, parseInt(String(indent), 10)) : JSON.stringify(v)),
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 = JSON.stringify(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) => JSON.stringify(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
- tojson: (v, indent) => new SafeString(indent !== void 0 ? JSON.stringify(v, null, parseInt(String(indent), 10)) : JSON.stringify(v)),
14023
- to_json: (v, indent) => new SafeString(indent !== void 0 ? JSON.stringify(v, null, parseInt(String(indent), 10)) : JSON.stringify(v)),
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 = JSON.stringify(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) => JSON.stringify(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
- tojson: (v, indent) => new SafeString(indent !== void 0 ? JSON.stringify(v, null, parseInt(String(indent), 10)) : JSON.stringify(v)),
1198
- to_json: (v, indent) => new SafeString(indent !== void 0 ? JSON.stringify(v, null, parseInt(String(indent), 10)) : JSON.stringify(v)),
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 = JSON.stringify(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 {&quot;a&quot;: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) => JSON.stringify(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
- tojson: (v, indent) => new SafeString(indent !== undefined ? JSON.stringify(v, null, parseInt(String(indent), 10)) : JSON.stringify(v)),
1435
- to_json: (v, indent) => new SafeString(indent !== undefined ? JSON.stringify(v, null, parseInt(String(indent), 10)) : JSON.stringify(v)),
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 = JSON.stringify(value); continue;
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) => JSON.stringify(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
- tojson: (v, indent) => new SafeString(indent !== void 0 ? JSON.stringify(v, null, parseInt(String(indent), 10)) : JSON.stringify(v)),
4659
- to_json: (v, indent) => new SafeString(indent !== void 0 ? JSON.stringify(v, null, parseInt(String(indent), 10)) : JSON.stringify(v)),
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 = JSON.stringify(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;