rhales 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CLAUDE.locale.txt +7 -0
- data/CLAUDE.md +90 -0
- data/LICENSE.txt +21 -0
- data/README.md +881 -0
- data/lib/rhales/adapters/base_auth.rb +106 -0
- data/lib/rhales/adapters/base_request.rb +97 -0
- data/lib/rhales/adapters/base_session.rb +93 -0
- data/lib/rhales/configuration.rb +156 -0
- data/lib/rhales/context.rb +240 -0
- data/lib/rhales/csp.rb +94 -0
- data/lib/rhales/errors/hydration_collision_error.rb +85 -0
- data/lib/rhales/errors.rb +36 -0
- data/lib/rhales/hydration_data_aggregator.rb +220 -0
- data/lib/rhales/hydration_registry.rb +58 -0
- data/lib/rhales/hydrator.rb +141 -0
- data/lib/rhales/parsers/handlebars-grammar-review.txt +39 -0
- data/lib/rhales/parsers/handlebars_parser.rb +727 -0
- data/lib/rhales/parsers/rue_format_parser.rb +385 -0
- data/lib/rhales/refinements/require_refinements.rb +236 -0
- data/lib/rhales/rue_document.rb +304 -0
- data/lib/rhales/template_engine.rb +353 -0
- data/lib/rhales/tilt.rb +214 -0
- data/lib/rhales/version.rb +6 -0
- data/lib/rhales/view.rb +412 -0
- data/lib/rhales/view_composition.rb +165 -0
- data/lib/rhales.rb +57 -0
- data/rhales.gemspec +46 -0
- metadata +78 -0
@@ -0,0 +1,304 @@
|
|
1
|
+
# lib/rhales/rue_document.rb
|
2
|
+
|
3
|
+
require_relative 'parsers/rue_format_parser'
|
4
|
+
|
5
|
+
module Rhales
|
6
|
+
# High-level interface for parsed .rue files
|
7
|
+
#
|
8
|
+
# This class provides a convenient interface to .rue files parsed by RueFormatParser.
|
9
|
+
# It uses RueFormatParser internally for low-level parsing and provides high-level
|
10
|
+
# methods for accessing sections, attributes, and extracted data.
|
11
|
+
#
|
12
|
+
# Features:
|
13
|
+
# - High-level interface to RueFormatParser AST
|
14
|
+
# - Accurate error reporting with line/column information
|
15
|
+
# - Convenient section access methods
|
16
|
+
# - Section validation and attribute extraction
|
17
|
+
# - Variable and partial dependency analysis
|
18
|
+
# - AST-to-string conversion when needed
|
19
|
+
#
|
20
|
+
# Note: This class represents a parsed .rue file document, similar to how
|
21
|
+
# HTML::Document represents a parsed HTML document.
|
22
|
+
#
|
23
|
+
# Usage:
|
24
|
+
# document = RueDocument.new(rue_content)
|
25
|
+
# document.parse!
|
26
|
+
# template_section = document.section('template')
|
27
|
+
# variables = document.template_variables
|
28
|
+
class RueDocument
|
29
|
+
class ParseError < ::Rhales::ValidationError; end
|
30
|
+
class SectionMissingError < ParseError; end
|
31
|
+
class SectionDuplicateError < ParseError; end
|
32
|
+
class InvalidSyntaxError < ParseError; end
|
33
|
+
|
34
|
+
# At least one of these sections must be present
|
35
|
+
REQUIRES_ONE_OF_SECTIONS = %w[data template].freeze
|
36
|
+
KNOWN_SECTIONS = %w[data template logic].freeze
|
37
|
+
ALL_SECTIONS = KNOWN_SECTIONS.freeze
|
38
|
+
|
39
|
+
# Known data section attributes
|
40
|
+
KNOWN_DATA_ATTRIBUTES = %w[window merge layout].freeze
|
41
|
+
|
42
|
+
attr_reader :content, :file_path, :grammar, :ast
|
43
|
+
|
44
|
+
def initialize(content, file_path = nil)
|
45
|
+
@content = content
|
46
|
+
@file_path = file_path
|
47
|
+
@grammar = RueFormatParser.new(content, file_path)
|
48
|
+
@ast = nil
|
49
|
+
end
|
50
|
+
|
51
|
+
def parse!
|
52
|
+
@grammar.parse!
|
53
|
+
@ast = @grammar.ast
|
54
|
+
parse_data_attributes!
|
55
|
+
self
|
56
|
+
rescue RueFormatParser::ParseError => ex
|
57
|
+
raise ParseError, "Parser error: #{ex.message}"
|
58
|
+
end
|
59
|
+
|
60
|
+
def sections
|
61
|
+
return {} unless @ast
|
62
|
+
|
63
|
+
@grammar.sections.transform_values do |section_node|
|
64
|
+
convert_nodes_to_string(section_node.value[:content])
|
65
|
+
end
|
66
|
+
end
|
67
|
+
|
68
|
+
def convert_nodes_to_string(nodes)
|
69
|
+
nodes.map { |node| convert_node_to_string(node) }.join
|
70
|
+
end
|
71
|
+
|
72
|
+
def convert_node_to_string(node)
|
73
|
+
case node.type
|
74
|
+
when :text
|
75
|
+
node.value
|
76
|
+
when :variable_expression
|
77
|
+
name = node.value[:name]
|
78
|
+
raw = node.value[:raw]
|
79
|
+
raw ? "{{{#{name}}}}" : "{{#{name}}}"
|
80
|
+
when :partial_expression
|
81
|
+
"{{> #{node.value[:name]}}}"
|
82
|
+
when :if_block
|
83
|
+
condition = node.value[:condition]
|
84
|
+
if_content = convert_nodes_to_string(node.value[:if_content])
|
85
|
+
else_content = convert_nodes_to_string(node.value[:else_content])
|
86
|
+
if else_content.empty?
|
87
|
+
"{{#if #{condition}}}#{if_content}{{/if}}"
|
88
|
+
else
|
89
|
+
"{{#if #{condition}}}#{if_content}{{else}}#{else_content}{{/if}}"
|
90
|
+
end
|
91
|
+
when :unless_block
|
92
|
+
condition = node.value[:condition]
|
93
|
+
content = convert_nodes_to_string(node.value[:content])
|
94
|
+
"{{#unless #{condition}}}#{content}{{/unless}}"
|
95
|
+
when :each_block
|
96
|
+
items = node.value[:items]
|
97
|
+
content = convert_nodes_to_string(node.value[:content])
|
98
|
+
"{{#each #{items}}}#{content}{{/each}}"
|
99
|
+
when :handlebars_expression
|
100
|
+
# Handle legacy format for data sections
|
101
|
+
if node.value[:raw]
|
102
|
+
"{{{#{node.value[:content]}}}"
|
103
|
+
else
|
104
|
+
"{{#{node.value[:content]}}}"
|
105
|
+
end
|
106
|
+
else
|
107
|
+
''
|
108
|
+
end
|
109
|
+
end
|
110
|
+
|
111
|
+
def section(name)
|
112
|
+
sections[name]
|
113
|
+
end
|
114
|
+
|
115
|
+
def data_attributes
|
116
|
+
@data_attributes ||= {}
|
117
|
+
end
|
118
|
+
|
119
|
+
def window_attribute
|
120
|
+
data_attributes['window'] || 'data'
|
121
|
+
end
|
122
|
+
|
123
|
+
def schema_path
|
124
|
+
data_attributes['schema']
|
125
|
+
end
|
126
|
+
|
127
|
+
def merge_strategy
|
128
|
+
data_attributes['merge']
|
129
|
+
end
|
130
|
+
|
131
|
+
def layout
|
132
|
+
data_attributes['layout']
|
133
|
+
end
|
134
|
+
|
135
|
+
def section?(name)
|
136
|
+
@grammar.sections.key?(name)
|
137
|
+
end
|
138
|
+
|
139
|
+
# Get the raw section node with location information
|
140
|
+
def section_node(name)
|
141
|
+
@grammar.sections[name]
|
142
|
+
end
|
143
|
+
|
144
|
+
def partials
|
145
|
+
return [] unless @ast
|
146
|
+
|
147
|
+
partials = []
|
148
|
+
extract_partials_from_node(@ast, partials)
|
149
|
+
partials.uniq
|
150
|
+
end
|
151
|
+
|
152
|
+
def template_variables
|
153
|
+
extract_variables_from_section('template', exclude_partials: true)
|
154
|
+
end
|
155
|
+
|
156
|
+
def data_variables
|
157
|
+
extract_variables_from_section('data')
|
158
|
+
end
|
159
|
+
|
160
|
+
def all_variables
|
161
|
+
(template_variables + data_variables).uniq
|
162
|
+
end
|
163
|
+
|
164
|
+
private
|
165
|
+
|
166
|
+
def extract_partials_from_node(node, partials)
|
167
|
+
return unless @ast
|
168
|
+
|
169
|
+
# Extract from all sections
|
170
|
+
@grammar.sections.each do |section_name, section_node|
|
171
|
+
content_nodes = section_node.value[:content]
|
172
|
+
next unless content_nodes.is_a?(Array)
|
173
|
+
|
174
|
+
extract_partials_from_content_nodes(content_nodes, partials)
|
175
|
+
end
|
176
|
+
end
|
177
|
+
|
178
|
+
def extract_partials_from_content_nodes(content_nodes, partials)
|
179
|
+
content_nodes.each do |content_node|
|
180
|
+
case content_node.type
|
181
|
+
when :partial_expression
|
182
|
+
partials << content_node.value[:name]
|
183
|
+
when :if_block
|
184
|
+
extract_partials_from_content_nodes(content_node.value[:if_content], partials)
|
185
|
+
extract_partials_from_content_nodes(content_node.value[:else_content], partials)
|
186
|
+
when :unless_block, :each_block
|
187
|
+
extract_partials_from_content_nodes(content_node.value[:content], partials)
|
188
|
+
when :handlebars_expression
|
189
|
+
# Handle old format for data sections
|
190
|
+
content = content_node.value[:content]
|
191
|
+
if content.start_with?('>')
|
192
|
+
partials << content[1..].strip
|
193
|
+
end
|
194
|
+
end
|
195
|
+
end
|
196
|
+
end
|
197
|
+
|
198
|
+
def extract_variables_from_section(section_name, exclude_partials: false)
|
199
|
+
section_node = @grammar.sections[section_name]
|
200
|
+
return [] unless section_node
|
201
|
+
|
202
|
+
variables = []
|
203
|
+
content_nodes = section_node.value[:content]
|
204
|
+
extract_variables_from_content(content_nodes, variables, exclude_partials: exclude_partials)
|
205
|
+
variables.uniq
|
206
|
+
end
|
207
|
+
|
208
|
+
def extract_variables_from_content(content_nodes, variables, exclude_partials: false)
|
209
|
+
return unless content_nodes.is_a?(Array)
|
210
|
+
|
211
|
+
content_nodes.each do |node|
|
212
|
+
case node.type
|
213
|
+
when :variable_expression
|
214
|
+
variables << node.value[:name]
|
215
|
+
when :if_block
|
216
|
+
variables << node.value[:condition]
|
217
|
+
extract_variables_from_content(node.value[:if_content], variables, exclude_partials: exclude_partials)
|
218
|
+
extract_variables_from_content(node.value[:else_content], variables, exclude_partials: exclude_partials)
|
219
|
+
when :unless_block
|
220
|
+
variables << node.value[:condition]
|
221
|
+
extract_variables_from_content(node.value[:content], variables, exclude_partials: exclude_partials)
|
222
|
+
when :each_block
|
223
|
+
variables << node.value[:items]
|
224
|
+
extract_variables_from_content(node.value[:content], variables, exclude_partials: exclude_partials)
|
225
|
+
when :partial_expression
|
226
|
+
# Skip partials if requested
|
227
|
+
next if exclude_partials
|
228
|
+
when :text
|
229
|
+
# Extract handlebars expressions from text content (for data sections)
|
230
|
+
extract_variables_from_text(node.value, variables, exclude_partials: exclude_partials)
|
231
|
+
when :handlebars_expression
|
232
|
+
# Handle old format for data sections
|
233
|
+
content = node.value[:content]
|
234
|
+
|
235
|
+
# Skip partials if requested
|
236
|
+
next if exclude_partials && content.start_with?('>')
|
237
|
+
|
238
|
+
# Skip block helpers
|
239
|
+
next if content.match?(%r{^(#|/)(if|unless|each|with)\s})
|
240
|
+
|
241
|
+
variables << content.strip
|
242
|
+
end
|
243
|
+
end
|
244
|
+
end
|
245
|
+
|
246
|
+
private
|
247
|
+
|
248
|
+
def extract_variables_from_text(text, variables, exclude_partials: false)
|
249
|
+
# Find all handlebars expressions in text content
|
250
|
+
text.scan(/\{\{(.+?)\}\}/) do |match|
|
251
|
+
content = match[0].strip
|
252
|
+
|
253
|
+
# Skip partials if requested
|
254
|
+
next if exclude_partials && content.start_with?('>')
|
255
|
+
|
256
|
+
# Skip block helpers
|
257
|
+
next if content.match?(%r{^(#|/)(if|unless|each|with)\s})
|
258
|
+
|
259
|
+
variables << content
|
260
|
+
end
|
261
|
+
end
|
262
|
+
|
263
|
+
def parse_data_attributes!
|
264
|
+
data_section = @grammar.sections['data']
|
265
|
+
@data_attributes = {}
|
266
|
+
|
267
|
+
if data_section
|
268
|
+
@data_attributes = data_section.value[:attributes].dup
|
269
|
+
|
270
|
+
# Validate attributes and warn about unknown ones
|
271
|
+
validate_data_attributes!
|
272
|
+
end
|
273
|
+
|
274
|
+
# Set default window attribute
|
275
|
+
@data_attributes['window'] ||= 'data'
|
276
|
+
end
|
277
|
+
|
278
|
+
def validate_data_attributes!
|
279
|
+
unknown_attributes = @data_attributes.keys - KNOWN_DATA_ATTRIBUTES
|
280
|
+
|
281
|
+
unknown_attributes.each do |attr|
|
282
|
+
warn_unknown_attribute(attr)
|
283
|
+
end
|
284
|
+
end
|
285
|
+
|
286
|
+
def warn_unknown_attribute(attribute)
|
287
|
+
file_info = @file_path ? " in #{@file_path}" : ""
|
288
|
+
warn "Warning: data section encountered '#{attribute}' attribute - not yet supported, ignoring#{file_info}"
|
289
|
+
end
|
290
|
+
|
291
|
+
class << self
|
292
|
+
def parse_file(file_path)
|
293
|
+
raise ArgumentError, 'Not a .rue file' unless rue_file?(file_path)
|
294
|
+
|
295
|
+
file_content = File.read(file_path)
|
296
|
+
new(file_content, file_path).parse!
|
297
|
+
end
|
298
|
+
|
299
|
+
def rue_file?(file_path)
|
300
|
+
File.extname(file_path) == '.rue'
|
301
|
+
end
|
302
|
+
end
|
303
|
+
end
|
304
|
+
end
|
@@ -0,0 +1,353 @@
|
|
1
|
+
# lib/rhales/template_engine.rb
|
2
|
+
|
3
|
+
require 'erb'
|
4
|
+
require_relative 'parsers/rue_format_parser'
|
5
|
+
require_relative 'parsers/handlebars_parser'
|
6
|
+
require_relative 'rue_document'
|
7
|
+
|
8
|
+
module Rhales
|
9
|
+
# Rhales - Ruby Handlebars-style template engine
|
10
|
+
#
|
11
|
+
# Modern AST-based template engine that supports both simple template strings
|
12
|
+
# and full .rue files. Uses RueFormatParser for parsing with proper
|
13
|
+
# nested structure handling and block statement support.
|
14
|
+
#
|
15
|
+
# Features:
|
16
|
+
# - Dual-mode operation: simple templates and .rue files
|
17
|
+
# - Full AST parsing eliminates regex-based vulnerabilities
|
18
|
+
# - Proper nested block handling with accurate error reporting
|
19
|
+
# - XSS protection through HTML escaping by default
|
20
|
+
# - Handlebars-compatible syntax with Ruby idioms
|
21
|
+
#
|
22
|
+
# Supported syntax:
|
23
|
+
# - {{variable}} - Variable interpolation with HTML escaping
|
24
|
+
# - {{{variable}}} - Raw variable interpolation (no escaping)
|
25
|
+
# - {{#if condition}} ... {{else}} ... {{/if}} - Conditionals with else
|
26
|
+
# - {{#unless condition}} ... {{/unless}} - Negated conditionals
|
27
|
+
# - {{#each items}} ... {{/each}} - Iteration with context
|
28
|
+
# - {{> partial_name}} - Partial inclusion
|
29
|
+
class TemplateEngine
|
30
|
+
class RenderError < ::Rhales::RenderError; end
|
31
|
+
class PartialNotFoundError < RenderError; end
|
32
|
+
class UndefinedVariableError < RenderError; end
|
33
|
+
class BlockNotFoundError < RenderError; end
|
34
|
+
|
35
|
+
attr_reader :template_content, :context, :partial_resolver, :parser
|
36
|
+
|
37
|
+
def initialize(template_content, context, partial_resolver: nil)
|
38
|
+
@template_content = template_content
|
39
|
+
@context = context
|
40
|
+
@partial_resolver = partial_resolver
|
41
|
+
@parser = nil
|
42
|
+
end
|
43
|
+
|
44
|
+
def render
|
45
|
+
# Check if this is a simple template or a full .rue file
|
46
|
+
if simple_template?
|
47
|
+
# Use HandlebarsParser for simple templates
|
48
|
+
parser = HandlebarsParser.new(@template_content)
|
49
|
+
parser.parse!
|
50
|
+
render_content_nodes(parser.ast.children)
|
51
|
+
else
|
52
|
+
# Use RueDocument for .rue files
|
53
|
+
@parser = RueDocument.new(@template_content)
|
54
|
+
@parser.parse!
|
55
|
+
|
56
|
+
# Get template section via RueDocument
|
57
|
+
template_content = @parser.section('template')
|
58
|
+
raise RenderError, 'Missing template section' unless template_content
|
59
|
+
|
60
|
+
# Render the template section as a simple template
|
61
|
+
render_template_string(template_content)
|
62
|
+
end
|
63
|
+
rescue ::Rhales::ParseError => ex
|
64
|
+
# Parse errors already have good error messages with location
|
65
|
+
raise RenderError, "Template parsing failed: #{ex.message}"
|
66
|
+
rescue ::Rhales::ValidationError => ex
|
67
|
+
# Validation errors from RueDocument
|
68
|
+
raise RenderError, "Template validation failed: #{ex.message}"
|
69
|
+
rescue StandardError => ex
|
70
|
+
raise RenderError, "Template rendering failed: #{ex.message}"
|
71
|
+
end
|
72
|
+
|
73
|
+
# Access window attribute from parsed .rue file
|
74
|
+
def window_attribute
|
75
|
+
@parser&.window_attribute
|
76
|
+
end
|
77
|
+
|
78
|
+
# Access schema path from parsed .rue file
|
79
|
+
def schema_path
|
80
|
+
@parser&.schema_path
|
81
|
+
end
|
82
|
+
|
83
|
+
# Access all data attributes from parsed .rue file
|
84
|
+
def data_attributes
|
85
|
+
@parser&.data_attributes || {}
|
86
|
+
end
|
87
|
+
|
88
|
+
# Get template variables used in the template
|
89
|
+
def template_variables
|
90
|
+
@parser&.template_variables || []
|
91
|
+
end
|
92
|
+
|
93
|
+
# Get all partials used in the template
|
94
|
+
def partials
|
95
|
+
@parser&.partials || []
|
96
|
+
end
|
97
|
+
|
98
|
+
private
|
99
|
+
|
100
|
+
def simple_template?
|
101
|
+
!@template_content.match?(/^<(data|template|logic)\b/)
|
102
|
+
end
|
103
|
+
|
104
|
+
def render_template_string(template_string)
|
105
|
+
# Parse the template string as a simple Handlebars template
|
106
|
+
parser = HandlebarsParser.new(template_string)
|
107
|
+
parser.parse!
|
108
|
+
render_content_nodes(parser.ast.children)
|
109
|
+
end
|
110
|
+
|
111
|
+
# Render array of AST content nodes with proper block handling
|
112
|
+
# Processes text nodes and AST block nodes directly
|
113
|
+
def render_content_nodes(content_nodes)
|
114
|
+
return '' unless content_nodes.is_a?(Array)
|
115
|
+
|
116
|
+
result = ''
|
117
|
+
|
118
|
+
content_nodes.each do |node|
|
119
|
+
case node.type
|
120
|
+
when :text
|
121
|
+
result += node.value
|
122
|
+
when :variable_expression
|
123
|
+
result += render_variable_expression(node)
|
124
|
+
when :partial_expression
|
125
|
+
result += render_partial_expression(node)
|
126
|
+
when :if_block
|
127
|
+
result += render_if_block(node)
|
128
|
+
when :unless_block
|
129
|
+
result += render_unless_block(node)
|
130
|
+
when :each_block
|
131
|
+
result += render_each_block(node)
|
132
|
+
when :handlebars_expression
|
133
|
+
# Handle old format for data sections
|
134
|
+
result += render_handlebars_expression(node)
|
135
|
+
end
|
136
|
+
end
|
137
|
+
|
138
|
+
result
|
139
|
+
end
|
140
|
+
|
141
|
+
def render_variable_expression(node)
|
142
|
+
name = node.value[:name]
|
143
|
+
raw = node.value[:raw]
|
144
|
+
|
145
|
+
value = get_variable_value(name)
|
146
|
+
raw ? value.to_s : escape_html(value.to_s)
|
147
|
+
end
|
148
|
+
|
149
|
+
def render_partial_expression(node)
|
150
|
+
partial_name = node.value[:name]
|
151
|
+
render_partial(partial_name)
|
152
|
+
end
|
153
|
+
|
154
|
+
def render_if_block(node)
|
155
|
+
condition = node.value[:condition]
|
156
|
+
if_content = node.value[:if_content]
|
157
|
+
else_content = node.value[:else_content]
|
158
|
+
|
159
|
+
if evaluate_condition(condition)
|
160
|
+
render_content_nodes(if_content)
|
161
|
+
else
|
162
|
+
render_content_nodes(else_content)
|
163
|
+
end
|
164
|
+
end
|
165
|
+
|
166
|
+
def render_unless_block(node)
|
167
|
+
condition = node.value[:condition]
|
168
|
+
content = node.value[:content]
|
169
|
+
|
170
|
+
if evaluate_condition(condition)
|
171
|
+
''
|
172
|
+
else
|
173
|
+
render_content_nodes(content)
|
174
|
+
end
|
175
|
+
end
|
176
|
+
|
177
|
+
def render_each_block(node)
|
178
|
+
items_var = node.value[:items]
|
179
|
+
block_content = node.value[:content]
|
180
|
+
|
181
|
+
items = get_variable_value(items_var)
|
182
|
+
|
183
|
+
if items.respond_to?(:each)
|
184
|
+
items.map.with_index do |item, index|
|
185
|
+
# Create context for each iteration
|
186
|
+
item_context = create_each_context(item, index, items_var)
|
187
|
+
engine = self.class.new('', item_context, partial_resolver: @partial_resolver)
|
188
|
+
engine.send(:render_content_nodes, block_content)
|
189
|
+
end.join
|
190
|
+
else
|
191
|
+
''
|
192
|
+
end
|
193
|
+
end
|
194
|
+
|
195
|
+
def render_handlebars_expression(node)
|
196
|
+
content = node.value[:content]
|
197
|
+
raw = node.value[:raw]
|
198
|
+
|
199
|
+
# Handle different expression types
|
200
|
+
case content
|
201
|
+
when /^>\s*(\w+)/ # Partials
|
202
|
+
render_partial(Regexp.last_match(1))
|
203
|
+
when %r{^(#|/)(if|unless|each)} # Block statements (should be handled by render_content_nodes)
|
204
|
+
''
|
205
|
+
else # Variables
|
206
|
+
value = get_variable_value(content)
|
207
|
+
raw ? value.to_s : escape_html(value.to_s)
|
208
|
+
end
|
209
|
+
end
|
210
|
+
|
211
|
+
def render_partial(partial_name)
|
212
|
+
return "{{> #{partial_name}}}" unless @partial_resolver
|
213
|
+
|
214
|
+
partial_content = @partial_resolver.call(partial_name)
|
215
|
+
raise PartialNotFoundError, "Partial '#{partial_name}' not found" unless partial_content
|
216
|
+
|
217
|
+
# Recursively render the partial content
|
218
|
+
engine = self.class.new(partial_content, @context, partial_resolver: @partial_resolver)
|
219
|
+
engine.render
|
220
|
+
end
|
221
|
+
|
222
|
+
# Get variable value from context
|
223
|
+
def get_variable_value(variable_name)
|
224
|
+
# Handle special variables
|
225
|
+
case variable_name
|
226
|
+
when 'this', '.'
|
227
|
+
return @context.respond_to?(:current_item) ? @context.current_item : nil
|
228
|
+
when '@index'
|
229
|
+
return @context.respond_to?(:current_index) ? @context.current_index : nil
|
230
|
+
end
|
231
|
+
|
232
|
+
# Get from context
|
233
|
+
if @context.respond_to?(:get)
|
234
|
+
@context.get(variable_name)
|
235
|
+
elsif @context.respond_to?(:[])
|
236
|
+
@context[variable_name] || @context[variable_name.to_sym]
|
237
|
+
else
|
238
|
+
nil
|
239
|
+
end
|
240
|
+
end
|
241
|
+
|
242
|
+
# Evaluate condition for if/unless blocks
|
243
|
+
def evaluate_condition(condition)
|
244
|
+
value = get_variable_value(condition)
|
245
|
+
|
246
|
+
# Handle truthy/falsy evaluation
|
247
|
+
case value
|
248
|
+
when nil, false
|
249
|
+
false
|
250
|
+
when ''
|
251
|
+
false
|
252
|
+
when 'false', 'False', 'FALSE'
|
253
|
+
false
|
254
|
+
when Array
|
255
|
+
!value.empty?
|
256
|
+
when Hash
|
257
|
+
!value.empty?
|
258
|
+
when 0
|
259
|
+
false
|
260
|
+
else
|
261
|
+
true
|
262
|
+
end
|
263
|
+
end
|
264
|
+
|
265
|
+
# Create context for each iteration
|
266
|
+
def create_each_context(item, index, items_var)
|
267
|
+
EachContext.new(@context, item, index, items_var)
|
268
|
+
end
|
269
|
+
|
270
|
+
# HTML escape for XSS protection
|
271
|
+
def escape_html(string)
|
272
|
+
ERB::Util.html_escape(string)
|
273
|
+
end
|
274
|
+
|
275
|
+
# Context wrapper for {{#each}} iterations
|
276
|
+
class EachContext
|
277
|
+
attr_reader :parent_context, :current_item, :current_index, :items_var
|
278
|
+
|
279
|
+
def initialize(parent_context, current_item, current_index, items_var)
|
280
|
+
@parent_context = parent_context
|
281
|
+
@current_item = current_item
|
282
|
+
@current_index = current_index
|
283
|
+
@items_var = items_var
|
284
|
+
end
|
285
|
+
|
286
|
+
def get(variable_name)
|
287
|
+
# Handle special each variables
|
288
|
+
case variable_name
|
289
|
+
when 'this', '.'
|
290
|
+
return @current_item
|
291
|
+
when '@index'
|
292
|
+
return @current_index
|
293
|
+
when '@first'
|
294
|
+
return @current_index == 0
|
295
|
+
when '@last'
|
296
|
+
# We'd need to know the total length for this
|
297
|
+
return false
|
298
|
+
end
|
299
|
+
|
300
|
+
# Check if it's a property of the current item
|
301
|
+
if @current_item.respond_to?(:[])
|
302
|
+
item_value = @current_item[variable_name] || @current_item[variable_name.to_sym]
|
303
|
+
return item_value unless item_value.nil?
|
304
|
+
end
|
305
|
+
|
306
|
+
if @current_item.respond_to?(variable_name)
|
307
|
+
return @current_item.public_send(variable_name)
|
308
|
+
end
|
309
|
+
|
310
|
+
# Fall back to parent context
|
311
|
+
@parent_context.get(variable_name) if @parent_context.respond_to?(:get)
|
312
|
+
end
|
313
|
+
|
314
|
+
def respond_to?(method_name)
|
315
|
+
super || @parent_context.respond_to?(method_name)
|
316
|
+
end
|
317
|
+
|
318
|
+
def method_missing(method_name, *)
|
319
|
+
if @parent_context.respond_to?(method_name)
|
320
|
+
@parent_context.public_send(method_name, *)
|
321
|
+
else
|
322
|
+
super
|
323
|
+
end
|
324
|
+
end
|
325
|
+
|
326
|
+
def respond_to_missing?(method_name, include_private = false)
|
327
|
+
super || @parent_context.respond_to?(method_name, include_private)
|
328
|
+
end
|
329
|
+
end
|
330
|
+
|
331
|
+
class << self
|
332
|
+
# Render template with context and optional partial resolver
|
333
|
+
def render(template_content, context, partial_resolver: nil)
|
334
|
+
new(template_content, context, partial_resolver: partial_resolver).render
|
335
|
+
end
|
336
|
+
|
337
|
+
# Create partial resolver that loads .rue files from a directory
|
338
|
+
def file_partial_resolver(templates_dir)
|
339
|
+
proc do |partial_name|
|
340
|
+
partial_path = File.join(templates_dir, "#{partial_name}.rue")
|
341
|
+
|
342
|
+
if File.exist?(partial_path)
|
343
|
+
# Load and parse the partial .rue file
|
344
|
+
document = RueDocument.parse_file(partial_path)
|
345
|
+
document.section('template')
|
346
|
+
else
|
347
|
+
nil
|
348
|
+
end
|
349
|
+
end
|
350
|
+
end
|
351
|
+
end
|
352
|
+
end
|
353
|
+
end
|