Class: Rhales::TemplateEngine

Inherits:
Object
  • Object
show all
Defined in:
lib/rhales/template_engine.rb

Overview

Rhales - Ruby Handlebars-style template engine

Modern AST-based template engine that supports both simple template strings and full .rue files. Uses RueFormatParser for parsing with proper nested structure handling and block statement support.

Features: - Dual-mode operation: simple templates and .rue files - Full AST parsing eliminates regex-based vulnerabilities - Proper nested block handling with accurate error reporting - XSS protection through HTML escaping by default - Handlebars-compatible syntax with Ruby idioms

Supported syntax: - {variable} - Variable interpolation with HTML escaping - {{variable}} - Raw variable interpolation (no escaping) - condition} … {else} … {/if} - Conditionals with else - condition} … {/unless} - Negated conditionals - items} … {/each} - Iteration with context - partial_name} - Partial inclusion

Defined Under Namespace

Classes: BlockNotFoundError, EachContext, PartialNotFoundError, RenderError, UndefinedVariableError

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(template_content, context, partial_resolver: nil) ⇒ TemplateEngine

Returns a new instance of TemplateEngine.



38
39
40
41
42
43
# File 'lib/rhales/template_engine.rb', line 38

def initialize(template_content, context, partial_resolver: nil)
  @template_content = template_content
  @context          = context
  @partial_resolver = partial_resolver
  @parser           = nil
end

Instance Attribute Details

#contextObject (readonly)

Returns the value of attribute context.



36
37
38
# File 'lib/rhales/template_engine.rb', line 36

def context
  @context
end

#parserObject (readonly)

Returns the value of attribute parser.



36
37
38
# File 'lib/rhales/template_engine.rb', line 36

def parser
  @parser
end

#partial_resolverObject (readonly)

Returns the value of attribute partial_resolver.



36
37
38
# File 'lib/rhales/template_engine.rb', line 36

def partial_resolver
  @partial_resolver
end

#template_contentObject (readonly)

Returns the value of attribute template_content.



36
37
38
# File 'lib/rhales/template_engine.rb', line 36

def template_content
  @template_content
end

Class Method Details

.file_partial_resolver(templates_dir) ⇒ Object

Create partial resolver that loads .rue files from a directory



380
381
382
383
384
385
386
387
388
389
390
# File 'lib/rhales/template_engine.rb', line 380

def file_partial_resolver(templates_dir)
  proc do |partial_name|
    partial_path = File.join(templates_dir, "#{partial_name}.rue")

    if File.exist?(partial_path)
      # Load and parse the partial .rue file
      document = RueDocument.parse_file(partial_path)
      document.section('template')
    end
  end
end

.render(template_content, context, partial_resolver: nil) ⇒ Object

Render template with context and optional partial resolver



375
376
377
# File 'lib/rhales/template_engine.rb', line 375

def render(template_content, context, partial_resolver: nil)
  new(template_content, context, partial_resolver: partial_resolver).render
end

Instance Method Details

#create_each_context(item, index, items_var) ⇒ Object (private)

Create context for each iteration



291
292
293
# File 'lib/rhales/template_engine.rb', line 291

def create_each_context(item, index, items_var)
  EachContext.new(@context, item, index, items_var)
end

#create_merged_context(parent_context, local_data) ⇒ Object (private)

Create a new context with merged data



301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
# File 'lib/rhales/template_engine.rb', line 301

def create_merged_context(parent_context, local_data)
  # Extract all props from parent context and merge with local data
  # Local data takes precedence over parent props
  merged_props = parent_context.props.merge(local_data)

  # Create new context with merged props, preserving other context attributes
  Context.for_view(
    parent_context.req,
    parent_context.sess,
    parent_context.cust,
    parent_context.locale,
    config: parent_context.config,
    **merged_props,
  )
end

#data_attributesObject

Access all data attributes from parsed .rue file



85
86
87
# File 'lib/rhales/template_engine.rb', line 85

def data_attributes
  @parser&.data_attributes || {}
end

#escape_html(string) ⇒ Object (private)

HTML escape for XSS protection



