Class: Rhales::View

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

Overview

Complete RSFC view implementation

Single public interface for RSFC template rendering that handles: - Context creation (with pluggable context classes) - Template loading and parsing - Template rendering with Rhales - Data hydration and injection

Context and Data Boundaries

Views implement a two-phase security model:

Server Templates: Full Context Access

Templates have complete access to all server-side data: - All props passed to View.new - Data from .rue file’s section (processed server-side) - Runtime data (CSRF tokens, nonces, request metadata) - Computed data (authentication status, theme classes) - User objects, configuration, internal APIs

Client Data: Explicit Allowlist

Only data declared in sections reahas_role?ches the browser: - Creates a REST API-like boundary - Server-side variable interpolation processes secrets safely - JSON serialization validates data structure - No accidental exposure of sensitive server data

Example: # Server template has full access: {user{user.admin?} {csrf_token} {internal_config}

# Client only gets declared data: { “user_name”: “{user{user.name}”, “theme”: “{user{user.theme}” }

See docs/CONTEXT_AND_DATA_BOUNDARIES.md for complete details.

Subclasses can override context_class to use different context implementations.

Defined Under Namespace

Classes: RenderError, TemplateNotFoundError

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(req, sess = nil, cust = nil, locale_override = nil, props: {}, config: nil) ⇒ View

Returns a new instance of View.



59
60
61
62
63
64
65
66
67
68
69
# File 'lib/rhales/view.rb', line 59

def initialize(req, sess = nil, cust = nil, locale_override = nil, props: {}, config: nil)
  @req           = req
  @sess          = sess
  @cust          = cust
  @locale        = locale_override
  @props         = props
  @config        = config || Rhales.configuration

  # Create context using the specified context class
  @rsfc_context = create_context
end

Instance Attribute Details

#configObject (readonly)

Returns the value of attribute config.



57
58
59
# File 'lib/rhales/view.rb', line 57

def config
  @config
end

#custObject (readonly)

Returns the value of attribute cust.



57
58
59
# File 'lib/rhales/view.rb', line 57

def cust
  @cust
end

#localeObject (readonly)

Returns the value of attribute locale.



57
58
59
# File 'lib/rhales/view.rb', line 57

def locale
  @locale
end

#propsObject (readonly)

Returns the value of attribute props.



57
58
59
# File 'lib/rhales/view.rb', line 57

def props
  @props
end

#reqObject (readonly)

Returns the value of attribute req.



57
58
59
# File 'lib/rhales/view.rb', line 57

def req
  @req
end

#rsfc_contextObject (readonly)

Returns the value of attribute rsfc_context.



57
58
59
# File 'lib/rhales/view.rb', line 57

def rsfc_context
  @rsfc_context
end

#sessObject (readonly)

Returns the value of attribute sess.



57
58
59
# File 'lib/rhales/view.rb', line 57

def sess
  @sess
end

Class Method Details

.default_template_nameObject

Get default template name based on class name



531
532
533
534
535
536
537
538
# File 'lib/rhales/view.rb', line 531

def default_template_name
  # Convert ClassName to class_name
  name.split('::').last
    .gsub(/([A-Z])/, '_\1')
    .downcase
    .sub(/^_/, '')
    .sub(/_view$/, '')
end

.render_with_data(req, sess, cust, locale, template_name: nil, config: nil, **props) ⇒ Object

Render template with props



541
542
543
544
# File 'lib/rhales/view.rb', line 541

def render_with_data(req, sess, cust, locale, template_name: nil, config: nil, **props)
  view = new(req, sess, cust, locale, props: props, config: config)
  view.render(template_name)
end

.with_data(req, sess, cust, locale, config: nil, **props) ⇒ Object

Create view instance with props



547
548
549
# File 'lib/rhales/view.rb', line 547

def with_data(req, sess, cust, locale, config: nil, **props)
  new(req, sess, cust, locale, props: props, config: config)
end

Instance Method Details

#build_view_composition(template_name) ⇒ Object (private)

Build view composition for the given template



343
344
345
346
347
# File 'lib/rhales/view.rb', line 343

def build_view_composition(template_name)
  loader      = method(:load_template_for_composition)
  composition = ViewComposition.new(template_name, loader: loader, config: @config)
  composition.resolve!
end

#calculate_etag(template_name = nil, additional_context = {}) ⇒ Object

Calculate ETag for current template data



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

def calculate_etag(template_name = nil, additional_context = {})
  require_relative 'hydration_endpoint'

  template_name ||= self.class.default_template_name
  endpoint = HydrationEndpoint.new(@config, @rsfc_context)
  endpoint.calculate_etag(template_name, additional_context)
