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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9e468b139c203a063276ccd0f890b0b7439a7c4459a96f132ed1e53c01623b11
4
- data.tar.gz: 786e0f95fa3d592b5451f7538d350f558f31545eb99973f5384bbde2fd474a99
3
+ metadata.gz: 7d4de8c7caddc2eb408dbb631654a055785456d23a13f28498d426a5f558464d
4
+ data.tar.gz: efe0545542afb04b016be65203ba8aede3e455b6075c6fe6d808764da4de6f93
5
5
  SHA512:
6
- metadata.gz: fd02c37df3e02b5e423eeb7a1929ca2393a98ee0c6569724256846cd168ffa5476e4d94e18cbd9fc28149d0f50ff957423fbd1c165fe0e30f83d281e6075ab04
7
- data.tar.gz: 8b9f7d88831a6e78ae811a56a4fe0c95e1c8962011ebbbcb7927e4cfb6dbcde798f66e4a2998fb89fcf30f356c7d2ca3134548971b3721331aacad6f90e501d5
6
+ metadata.gz: 7d9267703c89eff2da128aaecf692445e1b3533905b3c4218dd9feb5e855ecee037c52684b5d61bbb900a341d53bb14a4612bd27fc03d4f42733fa5c2621384d
7
+ data.tar.gz: b239a232e8d146bb3de0691151a152d85dcf42fa3117eb6e0aa4ac37745078eff4887e5ef969ed975e205c92c6b43e9cfa270a1cf981733384235ba70635d858
data/CLAUDE.md CHANGED
@@ -29,7 +29,6 @@ Follow test-driven development when possible:
29
29
  2. Confirm tests fail appropriately
30
30
  3. Commit tests
31
31
  4. Implement code to pass tests WITHOUT modifying tests
32
- 5. Ensure no global state dependencies (OT.conf references)
33
32
  6. Verify HTML escaping and security measures
34
33
  7. Commit implementation
35
34
 
data/README.md CHANGED
@@ -11,7 +11,8 @@ It all started with a simple mustache template many years ago. The successor to
11
11
  ## Features
12
12
 
13
13
  - **Server-side template rendering** with Handlebars-style syntax
14
- - **Client-side data hydration** with secure JSON injection
14
+ - **Enhanced hydration strategies** for optimal client-side performance
15
+ - **API endpoint generation** for link-based hydration strategies
15
16
  - **Window collision detection** prevents silent data overwrites
16
17
  - **Explicit merge strategies** for controlled data sharing (shallow, deep, strict)
17
18
  - **Clear security boundaries** between server context and client data
@@ -19,6 +20,7 @@ It all started with a simple mustache template many years ago. The successor to
19
20
  - **Pluggable authentication adapters** for any auth system
20
21
  - **Security-first design** with XSS protection and automatic CSP generation
21
22
  - **Dependency injection** for testability and flexibility
23
+ - **Resource hint optimization** with browser preload/prefetch support
22
24
 
23
25
  ## Installation
24
26
 
@@ -52,6 +54,13 @@ Rhales.configure do |config|
52
54
  config.features = { dark_mode: true }
53
55
  config.site_host = 'example.com'
54
56
 
57
+ # Enhanced Hydration Configuration
58
+ config.hydration.injection_strategy = :preload # or :late, :early, :earliest, :prefetch, :modulepreload, :lazy
59
+ config.hydration.api_endpoint_path = '/api/hydration'
60
+ config.hydration.fallback_to_late = true
61
+ config.hydration.api_cache_enabled = true
62
+ config.hydration.cors_enabled = true
63
+
55
64
  # CSP configuration (enabled by default)
56
65
  config.csp_enabled = true # Enable automatic CSP header generation
57
66
  config.auto_nonce = true # Automatically generate nonces
@@ -574,10 +583,175 @@ Rhales uses a Handlebars-style template syntax:
574
583
  {{> navigation}}
