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,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'
@@ -1,4 +1,6 @@
1
1
  # lib/rhales/parsers/handlebars_parser.rb
2
+ #
3
+ # frozen_string_literal: true
2
4
 
3
5
  module Rhales
4
6
  # Hand-rolled recursive descent parser for Handlebars template syntax
@@ -1,4 +1,6 @@
1
1
  # lib/rhales/parsers/rue_format_parser.rb
2
+ #
3
+ # frozen_string_literal: true
2
4
 
3
5
  require 'strscan'
4
6
  require_relative 'handlebars_parser'
@@ -9,7 +11,7 @@ module Rhales
9
11
  # This parser implements .rue file parsing rules in Ruby code and produces
10
12
  # an Abstract Syntax Tree (AST) for .rue file processing. It handles:
11
13
  #
12
- # - Section-based parsing: <data>, <template>, <logic>
14
+ # - Section-based parsing: <schema>, <template>, <logic>
13
15
  # - Attribute extraction from section tags
14
16
  # - Delegation to HandlebarsParser for template content
15
17
  # - Validation of required sections
@@ -21,19 +23,22 @@ module Rhales
21
23
  # File format structure:
22
24
  # rue_file := section+
23
25
  # section := '<' tag_name attributes? '>' content '</' tag_name '>'
24
- # tag_name := 'data' | 'template' | 'logic'
26
+ # tag_name := 'schema' | 'template' | 'logic'
25
27
  # attributes := attribute+
26
28
  # attribute := key '=' quoted_value
27
29
  # content := (text | handlebars_expression)*
28
30
  # handlebars_expression := '{{' expression '}}'
29
31
  class RueFormatParser
30
32
  # At least one of these sections must be present
31
- REQUIRES_ONE_OF_SECTIONS = %w[data template].freeze
32
- KNOWN_SECTIONS = %w[data template logic].freeze
33
- ALL_SECTIONS = KNOWN_SECTIONS.freeze
33
+ unless defined?(REQUIRES_ONE_OF_SECTIONS)
34
+ REQUIRES_ONE_OF_SECTIONS = %w[schema template].freeze
34
35
 
35
- # Regular expression to match HTML/XML comments outside of sections
36
- COMMENT_REGEX = /<!--.*?-->/m
36
+ KNOWN_SECTIONS = %w[schema template logic].freeze
37
+ ALL_SECTIONS = KNOWN_SECTIONS.freeze
38
+
39
+ # Regular expression to match HTML/XML comments outside of sections
40
+ COMMENT_REGEX = /<!--.*?-->/m
41
+ end
37
42
 
38
43
  class ParseError < ::Rhales::ParseError
39
44
  def initialize(message, line: nil, column: nil, offset: nil)
@@ -145,7 +150,7 @@ module Rhales
145
150
  def parse_tag_name
146
151
  start_pos = @position
147
152
 
148
- advance while !at_end? && current_char.match?(/[a-zA-Z]/)
153
+ advance while !at_end? && current_char.match?(/[a-zA-Z0-9_]/)
149
154
 
150
155
  if start_pos == @position
151
156
  parse_error('Expected tag name')
@@ -178,30 +183,42 @@ module Rhales
178
183
  attributes
179
184
  end
180
185
 
186
+ # Uses StringScanner to parse "content" in <section>content</section>
181
187
  def parse_section_content(tag_name)
182
- start_pos = @position
183
188
  content_start = @position
189
+ closing_tag = "</#{tag_name}>"
184
190
 
185
- # Extract the raw content between section tags
186
- raw_content = ''
187
- while !at_end? && !peek_closing_tag?(tag_name)
188
- raw_content << current_char
189
- advance
190
- end
191
+ # Create scanner from remaining content
192
+ scanner = StringScanner.new(@content[content_start..])
191
193
 
