rhales 0.3.0 → 0.4.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.
@@ -4,6 +4,7 @@ require 'erb'
4
4
  require_relative 'parsers/rue_format_parser'
5
5
  require_relative 'parsers/handlebars_parser'
6
6
  require_relative 'rue_document'
7
+ require_relative 'hydrator'
7
8
 
8
9
  module Rhales
9
10
  # Rhales - Ruby Handlebars-style template engine
@@ -214,9 +215,35 @@ module Rhales
214
215
  partial_content = @partial_resolver.call(partial_name)
215
216
  raise PartialNotFoundError, "Partial '#{partial_name}' not found" unless partial_content
216
217
 
217
- # Recursively render the partial content
218
- engine = self.class.new(partial_content, @context, partial_resolver: @partial_resolver)
219
- engine.render
218
+ # Check if this is a .rue document with sections
219
+ if partial_content.match?(/^<(data|template|logic)\b/)
220
+ # Parse as RueDocument to handle data sections properly
221
+ partial_doc = RueDocument.new(partial_content)
222
+ partial_doc.parse!
223
+
224
+ # Extract template section
225
+ template_content = partial_doc.section('template')
226
+ raise PartialNotFoundError, "Partial '#{partial_name}' missing template section" unless template_content
227
+
228
+ # Process data section if present and merge with parent context
229
+ merged_context = @context
230
+ if partial_doc.section('data')
231
+ # Create hydrator with parent context to process interpolations
232
+ hydrator = Hydrator.new(partial_doc, @context)
233
+ local_data = hydrator.processed_data_hash
234
+
235
+ # Create merged context (local data takes precedence)
236
+ merged_context = create_merged_context(@context, local_data)
237
+ end
238
+
239
+ # Render template with merged context
240
+ engine = self.class.new(template_content, merged_context, partial_resolver: @partial_resolver)
241
+ engine.render
242
+ else
243
+ # Simple template without sections - render as before
244
+ engine = self.class.new(partial_content, @context, partial_resolver: @partial_resolver)
245
+ engine.render
246
+ end
220
247
  end
221
248
 
222
249
  # Get variable value from context
@@ -234,8 +261,6 @@ module Rhales
234
261
  @context.get(variable_name)
235
262
  elsif @context.respond_to?(:[])
236
263
  @context[variable_name] || @context[variable_name.to_sym]
237
- else
238
- nil
239
264
  end
240
265
  end
241
266
 
@@ -272,6 +297,23 @@ module Rhales
272
297
  ERB::Util.html_escape(string)
273
298
  end
274
299
 
300
+ # Create a new context with merged data
301
+ def create_merged_context(parent_context, local_data)
302
+ # Extract all props from parent context and merge with local data
303
+ # Local data takes precedence over parent props
304
+ merged_props = parent_context.props.merge(local_data)
305
+
306
+ # Create new context with merged props, preserving other context attributes
307
+ Context.for_view(
308
+ parent_context.req,
309
+ parent_context.sess,
310
+ parent_context.cust,
311
+ parent_context.locale,
312
+ config: parent_context.config,
313
+ **merged_props,
314
+ )
315
+ end
316
+
275
317
  # Context wrapper for {{#each}} iterations
276
318
  class EachContext
277
319
  attr_reader :parent_context, :current_item, :current_index, :items_var
@@ -343,8 +385,6 @@ module Rhales
343
385
  # Load and parse the partial .rue file
344
386
  document = RueDocument.parse_file(partial_path)
345
387
  document.section('template')
346
- else
347
- nil
348
388
  end
349
389
  end
350
390
  end
data/lib/rhales/tilt.rb CHANGED
@@ -1,4 +1,4 @@
1
- # frozen_string_literal: true
1
+ # lib/rhales/tilt.rb
2
2
 
3
3
  require 'tilt'
4
4
  require 'rhales'
@@ -109,7 +109,8 @@ module Rhales
109
109
  framework_env = scope.request.env.merge({
110
110
  'nonce' => shared_nonce,
111
111
  'request_id' => SecureRandom.hex(8),
112
- })
112
+ },
113
+ )
113
114
 
