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,109 @@
1
+ # lib/rhales/hydration/mount_point_detector.rb
2
+ #
3
+ # frozen_string_literal: true
4
+
5
+ require 'strscan'
6
+ require_relative 'safe_injection_validator'
7
+
8
+ module Rhales
9
+ # Detects frontend application mount points in HTML templates
10
+ # Used to determine optimal hydration script injection points
11
+ #
12
+ # ## Mount Point Detection Order
13
+ #
14
+ # 1. **Selector Priority**: All selectors (default + custom) are checked in parallel
15
+ # 2. **Position Priority**: Returns the earliest mount point by position in HTML (not selector order)
16
+ # 3. **Safety Validation**: Validates injection points are outside unsafe contexts (scripts/styles/comments)
17
+ # 4. **Safe Position Search**: If original position unsafe, searches for nearest safe alternative:
18
+ # - First tries positions before the mount point (maintains earlier injection)
19
+ # - Then tries positions after the mount point (fallback)
20
+ # - Returns nil if no safe position found
21
+ #
22
+ # Default selectors are checked: ['#app', '#root', '[data-rsfc-mount]', '[data-mount]']
23
+ # Custom selectors can be added via configuration and are combined with defaults.
24
+ class MountPointDetector
25
+ DEFAULT_SELECTORS = ['#app', '#root', '[data-rsfc-mount]', '[data-mount]'].freeze
26
+
27
+ def detect(template_html, custom_selectors = [])
28
+ selectors = (DEFAULT_SELECTORS + Array(custom_selectors)).uniq
29
+ scanner = StringScanner.new(template_html)
30
+ validator = SafeInjectionValidator.new(template_html)
31
+ mount_points = []
32
+
33
+ selectors.each do |selector|
34
+ scanner.pos = 0
35
+ pattern = build_pattern(selector)
36
+
37
+ while scanner.scan_until(pattern)
38
+ # Calculate position where the full tag starts
39
+ tag_start_pos = find_tag_start(scanner, template_html)
40
+
41
+ # Only include mount points that are safe for injection
42
+ safe_position = find_safe_injection_position(validator, tag_start_pos)
43
+
44
+ if safe_position
45
+ mount_points << {
46
+ selector: selector,
47
+ position: safe_position,
48
+ original_position: tag_start_pos,
49
+ matched: scanner.matched
50
+ }
51
+ end
52
+ end
53
+ end
54
+
55
+ # Return earliest mount point by position
56
+ mount_points.min_by { |mp| mp[:position] }
57
+ end
58
+
59
+ private
60
+
61
+ def build_pattern(selector)
62
+ case selector
63
+ when /^#(.+)$/
64
+ # ID selector: <tag id="value">
65
+ id_name = Regexp.escape($1)
66
+ /id\s*=\s*["']#{id_name}["']/i
67
+ when /^\.(.+)$/
68
+ # Class selector: <tag class="... value ...">
69
+ class_name = Regexp.escape($1)
70
+ /class\s*=\s*["'][^"']*\b#{class_name}\b[^"']*["']/i
71
+ when /^\[([^\]]+)\]$/
72
+ # Attribute selector: <tag data-attr> or <tag data-attr="value">
73
+ attr_name = Regexp.escape($1)
74
+ /#{attr_name}(?:\s*=\s*["'][^"']*["'])?/i
75
+ else
76
+ # Invalid selector, match nothing
77
+ /(?!.*)/
78
+ end
79
+ end
80
+
81
+ def find_tag_start(scanner, template_html)
82
+ # Work backwards from current position to find the opening <
83
+ pos = scanner.pos - scanner.matched.length
84
+
85
+ while pos > 0 && template_html[pos - 1] != '<'
86
+ pos -= 1
87
+ end
88
+
89
+ # Return position of the < character
90
+ pos > 0 ? pos - 1 : 0
91
+ end
92
+
93
+ def find_safe_injection_position(validator, preferred_position)
94
+ # First check if the preferred position is safe
95
+ return preferred_position if validator.safe_injection_point?(preferred_position)
96
+
97
+ # Try to find a safe position before the preferred position
98
+ safe_before = validator.nearest_safe_point_before(preferred_position)
99
+ return safe_before if safe_before
100
+
101
+ # As a last resort, try after the preferred position
102
+ safe_after = validator.nearest_safe_point_after(preferred_position)
103
+ return safe_after if safe_after
104
+
105
+ # No safe position found
106
+ nil
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,103 @@
1
+ # lib/rhales/hydration/safe_injection_validator.rb
2
+ #
3
+ # frozen_string_literal: true
4
+
5
+ require 'strscan'
6
+
7
+ module Rhales
8
+ # Validates whether a hydration injection point is safe within HTML context
9
+ # Prevents injection inside script tags, style tags, comments, or other unsafe locations
10
+ class SafeInjectionValidator
11
+ UNSAFE_CONTEXTS = [
12
+ { start: /<script\b[^>]*>/i, end: /<\/script>/i },
13
+ { start: /<style\b[^>]*>/i, end: /<\/style>/i },
14
+ { start: /<!--/, end: /-->/ },
15
+ { start: /<!\[CDATA\[/, end: /\]\]>/ }
16
+ ].freeze
17
+
18
+ def initialize(html)
19
+ @html = html
20
+ @unsafe_ranges = calculate_unsafe_ranges
21
+ end
22
+
23
+ # Check if the given position is safe for injection
24
+ def safe_injection_point?(position)
25
+ return false if position < 0 || position > @html.length
26
+
27
+ # Check if position falls within any unsafe range
28
+ @unsafe_ranges.none? { |range| range.cover?(position) }
29
+ end
30
+
31
+ # Find the nearest safe injection point before the given position
32
+ def nearest_safe_point_before(position)
33
+ # Work backwards from position to find a safe point
34
+ (0...position).reverse_each do |pos|
35
+ return pos if safe_injection_point?(pos) && at_tag_boundary?(pos)
36
+ end
37
+
38
+ # If no safe point found before, return nil
39
+ nil
40
+ end
41
+
42
+ # Find the nearest safe injection point after the given position
43
+ def nearest_safe_point_after(position)
44
+ # Work forwards from position to find a safe point
45
+ (position...@html.length).each do |pos|
46
+ return pos if safe_injection_point?(pos) && at_tag_boundary?(pos)
47
+ end
48
+
49
+ # If no safe point found after, return nil
50
+ nil
51
+ end
52
+
53
+ private
54
+
55
+ def calculate_unsafe_ranges
56
+ ranges = []
57
+ scanner = StringScanner.new(@html)
58
+
59
+ UNSAFE_CONTEXTS.each do |context|
60
+ scanner.pos = 0
61
+
62
+ while scanner.scan_until(context[:start])
63
+ start_pos = scanner.pos - scanner.matched.length
64
+
65
+ # Find the corresponding end tag
66
+ if scanner.scan_until(context[:end])
67
+ end_pos = scanner.pos
68
+ ranges << (start_pos...end_pos)
69
+ else
70
+ # If no closing tag found, consider rest of document unsafe
71
+ ranges << (start_pos...@html.length)
72
+ break
73
+ end
74
+ end
75
+ end
76
+
77
+ ranges
78
+ end
79
+
80
+ # Check if position is at a tag boundary (before < or after >)
81
+ def at_tag_boundary?(position)
82
+ return true if position == 0 || position == @html.length
83
+
84
+ char_before = position > 0 ? @html[position - 1] : nil
85
+ char_at = @html[position]
86
+
87
+ # Safe positions:
88
+ # - Right after a closing >
89
+ # - Right before an opening <
90
+ # - At whitespace boundaries between tags
91
+ char_before == '>' || char_at == '<' || (char_at&.match?(/\s/) && next_non_whitespace_is_tag?(position))
92
+ end
93
+
94
+ def next_non_whitespace_is_tag?(position)
95
+ pos = position
96
+ while pos < @html.length && @html[pos].match?(/\s/)
97
+ pos += 1
98
+ end
99
+
100
+ pos < @html.length && @html[pos] == '<'
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,13 @@
1
+ # lib/rhales/hydration.rb
2
+ #
3
+ # frozen_string_literal: true
4
+
5
+ require_relative 'hydration/hydrator'
6
+ require_relative 'hydration/hydration_data_aggregator'
7
+ require_relative 'hydration/mount_point_detector'
8
+ require_relative 'hydration/safe_injection_validator'
9
+ require_relative 'hydration/earliest_injection_detector'
10
+ require_relative 'hydration/link_based_injection_detector'
11
+ require_relative 'hydration/hydration_injector'
12
+ require_relative 'hydration/hydration_endpoint'
13
+ require_relative 'hydration/hydration_registry'
@@ -1,6 +1,8 @@
1
1
  # lib/rhales/refinements/require_refinements.rb
2
+ #
3
+ # frozen_string_literal: true
2
4
 
3
- require_relative '../rue_document'
5
+ require_relative '../../core/rue_document'
4
6
 
5
7
  module Rhales
6
8
  module Ruequire
@@ -111,11 +113,7 @@ module Rhales
111
113
 
112
114
  parser
113
115
  rescue StandardError => ex
114
- if defined?(OT) && OT.respond_to?(:le)
115
- OT.le "[RSFC] Failed to process .rue file #{path}: #{ex.message}"
116
- else
117
- puts "[RSFC] Failed to process .rue file #{path}: #{ex.message}"
118
- end
116
+ puts "[RSFC] Failed to process .rue file #{path}: #{ex.message}"
119
117
  raise
120
118
  end
121
119
 
@@ -130,7 +128,7 @@ module Rhales
130
128
  return File.expand_path(path) if File.exist?(path)
131
129
 
132
130
  # Search in templates directory
133
- boot_root = defined?(OT) && OT.respond_to?(:boot_root) ? OT.boot_root : File.expand_path('../../..', __dir__)
131
+ boot_root = File.expand_path('../../..', __dir__)
134
132
  templates_path = File.join(boot_root, 'templates', path)
135
133
  return templates_path if File.exist?(templates_path)
136
134
 
@@ -197,9 +195,7 @@ module Rhales
197
195
  current_mtime = File.mtime(full_path)
198
196
 
199
197
  if current_mtime > last_mtime
200
- if defined?(OT) && OT.respond_to?(:ld)
201
- OT.ld "[RSFC] File changed, clearing cache: #{full_path}"
202
- end
198
+ puts "[RSFC] File changed, clearing cache: #{full_path}"
203
199
 
204
200
  # Thread-safe cache removal
205
201
  Rhales::Ruequire.instance_variable_get(:@cache_mutex).synchronize do
@@ -210,9 +206,7 @@ module Rhales
210
206
  end
211
207
  rescue StandardError => ex
212
208
  # File might have been deleted
213
- if defined?(OT) && OT.respond_to?(:ld)
214
- OT.ld "[RSFC] File watcher error for #{full_path}: #{ex.message}"
215
- end
209
+ puts "[RSFC] File watcher error for #{full_path}: #{ex.message}"
216
210
 
217
211
  # Clean up watcher entry on error
218
212
  watchers_mutex.synchronize do
@@ -1,8 +1,9 @@
1
+ # lib/rhales/integrations/tilt.rb
2
+ #
1
3
  # frozen_string_literal: true
2
4
 
3
5
  require 'tilt'
4
- require 'rhales'
5
- require_relative 'adapters/base_request'
6
+ require_relative '../adapters/base_request'
6
7
 
7
8
  module Rhales
8
9
  # Tilt integration for Rhales templates
@@ -109,7 +110,8 @@ module Rhales
109
110
  framework_env = scope.request.env.merge({
110
111
  'nonce' => shared_nonce,
111
112
  'request_id' => SecureRandom.hex(8),
112
- })
113
+ },
114
+ )
113
115
 