end

#context_classObject (protected)

Return the context class to use Subclasses can override this to use different context implementations



183
184
185
# File 'lib/rhales/view.rb', line 183

def context_class
  Context
end

#create_contextObject (protected)

Create the appropriate context for this view Subclasses can override this to use different context types



177
178
179
# File 'lib/rhales/view.rb', line 177

def create_context
  context_class.for_view(@req, @sess, @cust, @locale, config: @config, **@props)
end

#create_context_with_rue_data(parser) ⇒ Object (private)

Create context that includes data from .rue file’s data section



277
278
279
280
281
282
283
284
285
286
# File 'lib/rhales/view.rb', line 277

def create_context_with_rue_data(parser)
  # Get data from .rue file's data section
  rue_data = extract_rue_data(parser)

  # Merge rue data with existing props (rue data takes precedence)
  merged_props = @props.merge(rue_data)

  # Create new context with merged data
  context_class.for_view(@req, @sess, @cust, @locale, config: @config, **merged_props)
end

#create_partial_resolverObject (private)

Create partial resolver for partial} inclusions



257
258
259
260
261
262
263
264
265
266
267
268
269
# File 'lib/rhales/view.rb', line 257

def create_partial_resolver
  templates_dir = File.join(templates_root, 'web')

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

    if File.exist?(partial_path)
      # Return full partial content so TemplateEngine can process
      # data sections, otherwise nil.
      File.read(partial_path)
    end
  end
end

#create_partial_resolver_from_composition(composition) ⇒ Object (private)

Create partial resolver that uses pre-loaded templates from composition



400
401
402
403
404
405
# File 'lib/rhales/view.rb', line 400

def create_partial_resolver_from_composition(composition)
  proc do |partial_name|
    parser = composition.template(partial_name)
    parser ? parser.content : nil
  end
end

#data_changed?(template_name = nil, etag = nil, additional_context = {}) ⇒ Boolean

Check if template data has changed for caching

Returns:

  • (Boolean)


133
134
135
136
137
138
139
# File 'lib/rhales/view.rb', line 133

def data_changed?(template_name = nil, etag = nil, additional_context = {})
  require_relative 'hydration_endpoint'

  template_name ||= self.class.default_template_name
  endpoint = HydrationEndpoint.new(@config, @rsfc_context)
  endpoint.data_changed?(template_name, etag, additional_context)
end

#data_hash(template_name = nil) ⇒ Object

Get processed data as hash (for API endpoints or testing)



164
165
166
167
168
169
170
171
# File 'lib/rhales/view.rb', line 164

def data_hash(template_name = nil)
  template_name ||= self.class.default_template_name

  # Build composition and aggregate data
  composition = build_view_composition(template_name)
  aggregator  = HydrationDataAggregator.new(@rsfc_context)
  aggregator.aggregate(composition)
end

#detect_mount_point_in_rendered_html(template_html) ⇒ Object (private)

Detect mount points in fully rendered HTML



334
335
336
337
338
339
340
# File 'lib/rhales/view.rb', line 334

def detect_mount_point_in_rendered_html(template_html)
  return nil unless @config&.hydration

  custom_selectors = @config.hydration.mount_point_selectors || []
  detector = MountPointDetector.new
  detector.detect(template_html, custom_selectors)
end

#extract_rue_data(parser) ⇒ Object (private)

Extract and process data from .rue file’s data section



289
290
291
292
293
294
295
296
297
298
299
300
301
# File 'lib/rhales/view.rb', line 289

def extract_rue_data(parser)
  data_content = parser.section('data')
  return {} unless data_content

  # Process the data section as JSON and parse it
  hydrator = Hydrator.new(parser, @rsfc_context)
  hydrator.processed_data_hash
rescue JSON::ParserError, Hydrator::JSONSerializationError => ex
  puts "Error processing data section: #{ex.message}"
  # If data section isn't valid JSON, return empty hash
  # This allows templates to work even with malformed data sections
  {}
end

#generate_hydration(parser) ⇒ Object (private)

Generate data hydration HTML



272
273
274
# File 'lib/rhales/view.rb', line 272

def generate_hydration(parser)
  Hydrator.generate(parser, @rsfc_context)
end

#generate_hydration_from_merged_data(merged_data) ⇒ Object (private)

Generate hydration HTML from pre-merged data



408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
# File 'lib/rhales/view.rb', line 408