114
115
  # Create wrapper that preserves original but adds our env
115
116
  wrapped_request = Class.new do
@@ -118,8 +119,8 @@ module Rhales
118
119
  @custom_env = custom_env
119
120
  end
120
121
 
121
- def method_missing(method, *args, &block)
122
- @original.send(method, *args, &block)
122
+ def method_missing(method, *, &)
123
+ @original.send(method, *, &)
123
124
  end
124
125
 
125
126
  def respond_to_missing?(method, include_private = false)
@@ -141,7 +142,7 @@ module Rhales
141
142
  env: {
142
143
  'nonce' => shared_nonce,
143
144
  'request_id' => SecureRandom.hex(8),
144
- }
145
+ },
145
146
  )
146
147
  end
147
148
 
@@ -2,5 +2,7 @@
2
2
 
3
3
  module Rhales
4
4
  # Version information for the RSFC gem
5
- VERSION = '0.3.0'
5
+ unless defined?(Rhales::VERSION)
6
+ VERSION = '0.4.0'
7
+ end
6
8
  end
data/lib/rhales/view.rb CHANGED
@@ -87,8 +87,8 @@ module Rhales
87
87
  # Set CSP header if enabled
88
88
  set_csp_header_if_enabled
89
89
 
90
- # Combine template and hydration
91
- inject_hydration_into_template(template_html, hydration_html)
90
+ # Smart hydration injection with mount point detection
91
+ inject_hydration_with_mount_points(composition, template_name, template_html, hydration_html)
92
92
  rescue StandardError => ex
93
93
  raise RenderError, "Failed to render template '#{template_name}': #{ex.message}"
94
94
  end
@@ -102,6 +102,51 @@ module Rhales
102
102
  render_template_with_composition(composition, template_name)
103
103
  end
104
104
 
105
+ # Render JSON response for API endpoints (link-based strategies)
106
+ def render_json_only(template_name = nil, additional_context = {})
107
+ require_relative 'hydration_endpoint'
108
+
109
+ template_name ||= self.class.default_template_name
110
+ endpoint = HydrationEndpoint.new(@config, @rsfc_context)
111
+ endpoint.render_json(template_name, additional_context)
112
+ end
113
+
114
+ # Render ES module response for modulepreload strategy
115
+ def render_module_only(template_name = nil, additional_context = {})
116
+ require_relative 'hydration_endpoint'
117
+
118
+ template_name ||= self.class.default_template_name
119
+ endpoint = HydrationEndpoint.new(@config, @rsfc_context)
120
+ endpoint.render_module(template_name, additional_context)
121
+ end
122
+
123
+ # Render JSONP response with callback
124
+ def render_jsonp_only(template_name = nil, callback_name = 'callback', additional_context = {})
125
+ require_relative 'hydration_endpoint'
126
+
127
+ template_name ||= self.class.default_template_name
128
+ endpoint = HydrationEndpoint.new(@config, @rsfc_context)
129
+ endpoint.render_jsonp(template_name, callback_name, additional_context)
130
+ end
131
+
132
+ # Check if template data has changed for caching
133
+ def data_changed?(template_name = nil, etag = nil, additional_context = {})
134
+ require_relative 'hydration_endpoint'
135
+
136
+ template_name ||= self.class.default_template_name
137
+ endpoint = HydrationEndpoint.new(@config, @rsfc_context)
138
+ endpoint.data_changed?(template_name, etag, additional_context)
139
+ end
140
+
141
+ # Calculate ETag for current template data
142
+ def calculate_etag(template_name = nil, additional_context = {})
143
+ require_relative 'hydration_endpoint'
144
+
145
+ template_name ||= self.class.default_template_name
146
+ endpoint = HydrationEndpoint.new(@config, @rsfc_context)
147
+ endpoint.calculate_etag(template_name, additional_context)
148
+ end
149
+
105
150
  # Generate only the data hydration HTML
106
151
  def render_hydration_only(template_name = nil)
107
152
  template_name ||= self.class.default_template_name
@@ -182,11 +227,7 @@ module Rhales
182
227
 
183
228
  # Get templates root directory
184
229
  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
