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.
- checksums.yaml +4 -4
- data/.github/renovate.json5 +52 -0
- data/.github/workflows/ci.yml +123 -0
- data/.github/workflows/claude-code-review.yml +69 -0
- data/.github/workflows/claude.yml +49 -0
- data/.github/workflows/code-smells.yml +146 -0
- data/.github/workflows/ruby-lint.yml +78 -0
- data/.github/workflows/yardoc.yml +126 -0
- data/.gitignore +55 -0
- data/.pr_agent.toml +63 -0
- data/.pre-commit-config.yaml +89 -0
- data/.prettierignore +8 -0
- data/.prettierrc +38 -0
- data/.reek.yml +98 -0
- data/.rubocop.yml +428 -0
- data/.serena/.gitignore +3 -0
- data/.yardopts +56 -0
- data/CHANGELOG.md +44 -0
- data/CLAUDE.md +1 -1
- data/Gemfile +29 -0
- data/Gemfile.lock +189 -0
- data/README.md +686 -868
- data/Rakefile +46 -0
- data/debug_context.rb +25 -0
- data/demo/rhales-roda-demo/.gitignore +7 -0
- data/demo/rhales-roda-demo/Gemfile +32 -0
- data/demo/rhales-roda-demo/Gemfile.lock +151 -0
- data/demo/rhales-roda-demo/MAIL.md +405 -0
- data/demo/rhales-roda-demo/README.md +376 -0
- data/demo/rhales-roda-demo/RODA-TEMPLATE-ENGINES.md +192 -0
- data/demo/rhales-roda-demo/Rakefile +49 -0
- data/demo/rhales-roda-demo/app.rb +325 -0
- data/demo/rhales-roda-demo/bin/rackup +26 -0
- data/demo/rhales-roda-demo/config.ru +13 -0
- data/demo/rhales-roda-demo/db/migrate/001_create_rodauth_tables.rb +266 -0
- data/demo/rhales-roda-demo/db/migrate/002_create_rodauth_password_tables.rb +79 -0
- data/demo/rhales-roda-demo/db/migrate/003_add_admin_account.rb +68 -0
- data/demo/rhales-roda-demo/templates/change_login.rue +31 -0
- data/demo/rhales-roda-demo/templates/change_password.rue +36 -0
- data/demo/rhales-roda-demo/templates/close_account.rue +31 -0
- data/demo/rhales-roda-demo/templates/create_account.rue +40 -0
- data/demo/rhales-roda-demo/templates/dashboard.rue +150 -0
- data/demo/rhales-roda-demo/templates/home.rue +78 -0
- data/demo/rhales-roda-demo/templates/layouts/main.rue +168 -0
- data/demo/rhales-roda-demo/templates/login.rue +65 -0
- data/demo/rhales-roda-demo/templates/logout.rue +25 -0
- data/demo/rhales-roda-demo/templates/reset_password.rue +26 -0
- data/demo/rhales-roda-demo/templates/verify_account.rue +27 -0
- data/demo/rhales-roda-demo/test_full_output.rb +27 -0
- data/demo/rhales-roda-demo/test_simple.rb +24 -0
- data/docs/.gitignore +9 -0
- data/docs/architecture/data-flow.md +499 -0
- data/examples/dashboard-with-charts.rue +271 -0
- data/examples/form-with-validation.rue +180 -0
- data/examples/simple-page.rue +61 -0
- data/examples/vue.rue +136 -0
- data/generate-json-schemas.ts +158 -0
- data/json_schemer_migration_summary.md +172 -0
- data/lib/rhales/adapters/base_auth.rb +2 -0
- data/lib/rhales/adapters/base_request.rb +2 -0
- data/lib/rhales/adapters/base_session.rb +2 -0
- data/lib/rhales/adapters.rb +7 -0
- data/lib/rhales/configuration.rb +47 -0
- data/lib/rhales/core/context.rb +354 -0
- data/lib/rhales/{rue_document.rb → core/rue_document.rb} +56 -38
- data/lib/rhales/{template_engine.rb → core/template_engine.rb} +66 -59
- data/lib/rhales/{view.rb → core/view.rb} +112 -135
- data/lib/rhales/{view_composition.rb → core/view_composition.rb} +78 -8
- data/lib/rhales/core.rb +9 -0
- data/lib/rhales/errors/hydration_collision_error.rb +2 -0
- data/lib/rhales/errors.rb +2 -0
- data/lib/rhales/{earliest_injection_detector.rb → hydration/earliest_injection_detector.rb} +4 -0
- data/lib/rhales/hydration/hydration_data_aggregator.rb +487 -0
- data/lib/rhales/{hydration_endpoint.rb → hydration/hydration_endpoint.rb} +16 -12
- data/lib/rhales/{hydration_injector.rb → hydration/hydration_injector.rb} +4 -0
- data/lib/rhales/{hydration_registry.rb → hydration/hydration_registry.rb} +2 -0
- data/lib/rhales/hydration/hydrator.rb +102 -0
- data/lib/rhales/{link_based_injection_detector.rb → hydration/link_based_injection_detector.rb} +4 -0
- data/lib/rhales/{mount_point_detector.rb → hydration/mount_point_detector.rb} +4 -0
- data/lib/rhales/{safe_injection_validator.rb → hydration/safe_injection_validator.rb} +4 -0
- data/lib/rhales/hydration.rb +13 -0
- data/lib/rhales/{refinements → integrations/refinements}/require_refinements.rb +3 -1
- data/lib/rhales/{tilt.rb → integrations/tilt.rb} +22 -15
- data/lib/rhales/integrations.rb +6 -0
- data/lib/rhales/middleware/json_responder.rb +191 -0
- data/lib/rhales/middleware/schema_validator.rb +300 -0
- data/lib/rhales/middleware.rb +6 -0
- data/lib/rhales/parsers/handlebars_parser.rb +2 -0
- data/lib/rhales/parsers/rue_format_parser.rb +9 -7
- data/lib/rhales/parsers.rb +9 -0
- data/lib/rhales/{csp.rb → security/csp.rb} +27 -3
- data/lib/rhales/utils/json_serializer.rb +114 -0
- data/lib/rhales/utils/logging_helpers.rb +75 -0
- data/lib/rhales/utils/schema_extractor.rb +132 -0
- data/lib/rhales/utils/schema_generator.rb +194 -0
- data/lib/rhales/utils.rb +40 -0
- data/lib/rhales/version.rb +3 -1
- data/lib/rhales.rb +41 -24
- data/lib/tasks/rhales_schema.rake +197 -0
- data/package.json +10 -0
- data/pnpm-lock.yaml +345 -0
- data/pnpm-workspace.yaml +2 -0
- data/proofs/error_handling.rb +79 -0
- data/proofs/expanded_object_inheritance.rb +82 -0
- data/proofs/partial_context_scoping_fix.rb +168 -0
- data/proofs/ui_context_partial_inheritance.rb +236 -0
- data/rhales.gemspec +14 -6
- data/schema_vs_data_comparison.md +254 -0
- data/test_direct_access.rb +36 -0
- metadata +141 -23
- data/CLAUDE.locale.txt +0 -7
- data/lib/rhales/context.rb +0 -239
- data/lib/rhales/hydration_data_aggregator.rb +0 -221
- data/lib/rhales/hydrator.rb +0 -141
- data/lib/rhales/parsers/handlebars-grammar-review.txt +0 -39
|
@@ -1,5 +1,9 @@
|
|
|
1
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
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 #{
|
|
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}(#{
|
|
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(
|
|
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,
|
|
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.
|
|
153
|
-
@context.class.for_view(@context.req, @context.
|
|
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(
|
|
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(
|
|
177
|
+
headers['ETag'] = %("#{Digest::MD5.hexdigest(JSONSerializer.dump(data))}")
|
|
174
178
|
|
|
175
179
|
headers
|
|
176
180
|
end
|
|
@@ -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,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,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
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
-
|
|
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
|
-
|
|
177
|
-
|
|
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,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
|