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.
@@ -37,7 +37,7 @@ module Rhales
37
37
 
38
38
  private
39
39
 
40
- def process_template(template_name, parser)
40
+ def process_template(_template_name, parser)
41
41
  data_content = parser.section('data')
42
42
  return unless data_content
43
43
 
@@ -62,22 +62,22 @@ module Rhales
62
62
  end
63
63
 
64
64
  # Merge or set the data
65
- if @merged_data.key?(window_attr)
66
- @merged_data[window_attr] = merge_data(
65
+ @merged_data[window_attr] = if @merged_data.key?(window_attr)
66
+ merge_data(
67
67
  @merged_data[window_attr],
68
68
  processed_data,
69
69
  merge_strategy || 'deep',
70
70
  window_attr,
71
- template_path
71
+ template_path,
72
72
  )
73
73
  else
74
- @merged_data[window_attr] = processed_data
75
- end
74
+ processed_data
75
+ end
76
76
 
77
77
  # Track the window attribute
78
78
  @window_attributes[window_attr] = {
79
79
  path: template_path,
80
- merge_strategy: merge_strategy
80
+ merge_strategy: merge_strategy,
81
81
  }
82
82
  end
83
83
 
@@ -116,11 +116,11 @@ module Rhales
116
116
  result = target.dup
117
117
 
118
118
  source.each do |key, value|
119
- if result.key?(key) && result[key].is_a?(Hash) && value.is_a?(Hash)
120
- result[key] = deep_merge(result[key], value)
119
+ result[key] = if result.key?(key) && result[key].is_a?(Hash) && value.is_a?(Hash)
120
+ deep_merge(result[key], value)
121
121
  else
122
- result[key] = value
123
- end
122
+ value
123
+ end
124
124
  end
125
125
 
126
126
  result
@@ -134,7 +134,7 @@ module Rhales
134
134
  raise ::Rhales::HydrationCollisionError.new(
135
135
  "#{window_attr}.#{key}",
136
136
  @window_attributes[window_attr][:path],
137
- template_path
137
+ template_path,
138
138
  )
139
139
  end
140
140
  result[key] = value
@@ -146,13 +146,13 @@ module Rhales
146
146
  def strict_merge(target, source, window_attr, template_path)
147
147
  # In strict mode, any collision is an error
148
148
  target.each_key do |key|
149
- if source.key?(key)
150
- raise ::Rhales::HydrationCollisionError.new(
151
- "#{window_attr}.#{key}",
152
- @window_attributes[window_attr][:path],
153
- template_path
154
- )
155
- end
149
+ next unless source.key?(key)
150
+
151
+ raise ::Rhales::HydrationCollisionError.new(
152
+ "#{window_attr}.#{key}",
153
+ @window_attributes[window_attr][:path],
154
+ template_path,
155
+ )
156
156
  end
157
157
 
158
158
  target.merge(source)
@@ -175,6 +175,7 @@ module Rhales
175
175
  return true if data == {}
176
176
  return true if data == []
177
177
  return true if data.respond_to?(:empty?) && data.empty?
178
+
178
179
  false
179
180
  end
180
181
  end
@@ -186,8 +187,8 @@ module Rhales
186
187
  end
187
188
 
188
189
  # Delegate all methods to the wrapped context
189
- def method_missing(method, *args, &block)
190
- @context.send(method, *args, &block)
190
+ def method_missing(method, *, &)
191
+ @context.send(method, *, &)
191
192
  end
192
193
 
193
194
  def respond_to_missing?(method, include_private = false)
@@ -215,6 +216,6 @@ module Rhales
215
216
  end
216
217
 
217
218
  # Alias for compatibility with template engine
218
- alias_method :resolve_variable, :get
219
+ alias resolve_variable get
219
220
  end
220
221
  end
@@ -0,0 +1,211 @@
1
+ require 'json'
2
+ require 'digest'
3
+
4
+ module Rhales
5
+ # Handles API endpoint responses for link-based hydration strategies
6
+ #
7
+ # Provides JSON and ES module endpoints that serve hydration data
8
+ # separately from HTML templates, enabling better caching, parallel
9
+ # loading, and reduced HTML payload sizes.
10
+ #
11
+ # ## Supported Response Formats
12
+ #
13
+ # ### JSON Response (application/json)
14
+ # ```json
15
+ # {
16
+ # "myData": { "user": "john", "theme": "dark" },
17
+ # "config": { "apiUrl": "https://api.example.com" }
18
+ # }
19
+ # ```
20
+ #
21
+ # ### ES Module Response (text/javascript)
22
+ # ```javascript
23
+ # export default {
24
+ # "myData": { "user": "john", "theme": "dark" },
25
+ # "config": { "apiUrl": "https://api.example.com" }
26
+ # };
27
+ # ```
28
+ #
29
+ # ## Usage
30
+ #
31
+ # ```ruby
32
+ # endpoint = HydrationEndpoint.new(config, context)
33
+ #
34
+ # # JSON response
35
+ # json_response = endpoint.render_json('template_name')
36
+ #
37
+ # # ES Module response
38
+ # module_response = endpoint.render_module('template_name')
39
+ # ```
40
+ class HydrationEndpoint
41
+ def initialize(config, context = nil)
42
+ @config = config
43
+ @context = context
44
+ end
45
+
46
+ # Render JSON response for API endpoints
47
+ def render_json(template_name, additional_context = {})
48
+ merged_data = process_template_data(template_name, additional_context)
49
+
50
+ {
51
+ content: JSON.generate(merged_data),
52
+ content_type: 'application/json',
53
+ headers: json_headers(merged_data)
54
+ }
55
+ rescue JSON::NestingError, JSON::GeneratorError, ArgumentError, Encoding::UndefinedConversionError => e
56
+ # Handle JSON serialization errors and encoding issues
57
+ error_data = {
58
+ error: {
59
+ message: "Failed to serialize data to JSON: #{e.message}",
60
+ template: template_name,
61
+ timestamp: Time.now.iso8601
62
+ }
63
+ }
64
+
65
+ {
66
+ content: JSON.generate(error_data),
67
+ content_type: 'application/json',
68
+ headers: json_headers(error_data)
69
+ }
70
+ rescue StandardError => e
71
+ # Handle any other unexpected errors during JSON generation
72
+ error_data = {
73
+ error: {
74
+ message: "Unexpected error during JSON generation: #{e.message}",
75
+ template: template_name,
76
+ timestamp: Time.now.iso8601
77
+ }
78
+ }
79
+
80
+ {
81
+ content: JSON.generate(error_data),
82
+ content_type: 'application/json',
83
+ headers: json_headers(error_data)
84
+ }
85
+ end
86
+
87
+ # Render ES module response for modulepreload strategy
88
+ def render_module(template_name, additional_context = {})
89
+ merged_data = process_template_data(template_name, additional_context)
90
+
91
+ {
92
+ content: "export default #{JSON.generate(merged_data)};",
93
+ content_type: 'text/javascript',
94
+ headers: module_headers(merged_data)
95
+ }
96
+ end
97
+
98
+ # Render JSONP response with callback
99
+ def render_jsonp(template_name, callback_name, additional_context = {})
100
+ merged_data = process_template_data(template_name, additional_context)
101
+
102
+ {
103
+ content: "#{callback_name}(#{JSON.generate(merged_data)});",
104
+ content_type: 'application/javascript',
105
+ headers: jsonp_headers(merged_data),
106
+ }
107
+ end
108
+
109
+ # Check if template data has changed (for ETags)
110
+ def data_changed?(template_name, etag, additional_context = {})
111
+ current_etag = calculate_etag(template_name, additional_context)
112
+ current_etag != etag
113
+ end
114
+
115
+ # Get ETag for current template data
116
+ def calculate_etag(template_name, additional_context = {})
117
+ merged_data = process_template_data(template_name, additional_context)
118
+ # Simple ETag based on data hash
119
+ Digest::MD5.hexdigest(JSON.generate(merged_data))
120
+ end
121
+
122
+ private
123
+
124
+ def process_template_data(template_name, additional_context)
125
+ # Create a minimal context for data processing
126
+ template_context = create_template_context(additional_context)
127
+
128
+ # Process template to extract hydration data
129
+ view = View.new(@context.req, @context.sess, @context.cust, @context.locale, props: {})
130
+ aggregator = HydrationDataAggregator.new(template_context)
131
+
132
+ # Build composition to get template dependencies
133
+ composition = view.send(:build_view_composition, template_name)
134
+ composition.resolve!
135
+
136
+ # Aggregate data from all templates in the composition
137
+ aggregator.aggregate(composition)
138
+ rescue StandardError => ex
139
+ # Return error structure that can be serialized
140
+ {
141
+ error: {
142
+ message: "Failed to process template data: #{ex.message}",
143
+ template: template_name,
144
+ timestamp: Time.now.iso8601,
145
+ }
146
+ }
147
+ end
148
+
149
+ def create_template_context(additional_context)
150
+ if @context
151
+ # Merge additional context into existing context by reconstructing with merged props
152
+ merged_props = @context.props.merge(additional_context)
153
+ @context.class.for_view(@context.req, @context.sess, @context.cust, @context.locale, **merged_props)
154
+ else
155
+ # Create minimal context with just the additional data
156
+ Context.minimal(props: additional_context)
157
+ end
158
+ end
159
+
160
+ def json_headers(data)
161
+ headers = {
162
+ 'Content-Type' => 'application/json',
163
+ 'Cache-Control' => cache_control_header,
164
+ 'Vary' => 'Accept, Accept-Encoding'
165
+ }
166
+
167
+ # Add CORS headers if enabled
168
+ if cors_enabled?
169
+ headers.merge!(cors_headers)
170
+ end
171
+
172
+ # Add ETag for caching
173
+ headers['ETag'] = %("#{Digest::MD5.hexdigest(JSON.generate(data))}")
174
+
175
+ headers
176
+ end
177
+
178
+ def module_headers(data)
179
+ headers = json_headers(data)
180
+ headers['Content-Type'] = 'text/javascript'
181
+ headers
182
+ end
183
+
184
+ def jsonp_headers(data)
185
+ headers = json_headers(data)
186
+ headers['Content-Type'] = 'application/javascript'
187
+ headers
188
+ end
189
+
190
+ def cache_control_header
191
+ if @config.hydration.api_cache_enabled
192
+ "public, max-age=#{@config.hydration.api_cache_ttl || 300}"
193
+ else
194
+ "no-cache, no-store, must-revalidate"
195
+ end
196
+ end
197
+
198
+ def cors_enabled?
199
+ @config.hydration.cors_enabled || false
200
+ end
201
+
202
+ def cors_headers
203
+ {
204
+ 'Access-Control-Allow-Origin' => @config.hydration.cors_origin || '*',
205
+ 'Access-Control-Allow-Methods' => 'GET, HEAD, OPTIONS',
206
+ 'Access-Control-Allow-Headers' => 'Accept, Accept-Encoding, Authorization',
207
+ 'Access-Control-Max-Age' => '86400'
208
+ }
209
+ end
210
+ end
211
+ end
@@ -0,0 +1,171 @@
1
+ require_relative 'earliest_injection_detector'
2
+ require_relative 'link_based_injection_detector'
3
+
4
+ module Rhales
5
+ # Handles intelligent hydration script injection with multiple strategies
6
+ # for optimal performance and resource loading.
7
+ #
8
+ # ## Supported Injection Strategies
9
+ #
10
+ # ### Traditional Strategies
11
+ # - **`:late`** (default) - Inject before </body> tag (safest, backwards compatible)
12
+ # - **`:early`** - Inject before detected mount points (#app, #root, etc.)
13
+ # - **`:earliest`** - Inject in HTML head section for maximum performance
14
+ #
15
+ # ### Link-Based Strategies (API endpoints)
16
+ # - **`:link`** - Basic link reference to API endpoint
17
+ # - **`:prefetch`** - Browser prefetch for future page loads
18
+ # - **`:preload`** - High priority preload for current page
19
+ # - **`:modulepreload`** - ES module preloading
20
+ # - **`:lazy`** - Intersection observer-based lazy loading
21
+ #
22
+ # ## Strategy Selection Logic
23
+ #
24
+ # 1. **Template Disable Check**: Respect `disable_early_for_templates` configuration
25
+ # 2. **Strategy Routing**: Execute strategy-specific injection logic
26
+ # 3. **Fallback Chain**: :earliest → :early → :late (when enabled)
27
+ # 4. **Safety Validation**: All injection points validated for HTML safety
28
+ #
29
+ # Link-based strategies generate API calls instead of inline data,
30
+ # enabling better caching, parallel loading, and reduced HTML payload.
31
+ #
32
+ class HydrationInjector
33
+ LINK_BASED_STRATEGIES = [:link, :prefetch, :preload, :modulepreload, :lazy].freeze
34
+
35
+ def initialize(hydration_config, template_name = nil)
36
+ @hydration_config = hydration_config
37
+ @template_name = template_name
38
+ @strategy = hydration_config.injection_strategy
39
+ @fallback_to_late = hydration_config.fallback_to_late
40
+ @fallback_when_unsafe = hydration_config.fallback_when_unsafe
41
+ @disabled_templates = hydration_config.disable_early_for_templates
42
+ @earliest_detector = EarliestInjectionDetector.new
43
+ @link_detector = LinkBasedInjectionDetector.new(hydration_config)
44
+ end
45
+
46
+ def inject(template_html, hydration_html, mount_point_data = nil)
47
+ return template_html if hydration_html.nil? || hydration_html.strip.empty?
48
+
49
+ # Check if early/earliest injection is disabled for this template
50
+ if [:early, :earliest].include?(@strategy) && template_disabled_for_early?
51
+ return inject_late(template_html, hydration_html)
52
+ end
53
+
54
+ case @strategy
55
+ when :early
56
+ inject_early(template_html, hydration_html, mount_point_data)
57
+ when :earliest
58
+ inject_earliest(template_html, hydration_html)
59
+ when :late
60
+ inject_late(template_html, hydration_html)
61
+ when *LINK_BASED_STRATEGIES
62
+ inject_link_based(template_html, hydration_html)
63
+ else
64
+ inject_late(template_html, hydration_html)
65
+ end
66
+ end
67
+
68
+ # Special method for link-based strategies that need merged data context
69
+ def inject_link_based_strategy(template_html, merged_data, nonce = nil)
70
+ return template_html if merged_data.nil? || merged_data.empty?
71
+
72
+ # Check if early injection is disabled for this template
73
+ if template_disabled_for_early?
74
+ # For link strategies, we still generate the links but fall back to late positioning
75
+ link_html = generate_all_link_strategies(merged_data, nonce)
76
+ return inject_late(template_html, link_html)
77
+ end
78
+
79
+ link_html = generate_all_link_strategies(merged_data, nonce)
80
+
81
+ case @strategy
82
+ when :earliest
83
+ inject_earliest(template_html, link_html)
84
+ when *LINK_BASED_STRATEGIES
85
+ inject_link_based(template_html, link_html)
86
+ else
87
+ inject_late(template_html, link_html)
88
+ end
89
+ end
90
+
91
+ private
92
+
93
+ def inject_early(template_html, hydration_html, mount_point_data)
94
+ # Fallback to late injection if no mount point found
95
+ if mount_point_data.nil?
96
+ return @fallback_to_late ? inject_late(template_html, hydration_html) : template_html
97
+ end
98
+
99
+ # Check if the mount point data indicates an unsafe injection
100
+ # (This would be nil if SafeInjectionValidator found no safe position)
101
+ if mount_point_data[:position].nil?
102
+ return @fallback_when_unsafe ? inject_late(template_html, hydration_html) : template_html
103
+ end
104
+
105
+ # Insert hydration script before the mount element
106
+ position = mount_point_data[:position]
107
+
108
+ before = template_html[0...position]
109
+ after = template_html[position..]
110
+
111
+ "#{before}#{hydration_html}\n#{after}"
112
+ end
113
+
114
+ def template_disabled_for_early?
115
+ @template_name && @disabled_templates.include?(@template_name)
116
+ end
117
+
118
+ def inject_earliest(template_html, hydration_html)
119
+ begin
120
+ injection_position = @earliest_detector.detect(template_html)
121
+ rescue => e
122
+ # Fall back to late injection on detector error
123
+ return @fallback_to_late ? inject_late(template_html, hydration_html) : template_html
124
+ end
125
+
126
+ if injection_position
127
+ before = template_html[0...injection_position]
128
+ after = template_html[injection_position..]
129
+ "#{before}#{hydration_html}\n#{after}"
130
+ else
131
+ # Fallback to late injection if earliest fails
132
+ @fallback_to_late ? inject_late(template_html, hydration_html) : template_html
133
+ end
134
+ end
135
+
136
+ def inject_link_based(template_html, hydration_html)
137
+ # For link-based strategies, try earliest injection first, then fallback
138
+ injection_position = @earliest_detector.detect(template_html)
139
+
140
+ if injection_position
141
+ before = template_html[0...injection_position]
142
+ after = template_html[injection_position..]
143
+ "#{before}#{hydration_html}\n#{after}"
144
+ else
145
+ # Fallback to late injection
146
+ @fallback_to_late ? inject_late(template_html, hydration_html) : template_html
147
+ end
148
+ end
149
+
150
+ def generate_all_link_strategies(merged_data, nonce)
151
+ link_parts = []
152
+
153
+ merged_data.each do |window_attr, _data|
154
+ link_html = @link_detector.generate_for_strategy(@strategy, @template_name, window_attr, nonce)
155
+ link_parts << link_html
156
+ end
157
+
158
+ link_parts.join("\n")
159
+ end
160
+
161
+ def inject_late(template_html, hydration_html)
162
+ # Try to inject before closing </body> tag
163
+ if template_html.include?('</body>')
164
+ template_html.sub('</body>', "#{hydration_html}\n</body>")
165
+ else
166
+ # If no </body> tag, append to end
167
+ "#{template_html}\n#{hydration_html}"
168
+ end
169
+ end
170
+ end
171
+ end
@@ -63,8 +63,8 @@ module Rhales
63
63
  # This method is now deprecated in favor of the two-pass architecture
64
64
  # It's kept for backward compatibility but will be removed in future versions
65
65
  def generate_hydration_html
66
- warn "[DEPRECATION] Hydrator#generate_hydration_html is deprecated. Use the two-pass rendering architecture instead."
67
- ""
66
+ warn '[DEPRECATION] Hydrator#generate_hydration_html is deprecated. Use the two-pass rendering architecture instead.'
67
+ ''
68
68
  end
69
69
 
70
70
  # Process <data> section and return JSON string
@@ -123,7 +123,7 @@ module Rhales
123
123
  # Convenience method to generate hydration HTML
124
124
  # DEPRECATED: Use the two-pass rendering architecture instead
125
125
  def generate(parser, context)
126
- warn "[DEPRECATION] Hydrator.generate is deprecated. Use the two-pass rendering architecture instead."
126
+ warn '[DEPRECATION] Hydrator.generate is deprecated. Use the two-pass rendering architecture instead.'
127
127
  new(parser, context).generate_hydration_html
128
128
  end
129
129