tina4ruby 3.10.18 → 3.10.23

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b0dfa03a5f7986169a14d59895459e7ca50d70f0254789c36dbc29aca80b1b93
4
- data.tar.gz: e248121b6577b8c5d0ec172ab47e2f82235b500d3508f75e8e0fedd3071ac657
3
+ metadata.gz: 3ae8ae6d644a0cb19a5b5fc2f68b02066ecfc41c4f6b6e6bae2415fdbe078fcc
4
+ data.tar.gz: eb1dd0310b3ed508ea582ec186e64f050be8c4fc8925abd5f038689b20a6f551
5
5
  SHA512:
6
- metadata.gz: 164505c4f5449c7fbfa15ca1b21fbdffa3beac78a7d84e907407ed11881e3a4335d7df8dd6c46cb4bb4fb11ff3f9b89d2cd4b8cd02a77bdfb72a584b965f942e
7
- data.tar.gz: b8797d3fd1abe7baf178c73a752b47357d8e56753a699d833a100940433884d036211cfc9942fcee481ca56d610efcb50169ec086df04f31e549e73971b7fb65
6
+ metadata.gz: cdcb3bf5c7d76949b51ec90fea3eed309fb0e9ec312f5ed5a02451da2a6953c3eb46524d191c0dee3d0efb587dc50581021dabd65b4432b42b469ccaa53342bf
7
+ data.tar.gz: ac974932d01d89edff858d9873ac39709f88161958956763757d952d8a3d17e97fccf96cd0cc1ce2678bca88488a357f9a28b7f2243d4bcd158be26f4e7fb541
@@ -313,14 +313,18 @@ module Tina4
313
313
 
314
314
  # Pre-generate the next available primary key ID using engine-aware strategies.
315
315
  #
316
+ # Race-safe implementation using a `tina4_sequences` table for SQLite/MySQL/MSSQL
317
+ # fallback. Each call atomically increments the stored counter, so concurrent
318
+ # callers never receive the same value.
319
+ #
316
320
  # - Firebird: auto-creates a generator if missing, then increments via GEN_ID.
317
- # - PostgreSQL: tries nextval() on the standard sequence, falls through to MAX+1.
318
- # - SQLite/MySQL/MSSQL: uses MAX(pk) + 1.
321
+ # - PostgreSQL: tries nextval() on the named sequence, auto-creates it if missing.
322
+ # - SQLite/MySQL/MSSQL: atomic UPDATE on `tina4_sequences` table.
319
323
  # - Returns 1 if the table is empty or does not exist.
320
324
  #
321
325
  # @param table [String] Table name
322
326
  # @param pk_column [String] Primary key column name (default: "id")
323
- # @param generator_name [String, nil] Firebird generator name override
327
+ # @param generator_name [String, nil] Override for sequence/generator name
324
328
  # @return [Integer] The next available ID
325
329
  def get_next_id(table, pk_column: "id", generator_name: nil)
326
330
  drv = current_driver
@@ -338,36 +342,101 @@ module Tina4
338
342
 
339
343
  rows = drv.execute_query("SELECT GEN_ID(#{gen_name}, 1) AS NEXT_ID FROM RDB$DATABASE")
340
344
  row = rows.is_a?(Array) ? rows.first : nil
341
- return (row && (row["NEXT_ID"] || row["next_id"]))&.to_i || 1
345
+ val = row_value(row, :NEXT_ID) || row_value(row, :next_id)
346
+ return val&.to_i || 1
342
347
  end
343
348
 
344
- # PostgreSQL — try sequence first, fall through to MAX
349
+ # PostgreSQL — try sequence first, auto-create if missing
345
350
  if @driver_name == "postgres"
346
- seq_name = "#{table.downcase}_#{pk_column.downcase}_seq"
351
+ seq_name = generator_name || "#{table.downcase}_#{pk_column.downcase}_seq"
347
352
  begin
348
353
  rows = drv.execute_query("SELECT nextval('#{seq_name}') AS next_id")
349
354
  row = rows.is_a?(Array) ? rows.first : nil
350
- if row && (row["next_id"] || row["nextval"])
351
- return (row["next_id"] || row["nextval"]).to_i
352
- end
355
+ val = row_value(row, :next_id) || row_value(row, :nextval)
356
+ return val.to_i if val
353
357
  rescue
354
- # No sequencefall through to MAX
358
+ # Sequence does not exist auto-create it seeded from MAX
359
+ begin
360
+ max_rows = drv.execute_query("SELECT COALESCE(MAX(#{pk_column}), 0) AS max_id FROM #{table}")
361
+ max_row = max_rows.is_a?(Array) ? max_rows.first : nil
362
+ max_val = row_value(max_row, :max_id)
363
+ start_val = max_val ? max_val.to_i + 1 : 1
364
+ drv.execute("CREATE SEQUENCE #{seq_name} START WITH #{start_val}")
365
+ drv.commit rescue nil
366
+ rows = drv.execute_query("SELECT nextval('#{seq_name}') AS next_id")
367
+ row = rows.is_a?(Array) ? rows.first : nil
368
+ val = row_value(row, :next_id) || row_value(row, :nextval)
369
+ return val&.to_i || start_val
370
+ rescue
371
+ # Fall through to sequence table fallback
372
+ end
355
373
  end
356
374
  end
357
375
 
