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
@@ -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
@@ -1,5 +1,9 @@
1
- require 'json'
1
+ # lib/rhales/hydration/hydration_endpoint.rb
2
+ #
3
+ # frozen_string_literal: true
4
+
2
5
  require 'digest'
6
+ require_relative '../utils/json_serializer'
3
7
 
4
8
  module Rhales
5
9
  # Handles API endpoint responses for link-based hydration strategies
@@ -48,7 +52,7 @@ module Rhales
48
52
  merged_data = process_template_data(template_name, additional_context)
49
53
 
50
54
  {
51
- content: JSON.generate(merged_data),
55
+ content: JSONSerializer.dump(merged_data),
52
56
  content_type: 'application/json',
53
57
  headers: json_headers(merged_data)
54
58
  }
@@ -63,7 +67,7 @@ module Rhales
63
67
  }
64
68
 
65
69
  {
66
- content: JSON.generate(error_data),
70
+ content: JSONSerializer.dump(error_data),
67
71
  content_type: 'application/json',
68
72
  headers: json_headers(error_data)
69
73
  }
@@ -78,7 +82,7 @@ module Rhales
78
82
  }
79
83
 
80
84
  {
81
- content: JSON.generate(error_data),
85
+ content: JSONSerializer.dump(error_data),
82
86
  content_type: 'application/json',
83
87
  headers: json_headers(error_data)
84
88
  }
@@ -89,7 +93,7 @@ module Rhales
89
93
  merged_data = process_template_data(template_name, additional_context)
90
94
 
91
95
  {
92
- content: "export default #{JSON.generate(merged_data)};",
96
+ content: "export default #{JSONSerializer.dump(merged_data)};",
93
97
  content_type: 'text/javascript',
94
98
  headers: module_headers(merged_data)
95
99
  }
@@ -100,7 +104,7 @@ module Rhales
100
104
  merged_data = process_template_data(template_name, additional_context)
101
105
 
102
106
  {
103
- content: "#{callback_name}(#{JSON.generate(merged_data)});",
107
+ content: "#{callback_name}(#{JSONSerializer.dump(merged_data)});",
104
108
  content_type: 'application/javascript',
105
109
  headers: jsonp_headers(merged_data),
106
110
  }
@@ -116,7 +120,7 @@ module Rhales
116
120
  def calculate_etag(template_name, additional_context = {})
117
121
  merged_data = process_template_data(template_name, additional_context)
118
122
  # Simple ETag based on data hash
119
- Digest::MD5.hexdigest(JSON.generate(merged_data))
123
+ Digest::MD5.hexdigest(JSONSerializer.dump(merged_data))
120
124
  end
121
125
 
122
126
  private
@@ -126,7 +130,7 @@ module Rhales
126
130
  template_context = create_template_context(additional_context)
127
131
 
128
132
  # Process template to extract hydration data
129
- view = View.new(@context.req, @context.sess, @context.cust, @context.locale, props: {})
133
+ view = View.new(@context.req, client: {})
130
134
  aggregator = HydrationDataAggregator.new(template_context)
131
135
 
132
136
  # Build composition to get template dependencies
@@ -149,11 +153,11 @@ module Rhales
149
153
  def create_template_context(additional_context)
150
154
  if @context
151
155
  # Merge additional context into existing context by reconstructing with merged props
152
- merged_props = @context.props.merge(additional_context)
153
- @context.class.for_view(@context.req, @context.sess, @context.cust, @context.locale, **merged_props)
156
+ merged_props = @context.client.merge(additional_context)
157
+ @context.class.for_view(@context.req, @context.locale, **merged_props)
154
158
  else
155
159
  # Create minimal context with just the additional data
156
- Context.minimal(props: additional_context)
160
+ Context.minimal(client: additional_context)
157
161
  end
158
162
  end
159
163
 
@@ -170,7 +174,7 @@ module Rhales
170
174
  end
171
175
 
172
176
  # Add ETag for caching
173
- headers['ETag'] = %("#{Digest::MD5.hexdigest(JSON.generate(data))}")
177
+ headers['ETag'] = %("#{Digest::MD5.hexdigest(JSONSerializer.dump(data))}")
174
178
 
175
179
  headers
176
180
  end
@@ -1,3 +1,7 @@
1
+ # lib/rhales/hydration/hydration_injector.rb
2
+ #
3
+ # frozen_string_literal: true
4
+
1
5
  require_relative 'earliest_injection_detector'
