Class: Rhales::EarliestInjectionDetector

Inherits:
Object
  • Object
show all
Defined in:
lib/rhales/hydration/earliest_injection_detector.rb

Overview

Detects the earliest safe injection points in HTML head and body sections for optimal hydration script placement performance

Injection Priority Order

For <head></head> section: 1. After the last <link> tag 2. After the last <meta> tag 3. After the first <script> tag (assuming early scripts are intentional) 4. Before the </head> tag

If no <head> but there is <body>: - Before the <body> tag

All injection points are validated for safety using SafeInjectionValidator to prevent injection inside unsafe contexts (scripts, styles, comments).

Instance Method Summary collapse

Instance Method Details

#build_byte_to_char_map(str) ⇒ Hash<Integer, Integer> (private)

Builds a mapping from byte positions to character positions for efficient conversion when processing UTF-8 strings with StringScanner.

This method creates a hash where keys are byte positions and values are the corresponding character positions. For multibyte UTF-8 characters, only the starting byte position has an entry in the map.

Examples:

ASCII string

build_byte_to_char_map("Hello")
# => {0=>0, 1=>1, 2=>2, 3=>3, 4=>4, 5=>5}

UTF-8 with multibyte characters

build_byte_to_char_map("café")  # é is 2 bytes
# => {0=>0, 1=>1, 2=>2, 3=>3, 5=>4}  # Note: byte 4 is continuation byte

Parameters:

  • str (String)

    The UTF-8 encoded string to map

Returns:

  • (Hash<Integer, Integer>)

    A hash mapping byte positions to character positions



191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
# File 'lib/rhales/hydration/earliest_injection_detector.rb', line 191

def build_byte_to_char_map(str)
  map = {}
  char_pos = 0
  byte_pos = 0

  # Iterate through each character (not byte) in the string
  str.each_char do |char|
    # Map the starting byte position of this character
    map[byte_pos] = char_pos

    # Advance byte position by the byte size of this character
    byte_pos += char.bytesize
    char_pos += 1
  end

  # Add final mapping for the end of the string
  map[byte_pos] = char_pos
  map
end

#detect(template_html) ⇒ Object



26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
# File 'lib/rhales/hydration/earliest_injection_detector.rb', line 26

def detect(template_html)
  scanner = StringScanner.new(template_html)
  validator = SafeInjectionValidator.new(template_html)
  # Build byte-to-char map once for the entire template
  @byte_to_char_map = build_byte_to_char_map(template_html)

  # Try head section injection points first
  head_injection_point = detect_head_injection_point(scanner, validator, template_html)
  return head_injection_point if head_injection_point

  # Fallback to body tag injection
  body_injection_point = detect_body_injection_point(scanner, validator, template_html)
  return body_injection_point if body_injection_point

  # No suitable injection point found
  nil
end

#detect_body_injection_point(scanner, validator, template_html) ⇒ Object (private)



68
69
70
71
72
73
74
75
76
77
78
79
80
81
# File 'lib/rhales/hydration/earliest_injection_detector.rb', line 68

def detect_body_injection_point(scanner, validator, template_html)
  scanner.pos = 0

  # Find opening <body> tag
  if scanner.scan_until(/<body\b[^>]*>/i)
    # Convert byte position to character position using pre-built map
    byte_body_start = scanner.pos - scanner.matched.length
    body_start = @byte_to_char_map[byte_body_start]
    safe_position = find_safe_injection_position(validator, body_start)
    return safe_position if safe_position
  end

  nil
end

#detect_head_injection_point(scanner, validator, template_html) ⇒ Object (private)



46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
# File 'lib/rhales/hydration/earliest_injection_detector.rb', line 46