def generate_hydration_from_merged_data(merged_data)
  hydration_parts = []

  merged_data.each do |window_attr, data|
    # Generate unique ID for this data block
    unique_id = "rsfc-data-#{SecureRandom.hex(8)}"

    # Create JSON script tag with optional reflection attributes
    json_attrs = reflection_enabled? ? " data-window=\"#{window_attr}\"" : ""
    json_script = <<~HTML.strip
      <script id="#{unique_id}" type="application/json"#{json_attrs}>#{JSON.generate(data)}</script>
    HTML

    # Create hydration script with optional reflection attributes
    nonce_attr = nonce_attribute
    hydration_attrs = reflection_enabled? ? " data-hydration-target=\"#{window_attr}\"" : ""
    hydration_script = if reflection_enabled?
      <<~HTML.strip
        <script#{nonce_attr}#{hydration_attrs}>
        var dataScript = document.getElementById('#{unique_id}');
        var targetName = dataScript.getAttribute('data-window') || '#{window_attr}';
        window[targetName] = JSON.parse(dataScript.textContent);
        </script>
      HTML
    else
      <<~HTML.strip
        <script#{nonce_attr}#{hydration_attrs}>
        window['#{window_attr}'] = JSON.parse(document.getElementById('#{unique_id}').textContent);
        </script>
      HTML
    end

    hydration_parts << json_script
    hydration_parts << hydration_script
  end

  # Add reflection utilities if enabled
  if reflection_enabled? && !merged_data.empty?
    hydration_parts << generate_reflection_utilities
  end

  hydration_parts.join("\n")
end

#generate_reflection_utilitiesObject (private)

Generate JavaScript utilities for hydration reflection



458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
# File 'lib/rhales/view.rb', line 458

def generate_reflection_utilities
  nonce_attr = nonce_attribute

  <<~HTML.strip
    <script#{nonce_attr}>
    // Rhales hydration reflection utilities
    window.__rhales__ = window.__rhales__ || {
      getHydrationTargets: function() {
        return Array.from(document.querySelectorAll('[data-hydration-target]'));
      },
      getDataForTarget: function(target) {
        var targetName = target.dataset.hydrationTarget;
        return targetName ? window[targetName] : undefined;
      },
      getWindowAttribute: function(scriptEl) {
        return scriptEl.dataset.window;
      },
      getDataScripts: function() {
        return Array.from(document.querySelectorAll('script[data-window]'));
      },
      refreshData: function(target) {
        var targetName = target.dataset.hydrationTarget;
        var dataScript = document.querySelector('script[data-window="' + targetName + '"]');
        if (dataScript && targetName) {
          try {
            window[targetName] = JSON.parse(dataScript.textContent);
            return true;
          } catch (e) {
            console.error('Rhales: Failed to refresh data for ' + targetName, e);
            return false;
          }
        }
        return false;
      },
      getAllHydrationData: function() {
        var data = {};
        this.getHydrationTargets().forEach(function(target) {
          var targetName = target.dataset.hydrationTarget;
          if (targetName) {
            data[targetName] = window[targetName];
          }
        });
        return data;
      }
    };
    </script>
  HTML
end

#inject_hydration_into_template(template_html, hydration_html) ⇒ Object (private)

Legacy injection method (kept for backwards compatibility)



323
324
325
326
327
328
329
330
331
# File 'lib/rhales/view.rb', line 323

def inject_hydration_into_template(template_html, hydration_html)
  # Try to inject before closing </body> tag
  if template_html.include?('</body>')
    template_html.sub('</body>', "#{hydration_html}\n</body>")
  # Otherwise append to end
  else
    "#{template_html}\n#{hydration_html}"
  end
end

#inject_hydration_with_mount_points(composition, template_name, template_html, hydration_html) ⇒ Object (private)

Smart hydration injection with mount point detection on rendered HTML



304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
# File 'lib/rhales/view.rb', line 304

def inject_hydration_with_mount_points(composition, template_name, template_html, hydration_html)
  injector = HydrationInjector.new(@config.hydration, template_name)

  # Check if using link-based strategy
  if @config.hydration.link_based_strategy?
    # For link-based strategies, we need the merged data context
    aggregator = HydrationDataAggregator.new(@rsfc_context)
    merged_data = aggregator.aggregate(composition)
    nonce = @rsfc_context.get('nonce')

    injector.inject_link_based_strategy(template_html, merged_data, nonce)
  else
    # Traditional strategies (early, earliest, late)
    mount_point = detect_mount_point_in_rendered_html(template_html)
    injector.inject(template_html, hydration_html, mount_point)
  end