230
+ boot_root = File.expand_path('../../..', __dir__)
190
231
  File.join(boot_root, 'templates')
191
232
  end
192
233
 
@@ -220,11 +261,9 @@ module Rhales
220
261
  partial_path = File.join(templates_dir, "#{partial_name}.rue")
221
262
 
222
263
  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
264
+ # Return full partial content so TemplateEngine can process
265
+ # data sections, otherwise nil.
266
+ File.read(partial_path)
228
267
  end
229
268
  end
230
269
  end
@@ -255,12 +294,32 @@ module Rhales
255
294
  hydrator = Hydrator.new(parser, @rsfc_context)
256
295
  hydrator.processed_data_hash
257
296
  rescue JSON::ParserError, Hydrator::JSONSerializationError => ex
297
+ puts "Error processing data section: #{ex.message}"
258
298
  # If data section isn't valid JSON, return empty hash
259
299
  # This allows templates to work even with malformed data sections
260
300
  {}
261
301
  end
262
302
 
263
- # Inject hydration HTML into template
303
+ # Smart hydration injection with mount point detection on rendered HTML
304
+ def inject_hydration_with_mount_points(composition, template_name, template_html, hydration_html)
305
+ injector = HydrationInjector.new(@config.hydration, template_name)
306
+
307
+ # Check if using link-based strategy
308
+ if @config.hydration.link_based_strategy?
309
+ # For link-based strategies, we need the merged data context
310
+ aggregator = HydrationDataAggregator.new(@rsfc_context)
311
+ merged_data = aggregator.aggregate(composition)
312
+ nonce = @rsfc_context.get('nonce')
313
+
314
+ injector.inject_link_based_strategy(template_html, merged_data, nonce)
315
+ else
316
+ # Traditional strategies (early, earliest, late)
317
+ mount_point = detect_mount_point_in_rendered_html(template_html)
318
+ injector.inject(template_html, hydration_html, mount_point)
319
+ end
320
+ end
321
+
322
+ # Legacy injection method (kept for backwards compatibility)
264
323
  def inject_hydration_into_template(template_html, hydration_html)
265
324
  # Try to inject before closing </body> tag
266
325
  if template_html.include?('</body>')
@@ -271,10 +330,19 @@ module Rhales
271
330
  end
272
331
  end
273
332
 
333
+ # Detect mount points in fully rendered HTML
334
+ def detect_mount_point_in_rendered_html(template_html)
335
+ return nil unless @config&.hydration
336
+
337
+ custom_selectors = @config.hydration.mount_point_selectors || []
338
+ detector = MountPointDetector.new
339
+ detector.detect(template_html, custom_selectors)
340
+ end
341
+
274
342
  # Build view composition for the given template
275
343
  def build_view_composition(template_name)
276
344
  loader = method(:load_template_for_composition)
277
- composition = ViewComposition.new(template_name, loader: loader)
345
+ composition = ViewComposition.new(template_name, loader: loader, config: @config)
278
346
  composition.resolve!
279
347
  end
280
348
 
@@ -332,7 +400,7 @@ module Rhales
332
400
  def create_partial_resolver_from_composition(composition)
333
401
  proc do |partial_name|
334
402
  parser = composition.template(partial_name)
335
- parser ? parser.section('template') : nil
403
+ parser ? parser.content : nil
336
404
  end
337
405
  end
338
406
 
@@ -344,30 +412,102 @@ module Rhales
344
412
  # Generate unique ID for this data block
345
413
  unique_id = "rsfc-data-#{SecureRandom.hex(8)}"
346
414
 
347
- # Create JSON script tag
415
+ # Create JSON script tag with optional reflection attributes
416
+ json_attrs = reflection_enabled? ? " data-window=\"#{window_attr}\"" : ""
348
417
  json_script = <<~HTML.strip
349
- <script id="#{unique_id}" type="application/json">#{JSON.generate(data)}</script>
418
+ <script id="#{unique_id}" type="application/json"#{json_attrs}>#{JSON.generate(data)}</script>
350
419
  HTML