358
- # SQLite / MySQL / MSSQL / PostgreSQL fallback — MAX + 1
359
- begin
360
- rows = drv.execute_query("SELECT MAX(#{pk_column}) + 1 AS next_id FROM #{table}")
361
- row = rows.is_a?(Array) ? rows.first : nil
362
- next_id = row && (row["next_id"] || row["max"])
363
- return next_id ? next_id.to_i : 1
364
- rescue
365
- return 1
366
- end
376
+ # SQLite / MySQL / MSSQL / PostgreSQL fallback — atomic sequence table
377
+ seq_key = generator_name || "#{table}.#{pk_column}"
378
+ sequence_next(seq_key, table: table, pk_column: pk_column)
367
379
  end
368
380
 
369
381
  private
370
382
 
383
+ # Ensure the tina4_sequences table exists for race-safe ID generation.
384
+ def ensure_sequence_table
385
+ return if table_exists?("tina4_sequences")
386
+
387
+ drv = current_driver
388
+ if @driver_name == "mssql"
389
+ drv.execute("CREATE TABLE tina4_sequences (seq_name VARCHAR(200) NOT NULL PRIMARY KEY, current_value INTEGER NOT NULL DEFAULT 0)")
390
+ else
391
+ drv.execute("CREATE TABLE IF NOT EXISTS tina4_sequences (seq_name VARCHAR(200) NOT NULL PRIMARY KEY, current_value INTEGER NOT NULL DEFAULT 0)")
392
+ end
393
+ drv.commit rescue nil
394
+ end
395
+
396
+ # Atomically increment and return the next value for a named sequence.
397
+ # Seeds from MAX(pk_column) on first use so existing data is respected.
398
+ def sequence_next(seq_name, table: nil, pk_column: "id")
399
+ ensure_sequence_table
400
+ drv = current_driver
401
+
402
+ # Check if the sequence key already exists
403
+ rows = drv.execute_query("SELECT current_value FROM tina4_sequences WHERE seq_name = ?", [seq_name])
404
+ row = rows.is_a?(Array) ? rows.first : nil
405
+
406
+ if row.nil?
407
+ # Seed from MAX(pk_column) if table data exists
408
+ seed_value = 0
409
+ if table
410
+ begin
411
+ max_rows = drv.execute_query("SELECT MAX(#{pk_column}) AS max_id FROM #{table}")
412
+ max_row = max_rows.is_a?(Array) ? max_rows.first : nil
413
+ val = row_value(max_row, :max_id)
414
+ seed_value = val.to_i if val
415
+ rescue
416
+ # Table may not exist yet — start from 0
417
+ end
418
+ end
419
+ drv.execute("INSERT INTO tina4_sequences (seq_name, current_value) VALUES (?, ?)", [seq_name, seed_value])
420
+ drv.commit rescue nil
421
+ end
422
+
423
+ # Atomic increment
424
+ drv.execute("UPDATE tina4_sequences SET current_value = current_value + 1 WHERE seq_name = ?", [seq_name])
425
+ drv.commit rescue nil
426
+
427
+ # Read back the incremented value
428
+ rows = drv.execute_query("SELECT current_value FROM tina4_sequences WHERE seq_name = ?", [seq_name])
429
+ row = rows.is_a?(Array) ? rows.first : nil
430
+ val = row_value(row, :current_value)
431
+ val ? val.to_i : 1
432
+ end
433
+
434
+ # Safely extract a value from a driver result row, trying both symbol and string keys.
435
+ def row_value(row, key)
436
+ return nil unless row
437
+ row[key.to_sym] || row[key.to_s] || row[key.to_s.upcase] || row[key.to_s.downcase]
438
+ end
439
+
371
440
  def truthy?(val)
372
441
  %w[true 1 yes on].include?((val || "").to_s.strip.downcase)
373
442
  end
data/lib/tina4/frond.rb CHANGED
@@ -13,6 +13,7 @@ require "cgi"
13
13
  require "uri"
14
14
  require "date"
15
15
  require "time"
16
+ require "securerandom"
16
17
 
17
18
  module Tina4
18
19
  # Marker class for strings that should not be auto-escaped in Frond.