575
584
  ```
576
585
 
577
- ## Data Hydration
586
+ ## Enhanced Hydration Strategies
587
+
588
+ Rhales provides multiple hydration strategies optimized for different performance requirements and use cases:
589
+
590
+ ### Traditional Strategies
591
+
592
+ #### `:late` (Default - Backwards Compatible)
593
+ Injects scripts before the closing `</body>` tag. Safe and reliable for all scenarios.
594
+
595
+ ```ruby
596
+ config.hydration.injection_strategy = :late
597
+ ```
598
+
599
+ #### `:early` (Mount Point Optimization)
600
+ Injects scripts immediately before frontend mount points (`#app`, `#root`, etc.) for improved Time-to-Interactive.
601
+
602
+ ```ruby
603
+ config.hydration.injection_strategy = :early
604
+ config.hydration.mount_point_selectors = ['#app', '#root', '[data-mount]']
605
+ config.hydration.fallback_to_late = true
606
+ ```
607
+
608
+ #### `:earliest` (Head Section Injection)
609
+ Injects scripts in the HTML head section for maximum performance, after meta tags and stylesheets.
610
+
611
+ ```ruby
612
+ config.hydration.injection_strategy = :earliest
613
+ config.hydration.fallback_to_late = true
614
+ ```
615
+
616
+ ### Link-Based Strategies (API Endpoints)
617
+
618
+ These strategies generate separate API endpoints for hydration data, enabling better caching, parallel loading, and reduced HTML payload sizes.
619
+
620
+ #### `:preload` (High Priority Loading)
621
+ Generates `<link rel="preload">` tags with immediate script execution for critical data.
622
+
623
+ ```ruby
624
+ config.hydration.injection_strategy = :preload
625
+ config.hydration.api_endpoint_path = '/api/hydration'
626
+ config.hydration.link_crossorigin = true
627
+ ```
628
+
629
+ #### `:prefetch` (Future Page Optimization)
630
+ Generates `<link rel="prefetch">` tags for data that will be needed on subsequent page loads.
631
+
632
+ ```ruby
633
+ config.hydration.injection_strategy = :prefetch
634
+ ```
635
+
636
+ #### `:modulepreload` (ES Module Support)
637
+ Generates `<link rel="modulepreload">` tags with ES module imports for modern applications.
638
+
639
+ ```ruby
640
+ config.hydration.injection_strategy = :modulepreload
641
+ ```
642
+
643
+ #### `:lazy` (Intersection Observer)
644
+ Loads data only when mount points become visible using Intersection Observer API.
645
+
646
+ ```ruby
647
+ config.hydration.injection_strategy = :lazy
648
+ config.hydration.lazy_mount_selector = '#app'
649
+ ```
650
+
651
+ #### `:link` (Manual Loading)
652
+ Generates basic link references with manual loading functions for custom hydration logic.
653
+
654
+ ```ruby
655
+ config.hydration.injection_strategy = :link
656
+ ```
657
+
658
+ ### Strategy Performance Comparison
659
+
660
+ | Strategy | Time-to-Interactive | Caching | Parallel Loading | Best Use Case |
661
+ |----------|-------------------|---------|------------------|---------------|
662
+ | `:late` | Standard | Basic | No | Legacy compatibility |
663
+ | `:early` | Improved | Basic | No | SPA mount point optimization |
664
+ | `:earliest` | Excellent | Basic | No | Critical path optimization |
665
+ | `:preload` | Excellent | Advanced | Yes | High-priority data |
666
+ | `:prefetch` | Standard | Advanced | Yes | Multi-page apps |
667
+ | `:modulepreload` | Excellent | Advanced | Yes | Modern ES modules |
668
+ | `:lazy` | Variable | Advanced | Yes | Below-fold content |
669
+ | `:link` | Manual | Advanced | Yes | Custom implementations |
670
+
671
+ ### API Endpoint Setup
672
+
673
+ For link-based strategies, you'll need to set up API endpoints in your application:
674
+
675
+ ```ruby
676
+ # Rails example
677
+ class HydrationController < ApplicationController
678
+ def show
679
+ template_name = params[:template]
680
+ endpoint = Rhales::HydrationEndpoint.new(rhales_config, current_context)
681
+
682
+ case request.format
683
+ when :json
684
+ result = endpoint.render_json(template_name)
685
+ when :js
686
+ result = endpoint.render_module(template_name)
687
+ else
688
+ result = endpoint.render_json(template_name)
689
+ end
690
+
691
+ render json: result[:content],
692
+ content_type: result[:content_type],
693
+ headers: result[:headers]
694
+ end
695
+ end
696
+
697
+ # routes.rb
698
+ get '/api/hydration/:template', to: 'hydration#show'
699
+ get '/api/hydration/:template.js', to: 'hydration#show', defaults: { format: :js }
700
+ ```
701
+
702
+ #### Advanced Non-Rails Example
703
+
704
+ For applications using custom frameworks or middleware, here's a complete controller implementation from a production Rack-based application:
705
+
706
+ ```ruby
707
+ # Example from OneTime Secret (non-Rails application)
708
+ module Manifold
709
+ module Controllers
710
+ class Data
711
+ include Controllers::Base
712
+
713
+ def rhales_hydration
714
+ publically do
715
+ template_name = params[:template]
716
+
717
+ # Build Rhales context from your app's current state
718
+ context = Rhales::Context.for_view(req, sess, cust, locale)
719
+ endpoint = Rhales::HydrationEndpoint.new(Rhales.configuration, context)
720
+
721
+ # Handle different formats
722
+ result = case req.env['HTTP_ACCEPT']
723
+ when /application\/javascript/
724
+ endpoint.render_module(template_name)
725
+ else
726
+ endpoint.render_json(template_name)
727
+ end
728
+
729
+ # Set response content type and headers
730
+ res['Content-Type'] = result[:content_type]
731
+ result[:headers]&.each { |key, value| res[key] = value }
732
+
733
+ # Return the content
734
+ res.body = result[:content]
735
+ end
736
+ end
737
+ end
738
+ end
739
+ end
740
+
741
+ # Route configuration (framework-specific)
742
+ # GET /api/hydration/:template -> data#rhales_hydration
743
+ ```
744
+
745
+ **Key differences from Rails:**
746
+ - **Framework-agnostic**: Uses `req`, `res`, `sess`, `cust`, `locale` from your framework
747
+ - **Context creation**: Manually creates `Rhales::Context.for_view` with app objects
748
+ - **Global configuration**: Uses `Rhales.configuration` instead of passing custom config
749
+ - **Direct response handling**: Sets headers and body directly instead of using `render`
750
+ - **Custom format detection**: Uses `req.env['HTTP_ACCEPT']` instead of `request.format`
578
751
 
