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.
- checksums.yaml +4 -4
- data/.github/renovate.json5 +52 -0
- data/.github/workflows/ci.yml +123 -0
- data/.github/workflows/claude-code-review.yml +69 -0
- data/.github/workflows/claude.yml +49 -0
- data/.github/workflows/code-smells.yml +146 -0
- data/.github/workflows/ruby-lint.yml +78 -0
- data/.github/workflows/yardoc.yml +126 -0
- data/.gitignore +55 -0
- data/.pr_agent.toml +63 -0
- data/.pre-commit-config.yaml +89 -0
- data/.prettierignore +8 -0
- data/.prettierrc +38 -0
- data/.reek.yml +98 -0
- data/.rubocop.yml +428 -0
- data/.serena/.gitignore +3 -0
- data/.yardopts +56 -0
- data/CHANGELOG.md +44 -0
- data/CLAUDE.md +1 -1
- data/Gemfile +29 -0
- data/Gemfile.lock +189 -0
- data/README.md +686 -868
- data/Rakefile +46 -0
- data/debug_context.rb +25 -0
- data/demo/rhales-roda-demo/.gitignore +7 -0
- data/demo/rhales-roda-demo/Gemfile +32 -0
- data/demo/rhales-roda-demo/Gemfile.lock +151 -0
- data/demo/rhales-roda-demo/MAIL.md +405 -0
- data/demo/rhales-roda-demo/README.md +376 -0
- data/demo/rhales-roda-demo/RODA-TEMPLATE-ENGINES.md +192 -0
- data/demo/rhales-roda-demo/Rakefile +49 -0
- data/demo/rhales-roda-demo/app.rb +325 -0
- data/demo/rhales-roda-demo/bin/rackup +26 -0
- data/demo/rhales-roda-demo/config.ru +13 -0
- data/demo/rhales-roda-demo/db/migrate/001_create_rodauth_tables.rb +266 -0
- data/demo/rhales-roda-demo/db/migrate/002_create_rodauth_password_tables.rb +79 -0
- data/demo/rhales-roda-demo/db/migrate/003_add_admin_account.rb +68 -0
- data/demo/rhales-roda-demo/templates/change_login.rue +31 -0
- data/demo/rhales-roda-demo/templates/change_password.rue +36 -0
- data/demo/rhales-roda-demo/templates/close_account.rue +31 -0
- data/demo/rhales-roda-demo/templates/create_account.rue +40 -0
- data/demo/rhales-roda-demo/templates/dashboard.rue +150 -0
- data/demo/rhales-roda-demo/templates/home.rue +78 -0
- data/demo/rhales-roda-demo/templates/layouts/main.rue +168 -0
- data/demo/rhales-roda-demo/templates/login.rue +65 -0
- data/demo/rhales-roda-demo/templates/logout.rue +25 -0
- data/demo/rhales-roda-demo/templates/reset_password.rue +26 -0
- data/demo/rhales-roda-demo/templates/verify_account.rue +27 -0
- data/demo/rhales-roda-demo/test_full_output.rb +27 -0
- data/demo/rhales-roda-demo/test_simple.rb +24 -0
- data/docs/.gitignore +9 -0
- data/docs/architecture/data-flow.md +499 -0
- data/examples/dashboard-with-charts.rue +271 -0
- data/examples/form-with-validation.rue +180 -0
- data/examples/simple-page.rue +61 -0
- data/examples/vue.rue +136 -0
- data/generate-json-schemas.ts +158 -0
- data/json_schemer_migration_summary.md +172 -0
- data/lib/rhales/adapters/base_auth.rb +2 -0
- data/lib/rhales/adapters/base_request.rb +2 -0
- data/lib/rhales/adapters/base_session.rb +2 -0
- data/lib/rhales/adapters.rb +7 -0
- data/lib/rhales/configuration.rb +47 -0
- data/lib/rhales/core/context.rb +354 -0
- data/lib/rhales/{rue_document.rb → core/rue_document.rb} +56 -38
- data/lib/rhales/{template_engine.rb → core/template_engine.rb} +66 -59
- data/lib/rhales/{view.rb → core/view.rb} +112 -135
- data/lib/rhales/{view_composition.rb → core/view_composition.rb} +78 -8
- data/lib/rhales/core.rb +9 -0
- data/lib/rhales/errors/hydration_collision_error.rb +2 -0
- data/lib/rhales/errors.rb +2 -0
- data/lib/rhales/{earliest_injection_detector.rb → hydration/earliest_injection_detector.rb} +4 -0
- data/lib/rhales/hydration/hydration_data_aggregator.rb +487 -0
- data/lib/rhales/{hydration_endpoint.rb → hydration/hydration_endpoint.rb} +16 -12
- data/lib/rhales/{hydration_injector.rb → hydration/hydration_injector.rb} +4 -0
- data/lib/rhales/{hydration_registry.rb → hydration/hydration_registry.rb} +2 -0
- data/lib/rhales/hydration/hydrator.rb +102 -0
- data/lib/rhales/{link_based_injection_detector.rb → hydration/link_based_injection_detector.rb} +4 -0
- data/lib/rhales/{mount_point_detector.rb → hydration/mount_point_detector.rb} +4 -0
- data/lib/rhales/{safe_injection_validator.rb → hydration/safe_injection_validator.rb} +4 -0
- data/lib/rhales/hydration.rb +13 -0
- data/lib/rhales/{refinements → integrations/refinements}/require_refinements.rb +3 -1
- data/lib/rhales/{tilt.rb → integrations/tilt.rb} +22 -15
- data/lib/rhales/integrations.rb +6 -0
- data/lib/rhales/middleware/json_responder.rb +191 -0
- data/lib/rhales/middleware/schema_validator.rb +300 -0
- data/lib/rhales/middleware.rb +6 -0
- data/lib/rhales/parsers/handlebars_parser.rb +2 -0
- data/lib/rhales/parsers/rue_format_parser.rb +9 -7
- data/lib/rhales/parsers.rb +9 -0
- data/lib/rhales/{csp.rb → security/csp.rb} +27 -3
- data/lib/rhales/utils/json_serializer.rb +114 -0
- data/lib/rhales/utils/logging_helpers.rb +75 -0
- data/lib/rhales/utils/schema_extractor.rb +132 -0
- data/lib/rhales/utils/schema_generator.rb +194 -0
- data/lib/rhales/utils.rb +40 -0
- data/lib/rhales/version.rb +3 -1
- data/lib/rhales.rb +41 -24
- data/lib/tasks/rhales_schema.rake +197 -0
- data/package.json +10 -0
- data/pnpm-lock.yaml +345 -0
- data/pnpm-workspace.yaml +2 -0
- data/proofs/error_handling.rb +79 -0
- data/proofs/expanded_object_inheritance.rb +82 -0
- data/proofs/partial_context_scoping_fix.rb +168 -0
- data/proofs/ui_context_partial_inheritance.rb +236 -0
- data/rhales.gemspec +14 -6
- data/schema_vs_data_comparison.md +254 -0
- data/test_direct_access.rb +36 -0
- metadata +141 -23
- data/CLAUDE.locale.txt +0 -7
- data/lib/rhales/context.rb +0 -239
- data/lib/rhales/hydration_data_aggregator.rb +0 -221
- data/lib/rhales/hydrator.rb +0 -141
- 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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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,
|
|
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
|
-
|
|
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)
|
|
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
|
data/lib/rhales/core.rb
ADDED
data/lib/rhales/errors.rb
CHANGED
|
@@ -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
|