2
6
  require_relative 'link_based_injection_detector'
3
7
 
@@ -1,4 +1,6 @@
1
1
  # lib/rhales/hydration_registry.rb
2
+ #
3
+ # frozen_string_literal: true
2
4
 
3
5
  module Rhales
4
6
  # Registry to track window attributes used in hydration blocks
@@ -0,0 +1,102 @@
1
+ # lib/rhales/hydrator.rb
2
+ #
3
+ # frozen_string_literal: true
4
+
5
+ require 'securerandom'
6
+ require_relative '../utils/json_serializer'
7
+
8
+ module Rhales
9
+ # Data Hydrator for RSFC client-side data injection
10
+ #
11
+ # ## RSFC Security Model: Server-to-Client Security Boundary
12
+ #
13
+ # The Hydrator enforces a critical security boundary between server and client:
14
+ #
15
+ # ### Server Side (Template Rendering)
16
+ # - Templates have FULL server context access (like ERB/HAML)
17
+ # - Can access user objects, database connections, internal APIs
18
+ # - Can access secrets, configuration, authentication state
19
+ # - Can process sensitive business logic
20
+ #
21
+ # ### Client Side (Data Hydration)
22
+ # - Only data declared in <schema> sections reaches the browser
23
+ # - Creates explicit allowlist like designing a REST API
24
+ # - For <schema>: Direct props serialization (no interpolation)
25
+ # - JSON serialization validates data structure
26
+ #
27
+ # ### Process Flow (Schema-based, preferred)
28
+ # 1. Backend provides fully-resolved props to render call
29
+ # 2. Props are directly serialized as JSON
30
+ # 3. Client receives only the declared props
31
+ #
32
+ # ### Example (Schema-based)
33
+ # ```rue
34
+ # <schema lang="js-zod" window="appData">
35
+ # const schema = z.object({
36
+ # user_name: z.string(),
37
+ # theme: z.string()
38
+ # });
39
+ # </schema>
40
+ # ```
41
+ # Backend: render('template', user_name: user.name, theme: user.theme_preference)
42
+ #
43
+ #
44
+ # Server template can access {{user.admin?}} and {{internal_config}},
45
+ # but client only gets the declared user_name and theme values.
46
+ #
47
+ # This creates an API-like boundary where data is serialized once and
48
+ # parsed once, enforcing the same security model as REST endpoints.
49
+ #
50
+ # Note: With the new two-pass architecture, the Hydrator's role is
51
+ # greatly simplified. All data merging happens server-side in the
52
+ # HydrationDataAggregator, so this class only handles JSON generation
53
+ # for individual templates (used during the aggregation phase).
54
+ class Hydrator
55
+ class HydrationError < StandardError; end
56
+ class JSONSerializationError < HydrationError; end
57
+
58
+ attr_reader :parser, :context, :window_attribute
59
+
60
+ def initialize(parser, context)
61
+ @parser = parser
62
+ @context = context
63
+ @window_attribute = parser.window_attribute || 'data'
64
+ end
65
+
66
+ # Process <schema> section and return JSON string
67
+ def process_data_section
68
+ # Check for schema section
69
+ if @parser.schema_lang
70
+ # Schema section: Direct props serialization
71
+ JSONSerializer.dump(@context.client)
72
+ else
73
+ # No hydration section
74
+ '{}'
75
+ end
76
+ rescue JSON::ParserError => ex
77
+ raise JSONSerializationError, "Invalid JSON in schema section: #{ex.message}"
78
+ end
79
+
80
+ # Get processed data as Ruby hash (for internal use)
81
+ def processed_data_hash
82
+ json_string = process_data_section
83
+ JSONSerializer.parse(json_string)
84
+ rescue JSON::ParserError => ex
85
+ raise JSONSerializationError, "Cannot parse processed data as JSON: #{ex.message}"
86
+ end
87
+
88
+ private
89
+
90
+ class << self
91
+ # Generate only JSON data (for testing or API endpoints)
92
+ def generate_json(parser, context)
93
+ new(parser, context).process_data_section
94
+ end
95
+
96
+ # Generate data hash (for internal processing)
97
+ def generate_data_hash(parser, context)
98
+ new(parser, context).processed_data_hash
99
+ end
100
+ end
101
+ end
102
+ end