579
- The `<data>` section creates client-side JavaScript:
752
+ ### Data Hydration Examples
580
753
 
754
+ #### Traditional Inline Hydration
581
755
  ```erb
582
756
  <data window="myData">
583
757
  {
@@ -598,6 +772,131 @@ window.myData = JSON.parse(document.getElementById('rsfc-data-abc123').textConte
598
772
  </script>
599
773
  ```
600
774
 
775
+ #### Link-Based Hydration (`:preload` strategy)
776
+ ```erb
777
+ <data window="myData">
778
+ {
779
+ "apiUrl": "{{api_base_url}}",
780
+ "user": {{user}},
781
+ "csrfToken": "{{csrf_token}}"
782
+ }
783
+ </data>
784
+ ```
785
+
786
+ Generates:
787
+ ```html
788
+ <link rel="preload" href="/api/hydration/my_template" as="fetch" crossorigin>
789
+ <script nonce="nonce123" data-hydration-target="myData">
790
+ fetch('/api/hydration/my_template')
791
+ .then(r => r.json())
792
+ .then(data => {
793
+ window.myData = data;
794
+ window.dispatchEvent(new CustomEvent('rhales:hydrated', {
795
+ detail: { target: 'myData', data: data }
796
+ }));
797
+ })
798
+ .catch(err => console.error('Rhales hydration error:', err));
799
+ </script>
800
+ ```
801
+
802
+ #### ES Module Hydration (`:modulepreload` strategy)
803
+ ```html
804
+ <link rel="modulepreload" href="/api/hydration/my_template.js">
805
+ <script type="module" nonce="nonce123" data-hydration-target="myData">
806
+ import data from '/api/hydration/my_template.js';
807
+ window.myData = data;
808
+ window.dispatchEvent(new CustomEvent('rhales:hydrated', {
809
+ detail: { target: 'myData', data: data }
810
+ }));
811
+ </script>
812
+ ```
813
+
814
+ ### Migration Guide
815
+
816
+ #### From Basic to Enhanced Hydration
817
+
818
+ **Step 1**: Update your configuration to use enhanced strategies:
819
+
820
+ ```ruby
821
+ # Before (implicit :late strategy)
822
+ Rhales.configure do |config|
823
+ # Basic configuration
824
+ end
825
+
826
+ # After (explicit strategy selection)
827
+ Rhales.configure do |config|
828
+ # Choose your strategy based on your needs
829
+ config.hydration.injection_strategy = :preload # or :early, :earliest, etc.
830
+ config.hydration.fallback_to_late = true # Safe fallback
831
+ config.hydration.api_endpoint_path = '/api/hydration'
832
+ config.hydration.api_cache_enabled = true
833
+ end
834
+ ```
835
+
836
+ **Step 2**: Set up API endpoints for link-based strategies (if using `:preload`, `:prefetch`, `:modulepreload`, `:lazy`, or `:link`):
837
+
838
+ ```ruby
839
+ # Add to your routes
840
+ get '/api/hydration/:template', to: 'hydration#show'
841
+ get '/api/hydration/:template.js', to: 'hydration#show', defaults: { format: :js }
842
+
843
+ # Create controller
844
+ class HydrationController < ApplicationController
845
+ def show
846
+ template_name = params[:template]
847
+ endpoint = Rhales::HydrationEndpoint.new(rhales_config, current_context)
848
+ result = endpoint.render_json(template_name)
849
+
850
+ render json: result[:content],
851
+ content_type: result[:content_type],
852
+ headers: result[:headers]
853
+ end
854
+ end
855
+ ```
856
+
857
+ **Step 3**: Update your frontend code to listen for hydration events (optional):
858
+
859
+ ```javascript
860
+ // Listen for hydration completion
861
+ window.addEventListener('rhales:hydrated', (event) => {
862
+ console.log('Data loaded:', event.detail.target, event.detail.data);
863
+
864
+ // Initialize your app with the loaded data
865
+ if (event.detail.target === 'appData') {
866
+ initializeApp(event.detail.data);
867
+ }
868
+ });
869
+ ```
870
+
871
+ ### Troubleshooting
872
+
873
+ #### Common Issues
874
+
875
+ **1. Link-based strategies not working**
876
+ - Ensure API endpoints are set up correctly
877
+ - Check that `config.hydration.api_endpoint_path` matches your routes
878
+ - Verify CORS settings if loading from different domains
879
+
880
+ **2. Mount points not detected with `:early` strategy**
881
+ - Check that your HTML contains valid mount point selectors (`#app`, `#root`, etc.)
882
+ - Verify `config.hydration.mount_point_selectors` includes your selectors
883
+ - Enable fallback: `config.hydration.fallback_to_late = true`
884
+
885
+ **3. CSP violations with link-based strategies**
886
+ - Ensure nonces are properly configured: `config.auto_nonce = true`
887
+ - Add API endpoint domains to CSP `connect-src` directive
888
+ - Check that `crossorigin` attribute is properly configured
889
+
890
+ **4. Performance not improving with advanced strategies**
891
+ - Verify browser support for chosen strategy (modulepreload requires modern browsers)
892
+ - Check network timing in DevTools to confirm parallel loading
893
+ - Consider using `:prefetch` for subsequent page loads vs `:preload` for current page
894
+
895
+ **5. Hydration events not firing**
896
+ - Ensure JavaScript is not blocked by CSP
897
+ - Check browser console for script errors
898
+ - Verify API endpoints return valid JSON responses
899
+
601
900
  ### Window Collision Detection
