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
@@ -0,0 +1,191 @@
|
|
1
|
+
require 'strscan'
|
2
|
+
require_relative 'safe_injection_validator'
|
3
|
+
|
4
|
+
module Rhales
|
5
|
+
# Generates link-based hydration strategies that use browser resource hints
|
6
|
+
# and API endpoints instead of inline scripts
|
7
|
+
#
|
8
|
+
# ## Supported Strategies
|
9
|
+
#
|
10
|
+
# - **`:link`** - Basic link reference: `<link href="/api/hydration/template">`
|
11
|
+
# - **`:prefetch`** - Background prefetch: `<link rel="prefetch" href="..." as="fetch">`
|
12
|
+
# - **`:preload`** - High priority preload: `<link rel="preload" href="..." as="fetch">`
|
13
|
+
# - **`:modulepreload`** - ES module preload: `<link rel="modulepreload" href="..."`
|
14
|
+
# - **`:lazy`** - Lazy loading with intersection observer
|
15
|
+
#
|
16
|
+
# All strategies generate both link tags and accompanying JavaScript
|
17
|
+
# for data fetching and assignment to window objects.
|
18
|
+
class LinkBasedInjectionDetector
|
19
|
+
def initialize(hydration_config)
|
20
|
+
@hydration_config = hydration_config
|
21
|
+
@api_endpoint_path = hydration_config.api_endpoint_path || '/api/hydration'
|
22
|
+
@crossorigin_enabled = hydration_config.link_crossorigin.nil? ? true : hydration_config.link_crossorigin
|
23
|
+
end
|
24
|
+
|
25
|
+
def generate_for_strategy(strategy, template_name, window_attr, nonce = nil)
|
26
|
+
case strategy
|
27
|
+
when :link
|
28
|
+
generate_basic_link(template_name, window_attr, nonce)
|
29
|
+
when :prefetch
|
30
|
+
generate_prefetch_link(template_name, window_attr, nonce)
|
31
|
+
when :preload
|
32
|
+
generate_preload_link(template_name, window_attr, nonce)
|
33
|
+
when :modulepreload
|
34
|
+
generate_modulepreload_link(template_name, window_attr, nonce)
|
35
|
+
when :lazy
|
36
|
+
generate_lazy_loading(template_name, window_attr, nonce)
|
37
|
+
else
|
38
|
+
raise ArgumentError, "Unsupported link strategy: #{strategy}"
|
39
|
+
end
|
40
|
+
end
|
41
|
+
|
42
|
+
private
|
43
|
+
|
44
|
+
def generate_basic_link(template_name, window_attr, nonce)
|
45
|
+
endpoint_url = "#{@api_endpoint_path}/#{template_name}"
|
46
|
+
|
47
|
+
link_tag = %(<link href="#{endpoint_url}" type="application/json">)
|
48
|
+
|
49
|
+
script_tag = <<~HTML.strip
|
50
|
+
<script#{nonce_attribute(nonce)} data-hydration-target="#{window_attr}">
|
51
|
+
// Load hydration data for #{window_attr}
|
52
|
+
window.__rhales__ = window.__rhales__ || {};
|
53
|
+
if (!window.__rhales__.loadData) {
|
54
|
+
window.__rhales__.loadData = function(target, url) {
|
55
|
+
fetch(url)
|
56
|
+
.then(r => r.json())
|
57
|
+
.then(data => window[target] = data);
|
58
|
+
};
|
59
|
+
}
|
60
|
+
window.__rhales__.loadData('#{window_attr}', '#{endpoint_url}');
|
61
|
+
</script>
|
62
|
+
HTML
|
63
|
+
|
64
|
+
"#{link_tag}\n#{script_tag}"
|
65
|
+
end
|
66
|
+
|
67
|
+
def generate_prefetch_link(template_name, window_attr, nonce)
|
68
|
+
endpoint_url = "#{@api_endpoint_path}/#{template_name}"
|
69
|
+
crossorigin_attr = @crossorigin_enabled ? ' crossorigin' : ''
|
70
|
+
|
71
|
+
link_tag = %(<link rel="prefetch" href="#{endpoint_url}" as="fetch"#{crossorigin_attr}>)
|
72
|
+
|
73
|
+
script_tag = <<~HTML.strip
|
74
|
+
<script#{nonce_attribute(nonce)} data-hydration-target="#{window_attr}">
|
75
|
+
// Prefetch hydration data for #{window_attr}
|
76
|
+
window.__rhales__ = window.__rhales__ || {};
|
77
|
+
if (!window.__rhales__.loadPrefetched) {
|
78
|
+
window.__rhales__.loadPrefetched = function(target, url) {
|
79
|
+
fetch(url)
|
80
|
+
.then(r => r.json())
|
81
|
+
.then(data => window[target] = data);
|
82
|
+
};
|
83
|
+
}
|
84
|
+
window.__rhales__.loadPrefetched('#{window_attr}', '#{endpoint_url}');
|
85
|
+
</script>
|
86
|
+
HTML
|
87
|
+
|
88
|
+
"#{link_tag}\n#{script_tag}"
|
89
|
+
end
|
90
|
+
|
91
|
+
def generate_preload_link(template_name, window_attr, nonce)
|
92
|
+
endpoint_url = "#{@api_endpoint_path}/#{template_name}"
|
93
|
+
crossorigin_attr = @crossorigin_enabled ? ' crossorigin' : ''
|
94
|
+
|
95
|
+
link_tag = %(<link rel="preload" href="#{endpoint_url}" as="fetch"#{crossorigin_attr}>)
|
96
|
+
|
97
|
+
script_tag = <<~HTML.strip
|
98
|
+
<script#{nonce_attribute(nonce)} data-hydration-target="#{window_attr}">
|
99
|
+
// Preload strategy - high priority fetch
|
100
|
+
fetch('#{endpoint_url}')
|
101
|
+
.then(r => r.json())
|
102
|
+
.then(data => {
|
103
|
+
window['#{window_attr}'] = data;
|
104
|
+
// Dispatch ready event
|
105
|
+
window.dispatchEvent(new CustomEvent('rhales:hydrated', {
|
106
|
+
detail: { target: '#{window_attr}', data: data }
|
107
|
+
}));
|
108
|
+
})
|
109
|
+
.catch(err => console.error('Rhales hydration error:', err));
|
110
|
+
</script>
|
111
|
+
HTML
|
112
|
+
|
113
|
+
"#{link_tag}\n#{script_tag}"
|
114
|
+
end
|
115
|
+
|
116
|
+
def generate_modulepreload_link(template_name, window_attr, nonce)
|
117
|
+
endpoint_url = "#{@api_endpoint_path}/#{template_name}.js"
|
118
|
+
|
119
|
+
link_tag = %(<link rel="modulepreload" href="#{endpoint_url}">)
|
120
|
+
|
121
|
+
script_tag = <<~HTML.strip
|
122
|
+
<script type="module"#{nonce_attribute(nonce)} data-hydration-target="#{window_attr}">
|
123
|
+
// Module preload strategy
|
124
|
+
import data from '#{endpoint_url}';
|
125
|
+
window['#{window_attr}'] = data;
|
126
|
+
|
127
|
+
// Dispatch ready event
|
128
|
+
window.dispatchEvent(new CustomEvent('rhales:hydrated', {
|
129
|
+
detail: { target: '#{window_attr}', data: data }
|
130
|
+
}));
|
131
|
+
</script>
|
132
|
+
HTML
|
133
|
+
|
134
|
+
"#{link_tag}\n#{script_tag}"
|
135
|
+
end
|
136
|
+
|
137
|
+
def generate_lazy_loading(template_name, window_attr, nonce)
|
138
|
+
endpoint_url = "#{@api_endpoint_path}/#{template_name}"
|
139
|
+
mount_selector = @hydration_config.lazy_mount_selector || '#app'
|
140
|
+
|
141
|
+
# No link tag for lazy loading - purely script-driven
|
142
|
+
script_tag = <<~HTML.strip
|
143
|
+
<script#{nonce_attribute(nonce)} data-hydration-target="#{window_attr}" data-lazy-src="#{endpoint_url}">
|
144
|
+
// Lazy loading strategy with intersection observer
|
145
|
+
window.__rhales__ = window.__rhales__ || {};
|
146
|
+
window.__rhales__.initLazyLoading = function() {
|
147
|
+
const mountElement = document.querySelector('#{mount_selector}');
|
148
|
+
if (!mountElement) {
|
149
|
+
console.warn('Rhales: Mount element "#{mount_selector}" not found for lazy loading');
|
150
|
+
return;
|
151
|
+
}
|
152
|
+
|
153
|
+
const observer = new IntersectionObserver((entries) => {
|
154
|
+
entries.forEach(entry => {
|
155
|
+
if (entry.isIntersecting) {
|
156
|
+
fetch('#{endpoint_url}')
|
157
|
+
.then(r => r.json())
|
158
|
+
.then(data => {
|
159
|
+
window['#{window_attr}'] = data;
|
160
|
+
window.dispatchEvent(new CustomEvent('rhales:hydrated', {
|
161
|
+
detail: { target: '#{window_attr}', data: data }
|
162
|
+
}));
|
163
|
+
})
|
164
|
+
.catch(err => console.error('Rhales lazy hydration error:', err));
|
165
|
+
|
166
|
+
observer.unobserve(entry.target);
|
167
|
+
}
|
168
|
+
});
|
169
|
+
});
|
170
|
+
|
171
|
+
observer.observe(mountElement);
|
172
|
+
};
|
173
|
+
|
174
|
+
// Initialize when DOM is ready
|
175
|
+
if (document.readyState === 'loading') {
|
176
|
+
document.addEventListener('DOMContentLoaded', window.__rhales__.initLazyLoading);
|
177
|
+
} else {
|
178
|
+
window.__rhales__.initLazyLoading();
|
179
|
+
}
|
180
|
+
</script>
|
181
|
+
HTML
|
182
|
+
|
183
|
+
script_tag
|
184
|
+
end
|
185
|
+
|
186
|
+
def nonce_attribute(nonce)
|
187
|
+
require 'erb'
|
188
|
+
nonce ? " nonce=\"#{ERB::Util.html_escape(nonce)}\"" : ''
|
189
|
+
end
|
190
|
+
end
|
191
|
+
end
|
@@ -0,0 +1,105 @@
|
|
1
|
+
require 'strscan'
|
2
|
+
require_relative 'safe_injection_validator'
|
3
|
+
|
4
|
+
module Rhales
|
5
|
+
# Detects frontend application mount points in HTML templates
|
6
|
+
# Used to determine optimal hydration script injection points
|
7
|
+
#
|
8
|
+
# ## Mount Point Detection Order
|
9
|
+
#
|
10
|
+
# 1. **Selector Priority**: All selectors (default + custom) are checked in parallel
|
11
|
+
# 2. **Position Priority**: Returns the earliest mount point by position in HTML (not selector order)
|
12
|
+
# 3. **Safety Validation**: Validates injection points are outside unsafe contexts (scripts/styles/comments)
|
13
|
+
# 4. **Safe Position Search**: If original position unsafe, searches for nearest safe alternative:
|
14
|
+
# - First tries positions before the mount point (maintains earlier injection)
|
15
|
+
# - Then tries positions after the mount point (fallback)
|
16
|
+
# - Returns nil if no safe position found
|
17
|
+
#
|
18
|
+
# Default selectors are checked: ['#app', '#root', '[data-rsfc-mount]', '[data-mount]']
|
19
|
+
# Custom selectors can be added via configuration and are combined with defaults.
|
20
|
+
class MountPointDetector
|
21
|
+
DEFAULT_SELECTORS = ['#app', '#root', '[data-rsfc-mount]', '[data-mount]'].freeze
|
22
|
+
|
23
|
+
def detect(template_html, custom_selectors = [])
|
24
|
+
selectors = (DEFAULT_SELECTORS + Array(custom_selectors)).uniq
|
25
|
+
scanner = StringScanner.new(template_html)
|
26
|
+
validator = SafeInjectionValidator.new(template_html)
|
27
|
+
mount_points = []
|
28
|
+
|
29
|
+
selectors.each do |selector|
|
30
|
+
scanner.pos = 0
|
31
|
+
pattern = build_pattern(selector)
|
32
|
+
|
33
|
+
while scanner.scan_until(pattern)
|
34
|
+
# Calculate position where the full tag starts
|
35
|
+
tag_start_pos = find_tag_start(scanner, template_html)
|
36
|
+
|
37
|
+
# Only include mount points that are safe for injection
|
38
|
+
safe_position = find_safe_injection_position(validator, tag_start_pos)
|
39
|
+
|
40
|
+
if safe_position
|
41
|
+
mount_points << {
|
42
|
+
selector: selector,
|
43
|
+
position: safe_position,
|
44
|
+
original_position: tag_start_pos,
|
45
|
+
matched: scanner.matched
|
46
|
+
}
|
47
|
+
end
|
48
|
+
end
|
49
|
+
end
|
50
|
+
|
51
|
+
# Return earliest mount point by position
|
52
|
+
mount_points.min_by { |mp| mp[:position] }
|
53
|
+
end
|
54
|
+
|
55
|
+
private
|
56
|
+
|
57
|
+
def build_pattern(selector)
|
58
|
+
case selector
|
59
|
+
when /^#(.+)$/
|
60
|
+
# ID selector: <tag id="value">
|
61
|
+
id_name = Regexp.escape($1)
|
62
|
+
/id\s*=\s*["']#{id_name}["']/i
|
63
|
+
when /^\.(.+)$/
|
64
|
+
# Class selector: <tag class="... value ...">
|
65
|
+
class_name = Regexp.escape($1)
|
66
|
+
/class\s*=\s*["'][^"']*\b#{class_name}\b[^"']*["']/i
|
67
|
+
when /^\[([^\]]+)\]$/
|
68
|
+
# Attribute selector: <tag data-attr> or <tag data-attr="value">
|
69
|
+
attr_name = Regexp.escape($1)
|
70
|
+
/#{attr_name}(?:\s*=\s*["'][^"']*["'])?/i
|
71
|
+
else
|
72
|
+
# Invalid selector, match nothing
|
73
|
+
/(?!.*)/
|
74
|
+
end
|
75
|
+
end
|
76
|
+
|
77
|
+
def find_tag_start(scanner, template_html)
|
78
|
+
# Work backwards from current position to find the opening <
|
79
|
+
pos = scanner.pos - scanner.matched.length
|
80
|
+
|
81
|
+
while pos > 0 && template_html[pos - 1] != '<'
|
82
|
+
pos -= 1
|
83
|
+
end
|
84
|
+
|
85
|
+
# Return position of the < character
|
86
|
+
pos > 0 ? pos - 1 : 0
|
87
|
+
end
|
88
|
+
|
89
|
+
def find_safe_injection_position(validator, preferred_position)
|
90
|
+
# First check if the preferred position is safe
|
91
|
+
return preferred_position if validator.safe_injection_point?(preferred_position)
|
92
|
+
|
93
|
+
# Try to find a safe position before the preferred position
|
94
|
+
safe_before = validator.nearest_safe_point_before(preferred_position)
|
95
|
+
return safe_before if safe_before
|
96
|
+
|
97
|
+
# As a last resort, try after the preferred position
|
98
|
+
safe_after = validator.nearest_safe_point_after(preferred_position)
|
99
|
+
return safe_after if safe_after
|
100
|
+
|
101
|
+
# No safe position found
|
102
|
+
nil
|
103
|
+
end
|
104
|
+
end
|
105
|
+
end
|
@@ -28,12 +28,15 @@ module Rhales
|
|
28
28
|
# handlebars_expression := '{{' expression '}}'
|
29
29
|
class RueFormatParser
|
30
30
|
# At least one of these sections must be present
|
31
|
-
REQUIRES_ONE_OF_SECTIONS
|
32
|
-
|
33
|
-
ALL_SECTIONS = KNOWN_SECTIONS.freeze
|
31
|
+
unless defined?(REQUIRES_ONE_OF_SECTIONS)
|
32
|
+
REQUIRES_ONE_OF_SECTIONS = %w[data template].freeze
|
34
33
|
|
35
|
-
|
36
|
-
|
34
|
+
KNOWN_SECTIONS = %w[data template logic].freeze
|
35
|
+
ALL_SECTIONS = KNOWN_SECTIONS.freeze
|
36
|
+
|
37
|
+
# Regular expression to match HTML/XML comments outside of sections
|
38
|
+
COMMENT_REGEX = /<!--.*?-->/m
|
39
|
+
end
|
37
40
|
|
38
41
|
class ParseError < ::Rhales::ParseError
|
39
42
|
def initialize(message, line: nil, column: nil, offset: nil)
|
@@ -145,7 +148,7 @@ module Rhales
|
|
145
148
|
def parse_tag_name
|
146
149
|
start_pos = @position
|
147
150
|
|
148
|
-
advance while !at_end? && current_char.match?(/[a-zA-
|
151
|
+
advance while !at_end? && current_char.match?(/[a-zA-Z0-9_]/)
|
149
152
|
|
150
153
|
if start_pos == @position
|
151
154
|
parse_error('Expected tag name')
|
@@ -178,30 +181,42 @@ module Rhales
|
|
178
181
|
attributes
|
179
182
|
end
|
180
183
|
|
184
|
+
# Uses StringScanner to parse "content" in <section>content</section>
|
181
185
|
def parse_section_content(tag_name)
|
182
|
-
start_pos = @position
|
183
186
|
content_start = @position
|
187
|
+
closing_tag = "</#{tag_name}>"
|
184
188
|
|
185
|
-
#
|
186
|
-
|
187
|
-
while !at_end? && !peek_closing_tag?(tag_name)
|
188
|
-
raw_content << current_char
|
189
|
-
advance
|
190
|
-
end
|
189
|
+
# Create scanner from remaining content
|
190
|
+
scanner = StringScanner.new(@content[content_start..])
|
191
191
|
|
192
|
-
#
|
193
|
-
if
|
194
|
-
|
195
|
-
|
196
|
-
|
197
|
-
else
|
198
|
-
# For data and logic sections, keep as simple text
|
199
|
-
return [Node.new(:text, current_location, value: raw_content)] unless raw_content.empty?
|
192
|
+
# Find the closing tag position
|
193
|
+
if scanner.scan_until(/(?=#{Regexp.escape(closing_tag)})/)
|
194
|
+
# Calculate content length (scanner.charpos gives us position right before closing tag)
|
195
|
+
content_length = scanner.charpos
|
196
|
+
raw_content = @content[content_start, content_length]
|
200
197
|
|
201
|
-
|
198
|
+
# Advance position tracking to end of content
|
199
|
+
advance_to_position(content_start + content_length)
|
200
|
+
|
201
|
+
# Process content based on tag type
|
202
|
+
if tag_name == 'template'
|
203
|
+
handlebars_parser = HandlebarsParser.new(raw_content)
|
204
|
+
handlebars_parser.parse!
|
205
|
+
handlebars_parser.ast.children
|
206
|
+
else
|
207
|
+
# For data and logic sections, keep as simple text
|
208
|
+
raw_content.empty? ? [] : [Node.new(:text, current_location, value: raw_content)]
|
209
|
+
end
|
210
|
+
else
|
211
|
+
parse_error("Expected '#{closing_tag}' to close section")
|
202
212
|
end
|
203
213
|
end
|
204
214
|
|
215
|
+
# Add this helper method to advance position tracking to a specific offset
|
216
|
+
def advance_to_position(target_position)
|
217
|
+
advance while @position < target_position && !at_end?
|
218
|
+
end
|
219
|
+
|
205
220
|
def parse_quoted_string
|
206
221
|
quote_char = current_char
|
207
222
|
unless ['"', "'"].include?(quote_char)
|
@@ -209,7 +224,7 @@ module Rhales
|
|
209
224
|
end
|
210
225
|
|
211
226
|
advance # Skip opening quote
|
212
|
-
value =
|
227
|
+
value = []
|
213
228
|
|
214
229
|
while !at_end? && current_char != quote_char
|
215
230
|
value << current_char
|
@@ -217,7 +232,11 @@ module Rhales
|
|
217
232
|
end
|
218
233
|
|
219
234
|
consume(quote_char) || parse_error('Unterminated quoted string')
|
220
|
-
|
235
|
+
|
236
|
+
# NOTE: Character-by-character parsing is acceptable here since attribute values
|
237
|
+
# in section tags (e.g., <tag attribute="value">) are typically short strings.
|
238
|
+
# Using StringScanner would be overkill for this use case.
|
239
|
+
value.join
|
221
240
|
end
|
222
241
|
|
223
242
|
def parse_identifier
|
@@ -350,8 +369,6 @@ module Rhales
|
|
350
369
|
result_parts.join
|
351
370
|
end
|
352
371
|
|
353
|
-
private
|
354
|
-
|
355
372
|
# Tokenize content into structured tokens for pattern matching
|
356
373
|
# Uses StringScanner for better performance and cleaner code
|
357
374
|
def tokenize_content(content)
|
@@ -359,23 +376,23 @@ module Rhales
|
|
359
376
|
tokens = []
|
360
377
|
|
361
378
|
until scanner.eos?
|
362
|
-
case
|
379
|
+
tokens << case
|
363
380
|
when scanner.scan(/<!--.*?-->/m)
|
364
381
|
# Comment token - non-greedy match for complete comments
|
365
|
-
|
382
|
+
{ type: :comment, content: scanner.matched }
|
366
383
|
when scanner.scan(/<(data|template|logic)(\s[^>]*)?>/m)
|
367
384
|
# Section start token - matches opening tags with optional attributes
|
368
|
-
|
369
|
-
when scanner.scan(
|
385
|
+
{ type: :section_start, content: scanner.matched }
|
386
|
+
when scanner.scan(%r{</(data|template|logic)>}m)
|
370
387
|
# Section end token - matches closing tags
|
371
|
-
|
388
|
+
{ type: :section_end, content: scanner.matched }
|
372
389
|
when scanner.scan(/[^<]+/)
|
373
390
|
# Text token - consolidates runs of non-< characters for efficiency
|
374
|
-
|
391
|
+
{ type: :text, content: scanner.matched }
|
375
392
|
else
|
376
393
|
# Fallback for single characters (< that don't match patterns)
|
377
394
|
# This maintains compatibility with the original character-by-character behavior
|
378
|
-
|
395
|
+
{ type: :text, content: scanner.getch }
|
379
396
|
end
|
380
397
|
end
|
381
398
|
|
@@ -111,11 +111,7 @@ module Rhales
|
|
111
111
|
|
112
112
|
parser
|
113
113
|
rescue StandardError => ex
|
114
|
-
|
115
|
-
OT.le "[RSFC] Failed to process .rue file #{path}: #{ex.message}"
|
116
|
-
else
|
117
|
-
puts "[RSFC] Failed to process .rue file #{path}: #{ex.message}"
|
118
|
-
end
|
114
|
+
puts "[RSFC] Failed to process .rue file #{path}: #{ex.message}"
|
119
115
|
raise
|
120
116
|
end
|
121
117
|
|
@@ -130,7 +126,7 @@ module Rhales
|
|
130
126
|
return File.expand_path(path) if File.exist?(path)
|
131
127
|
|
132
128
|
# Search in templates directory
|
133
|
-
boot_root =
|
129
|
+
boot_root = File.expand_path('../../..', __dir__)
|
134
130
|
templates_path = File.join(boot_root, 'templates', path)
|
135
131
|
return templates_path if File.exist?(templates_path)
|
136
132
|
|
@@ -197,9 +193,7 @@ module Rhales
|
|
197
193
|
current_mtime = File.mtime(full_path)
|
198
194
|
|
199
195
|
if current_mtime > last_mtime
|
200
|
-
|
201
|
-
OT.ld "[RSFC] File changed, clearing cache: #{full_path}"
|
202
|
-
end
|
196
|
+
puts "[RSFC] File changed, clearing cache: #{full_path}"
|
203
197
|
|
204
198
|
# Thread-safe cache removal
|
205
199
|
Rhales::Ruequire.instance_variable_get(:@cache_mutex).synchronize do
|
@@ -210,9 +204,7 @@ module Rhales
|
|
210
204
|
end
|
211
205
|
rescue StandardError => ex
|
212
206
|
# File might have been deleted
|
213
|
-
|
214
|
-
OT.ld "[RSFC] File watcher error for #{full_path}: #{ex.message}"
|
215
|
-
end
|
207
|
+
puts "[RSFC] File watcher error for #{full_path}: #{ex.message}"
|
216
208
|
|
217
209
|
# Clean up watcher entry on error
|
218
210
|
watchers_mutex.synchronize do
|
data/lib/rhales/rue_document.rb
CHANGED
@@ -163,11 +163,11 @@ module Rhales
|
|
163
163
|
|
164
164
|
private
|
165
165
|
|
166
|
-
def extract_partials_from_node(
|
166
|
+
def extract_partials_from_node(_node, partials)
|
167
167
|
return unless @ast
|
168
168
|
|
169
169
|
# Extract from all sections
|
170
|
-
@grammar.sections.each do |
|
170
|
+
@grammar.sections.each do |_section_name, section_node|
|
171
171
|
content_nodes = section_node.value[:content]
|
172
172
|
next unless content_nodes.is_a?(Array)
|
173
173
|
|
@@ -243,8 +243,6 @@ module Rhales
|
|
243
243
|
end
|
244
244
|
end
|
245
245
|
|
246
|
-
private
|
247
|
-
|
248
246
|
def extract_variables_from_text(text, variables, exclude_partials: false)
|
249
247
|
# Find all handlebars expressions in text content
|
250
248
|
text.scan(/\{\{(.+?)\}\}/) do |match|
|
@@ -284,7 +282,7 @@ module Rhales
|
|
284
282
|
end
|
285
283
|
|
286
284
|
def warn_unknown_attribute(attribute)
|
287
|
-
file_info = @file_path ? " in #{@file_path}" :
|
285
|
+
file_info = @file_path ? " in #{@file_path}" : ''
|
288
286
|
warn "Warning: data section encountered '#{attribute}' attribute - not yet supported, ignoring#{file_info}"
|
289
287
|
end
|
290
288
|
|
@@ -0,0 +1,99 @@
|
|
1
|
+
require 'strscan'
|
2
|
+
|
3
|
+
module Rhales
|
4
|
+
# Validates whether a hydration injection point is safe within HTML context
|
5
|
+
# Prevents injection inside script tags, style tags, comments, or other unsafe locations
|
6
|
+
class SafeInjectionValidator
|
7
|
+
UNSAFE_CONTEXTS = [
|
8
|
+
{ start: /<script\b[^>]*>/i, end: /<\/script>/i },
|
9
|
+
{ start: /<style\b[^>]*>/i, end: /<\/style>/i },
|
10
|
+
{ start: /<!--/, end: /-->/ },
|
11
|
+
{ start: /<!\[CDATA\[/, end: /\]\]>/ }
|
12
|
+
].freeze
|
13
|
+
|
14
|
+
def initialize(html)
|
15
|
+
@html = html
|
16
|
+
@unsafe_ranges = calculate_unsafe_ranges
|
17
|
+
end
|
18
|
+
|
19
|
+
# Check if the given position is safe for injection
|
20
|
+
def safe_injection_point?(position)
|
21
|
+
return false if position < 0 || position > @html.length
|
22
|
+
|
23
|
+
# Check if position falls within any unsafe range
|
24
|
+
@unsafe_ranges.none? { |range| range.cover?(position) }
|
25
|
+
end
|
26
|
+
|
27
|
+
# Find the nearest safe injection point before the given position
|
28
|
+
def nearest_safe_point_before(position)
|
29
|
+
# Work backwards from position to find a safe point
|
30
|
+
(0...position).reverse_each do |pos|
|
31
|
+
return pos if safe_injection_point?(pos) && at_tag_boundary?(pos)
|
32
|
+
end
|
33
|
+
|
34
|
+
# If no safe point found before, return nil
|
35
|
+
nil
|
36
|
+
end
|
37
|
+
|
38
|
+
# Find the nearest safe injection point after the given position
|
39
|
+
def nearest_safe_point_after(position)
|
40
|
+
# Work forwards from position to find a safe point
|
41
|
+
(position...@html.length).each do |pos|
|
42
|
+
return pos if safe_injection_point?(pos) && at_tag_boundary?(pos)
|
43
|
+
end
|
44
|
+
|
45
|
+
# If no safe point found after, return nil
|
46
|
+
nil
|
47
|
+
end
|
48
|
+
|
49
|
+
private
|
50
|
+
|
51
|
+
def calculate_unsafe_ranges
|
52
|
+
ranges = []
|
53
|
+
scanner = StringScanner.new(@html)
|
54
|
+
|
55
|
+
UNSAFE_CONTEXTS.each do |context|
|
56
|
+
scanner.pos = 0
|
57
|
+
|
58
|
+
while scanner.scan_until(context[:start])
|
59
|
+
start_pos = scanner.pos - scanner.matched.length
|
60
|
+
|
61
|
+
# Find the corresponding end tag
|
62
|
+
if scanner.scan_until(context[:end])
|
63
|
+
end_pos = scanner.pos
|
64
|
+
ranges << (start_pos...end_pos)
|
65
|
+
else
|
66
|
+
# If no closing tag found, consider rest of document unsafe
|
67
|
+
ranges << (start_pos...@html.length)
|
68
|
+
break
|
69
|
+
end
|
70
|
+
end
|
71
|
+
end
|
72
|
+
|
73
|
+
ranges
|
74
|
+
end
|
75
|
+
|
76
|
+
# Check if position is at a tag boundary (before < or after >)
|
77
|
+
def at_tag_boundary?(position)
|
78
|
+
return true if position == 0 || position == @html.length
|
79
|
+
|
80
|
+
char_before = position > 0 ? @html[position - 1] : nil
|
81
|
+
char_at = @html[position]
|
82
|
+
|
83
|
+
# Safe positions:
|
84
|
+
# - Right after a closing >
|
85
|
+
# - Right before an opening <
|
86
|
+
# - At whitespace boundaries between tags
|
87
|
+
char_before == '>' || char_at == '<' || (char_at&.match?(/\s/) && next_non_whitespace_is_tag?(position))
|
88
|
+
end
|
89
|
+
|
90
|
+
def next_non_whitespace_is_tag?(position)
|
91
|
+
pos = position
|
92
|
+
while pos < @html.length && @html[pos].match?(/\s/)
|
93
|
+
pos += 1
|
94
|
+
end
|
95
|
+
|
96
|
+
pos < @html.length && @html[pos] == '<'
|
97
|
+
end
|
98
|
+
end
|
99
|
+
end
|