rhales 0.3.0 → 0.5.3

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.
Files changed (116) hide show
  1. checksums.yaml +4 -4
  2. data/.github/renovate.json5 +52 -0
  3. data/.github/workflows/ci.yml +123 -0
  4. data/.github/workflows/claude-code-review.yml +69 -0
  5. data/.github/workflows/claude.yml +49 -0
  6. data/.github/workflows/code-smells.yml +146 -0
  7. data/.github/workflows/ruby-lint.yml +78 -0
  8. data/.github/workflows/yardoc.yml +126 -0
  9. data/.gitignore +55 -0
  10. data/.pr_agent.toml +63 -0
  11. data/.pre-commit-config.yaml +89 -0
  12. data/.prettierignore +8 -0
  13. data/.prettierrc +38 -0
  14. data/.reek.yml +98 -0
  15. data/.rubocop.yml +428 -0
  16. data/.serena/.gitignore +3 -0
  17. data/.yardopts +56 -0
  18. data/CHANGELOG.md +44 -0
  19. data/CLAUDE.md +1 -2
  20. data/Gemfile +29 -0
  21. data/Gemfile.lock +189 -0
  22. data/README.md +706 -589
  23. data/Rakefile +46 -0
  24. data/debug_context.rb +25 -0
  25. data/demo/rhales-roda-demo/.gitignore +7 -0
  26. data/demo/rhales-roda-demo/Gemfile +32 -0
  27. data/demo/rhales-roda-demo/Gemfile.lock +151 -0
  28. data/demo/rhales-roda-demo/MAIL.md +405 -0
  29. data/demo/rhales-roda-demo/README.md +376 -0
  30. data/demo/rhales-roda-demo/RODA-TEMPLATE-ENGINES.md +192 -0
  31. data/demo/rhales-roda-demo/Rakefile +49 -0
  32. data/demo/rhales-roda-demo/app.rb +325 -0
  33. data/demo/rhales-roda-demo/bin/rackup +26 -0
  34. data/demo/rhales-roda-demo/config.ru +13 -0
  35. data/demo/rhales-roda-demo/db/migrate/001_create_rodauth_tables.rb +266 -0
  36. data/demo/rhales-roda-demo/db/migrate/002_create_rodauth_password_tables.rb +79 -0
  37. data/demo/rhales-roda-demo/db/migrate/003_add_admin_account.rb +68 -0
  38. data/demo/rhales-roda-demo/templates/change_login.rue +31 -0
  39. data/demo/rhales-roda-demo/templates/change_password.rue +36 -0
  40. data/demo/rhales-roda-demo/templates/close_account.rue +31 -0
  41. data/demo/rhales-roda-demo/templates/create_account.rue +40 -0
  42. data/demo/rhales-roda-demo/templates/dashboard.rue +150 -0
  43. data/demo/rhales-roda-demo/templates/home.rue +78 -0
  44. data/demo/rhales-roda-demo/templates/layouts/main.rue +168 -0
  45. data/demo/rhales-roda-demo/templates/login.rue +65 -0
  46. data/demo/rhales-roda-demo/templates/logout.rue +25 -0
  47. data/demo/rhales-roda-demo/templates/reset_password.rue +26 -0
  48. data/demo/rhales-roda-demo/templates/verify_account.rue +27 -0
  49. data/demo/rhales-roda-demo/test_full_output.rb +27 -0
  50. data/demo/rhales-roda-demo/test_simple.rb +24 -0
  51. data/docs/.gitignore +9 -0
  52. data/docs/architecture/data-flow.md +499 -0
  53. data/examples/dashboard-with-charts.rue +271 -0
  54. data/examples/form-with-validation.rue +180 -0
  55. data/examples/simple-page.rue +61 -0
  56. data/examples/vue.rue +136 -0
  57. data/generate-json-schemas.ts +158 -0
  58. data/json_schemer_migration_summary.md +172 -0
  59. data/lib/rhales/adapters/base_auth.rb +2 -0
  60. data/lib/rhales/adapters/base_request.rb +2 -0
  61. data/lib/rhales/adapters/base_session.rb +2 -0
  62. data/lib/rhales/adapters.rb +7 -0
  63. data/lib/rhales/configuration.rb +161 -1
  64. data/lib/rhales/core/context.rb +354 -0
  65. data/lib/rhales/{rue_document.rb → core/rue_document.rb} +59 -43
  66. data/lib/rhales/{template_engine.rb → core/template_engine.rb} +80 -33
  67. data/lib/rhales/core/view.rb +529 -0
  68. data/lib/rhales/{view_composition.rb → core/view_composition.rb} +81 -9
  69. data/lib/rhales/core.rb +9 -0
  70. data/lib/rhales/errors/hydration_collision_error.rb +2 -0
  71. data/lib/rhales/errors.rb +2 -0
  72. data/lib/rhales/hydration/earliest_injection_detector.rb +153 -0
  73. data/lib/rhales/hydration/hydration_data_aggregator.rb +487 -0
  74. data/lib/rhales/hydration/hydration_endpoint.rb +215 -0
  75. data/lib/rhales/hydration/hydration_injector.rb +175 -0
  76. data/lib/rhales/{hydration_registry.rb → hydration/hydration_registry.rb} +2 -0
  77. data/lib/rhales/hydration/hydrator.rb +102 -0
  78. data/lib/rhales/hydration/link_based_injection_detector.rb +195 -0
  79. data/lib/rhales/hydration/mount_point_detector.rb +109 -0
  80. data/lib/rhales/hydration/safe_injection_validator.rb +103 -0
  81. data/lib/rhales/hydration.rb +13 -0
  82. data/lib/rhales/{refinements → integrations/refinements}/require_refinements.rb +7 -13
  83. data/lib/rhales/{tilt.rb → integrations/tilt.rb} +26 -18
  84. data/lib/rhales/integrations.rb +6 -0
  85. data/lib/rhales/middleware/json_responder.rb +191 -0
  86. data/lib/rhales/middleware/schema_validator.rb +300 -0
  87. data/lib/rhales/middleware.rb +6 -0
  88. data/lib/rhales/parsers/handlebars_parser.rb +2 -0
  89. data/lib/rhales/parsers/rue_format_parser.rb +55 -36
  90. data/lib/rhales/parsers.rb +9 -0
  91. data/lib/rhales/{csp.rb → security/csp.rb} +27 -3
  92. data/lib/rhales/utils/json_serializer.rb +114 -0
  93. data/lib/rhales/utils/logging_helpers.rb +75 -0
  94. data/lib/rhales/utils/schema_extractor.rb +132 -0
  95. data/lib/rhales/utils/schema_generator.rb +194 -0
  96. data/lib/rhales/utils.rb +40 -0
  97. data/lib/rhales/version.rb +5 -1
  98. data/lib/rhales.rb +47 -19
  99. data/lib/tasks/rhales_schema.rake +197 -0
  100. data/package.json +10 -0
  101. data/pnpm-lock.yaml +345 -0
  102. data/pnpm-workspace.yaml +2 -0
  103. data/proofs/error_handling.rb +79 -0
  104. data/proofs/expanded_object_inheritance.rb +82 -0
  105. data/proofs/partial_context_scoping_fix.rb +168 -0
  106. data/proofs/ui_context_partial_inheritance.rb +236 -0
  107. data/rhales.gemspec +14 -6
  108. data/schema_vs_data_comparison.md +254 -0
  109. data/test_direct_access.rb +36 -0
  110. metadata +142 -18
  111. data/CLAUDE.locale.txt +0 -7
  112. data/lib/rhales/context.rb +0 -240
  113. data/lib/rhales/hydration_data_aggregator.rb +0 -220
  114. data/lib/rhales/hydrator.rb +0 -141
  115. data/lib/rhales/parsers/handlebars-grammar-review.txt +0 -39
  116. data/lib/rhales/view.rb +0 -412
