tina4ruby 3.11.13 → 3.11.15

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 (132) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +80 -80
  3. data/LICENSE.txt +21 -21
  4. data/README.md +137 -137
  5. data/exe/tina4ruby +5 -5
  6. data/lib/tina4/ai.rb +696 -696
  7. data/lib/tina4/api.rb +189 -189
  8. data/lib/tina4/auth.rb +305 -305
  9. data/lib/tina4/auto_crud.rb +244 -244
  10. data/lib/tina4/cache.rb +154 -154
  11. data/lib/tina4/cli.rb +1449 -1449
  12. data/lib/tina4/constants.rb +46 -46
  13. data/lib/tina4/container.rb +74 -74
  14. data/lib/tina4/cors.rb +74 -74
  15. data/lib/tina4/crud.rb +692 -692
  16. data/lib/tina4/database/sqlite3_adapter.rb +165 -165
  17. data/lib/tina4/database.rb +625 -625
  18. data/lib/tina4/database_result.rb +208 -208
  19. data/lib/tina4/debug.rb +8 -8
  20. data/lib/tina4/dev.rb +14 -14
  21. data/lib/tina4/dev_admin.rb +935 -935
  22. data/lib/tina4/dev_mailbox.rb +191 -191
  23. data/lib/tina4/drivers/firebird_driver.rb +124 -110
  24. data/lib/tina4/drivers/mongodb_driver.rb +561 -561
  25. data/lib/tina4/drivers/mssql_driver.rb +112 -112
  26. data/lib/tina4/drivers/mysql_driver.rb +90 -90
  27. data/lib/tina4/drivers/odbc_driver.rb +191 -191
  28. data/lib/tina4/drivers/postgres_driver.rb +116 -106
  29. data/lib/tina4/drivers/sqlite_driver.rb +122 -122
  30. data/lib/tina4/env.rb +95 -95
  31. data/lib/tina4/error_overlay.rb +252 -252
  32. data/lib/tina4/events.rb +109 -109
  33. data/lib/tina4/field_types.rb +154 -154
  34. data/lib/tina4/frond.rb +2025 -2025
  35. data/lib/tina4/gallery/auth/meta.json +1 -1
  36. data/lib/tina4/gallery/auth/src/routes/api/gallery_auth.rb +114 -114
  37. data/lib/tina4/gallery/database/meta.json +1 -1
  38. data/lib/tina4/gallery/database/src/routes/api/gallery_db.rb +43 -43
  39. data/lib/tina4/gallery/error-overlay/meta.json +1 -1
  40. data/lib/tina4/gallery/error-overlay/src/routes/api/gallery_crash.rb +17 -17
  41. data/lib/tina4/gallery/orm/meta.json +1 -1
  42. data/lib/tina4/gallery/orm/src/routes/api/gallery_products.rb +16 -16
  43. data/lib/tina4/gallery/queue/meta.json +1 -1
  44. data/lib/tina4/gallery/queue/src/routes/api/gallery_queue.rb +325 -325
  45. data/lib/tina4/gallery/rest-api/meta.json +1 -1
  46. data/lib/tina4/gallery/rest-api/src/routes/api/gallery_hello.rb +14 -14
  47. data/lib/tina4/gallery/templates/meta.json +1 -1
  48. data/lib/tina4/gallery/templates/src/routes/gallery_page.rb +12 -12
  49. data/lib/tina4/gallery/templates/src/templates/gallery_page.twig +257 -257
  50. data/lib/tina4/graphql.rb +966 -966
  51. data/lib/tina4/health.rb +39 -39
  52. data/lib/tina4/html_element.rb +170 -170
  53. data/lib/tina4/job.rb +80 -80
  54. data/lib/tina4/localization.rb +168 -168
  55. data/lib/tina4/log.rb +203 -203
  56. data/lib/tina4/mcp.rb +696 -696
  57. data/lib/tina4/messenger.rb +587 -587
  58. data/lib/tina4/metrics.rb +793 -793
  59. data/lib/tina4/middleware.rb +445 -445
  60. data/lib/tina4/migration.rb +451 -451
  61. data/lib/tina4/orm.rb +790 -790
  62. data/lib/tina4/public/css/tina4.css +2463 -2463
  63. data/lib/tina4/public/css/tina4.min.css +1 -1
  64. data/lib/tina4/public/images/logo.svg +5 -5
  65. data/lib/tina4/public/js/frond.min.js +2 -2
  66. data/lib/tina4/public/js/tina4-dev-admin.js +565 -565
  67. data/lib/tina4/public/js/tina4-dev-admin.min.js +480 -480
  68. data/lib/tina4/public/js/tina4.min.js +92 -92
  69. data/lib/tina4/public/js/tina4js.min.js +48 -48
  70. data/lib/tina4/public/swagger/index.html +90 -90
  71. data/lib/tina4/public/swagger/oauth2-redirect.html +63 -63
  72. data/lib/tina4/query_builder.rb +380 -380
  73. data/lib/tina4/queue.rb +366 -366
  74. data/lib/tina4/queue_backends/kafka_backend.rb +80 -80
  75. data/lib/tina4/queue_backends/lite_backend.rb +298 -298
  76. data/lib/tina4/queue_backends/mongo_backend.rb +126 -126
  77. data/lib/tina4/queue_backends/rabbitmq_backend.rb +73 -73
  78. data/lib/tina4/rack_app.rb +817 -817
  79. data/lib/tina4/rate_limiter.rb +130 -130
  80. data/lib/tina4/request.rb +268 -255
  81. data/lib/tina4/response.rb +346 -346
  82. data/lib/tina4/response_cache.rb +551 -551
  83. data/lib/tina4/router.rb +406 -406
  84. data/lib/tina4/scss/tina4css/_alerts.scss +34 -34
  85. data/lib/tina4/scss/tina4css/_badges.scss +22 -22
  86. data/lib/tina4/scss/tina4css/_buttons.scss +69 -69
  87. data/lib/tina4/scss/tina4css/_cards.scss +49 -49
  88. data/lib/tina4/scss/tina4css/_forms.scss +156 -156
  89. data/lib/tina4/scss/tina4css/_grid.scss +81 -81
  90. data/lib/tina4/scss/tina4css/_modals.scss +84 -84
  91. data/lib/tina4/scss/tina4css/_nav.scss +149 -149
  92. data/lib/tina4/scss/tina4css/_reset.scss +94 -94
  93. data/lib/tina4/scss/tina4css/_tables.scss +54 -54
  94. data/lib/tina4/scss/tina4css/_typography.scss +55 -55
  95. data/lib/tina4/scss/tina4css/_utilities.scss +197 -197
  96. data/lib/tina4/scss/tina4css/_variables.scss +117 -117
  97. data/lib/tina4/scss/tina4css/base.scss +1 -1
  98. data/lib/tina4/scss/tina4css/colors.scss +48 -48
  99. data/lib/tina4/scss/tina4css/tina4.scss +17 -17
  100. data/lib/tina4/scss_compiler.rb +178 -178
  101. data/lib/tina4/seeder.rb +567 -567
  102. data/lib/tina4/service_runner.rb +303 -303
  103. data/lib/tina4/session.rb +297 -297
  104. data/lib/tina4/session_handlers/database_handler.rb +72 -72
  105. data/lib/tina4/session_handlers/file_handler.rb +67 -67
  106. data/lib/tina4/session_handlers/mongo_handler.rb +49 -49
  107. data/lib/tina4/session_handlers/redis_handler.rb +43 -43
  108. data/lib/tina4/session_handlers/valkey_handler.rb +43 -43
  109. data/lib/tina4/shutdown.rb +84 -84
  110. data/lib/tina4/sql_translation.rb +158 -158
  111. data/lib/tina4/swagger.rb +124 -124
  112. data/lib/tina4/template.rb +894 -894
  113. data/lib/tina4/templates/base.twig +26 -26
  114. data/lib/tina4/templates/errors/302.twig +14 -14
  115. data/lib/tina4/templates/errors/401.twig +9 -9
  116. data/lib/tina4/templates/errors/403.twig +29 -29
  117. data/lib/tina4/templates/errors/404.twig +29 -29
  118. data/lib/tina4/templates/errors/500.twig +38 -38
  119. data/lib/tina4/templates/errors/502.twig +9 -9
  120. data/lib/tina4/templates/errors/503.twig +12 -12
  121. data/lib/tina4/templates/errors/base.twig +37 -37
  122. data/lib/tina4/test_client.rb +159 -159
  123. data/lib/tina4/testing.rb +340 -340
  124. data/lib/tina4/validator.rb +174 -174
  125. data/lib/tina4/version.rb +1 -1
  126. data/lib/tina4/webserver.rb +312 -312
  127. data/lib/tina4/websocket.rb +343 -343
  128. data/lib/tina4/websocket_backplane.rb +190 -190
  129. data/lib/tina4/wsdl.rb +564 -564
  130. data/lib/tina4.rb +458 -458
  131. data/lib/tina4ruby.rb +4 -4
  132. metadata +3 -3
