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
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
|
|
3
|
+
# Proof that partials work correctly with UIContext (user context class)
|
|
4
|
+
# This tests that the context scoping fix works with the real OneTimeSecret context
|
|
5
|
+
|
|
6
|
+
require_relative '../lib/rhales'
|
|
7
|
+
|
|
8
|
+
puts '=== Testing Partial Inheritance with UIContext ==='
|
|
9
|
+
puts "Verifying that partials work correctly with the user UIContext class\n\n"
|
|
10
|
+
|
|
11
|
+
# Mock the UIContext class structure for testing
|
|
12
|
+
# (simplified version based on the provided code)
|
|
13
|
+
class MockUIContext < Rhales::Context
|
|
14
|
+
def initialize(req = nil, locale_override = nil, client: {})
|
|
15
|
+
# Simulate building onetime_window data like UIContext does
|
|
16
|
+
onetime_window = build_mock_onetime_window_data(req, locale_override)
|
|
17
|
+
enhanced_props = props.merge(onetime_window: onetime_window)
|
|
18
|
+
|
|
19
|
+
# Call parent constructor with enhanced data
|
|
20
|
+
super(req, locale_override, client: enhanced_props)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def build_mock_onetime_window_data(req, locale_override)
|
|
26
|
+
{
|
|
27
|
+
authenticated: true,
|
|
28
|
+
custid: 'cust123',
|
|
29
|
+
email: 'test@example.com',
|
|
30
|
+
ui: {
|
|
31
|
+
theme: 'dark',
|
|
32
|
+
language: 'en',
|
|
33
|
+
features: {
|
|
34
|
+
account_creation: true,
|
|
35
|
+
social_login: false,
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
site_host: 'onetimesecret.com',
|
|
39
|
+
locale: locale_override || 'en',
|
|
40
|
+
nonce: req&.env&.fetch('ots.nonce', 'test-nonce-123'),
|
|
41
|
+
plans_enabled: true,
|
|
42
|
+
regions_enabled: false,
|
|
43
|
+
frontend_development: false,
|
|
44
|
+
messages: [],
|
|
45
|
+
}
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Override resolve_variable to handle onetime_window paths like UIContext does
|
|
49
|
+
def resolve_variable(variable_path)
|
|
50
|
+
# Handle direct onetime_window reference
|
|
51
|
+
if variable_path == 'onetime_window'
|
|
52
|
+
return get('onetime_window')
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Handle nested onetime_window paths like onetime_window.authenticated
|
|
56
|
+
if variable_path.start_with?('onetime_window.')
|
|
57
|
+
nested_path = variable_path.sub('onetime_window.', '')
|
|
58
|
+
onetime_data = get('onetime_window')
|
|
59
|
+
return nil unless onetime_data.is_a?(Hash)
|
|
60
|
+
|
|
61
|
+
# Navigate nested path in onetime_window data
|
|
62
|
+
path_parts = nested_path.split('.')
|
|
63
|
+
current_value = onetime_data
|
|
64
|
+
|
|
65
|
+
path_parts.each do |part|
|
|
66
|
+
case current_value
|
|
67
|
+
when Hash
|
|
68
|
+
current_value = current_value[part] || current_value[part.to_sym]
|
|
69
|
+
else
|
|
70
|
+
return nil
|
|
71
|
+
end
|
|
72
|
+
return nil if current_value.nil?
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
return current_value
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Fall back to parent implementation
|
|
79
|
+
get(variable_path)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
class << self
|
|
83
|
+
def for_view(req, locale, config: nil, **props)
|
|
84
|
+
new(req, locale, client: props)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def minimal(client: {})
|
|
88
|
+
new(nil, nil, nil, 'en', client: props)
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Test 1: UIContext with partial that accesses onetime_window data
|
|
94
|
+
puts 'Test 1: UIContext context inheritance in partials'
|
|
95
|
+
puts '-' * 50
|
|
96
|
+
|
|
97
|
+
# Simple main template that includes a partial
|
|
98
|
+
main_template = <<~RUE
|
|
99
|
+
<template>
|
|
100
|
+
<div class="app">
|
|
101
|
+
<h1>OneTime Secret</h1>
|
|
102
|
+
{{> head}}
|
|
103
|
+
</div>
|
|
104
|
+
</template>
|
|
105
|
+
RUE
|
|
106
|
+
|
|
107
|
+
# Head partial that should inherit the UIContext onetime_window data
|
|
108
|
+
head_partial = <<~RUE
|
|
109
|
+
<data>
|
|
110
|
+
{
|
|
111
|
+
"page_title": "One Time Secret - Secure sharing",
|
|
112
|
+
"theme_color": "#dc4a22"
|
|
113
|
+
}
|
|
114
|
+
</data>
|
|
115
|
+
|
|
116
|
+
<template>
|
|
117
|
+
<head>
|
|
118
|
+
<title>{{page_title}}</title>
|
|
119
|
+
<meta name="theme-color" content="{{theme_color}}">
|
|
120
|
+
<meta name="authenticated" content="{{onetime_window.authenticated}}">
|
|
121
|
+
<meta name="site-host" content="{{onetime_window.site_host}}">
|
|
122
|
+
<meta name="ui-theme" content="{{onetime_window.ui.theme}}">
|
|
123
|
+
<meta name="user-email" content="{{onetime_window.email}}">
|
|
124
|
+
<meta name="nonce" content="{{onetime_window.nonce}}">
|
|
125
|
+
</head>
|
|
126
|
+
</template>
|
|
127
|
+
RUE
|
|
128
|
+
|
|
129
|
+
partial_resolver = proc do |name|
|
|
130
|
+
case name
|
|
131
|
+
when 'head' then head_partial
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Create UIContext with mock request environment
|
|
136
|
+
mock_req = Object.new
|
|
137
|
+
def mock_req.env
|
|
138
|
+
{ 'ots.nonce' => 'test-nonce-from-request' }
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
ui_context = MockUIContext.minimal(client: {
|
|
142
|
+
extra_prop: 'from props',
|
|
143
|
+
},
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
# Test the template engine with UIContext
|
|
147
|
+
engine = Rhales::TemplateEngine.new(main_template, ui_context, partial_resolver: partial_resolver)
|
|
148
|
+
result = engine.render
|
|
149
|
+
|
|
150
|
+
puts result
|
|
151
|
+
puts "\nHead Partial Checks (inherits from UIContext):"
|
|
152
|
+
puts '✅ Head has its own page_title' if result.include?('<title>One Time Secret - Secure sharing</title>')
|
|
153
|
+
puts '✅ Head has its own theme_color' if result.include?('content="#dc4a22"')
|
|
154
|
+
puts '✅ Head accesses onetime_window.authenticated' if result.include?('content="true"')
|
|
155
|
+
puts '✅ Head accesses onetime_window.site_host' if result.include?('content="onetimesecret.com"')
|
|
156
|
+
puts '✅ Head accesses onetime_window.ui.theme' if result.include?('content="dark"')
|
|
157
|
+
puts '✅ Head accesses onetime_window.email' if result.include?('content="test@example.com"')
|
|
158
|
+
puts '✅ Head accesses onetime_window.nonce' if result.include?('content="test-nonce-123"')
|
|
159
|
+
|
|
160
|
+
# Test 2: Verify variable precedence with UIContext
|
|
161
|
+
puts "\n\nTest 2: Variable precedence with UIContext"
|
|
162
|
+
puts '-' * 50
|
|
163
|
+
|
|
164
|
+
override_partial = <<~RUE
|
|
165
|
+
<data>
|
|
166
|
+
{
|
|
167
|
+
"local_message": "This is from the partial's data section",
|
|
168
|
+
"page_theme": "light-override"
|
|
169
|
+
}
|
|
170
|
+
</data>
|
|
171
|
+
|
|
172
|
+
<template>
|
|
173
|
+
<div class="override-test">
|
|
174
|
+
<p>Inherited auth: {{onetime_window.authenticated}}</p>
|
|
175
|
+
<p>Inherited site: {{onetime_window.site_host}}</p>
|
|
176
|
+
<p>Inherited theme: {{onetime_window.ui.theme}}</p>
|
|
177
|
+
<p>Local message: {{local_message}}</p>
|
|
178
|
+
<p>Local theme: {{page_theme}}</p>
|
|
179
|
+
</div>
|
|
180
|
+
</template>
|
|
181
|
+
RUE
|
|
182
|
+
|
|
183
|
+
main_override = <<~RUE
|
|
184
|
+
<template>
|
|
185
|
+
<div class="main">
|
|
186
|
+
<h2>Main Template</h2>
|
|
187
|
+
{{> override_test}}
|
|
188
|
+
</div>
|
|
189
|
+
</template>
|
|
190
|
+
RUE
|
|
191
|
+
|
|
192
|
+
partial_resolver2 = proc do |name|
|
|
193
|
+
case name
|
|
194
|
+
when 'override_test' then override_partial
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
engine2 = Rhales::TemplateEngine.new(main_override, ui_context, partial_resolver: partial_resolver2)
|
|
199
|
+
result2 = engine2.render
|
|
200
|
+
|
|
201
|
+
puts result2
|
|
202
|
+
puts "\nVariable Access Checks:"
|
|
203
|
+
puts '✅ Partial inherits onetime_window.authenticated' if result2.include?('Inherited auth: true')
|
|
204
|
+
puts '✅ Partial inherits onetime_window.site_host' if result2.include?('Inherited site: onetimesecret.com')
|
|
205
|
+
puts '✅ Partial inherits onetime_window.ui.theme' if result2.include?('Inherited theme: dark')
|
|
206
|
+
puts '✅ Partial has its own local data' if result2.include?('Local message: This is from the partial's data section')
|
|
207
|
+
puts '✅ Partial can define new local variables' if result2.include?('Local theme: light-override')
|
|
208
|
+
|
|
209
|
+
# Test 3: Test access to onetime_window data via get method
|
|
210
|
+
puts "\n\nTest 3: UIContext data access methods"
|
|
211
|
+
puts '-' * 50
|
|
212
|
+
|
|
213
|
+
# Test that the data is accessible via the public get method
|
|
214
|
+
onetime_window = ui_context.get('onetime_window')
|
|
215
|
+
auth_direct = ui_context.get('authenticated')
|
|
216
|
+
ui_data = ui_context.get('ui')
|
|
217
|
+
|
|
218
|
+
puts "Onetime window data: #{onetime_window.inspect}"
|
|
219
|
+
puts "Direct authenticated: #{auth_direct}"
|
|
220
|
+
puts "UI data: #{ui_data.inspect}"
|
|
221
|
+
|
|
222
|
+
puts "\nData Access Checks:"
|
|
223
|
+
puts '✅ Onetime window object accessible' if onetime_window.is_a?(Hash)
|
|
224
|
+
if onetime_window.is_a?(Hash)
|
|
225
|
+
puts '✅ Authenticated data accessible' if onetime_window['authenticated'] == true
|
|
226
|
+
puts '✅ UI data accessible' if onetime_window['ui'].is_a?(Hash)
|
|
227
|
+
puts '✅ Nested UI theme accessible' if onetime_window['ui'] && onetime_window['ui']['theme'] == 'dark'
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
puts "\n\n=== Summary ==="
|
|
231
|
+
puts '✅ The context scoping fix works correctly with UIContext'
|
|
232
|
+
puts '✅ Partials can access their own <data> section variables'
|
|
233
|
+
puts '✅ Partials inherit expanded onetime_window properties'
|
|
234
|
+
puts '✅ Variable precedence works correctly (local > inherited)'
|
|
235
|
+
puts "✅ UIContext's resolve_variable method is compatible"
|
|
236
|
+
puts '✅ All OneTimeSecret-specific variables are accessible in partials'
|
data/rhales.gemspec
CHANGED
|
@@ -21,8 +21,7 @@ Gem::Specification.new do |spec|
|
|
|
21
21
|
|
|
22
22
|
spec.homepage = 'https://github.com/onetimesecret/rhales'
|
|
23
23
|
spec.license = 'MIT'
|
|
24
|
-
spec.required_ruby_version = '>= 3.4
|
|
25
|
-
|
|
24
|
+
spec.required_ruby_version = '>= 3.3.4'
|
|
26
25
|
|
|
27
26
|
spec.metadata['source_code_uri'] = 'https://github.com/onetimesecret/rhales'
|
|
28
27
|
spec.metadata['changelog_uri'] = 'https://github.com/onetimesecret/rhales/blob/main/CHANGELOG.md'
|
|
@@ -30,16 +29,25 @@ Gem::Specification.new do |spec|
|
|
|
30
29
|
spec.metadata['rubygems_mfa_required'] = 'true'
|
|
31
30
|
|
|
32
31
|
# Specify which files should be added to the gem
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
32
|
+
# Use git if available, otherwise fall back to Dir.glob for non-git environments
|
|
33
|
+
spec.files = if File.exist?('.git') && system('git --version > /dev/null 2>&1')
|
|
34
|
+
`git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
|
|
35
|
+
else
|
|
36
|
+
Dir.glob('{lib,exe}/**/*', File::FNM_DOTMATCH).reject { |f| File.directory?(f) }
|
|
37
|
+
end
|
|
36
38
|
|
|
37
39
|
spec.bindir = 'exe'
|
|
38
40
|
spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
|
|
39
41
|
spec.require_paths = ['lib']
|
|
40
42
|
|
|
41
43
|
# Runtime dependencies
|
|
42
|
-
|
|
44
|
+
spec.add_dependency 'json_schemer', '~> 2.3' # JSON Schema validation in middleware
|
|
45
|
+
spec.add_dependency 'logger' # Standard library logger for logging support
|
|
46
|
+
spec.add_dependency 'tilt', '~> 2' # Templating engine for rendering RSFCs
|
|
47
|
+
|
|
48
|
+
# Optional dependencies for performance optimization
|
|
49
|
+
# Install oj for 10-20x faster JSON parsing and 5-10x faster generation
|
|
50
|
+
# spec.add_dependency 'oj', '~> 3.13'
|
|
43
51
|
|
|
44
52
|
# Development dependencies should be specified in Gemfile instead of gemspec
|
|
45
53
|
# See: https://bundler.io/guides/creating_gem.html#testing-our-gem
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
# Schema vs Data Section Comparison for Onetime Secret Migration
|
|
2
|
+
|
|
3
|
+
## Executive Summary
|
|
4
|
+
|
|
5
|
+
✅ **Schema sections are ready for Onetime Secret migration**
|
|
6
|
+
|
|
7
|
+
The test demonstrates that `<schema>` sections provide **direct JSON serialization** without template interpolation, which is exactly what's needed for Vue SPA mount points.
|
|
8
|
+
|
|
9
|
+
## Key Findings
|
|
10
|
+
|
|
11
|
+
### 1. Data Flow with Schema Sections
|
|
12
|
+
|
|
13
|
+
**Backend (Ruby):**
|
|
14
|
+
```ruby
|
|
15
|
+
view.render('vue_spa_mount',
|
|
16
|
+
ui: { theme: 'dark', locale: 'en' },
|
|
17
|
+
authentication: { authenticated: true, custid: 'cust_12345' },
|
|
18
|
+
user: { email: 'test@example.com', account_since: 1640000000 },
|
|
19
|
+
# ... more props
|
|
20
|
+
)
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
**Template (.rue file):**
|
|
24
|
+
```xml
|
|
25
|
+
<schema lang="js-zod" window="__ONETIME_STATE__">
|
|
26
|
+
const schema = z.object({
|
|
27
|
+
ui: z.object({
|
|
28
|
+
theme: z.string(),
|
|
29
|
+
locale: z.string()
|
|
30
|
+
}),
|
|
31
|
+
authentication: z.object({
|
|
32
|
+
authenticated: z.boolean(),
|
|
33
|
+
custid: z.string().nullable()
|
|
34
|
+
}),
|
|
35
|
+
# ... more schema definitions
|
|
36
|
+
});
|
|
37
|
+
</schema>
|
|
38
|
+
|
|
39
|
+
<template>
|
|
40
|
+
<!DOCTYPE html>
|
|
41
|
+
<html>
|
|
42
|
+
<body>
|
|
43
|
+
<div id="app"><router-view></router-view></div>
|
|
44
|
+
</body>
|
|
45
|
+
</html>
|
|
46
|
+
</template>
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
**Rendered Output:**
|
|
50
|
+
```html
|
|
51
|
+
<script id="rsfc-data-..." type="application/json" data-window="__ONETIME_STATE__">
|
|
52
|
+
{"ui":{"theme":"dark","locale":"en"},"authentication":{"authenticated":true,"custid":"cust_12345"},"user":{"email":"test@example.com","account_since":1640000000},...}
|
|
53
|
+
</script>
|
|
54
|
+
<script nonce="..." data-hydration-target="__ONETIME_STATE__">
|
|
55
|
+
window['__ONETIME_STATE__'] = JSON.parse(dataScript.textContent);
|
|
56
|
+
</script>
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
### 2. Critical Differences: Schema vs Data Sections
|
|
60
|
+
|
|
61
|
+
| Feature | Data Section (deprecated) | Schema Section (current) |
|
|
62
|
+
|---------|--------------------------|-------------------------|
|
|
63
|
+
| **Backend Props** | Can pass any values, uses interpolation | Must pass fully-resolved values |
|
|
64
|
+
| **Template Syntax** | `{"user": "{{user.name}}"}` | Props serialized directly |
|
|
65
|
+
| **Interpolation** | Yes - `{{var}}` evaluated | No - props → JSON directly |
|
|
66
|
+
| **Type Safety** | No | Yes (via Zod schema) |
|
|
67
|
+
| **JSON Output** | After interpolation | Direct serialization |
|
|
68
|
+
|
|
69
|
+
### 3. What This Means for Onetime Secret
|
|
70
|
+
|
|
71
|
+
**Current System (data section):**
|
|
72
|
+
```ruby
|
|
73
|
+
# VuePoint passes pre-serialized JSON
|
|
74
|
+
locals = {
|
|
75
|
+
'ui' => UiSerializer.serialize(...).to_json, # ❌ Already JSON string
|
|
76
|
+
'authentication' => {...}.to_json # ❌ Already JSON string
|
|
77
|
+
}
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
```xml
|
|
81
|
+
<data window="__ONETIME_STATE__">
|
|
82
|
+
{
|
|
83
|
+
"ui": {{{ui}}}, # ❌ Triple braces for raw output
|
|
84
|
+
"authentication": {{{authentication}}}
|
|
85
|
+
}
|
|
86
|
+
</data>
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
**New System (schema section):**
|
|
90
|
+
```ruby
|
|
91
|
+
# VuePoint passes Ruby hashes
|
|
92
|
+
locals = {
|
|
93
|
+
'ui' => UiSerializer.serialize(...), # ✅ Just the hash
|
|
94
|
+
'authentication' => {...} # ✅ Just the hash
|
|
95
|
+
}
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
```xml
|
|
99
|
+
<schema lang="js-zod" window="__ONETIME_STATE__">
|
|
100
|
+
const schema = z.object({
|
|
101
|
+
ui: z.object({ theme: z.string(), locale: z.string() }),
|
|
102
|
+
authentication: z.object({ authenticated: z.boolean(), custid: z.string().nullable() })
|
|
103
|
+
});
|
|
104
|
+
</schema>
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
### 4. Implementation Code Path
|
|
108
|
+
|
|
109
|
+
From `lib/rhales/hydration_data_aggregator.rb:50-90`:
|
|
110
|
+
|
|
111
|
+
```ruby
|
|
112
|
+
def process_schema_section(parser)
|
|
113
|
+
window_attr = parser.schema_window || 'data'
|
|
114
|
+
|
|
115
|
+
# CRITICAL: Direct serialization of props (no template interpolation)
|
|
116
|
+
processed_data = @context.props
|
|
117
|
+
|
|
118
|
+
# ... merge logic ...
|
|
119
|
+
|
|
120
|
+
@merged_data[window_attr] = processed_data
|
|
121
|
+
end
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
The key insight: **Schema sections skip the template interpolation step entirely** and serialize `@context.props` directly to JSON.
|
|
125
|
+
|
|
126
|
+
### 5. Tested Scenarios
|
|
127
|
+
|
|
128
|
+
✅ Complex nested objects (ui, authentication, user, etc.)
|
|
129
|
+
✅ Nil/null values (nullable schema fields)
|
|
130
|
+
✅ Boolean values (true/false preserved)
|
|
131
|
+
✅ Numbers (integers preserved)
|
|
132
|
+
✅ Strings with template-like syntax (not interpolated)
|
|
133
|
+
✅ Custom window attribute (`__ONETIME_STATE__`)
|
|
134
|
+
✅ CSP nonce integration (`{{app.nonce}}` in template)
|
|
135
|
+
✅ Dark mode inline script (before hydration)
|
|
136
|
+
✅ Vue SPA mount point (`<div id="app">`)
|
|
137
|
+
|
|
138
|
+
### 6. Migration Checklist for Onetime Secret
|
|
139
|
+
|
|
140
|
+
- [ ] Remove `.to_json` calls in VuePoint class
|
|
141
|
+
- [ ] Pass Ruby hashes as locals (not JSON strings)
|
|
142
|
+
- [ ] Convert `<data>` section to `<schema>` section
|
|
143
|
+
- [ ] Remove triple-braces `{{{var}}}` (not needed with schema)
|
|
144
|
+
- [ ] Define Zod schema matching serializer structure
|
|
145
|
+
- [ ] Test with all 6 serializers (Config, Auth, Domain, I18n, Messages, System)
|
|
146
|
+
- [ ] Verify `window.__ONETIME_STATE__` structure is identical
|
|
147
|
+
- [ ] Verify Vue.js initializes correctly with new hydration
|
|
148
|
+
|
|
149
|
+
### 7. Benefits of Schema Sections
|
|
150
|
+
|
|
151
|
+
1. **Simpler Backend Code**: No need to pre-serialize to JSON
|
|
152
|
+
2. **Type Safety**: Zod schema catches mismatches at build time
|
|
153
|
+
3. **Validation**: Runtime validation with middleware (optional)
|
|
154
|
+
4. **Cleaner Templates**: No template interpolation confusion
|
|
155
|
+
5. **JSON Schema Generation**: Can generate TypeScript types
|
|
156
|
+
6. **Better Performance**: One serialization pass (not two)
|
|
157
|
+
|
|
158
|
+
## Example Migration
|
|
159
|
+
|
|
160
|
+
**Before (current Onetime Secret):**
|
|
161
|
+
```ruby
|
|
162
|
+
# apps/web/core/views.rb
|
|
163
|
+
class VuePoint < BaseView
|
|
164
|
+
def render(template_name)
|
|
165
|
+
@serialized_data = run_serializers(@view_vars, i18n)
|
|
166
|
+
|
|
167
|
+
locals = {}
|
|
168
|
+
@serialized_data.each do |key, value|
|
|
169
|
+
locals[key] = value.to_json # Pre-serialize everything
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
super(template_name, locals: locals)
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
```xml
|
|
178
|
+
<!-- index.html.erb -->
|
|
179
|
+
<data window="__ONETIME_STATE__">
|
|
180
|
+
{
|
|
181
|
+
"ui": {{{ui}}},
|
|
182
|
+
"authentication": {{{authentication}}},
|
|
183
|
+
"custid": "{{{custid}}}",
|
|
184
|
+
...
|
|
185
|
+
}
|
|
186
|
+
</data>
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
**After (with Rhales schema):**
|
|
190
|
+
```ruby
|
|
191
|
+
# apps/web/core/views.rb
|
|
192
|
+
class VuePoint
|
|
193
|
+
def render(template_name)
|
|
194
|
+
@serialized_data = run_serializers(@view_vars, i18n)
|
|
195
|
+
|
|
196
|
+
# Pass hashes directly - no .to_json needed
|
|
197
|
+
view = Rhales::View.new(@req)
|
|
198
|
+
view.render(template_name, @serialized_data)
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
```xml
|
|
204
|
+
<!-- index.rue -->
|
|
205
|
+
<schema lang="js-zod" window="__ONETIME_STATE__">
|
|
206
|
+
const schema = z.object({
|
|
207
|
+
ui: z.object({
|
|
208
|
+
theme: z.string(),
|
|
209
|
+
locale: z.string()
|
|
210
|
+
}),
|
|
211
|
+
authentication: z.object({
|
|
212
|
+
authenticated: z.boolean(),
|
|
213
|
+
custid: z.string().nullable()
|
|
214
|
+
}),
|
|
215
|
+
// ... define all 40+ fields from serializers
|
|
216
|
+
});
|
|
217
|
+
</schema>
|
|
218
|
+
|
|
219
|
+
<template>
|
|
220
|
+
<!DOCTYPE html>
|
|
221
|
+
<html lang="{{locale}}" class="light">
|
|
222
|
+
<head>
|
|
223
|
+
<script nonce="{{app.nonce}}" language="javascript" type="text/javascript">
|
|
224
|
+
// Dark mode script
|
|
225
|
+
</script>
|
|
226
|
+
</head>
|
|
227
|
+
<body>
|
|
228
|
+
<div id="app"><router-view></router-view></div>
|
|
229
|
+
</body>
|
|
230
|
+
</html>
|
|
231
|
+
</template>
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
## Conclusion
|
|
235
|
+
|
|
236
|
+
Schema sections are **production-ready** for the Onetime Secret migration. The test demonstrates:
|
|
237
|
+
|
|
238
|
+
1. Direct JSON serialization without interpolation
|
|
239
|
+
2. Correct handling of complex nested structures
|
|
240
|
+
3. Proper nil/null value handling
|
|
241
|
+
4. CSP nonce integration
|
|
242
|
+
5. Custom window variables
|
|
243
|
+
6. Vue SPA mount point compatibility
|
|
244
|
+
|
|
245
|
+
The migration simplifies backend code by eliminating pre-serialization and provides type safety through Zod schemas.
|
|
246
|
+
|
|
247
|
+
## Next Steps
|
|
248
|
+
|
|
249
|
+
1. Create full Zod schema definition for all 40+ Onetime Secret fields
|
|
250
|
+
2. Refactor VuePoint to pass hashes instead of JSON strings
|
|
251
|
+
3. Convert index.html.erb to index.rue with schema section
|
|
252
|
+
4. Run integration tests to verify `window.__ONETIME_STATE__` structure
|
|
253
|
+
5. Test Vue.js initialization with new hydration format
|
|
254
|
+
6. Deploy to staging for validation
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
|
|
3
|
+
# Simple test to verify direct access works
|
|
4
|
+
require_relative 'lib/rhales'
|
|
5
|
+
|
|
6
|
+
# Create a test configuration
|
|
7
|
+
config = Rhales::Configuration.new do |conf|
|
|
8
|
+
conf.template_paths = [File.join(__dir__, 'spec/fixtures/templates')]
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
# Create a view with test props
|
|
12
|
+
view = Rhales::View.new(
|
|
13
|
+
nil, nil, nil, 'en',
|
|
14
|
+
client: { 'greeting' => 'Hello World', 'user' => { 'name' => 'John Doe' } },
|
|
15
|
+
config: config
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
# Render the template
|
|
19
|
+
result = view.render('test_direct_access')
|
|
20
|
+
|
|
21
|
+
puts "=== Rendered Output ==="
|
|
22
|
+
puts result
|
|
23
|
+
puts "======================="
|
|
24
|
+
|
|
25
|
+
# Check if direct access worked
|
|
26
|
+
if result.include?('Direct: Hello World')
|
|
27
|
+
puts "✅ Direct access to 'message' works"
|
|
28
|
+
else
|
|
29
|
+
puts "❌ Direct access to 'message' failed"
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
if result.include?('User: John Doe')
|
|
33
|
+
puts "✅ Direct access to 'userName' works"
|
|
34
|
+
else
|
|
35
|
+
puts "❌ Direct access to 'userName' failed"
|
|
36
|
+
end
|