351
420
 
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
421
+ # Create hydration script with optional reflection attributes
422
+ nonce_attr = nonce_attribute
423
+ hydration_attrs = reflection_enabled? ? " data-hydration-target=\"#{window_attr}\"" : ""
424
+ hydration_script = if reflection_enabled?
425
+ <<~HTML.strip
426
+ <script#{nonce_attr}#{hydration_attrs}>
427
+ var dataScript = document.getElementById('#{unique_id}');
428
+ var targetName = dataScript.getAttribute('data-window') || '#{window_attr}';
429
+ window[targetName] = JSON.parse(dataScript.textContent);
430
+ </script>
431
+ HTML
432
+ else
433
+ <<~HTML.strip
434
+ <script#{nonce_attr}#{hydration_attrs}>
435
+ window['#{window_attr}'] = JSON.parse(document.getElementById('#{unique_id}').textContent);
436
+ </script>
437
+ HTML
438
+ end
359
439
 
360
440
  hydration_parts << json_script
361
441
  hydration_parts << hydration_script
362
442
  end
363
443
 
444
+ # Add reflection utilities if enabled
445
+ if reflection_enabled? && !merged_data.empty?
446
+ hydration_parts << generate_reflection_utilities
447
+ end
448
+
364
449
  hydration_parts.join("\n")
365
450
  end
366
451
 
452
+ # Check if reflection system is enabled
453
+ def reflection_enabled?
454
+ @config.hydration.reflection_enabled
455
+ end
456
+
457
+ # Generate JavaScript utilities for hydration reflection
458
+ def generate_reflection_utilities
459
+ nonce_attr = nonce_attribute
460
+
461
+ <<~HTML.strip
462
+ <script#{nonce_attr}>
463
+ // Rhales hydration reflection utilities
464
+ window.__rhales__ = window.__rhales__ || {
465
+ getHydrationTargets: function() {
466
+ return Array.from(document.querySelectorAll('[data-hydration-target]'));
467
+ },
468
+ getDataForTarget: function(target) {
469
+ var targetName = target.dataset.hydrationTarget;
470
+ return targetName ? window[targetName] : undefined;
471
+ },
472
+ getWindowAttribute: function(scriptEl) {
473
+ return scriptEl.dataset.window;
474
+ },
475
+ getDataScripts: function() {
476
+ return Array.from(document.querySelectorAll('script[data-window]'));
477
+ },
478
+ refreshData: function(target) {
479
+ var targetName = target.dataset.hydrationTarget;
480
+ var dataScript = document.querySelector('script[data-window="' + targetName + '"]');
481
+ if (dataScript && targetName) {
482
+ try {
483
+ window[targetName] = JSON.parse(dataScript.textContent);
484
+ return true;
485
+ } catch (e) {
486
+ console.error('Rhales: Failed to refresh data for ' + targetName, e);
487
+ return false;
488
+ }
489
+ }
490
+ return false;
491
+ },
492
+ getAllHydrationData: function() {
493
+ var data = {};
494
+ this.getHydrationTargets().forEach(function(target) {
495
+ var targetName = target.dataset.hydrationTarget;
496
+ if (targetName) {
497
+ data[targetName] = window[targetName];
498
+ }
499
+ });
500
+ return data;
501
+ }
502
+ };
503
+ </script>
504
+ HTML
505
+ end
506
+
367
507
  # Get nonce attribute if available
368
508
  def nonce_attribute
369
509
  nonce = @rsfc_context.get('nonce')
370
- nonce ? " nonce=\"#{nonce}\"" : ''
510
+ nonce ? " nonce=\"#{ERB::Util.html_escape(nonce)}\"" : ''
371
511
  end
372
512
 
373
513
  # Set CSP header if enabled
@@ -28,9 +28,10 @@ module Rhales
28
28
 
29
29
  attr_reader :root_template_name, :templates, :dependencies
30
30
 
31
- def initialize(root_template_name, loader:)
31
+ def initialize(root_template_name, loader:, config: nil)
32
32
  @root_template_name = root_template_name
33
33
  @loader = loader
34
+ @config = config
34
35
  @templates = {}
35
36
  @dependencies = {}
36
37
  @loading = Set.new
