rhales 0.4.0 → 0.5.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (115) hide show
  1. checksums.yaml +4 -4
  2. data/.github/renovate.json5 +52 -0
  3. data/.github/workflows/ci.yml +123 -0
  4. data/.github/workflows/claude-code-review.yml +69 -0
  5. data/.github/workflows/claude.yml +49 -0
  6. data/.github/workflows/code-smells.yml +146 -0
  7. data/.github/workflows/ruby-lint.yml +78 -0
  8. data/.github/workflows/yardoc.yml +126 -0
  9. data/.gitignore +55 -0
  10. data/.pr_agent.toml +63 -0
  11. data/.pre-commit-config.yaml +89 -0
  12. data/.prettierignore +8 -0
  13. data/.prettierrc +38 -0
  14. data/.reek.yml +98 -0
  15. data/.rubocop.yml +428 -0
  16. data/.serena/.gitignore +3 -0
  17. data/.yardopts +56 -0
  18. data/CHANGELOG.md +44 -0
  19. data/CLAUDE.md +1 -1
  20. data/Gemfile +29 -0
  21. data/Gemfile.lock +189 -0
  22. data/README.md +686 -868
  23. data/Rakefile +46 -0
  24. data/debug_context.rb +25 -0
  25. data/demo/rhales-roda-demo/.gitignore +7 -0
  26. data/demo/rhales-roda-demo/Gemfile +32 -0
  27. data/demo/rhales-roda-demo/Gemfile.lock +151 -0
  28. data/demo/rhales-roda-demo/MAIL.md +405 -0
  29. data/demo/rhales-roda-demo/README.md +376 -0
  30. data/demo/rhales-roda-demo/RODA-TEMPLATE-ENGINES.md +192 -0
  31. data/demo/rhales-roda-demo/Rakefile +49 -0
  32. data/demo/rhales-roda-demo/app.rb +325 -0
  33. data/demo/rhales-roda-demo/bin/rackup +26 -0
  34. data/demo/rhales-roda-demo/config.ru +13 -0
  35. data/demo/rhales-roda-demo/db/migrate/001_create_rodauth_tables.rb +266 -0
  36. data/demo/rhales-roda-demo/db/migrate/002_create_rodauth_password_tables.rb +79 -0
  37. data/demo/rhales-roda-demo/db/migrate/003_add_admin_account.rb +68 -0
  38. data/demo/rhales-roda-demo/templates/change_login.rue +31 -0
  39. data/demo/rhales-roda-demo/templates/change_password.rue +36 -0
  40. data/demo/rhales-roda-demo/templates/close_account.rue +31 -0
  41. data/demo/rhales-roda-demo/templates/create_account.rue +40 -0
  42. data/demo/rhales-roda-demo/templates/dashboard.rue +150 -0
  43. data/demo/rhales-roda-demo/templates/home.rue +78 -0
  44. data/demo/rhales-roda-demo/templates/layouts/main.rue +168 -0
  45. data/demo/rhales-roda-demo/templates/login.rue +65 -0
  46. data/demo/rhales-roda-demo/templates/logout.rue +25 -0
  47. data/demo/rhales-roda-demo/templates/reset_password.rue +26 -0
  48. data/demo/rhales-roda-demo/templates/verify_account.rue +27 -0
  49. data/demo/rhales-roda-demo/test_full_output.rb +27 -0
  50. data/demo/rhales-roda-demo/test_simple.rb +24 -0
  51. data/docs/.gitignore +9 -0
  52. data/docs/architecture/data-flow.md +499 -0
  53. data/examples/dashboard-with-charts.rue +271 -0
  54. data/examples/form-with-validation.rue +180 -0
  55. data/examples/simple-page.rue +61 -0
  56. data/examples/vue.rue +136 -0
  57. data/generate-json-schemas.ts +158 -0
  58. data/json_schemer_migration_summary.md +172 -0
  59. data/lib/rhales/adapters/base_auth.rb +2 -0
  60. data/lib/rhales/adapters/base_request.rb +2 -0
  61. data/lib/rhales/adapters/base_session.rb +2 -0
  62. data/lib/rhales/adapters.rb +7 -0
  63. data/lib/rhales/configuration.rb +47 -0
  64. data/lib/rhales/core/context.rb +354 -0
  65. data/lib/rhales/{rue_document.rb → core/rue_document.rb} +56 -38
  66. data/lib/rhales/{template_engine.rb → core/template_engine.rb} +66 -59
  67. data/lib/rhales/{view.rb → core/view.rb} +112 -135
  68. data/lib/rhales/{view_composition.rb → core/view_composition.rb} +78 -8
  69. data/lib/rhales/core.rb +9 -0
  70. data/lib/rhales/errors/hydration_collision_error.rb +2 -0
  71. data/lib/rhales/errors.rb +2 -0
  72. data/lib/rhales/{earliest_injection_detector.rb → hydration/earliest_injection_detector.rb} +4 -0
  73. data/lib/rhales/hydration/hydration_data_aggregator.rb +487 -0
  74. data/lib/rhales/{hydration_endpoint.rb → hydration/hydration_endpoint.rb} +16 -12
  75. data/lib/rhales/{hydration_injector.rb → hydration/hydration_injector.rb} +4 -0
  76. data/lib/rhales/{hydration_registry.rb → hydration/hydration_registry.rb} +2 -0
  77. data/lib/rhales/hydration/hydrator.rb +102 -0
  78. data/lib/rhales/{link_based_injection_detector.rb → hydration/link_based_injection_detector.rb} +4 -0
  79. data/lib/rhales/{mount_point_detector.rb → hydration/mount_point_detector.rb} +4 -0
  80. data/lib/rhales/{safe_injection_validator.rb → hydration/safe_injection_validator.rb} +4 -0
  81. data/lib/rhales/hydration.rb +13 -0
  82. data/lib/rhales/{refinements → integrations/refinements}/require_refinements.rb +3 -1
  83. data/lib/rhales/{tilt.rb → integrations/tilt.rb} +22 -15
  84. data/lib/rhales/integrations.rb +6 -0
  85. data/lib/rhales/middleware/json_responder.rb +191 -0
  86. data/lib/rhales/middleware/schema_validator.rb +300 -0
  87. data/lib/rhales/middleware.rb +6 -0
  88. data/lib/rhales/parsers/handlebars_parser.rb +2 -0
  89. data/lib/rhales/parsers/rue_format_parser.rb +9 -7
  90. data/lib/rhales/parsers.rb +9 -0
  91. data/lib/rhales/{csp.rb → security/csp.rb} +27 -3
  92. data/lib/rhales/utils/json_serializer.rb +114 -0
  93. data/lib/rhales/utils/logging_helpers.rb +75 -0
  94. data/lib/rhales/utils/schema_extractor.rb +132 -0
  95. data/lib/rhales/utils/schema_generator.rb +194 -0
  96. data/lib/rhales/utils.rb +40 -0
  97. data/lib/rhales/version.rb +3 -1
  98. data/lib/rhales.rb +41 -24
  99. data/lib/tasks/rhales_schema.rake +197 -0
  100. data/package.json +10 -0
  101. data/pnpm-lock.yaml +345 -0
  102. data/pnpm-workspace.yaml +2 -0
  103. data/proofs/error_handling.rb +79 -0
  104. data/proofs/expanded_object_inheritance.rb +82 -0
  105. data/proofs/partial_context_scoping_fix.rb +168 -0
  106. data/proofs/ui_context_partial_inheritance.rb +236 -0
  107. data/rhales.gemspec +14 -6
  108. data/schema_vs_data_comparison.md +254 -0
  109. data/test_direct_access.rb +36 -0
  110. metadata +141 -23
  111. data/CLAUDE.locale.txt +0 -7
  112. data/lib/rhales/context.rb +0 -239
  113. data/lib/rhales/hydration_data_aggregator.rb +0 -221
  114. data/lib/rhales/hydrator.rb +0 -141
  115. data/lib/rhales/parsers/handlebars-grammar-review.txt +0 -39
