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.
@@ -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'
@@ -0,0 +1,6 @@
1
+ # lib/rhales/version.rb
2
+
3
+ module Rhales
4
+ # Version information for the RSFC gem
5
+ VERSION = '0.3.0'
6
+ end
@@ -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