rhales 0.4.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 (115) 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 -1
  20. data/Gemfile +29 -0
  21. data/Gemfile.lock +189 -0
  22. data/README.md +686 -868
  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 +47 -0
  64. data/lib/rhales/core/context.rb +354 -0
  65. data/lib/rhales/{rue_document.rb → core/rue_document.rb} +56 -38
  66. data/lib/rhales/{template_engine.rb → core/template_engine.rb} +66 -59
  67. data/lib/rhales/{view.rb → core/view.rb} +112 -135
  68. data/lib/rhales/{view_composition.rb → core/view_composition.rb} +78 -8
  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/{earliest_injection_detector.rb → hydration/earliest_injection_detector.rb} +4 -0
  73. data/lib/rhales/hydration/hydration_data_aggregator.rb +487 -0
  74. data/lib/rhales/{hydration_endpoint.rb → hydration/hydration_endpoint.rb} +16 -12
  75. data/lib/rhales/{hydration_injector.rb → hydration/hydration_injector.rb} +4 -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/{link_based_injection_detector.rb → hydration/link_based_injection_detector.rb} +4 -0
  79. data/lib/rhales/{mount_point_detector.rb → hydration/mount_point_detector.rb} +4 -0
  80. data/lib/rhales/{safe_injection_validator.rb → hydration/safe_injection_validator.rb} +4 -0
  81. data/lib/rhales/hydration.rb +13 -0
  82. data/lib/rhales/{refinements → integrations/refinements}/require_refinements.rb +3 -1
  83. data/lib/rhales/{tilt.rb → integrations/tilt.rb} +22 -15
  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 +9 -7
  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 +3 -1
  98. data/lib/rhales.rb +41 -24
  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 +141 -23
  111. data/CLAUDE.locale.txt +0 -7
  112. data/lib/rhales/context.rb +0 -239
  113. data/lib/rhales/hydration_data_aggregator.rb +0 -221
  114. data/lib/rhales/hydrator.rb +0 -141
  115. data/lib/rhales/parsers/handlebars-grammar-review.txt +0 -39
@@ -1,5 +1,9 @@
1
- require 'json'
1
+ # lib/rhales/hydration/hydration_endpoint.rb
2
+ #
3
+ # frozen_string_literal: true
4
+
2
5
  require 'digest'
6
+ require_relative '../utils/json_serializer'
3
7
 
4
8
  module Rhales
5
9
  # Handles API endpoint responses for link-based hydration strategies
@@ -48,7 +52,7 @@ module Rhales
48
52
  merged_data = process_template_data(template_name, additional_context)
49
53
 
50
54
  {
51
- content: JSON.generate(merged_data),
55
+ content: JSONSerializer.dump(merged_data),
52
56
  content_type: 'application/json',
53
57
  headers: json_headers(merged_data)
54
58
  }
@@ -63,7 +67,7 @@ module Rhales
63
67
  }
64
68
 
65
69
  {
66
- content: JSON.generate(error_data),
70
+ content: JSONSerializer.dump(error_data),
67
71
  content_type: 'application/json',
68
72
  headers: json_headers(error_data)
69
73
  }
@@ -78,7 +82,7 @@ module Rhales
78
82
  }
79
83
 
80
84
  {
81
- content: JSON.generate(error_data),
85
+ content: JSONSerializer.dump(error_data),
82
86
  content_type: 'application/json',
83
87
  headers: json_headers(error_data)
84
88
  }
@@ -89,7 +93,7 @@ module Rhales
89
93
  merged_data = process_template_data(template_name, additional_context)
90
94
 
91
95
  {
92
- content: "export default #{JSON.generate(merged_data)};",
96
+ content: "export default #{JSONSerializer.dump(merged_data)};",
93
97
  content_type: 'text/javascript',
94
98
  headers: module_headers(merged_data)
95
99
  }
@@ -100,7 +104,7 @@ module Rhales
100
104
  merged_data = process_template_data(template_name, additional_context)
101
105
 
102
106
  {
103
- content: "#{callback_name}(#{JSON.generate(merged_data)});",
107
+ content: "#{callback_name}(#{JSONSerializer.dump(merged_data)});",
104
108
  content_type: 'application/javascript',
105
109
  headers: jsonp_headers(merged_data),
106
110
  }
@@ -116,7 +120,7 @@ module Rhales
116
120
  def calculate_etag(template_name, additional_context = {})
