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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +80 -80
- data/LICENSE.txt +21 -21
- data/README.md +137 -137
- data/exe/tina4ruby +5 -5
- data/lib/tina4/ai.rb +696 -696
- data/lib/tina4/api.rb +189 -189
- data/lib/tina4/auth.rb +305 -305
- data/lib/tina4/auto_crud.rb +244 -244
- data/lib/tina4/cache.rb +154 -154
- data/lib/tina4/cli.rb +1449 -1449
- data/lib/tina4/constants.rb +46 -46
- data/lib/tina4/container.rb +74 -74
- data/lib/tina4/cors.rb +74 -74
- data/lib/tina4/crud.rb +692 -692
- data/lib/tina4/database/sqlite3_adapter.rb +165 -165
- data/lib/tina4/database.rb +625 -625
- data/lib/tina4/database_result.rb +208 -208
- data/lib/tina4/debug.rb +8 -8
- data/lib/tina4/dev.rb +14 -14
- data/lib/tina4/dev_admin.rb +935 -935
- data/lib/tina4/dev_mailbox.rb +191 -191
- data/lib/tina4/drivers/firebird_driver.rb +124 -110
- data/lib/tina4/drivers/mongodb_driver.rb +561 -561
- data/lib/tina4/drivers/mssql_driver.rb +112 -112
- data/lib/tina4/drivers/mysql_driver.rb +90 -90
- data/lib/tina4/drivers/odbc_driver.rb +191 -191
- data/lib/tina4/drivers/postgres_driver.rb +116 -106
- data/lib/tina4/drivers/sqlite_driver.rb +122 -122
- data/lib/tina4/env.rb +95 -95
- data/lib/tina4/error_overlay.rb +252 -252
- data/lib/tina4/events.rb +109 -109
- data/lib/tina4/field_types.rb +154 -154
- data/lib/tina4/frond.rb +2025 -2025
- data/lib/tina4/gallery/auth/meta.json +1 -1
- data/lib/tina4/gallery/auth/src/routes/api/gallery_auth.rb +114 -114
- data/lib/tina4/gallery/database/meta.json +1 -1
- data/lib/tina4/gallery/database/src/routes/api/gallery_db.rb +43 -43
- data/lib/tina4/gallery/error-overlay/meta.json +1 -1
- data/lib/tina4/gallery/error-overlay/src/routes/api/gallery_crash.rb +17 -17
- data/lib/tina4/gallery/orm/meta.json +1 -1
- data/lib/tina4/gallery/orm/src/routes/api/gallery_products.rb +16 -16
- data/lib/tina4/gallery/queue/meta.json +1 -1
- data/lib/tina4/gallery/queue/src/routes/api/gallery_queue.rb +325 -325
- data/lib/tina4/gallery/rest-api/meta.json +1 -1
- data/lib/tina4/gallery/rest-api/src/routes/api/gallery_hello.rb +14 -14
- data/lib/tina4/gallery/templates/meta.json +1 -1
- data/lib/tina4/gallery/templates/src/routes/gallery_page.rb +12 -12
- data/lib/tina4/gallery/templates/src/templates/gallery_page.twig +257 -257
- data/lib/tina4/graphql.rb +966 -966
- data/lib/tina4/health.rb +39 -39
- data/lib/tina4/html_element.rb +170 -170
- data/lib/tina4/job.rb +80 -80
- data/lib/tina4/localization.rb +168 -168
- data/lib/tina4/log.rb +203 -203
- data/lib/tina4/mcp.rb +696 -696
- data/lib/tina4/messenger.rb +587 -587
- data/lib/tina4/metrics.rb +793 -793
- data/lib/tina4/middleware.rb +445 -445
- data/lib/tina4/migration.rb +451 -451
- data/lib/tina4/orm.rb +790 -790
- data/lib/tina4/public/css/tina4.css +2463 -2463
- data/lib/tina4/public/css/tina4.min.css +1 -1
- data/lib/tina4/public/images/logo.svg +5 -5
- data/lib/tina4/public/js/frond.min.js +2 -2
- data/lib/tina4/public/js/tina4-dev-admin.js +565 -565
- data/lib/tina4/public/js/tina4-dev-admin.min.js +480 -480
- data/lib/tina4/public/js/tina4.min.js +92 -92
- data/lib/tina4/public/js/tina4js.min.js +48 -48
- data/lib/tina4/public/swagger/index.html +90 -90
- data/lib/tina4/public/swagger/oauth2-redirect.html +63 -63
- data/lib/tina4/query_builder.rb +380 -380
- data/lib/tina4/queue.rb +366 -366
- data/lib/tina4/queue_backends/kafka_backend.rb +80 -80
- data/lib/tina4/queue_backends/lite_backend.rb +298 -298
- data/lib/tina4/queue_backends/mongo_backend.rb +126 -126
- data/lib/tina4/queue_backends/rabbitmq_backend.rb +73 -73
- data/lib/tina4/rack_app.rb +817 -817
- data/lib/tina4/rate_limiter.rb +130 -130
- data/lib/tina4/request.rb +268 -255
- data/lib/tina4/response.rb +346 -346
- data/lib/tina4/response_cache.rb +551 -551
- data/lib/tina4/router.rb +406 -406
- data/lib/tina4/scss/tina4css/_alerts.scss +34 -34
- data/lib/tina4/scss/tina4css/_badges.scss +22 -22
- data/lib/tina4/scss/tina4css/_buttons.scss +69 -69
- data/lib/tina4/scss/tina4css/_cards.scss +49 -49
- data/lib/tina4/scss/tina4css/_forms.scss +156 -156
- data/lib/tina4/scss/tina4css/_grid.scss +81 -81
- data/lib/tina4/scss/tina4css/_modals.scss +84 -84
- data/lib/tina4/scss/tina4css/_nav.scss +149 -149
- data/lib/tina4/scss/tina4css/_reset.scss +94 -94
- data/lib/tina4/scss/tina4css/_tables.scss +54 -54
- data/lib/tina4/scss/tina4css/_typography.scss +55 -55
- data/lib/tina4/scss/tina4css/_utilities.scss +197 -197
- data/lib/tina4/scss/tina4css/_variables.scss +117 -117
- data/lib/tina4/scss/tina4css/base.scss +1 -1
- data/lib/tina4/scss/tina4css/colors.scss +48 -48
- data/lib/tina4/scss/tina4css/tina4.scss +17 -17
- data/lib/tina4/scss_compiler.rb +178 -178
- data/lib/tina4/seeder.rb +567 -567
- data/lib/tina4/service_runner.rb +303 -303
- data/lib/tina4/session.rb +297 -297
- data/lib/tina4/session_handlers/database_handler.rb +72 -72
- data/lib/tina4/session_handlers/file_handler.rb +67 -67
- data/lib/tina4/session_handlers/mongo_handler.rb +49 -49
- data/lib/tina4/session_handlers/redis_handler.rb +43 -43
- data/lib/tina4/session_handlers/valkey_handler.rb +43 -43
- data/lib/tina4/shutdown.rb +84 -84
- data/lib/tina4/sql_translation.rb +158 -158
- data/lib/tina4/swagger.rb +124 -124
- data/lib/tina4/template.rb +894 -894
- data/lib/tina4/templates/base.twig +26 -26
- data/lib/tina4/templates/errors/302.twig +14 -14
- data/lib/tina4/templates/errors/401.twig +9 -9
- data/lib/tina4/templates/errors/403.twig +29 -29
- data/lib/tina4/templates/errors/404.twig +29 -29
- data/lib/tina4/templates/errors/500.twig +38 -38
- data/lib/tina4/templates/errors/502.twig +9 -9
- data/lib/tina4/templates/errors/503.twig +12 -12
- data/lib/tina4/templates/errors/base.twig +37 -37
- data/lib/tina4/test_client.rb +159 -159
- data/lib/tina4/testing.rb +340 -340
- data/lib/tina4/validator.rb +174 -174
- data/lib/tina4/version.rb +1 -1
- data/lib/tina4/webserver.rb +312 -312
- data/lib/tina4/websocket.rb +343 -343
- data/lib/tina4/websocket_backplane.rb +190 -190
- data/lib/tina4/wsdl.rb +564 -564
- data/lib/tina4.rb +458 -458
- data/lib/tina4ruby.rb +4 -4
- metadata +3 -3
data/lib/tina4/template.rb
CHANGED
|
@@ -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 = { "&" => "&", "<" => "<", ">" => ">", '"' => """, "'" => "'" }.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 = { "&" => "&", "<" => "<", ">" => ">", '"' => """, "'" => "'" }.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
|