tina4ruby 3.10.18 → 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 +4 -4
- data/lib/tina4/database.rb +88 -19
- data/lib/tina4/frond.rb +219 -59
- data/lib/tina4/version.rb +1 -1
- metadata +3 -6
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 29aa756b32ea3d0051d1b5d26cb8b1312bb77c871fec39fea3e4ce2a8d5c5afd
|
|
4
|
+
data.tar.gz: 326553173c9e4753b6b0bf0471729c065d169d62f609c663b0c3400847e889e8
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 2f23cc2b84be7fdc7d7f6b9e21ce400a124a95d51b78e5d5b458313516d4aba930e166fc32120df8c1c6f317df78e3d4937b9722d3459bcfbb00cd2cd7c731c0
|
|
7
|
+
data.tar.gz: db8f5ac24ff747c59cb6b93b4877b99ac5790b3234b1f7e360d811fb002bb00618215d32fb2fa1a707108b68257de31dfd937f4c312e41f7b63f8caf65b359e7
|
data/lib/tina4/database.rb
CHANGED
|
@@ -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
|
|
318
|
-
# - SQLite/MySQL/MSSQL:
|
|
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]
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
351
|
-
|
|
352
|
-
end
|
|
355
|
+
val = row_value(row, :next_id) || row_value(row, :nextval)
|
|
356
|
+
return val.to_i if val
|
|
353
357
|
rescue
|
|
354
|
-
#
|
|
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 —
|
|
359
|
-
|
|
360
|
-
|
|
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 =~
|
|
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 =~
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
|
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,9 +637,9 @@ module Tina4
|
|
|
489
637
|
end
|
|
490
638
|
|
|
491
639
|
def eval_filter_arg(arg, context)
|
|
492
|
-
return Regexp.last_match(1) if arg =~
|
|
493
|
-
return arg.to_i if arg =~
|
|
494
|
-
return arg.to_f if arg =~
|
|
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
|
|
|
@@ -597,13 +745,16 @@ module Tina4
|
|
|
597
745
|
# -----------------------------------------------------------------------
|
|
598
746
|
|
|
599
747
|
def parse_filter_chain(expr)
|
|
748
|
+
cached = @filter_chain_cache[expr]
|
|
749
|
+
return cached if cached
|
|
750
|
+
|
|
600
751
|
parts = split_on_pipe(expr)
|
|
601
752
|
variable = parts[0].strip
|
|
602
753
|
filters = []
|
|
603
754
|
|
|
604
755
|
parts[1..].each do |f|
|
|
605
756
|
f = f.strip
|
|
606
|
-
if f =~
|
|
757
|
+
if f =~ FILTER_WITH_ARGS_RE
|
|
607
758
|
name = Regexp.last_match(1)
|
|
608
759
|
raw_args = Regexp.last_match(2).strip
|
|
609
760
|
args = raw_args.empty? ? [] : parse_args(raw_args)
|
|
@@ -613,7 +764,9 @@ module Tina4
|
|
|
613
764
|
end
|
|
614
765
|
end
|
|
615
766
|
|
|
616
|
-
[variable, filters]
|
|
767
|
+
result = [variable, filters].freeze
|
|
768
|
+
@filter_chain_cache[expr] = result
|
|
769
|
+
result
|
|
617
770
|
end
|
|
618
771
|
|
|
619
772
|
# Split expression on | but not inside quotes or parens.
|
|
@@ -694,8 +847,8 @@ module Tina4
|
|
|
694
847
|
end
|
|
695
848
|
|
|
696
849
|
# Numeric
|
|
697
|
-
return expr.to_i if expr =~
|
|
698
|
-
return expr.to_f if expr =~
|
|
850
|
+
return expr.to_i if expr =~ INTEGER_RE
|
|
851
|
+
return expr.to_f if expr =~ FLOAT_RE
|
|
699
852
|
|
|
700
853
|
# Boolean/null
|
|
701
854
|
return true if expr == "true"
|
|
@@ -703,17 +856,17 @@ module Tina4
|
|
|
703
856
|
return nil if expr == "null" || expr == "none" || expr == "nil"
|
|
704
857
|
|
|
705
858
|
# Array literal [a, b, c]
|
|
706
|
-
if expr =~
|
|
859
|
+
if expr =~ ARRAY_LIT_RE
|
|
707
860
|
inner = Regexp.last_match(1)
|
|
708
861
|
return split_args_toplevel(inner).map { |item| eval_expr(item.strip, context) }
|
|
709
862
|
end
|
|
710
863
|
|
|
711
864
|
# Hash literal { key: value, ... }
|
|
712
|
-
if expr =~
|
|
865
|
+
if expr =~ HASH_LIT_RE
|
|
713
866
|
inner = Regexp.last_match(1)
|
|
714
867
|
hash = {}
|
|
715
868
|
split_args_toplevel(inner).each do |pair|
|
|
716
|
-
if pair =~
|
|
869
|
+
if pair =~ HASH_PAIR_RE
|
|
717
870
|
hash[Regexp.last_match(1)] = eval_expr(Regexp.last_match(2).strip, context)
|
|
718
871
|
end
|
|
719
872
|
end
|
|
@@ -721,7 +874,7 @@ module Tina4
|
|
|
721
874
|
end
|
|
722
875
|
|
|
723
876
|
# Range literal: 1..5
|
|
724
|
-
if expr =~
|
|
877
|
+
if expr =~ RANGE_LIT_RE
|
|
725
878
|
return (Regexp.last_match(1).to_i..Regexp.last_match(2).to_i).to_a
|
|
726
879
|
end
|
|
727
880
|
|
|
@@ -786,7 +939,7 @@ module Tina4
|
|
|
786
939
|
end
|
|
787
940
|
|
|
788
941
|
# Arithmetic: +, -, *, /, %
|
|
789
|
-
if expr =~
|
|
942
|
+
if expr =~ ARITHMETIC_RE
|
|
790
943
|
left = eval_expr(Regexp.last_match(1), context)
|
|
791
944
|
op = Regexp.last_match(2)
|
|
792
945
|
right = eval_expr(Regexp.last_match(3), context)
|
|
@@ -794,7 +947,7 @@ module Tina4
|
|
|
794
947
|
end
|
|
795
948
|
|
|
796
949
|
# Function call: name(arg1, arg2)
|
|
797
|
-
if expr =~
|
|
950
|
+
if expr =~ FUNC_CALL_RE
|
|
798
951
|
fn_name = Regexp.last_match(1)
|
|
799
952
|
raw_args = Regexp.last_match(2).strip
|
|
800
953
|
fn = context[fn_name]
|
|
@@ -851,49 +1004,50 @@ module Tina4
|
|
|
851
1004
|
# Comparison / logical evaluator
|
|
852
1005
|
# -----------------------------------------------------------------------
|
|
853
1006
|
|
|
854
|
-
def eval_comparison(expr, context)
|
|
1007
|
+
def eval_comparison(expr, context, eval_fn = nil)
|
|
1008
|
+
eval_fn ||= method(:eval_expr)
|
|
855
1009
|
expr = expr.strip
|
|
856
1010
|
|
|
857
1011
|
# Handle 'not' prefix
|
|
858
1012
|
if expr.start_with?("not ")
|
|
859
|
-
return !eval_comparison(expr[4..], context)
|
|
1013
|
+
return !eval_comparison(expr[4..], context, eval_fn)
|
|
860
1014
|
end
|
|
861
1015
|
|
|
862
1016
|
# 'or' (lowest precedence)
|
|
863
|
-
or_parts = expr.split(
|
|
1017
|
+
or_parts = expr.split(OR_SPLIT_RE)
|
|
864
1018
|
if or_parts.length > 1
|
|
865
|
-
return or_parts.any? { |p| eval_comparison(p, context) }
|
|
1019
|
+
return or_parts.any? { |p| eval_comparison(p, context, eval_fn) }
|
|
866
1020
|
end
|
|
867
1021
|
|
|
868
1022
|
# 'and'
|
|
869
|
-
and_parts = expr.split(
|
|
1023
|
+
and_parts = expr.split(AND_SPLIT_RE)
|
|
870
1024
|
if and_parts.length > 1
|
|
871
|
-
return and_parts.all? { |p| eval_comparison(p, context) }
|
|
1025
|
+
return and_parts.all? { |p| eval_comparison(p, context, eval_fn) }
|
|
872
1026
|
end
|
|
873
1027
|
|
|
874
1028
|
# 'is not' test
|
|
875
|
-
if expr =~
|
|
1029
|
+
if expr =~ IS_NOT_RE
|
|
876
1030
|
return !eval_test(Regexp.last_match(1).strip, Regexp.last_match(2),
|
|
877
|
-
Regexp.last_match(3).strip, context)
|
|
1031
|
+
Regexp.last_match(3).strip, context, eval_fn)
|
|
878
1032
|
end
|
|
879
1033
|
|
|
880
1034
|
# 'is' test
|
|
881
|
-
if expr =~
|
|
1035
|
+
if expr =~ IS_RE
|
|
882
1036
|
return eval_test(Regexp.last_match(1).strip, Regexp.last_match(2),
|
|
883
|
-
Regexp.last_match(3).strip, context)
|
|
1037
|
+
Regexp.last_match(3).strip, context, eval_fn)
|
|
884
1038
|
end
|
|
885
1039
|
|
|
886
1040
|
# 'not in'
|
|
887
|
-
if expr =~
|
|
888
|
-
val =
|
|
889
|
-
collection =
|
|
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)
|
|
890
1044
|
return !(collection.respond_to?(:include?) && collection.include?(val))
|
|
891
1045
|
end
|
|
892
1046
|
|
|
893
1047
|
# 'in'
|
|
894
|
-
if expr =~
|
|
895
|
-
val =
|
|
896
|
-
collection =
|
|
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)
|
|
897
1051
|
return collection.respond_to?(:include?) ? collection.include?(val) : false
|
|
898
1052
|
end
|
|
899
1053
|
|
|
@@ -906,8 +1060,8 @@ module Tina4
|
|
|
906
1060
|
["<", ->(a, b) { a.to_f < b.to_f }]].each do |op, fn|
|
|
907
1061
|
if expr.include?(op)
|
|
908
1062
|
left, _, right = expr.partition(op)
|
|
909
|
-
l =
|
|
910
|
-
r =
|
|
1063
|
+
l = eval_fn.call(left.strip, context)
|
|
1064
|
+
r = eval_fn.call(right.strip, context)
|
|
911
1065
|
begin
|
|
912
1066
|
return fn.call(l, r)
|
|
913
1067
|
rescue
|
|
@@ -917,7 +1071,7 @@ module Tina4
|
|
|
917
1071
|
end
|
|
918
1072
|
|
|
919
1073
|
# Fall through to simple eval
|
|
920
|
-
val =
|
|
1074
|
+
val = eval_fn.call(expr, context)
|
|
921
1075
|
truthy?(val)
|
|
922
1076
|
end
|
|
923
1077
|
|
|
@@ -925,12 +1079,13 @@ module Tina4
|
|
|
925
1079
|
# Tests ('is' expressions)
|
|
926
1080
|
# -----------------------------------------------------------------------
|
|
927
1081
|
|
|
928
|
-
def eval_test(value_expr, test_name, args_str, context)
|
|
929
|
-
|
|
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)
|
|
930
1085
|
|
|
931
1086
|
# 'divisible by(n)'
|
|
932
1087
|
if test_name == "divisible"
|
|
933
|
-
if args_str =~
|
|
1088
|
+
if args_str =~ DIVISIBLE_BY_RE
|
|
934
1089
|
n = Regexp.last_match(1).to_i
|
|
935
1090
|
return val.is_a?(Integer) && (val % n).zero?
|
|
936
1091
|
end
|
|
@@ -964,14 +1119,19 @@ module Tina4
|
|
|
964
1119
|
# -----------------------------------------------------------------------
|
|
965
1120
|
|
|
966
1121
|
def resolve(expr, context)
|
|
967
|
-
parts = expr
|
|
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
|
+
|
|
968
1128
|
value = context
|
|
969
1129
|
|
|
970
1130
|
parts.each do |part|
|
|
971
|
-
part = part.strip.gsub(
|
|
972
|
-
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)
|
|
973
1133
|
value = value[part] || value[part.to_sym]
|
|
974
|
-
elsif value.is_a?(Array) && part =~
|
|
1134
|
+
elsif value.is_a?(Array) && part =~ DIGIT_RE
|
|
975
1135
|
value = value[part.to_i]
|
|
976
1136
|
elsif value.respond_to?(part.to_sym)
|
|
977
1137
|
value = value.send(part.to_sym)
|
|
@@ -1073,7 +1233,7 @@ module Tina4
|
|
|
1073
1233
|
end
|
|
1074
1234
|
|
|
1075
1235
|
branches.each do |cond, branch_tokens|
|
|
1076
|
-
if cond.nil? || eval_comparison(cond, context)
|
|
1236
|
+
if cond.nil? || eval_comparison(cond, context, method(:eval_var_raw))
|
|
1077
1237
|
return [render_tokens(branch_tokens.dup, context), i]
|
|
1078
1238
|
end
|
|
1079
1239
|
end
|
|
@@ -1084,7 +1244,7 @@ module Tina4
|
|
|
1084
1244
|
# {% for item in items %}...{% else %}...{% endfor %}
|
|
1085
1245
|
def handle_for(tokens, start, context)
|
|
1086
1246
|
content, _, strip_a_open = strip_tag(tokens[start][1])
|
|
1087
|
-
m = content.match(
|
|
1247
|
+
m = content.match(FOR_RE)
|
|
1088
1248
|
return ["", start + 1] unless m
|
|
1089
1249
|
|
|
1090
1250
|
var1 = m[1]
|
|
@@ -1158,7 +1318,7 @@ module Tina4
|
|
|
1158
1318
|
total = items.length
|
|
1159
1319
|
|
|
1160
1320
|
items.each_with_index do |item, idx|
|
|
1161
|
-
loop_ctx = context
|
|
1321
|
+
loop_ctx = LoopContext.new(context)
|
|
1162
1322
|
loop_ctx["loop"] = {
|
|
1163
1323
|
"index" => idx + 1,
|
|
1164
1324
|
"index0" => idx,
|
|
@@ -1196,7 +1356,7 @@ module Tina4
|
|
|
1196
1356
|
|
|
1197
1357
|
# {% set name = expr %}
|
|
1198
1358
|
def handle_set(content, context)
|
|
1199
|
-
if content =~
|
|
1359
|
+
if content =~ SET_RE
|
|
1200
1360
|
name = Regexp.last_match(1)
|
|
1201
1361
|
expr = Regexp.last_match(2).strip
|
|
1202
1362
|
context[name] = eval_expr(expr, context)
|
|
@@ -1208,7 +1368,7 @@ module Tina4
|
|
|
1208
1368
|
ignore_missing = content.include?("ignore missing")
|
|
1209
1369
|
content = content.gsub("ignore missing", "").strip
|
|
1210
1370
|
|
|
1211
|
-
m = content.match(
|
|
1371
|
+
m = content.match(INCLUDE_RE)
|
|
1212
1372
|
return "" unless m
|
|
1213
1373
|
|
|
1214
1374
|
filename = m[1]
|
|
@@ -1233,7 +1393,7 @@ module Tina4
|
|
|
1233
1393
|
# {% macro name(args) %}...{% endmacro %}
|
|
1234
1394
|
def handle_macro(tokens, start, context)
|
|
1235
1395
|
content, _, _ = strip_tag(tokens[start][1])
|
|
1236
|
-
m = content.match(
|
|
1396
|
+
m = content.match(MACRO_RE)
|
|
1237
1397
|
unless m
|
|
1238
1398
|
i = start + 1
|
|
1239
1399
|
while i < tokens.length
|
|
@@ -1276,7 +1436,7 @@ module Tina4
|
|
|
1276
1436
|
|
|
1277
1437
|
# {% from "file" import macro1, macro2 %}
|
|
1278
1438
|
def handle_from_import(content, context)
|
|
1279
|
-
m = content.match(
|
|
1439
|
+
m = content.match(FROM_IMPORT_RE)
|
|
1280
1440
|
return unless m
|
|
1281
1441
|
|
|
1282
1442
|
filename = m[1]
|
|
@@ -1292,7 +1452,7 @@ module Tina4
|
|
|
1292
1452
|
tag_content, _, _ = strip_tag(raw)
|
|
1293
1453
|
tag = (tag_content.split[0] || "")
|
|
1294
1454
|
if tag == "macro"
|
|
1295
|
-
macro_m = tag_content.match(
|
|
1455
|
+
macro_m = tag_content.match(MACRO_RE)
|
|
1296
1456
|
if macro_m && names.include?(macro_m[1])
|
|
1297
1457
|
macro_name = macro_m[1]
|
|
1298
1458
|
param_names = macro_m[2].split(",").map(&:strip).reject(&:empty?)
|
|
@@ -1332,7 +1492,7 @@ module Tina4
|
|
|
1332
1492
|
# {% cache "key" ttl %}...{% endcache %}
|
|
1333
1493
|
def handle_cache(tokens, start, context)
|
|
1334
1494
|
content, _, _ = strip_tag(tokens[start][1])
|
|
1335
|
-
m = content.match(
|
|
1495
|
+
m = content.match(CACHE_RE)
|
|
1336
1496
|
cache_key = m ? m[1] : "default"
|
|
1337
1497
|
ttl = m && m[2] ? m[2].to_i : 60
|
|
1338
1498
|
|
|
@@ -1420,13 +1580,13 @@ module Tina4
|
|
|
1420
1580
|
end
|
|
1421
1581
|
|
|
1422
1582
|
rendered = render_tokens(body_tokens.dup, context)
|
|
1423
|
-
rendered = rendered.gsub(
|
|
1583
|
+
rendered = rendered.gsub(SPACELESS_RE, "><")
|
|
1424
1584
|
[rendered, i]
|
|
1425
1585
|
end
|
|
1426
1586
|
|
|
1427
1587
|
def handle_autoescape(tokens, start, context)
|
|
1428
1588
|
content, _, _ = strip_tag(tokens[start][1])
|
|
1429
|
-
mode_match = content.match(
|
|
1589
|
+
mode_match = content.match(AUTOESCAPE_RE)
|
|
1430
1590
|
auto_escape_on = !(mode_match && mode_match[1] == "false")
|
|
1431
1591
|
|
|
1432
1592
|
body_tokens = []
|
|
@@ -1497,7 +1657,7 @@ module Tina4
|
|
|
1497
1657
|
"ltrim" => ->(v, *_a) { v.to_s.lstrip },
|
|
1498
1658
|
"rtrim" => ->(v, *_a) { v.to_s.rstrip },
|
|
1499
1659
|
"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(
|
|
1660
|
+
"striptags" => ->(v, *_a) { v.to_s.gsub(STRIPTAGS_RE, "") },
|
|
1501
1661
|
|
|
1502
1662
|
# -- Encoding --
|
|
1503
1663
|
"escape" => ->(v, *_a) { Frond.escape_html(v.to_s) },
|
|
@@ -1555,7 +1715,7 @@ module Tina4
|
|
|
1555
1715
|
formatted = format("%.#{decimals}f", v.to_f)
|
|
1556
1716
|
# Add comma thousands separator
|
|
1557
1717
|
parts = formatted.split(".")
|
|
1558
|
-
parts[0] = parts[0].gsub(
|
|
1718
|
+
parts[0] = parts[0].gsub(THOUSANDS_RE, '\\1,')
|
|
1559
1719
|
parts.join(".")
|
|
1560
1720
|
},
|
|
1561
1721
|
|
|
@@ -1660,7 +1820,7 @@ module Tina4
|
|
|
1660
1820
|
lines << current unless current.empty?
|
|
1661
1821
|
lines.join("\n")
|
|
1662
1822
|
},
|
|
1663
|
-
"slug" => ->(v, *_a) { v.to_s.downcase.gsub(
|
|
1823
|
+
"slug" => ->(v, *_a) { v.to_s.downcase.gsub(SLUG_CLEAN_RE, "-").gsub(SLUG_TRIM_RE, "") },
|
|
1664
1824
|
"nl2br" => ->(v, *_a) { v.to_s.gsub("\n", "<br>\n") },
|
|
1665
1825
|
"format" => ->(v, *a) {
|
|
1666
1826
|
if a.any?
|
data/lib/tina4/version.rb
CHANGED
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.
|
|
4
|
+
version: 3.10.21
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Tina4 Team
|
|
8
|
-
autorequire:
|
|
9
8
|
bindir: exe
|
|
10
9
|
cert_chain: []
|
|
11
|
-
date:
|
|
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:
|
|
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: []
|