tina4-nodejs 3.13.88 → 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.88)
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.88 - 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,899 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.88",
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": [
@@ -13793,7 +13793,7 @@ function _generateFormToken(descriptor = "") {
13793
13793
  function _generateFormTokenValue(descriptor = "") {
13794
13794
  return new SafeString(_buildFormTokenJwt(descriptor));
13795
13795
  }
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;
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;
13797
13797
  var init_engine = __esm({
13798
13798
  "../frond/src/engine.ts"() {
13799
13799
  "use strict";
@@ -13805,6 +13805,37 @@ var init_engine = __esm({
13805
13805
  return this.value;
13806
13806
  }
13807
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
+ ]);
13808
13839
  JSON_UNSAFE_RE = /[<>&'\u2028\u2029]/g;
13809
13840
  JSON_UNSAFE_MAP = {
13810
13841
  "<": "\\u003c",
@@ -14459,8 +14490,11 @@ var init_engine = __esm({
14459
14490
  i = skip;
14460
14491
  }
14461
14492
  } else if (tag === "set") {
14493
+ const isBlockSet = !content.includes("=");
14462
14494
  if (this._sandbox && this._allowedTags !== null && !this._allowedTags.has("set")) {
14463
- i++;
14495
+ i = isBlockSet ? this.skipBlock(tokens, i, "set", "endset") : i + 1;
14496
+ } else if (isBlockSet) {
14497
+ i = this.handleSetBlock(tokens, i, context);
14464
14498
  } else {
14465
14499
  this.handleSet(content, context);
14466
14500
  i++;
@@ -14502,6 +14536,11 @@ var init_engine = __esm({
14502
14536
  i++;
14503
14537
  } else {
14504
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
+ }
14505
14544
  }
14506
14545
  if (stripA && i < tokens.length && tokens[i][0] === "TEXT") {
14507
14546
  tokens[i] = ["TEXT", tokens[i][1].replace(LEADING_WS_RE, "")];
@@ -15398,6 +15437,47 @@ var init_engine = __esm({
15398
15437
  }
15399
15438
  return html;
15400
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
+ }
15401
15481
  handleSpaceless(tokens, start2, context) {
15402
15482
  const bodyTokens = [];
15403
15483
  let i = start2 + 1;
@@ -13792,7 +13792,7 @@ function _generateFormToken(descriptor = "") {
13792
13792
  function _generateFormTokenValue(descriptor = "") {
13793
13793
  return new SafeString(_buildFormTokenJwt(descriptor));
13794
13794
  }
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;
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;
13796
13796
  var init_engine = __esm({
13797
13797
  "../frond/src/engine.ts"() {
13798
13798
  "use strict";
@@ -13804,6 +13804,37 @@ var init_engine = __esm({
13804
13804
  return this.value;
13805
13805
  }
13806
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
+ ]);
13807
13838
  JSON_UNSAFE_RE = /[<>&'\u2028\u2029]/g;
13808
13839
  JSON_UNSAFE_MAP = {
13809
13840
  "<": "\\u003c",
@@ -14458,8 +14489,11 @@ var init_engine = __esm({
14458
14489
  i = skip;
14459
14490
  }
14460
14491
  } else if (tag === "set") {
14492
+ const isBlockSet = !content.includes("=");
14461
14493
  if (this._sandbox && this._allowedTags !== null && !this._allowedTags.has("set")) {
14462
- i++;
14494
+ i = isBlockSet ? this.skipBlock(tokens, i, "set", "endset") : i + 1;
14495
+ } else if (isBlockSet) {
14496
+ i = this.handleSetBlock(tokens, i, context);
14463
14497
  } else {
14464
14498
  this.handleSet(content, context);
14465
14499
  i++;
@@ -14501,6 +14535,11 @@ var init_engine = __esm({
14501
14535
  i++;
14502
14536
  } else {
14503
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
+ }
14504
14543
  }
14505
14544
  if (stripA && i < tokens.length && tokens[i][0] === "TEXT") {
14506
14545
  tokens[i] = ["TEXT", tokens[i][1].replace(LEADING_WS_RE, "")];
@@ -15397,6 +15436,47 @@ var init_engine = __esm({
15397
15436
  }
15398
15437
  return html;
15399
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
+ }
15400
15480
  handleSpaceless(tokens, start2, context) {
15401
15481
  const bodyTokens = [];
15402
15482
  let i = start2 + 1;
@@ -10,6 +10,37 @@ 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
+ ]);
13
44
  function jsonText(value) {
14
45
  try {
15
46
  const text = JSON.stringify(value);
@@ -1668,8 +1699,11 @@ var Frond = class _Frond {
1668
1699
  i = skip;
1669
1700
  }
1670
1701
  } else if (tag === "set") {
1702
+ const isBlockSet = !content.includes("=");
1671
1703
  if (this._sandbox && this._allowedTags !== null && !this._allowedTags.has("set")) {
1672
- i++;
1704
+ i = isBlockSet ? this.skipBlock(tokens, i, "set", "endset") : i + 1;
1705
+ } else if (isBlockSet) {
1706
+ i = this.handleSetBlock(tokens, i, context);
1673
1707
  } else {
1674
1708
  this.handleSet(content, context);
1675
1709
  i++;
@@ -1711,6 +1745,11 @@ var Frond = class _Frond {
1711
1745
  i++;
1712
1746
  } else {
1713
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
+ }
1714
1753
  }
1715
1754
  if (stripA && i < tokens.length && tokens[i][0] === "TEXT") {
1716
1755
  tokens[i] = ["TEXT", tokens[i][1].replace(LEADING_WS_RE, "")];
@@ -2607,6 +2646,47 @@ var Frond = class _Frond {
2607
2646
  }
2608
2647
  return html;
2609
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
+ }
2610
2690
  handleSpaceless(tokens, start, context) {
2611
2691
  const bodyTokens = [];
2612
2692
  let i = start + 1;
@@ -34,6 +34,31 @@ 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
+
37
62
  /**
38
63
  * Serialize a value to compact JSON text that is always valid JSON.
39
64
  *
@@ -2077,8 +2102,16 @@ export class Frond {
2077
2102
  i = skip;
2078
2103
  }
2079
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("=");
2080
2111
  if (this._sandbox && this._allowedTags !== null && !this._allowedTags.has("set")) {
2081
- i++;
2112
+ i = isBlockSet ? this.skipBlock(tokens, i, "set", "endset") : i + 1;
2113
+ } else if (isBlockSet) {
2114
+ i = this.handleSetBlock(tokens, i, context);
2082
2115
  } else {
2083
2116
  this.handleSet(content, context);
2084
2117
  i++;
@@ -2120,6 +2153,16 @@ export class Frond {
2120
2153
  i++; // Already handled
2121
2154
  } else {
2122
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.
2123
2166
  }
2124
2167
 
2125
2168
  if (stripA && i < tokens.length && tokens[i][0] === "TEXT") {
@@ -3121,6 +3164,50 @@ export class Frond {
3121
3164
  return html;
3122
3165
  }
3123
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
+
3124
3211
  private handleSpaceless(tokens: Token[], start: number, context: Record<string, unknown>): [string, number] {
3125
3212
  const bodyTokens: Token[] = [];
3126
3213
  let i = start + 1;
@@ -4428,7 +4428,7 @@ function _generateFormToken(descriptor = "") {
4428
4428
  function _generateFormTokenValue(descriptor = "") {
4429
4429
  return new SafeString(_buildFormTokenJwt(descriptor));
4430
4430
  }
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;
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;
4432
4432
  var init_engine = __esm({
4433
4433
  "../frond/src/engine.ts"() {
4434
4434
  "use strict";
@@ -4440,6 +4440,37 @@ var init_engine = __esm({
4440
4440
  return this.value;
4441
4441
  }
4442
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
+ ]);
4443
4474
  JSON_UNSAFE_RE = /[<>&'\u2028\u2029]/g;
4444
4475
  JSON_UNSAFE_MAP = {
4445
4476
  "<": "\\u003c",
@@ -5094,8 +5125,11 @@ var init_engine = __esm({
5094
5125
  i = skip;
5095
5126
  }
5096
5127
  } else if (tag === "set") {
5128
+ const isBlockSet = !content.includes("=");
5097
5129
  if (this._sandbox && this._allowedTags !== null && !this._allowedTags.has("set")) {
5098
- i++;
5130
+ i = isBlockSet ? this.skipBlock(tokens, i, "set", "endset") : i + 1;
5131
+ } else if (isBlockSet) {
5132
+ i = this.handleSetBlock(tokens, i, context);
5099
5133
  } else {
5100
5134
  this.handleSet(content, context);
5101
5135
  i++;
@@ -5137,6 +5171,11 @@ var init_engine = __esm({
5137
5171
  i++;
5138
5172
  } else {
5139
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
+ }
5140
5179
  }
5141
5180
  if (stripA && i < tokens.length && tokens[i][0] === "TEXT") {
5142
5181
  tokens[i] = ["TEXT", tokens[i][1].replace(LEADING_WS_RE, "")];
@@ -6033,6 +6072,47 @@ var init_engine = __esm({
6033
6072
  }
6034
6073
  return html;
6035
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
+ }
6036
6116
  handleSpaceless(tokens, start2, context) {
6037
6117
  const bodyTokens = [];
6038
6118
  let i = start2 + 1;