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
@@ -1,3 +1,5 @@
1
+ # lib/rhales/errors/hydration_collision_error.rb
2
+ #
1
3
  # frozen_string_literal: true
2
4
 
3
5
  module Rhales
data/lib/rhales/errors.rb CHANGED
@@ -1,4 +1,6 @@
1
1
  # lib/rhales/errors.rb
2
+ #
3
+ # frozen_string_literal: true
2
4
 
3
5
  module Rhales
4
6
  class Error < StandardError; end
@@ -0,0 +1,153 @@
1
+ # lib/rhales/hydration/earliest_injection_detector.rb
2
+ #
3
+ # frozen_string_literal: true
4
+
5
+ require 'strscan'
6
+ require_relative 'safe_injection_validator'
7
+
8
+ module Rhales
9
+ # Detects the earliest safe injection points in HTML head and body sections
10
+ # for optimal hydration script placement performance
11
+ #
12
+ # ## Injection Priority Order
13
+ #
14
+ # For `<head></head>` section:
15
+ # 1. After the last `<link>` tag
16
+ # 2. After the last `<meta>` tag
17
+ # 3. After the first `<script>` tag (assuming early scripts are intentional)
18
+ # 4. Before the `</head>` tag
19
+ #
20
+ # If no `<head>` but there is `<body>`:
21
+ # - Before the `<body>` tag
22
+ #
23
+ # All injection points are validated for safety using SafeInjectionValidator
24
+ # to prevent injection inside unsafe contexts (scripts, styles, comments).
25
+ class EarliestInjectionDetector
26
+ def detect(template_html)
27
+ scanner = StringScanner.new(template_html)
28
+ validator = SafeInjectionValidator.new(template_html)
29
+
30
+ # Try head section injection points first
31
+ head_injection_point = detect_head_injection_point(scanner, validator, template_html)
32
+ return head_injection_point if head_injection_point
33
+
34
+ # Fallback to body tag injection
35
+ body_injection_point = detect_body_injection_point(scanner, validator, template_html)
36
+ return body_injection_point if body_injection_point
37
+
38
+ # No suitable injection point found
39
+ nil
40
+ end
41
+
42
+ private
43
+
44
+ def detect_head_injection_point(scanner, validator, template_html)
45
+ # Find head section bounds
46
+ head_start, head_end = find_head_section(template_html)
47
+ return nil unless head_start && head_end
48
+
49
+ # Try injection points in priority order within head section
50
+ injection_candidates = [
51
+ find_after_last_link(template_html, head_start, head_end),
52
+ find_after_last_meta(template_html, head_start, head_end),
53
+ find_after_first_script(template_html, head_start, head_end),
54
+ head_end # Before </head>
55
+ ].compact
56
+
57
+ # Return first safe injection point
58
+ injection_candidates.each do |position|
59
+ safe_position = find_safe_injection_position(validator, position)
60
+ return safe_position if safe_position
61
+ end
62
+
63
+ nil
64
+ end
65
+
66
+ def detect_body_injection_point(scanner, validator, template_html)
67
+ scanner.pos = 0
68
+
69
+ # Find opening <body> tag
70
+ if scanner.scan_until(/<body\b[^>]*>/i)
71
+ body_start = scanner.pos - scanner.matched.length
72
+ safe_position = find_safe_injection_position(validator, body_start)
73
+ return safe_position if safe_position
74
+ end
75
+
76
+ nil
77
+ end
78
+
79
+ def find_head_section(template_html)
80
+ scanner = StringScanner.new(template_html)
81
+
82
+ # Find opening <head> tag
83
+ return nil unless scanner.scan_until(/<head\b[^>]*>/i)
84
+ head_start = scanner.pos
85
+
86
+ # Find closing </head> tag
87
+ return nil unless scanner.scan_until(/<\/head>/i)
88
+ head_end = scanner.pos - scanner.matched.length
89
+
90
+ [head_start, head_end]
91
+ end
92
+
93
+ def find_after_last_link(template_html, head_start, head_end)
94
+ head_content = template_html[head_start...head_end]
95
+ scanner = StringScanner.new(head_content)
96
+ last_link_end = nil
97
+
98
+ while scanner.scan_until(/<link\b[^>]*\/?>/i)
99
+ last_link_end = scanner.pos
100
+ end
101
+
102
+ last_link_end ? head_start + last_link_end : nil
103
+ end
104
+
105
+ def find_after_last_meta(template_html, head_start, head_end)
106
+ head_content = template_html[head_start...head_end]
107
+ scanner = StringScanner.new(head_content)
108
+ last_meta_end = nil
109
+
110
+ while scanner.scan_until(/<meta\b[^>]*\/?>/i)
111
+ last_meta_end = scanner.pos
112
+ end
113
+
114
+ last_meta_end ? head_start + last_meta_end : nil
115
+ end
116
+
117
+ def find_after_first_script(template_html, head_start, head_end)
118
+ head_content = template_html[head_start...head_end]
119
+ scanner = StringScanner.new(head_content)
120
+
121
+ # Find first script opening tag
122
+ if scanner.scan_until(/<script\b[^>]*>/i)
123
+ script_start = scanner.pos - scanner.matched.length
124
+
125
+ # Find corresponding closing tag
126
+ if scanner.scan_until(/<\/script>/i)
127
+ first_script_end = scanner.pos
128
+ return head_start + first_script_end
129
+ end
130
+ end
131
+
132
+ nil
133
+ end
134
+
135
+ def find_safe_injection_position(validator, preferred_position)
136
+ return nil unless preferred_position
137
+
138
+ # First check if the preferred position is safe
139
+ return preferred_position if validator.safe_injection_point?(preferred_position)
140
+
141
+ # Try to find a safe position before the preferred position
142
+ safe_before = validator.nearest_safe_point_before(preferred_position)
143
+ return safe_before if safe_before
144
+
145
+ # As a last resort, try after the preferred position
146
+ safe_after = validator.nearest_safe_point_after(preferred_position)
147
+ return safe_after if safe_after
148
+
149
+ # No safe position found
150
+ nil
151
+ end
152
+ end
153
+ end
@@ -0,0 +1,487 @@
1
+ # lib/rhales/hydration_data_aggregator.rb
2
+ #
3
+ # frozen_string_literal: true
4
+
5
+ require 'json'
6
+ require_relative '../core/template_engine'
7
+ require_relative '../errors'
8
+
9
+ module Rhales
10
+ # HydrationDataAggregator traverses the ViewComposition and executes
11
+ # all <schema> sections to produce a single, merged JSON structure.
12
+ #
13
+ # This class implements the server-side data aggregation phase of the
14
+ # two-pass rendering model, handling:
15
+ # - Traversal of the template dependency tree
16
+ # - Direct serialization of props for <schema> sections
17
+ # - Merge strategies (deep, shallow, strict)
18
+ # - Collision detection and error reporting
19
+ #
20
+ # The aggregator replaces the HydrationRegistry by performing all
21
+ # data merging in a single, coordinated pass.
22
+ class HydrationDataAggregator
23
+ include Rhales::Utils::LoggingHelpers
24
+
25
+ class JSONSerializationError < StandardError; end
26
+
27
+ def initialize(context)
28
+ @context = context
29
+ @window_attributes = {}
30
+ @merged_data = {}
31
+ @schema_cache = {}
32
+ @schemas_dir = File.join(Dir.pwd, 'public/schemas')
33
+ end
34
+
35
+ # Aggregate all hydration data from the view composition
36
+ def aggregate(composition)
37
+ log_timed_operation(Rhales.logger, :debug, 'Schema aggregation started',
38
+ template_count: composition.template_names.size
39
+ ) do
40
+ composition.each_document_in_render_order do |template_name, parser|
41
+ process_template(template_name, parser)
42
+ end
43
+
44
+ @merged_data
45
+ end
46
+ end
47
+
48
+ private
49
+
50
+ # Log hydration schema mismatch using configured format
51
+ def log_hydration_mismatch(template_path, window_attr, expected, actual, missing, extra, data_size)
52
+ format = Rhales.config.hydration_mismatch_format || :compact
53
+
54
+ formatter = case format
55
+ when :sidebyside
56
+ format_sidebyside(template_path, window_attr, expected, actual, missing, extra, data_size)
57
+ when :multiline
58
+ format_multiline(template_path, window_attr, expected, actual, missing, extra, data_size)
59
+ when :compact
60
+ format_compact(template_path, window_attr, expected, actual, missing, extra, data_size)
61
+ when :json
62
+ format_json(template_path, window_attr, expected, actual, missing, extra, data_size)
63
+ else
64
+ raise ArgumentError, "Unknown hydration_mismatch_format: #{format}. Valid: :compact, :multiline, :sidebyside, :json"
65
+ end
66
+
67
+ Rhales.logger.warn formatter
68
+ end
69
+
70
+ # Format: Side-by-side comparison (most visual, for development)
71
+ def format_sidebyside(template_path, window_attr, expected, actual, missing, extra, data_size)
72
+ # Determine authority: schema (default) or data
73
+ authority = Rhales.config.hydration_authority || :schema
74
+
75
+ lines = []
76
+ lines << 'Hydration schema mismatch'
77
+ lines << " Template: #{template_path}"
78
+ lines << " Window: #{window_attr} (#{data_size} keys)"
79
+ lines << ''
80
+
81
+ if authority == :schema
82
+ # Schema is correct, data needs fixing
83
+ lines << ' Schema (correct) │ Data (fix)'
84
+ lines << ' ─────────────────┼────────────'
85
+
86
+ # Show all expected keys with their status
87
+ expected.each do |key|
88
+ lines << if actual.include?(key)
89
+ " #{key.ljust(17)}│ ✓ #{key}"
90
+ else
91
+ " #{key.ljust(17)}│ ✗ missing ← add to data source"
92
+ end
93
+ end
94
+
95
+ # Show extra keys that shouldn't be in data
96
+ extra.each do |key|
97
+ lines << " (not in schema) │ ✗ #{key} ← remove from data source"
98
+ end
99
+ else
100
+ # Data is correct, schema needs fixing
101
+ lines << ' Schema (fix) │ Data (correct)'
102
+ lines << ' ─────────────────┼───────────────'
103
+
104
+ # Show all actual keys with their status
105
+ actual.each do |key|
106
+ lines << if expected.include?(key)
107
+ " #{key.ljust(17)}│ ✓ #{key}"
108
+ else
109
+ " [missing] │ ✓ #{key} ← add to schema"
110
+ end
111
+ end
112
+
113
+ # Show keys in schema but not in data
114
+ missing.each do |key|
115
+ lines << " #{key.ljust(17)}│ (not in data) ← remove from schema"
116
+ end
117
+ end
118
+
119
+ lines.join("\n")
120
+ end
121
+
122
+ # Format: Multi-line with visual indicators (balanced)
123
+ def format_multiline(template_path, window_attr, expected, actual, missing, extra, data_size)
124
+ # Check if order changed (same keys, different positions)
125
+ order_changed = (expected.to_set == actual.to_set) && (expected != actual)
126
+
127
+ # Find keys that moved position
128
+ moved_keys = if order_changed
129
+ (expected & actual).select do |k|
130
+ expected.index(k) != actual.index(k)
131
+ end
132
+ else
133
+ []
134
+ end
135
+
136
+ lines = []
137
+ lines << 'Hydration schema mismatch'
138
+ lines << " Template: #{template_path}"
139
+ lines << " Window: #{window_attr}"
140
+ lines << " Data size: #{data_size}"
141
+
142
+ if missing.any?
143
+ lines << " ✗ Schema expects (#{missing.size}): #{missing.join(', ')}"
144
+ end
145
+
146
+ if extra.any?
147
+ lines << " + Data provides (#{extra.size}): #{extra.join(', ')}"
148
+ end
149
+
150
+ if moved_keys.any?
151
+ lines << " ↔ Order changed (#{moved_keys.size}): #{moved_keys.join(', ')}"
152
+ end
153
+
154
+ lines.join("\n")
155
+ end
156
+
157
+ # Format: Single line compact (for production)
158
+ def format_compact(template_path, window_attr, _expected, _actual, missing, extra, data_size)
159
+ parts = []
160
+ parts << "#{missing.size} missing" if missing.any?
161
+ parts << "#{extra.size} extra" if extra.any?
162
+
163
+ summary = parts.join(', ')
164
+ metadata = {
165
+ template: template_path,
166
+ window_attribute: window_attr,
167
+ missing_keys: missing,
168
+ extra_keys: extra,
169
+ client_data_size: data_size,
170
+ }
171
+
172
+ # Use existing metadata formatter
173
+ metadata_str = metadata.map do |k, v|
174
+ "#{k}=#{format_metadata_value(v)}"
175
+ end.join(' ')
176
+
177
+ "Schema mismatch (#{summary}): #{metadata_str}"
178
+ end
179
+
180
+ # Format: JSON (for structured logging systems)
181
+ def format_json(template_path, window_attr, expected, actual, missing, extra, data_size)
182
+ require 'json'
183
+
184
+ authority = Rhales.config.hydration_authority || :schema
185
+
186
+ # Check if order changed
187
+ order_changed = (expected.to_set == actual.to_set) && (expected != actual)
188
+ moved_keys = if order_changed
189
+ (expected & actual).select { |k| expected.index(k) != actual.index(k) }
190
+ else
191
+ []
192
+ end
193
+
194
+ data = {
195
+ event: 'hydration_schema_mismatch',
196
+ template: template_path,
197
+ window_attribute: window_attr,
198
+ authority: authority,
199
+ schema: {
200
+ expected_keys: expected,
201
+ key_count: expected.size,
202
+ },
203
+ data: {
204
+ actual_keys: actual,
205
+ key_count: data_size,
206
+ },
207
+ diff: {
208
+ missing_keys: missing,
209
+ missing_count: missing.size,
210
+ extra_keys: extra,
211
+ extra_count: extra.size,
212
+ order_changed: order_changed,
213
+ moved_keys: moved_keys,
214
+ },
215
+ }
216
+
217
+ JSON.generate(data)
218
+ end
219
+
220
+ # Format values for compact metadata output
221
+ def format_metadata_value(value)
222
+ case value
223
+ when Array
224
+ if value.empty?
225
+ '[]'
226
+ else
227
+ "[#{value.join(', ')}]"
228
+ end
229
+ when String
230
+ value.include?(' ') ? "\"#{value}\"" : value
231
+ else
232
+ value.to_s
233
+ end
234
+ end
235
+
236
+ def process_template(template_name, parser)
237
+ # Process schema section
238
+ if parser.schema_lang
239
+ log_timed_operation(Rhales.logger, :debug, 'Schema validation',
240
+ template: template_name,
241
+ schema_lang: parser.schema_lang
242
+ ) do
243
+ process_schema_section(template_name, parser)
244
+ end
245
+ end
246
+ end
247
+
248
+ # Process schema section: Direct JSON serialization
249
+ def process_schema_section(template_name, parser)
250
+ window_attr = parser.schema_window || 'data'
251
+ merge_strategy = parser.schema_merge_strategy
252
+
253
+ # Extract client data for validation
254
+ client_data = @context.client || {}
255
+ schema_content = parser.section('schema')
256
+ expected_keys = extract_expected_keys(template_name, schema_content) if schema_content
257
+
258
+ # Build template path for error reporting
259
+ template_path = build_template_path_for_schema(parser)
260
+
261
+ # Log schema validation details
262
+ if expected_keys && expected_keys.any?
263
+ actual_keys = client_data.keys.map(&:to_s)
264
+ missing_keys = expected_keys - actual_keys
265
+ extra_keys = actual_keys - expected_keys
266
+
267
+ if missing_keys.any? || extra_keys.any?
268
+ log_hydration_mismatch(
269
+ Rhales.pretty_path(template_path),
270
+ window_attr,
271
+ expected_keys,
272
+ actual_keys,
273
+ missing_keys,
274
+ extra_keys,
275
+ client_data.size,
276
+ )
277
+ else
278
+ log_with_metadata(Rhales.logger, :debug, 'Schema validation passed',
279
+ template: Rhales.pretty_path(template_path),
280
+ window_attribute: window_attr,
281
+ key_count: expected_keys.size,
282
+ client_data_size: client_data.size
283
+ )
284
+ end
285
+ end
286
+
287
+ # Direct serialization of client data (no template interpolation)
288
+ processed_data = @context.client
289
+
290
+ # Check for collisions only if the data is not empty
291
+ if @window_attributes.key?(window_attr) && merge_strategy.nil? && !empty_data?(processed_data)
292
+ existing = @window_attributes[window_attr]
293
+ existing_data = @merged_data[window_attr]
294
+
295
+ # Only raise collision error if existing data is also not empty
296
+ unless empty_data?(existing_data)
297
+ raise ::Rhales::HydrationCollisionError.new(window_attr, existing[:path], template_path)
298
+ end
299
+ end
300
+
301
+ # Merge or set the data
302
+ @merged_data[window_attr] = if @merged_data.key?(window_attr)
303
+ merge_data(
304
+ @merged_data[window_attr],
305
+ processed_data,
306
+ merge_strategy || 'deep',
307
+ window_attr,
308
+ template_path,
309
+ )
310
+ else
311
+ processed_data
312
+ end
313
+
314
+ # Track the window attribute
315
+ @window_attributes[window_attr] = {
316
+ path: template_path,
317
+ merge_strategy: merge_strategy,
318
+ section_type: :schema,
319
+ }
320
+ end
321
+
322
+ def merge_data(target, source, strategy, window_attr, template_path)
323
+ case strategy
324
+ when 'deep'
325
+ deep_merge(target, source)
326
+ when 'shallow'
327
+ shallow_merge(target, source, window_attr, template_path)
328
+ when 'strict'
329
+ strict_merge(target, source, window_attr, template_path)
330
+ else
331
+ raise ArgumentError, "Unknown merge strategy: #{strategy}"
332
+ end
333
+ end
334
+
335
+ def deep_merge(target, source)
336
+ result = target.dup
337
+
338
+ source.each do |key, value|
339
+ result[key] = if result.key?(key) && result[key].is_a?(Hash) && value.is_a?(Hash)
340
+ deep_merge(result[key], value)
341
+ else
342
+ value
343
+ end
344
+ end
345
+
346
+ result
347
+ end
348
+
349
+ def shallow_merge(target, source, window_attr, template_path)
350
+ result = target.dup
351
+
352
+ source.each do |key, value|
353
+ if result.key?(key)
354
+ raise ::Rhales::HydrationCollisionError.new(
355
+ "#{window_attr}.#{key}",
356
+ @window_attributes[window_attr][:path],
357
+ template_path,
358
+ )
359
+ end
360
+ result[key] = value
361
+ end
362
+
363
+ result
364
+ end
365
+
366
+ def strict_merge(target, source, window_attr, template_path)
367
+ # In strict mode, any collision is an error
368
+ target.each_key do |key|
369
+ next unless source.key?(key)
370
+
371
+ raise ::Rhales::HydrationCollisionError.new(
372
+ "#{window_attr}.#{key}",
373
+ @window_attributes[window_attr][:path],
374
+ template_path,
375
+ )
376
+ end
377
+
378
+ target.merge(source)
379
+ end
380
+
381
+ def build_template_path_for_schema(parser)
382
+ schema_node = parser.section_node('schema')
383
+ line_number = schema_node ? schema_node.location.start_line : 1
384
+
385
+ if parser.file_path
386
+ "#{parser.file_path}:#{line_number}"
387
+ else
388
+ "<inline>:#{line_number}"
389
+ end
390
+ end
391
+
392
+ # Check if data is considered empty for collision detection
393
+ def empty_data?(data)
394
+ return true if data.nil?
395
+ return true if data == {}
396
+ return true if data == []
397
+ return true if data.respond_to?(:empty?) && data.empty?
398
+
399
+ false
400
+ end
401
+
402
+ # Extract expected keys using hybrid approach
403
+ #
404
+ # Tries to load pre-generated JSON schema first (reliable, handles all Zod patterns).
405
+ # Falls back to regex parsing for development (before schemas are generated).
406
+ #
407
+ # To generate JSON schemas, run: rake rhales:schema:generate
408
+ def extract_expected_keys(template_name, schema_content)
409
+ # Try JSON schema first (reliable, comprehensive)
410
+ keys = extract_keys_from_json_schema(template_name)
411
+ if keys&.any?
412
+ log_with_metadata(Rhales.logger, :debug, 'Schema keys extracted from JSON schema',
413
+ template: template_name, key_count: keys.size, method: 'json_schema'
414
+ )
415
+ return keys
416
+ end
417
+
418
+ # Fall back to regex (development, before schemas generated)
419
+ keys = extract_keys_from_zod_regex(schema_content)
420
+ if keys.any?
421
+ log_with_metadata(Rhales.logger, :debug, 'Schema keys extracted from Zod regex',
422
+ template: template_name, key_count: keys.size, method: 'regex_fallback',
423
+ note: 'Run rake rhales:schema:generate for reliable validation'
424
+ )
425
+ end
426
+
427
+ keys
428
+ end
429
+
430
+ # Extract keys from pre-generated JSON schema (preferred method)
431
+ def extract_keys_from_json_schema(template_name)
432
+ schema = load_schema_cached(template_name)
433
+ return nil unless schema
434
+
435
+ # Extract all properties from JSON schema
436
+ properties = schema.dig('properties') || {}
437
+ properties.keys
438
+ rescue StandardError => ex
439
+ log_with_metadata(Rhales.logger, :debug, 'JSON schema loading failed',
440
+ template: template_name, error: ex.message
441
+ )
442
+ nil
443
+ end
444
+
445
+ # Load and cache JSON schema from disk
446
+ def load_schema_cached(template_name)
447
+ @schema_cache[template_name] ||= begin
448
+ schema_path = File.join(@schemas_dir, "#{template_name}.json")
449
+ return nil unless File.exist?(schema_path)
450
+
451
+ JSON.parse(File.read(schema_path))
452
+ rescue JSON::ParserError, Errno::ENOENT => ex
453
+ log_with_metadata(Rhales.logger, :debug, 'Schema file error',
454
+ template: template_name, path: schema_path, error: ex.class.name
455
+ )
456
+ nil
457
+ end
458
+ end
459
+
460
+ # Extract keys from Zod schema using regex (fallback method)
461
+ #
462
+ # NOTE: This is a basic implementation that only matches simple patterns like:
463
+ # fieldName: z.string()
464
+ #
465
+ # It will miss:
466
+ # - Nested object literals: settings: { theme: z.enum([...]) }
467
+ # - Complex compositions and unions
468
+ # - Multiline definitions
469
+ #
470
+ # For reliable validation, generate JSON schemas with: rake rhales:schema:generate
471
+ def extract_keys_from_zod_regex(schema_content)
472
+ return [] unless schema_content
473
+
474
+ keys = []
475
+ schema_content.scan(/(\w+):\s*z\./) do |match|
476
+ keys << match[0]
477
+ end
478
+ keys
479
+ rescue StandardError => ex
480
+ log_with_metadata(Rhales.logger, :debug, 'Regex key extraction failed',
481
+ error: ex.message,
482
+ schema_preview: schema_content[0..100]
483
+ )
484
+ []
485
+ end
486
+ end
487
+ end