@@ -1,7 +1,9 @@
1
1
  # lib/rhales/view_composition.rb
2
+ #
3
+ # frozen_string_literal: true
2
4
 
3
5
  require_relative 'rue_document'
4
- require_relative 'refinements/require_refinements'
6
+ require_relative '../integrations/refinements/require_refinements'
5
7
 
6
8
  using Rhales::Ruequire
7
9
 
@@ -23,6 +25,8 @@ module Rhales
23
25
  # - Immutable: Once created, the composition is read-only
24
26
  # - Cacheable: Can be cached in production for performance
25
27
  class ViewComposition
28
+ include Rhales::Utils::LoggingHelpers
29
+
26
30
  class TemplateNotFoundError < StandardError; end
27
31
  class CircularDependencyError < StandardError; end
28
32
 
@@ -39,9 +43,23 @@ module Rhales
39
43
 
40
44
  # Resolve all template dependencies
41
45
  def resolve!
42
- load_template_recursive(@root_template_name)
43
- freeze_composition
44
- self
46
+ log_timed_operation(Rhales.logger, :debug, 'Template dependency resolution',
47
+ root_template: @root_template_name
48
+ ) do
49
+ load_template_recursive(@root_template_name)
50
+ freeze_composition
51
+
52
+ # Log resolution results
53
+ log_with_metadata(Rhales.logger, :debug, 'Template composition resolved',
54
+ root_template: @root_template_name,
55
+ total_templates: @templates.size,
56
+ total_dependencies: @dependencies.values.sum(&:size),
57
+ partials: @dependencies.values.flatten.uniq,
58
+ layout: layout
59
+ )
60
+
61
+ self
62
+ end
45
63
  end