602
901
 
603
902
  Rhales automatically detects when multiple templates try to use the same window attribute, preventing silent data overwrites:
@@ -1,6 +1,115 @@
1
1
  # lib/rhales/configuration.rb
2
2
 
3
3
  module Rhales
4
+ # Hydration-specific configuration settings
5
+ #
6
+ # Controls how hydration scripts are injected into HTML templates.
7
+ # Supports multiple injection strategies for different performance characteristics:
8
+ #
9
+ # ## Traditional Strategies
10
+ # - `:late` (default) - injects before </body> tag (safest, backwards compatible)
11
+ # - `:early` - injects before detected mount points for improved performance
12
+ # - `:earliest` - injects in HTML head section for maximum performance
13
+ #
14
+ # ## Link-Based Strategies (API endpoints)
15
+ # - `:link` - basic link reference to API endpoint
16
+ # - `:prefetch` - browser prefetch for future page loads
17
+ # - `:preload` - high priority preload for current page
18
+ # - `:modulepreload` - ES module preloading
19
+ # - `:lazy` - intersection observer-based lazy loading
20
+ class HydrationConfiguration
21
+ VALID_STRATEGIES = [:late, :early, :earliest, :link, :prefetch, :preload, :modulepreload, :lazy].freeze
22
+ LINK_BASED_STRATEGIES = [:link, :prefetch, :preload, :modulepreload, :lazy].freeze
23
+ DEFAULT_API_CACHE_TTL = 300 # 5 minutes
24
+
25
+ # Injection strategy - one of VALID_STRATEGIES
26
+ attr_accessor :injection_strategy
27
+
28
+ # Array of CSS selectors to detect frontend mount points
29
+ attr_accessor :mount_point_selectors
30
+
31
+ # Whether to fallback to late injection when no mount points detected
32
+ attr_accessor :fallback_to_late
33
+
34
+ # Whether to fallback to late injection when early injection is unsafe
35
+ attr_accessor :fallback_when_unsafe
36
+
37
+ # Disable early injection for specific templates (array of template names)
38
+ attr_accessor :disable_early_for_templates
39
+
40
+ # API endpoint configuration for link-based strategies
41
+ attr_accessor :api_endpoint_enabled, :api_endpoint_path
42
+
43
+ # Link tag configuration
44
+ attr_accessor :link_crossorigin
45
+
46
+ # Module export configuration for :modulepreload strategy
47
+ attr_accessor :module_export_enabled
48
+
49
+ # Lazy loading configuration
50
+ attr_accessor :lazy_mount_selector
51
+
52
+ # Data attribute reflection system
53
+ attr_accessor :reflection_enabled
54
+
55
+ # Caching configuration for API endpoints
56
+ attr_accessor :api_cache_enabled, :api_cache_ttl
57
+
58
+ # CORS configuration for API endpoints
59
+ attr_accessor :cors_enabled, :cors_origin
60
+
61
+ def initialize
62
+ # Traditional strategy settings
63
+ @injection_strategy = :late
64
+ @mount_point_selectors = ['#app', '#root', '[data-rsfc-mount]', '[data-mount]']
65
+ @fallback_to_late = true
66
+ @fallback_when_unsafe = true
67
+ @disable_early_for_templates = []
68
+
69
+ # API endpoint settings
70
+ @api_endpoint_enabled = false
71
+ @api_endpoint_path = '/api/hydration'
72
+
73
+ # Link tag settings
74
+ @link_crossorigin = true
75
+
76
+ # Module export settings
77
+ @module_export_enabled = false
78
+
79
+ # Lazy loading settings
80
+ @lazy_mount_selector = '#app'
81
+
82
+ # Reflection system settings
83
+ @reflection_enabled = true
84
+
85
+ # Caching settings
86
+ @api_cache_enabled = false
87
+ @api_cache_ttl = DEFAULT_API_CACHE_TTL
88
+
89
+ # CORS settings
90
+ @cors_enabled = false
91
+ @cors_origin = '*'
92
+ end
93
+
94
+ # Validate the injection strategy
95
+ def injection_strategy=(strategy)
96
+ unless VALID_STRATEGIES.include?(strategy)
97
+ raise ArgumentError, "Invalid injection strategy: #{strategy}. Valid options: #{VALID_STRATEGIES.join(', ')}"
98
+ end
99
+ @injection_strategy = strategy
100
+ end
101
+
102
+ # Check if current strategy is link-based
103
+ def link_based_strategy?
104
+ LINK_BASED_STRATEGIES.include?(@injection_strategy)
105
+ end
106
+
107
+ # Check if API endpoints should be enabled
108
+ def api_endpoints_required?
109
+ link_based_strategy? || @api_endpoint_enabled
110
+ end
111
+ end
112
+
4
113
  # Configuration management for Rhales library
