tina4-nodejs 3.13.86 → 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.
@@ -1674,18 +1674,21 @@ var init_postgres = __esm({
1674
1674
  }
1675
1675
  async startTransactionAsync() {
1676
1676
  await this.executeAsync("BEGIN");
1677
+ this._inTransaction = true;
1677
1678
  }
1678
1679
  commit() {
1679
1680
  throw new Error("Use commitAsync() for PostgreSQL.");
1680
1681
  }
1681
1682
  async commitAsync() {
1682
1683
  await this.executeAsync("COMMIT");
1684
+ this._inTransaction = false;
1683
1685
  }
1684
1686
  rollback() {
1685
1687
  throw new Error("Use rollbackAsync() for PostgreSQL.");
1686
1688
  }
1687
1689
  async rollbackAsync() {
1688
1690
  await this.executeAsync("ROLLBACK");
1691
+ this._inTransaction = false;
1689
1692
  }
1690
1693
  tables() {
1691
1694
  throw new Error("Use tablesAsync() for PostgreSQL.");
@@ -2704,10 +2707,35 @@ var init_firebird = __esm({
2704
2707
  translated = SQLTranslator.ilikeToLike(translated);
2705
2708
  return translated;
2706
2709
  }
2710
+ /**
2711
+ * The handle every statement runs on. While an explicit transaction is open
2712
+ * (startTransactionAsync set `this.transaction`), statements MUST run on that
2713
+ * transaction object so they are undone by rollbackAsync() / persisted by
2714
+ * commitAsync() — node-firebird's transaction exposes the same
2715
+ * query()/execute() as the connection. With no transaction open we run on
2716
+ * `this.db`, whose per-statement work auto-commits on the connection.
2717
+ *
2718
+ * This matches the Python master's contract (tina4_python/database/firebird.py):
2719
+ * there, ALL statements run on the single connection and start_transaction()
2720
+ * merely suppresses the per-statement autocommit in execute() so the batch
2721
+ * stays open until commit()/rollback(). node-firebird has no such suppression
2722
+ * hook — its `db.query/execute` always auto-commit — so the equivalent is to
2723
+ * route statements through the transaction object instead. Same observable
2724
+ * behaviour: an open transaction is atomic and rolls back cleanly.
2725
+ *
2726
+ * Previously every statement ran on `this.db` unconditionally, so the
2727
+ * transaction created by startTransactionAsync() never saw a single statement
2728
+ * — rollbackAsync() rolled back an EMPTY transaction and the already
2729
+ * auto-committed write survived (silent no-op). Twin of the PHP pdo_firebird
2730
+ * bug fixed in 3.13.86.
2731
+ */
2732
+ statementHandle() {
2733
+ return this.transaction ?? this.db;
2734
+ }
2707
2735
  queryPromise(sql, params) {
2708
2736
  return new Promise((resolve21, reject) => {
2709
2737
  const translated = this.translateSql(sql);
2710
- this.db.query(translated, params ?? [], (err, result) => {
2738
+ this.statementHandle().query(translated, params ?? [], (err, result) => {
2711
2739
  if (err) reject(err);
2712
2740
  else resolve21(result ?? []);
2713
2741
  });
@@ -2716,7 +2744,7 @@ var init_firebird = __esm({
2716
2744
  executePromise(sql, params) {
2717
2745
  return new Promise((resolve21, reject) => {
2718
2746
  const translated = this.translateSql(sql);
2719
- this.db.execute(translated, params ?? [], (err) => {
2747
+ this.statementHandle().execute(translated, params ?? [], (err) => {
2720
2748
  if (err) reject(err);
2721
2749
  else resolve21();
2722
2750
  });
@@ -4690,14 +4718,15 @@ var init_database = __esm({
4690
4718
  async executeMany(sql, paramSets = []) {
4691
4719
  const adapter = this.getNextAdapter();
4692
4720
  const results = [];
4693
- await adapterStartTransaction(adapter);
4721
+ const owns = !this.inExplicitTransaction();
4722
+ if (owns) await adapterStartTransaction(adapter);
4694
4723
  try {
4695
4724
  for (const params of paramSets) {
4696
4725
  results.push(await adapterExecute(adapter, sql, params));
4697
4726
  }
4698
- await adapterCommit(adapter);
4727
+ if (owns) await adapterCommit(adapter);
4699
4728
  } catch (e) {
4700
- await adapterRollback(adapter);
4729
+ if (owns) await adapterRollback(adapter);
4701
4730
  throw e;
4702
4731
  }
4703
4732
  return results;
@@ -12750,11 +12779,32 @@ var init_request = __esm({
12750
12779
  var engine_exports = {};
12751
12780
  __export(engine_exports, {
12752
12781
  Frond: () => Frond,
12782
+ TEMPLATE_CACHE_MAX: () => TEMPLATE_CACHE_MAX,
12753
12783
  setFormTokenSessionId: () => setFormTokenSessionId
12754
12784
  });
12755
12785
  import { createHash as createHash2, createHmac as createHmac3, randomBytes as randomBytes4 } from "node:crypto";
12756
12786
  import { readFileSync as readFileSync6, existsSync as existsSync8, statSync as statSync6 } from "node:fs";
12757
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
+ }
12758
12808
  function inspectValue(value, seen = /* @__PURE__ */ new WeakSet(), depth = 0) {
12759
12809
  if (value === null) return "null";
12760
12810
  if (value === void 0) return "undefined";
@@ -12825,6 +12875,14 @@ function renderDump(value) {
12825
12875
  function liveAttr(value) {
12826
12876
  return String(value).replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
12827
12877
  }
12878
+ function capCache(cache, maxEntries) {
12879
+ if (cache.size < maxEntries) return;
12880
+ let drop = Math.floor(maxEntries / 2);
12881
+ for (const key of cache.keys()) {
12882
+ cache.delete(key);
12883
+ if (--drop <= 0) break;
12884
+ }
12885
+ }
12828
12886
  function tokenize(source) {
12829
12887
  const rawBlocks = [];
12830
12888
  source = source.replace(RAW_BLOCK_RE, (_match, content) => {
@@ -13176,6 +13234,9 @@ function evalExpr(expr, context) {
13176
13234
  }).join("");
13177
13235
  }
13178
13236
  }
13237
+ if (expr.startsWith("not ")) {
13238
+ return evalComparison(expr, context);
13239
+ }
13179
13240
  for (const op of [" not in ", " in ", " is not ", " is ", "!=", "==", ">=", "<=", ">", "<", " and ", " or ", " not "]) {
13180
13241
  if (findOutsideQuotes(expr, op) >= 0) {
13181
13242
  return evalComparison(expr, context);
@@ -13731,7 +13792,7 @@ function _generateFormToken(descriptor = "") {
13731
13792
  function _generateFormTokenValue(descriptor = "") {
13732
13793
  return new SafeString(_buildFormTokenJwt(descriptor));
13733
13794
  }
13734
- 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, 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;
13735
13796
  var init_engine = __esm({
13736
13797
  "../frond/src/engine.ts"() {
13737
13798
  "use strict";
@@ -13743,6 +13804,15 @@ var init_engine = __esm({
13743
13804
  return this.value;
13744
13805
  }
13745
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
+ };
13746
13816
  NUMERIC_RE = /^-?\d+(\.\d+)?$/;
13747
13817
  METHOD_CALL_RE = /^(\w+)\s*\(([\s\S]*)?\)$/;
13748
13818
  FN_CALL_RE = /^([\w.]+)\s*\(([\s\S]*)?\)$/;
@@ -13764,6 +13834,7 @@ var init_engine = __esm({
13764
13834
  LIVE_SRC_RE = /src\s+["']([^"']+)["']/;
13765
13835
  filterChainCache = /* @__PURE__ */ new Map();
13766
13836
  pathParseCache = /* @__PURE__ */ new Map();
13837
+ TEMPLATE_CACHE_MAX = 256;
13767
13838
  TOKEN_RE = /(\{%-?\s*[\s\S]*?\s*-?%\})|(\{\{-?\s*[\s\S]*?\s*-?\}\})|(\{#[\s\S]*?#\})/g;
13768
13839
  RAW_BLOCK_RE = /\{%-?\s*raw\s*-?%\}([\s\S]*?)\{%-?\s*endraw\s*-?%\}/g;
13769
13840
  VarRef = class {
@@ -13832,7 +13903,7 @@ var init_engine = __esm({
13832
13903
  int: (v) => v ? parseInt(String(v), 10) || 0 : 0,
13833
13904
  float: (v) => v ? parseFloat(String(v)) || 0 : 0,
13834
13905
  string: (v) => String(v),
13835
- json_encode: (v) => JSON.stringify(v),
13906
+ json_encode: (v) => jsonSafe(v),
13836
13907
  json_decode: (v) => typeof v === "string" ? JSON.parse(v) : v,
13837
13908
  keys: (v) => typeof v === "object" && v !== null && !Array.isArray(v) ? Object.keys(v) : [],
13838
13909
  values: (v) => typeof v === "object" && v !== null && !Array.isArray(v) ? Object.values(v) : [],
@@ -13977,8 +14048,12 @@ var init_engine = __esm({
13977
14048
  form_token: (v) => _generateFormToken(v != null ? String(v) : ""),
13978
14049
  formTokenValue: (v) => _generateFormTokenValue(v != null ? String(v) : ""),
13979
14050
  form_token_value: (v) => _generateFormTokenValue(v != null ? String(v) : ""),
13980
- tojson: (v, indent) => new SafeString(indent !== void 0 ? JSON.stringify(v, null, parseInt(String(indent), 10)) : JSON.stringify(v)),
13981
- 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),
13982
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"))
13983
14058
  };
13984
14059
  _formTokenSessionId = "";
@@ -14169,6 +14244,7 @@ var init_engine = __esm({
14169
14244
  const source = readFileSync6(filePath, "utf-8");
14170
14245
  const mtime = statSync6(filePath).mtimeMs;
14171
14246
  const tokens = tokenize(source);
14247
+ capCache(this.compiled, TEMPLATE_CACHE_MAX);
14172
14248
  this.compiled.set(template, { tokens, mtime, cachedAt: Date.now() });
14173
14249
  return this.executeWithSource(source, tokens, context);
14174
14250
  }
@@ -14183,6 +14259,7 @@ var init_engine = __esm({
14183
14259
  }
14184
14260
  }
14185
14261
  const tokens = tokenize(source);
14262
+ capCache(this.compiledStrings, TEMPLATE_CACHE_MAX);
14186
14263
  this.compiledStrings.set(key, { tokens, cachedAt: Date.now() });
14187
14264
  return this.executeCached(tokens, context);
14188
14265
  }
@@ -14398,6 +14475,9 @@ var init_engine = __esm({
14398
14475
  } else if (tag === "macro") {
14399
14476
  const skip = this.handleMacro(tokens, i, context);
14400
14477
  i = skip;
14478
+ } else if (tag === "import") {
14479
+ this.handleImportAs(content, context);
14480
+ i++;
14401
14481
  } else if (tag === "from") {
14402
14482
  this.handleFromImport(content, context);
14403
14483
  i++;
@@ -14713,7 +14793,7 @@ var init_engine = __esm({
14713
14793
  value = typeof value === "object" && value !== null && !Array.isArray(value) ? Object.values(value) : [];
14714
14794
  continue;
14715
14795
  case "json_encode":
14716
- value = JSON.stringify(value);
14796
+ value = jsonSafe(value);
14717
14797
  continue;
14718
14798
  case "dump":
14719
14799
  value = renderDump(value);
@@ -14964,7 +15044,7 @@ var init_engine = __esm({
14964
15044
  return i2;
14965
15045
  }
14966
15046
  const macroName = m[1];
14967
- const paramNames = m[2].split(",").map((p) => p.trim()).filter(Boolean);
15047
+ const params = _Frond.parseMacroParams(m[2]);
14968
15048
  const bodyTokens = [];
14969
15049
  let i = start2 + 1;
14970
15050
  while (i < tokens.length) {
@@ -14979,13 +15059,94 @@ var init_engine = __esm({
14979
15059
  const capturedContext = { ...context };
14980
15060
  context[macroName] = (...args) => {
14981
15061
  const macroCtx = { ...capturedContext };
14982
- for (let pi = 0; pi < paramNames.length; pi++) {
14983
- macroCtx[paramNames[pi]] = pi < args.length ? args[pi] : null;
15062
+ for (let pi = 0; pi < params.length; pi++) {
15063
+ const [pname, pdefault] = params[pi];
15064
+ macroCtx[pname] = pi < args.length ? args[pi] : pdefault;
14984
15065
  }
14985
15066
  return new SafeString(engine.renderTokens([...bodyTokens], macroCtx));
14986
15067
  };
14987
15068
  return i;
14988
15069
  }
15070
+ /**
15071
+ * Parse a macro parameter list into [name, default] pairs.
15072
+ *
15073
+ * Handles: name, name="default", name='default'. Splitting on "," alone left a
15074
+ * defaulted parameter literally NAMED `greeting='Hello'`, so the body's
15075
+ * {{ greeting }} matched nothing (rendered empty) AND the caller's positional
15076
+ * argument was stored under that junk key and lost. Mirrors the Python master's
15077
+ * _parse_macro_params. The default is null when none is declared.
15078
+ */
15079
+ static parseMacroParams(rawParams) {
15080
+ return rawParams.split(",").map((p) => p.trim()).filter(Boolean).map((p) => {
15081
+ const eq = p.indexOf("=");
15082
+ if (eq === -1) return [p, null];
15083
+ const name = p.slice(0, eq).trim();
15084
+ let dflt = p.slice(eq + 1).trim();
15085
+ if (dflt.length >= 2 && (dflt.startsWith('"') && dflt.endsWith('"') || dflt.startsWith("'") && dflt.endsWith("'"))) {
15086
+ dflt = dflt.slice(1, -1);
15087
+ }
15088
+ return [name, dflt];
15089
+ });
15090
+ }
15091
+ /**
15092
+ * {% import "file" as alias %} -- load EVERY macro in a file under one namespace.
15093
+ *
15094
+ * The alias is bound as a plain object of macro functions, so {{ alias.greet(x) }}
15095
+ * resolves through the engine's existing dotted-call path and each macro keeps the
15096
+ * same argument binding, default handling and SafeString output as any other macro.
15097
+ * A namespace object (not a class) is deliberate: a function stored as a class
15098
+ * attribute binds as a method and would inject the namespace as the first argument,
15099
+ * which is exactly the argument-shift bug the Python master carried (fixed there
15100
+ * with types.SimpleNamespace). Both import forms must render identically.
15101
+ */
15102
+ handleImportAs(content, context) {
15103
+ const m = content.match(/^import\s+["'](.+?)["']\s+as\s+(\w+)/);
15104
+ if (!m) return;
15105
+ const filename = m[1];
15106
+ const alias = m[2];
15107
+ const namespace = {};
15108
+ const source = this.load(filename);
15109
+ const tokens = tokenize(source);
15110
+ let i = 0;
15111
+ while (i < tokens.length) {
15112
+ const [ttype, raw] = tokens[i];
15113
+ if (ttype === "BLOCK") {
15114
+ const [tagContent] = stripTag(raw);
15115
+ if ((tagContent.split(/\s+/)[0] || "") === "macro") {
15116
+ const macroM = tagContent.match(/^macro\s+(\w+)\s*\(([^)]*)\)/);
15117
+ if (macroM) {
15118
+ const macroName = macroM[1];
15119
+ const params = _Frond.parseMacroParams(macroM[2]);
15120
+ const bodyTokens = [];
15121
+ i++;
15122
+ while (i < tokens.length) {
15123
+ if (tokens[i][0] === "BLOCK" && tokens[i][1].includes("endmacro")) {
15124
+ i++;
15125
+ break;
15126
+ }
15127
+ bodyTokens.push(tokens[i]);
15128
+ i++;
15129
+ }
15130
+ const capturedBody = [...bodyTokens];
15131
+ const capturedParams = [...params];
15132
+ const capturedCtx = { ...context };
15133
+ const engine = this;
15134
+ namespace[macroName] = (...args) => {
15135
+ const macroCtx = { ...capturedCtx };
15136
+ for (let pi = 0; pi < capturedParams.length; pi++) {
15137
+ const [pname, pdefault] = capturedParams[pi];
15138
+ macroCtx[pname] = pi < args.length ? args[pi] : pdefault;
15139
+ }
15140
+ return new SafeString(engine.renderTokens([...capturedBody], macroCtx));
15141
+ };
15142
+ continue;
15143
+ }
15144
+ }
15145
+ }
15146
+ i++;
15147
+ }
15148
+ context[alias] = namespace;
15149
+ }
14989
15150
  handleFromImport(content, context) {
14990
15151
  const m = content.match(/^from\s+["'](.+?)["']\s+import\s+(.+)/);
14991
15152
  if (!m) return;
@@ -15003,7 +15164,7 @@ var init_engine = __esm({
15003
15164
  const macroM = tagContent.match(/^macro\s+(\w+)\s*\(([^)]*)\)/);
15004
15165
  if (macroM && names.includes(macroM[1])) {
15005
15166
  const macroName = macroM[1];
15006
- const paramNames = macroM[2].split(",").map((p) => p.trim()).filter(Boolean);
15167
+ const paramNames = _Frond.parseMacroParams(macroM[2]);
15007
15168
  const bodyTokens = [];
15008
15169
  i++;
15009
15170
  while (i < tokens.length) {
@@ -15021,7 +15182,8 @@ var init_engine = __esm({
15021
15182
  context[macroName] = (...args) => {
15022
15183
  const macroCtx = { ...capturedCtx };
15023
15184
  for (let pi = 0; pi < capturedParams.length; pi++) {
15024
- macroCtx[capturedParams[pi]] = pi < args.length ? args[pi] : null;
15185
+ const [pname, pdefault] = capturedParams[pi];
15186
+ macroCtx[pname] = pi < args.length ? args[pi] : pdefault;
15025
15187
  }
15026
15188
  return new SafeString(engine.renderTokens([...capturedBody], macroCtx));
15027
15189
  };
@@ -18128,7 +18290,14 @@ function fullAnalysis(root = "src") {
18128
18290
  total_functions: allFunctions.length,
18129
18291
  avg_complexity: Math.round(avgCC * 100) / 100,
18130
18292
  avg_maintainability: Math.round(avgMI * 10) / 10,
18293
+ // Display-only: the top-15 for the "most complex functions" report.
18294
+ // Do NOT source offenders / --fail-on from this — capping here silently
18295
+ // hides the 16th+ over-threshold function from the gate. offenders()
18296
+ // reads "all_functions" (below) instead.
18131
18297
  most_complex_functions: allFunctions.slice(0, 15),
18298
+ // Full, uncapped, complexity-sorted list — offenders()/--fail-on use this
18299
+ // so no function over the complexity threshold ever escapes the gate.
18300
+ all_functions: allFunctions,
18132
18301
  file_metrics: fileMetrics,
18133
18302
  violations,
18134
18303
  dependency_graph: importGraph,
@@ -1183,7 +1183,14 @@ export function fullAnalysis(root: string = "src"): Record<string, any> {
1183
1183
  total_functions: allFunctions.length,
1184
1184
  avg_complexity: Math.round(avgCC * 100) / 100,
1185
1185
  avg_maintainability: Math.round(avgMI * 10) / 10,
1186
+ // Display-only: the top-15 for the "most complex functions" report.
1187
+ // Do NOT source offenders / --fail-on from this — capping here silently
1188
+ // hides the 16th+ over-threshold function from the gate. offenders()
1189
+ // reads "all_functions" (below) instead.
1186
1190
  most_complex_functions: allFunctions.slice(0, 15),
1191
+ // Full, uncapped, complexity-sorted list — offenders()/--fail-on use this
1192
+ // so no function over the complexity threshold ever escapes the gate.
1193
+ all_functions: allFunctions,
1187
1194
  file_metrics: fileMetrics,
1188
1195
  violations,
1189
1196
  dependency_graph: importGraph,
@@ -1243,8 +1250,10 @@ export function offenders(root: string = "src", top: number = 20): OffendersResu
1243
1250
 
1244
1251
  const items: Offender[] = [];
1245
1252
 
1246
- // Function-level: cyclomatic complexity.
1247
- for (const fn of analysis.most_complex_functions || []) {
1253
+ // Function-level: cyclomatic complexity. Use the FULL function list (not the
1254
+ // display-capped most_complex_functions[:15]) so a 16th+ over-threshold
1255
+ // function is never silently dropped from the offenders list or --fail-on.
1256
+ for (const fn of analysis.all_functions || analysis.most_complex_functions || []) {
1248
1257
  const cc: number = fn.complexity;
1249
1258
  if (cc > 10) {
1250
1259
  items.push({
@@ -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";
@@ -101,6 +130,15 @@ function liveAttr(value) {
101
130
  }
102
131
  var filterChainCache = /* @__PURE__ */ new Map();
103
132
  var pathParseCache = /* @__PURE__ */ new Map();
133
+ var TEMPLATE_CACHE_MAX = 256;
134
+ function capCache(cache, maxEntries) {
135
+ if (cache.size < maxEntries) return;
136
+ let drop = Math.floor(maxEntries / 2);
137
+ for (const key of cache.keys()) {
138
+ cache.delete(key);
139
+ if (--drop <= 0) break;
140
+ }
141
+ }
104
142
  var TOKEN_RE = /(\{%-?\s*[\s\S]*?\s*-?%\})|(\{\{-?\s*[\s\S]*?\s*-?\}\})|(\{#[\s\S]*?#\})/g;
105
143
  var RAW_BLOCK_RE = /\{%-?\s*raw\s*-?%\}([\s\S]*?)\{%-?\s*endraw\s*-?%\}/g;
106
144
  function tokenize(source) {
@@ -454,6 +492,9 @@ function evalExpr(expr, context) {
454
492
  }).join("");
455
493
  }
456
494
  }
495
+ if (expr.startsWith("not ")) {
496
+ return evalComparison(expr, context);
497
+ }
457
498
  for (const op of [" not in ", " in ", " is not ", " is ", "!=", "==", ">=", "<=", ">", "<", " and ", " or ", " not "]) {
458
499
  if (findOutsideQuotes(expr, op) >= 0) {
459
500
  return evalComparison(expr, context);
@@ -1037,7 +1078,7 @@ var BUILTIN_FILTERS = {
1037
1078
  int: (v) => v ? parseInt(String(v), 10) || 0 : 0,
1038
1079
  float: (v) => v ? parseFloat(String(v)) || 0 : 0,
1039
1080
  string: (v) => String(v),
1040
- json_encode: (v) => JSON.stringify(v),
1081
+ json_encode: (v) => jsonSafe(v),
1041
1082
  json_decode: (v) => typeof v === "string" ? JSON.parse(v) : v,
1042
1083
  keys: (v) => typeof v === "object" && v !== null && !Array.isArray(v) ? Object.keys(v) : [],
1043
1084
  values: (v) => typeof v === "object" && v !== null && !Array.isArray(v) ? Object.values(v) : [],
@@ -1182,8 +1223,12 @@ var BUILTIN_FILTERS = {
1182
1223
  form_token: (v) => _generateFormToken(v != null ? String(v) : ""),
1183
1224
  formTokenValue: (v) => _generateFormTokenValue(v != null ? String(v) : ""),
1184
1225
  form_token_value: (v) => _generateFormTokenValue(v != null ? String(v) : ""),
1185
- tojson: (v, indent) => new SafeString(indent !== void 0 ? JSON.stringify(v, null, parseInt(String(indent), 10)) : JSON.stringify(v)),
1186
- 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),
1187
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"))
1188
1233
  };
1189
1234
  function _b64url(data) {
@@ -1409,6 +1454,7 @@ var Frond = class _Frond {
1409
1454
  const source = readFileSync(filePath, "utf-8");
1410
1455
  const mtime = statSync(filePath).mtimeMs;
1411
1456
  const tokens = tokenize(source);
1457
+ capCache(this.compiled, TEMPLATE_CACHE_MAX);
1412
1458
  this.compiled.set(template, { tokens, mtime, cachedAt: Date.now() });
1413
1459
  return this.executeWithSource(source, tokens, context);
1414
1460
  }
@@ -1423,6 +1469,7 @@ var Frond = class _Frond {
1423
1469
  }
1424
1470
  }
1425
1471
  const tokens = tokenize(source);
1472
+ capCache(this.compiledStrings, TEMPLATE_CACHE_MAX);
1426
1473
  this.compiledStrings.set(key, { tokens, cachedAt: Date.now() });
1427
1474
  return this.executeCached(tokens, context);
1428
1475
  }
@@ -1638,6 +1685,9 @@ var Frond = class _Frond {
1638
1685
  } else if (tag === "macro") {
1639
1686
  const skip = this.handleMacro(tokens, i, context);
1640
1687
  i = skip;
1688
+ } else if (tag === "import") {
1689
+ this.handleImportAs(content, context);
1690
+ i++;
1641
1691
  } else if (tag === "from") {
1642
1692
  this.handleFromImport(content, context);
1643
1693
  i++;
@@ -1953,7 +2003,7 @@ var Frond = class _Frond {
1953
2003
  value = typeof value === "object" && value !== null && !Array.isArray(value) ? Object.values(value) : [];
1954
2004
  continue;
1955
2005
  case "json_encode":
1956
- value = JSON.stringify(value);
2006
+ value = jsonSafe(value);
1957
2007
  continue;
1958
2008
  case "dump":
1959
2009
  value = renderDump(value);
@@ -2204,7 +2254,7 @@ var Frond = class _Frond {
2204
2254
  return i2;
2205
2255
  }
2206
2256
  const macroName = m[1];
2207
- const paramNames = m[2].split(",").map((p) => p.trim()).filter(Boolean);
2257
+ const params = _Frond.parseMacroParams(m[2]);
2208
2258
  const bodyTokens = [];
2209
2259
  let i = start + 1;
2210
2260
  while (i < tokens.length) {
@@ -2219,13 +2269,94 @@ var Frond = class _Frond {
2219
2269
  const capturedContext = { ...context };
2220
2270
  context[macroName] = (...args) => {
2221
2271
  const macroCtx = { ...capturedContext };
2222
- for (let pi = 0; pi < paramNames.length; pi++) {
2223
- macroCtx[paramNames[pi]] = pi < args.length ? args[pi] : null;
2272
+ for (let pi = 0; pi < params.length; pi++) {
2273
+ const [pname, pdefault] = params[pi];
2274
+ macroCtx[pname] = pi < args.length ? args[pi] : pdefault;
2224
2275
  }
2225
2276
  return new SafeString(engine.renderTokens([...bodyTokens], macroCtx));
2226
2277
  };
2227
2278
  return i;
2228
2279
  }
2280
+ /**
2281
+ * Parse a macro parameter list into [name, default] pairs.
2282
+ *
2283
+ * Handles: name, name="default", name='default'. Splitting on "," alone left a
2284
+ * defaulted parameter literally NAMED `greeting='Hello'`, so the body's
2285
+ * {{ greeting }} matched nothing (rendered empty) AND the caller's positional
2286
+ * argument was stored under that junk key and lost. Mirrors the Python master's
2287
+ * _parse_macro_params. The default is null when none is declared.
2288
+ */
2289
+ static parseMacroParams(rawParams) {
2290
+ return rawParams.split(",").map((p) => p.trim()).filter(Boolean).map((p) => {
2291
+ const eq = p.indexOf("=");
2292
+ if (eq === -1) return [p, null];
2293
+ const name = p.slice(0, eq).trim();
2294
+ let dflt = p.slice(eq + 1).trim();
2295
+ if (dflt.length >= 2 && (dflt.startsWith('"') && dflt.endsWith('"') || dflt.startsWith("'") && dflt.endsWith("'"))) {
2296
+ dflt = dflt.slice(1, -1);
2297
+ }
2298
+ return [name, dflt];
2299
+ });
2300
+ }
2301
+ /**
2302
+ * {% import "file" as alias %} -- load EVERY macro in a file under one namespace.
2303
+ *
2304
+ * The alias is bound as a plain object of macro functions, so {{ alias.greet(x) }}
2305
+ * resolves through the engine's existing dotted-call path and each macro keeps the
2306
+ * same argument binding, default handling and SafeString output as any other macro.
2307
+ * A namespace object (not a class) is deliberate: a function stored as a class
2308
+ * attribute binds as a method and would inject the namespace as the first argument,
2309
+ * which is exactly the argument-shift bug the Python master carried (fixed there
2310
+ * with types.SimpleNamespace). Both import forms must render identically.
2311
+ */
2312
+ handleImportAs(content, context) {
2313
+ const m = content.match(/^import\s+["'](.+?)["']\s+as\s+(\w+)/);
2314
+ if (!m) return;
2315
+ const filename = m[1];
2316
+ const alias = m[2];
2317
+ const namespace = {};
2318
+ const source = this.load(filename);
2319
+ const tokens = tokenize(source);
2320
+ let i = 0;
2321
+ while (i < tokens.length) {
2322
+ const [ttype, raw] = tokens[i];
2323
+ if (ttype === "BLOCK") {
2324
+ const [tagContent] = stripTag(raw);
2325
+ if ((tagContent.split(/\s+/)[0] || "") === "macro") {
2326
+ const macroM = tagContent.match(/^macro\s+(\w+)\s*\(([^)]*)\)/);
2327
+ if (macroM) {
2328
+ const macroName = macroM[1];
2329
+ const params = _Frond.parseMacroParams(macroM[2]);
2330
+ const bodyTokens = [];
2331
+ i++;
2332
+ while (i < tokens.length) {
2333
+ if (tokens[i][0] === "BLOCK" && tokens[i][1].includes("endmacro")) {
2334
+ i++;
2335
+ break;
2336
+ }
2337
+ bodyTokens.push(tokens[i]);
2338
+ i++;
2339
+ }
2340
+ const capturedBody = [...bodyTokens];
2341
+ const capturedParams = [...params];
2342
+ const capturedCtx = { ...context };
2343
+ const engine = this;
2344
+ namespace[macroName] = (...args) => {
2345
+ const macroCtx = { ...capturedCtx };
2346
+ for (let pi = 0; pi < capturedParams.length; pi++) {
2347
+ const [pname, pdefault] = capturedParams[pi];
2348
+ macroCtx[pname] = pi < args.length ? args[pi] : pdefault;
2349
+ }
2350
+ return new SafeString(engine.renderTokens([...capturedBody], macroCtx));
2351
+ };
2352
+ continue;
2353
+ }
2354
+ }
2355
+ }
2356
+ i++;
2357
+ }
2358
+ context[alias] = namespace;
2359
+ }
2229
2360
  handleFromImport(content, context) {
2230
2361
  const m = content.match(/^from\s+["'](.+?)["']\s+import\s+(.+)/);
2231
2362
  if (!m) return;
@@ -2243,7 +2374,7 @@ var Frond = class _Frond {
2243
2374
  const macroM = tagContent.match(/^macro\s+(\w+)\s*\(([^)]*)\)/);
2244
2375
  if (macroM && names.includes(macroM[1])) {
2245
2376
  const macroName = macroM[1];
2246
- const paramNames = macroM[2].split(",").map((p) => p.trim()).filter(Boolean);
2377
+ const paramNames = _Frond.parseMacroParams(macroM[2]);
2247
2378
  const bodyTokens = [];
2248
2379
  i++;
2249
2380
  while (i < tokens.length) {
@@ -2261,7 +2392,8 @@ var Frond = class _Frond {
2261
2392
  context[macroName] = (...args) => {
2262
2393
  const macroCtx = { ...capturedCtx };
2263
2394
  for (let pi = 0; pi < capturedParams.length; pi++) {
2264
- macroCtx[capturedParams[pi]] = pi < args.length ? args[pi] : null;
2395
+ const [pname, pdefault] = capturedParams[pi];
2396
+ macroCtx[pname] = pi < args.length ? args[pi] : pdefault;
2265
2397
  }
2266
2398
  return new SafeString(engine.renderTokens([...capturedBody], macroCtx));
2267
2399
  };