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.
- checksums.yaml +4 -4
- data/CLAUDE.md +0 -1
- data/README.md +302 -3
- data/lib/rhales/configuration.rb +114 -1
- data/lib/rhales/context.rb +0 -1
- data/lib/rhales/earliest_injection_detector.rb +149 -0
- data/lib/rhales/errors/hydration_collision_error.rb +1 -1
- data/lib/rhales/hydration_data_aggregator.rb +23 -22
- data/lib/rhales/hydration_endpoint.rb +211 -0
- data/lib/rhales/hydration_injector.rb +171 -0
- data/lib/rhales/hydrator.rb +3 -3
- data/lib/rhales/link_based_injection_detector.rb +191 -0
- data/lib/rhales/mount_point_detector.rb +105 -0
- data/lib/rhales/parsers/rue_format_parser.rb +50 -33
- data/lib/rhales/refinements/require_refinements.rb +4 -12
- data/lib/rhales/rue_document.rb +3 -5
- data/lib/rhales/safe_injection_validator.rb +99 -0
- data/lib/rhales/template_engine.rb +47 -7
- data/lib/rhales/tilt.rb +6 -5
- data/lib/rhales/version.rb +3 -1
- data/lib/rhales/view.rb +165 -25
- data/lib/rhales/view_composition.rb +5 -3
- data/lib/rhales.rb +12 -1
- metadata +9 -3
@@ -37,7 +37,7 @@ module Rhales
|
|
37
37
|
|
38
38
|
private
|
39
39
|
|
40
|
-
def process_template(
|
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
|
-
|
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
|
-
|
75
|
-
|
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
|
-
|
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
|
-
|
123
|
-
|
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
|
-
|
150
|
-
|
151
|
-
|
152
|
-
|
153
|
-
|
154
|
-
|
155
|
-
|
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,
|
190
|
-
@context.send(method,
|
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
|
-
|
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
|
data/lib/rhales/hydrator.rb
CHANGED
@@ -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
|
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
|
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
|
|