@@ -1,894 +1,894 @@
1
- # frozen_string_literal: true
2
-
3
- module Tina4
4
- module Template
5
- TEMPLATE_DIRS = %w[templates src/templates src/views views].freeze
6
-
7
- class << self
8
- def globals
9
- @globals ||= {}
10
- end
11
-
12
- def add_global(key, value)
13
- globals[key.to_s] = value
14
- end
15
-
16
- def render(template_path, data = {})
17
- full_path = resolve_path(template_path)
18
- unless full_path && File.exist?(full_path)
19
- raise "Template not found: #{template_path}"
20
- end
21
-
22
- content = File.read(full_path)
23
- ext = File.extname(full_path).downcase
24
- context = globals.merge(data.transform_keys(&:to_s))
25
-
26
- case ext
27
- when ".twig", ".html", ".tina4"
28
- TwigEngine.new(context, File.dirname(full_path)).render(content)
29
- when ".erb"
30
- ErbEngine.render(content, context)
31
- else
32
- TwigEngine.new(context, File.dirname(full_path)).render(content)
33
- end
34
- end
35
-
36
- def render_error(code, data = {})
37
- error_dirs = TEMPLATE_DIRS.map { |d| File.join(Dir.pwd, d, "errors") }
38
- error_dirs << File.join(File.dirname(__FILE__), "templates", "errors")
39
-
40
- context = { "code" => code }.merge(data.transform_keys(&:to_s))
41
-
42
- error_dirs.each do |dir|
43
- %w[.twig .html .erb].each do |ext|
44
- path = File.join(dir, "#{code}#{ext}")
45
- if File.exist?(path)
46
- content = File.read(path)
47
- return TwigEngine.new(context, dir).render(content)
48
- end
49
- end
50
- end
51
- default_error_html(code)
52
- end
53
-
54
- private
55
-
56
- def resolve_path(template_path)
57
- return template_path if File.exist?(template_path)
58
- TEMPLATE_DIRS.each do |dir|
59
- full = File.join(Dir.pwd, dir, template_path)
60
- return full if File.exist?(full)
61
- end
62
- gem_templates = File.join(File.dirname(__FILE__), "templates")
63
- full = File.join(gem_templates, template_path)
64
- return full if File.exist?(full)
65
- nil
66
- end
67
-
68
- def default_error_html(code)
69
- messages = { 403 => "Forbidden", 404 => "Not Found", 500 => "Internal Server Error" }
70
- msg = messages[code] || "Error"
71
- colors = { 403 => "#f59e0b", 404 => "#3b82f6", 500 => "#ef4444" }
72
- color = colors[code] || "#ef4444"
73
-
74
- <<~HTML
75
- <!DOCTYPE html>
76
- <html lang="en">
77
- #{error_overlay_head("#{code} — #{msg}")}
78
- <body>
79
- #{error_overlay_css(color)}
80
- <div class="error-card">
81
- <div class="logo">T4</div>
82
- <div class="error-code">#{code}</div>
83
- <div class="error-title">#{msg}</div>
84
- <div class="error-msg">Something went wrong while processing your request.</div>
85
- <a href="/" class="error-home">Go Home</a>
86
- </div>
87
- </body>
88
- </html>
89
- HTML
90
- end
91
-
92
- def error_overlay_css(color)
93
- <<~CSS
94
- <style>
95
- * { box-sizing: border-box; margin: 0; padding: 0; }
96
- body { font-family: system-ui, -apple-system, sans-serif; background: #0f172a; color: #e2e8f0; min-height: 100vh; display: flex; align-items: center; justify-content: center; }
97
- .error-card { background: #1e293b; border: 1px solid #334155; border-radius: 1rem; padding: 3rem; text-align: center; max-width: 520px; width: 90%; }
98
- .error-code { font-size: 8rem; font-weight: 900; color: #{color}; opacity: 0.6; line-height: 1; margin-bottom: 0.5rem; }
99
- .error-title { font-size: 1.5rem; font-weight: 700; margin-bottom: 0.75rem; }
100
- .error-msg { color: #94a3b8; font-size: 1rem; margin-bottom: 1.5rem; line-height: 1.5; }
101
- .error-home { display: inline-block; padding: 0.6rem 2rem; background: #3b82f6; color: #fff; text-decoration: none; border-radius: 0.5rem; font-size: 0.9rem; font-weight: 600; }
102
- .error-home:hover { opacity: 0.9; }
103
- .logo { font-size: 1.5rem; margin-bottom: 1rem; opacity: 0.5; }
104
- </style>
105
- CSS
106
- end
107
-
108
- def error_overlay_head(title)
109
- <<~HEAD
110
- <head>
111
- <meta charset="utf-8">
112
- <meta name="viewport" content="width=device-width, initial-scale=1">
113
- <title>#{title}</title>
114
- </head>
115
- HEAD
116
- end
117
-
118
- def error_overlay_stacktrace(exception)
119
- return "" unless exception.respond_to?(:backtrace) && exception.backtrace
120
- lines = exception.backtrace.map { |l| "<li>#{l}</li>" }.join("\n")
121
- "<ul class=\"stacktrace\">#{lines}</ul>"
122
- end
123
-
124
- def error_overlay_source(file, line)
125
- return "" unless file && line && File.exist?(file)
126
- lines = File.readlines(file)
127
- start = [line.to_i - 4, 0].max
128
- finish = [line.to_i + 3, lines.length - 1].min
129
- snippet = lines[start..finish].each_with_index.map do |l, i|
130
- num = start + i + 1
131
- "<div class=\"source-line#{num == line.to_i ? ' highlight' : ''}\"><span class=\"line-num\">#{num}</span>#{l.chomp}</div>"
132
- end.join("\n")
133
- "<pre class=\"source-context\">#{snippet}</pre>"
134
- end
135
-
136
- def error_overlay_request(env)
137
- return "" unless env.is_a?(Hash)
138
- method = env["REQUEST_METHOD"] || "?"
139
- path = env["PATH_INFO"] || "?"
140
- "<div class=\"request-info\"><strong>#{method}</strong> #{path}</div>"
141
- end
142
-
143
- def error_overlay_env
144
- "<div class=\"env-info\">Ruby #{RUBY_VERSION} | Tina4</div>"
145
- end
146
- end
147
-
148
- class TwigEngine
149
- FILTERS = {
150
- "upper" => ->(v) { v.to_s.upcase },
151
- "lower" => ->(v) { v.to_s.downcase },
152
- "capitalize" => ->(v) { v.to_s.capitalize },
153
- "title" => ->(v) { v.to_s.split.map(&:capitalize).join(" ") },
154
- "trim" => ->(v) { v.to_s.strip },
155
- "length" => ->(v) { v.respond_to?(:length) ? v.length : v.to_s.length },
156
- "reverse" => ->(v) { v.respond_to?(:reverse) ? v.reverse : v.to_s.reverse },
157
- "first" => ->(v) { v.respond_to?(:first) ? v.first : v.to_s[0] },
158
- "last" => ->(v) { v.respond_to?(:last) ? v.last : v.to_s[-1] },
159
- "join" => ->(v, sep) { v.respond_to?(:join) ? v.join(sep || ", ") : v.to_s },
160
- "default" => ->(v, d) { (v.nil? || v.to_s.empty?) ? d : v },
161
- "escape" => ->(v) { TwigEngine.escape_html(v.to_s) },
162
- "e" => ->(v) { TwigEngine.escape_html(v.to_s) },
163
- "nl2br" => ->(v) { v.to_s.gsub("\n", "<br>") },
164
- "number_format" => ->(v, d) { format("%.#{d || 0}f", v.to_f) },
165
- "raw" => ->(v) { v },
166
- "striptags" => ->(v) { v.to_s.gsub(/<[^>]+>/, "") },
167
- "sort" => ->(v) { v.respond_to?(:sort) ? v.sort : v },
168
- "keys" => ->(v) { v.respond_to?(:keys) ? v.keys : [] },
169
- "values" => ->(v) { v.respond_to?(:values) ? v.values : [v] },
170
- "abs" => ->(v) { v.to_f.abs },
171
- "round" => ->(v, p) { v.to_f.round(p&.to_i || 0) },
172
- "url_encode" => ->(v) { URI.encode_www_form_component(v.to_s) },
173
- "json_encode" => ->(v) { JSON.generate(v) rescue v.to_s },
174
- "slice" => ->(v, s, e) { v.to_s[(s.to_i)..(e ? e.to_i : -1)] },
175
- "merge" => ->(v, o) { v.respond_to?(:merge) ? v.merge(o || {}) : v },
176
- "batch" => ->(v, s) { v.respond_to?(:each_slice) ? v.each_slice(s.to_i).to_a : [v] },
177
- "date" => ->(v, fmt) { TwigEngine.format_date(v, fmt) },
178
- "to_json" => ->(v) { JSON.generate(v).gsub("<", "\\u003c").gsub(">", "\\u003e").gsub("&", "\\u0026") rescue v.to_s },
179
- "tojson" => ->(v) { JSON.generate(v).gsub("<", "\\u003c").gsub(">", "\\u003e").gsub("&", "\\u0026") rescue v.to_s },
180
- "js_escape" => ->(v) { v.to_s.gsub("\\", "\\\\").gsub("'", "\\'").gsub('"', '\\"').gsub("\n", "\\n").gsub("\r", "\\r") }
181
- }.freeze
182
-
183
- def initialize(context = {}, base_dir = nil)
184
- @context = context
185
- @base_dir = base_dir || Dir.pwd
186
- @blocks = {}
187
- @parent_template = nil
188
- end
189
-
190
- # Reset context and blocks for reuse (avoids creating new instances in loops)
191
- def reset_context(context)
192
- @context = context
193
- @blocks = {}
194
- @parent_template = nil
195
- self
196
- end
197
-
198
- def render(content)
199
- content = process_extends(content)
200
- content = process_blocks(content)
201
- content = process_includes(content)
202
- content = process_for_loops(content)
203
- content = process_conditionals(content)
204
- content = process_set(content)
205
- content = process_expressions(content)
206
- content = content.gsub(/\{%.*?%\}/m, "")
207
- content = content.gsub(/\{#.*?#\}/m, "")
208
- content
209
- end
210
-
211
- HTML_ESCAPE = { "&" => "&amp;", "<" => "&lt;", ">" => "&gt;", '"' => "&quot;", "'" => "&#39;" }.freeze
212
- HTML_ESCAPE_PATTERN = /[&<>"']/
213
-
214
- def self.escape_html(str)
215
- str.gsub(HTML_ESCAPE_PATTERN, HTML_ESCAPE)
216
- end
217
-
218
- def self.format_date(value, fmt)
219
- require "date"
220
- d = value.is_a?(String) ? DateTime.parse(value) : value
221
- d.respond_to?(:strftime) ? d.strftime(fmt || "%Y-%m-%d") : value.to_s
222
- rescue
223
- value.to_s
224
- end
225
-
226
- private
227
-
228
- def process_extends(content)
229
- if content =~ /\{%\s*extends\s+["'](.+?)["']\s*%\}/
230
- parent_path = Regexp.last_match(1)
231
- full_parent = resolve_template(parent_path)
232
- if full_parent && File.exist?(full_parent)
233
- parent_source = File.read(full_parent)
234
- child_blocks = extract_blocks(content)
235
- @blocks.merge!(child_blocks)
236
- content = render_with_blocks(parent_source, @blocks)
237
- end
238
- end
239
- content
240
- end
241
-
242
- # Extract {% block name %}...{% endblock %} pairs using depth counting
243
- # so that nested blocks are handled correctly (non-greedy regex fails
244
- # when blocks are nested inside other blocks).
245
- def extract_blocks(source)
246
- blocks = {}
247
- block_open = /\{%[-\s]*block\s+(\w+)\s*-?%\}/
248
- block_close = /\{%[-\s]*endblock\s*-?%\}/
249
-
250
- pos = 0
251
- while pos < source.length
252
- m_open = block_open.match(source, pos)
253
- break unless m_open
254
-
255
- name = m_open[1]
256
- content_start = m_open.end(0)
257
- depth = 1
258
- scan = content_start
259
-
260
- while depth > 0 && scan < source.length
261
- next_open = block_open.match(source, scan)
262
- next_close = block_close.match(source, scan)
263
-
264
- break unless next_close # malformed — no matching endblock
265
-
266
- if next_open && next_open.begin(0) < next_close.begin(0)
267
- depth += 1
268
- scan = next_open.end(0)
269
- else
270
- depth -= 1
271
- if depth == 0
272
- blocks[name] = source[content_start...next_close.begin(0)]
273
- pos = next_close.end(0)
274
- break
275
- end
276
- scan = next_close.end(0)
277
- end
278
- end
279
-
280
- # If we didn't break out via depth==0, skip forward to avoid infinite loop
281
- pos = content_start if depth > 0
282
- end
283
-
284
- blocks
285
- end
286
-
287
- # Render a parent template replacing blocks with child overrides.
288
- # Supports multi-level inheritance: if the parent itself extends a
289
- # grandparent, blocks are merged (child overrides parent) and the
290
- # chain is followed recursively.
291
- def render_with_blocks(parent_source, child_blocks)
292
- extends_re = /\A\s*\{%\s*extends\s+["'](.+?)["']\s*%\}/
293
-
294
- # Multi-level: if the parent itself extends another template, recurse
295
- if parent_source =~ extends_re
296
- grandparent_name = Regexp.last_match(1)
297
- full_grandparent = resolve_template(grandparent_name)
298
- if full_grandparent && File.exist?(full_grandparent)
299
- grandparent_source = File.read(full_grandparent)
300
-
301
- # Extract block defaults defined in the parent template
302
- parent_blocks = extract_blocks(parent_source)
303
-
304
- # Child blocks override parent blocks at the same name
305
- merged_blocks = parent_blocks.merge(child_blocks)
306
-
307
- # Resolve nested blocks: if a block value contains {% block inner %}
308
- # tags, replace them with merged_blocks values too
309
- block_re = /\{%[-\s]*block\s+(\w+)\s*-?%\}(.*?)\{%[-\s]*endblock\s*-?%\}/m
310
- changed = true
311
- while changed
312
- changed = false
313
- merged_blocks.each do |bname, bsource|
314
- resolved = bsource.gsub(block_re) do
315
- inner_name = Regexp.last_match(1)
316
- inner_default = Regexp.last_match(2)
317
- merged_blocks[inner_name] || inner_default
318
- end
319
- if resolved != bsource
320
- merged_blocks[bname] = resolved
321
- changed = true
322
- end
323
- end
324
- end
325
-
326
- # Recurse up the chain (handles 3+, 4+, ... levels)
327
- return render_with_blocks(grandparent_source, merged_blocks)
328
- end
329
- end
330
-
331
- # Leaf parent (no extends) — resolve blocks and render
332
- parent_source.gsub(/\{%[-\s]*block\s+(\w+)\s*-?%\}(.*?)\{%[-\s]*endblock\s*-?%\}/m) do
333
- name = Regexp.last_match(1)
334
- default_body = Regexp.last_match(2)
335
- child_blocks[name] || default_body
336
- end
337
- end
338
-
339
- def process_blocks(content)
340
- content.gsub(/\{%\s*block\s+(\w+)\s*%\}(.*?)\{%\s*endblock\s*%\}/m) do
341
- name = Regexp.last_match(1)
342
- default_body = Regexp.last_match(2)
343
- @blocks[name] || default_body
344
- end
345
- end
346
-
347
- def process_includes(content)
348
- content.gsub(/\{%\s*include\s+["'](.+?)["'](?:\s+with\s+(.+?))?\s*%\}/) do
349
- inc_path = Regexp.last_match(1)
350
- full_path = resolve_template(inc_path)
351
- if full_path && File.exist?(full_path)
352
- inc_content = File.read(full_path)
353
- TwigEngine.new(@context.dup, File.dirname(full_path)).render(inc_content)
354
- else
355
- "<!-- include not found: #{inc_path} -->"
356
- end
357
- end
358
- end
359
-
360
- def process_for_loops(content)
361
- max_depth = 10
362
- depth = 0
363
- while content =~ /\{%\s*for\s+/ && depth < max_depth
364
- content = content.gsub(/\{%\s*for\s+(\w+)(?:\s*,\s*(\w+))?\s+in\s+(.+?)\s*%\}(.*?)\{%\s*endfor\s*%\}/m) do
365
- key_or_val = Regexp.last_match(1)
366
- val_name = Regexp.last_match(2)
367
- collection_expr = Regexp.last_match(3)
368
- body = Regexp.last_match(4)
369
- collection = evaluate_expression(collection_expr)
370
- output = +""
371
- items = case collection
372
- when Array then collection
373
- when Hash then collection.to_a
374
- when Range then collection.to_a
375
- when Integer then (0...collection).to_a
376
- else []
377
- end
378
- sub_engine = TwigEngine.new({}, @base_dir)
379
- items.each_with_index do |item, index|
380
- loop_context = @context.dup
381
- loop_context["loop"] = {
382
- "index" => index + 1, "index0" => index,
383
- "first" => index == 0, "last" => index == items.length - 1,
384
- "length" => items.length,
385
- "revindex" => items.length - index,
386
- "revindex0" => items.length - index - 1
387
- }
388
- if val_name
389
- loop_context[key_or_val] = item.is_a?(Array) ? item[0] : index
390
- loop_context[val_name] = item.is_a?(Array) ? item[1] : item
391
- else
392
- loop_context[key_or_val] = item
393
- end
394
- sub_engine.reset_context(loop_context)
395
- output << sub_engine.render(body)
396
- end
397
- output
398
- end
399
- depth += 1
400
- end
401
- content
402
- end
403
-
404
- def process_conditionals(content)
405
- max_depth = 10
406
- depth = 0
407
- while content =~ /\{%\s*if\s+/ && depth < max_depth
408
- content = content.gsub(
409
- /\{%\s*if\s+(.+?)\s*%\}(.*?)(?:\{%\s*else\s*%\}(.*?))?\{%\s*endif\s*%\}/m
410
- ) do
411
- condition = Regexp.last_match(1)
412
- true_body = Regexp.last_match(2)
413
- false_body = Regexp.last_match(3) || ""
414
-
415
- if true_body =~ /\{%\s*elseif\s+/
416
- segments = true_body.split(/\{%\s*elseif\s+/)
417
- if evaluate_condition(condition)
418
- segments[0]
419
- else
420
- resolved = false
421
- result = ""
422
- segments[1..].each do |seg|
423
- next if resolved
424
- if seg =~ /\A(.+?)\s*%\}(.*)\z/m
425
- if evaluate_condition(Regexp.last_match(1))
426
- result = Regexp.last_match(2)
427
- resolved = true
428
- end
429
- end
430
- end
431
- resolved ? result : false_body
432
- end
433
- elsif evaluate_condition(condition)
434
- true_body
435
- else
436
- false_body
437
- end
438
- end
439
- depth += 1
440
- end
441
- content
442
- end
443
-
444
- def process_set(content)
445
- content.gsub(/\{%\s*set\s+(\w+)\s*=\s*(.+?)\s*%\}/) do
446
- var_name = Regexp.last_match(1)
447
- expr = Regexp.last_match(2)
448
- @context[var_name] = evaluate_expression(expr)
449
- ""
450
- end
451
- end
452
-
453
- def process_expressions(content)
454
- content.gsub(/\{\{\s*(.+?)\s*\}\}/) do
455
- expr = Regexp.last_match(1)
456
- evaluate_piped_expression(expr).to_s
457
- end
458
- end
459
-
460
- def evaluate_piped_expression(expr)
461
- parts = expr.split("|").map(&:strip)
462
- value = evaluate_expression(parts[0])
463
- parts[1..].each do |filter_expr|
464
- if filter_expr =~ /\A(\w+)(?:\((.+?)\))?\z/
465
- filter_name = Regexp.last_match(1)
466
- args_str = Regexp.last_match(2)
467
- args = args_str ? parse_filter_args(args_str) : []
468
- filter = FILTERS[filter_name]
469
- value = args.empty? ? filter.call(value) : filter.call(value, *args) if filter
470
- end
471
- end
472
- value
473
- end
474
-
475
- def parse_filter_args(args_str)
476
- args = []
477
- current = +""
478
- in_quote = nil
479
- escaped = false
480
- args_str.each_char do |ch|
481
- if escaped
482
- current << ch
483
- escaped = false
484
- elsif ch == "\\"
485
- current << ch
486
- escaped = true
487
- elsif in_quote
488
- if ch == in_quote
489
- current << ch
490
- in_quote = nil
491
- else
492
- current << ch
493
- end
494
- elsif ch == '"' || ch == "'"
495
- in_quote = ch
496
- current << ch
497
- elsif ch == ","
498
- args << current.strip
499
- current = +""
500
- else
501
- current << ch
502
- end
503
- end
504
- args << current.strip unless current.strip.empty?
505
-
506
- args.map do |arg|
507
- if arg =~ /\A(["'])(.*)\1\z/m
508
- process_escapes(Regexp.last_match(2))
509
- elsif arg =~ /\A\d+\z/
510
- arg.to_i
511
- elsif arg =~ /\A\d+\.\d+\z/
512
- arg.to_f
513
- else
514
- evaluate_expression(arg)
515
- end
516
- end
517
- end
518
-
519
- # Find the first occurrence of +needle+ outside quotes and parentheses.
520
- # Returns the index, or -1 if not found.
521
- def find_outside_quotes(expr, needle)
522
- in_q = nil
523
- depth = 0
524
- i = 0
525
- while i <= expr.length - needle.length
526
- ch = expr[i]
527
- if (ch == '"' || ch == "'") && depth == 0
528
- if in_q.nil?
529
- in_q = ch
530
- elsif ch == in_q
531
- in_q = nil
532
- end
533
- i += 1
534
- next
535
- end
536
- if in_q
537
- i += 1
538
- next
539
- end
540
- if ch == "("
541
- depth += 1
542
- elsif ch == ")"
543
- depth -= 1
544
- end
545
- if depth == 0 && expr[i, needle.length] == needle
546
- return i
547
- end
548
- i += 1
549
- end
550
- -1
551
- end
552
-
553
- # Split +expr+ on +sep+ only when +sep+ is outside quotes and parentheses.
554
- def split_outside_quotes(expr, sep)
555
- parts = []
556
- current_start = 0
557
- in_q = nil
558
- depth = 0
559
- i = 0
560
- while i <= expr.length - sep.length
561
- ch = expr[i]
562
- if (ch == '"' || ch == "'") && depth == 0
563
- if in_q.nil?
564
- in_q = ch
565
- elsif ch == in_q
566
- in_q = nil
567
- end
568
- i += 1
569
- next
570
- end
571
- if in_q
572
- i += 1
573
- next
574
- end
575
- if ch == "("
576
- depth += 1
577
- elsif ch == ")"
578
- depth -= 1
579
- end
580
- if depth == 0 && expr[i, sep.length] == sep
581
- parts << expr[current_start...i]
582
- i += sep.length
583
- current_start = i
584
- next
585
- end
586
- i += 1
587
- end
588
- parts << expr[current_start..]
589
- parts
590
- end
591
-
592
- def evaluate_expression(expr)
593
- expr = expr.strip
594
-
595
- # String literal early-return
596
- if expr =~ /\A"([^"]*)"\z/ || expr =~ /\A'([^']*)'\z/
597
- return process_escapes(Regexp.last_match(1))
598
- end
599
-
600
- return expr.to_i if expr =~ /\A-?\d+\z/
601
- return expr.to_f if expr =~ /\A-?\d+\.\d+\z/
602
- return true if expr == "true"
603
- return false if expr == "false"
604
- return nil if expr == "null" || expr == "none" || expr == "nil"
605
- if expr =~ /\A\[(.+)\]\z/
606
- return Regexp.last_match(1).split(",").map { |i| evaluate_expression(i.strip) }
607
- end
608
- if expr =~ /\A(\d+)\.\.(\d+)\z/
609
- return (Regexp.last_match(1).to_i..Regexp.last_match(2).to_i)
610
- end
611
-
612
- # Null coalescing: value ?? "default"
613
- if find_outside_quotes(expr, "??") >= 0
614
- pos = find_outside_quotes(expr, "??")
615
- left = expr[0...pos]
616
- right = expr[(pos + 2)..]
617
- val = evaluate_expression(left.strip)
618
- return val unless val.nil?
619
- return evaluate_expression(right.strip)
620
- end
621
-
622
- # String concatenation with ~ (only outside quotes/parens)
623
- if find_outside_quotes(expr, "~") >= 0
624
- parts = split_outside_quotes(expr, "~")
625
- return parts.map { |p| (evaluate_expression(p.strip) || "").to_s }.join
626
- end
627
-
628
- # Comparison operators (only outside quotes/parens)
629
- [" not in ", " in ", " is not ", " is ", "!=", "==", ">=", "<=", ">", "<", " and ", " or ", " not "].each do |op|
630
- if find_outside_quotes(expr, op) >= 0
631
- return evaluate_condition(expr)
632
- end
633
- end
634
-
635
- if expr =~ /\A(.+?)\s*(\+|-|\*|\/|%)\s*(.+)\z/
636
- left = evaluate_expression(Regexp.last_match(1))
637
- op = Regexp.last_match(2)
638
- right = evaluate_expression(Regexp.last_match(3))
639
- return apply_math(left, op, right)
640
- end
641
-
642
- # Function call with dotted name: obj.method(args)
643
- if expr =~ /\A([\w.]+)\s*\((.*)\)\z/m
644
- func_name = Regexp.last_match(1)
645
- args_str = Regexp.last_match(2)
646
- if func_name.include?(".")
647
- last_dot = func_name.rindex(".")
648
- obj_path = func_name[0...last_dot]
649
- method_name = func_name[(last_dot + 1)..]
650
- obj = resolve_variable(obj_path)
651
- if obj.respond_to?(:call)
652
- # obj itself is callable — unlikely but handle
653
- elsif obj.is_a?(Hash)
654
- callable = obj[method_name] || obj[method_name.to_sym] || obj[method_name.to_s]
655
- if callable.respond_to?(:call)
656
- args = args_str && !args_str.strip.empty? ? parse_filter_args(args_str) : []
657
- return callable.call(*args)
658
- end
659
- elsif obj.respond_to?(method_name.to_sym)
660
- args = args_str && !args_str.strip.empty? ? parse_filter_args(args_str) : []
661
- return obj.send(method_name.to_sym, *args)
662
- end
663
- return nil
664
- end
665
- end
666
-
667
- resolve_variable(expr)
668
- end
669
-
670
- def resolve_variable(expr)
671
- parts = split_dot_parts(expr)
672
- value = @context
673
- parts.each do |part|
674
- if part =~ /\A(\w+)\((.*)?\)\z/m
675
- # Method call: e.g. t("key") or greet("hello", "world")
676
- method_name = Regexp.last_match(1)
677
- args_str = Regexp.last_match(2)
678
- callable = access_value(value, method_name)
679
- if callable.respond_to?(:call)
680
- args = args_str && !args_str.strip.empty? ? parse_filter_args(args_str) : []
681
- value = callable.call(*args)
682
- elsif callable.respond_to?(method_name.to_sym)
683
- args = args_str && !args_str.strip.empty? ? parse_filter_args(args_str) : []
684
- value = callable.send(method_name.to_sym, *args)
685
- else
686
- return nil
687
- end
688
- elsif part =~ /\A(\w+)\[(.+?)\]\z/
689
- base = Regexp.last_match(1)
690
- index = Regexp.last_match(2).strip
691
- value = access_value(value, base)
692
- if index =~ /\A["'](.*)["']\z/
693
- # Quoted string literal — use as-is
694
- index = Regexp.last_match(1)
695
- value = access_value(value, index)
696
- elsif index =~ /\A\d+\z/
697
- value = value[index.to_i] if value.respond_to?(:[])
698
- else
699
- # Resolve as a variable from context
700
- resolved = resolve_variable(index)
701
- value = access_value(value, resolved.to_s) unless resolved.nil?
702
- value = nil if resolved.nil?
703
- end
704
- else
705
- value = access_value(value, part)
706
- end
707
- return nil if value.nil?
708
- end
709
- value
710
- end
711
-
712
- # Split expression on dots, respecting quotes, parentheses and brackets.
713
- # Dots inside quoted strings or nested parens/brackets are NOT separators.
714
- # Bracket access like foo["bar"] is emitted as a separate part.
715
- def split_dot_parts(expr)
716
- parts = []
717
- current = +""
718
- paren_depth = 0
719
- bracket_depth = 0
720
- in_quote = nil
721
-
722
- i = 0
723
- chars = expr.chars
724
- while i < chars.length
725
- ch = chars[i]
726
-
727
- if in_quote
728
- current << ch
729
- # End quote only when matching unescaped closer
730
- in_quote = nil if ch == in_quote && (i == 0 || chars[i - 1] != "\\")
731
- elsif ch == '"' || ch == "'"
732
- in_quote = ch
733
- current << ch
734
- elsif ch == "("
735
- paren_depth += 1
736
- current << ch
737
- elsif ch == ")"
738
- paren_depth -= 1
739
- current << ch
740
- elsif ch == "[" && paren_depth == 0
741
- if bracket_depth == 0 && !current.empty?
742
- # Start of bracket access on an existing part -- split here
743
- parts << current
744
- current = +""
745
- end
746
- bracket_depth += 1
747
- current << ch
748
- elsif ch == "]"
749
- bracket_depth -= 1
750
- current << ch
751
- if bracket_depth == 0 && paren_depth == 0
752
- # End of top-level bracket access -- emit as its own part
753
- parts << current
754
- current = +""
755
- # Skip a trailing dot that merely chains the next segment
756
- i += 1 if i + 1 < chars.length && chars[i + 1] == "."
757
- end
758
- elsif ch == "." && paren_depth == 0 && bracket_depth == 0
759
- parts << current unless current.empty?
760
- current = +""
761
- else
762
- current << ch
763
- end
764
-
765
- i += 1
766
- end
767
- parts << current unless current.empty?
768
- parts
769
- end
770
-
771
- # Process backslash escape sequences in a single pass so that
772
- # \\' does not collapse to ' (it should become \').
773
- def process_escapes(s)
774
- out = +""
775
- i = 0
776
- while i < s.length
777
- if s[i] == "\\" && i + 1 < s.length
778
- nxt = s[i + 1]
779
- case nxt
780
- when "n" then out << "\n"; i += 2
781
- when "t" then out << "\t"; i += 2
782
- when "\\" then out << "\\"; i += 2
783
- when "'" then out << "'"; i += 2
784
- when '"' then out << '"'; i += 2
785
- else out << "\\"; i += 1
786
- end
787
- else
788
- out << s[i]
789
- i += 1
790
- end
791
- end
792
- out
793
- end
794
-
795
- def access_value(obj, key)
796
- return nil if obj.nil?
797
- if obj.is_a?(Hash)
798
- obj[key] || obj[key.to_sym] || obj[key.to_s]
799
- elsif obj.respond_to?(key.to_sym)
800
- obj.send(key.to_sym)
801
- elsif obj.respond_to?(:[])
802
- obj[key] rescue nil
803
- end
804
- end
805
-
806
- def evaluate_condition(expr)
807
- expr = expr.strip
808
- return !evaluate_condition(Regexp.last_match(1)) if expr =~ /\Anot\s+(.+)\z/
809
- if expr.include?(" and ")
810
- return expr.split(/\s+and\s+/).all? { |p| evaluate_condition(p) }
811
- end
812
- if expr.include?(" or ")
813
- return expr.split(/\s+or\s+/).any? { |p| evaluate_condition(p) }
814
- end
815
- if expr =~ /\A(.+?)\s*(==|!=|>=|<=|>|<)\s*(.+)\z/
816
- left = evaluate_expression(Regexp.last_match(1).strip)
817
- op = Regexp.last_match(2).strip
818
- right = evaluate_expression(Regexp.last_match(3).strip)
819
- return compare(left, op, right)
820
- end
821
- if expr =~ /\A(.+?)\s+in\s+(.+)\z/
822
- needle = evaluate_expression(Regexp.last_match(1).strip)
823
- haystack = evaluate_expression(Regexp.last_match(2).strip)
824
- return haystack.respond_to?(:include?) ? haystack.include?(needle) : false
825
- end
826
- if expr =~ /\A(.+?)\s+is\s+defined\z/
827
- return !evaluate_expression(Regexp.last_match(1).strip).nil?
828
- end
829
- if expr =~ /\A(.+?)\s+is\s+empty\z/
830
- val = evaluate_expression(Regexp.last_match(1).strip)
831
- return val.nil? || (val.respond_to?(:empty?) && val.empty?)
832
- end
833
- truthy?(evaluate_expression(expr))
834
- end
835
-
836
- def compare(left, op, right)
837
- case op
838
- when "==" then left == right
839
- when "!=" then left != right
840
- when ">" then left.to_f > right.to_f
841
- when "<" then left.to_f < right.to_f
842
- when ">=" then left.to_f >= right.to_f
843
- when "<=" then left.to_f <= right.to_f
844
- else false
845
- end
846
- end
847
-
848
- def apply_math(left, op, right)
849
- l = left.to_f
850
- r = right.to_f
851
- case op
852
- when "+" then l + r
853
- when "-" then l - r
854
- when "*" then l * r
855
- when "/" then r != 0 ? l / r : 0
856
- when "%" then l % r
857
- else 0
858
- end
859
- end
860
-
861
- def truthy?(val)
862
- return false if val.nil? || val == false || val == 0 || val == ""
863
- return false if val.respond_to?(:empty?) && val.empty?
864
- true
865
- end
866
-
867
- def resolve_template(path)
868
- full = File.join(@base_dir, path)
869
- return full if File.exist?(full)
870
- Tina4::Template::TEMPLATE_DIRS.each do |dir|
871
- candidate = File.join(Dir.pwd, dir, path)
872
- return candidate if File.exist?(candidate)
873
- end
874
- nil
875
- end
876
- end
877
-
878
- class ErbEngine
879
- def self.render(content, context)
880
- require "erb"
881
- binding_obj = create_binding(context)
882
- ERB.new(content, trim_mode: "-").result(binding_obj)
883
- end
884
-
885
- def self.create_binding(context)
886
- b = binding
887
- context.each do |key, value|
888
- b.local_variable_set(key.to_sym, value)
889
- end
890
- b
891
- end
892
- end
893
- end
894
- end
1
+ # frozen_string_literal: true
2
+
3
+ module Tina4
4
+ module Template
5
+ TEMPLATE_DIRS = %w[templates src/templates src/views views].freeze
6
+
7
+ class << self
8
+ def globals
9
+ @globals ||= {}
10
+ end
11
+
12
+ def add_global(key, value)
13
+ globals[key.to_s] = value
14
+ end
15
+
16
+ def render(template_path, data = {})
17
+ full_path = resolve_path(template_path)
18
+ unless full_path && File.exist?(full_path)
19
+ raise "Template not found: #{template_path}"
20
+ end
21
+
22
+ content = File.read(full_path)
23
+ ext = File.extname(full_path).downcase
24
+ context = globals.merge(data.transform_keys(&:to_s))
25
+
26
+ case ext
27
+ when ".twig", ".html", ".tina4"
28
+ TwigEngine.new(context, File.dirname(full_path)).render(content)
29
+ when ".erb"
30
+ ErbEngine.render(content, context)
31
+ else
32
+ TwigEngine.new(context, File.dirname(full_path)).render(content)
33
+ end
34
+ end
35
+
36
+ def render_error(code, data = {})
37
+ error_dirs = TEMPLATE_DIRS.map { |d| File.join(Dir.pwd, d, "errors") }
38
+ error_dirs << File.join(File.dirname(__FILE__), "templates", "errors")
39
+
40
+ context = { "code" => code }.merge(data.transform_keys(&:to_s))
41
+
42
+ error_dirs.each do |dir|
43
+ %w[.twig .html .erb].each do |ext|
44
+ path = File.join(dir, "#{code}#{ext}")
45
+ if File.exist?(path)
46
+ content = File.read(path)
47
+ return TwigEngine.new(context, dir).render(content)
48
+ end
49
+ end
50
+ end
51
+ default_error_html(code)
52
+ end
53
+
54
+ private
55
+
56
+ def resolve_path(template_path)
57
+ return template_path if File.exist?(template_path)
58
+ TEMPLATE_DIRS.each do |dir|
59
+ full = File.join(Dir.pwd, dir, template_path)
60
+ return full if File.exist?(full)
61
+ end
62
+ gem_templates = File.join(File.dirname(__FILE__), "templates")
63
+ full = File.join(gem_templates, template_path)
64
+ return full if File.exist?(full)
65
+ nil
66
+ end
67
+
68
+ def default_error_html(code)
69
+ messages = { 403 => "Forbidden", 404 => "Not Found", 500 => "Internal Server Error" }
70
+ msg = messages[code] || "Error"
71
+ colors = { 403 => "#f59e0b", 404 => "#3b82f6", 500 => "#ef4444" }
72
+ color = colors[code] || "#ef4444"
73
+
74
+ <<~HTML
75
+ <!DOCTYPE html>
76
+ <html lang="en">
77
+ #{error_overlay_head("#{code} — #{msg}")}
78
+ <body>
79
+ #{error_overlay_css(color)}
80
+ <div class="error-card">
81
+ <div class="logo">T4</div>
82
+ <div class="error-code">#{code}</div>
83
+ <div class="error-title">#{msg}</div>
84
+ <div class="error-msg">Something went wrong while processing your request.</div>
85
+ <a href="/" class="error-home">Go Home</a>
86
+ </div>
87
+ </body>
88
+ </html>
89
+ HTML
90
+ end
91
+
92
+ def error_overlay_css(color)
93
+ <<~CSS
94
+ <style>
95
+ * { box-sizing: border-box; margin: 0; padding: 0; }
96
+ body { font-family: system-ui, -apple-system, sans-serif; background: #0f172a; color: #e2e8f0; min-height: 100vh; display: flex; align-items: center; justify-content: center; }
97
+ .error-card { background: #1e293b; border: 1px solid #334155; border-radius: 1rem; padding: 3rem; text-align: center; max-width: 520px; width: 90%; }
98
+ .error-code { font-size: 8rem; font-weight: 900; color: #{color}; opacity: 0.6; line-height: 1; margin-bottom: 0.5rem; }
99
+ .error-title { font-size: 1.5rem; font-weight: 700; margin-bottom: 0.75rem; }
100
+ .error-msg { color: #94a3b8; font-size: 1rem; margin-bottom: 1.5rem; line-height: 1.5; }
101
+ .error-home { display: inline-block; padding: 0.6rem 2rem; background: #3b82f6; color: #fff; text-decoration: none; border-radius: 0.5rem; font-size: 0.9rem; font-weight: 600; }
102
+ .error-home:hover { opacity: 0.9; }
103
+ .logo { font-size: 1.5rem; margin-bottom: 1rem; opacity: 0.5; }
104
+ </style>
105
+ CSS
106
+ end
107
+
108
+ def error_overlay_head(title)
109
+ <<~HEAD
110
+ <head>
111
+ <meta charset="utf-8">
112
+ <meta name="viewport" content="width=device-width, initial-scale=1">
113
+ <title>#{title}</title>
114
+ </head>
115
+ HEAD
116
+ end
117
+
118
+ def error_overlay_stacktrace(exception)
119
+ return "" unless exception.respond_to?(:backtrace) && exception.backtrace
120
+ lines = exception.backtrace.map { |l| "<li>#{l}</li>" }.join("\n")
121
+ "<ul class=\"stacktrace\">#{lines}</ul>"
122
+ end
123
+
124
+ def error_overlay_source(file, line)
125
+ return "" unless file && line && File.exist?(file)
126
+ lines = File.readlines(file)
127
+ start = [line.to_i - 4, 0].max
128
+ finish = [line.to_i + 3, lines.length - 1].min
129
+ snippet = lines[start..finish].each_with_index.map do |l, i|
130
+ num = start + i + 1
131
+ "<div class=\"source-line#{num == line.to_i ? ' highlight' : ''}\"><span class=\"line-num\">#{num}</span>#{l.chomp}</div>"
132
+ end.join("\n")
133
+ "<pre class=\"source-context\">#{snippet}</pre>"
134
+ end
135
+
136
+ def error_overlay_request(env)
137
+ return "" unless env.is_a?(Hash)
138
+ method = env["REQUEST_METHOD"] || "?"
139
+ path = env["PATH_INFO"] || "?"
140
+ "<div class=\"request-info\"><strong>#{method}</strong> #{path}</div>"
141
+ end
142
+
143
+ def error_overlay_env
144
+ "<div class=\"env-info\">Ruby #{RUBY_VERSION} | Tina4</div>"
145
+ end
146
+ end
147
+
148
+ class TwigEngine
149
+ FILTERS = {
150
+ "upper" => ->(v) { v.to_s.upcase },
151
+ "lower" => ->(v) { v.to_s.downcase },
152
+ "capitalize" => ->(v) { v.to_s.capitalize },
153
+ "title" => ->(v) { v.to_s.split.map(&:capitalize).join(" ") },
154
+ "trim" => ->(v) { v.to_s.strip },
155
+ "length" => ->(v) { v.respond_to?(:length) ? v.length : v.to_s.length },
156
+ "reverse" => ->(v) { v.respond_to?(:reverse) ? v.reverse : v.to_s.reverse },
157
+ "first" => ->(v) { v.respond_to?(:first) ? v.first : v.to_s[0] },
158
+ "last" => ->(v) { v.respond_to?(:last) ? v.last : v.to_s[-1] },
159
+ "join" => ->(v, sep) { v.respond_to?(:join) ? v.join(sep || ", ") : v.to_s },
160
+ "default" => ->(v, d) { (v.nil? || v.to_s.empty?) ? d : v },
161
+ "escape" => ->(v) { TwigEngine.escape_html(v.to_s) },
162
+ "e" => ->(v) { TwigEngine.escape_html(v.to_s) },
163
+ "nl2br" => ->(v) { v.to_s.gsub("\n", "<br>") },
164
+ "number_format" => ->(v, d) { format("%.#{d || 0}f", v.to_f) },
165
+ "raw" => ->(v) { v },
166
+ "striptags" => ->(v) { v.to_s.gsub(/<[^>]+>/, "") },
167
+ "sort" => ->(v) { v.respond_to?(:sort) ? v.sort : v },
168
+ "keys" => ->(v) { v.respond_to?(:keys) ? v.keys : [] },
169
+ "values" => ->(v) { v.respond_to?(:values) ? v.values : [v] },
170
+ "abs" => ->(v) { v.to_f.abs },
171
+ "round" => ->(v, p) { v.to_f.round(p&.to_i || 0) },
172
+ "url_encode" => ->(v) { URI.encode_www_form_component(v.to_s) },
173
+ "json_encode" => ->(v) { JSON.generate(v) rescue v.to_s },
174
+ "slice" => ->(v, s, e) { v.to_s[(s.to_i)..(e ? e.to_i : -1)] },
175
+ "merge" => ->(v, o) { v.respond_to?(:merge) ? v.merge(o || {}) : v },
176
+ "batch" => ->(v, s) { v.respond_to?(:each_slice) ? v.each_slice(s.to_i).to_a : [v] },
177
+ "date" => ->(v, fmt) { TwigEngine.format_date(v, fmt) },
178
+ "to_json" => ->(v) { JSON.generate(v).gsub("<", "\\u003c").gsub(">", "\\u003e").gsub("&", "\\u0026") rescue v.to_s },
179
+ "tojson" => ->(v) { JSON.generate(v).gsub("<", "\\u003c").gsub(">", "\\u003e").gsub("&", "\\u0026") rescue v.to_s },
180
+ "js_escape" => ->(v) { v.to_s.gsub("\\", "\\\\").gsub("'", "\\'").gsub('"', '\\"').gsub("\n", "\\n").gsub("\r", "\\r") }
181
+ }.freeze
182
+
183
+ def initialize(context = {}, base_dir = nil)
184
+ @context = context
185
+ @base_dir = base_dir || Dir.pwd
186
+ @blocks = {}
187
+ @parent_template = nil
188
+ end
189
+
190
+ # Reset context and blocks for reuse (avoids creating new instances in loops)
191
+ def reset_context(context)
192
+ @context = context
193
+ @blocks = {}
194
+ @parent_template = nil
195
+ self
196
+ end
197
+
198
+ def render(content)
199
+ content = process_extends(content)
200
+ content = process_blocks(content)
201
+ content = process_includes(content)
202
+ content = process_for_loops(content)
203
+ content = process_conditionals(content)
204
+ content = process_set(content)
205
+ content = process_expressions(content)
206
+ content = content.gsub(/\{%.*?%\}/m, "")
207
+ content = content.gsub(/\{#.*?#\}/m, "")
208
+ content
209
+ end
210
+
211
+ HTML_ESCAPE = { "&" => "&amp;", "<" => "&lt;", ">" => "&gt;", '"' => "&quot;", "'" => "&#39;" }.freeze
212
+ HTML_ESCAPE_PATTERN = /[&<>"']/
213
+
214
+ def self.escape_html(str)
215
+ str.gsub(HTML_ESCAPE_PATTERN, HTML_ESCAPE)
216
+ end
217
+
218
+ def self.format_date(value, fmt)
219
+ require "date"
220
+ d = value.is_a?(String) ? DateTime.parse(value) : value
221
+ d.respond_to?(:strftime) ? d.strftime(fmt || "%Y-%m-%d") : value.to_s
222
+ rescue
223
+ value.to_s
224
+ end
225
+
226
+ private
227
+
228
+ def process_extends(content)
229
+ if content =~ /\{%\s*extends\s+["'](.+?)["']\s*%\}/
230
+ parent_path = Regexp.last_match(1)
231
+ full_parent = resolve_template(parent_path)
232
+ if full_parent && File.exist?(full_parent)
233
+ parent_source = File.read(full_parent)
234
+ child_blocks = extract_blocks(content)
235
+ @blocks.merge!(child_blocks)
236
+ content = render_with_blocks(parent_source, @blocks)
237
+ end
238
+ end
239
+ content
240
+ end
241
+
242
+ # Extract {% block name %}...{% endblock %} pairs using depth counting
243
+ # so that nested blocks are handled correctly (non-greedy regex fails
244
+ # when blocks are nested inside other blocks).
245
+ def extract_blocks(source)
246
+ blocks = {}
247
+ block_open = /\{%[-\s]*block\s+(\w+)\s*-?%\}/
248
+ block_close = /\{%[-\s]*endblock\s*-?%\}/
249
+
250
+ pos = 0
251
+ while pos < source.length
252
+ m_open = block_open.match(source, pos)
253
+ break unless m_open
254
+
255
+ name = m_open[1]
256
+ content_start = m_open.end(0)
257
+ depth = 1
258
+ scan = content_start
259
+
260
+ while depth > 0 && scan < source.length
261
+ next_open = block_open.match(source, scan)
262
+ next_close = block_close.match(source, scan)
263
+
264
+ break unless next_close # malformed — no matching endblock
265
+
266
+ if next_open && next_open.begin(0) < next_close.begin(0)
267
+ depth += 1
268
+ scan = next_open.end(0)
269
+ else
270
+ depth -= 1
271
+ if depth == 0
272
+ blocks[name] = source[content_start...next_close.begin(0)]
273
+ pos = next_close.end(0)
274
+ break
275
+ end
276
+ scan = next_close.end(0)
277
+ end
278
+ end
279
+
280
+ # If we didn't break out via depth==0, skip forward to avoid infinite loop
281
+ pos = content_start if depth > 0
282
+ end
283
+
284
+ blocks
285
+ end
286
+
287
+ # Render a parent template replacing blocks with child overrides.
288
+ # Supports multi-level inheritance: if the parent itself extends a
289
+ # grandparent, blocks are merged (child overrides parent) and the
290
+ # chain is followed recursively.
291
+ def render_with_blocks(parent_source, child_blocks)
292
+ extends_re = /\A\s*\{%\s*extends\s+["'](.+?)["']\s*%\}/
293
+
294
+ # Multi-level: if the parent itself extends another template, recurse
295
+ if parent_source =~ extends_re
296
+ grandparent_name = Regexp.last_match(1)
297
+ full_grandparent = resolve_template(grandparent_name)
298
+ if full_grandparent && File.exist?(full_grandparent)
299
+ grandparent_source = File.read(full_grandparent)
300
+
301
+ # Extract block defaults defined in the parent template
302
+ parent_blocks = extract_blocks(parent_source)
303
+
304
+ # Child blocks override parent blocks at the same name
305
+ merged_blocks = parent_blocks.merge(child_blocks)
306
+
307
+ # Resolve nested blocks: if a block value contains {% block inner %}
308
+ # tags, replace them with merged_blocks values too
309
+ block_re = /\{%[-\s]*block\s+(\w+)\s*-?%\}(.*?)\{%[-\s]*endblock\s*-?%\}/m
310
+ changed = true
311
+ while changed
312
+ changed = false
313
+ merged_blocks.each do |bname, bsource|
314
+ resolved = bsource.gsub(block_re) do
315
+ inner_name = Regexp.last_match(1)
316
+ inner_default = Regexp.last_match(2)
317
+ merged_blocks[inner_name] || inner_default
318
+ end
319
+ if resolved != bsource
320
+ merged_blocks[bname] = resolved
321
+ changed = true
322
+ end
323
+ end
324
+ end
325
+
326
+ # Recurse up the chain (handles 3+, 4+, ... levels)
327
+ return render_with_blocks(grandparent_source, merged_blocks)
328
+ end
329
+ end
330
+
331
+ # Leaf parent (no extends) — resolve blocks and render
332
+ parent_source.gsub(/\{%[-\s]*block\s+(\w+)\s*-?%\}(.*?)\{%[-\s]*endblock\s*-?%\}/m) do
333
+ name = Regexp.last_match(1)
334
+ default_body = Regexp.last_match(2)
335
+ child_blocks[name] || default_body
336
+ end
337
+ end
338
+
339
+ def process_blocks(content)
340
+ content.gsub(/\{%\s*block\s+(\w+)\s*%\}(.*?)\{%\s*endblock\s*%\}/m) do
341
+ name = Regexp.last_match(1)
342
+ default_body = Regexp.last_match(2)
343
+ @blocks[name] || default_body
344
+ end
345
+ end
346
+
347
+ def process_includes(content)
348
+ content.gsub(/\{%\s*include\s+["'](.+?)["'](?:\s+with\s+(.+?))?\s*%\}/) do
349
+ inc_path = Regexp.last_match(1)
350
+ full_path = resolve_template(inc_path)
351
+ if full_path && File.exist?(full_path)
352
+ inc_content = File.read(full_path)
353
+ TwigEngine.new(@context.dup, File.dirname(full_path)).render(inc_content)
354
+ else
355
+ "<!-- include not found: #{inc_path} -->"
356
+ end
357
+ end
358
+ end
359
+
360
+ def process_for_loops(content)
361
+ max_depth = 10
362
+ depth = 0
363
+ while content =~ /\{%\s*for\s+/ && depth < max_depth
364
+ content = content.gsub(/\{%\s*for\s+(\w+)(?:\s*,\s*(\w+))?\s+in\s+(.+?)\s*%\}(.*?)\{%\s*endfor\s*%\}/m) do
365
+ key_or_val = Regexp.last_match(1)
366
+ val_name = Regexp.last_match(2)
367
+ collection_expr = Regexp.last_match(3)
368
+ body = Regexp.last_match(4)
369
+ collection = evaluate_expression(collection_expr)
370
+ output = +""
371
+ items = case collection
372
+ when Array then collection
373
+ when Hash then collection.to_a
374
+ when Range then collection.to_a
375
+ when Integer then (0...collection).to_a
376
+ else []
377
+ end
378
+ sub_engine = TwigEngine.new({}, @base_dir)
379
+ items.each_with_index do |item, index|
380
+ loop_context = @context.dup
381
+ loop_context["loop"] = {
382
+ "index" => index + 1, "index0" => index,
383
+ "first" => index == 0, "last" => index == items.length - 1,
384
+ "length" => items.length,
385
+ "revindex" => items.length - index,
386
+ "revindex0" => items.length - index - 1
387
+ }
388
+ if val_name
389
+ loop_context[key_or_val] = item.is_a?(Array) ? item[0] : index
390
+ loop_context[val_name] = item.is_a?(Array) ? item[1] : item
391
+ else
392
+ loop_context[key_or_val] = item
393
+ end
394
+ sub_engine.reset_context(loop_context)
395
+ output << sub_engine.render(body)
396
+ end
397
+ output
398
+ end
399
+ depth += 1
400
+ end
401
+ content
402
+ end
403
+
404
+ def process_conditionals(content)
405
+ max_depth = 10
406
+ depth = 0
407
+ while content =~ /\{%\s*if\s+/ && depth < max_depth
408
+ content = content.gsub(
409
+ /\{%\s*if\s+(.+?)\s*%\}(.*?)(?:\{%\s*else\s*%\}(.*?))?\{%\s*endif\s*%\}/m
410
+ ) do
411
+ condition = Regexp.last_match(1)
412
+ true_body = Regexp.last_match(2)
413
+ false_body = Regexp.last_match(3) || ""
414
+
415
+ if true_body =~ /\{%\s*elseif\s+/
416
+ segments = true_body.split(/\{%\s*elseif\s+/)
417
+ if evaluate_condition(condition)
418
+ segments[0]
419
+ else
420
+ resolved = false
421
+ result = ""
422
+ segments[1..].each do |seg|
423
+ next if resolved
424
+ if seg =~ /\A(.+?)\s*%\}(.*)\z/m
425
+ if evaluate_condition(Regexp.last_match(1))
426
+ result = Regexp.last_match(2)
427
+ resolved = true
428
+ end
429
+ end
430
+ end
431
+ resolved ? result : false_body
432
+ end
433
+ elsif evaluate_condition(condition)
434
+ true_body
435
+ else
436
+ false_body
437
+ end
438
+ end
439
+ depth += 1
440
+ end
441
+ content
442
+ end
443
+
444
+ def process_set(content)
445
+ content.gsub(/\{%\s*set\s+(\w+)\s*=\s*(.+?)\s*%\}/) do
446
+ var_name = Regexp.last_match(1)
447
+ expr = Regexp.last_match(2)
448
+ @context[var_name] = evaluate_expression(expr)
449
+ ""
450
+ end
451
+ end
452
+
453
+ def process_expressions(content)
454
+ content.gsub(/\{\{\s*(.+?)\s*\}\}/) do
455
+ expr = Regexp.last_match(1)
456
+ evaluate_piped_expression(expr).to_s
457
+ end
458
+ end
459
+
460
+ def evaluate_piped_expression(expr)
461
+ parts = expr.split("|").map(&:strip)
462
+ value = evaluate_expression(parts[0])
463
+ parts[1..].each do |filter_expr|
464
+ if filter_expr =~ /\A(\w+)(?:\((.+?)\))?\z/
465
+ filter_name = Regexp.last_match(1)
466
+ args_str = Regexp.last_match(2)
467
+ args = args_str ? parse_filter_args(args_str) : []
468
+ filter = FILTERS[filter_name]
469
+ value = args.empty? ? filter.call(value) : filter.call(value, *args) if filter
470
+ end
471
+ end
472
+ value
473
+ end
474
+
475
+ def parse_filter_args(args_str)
476
+ args = []
477
+ current = +""
478
+ in_quote = nil
479
+ escaped = false
480
+ args_str.each_char do |ch|
481
+ if escaped
482
+ current << ch
483
+ escaped = false
484
+ elsif ch == "\\"
485
+ current << ch
486
+ escaped = true
487
+ elsif in_quote
488
+ if ch == in_quote
489
+ current << ch
490
+ in_quote = nil
491
+ else
492
+ current << ch
493
+ end
494
+ elsif ch == '"' || ch == "'"
495
+ in_quote = ch
496
+ current << ch
497
+ elsif ch == ","
498
+ args << current.strip
499
+ current = +""
500
+ else
501
+ current << ch
502
+ end
503
+ end
504
+ args << current.strip unless current.strip.empty?
505
+
506
+ args.map do |arg|
507
+ if arg =~ /\A(["'])(.*)\1\z/m
508
+ process_escapes(Regexp.last_match(2))
509
+ elsif arg =~ /\A\d+\z/
510
+ arg.to_i
511
+ elsif arg =~ /\A\d+\.\d+\z/
512
+ arg.to_f
513
+ else
514
+ evaluate_expression(arg)
515
+ end
516
+ end
517
+ end
518
+
519
+ # Find the first occurrence of +needle+ outside quotes and parentheses.
520
+ # Returns the index, or -1 if not found.
521
+ def find_outside_quotes(expr, needle)
522
+ in_q = nil
523
+ depth = 0
524
+ i = 0
525
+ while i <= expr.length - needle.length
526
+ ch = expr[i]
527
+ if (ch == '"' || ch == "'") && depth == 0
528
+ if in_q.nil?
529
+ in_q = ch
530
+ elsif ch == in_q
531
+ in_q = nil
532
+ end
533
+ i += 1
534
+ next
535
+ end
536
+ if in_q
537
+ i += 1
538
+ next
539
+ end
540
+ if ch == "("
541
+ depth += 1
542
+ elsif ch == ")"
543
+ depth -= 1
544
+ end
545
+ if depth == 0 && expr[i, needle.length] == needle
546
+ return i
547
+ end
548
+ i += 1
549
+ end
550
+ -1
551
+ end
552
+
553
+ # Split +expr+ on +sep+ only when +sep+ is outside quotes and parentheses.
554
+ def split_outside_quotes(expr, sep)
555
+ parts = []
556
+ current_start = 0
557
+ in_q = nil
558
+ depth = 0
559
+ i = 0
560
+ while i <= expr.length - sep.length
561
+ ch = expr[i]
562
+ if (ch == '"' || ch == "'") && depth == 0
563
+ if in_q.nil?
564
+ in_q = ch
565
+ elsif ch == in_q
566
+ in_q = nil
567
+ end
568
+ i += 1
569
+ next
570
+ end
571
+ if in_q
572
+ i += 1
573
+ next
574
+ end
575
+ if ch == "("
576
+ depth += 1
577
+ elsif ch == ")"
578
+ depth -= 1
579
+ end
580
+ if depth == 0 && expr[i, sep.length] == sep
581
+ parts << expr[current_start...i]
582
+ i += sep.length
583
+ current_start = i
584
+ next
585
+ end
586
+ i += 1
587
+ end
588
+ parts << expr[current_start..]
589
+ parts
590
+ end
591
+
592
+ def evaluate_expression(expr)
593
+ expr = expr.strip
594
+
595
+ # String literal early-return
596
+ if expr =~ /\A"([^"]*)"\z/ || expr =~ /\A'([^']*)'\z/
597
+ return process_escapes(Regexp.last_match(1))
598
+ end
599
+
600
+ return expr.to_i if expr =~ /\A-?\d+\z/
601
+ return expr.to_f if expr =~ /\A-?\d+\.\d+\z/
602
+ return true if expr == "true"
603
+ return false if expr == "false"
604
+ return nil if expr == "null" || expr == "none" || expr == "nil"
605
+ if expr =~ /\A\[(.+)\]\z/
606
+ return Regexp.last_match(1).split(",").map { |i| evaluate_expression(i.strip) }
607
+ end
608
+ if expr =~ /\A(\d+)\.\.(\d+)\z/
609
+ return (Regexp.last_match(1).to_i..Regexp.last_match(2).to_i)
610
+ end
611
+
612
+ # Null coalescing: value ?? "default"
613
+ if find_outside_quotes(expr, "??") >= 0
614
+ pos = find_outside_quotes(expr, "??")
615
+ left = expr[0...pos]
616
+ right = expr[(pos + 2)..]
617
+ val = evaluate_expression(left.strip)
618
+ return val unless val.nil?
619
+ return evaluate_expression(right.strip)
620
+ end
621
+
622
+ # String concatenation with ~ (only outside quotes/parens)
623
+ if find_outside_quotes(expr, "~") >= 0
624
+ parts = split_outside_quotes(expr, "~")
625
+ return parts.map { |p| (evaluate_expression(p.strip) || "").to_s }.join
626
+ end
627
+
628
+ # Comparison operators (only outside quotes/parens)
629
+ [" not in ", " in ", " is not ", " is ", "!=", "==", ">=", "<=", ">", "<", " and ", " or ", " not "].each do |op|
630
+ if find_outside_quotes(expr, op) >= 0
631
+ return evaluate_condition(expr)
632
+ end
633
+ end
634
+
635
+ if expr =~ /\A(.+?)\s*(\+|-|\*|\/|%)\s*(.+)\z/
636
+ left = evaluate_expression(Regexp.last_match(1))
637
+ op = Regexp.last_match(2)
638
+ right = evaluate_expression(Regexp.last_match(3))
639
+ return apply_math(left, op, right)
640
+ end
641
+
642
+ # Function call with dotted name: obj.method(args)
643
+ if expr =~ /\A([\w.]+)\s*\((.*)\)\z/m
644
+ func_name = Regexp.last_match(1)
645
+ args_str = Regexp.last_match(2)
646
+ if func_name.include?(".")
647
+ last_dot = func_name.rindex(".")
648
+ obj_path = func_name[0...last_dot]
649
+ method_name = func_name[(last_dot + 1)..]
650
+ obj = resolve_variable(obj_path)
651
+ if obj.respond_to?(:call)
652
+ # obj itself is callable — unlikely but handle
653
+ elsif obj.is_a?(Hash)
654
+ callable = obj[method_name] || obj[method_name.to_sym] || obj[method_name.to_s]
655
+ if callable.respond_to?(:call)
656
+ args = args_str && !args_str.strip.empty? ? parse_filter_args(args_str) : []
657
+ return callable.call(*args)
658
+ end
659
+ elsif obj.respond_to?(method_name.to_sym)
660
+ args = args_str && !args_str.strip.empty? ? parse_filter_args(args_str) : []
661
+ return obj.send(method_name.to_sym, *args)
662
+ end
663
+ return nil
664
+ end
665
+ end
666
+
667
+ resolve_variable(expr)
668
+ end
669
+
670
+ def resolve_variable(expr)
671
+ parts = split_dot_parts(expr)
672
+ value = @context
673
+ parts.each do |part|
674
+ if part =~ /\A(\w+)\((.*)?\)\z/m
675
+ # Method call: e.g. t("key") or greet("hello", "world")
676
+ method_name = Regexp.last_match(1)
677
+ args_str = Regexp.last_match(2)
678
+ callable = access_value(value, method_name)
679
+ if callable.respond_to?(:call)
680
+ args = args_str && !args_str.strip.empty? ? parse_filter_args(args_str) : []
681
+ value = callable.call(*args)
682
+ elsif callable.respond_to?(method_name.to_sym)
683
+ args = args_str && !args_str.strip.empty? ? parse_filter_args(args_str) : []
684
+ value = callable.send(method_name.to_sym, *args)
685
+ else
686
+ return nil
687
+ end
688
+ elsif part =~ /\A(\w+)\[(.+?)\]\z/
689
+ base = Regexp.last_match(1)
690
+ index = Regexp.last_match(2).strip
691
+ value = access_value(value, base)
692
+ if index =~ /\A["'](.*)["']\z/
693
+ # Quoted string literal — use as-is
694
+ index = Regexp.last_match(1)
695
+ value = access_value(value, index)
696
+ elsif index =~ /\A\d+\z/
697
+ value = value[index.to_i] if value.respond_to?(:[])
698
+ else
699
+ # Resolve as a variable from context
700
+ resolved = resolve_variable(index)
701
+ value = access_value(value, resolved.to_s) unless resolved.nil?
702
+ value = nil if resolved.nil?
703
+ end
704
+ else
705
+ value = access_value(value, part)
706
+ end
707
+ return nil if value.nil?
708
+ end
709
+ value
710
+ end
711
+
712
+ # Split expression on dots, respecting quotes, parentheses and brackets.
713
+ # Dots inside quoted strings or nested parens/brackets are NOT separators.
714
+ # Bracket access like foo["bar"] is emitted as a separate part.
715
+ def split_dot_parts(expr)
716
+ parts = []
717
+ current = +""
718
+ paren_depth = 0
719
+ bracket_depth = 0
720
+ in_quote = nil
721
+
722
+ i = 0
723
+ chars = expr.chars
724
+ while i < chars.length
725
+ ch = chars[i]
726
+
727
+ if in_quote
728
+ current << ch
729
+ # End quote only when matching unescaped closer
730
+ in_quote = nil if ch == in_quote && (i == 0 || chars[i - 1] != "\\")
731
+ elsif ch == '"' || ch == "'"
732
+ in_quote = ch
733
+ current << ch
734
+ elsif ch == "("
735
+ paren_depth += 1
736
+ current << ch
737
+ elsif ch == ")"
738
+ paren_depth -= 1
739
+ current << ch
740
+ elsif ch == "[" && paren_depth == 0
741
+ if bracket_depth == 0 && !current.empty?
742
+ # Start of bracket access on an existing part -- split here
743
+ parts << current
744
+ current = +""
745
+ end
746
+ bracket_depth += 1
747
+ current << ch
748
+ elsif ch == "]"
749
+ bracket_depth -= 1
750
+ current << ch
751
+ if bracket_depth == 0 && paren_depth == 0
752
+ # End of top-level bracket access -- emit as its own part
753
+ parts << current
754
+ current = +""
755
+ # Skip a trailing dot that merely chains the next segment
756
+ i += 1 if i + 1 < chars.length && chars[i + 1] == "."
757
+ end
758
+ elsif ch == "." && paren_depth == 0 && bracket_depth == 0
759
+ parts << current unless current.empty?
760
+ current = +""
761
+ else
762
+ current << ch
763
+ end
764
+
765
+ i += 1
766
+ end
767
+ parts << current unless current.empty?
768
+ parts
769
+ end
770
+
771
+ # Process backslash escape sequences in a single pass so that
772
+ # \\' does not collapse to ' (it should become \').
773
+ def process_escapes(s)
774
+ out = +""
775
+ i = 0
776
+ while i < s.length
777
+ if s[i] == "\\" && i + 1 < s.length
778
+ nxt = s[i + 1]
779
+ case nxt
780
+ when "n" then out << "\n"; i += 2
781
+ when "t" then out << "\t"; i += 2
782
+ when "\\" then out << "\\"; i += 2
783
+ when "'" then out << "'"; i += 2
784
+ when '"' then out << '"'; i += 2
785
+ else out << "\\"; i += 1
786
+ end
787
+ else
788
+ out << s[i]
789
+ i += 1
790
+ end
791
+ end
792
+ out
793
+ end
794
+
795
+ def access_value(obj, key)
796
+ return nil if obj.nil?
797
+ if obj.is_a?(Hash)
798
+ obj[key] || obj[key.to_sym] || obj[key.to_s]
799
+ elsif obj.respond_to?(key.to_sym)
800
+ obj.send(key.to_sym)
801
+ elsif obj.respond_to?(:[])
802
+ obj[key] rescue nil
803
+ end
804
+ end
805
+
806
+ def evaluate_condition(expr)
807
+ expr = expr.strip
808
+ return !evaluate_condition(Regexp.last_match(1)) if expr =~ /\Anot\s+(.+)\z/
809
+ if expr.include?(" and ")
810
+ return expr.split(/\s+and\s+/).all? { |p| evaluate_condition(p) }
811
+ end
812
+ if expr.include?(" or ")
813
+ return expr.split(/\s+or\s+/).any? { |p| evaluate_condition(p) }
814
+ end
815
+ if expr =~ /\A(.+?)\s*(==|!=|>=|<=|>|<)\s*(.+)\z/
816
+ left = evaluate_expression(Regexp.last_match(1).strip)
817
+ op = Regexp.last_match(2).strip
818
+ right = evaluate_expression(Regexp.last_match(3).strip)
819
+ return compare(left, op, right)
820
+ end
821
+ if expr =~ /\A(.+?)\s+in\s+(.+)\z/
822
+ needle = evaluate_expression(Regexp.last_match(1).strip)
823
+ haystack = evaluate_expression(Regexp.last_match(2).strip)
824
+ return haystack.respond_to?(:include?) ? haystack.include?(needle) : false
825
+ end
826
+ if expr =~ /\A(.+?)\s+is\s+defined\z/
827
+ return !evaluate_expression(Regexp.last_match(1).strip).nil?
828
+ end
829
+ if expr =~ /\A(.+?)\s+is\s+empty\z/
830
+ val = evaluate_expression(Regexp.last_match(1).strip)
831
+ return val.nil? || (val.respond_to?(:empty?) && val.empty?)
832
+ end
833
+ truthy?(evaluate_expression(expr))
834
+ end
835
+
836
+ def compare(left, op, right)
837
+ case op
838
+ when "==" then left == right
839
+ when "!=" then left != right
840
+ when ">" then left.to_f > right.to_f
841
+ when "<" then left.to_f < right.to_f
842
+ when ">=" then left.to_f >= right.to_f
843
+ when "<=" then left.to_f <= right.to_f
844
+ else false
845
+ end
846
+ end
847
+
848
+ def apply_math(left, op, right)
849
+ l = left.to_f
850
+ r = right.to_f
851
+ case op
852
+ when "+" then l + r
853
+ when "-" then l - r
854
+ when "*" then l * r
855
+ when "/" then r != 0 ? l / r : 0
856
+ when "%" then l % r
857
+ else 0
858
+ end
859
+ end
860
+
861
+ def truthy?(val)
862
+ return false if val.nil? || val == false || val == 0 || val == ""
863
+ return false if val.respond_to?(:empty?) && val.empty?
864
+ true
865
+ end
866
+
867
+ def resolve_template(path)
868
+ full = File.join(@base_dir, path)
869
+ return full if File.exist?(full)
870
+ Tina4::Template::TEMPLATE_DIRS.each do |dir|
871
+ candidate = File.join(Dir.pwd, dir, path)
872
+ return candidate if File.exist?(candidate)
873
+ end
874
+ nil
875
+ end
876
+ end
877
+
878
+ class ErbEngine
879
+ def self.render(content, context)
880
+ require "erb"
881
+ binding_obj = create_binding(context)
882
+ ERB.new(content, trim_mode: "-").result(binding_obj)
883
+ end
884
+
885
+ def self.create_binding(context)
886
+ b = binding
887
+ context.each do |key, value|
888
+ b.local_variable_set(key.to_sym, value)
889
+ end
890
+ b
891
+ end
892
+ end
893
+ end
894
+ end