def detect_head_injection_point(scanner, validator, template_html)
  # Find head section bounds
  head_start, head_end = find_head_section(template_html)
  return nil unless head_start && head_end

  # Try injection points in priority order within head section
  injection_candidates = [
    find_after_last_link(template_html, head_start, head_end),
    find_after_last_meta(template_html, head_start, head_end),
    find_after_first_script(template_html, head_start, head_end),
    head_end # Before </head>
  ].compact

  # Return first safe injection point
  injection_candidates.each do |position|
    safe_position = find_safe_injection_position(validator, position)
    return safe_position if safe_position
  end

  nil
end

#find_after_first_script(template_html, head_start, head_end) ⇒ Object (private)



133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
# File 'lib/rhales/hydration/earliest_injection_detector.rb', line 133

def find_after_first_script(template_html, head_start, head_end)
  head_content = template_html[head_start...head_end]
  scanner = StringScanner.new(head_content)
  byte_to_char_map = build_byte_to_char_map(head_content)

  # Find first script opening tag
  if scanner.scan_until(/<script\b[^>]*>/i)
    # Only the script end position is needed for this method, not the start position

    # Find corresponding closing tag
    if scanner.scan_until(/<\/script>/i)
      # scanner.pos is byte position within head_content
      byte_script_end = scanner.pos
      # Convert to character position using pre-built map
      first_script_end = byte_to_char_map[byte_script_end]
      return head_start + first_script_end
    end
  end

  nil
end


101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
# File 'lib/rhales/hydration/earliest_injection_detector.rb', line 101

def find_after_last_link(template_html, head_start, head_end)
  head_content = template_html[head_start...head_end]
  scanner = StringScanner.new(head_content)
  last_link_end = nil
  byte_to_char_map = build_byte_to_char_map(head_content)

  while scanner.scan_until(/<link\b[^>]*\/?>/i)
    # scanner.pos is byte position within head_content
    byte_pos = scanner.pos
    # Convert to character position using pre-built map
    last_link_end = byte_to_char_map[byte_pos]
  end

  last_link_end ? head_start + last_link_end : nil
end

#find_after_last_meta(template_html, head_start, head_end) ⇒ Object (private)



117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
# File 'lib/rhales/hydration/earliest_injection_detector.rb', line 117

def find_after_last_meta(template_html, head_start, head_end)
  head_content = template_html[head_start...head_end]
  scanner = StringScanner.new(head_content)
  last_meta_end = nil
  byte_to_char_map = build_byte_to_char_map(head_content)

  while scanner.scan_until(/<meta\b[^>]*\/?>/i)
    # scanner.pos is byte position within head_content
    byte_pos = scanner.pos
    # Convert to character position using pre-built map
    last_meta_end = byte_to_char_map[byte_pos]
  end

  last_meta_end ? head_start + last_meta_end : nil
end

#find_head_section(template_html) ⇒ Object (private)



83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
# File 'lib/rhales/hydration/earliest_injection_detector.rb', line 83

def find_head_section(template_html)
  scanner = StringScanner.new(template_html)

  # Find opening <head> tag
  return nil unless scanner.scan_until(/<head\b[^>]*>/i)
  # Convert byte position to character position using pre-built map
  byte_head_start = scanner.pos
  head_start = @byte_to_char_map[byte_head_start]

  # Find closing </head> tag
  return nil unless scanner.scan_until(/<\/head>/i)
  # Convert byte position to character position using pre-built map
  byte_head_end = scanner.pos - scanner.matched.length
  head_end = @byte_to_char_map[byte_head_end]

  [head_start, head_end]
end

#find_safe_injection_position(validator, preferred_position) ⇒ Object (private)



155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
# File 'lib/rhales/hydration/earliest_injection_detector.rb', line 155

def find_safe_injection_position(validator, preferred_position)
  return nil unless preferred_position

  # First check if the preferred position is safe
  return preferred_position if validator.safe_injection_point?(preferred_position)

  # Try to find a safe position before the preferred position
  safe_before = validator.nearest_safe_point_before(preferred_position)
  return safe_before if safe_before

  # As a last resort, try after the preferred position
  safe_after = validator.nearest_safe_point_after(preferred_position)
  return safe_after if safe_after

  # No safe position found
  nil
end