tina4ruby 0.5.2 → 3.0.0

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.
Files changed (90) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +1 -1
  3. data/README.md +360 -559
  4. data/exe/{tina4 → tina4ruby} +1 -0
  5. data/lib/tina4/ai.rb +312 -0
  6. data/lib/tina4/auth.rb +44 -3
  7. data/lib/tina4/auto_crud.rb +163 -0
  8. data/lib/tina4/cli.rb +242 -77
  9. data/lib/tina4/constants.rb +46 -0
  10. data/lib/tina4/cors.rb +74 -0
  11. data/lib/tina4/database/sqlite3_adapter.rb +139 -0
  12. data/lib/tina4/database.rb +43 -7
  13. data/lib/tina4/debug.rb +4 -79
  14. data/lib/tina4/dev_admin.rb +1162 -0
  15. data/lib/tina4/dev_mailbox.rb +191 -0
  16. data/lib/tina4/dev_reload.rb +9 -9
  17. data/lib/tina4/drivers/firebird_driver.rb +19 -3
  18. data/lib/tina4/drivers/mssql_driver.rb +3 -3
  19. data/lib/tina4/drivers/mysql_driver.rb +4 -4
  20. data/lib/tina4/drivers/postgres_driver.rb +9 -2
  21. data/lib/tina4/drivers/sqlite_driver.rb +1 -1
  22. data/lib/tina4/env.rb +42 -2
  23. data/lib/tina4/error_overlay.rb +252 -0
  24. data/lib/tina4/events.rb +90 -0
  25. data/lib/tina4/field_types.rb +4 -0
  26. data/lib/tina4/frond.rb +1336 -0
  27. data/lib/tina4/gallery/auth/meta.json +1 -0
  28. data/lib/tina4/gallery/auth/src/routes/api/gallery_auth.rb +114 -0
  29. data/lib/tina4/gallery/database/meta.json +1 -0
  30. data/lib/tina4/gallery/database/src/routes/api/gallery_db.rb +43 -0
  31. data/lib/tina4/gallery/error-overlay/meta.json +1 -0
  32. data/lib/tina4/gallery/error-overlay/src/routes/api/gallery_crash.rb +17 -0
  33. data/lib/tina4/gallery/orm/meta.json +1 -0
  34. data/lib/tina4/gallery/orm/src/routes/api/gallery_products.rb +16 -0
  35. data/lib/tina4/gallery/queue/meta.json +1 -0
  36. data/lib/tina4/gallery/queue/src/routes/api/gallery_queue.rb +27 -0
  37. data/lib/tina4/gallery/rest-api/meta.json +1 -0
  38. data/lib/tina4/gallery/rest-api/src/routes/api/gallery_hello.rb +14 -0
  39. data/lib/tina4/gallery/templates/meta.json +1 -0
  40. data/lib/tina4/gallery/templates/src/routes/gallery_page.rb +12 -0
  41. data/lib/tina4/gallery/templates/src/templates/gallery_page.twig +257 -0
  42. data/lib/tina4/health.rb +39 -0
  43. data/lib/tina4/html_element.rb +148 -0
  44. data/lib/tina4/localization.rb +2 -2
  45. data/lib/tina4/log.rb +203 -0
  46. data/lib/tina4/messenger.rb +484 -0
  47. data/lib/tina4/migration.rb +132 -29
  48. data/lib/tina4/orm.rb +337 -31
  49. data/lib/tina4/public/css/tina4.css +178 -1
  50. data/lib/tina4/public/css/tina4.min.css +1 -2
  51. data/lib/tina4/public/favicon.ico +0 -0
  52. data/lib/tina4/public/images/logo.svg +5 -0
  53. data/lib/tina4/public/images/tina4-logo-icon.webp +0 -0
  54. data/lib/tina4/public/js/frond.min.js +420 -0
  55. data/lib/tina4/public/js/tina4-dev-admin.min.js +367 -0
  56. data/lib/tina4/public/js/tina4.min.js +93 -0
  57. data/lib/tina4/public/swagger/index.html +90 -0
  58. data/lib/tina4/public/swagger/oauth2-redirect.html +63 -0
  59. data/lib/tina4/queue.rb +40 -4
  60. data/lib/tina4/queue_backends/lite_backend.rb +88 -0
  61. data/lib/tina4/rack_app.rb +314 -23
  62. data/lib/tina4/rate_limiter.rb +123 -0
  63. data/lib/tina4/request.rb +61 -15
  64. data/lib/tina4/response.rb +54 -24
  65. data/lib/tina4/response_cache.rb +134 -0
  66. data/lib/tina4/router.rb +90 -15
  67. data/lib/tina4/scss_compiler.rb +2 -2
  68. data/lib/tina4/seeder.rb +56 -61
  69. data/lib/tina4/service_runner.rb +303 -0
  70. data/lib/tina4/session.rb +85 -0
  71. data/lib/tina4/session_handlers/mongo_handler.rb +1 -1
  72. data/lib/tina4/session_handlers/valkey_handler.rb +43 -0
  73. data/lib/tina4/shutdown.rb +84 -0
  74. data/lib/tina4/sql_translation.rb +295 -0
  75. data/lib/tina4/template.rb +36 -6
  76. data/lib/tina4/templates/base.twig +2 -2
  77. data/lib/tina4/templates/errors/302.twig +14 -0
  78. data/lib/tina4/templates/errors/401.twig +9 -0
  79. data/lib/tina4/templates/errors/403.twig +22 -15
  80. data/lib/tina4/templates/errors/404.twig +22 -15
  81. data/lib/tina4/templates/errors/500.twig +31 -15
  82. data/lib/tina4/templates/errors/502.twig +9 -0
  83. data/lib/tina4/templates/errors/503.twig +12 -0
  84. data/lib/tina4/templates/errors/base.twig +37 -0
  85. data/lib/tina4/version.rb +1 -1
  86. data/lib/tina4/webserver.rb +28 -18
  87. data/lib/tina4.rb +57 -21
  88. metadata +51 -19
  89. data/lib/tina4/public/js/tina4.js +0 -134
  90. data/lib/tina4/public/js/tina4helper.js +0 -387
