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.
@@ -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 = %w[data template].freeze
32
- KNOWN_SECTIONS = %w[data template logic].freeze
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
- # Regular expression to match HTML/XML comments outside of sections
36
- COMMENT_REGEX = /<!--.*?-->/m
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-Z]/)
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
- # Extract the raw content between section tags
186
- raw_content = ''
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
- # For template sections, use HandlebarsParser to parse the content
193
- if tag_name == 'template'
194
- handlebars_parser = HandlebarsParser.new(raw_content)
195
- handlebars_parser.parse!
196
- handlebars_parser.ast.children
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
- value
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
- tokens << { type: :comment, content: scanner.matched }
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
- tokens << { type: :section_start, content: scanner.matched }
369
- when scanner.scan(/<\/(data|template|logic)>/m)
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
- tokens << { type: :section_end, content: scanner.matched }
388
+ { type: :section_end, content: scanner.matched }
372
389
  when scanner.scan(/[^<]+/)
373
390
  # Text token - consolidates runs of non-< characters for efficiency
374
- tokens << { type: :text, content: scanner.matched }
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
- tokens << { type: :text, content: scanner.getch }
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
- if defined?(OT) && OT.respond_to?(:le)
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 = defined?(OT) && OT.respond_to?(:boot_root) ? OT.boot_root : File.expand_path('../../..', __dir__)
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
- if defined?(OT) && OT.respond_to?(:ld)
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
- if defined?(OT) && OT.respond_to?(:ld)
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
@@ -163,11 +163,11 @@ module Rhales
163
163
 
164
164
  private
165
165
 
166
- def extract_partials_from_node(node, partials)
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 |section_name, section_node|
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