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 +4 -4
- data/lib/tina4/database.rb +88 -19
- data/lib/tina4/frond.rb +311 -69
- data/lib/tina4/version.rb +1 -1
- metadata +1 -1
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,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 =~
|
|
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
|
|
|
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 =~
|
|
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 =~
|
|
663
|
-
return expr.to_f if expr =~
|
|
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 =~
|
|
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 =~
|
|
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 =~
|
|
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 =~
|
|
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
|
-
#
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
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
|
-
#
|
|
701
|
-
|
|
702
|
-
if
|
|
703
|
-
|
|
704
|
-
|
|
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 =~
|
|
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 =~
|
|
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(
|
|
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(
|
|
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 =~
|
|
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 =~
|
|
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 =~
|
|
825
|
-
val =
|
|
826
|
-
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)
|
|
827
1044
|
return !(collection.respond_to?(:include?) && collection.include?(val))
|
|
828
1045
|
end
|
|
829
1046
|
|
|
830
1047
|
# 'in'
|
|
831
|
-
if expr =~
|
|
832
|
-
val =
|
|
833
|
-
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)
|
|
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 =
|
|
847
|
-
r =
|
|
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 =
|
|
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
|
-
|
|
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 =~
|
|
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
|
|
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(
|
|
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 =~
|
|
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(
|
|
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
|
|
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 =~
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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