46
64
 
47
65
  # Iterate through all documents in render order
@@ -84,25 +102,50 @@ module Rhales
84
102
  @dependencies[template_name] || []
85
103
  end
86
104
 
105
+ # Get the layout for the root template (if any)
106
+ def layout
107
+ root_template = @templates[@root_template_name]
108
+ return nil unless root_template
109
+
110
+ root_template.layout
111
+ end
87
112
 
88
113
  private
89
114
 
90
- def load_template_recursive(template_name, _parent_path = nil)
115
+ def load_template_recursive(template_name, parent_path = nil)
116
+ depth = @loading.size
117
+
91
118
  # Check for circular dependencies
92
119
  if @loading.include?(template_name)
120
+ log_with_metadata(Rhales.logger, :error, 'Circular dependency detected',
121
+ template: template_name,
122
+ dependency_chain: @loading.to_a,
123
+ depth: depth
124
+ )
93
125
  raise CircularDependencyError, "Circular dependency detected: #{template_name} -> #{@loading.to_a.join(' -> ')}"
94
126
  end
95
127
 
96
- # Skip if already loaded
97
- return if @templates.key?(template_name)
128
+ # Skip if already loaded (cache hit)
129
+ if @templates.key?(template_name)
130
+ Rhales.logger.debug("Template cache hit: template=#{template_name}")
131
+ return
132
+ end
98
133
 
99
134
  @loading.add(template_name)
100
135
 
101
136
  begin
102
137
  # Load template using the provided loader
138
+ start_time = now_in_μs
103
139
  parser = @loader.call(template_name)
140
+ load_duration = now_in_μs - start_time
104
141
 
105
142
  unless parser
143
+ log_with_metadata(Rhales.logger, :error, 'Template not found',
144
+ template: template_name,
145
+ parent: parent_path,
146
+ depth: depth,
147
+ search_duration: load_duration
148
+ )
106
149
  raise TemplateNotFoundError, "Template not found: #{template_name}"
107
150
  end
108
151
 
@@ -111,15 +154,42 @@ module Rhales
111
154
  @dependencies[template_name] = []
112
155
 
113
156
  # Extract and load partials
114
- extract_partials(parser).each do |partial_name|
157
+ partials = extract_partials(parser)
158
+
159
+ if partials.any?
160
+ log_with_metadata(Rhales.logger, :debug, 'Resolved partial',
161
+ template: template_name,
162
+ partials_found: partials,
163
+ partial_count: partials.size,
164
+ depth: depth,
165
+ load_duration: load_duration
166
+ )
167
+ end
168
+
169
+ partials.each do |partial_name|
115
170
  @dependencies[template_name] << partial_name
116
171
  load_template_recursive(partial_name, template_name)
117
172
  end
118
173
 
119
174
  # Load layout if specified and not already loaded
120
175
  if parser.layout && !@templates.key?(parser.layout)
176
+ log_with_metadata(Rhales.logger, :debug, 'Layout resolution',
177
+ template: template_name,
178
+ layout: parser.layout,
179
+ depth: depth
180
+ )
121
181
  load_template_recursive(parser.layout, template_name)
122
182
  end
183
+
184
+ # Log successful template load
185
+ log_with_metadata(Rhales.logger, :debug, 'Template loaded',
186
+ template: template_name,
187
+ parent: parent_path,
188
+ depth: depth,
189
+ has_partials: partials.any?,
190
+ has_layout: !parser.layout.nil?,
191
+ load_duration: load_duration
192
+ )
123
193
  ensure
124
194
  @loading.delete(template_name)
125
195
  end
@@ -0,0 +1,9 @@
1
+ # lib/rhales/core.rb
2
+ #
3
+ # frozen_string_literal: true
4
+
5
+ require_relative 'core/context'
6
+ require_relative 'core/rue_document'
7
+ require_relative 'core/template_engine'
8
+ require_relative 'core/view_composition'
9
+ require_relative 'core/view'
@@ -1,4 +1,6 @@
1
1
  # lib/rhales/errors/hydration_collision_error.rb
2
+ #
3
+ # frozen_string_literal: true
2
4
 
3
5
  module Rhales
4
6
  class HydrationCollisionError < Error
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
@@ -1,3 +1,7 @@
1
+ # lib/rhales/hydration/earliest_injection_detector.rb
2
+ #
3
+ # frozen_string_literal: true
4
+
1
5
  require 'strscan'
2
6
  require_relative 'safe_injection_validator'
3
7
 
@@ -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