@@ -0,0 +1,1336 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Tina4 Frond Engine -- Lexer, parser, and runtime.
4
+ # Zero-dependency twig-like template engine.
5
+ # Supports: variables, filters, if/elseif/else/endif, for/else/endfor,
6
+ # extends/block, include, macro, set, comments, whitespace control, tests,
7
+ # fragment caching, sandboxing, auto-escaping, custom filters/tests/globals.
8
+
9
+ require "json"
10
+ require "digest"
11
+ require "base64"
12
+ require "cgi"
13
+ require "uri"
14
+ require "date"
15
+ require "time"
16
+
17
+ module Tina4
18
+ # Marker class for strings that should not be auto-escaped in Frond.
19
+ class SafeString < String
20
+ end
21
+
22
+ class Frond
23
+ # -- Token types ----------------------------------------------------------
24
+ TEXT = :text
25
+ VAR = :var # {{ ... }}
26
+ BLOCK = :block # {% ... %}
27
+ COMMENT = :comment # {# ... #}
28
+
29
+ # Regex to split template source into tokens
30
+ TOKEN_RE = /(\{%-?\s*.*?\s*-?%\})|(\{\{-?\s*.*?\s*-?\}\})|(\{#.*?#\})/m
31
+
32
+ # HTML escape table
33
+ HTML_ESCAPE_MAP = { "&" => "&amp;", "<" => "&lt;", ">" => "&gt;",
34
+ '"' => "&quot;", "'" => "&#39;" }.freeze
35
+ HTML_ESCAPE_RE = /[&<>"']/
36
+
37
+ # -----------------------------------------------------------------------
38
+ # Public API
39
+ # -----------------------------------------------------------------------
40
+
41
+ attr_reader :template_dir
42
+
43
+ def initialize(template_dir: "src/templates")
44
+ @template_dir = template_dir
45
+ @filters = default_filters
46
+ @globals = {}
47
+ @tests = default_tests
48
+ @auto_escape = true
49
+
50
+ # Sandboxing
51
+ @sandbox = false
52
+ @allowed_filters = nil
53
+ @allowed_tags = nil
54
+ @allowed_vars = nil
55
+
56
+ # Fragment cache: key => [html, expires_at]
57
+ @fragment_cache = {}
58
+
59
+ # Built-in global functions
60
+ register_builtin_globals
61
+ end
62
+
63
+ # Render a template file with data.
64
+ def render(template, data = {})
65
+ context = @globals.merge(stringify_keys(data))
66
+ source = load_template(template)
67
+ execute(source, context)
68
+ end
69
+
70
+ # Render a template string directly.
71
+ def render_string(source, data = {})
72
+ context = @globals.merge(stringify_keys(data))
73
+ execute(source, context)
74
+ end
75
+
76
+ # Register a custom filter.
77
+ def add_filter(name, &blk)
78
+ @filters[name.to_s] = blk
79
+ end
80
+
81
+ # Register a custom test.
82
+ def add_test(name, &blk)
83
+ @tests[name.to_s] = blk
84
+ end
85
+
86
+ # Register a global variable available in all templates.
87
+ def add_global(name, value)
88
+ @globals[name.to_s] = value
89
+ end
90
+
91
+ # Enable sandbox mode.
92
+ def sandbox(filters: nil, tags: nil, vars: nil)
93
+ @sandbox = true
94
+ @allowed_filters = filters ? filters.map(&:to_s) : nil
95
+ @allowed_tags = tags ? tags.map(&:to_s) : nil
96
+ @allowed_vars = vars ? vars.map(&:to_s) : nil
97
+ self
98
+ end
99
+
100
+ # Disable sandbox mode.
101
+ def unsandbox
102
+ @sandbox = false
103
+ @allowed_filters = nil
104
+ @allowed_tags = nil
105
+ @allowed_vars = nil
106
+ self
107
+ end
108
+
109
+ # Utility: HTML escape
110
+ def self.escape_html(str)
111
+ str.to_s.gsub(HTML_ESCAPE_RE, HTML_ESCAPE_MAP)
112
+ end
113
+
114
+ private
115
+
116
+ # -----------------------------------------------------------------------
117
+ # Tokenizer
118
+ # -----------------------------------------------------------------------
119
+
120
+ # Regex to extract {% raw %}...{% endraw %} blocks before tokenizing
121
+ RAW_BLOCK_RE = /\{%-?\s*raw\s*-?%\}(.*?)\{%-?\s*endraw\s*-?%\}/m
122
+
123
+ def tokenize(source)
124
+ # 1. Extract raw blocks and replace with placeholders
125
+ raw_blocks = []
126
+ source = source.gsub(RAW_BLOCK_RE) do
127
+ idx = raw_blocks.length
128
+ raw_blocks << Regexp.last_match(1)
129
+ "\x00RAW_#{idx}\x00"
130
+ end
131
+
132
+ # 2. Normal tokenization
133
+ tokens = []
134
+ pos = 0
135
+ source.scan(TOKEN_RE) do
136
+ m = Regexp.last_match
137
+ start = m.begin(0)
138
+ tokens << [TEXT, source[pos...start]] if start > pos
139
+
140
+ raw = m[0]
141
+ if raw.start_with?("{#")
142
+ tokens << [COMMENT, raw]
143
+ elsif raw.start_with?("{{")
144
+ tokens << [VAR, raw]
145
+ elsif raw.start_with?("{%")
146
+ tokens << [BLOCK, raw]
147
+ end
148
+ pos = m.end(0)
149
+ end
150
+ tokens << [TEXT, source[pos..]] if pos < source.length
151
+
152
+ # 3. Restore raw block placeholders as literal TEXT
153
+ unless raw_blocks.empty?
154
+ tokens = tokens.map do |ttype, value|
155
+ if ttype == TEXT && value.include?("\x00RAW_")
156
+ raw_blocks.each_with_index do |content, idx|
157
+ value = value.gsub("\x00RAW_#{idx}\x00", content)
158
+ end
159
+ end
160
+ [ttype, value]
161
+ end
162
+ end
163
+
164
+ tokens
165
+ end
166
+
167
+ # Strip delimiters from a tag and detect whitespace control markers.
168
+ # Returns [content, strip_before, strip_after].
169
+ def strip_tag(raw)
170
+ inner = raw[2..-3] # remove {{ }} or {% %} or {# #}
171
+ strip_before = false
172
+ strip_after = false
173
+
174
+ if inner.start_with?("-")
175
+ strip_before = true
176
+ inner = inner[1..]
177
+ end
178
+ if inner.end_with?("-")
179
+ strip_after = true
180
+ inner = inner[0..-2]
181
+ end
182
+
183
+ [inner.strip, strip_before, strip_after]
184
+ end
185
+
186
+ # -----------------------------------------------------------------------
187
+ # Template loading
188
+ # -----------------------------------------------------------------------
189
+
190
+ def load_template(name)
191
+ path = File.join(@template_dir, name)
192
+ raise "Template not found: #{path}" unless File.exist?(path)
193
+
194
+ File.read(path, encoding: "utf-8")
195
+ end
196
+
197
+ # -----------------------------------------------------------------------
198
+ # Execution
199
+ # -----------------------------------------------------------------------
200
+
201
+ def execute(source, context)
202
+ # Handle extends first
203
+ if source =~ /\{%-?\s*extends\s+["'](.+?)["']\s*-?%\}/
204
+ parent_name = Regexp.last_match(1)
205
+ parent_source = load_template(parent_name)
206
+ child_blocks = extract_blocks(source)
207
+ return render_with_blocks(parent_source, context, child_blocks)
208
+ end
209
+
210
+ render_tokens(tokenize(source), context)
211
+ end
212
+
213
+ def extract_blocks(source)
214
+ blocks = {}
215
+ source.scan(/\{%-?\s*block\s+(\w+)\s*-?%\}(.*?)\{%-?\s*endblock\s*-?%\}/m) do
216
+ blocks[Regexp.last_match(1)] = Regexp.last_match(2)
217
+ end
218
+ blocks
219
+ end
220
+
221
+ def render_with_blocks(parent_source, context, child_blocks)
222
+ result = parent_source.gsub(/\{%-?\s*block\s+(\w+)\s*-?%\}(.*?)\{%-?\s*endblock\s*-?%\}/m) do
223
+ name = Regexp.last_match(1)
224
+ default_content = Regexp.last_match(2)
225
+ block_source = child_blocks.fetch(name, default_content)
226
+ render_tokens(tokenize(block_source), context)
227
+ end
228
+ render_tokens(tokenize(result), context)
229
+ end
230
+
231
+ # -----------------------------------------------------------------------
232
+ # Token renderer
233
+ # -----------------------------------------------------------------------
234
+
235
+ def render_tokens(tokens, context)
236
+ output = []
237
+ i = 0
238
+
239
+ while i < tokens.length
240
+ ttype, raw = tokens[i]
241
+
242
+ case ttype
243
+ when TEXT
244
+ output << raw
245
+ i += 1
246
+
247
+ when COMMENT
248
+ i += 1
249
+
250
+ when VAR
251
+ content, strip_b, strip_a = strip_tag(raw)
252
+ output[-1] = output[-1].rstrip if strip_b && !output.empty?
253
+
254
+ result = eval_var(content, context)
255
+ output << (result.nil? ? "" : result.to_s)
256
+
257
+ if strip_a && i + 1 < tokens.length && tokens[i + 1][0] == TEXT
258
+ tokens[i + 1] = [TEXT, tokens[i + 1][1].lstrip]
259
+ end
260
+ i += 1
261
+
262
+ when BLOCK
263
+ content, strip_b, strip_a = strip_tag(raw)
264
+ output[-1] = output[-1].rstrip if strip_b && !output.empty?
265
+
266
+ tag = content.split[0] || ""
267
+
268
+ case tag
269
+ when "if"
270
+ result, i = handle_if(tokens, i, context)
271
+ output << result
272
+ when "for"
273
+ result, i = handle_for(tokens, i, context)
274
+ output << result
275
+ when "set"
276
+ handle_set(content, context)
277
+ i += 1
278
+ when "include"
279
+ if @sandbox && @allowed_tags && !@allowed_tags.include?("include")
280
+ i += 1
281
+ else
282
+ output << handle_include(content, context)
283
+ i += 1
284
+ end
285
+ when "macro"
286
+ i = handle_macro(tokens, i, context)
287
+ when "from"
288
+ handle_from_import(content, context)
289
+ i += 1
290
+ when "cache"
291
+ result, i = handle_cache(tokens, i, context)
292
+ output << result
293
+ when "block", "endblock", "extends"
294
+ i += 1
295
+ else
296
+ i += 1
297
+ end
298
+
299
+ if strip_a && i < tokens.length && tokens[i][0] == TEXT
300
+ tokens[i] = [TEXT, tokens[i][1].lstrip]
301
+ end
302
+ else
303
+ i += 1
304
+ end
305
+ end
306
+
307
+ output.join
308
+ end
309
+
310
+ # -----------------------------------------------------------------------
311
+ # Variable evaluation
312
+ # -----------------------------------------------------------------------
313
+
314
+ def eval_var(expr, context)
315
+ var_name, filters = parse_filter_chain(expr)
316
+
317
+ # Sandbox: check variable access
318
+ if @sandbox && @allowed_vars
319
+ root_var = var_name.split(".")[0].split("[")[0].strip
320
+ return "" if !root_var.empty? && !@allowed_vars.include?(root_var) && root_var != "loop"
321
+ end
322
+
323
+ value = eval_expr(var_name, context)
324
+
325
+ is_safe = false
326
+ filters.each do |fname, args|
327
+ if fname == "raw" || fname == "safe"
328
+ is_safe = true
329
+ next
330
+ end
331
+
332
+ # Sandbox: check filter access
333
+ if @sandbox && @allowed_filters && !@allowed_filters.include?(fname)
334
+ next
335
+ end
336
+
337
+ fn = @filters[fname]
338
+ if fn
339
+ evaluated_args = args.map { |a| eval_filter_arg(a, context) }
340
+ value = fn.call(value, *evaluated_args)
341
+ end
342
+ end
343
+
344
+ # Auto-escape HTML unless marked safe or SafeString
345
+ if @auto_escape && !is_safe && value.is_a?(String) && !value.is_a?(SafeString)
346
+ value = Frond.escape_html(value)
347
+ end
348
+
349
+ value
350
+ end
351
+
352
+ def eval_filter_arg(arg, context)
353
+ return Regexp.last_match(1) if arg =~ /\A["'](.*)["']\z/
354
+ return arg.to_i if arg =~ /\A-?\d+\z/
355
+ return arg.to_f if arg =~ /\A-?\d+\.\d+\z/
356
+ eval_expr(arg, context)
357
+ end
358
+
359
+ # -----------------------------------------------------------------------
360
+ # Filter chain parser
361
+ # -----------------------------------------------------------------------
362
+
363
+ def parse_filter_chain(expr)
364
+ parts = split_on_pipe(expr)
365
+ variable = parts[0].strip
366
+ filters = []
367
+
368
+ parts[1..].each do |f|
369
+ f = f.strip
370
+ if f =~ /\A(\w+)\s*\((.*)\)\z/m
371
+ name = Regexp.last_match(1)
372
+ raw_args = Regexp.last_match(2).strip
373
+ args = raw_args.empty? ? [] : parse_args(raw_args)
374
+ filters << [name, args]
375
+ else
376
+ filters << [f.strip, []]
377
+ end
378
+ end
379
+
380
+ [variable, filters]
381
+ end
382
+
383
+ # Split expression on | but not inside quotes or parens.
384
+ def split_on_pipe(expr)
385
+ parts = []
386
+ current = +""
387
+ in_quote = nil
388
+ depth = 0
389
+
390
+ expr.each_char do |ch|
391
+ if in_quote
392
+ current << ch
393
+ in_quote = nil if ch == in_quote
394
+ elsif ch == '"' || ch == "'"
395
+ in_quote = ch
396
+ current << ch
397
+ elsif ch == "("
398
+ depth += 1
399
+ current << ch
400
+ elsif ch == ")"
401
+ depth -= 1
402
+ current << ch
403
+ elsif ch == "|" && depth == 0
404
+ parts << current
405
+ current = +""
406
+ else
407
+ current << ch
408
+ end
409
+ end
410
+ parts << current unless current.empty?
411
+ parts
412
+ end
413
+
414
+ def parse_args(raw)
415
+ args = []
416
+ current = +""
417
+ in_quote = nil
418
+ depth = 0
419
+
420
+ raw.each_char do |ch|
421
+ if in_quote
422
+ if ch == in_quote
423
+ in_quote = nil
424
+ end
425
+ current << ch
426
+ elsif ch == '"' || ch == "'"
427
+ in_quote = ch
428
+ current << ch
429
+ elsif ch == "("
430
+ depth += 1
431
+ current << ch
432
+ elsif ch == ")"
433
+ depth -= 1
434
+ current << ch
435
+ elsif ch == "," && depth == 0
436
+ args << current.strip
437
+ current = +""
438
+ else
439
+ current << ch
440
+ end
441
+ end
442
+ args << current.strip unless current.strip.empty?
443
+ args
444
+ end
445
+
446
+ # -----------------------------------------------------------------------
447
+ # Expression evaluator
448
+ # -----------------------------------------------------------------------
449
+
450
+ def eval_expr(expr, context)
451
+ expr = expr.strip
452
+ return nil if expr.empty?
453
+
454
+ # String literal
455
+ if (expr.start_with?('"') && expr.end_with?('"')) ||
456
+ (expr.start_with?("'") && expr.end_with?("'"))
457
+ return expr[1..-2]
458
+ end
459
+
460
+ # Numeric
461
+ return expr.to_i if expr =~ /\A-?\d+\z/
462
+ return expr.to_f if expr =~ /\A-?\d+\.\d+\z/
463
+
464
+ # Boolean/null
465
+ return true if expr == "true"
466
+ return false if expr == "false"
467
+ return nil if expr == "null" || expr == "none" || expr == "nil"
468
+
469
+ # Array literal [a, b, c]
470
+ if expr =~ /\A\[(.+)\]\z/m
471
+ inner = Regexp.last_match(1)
472
+ return split_args_toplevel(inner).map { |item| eval_expr(item.strip, context) }
473
+ end
474
+
475
+ # Hash literal { key: value, ... }
476
+ if expr =~ /\A\{(.+)\}\z/m
477
+ inner = Regexp.last_match(1)
478
+ hash = {}
479
+ split_args_toplevel(inner).each do |pair|
480
+ if pair =~ /\A\s*["']?(\w+)["']?\s*:\s*(.+)\z/
481
+ hash[Regexp.last_match(1)] = eval_expr(Regexp.last_match(2).strip, context)
482
+ end
483
+ end
484
+ return hash
485
+ end
486
+
487
+ # Range literal: 1..5
488
+ if expr =~ /\A(\d+)\.\.(\d+)\z/
489
+ return (Regexp.last_match(1).to_i..Regexp.last_match(2).to_i).to_a
490
+ end
491
+
492
+ # Ternary: condition ? "yes" : "no"
493
+ ternary = expr.match(/\A(.+?)\s*\?\s*(.+?)\s*:\s*(.+)\z/)
494
+ if ternary
495
+ cond = eval_expr(ternary[1], context)
496
+ return truthy?(cond) ? eval_expr(ternary[2], context) : eval_expr(ternary[3], context)
497
+ end
498
+
499
+ # Null coalescing: value ?? "default"
500
+ if expr.include?("??")
501
+ left, _, right = expr.partition("??")
502
+ val = eval_expr(left.strip, context)
503
+ return val.nil? ? eval_expr(right.strip, context) : val
504
+ end
505
+
506
+ # String concatenation with ~
507
+ if expr.include?("~")
508
+ parts = expr.split("~")
509
+ return parts.map { |p| (eval_expr(p.strip, context) || "").to_s }.join
510
+ end
511
+
512
+ # Check for comparison / logical operators -- delegate
513
+ if has_comparison?(expr)
514
+ return eval_comparison(expr, context)
515
+ end
516
+
517
+ # Arithmetic: +, -, *, /, %
518
+ if expr =~ /\A(.+?)\s*(\+|-|\*|\/|%)\s*(.+)\z/
519
+ left = eval_expr(Regexp.last_match(1), context)
520
+ op = Regexp.last_match(2)
521
+ right = eval_expr(Regexp.last_match(3), context)
522
+ return apply_math(left, op, right)
523
+ end
524
+
525
+ # Function call: name(arg1, arg2)
526
+ if expr =~ /\A(\w+)\s*\((.*)\)\z/m
527
+ fn_name = Regexp.last_match(1)
528
+ raw_args = Regexp.last_match(2).strip
529
+ fn = context[fn_name]
530
+ if fn.respond_to?(:call)
531
+ if raw_args.empty?
532
+ return fn.call
533
+ else
534
+ args = split_args_toplevel(raw_args).map { |a| eval_expr(a.strip, context) }
535
+ return fn.call(*args)
536
+ end
537
+ end
538
+ end
539
+
540
+ resolve(expr, context)
541
+ end
542
+
543
+ def has_comparison?(expr)
544
+ [" not in ", " in ", " is not ", " is ", "!=", "==", ">=", "<=", ">", "<",
545
+ " and ", " or ", " not "].any? { |op| expr.include?(op) }
546
+ end
547
+
548
+ # Split comma-separated args at top level (not inside quotes/parens/brackets).
549
+ def split_args_toplevel(str)
550
+ parts = []
551
+ current = +""
552
+ in_quote = nil
553
+ depth = 0
554
+
555
+ str.each_char do |ch|
556
+ if in_quote
557
+ current << ch
558
+ in_quote = nil if ch == in_quote
559
+ elsif ch == '"' || ch == "'"
560
+ in_quote = ch
561
+ current << ch
562
+ elsif ch == "(" || ch == "[" || ch == "{"
563
+ depth += 1
564
+ current << ch
565
+ elsif ch == ")" || ch == "]" || ch == "}"
566
+ depth -= 1
567
+ current << ch
568
+ elsif ch == "," && depth == 0
569
+ parts << current.strip
570
+ current = +""
571
+ else
572
+ current << ch
573
+ end
574
+ end
575
+ parts << current.strip unless current.strip.empty?
576
+ parts
577
+ end
578
+
579
+ # -----------------------------------------------------------------------
580
+ # Comparison / logical evaluator
581
+ # -----------------------------------------------------------------------
582
+
583
+ def eval_comparison(expr, context)
584
+ expr = expr.strip
585
+
586
+ # Handle 'not' prefix
587
+ if expr.start_with?("not ")
588
+ return !eval_comparison(expr[4..], context)
589
+ end
590
+
591
+ # 'or' (lowest precedence)
592
+ or_parts = expr.split(/\s+or\s+/)
593
+ if or_parts.length > 1
594
+ return or_parts.any? { |p| eval_comparison(p, context) }
595
+ end
596
+
597
+ # 'and'
598
+ and_parts = expr.split(/\s+and\s+/)
599
+ if and_parts.length > 1
600
+ return and_parts.all? { |p| eval_comparison(p, context) }
601
+ end
602
+
603
+ # 'is not' test
604
+ if expr =~ /\A(.+?)\s+is\s+not\s+(\w+)(.*)\z/
605
+ return !eval_test(Regexp.last_match(1).strip, Regexp.last_match(2),
606
+ Regexp.last_match(3).strip, context)
607
+ end
608
+
609
+ # 'is' test
610
+ if expr =~ /\A(.+?)\s+is\s+(\w+)(.*)\z/
611
+ return eval_test(Regexp.last_match(1).strip, Regexp.last_match(2),
612
+ Regexp.last_match(3).strip, context)
613
+ end
614
+
615
+ # 'not in'
616
+ if expr =~ /\A(.+?)\s+not\s+in\s+(.+)\z/
617
+ val = eval_expr(Regexp.last_match(1).strip, context)
618
+ collection = eval_expr(Regexp.last_match(2).strip, context)
619
+ return !(collection.respond_to?(:include?) && collection.include?(val))
620
+ end
621
+
622
+ # 'in'
623
+ if expr =~ /\A(.+?)\s+in\s+(.+)\z/
624
+ val = eval_expr(Regexp.last_match(1).strip, context)
625
+ collection = eval_expr(Regexp.last_match(2).strip, context)
626
+ return collection.respond_to?(:include?) ? collection.include?(val) : false
627
+ end
628
+
629
+ # Binary comparison operators
630
+ [["!=", ->(a, b) { a != b }],
631
+ ["==", ->(a, b) { a == b }],
632
+ [">=", ->(a, b) { a.to_f >= b.to_f }],
633
+ ["<=", ->(a, b) { a.to_f <= b.to_f }],
634
+ [">", ->(a, b) { a.to_f > b.to_f }],
635
+ ["<", ->(a, b) { a.to_f < b.to_f }]].each do |op, fn|
636
+ if expr.include?(op)
637
+ left, _, right = expr.partition(op)
638
+ l = eval_expr(left.strip, context)
639
+ r = eval_expr(right.strip, context)
640
+ begin
641
+ return fn.call(l, r)
642
+ rescue
643
+ return false
644
+ end
645
+ end
646
+ end
647
+
648
+ # Fall through to simple eval
649
+ val = eval_expr(expr, context)
650
+ truthy?(val)
651
+ end
652
+
653
+ # -----------------------------------------------------------------------
654
+ # Tests ('is' expressions)
655
+ # -----------------------------------------------------------------------
656
+
657
+ def eval_test(value_expr, test_name, args_str, context)
658
+ val = eval_expr(value_expr, context)
659
+
660
+ # 'divisible by(n)'
661
+ if test_name == "divisible"
662
+ if args_str =~ /\s*by\s*\(\s*(\d+)\s*\)/
663
+ n = Regexp.last_match(1).to_i
664
+ return val.is_a?(Integer) && (val % n).zero?
665
+ end
666
+ return false
667
+ end
668
+
669
+ # Check custom tests first
670
+ custom = @tests[test_name]
671
+ return custom.call(val) if custom
672
+
673
+ false
674
+ end
675
+
676
+ def default_tests
677
+ {
678
+ "defined" => ->(v) { !v.nil? },
679
+ "empty" => ->(v) { v.nil? || (v.respond_to?(:empty?) && v.empty?) || v == 0 || v == false },
680
+ "null" => ->(v) { v.nil? },
681
+ "none" => ->(v) { v.nil? },
682
+ "even" => ->(v) { v.is_a?(Integer) && v.even? },
683
+ "odd" => ->(v) { v.is_a?(Integer) && v.odd? },
684
+ "iterable" => ->(v) { v.respond_to?(:each) && !v.is_a?(String) },
685
+ "string" => ->(v) { v.is_a?(String) },
686
+ "number" => ->(v) { v.is_a?(Numeric) },
687
+ "boolean" => ->(v) { v.is_a?(TrueClass) || v.is_a?(FalseClass) },
688
+ }
689
+ end
690
+
691
+ # -----------------------------------------------------------------------
692
+ # Variable resolver
693
+ # -----------------------------------------------------------------------
694
+
695
+ def resolve(expr, context)
696
+ parts = expr.split(/\.|\[([^\]]+)\]/).reject(&:empty?)
697
+ value = context
698
+
699
+ parts.each do |part|
700
+ part = part.strip.gsub(/\A["']|["']\z/, "") # strip quotes from bracket access
701
+ if value.is_a?(Hash)
702
+ value = value[part] || value[part.to_sym]
703
+ elsif value.is_a?(Array) && part =~ /\A\d+\z/
704
+ value = value[part.to_i]
705
+ elsif value.respond_to?(part.to_sym)
706
+ value = value.send(part.to_sym)
707
+ else
708
+ return nil
709
+ end
710
+ return nil if value.nil?
711
+ end
712
+
713
+ value
714
+ end
715
+
716
+ # -----------------------------------------------------------------------
717
+ # Math
718
+ # -----------------------------------------------------------------------
719
+
720
+ def apply_math(left, op, right)
721
+ l = left.to_f
722
+ r = right.to_f
723
+ result = case op
724
+ when "+" then l + r
725
+ when "-" then l - r
726
+ when "*" then l * r
727
+ when "/" then r != 0 ? l / r : 0
728
+ when "%" then l % r
729
+ else 0
730
+ end
731
+ result == result.to_i ? result.to_i : result
732
+ end
733
+
734
+ # -----------------------------------------------------------------------
735
+ # Block handlers
736
+ # -----------------------------------------------------------------------
737
+
738
+ # {% if %}...{% elseif %}...{% else %}...{% endif %}
739
+ def handle_if(tokens, start, context)
740
+ content, _, strip_a_open = strip_tag(tokens[start][1])
741
+ condition_expr = content.sub(/\Aif\s+/, "").strip
742
+
743
+ branches = []
744
+ current_tokens = []
745
+ current_cond = condition_expr
746
+ depth = 0
747
+ i = start + 1
748
+
749
+ # If the opening {%- if -%} has strip_after, lstrip the first body text
750
+ pending_lstrip = strip_a_open
751
+
752
+ while i < tokens.length
753
+ ttype, raw = tokens[i]
754
+ if ttype == BLOCK
755
+ tag_content, strip_b_tag, strip_a_tag = strip_tag(raw)
756
+ tag = tag_content.split[0] || ""
757
+
758
+ if tag == "if"
759
+ depth += 1
760
+ current_tokens << tokens[i]
761
+ elsif tag == "endif" && depth > 0
762
+ depth -= 1
763
+ current_tokens << tokens[i]
764
+ elsif tag == "endif" && depth == 0
765
+ # Apply strip_before from endif to last body token
766
+ if strip_b_tag && !current_tokens.empty? && current_tokens[-1][0] == TEXT
767
+ current_tokens[-1] = [TEXT, current_tokens[-1][1].rstrip]
768
+ end
769
+ branches << [current_cond, current_tokens]
770
+ i += 1
771
+ break
772
+ elsif (tag == "elseif" || tag == "elif") && depth == 0
773
+ # Apply strip_before from elseif to last body token
774
+ if strip_b_tag && !current_tokens.empty? && current_tokens[-1][0] == TEXT
775
+ current_tokens[-1] = [TEXT, current_tokens[-1][1].rstrip]
776
+ end
777
+ branches << [current_cond, current_tokens]
778
+ current_cond = tag_content.sub(/\A(?:elseif|elif)\s+/, "").strip
779
+ current_tokens = []
780
+ pending_lstrip = strip_a_tag
781
+ elsif tag == "else" && depth == 0
782
+ # Apply strip_before from else to last body token
783
+ if strip_b_tag && !current_tokens.empty? && current_tokens[-1][0] == TEXT
784
+ current_tokens[-1] = [TEXT, current_tokens[-1][1].rstrip]
785
+ end
786
+ branches << [current_cond, current_tokens]
787
+ current_cond = nil
788
+ current_tokens = []
789
+ pending_lstrip = strip_a_tag
790
+ else
791
+ current_tokens << tokens[i]
792
+ end
793
+ else
794
+ tok = tokens[i]
795
+ if pending_lstrip && ttype == TEXT
796
+ tok = [TEXT, tok[1].lstrip]
797
+ pending_lstrip = false
798
+ end
799
+ current_tokens << tok
800
+ end
801
+ i += 1
802
+ end
803
+
804
+ branches.each do |cond, branch_tokens|
805
+ if cond.nil? || eval_comparison(cond, context)
806
+ return [render_tokens(branch_tokens.dup, context), i]
807
+ end
808
+ end
809
+
810
+ ["", i]
811
+ end
812
+
813
+ # {% for item in items %}...{% else %}...{% endfor %}
814
+ def handle_for(tokens, start, context)
815
+ content, _, strip_a_open = strip_tag(tokens[start][1])
816
+ m = content.match(/\Afor\s+(\w+)(?:\s*,\s*(\w+))?\s+in\s+(.+)\z/)
817
+ return ["", start + 1] unless m
818
+
819
+ var1 = m[1]
820
+ var2 = m[2]
821
+ iterable_expr = m[3].strip
822
+
823
+ body_tokens = []
824
+ else_tokens = []
825
+ in_else = false
826
+ for_depth = 0
827
+ if_depth = 0
828
+ i = start + 1
829
+ pending_lstrip = strip_a_open
830
+
831
+ while i < tokens.length
832
+ ttype, raw = tokens[i]
833
+ if ttype == BLOCK
834
+ tag_content, strip_b_tag, strip_a_tag = strip_tag(raw)
835
+ tag = tag_content.split[0] || ""
836
+
837
+ if tag == "for"
838
+ for_depth += 1
839
+ (in_else ? else_tokens : body_tokens) << tokens[i]
840
+ elsif tag == "endfor" && for_depth > 0
841
+ for_depth -= 1
842
+ (in_else ? else_tokens : body_tokens) << tokens[i]
843
+ elsif tag == "endfor" && for_depth == 0
844
+ target = in_else ? else_tokens : body_tokens
845
+ if strip_b_tag && !target.empty? && target[-1][0] == TEXT
846
+ target[-1] = [TEXT, target[-1][1].rstrip]
847
+ end
848
+ i += 1
849
+ break
850
+ elsif tag == "if"
851
+ if_depth += 1
852
+ (in_else ? else_tokens : body_tokens) << tokens[i]
853
+ elsif tag == "endif"
854
+ if_depth -= 1
855
+ (in_else ? else_tokens : body_tokens) << tokens[i]
856
+ elsif tag == "else" && for_depth == 0 && if_depth == 0
857
+ if strip_b_tag && !body_tokens.empty? && body_tokens[-1][0] == TEXT
858
+ body_tokens[-1] = [TEXT, body_tokens[-1][1].rstrip]
859
+ end
860
+ in_else = true
861
+ pending_lstrip = strip_a_tag
862
+ else
863
+ (in_else ? else_tokens : body_tokens) << tokens[i]
864
+ end
865
+ else
866
+ tok = tokens[i]
867
+ if pending_lstrip && ttype == TEXT
868
+ tok = [TEXT, tok[1].lstrip]
869
+ pending_lstrip = false
870
+ end
871
+ (in_else ? else_tokens : body_tokens) << tok
872
+ end
873
+ i += 1
874
+ end
875
+
876
+ iterable = eval_expr(iterable_expr, context)
877
+
878
+ if iterable.nil? || (iterable.respond_to?(:empty?) && iterable.empty?)
879
+ if else_tokens.any?
880
+ return [render_tokens(else_tokens.dup, context), i]
881
+ end
882
+ return ["", i]
883
+ end
884
+
885
+ output = []
886
+ items = iterable.is_a?(Hash) ? iterable.to_a : Array(iterable)
887
+ total = items.length
888
+
889
+ items.each_with_index do |item, idx|
890
+ loop_ctx = context.dup
891
+ loop_ctx["loop"] = {
892
+ "index" => idx + 1,
893
+ "index0" => idx,
894
+ "first" => idx == 0,
895
+ "last" => idx == total - 1,
896
+ "length" => total,
897
+ "revindex" => total - idx,
898
+ "revindex0" => total - idx - 1,
899
+ "even" => ((idx + 1) % 2).zero?,
900
+ "odd" => ((idx + 1) % 2) != 0,
901
+ }
902
+
903
+ if iterable.is_a?(Hash)
904
+ key, value = item
905
+ if var2
906
+ loop_ctx[var1] = key
907
+ loop_ctx[var2] = value
908
+ else
909
+ loop_ctx[var1] = key
910
+ end
911
+ else
912
+ if var2
913
+ loop_ctx[var1] = idx
914
+ loop_ctx[var2] = item
915
+ else
916
+ loop_ctx[var1] = item
917
+ end
918
+ end
919
+
920
+ output << render_tokens(body_tokens.dup, loop_ctx)
921
+ end
922
+
923
+ [output.join, i]
924
+ end
925
+
926
+ # {% set name = expr %}
927
+ def handle_set(content, context)
928
+ if content =~ /\Aset\s+(\w+)\s*=\s*(.+)\z/m
929
+ name = Regexp.last_match(1)
930
+ expr = Regexp.last_match(2).strip
931
+ context[name] = eval_expr(expr, context)
932
+ end
933
+ end
934
+
935
+ # {% include "file.html" %}
936
+ def handle_include(content, context)
937
+ ignore_missing = content.include?("ignore missing")
938
+ content = content.gsub("ignore missing", "").strip
939
+
940
+ m = content.match(/\Ainclude\s+["'](.+?)["'](?:\s+with\s+(.+))?\z/)
941
+ return "" unless m
942
+
943
+ filename = m[1]
944
+ with_expr = m[2]
945
+
946
+ begin
947
+ source = load_template(filename)
948
+ rescue
949
+ return "" if ignore_missing
950
+ raise
951
+ end
952
+
953
+ inc_context = context.dup
954
+ if with_expr
955
+ extra = eval_expr(with_expr, context)
956
+ inc_context.merge!(stringify_keys(extra)) if extra.is_a?(Hash)
957
+ end
958
+
959
+ execute(source, inc_context)
960
+ end
961
+
962
+ # {% macro name(args) %}...{% endmacro %}
963
+ def handle_macro(tokens, start, context)
964
+ content, _, _ = strip_tag(tokens[start][1])
965
+ m = content.match(/\Amacro\s+(\w+)\s*\(([^)]*)\)/)
966
+ unless m
967
+ i = start + 1
968
+ while i < tokens.length
969
+ if tokens[i][0] == BLOCK && tokens[i][1].include?("endmacro")
970
+ return i + 1
971
+ end
972
+ i += 1
973
+ end
974
+ return i
975
+ end
976
+
977
+ macro_name = m[1]
978
+ param_names = m[2].split(",").map(&:strip).reject(&:empty?)
979
+
980
+ body_tokens = []
981
+ i = start + 1
982
+ while i < tokens.length
983
+ if tokens[i][0] == BLOCK && tokens[i][1].include?("endmacro")
984
+ i += 1
985
+ break
986
+ end
987
+ body_tokens << tokens[i]
988
+ i += 1
989
+ end
990
+
991
+ engine = self
992
+ captured_body = body_tokens.dup
993
+ captured_context = context
994
+
995
+ context[macro_name] = lambda { |*args|
996
+ macro_ctx = captured_context.dup
997
+ param_names.each_with_index do |pname, pi|
998
+ macro_ctx[pname] = pi < args.length ? args[pi] : nil
999
+ end
1000
+ engine.send(:render_tokens, captured_body.dup, macro_ctx)
1001
+ }
1002
+
1003
+ i
1004
+ end
1005
+
1006
+ # {% from "file" import macro1, macro2 %}
1007
+ def handle_from_import(content, context)
1008
+ m = content.match(/\Afrom\s+["'](.+?)["']\s+import\s+(.+)/)
1009
+ return unless m
1010
+
1011
+ filename = m[1]
1012
+ names = m[2].split(",").map(&:strip).reject(&:empty?)
1013
+
1014
+ source = load_template(filename)
1015
+ tokens = tokenize(source)
1016
+
1017
+ i = 0
1018
+ while i < tokens.length
1019
+ ttype, raw = tokens[i]
1020
+ if ttype == BLOCK
1021
+ tag_content, _, _ = strip_tag(raw)
1022
+ tag = (tag_content.split[0] || "")
1023
+ if tag == "macro"
1024
+ macro_m = tag_content.match(/\Amacro\s+(\w+)\s*\(([^)]*)\)/)
1025
+ if macro_m && names.include?(macro_m[1])
1026
+ macro_name = macro_m[1]
1027
+ param_names = macro_m[2].split(",").map(&:strip).reject(&:empty?)
1028
+
1029
+ body_tokens = []
1030
+ i += 1
1031
+ while i < tokens.length
1032
+ if tokens[i][0] == BLOCK && tokens[i][1].include?("endmacro")
1033
+ i += 1
1034
+ break
1035
+ end
1036
+ body_tokens << tokens[i]
1037
+ i += 1
1038
+ end
1039
+
1040
+ context[macro_name] = _make_macro_fn(body_tokens.dup, param_names.dup, context.dup)
1041
+ next
1042
+ end
1043
+ end
1044
+ end
1045
+ i += 1
1046
+ end
1047
+ end
1048
+
1049
+ # Build an isolated lambda for a macro — avoids closure-in-loop variable sharing.
1050
+ def _make_macro_fn(body_tokens, param_names, ctx)
1051
+ engine = self
1052
+ lambda { |*args|
1053
+ macro_ctx = ctx.dup
1054
+ param_names.each_with_index do |pname, pi|
1055
+ macro_ctx[pname] = pi < args.length ? args[pi] : nil
1056
+ end
1057
+ engine.send(:render_tokens, body_tokens.dup, macro_ctx)
1058
+ }
1059
+ end
1060
+
1061
+ # {% cache "key" ttl %}...{% endcache %}
1062
+ def handle_cache(tokens, start, context)
1063
+ content, _, _ = strip_tag(tokens[start][1])
1064
+ m = content.match(/\Acache\s+["'](.+?)["']\s*(\d+)?/)
1065
+ cache_key = m ? m[1] : "default"
1066
+ ttl = m && m[2] ? m[2].to_i : 60
1067
+
1068
+ # Check cache
1069
+ cached = @fragment_cache[cache_key]
1070
+ if cached
1071
+ html_content, expires_at = cached
1072
+ if Time.now.to_f < expires_at
1073
+ # Skip to endcache
1074
+ i = start + 1
1075
+ depth = 0
1076
+ while i < tokens.length
1077
+ if tokens[i][0] == BLOCK
1078
+ tc, _, _ = strip_tag(tokens[i][1])
1079
+ tag = tc.split[0] || ""
1080
+ if tag == "cache"
1081
+ depth += 1
1082
+ elsif tag == "endcache"
1083
+ return [html_content, i + 1] if depth == 0
1084
+ depth -= 1
1085
+ end
1086
+ end
1087
+ i += 1
1088
+ end
1089
+ return [html_content, i]
1090
+ end
1091
+ end
1092
+
1093
+ body_tokens = []
1094
+ i = start + 1
1095
+ depth = 0
1096
+ while i < tokens.length
1097
+ if tokens[i][0] == BLOCK
1098
+ tc, _, _ = strip_tag(tokens[i][1])
1099
+ tag = tc.split[0] || ""
1100
+ if tag == "cache"
1101
+ depth += 1
1102
+ body_tokens << tokens[i]
1103
+ elsif tag == "endcache"
1104
+ if depth == 0
1105
+ i += 1
1106
+ break
1107
+ end
1108
+ depth -= 1
1109
+ body_tokens << tokens[i]
1110
+ else
1111
+ body_tokens << tokens[i]
1112
+ end
1113
+ else
1114
+ body_tokens << tokens[i]
1115
+ end
1116
+ i += 1
1117
+ end
1118
+
1119
+ rendered = render_tokens(body_tokens.dup, context)
1120
+ @fragment_cache[cache_key] = [rendered, Time.now.to_f + ttl]
1121
+ [rendered, i]
1122
+ end
1123
+
1124
+ # -----------------------------------------------------------------------
1125
+ # Helpers
1126
+ # -----------------------------------------------------------------------
1127
+
1128
+ def truthy?(val)
1129
+ return false if val.nil? || val == false || val == 0 || val == ""
1130
+ return false if val.respond_to?(:empty?) && val.empty?
1131
+ true
1132
+ end
1133
+
1134
+ def stringify_keys(hash)
1135
+ return {} unless hash.is_a?(Hash)
1136
+ hash.each_with_object({}) { |(k, v), h| h[k.to_s] = v }
1137
+ end
1138
+
1139
+ # -----------------------------------------------------------------------
1140
+ # Built-in filters (53 total)
1141
+ # -----------------------------------------------------------------------
1142
+
1143
+ def default_filters
1144
+ {
1145
+ # -- Text --
1146
+ "upper" => ->(v, *_a) { v.to_s.upcase },
1147
+ "lower" => ->(v, *_a) { v.to_s.downcase },
1148
+ "capitalize" => ->(v, *_a) { v.to_s.capitalize },
1149
+ "title" => ->(v, *_a) { v.to_s.split.map(&:capitalize).join(" ") },
1150
+ "trim" => ->(v, *_a) { v.to_s.strip },
1151
+ "ltrim" => ->(v, *_a) { v.to_s.lstrip },
1152
+ "rtrim" => ->(v, *_a) { v.to_s.rstrip },
1153
+ "replace" => ->(v, *a) { a.length >= 2 ? v.to_s.gsub(a[0].to_s, a[1].to_s) : v.to_s },
1154
+ "striptags" => ->(v, *_a) { v.to_s.gsub(/<[^>]+>/, "") },
1155
+
1156
+ # -- Encoding --
1157
+ "escape" => ->(v, *_a) { Frond.escape_html(v.to_s) },
1158
+ "e" => ->(v, *_a) { Frond.escape_html(v.to_s) },
1159
+ "raw" => ->(v, *_a) { v },
1160
+ "safe" => ->(v, *_a) { v },
1161
+ "json_encode" => ->(v, *_a) { JSON.generate(v) rescue v.to_s },
1162
+ "json_decode" => ->(v, *_a) { v.is_a?(String) ? (JSON.parse(v) rescue v) : v },
1163
+ "base64_encode" => ->(v, *_a) { Base64.strict_encode64(v.to_s) },
1164
+ "base64_decode" => ->(v, *_a) { Base64.decode64(v.to_s) },
1165
+ "url_encode" => ->(v, *_a) { CGI.escape(v.to_s) },
1166
+
1167
+ # -- Hashing --
1168
+ "md5" => ->(v, *_a) { Digest::MD5.hexdigest(v.to_s) },
1169
+ "sha256" => ->(v, *_a) { Digest::SHA256.hexdigest(v.to_s) },
1170
+
1171
+ # -- Numbers --
1172
+ "abs" => ->(v, *_a) { v.is_a?(Numeric) ? v.abs : v.to_f.abs },
1173
+ "round" => ->(v, *a) { v.to_f.round(a[0] ? a[0].to_i : 0) },
1174
+ "int" => ->(v, *_a) { v.to_i },
1175
+ "float" => ->(v, *_a) { v.to_f },
1176
+ "number_format" => ->(v, *a) {
1177
+ decimals = a[0] ? a[0].to_i : 0
1178
+ formatted = format("%.#{decimals}f", v.to_f)
1179
+ # Add comma thousands separator
1180
+ parts = formatted.split(".")
1181
+ parts[0] = parts[0].gsub(/(\d)(?=(\d{3})+(?!\d))/, '\\1,')
1182
+ parts.join(".")
1183
+ },
1184
+
1185
+ # -- Date --
1186
+ "date" => ->(v, *a) {
1187
+ fmt = a[0] || "%Y-%m-%d"
1188
+ begin
1189
+ if v.is_a?(String)
1190
+ dt = DateTime.parse(v)
1191
+ dt.strftime(fmt)
1192
+ elsif v.respond_to?(:strftime)
1193
+ v.strftime(fmt)
1194
+ else
1195
+ v.to_s
1196
+ end
1197
+ rescue
1198
+ v.to_s
1199
+ end
1200
+ },
1201
+
1202
+ # -- Arrays --
1203
+ "length" => ->(v, *_a) { v.respond_to?(:length) ? v.length : v.to_s.length },
1204
+ "first" => ->(v, *_a) { v.respond_to?(:first) ? v.first : (v.to_s[0] rescue nil) },
1205
+ "last" => ->(v, *_a) { v.respond_to?(:last) ? v.last : (v.to_s[-1] rescue nil) },
1206
+ "reverse" => ->(v, *_a) { v.respond_to?(:reverse) ? v.reverse : v.to_s.reverse },
1207
+ "sort" => ->(v, *_a) { v.respond_to?(:sort) ? v.sort : v },
1208
+ "shuffle" => ->(v, *_a) { v.respond_to?(:shuffle) ? v.shuffle : v },
1209
+ "unique" => ->(v, *_a) { v.is_a?(Array) ? v.uniq : v },
1210
+ "join" => ->(v, *a) { v.respond_to?(:join) ? v.join(a[0] || ", ") : v.to_s },
1211
+ "split" => ->(v, *a) { v.to_s.split(a[0] || " ") },
1212
+ "slice" => ->(v, *a) {
1213
+ if a.length >= 2
1214
+ s = a[0].to_i
1215
+ e = a[1].to_i
1216
+ if v.is_a?(Array)
1217
+ v[s...e]
1218
+ else
1219
+ v.to_s[s...e]
1220
+ end
1221
+ else
1222
+ v
1223
+ end
1224
+ },
1225
+ "batch" => ->(v, *a) {
1226
+ if a[0] && v.respond_to?(:each_slice)
1227
+ v.each_slice(a[0].to_i).to_a
1228
+ else
1229
+ [v]
1230
+ end
1231
+ },
1232
+ "map" => ->(v, *a) {
1233
+ if a[0] && v.is_a?(Array)
1234
+ v.map { |item| item.is_a?(Hash) ? (item[a[0]] || item[a[0].to_sym]) : nil }
1235
+ else
1236
+ v
1237
+ end
1238
+ },
1239
+ "filter" => ->(v, *_a) { v.is_a?(Array) ? v.select { |item| item } : v },
1240
+ "column" => ->(v, *a) {
1241
+ if a[0] && v.is_a?(Array)
1242
+ v.map { |row| row.is_a?(Hash) ? (row[a[0]] || row[a[0].to_sym]) : nil }
1243
+ else
1244
+ v
1245
+ end
1246
+ },
1247
+
1248
+ # -- Dict --
1249
+ "keys" => ->(v, *_a) { v.respond_to?(:keys) ? v.keys : [] },
1250
+ "values" => ->(v, *_a) { v.respond_to?(:values) ? v.values : [v] },
1251
+ "merge" => ->(v, *a) {
1252
+ if v.respond_to?(:merge) && a[0].is_a?(Hash)
1253
+ v.merge(a[0])
1254
+ elsif v.is_a?(Array) && a[0].is_a?(Array)
1255
+ v + a[0]
1256
+ else
1257
+ v
1258
+ end
1259
+ },
1260
+
1261
+ # -- Utility --
1262
+ "default" => ->(v, *a) { (v.nil? || v.to_s.empty?) ? (a[0] || "") : v },
1263
+ "dump" => ->(v, *_a) { v.inspect },
1264
+ "string" => ->(v, *_a) { v.to_s },
1265
+ "truncate" => ->(v, *a) {
1266
+ len = a[0] ? a[0].to_i : 50
1267
+ str = v.to_s
1268
+ str.length > len ? str[0...len] + "..." : str
1269
+ },
1270
+ "wordwrap" => ->(v, *a) {
1271
+ width = a[0] ? a[0].to_i : 75
1272
+ words = v.to_s.split
1273
+ lines = []
1274
+ current = +""
1275
+ words.each do |word|
1276
+ if !current.empty? && current.length + 1 + word.length > width
1277
+ lines << current
1278
+ current = word
1279
+ else
1280
+ current = current.empty? ? word : "#{current} #{word}"
1281
+ end
1282
+ end
1283
+ lines << current unless current.empty?
1284
+ lines.join("\n")
1285
+ },
1286
+ "slug" => ->(v, *_a) { v.to_s.downcase.gsub(/[^a-z0-9]+/, "-").gsub(/\A-|-\z/, "") },
1287
+ "nl2br" => ->(v, *_a) { v.to_s.gsub("\n", "<br>\n") },
1288
+ "format" => ->(v, *a) {
1289
+ if a.any?
1290
+ v.to_s % a
1291
+ else
1292
+ v.to_s
1293
+ end
1294
+ },
1295
+ "form_token" => ->(_v, *_a) { Frond.generate_form_token(_v.to_s) },
1296
+ }
1297
+ end
1298
+
1299
+ # -----------------------------------------------------------------------
1300
+ # Built-in globals
1301
+ # -----------------------------------------------------------------------
1302
+
1303
+ def register_builtin_globals
1304
+ @globals["form_token"] = ->(descriptor = "") { Frond.generate_form_token(descriptor.to_s) }
1305
+ end
1306
+
1307
+ # Generate a JWT form token and return a hidden input element.
1308
+ #
1309
+ # @param descriptor [String] Optional string to enrich the token payload.
1310
+ # - Empty: payload is {"type" => "form"}
1311
+ # - "admin_panel": payload is {"type" => "form", "context" => "admin_panel"}
1312
+ # - "checkout|order_123": payload is {"type" => "form", "context" => "checkout", "ref" => "order_123"}
1313
+ #
1314
+ # @return [String] <input type="hidden" name="formToken" value="TOKEN">
1315
+ def self.generate_form_token(descriptor = "")
1316
+ require_relative "log"
1317
+ require_relative "auth"
1318
+
1319
+ payload = { "type" => "form" }
1320
+ if descriptor && !descriptor.empty?
1321
+ if descriptor.include?("|")
1322
+ parts = descriptor.split("|", 2)
1323
+ payload["context"] = parts[0]
1324
+ payload["ref"] = parts[1]
1325
+ else
1326
+ payload["context"] = descriptor
1327
+ end
1328
+ end
1329
+
1330
+ ttl_minutes = (ENV["TINA4_TOKEN_LIMIT"] || "30").to_i
1331
+ expires_in = ttl_minutes * 60
1332
+ token = Tina4::Auth.create_token(payload, expires_in: expires_in)
1333
+ Tina4::SafeString.new(%(<input type="hidden" name="formToken" value="#{CGI.escapeHTML(token)}">))
1334
+ end
1335
+ end
1336
+ end