rhales 0.4.0 → 0.5.4

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} +69 -7
  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} +49 -2
  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,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
@@ -51,16 +55,21 @@ module Rhales
51
55
  def calculate_unsafe_ranges
52
56
  ranges = []
53
57
  scanner = StringScanner.new(@html)
58
+ byte_to_char_map = build_byte_to_char_map(@html)
54
59
 
55
60
  UNSAFE_CONTEXTS.each do |context|
56
61
  scanner.pos = 0
57
62
 
58
63
  while scanner.scan_until(context[:start])
59
- start_pos = scanner.pos - scanner.matched.length
64
+ # Convert byte position to character position using pre-built map
65
+ byte_start_pos = scanner.pos - scanner.matched.length
66
+ start_pos = byte_to_char_map[byte_start_pos]
60
67
 
61
68
  # Find the corresponding end tag
62
69
  if scanner.scan_until(context[:end])
63
- end_pos = scanner.pos
70
+ # Convert byte position to character position using pre-built map
71
+ byte_end_pos = scanner.pos
72
+ end_pos = byte_to_char_map[byte_end_pos]
64
73
  ranges << (start_pos...end_pos)
65
74
  else
66
75
  # If no closing tag found, consider rest of document unsafe
@@ -95,5 +104,43 @@ module Rhales
95
104
 
96
105
  pos < @html.length && @html[pos] == '<'
97
106
  end
107
+
108
+ # Builds a mapping from byte positions to character positions for efficient
109
+ # conversion when processing UTF-8 strings with StringScanner.
110
+ #
111
+ # This method creates a hash where keys are byte positions and values are
112
+ # the corresponding character positions. For multibyte UTF-8 characters,
113
+ # only the starting byte position has an entry in the map.
114
+ #
115
+ # @param str [String] The UTF-8 encoded string to map
116
+ # @return [Hash<Integer, Integer>] A hash mapping byte positions to character positions
117
+ #
118
+ # @example ASCII string
119
+ # build_byte_to_char_map("Hello")
120
+ # # => {0=>0, 1=>1, 2=>2, 3=>3, 4=>4, 5=>5}
121
+ #
122
+ # @example UTF-8 with multibyte characters
123
+ # build_byte_to_char_map("café") # é is 2 bytes
124
+ # # => {0=>0, 1=>1, 2=>2, 3=>3, 5=>4} # Note: byte 4 is continuation byte
125
+ #
126
+ def build_byte_to_char_map(str)
127
+ map = {}
128
+ char_pos = 0
129
+ byte_pos = 0
130
+
131
+ # Iterate through each character (not byte) in the string
132
+ str.each_char do |char|
133
+ # Map the starting byte position of this character
134
+ map[byte_pos] = char_pos
135
+
136
+ # Advance byte position by the byte size of this character
137
+ byte_pos += char.bytesize
138
+ char_pos += 1
139
+ end
140
+
141
+ # Add final mapping for the end of the string
142
+ map[byte_pos] = char_pos
143
+ map
144
+ end
98
145
  end
99
146
  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