@@ -0,0 +1,215 @@
1
+ # lib/rhales/hydration/hydration_endpoint.rb
2
+ #
3
+ # frozen_string_literal: true
4
+
5
+ require 'digest'
6
+ require_relative '../utils/json_serializer'
7
+
8
+ module Rhales
9
+ # Handles API endpoint responses for link-based hydration strategies
10
+ #
11
+ # Provides JSON and ES module endpoints that serve hydration data
12
+ # separately from HTML templates, enabling better caching, parallel
13
+ # loading, and reduced HTML payload sizes.
14
+ #
15
+ # ## Supported Response Formats
16
+ #
17
+ # ### JSON Response (application/json)
18
+ # ```json
19
+ # {
20
+ # "myData": { "user": "john", "theme": "dark" },
21
+ # "config": { "apiUrl": "https://api.example.com" }
22
+ # }
23
+ # ```
24
+ #
25
+ # ### ES Module Response (text/javascript)
26
+ # ```javascript
27
+ # export default {
28
+ # "myData": { "user": "john", "theme": "dark" },
29
+ # "config": { "apiUrl": "https://api.example.com" }
30
+ # };
31
+ # ```
32
+ #
33
+ # ## Usage
34
+ #
35
+ # ```ruby
36
+ # endpoint = HydrationEndpoint.new(config, context)
37
+ #
38
+ # # JSON response
39
+ # json_response = endpoint.render_json('template_name')
40
+ #
41
+ # # ES Module response
42
+ # module_response = endpoint.render_module('template_name')
43
+ # ```
44
+ class HydrationEndpoint
45
+ def initialize(config, context = nil)
46
+ @config = config
47
+ @context = context
48
+ end
49
+
50
+ # Render JSON response for API endpoints
51
+ def render_json(template_name, additional_context = {})
52
+ merged_data = process_template_data(template_name, additional_context)
53
+
54
+ {
55
+ content: JSONSerializer.dump(merged_data),
56
+ content_type: 'application/json',
57
+ headers: json_headers(merged_data)
58
+ }
59
+ rescue JSON::NestingError, JSON::GeneratorError, ArgumentError, Encoding::UndefinedConversionError => e
60
+ # Handle JSON serialization errors and encoding issues
61
+ error_data = {
62
+ error: {
63
+ message: "Failed to serialize data to JSON: #{e.message}",
64
+ template: template_name,
65
+ timestamp: Time.now.iso8601
66
+ }
67
+ }
68
+
69
+ {
70
+ content: JSONSerializer.dump(error_data),
71
+ content_type: 'application/json',
72
+ headers: json_headers(error_data)
73
+ }
74
+ rescue StandardError => e
75
+ # Handle any other unexpected errors during JSON generation
76
+ error_data = {
77
+ error: {
78
+ message: "Unexpected error during JSON generation: #{e.message}",
79
+ template: template_name,
80
+ timestamp: Time.now.iso8601
81
+ }
82
+ }
83
+
84
+ {
85
+ content: JSONSerializer.dump(error_data),
86
+ content_type: 'application/json',
87
+ headers: json_headers(error_data)
88
+ }
89
+ end
90
+
91
+ # Render ES module response for modulepreload strategy
92
+ def render_module(template_name, additional_context = {})
93
+ merged_data = process_template_data(template_name, additional_context)
94
+
95
+ {
96
+ content: "export default #{JSONSerializer.dump(merged_data)};",
97
+ content_type: 'text/javascript',
98
+ headers: module_headers(merged_data)
99
+ }
100
+ end
101
+
102
+ # Render JSONP response with callback
103
+ def render_jsonp(template_name, callback_name, additional_context = {})
104
+ merged_data = process_template_data(template_name, additional_context)
105
+
106
+ {
107
+ content: "#{callback_name}(#{JSONSerializer.dump(merged_data)});",
108
+ content_type: 'application/javascript',
109
+ headers: jsonp_headers(merged_data),
110
+ }
111
+ end
112
+
113
+ # Check if template data has changed (for ETags)
114
+ def data_changed?(template_name, etag, additional_context = {})
115
+ current_etag = calculate_etag(template_name, additional_context)
116
+ current_etag != etag
117
+ end
118
+
119
+ # Get ETag for current template data
120
+ def calculate_etag(template_name, additional_context = {})
121
+ merged_data = process_template_data(template_name, additional_context)
122
+ # Simple ETag based on data hash
123
+ Digest::MD5.hexdigest(JSONSerializer.dump(merged_data))
124
+ end
125
+
126
+ private
127
+
128
+ def process_template_data(template_name, additional_context)
129
+ # Create a minimal context for data processing
130
+ template_context = create_template_context(additional_context)
131
+
132
+ # Process template to extract hydration data
133
+ view = View.new(@context.req, client: {})
134
+ aggregator = HydrationDataAggregator.new(template_context)
135
+
136
+ # Build composition to get template dependencies
137
+ composition = view.send(:build_view_composition, template_name)
138
+ composition.resolve!
139
+
140
+ # Aggregate data from all templates in the composition
141
+ aggregator.aggregate(composition)
142
+ rescue StandardError => ex
143
+ # Return error structure that can be serialized
144
+ {
145
+ error: {
146
+ message: "Failed to process template data: #{ex.message}",
147
+ template: template_name,
148
+ timestamp: Time.now.iso8601,
149
+ }
150
+ }
151
+ end
152
+
153
+ def create_template_context(additional_context)
154
+ if @context
155
+ # Merge additional context into existing context by reconstructing with merged props
156
+ merged_props = @context.client.merge(additional_context)
157
+ @context.class.for_view(@context.req, @context.locale, **merged_props)
158
+ else
159
+ # Create minimal context with just the additional data
160
+ Context.minimal(client: additional_context)
161
+ end
162
+ end
163
+
164
+ def json_headers(data)
165
+ headers = {
166
+ 'Content-Type' => 'application/json',
167
+ 'Cache-Control' => cache_control_header,
168
+ 'Vary' => 'Accept, Accept-Encoding'
169
+ }
170
+
171
+ # Add CORS headers if enabled
172
+ if cors_enabled?
173
+ headers.merge!(cors_headers)
174
+ end
175
+
176
+ # Add ETag for caching
177
+ headers['ETag'] = %("#{Digest::MD5.hexdigest(JSONSerializer.dump(data))}")
178
+
179
+ headers
180
+ end
181
+
182
+ def module_headers(data)
183
+ headers = json_headers(data)
184
+ headers['Content-Type'] = 'text/javascript'
185
+ headers
186
+ end
187
+
188
+ def jsonp_headers(data)
189
+ headers = json_headers(data)
190
+ headers['Content-Type'] = 'application/javascript'
191
+ headers
192
+ end
193
+
194
+ def cache_control_header
195
+ if @config.hydration.api_cache_enabled
196
+ "public, max-age=#{@config.hydration.api_cache_ttl || 300}"
197
+ else
198
+ "no-cache, no-store, must-revalidate"
199
+ end
200
+ end
201
+
202
+ def cors_enabled?
203
+ @config.hydration.cors_enabled || false
204
+ end
205
+
206
+ def cors_headers
207
+ {
208
+ 'Access-Control-Allow-Origin' => @config.hydration.cors_origin || '*',
209
+ 'Access-Control-Allow-Methods' => 'GET, HEAD, OPTIONS',
210
+ 'Access-Control-Allow-Headers' => 'Accept, Accept-Encoding, Authorization',
211
+ 'Access-Control-Max-Age' => '86400'
212
+ }
213
+ end
214
+ end
215
+ end
@@ -0,0 +1,175 @@
1
+ # lib/rhales/hydration/hydration_injector.rb
2
+ #
3
+ # frozen_string_literal: true
4
+
5
+ require_relative 'earliest_injection_detector'
6
+ require_relative 'link_based_injection_detector'
7
+
8
+ module Rhales
9
+ # Handles intelligent hydration script injection with multiple strategies
10
+ # for optimal performance and resource loading.
11
+ #
12
+ # ## Supported Injection Strategies
13
+ #
14
+ # ### Traditional Strategies
15
+ # - **`:late`** (default) - Inject before </body> tag (safest, backwards compatible)
16
+ # - **`:early`** - Inject before detected mount points (#app, #root, etc.)
17
+ # - **`:earliest`** - Inject in HTML head section for maximum performance
18
+ #
19
+ # ### Link-Based Strategies (API endpoints)
20
+ # - **`:link`** - Basic link reference to API endpoint
21
+ # - **`:prefetch`** - Browser prefetch for future page loads
22
+ # - **`:preload`** - High priority preload for current page
23
+ # - **`:modulepreload`** - ES module preloading
24
+ # - **`:lazy`** - Intersection observer-based lazy loading
25
+ #
26
+ # ## Strategy Selection Logic
27
+ #
28
+ # 1. **Template Disable Check**: Respect `disable_early_for_templates` configuration
29
+ # 2. **Strategy Routing**: Execute strategy-specific injection logic
30
+ # 3. **Fallback Chain**: :earliest → :early → :late (when enabled)
31
+ # 4. **Safety Validation**: All injection points validated for HTML safety
32
+ #
33
+ # Link-based strategies generate API calls instead of inline data,
34
+ # enabling better caching, parallel loading, and reduced HTML payload.
35
+ #
36
+ class HydrationInjector
37
+ LINK_BASED_STRATEGIES = [:link, :prefetch, :preload, :modulepreload, :lazy].freeze
38
+
39
+ def initialize(hydration_config, template_name = nil)
40
+ @hydration_config = hydration_config
41
+ @template_name = template_name
42
+ @strategy = hydration_config.injection_strategy
43
+ @fallback_to_late = hydration_config.fallback_to_late
44
+ @fallback_when_unsafe = hydration_config.fallback_when_unsafe
45
+ @disabled_templates = hydration_config.disable_early_for_templates
46
+ @earliest_detector = EarliestInjectionDetector.new
47
+ @link_detector = LinkBasedInjectionDetector.new(hydration_config)
48
+ end
49
+
50
+ def inject(template_html, hydration_html, mount_point_data = nil)
51
+ return template_html if hydration_html.nil? || hydration_html.strip.empty?
52
+
53
+ # Check if early/earliest injection is disabled for this template
54
+ if [:early, :earliest].include?(@strategy) && template_disabled_for_early?
55
+ return inject_late(template_html, hydration_html)
56
+ end
57
+
58
+ case @strategy
59
+ when :early
60
+ inject_early(template_html, hydration_html, mount_point_data)
61
+ when :earliest
62
+ inject_earliest(template_html, hydration_html)
63
+ when :late
64
+ inject_late(template_html, hydration_html)
65
+ when *LINK_BASED_STRATEGIES
66
+ inject_link_based(template_html, hydration_html)
67
+ else
68
+ inject_late(template_html, hydration_html)
69
+ end
70
+ end
71
+
72
+ # Special method for link-based strategies that need merged data context
73
+ def inject_link_based_strategy(template_html, merged_data, nonce = nil)
74
+ return template_html if merged_data.nil? || merged_data.empty?
75
+
76
+ # Check if early injection is disabled for this template
77
+ if template_disabled_for_early?
78
+ # For link strategies, we still generate the links but fall back to late positioning
79
+ link_html = generate_all_link_strategies(merged_data, nonce)
80
+ return inject_late(template_html, link_html)
81
+ end
82
+
83
+ link_html = generate_all_link_strategies(merged_data, nonce)
84
+
85
+ case @strategy
86
+ when :earliest
87
+ inject_earliest(template_html, link_html)
88
+ when *LINK_BASED_STRATEGIES
89
+ inject_link_based(template_html, link_html)
90
+ else
91
+ inject_late(template_html, link_html)
92
+ end
93
+ end
94
+
95
+ private
96
+
97
+ def inject_early(template_html, hydration_html, mount_point_data)
98
+ # Fallback to late injection if no mount point found
99
+ if mount_point_data.nil?
100
+ return @fallback_to_late ? inject_late(template_html, hydration_html) : template_html
101
+ end
102
+
103
+ # Check if the mount point data indicates an unsafe injection
104
+ # (This would be nil if SafeInjectionValidator found no safe position)
105
+ if mount_point_data[:position].nil?
106
+ return @fallback_when_unsafe ? inject_late(template_html, hydration_html) : template_html
107
+ end
108
+
109
+ # Insert hydration script before the mount element
110
+ position = mount_point_data[:position]
111
+
112
+ before = template_html[0...position]
113
+ after = template_html[position..]
114
+
115
+ "#{before}#{hydration_html}\n#{after}"
116
+ end
117
+
118
+ def template_disabled_for_early?
119
+ @template_name && @disabled_templates.include?(@template_name)
120
+ end
121
+
122
+ def inject_earliest(template_html, hydration_html)
123
+ begin
124
+ injection_position = @earliest_detector.detect(template_html)
125
+ rescue => e
126
+ # Fall back to late injection on detector error
127
+ return @fallback_to_late ? inject_late(template_html, hydration_html) : template_html
128
+ end
129
+
130
+ if injection_position
131
+ before = template_html[0...injection_position]
132
+ after = template_html[injection_position..]
133
+ "#{before}#{hydration_html}\n#{after}"
134
+ else
135
+ # Fallback to late injection if earliest fails
136
+ @fallback_to_late ? inject_late(template_html, hydration_html) : template_html
137
+ end
138
+ end
139
+
140
+ def inject_link_based(template_html, hydration_html)
141
+ # For link-based strategies, try earliest injection first, then fallback
142
+ injection_position = @earliest_detector.detect(template_html)
143
+
144
+ if injection_position
145
+ before = template_html[0...injection_position]
146
+ after = template_html[injection_position..]
147
+ "#{before}#{hydration_html}\n#{after}"
148
+ else
149
+ # Fallback to late injection
150
+ @fallback_to_late ? inject_late(template_html, hydration_html) : template_html
151
+ end
152
+ end
153
+
154
+ def generate_all_link_strategies(merged_data, nonce)
155
+ link_parts = []
156
+
157
+ merged_data.each do |window_attr, _data|
158
+ link_html = @link_detector.generate_for_strategy(@strategy, @template_name, window_attr, nonce)
159
+ link_parts << link_html
160
+ end
161
+
162
+ link_parts.join("\n")
163
+ end
164
+
165
+ def inject_late(template_html, hydration_html)
166
+ # Try to inject before closing </body> tag
167
+ if template_html.include?('</body>')
168
+ template_html.sub('</body>', "#{hydration_html}\n</body>")
169
+ else
170
+ # If no </body> tag, append to end
171
+ "#{template_html}\n#{hydration_html}"
172
+ end
173
+ end
174
+ end
175
+ end
@@ -1,4 +1,6 @@
1
1
  # lib/rhales/hydration_registry.rb