end

#load_template(template_name) ⇒ Object (private)

Load and parse template



190
191
192
193
194
195
196
197
198
199
# File 'lib/rhales/view.rb', line 190

def load_template(template_name)
  template_path = resolve_template_path(template_name)

  unless File.exist?(template_path)
    raise TemplateNotFoundError, "Template not found: #{template_path}"
  end

  # Use refinement to load .rue file
  require template_path
end

#load_template_for_composition(template_name) ⇒ Object (private)

Loader proc for ViewComposition



350
351
352
353
354
355
356
357
# File 'lib/rhales/view.rb', line 350

def load_template_for_composition(template_name)
  template_path = resolve_template_path(template_name)
  return nil unless File.exist?(template_path)

  require template_path
rescue StandardError => ex
  raise TemplateNotFoundError, "Failed to load template #{template_name}: #{ex.message}"
end

#nonce_attributeObject (private)

Get nonce attribute if available



508
509
510
511
# File 'lib/rhales/view.rb', line 508

def nonce_attribute
  nonce = @rsfc_context.get('nonce')
  nonce ? " nonce=\"#{ERB::Util.html_escape(nonce)}\"" : ''
end

#reflection_enabled?Boolean (private)

Check if reflection system is enabled

Returns:

  • (Boolean)


453
454
455
# File 'lib/rhales/view.rb', line 453

def reflection_enabled?
  @config.hydration.reflection_enabled
end

#render(template_name = nil) ⇒ Object

Render RSFC template with hydration using two-pass architecture



72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
# File 'lib/rhales/view.rb', line 72

def render(template_name = nil)
  template_name ||= self.class.default_template_name

  # Phase 1: Build view composition and aggregate data
  composition           = build_view_composition(template_name)
  aggregator            = HydrationDataAggregator.new(@rsfc_context)
  merged_hydration_data = aggregator.aggregate(composition)

  # Phase 2: Render HTML with pre-computed data
  # Render template content
  template_html = render_template_with_composition(composition, template_name)

  # Generate hydration HTML with merged data
  hydration_html = generate_hydration_from_merged_data(merged_hydration_data)

  # Set CSP header if enabled
  set_csp_header_if_enabled

  # Smart hydration injection with mount point detection
  inject_hydration_with_mount_points(composition, template_name, template_html, hydration_html)
rescue StandardError => ex
  raise RenderError, "Failed to render template '#{template_name}': #{ex.message}"
end

#render_hydration_only(template_name = nil) ⇒ Object

Generate only the data hydration HTML



151
152
153
154
155
156
157
158
159
160
161
# File 'lib/rhales/view.rb', line 151

def render_hydration_only(template_name = nil)
  template_name ||= self.class.default_template_name

  # Build composition and aggregate data
  composition           = build_view_composition(template_name)
  aggregator            = HydrationDataAggregator.new(@rsfc_context)
  merged_hydration_data = aggregator.aggregate(composition)

  # Generate hydration HTML
  generate_hydration_from_merged_data(merged_hydration_data)
end

#render_json_only(template_name = nil, additional_context = {}) ⇒ Object

Render JSON response for API endpoints (link-based strategies)



106
107
108
109
110
111
112
# File 'lib/rhales/view.rb', line 106

def render_json_only(template_name = nil, additional_context = {})
  require_relative 'hydration_endpoint'

  template_name ||= self.class.default_template_name
  endpoint = HydrationEndpoint.new(@config, @rsfc_context)
  endpoint.render_json(template_name, additional_context)
end

#render_jsonp_only(template_name = nil, callback_name = 'callback', additional_context = {}) ⇒ Object

Render JSONP response with callback



124
125
126
127
128
129
130
# File 'lib/rhales/view.rb', line 124

def render_jsonp_only(template_name = nil, callback_name = 'callback', additional_context = {})
  require_relative 'hydration_endpoint'

  template_name ||= self.class.default_template_name
  endpoint = HydrationEndpoint.new(@config, @rsfc_context)
  endpoint.render_jsonp(template_name, callback_name, additional_context)
end

#render_module_only(template_name = nil, additional_context = {}) ⇒ Object

Render ES module response for modulepreload strategy



115
116
117
118
119
120
121
# File 'lib/rhales/view.rb', line 115

def render_module_only(template_name = nil, additional_context = {})
  require_relative 'hydration_endpoint'

  template_name ||= self.class.default_template_name
  endpoint = HydrationEndpoint.new(@config, @rsfc_context)
  endpoint.render_module(template_name, additional_context)
end

