Class: Rhales::MountPointDetector
- Inherits:
-
Object
- Object
- Rhales::MountPointDetector
- 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
- Selector Priority: All selectors (default + custom) are checked in parallel
- Position Priority: Returns the earliest mount point by position in HTML (not selector order)
- Safety Validation: Validates injection points are outside unsafe contexts (scripts/styles/comments)
- 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
-
#build_pattern(selector) ⇒ Object
private
Build the regex fragment that matches a single selector.
-
#combined_pattern(selectors) ⇒ Object
private
Build one alternation pattern from all selectors.
-
#compute_detection(template_html, selectors) ⇒ Object
private
-
#detect(template_html, custom_selectors = []) ⇒ Object
-
#find_safe_injection_position(validator, preferred_position) ⇒ Object
private
-
#find_tag_start(scanner, template_html) ⇒ Object
private
-
#initialize ⇒ MountPointDetector
constructor
A new instance of MountPointDetector.
-
#matched_group_index(scanner, count) ⇒ Object
private
Constructor Details
#initialize ⇒ MountPointDetector
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 |