@@ -34,6 +35,121 @@ module Tina4
34
35
  '"' => """, "'" => "'" }.freeze
35
36
  HTML_ESCAPE_RE = /[&<>"']/
36
37
 
38
+ # -- Compiled regex constants (optimization: avoid re-compiling in methods) --
39
+ EXTENDS_RE = /\{%-?\s*extends\s+["'](.+?)["']\s*-?%\}/
40
+ BLOCK_RE = /\{%-?\s*block\s+(\w+)\s*-?%\}(.*?)\{%-?\s*endblock\s*-?%\}/m
41
+ STRING_LIT_RE = /\A["'](.*)["']\z/
42
+ INTEGER_RE = /\A-?\d+\z/
43
+ FLOAT_RE = /\A-?\d+\.\d+\z/
44
+ ARRAY_LIT_RE = /\A\[(.+)\]\z/m
45
+ HASH_LIT_RE = /\A\{(.+)\}\z/m
46
+ HASH_PAIR_RE = /\A\s*["']?(\w+)["']?\s*:\s*(.+)\z/
47
+ RANGE_LIT_RE = /\A(\d+)\.\.(\d+)\z/
48
+ ARITHMETIC_RE = /\A(.+?)\s*(\+|-|\*|\/|%)\s*(.+)\z/
49
+ FUNC_CALL_RE = /\A(\w+)\s*\((.*)\)\z/m
50
+ FILTER_WITH_ARGS_RE = /\A(\w+)\s*\((.*)\)\z/m
51
+ FILTER_CMP_RE = /\A(\w+)\s*(!=|==|>=|<=|>|<)\s*(.+)\z/
52
+ OR_SPLIT_RE = /\s+or\s+/
53
+ AND_SPLIT_RE = /\s+and\s+/
54
+ IS_NOT_RE = /\A(.+?)\s+is\s+not\s+(\w+)(.*)\z/
55
+ IS_RE = /\A(.+?)\s+is\s+(\w+)(.*)\z/
56
+ NOT_IN_RE = /\A(.+?)\s+not\s+in\s+(.+)\z/
57
+ IN_RE = /\A(.+?)\s+in\s+(.+)\z/
58
+ DIVISIBLE_BY_RE = /\s*by\s*\(\s*(\d+)\s*\)/
59
+ RESOLVE_SPLIT_RE = /\.|\[([^\]]+)\]/
60
+ RESOLVE_STRIP_RE = /\A["']|["']\z/
61
+ DIGIT_RE = /\A\d+\z/
62
+ FOR_RE = /\Afor\s+(\w+)(?:\s*,\s*(\w+))?\s+in\s+(.+)\z/
63
+ SET_RE = /\Aset\s+(\w+)\s*=\s*(.+)\z/m
64
+ INCLUDE_RE = /\Ainclude\s+["'](.+?)["'](?:\s+with\s+(.+))?\z/
65
+ MACRO_RE = /\Amacro\s+(\w+)\s*\(([^)]*)\)/
66
+ FROM_IMPORT_RE = /\Afrom\s+["'](.+?)["']\s+import\s+(.+)/
67
+ CACHE_RE = /\Acache\s+["'](.+?)["']\s*(\d+)?/
68
+ SPACELESS_RE = />\s+</
69
+ AUTOESCAPE_RE = /\Aautoescape\s+(false|true)/
70
+ STRIPTAGS_RE = /<[^>]+>/
71
+ THOUSANDS_RE = /(\d)(?=(\d{3})+(?!\d))/
72
+ SLUG_CLEAN_RE = /[^a-z0-9]+/
73
+ SLUG_TRIM_RE = /\A-|-\z/
74
+
75
+ # Set of common no-arg filter names that can be inlined for speed
76
+ INLINE_FILTERS = %w[upper lower length trim capitalize title string int escape e].each_with_object({}) { |f, h| h[f] = true }.freeze
77
+
78
+ # -- Lazy context overlay for for-loops (avoids full Hash#dup) --
79
+ class LoopContext
80
+ def initialize(parent)
81
+ @parent = parent
82
+ @local = {}
83
+ end
84
+
85
+ def [](key)
86
+ @local.key?(key) ? @local[key] : @parent[key]
87
+ end
88
+
89
+ def []=(key, value)
90
+ @local[key] = value
91
+ end
92
+
93
+ def key?(key)
94
+ @local.key?(key) || @parent.key?(key)
95
+ end
96
+ alias include? key?
97
+ alias has_key? key?
98
+
99
+ def fetch(key, *args, &block)
100
+ if @local.key?(key)
101
+ @local[key]
102
+ elsif @parent.key?(key)
103
+ @parent[key]
104
+ elsif block
105
+ yield key
106
+ elsif !args.empty?
107
+ args[0]
108
+ else
109
+ raise KeyError, "key not found: #{key.inspect}"
110
+ end
111
+ end
112
+
113
+ def merge(other)
114
+ dup_hash = to_h
115
+ dup_hash.merge!(other)
116
+ dup_hash
117
+ end
118
+
119
+ def merge!(other)
120
+ other.each { |k, v| @local[k] = v }
121
+ self
122
+ end
123
+
124
+ def dup
125
+ copy = LoopContext.new(@parent)
126
+ @local.each { |k, v| copy[k] = v }
127
+ copy
128
+ end
129
+
130
+ def to_h
131
+ h = @parent.is_a?(LoopContext) ? @parent.to_h : @parent.dup
132
+ @local.each { |k, v| h[k] = v }
133
+ h
134
+ end
135
+
136
+ def each(&block)
137
+ to_h.each(&block)
138
+ end
139
+
140
+ def respond_to_missing?(name, include_private = false)
141
+ @parent.respond_to?(name, include_private) || super
142
+ end
143
+
144
+ def is_a?(klass)
145
+ klass == Hash || super
146
+ end
147
+
148
+ def keys
149
+ (@parent.is_a?(LoopContext) ? @parent.keys : @parent.keys) | @local.keys
150
+ end
151
+ end
152
+
37
153
  # -----------------------------------------------------------------------
38
154
  # Public API
39
155
  # -----------------------------------------------------------------------
@@ -60,6 +176,15 @@ module Tina4
60
176
  @compiled = {} # {template_name => [tokens, mtime]}
61
177
  @compiled_strings = {} # {md5_hash => tokens}
62
178
 
179
+ # Parsed filter chain cache: expr_string => [variable, filters]
180
+ @filter_chain_cache = {}
181
+
182
+ # Resolved dotted-path split cache: expr_string => parts_array
183
+ @resolve_cache = {}
184
+
185
+ # Sandbox root-var split cache: var_name => root_var_string
186
+ @dotted_split_cache = {}
187
+
63
188
  # Built-in global functions
64
189
  register_builtin_globals
65
190
  end
@@ -115,6 +240,9 @@ module Tina4
115
240
  def clear_cache
116
241
  @compiled.clear
117
242
  @compiled_strings.clear
243
+ @filter_chain_cache.clear
244
+ @resolve_cache.clear
245
+ @dotted_split_cache.clear
118
246
  end
119
247
 
120
248
  # Register a custom filter.
@@ -261,7 +389,7 @@ module Tina4
261
389
 
262
390
  def execute_with_tokens(source, tokens, context)
263
391
  # Handle extends first
264
- if source =~ /\{%-?\s*extends\s+["'](.+?)["']\s*-?%\}/
392
+ if source =~ EXTENDS_RE
265
393
  parent_name = Regexp.last_match(1)
266
394
  parent_source = load_template(parent_name)
267
395
  child_blocks = extract_blocks(source)
@@ -273,7 +401,7 @@ module Tina4
273
401
 
274
402
  def execute(source, context)
275
403
  # Handle extends first
276
- if source =~ /\{%-?\s*extends\s+["'](.+?)["']\s*-?%\}/
404
+ if source =~ EXTENDS_RE
277
405
  parent_name = Regexp.last_match(1)
278
406
  parent_source = load_template(parent_name)
279
407
  child_blocks = extract_blocks(source)
@@ -285,14 +413,14 @@ module Tina4
285
413
 
286
414
  def extract_blocks(source)
287
415
  blocks = {}
288
- source.scan(/\{%-?\s*block\s+(\w+)\s*-?%\}(.*?)\{%-?\s*endblock\s*-?%\}/m) do
416
+ source.scan(BLOCK_RE) do
289
417
  blocks[Regexp.last_match(1)] = Regexp.last_match(2)
290
418
  end
291
419
  blocks
292
420
  end
293
421
 
294
422
  def render_with_blocks(parent_source, context, child_blocks)
295
- result = parent_source.gsub(/\{%-?\s*block\s+(\w+)\s*-?%\}(.*?)\{%-?\s*endblock\s*-?%\}/m) do
423
+ result = parent_source.gsub(BLOCK_RE) do
296
424
  name = Regexp.last_match(1)
297
425
  default_content = Regexp.last_match(2)
298
426
  block_source = child_blocks.fetch(name, default_content)
@@ -422,7 +550,7 @@ module Tina4
422
550
  # The filter name may include a trailing comparison operator,
423
551
  # e.g. "length != 1". Extract the real filter name and the
424
552
  # comparison suffix, apply the filter, then evaluate the comparison.
425
- m = fname.match(/\A(\w+)\s*(!=|==|>=|<=|>|<)\s*(.+)\z/)
553
+ m = fname.match(FILTER_CMP_RE)
426
554
  if m
427
555
  real_filter = m[1]
428
556
  op = m[2]
@@ -455,7 +583,11 @@ module Tina4
455
583
 
456
584
  # Sandbox: check variable access
457
585
  if @sandbox && @allowed_vars
458
- root_var = var_name.split(".")[0].split("[")[0].strip
586
+ root_var = @dotted_split_cache[var_name]
587
+ unless root_var
588
+ root_var = var_name.split(".")[0].split("[")[0].strip
589
+ @dotted_split_cache[var_name] = root_var
590
+ end
459
591
  return "" if !root_var.empty? && !@allowed_vars.include?(root_var) && root_var != "loop"
460
592
  end
461
593
 
@@ -473,6 +605,23 @@ module Tina4
473
605
  next
474
606
  end
475
607
 
608
+ # Inline common no-arg filters for speed (skip generic dispatch)
609
+ if args.empty? && INLINE_FILTERS.include?(fname)
610
+ value = case fname
611
+ when "upper" then value.to_s.upcase
612
+ when "lower" then value.to_s.downcase
613
+ when "length" then value.respond_to?(:length) ? value.length : value.to_s.length
614
+ when "trim" then value.to_s.strip
615
+ when "capitalize" then value.to_s.capitalize
616
+ when "title" then value.to_s.split.map(&:capitalize).join(" ")
617
+ when "string" then value.to_s
618
+ when "int" then value.to_i
619
+ when "escape", "e" then Frond.escape_html(value.to_s)
620
+ else value
621
+ end
622
+ next
623
+ end
624
+
476
625
  fn = @filters[fname]
477
626
  if fn
478
627
  evaluated_args = args.map { |a| eval_filter_arg(a, context) }
@@ -489,9 +638,9 @@ module Tina4
489
638
  end
490
639
 
491
640
  def eval_filter_arg(arg, context)
492
- return Regexp.last_match(1) if arg =~ /\A["'](.*)["']\z/
493
- return arg.to_i if arg =~ /\A-?\d+\z/
494
- return arg.to_f if arg =~ /\A-?\d+\.\d+\z/
641
+ return Regexp.last_match(1) if arg =~ STRING_LIT_RE
642
+ return arg.to_i if arg =~ INTEGER_RE
643
+ return arg.to_f if arg =~ FLOAT_RE
495
644
  eval_expr(arg, context)
496
645
  end
497
646
 
@@ -597,13 +746,16 @@ module Tina4
597
746
  # -----------------------------------------------------------------------
598
747
 
599
748
  def parse_filter_chain(expr)
749
+ cached = @filter_chain_cache[expr]
750
+ return cached if cached
751
+
600
752
  parts = split_on_pipe(expr)
601
753
  variable = parts[0].strip
602
754
  filters = []
603
755
 
604
756
  parts[1..].each do |f|
605
757
  f = f.strip
606
- if f =~ /\A(\w+)\s*\((.*)\)\z/m
758
+ if f =~ FILTER_WITH_ARGS_RE
607
759
  name = Regexp.last_match(1)
608
760
  raw_args = Regexp.last_match(2).strip
609
761
  args = raw_args.empty? ? [] : parse_args(raw_args)
@@ -613,7 +765,9 @@ module Tina4
613
765
  end
614
766
  end
615
767
 
616
- [variable, filters]
768
+ result = [variable, filters].freeze
769
+ @filter_chain_cache[expr] = result
770
+ result
617
771
  end
618
772
 
619
773
  # Split expression on | but not inside quotes or parens.
@@ -694,8 +848,8 @@ module Tina4
694
848
  end
695
849
 
696
850
  # Numeric
697
- return expr.to_i if expr =~ /\A-?\d+\z/
698
- return expr.to_f if expr =~ /\A-?\d+\.\d+\z/
851
+ return expr.to_i if expr =~ INTEGER_RE
852
+ return expr.to_f if expr =~ FLOAT_RE
699
853
 
700
854
  # Boolean/null
701
855
  return true if expr == "true"
@@ -703,17 +857,17 @@ module Tina4
703
857
  return nil if expr == "null" || expr == "none" || expr == "nil"
704
858
 
705
859
  # Array literal [a, b, c]
706
- if expr =~ /\A\[(.+)\]\z/m
860
+ if expr =~ ARRAY_LIT_RE
707
861
  inner = Regexp.last_match(1)
708
862
  return split_args_toplevel(inner).map { |item| eval_expr(item.strip, context) }
709
863
  end
710
864
 
711
865
  # Hash literal { key: value, ... }
712
- if expr =~ /\A\{(.+)\}\z/m
866
+ if expr =~ HASH_LIT_RE
713
867
  inner = Regexp.last_match(1)
714
868
  hash = {}
715
869
  split_args_toplevel(inner).each do |pair|
716
- if pair =~ /\A\s*["']?(\w+)["']?\s*:\s*(.+)\z/
870
+ if pair =~ HASH_PAIR_RE
717
871
  hash[Regexp.last_match(1)] = eval_expr(Regexp.last_match(2).strip, context)
718
872
  end
719
873
  end
@@ -721,7 +875,7 @@ module Tina4
721
875
  end
722
876
 
723
877
  # Range literal: 1..5
724
- if expr =~ /\A(\d+)\.\.(\d+)\z/
878
+ if expr =~ RANGE_LIT_RE
725
879
  return (Regexp.last_match(1).to_i..Regexp.last_match(2).to_i).to_a
726
880
  end
727
881
 
@@ -786,7 +940,7 @@ module Tina4
786
940
  end
787
941
 
788
942
  # Arithmetic: +, -, *, /, %
789
- if expr =~ /\A(.+?)\s*(\+|-|\*|\/|%)\s*(.+)\z/
943
+ if expr =~ ARITHMETIC_RE
790
944
  left = eval_expr(Regexp.last_match(1), context)
791
945
  op = Regexp.last_match(2)
792
946
  right = eval_expr(Regexp.last_match(3), context)
@@ -794,7 +948,7 @@ module Tina4
794
948
  end
795
949
 
796
950
  # Function call: name(arg1, arg2)
797
- if expr =~ /\A(\w+)\s*\((.*)\)\z/m
951
+ if expr =~ FUNC_CALL_RE
798
952
  fn_name = Regexp.last_match(1)
799
953
  raw_args = Regexp.last_match(2).strip
800
954
  fn = context[fn_name]
@@ -851,49 +1005,50 @@ module Tina4
851
1005
  # Comparison / logical evaluator
852
1006
  # -----------------------------------------------------------------------
853
1007
 
854
- def eval_comparison(expr, context)
1008
+ def eval_comparison(expr, context, eval_fn = nil)
1009
+ eval_fn ||= method(:eval_expr)
855
1010
  expr = expr.strip
856
1011
 
857
1012
  # Handle 'not' prefix
858
1013
  if expr.start_with?("not ")
859
- return !eval_comparison(expr[4..], context)
1014
+ return !eval_comparison(expr[4..], context, eval_fn)
860
1015
  end
861
1016
 
862
1017
  # 'or' (lowest precedence)
863
- or_parts = expr.split(/\s+or\s+/)
1018
+ or_parts = expr.split(OR_SPLIT_RE)
864
1019
  if or_parts.length > 1
865
- return or_parts.any? { |p| eval_comparison(p, context) }
1020
+ return or_parts.any? { |p| eval_comparison(p, context, eval_fn) }
866
1021
  end
867
1022
 
868
1023
  # 'and'
869
- and_parts = expr.split(/\s+and\s+/)
1024
+ and_parts = expr.split(AND_SPLIT_RE)
870
1025
  if and_parts.length > 1
871
- return and_parts.all? { |p| eval_comparison(p, context) }
1026
+ return and_parts.all? { |p| eval_comparison(p, context, eval_fn) }
872
1027
  end
873
1028
 
874
1029
  # 'is not' test
875
- if expr =~ /\A(.+?)\s+is\s+not\s+(\w+)(.*)\z/
1030
+ if expr =~ IS_NOT_RE
876
1031
  return !eval_test(Regexp.last_match(1).strip, Regexp.last_match(2),
877
- Regexp.last_match(3).strip, context)
1032
+ Regexp.last_match(3).strip, context, eval_fn)
878
1033
  end
879
1034
 
880
1035
  # 'is' test
881
- if expr =~ /\A(.+?)\s+is\s+(\w+)(.*)\z/
1036
+ if expr =~ IS_RE
882
1037
  return eval_test(Regexp.last_match(1).strip, Regexp.last_match(2),
883
- Regexp.last_match(3).strip, context)
1038
+ Regexp.last_match(3).strip, context, eval_fn)
884
1039
  end
885
1040
 
886
1041
  # 'not in'
887
- if expr =~ /\A(.+?)\s+not\s+in\s+(.+)\z/
888
- val = eval_expr(Regexp.last_match(1).strip, context)
889
- collection = eval_expr(Regexp.last_match(2).strip, context)
1042
+ if expr =~ NOT_IN_RE
1043
+ val = eval_fn.call(Regexp.last_match(1).strip, context)
1044
+ collection = eval_fn.call(Regexp.last_match(2).strip, context)
890
1045
  return !(collection.respond_to?(:include?) && collection.include?(val))
891
1046
  end
892
1047
 
893
1048
  # 'in'
894
- if expr =~ /\A(.+?)\s+in\s+(.+)\z/
895
- val = eval_expr(Regexp.last_match(1).strip, context)
896
- collection = eval_expr(Regexp.last_match(2).strip, context)
1049
+ if expr =~ IN_RE
1050
+ val = eval_fn.call(Regexp.last_match(1).strip, context)
1051
+ collection = eval_fn.call(Regexp.last_match(2).strip, context)
897
1052
  return collection.respond_to?(:include?) ? collection.include?(val) : false
898
1053
  end
899
1054
 
@@ -906,8 +1061,8 @@ module Tina4
906
1061
  ["<", ->(a, b) { a.to_f < b.to_f }]].each do |op, fn|
907
1062
  if expr.include?(op)
908
1063
  left, _, right = expr.partition(op)
909
- l = eval_expr(left.strip, context)
910
- r = eval_expr(right.strip, context)
1064
+ l = eval_fn.call(left.strip, context)
1065
+ r = eval_fn.call(right.strip, context)
911
1066
  begin
912
1067
  return fn.call(l, r)
913
1068
  rescue
@@ -917,7 +1072,7 @@ module Tina4
917
1072
  end
918
1073
 
919
1074
  # Fall through to simple eval
920
- val = eval_expr(expr, context)
1075
+ val = eval_fn.call(expr, context)
921
1076
  truthy?(val)
922
1077
  end
923
1078
 
@@ -925,12 +1080,13 @@ module Tina4
925
1080
  # Tests ('is' expressions)
926
1081
  # -----------------------------------------------------------------------
927
1082
 
928
- def eval_test(value_expr, test_name, args_str, context)
929
- val = eval_expr(value_expr, context)
1083
+ def eval_test(value_expr, test_name, args_str, context, eval_fn = nil)
1084
+ eval_fn ||= method(:eval_expr)
1085
+ val = eval_fn.call(value_expr, context)
930
1086
 
931
1087
  # 'divisible by(n)'
932
1088
  if test_name == "divisible"
933
- if args_str =~ /\s*by\s*\(\s*(\d+)\s*\)/
1089
+ if args_str =~ DIVISIBLE_BY_RE
934
1090
  n = Regexp.last_match(1).to_i
935
1091
  return val.is_a?(Integer) && (val % n).zero?
936
1092
  end
@@ -964,14 +1120,19 @@ module Tina4
964
1120
  # -----------------------------------------------------------------------
965
1121
 
966
1122
  def resolve(expr, context)
967
- parts = expr.split(/\.|\[([^\]]+)\]/).reject(&:empty?)
1123
+ parts = @resolve_cache[expr]
1124
+ unless parts
1125
+ parts = expr.split(RESOLVE_SPLIT_RE).reject(&:empty?)
1126
+ @resolve_cache[expr] = parts
1127
+ end
1128
+
968
1129
  value = context
969
1130
 
970
1131
  parts.each do |part|
971
- part = part.strip.gsub(/\A["']|["']\z/, "") # strip quotes from bracket access
972
- if value.is_a?(Hash)
1132
+ part = part.strip.gsub(RESOLVE_STRIP_RE, "") # strip quotes from bracket access
1133
+ if value.is_a?(Hash) || value.is_a?(LoopContext)
973
1134
  value = value[part] || value[part.to_sym]
974
- elsif value.is_a?(Array) && part =~ /\A\d+\z/
1135
+ elsif value.is_a?(Array) && part =~ DIGIT_RE
975
1136
  value = value[part.to_i]
976
1137
  elsif value.respond_to?(part.to_sym)
977
1138
  value = value.send(part.to_sym)
@@ -1073,7 +1234,7 @@ module Tina4
1073
1234
  end
1074
1235
 
1075
1236
  branches.each do |cond, branch_tokens|
1076
- if cond.nil? || eval_comparison(cond, context)
1237
+ if cond.nil? || eval_comparison(cond, context, method(:eval_var_raw))
1077
1238
  return [render_tokens(branch_tokens.dup, context), i]
1078
1239
  end
1079
1240
  end
@@ -1084,7 +1245,7 @@ module Tina4
1084
1245
  # {% for item in items %}...{% else %}...{% endfor %}
1085
1246
  def handle_for(tokens, start, context)
1086
1247
  content, _, strip_a_open = strip_tag(tokens[start][1])
1087
- m = content.match(/\Afor\s+(\w+)(?:\s*,\s*(\w+))?\s+in\s+(.+)\z/)
1248
+ m = content.match(FOR_RE)
1088
1249
  return ["", start + 1] unless m
1089
1250
 
1090
1251
  var1 = m[1]
@@ -1158,7 +1319,7 @@ module Tina4
1158
1319
  total = items.length
1159
1320
 
1160
1321
  items.each_with_index do |item, idx|
1161
- loop_ctx = context.dup
1322
+ loop_ctx = LoopContext.new(context)
1162
1323
  loop_ctx["loop"] = {
1163
1324
  "index" => idx + 1,
1164
1325
  "index0" => idx,
@@ -1196,7 +1357,7 @@ module Tina4
1196
1357
 
1197
1358
  # {% set name = expr %}
1198
1359
  def handle_set(content, context)
1199
- if content =~ /\Aset\s+(\w+)\s*=\s*(.+)\z/m
1360
+ if content =~ SET_RE
1200
1361
  name = Regexp.last_match(1)
1201
1362
  expr = Regexp.last_match(2).strip
1202
1363
  context[name] = eval_expr(expr, context)
@@ -1208,7 +1369,7 @@ module Tina4
1208
1369
  ignore_missing = content.include?("ignore missing")
1209
1370
  content = content.gsub("ignore missing", "").strip
1210
1371
 
1211
- m = content.match(/\Ainclude\s+["'](.+?)["'](?:\s+with\s+(.+))?\z/)
1372
+ m = content.match(INCLUDE_RE)
1212
1373
  return "" unless m
1213
1374
 
1214
1375
  filename = m[1]
@@ -1233,7 +1394,7 @@ module Tina4
1233
1394
  # {% macro name(args) %}...{% endmacro %}
1234
1395
  def handle_macro(tokens, start, context)
1235
1396
  content, _, _ = strip_tag(tokens[start][1])
1236
- m = content.match(/\Amacro\s+(\w+)\s*\(([^)]*)\)/)
1397
+ m = content.match(MACRO_RE)
1237
1398
  unless m
1238
1399
  i = start + 1
1239
1400
  while i < tokens.length
@@ -1276,7 +1437,7 @@ module Tina4
1276
1437
 
1277
1438
  # {% from "file" import macro1, macro2 %}
1278
1439
  def handle_from_import(content, context)
1279
- m = content.match(/\Afrom\s+["'](.+?)["']\s+import\s+(.+)/)
1440
+ m = content.match(FROM_IMPORT_RE)
1280
1441
  return unless m
1281
1442
 
1282
1443
  filename = m[1]
@@ -1292,7 +1453,7 @@ module Tina4
1292
1453
  tag_content, _, _ = strip_tag(raw)
1293
1454
  tag = (tag_content.split[0] || "")
1294
1455
  if tag == "macro"
1295
- macro_m = tag_content.match(/\Amacro\s+(\w+)\s*\(([^)]*)\)/)
1456
+ macro_m = tag_content.match(MACRO_RE)
1296
1457
  if macro_m && names.include?(macro_m[1])
1297
1458
  macro_name = macro_m[1]
1298
1459
  param_names = macro_m[2].split(",").map(&:strip).reject(&:empty?)
@@ -1332,7 +1493,7 @@ module Tina4
1332
1493
  # {% cache "key" ttl %}...{% endcache %}
1333
1494
  def handle_cache(tokens, start, context)
1334
1495
  content, _, _ = strip_tag(tokens[start][1])
1335
- m = content.match(/\Acache\s+["'](.+?)["']\s*(\d+)?/)
1496
+ m = content.match(CACHE_RE)
1336
1497
  cache_key = m ? m[1] : "default"
1337
1498
  ttl = m && m[2] ? m[2].to_i : 60
1338
1499
 
@@ -1420,13 +1581,13 @@ module Tina4
1420
1581
  end
1421
1582
 
1422
1583
  rendered = render_tokens(body_tokens.dup, context)
1423
- rendered = rendered.gsub(/>\s+</, "><")
1584
+ rendered = rendered.gsub(SPACELESS_RE, "><")
1424
1585
  [rendered, i]
1425
1586
  end
1426
1587
 
1427
1588
  def handle_autoescape(tokens, start, context)
1428
1589
  content, _, _ = strip_tag(tokens[start][1])
1429
- mode_match = content.match(/\Aautoescape\s+(false|true)/)
1590
+ mode_match = content.match(AUTOESCAPE_RE)
1430
1591
  auto_escape_on = !(mode_match && mode_match[1] == "false")
1431
1592
 
1432
1593
  body_tokens = []
@@ -1497,7 +1658,7 @@ module Tina4
1497
1658
  "ltrim" => ->(v, *_a) { v.to_s.lstrip },
1498
1659
  "rtrim" => ->(v, *_a) { v.to_s.rstrip },
1499
1660
  "replace" => ->(v, *a) { a.length >= 2 ? v.to_s.gsub(a[0].to_s, a[1].to_s) : v.to_s },
1500
- "striptags" => ->(v, *_a) { v.to_s.gsub(/<[^>]+>/, "") },
1661
+ "striptags" => ->(v, *_a) { v.to_s.gsub(STRIPTAGS_RE, "") },
1501
1662
 
1502
1663
  # -- Encoding --
1503
1664
  "escape" => ->(v, *_a) { Frond.escape_html(v.to_s) },
@@ -1555,7 +1716,7 @@ module Tina4
1555
1716
  formatted = format("%.#{decimals}f", v.to_f)
1556
1717
  # Add comma thousands separator
1557
1718
  parts = formatted.split(".")
1558
- parts[0] = parts[0].gsub(/(\d)(?=(\d{3})+(?!\d))/, '\\1,')
1719
+ parts[0] = parts[0].gsub(THOUSANDS_RE, '\\1,')
1559
1720
  parts.join(".")
1560
1721
  },
1561
1722
 
@@ -1660,7 +1821,7 @@ module Tina4
1660
1821
  lines << current unless current.empty?
1661
1822
  lines.join("\n")
1662
1823
  },
1663
- "slug" => ->(v, *_a) { v.to_s.downcase.gsub(/[^a-z0-9]+/, "-").gsub(/\A-|-\z/, "") },
1824
+ "slug" => ->(v, *_a) { v.to_s.downcase.gsub(SLUG_CLEAN_RE, "-").gsub(SLUG_TRIM_RE, "") },
1664
1825
  "nl2br" => ->(v, *_a) { v.to_s.gsub("\n", "<br>\n") },
1665
1826
  "format" => ->(v, *a) {
1666
1827
  if a.any?
@@ -1679,6 +1840,8 @@ module Tina4
1679
1840
 
1680
1841
  def register_builtin_globals
1681
1842
  @globals["form_token"] = ->(descriptor = "") { Frond.generate_form_token(descriptor.to_s) }
1843
+ @globals["formTokenValue"] = ->(descriptor = "") { Frond.generate_form_token_value(descriptor.to_s) }
1844
+ @globals["form_token_value"] = ->(descriptor = "") { Frond.generate_form_token_value(descriptor.to_s) }
1682
1845
  end
1683
1846
 
1684
1847
  # Generate a JWT form token and return a hidden input element.
@@ -1697,11 +1860,19 @@ module Tina4
1697
1860
  attr_accessor :form_token_session_id
1698
1861
  end
1699
1862
 
1700
- def self.generate_form_token(descriptor = "")
1863
+ # Generate a raw JWT form token string.
1864
+ #
1865
+ # @param descriptor [String] Optional string to enrich the token payload.
1866
+ # - Empty: payload is {"type" => "form"}
1867
+ # - "admin_panel": payload is {"type" => "form", "context" => "admin_panel"}
1868
+ # - "checkout|order_123": payload is {"type" => "form", "context" => "checkout", "ref" => "order_123"}
1869
+ #
1870
+ # @return [String] The raw JWT token string.
1871
+ def self.generate_form_jwt(descriptor = "")
1701
1872
  require_relative "log"
1702
1873
  require_relative "auth"
1703
1874
 
1704
- payload = { "type" => "form" }
1875
+ payload = { "type" => "form", "nonce" => SecureRandom.hex(8) }
1705
1876
  if descriptor && !descriptor.empty?
1706
1877
  if descriptor.include?("|")
1707
1878
  parts = descriptor.split("|", 2)
@@ -1718,8 +1889,18 @@ module Tina4
1718
1889
 
1719
1890
  ttl_minutes = (ENV["TINA4_TOKEN_LIMIT"] || "60").to_i
1720
1891
  expires_in = ttl_minutes * 60
1721
- token = Tina4::Auth.create_token(payload, expires_in: expires_in)
1892
+ Tina4::Auth.create_token(payload, expires_in: expires_in)
1893
+ end
1894
+
1895
+ def self.generate_form_token(descriptor = "")
1896
+ token = generate_form_jwt(descriptor)
1722
1897
  Tina4::SafeString.new(%(<input type="hidden" name="formToken" value="#{CGI.escapeHTML(token)}">))
1723
1898
  end
1899
+
1900
+ # Return just the raw JWT form token string (no <input> wrapper).
1901
+ # Registered as both formTokenValue and form_token_value template globals.
1902
+ def self.generate_form_token_value(descriptor = "")
1903
+ Tina4::SafeString.new(generate_form_jwt(descriptor))
1904
+ end
1724
1905
  end
1725
1906
  end
data/lib/tina4/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Tina4
4
- VERSION = "3.10.18"
4
+ VERSION = "3.10.23"
5
5
  end
metadata CHANGED
@@ -1,14 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: tina4ruby
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.10.18
4
+ version: 3.10.23
5
5
  platform: ruby
6
6
  authors:
7
7
  - Tina4 Team
8
- autorequire:
9
8
  bindir: exe
10
9
  cert_chain: []
11
- date: 2026-03-29 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
12
11
  dependencies:
13
12
  - !ruby/object:Gem::Dependency
14
13
  name: rack
@@ -400,7 +399,6 @@ licenses:
400
399
  - MIT
401
400
  metadata:
402
401
  homepage_uri: https://tina4.com
403
- post_install_message:
404
402
  rdoc_options: []
405
403
  require_paths:
406
404
  - lib
@@ -415,8 +413,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
415
413
  - !ruby/object:Gem::Version
416
414
  version: '0'
417
415
  requirements: []
418
- rubygems_version: 3.4.19
419
- signing_key:
416
+ rubygems_version: 4.0.3
420
417
  specification_version: 4
421
418
  summary: Simple. Fast. Human. This is not a framework.
422
419
  test_files: []