2
+ #
3
+ # frozen_string_literal: true
2
4
 
3
5
  module Rhales
4
6
  # Registry to track window attributes used in hydration blocks
@@ -0,0 +1,102 @@
1
+ # lib/rhales/hydrator.rb
2
+ #
3
+ # frozen_string_literal: true
4
+
5
+ require 'securerandom'
6
+ require_relative '../utils/json_serializer'
7
+
8
+ module Rhales
9
+ # Data Hydrator for RSFC client-side data injection
10
+ #
11
+ # ## RSFC Security Model: Server-to-Client Security Boundary
12
+ #
13
+ # The Hydrator enforces a critical security boundary between server and client:
14
+ #
15
+ # ### Server Side (Template Rendering)
16
+ # - Templates have FULL server context access (like ERB/HAML)
17
+ # - Can access user objects, database connections, internal APIs
18
+ # - Can access secrets, configuration, authentication state
19
+ # - Can process sensitive business logic
20
+ #
21
+ # ### Client Side (Data Hydration)
22
+ # - Only data declared in <schema> sections reaches the browser
23
+ # - Creates explicit allowlist like designing a REST API
24
+ # - For <schema>: Direct props serialization (no interpolation)
25
+ # - JSON serialization validates data structure
26
+ #
27
+ # ### Process Flow (Schema-based, preferred)
28
+ # 1. Backend provides fully-resolved props to render call
29
+ # 2. Props are directly serialized as JSON
30
+ # 3. Client receives only the declared props
31
+ #
32
+ # ### Example (Schema-based)
33
+ # ```rue
34
+ # <schema lang="js-zod" window="appData">
35
+ # const schema = z.object({
36
+ # user_name: z.string(),
37
+ # theme: z.string()
38
+ # });
39
+ # </schema>
40
+ # ```
41
+ # Backend: render('template', user_name: user.name, theme: user.theme_preference)
42
+ #
43
+ #
44
+ # Server template can access {{user.admin?}} and {{internal_config}},
45
+ # but client only gets the declared user_name and theme values.
46
+ #
47
+ # This creates an API-like boundary where data is serialized once and
48
+ # parsed once, enforcing the same security model as REST endpoints.
49
+ #
50
+ # Note: With the new two-pass architecture, the Hydrator's role is
51
+ # greatly simplified. All data merging happens server-side in the
52
+ # HydrationDataAggregator, so this class only handles JSON generation
53
+ # for individual templates (used during the aggregation phase).
54
+ class Hydrator
55
+ class HydrationError < StandardError; end
56
+ class JSONSerializationError < HydrationError; end
57
+
58
+ attr_reader :parser, :context, :window_attribute
59
+
60
+ def initialize(parser, context)
61
+ @parser = parser
62
+ @context = context
63
+ @window_attribute = parser.window_attribute || 'data'
64
+ end
65
+
66
+ # Process <schema> section and return JSON string
67
+ def process_data_section
68
+ # Check for schema section
69
+ if @parser.schema_lang
70
+ # Schema section: Direct props serialization
71
+ JSONSerializer.dump(@context.client)
72
+ else
73
+ # No hydration section
74
+ '{}'
75
+ end
76
+ rescue JSON::ParserError => ex
77
+ raise JSONSerializationError, "Invalid JSON in schema section: #{ex.message}"
78
+ end
79
+
80
+ # Get processed data as Ruby hash (for internal use)
81
+ def processed_data_hash
82
+ json_string = process_data_section
83
+ JSONSerializer.parse(json_string)
84
+ rescue JSON::ParserError => ex
85
+ raise JSONSerializationError, "Cannot parse processed data as JSON: #{ex.message}"
86
+ end
87
+
88
+ private
89
+
90
+ class << self
91
+ # Generate only JSON data (for testing or API endpoints)
92
+ def generate_json(parser, context)
93
+ new(parser, context).process_data_section
94
+ end
95
+
96
+ # Generate data hash (for internal processing)
97
+ def generate_data_hash(parser, context)
98
+ new(parser, context).processed_data_hash
99
+ end
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,195 @@
1
+ # lib/rhales/hydration/link_based_injection_detector.rb
2
+ #
3
+ # frozen_string_literal: true
4
+
5
+ require 'strscan'
6
+ require_relative 'safe_injection_validator'
7
+
8
+ module Rhales
9
+ # Generates link-based hydration strategies that use browser resource hints
10
+ # and API endpoints instead of inline scripts
11
+ #
12
+ # ## Supported Strategies
13
+ #
14
+ # - **`:link`** - Basic link reference: `<link href="/api/hydration/template">`
15
+ # - **`:prefetch`** - Background prefetch: `<link rel="prefetch" href="..." as="fetch">`
16
+ # - **`:preload`** - High priority preload: `<link rel="preload" href="..." as="fetch">`
17
+ # - **`:modulepreload`** - ES module preload: `<link rel="modulepreload" href="..."`
18
+ # - **`:lazy`** - Lazy loading with intersection observer
19
+ #
20
+ # All strategies generate both link tags and accompanying JavaScript
21
+ # for data fetching and assignment to window objects.
22
+ class LinkBasedInjectionDetector
23
+ def initialize(hydration_config)
24
+ @hydration_config = hydration_config
25
+ @api_endpoint_path = hydration_config.api_endpoint_path || '/api/hydration'
26
+ @crossorigin_enabled = hydration_config.link_crossorigin.nil? ? true : hydration_config.link_crossorigin
27
+ end
28
+
29
+ def generate_for_strategy(strategy, template_name, window_attr, nonce = nil)
30
+ case strategy
31
+ when :link
32
+ generate_basic_link(template_name, window_attr, nonce)
33
+ when :prefetch
34
+ generate_prefetch_link(template_name, window_attr, nonce)
35
+ when :preload
36
+ generate_preload_link(template_name, window_attr, nonce)
37
+ when :modulepreload
38
+ generate_modulepreload_link(template_name, window_attr, nonce)
39
+ when :lazy
40
+ generate_lazy_loading(template_name, window_attr, nonce)
41
+ else
42
+ raise ArgumentError, "Unsupported link strategy: #{strategy}"
43
+ end
44
+ end
45
+
46
+ private
47
+
48
+ def generate_basic_link(template_name, window_attr, nonce)
49
+ endpoint_url = "#{@api_endpoint_path}/#{template_name}"
50
+
51
+ link_tag = %(<link href="#{endpoint_url}" type="application/json">)
52
+
53
+ script_tag = <<~HTML.strip
54
+ <script#{nonce_attribute(nonce)} data-hydration-target="#{window_attr}">
55
+ // Load hydration data for #{window_attr}
56
+ window.__rhales__ = window.__rhales__ || {};
57
+ if (!window.__rhales__.loadData) {
58
+ window.__rhales__.loadData = function(target, url) {
59
+ fetch(url)
60
+ .then(r => r.json())
61
+ .then(data => window[target] = data);
62
+ };
63
+ }
64
+ window.__rhales__.loadData('#{window_attr}', '#{endpoint_url}');
65
+ </script>
66
+ HTML
67
+
68
+ "#{link_tag}\n#{script_tag}"
69
+ end
70
+
71
+ def generate_prefetch_link(template_name, window_attr, nonce)
72
+ endpoint_url = "#{@api_endpoint_path}/#{template_name}"
73
+ crossorigin_attr = @crossorigin_enabled ? ' crossorigin' : ''
74
+
75
+ link_tag = %(<link rel="prefetch" href="#{endpoint_url}" as="fetch"#{crossorigin_attr}>)
76
+
77
+ script_tag = <<~HTML.strip
78
+ <script#{nonce_attribute(nonce)} data-hydration-target="#{window_attr}">
79
+ // Prefetch hydration data for #{window_attr}
80
+ window.__rhales__ = window.__rhales__ || {};
81
+ if (!window.__rhales__.loadPrefetched) {
82
+ window.__rhales__.loadPrefetched = function(target, url) {
83
+ fetch(url)
84
+ .then(r => r.json())
85
+ .then(data => window[target] = data);
86
+ };
87
+ }
88
+ window.__rhales__.loadPrefetched('#{window_attr}', '#{endpoint_url}');
89
+ </script>
90
+ HTML
91
+
92
+ "#{link_tag}\n#{script_tag}"
93
+ end
94
+
95
+ def generate_preload_link(template_name, window_attr, nonce)
96
+ endpoint_url = "#{@api_endpoint_path}/#{template_name}"
97
+ crossorigin_attr = @crossorigin_enabled ? ' crossorigin' : ''
98
+
99
+ link_tag = %(<link rel="preload" href="#{endpoint_url}" as="fetch"#{crossorigin_attr}>)
100
+
101
+ script_tag = <<~HTML.strip
102
+ <script#{nonce_attribute(nonce)} data-hydration-target="#{window_attr}">
103
+ // Preload strategy - high priority fetch
104
+ fetch('#{endpoint_url}')
105
+ .then(r => r.json())
106
+ .then(data => {
107
+ window['#{window_attr}'] = data;
108
+ // Dispatch ready event
109
+ window.dispatchEvent(new CustomEvent('rhales:hydrated', {
110
+ detail: { target: '#{window_attr}', data: data }
111
+ }));
112
+ })
113
+ .catch(err => console.error('Rhales hydration error:', err));
114
+ </script>
115
+ HTML
116
+
117
+ "#{link_tag}\n#{script_tag}"
118
+ end
119
+
120
+ def generate_modulepreload_link(template_name, window_attr, nonce)
121
+ endpoint_url = "#{@api_endpoint_path}/#{template_name}.js"
122
+
123
+ link_tag = %(<link rel="modulepreload" href="#{endpoint_url}">)
124
+
125
+ script_tag = <<~HTML.strip
126
+ <script type="module"#{nonce_attribute(nonce)} data-hydration-target="#{window_attr}">
127
+ // Module preload strategy
128
+ import data from '#{endpoint_url}';
129
+ window['#{window_attr}'] = data;
130
+
131
+ // Dispatch ready event
132
+ window.dispatchEvent(new CustomEvent('rhales:hydrated', {
133
+ detail: { target: '#{window_attr}', data: data }
134
+ }));
135
+ </script>
136
+ HTML
137
+
138
+ "#{link_tag}\n#{script_tag}"
139
+ end
140
+
141
+ def generate_lazy_loading(template_name, window_attr, nonce)
142
+ endpoint_url = "#{@api_endpoint_path}/#{template_name}"
143
+ mount_selector = @hydration_config.lazy_mount_selector || '#app'
144
+
145
+ # No link tag for lazy loading - purely script-driven
146
+ script_tag = <<~HTML.strip
147
+ <script#{nonce_attribute(nonce)} data-hydration-target="#{window_attr}" data-lazy-src="#{endpoint_url}">
148
+ // Lazy loading strategy with intersection observer
149
+ window.__rhales__ = window.__rhales__ || {};
150
+ window.__rhales__.initLazyLoading = function() {
151
+ const mountElement = document.querySelector('#{mount_selector}');
152
+ if (!mountElement) {
153
+ console.warn('Rhales: Mount element "#{mount_selector}" not found for lazy loading');
154
+ return;
155
+ }
156
+
157
+ const observer = new IntersectionObserver((entries) => {
158
+ entries.forEach(entry => {
159
+ if (entry.isIntersecting) {
160
+ fetch('#{endpoint_url}')
161
+ .then(r => r.json())
162
+ .then(data => {
163
+ window['#{window_attr}'] = data;
164
+ window.dispatchEvent(new CustomEvent('rhales:hydrated', {
165
+ detail: { target: '#{window_attr}', data: data }
166
+ }));
167
+ })
168
+ .catch(err => console.error('Rhales lazy hydration error:', err));
169
+
170
+ observer.unobserve(entry.target);
171
+ }
172
+ });
173
+ });
174
+
175
+ observer.observe(mountElement);
176
+ };
177
+
178
+ // Initialize when DOM is ready
179
+ if (document.readyState === 'loading') {
180
+ document.addEventListener('DOMContentLoaded', window.__rhales__.initLazyLoading);
181
+ } else {
182
+ window.__rhales__.initLazyLoading();
183
+ }
184
+ </script>
185
+ HTML
186
+
187
+ script_tag
188
+ end
189
+
190
+ def nonce_attribute(nonce)
191
+ require 'erb'
192
+ nonce ? " nonce=\"#{ERB::Util.html_escape(nonce)}\"" : ''
193
+ end
194
+ end
195
+ end