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.
- 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 -2
- data/Gemfile +29 -0
- data/Gemfile.lock +189 -0
- data/README.md +706 -589
- 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 +161 -1
- data/lib/rhales/core/context.rb +354 -0
- data/lib/rhales/{rue_document.rb → core/rue_document.rb} +59 -43
- data/lib/rhales/{template_engine.rb → core/template_engine.rb} +80 -33
- data/lib/rhales/core/view.rb +529 -0
- data/lib/rhales/{view_composition.rb → core/view_composition.rb} +81 -9
- 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/hydration/earliest_injection_detector.rb +153 -0
- data/lib/rhales/hydration/hydration_data_aggregator.rb +487 -0
- data/lib/rhales/hydration/hydration_endpoint.rb +215 -0
- data/lib/rhales/hydration/hydration_injector.rb +175 -0
- data/lib/rhales/{hydration_registry.rb → hydration/hydration_registry.rb} +2 -0
- data/lib/rhales/hydration/hydrator.rb +102 -0
- data/lib/rhales/hydration/link_based_injection_detector.rb +195 -0
- data/lib/rhales/hydration/mount_point_detector.rb +109 -0
- data/lib/rhales/hydration/safe_injection_validator.rb +103 -0
- data/lib/rhales/hydration.rb +13 -0
- data/lib/rhales/{refinements → integrations/refinements}/require_refinements.rb +7 -13
- data/lib/rhales/{tilt.rb → integrations/tilt.rb} +26 -18
- 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 +55 -36
- 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 +5 -1
- data/lib/rhales.rb +47 -19
- 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 +142 -18
- data/CLAUDE.locale.txt +0 -7
- data/lib/rhales/context.rb +0 -240
- data/lib/rhales/hydration_data_aggregator.rb +0 -220
- data/lib/rhales/hydrator.rb +0 -141
- data/lib/rhales/parsers/handlebars-grammar-review.txt +0 -39
- data/lib/rhales/view.rb +0 -412
data/lib/rhales/errors.rb
CHANGED
|
@@ -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
|