5
114
  #
6
115
  # Provides a clean, testable alternative to global configuration access.
@@ -31,6 +140,9 @@ module Rhales
31
140
  # Performance settings
32
141
  attr_accessor :cache_parsed_templates, :cache_ttl
33
142
 
143
+ # Hydration settings
144
+ attr_accessor :hydration
145
+
34
146
  def initialize
35
147
  # Set sensible defaults
36
148
  @default_locale = 'en'
@@ -47,6 +159,7 @@ module Rhales
47
159
  @site_ssl_enabled = false
48
160
  @cache_parsed_templates = true
49
161
  @cache_ttl = 3600 # 1 hour
162
+ @hydration = HydrationConfiguration.new
50
163
  end
51
164
 
52
165
  # Build API base URL from site configuration
@@ -86,7 +199,7 @@ module Rhales
86
199
  'worker-src' => ["'self'"],
87
200
  'manifest-src' => ["'self'"],
88
201
  'prefetch-src' => ["'self'"],
89
- 'upgrade-insecure-requests' => []
202
+ 'upgrade-insecure-requests' => [],
90
203
  }.freeze
91
204
  end
92
205
 
@@ -97,7 +97,6 @@ module Rhales
97
97
 
98
98
  private
99
99
 
100
-
101
100
  # Build consolidated app data (replaces runtime_data + computed_data)
