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 +4 -4
- data/CLAUDE.md +0 -1
- data/README.md +302 -3
- data/lib/rhales/configuration.rb +114 -1
- data/lib/rhales/context.rb +0 -1
- data/lib/rhales/earliest_injection_detector.rb +149 -0
- data/lib/rhales/errors/hydration_collision_error.rb +1 -1
- data/lib/rhales/hydration_data_aggregator.rb +23 -22
- data/lib/rhales/hydration_endpoint.rb +211 -0
- data/lib/rhales/hydration_injector.rb +171 -0
- data/lib/rhales/hydrator.rb +3 -3
- data/lib/rhales/link_based_injection_detector.rb +191 -0
- data/lib/rhales/mount_point_detector.rb +105 -0
- data/lib/rhales/parsers/rue_format_parser.rb +50 -33
- data/lib/rhales/refinements/require_refinements.rb +4 -12
- data/lib/rhales/rue_document.rb +3 -5
- data/lib/rhales/safe_injection_validator.rb +99 -0
- data/lib/rhales/template_engine.rb +47 -7
- data/lib/rhales/tilt.rb +6 -5
- data/lib/rhales/version.rb +3 -1
- data/lib/rhales/view.rb +165 -25
- data/lib/rhales/view_composition.rb +5 -3
- data/lib/rhales.rb +12 -1
- metadata +9 -3
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 7d4de8c7caddc2eb408dbb631654a055785456d23a13f28498d426a5f558464d
|
4
|
+
data.tar.gz: efe0545542afb04b016be65203ba8aede3e455b6075c6fe6d808764da4de6f93
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
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
|
-
- **
|
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
|
-
##
|
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
|
-
|
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:
|
data/lib/rhales/configuration.rb
CHANGED
@@ -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
|
|
data/lib/rhales/context.rb
CHANGED
@@ -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
|