Class: Rhales::MountPointDetector

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

Overview

Detects frontend application mount points in HTML templates Used to determine optimal hydration script injection points

Mount Point Detection Order

  1. Selector Priority: All selectors (default + custom) are checked in parallel
  2. Position Priority: Returns the earliest mount point by position in HTML (not selector order)
  3. Safety Validation: Validates injection points are outside unsafe contexts (scripts/styles/comments)
  4. Safe Position Search: If original position unsafe, searches for nearest safe alternative:
    • First tries positions before the mount point (maintains earlier injection)
    • Then tries positions after the mount point (fallback)
    • Returns nil if no safe position found

Default selectors are checked: [‘#app’, ‘#root’, ‘[data-rsfc-mount]’, ‘[data-mount]’] Custom selectors can be added via configuration and are combined with defaults.

Performance

Detection scans the HTML a single time using one combined alternation pattern built from all selectors, rather than re-scanning the whole document once per selector. Results are memoized per instance keyed by [template_html, selectors], so repeated detection on the same rendered HTML (e.g. across renders that reuse the detector) reuses the prior result and its SafeInjectionValidator work instead of recomputing.

Constant Summary collapse

DEFAULT_SELECTORS =
['#app', '#root', '[data-rsfc-mount]', '[data-mount]'].freeze

Instance Method Summary collapse

Constructor Details

#initializeMountPointDetector

Returns a new instance of MountPointDetector.



36
37
38
# File 'lib/rhales/hydration/mount_point_detector.rb', line 36

def initialize
  @cache = {}
end

Instance Method Details

#build_pattern(selector) ⇒ Object (private)

Build the regex fragment that matches a single selector.

INVARIANT: sub-patterns must not contain capturing groups. combined_pattern wraps each fragment in exactly one capture group and maps that group’s index back to a selector, so any stray (...) here would shift the numbering and misattribute matches. Use non-capturing groups (?:...).



110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
# File 'lib/rhales/hydration/mount_point_detector.rb', line 110

def build_pattern(selector)
  case selector
  when /^#(.+)$/
    # ID selector: <tag id="value">
    id_name = Regexp.escape($1)
    /id\s*=\s*["']#{id_name}["']/i
  when /^\.(.+)$/
    # Class selector: <tag class="... value ...">
    class_name = Regexp.escape($1)
    /class\s*=\s*["'][^"']*\b#{class_name}\b[^"']*["']/i
  when /^\[([^\]]+)\]$/
    # Attribute selector: <tag data-attr> or <tag data-attr="value">
    attr_name = Regexp.escape($1)
    /#{attr_name}(?:\s*=\s*["'][^"']*["'])?/i
  else
    # Invalid selector, match nothing
    /(?!.*)/
  end
end

#combined_pattern(selectors) ⇒ Object (private)

Build one alternation pattern from all selectors. Each selector’s sub-pattern is wrapped in a capture group so the matched group index identifies which selector matched. None of the sub-patterns introduce their own capture groups, so group N corresponds to selector N-1.



87
88
89
90
# File 'lib/rhales/hydration/mount_point_detector.rb', line 87

def combined_pattern(selectors)
  union = selectors.map { |selector| "(#{build_pattern(selector).source})" }.join('|')
  Regexp.new(union, Regexp::IGNORECASE)
end

#compute_detection(template_html, selectors) ⇒ Object (private)



50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
# File 'lib/rhales/hydration/mount_point_detector.rb', line 50

def compute_detection(template_html, selectors)
  validator = SafeInjectionValidator.new(template_html)
  pattern   = combined_pattern(selectors)
  scanner   = StringScanner.new(template_html)
  matches   = []

  # Single pass over the HTML: the combined alternation finds every
  # selector occurrence in document order, and the matching capture-group
  # index tells us which selector produced it.
  while scanner.scan_until(pattern)
    selector  = selectors[matched_group_index(scanner, selectors.length)]
    tag_start = find_tag_start(scanner, template_html)

    # Only include mount points that are safe for injection
    safe_position = find_safe_injection_position(validator, tag_start)
    next unless safe_position

    matches << {
      selector: selector,
      position: safe_position,
      original_position: tag_start,
      matched: scanner.matched,
    }
  end

  # Preserve the original selection semantics: matches are considered in
  # selector-priority order (then document order within a selector), and
  # the earliest injection position wins. Ordering the matches this way
  # before min_by reproduces the previous per-selector tie-breaking.
  ordered = selectors.flat_map { |selector| matches.select { |mp| mp[:selector] == selector } }
  ordered.min_by { |mp| mp[:position] }
end

#detect(template_html, custom_selectors = []) ⇒ Object



40
41
42
43
44
45
46
# File 'lib/rhales/hydration/mount_point_detector.rb', line 40

def detect(template_html, custom_selectors = [])
  selectors = (DEFAULT_SELECTORS + Array(custom_selectors)).uniq
  cache_key = [template_html, selectors]
  return @cache[cache_key] if @cache.key?(cache_key)

  @cache[cache_key] = compute_detection(template_html, selectors)
end

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



142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
# File 'lib/rhales/hydration/mount_point_detector.rb', line 142

def find_safe_injection_position(validator, 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

#find_tag_start(scanner, template_html) ⇒ Object (private)



130
131
132
133
134
135
136
137
138
139
140
# File 'lib/rhales/hydration/mount_point_detector.rb', line 130

def find_tag_start(scanner, template_html)
  # Work backwards from current position to find the opening <
  pos = scanner.pos - scanner.matched.length

  while pos > 0 && template_html[pos - 1] != '<'
    pos -= 1
  end

  # Return position of the < character
  pos > 0 ? pos - 1 : 0
end

#matched_group_index(scanner, count) ⇒ Object (private)



92
93
94
95
96
97
98
99
100
101
102
# File 'lib/rhales/hydration/mount_point_detector.rb', line 92

def matched_group_index(scanner, count)
  count.times { |index| return index if scanner[index + 1] }

  # Unreachable in normal operation: after a successful scan_until against
  # combined_pattern exactly one wrapped selector group has captured. If
  # none has, a build_pattern sub-pattern introduced its own capturing
  # group and shifted the numbering, so fail loudly rather than silently
  # attributing the match to the first selector.
  raise 'MountPointDetector: no selector capture group matched; ' \
        'build_pattern sub-patterns must not contain capturing groups'
end