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
Constructor Details
#initialize(req, client: {}, server: {}, config: nil) ⇒ View
Returns a new instance of View.
70 71 72 73 |
# File 'lib/rhales/core/view.rb', line 70 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.
65 66 67 |
# File 'lib/rhales/core/view.rb', line 65 def req @req end |
#rsfc_context ⇒ Object (readonly)
Returns the value of attribute rsfc_context.
65 66 67 |
# File 'lib/rhales/core/view.rb', line 65 def rsfc_context @rsfc_context end |
Class Method Details
.default_template_name ⇒ Object
Get default template name based on class name
506 507 508 509 510 511 512 513 |
# File 'lib/rhales/core/view.rb', line 506 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
516 517 518 519 |
# File 'lib/rhales/core/view.rb', line 516 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
522 523 524 |
# File 'lib/rhales/core/view.rb', line 522 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
323 324 325 326 327 |
# File 'lib/rhales/core/view.rb', line 323 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
175 176 177 178 179 180 181 |
# File 'lib/rhales/core/view.rb', line 175 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
210 211 212 |
# File 'lib/rhales/core/view.rb', line 210 def context_class Context end |
#create_partial_resolver ⇒ Object (private)
Create partial resolver for partial} inclusions
280 281 282 283 284 285 286 287 288 289 290 291 292 |
# File 'lib/rhales/core/view.rb', line 280 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
372 373 374 375 376 377 |
# File 'lib/rhales/core/view.rb', line 372 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
166 167 168 169 170 171 172 |
# File 'lib/rhales/core/view.rb', line 166 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)
197 198 199 200 201 202 203 204 |
# File 'lib/rhales/core/view.rb', line 197 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
314 315 316 317 318 319 320 |
# File 'lib/rhales/core/view.rb', line 314 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
380 381 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 |
# File 'lib/rhales/core/view.rb', line 380 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
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 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 |
# File 'lib/rhales/core/view.rb', line 433 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
295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 |
# File 'lib/rhales/core/view.rb', line 295 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
217 218 219 220 221 222 223 224 225 226 |
# File 'lib/rhales/core/view.rb', line 217 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
330 331 332 333 334 335 336 337 |
# File 'lib/rhales/core/view.rb', line 330 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
483 484 485 486 |
# File 'lib/rhales/core/view.rb', line 483 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
428 429 430 |
# File 'lib/rhales/core/view.rb', line 428 def reflection_enabled? config.hydration.reflection_enabled end |
#render(template_name = nil) ⇒ Object
Render RSFC template with hydration using two-pass architecture
76 77 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 |
# File 'lib/rhales/core/view.rb', line 76 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, :info, '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
184 185 186 187 188 189 190 191 192 193 194 |
# File 'lib/rhales/core/view.rb', line 184 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)
139 140 141 142 143 144 145 |
# File 'lib/rhales/core/view.rb', line 139 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
157 158 159 160 161 162 163 |
# File 'lib/rhales/core/view.rb', line 157 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
148 149 150 151 152 153 154 |
# File 'lib/rhales/core/view.rb', line 148 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)
130 131 132 133 134 135 136 |
# File 'lib/rhales/core/view.rb', line 130 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
268 269 270 271 272 273 274 275 276 277 |
# File 'lib/rhales/core/view.rb', line 268 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
340 341 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 |
# File 'lib/rhales/core/view.rb', line 340 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
229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 |
# File 'lib/rhales/core/view.rb', line 229 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
489 490 491 492 493 494 495 496 497 498 499 500 501 502 |
# File 'lib/rhales/core/view.rb', line 489 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
256 257 258 259 |
# File 'lib/rhales/core/view.rb', line 256 def templates_root boot_root = File.('../../..', __dir__) File.join(boot_root, 'templates') end |