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