#render_template_only(template_name = nil) ⇒ Object

Render only the template section (without data hydration)



97
98
99
100
101
102
103
# File 'lib/rhales/view.rb', line 97

def render_template_only(template_name = nil)
  template_name ||= self.class.default_template_name

  # Build composition for consistent behavior
  composition = build_view_composition(template_name)
  render_template_with_composition(composition, template_name)
end

#render_template_section(parser) ⇒ Object (private)

Render template section with Rhales

RSFC Security Model: Templates have full server context access - Templates can access all business data, user objects, methods, etc. - Templates can access data from .rue file’s section (processed server-side) - This is like any server-side template (ERB, HAML, etc.) - Security boundary is at server-to-client handoff, not within server rendering - Only data declared in section reaches the client (after processing)



242
243
244
245
246
247
248
249
250
251
252
253
254
# File 'lib/rhales/view.rb', line 242

def render_template_section(parser)
  template_content = parser.section('template')
  return '' unless template_content

  # Create partial resolver
  partial_resolver = create_partial_resolver

  # Merge .rue file data with existing context
  context_with_rue_data = create_context_with_rue_data(parser)

  # Render with full server context (props + computed context + rue data)
  TemplateEngine.render(template_content, context_with_rue_data, partial_resolver: partial_resolver)
end

#render_template_with_composition(composition, root_template_name) ⇒ Object (private)

Render template using the view composition



360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
# File 'lib/rhales/view.rb', line 360

def render_template_with_composition(composition, root_template_name)
  root_parser      = composition.template(root_template_name)
  template_content = root_parser.section('template')
  return '' unless template_content

  # Create partial resolver that uses the composition
  partial_resolver = create_partial_resolver_from_composition(composition)

  # Merge .rue file data with existing context
  context_with_rue_data = create_context_with_rue_data(root_parser)

  # Check if template has a layout
  if root_parser.layout && composition.template(root_parser.layout)
    # Render content template first
    content_html = TemplateEngine.render(template_content, context_with_rue_data, partial_resolver: partial_resolver)

    # Then render layout with content
    layout_parser  = composition.template(root_parser.layout)
    layout_content = layout_parser.section('template')
    return '' unless layout_content

    # Create new context with content for layout rendering
    layout_props   = context_with_rue_data.props.merge('content' => content_html)
    layout_context = Context.new(
      context_with_rue_data.req,
      context_with_rue_data.sess,
      context_with_rue_data.cust,
      context_with_rue_data.locale,
      props: layout_props,
      config: context_with_rue_data.config,
    )

    TemplateEngine.render(layout_content, layout_context, partial_resolver: partial_resolver)
  else
    # Render with full server context (no layout)
    TemplateEngine.render(template_content, context_with_rue_data, partial_resolver: partial_resolver)
  end
end

#resolve_template_path(template_name) ⇒ Object (private)

Resolve template path



202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
# File 'lib/rhales/view.rb', line 202

def resolve_template_path(template_name)
  # Check configured template paths first
  if @config && @config.template_paths && !@config.template_paths.empty?
    @config.template_paths.each do |path|
      template_path = File.join(path, "#{template_name}.rue")
      return template_path if File.exist?(template_path)
    end
  end

  # Fallback to default template structure
  # First try templates/web directory
  web_path = File.join(templates_root, 'web', "#{template_name}.rue")
  return web_path if File.exist?(web_path)

  # Then try templates directory
  templates_path = File.join(templates_root, "#{template_name}.rue")
  return templates_path if File.exist?(templates_path)

  # Return first configured path or web path for error message
  if @config && @config.template_paths && !@config.template_paths.empty?
    File.join(@config.template_paths.first, "#{template_name}.rue")
  else
    web_path
  end
end

#set_csp_header_if_enabledObject (private)

Set CSP header if enabled



514
515
516
517
518
519
520
521
522
523
524
525
526
527
# File 'lib/rhales/view.rb', line 514

def set_csp_header_if_enabled
  return unless @config.csp_enabled
  return unless @req && @req.respond_to?(:env)

  # Get nonce from context
  nonce = @rsfc_context.get('nonce')

  # Create CSP instance and build header
  csp = CSP.new(@config, nonce: nonce)
  header_value = csp.build_header

  # Set header in request environment for framework to use
  @req.env['csp_header'] = header_value if header_value
end

#templates_rootObject (private)

Get templates root directory



229
230
231
232
# File 'lib/rhales/view.rb', line 229

def templates_root
  boot_root = File.expand_path('../../..', __dir__)
  File.join(boot_root, 'templates')
end