tina4ruby 3.10.16 → 3.10.21

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: 3b4c3594fbc563cd59432d661382280e066d4632f94d2e8244a42479d2016b10
4
- data.tar.gz: 15d5e418f53d660591813dd65a7eab0ff66edb98bc4c99b6c22f062b7c36fd55
3
+ metadata.gz: 29aa756b32ea3d0051d1b5d26cb8b1312bb77c871fec39fea3e4ce2a8d5c5afd
4
+ data.tar.gz: 326553173c9e4753b6b0bf0471729c065d169d62f609c663b0c3400847e889e8
5
5
  SHA512:
6
- metadata.gz: a0c02ec7be3a98f05845983cdea86f11dc9c9d6994e028fe61817daebe60bcec7b24dc07318932d66b8abd48d6145cddb6f1490b00a6fd9a2a74280a524591a6
7
- data.tar.gz: a169f586a60bcd5c01b9600f3905aacb9ffa65c0cafc2e478acd31a09fbf572e94dc092b91cdf09cb02ada09fde75c04973b058a65c50a12ab7a488fea3fb96a
6
+ metadata.gz: 2f23cc2b84be7fdc7d7f6b9e21ce400a124a95d51b78e5d5b458313516d4aba930e166fc32120df8c1c6f317df78e3d4937b9722d3459bcfbb00cd2cd7c731c0
7
+ data.tar.gz: db8f5ac24ff747c59cb6b93b4877b99ac5790b3234b1f7e360d811fb002bb00618215d32fb2fa1a707108b68257de31dfd937f4c312e41f7b63f8caf65b359e7
@@ -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
@@ -34,6 +34,121 @@ module Tina4
34
34
  '"' => """, "'" => "'" }.freeze
35
35
  HTML_ESCAPE_RE = /[&<>"']/
36
36
 
37
+ # -- Compiled regex constants (optimization: avoid re-compiling in methods) --
38
+ EXTENDS_RE = /\{%-?\s*extends\s+["'](.+?)["']\s*-?%\}/
39
+ BLOCK_RE = /\{%-?\s*block\s+(\w+)\s*-?%\}(.*?)\{%-?\s*endblock\s*-?%\}/m
40
+ STRING_LIT_RE = /\A["'](.*)["']\z/
41
+ INTEGER_RE = /\A-?\d+\z/
42
+ FLOAT_RE = /\A-?\d+\.\d+\z/
43
+ ARRAY_LIT_RE = /\A\[(.+)\]\z/m
44
+ HASH_LIT_RE = /\A\{(.+)\}\z/m
45
+ HASH_PAIR_RE = /\A\s*["']?(\w+)["']?\s*:\s*(.+)\z/
46
+ RANGE_LIT_RE = /\A(\d+)\.\.(\d+)\z/
47
+ ARITHMETIC_RE = /\A(.+?)\s*(\+|-|\*|\/|%)\s*(.+)\z/
48
+ FUNC_CALL_RE = /\A(\w+)\s*\((.*)\)\z/m
49
+ FILTER_WITH_ARGS_RE = /\A(\w+)\s*\((.*)\)\z/m
50
+ FILTER_CMP_RE = /\A(\w+)\s*(!=|==|>=|<=|>|<)\s*(.+)\z/
51
+ OR_SPLIT_RE = /\s+or\s+/
52
+ AND_SPLIT_RE = /\s+and\s+/
53
+ IS_NOT_RE = /\A(.+?)\s+is\s+not\s+(\w+)(.*)\z/
54
+ IS_RE = /\A(.+?)\s+is\s+(\w+)(.*)\z/
55
+ NOT_IN_RE = /\A(.+?)\s+not\s+in\s+(.+)\z/
56
+ IN_RE = /\A(.+?)\s+in\s+(.+)\z/
57
+ DIVISIBLE_BY_RE = /\s*by\s*\(\s*(\d+)\s*\)/
58
+ RESOLVE_SPLIT_RE = /\.|\[([^\]]+)\]/
59
+ RESOLVE_STRIP_RE = /\A["']|["']\z/
60
+ DIGIT_RE = /\A\d+\z/
61
+ FOR_RE = /\Afor\s+(\w+)(?:\s*,\s*(\w+))?\s+in\s+(.+)\z/
62
+ SET_RE = /\Aset\s+(\w+)\s*=\s*(.+)\z/m
63
+ INCLUDE_RE = /\Ainclude\s+["'](.+?)["'](?:\s+with\s+(.+))?\z/
64
+ MACRO_RE = /\Amacro\s+(\w+)\s*\(([^)]*)\)/
65
+ FROM_IMPORT_RE = /\Afrom\s+["'](.+?)["']\s+import\s+(.+)/
66
+ CACHE_RE = /\Acache\s+["'](.+?)["']\s*(\d+)?/
67
+ SPACELESS_RE = />\s+</
68
+ AUTOESCAPE_RE = /\Aautoescape\s+(false|true)/
69
+ STRIPTAGS_RE = /<[^>]+>/
70
+ THOUSANDS_RE = /(\d)(?=(\d{3})+(?!\d))/
71
+ SLUG_CLEAN_RE = /[^a-z0-9]+/
72
+ SLUG_TRIM_RE = /\A-|-\z/
73
+
74
+ # Set of common no-arg filter names that can be inlined for speed
75
+ INLINE_FILTERS = %w[upper lower length trim capitalize title string int escape e].each_with_object({}) { |f, h| h[f] = true }.freeze
76
+
77
+ # -- Lazy context overlay for for-loops (avoids full Hash#dup) --
78
+ class LoopContext
79
+ def initialize(parent)
80
+ @parent = parent
81
+ @local = {}
82
+ end
83
+
84
+ def [](key)
85
+ @local.key?(key) ? @local[key] : @parent[key]
86
+ end
87
+
88
+ def []=(key, value)
89
+ @local[key] = value
90
+ end
91
+
92
+ def key?(key)
93
+ @local.key?(key) || @parent.key?(key)
94
+ end
95
+ alias include? key?
96
+ alias has_key? key?
97
+
98
+ def fetch(key, *args, &block)
99
+ if @local.key?(key)
100
+ @local[key]
101
+ elsif @parent.key?(key)
102
+ @parent[key]
103
+ elsif block
104
+ yield key
105
+ elsif !args.empty?
106
+ args[0]
107
+ else
108
+ raise KeyError, "key not found: #{key.inspect}"
109
+ end
110
+ end
111
+
112
+ def merge(other)
113
+ dup_hash = to_h
114
+ dup_hash.merge!(other)
115
+ dup_hash
116
+ end
117
+
118
+ def merge!(other)
119
+ other.each { |k, v| @local[k] = v }
120
+ self
121
+ end
122
+
123
+ def dup
124
+ copy = LoopContext.new(@parent)
125
+ @local.each { |k, v| copy[k] = v }
126
+ copy
127
+ end
128
+
129
+ def to_h
130
+ h = @parent.is_a?(LoopContext) ? @parent.to_h : @parent.dup
131
+ @local.each { |k, v| h[k] = v }
132
+ h
133
+ end
134
+
135
+ def each(&block)
136
+ to_h.each(&block)
137
+ end
138
+
139
+ def respond_to_missing?(name, include_private = false)
140
+ @parent.respond_to?(name, include_private) || super
141
+ end
142
+
143
+ def is_a?(klass)
144
+ klass == Hash || super
145
+ end
146
+
147
+ def keys
148
+ (@parent.is_a?(LoopContext) ? @parent.keys : @parent.keys) | @local.keys
149
+ end
150
+ end
151
+
37
152
  # -----------------------------------------------------------------------
38
153
  # Public API
39
154
  # -----------------------------------------------------------------------
@@ -60,6 +175,15 @@ module Tina4
60
175
  @compiled = {} # {template_name => [tokens, mtime]}
61
176
  @compiled_strings = {} # {md5_hash => tokens}
62
177
 
178
+ # Parsed filter chain cache: expr_string => [variable, filters]
179
+ @filter_chain_cache = {}
180
+
181
+ # Resolved dotted-path split cache: expr_string => parts_array
182
+ @resolve_cache = {}
183
+
184
+ # Sandbox root-var split cache: var_name => root_var_string
185
+ @dotted_split_cache = {}
186
+
63
187
  # Built-in global functions
64
188
  register_builtin_globals
65
189
  end
@@ -115,6 +239,9 @@ module Tina4
115
239
  def clear_cache
116
240
  @compiled.clear
117
241
  @compiled_strings.clear
242
+ @filter_chain_cache.clear
243
+ @resolve_cache.clear
244
+ @dotted_split_cache.clear
118
245
  end
119
246
 
120
247
  # Register a custom filter.
@@ -261,7 +388,7 @@ module Tina4
261
388
 
262
389
  def execute_with_tokens(source, tokens, context)
263
390
  # Handle extends first
264
- if source =~ /\{%-?\s*extends\s+["'](.+?)["']\s*-?%\}/
391
+ if source =~ EXTENDS_RE
265
392
  parent_name = Regexp.last_match(1)
266
393
  parent_source = load_template(parent_name)
267
394
  child_blocks = extract_blocks(source)
@@ -273,7 +400,7 @@ module Tina4
273
400
 
274
401
  def execute(source, context)
275
402
  # Handle extends first
276
- if source =~ /\{%-?\s*extends\s+["'](.+?)["']\s*-?%\}/
403
+ if source =~ EXTENDS_RE
277
404
  parent_name = Regexp.last_match(1)
278
405
  parent_source = load_template(parent_name)
279
406
  child_blocks = extract_blocks(source)
@@ -285,14 +412,14 @@ module Tina4
285
412
 
286
413
  def extract_blocks(source)
287
414
  blocks = {}
288
- source.scan(/\{%-?\s*block\s+(\w+)\s*-?%\}(.*?)\{%-?\s*endblock\s*-?%\}/m) do
415
+ source.scan(BLOCK_RE) do
289
416
  blocks[Regexp.last_match(1)] = Regexp.last_match(2)
290
417
  end
291
418
  blocks
292
419
  end
293
420
 
294
421
  def render_with_blocks(parent_source, context, child_blocks)
295
- result = parent_source.gsub(/\{%-?\s*block\s+(\w+)\s*-?%\}(.*?)\{%-?\s*endblock\s*-?%\}/m) do
422
+ result = parent_source.gsub(BLOCK_RE) do
296
423
  name = Regexp.last_match(1)
297
424
  default_content = Regexp.last_match(2)
298
425
  block_source = child_blocks.fetch(name, default_content)
@@ -422,7 +549,7 @@ module Tina4
422
549
  # The filter name may include a trailing comparison operator,
423
550
  # e.g. "length != 1". Extract the real filter name and the
424
551
  # comparison suffix, apply the filter, then evaluate the comparison.
425
- m = fname.match(/\A(\w+)\s*(!=|==|>=|<=|>|<)\s*(.+)\z/)
552
+ m = fname.match(FILTER_CMP_RE)
426
553
  if m
427
554
  real_filter = m[1]
428
555
  op = m[2]
@@ -455,7 +582,11 @@ module Tina4
455
582
 
456
583
  # Sandbox: check variable access
457
584
  if @sandbox && @allowed_vars
458
- root_var = var_name.split(".")[0].split("[")[0].strip
585
+ root_var = @dotted_split_cache[var_name]
586
+ unless root_var
587
+ root_var = var_name.split(".")[0].split("[")[0].strip
588
+ @dotted_split_cache[var_name] = root_var
589
+ end
459
590
  return "" if !root_var.empty? && !@allowed_vars.include?(root_var) && root_var != "loop"
460
591
  end
461
592
 
@@ -473,6 +604,23 @@ module Tina4
473
604
  next
474
605
  end
475
606
 
607
+ # Inline common no-arg filters for speed (skip generic dispatch)
608
+ if args.empty? && INLINE_FILTERS.include?(fname)
609
+ value = case fname
610
+ when "upper" then value.to_s.upcase
611
+ when "lower" then value.to_s.downcase
612
+ when "length" then value.respond_to?(:length) ? value.length : value.to_s.length
613
+ when "trim" then value.to_s.strip
614
+ when "capitalize" then value.to_s.capitalize
615
+ when "title" then value.to_s.split.map(&:capitalize).join(" ")
616
+ when "string" then value.to_s
617
+ when "int" then value.to_i
618
+ when "escape", "e" then Frond.escape_html(value.to_s)
619
+ else value
620
+ end
621
+ next
622
+ end
623
+
476
624
  fn = @filters[fname]
477
625
  if fn
478
626
  evaluated_args = args.map { |a| eval_filter_arg(a, context) }
@@ -489,12 +637,47 @@ module Tina4
489
637
  end
490
638
 
491
639
  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/
640
+ return Regexp.last_match(1) if arg =~ STRING_LIT_RE
641
+ return arg.to_i if arg =~ INTEGER_RE
642
+ return arg.to_f if arg =~ FLOAT_RE
495
643
  eval_expr(arg, context)
496
644
  end
497
645
 
646
+ # Find the first occurrence of +needle+ that is not inside quotes or
647
+ # parentheses. Returns the index, or -1 if not found.
648
+ def find_outside_quotes(expr, needle)
649
+ in_q = nil
650
+ depth = 0
651
+ i = 0
652
+ nlen = needle.length
653
+ while i <= expr.length - nlen
654
+ ch = expr[i]
655
+ if (ch == '"' || ch == "'") && depth == 0
656
+ if in_q.nil?
657
+ in_q = ch
658
+ elsif ch == in_q
659
+ in_q = nil
660
+ end
661
+ i += 1
662
+ next
663
+ end
664
+ if in_q
665
+ i += 1
666
+ next
667
+ end
668
+ if ch == "("
669
+ depth += 1
670
+ elsif ch == ")"
671
+ depth -= 1
672
+ end
673
+ if depth == 0 && expr[i, nlen] == needle
674
+ return i
675
+ end
676
+ i += 1
677
+ end
678
+ -1
679
+ end
680
+
498
681
  # Find the index of a top-level ``?`` that is part of a ternary operator.
499
682
  # Respects quoted strings, parentheses, and skips ``??`` (null coalesce).
500
683
  # Returns -1 if not found.
@@ -562,13 +745,16 @@ module Tina4
562
745
  # -----------------------------------------------------------------------
563
746
 
564
747
  def parse_filter_chain(expr)
748
+ cached = @filter_chain_cache[expr]
749
+ return cached if cached
750
+
565
751
  parts = split_on_pipe(expr)
566
752
  variable = parts[0].strip
567
753
  filters = []
568
754
 
569
755
  parts[1..].each do |f|
570
756
  f = f.strip
571
- if f =~ /\A(\w+)\s*\((.*)\)\z/m
757
+ if f =~ FILTER_WITH_ARGS_RE
572
758
  name = Regexp.last_match(1)
573
759
  raw_args = Regexp.last_match(2).strip
574
760
  args = raw_args.empty? ? [] : parse_args(raw_args)
@@ -578,7 +764,9 @@ module Tina4
578
764
  end
579
765
  end
580
766
 
581
- [variable, filters]
767
+ result = [variable, filters].freeze
768
+ @filter_chain_cache[expr] = result
769
+ result
582
770
  end
583
771
 
584
772
  # Split expression on | but not inside quotes or parens.
@@ -659,8 +847,8 @@ module Tina4
659
847
  end
660
848
 
661
849
  # Numeric
662
- return expr.to_i if expr =~ /\A-?\d+\z/
663
- return expr.to_f if expr =~ /\A-?\d+\.\d+\z/
850
+ return expr.to_i if expr =~ INTEGER_RE
851
+ return expr.to_f if expr =~ FLOAT_RE
664
852
 
665
853
  # Boolean/null
666
854
  return true if expr == "true"
@@ -668,17 +856,17 @@ module Tina4
668
856
  return nil if expr == "null" || expr == "none" || expr == "nil"
669
857
 
670
858
  # Array literal [a, b, c]
671
- if expr =~ /\A\[(.+)\]\z/m
859
+ if expr =~ ARRAY_LIT_RE
672
860
  inner = Regexp.last_match(1)
673
861
  return split_args_toplevel(inner).map { |item| eval_expr(item.strip, context) }
674
862
  end
675
863
 
676
864
  # Hash literal { key: value, ... }
677
- if expr =~ /\A\{(.+)\}\z/m
865
+ if expr =~ HASH_LIT_RE
678
866
  inner = Regexp.last_match(1)
679
867
  hash = {}
680
868
  split_args_toplevel(inner).each do |pair|
681
- if pair =~ /\A\s*["']?(\w+)["']?\s*:\s*(.+)\z/
869
+ if pair =~ HASH_PAIR_RE
682
870
  hash[Regexp.last_match(1)] = eval_expr(Regexp.last_match(2).strip, context)
683
871
  end
684
872
  end
@@ -686,22 +874,50 @@ module Tina4
686
874
  end
687
875
 
688
876
  # Range literal: 1..5
689
- if expr =~ /\A(\d+)\.\.(\d+)\z/
877
+ if expr =~ RANGE_LIT_RE
690
878
  return (Regexp.last_match(1).to_i..Regexp.last_match(2).to_i).to_a
691
879
  end
692
880
 
693
- # Ternary: condition ? "yes" : "no"
694
- ternary = expr.match(/\A(.+?)\s*\?\s*(.+?)\s*:\s*(.+)\z/)
695
- if ternary
696
- cond = eval_expr(ternary[1], context)
697
- return truthy?(cond) ? eval_expr(ternary[2], context) : eval_expr(ternary[3], context)
881
+ # Parenthesized sub-expression: (expr) strip parens and evaluate inner
882
+ if expr.start_with?("(") && expr.end_with?(")")
883
+ depth = 0
884
+ matched = true
885
+ expr.each_char.with_index do |ch, pi|
886
+ depth += 1 if ch == "("
887
+ depth -= 1 if ch == ")"
888
+ if depth == 0 && pi < expr.length - 1
889
+ matched = false
890
+ break
891
+ end
892
+ end
893
+ return eval_expr(expr[1..-2], context) if matched
698
894
  end
699
895
 
700
- # Jinja2-style inline if: value if condition else other_value
701
- inline_if = expr.match(/\A(.+?)\s+if\s+(.+?)\s+else\s+(.+)\z/)
702
- if inline_if
703
- cond = eval_expr(inline_if[2], context)
704
- return truthy?(cond) ? eval_expr(inline_if[1], context) : eval_expr(inline_if[3], context)
896
+ # Ternary: condition ? "yes" : "no" quote-aware
897
+ q_pos = find_outside_quotes(expr, "?")
898
+ if q_pos > 0
899
+ cond_part = expr[0...q_pos].strip
900
+ rest = expr[(q_pos + 1)..]
901
+ c_pos = find_outside_quotes(rest, ":")
902
+ if c_pos >= 0
903
+ true_part = rest[0...c_pos].strip
904
+ false_part = rest[(c_pos + 1)..].strip
905
+ cond = eval_expr(cond_part, context)
906
+ return truthy?(cond) ? eval_expr(true_part, context) : eval_expr(false_part, context)
907
+ end
908
+ end
909
+
910
+ # Jinja2-style inline if: value if condition else other_value — quote-aware
911
+ if_pos = find_outside_quotes(expr, " if ")
912
+ if if_pos >= 0
913
+ else_pos = find_outside_quotes(expr, " else ")
914
+ if else_pos && else_pos > if_pos
915
+ value_part = expr[0...if_pos].strip
916
+ cond_part = expr[(if_pos + 4)...else_pos].strip
917
+ else_part = expr[(else_pos + 6)..].strip
918
+ cond = eval_expr(cond_part, context)
919
+ return truthy?(cond) ? eval_expr(value_part, context) : eval_expr(else_part, context)
920
+ end
705
921
  end
706
922
 
707
923
  # Null coalescing: value ?? "default"
@@ -723,7 +939,7 @@ module Tina4
723
939
  end
724
940
 
725
941
  # Arithmetic: +, -, *, /, %
726
- if expr =~ /\A(.+?)\s*(\+|-|\*|\/|%)\s*(.+)\z/
942
+ if expr =~ ARITHMETIC_RE
727
943
  left = eval_expr(Regexp.last_match(1), context)
728
944
  op = Regexp.last_match(2)
729
945
  right = eval_expr(Regexp.last_match(3), context)
@@ -731,7 +947,7 @@ module Tina4
731
947
  end
732
948
 
733
949
  # Function call: name(arg1, arg2)
734
- if expr =~ /\A(\w+)\s*\((.*)\)\z/m
950
+ if expr =~ FUNC_CALL_RE
735
951
  fn_name = Regexp.last_match(1)
736
952
  raw_args = Regexp.last_match(2).strip
737
953
  fn = context[fn_name]
@@ -788,49 +1004,50 @@ module Tina4
788
1004
  # Comparison / logical evaluator
789
1005
  # -----------------------------------------------------------------------
790
1006
 
791
- def eval_comparison(expr, context)
1007
+ def eval_comparison(expr, context, eval_fn = nil)
1008
+ eval_fn ||= method(:eval_expr)
792
1009
  expr = expr.strip
793
1010
 
794
1011
  # Handle 'not' prefix
795
1012
  if expr.start_with?("not ")
796
- return !eval_comparison(expr[4..], context)
1013
+ return !eval_comparison(expr[4..], context, eval_fn)
797
1014
  end
798
1015
 
799
1016
  # 'or' (lowest precedence)
800
- or_parts = expr.split(/\s+or\s+/)
1017
+ or_parts = expr.split(OR_SPLIT_RE)
801
1018
  if or_parts.length > 1
802
- return or_parts.any? { |p| eval_comparison(p, context) }
1019
+ return or_parts.any? { |p| eval_comparison(p, context, eval_fn) }
803
1020
  end
804
1021
 
805
1022
  # 'and'
806
- and_parts = expr.split(/\s+and\s+/)
1023
+ and_parts = expr.split(AND_SPLIT_RE)
807
1024
  if and_parts.length > 1
808
- return and_parts.all? { |p| eval_comparison(p, context) }
1025
+ return and_parts.all? { |p| eval_comparison(p, context, eval_fn) }
809
1026
  end
810
1027
 
811
1028
  # 'is not' test
812
- if expr =~ /\A(.+?)\s+is\s+not\s+(\w+)(.*)\z/
1029
+ if expr =~ IS_NOT_RE
813
1030
  return !eval_test(Regexp.last_match(1).strip, Regexp.last_match(2),
814
- Regexp.last_match(3).strip, context)
1031
+ Regexp.last_match(3).strip, context, eval_fn)
815
1032
  end
816
1033
 
817
1034
  # 'is' test
818
- if expr =~ /\A(.+?)\s+is\s+(\w+)(.*)\z/
1035
+ if expr =~ IS_RE
819
1036
  return eval_test(Regexp.last_match(1).strip, Regexp.last_match(2),
820
- Regexp.last_match(3).strip, context)
1037
+ Regexp.last_match(3).strip, context, eval_fn)
821
1038
  end
822
1039
 
823
1040
  # 'not in'
824
- if expr =~ /\A(.+?)\s+not\s+in\s+(.+)\z/
825
- val = eval_expr(Regexp.last_match(1).strip, context)
826
- collection = eval_expr(Regexp.last_match(2).strip, context)
1041
+ if expr =~ NOT_IN_RE
1042
+ val = eval_fn.call(Regexp.last_match(1).strip, context)
1043
+ collection = eval_fn.call(Regexp.last_match(2).strip, context)
827
1044
  return !(collection.respond_to?(:include?) && collection.include?(val))
828
1045
  end
829
1046
 
830
1047
  # 'in'
831
- if expr =~ /\A(.+?)\s+in\s+(.+)\z/
832
- val = eval_expr(Regexp.last_match(1).strip, context)
833
- collection = eval_expr(Regexp.last_match(2).strip, context)
1048
+ if expr =~ IN_RE
1049
+ val = eval_fn.call(Regexp.last_match(1).strip, context)
1050
+ collection = eval_fn.call(Regexp.last_match(2).strip, context)
834
1051
  return collection.respond_to?(:include?) ? collection.include?(val) : false
835
1052
  end
836
1053
 
@@ -843,8 +1060,8 @@ module Tina4
843
1060
  ["<", ->(a, b) { a.to_f < b.to_f }]].each do |op, fn|
844
1061
  if expr.include?(op)
845
1062
  left, _, right = expr.partition(op)
846
- l = eval_expr(left.strip, context)
847
- r = eval_expr(right.strip, context)
1063
+ l = eval_fn.call(left.strip, context)
1064
+ r = eval_fn.call(right.strip, context)
848
1065
  begin
849
1066
  return fn.call(l, r)
850
1067
  rescue
@@ -854,7 +1071,7 @@ module Tina4
854
1071
  end
855
1072
 
856
1073
  # Fall through to simple eval
857
- val = eval_expr(expr, context)
1074
+ val = eval_fn.call(expr, context)
858
1075
  truthy?(val)
859
1076
  end
860
1077
 
@@ -862,12 +1079,13 @@ module Tina4
862
1079
  # Tests ('is' expressions)
863
1080
  # -----------------------------------------------------------------------
864
1081
 
865
- def eval_test(value_expr, test_name, args_str, context)
866
- val = eval_expr(value_expr, context)
1082
+ def eval_test(value_expr, test_name, args_str, context, eval_fn = nil)
1083
+ eval_fn ||= method(:eval_expr)
1084
+ val = eval_fn.call(value_expr, context)
867
1085
 
868
1086
  # 'divisible by(n)'
869
1087
  if test_name == "divisible"
870
- if args_str =~ /\s*by\s*\(\s*(\d+)\s*\)/
1088
+ if args_str =~ DIVISIBLE_BY_RE
871
1089
  n = Regexp.last_match(1).to_i
872
1090
  return val.is_a?(Integer) && (val % n).zero?
873
1091
  end
@@ -901,14 +1119,19 @@ module Tina4
901
1119
  # -----------------------------------------------------------------------
902
1120
 
903
1121
  def resolve(expr, context)
904
- parts = expr.split(/\.|\[([^\]]+)\]/).reject(&:empty?)
1122
+ parts = @resolve_cache[expr]
1123
+ unless parts
1124
+ parts = expr.split(RESOLVE_SPLIT_RE).reject(&:empty?)
1125
+ @resolve_cache[expr] = parts
1126
+ end
1127
+
905
1128
  value = context
906
1129
 
907
1130
  parts.each do |part|
908
- part = part.strip.gsub(/\A["']|["']\z/, "") # strip quotes from bracket access
909
- if value.is_a?(Hash)
1131
+ part = part.strip.gsub(RESOLVE_STRIP_RE, "") # strip quotes from bracket access
1132
+ if value.is_a?(Hash) || value.is_a?(LoopContext)
910
1133
  value = value[part] || value[part.to_sym]
911
- elsif value.is_a?(Array) && part =~ /\A\d+\z/
1134
+ elsif value.is_a?(Array) && part =~ DIGIT_RE
912
1135
  value = value[part.to_i]
913
1136
  elsif value.respond_to?(part.to_sym)
914
1137
  value = value.send(part.to_sym)
@@ -1010,7 +1233,7 @@ module Tina4
1010
1233
  end
1011
1234
 
1012
1235
  branches.each do |cond, branch_tokens|
1013
- if cond.nil? || eval_comparison(cond, context)
1236
+ if cond.nil? || eval_comparison(cond, context, method(:eval_var_raw))
1014
1237
  return [render_tokens(branch_tokens.dup, context), i]
1015
1238
  end
1016
1239
  end
@@ -1021,7 +1244,7 @@ module Tina4
1021
1244
  # {% for item in items %}...{% else %}...{% endfor %}
1022
1245
  def handle_for(tokens, start, context)
1023
1246
  content, _, strip_a_open = strip_tag(tokens[start][1])
1024
- m = content.match(/\Afor\s+(\w+)(?:\s*,\s*(\w+))?\s+in\s+(.+)\z/)
1247
+ m = content.match(FOR_RE)
1025
1248
  return ["", start + 1] unless m
1026
1249
 
1027
1250
  var1 = m[1]
@@ -1095,7 +1318,7 @@ module Tina4
1095
1318
  total = items.length
1096
1319
 
1097
1320
  items.each_with_index do |item, idx|
1098
- loop_ctx = context.dup
1321
+ loop_ctx = LoopContext.new(context)
1099
1322
  loop_ctx["loop"] = {
1100
1323
  "index" => idx + 1,
1101
1324
  "index0" => idx,
@@ -1133,7 +1356,7 @@ module Tina4
1133
1356
 
1134
1357
  # {% set name = expr %}
1135
1358
  def handle_set(content, context)
1136
- if content =~ /\Aset\s+(\w+)\s*=\s*(.+)\z/m
1359
+ if content =~ SET_RE
1137
1360
  name = Regexp.last_match(1)
1138
1361
  expr = Regexp.last_match(2).strip
1139
1362
  context[name] = eval_expr(expr, context)
@@ -1145,7 +1368,7 @@ module Tina4
1145
1368
  ignore_missing = content.include?("ignore missing")
1146
1369
  content = content.gsub("ignore missing", "").strip
1147
1370
 
1148
- m = content.match(/\Ainclude\s+["'](.+?)["'](?:\s+with\s+(.+))?\z/)
1371
+ m = content.match(INCLUDE_RE)
1149
1372
  return "" unless m
1150
1373
 
1151
1374
  filename = m[1]
@@ -1170,7 +1393,7 @@ module Tina4
1170
1393
  # {% macro name(args) %}...{% endmacro %}
1171
1394
  def handle_macro(tokens, start, context)
1172
1395
  content, _, _ = strip_tag(tokens[start][1])
1173
- m = content.match(/\Amacro\s+(\w+)\s*\(([^)]*)\)/)
1396
+ m = content.match(MACRO_RE)
1174
1397
  unless m
1175
1398
  i = start + 1
1176
1399
  while i < tokens.length
@@ -1213,7 +1436,7 @@ module Tina4
1213
1436
 
1214
1437
  # {% from "file" import macro1, macro2 %}
1215
1438
  def handle_from_import(content, context)
1216
- m = content.match(/\Afrom\s+["'](.+?)["']\s+import\s+(.+)/)
1439
+ m = content.match(FROM_IMPORT_RE)
1217
1440
  return unless m
1218
1441
 
1219
1442
  filename = m[1]
@@ -1229,7 +1452,7 @@ module Tina4
1229
1452
  tag_content, _, _ = strip_tag(raw)
1230
1453
  tag = (tag_content.split[0] || "")
1231
1454
  if tag == "macro"
1232
- macro_m = tag_content.match(/\Amacro\s+(\w+)\s*\(([^)]*)\)/)
1455
+ macro_m = tag_content.match(MACRO_RE)
1233
1456
  if macro_m && names.include?(macro_m[1])
1234
1457
  macro_name = macro_m[1]
1235
1458
  param_names = macro_m[2].split(",").map(&:strip).reject(&:empty?)
@@ -1269,7 +1492,7 @@ module Tina4
1269
1492
  # {% cache "key" ttl %}...{% endcache %}
1270
1493
  def handle_cache(tokens, start, context)
1271
1494
  content, _, _ = strip_tag(tokens[start][1])
1272
- m = content.match(/\Acache\s+["'](.+?)["']\s*(\d+)?/)
1495
+ m = content.match(CACHE_RE)
1273
1496
  cache_key = m ? m[1] : "default"
1274
1497
  ttl = m && m[2] ? m[2].to_i : 60
1275
1498
 
@@ -1357,13 +1580,13 @@ module Tina4
1357
1580
  end
1358
1581
 
1359
1582
  rendered = render_tokens(body_tokens.dup, context)
1360
- rendered = rendered.gsub(/>\s+</, "><")
1583
+ rendered = rendered.gsub(SPACELESS_RE, "><")
1361
1584
  [rendered, i]
1362
1585
  end
1363
1586
 
1364
1587
  def handle_autoescape(tokens, start, context)
1365
1588
  content, _, _ = strip_tag(tokens[start][1])
1366
- mode_match = content.match(/\Aautoescape\s+(false|true)/)
1589
+ mode_match = content.match(AUTOESCAPE_RE)
1367
1590
  auto_escape_on = !(mode_match && mode_match[1] == "false")
1368
1591
 
1369
1592
  body_tokens = []
@@ -1434,7 +1657,7 @@ module Tina4
1434
1657
  "ltrim" => ->(v, *_a) { v.to_s.lstrip },
1435
1658
  "rtrim" => ->(v, *_a) { v.to_s.rstrip },
1436
1659
  "replace" => ->(v, *a) { a.length >= 2 ? v.to_s.gsub(a[0].to_s, a[1].to_s) : v.to_s },
1437
- "striptags" => ->(v, *_a) { v.to_s.gsub(/<[^>]+>/, "") },
1660
+ "striptags" => ->(v, *_a) { v.to_s.gsub(STRIPTAGS_RE, "") },
1438
1661
 
1439
1662
  # -- Encoding --
1440
1663
  "escape" => ->(v, *_a) { Frond.escape_html(v.to_s) },
@@ -1459,6 +1682,25 @@ module Tina4
1459
1682
  },
1460
1683
  "url_encode" => ->(v, *_a) { CGI.escape(v.to_s) },
1461
1684
 
1685
+ # -- JSON / JS --
1686
+ "to_json" => ->(v, *a) {
1687
+ indent = a[0] ? a[0].to_i : nil
1688
+ json = indent ? JSON.pretty_generate(v) : JSON.generate(v)
1689
+ # Escape <, >, & for safe HTML embedding
1690
+ Tina4::SafeString.new(json.gsub("<", '\u003c').gsub(">", '\u003e').gsub("&", '\u0026'))
1691
+ },
1692
+ "tojson" => ->(v, *a) {
1693
+ indent = a[0] ? a[0].to_i : nil
1694
+ json = indent ? JSON.pretty_generate(v) : JSON.generate(v)
1695
+ Tina4::SafeString.new(json.gsub("<", '\u003c').gsub(">", '\u003e').gsub("&", '\u0026'))
1696
+ },
1697
+ "js_escape" => ->(v, *_a) {
1698
+ Tina4::SafeString.new(
1699
+ v.to_s.gsub("\\", "\\\\").gsub("'", "\\'").gsub('"', '\\"')
1700
+ .gsub("\n", "\\n").gsub("\r", "\\r").gsub("\t", "\\t")
1701
+ )
1702
+ },
1703
+
1462
1704
  # -- Hashing --
1463
1705
  "md5" => ->(v, *_a) { Digest::MD5.hexdigest(v.to_s) },
1464
1706
  "sha256" => ->(v, *_a) { Digest::SHA256.hexdigest(v.to_s) },
@@ -1473,7 +1715,7 @@ module Tina4
1473
1715
  formatted = format("%.#{decimals}f", v.to_f)
1474
1716
  # Add comma thousands separator
1475
1717
  parts = formatted.split(".")
1476
- parts[0] = parts[0].gsub(/(\d)(?=(\d{3})+(?!\d))/, '\\1,')
1718
+ parts[0] = parts[0].gsub(THOUSANDS_RE, '\\1,')
1477
1719
  parts.join(".")
1478
1720
  },
1479
1721
 
@@ -1578,7 +1820,7 @@ module Tina4
1578
1820
  lines << current unless current.empty?
1579
1821
  lines.join("\n")
1580
1822
  },
1581
- "slug" => ->(v, *_a) { v.to_s.downcase.gsub(/[^a-z0-9]+/, "-").gsub(/\A-|-\z/, "") },
1823
+ "slug" => ->(v, *_a) { v.to_s.downcase.gsub(SLUG_CLEAN_RE, "-").gsub(SLUG_TRIM_RE, "") },
1582
1824
  "nl2br" => ->(v, *_a) { v.to_s.gsub("\n", "<br>\n") },
1583
1825
  "format" => ->(v, *a) {
1584
1826
  if a.any?
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.16"
4
+ VERSION = "3.10.21"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: tina4ruby
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.10.16
4
+ version: 3.10.21
5
5
  platform: ruby
6
6
  authors:
7
7
  - Tina4 Team