296
297
298
# File 'lib/rhales/template_engine.rb', line 296

def escape_html(string)
  ERB::Util.html_escape(string)
end

#evaluate_condition(condition) ⇒ Object (private)

Evaluate condition for if/unless blocks



268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
# File 'lib/rhales/template_engine.rb', line 268

def evaluate_condition(condition)
  value = get_variable_value(condition)

  # Handle truthy/falsy evaluation
  case value
  when nil, false
    false
  when ''
    false
  when 'false', 'False', 'FALSE'
    false
  when Array
    !value.empty?
  when Hash
    !value.empty?
  when 0
    false
  else
    true
  end
end

#get_variable_value(variable_name) ⇒ Object (private)

Get variable value from context



250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
# File 'lib/rhales/template_engine.rb', line 250

def get_variable_value(variable_name)
  # Handle special variables
  case variable_name
  when 'this', '.'
    return @context.respond_to?(:current_item) ? @context.current_item : nil
  when '@index'
    return @context.respond_to?(:current_index) ? @context.current_index : nil
  end

  # Get from context
  if @context.respond_to?(:get)
    @context.get(variable_name)
  elsif @context.respond_to?(:[])
    @context[variable_name] || @context[variable_name.to_sym]
  end
end

#partialsObject

Get all partials used in the template



95
96
97
# File 'lib/rhales/template_engine.rb', line 95

def partials
  @parser&.partials || []
end

#renderObject



45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
# File 'lib/rhales/template_engine.rb', line 45

def render
  # Check if this is a simple template or a full .rue file
  if simple_template?
    # Use HandlebarsParser for simple templates
    parser = HandlebarsParser.new(@template_content)
    parser.parse!
    render_content_nodes(parser.ast.children)
  else
    # Use RueDocument for .rue files
    @parser = RueDocument.new(@template_content)
    @parser.parse!

    # Get template section via RueDocument
    template_content = @parser.section('template')
    raise RenderError, 'Missing template section' unless template_content

    # Render the template section as a simple template
    render_template_string(template_content)
  end
rescue ::Rhales::ParseError => ex
  # Parse errors already have good error messages with location
  raise RenderError, "Template parsing failed: #{ex.message}"
rescue ::Rhales::ValidationError => ex
  # Validation errors from RueDocument
  raise RenderError, "Template validation failed: #{ex.message}"
rescue StandardError => ex
  raise RenderError, "Template rendering failed: #{ex.message}"
end

#render_content_nodes(content_nodes) ⇒ Object (private)

Render array of AST content nodes with proper block handling Processes text nodes and AST block nodes directly



114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
# File 'lib/rhales/template_engine.rb', line 114

def render_content_nodes(content_nodes)
  return '' unless content_nodes.is_a?(Array)

  result = ''

  content_nodes.each do |node|
    case node.type
    when :text
      result += node.value
    when :variable_expression
      result += render_variable_expression(node)
    when :partial_expression
      result += render_partial_expression(node)
    when :if_block
      result += render_if_block(node)
    when :unless_block
      result += render_unless_block(node)
    when :each_block
      result += render_each_block(node)
    when :handlebars_expression
      # Handle old format for data sections
      result += render_handlebars_expression(node)
    end
  end

  result
end

#render_each_block(node) ⇒ Object (private)



178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
# File 'lib/rhales/template_engine.rb', line 178

def render_each_block(node)
  items_var     = node.value[:items]
  block_content = node.value[:content]

  items = get_variable_value(items_var)

  if items.respond_to?(:each)
    items.map.with_index do |item, index|
      # Create context for each iteration
      item_context = create_each_context(item, index, items_var)
      engine       = self.class.new('', item_context, partial_resolver: @partial_resolver)
      engine.send(:render_content_nodes, block_content)
    end.join
  else
    ''
  end
end

#render_handlebars_expression(node) ⇒ Object (private)



196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
# File 'lib/rhales/template_engine.rb', line 196