102
101
  def build_app_data
103
102
  app = {}
@@ -0,0 +1,149 @@
1
+ require 'strscan'
2
+ require_relative 'safe_injection_validator'
3
+
4
+ module Rhales
5
+ # Detects the earliest safe injection points in HTML head and body sections
6
+ # for optimal hydration script placement performance
7
+ #
8
+ # ## Injection Priority Order
9
+ #
10
+ # For `<head></head>` section:
11
+ # 1. After the last `<link>` tag
12
+ # 2. After the last `<meta>` tag
13
+ # 3. After the first `<script>` tag (assuming early scripts are intentional)
14
+ # 4. Before the `</head>` tag
15
+ #
16
+ # If no `<head>` but there is `<body>`:
17
+ # - Before the `<body>` tag
18
+ #
19
+ # All injection points are validated for safety using SafeInjectionValidator
20
+ # to prevent injection inside unsafe contexts (scripts, styles, comments).
21
+ class EarliestInjectionDetector
22
+ def detect(template_html)
23
+ scanner = StringScanner.new(template_html)
24
+ validator = SafeInjectionValidator.new(template_html)
25
+
26
+ # Try head section injection points first
27
+ head_injection_point = detect_head_injection_point(scanner, validator, template_html)
28
+ return head_injection_point if head_injection_point
29
+
30
+ # Fallback to body tag injection
31
+ body_injection_point = detect_body_injection_point(scanner, validator, template_html)
32
+ return body_injection_point if body_injection_point
33
+
34
+ # No suitable injection point found
35
+ nil
36
+ end
37
+
38
+ private
39
+
40
+ def detect_head_injection_point(scanner, validator, template_html)
41
+ # Find head section bounds
42
+ head_start, head_end = find_head_section(template_html)
43
+ return nil unless head_start && head_end
44
+
45
+ # Try injection points in priority order within head section
46
+ injection_candidates = [
47
+ find_after_last_link(template_html, head_start, head_end),
48
+ find_after_last_meta(template_html, head_start, head_end),
49
+ find_after_first_script(template_html, head_start, head_end),
50
+ head_end # Before </head>
51
+ ].compact
52
+
53
+ # Return first safe injection point
54
+ injection_candidates.each do |position|
55
+ safe_position = find_safe_injection_position(validator, position)
56
+ return safe_position if safe_position
57
+ end
58
+
59
+ nil
60
+ end
61
+
62
+ def detect_body_injection_point(scanner, validator, template_html)
63
+ scanner.pos = 0
64
+
65
+ # Find opening <body> tag
66
+ if scanner.scan_until(/<body\b[^>]*>/i)
67
+ body_start = scanner.pos - scanner.matched.length
68
+ safe_position = find_safe_injection_position(validator, body_start)
69
+ return safe_position if safe_position
70
+ end
71
+
72
+ nil
73
+ end
74
+
75
+ def find_head_section(template_html)
76
+ scanner = StringScanner.new(template_html)
77
+
78
+ # Find opening <head> tag
79
+ return nil unless scanner.scan_until(/<head\b[^>]*>/i)
80
+ head_start = scanner.pos
81
+
82
+ # Find closing </head> tag
83
+ return nil unless scanner.scan_until(/<\/head>/i)
84
+ head_end = scanner.pos - scanner.matched.length
85
+
86
+ [head_start, head_end]
87
+ end
88
+
89
+ def find_after_last_link(template_html, head_start, head_end)
90
+ head_content = template_html[head_start...head_end]
91
+ scanner = StringScanner.new(head_content)
92
+ last_link_end = nil
93
+
94
+ while scanner.scan_until(/<link\b[^>]*\/?>/i)
95
+ last_link_end = scanner.pos
96
+ end
97
+
98
+ last_link_end ? head_start + last_link_end : nil
99
+ end
100
+
101
+ def find_after_last_meta(template_html, head_start, head_end)
102
+ head_content = template_html[head_start...head_end]
103
+ scanner = StringScanner.new(head_content)
104
+ last_meta_end = nil
105
+
106
+ while scanner.scan_until(/<meta\b[^>]*\/?>/i)
107
+ last_meta_end = scanner.pos
108
+ end
109
+
110
+ last_meta_end ? head_start + last_meta_end : nil
111
+ end
112
+
113
+ def find_after_first_script(template_html, head_start, head_end)
114
+ head_content = template_html[head_start...head_end]
115
+ scanner = StringScanner.new(head_content)
116
+
117
+ # Find first script opening tag
118
+ if scanner.scan_until(/<script\b[^>]*>/i)
119
+ script_start = scanner.pos - scanner.matched.length
120
+
121
+ # Find corresponding closing tag
122
+ if scanner.scan_until(/<\/script>/i)
123
+ first_script_end = scanner.pos
124
+ return head_start + first_script_end
125
+ end
126
+ end
127
+
128
+ nil
129
+ end
130
+
131
+ def find_safe_injection_position(validator, preferred_position)
132
+ return nil unless preferred_position
133
+
134
+ # First check if the preferred position is safe
135
+ return preferred_position if validator.safe_injection_point?(preferred_position)
136
+
137
+ # Try to find a safe position before the preferred position
138
+ safe_before = validator.nearest_safe_point_before(preferred_position)
139
+ return safe_before if safe_before
140
+
141
+ # As a last resort, try after the preferred position
142
+ safe_after = validator.nearest_safe_point_after(preferred_position)
143
+ return safe_after if safe_after
144
+
145
+ # No safe position found
146
+ nil
147
+ end
148
+ end
149
+ end
@@ -1,4 +1,4 @@
1
- # frozen_string_literal: true
1
+ # lib/rhales/errors/hydration_collision_error.rb
2
2
 
3
3
  module Rhales
4
4
  class HydrationCollisionError < Error