117
121
  merged_data = process_template_data(template_name, additional_context)
118
122
  # Simple ETag based on data hash
119
- Digest::MD5.hexdigest(JSON.generate(merged_data))
123
+ Digest::MD5.hexdigest(JSONSerializer.dump(merged_data))
120
124
  end
121
125
 
122
126
  private
@@ -126,7 +130,7 @@ module Rhales
126
130
  template_context = create_template_context(additional_context)
127
131
 
128
132
  # Process template to extract hydration data
129
- view = View.new(@context.req, @context.sess, @context.cust, @context.locale, props: {})
133
+ view = View.new(@context.req, client: {})
130
134
  aggregator = HydrationDataAggregator.new(template_context)
131
135
 
132
136
  # Build composition to get template dependencies
@@ -149,11 +153,11 @@ module Rhales
149
153
  def create_template_context(additional_context)
150
154
  if @context
151
155
  # Merge additional context into existing context by reconstructing with merged props
152
- merged_props = @context.props.merge(additional_context)
153
- @context.class.for_view(@context.req, @context.sess, @context.cust, @context.locale, **merged_props)
156
+ merged_props = @context.client.merge(additional_context)
157
+ @context.class.for_view(@context.req, @context.locale, **merged_props)
154
158
  else
155
159
  # Create minimal context with just the additional data
156
- Context.minimal(props: additional_context)
160
+ Context.minimal(client: additional_context)
157
161
  end
158
162
  end
159
163
 
@@ -170,7 +174,7 @@ module Rhales
170
174
  end
171
175
 
172
176
  # Add ETag for caching
173
- headers['ETag'] = %("#{Digest::MD5.hexdigest(JSON.generate(data))}")
177
+ headers['ETag'] = %("#{Digest::MD5.hexdigest(JSONSerializer.dump(data))}")
174
178
 
175
179
  headers
176
180
  end
@@ -1,3 +1,7 @@
1
+ # lib/rhales/hydration/hydration_injector.rb
2
+ #
3
+ # frozen_string_literal: true
4
+
1
5
  require_relative 'earliest_injection_detector'
2
6
  require_relative 'link_based_injection_detector'
3
7
 
@@ -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
@@ -1,3 +1,7 @@
1
+ # lib/rhales/hydration/link_based_injection_detector.rb
2
+ #
3
+ # frozen_string_literal: true
4
+
1
5
  require 'strscan'
2
6
  require_relative 'safe_injection_validator'
3
7
 
@@ -1,3 +1,7 @@
1
+ # lib/rhales/hydration/mount_point_detector.rb
2
+ #
3
+ # frozen_string_literal: true
4
+
1
5
  require 'strscan'
2
6
  require_relative 'safe_injection_validator'
3
7
 
@@ -1,3 +1,7 @@
1
+ # lib/rhales/hydration/safe_injection_validator.rb
2
+ #
3
+ # frozen_string_literal: true
4
+
1
5
  require 'strscan'
2
6
 
3
7
  module Rhales
@@ -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
@@ -1,8 +1,9 @@
1
- # lib/rhales/tilt.rb
1
+ # lib/rhales/integrations/tilt.rb
2
+ #
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
@@ -146,7 +147,7 @@ module Rhales
146
147
  )
147
148
  end
148
149
 
149
- # Use proper session adapter
150
+ # Build session and auth adapters
150
151
  session_data = if scope.respond_to?(:logged_in?) && scope.logged_in?
151
152
  Rhales::Adapters::AuthenticatedSession.new(
152
153
  {
@@ -158,25 +159,31 @@ module Rhales
158
159
  Rhales::Adapters::AnonymousSession.new
159
160
  end
160
161
 
161
- # Use proper auth adapter
162
- if scope.respond_to?(:logged_in?) && scope.logged_in? && scope.respond_to?(:current_user)
163
- user = scope.current_user
164
- 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({
165
165
  id: user[:id],
166
166
  email: user[:email],
167
167
  authenticated: true,
168
- },
169
- )
168
+ })
170
169
  else
171
- auth_data = Rhales::Adapters::AnonymousAuth.new
170
+ Rhales::Adapters::AnonymousAuth.new
172
171
  end
173
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
+
174
183
  ::Rhales::View.new(
175
184
  request_data,
176
- session_data,
177
- auth_data,
178
- nil, # locale_override
179
- props: props,
185
+ client: client_data,
186
+ server: server_data,
180
187
  )
181
188
  end
182
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