192
- # For template sections, use HandlebarsParser to parse the content
193
- if tag_name == 'template'
194
- handlebars_parser = HandlebarsParser.new(raw_content)
195
- handlebars_parser.parse!
196
- handlebars_parser.ast.children
197
- else
198
- # For data and logic sections, keep as simple text
199
- return [Node.new(:text, current_location, value: raw_content)] unless raw_content.empty?
194
+ # Find the closing tag position
195
+ if scanner.scan_until(/(?=#{Regexp.escape(closing_tag)})/)
196
+ # Calculate content length (scanner.charpos gives us position right before closing tag)
197
+ content_length = scanner.charpos
198
+ raw_content = @content[content_start, content_length]
200
199
 
201
- []
200
+ # Advance position tracking to end of content
201
+ advance_to_position(content_start + content_length)
202
+
203
+ # Process content based on tag type
204
+ if tag_name == 'template'
205
+ handlebars_parser = HandlebarsParser.new(raw_content)
206
+ handlebars_parser.parse!
207
+ handlebars_parser.ast.children
208
+ else
209
+ # For schema and logic sections, keep as simple text
210
+ raw_content.empty? ? [] : [Node.new(:text, current_location, value: raw_content)]
211
+ end
212
+ else
213
+ parse_error("Expected '#{closing_tag}' to close section")
202
214
  end
203
215
  end
204
216
 
217
+ # Add this helper method to advance position tracking to a specific offset
218
+ def advance_to_position(target_position)
219
+ advance while @position < target_position && !at_end?
220
+ end
221
+
205
222
  def parse_quoted_string
206
223
  quote_char = current_char
207
224
  unless ['"', "'"].include?(quote_char)
@@ -209,7 +226,7 @@ module Rhales
209
226
  end
210
227
 
211
228
  advance # Skip opening quote
212
- value = ''
229
+ value = []
213
230
 
214
231
  while !at_end? && current_char != quote_char
215
232
  value << current_char
@@ -217,7 +234,11 @@ module Rhales
217
234
  end
218
235
 
219
236
  consume(quote_char) || parse_error('Unterminated quoted string')
220
- value
237
+
238
+ # NOTE: Character-by-character parsing is acceptable here since attribute values
239
+ # in section tags (e.g., <tag attribute="value">) are typically short strings.
240
+ # Using StringScanner would be overkill for this use case.
241
+ value.join
221
242
  end
222
243
 
223
244
  def parse_identifier
@@ -350,8 +371,6 @@ module Rhales
350
371
  result_parts.join
351
372
  end
352
373
 
353
- private
354
-
355
374
  # Tokenize content into structured tokens for pattern matching
356
375
  # Uses StringScanner for better performance and cleaner code
357
376
  def tokenize_content(content)
@@ -359,23 +378,23 @@ module Rhales
359
378
  tokens = []
360
379
 
361
380
  until scanner.eos?
362
- case
381
+ tokens << case
363
382
  when scanner.scan(/<!--.*?-->/m)
364
383
  # Comment token - non-greedy match for complete comments
365
- tokens << { type: :comment, content: scanner.matched }
366
- when scanner.scan(/<(data|template|logic)(\s[^>]*)?>/m)
384
+ { type: :comment, content: scanner.matched }
385
+ when scanner.scan(/<(schema|template|logic)(\s[^>]*)?>/m)
367
386
  # Section start token - matches opening tags with optional attributes
368
- tokens << { type: :section_start, content: scanner.matched }
369
- when scanner.scan(/<\/(data|template|logic)>/m)
387
+ { type: :section_start, content: scanner.matched }
388
+ when scanner.scan(%r{</(schema|template|logic)>}m)
370
389
  # Section end token - matches closing tags
371
- tokens << { type: :section_end, content: scanner.matched }
390
+ { type: :section_end, content: scanner.matched }
372
391
  when scanner.scan(/[^<]+/)
373
392
  # Text token - consolidates runs of non-< characters for efficiency
374
- tokens << { type: :text, content: scanner.matched }
393
+ { type: :text, content: scanner.matched }
375
394
  else
376
395
  # Fallback for single characters (< that don't match patterns)
377
396
  # This maintains compatibility with the original character-by-character behavior
378
- tokens << { type: :text, content: scanner.getch }
397
+ { type: :text, content: scanner.getch }
379
398
  end
380
399
  end
381
400
 
@@ -0,0 +1,9 @@
1
+ # lib/rhales/parsers.rb
2
+ #
3
+ # frozen_string_literal: true
4
+
5
+ # Dependencies
6
+ require_relative 'errors'
7
+
8
+ require_relative 'parsers/handlebars_parser'
9
+ require_relative 'parsers/rue_format_parser'
@@ -1,4 +1,6 @@
1
1
  # lib/rhales/csp.rb
2
+ #
3
+ # frozen_string_literal: true
2
4
 
3
5
  module Rhales
4
6
  # Content Security Policy (CSP) header generation and management
@@ -11,6 +13,8 @@ module Rhales
11
13
  # header = csp.build_header
12
14
  # # => "default-src 'self'; script-src 'self' 'nonce-abc123'; ..."
13
15
  class CSP
16
+ include Rhales::Utils::LoggingHelpers
17
+
14
18
  attr_reader :config, :nonce
15
19
 
16
20
  def initialize(config, nonce: nil)
@@ -23,6 +27,7 @@ module Rhales
23
27
  return nil unless @config.csp_enabled
24
28
 
25
29
  policy_directives = []
30
+ nonce_used = false
26
31
 
27
32
  @config.csp_policy.each do |directive, sources|
28
33
  if sources.empty?
@@ -30,18 +35,37 @@ module Rhales
30
35
  policy_directives << directive
31
36
  else
32
37
  # Process sources and interpolate nonce if present
33
- processed_sources = sources.map { |source| interpolate_nonce(source) }
38
+ processed_sources = sources.map do |source|
39
+ interpolated = interpolate_nonce(source)
40
+ nonce_used = true if interpolated != source
41
+ interpolated
42
+ end
34
43
  directive_string = "#{directive} #{processed_sources.join(' ')}"
35
44
  policy_directives << directive_string
36
45
  end
37
46
  end
38
47
 
39
- policy_directives.join('; ')
48
+ header = policy_directives.join('; ')
49
+
50
+ # Log CSP header generation for security audit
51
+ log_with_metadata(Rhales.logger, :debug, "CSP header generated",
52
+ nonce_used: nonce_used,
53
+ nonce: @nonce,
54
+ directive_count: policy_directives.size,
55
+ header_length: header.length
56
+ )
57
+
58
+ header
40
59
  end
41
60
 
42
61
  # Generate a new nonce value
43
62
  def self.generate_nonce
44
- SecureRandom.hex(16)
63
+ nonce = SecureRandom.hex(16)
64
+
65
+ # Log nonce generation for security audit trail
66
+ Rhales.logger.debug("CSP nonce generated: nonce=#{nonce} length=#{nonce.length} entropy_bits=#{nonce.length * 4}")
67
+
68
+ nonce
45
69
  end
46
70
 
47
71
  # Validate CSP policy configuration
@@ -0,0 +1,114 @@
1
+ # lib/rhales/utils/json_serializer.rb
2
+ #
3
+ # frozen_string_literal: true
4
+
5
+ module Rhales
6
+ # Centralized JSON serialization with optional Oj support
7
+ #
8
+ # This module provides a unified interface for JSON operations with automatic
9
+ # Oj optimization when available. Oj is 10-20x faster than stdlib JSON for
10
+ # parsing and 5-10x faster for generation.
11
+ #
12
+ # The serializer backend is determined once at load time for optimal performance.
13
+ # If you want to use Oj, require it before requiring Rhales:
14
+ #
15
+ # @example Ensuring Oj is used
16
+ # require 'oj'
17
+ # require 'rhales'
18
+ #
19
+ # @example Basic usage
20
+ # Rhales::JSONSerializer.dump({ user: 'Alice' })
21
+ # # => "{\"user\":\"Alice\"}"
22
+ #
23
+ # Rhales::JSONSerializer.parse('{"user":"Alice"}')
24
+ # # => {"user"=>"Alice"}
25
+ #
26
+ # @example Pretty printing
27
+ # Rhales::JSONSerializer.pretty_dump({ user: 'Alice', count: 42 })
28
+ # # => "{\n \"user\": \"Alice\",\n \"count\": 42\n}"
29
+ #
30
+ # @example Check backend
31
+ # Rhales::JSONSerializer.backend
32
+ # # => :oj (if available) or :json (stdlib)
33
+ #
34
+ module JSONSerializer
35
+ class << self
36
+ # Serialize Ruby object to JSON string
37
+ #
38
+ # Uses the serializer backend determined at load time (Oj or stdlib JSON).
39
+ #
40
+ # @param obj [Object] Ruby object to serialize
41
+ # @return [String] JSON string (compact format)
42
+ # @raise [TypeError] if object contains non-serializable types
43
+ def dump(obj)
44
+ @json_dumper.call(obj)
45
+ end
46
+
47
+ # Serialize Ruby object to pretty-printed JSON string
48
+ #
49
+ # Uses the serializer backend determined at load time (Oj or stdlib JSON).
50
+ # Output is formatted with indentation for readability.
51
+ #
52
+ # @param obj [Object] Ruby object to serialize
53
+ # @return [String] Pretty-printed JSON string with 2-space indentation
54
+ # @raise [TypeError] if object contains non-serializable types
55
+ def pretty_dump(obj)
56
+ @json_pretty_dumper.call(obj)
57
+ end
58
+
59
+ # Parse JSON string to Ruby object
60
+ #
61
+ # Uses the parser backend determined at load time (Oj or stdlib JSON).
62
+ # Always returns hashes with string keys (not symbols) for consistency.
63
+ #
64
+ # @param json_string [String] JSON string to parse
65
+ # @return [Object] parsed Ruby object (Hash with string keys)
66
+ # @raise [JSON::ParserError, Oj::ParseError] if JSON is malformed
67
+ def parse(json_string)
68
+ @json_loader.call(json_string)
69
+ end
70
+
71
+ # Returns the active JSON backend
72
+ #
73
+ # @return [Symbol] :oj or :json
74
+ def backend
75
+ @backend
76
+ end
77
+
78
+ # Reset backend detection (useful for testing)
79
+ #
80
+ # @api private
81
+ def reset!
82
+ detect_backend!
83
+ end
84
+
85
+ private
86
+
87
+ # Detect and configure JSON backend at load time
88
+ def detect_backend!
89
+ oj_available = begin
90
+ require 'oj'
91
+ true
92
+ rescue LoadError
93
+ false
94
+ end
95
+
96
+ if oj_available
97
+ @backend = :oj
98
+ @json_dumper = ->(obj) { Oj.dump(obj, mode: :strict) }
99
+ @json_pretty_dumper = ->(obj) { Oj.dump(obj, mode: :strict, indent: 2) }
100
+ @json_loader = ->(json_string) { Oj.load(json_string, mode: :strict, symbol_keys: false) }
101
+ else
102
+ require 'json'
103
+ @backend = :json
104
+ @json_dumper = ->(obj) { JSON.generate(obj) }
105
+ @json_pretty_dumper = ->(obj) { JSON.pretty_generate(obj) }
106
+ @json_loader = ->(json_string) { JSON.parse(json_string) }
107
+ end
108
+ end
109
+ end
110
+
111
+ # Initialize backend on load
112
+ detect_backend!
113
+ end
114
+ end