@@ -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
@@ -0,0 +1,300 @@
1
+ # lib/rhales/middleware/schema_validator.rb
2
+ #
3
+ # frozen_string_literal: true
4
+
5
+ require 'json_schemer'
6
+ require_relative '../utils/json_serializer'
7
+
8
+ module Rhales
9
+ module Middleware
10
+ # Rack middleware that validates hydration data against JSON Schemas
11
+ #
12
+ # This middleware extracts hydration JSON from HTML responses and validates
13
+ # it against the JSON Schema for the template. In development, it fails
14
+ # loudly on mismatches. In production, it logs warnings but continues serving.
15
+ #
16
+ # @example Basic usage with Rack
17
+ # use Rhales::Middleware::SchemaValidator,
18
+ # schemas_dir: './public/schemas',
19
+ # fail_on_error: ENV['RACK_ENV'] == 'development'
20
+ #
21
+ # @example With Roda
22
+ # use Rhales::Middleware::SchemaValidator,
23
+ # schemas_dir: File.expand_path('../public/schemas', __dir__),
24
+ # fail_on_error: ENV['RACK_ENV'] == 'development',
25
+ # enabled: true
26
+ #
27
+ # @example Accessing statistics
28
+ # validator = app.middleware.find { |m| m.is_a?(Rhales::Middleware::SchemaValidator) }
29
+ # puts validator.stats
30
+ class SchemaValidator
31
+ # Raised when schema validation fails in development mode
32
+ class ValidationError < StandardError; end
33
+
34
+ # Initialize the middleware
35
+ #
36
+ # @param app [#call] The Rack application
37
+ # @param options [Hash] Configuration options
38
+ # @option options [String] :schemas_dir Path to JSON schemas directory
39
+ # @option options [Boolean] :fail_on_error Whether to raise on validation errors
40
+ # @option options [Boolean] :enabled Whether validation is enabled
41
+ # @option options [Array<String>] :skip_paths Additional paths to skip validation
42
+ def initialize(app, options = {})
43
+ @app = app
44
+ # Default to public/schemas in implementing project's directory
45
+ @schemas_dir = options.fetch(:schemas_dir, './public/schemas')
46
+ @fail_on_error = options.fetch(:fail_on_error, false)
47
+ @enabled = options.fetch(:enabled, true)
48
+ @skip_paths = options.fetch(:skip_paths, [])
49
+ @schema_cache = {}
50
+ @stats = {
51
+ total_validations: 0,
52
+ total_time_ms: 0,
53
+ failures: 0
54
+ }
55
+ end
56
+
57
+ # Process the Rack request
58
+ #
59
+ # @param env [Hash] The Rack environment
60
+ # @return [Array] Rack response tuple [status, headers, body]
61
+ def call(env)
62
+ return @app.call(env) unless @enabled
63
+ return @app.call(env) if skip_validation?(env)
64
+
65
+ status, headers, body = @app.call(env)
66
+
67
+ # Only validate HTML responses
68
+ content_type = headers['Content-Type']
69
+ return [status, headers, body] unless content_type&.include?('text/html')
70
+
71
+ # Get template name from env (set by View)
72
+ template_name = env['rhales.template_name']
73
+ return [status, headers, body] unless template_name
74
+
75
+ # Get template path if available (for better error messages)
76
+ template_path = env['rhales.template_path']
77
+
78
+ # Load schema for template
79
+ schema = load_schema_cached(template_name)
80
+ return [status, headers, body] unless schema
81
+
82
+ # Extract hydration data from response
83
+ html_body = extract_body(body)
84
+ hydration_data = extract_hydration_data(html_body)
85
+ return [status, headers, body] if hydration_data.empty?
86
+
87
+ # Validate each hydration block
88
+ start_time = Time.now
89
+ errors = validate_hydration_data(hydration_data, schema, template_name)
90
+ elapsed_ms = ((Time.now - start_time) * 1000).round(2)
91
+
92
+ # Update stats
93
+ @stats[:total_validations] += 1
94
+ @stats[:total_time_ms] += elapsed_ms
95
+ @stats[:failures] += 1 if errors.any?
96
+
97
+ # Handle errors
98
+ handle_errors(errors, template_name, template_path, elapsed_ms) if errors.any?
99
+
100
+ [status, headers, body]
101
+ end
102
+
103
+ # Get validation statistics
104
+ #
105
+ # @return [Hash] Statistics including avg_time_ms and success_rate
106
+ def stats
107
+ avg_time = @stats[:total_validations] > 0 ?
108
+ (@stats[:total_time_ms] / @stats[:total_validations]).round(2) : 0
109
+
110
+ @stats.merge(
111
+ avg_time_ms: avg_time,
112
+ success_rate: @stats[:total_validations] > 0 ?
113
+ ((@stats[:total_validations] - @stats[:failures]).to_f / @stats[:total_validations] * 100).round(2) : 0
114
+ )
115
+ end
116
+
117
+ private
118
+
119
+ # Check if validation should be skipped for this request
120
+ def skip_validation?(env)
121
+ path = env['PATH_INFO']
122
+
123
+ # Skip static assets, APIs, public files
124
+ return true if path.start_with?('/assets', '/api', '/public')
125
+
126
+ # Skip configured custom paths
127
+ return true if @skip_paths.any? { |skip_path| path.start_with?(skip_path) }
128
+
129
+ # Skip files with extensions typically not rendered by templates
130
+ return true if path.match?(/\.(css|js|png|jpg|jpeg|gif|svg|ico|woff|woff2|ttf|eot)\z/i)
131
+
132
+ false
133
+ end
134
+
135
+ # Load and cache JSON schema for template
136
+ def load_schema_cached(template_name)
137
+ @schema_cache[template_name] ||= begin
138
+ schema_path = File.join(@schemas_dir, "#{template_name}.json")
139
+
140
+ return nil unless File.exist?(schema_path)
141
+
142
+ schema_json = File.read(schema_path)
143
+ schema_hash = JSONSerializer.parse(schema_json)
144
+
145
+ # Create JSONSchemer validator
146
+ # Note: json_schemer handles $schema and $id properly
147
+ JSONSchemer.schema(schema_hash)
148
+ rescue JSON::ParserError => e
149
+ warn "Rhales::SchemaValidator: Failed to parse schema for #{template_name}: #{e.message}"
150
+ nil
151
+ rescue StandardError => e
152
+ warn "Rhales::SchemaValidator: Failed to load schema for #{template_name}: #{e.message}"
153
+ nil
154
+ end
155
+ end
156
+
157
+ # Extract response body as string
158
+ def extract_body(body)
159
+ if body.respond_to?(:each)
160
+ body.each.to_a.join
161
+ elsif body.respond_to?(:read)
162
+ body.read
163
+ else
164
+ body.to_s
165
+ end
166
+ end
167
+
168
+ # Extract hydration JSON blocks from HTML
169
+ #
170
+ # Looks for <script type="application/json" data-window="varName"> tags
171
+ def extract_hydration_data(html)
172
+ hydration_blocks = {}
173
+
174
+ # Match script tags with data-window attribute
175
+ html.scan(/<script[^>]*type=["']application\/json["'][^>]*data-window=["']([^"']+)["'][^>]*>(.*?)<\/script>/m) do |window_var, json_content|
176
+ begin
177
+ hydration_blocks[window_var] = JSONSerializer.parse(json_content.strip)
178
+ rescue JSON::ParserError => e
179
+ warn "Rhales::SchemaValidator: Failed to parse hydration JSON for window.#{window_var}: #{e.message}"
180
+ end
181
+ end
182
+
183
+ hydration_blocks
184
+ end
185
+
186
+ # Validate hydration data against schema
187
+ def validate_hydration_data(hydration_data, schema, template_name)
188
+ errors = []
189
+
190
+ hydration_data.each do |window_var, data|
191
+ # Validate data against schema using json_schemer
192
+ begin
193
+ validation_errors = schema.validate(data).to_a
194
+
195
+ if validation_errors.any?
196
+ errors << {
197
+ window: window_var,
198
+ template: template_name,
199
+ errors: format_errors(validation_errors)
200
+ }
201
+ end
202
+ rescue StandardError => e
203
+ warn "Rhales::SchemaValidator: Schema validation error for #{template_name}: #{e.message}"
204
+ # Don't add to errors array - this is a schema definition problem, not data problem
205
+ end
206
+ end
207
+
208
+ errors
209
+ end
210
+
211
+ # Format json_schemer errors for display
212
+ def format_errors(validation_errors)
213
+ validation_errors.map do |error|
214
+ # json_schemer provides detailed error hash
215
+ # Example: { "data" => value, "data_pointer" => "/user/id", "schema" => {...}, "type" => "required", "error" => "..." }
216
+
217
+ path = error['data_pointer'] || '/'
218
+ type = error['type']
219
+ schema = error['schema'] || {}
220
+ data = error['data']
221
+
222
+ # For type validation errors, format like json-schema did
223
+ # "The property '#/count' of type string did not match the following type: number"
224
+ if schema['type'] && data
225
+ expected = schema['type']
226
+ actual = case data
227
+ when String then 'string'
228
+ when Integer, Float then 'number'
229
+ when TrueClass, FalseClass then 'boolean'
230
+ when Array then 'array'
231
+ when Hash then 'object'
232
+ when NilClass then 'null'
233
+ else data.class.name.downcase
234
+ end
235
+
236
+ "The property '#{path}' of type #{actual} did not match the following type: #{expected}"
237
+ elsif type == 'required'
238
+ details = error['details'] || {}
239
+ missing = details['missing_keys']&.join(', ') || 'unknown'
240
+ "The property '#{path}' is missing required field(s): #{missing}"
241
+ elsif schema['enum']
242
+ expected = schema['enum'].join(', ')
243
+ "The property '#{path}' must be one of: #{expected}"
244
+ elsif schema['minimum']
245
+ min = schema['minimum']
246
+ "The property '#{path}' must be >= #{min}"
247
+ elsif schema['maximum']
248
+ max = schema['maximum']
249
+ "The property '#{path}' must be <= #{max}"
250
+ elsif type == 'additionalProperties'
251
+ "The property '#{path}' is not defined in the schema and the schema does not allow additional properties"
252
+ else
253
+ # Fallback: use json_schemer's built-in error message
254
+ error['error'] || "The property '#{path}' failed '#{type}' validation"
255
+ end
256
+ end
257
+ end
258
+
259
+ # Handle validation errors
260
+ def handle_errors(errors, template_name, template_path, elapsed_ms)
261
+ error_message = build_error_message(errors, template_name, template_path, elapsed_ms)
262
+
263
+ if @fail_on_error
264
+ # Development: Fail loudly
265
+ raise ValidationError, error_message
266
+ else
267
+ # Production: Log warning
268
+ warn error_message
269
+ end
270
+ end
271
+
272
+ # Build detailed error message
273
+ def build_error_message(errors, template_name, template_path, elapsed_ms)
274
+ msg = ["Schema validation failed for template: #{template_name}"]
275
+ msg << "Template path: #{template_path}" if template_path
276
+ msg << "Validation time: #{elapsed_ms}ms"
277
+ msg << ""
278
+
279
+ errors.each do |error|
280
+ msg << "Window variable: #{error[:window]}"
281
+ msg << "Errors:"
282
+ error[:errors].each do |err|
283
+ msg << " - #{err}"
284
+ end
285
+ msg << ""
286
+ end
287
+
288
+ msg << "This means your backend is sending data that doesn't match the contract"
289
+ msg << "defined in the <schema> section of #{template_name}.rue"
290
+ msg << ""
291
+ msg << "To fix:"
292
+ msg << "1. Check the schema definition in #{template_name}.rue"
293
+ msg << "2. Verify the data passed to render('#{template_name}', ...)"
294
+ msg << "3. Ensure types match (string vs number, required fields, etc.)"
295
+
296
+ msg.join("\n")
297
+ end
298
+ end
299
+ end
300
+ end
@@ -0,0 +1,6 @@
1
+ # lib/rhales/middleware.rb
2
+ #
3
+ # frozen_string_literal: true
4
+
5
+ require_relative 'middleware/json_responder'
6
+ require_relative 'middleware/schema_validator'