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.
-
#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.
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
508 509 510 511 512 513 514 515 |
# File 'lib/rhales/core/view.rb', line 508 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
518 519 520 521 |
# File 'lib/rhales/core/view.rb', line 518 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
524 525 526 |
# File 'lib/rhales/core/view.rb', line 524 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
325 326 327 328 329 |
# File 'lib/rhales/core/view.rb', line 325 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
282 283 284 285 286 287 288 289 290 291 292 293 294 |
# File 'lib/rhales/core/view.rb', line 282 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
374 375 376 377 378 379 |
# File 'lib/rhales/core/view.rb', line 374 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
316 317 318 319 320 321 322 |
# File 'lib/rhales/core/view.rb', line 316 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 |
#generate_hydration_from_merged_data(merged_data) ⇒ Object (private)
Generate hydration HTML from pre-merged data
382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 |
# File 'lib/rhales/core/view.rb', line 382 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 # Create JSON script tag with optional reflection attributes json_attrs = reflection_enabled? ? " data-window=\"#{window_attr}\"" : '' json_script = <<~HTML.strip <script#{nonce_attr} id="#{unique_id}" type="application/json"#{json_attrs}>#{JSONSerializer.dump(data)}</script> HTML # Create hydration script with optional reflection attributes 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 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
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 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 |
# File 'lib/rhales/core/view.rb', line 435 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
297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 |
# File 'lib/rhales/core/view.rb', line 297 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
332 333 334 335 336 337 338 339 |
# File 'lib/rhales/core/view.rb', line 332 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 |
#nonce_attribute ⇒ Object (private)
Get nonce attribute if available
485 486 487 488 |
# File 'lib/rhales/core/view.rb', line 485 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
430 431 432 |
# File 'lib/rhales/core/view.rb', line 430 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
270 271 272 273 274 275 276 277 278 279 |
# File 'lib/rhales/core/view.rb', line 270 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
342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 |
# File 'lib/rhales/core/view.rb', line 342 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 |
# File 'lib/rhales/core/view.rb', line 231 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_enabled ⇒ Object (private)
Set CSP header if enabled
491 492 493 494 495 496 497 498 499 500 501 502 503 504 |
# File 'lib/rhales/core/view.rb', line 491 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
258 259 260 261 |
# File 'lib/rhales/core/view.rb', line 258 def templates_root boot_root = File.('../../..', __dir__) File.join(boot_root, 'templates') end |