def render_handlebars_expression(node)
  content = node.value[:content]
  raw     = node.value[:raw]

  # Handle different expression types
  case content
  when /^>\s*(\w+)/ # Partials
    render_partial(Regexp.last_match(1))
  when %r{^(#|/)(if|unless|each)} # Block statements (should be handled by render_content_nodes)
    ''
  else # Variables
    value = get_variable_value(content)
    raw ? value.to_s : escape_html(value.to_s)
  end
end

#render_if_block(node) ⇒ Object (private)



155
156
157
158
159
160
161
162
163
164
165
# File 'lib/rhales/template_engine.rb', line 155

def render_if_block(node)
  condition    = node.value[:condition]
  if_content   = node.value[:if_content]
  else_content = node.value[:else_content]

  if evaluate_condition(condition)
    render_content_nodes(if_content)
  else
    render_content_nodes(else_content)
  end
end

#render_partial(partial_name) ⇒ Object (private)



212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
# File 'lib/rhales/template_engine.rb', line 212

def render_partial(partial_name)
  return "{{> #{partial_name}}}" unless @partial_resolver

  partial_content = @partial_resolver.call(partial_name)
  raise PartialNotFoundError, "Partial '#{partial_name}' not found" unless partial_content

  # Check if this is a .rue document with sections
  if partial_content.match?(/^<(data|template|logic)\b/)
    # Parse as RueDocument to handle data sections properly
    partial_doc = RueDocument.new(partial_content)
    partial_doc.parse!

    # Extract template section
    template_content = partial_doc.section('template')
    raise PartialNotFoundError, "Partial '#{partial_name}' missing template section" unless template_content

    # Process data section if present and merge with parent context
    merged_context = @context
    if partial_doc.section('data')
      # Create hydrator with parent context to process interpolations
      hydrator = Hydrator.new(partial_doc, @context)
      local_data = hydrator.processed_data_hash

      # Create merged context (local data takes precedence)
      merged_context = create_merged_context(@context, local_data)
    end

    # Render template with merged context
    engine = self.class.new(template_content, merged_context, partial_resolver: @partial_resolver)
    engine.render
  else
    # Simple template without sections - render as before
    engine = self.class.new(partial_content, @context, partial_resolver: @partial_resolver)
    engine.render
  end
end

#render_partial_expression(node) ⇒ Object (private)



150
151
152
153
# File 'lib/rhales/template_engine.rb', line 150

def render_partial_expression(node)
  partial_name = node.value[:name]
  render_partial(partial_name)
end

#render_template_string(template_string) ⇒ Object (private)



105
106
107
108
109
110
# File 'lib/rhales/template_engine.rb', line 105

def render_template_string(template_string)
  # Parse the template string as a simple Handlebars template
  parser = HandlebarsParser.new(template_string)
  parser.parse!
  render_content_nodes(parser.ast.children)
end

#render_unless_block(node) ⇒ Object (private)



167
168
169
170
171
172
173
174
175
176
# File 'lib/rhales/template_engine.rb', line 167

def render_unless_block(node)
  condition = node.value[:condition]
  content   = node.value[:content]

  if evaluate_condition(condition)
    ''
  else
    render_content_nodes(content)
  end
end

#render_variable_expression(node) ⇒ Object (private)



142
143
144
145
146
147
148
# File 'lib/rhales/template_engine.rb', line 142

def render_variable_expression(node)
  name = node.value[:name]
  raw  = node.value[:raw]

  value = get_variable_value(name)
  raw ? value.to_s : escape_html(value.to_s)
end

#schema_pathObject

Access schema path from parsed .rue file



80
81
82
# File 'lib/rhales/template_engine.rb', line 80

def schema_path
  @parser&.schema_path
end

#simple_template?Boolean (private)

Returns:

  • (Boolean)


101
102
103
# File 'lib/rhales/template_engine.rb', line 101

def simple_template?
  !@template_content.match?(/^<(data|template|logic)\b/)
end

#template_variablesObject

Get template variables used in the template



90
91
92
# File 'lib/rhales/template_engine.rb', line 90

def template_variables
  @parser&.template_variables || []
end

#window_attributeObject

Access window attribute from parsed .rue file



75
76
77
# File 'lib/rhales/template_engine.rb', line 75

def window_attribute
  @parser&.window_attribute
end