tina4-nodejs 3.13.87 → 3.13.88

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.88)
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.88 - 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,899 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.88",
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, 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,15 @@ var init_engine = __esm({
13785
13805
  return this.value;
13786
13806
  }
13787
13807
  };
13808
+ JSON_UNSAFE_RE = /[<>&'\u2028\u2029]/g;
13809
+ JSON_UNSAFE_MAP = {
13810
+ "<": "\\u003c",
13811
+ ">": "\\u003e",
13812
+ "&": "\\u0026",
13813
+ "'": "\\u0027",
13814
+ "\u2028": "\\u2028",
13815
+ "\u2029": "\\u2029"
13816
+ };
13788
13817
  NUMERIC_RE = /^-?\d+(\.\d+)?$/;
13789
13818
  METHOD_CALL_RE = /^(\w+)\s*\(([\s\S]*)?\)$/;
13790
13819
  FN_CALL_RE = /^([\w.]+)\s*\(([\s\S]*)?\)$/;
@@ -13875,7 +13904,7 @@ var init_engine = __esm({
13875
13904
  int: (v) => v ? parseInt(String(v), 10) || 0 : 0,
13876
13905
  float: (v) => v ? parseFloat(String(v)) || 0 : 0,
13877
13906
  string: (v) => String(v),
13878
- json_encode: (v) => JSON.stringify(v),
13907
+ json_encode: (v) => jsonSafe(v),
13879
13908
  json_decode: (v) => typeof v === "string" ? JSON.parse(v) : v,
13880
13909
  keys: (v) => typeof v === "object" && v !== null && !Array.isArray(v) ? Object.keys(v) : [],
13881
13910
  values: (v) => typeof v === "object" && v !== null && !Array.isArray(v) ? Object.values(v) : [],
@@ -14020,8 +14049,12 @@ var init_engine = __esm({
14020
14049
  form_token: (v) => _generateFormToken(v != null ? String(v) : ""),
14021
14050
  formTokenValue: (v) => _generateFormTokenValue(v != null ? String(v) : ""),
14022
14051
  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)),
14052
+ // Same serializer as json_encode -- the three names are one behaviour. The
14053
+ // old indent argument is gone: PHP cannot honour an arbitrary indent
14054
+ // (JSON_PRETTY_PRINT is fixed at four spaces), so honouring it here alone
14055
+ // broke byte-parity for the one filter whose whole job is a wire format.
14056
+ tojson: (v) => jsonSafe(v),
14057
+ to_json: (v) => jsonSafe(v),
14025
14058
  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
14059
  };
14027
14060
  _formTokenSessionId = "";
@@ -14761,7 +14794,7 @@ var init_engine = __esm({
14761
14794
  value = typeof value === "object" && value !== null && !Array.isArray(value) ? Object.values(value) : [];
14762
14795
  continue;
14763
14796
  case "json_encode":
14764
- value = JSON.stringify(value);
14797
+ value = jsonSafe(value);
14765
14798
  continue;
14766
14799
  case "dump":
14767
14800
  value = renderDump(value);
@@ -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, 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,15 @@ var init_engine = __esm({
13784
13804
  return this.value;
13785
13805
  }
13786
13806
  };
13807
+ JSON_UNSAFE_RE = /[<>&'\u2028\u2029]/g;
13808
+ JSON_UNSAFE_MAP = {
13809
+ "<": "\\u003c",
13810
+ ">": "\\u003e",
13811
+ "&": "\\u0026",
13812
+ "'": "\\u0027",
13813
+ "\u2028": "\\u2028",
13814
+ "\u2029": "\\u2029"
13815
+ };
13787
13816
  NUMERIC_RE = /^-?\d+(\.\d+)?$/;
13788
13817
  METHOD_CALL_RE = /^(\w+)\s*\(([\s\S]*)?\)$/;
13789
13818
  FN_CALL_RE = /^([\w.]+)\s*\(([\s\S]*)?\)$/;
@@ -13874,7 +13903,7 @@ var init_engine = __esm({
13874
13903
  int: (v) => v ? parseInt(String(v), 10) || 0 : 0,
13875
13904
  float: (v) => v ? parseFloat(String(v)) || 0 : 0,
13876
13905
  string: (v) => String(v),
13877
- json_encode: (v) => JSON.stringify(v),
13906
+ json_encode: (v) => jsonSafe(v),
13878
13907
  json_decode: (v) => typeof v === "string" ? JSON.parse(v) : v,
13879
13908
  keys: (v) => typeof v === "object" && v !== null && !Array.isArray(v) ? Object.keys(v) : [],
13880
13909
  values: (v) => typeof v === "object" && v !== null && !Array.isArray(v) ? Object.values(v) : [],
@@ -14019,8 +14048,12 @@ var init_engine = __esm({
14019
14048
  form_token: (v) => _generateFormToken(v != null ? String(v) : ""),
14020
14049
  formTokenValue: (v) => _generateFormTokenValue(v != null ? String(v) : ""),
14021
14050
  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)),
14051
+ // Same serializer as json_encode -- the three names are one behaviour. The
14052
+ // old indent argument is gone: PHP cannot honour an arbitrary indent
14053
+ // (JSON_PRETTY_PRINT is fixed at four spaces), so honouring it here alone
14054
+ // broke byte-parity for the one filter whose whole job is a wire format.
14055
+ tojson: (v) => jsonSafe(v),
14056
+ to_json: (v) => jsonSafe(v),
14024
14057
  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
14058
  };
14026
14059
  _formTokenSessionId = "";
@@ -14760,7 +14793,7 @@ var init_engine = __esm({
14760
14793
  value = typeof value === "object" && value !== null && !Array.isArray(value) ? Object.values(value) : [];
14761
14794
  continue;
14762
14795
  case "json_encode":
14763
- value = JSON.stringify(value);
14796
+ value = jsonSafe(value);
14764
14797
  continue;
14765
14798
  case "dump":
14766
14799
  value = renderDump(value);
@@ -10,6 +10,35 @@ var SafeString = class {
10
10
  return this.value;
11
11
  }
12
12
  };
13
+ function jsonText(value) {
14
+ try {
15
+ const text = JSON.stringify(value);
16
+ return text === void 0 ? "null" : text;
17
+ } catch {
18
+ const seen = /* @__PURE__ */ new WeakSet();
19
+ const text = JSON.stringify(value, (_key, v) => {
20
+ if (typeof v === "bigint") return v.toString();
21
+ if (typeof v === "object" && v !== null) {
22
+ if (seen.has(v)) return null;
23
+ seen.add(v);
24
+ }
25
+ return v;
26
+ });
27
+ return text === void 0 ? "null" : text;
28
+ }
29
+ }
30
+ var JSON_UNSAFE_RE = /[<>&'\u2028\u2029]/g;
31
+ var JSON_UNSAFE_MAP = {
32
+ "<": "\\u003c",
33
+ ">": "\\u003e",
34
+ "&": "\\u0026",
35
+ "'": "\\u0027",
36
+ "\u2028": "\\u2028",
37
+ "\u2029": "\\u2029"
38
+ };
39
+ function jsonSafe(value) {
40
+ return new SafeString(jsonText(value).replace(JSON_UNSAFE_RE, (c) => JSON_UNSAFE_MAP[c]));
41
+ }
13
42
  function inspectValue(value, seen = /* @__PURE__ */ new WeakSet(), depth = 0) {
14
43
  if (value === null) return "null";
15
44
  if (value === void 0) return "undefined";
@@ -1049,7 +1078,7 @@ var BUILTIN_FILTERS = {
1049
1078
  int: (v) => v ? parseInt(String(v), 10) || 0 : 0,
1050
1079
  float: (v) => v ? parseFloat(String(v)) || 0 : 0,
1051
1080
  string: (v) => String(v),
1052
- json_encode: (v) => JSON.stringify(v),
1081
+ json_encode: (v) => jsonSafe(v),
1053
1082
  json_decode: (v) => typeof v === "string" ? JSON.parse(v) : v,
1054
1083
  keys: (v) => typeof v === "object" && v !== null && !Array.isArray(v) ? Object.keys(v) : [],
1055
1084
  values: (v) => typeof v === "object" && v !== null && !Array.isArray(v) ? Object.values(v) : [],
@@ -1194,8 +1223,12 @@ var BUILTIN_FILTERS = {
1194
1223
  form_token: (v) => _generateFormToken(v != null ? String(v) : ""),
1195
1224
  formTokenValue: (v) => _generateFormTokenValue(v != null ? String(v) : ""),
1196
1225
  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)),
1226
+ // Same serializer as json_encode -- the three names are one behaviour. The
1227
+ // old indent argument is gone: PHP cannot honour an arbitrary indent
1228
+ // (JSON_PRETTY_PRINT is fixed at four spaces), so honouring it here alone
1229
+ // broke byte-parity for the one filter whose whole job is a wire format.
1230
+ tojson: (v) => jsonSafe(v),
1231
+ to_json: (v) => jsonSafe(v),
1199
1232
  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
1233
  };
1201
1234
  function _b64url(data) {
@@ -1970,7 +2003,7 @@ var Frond = class _Frond {
1970
2003
  value = typeof value === "object" && value !== null && !Array.isArray(value) ? Object.values(value) : [];
1971
2004
  continue;
1972
2005
  case "json_encode":
1973
- value = JSON.stringify(value);
2006
+ value = jsonSafe(value);
1974
2007
  continue;
1975
2008
  case "dump":
1976
2009
  value = renderDump(value);
@@ -34,6 +34,69 @@ class SafeString {
34
34
  toString() { return this.value; }
35
35
  }
36
36
 
37
+ /**
38
+ * Serialize a value to compact JSON text that is always valid JSON.
39
+ *
40
+ * Never throws and never returns an empty string. JSON.stringify already maps a
41
+ * non-finite number to null, but it returns the VALUE `undefined` for undefined,
42
+ * a function, or a symbol, and it throws on a BigInt or a circular structure --
43
+ * all four of which would otherwise reach the page as nothing or as a crash.
44
+ */
45
+ function jsonText(value: unknown): string {
46
+ try {
47
+ const text = JSON.stringify(value);
48
+ return text === undefined ? "null" : text;
49
+ } catch {
50
+ // Only reached when the happy path threw, so a well-formed payload never
51
+ // pays for the walk.
52
+ const seen = new WeakSet<object>();
53
+ const text = JSON.stringify(value, (_key, v) => {
54
+ if (typeof v === "bigint") return v.toString();
55
+ if (typeof v === "object" && v !== null) {
56
+ if (seen.has(v)) return null;
57
+ seen.add(v);
58
+ }
59
+ return v;
60
+ });
61
+ return text === undefined ? "null" : text;
62
+ }
63
+ }
64
+
65
+ const JSON_UNSAFE_RE = /[<>&'\u2028\u2029]/g;
66
+ const JSON_UNSAFE_MAP: Record<string, string> = {
67
+ "<": "\\u003c", ">": "\\u003e", "&": "\\u0026", "'": "\\u0027",
68
+ "\u2028": "\\u2028", "\u2029": "\\u2029",
69
+ };
70
+
71
+ /**
72
+ * Serialize to JSON that is valid JSON, valid JavaScript, and safe in HTML.
73
+ *
74
+ * THE cross-framework contract for json_encode / to_json / tojson. Keep the four
75
+ * implementations byte-identical; frond_expression_corpus.txt locks it.
76
+ *
77
+ * Three things this must never do, each of which was a real bug in one of the
78
+ * four engines:
79
+ *
80
+ * 1. Never emit a non-finite literal. Python wrote a bare `Infinity`, PHP
81
+ * returned false, Ruby fell back to inspect output -- none of them parse.
82
+ * Node was the one that already got this right. Reported as tina4-php#184 by
83
+ * justin-k-bruce.
84
+ * 2. Never emit nothing, and never emit something that still parses and means
85
+ * something else. "var ROWS = ;" is at least a loud SyntaxError.
86
+ * 3. Never HTML-escape it. Entity-encoding JSON produces {&quot;a&quot;:1}, a
87
+ * SyntaxError inside <script>, which is the filter's whole point. Escape only
88
+ * the dangerous characters, as JSON \uXXXX escapes: the result stays valid
89
+ * JSON AND valid JavaScript, </script> cannot terminate the block, and it is
90
+ * safe inside a single-quoted attribute. This is what Jinja2's tojson does,
91
+ * and it is why the result is a SafeString.
92
+ *
93
+ * U+2028 and U+2029 join that escape set. Both are legal inside a JSON string and
94
+ * both were illegal inside a JavaScript string literal before ES2019.
95
+ */
96
+ function jsonSafe(value: unknown): SafeString {
97
+ return new SafeString(jsonText(value).replace(JSON_UNSAFE_RE, (c) => JSON_UNSAFE_MAP[c]));
98
+ }
99
+
37
100
  /**
38
101
  * Produce a human-readable, debugger-friendly inspection of any value.
39
102
  *
@@ -1307,7 +1370,7 @@ const BUILTIN_FILTERS: Record<string, FilterFn> = {
1307
1370
  int: (v) => v ? parseInt(String(v), 10) || 0 : 0,
1308
1371
  float: (v) => v ? parseFloat(String(v)) || 0.0 : 0.0,
1309
1372
  string: (v) => String(v),
1310
- json_encode: (v) => JSON.stringify(v),
1373
+ json_encode: (v) => jsonSafe(v),
1311
1374
  json_decode: (v) => typeof v === "string" ? JSON.parse(v) : v,
1312
1375
  keys: (v) => (typeof v === "object" && v !== null && !Array.isArray(v)) ? Object.keys(v) : [],
1313
1376
  values: (v) => (typeof v === "object" && v !== null && !Array.isArray(v)) ? Object.values(v) : [],
@@ -1431,8 +1494,12 @@ const BUILTIN_FILTERS: Record<string, FilterFn> = {
1431
1494
  form_token: (v?: unknown) => _generateFormToken(v != null ? String(v) : ""),
1432
1495
  formTokenValue: (v?: unknown) => _generateFormTokenValue(v != null ? String(v) : ""),
1433
1496
  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)),
1497
+ // Same serializer as json_encode -- the three names are one behaviour. The
1498
+ // old indent argument is gone: PHP cannot honour an arbitrary indent
1499
+ // (JSON_PRETTY_PRINT is fixed at four spaces), so honouring it here alone
1500
+ // broke byte-parity for the one filter whose whole job is a wire format.
1501
+ tojson: (v) => jsonSafe(v),
1502
+ to_json: (v) => jsonSafe(v),
1436
1503
  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
1504
  };
1438
1505
 
@@ -2346,7 +2413,7 @@ export class Frond {
2346
2413
  case "last": value = Array.isArray(value) ? value[value.length - 1] ?? null : null; continue;
2347
2414
  case "keys": value = (typeof value === "object" && value !== null && !Array.isArray(value)) ? Object.keys(value) : []; continue;
2348
2415
  case "values": value = (typeof value === "object" && value !== null && !Array.isArray(value)) ? Object.values(value) : []; continue;
2349
- case "json_encode": value = JSON.stringify(value); continue;
2416
+ case "json_encode": value = jsonSafe(value); continue;
2350
2417
  case "dump":
2351
2418
  // Delegates to renderDump(), which is gated on TINA4_DEBUG.
2352
2419
  // In production this emits an empty SafeString (no leaked state).
@@ -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, 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,15 @@ var init_engine = __esm({
4420
4440
  return this.value;
4421
4441
  }
4422
4442
  };
4443
+ JSON_UNSAFE_RE = /[<>&'\u2028\u2029]/g;
4444
+ JSON_UNSAFE_MAP = {
4445
+ "<": "\\u003c",
4446
+ ">": "\\u003e",
4447
+ "&": "\\u0026",
4448
+ "'": "\\u0027",
4449
+ "\u2028": "\\u2028",
4450
+ "\u2029": "\\u2029"
4451
+ };
4423
4452
  NUMERIC_RE = /^-?\d+(\.\d+)?$/;
4424
4453
  METHOD_CALL_RE = /^(\w+)\s*\(([\s\S]*)?\)$/;
4425
4454
  FN_CALL_RE = /^([\w.]+)\s*\(([\s\S]*)?\)$/;
@@ -4510,7 +4539,7 @@ var init_engine = __esm({
4510
4539
  int: (v) => v ? parseInt(String(v), 10) || 0 : 0,
4511
4540
  float: (v) => v ? parseFloat(String(v)) || 0 : 0,
4512
4541
  string: (v) => String(v),
4513
- json_encode: (v) => JSON.stringify(v),
4542
+ json_encode: (v) => jsonSafe(v),
4514
4543
  json_decode: (v) => typeof v === "string" ? JSON.parse(v) : v,
4515
4544
  keys: (v) => typeof v === "object" && v !== null && !Array.isArray(v) ? Object.keys(v) : [],
4516
4545
  values: (v) => typeof v === "object" && v !== null && !Array.isArray(v) ? Object.values(v) : [],
@@ -4655,8 +4684,12 @@ var init_engine = __esm({
4655
4684
  form_token: (v) => _generateFormToken(v != null ? String(v) : ""),
4656
4685
  formTokenValue: (v) => _generateFormTokenValue(v != null ? String(v) : ""),
4657
4686
  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)),
4687
+ // Same serializer as json_encode -- the three names are one behaviour. The
4688
+ // old indent argument is gone: PHP cannot honour an arbitrary indent
4689
+ // (JSON_PRETTY_PRINT is fixed at four spaces), so honouring it here alone
4690
+ // broke byte-parity for the one filter whose whole job is a wire format.
4691
+ tojson: (v) => jsonSafe(v),
4692
+ to_json: (v) => jsonSafe(v),
4660
4693
  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
4694
  };
4662
4695
  _formTokenSessionId = "";
@@ -5396,7 +5429,7 @@ var init_engine = __esm({
5396
5429
  value = typeof value === "object" && value !== null && !Array.isArray(value) ? Object.values(value) : [];
5397
5430
  continue;
5398
5431
  case "json_encode":
5399
- value = JSON.stringify(value);
5432
+ value = jsonSafe(value);
5400
5433
  continue;
5401
5434
  case "dump":
5402
5435
  value = renderDump(value);