114
116
  # Create wrapper that preserves original but adds our env
115
117
  wrapped_request = Class.new do
@@ -118,8 +120,8 @@ module Rhales
118
120
  @custom_env = custom_env
119
121
  end
120
122
 
121
- def method_missing(method, *args, &block)
122
- @original.send(method, *args, &block)
123
+ def method_missing(method, *, &)
124
+ @original.send(method, *, &)
123
125
  end
124
126
 
125
127
  def respond_to_missing?(method, include_private = false)
@@ -141,11 +143,11 @@ module Rhales
141
143
  env: {
142
144
  'nonce' => shared_nonce,
143
145
  'request_id' => SecureRandom.hex(8),
144
- }
146
+ },
145
147
  )
146
148
  end
147
149
 
148
- # Use proper session adapter
150
+ # Build session and auth adapters
149
151
  session_data = if scope.respond_to?(:logged_in?) && scope.logged_in?
150
152
  Rhales::Adapters::AuthenticatedSession.new(
151
153
  {
@@ -157,25 +159,31 @@ module Rhales
157
159
  Rhales::Adapters::AnonymousSession.new
158
160
  end
159
161
 
160
- # Use proper auth adapter
161
- if scope.respond_to?(:logged_in?) && scope.logged_in? && scope.respond_to?(:current_user)
162
- user = scope.current_user
163
- auth_data = Rhales::Adapters::AuthenticatedAuth.new({
162
+ auth_data = if scope.respond_to?(:logged_in?) && scope.logged_in? && scope.respond_to?(:current_user)
163
+ user = scope.current_user
164
+ Rhales::Adapters::AuthenticatedAuth.new({
164
165
  id: user[:id],
165
166
  email: user[:email],
166
167
  authenticated: true,
167
- },
168
- )
168
+ })
169
169
  else
170
- auth_data = Rhales::Adapters::AnonymousAuth.new
170
+ Rhales::Adapters::AnonymousAuth.new
171
171
  end
172
172
 
173
+ # Extend request_data to expose session and user
174
+ request_data.define_singleton_method(:session) { session_data }
175
+ request_data.define_singleton_method(:user) { auth_data }
176
+
177
+ # Split props into client (serialized) and server (template-only) data
178
+ # By default, all Tilt locals go to client for backward compatibility
179
+ # Frameworks can use :server_data and :client_data keys to override
180
+ client_data = props.delete(:client_data) || props.dup
181
+ server_data = props.delete(:server_data) || {}
182
+
173
183
  ::Rhales::View.new(
174
184
  request_data,
175
- session_data,
176
- auth_data,
177
- nil, # locale_override
178
- props: props,
185
+ client: client_data,
186
+ server: server_data,
179
187
  )
180
188
  end
181
189
 
@@ -0,0 +1,6 @@
1
+ # lib/rhales/integrations.rb
2
+ #
3
+ # frozen_string_literal: true
4
+
5
+ require_relative 'integrations/tilt'
6
+ require_relative 'integrations/refinements/require_refinements'
@@ -0,0 +1,191 @@
1
+ # lib/rhales/middleware/json_responder.rb
2
+ #
3
+ # frozen_string_literal: true
4
+
5
+ require_relative '../utils/json_serializer'
6
+
7
+ module Rhales
8
+ module Middleware
9
+ # Rack middleware that returns hydration data as JSON when Accept: application/json
10
+ #
11
+ # When a request has Accept: application/json header, this middleware
12
+ # intercepts the response and returns just the hydration data as JSON
13
+ # instead of rendering the full HTML template.
14
+ #
15
+ # This enables:
16
+ # - API clients to fetch data from the same endpoints
17
+ # - Testing hydration data without parsing HTML
18
+ # - Development inspection of data flow
19
+ # - Mobile/native clients using the same routes
20
+ #
21
+ # @example Basic usage with Rack
22
+ # use Rhales::Middleware::JsonResponder,
23
+ # enabled: true,
24
+ # include_metadata: false
25
+ #
26
+ # @example With Roda
27
+ # use Rhales::Middleware::JsonResponder,
28
+ # enabled: ENV['RACK_ENV'] != 'production',
29
+ # include_metadata: ENV['RACK_ENV'] == 'development'
30
+ #
31
+ # @example Response format (single window)
32
+ # GET /dashboard
33
+ # Accept: application/json
34
+ #
35
+ # {
36
+ # "user": {"id": 1, "name": "Alice"},
37
+ # "authenticated": true
38
+ # }
39
+ #
40
+ # @example Response format (multiple windows)
41
+ # {
42
+ # "appData": {"user": {...}},
43
+ # "config": {"theme": "dark"}
44
+ # }
45
+ #
46
+ # @example Response with metadata (development)
47
+ # {
48
+ # "template": "dashboard",
49
+ # "data": {"user": {...}}
50
+ # }
51
+ class JsonResponder
52
+ # Initialize the middleware
53
+ #
54
+ # @param app [#call] The Rack application
55
+ # @param options [Hash] Configuration options
56
+ # @option options [Boolean] :enabled Whether JSON responses are enabled (default: true)
57
+ # @option options [Boolean] :include_metadata Whether to include metadata in responses (default: false)
58
+ def initialize(app, options = {})
59
+ @app = app
60
+ @enabled = options.fetch(:enabled, true)
61
+ @include_metadata = options.fetch(:include_metadata, false)
62
+ end
63
+
64
+ # Process the Rack request
65
+ #
66
+ # @param env [Hash] The Rack environment
67
+ # @return [Array] Rack response tuple [status, headers, body]
68
+ def call(env)
69
+ return @app.call(env) unless @enabled
70
+ return @app.call(env) unless accepts_json?(env)
71
+
72
+ # Get the response from the app
73
+ status, headers, body = @app.call(env)
74
+
75
+ # Only process successful HTML responses
76
+ return [status, headers, body] unless status == 200
77
+ return [status, headers, body] unless html_response?(headers)
78
+
79
+ # Extract hydration data from HTML
80
+ html_body = extract_body(body)
81
+ hydration_data = extract_hydration_data(html_body)
82
+
83
+ # Return empty object if no hydration data found
84
+ if hydration_data.empty?
85
+ return json_response({}, env)
86
+ end
87
+
88
+ # Build response data
89
+ response_data = if @include_metadata
90
+ {
91
+ template: env['rhales.template_name'],
92
+ data: hydration_data
93
+ }
94
+ else
95
+ # Flatten if single window, or return all windows
96
+ hydration_data.size == 1 ? hydration_data.values.first : hydration_data
97
+ end
98
+
99
+ json_response(response_data, env)
100
+ end
101
+
102
+ private
103
+
104
+ # Check if request accepts JSON
105
+ #
106
+ # Parses Accept header and checks for application/json.
107
+ # Handles weighted preferences (e.g., "application/json;q=0.9")
108
+ #
109
+ # @param env [Hash] Rack environment
110
+ # @return [Boolean] true if application/json is accepted
111
+ def accepts_json?(env)
112
+ accept = env['HTTP_ACCEPT']
113
+ return false unless accept
114
+
115
+ # Check if application/json is requested
116
+ # Handle weighted preferences (e.g., "application/json;q=0.9")
117
+ accept.split(',').any? do |type|
118
+ type.strip.start_with?('application/json')
119
+ end
120
+ end
121
+
122
+ # Check if response is HTML
123
+ #
124
+ # @param headers [Hash] Response headers
125
+ # @return [Boolean] true if Content-Type is text/html
126
+ def html_response?(headers)
127
+ # Support both uppercase and lowercase header names for compatibility
128
+ content_type = headers['content-type'] || headers['Content-Type']
129
+ content_type && content_type.include?('text/html')
130
+ end
131
+
132
+ # Extract response body as string
133
+ #
134
+ # Handles different Rack body types (Array, IO, String)
135
+ #
136
+ # @param body [Array, IO, String] Rack response body
137
+ # @return [String] Body content as string
138
+ def extract_body(body)
139
+ if body.respond_to?(:each)
140
+ body.each.to_a.join
141
+ elsif body.respond_to?(:read)
142
+ body.read
143
+ else
144
+ body.to_s
145
+ end
146
+ end
147
+
148
+ # Extract hydration JSON blocks from HTML
149
+ #
150
+ # Looks for <script type="application/json" data-window="varName"> tags
151
+ # and parses their JSON content. Returns a hash keyed by window variable name.
152
+ #
153
+ # @param html [String] HTML response body
154
+ # @return [Hash] Hydration data keyed by window variable name
155
+ def extract_hydration_data(html)
156
+ hydration_blocks = {}
157
+
158
+ # Match script tags with data-window attribute
159
+ html.scan(/<script[^>]*type=["']application\/json["'][^>]*data-window=["']([^"']+)["'][^>]*>(.*?)<\/script>/m) do |window_var, json_content|
160
+ begin
161
+ hydration_blocks[window_var] = JSONSerializer.parse(json_content.strip)
162
+ rescue JSON::ParserError => e
163
+ # Skip malformed JSON blocks
164
+ warn "Rhales::JsonResponder: Failed to parse hydration JSON for window.#{window_var}: #{e.message}"
165
+ end
166
+ end
167
+
168
+ hydration_blocks
169
+ end
170
+
171
+ # Build JSON response
172
+ #
173
+ # @param data [Hash, Array, Object] Response data
174
+ # @param env [Hash] Rack environment
175
+ # @return [Array] Rack response tuple
176
+ def json_response(data, env)
177
+ json_body = JSONSerializer.dump(data)
178
+
179
+ [
180
+ 200,
181
+ {
182
+ 'content-type' => 'application/json',
183
+ 'content-length' => json_body.bytesize.to_s,
184
+ 'cache-control' => 'no-cache'
185
+ },
186
+ [json_body]
187
+ ]
188
+ end
189
+ end
190
+ end
191
+ end