Class: Rhales::View
- Inherits:
-
Object
- Object
- Rhales::View
- Extended by:
- Forwardable
- Includes:
- Utils::LoggingHelpers
- Defined in:
- lib/rhales/core/view.rb
Overview
- Runtime data (CSRF tokens, nonces, request metadata)
- Computed data (authentication status, theme classes)
- User objects, configuration, internal APIs
Client Data: Explicit Allowlist
Only client data declared in
Example: # Server template has full access: {user{user.admin?} {csrf_token} {internal_config}
# Client only gets declared data from schema:
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
-
#req ⇒ Object
readonly
Returns the value of attribute req.
-
#rsfc_context ⇒ Object
readonly
Returns the value of attribute rsfc_context.
Class Method Summary collapse
-
.default_template_name ⇒ Object
Get default template name based on class name.
-
.render_with_data(req, template_name: nil, config: nil, **client_data) ⇒ Object
Render template with client data.
-
.with_data(req, config: nil, **client_data) ⇒ Object
Create view instance with client data.
Instance Method Summary collapse
-
#build_view_composition(template_name) ⇒ Object
private
Build view composition for the given template.
-
#calculate_etag(template_name = nil, additional_context = {}) ⇒ Object
Calculate ETag for current template data.
-
#context_class ⇒ Object
protected
Return the context class to use Subclasses can override this to use different context implementations.
-
#create_partial_resolver ⇒ Object
private
Create partial resolver for partial} inclusions.
-
#create_partial_resolver_from_composition(composition) ⇒ Object
private
Create partial resolver that uses pre-loaded templates from composition.
-
#data_changed?(template_name = nil, etag = nil, additional_context = {}) ⇒ Boolean
Check if template data has changed for caching.
-
#data_hash(template_name = nil) ⇒ Object
Get processed data as hash (for API endpoints or testing).
-
#detect_mount_point_in_rendered_html(template_html) ⇒ Object
private
Detect mount points in fully rendered HTML.
-
#generate_hydration_from_merged_data(merged_data) ⇒ Object
private
Generate hydration HTML from pre-merged data.
-
#generate_reflection_utilities ⇒ Object
private
Generate JavaScript utilities for hydration reflection.
-
#initialize(req, client: {}, server: {}, config: nil) ⇒ View
constructor
A new instance of View.
-
#inject_hydration_with_mount_points(composition, template_name, template_html, hydration_html) ⇒ Object
private
Smart hydration injection with mount point detection on rendered HTML.
-
#load_template(template_name) ⇒ Object
private
Load and parse template.
-
#load_template_for_composition(template_name) ⇒ Object
private
Loader proc for ViewComposition.
-
#mount_point_detector ⇒ Object
private
Memoized mount point detector.
-
#nonce_attribute ⇒ Object
private
Get nonce attribute if available.
-
#reflection_enabled? ⇒ Boolean
private
Check if reflection system is enabled.
-
#render(template_name = nil) ⇒ Object
Render RSFC template with hydration using two-pass architecture.
-
#render_hydration_only(template_name = nil) ⇒ Object
Generate only the data hydration HTML.
-
#render_json_only(template_name = nil, additional_context = {}) ⇒ Object
Render JSON response for API endpoints (link-based strategies).
-
#render_jsonp_only(template_name = nil, callback_name = 'callback', additional_context = {}) ⇒ Object
Render JSONP response with callback.
-
#render_module_only(template_name = nil, additional_context = {}) ⇒ Object
Render ES module response for modulepreload strategy.
-
#render_template_only(template_name = nil) ⇒ Object
Render only the template section (without data hydration).
-
#render_template_section(parser) ⇒ Object
private
Render template section with Rhales.
-
#render_template_with_composition(composition, root_template_name) ⇒ Object
private
Render template using the view composition.
-
#resolve_template_path(template_name) ⇒ Object
private
Resolve template path.
-
#set_csp_header_if_enabled ⇒ Object
private
Set CSP header if enabled.
-
#templates_root ⇒ Object
private
Get templates root directory.
-
#validate_template_name!(template_name) ⇒ Object
private
Guard against path-traversal in template names.
Methods included from Utils::LoggingHelpers
#format_value, #log_timed_operation, #log_with_metadata
Methods included from Utils
#now, #now_in_μs, #pretty_path
Constructor Details
#initialize(req, client: {}, server: {}, config: nil) ⇒ View
Returns a new instance of View.
72 73 74 75 |
# File 'lib/rhales/core/view.rb', line 72 def initialize(req, client: {}, server: {}, config: nil) @req = req @rsfc_context = context_class.for_view(req, client: client, server: server, config: config || Rhales.configuration) end |
Instance Attribute Details
#req ⇒ Object (readonly)
Returns the value of attribute req.
67 68 69 |
# File 'lib/rhales/core/view.rb', line 67 def req @req end |
#rsfc_context ⇒ Object (readonly)
Returns the value of attribute rsfc_context.
67 68 69 |
# File 'lib/rhales/core/view.rb', line 67 def rsfc_context @rsfc_context end |
Class Method Details
.default_template_name ⇒ Object
Get default template name based on class name
543 544 545 546 547 548 549 550 |
# File 'lib/rhales/core/view.rb', line 543 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, template_name: nil, config: nil, **client_data) ⇒ Object
Render template with client data
553 554 555 556 |
# File 'lib/rhales/core/view.rb', line 553 def render_with_data(req, template_name: nil, config: nil, **client_data) view = new(req, client: client_data, config: config) view.render(template_name) end |
.with_data(req, config: nil, **client_data) ⇒ Object
Create view instance with client data
559 560 561 |
# File 'lib/rhales/core/view.rb', line 559 def with_data(req, config: nil, **client_data) new(req, client: client_data, config: config) end |
Instance Method Details
#build_view_composition(template_name) ⇒ Object (private)
Build view composition for the given template
355 356 357 358 359 |
# File 'lib/rhales/core/view.rb', line 355 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
177 178 179 180 181 182 183 |
# File 'lib/rhales/core/view.rb', line 177 def calculate_etag(template_name = nil, additional_context = {}) require_relative '../hydration/hydration_endpoint' template_name ||= self.class.default_template_name endpoint = HydrationEndpoint.new(config, @rsfc_context) endpoint.calculate_etag(template_name, additional_context) end |
#context_class ⇒ Object (protected)
Return the context class to use Subclasses can override this to use different context implementations
212 213 214 |
# File 'lib/rhales/core/view.rb', line 212 def context_class Context end |
#create_partial_resolver ⇒ Object (private)
Create partial resolver for partial} inclusions
306 307 308 309 310 311 312 313 314 315 316 317 318 |
# File 'lib/rhales/core/view.rb', line 306 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
404 405 406 407 408 409 |
# File 'lib/rhales/core/view.rb', line 404 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
168 169 170 171 172 173 174 |
# File 'lib/rhales/core/view.rb', line 168 def data_changed?(template_name = nil, etag = nil, additional_context = {}) require_relative '../hydration/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)
199 200 201 202 203 204 205 206 |
# File 'lib/rhales/core/view.rb', line 199 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
340 341 342 343 344 345 |
# File 'lib/rhales/core/view.rb', line 340 def detect_mount_point_in_rendered_html(template_html) return nil unless config&.hydration custom_selectors = config.hydration.mount_point_selectors || [] mount_point_detector.detect(template_html, custom_selectors) end |
#generate_hydration_from_merged_data(merged_data) ⇒ Object (private)
Generate hydration HTML from pre-merged data
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 451 452 453 454 455 456 457 458 459 460 461 462 |
# File 'lib/rhales/core/view.rb', line 412 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)}" nonce_attr = nonce_attribute # Escape the window name for its two output contexts (issue #57). Parse-time # validation already restricts it to a JS identifier; this is defense in depth. window_attr_html = ERB::Util.html_escape(window_attr) window_attr_js = JSONSerializer.dump_html_safe(window_attr) # Create JSON script tag with optional reflection attributes json_attrs = reflection_enabled? ? " data-window=\"#{window_attr_html}\"" : '' json_script = <<~HTML.strip <script#{nonce_attr} id="#{unique_id}" type="application/json"#{json_attrs}>#{JSONSerializer.dump_html_safe(data)}</script> HTML # Create hydration script with optional reflection attributes hydration_attrs = reflection_enabled? ? " data-hydration-target=\"#{window_attr_html}\"" : '' 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_js}; window[targetName] = JSON.parse(dataScript.textContent); </script> HTML else <<~HTML.strip <script#{nonce_attr}#{hydration_attrs}> window[#{window_attr_js}] = 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 return '' if hydration_parts.empty? hydration_content = hydration_parts.join("\n") "\n\n<!-- Rhales Hydration Start -->\n#{hydration_content}\n<!-- Rhales Hydration End -->" end |
#generate_reflection_utilities ⇒ Object (private)
Generate JavaScript utilities for hydration reflection
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 506 507 508 509 510 511 512 513 514 515 516 517 |
# File 'lib/rhales/core/view.rb', line 470 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_with_mount_points(composition, template_name, template_html, hydration_html) ⇒ Object (private)
Smart hydration injection with mount point detection on rendered HTML
321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 |
# File 'lib/rhales/core/view.rb', line 321 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
219 220 221 222 223 224 225 226 227 228 |
# File 'lib/rhales/core/view.rb', line 219 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
362 363 364 365 366 367 368 369 |
# File 'lib/rhales/core/view.rb', line 362 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.}" end |
#mount_point_detector ⇒ Object (private)
Memoized mount point detector. Reusing one instance lets its per-instance result cache avoid rebuilding a SafeInjectionValidator and re-scanning identical rendered HTML across calls within this view’s lifetime.
350 351 352 |
# File 'lib/rhales/core/view.rb', line 350 def mount_point_detector @mount_point_detector ||= MountPointDetector.new end |
#nonce_attribute ⇒ Object (private)
Get nonce attribute if available
520 521 522 523 |
# File 'lib/rhales/core/view.rb', line 520 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
465 466 467 |
# File 'lib/rhales/core/view.rb', line 465 def reflection_enabled? config.hydration.reflection_enabled end |
#render(template_name = nil) ⇒ Object
Render RSFC template with hydration using two-pass architecture
78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 |
# File 'lib/rhales/core/view.rb', line 78 def render(template_name = nil) start_time = now_in_μs template_name ||= self.class.default_template_name # Store template name in request env for middleware validation @req.env['rhales.template_name'] = template_name if @req && @req.respond_to?(:env) begin # 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 result = inject_hydration_with_mount_points(composition, template_name, template_html, hydration_html) # Log successful render duration = now_in_μs - start_time hydration_size = merged_hydration_data.to_json.bytesize if merged_hydration_data (Rhales.logger, :debug, 'View rendered', template: template_name, layout: composition.layout, partials: composition.dependencies.values.flatten.uniq, duration: duration, hydration_size_bytes: hydration_size ) result rescue StandardError => ex duration = now_in_μs - start_time (Rhales.logger, :error, 'View render failed', template: template_name, duration: duration, error: ex., error_class: ex.class.name ) raise RenderError, "Failed to render template '#{template_name}': #{ex.}" end end |
#render_hydration_only(template_name = nil) ⇒ Object
Generate only the data hydration HTML
186 187 188 189 190 191 192 193 194 195 196 |
# File 'lib/rhales/core/view.rb', line 186 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)
141 142 143 144 145 146 147 |
# File 'lib/rhales/core/view.rb', line 141 def render_json_only(template_name = nil, additional_context = {}) require_relative '../hydration/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
159 160 161 162 163 164 165 |
# File 'lib/rhales/core/view.rb', line 159 def render_jsonp_only(template_name = nil, callback_name = 'callback', additional_context = {}) require_relative '../hydration/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
150 151 152 153 154 155 156 |
# File 'lib/rhales/core/view.rb', line 150 def render_module_only(template_name = nil, additional_context = {}) require_relative '../hydration/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)
132 133 134 135 136 137 138 |
# File 'lib/rhales/core/view.rb', line 132 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.
- 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
294 295 296 297 298 299 300 301 302 303 |
# File 'lib/rhales/core/view.rb', line 294 def render_template_section(parser) template_content = parser.section('template') return '' unless template_content # Create partial resolver partial_resolver = create_partial_resolver # Render with full server context TemplateEngine.render(template_content, @rsfc_context, partial_resolver: partial_resolver) end |
#render_template_with_composition(composition, root_template_name) ⇒ Object (private)
Render template using the view composition
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 398 399 400 401 |
# File 'lib/rhales/core/view.rb', line 372 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) # Use existing context for rendering context_with_rue_data = @rsfc_context # 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 # Use builder pattern to create new context with content for layout rendering layout_context = context_with_rue_data.merge_client('content' => content_html) 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
231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 |
# File 'lib/rhales/core/view.rb', line 231 def resolve_template_path(template_name) validate_template_name!(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_enabled ⇒ Object (private)
Set CSP header if enabled
526 527 528 529 530 531 532 533 534 535 536 537 538 539 |
# File 'lib/rhales/core/view.rb', line 526 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_root ⇒ Object (private)
Get templates root directory
282 283 284 285 |
# File 'lib/rhales/core/view.rb', line 282 def templates_root boot_root = File.('../../..', __dir__) File.join(boot_root, 'templates') end |
#validate_template_name!(template_name) ⇒ Object (private)
Guard against path-traversal in template names.
Template names may contain forward slashes to address subdirectories (e.g. “web/homepage”), but must not reference parent directories, embed null bytes, or be absolute paths. HydrationEndpoint exposes template names to API callers, so a name derived from request input must never be able to escape the configured template directories and read arbitrary files off disk.
267 268 269 270 271 272 273 274 275 276 277 278 279 |
# File 'lib/rhales/core/view.rb', line 267 def validate_template_name!(template_name) unless template_name.is_a?(String) && !template_name.empty? raise TemplateNotFoundError, "Invalid template name: #{template_name.inspect}" end absolute = template_name.start_with?('/') || template_name.match?(/\A[a-zA-Z]:[\\\/]/) null_byte = template_name.bytes.include?(0) traversal = template_name.split(%r{[\\/]}).include?('..') if absolute || null_byte || traversal raise TemplateNotFoundError, "Unsafe template name: #{template_name.inspect}" end end |