rhales 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CLAUDE.locale.txt +7 -0
- data/CLAUDE.md +90 -0
- data/LICENSE.txt +21 -0
- data/README.md +881 -0
- data/lib/rhales/adapters/base_auth.rb +106 -0
- data/lib/rhales/adapters/base_request.rb +97 -0
- data/lib/rhales/adapters/base_session.rb +93 -0
- data/lib/rhales/configuration.rb +156 -0
- data/lib/rhales/context.rb +240 -0
- data/lib/rhales/csp.rb +94 -0
- data/lib/rhales/errors/hydration_collision_error.rb +85 -0
- data/lib/rhales/errors.rb +36 -0
- data/lib/rhales/hydration_data_aggregator.rb +220 -0
- data/lib/rhales/hydration_registry.rb +58 -0
- data/lib/rhales/hydrator.rb +141 -0
- data/lib/rhales/parsers/handlebars-grammar-review.txt +39 -0
- data/lib/rhales/parsers/handlebars_parser.rb +727 -0
- data/lib/rhales/parsers/rue_format_parser.rb +385 -0
- data/lib/rhales/refinements/require_refinements.rb +236 -0
- data/lib/rhales/rue_document.rb +304 -0
- data/lib/rhales/template_engine.rb +353 -0
- data/lib/rhales/tilt.rb +214 -0
- data/lib/rhales/version.rb +6 -0
- data/lib/rhales/view.rb +412 -0
- data/lib/rhales/view_composition.rb +165 -0
- data/lib/rhales.rb +57 -0
- data/rhales.gemspec +46 -0
- metadata +78 -0
data/lib/rhales/tilt.rb
ADDED
@@ -0,0 +1,214 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'tilt'
|
4
|
+
require 'rhales'
|
5
|
+
require_relative 'adapters/base_request'
|
6
|
+
|
7
|
+
module Rhales
|
8
|
+
# Tilt integration for Rhales templates
|
9
|
+
#
|
10
|
+
# This allows Rhales to be used with any framework that supports Tilt,
|
11
|
+
# including Roda's render plugin, Sinatra, and others.
|
12
|
+
#
|
13
|
+
# Usage in Roda:
|
14
|
+
# require 'rhales/tilt'
|
15
|
+
# plugin :render, engine: 'rhales'
|
16
|
+
#
|
17
|
+
# Usage in Sinatra:
|
18
|
+
# require 'rhales/tilt'
|
19
|
+
# set :template_engine, :rhales
|
20
|
+
#
|
21
|
+
class TiltTemplate < Tilt::Template
|
22
|
+
self.default_mime_type = 'text/html'
|
23
|
+
|
24
|
+
# Parse template during initialization
|
25
|
+
def prepare
|
26
|
+
# Store the template content - parsing happens during render
|
27
|
+
@template_content = data
|
28
|
+
end
|
29
|
+
|
30
|
+
# Render the template with given scope and locals
|
31
|
+
#
|
32
|
+
# @param scope [Object] The scope object (usually the Roda/Sinatra app instance)
|
33
|
+
# @param locals [Hash] Local variables for the template
|
34
|
+
# @param block [Proc] Optional block content
|
35
|
+
# @return [String] Rendered HTML
|
36
|
+
def evaluate(scope, locals = {}, &)
|
37
|
+
# Build template props from locals and scope
|
38
|
+
props = build_props(scope, locals, &)
|
39
|
+
|
40
|
+
# Create Rhales context adapters from scope
|
41
|
+
rhales_context = build_rhales_context(scope, props)
|
42
|
+
|
43
|
+
# Get template name from file path
|
44
|
+
template_name = derive_template_name
|
45
|
+
|
46
|
+
# Render the template
|
47
|
+
rhales_context.render(template_name)
|
48
|
+
end
|
49
|
+
|
50
|
+
private
|
51
|
+
|
52
|
+
# Get shared nonce from scope if available, otherwise generate one
|
53
|
+
def get_shared_nonce(scope)
|
54
|
+
# Try to get nonce from scope's CSP nonce or instance variable
|
55
|
+
if scope.respond_to?(:csp_nonce) && scope.csp_nonce
|
56
|
+
scope.csp_nonce
|
57
|
+
elsif scope.respond_to?(:request) && scope.request.env['csp.nonce']
|
58
|
+
scope.request.env['csp.nonce']
|
59
|
+
elsif scope.instance_variable_defined?(:@csp_nonce)
|
60
|
+
scope.instance_variable_get(:@csp_nonce)
|
61
|
+
else
|
62
|
+
# Generate a new nonce and store it for consistency
|
63
|
+
nonce = SecureRandom.hex(16)
|
64
|
+
scope.instance_variable_set(:@csp_nonce, nonce) if scope.respond_to?(:instance_variable_set)
|
65
|
+
nonce
|
66
|
+
end
|
67
|
+
end
|
68
|
+
|
69
|
+
# Build props hash from locals and scope context
|
70
|
+
def build_props(scope, locals, &block)
|
71
|
+
props = locals.dup
|
72
|
+
|
73
|
+
# Add block content if provided
|
74
|
+
props['content'] = yield if block
|
75
|
+
|
76
|
+
# Add scope-specific data
|
77
|
+
if scope.respond_to?(:request)
|
78
|
+
props['current_path'] = scope.request.path
|
79
|
+
props['request_method'] = scope.request.request_method
|
80
|
+
end
|
81
|
+
|
82
|
+
# Add flash messages if available
|
83
|
+
if scope.respond_to?(:flash)
|
84
|
+
props['flash_notice'] = scope.flash['notice']
|
85
|
+
props['flash_error'] = scope.flash['error']
|
86
|
+
end
|
87
|
+
|
88
|
+
# Add rodauth object if available
|
89
|
+
if scope.respond_to?(:rodauth)
|
90
|
+
props['rodauth'] = scope.rodauth
|
91
|
+
end
|
92
|
+
|
93
|
+
# Add authentication status
|
94
|
+
if scope.respond_to?(:logged_in?)
|
95
|
+
props['authenticated'] = scope.logged_in?
|
96
|
+
end
|
97
|
+
|
98
|
+
props
|
99
|
+
end
|
100
|
+
|
101
|
+
# Build Rhales context objects from scope
|
102
|
+
def build_rhales_context(scope, props)
|
103
|
+
# Get shared nonce from scope if available, otherwise generate one
|
104
|
+
shared_nonce = get_shared_nonce(scope)
|
105
|
+
|
106
|
+
# Use proper request adapter
|
107
|
+
request_data = if scope.respond_to?(:request)
|
108
|
+
# Add CSP nonce to framework request env
|
109
|
+
framework_env = scope.request.env.merge({
|
110
|
+
'nonce' => shared_nonce,
|
111
|
+
'request_id' => SecureRandom.hex(8),
|
112
|
+
})
|
113
|
+
|
114
|
+
# Create wrapper that preserves original but adds our env
|
115
|
+
wrapped_request = Class.new do
|
116
|
+
def initialize(original, custom_env)
|
117
|
+
@original = original
|
118
|
+
@custom_env = custom_env
|
119
|
+
end
|
120
|
+
|
121
|
+
def method_missing(method, *args, &block)
|
122
|
+
@original.send(method, *args, &block)
|
123
|
+
end
|
124
|
+
|
125
|
+
def respond_to_missing?(method, include_private = false)
|
126
|
+
@original.respond_to?(method, include_private)
|
127
|
+
end
|
128
|
+
|
129
|
+
def env
|
130
|
+
@custom_env
|
131
|
+
end
|
132
|
+
end.new(scope.request, framework_env)
|
133
|
+
|
134
|
+
Rhales::Adapters::FrameworkRequest.new(wrapped_request)
|
135
|
+
else
|
136
|
+
Rhales::Adapters::SimpleRequest.new(
|
137
|
+
path: '/',
|
138
|
+
method: 'GET',
|
139
|
+
ip: '127.0.0.1',
|
140
|
+
params: {},
|
141
|
+
env: {
|
142
|
+
'nonce' => shared_nonce,
|
143
|
+
'request_id' => SecureRandom.hex(8),
|
144
|
+
}
|
145
|
+
)
|
146
|
+
end
|
147
|
+
|
148
|
+
# Use proper session adapter
|
149
|
+
session_data = if scope.respond_to?(:logged_in?) && scope.logged_in?
|
150
|
+
Rhales::Adapters::AuthenticatedSession.new(
|
151
|
+
{
|
152
|
+
id: SecureRandom.hex(8),
|
153
|
+
created_at: Time.now,
|
154
|
+
},
|
155
|
+
)
|
156
|
+
else
|
157
|
+
Rhales::Adapters::AnonymousSession.new
|
158
|
+
end
|
159
|
+
|
160
|
+
# Use proper auth adapter
|
161
|
+
if scope.respond_to?(:logged_in?) && scope.logged_in? && scope.respond_to?(:current_user)
|
162
|
+
user = scope.current_user
|
163
|
+
auth_data = Rhales::Adapters::AuthenticatedAuth.new({
|
164
|
+
id: user[:id],
|
165
|
+
email: user[:email],
|
166
|
+
authenticated: true,
|
167
|
+
},
|
168
|
+
)
|
169
|
+
else
|
170
|
+
auth_data = Rhales::Adapters::AnonymousAuth.new
|
171
|
+
end
|
172
|
+
|
173
|
+
::Rhales::View.new(
|
174
|
+
request_data,
|
175
|
+
session_data,
|
176
|
+
auth_data,
|
177
|
+
nil, # locale_override
|
178
|
+
props: props,
|
179
|
+
)
|
180
|
+
end
|
181
|
+
|
182
|
+
# Derive template name from file path
|
183
|
+
def derive_template_name
|
184
|
+
return @template_name if @template_name
|
185
|
+
|
186
|
+
if file
|
187
|
+
# Remove extension and get relative path from template directory
|
188
|
+
template_path = File.basename(file, '.*')
|
189
|
+
|
190
|
+
# Check if this is in a subdirectory (relative to configured paths)
|
191
|
+
if ::Rhales.configuration.template_paths
|
192
|
+
::Rhales.configuration.template_paths.each do |path|
|
193
|
+
next unless file.start_with?(path)
|
194
|
+
|
195
|
+
relative_path = file.sub(path + '/', '')
|
196
|
+
template_path = if File.dirname(relative_path) == '.'
|
197
|
+
File.basename(relative_path, '.*')
|
198
|
+
else
|
199
|
+
File.join(File.dirname(relative_path), File.basename(relative_path, '.*'))
|
200
|
+
end
|
201
|
+
break
|
202
|
+
end
|
203
|
+
end
|
204
|
+
|
205
|
+
@template_name = template_path
|
206
|
+
else
|
207
|
+
@template_name = 'unknown'
|
208
|
+
end
|
209
|
+
end
|
210
|
+
end
|
211
|
+
end
|
212
|
+
|
213
|
+
# Register the template with Tilt
|
214
|
+
Tilt.register Rhales::TiltTemplate, 'rue'
|
data/lib/rhales/view.rb
ADDED
@@ -0,0 +1,412 @@
|
|
1
|
+
# lib/rhales/view.rb
|
2
|
+
|
3
|
+
require 'securerandom'
|
4
|
+
require_relative 'context'
|
5
|
+
require_relative 'rue_document'
|
6
|
+
require_relative 'template_engine'
|
7
|
+
require_relative 'hydrator'
|
8
|
+
require_relative 'view_composition'
|
9
|
+
require_relative 'hydration_data_aggregator'
|
10
|
+
require_relative 'csp'
|
11
|
+
require_relative 'refinements/require_refinements'
|
12
|
+
|
13
|
+
using Rhales::Ruequire
|
14
|
+
|
15
|
+
module Rhales
|
16
|
+
# Complete RSFC view implementation
|
17
|
+
#
|
18
|
+
# Single public interface for RSFC template rendering that handles:
|
19
|
+
# - Context creation (with pluggable context classes)
|
20
|
+
# - Template loading and parsing
|
21
|
+
# - Template rendering with Rhales
|
22
|
+
# - Data hydration and injection
|
23
|
+
#
|
24
|
+
# ## Context and Data Boundaries
|
25
|
+
#
|
26
|
+
# Views implement a two-phase security model:
|
27
|
+
#
|
28
|
+
# ### Server Templates: Full Context Access
|
29
|
+
# Templates have complete access to all server-side data:
|
30
|
+
# - All props passed to View.new
|
31
|
+
# - Data from .rue file's <data> section (processed server-side)
|
32
|
+
# - Runtime data (CSRF tokens, nonces, request metadata)
|
33
|
+
# - Computed data (authentication status, theme classes)
|
34
|
+
# - User objects, configuration, internal APIs
|
35
|
+
#
|
36
|
+
# ### Client Data: Explicit Allowlist
|
37
|
+
# Only data declared in <data> sections reahas_role?ches the browser:
|
38
|
+
# - Creates a REST API-like boundary
|
39
|
+
# - Server-side variable interpolation processes secrets safely
|
40
|
+
# - JSON serialization validates data structure
|
41
|
+
# - No accidental exposure of sensitive server data
|
42
|
+
#
|
43
|
+
# Example:
|
44
|
+
# # Server template has full access:
|
45
|
+
# {{user.admin?}} {{csrf_token}} {{internal_config}}
|
46
|
+
#
|
47
|
+
# # Client only gets declared data:
|
48
|
+
# { "user_name": "{{user.name}}", "theme": "{{user.theme}}" }
|
49
|
+
#
|
50
|
+
# See docs/CONTEXT_AND_DATA_BOUNDARIES.md for complete details.
|
51
|
+
#
|
52
|
+
# Subclasses can override context_class to use different context implementations.
|
53
|
+
class View
|
54
|
+
class RenderError < StandardError; end
|
55
|
+
class TemplateNotFoundError < RenderError; end
|
56
|
+
|
57
|
+
attr_reader :req, :sess, :cust, :locale, :rsfc_context, :props, :config
|
58
|
+
|
59
|
+
def initialize(req, sess = nil, cust = nil, locale_override = nil, props: {}, config: nil)
|
60
|
+
@req = req
|
61
|
+
@sess = sess
|
62
|
+
@cust = cust
|
63
|
+
@locale = locale_override
|
64
|
+
@props = props
|
65
|
+
@config = config || Rhales.configuration
|
66
|
+
|
67
|
+
# Create context using the specified context class
|
68
|
+
@rsfc_context = create_context
|
69
|
+
end
|
70
|
+
|
71
|
+
# Render RSFC template with hydration using two-pass architecture
|
72
|
+
def render(template_name = nil)
|
73
|
+
template_name ||= self.class.default_template_name
|
74
|
+
|
75
|
+
# Phase 1: Build view composition and aggregate data
|
76
|
+
composition = build_view_composition(template_name)
|
77
|
+
aggregator = HydrationDataAggregator.new(@rsfc_context)
|
78
|
+
merged_hydration_data = aggregator.aggregate(composition)
|
79
|
+
|
80
|
+
# Phase 2: Render HTML with pre-computed data
|
81
|
+
# Render template content
|
82
|
+
template_html = render_template_with_composition(composition, template_name)
|
83
|
+
|
84
|
+
# Generate hydration HTML with merged data
|
85
|
+
hydration_html = generate_hydration_from_merged_data(merged_hydration_data)
|
86
|
+
|
87
|
+
# Set CSP header if enabled
|
88
|
+
set_csp_header_if_enabled
|
89
|
+
|
90
|
+
# Combine template and hydration
|
91
|
+
inject_hydration_into_template(template_html, hydration_html)
|
92
|
+
rescue StandardError => ex
|
93
|
+
raise RenderError, "Failed to render template '#{template_name}': #{ex.message}"
|
94
|
+
end
|
95
|
+
|
96
|
+
# Render only the template section (without data hydration)
|
97
|
+
def render_template_only(template_name = nil)
|
98
|
+
template_name ||= self.class.default_template_name
|
99
|
+
|
100
|
+
# Build composition for consistent behavior
|
101
|
+
composition = build_view_composition(template_name)
|
102
|
+
render_template_with_composition(composition, template_name)
|
103
|
+
end
|
104
|
+
|
105
|
+
# Generate only the data hydration HTML
|
106
|
+
def render_hydration_only(template_name = nil)
|
107
|
+
template_name ||= self.class.default_template_name
|
108
|
+
|
109
|
+
# Build composition and aggregate data
|
110
|
+
composition = build_view_composition(template_name)
|
111
|
+
aggregator = HydrationDataAggregator.new(@rsfc_context)
|
112
|
+
merged_hydration_data = aggregator.aggregate(composition)
|
113
|
+
|
114
|
+
# Generate hydration HTML
|
115
|
+
generate_hydration_from_merged_data(merged_hydration_data)
|
116
|
+
end
|
117
|
+
|
118
|
+
# Get processed data as hash (for API endpoints or testing)
|
119
|
+
def data_hash(template_name = nil)
|
120
|
+
template_name ||= self.class.default_template_name
|
121
|
+
|
122
|
+
# Build composition and aggregate data
|
123
|
+
composition = build_view_composition(template_name)
|
124
|
+
aggregator = HydrationDataAggregator.new(@rsfc_context)
|
125
|
+
aggregator.aggregate(composition)
|
126
|
+
end
|
127
|
+
|
128
|
+
protected
|
129
|
+
|
130
|
+
# Create the appropriate context for this view
|
131
|
+
# Subclasses can override this to use different context types
|
132
|
+
def create_context
|
133
|
+
context_class.for_view(@req, @sess, @cust, @locale, config: @config, **@props)
|
134
|
+
end
|
135
|
+
|
136
|
+
# Return the context class to use
|
137
|
+
# Subclasses can override this to use different context implementations
|
138
|
+
def context_class
|
139
|
+
Context
|
140
|
+
end
|
141
|
+
|
142
|
+
private
|
143
|
+
|
144
|
+
# Load and parse template
|
145
|
+
def load_template(template_name)
|
146
|
+
template_path = resolve_template_path(template_name)
|
147
|
+
|
148
|
+
unless File.exist?(template_path)
|
149
|
+
raise TemplateNotFoundError, "Template not found: #{template_path}"
|
150
|
+
end
|
151
|
+
|
152
|
+
# Use refinement to load .rue file
|
153
|
+
require template_path
|
154
|
+
end
|
155
|
+
|
156
|
+
# Resolve template path
|
157
|
+
def resolve_template_path(template_name)
|
158
|
+
# Check configured template paths first
|
159
|
+
if @config && @config.template_paths && !@config.template_paths.empty?
|
160
|
+
@config.template_paths.each do |path|
|
161
|
+
template_path = File.join(path, "#{template_name}.rue")
|
162
|
+
return template_path if File.exist?(template_path)
|
163
|
+
end
|
164
|
+
end
|
165
|
+
|
166
|
+
# Fallback to default template structure
|
167
|
+
# First try templates/web directory
|
168
|
+
web_path = File.join(templates_root, 'web', "#{template_name}.rue")
|
169
|
+
return web_path if File.exist?(web_path)
|
170
|
+
|
171
|
+
# Then try templates directory
|
172
|
+
templates_path = File.join(templates_root, "#{template_name}.rue")
|
173
|
+
return templates_path if File.exist?(templates_path)
|
174
|
+
|
175
|
+
# Return first configured path or web path for error message
|
176
|
+
if @config && @config.template_paths && !@config.template_paths.empty?
|
177
|
+
File.join(@config.template_paths.first, "#{template_name}.rue")
|
178
|
+
else
|
179
|
+
web_path
|
180
|
+
end
|
181
|
+
end
|
182
|
+
|
183
|
+
# Get templates root directory
|
184
|
+
def templates_root
|
185
|
+
boot_root = if defined?(OT) && OT.respond_to?(:boot_root)
|
186
|
+
OT.boot_root
|
187
|
+
else
|
188
|
+
File.expand_path('../../..', __dir__)
|
189
|
+
end
|
190
|
+
File.join(boot_root, 'templates')
|
191
|
+
end
|
192
|
+
|
193
|
+
# Render template section with Rhales
|
194
|
+
#
|
195
|
+
# RSFC Security Model: Templates have full server context access
|
196
|
+
# - Templates can access all business data, user objects, methods, etc.
|
197
|
+
# - Templates can access data from .rue file's <data> section (processed server-side)
|
198
|
+
# - This is like any server-side template (ERB, HAML, etc.)
|
199
|
+
# - Security boundary is at server-to-client handoff, not within server rendering
|
200
|
+
# - Only data declared in <data> section reaches the client (after processing)
|
201
|
+
def render_template_section(parser)
|
202
|
+
template_content = parser.section('template')
|
203
|
+
return '' unless template_content
|
204
|
+
|
205
|
+
# Create partial resolver
|
206
|
+
partial_resolver = create_partial_resolver
|
207
|
+
|
208
|
+
# Merge .rue file data with existing context
|
209
|
+
context_with_rue_data = create_context_with_rue_data(parser)
|
210
|
+
|
211
|
+
# Render with full server context (props + computed context + rue data)
|
212
|
+
TemplateEngine.render(template_content, context_with_rue_data, partial_resolver: partial_resolver)
|
213
|
+
end
|
214
|
+
|
215
|
+
# Create partial resolver for {{> partial}} inclusions
|
216
|
+
def create_partial_resolver
|
217
|
+
templates_dir = File.join(templates_root, 'web')
|
218
|
+
|
219
|
+
proc do |partial_name|
|
220
|
+
partial_path = File.join(templates_dir, "#{partial_name}.rue")
|
221
|
+
|
222
|
+
if File.exist?(partial_path)
|
223
|
+
# Parse partial and return template section
|
224
|
+
partial_parser = require(partial_path)
|
225
|
+
partial_parser.section('template')
|
226
|
+
else
|
227
|
+
nil
|
228
|
+
end
|
229
|
+
end
|
230
|
+
end
|
231
|
+
|
232
|
+
# Generate data hydration HTML
|
233
|
+
def generate_hydration(parser)
|
234
|
+
Hydrator.generate(parser, @rsfc_context)
|
235
|
+
end
|
236
|
+
|
237
|
+
# Create context that includes data from .rue file's data section
|
238
|
+
def create_context_with_rue_data(parser)
|
239
|
+
# Get data from .rue file's data section
|
240
|
+
rue_data = extract_rue_data(parser)
|
241
|
+
|
242
|
+
# Merge rue data with existing props (rue data takes precedence)
|
243
|
+
merged_props = @props.merge(rue_data)
|
244
|
+
|
245
|
+
# Create new context with merged data
|
246
|
+
context_class.for_view(@req, @sess, @cust, @locale, config: @config, **merged_props)
|
247
|
+
end
|
248
|
+
|
249
|
+
# Extract and process data from .rue file's data section
|
250
|
+
def extract_rue_data(parser)
|
251
|
+
data_content = parser.section('data')
|
252
|
+
return {} unless data_content
|
253
|
+
|
254
|
+
# Process the data section as JSON and parse it
|
255
|
+
hydrator = Hydrator.new(parser, @rsfc_context)
|
256
|
+
hydrator.processed_data_hash
|
257
|
+
rescue JSON::ParserError, Hydrator::JSONSerializationError => ex
|
258
|
+
# If data section isn't valid JSON, return empty hash
|
259
|
+
# This allows templates to work even with malformed data sections
|
260
|
+
{}
|
261
|
+
end
|
262
|
+
|
263
|
+
# Inject hydration HTML into template
|
264
|
+
def inject_hydration_into_template(template_html, hydration_html)
|
265
|
+
# Try to inject before closing </body> tag
|
266
|
+
if template_html.include?('</body>')
|
267
|
+
template_html.sub('</body>', "#{hydration_html}\n</body>")
|
268
|
+
# Otherwise append to end
|
269
|
+
else
|
270
|
+
"#{template_html}\n#{hydration_html}"
|
271
|
+
end
|
272
|
+
end
|
273
|
+
|
274
|
+
# Build view composition for the given template
|
275
|
+
def build_view_composition(template_name)
|
276
|
+
loader = method(:load_template_for_composition)
|
277
|
+
composition = ViewComposition.new(template_name, loader: loader)
|
278
|
+
composition.resolve!
|
279
|
+
end
|
280
|
+
|
281
|
+
# Loader proc for ViewComposition
|
282
|
+
def load_template_for_composition(template_name)
|
283
|
+
template_path = resolve_template_path(template_name)
|
284
|
+
return nil unless File.exist?(template_path)
|
285
|
+
|
286
|
+
require template_path
|
287
|
+
rescue StandardError => ex
|
288
|
+
raise TemplateNotFoundError, "Failed to load template #{template_name}: #{ex.message}"
|
289
|
+
end
|
290
|
+
|
291
|
+
# Render template using the view composition
|
292
|
+
def render_template_with_composition(composition, root_template_name)
|
293
|
+
root_parser = composition.template(root_template_name)
|
294
|
+
template_content = root_parser.section('template')
|
295
|
+
return '' unless template_content
|
296
|
+
|
297
|
+
# Create partial resolver that uses the composition
|
298
|
+
partial_resolver = create_partial_resolver_from_composition(composition)
|
299
|
+
|
300
|
+
# Merge .rue file data with existing context
|
301
|
+
context_with_rue_data = create_context_with_rue_data(root_parser)
|
302
|
+
|
303
|
+
# Check if template has a layout
|
304
|
+
if root_parser.layout && composition.template(root_parser.layout)
|
305
|
+
# Render content template first
|
306
|
+
content_html = TemplateEngine.render(template_content, context_with_rue_data, partial_resolver: partial_resolver)
|
307
|
+
|
308
|
+
# Then render layout with content
|
309
|
+
layout_parser = composition.template(root_parser.layout)
|
310
|
+
layout_content = layout_parser.section('template')
|
311
|
+
return '' unless layout_content
|
312
|
+
|
313
|
+
# Create new context with content for layout rendering
|
314
|
+
layout_props = context_with_rue_data.props.merge('content' => content_html)
|
315
|
+
layout_context = Context.new(
|
316
|
+
context_with_rue_data.req,
|
317
|
+
context_with_rue_data.sess,
|
318
|
+
context_with_rue_data.cust,
|
319
|
+
context_with_rue_data.locale,
|
320
|
+
props: layout_props,
|
321
|
+
config: context_with_rue_data.config,
|
322
|
+
)
|
323
|
+
|
324
|
+
TemplateEngine.render(layout_content, layout_context, partial_resolver: partial_resolver)
|
325
|
+
else
|
326
|
+
# Render with full server context (no layout)
|
327
|
+
TemplateEngine.render(template_content, context_with_rue_data, partial_resolver: partial_resolver)
|
328
|
+
end
|
329
|
+
end
|
330
|
+
|
331
|
+
# Create partial resolver that uses pre-loaded templates from composition
|
332
|
+
def create_partial_resolver_from_composition(composition)
|
333
|
+
proc do |partial_name|
|
334
|
+
parser = composition.template(partial_name)
|
335
|
+
parser ? parser.section('template') : nil
|
336
|
+
end
|
337
|
+
end
|
338
|
+
|
339
|
+
# Generate hydration HTML from pre-merged data
|
340
|
+
def generate_hydration_from_merged_data(merged_data)
|
341
|
+
hydration_parts = []
|
342
|
+
|
343
|
+
merged_data.each do |window_attr, data|
|
344
|
+
# Generate unique ID for this data block
|
345
|
+
unique_id = "rsfc-data-#{SecureRandom.hex(8)}"
|
346
|
+
|
347
|
+
# Create JSON script tag
|
348
|
+
json_script = <<~HTML.strip
|
349
|
+
<script id="#{unique_id}" type="application/json">#{JSON.generate(data)}</script>
|
350
|
+
HTML
|
351
|
+
|
352
|
+
# Create hydration script
|
353
|
+
nonce_attr = nonce_attribute
|
354
|
+
hydration_script = <<~HTML.strip
|
355
|
+
<script#{nonce_attr}>
|
356
|
+
window.#{window_attr} = JSON.parse(document.getElementById('#{unique_id}').textContent);
|
357
|
+
</script>
|
358
|
+
HTML
|
359
|
+
|
360
|
+
hydration_parts << json_script
|
361
|
+
hydration_parts << hydration_script
|
362
|
+
end
|
363
|
+
|
364
|
+
hydration_parts.join("\n")
|
365
|
+
end
|
366
|
+
|
367
|
+
# Get nonce attribute if available
|
368
|
+
def nonce_attribute
|
369
|
+
nonce = @rsfc_context.get('nonce')
|
370
|
+
nonce ? " nonce=\"#{nonce}\"" : ''
|
371
|
+
end
|
372
|
+
|
373
|
+
# Set CSP header if enabled
|
374
|
+
def set_csp_header_if_enabled
|
375
|
+
return unless @config.csp_enabled
|
376
|
+
return unless @req && @req.respond_to?(:env)
|
377
|
+
|
378
|
+
# Get nonce from context
|
379
|
+
nonce = @rsfc_context.get('nonce')
|
380
|
+
|
381
|
+
# Create CSP instance and build header
|
382
|
+
csp = CSP.new(@config, nonce: nonce)
|
383
|
+
header_value = csp.build_header
|
384
|
+
|
385
|
+
# Set header in request environment for framework to use
|
386
|
+
@req.env['csp_header'] = header_value if header_value
|
387
|
+
end
|
388
|
+
|
389
|
+
class << self
|
390
|
+
# Get default template name based on class name
|
391
|
+
def default_template_name
|
392
|
+
# Convert ClassName to class_name
|
393
|
+
name.split('::').last
|
394
|
+
.gsub(/([A-Z])/, '_\1')
|
395
|
+
.downcase
|
396
|
+
.sub(/^_/, '')
|
397
|
+
.sub(/_view$/, '')
|
398
|
+
end
|
399
|
+
|
400
|
+
# Render template with props
|
401
|
+
def render_with_data(req, sess, cust, locale, template_name: nil, config: nil, **props)
|
402
|
+
view = new(req, sess, cust, locale, props: props, config: config)
|
403
|
+
view.render(template_name)
|
404
|
+
end
|
405
|
+
|
406
|
+
# Create view instance with props
|
407
|
+
def with_data(req, sess, cust, locale, config: nil, **props)
|
408
|
+
new(req, sess, cust, locale, props: props, config: config)
|
409
|
+
end
|
410
|
+
end
|
411
|
+
end
|
412
|
+
end
|