tina4ruby 0.5.2 → 3.2.1

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