@@ -69,7 +70,7 @@ module Rhales
69
70
  end
70
71
 
71
72
  # Check if a template exists in the composition
72
- def has_template?(name)
73
+ def template?(name)
73
74
  @templates.key?(name)
74
75
  end
75
76
 
@@ -83,9 +84,10 @@ module Rhales
83
84
  @dependencies[template_name] || []
84
85
  end
85
86
 
87
+
86
88
  private
87
89
 
88
- def load_template_recursive(template_name, parent_path = nil)
90
+ def load_template_recursive(template_name, _parent_path = nil)
89
91
  # Check for circular dependencies
90
92
  if @loading.include?(template_name)
91
93
  raise CircularDependencyError, "Circular dependency detected: #{template_name} -> #{@loading.to_a.join(' -> ')}"
data/lib/rhales.rb CHANGED
@@ -13,6 +13,12 @@ require_relative 'rhales/template_engine'
13
13
  require_relative 'rhales/hydrator'
14
14
  require_relative 'rhales/view_composition'
15
15
  require_relative 'rhales/hydration_data_aggregator'
16
+ require_relative 'rhales/mount_point_detector'
17
+ require_relative 'rhales/safe_injection_validator'
18
+ require_relative 'rhales/earliest_injection_detector'
19
+ require_relative 'rhales/link_based_injection_detector'
20
+ require_relative 'rhales/hydration_injector'
21
+ require_relative 'rhales/hydration_endpoint'
16
22
  require_relative 'rhales/refinements/require_refinements'
17
23
  require_relative 'rhales/view'
18
24
 
@@ -30,9 +36,14 @@ require_relative 'rhales/view'
30
36
  #
31
37
  # Usage:
32
38
  # Rhales.configure do |config|
33
- # config.default_localhas_role?e = 'en'
39
+ # config.default_locale = 'en'
34
40
  # config.template_paths = ['app/templates']
35
41
  # config.features = { dark_mode: true }
42
+ #
43
+ # # Hydration configuration
44
+ # config.hydration.injection_strategy = :early # :early or :late (default)
45
+ # config.hydration.mount_point_selectors = ['#app', '#root', '[data-mount]']
46
+ # config.hydration.fallback_to_late = true
36
47
  # end
37
48
  #
38
49
  # view = Rhales::View.new(request, session, user)
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rhales
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - delano
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2025-07-09 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies: []
12
12
  description: |
13
13
  Rhales is a framework for building server-rendered components with
@@ -34,16 +34,22 @@ files:
34
34
  - lib/rhales/configuration.rb
35
35
  - lib/rhales/context.rb
36
36
  - lib/rhales/csp.rb
37
+ - lib/rhales/earliest_injection_detector.rb
37
38
  - lib/rhales/errors.rb
38
39
  - lib/rhales/errors/hydration_collision_error.rb
39
40
  - lib/rhales/hydration_data_aggregator.rb
41
+ - lib/rhales/hydration_endpoint.rb
42
+ - lib/rhales/hydration_injector.rb
40
43
  - lib/rhales/hydration_registry.rb
41
44
  - lib/rhales/hydrator.rb
45
+ - lib/rhales/link_based_injection_detector.rb
46
+ - lib/rhales/mount_point_detector.rb
42
47
  - lib/rhales/parsers/handlebars-grammar-review.txt
43
48
  - lib/rhales/parsers/handlebars_parser.rb
44
49
  - lib/rhales/parsers/rue_format_parser.rb
45
50
  - lib/rhales/refinements/require_refinements.rb
46
51
  - lib/rhales/rue_document.rb
52
+ - lib/rhales/safe_injection_validator.rb
47
53
  - lib/rhales/template_engine.rb
48
54
  - lib/rhales/tilt.rb
49
55
  - lib/rhales/version.rb
@@ -72,7 +78,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
72
78
  - !ruby/object:Gem::Version
73
79
  version: '0'
74
80
  requirements: []
75
- rubygems_version: 3.6.2
81
+ rubygems_version: 3.6.9
76
82
  specification_version: 4
77
83
  summary: Rhales - Server-rendered components with client-side hydration (RSFCs)
78
84
  test_files: []