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
data/README.md
CHANGED
|
@@ -1,100 +1,90 @@
|
|
|
1
1
|
# Rhales - Ruby Single File Components
|
|
2
2
|
|
|
3
3
|
> [!CAUTION]
|
|
4
|
-
> **Early Development Release** - Rhales is in active development (v0.
|
|
4
|
+
> **Early Development Release** - Rhales is in active development (v0.5). The API underwent breaking changes from v0.4. While functional and tested, it's recommended for experimental use and contributions. Please report issues and provide feedback through GitHub.
|
|
5
5
|
|
|
6
|
-
Rhales is a framework for
|
|
6
|
+
Rhales is a **type-safe contract enforcement framework** for server-rendered pages with client-side data hydration. It uses `.rue` files (Ruby Single File Components) that combine Zod v4 schemas, Handlebars templates, and documentation into a single contract-first format.
|
|
7
7
|
|
|
8
|
-
About the name
|
|
9
|
-
|
|
8
|
+
**About the name:** It all started with a simple mustache template many years ago. Mustache's successor, "Handlebars," is a visual analog for a mustache. "Two Whales Kissing" is another visual analog for a mustache, and since we're working with Ruby, we call it "Rhales" (Ruby + Whales). It's a perfect name with absolutely no ambiguity or risk of confusion.
|
|
9
|
+
|
|
10
|
+
## What's New in v0.5
|
|
11
|
+
|
|
12
|
+
- ✅ **Schema-First Design**: Replaced `<data>` sections with Zod v4 `<schema>` sections
|
|
13
|
+
- ✅ **Type Safety**: Contract enforcement between backend and frontend
|
|
14
|
+
- ✅ **Simplified API**: Removed deprecated parameters (`sess`, `cust`, `props:`, `app_data:`)
|
|
15
|
+
- ✅ **Clear Context Layers**: Renamed `app` → `request` for clarity
|
|
16
|
+
- ✅ **Schema Tooling**: Rake tasks for schema generation and validation
|
|
17
|
+
- ✅ **100% Migration**: All demo templates use schemas
|
|
18
|
+
|
|
19
|
+
**Breaking changes from v0.4:** See [Migration Guide](#migration-from-v04-to-v05) below.
|
|
10
20
|
|
|
11
21
|
## Features
|
|
12
22
|
|
|
13
|
-
- **
|
|
14
|
-
- **
|
|
15
|
-
- **
|
|
16
|
-
- **
|
|
17
|
-
- **
|
|
18
|
-
- **
|
|
19
|
-
- **
|
|
20
|
-
- **Pluggable authentication adapters** for any auth system
|
|
21
|
-
- **Security-first design** with XSS protection and automatic CSP generation
|
|
23
|
+
- **Schema-based hydration** with Zod v4 for type-safe client data
|
|
24
|
+
- **Server-side rendering** with Handlebars-style template syntax
|
|
25
|
+
- **Three-layer context** for request, server, and client data separation
|
|
26
|
+
- **Security-first design** with explicit server-to-client boundaries
|
|
27
|
+
- **Layout & partial composition** for component reuse
|
|
28
|
+
- **CSP support** with automatic nonce generation
|
|
29
|
+
- **Framework agnostic** - works with Rails, Roda, Sinatra, Grape, Padrino
|
|
22
30
|
- **Dependency injection** for testability and flexibility
|
|
23
|
-
- **Resource hint optimization** with browser preload/prefetch support
|
|
24
31
|
|
|
25
32
|
## Installation
|
|
26
33
|
|
|
27
|
-
Add
|
|
34
|
+
Add to your Gemfile:
|
|
28
35
|
|
|
29
36
|
```ruby
|
|
30
37
|
gem 'rhales'
|
|
31
38
|
```
|
|
32
39
|
|
|
33
|
-
|
|
40
|
+
Then execute:
|
|
34
41
|
|
|
35
42
|
```bash
|
|
36
43
|
bundle install
|
|
37
44
|
```
|
|
38
45
|
|
|
39
|
-
Or install it yourself as:
|
|
40
|
-
|
|
41
|
-
```bash
|
|
42
|
-
gem install rhales
|
|
43
|
-
```
|
|
44
|
-
|
|
45
46
|
## Quick Start
|
|
46
47
|
|
|
47
48
|
### 1. Configure Rhales
|
|
48
49
|
|
|
49
50
|
```ruby
|
|
50
|
-
#
|
|
51
|
+
# config/initializers/rhales.rb or similar
|
|
51
52
|
Rhales.configure do |config|
|
|
52
53
|
config.default_locale = 'en'
|
|
53
|
-
config.template_paths = ['templates']
|
|
54
|
+
config.template_paths = ['templates']
|
|
54
55
|
config.features = { dark_mode: true }
|
|
55
56
|
config.site_host = 'example.com'
|
|
56
57
|
|
|
57
|
-
#
|
|
58
|
-
config.
|
|
59
|
-
config.
|
|
60
|
-
config.hydration.fallback_to_late = true
|
|
61
|
-
config.hydration.api_cache_enabled = true
|
|
62
|
-
config.hydration.cors_enabled = true
|
|
63
|
-
|
|
64
|
-
# CSP configuration (enabled by default)
|
|
65
|
-
config.csp_enabled = true # Enable automatic CSP header generation
|
|
66
|
-
config.auto_nonce = true # Automatically generate nonces
|
|
67
|
-
config.csp_policy = { # Customize CSP policy (optional)
|
|
68
|
-
'default-src' => ["'self'"],
|
|
69
|
-
'script-src' => ["'self'", "'nonce-{{nonce}}'"],
|
|
70
|
-
'style-src' => ["'self'", "'nonce-{{nonce}}'", "'unsafe-hashes'"]
|
|
71
|
-
# ... more directives
|
|
72
|
-
}
|
|
58
|
+
# CSP configuration
|
|
59
|
+
config.csp_enabled = true
|
|
60
|
+
config.auto_nonce = true
|
|
73
61
|
end
|
|
62
|
+
|
|
63
|
+
# Optional: Configure logger
|
|
64
|
+
Rhales.logger = Rails.logger
|
|
74
65
|
```
|
|
75
66
|
|
|
76
|
-
### 2. Create a
|
|
67
|
+
### 2. Create a .rue Component
|
|
77
68
|
|
|
78
|
-
Create
|
|
69
|
+
Create `templates/hello.rue`:
|
|
79
70
|
|
|
80
|
-
```
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
</data>
|
|
71
|
+
```xml
|
|
72
|
+
<schema lang="js-zod" window="appData">
|
|
73
|
+
const schema = z.object({
|
|
74
|
+
greeting: z.string(),
|
|
75
|
+
userName: z.string()
|
|
76
|
+
});
|
|
77
|
+
</schema>
|
|
88
78
|
|
|
89
79
|
<template>
|
|
90
80
|
<div class="hello-component">
|
|
91
|
-
<h1>{{greeting}}, {{
|
|
92
|
-
<p>Welcome to Rhales
|
|
81
|
+
<h1>{{greeting}}, {{userName}}!</h1>
|
|
82
|
+
<p>Welcome to Rhales v0.5</p>
|
|
93
83
|
</div>
|
|
94
84
|
</template>
|
|
95
85
|
|
|
96
86
|
<logic>
|
|
97
|
-
# Simple greeting component
|
|
87
|
+
# Simple greeting component demonstrating schema-based hydration
|
|
98
88
|
</logic>
|
|
99
89
|
```
|
|
100
90
|
|
|
@@ -102,232 +92,469 @@ Create a `.rue` file in your templates directory:
|
|
|
102
92
|
|
|
103
93
|
```ruby
|
|
104
94
|
# In your controller/route handler
|
|
105
|
-
view = Rhales::View.new(
|
|
106
|
-
|
|
95
|
+
view = Rhales::View.new(
|
|
96
|
+
request,
|
|
97
|
+
client: {
|
|
107
98
|
greeting: 'Hello',
|
|
108
|
-
|
|
99
|
+
userName: 'World'
|
|
109
100
|
}
|
|
110
101
|
)
|
|
111
102
|
|
|
112
103
|
html = view.render('hello')
|
|
113
|
-
# Returns HTML with
|
|
104
|
+
# Returns HTML with schema-validated data injected as window.appData
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
## The .rue File Format
|
|
108
|
+
|
|
109
|
+
A `.rue` file contains three sections:
|
|
110
|
+
|
|
111
|
+
```xml
|
|
112
|
+
<schema lang="js-zod" window="data" [version="2"] [envelope="Envelope"] [layout="layouts/main"]>
|
|
113
|
+
const schema = z.object({
|
|
114
|
+
// Zod v4 schema defining client data contract
|
|
115
|
+
});
|
|
116
|
+
</schema>
|
|
117
|
+
|
|
118
|
+
<template>
|
|
119
|
+
<!-- Handlebars-style HTML template -->
|
|
120
|
+
<!-- Has access to ALL context layers -->
|
|
121
|
+
</template>
|
|
122
|
+
|
|
123
|
+
<logic>
|
|
124
|
+
# Optional Ruby documentation/comments
|
|
125
|
+
</logic>
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
### Schema Section Attributes
|
|
129
|
+
|
|
130
|
+
| Attribute | Required | Description | Example |
|
|
131
|
+
|-----------|----------|-------------|---------|
|
|
132
|
+
| `lang` | Yes | Schema language (currently only `js-zod`) | `"js-zod"` |
|
|
133
|
+
| `window` | Yes | Browser global name | `"appData"` → `window.appData` |
|
|
134
|
+
| `version` | No | Schema version | `"2"` |
|
|
135
|
+
| `envelope` | No | Response wrapper type | `"SuccessEnvelope"` |
|
|
136
|
+
| `layout` | No | Layout template reference | `"layouts/main"` |
|
|
137
|
+
|
|
138
|
+
### Zod Schema Examples
|
|
139
|
+
|
|
140
|
+
```javascript
|
|
141
|
+
// Simple types
|
|
142
|
+
z.object({
|
|
143
|
+
user: z.string(),
|
|
144
|
+
count: z.number(),
|
|
145
|
+
active: z.boolean()
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
// Complex nested structures
|
|
149
|
+
z.object({
|
|
150
|
+
user: z.object({
|
|
151
|
+
id: z.number(),
|
|
152
|
+
name: z.string(),
|
|
153
|
+
email: z.string().email()
|
|
154
|
+
}),
|
|
155
|
+
items: z.array(z.object({
|
|
156
|
+
id: z.number(),
|
|
157
|
+
title: z.string(),
|
|
158
|
+
price: z.number().positive()
|
|
159
|
+
})),
|
|
160
|
+
metadata: z.record(z.string())
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
// Optional and nullable
|
|
164
|
+
z.object({
|
|
165
|
+
theme: z.string().optional(),
|
|
166
|
+
lastLogin: z.string().nullable()
|
|
167
|
+
})
|
|
114
168
|
```
|
|
115
169
|
|
|
116
170
|
## Context and Data Model
|
|
117
171
|
|
|
118
|
-
Rhales uses a **
|
|
172
|
+
Rhales uses a **three-layer context system** that separates concerns and enforces security boundaries:
|
|
173
|
+
|
|
174
|
+
### 1. Request Layer (Framework Data)
|
|
119
175
|
|
|
120
|
-
|
|
121
|
-
All framework-provided data is available under the `app` namespace:
|
|
176
|
+
Framework-provided data available under the `request` namespace:
|
|
122
177
|
|
|
123
178
|
```handlebars
|
|
124
|
-
<!--
|
|
125
|
-
{{
|
|
126
|
-
{{
|
|
127
|
-
{{
|
|
128
|
-
{{app.environment}} <!-- Current environment -->
|
|
129
|
-
{{app.features.dark_mode}} <!-- Feature flags -->
|
|
130
|
-
{{app.theme_class}} <!-- Current theme -->
|
|
179
|
+
{{request.nonce}} <!-- CSP nonce for scripts -->
|
|
180
|
+
{{request.csrf_token}} <!-- CSRF token for forms -->
|
|
181
|
+
{{request.authenticated?}} <!-- Authentication state -->
|
|
182
|
+
{{request.locale}} <!-- Current locale -->
|
|
131
183
|
```
|
|
132
184
|
|
|
133
|
-
**Available
|
|
134
|
-
- `
|
|
135
|
-
- `
|
|
136
|
-
- `
|
|
137
|
-
- `
|
|
138
|
-
- `
|
|
139
|
-
- `
|
|
140
|
-
- `app.nonce` - CSP nonce for inline scripts
|
|
141
|
-
- `app.theme_class` - Current theme CSS class
|
|
185
|
+
**Available Request Variables:**
|
|
186
|
+
- `request.nonce` - CSP nonce for inline scripts/styles
|
|
187
|
+
- `request.csrf_token` - CSRF token for form submissions
|
|
188
|
+
- `request.authenticated?` - User authentication status
|
|
189
|
+
- `request.locale` - Current locale (e.g., 'en', 'es')
|
|
190
|
+
- `request.session` - Session object (if available)
|
|
191
|
+
- `request.user` - User object (if available)
|
|
142
192
|
|
|
143
|
-
### 2.
|
|
144
|
-
|
|
193
|
+
### 2. Server Layer (Template-Only Data)
|
|
194
|
+
|
|
195
|
+
Application data that stays on the server (not sent to browser):
|
|
196
|
+
|
|
197
|
+
```ruby
|
|
198
|
+
view = Rhales::View.new(
|
|
199
|
+
request,
|
|
200
|
+
server: {
|
|
201
|
+
page_title: 'Dashboard',
|
|
202
|
+
vite_assets_html: vite_javascript_tag('application'),
|
|
203
|
+
admin_notes: 'Internal use only' # Never sent to client
|
|
204
|
+
}
|
|
205
|
+
)
|
|
206
|
+
```
|
|
145
207
|
|
|
146
208
|
```handlebars
|
|
147
|
-
<!--
|
|
148
|
-
{{
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
209
|
+
{{server.page_title}} <!-- Available in templates -->
|
|
210
|
+
{{server.vite_assets_html}} <!-- Server-side only -->
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
### 3. Client Layer (Serialized to Browser)
|
|
214
|
+
|
|
215
|
+
Data serialized to browser via schema validation:
|
|
216
|
+
|
|
217
|
+
```ruby
|
|
218
|
+
view = Rhales::View.new(
|
|
219
|
+
request,
|
|
220
|
+
client: {
|
|
221
|
+
user: current_user.name,
|
|
222
|
+
items: Item.all.map(&:to_h)
|
|
223
|
+
}
|
|
224
|
+
)
|
|
153
225
|
```
|
|
154
226
|
|
|
227
|
+
```handlebars
|
|
228
|
+
{{client.user}} <!-- Also serialized to window.appData.user -->
|
|
229
|
+
{{client.items}} <!-- Also serialized to window.appData.items -->
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
### Context Layer Fallback
|
|
233
|
+
|
|
234
|
+
Variables can use shorthand notation (checks `client` → `server` → `request`):
|
|
235
|
+
|
|
236
|
+
```handlebars
|
|
237
|
+
<!-- Explicit layer access -->
|
|
238
|
+
{{client.user}}
|
|
239
|
+
{{server.page_title}}
|
|
240
|
+
{{request.nonce}}
|
|
241
|
+
|
|
242
|
+
<!-- Shorthand (automatic layer lookup) -->
|
|
243
|
+
{{user}} <!-- Finds client.user -->
|
|
244
|
+
{{page_title}} <!-- Finds server.page_title -->
|
|
245
|
+
{{nonce}} <!-- Finds request.nonce -->
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
## Security Model: Server-to-Client Boundary
|
|
249
|
+
|
|
250
|
+
The `.rue` format enforces a **security boundary at the server-to-client handoff**:
|
|
251
|
+
|
|
155
252
|
### Server Templates: Full Context Access
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
- Database connections and internal APIs
|
|
159
|
-
- Configuration values and secrets
|
|
160
|
-
- Request metadata (CSRF tokens, nonces)
|
|
253
|
+
|
|
254
|
+
Templates have access to ALL context layers:
|
|
161
255
|
|
|
162
256
|
```handlebars
|
|
163
|
-
<!--
|
|
164
|
-
{{#if
|
|
165
|
-
<
|
|
257
|
+
<!-- Server-side template has full access -->
|
|
258
|
+
{{#if request.authenticated?}}
|
|
259
|
+
<div class="admin-panel">
|
|
260
|
+
<h2>Welcome {{client.user}}</h2>
|
|
261
|
+
<p>Secret: {{server.admin_notes}}</p> <!-- Not sent to browser -->
|
|
262
|
+
</div>
|
|
166
263
|
{{/if}}
|
|
167
|
-
<div class="{{app.theme_class}}">{{user.full_name}}</div>
|
|
168
264
|
```
|
|
169
265
|
|
|
170
266
|
### Client Data: Explicit Allowlist
|
|
171
|
-
Only data declared in `<data>` sections reaches the browser:
|
|
172
267
|
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
268
|
+
Only schema-declared data reaches the browser:
|
|
269
|
+
|
|
270
|
+
```xml
|
|
271
|
+
<schema lang="js-zod" window="data">
|
|
272
|
+
const schema = z.object({
|
|
273
|
+
user: z.string(),
|
|
274
|
+
userId: z.number()
|
|
275
|
+
// NOT declared: admin_notes, secret_key, internal_api_url
|
|
276
|
+
});
|
|
277
|
+
</schema>
|
|
182
278
|
```
|
|
183
279
|
|
|
184
|
-
|
|
280
|
+
**Result on client:**
|
|
281
|
+
|
|
185
282
|
```javascript
|
|
186
283
|
window.data = {
|
|
187
|
-
|
|
188
|
-
|
|
284
|
+
user: "Alice",
|
|
285
|
+
userId: 123
|
|
286
|
+
// admin_notes, secret_key NOT included (never declared in schema)
|
|
189
287
|
}
|
|
190
|
-
// No access to user.admin?, internal APIs, etc.
|
|
191
288
|
```
|
|
192
289
|
|
|
193
|
-
This creates a **REST API-like boundary** where you explicitly declare what data crosses the
|
|
290
|
+
This creates a **REST API-like boundary** where you explicitly declare what data crosses the security boundary.
|
|
194
291
|
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
292
|
+
### ⚠️ Critical: Schema Validates, Does NOT Filter
|
|
293
|
+
|
|
294
|
+
**IMPORTANT**: The schema does NOT filter which data gets serialized. The ENTIRE `client:` hash is serialized to the browser. The schema only validates that the serialized data matches the expected structure.
|
|
295
|
+
|
|
296
|
+
```ruby
|
|
297
|
+
# ⚠️ DANGER: ALL client data serialized (including password!)
|
|
298
|
+
view = Rhales::View.new(request,
|
|
299
|
+
client: {
|
|
300
|
+
user: 'Alice',
|
|
301
|
+
password: 'secret123', # ← Serialized to browser!
|
|
302
|
+
api_key: 'xyz' # ← Serialized to browser!
|
|
303
|
+
}
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
# Schema only validates structure, doesn't prevent serialization
|
|
307
|
+
# If schema doesn't include password/api_key, validation FAILS
|
|
308
|
+
# But data already leaked to browser in HTML response
|
|
198
309
|
```
|
|
199
310
|
|
|
200
|
-
|
|
311
|
+
**Your Responsibility**: Ensure the `client:` hash contains ONLY safe, public data. Never pass:
|
|
312
|
+
- Passwords or credentials
|
|
313
|
+
- API keys or secrets
|
|
314
|
+
- Internal URLs or configuration
|
|
315
|
+
- Personally identifiable information (PII) not intended for client
|
|
316
|
+
|
|
317
|
+
The schema is a **contract validator**, not a **data filter**.
|
|
318
|
+
|
|
319
|
+
## Complete Example: Dashboard
|
|
320
|
+
|
|
321
|
+
### Backend (Ruby)
|
|
201
322
|
|
|
202
|
-
|
|
323
|
+
```ruby
|
|
324
|
+
# config/routes.rb (Rails) or route handler
|
|
325
|
+
class DashboardController < ApplicationController
|
|
326
|
+
def show
|
|
327
|
+
view = Rhales::View.new(
|
|
328
|
+
request,
|
|
329
|
+
client: {
|
|
330
|
+
user: current_user.name,
|
|
331
|
+
userId: current_user.id,
|
|
332
|
+
items: current_user.items.map { |i|
|
|
333
|
+
{ id: i.id, name: i.name, price: i.price }
|
|
334
|
+
},
|
|
335
|
+
apiBaseUrl: ENV['API_BASE_URL']
|
|
336
|
+
},
|
|
337
|
+
server: {
|
|
338
|
+
page_title: 'Dashboard',
|
|
339
|
+
internal_notes: 'User has premium access', # Server-only
|
|
340
|
+
vite_assets: vite_javascript_tag('application')
|
|
341
|
+
}
|
|
342
|
+
)
|
|
343
|
+
|
|
344
|
+
render html: view.render('dashboard').html_safe
|
|
345
|
+
end
|
|
346
|
+
end
|
|
347
|
+
```
|
|
348
|
+
|
|
349
|
+
### Frontend (.rue file)
|
|
203
350
|
|
|
204
351
|
```xml
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
"api": {
|
|
219
|
-
"baseUrl": "{{api_base_url}}",
|
|
220
|
-
"csrfToken": "{{csrf_token}}"
|
|
221
|
-
},
|
|
222
|
-
"features": {{enabled_features}}
|
|
223
|
-
}
|
|
224
|
-
</data>
|
|
352
|
+
<!-- templates/dashboard.rue -->
|
|
353
|
+
<schema lang="js-zod" version="2" window="dashboardData" layout="layouts/main">
|
|
354
|
+
const schema = z.object({
|
|
355
|
+
user: z.string(),
|
|
356
|
+
userId: z.number(),
|
|
357
|
+
items: z.array(z.object({
|
|
358
|
+
id: z.number(),
|
|
359
|
+
name: z.string(),
|
|
360
|
+
price: z.number()
|
|
361
|
+
})),
|
|
362
|
+
apiBaseUrl: z.string().url()
|
|
363
|
+
});
|
|
364
|
+
</schema>
|
|
225
365
|
|
|
226
366
|
<template>
|
|
227
|
-
|
|
228
|
-
<
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
<
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
</main>
|
|
246
|
-
</div>
|
|
367
|
+
<div class="dashboard">
|
|
368
|
+
<h1>{{server.page_title}}</h1>
|
|
369
|
+
|
|
370
|
+
{{#if request.authenticated?}}
|
|
371
|
+
<p>Welcome, {{client.user}}!</p>
|
|
372
|
+
|
|
373
|
+
<div class="items">
|
|
374
|
+
{{#each client.items}}
|
|
375
|
+
<div class="item">
|
|
376
|
+
<h3>{{name}}</h3>
|
|
377
|
+
<p>${{price}}</p>
|
|
378
|
+
</div>
|
|
379
|
+
{{/each}}
|
|
380
|
+
</div>
|
|
381
|
+
{{else}}
|
|
382
|
+
<p>Please log in</p>
|
|
383
|
+
{{/if}}
|
|
384
|
+
</div>
|
|
247
385
|
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
386
|
+
<!-- Client-side JavaScript can access validated data -->
|
|
387
|
+
<script nonce="{{request.nonce}}">
|
|
388
|
+
// window.dashboardData is populated with schema-validated data
|
|
389
|
+
console.log('User ID:', window.dashboardData.userId);
|
|
390
|
+
console.log('Items:', window.dashboardData.items);
|
|
253
391
|
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
392
|
+
// Fetch additional data from API
|
|
393
|
+
fetch(window.dashboardData.apiBaseUrl + '/user/preferences')
|
|
394
|
+
.then(r => r.json())
|
|
395
|
+
.then(prefs => console.log('Preferences:', prefs));
|
|
396
|
+
</script>
|
|
258
397
|
</template>
|
|
398
|
+
|
|
399
|
+
<logic>
|
|
400
|
+
# Dashboard component demonstrates:
|
|
401
|
+
# - Schema-based type safety
|
|
402
|
+
# - Three-layer context access
|
|
403
|
+
# - Conditional rendering based on auth
|
|
404
|
+
# - Client-side data hydration
|
|
405
|
+
# - CSP nonce support
|
|
406
|
+
</logic>
|
|
407
|
+
```
|
|
408
|
+
|
|
409
|
+
### Generated HTML
|
|
410
|
+
|
|
411
|
+
```html
|
|
412
|
+
<div class="dashboard">
|
|
413
|
+
<h1>Dashboard</h1>
|
|
414
|
+
<p>Welcome, Alice!</p>
|
|
415
|
+
<div class="items">
|
|
416
|
+
<div class="item"><h3>Widget</h3><p>$19.99</p></div>
|
|
417
|
+
<div class="item"><h3>Gadget</h3><p>$29.99</p></div>
|
|
418
|
+
</div>
|
|
419
|
+
</div>
|
|
420
|
+
|
|
421
|
+
<!-- Hydration script injected automatically -->
|
|
422
|
+
<script id="rsfc-data-abc123" type="application/json">
|
|
423
|
+
{"user":"Alice","userId":123,"items":[{"id":1,"name":"Widget","price":19.99},{"id":2,"name":"Gadget","price":29.99}],"apiBaseUrl":"https://api.example.com"}
|
|
424
|
+
</script>
|
|
425
|
+
<script nonce="nonce-xyz789">
|
|
426
|
+
window.dashboardData = JSON.parse(document.getElementById('rsfc-data-abc123').textContent);
|
|
427
|
+
</script>
|
|
428
|
+
|
|
429
|
+
<!-- Your client-side script -->
|
|
430
|
+
<script nonce="nonce-xyz789">
|
|
431
|
+
console.log('User ID:', window.dashboardData.userId);
|
|
432
|
+
console.log('Items:', window.dashboardData.items);
|
|
433
|
+
fetch(window.dashboardData.apiBaseUrl + '/user/preferences')
|
|
434
|
+
.then(r => r.json())
|
|
435
|
+
.then(prefs => console.log('Preferences:', prefs));
|
|
436
|
+
</script>
|
|
437
|
+
```
|
|
438
|
+
|
|
439
|
+
## Template Syntax
|
|
440
|
+
|
|
441
|
+
Rhales uses Handlebars-style syntax:
|
|
442
|
+
|
|
443
|
+
### Variables
|
|
444
|
+
|
|
445
|
+
```handlebars
|
|
446
|
+
{{variable}} <!-- HTML-escaped (safe) -->
|
|
447
|
+
{{{variable}}} <!-- Raw output (use carefully!) -->
|
|
448
|
+
{{object.property}} <!-- Dot notation -->
|
|
449
|
+
{{array.0}} <!-- Array index -->
|
|
259
450
|
```
|
|
260
451
|
|
|
261
|
-
###
|
|
452
|
+
### Conditionals
|
|
262
453
|
|
|
263
|
-
|
|
454
|
+
```handlebars
|
|
455
|
+
{{#if condition}}
|
|
456
|
+
Content when true
|
|
457
|
+
{{else}}
|
|
458
|
+
Content when false
|
|
459
|
+
{{/if}}
|
|
264
460
|
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
- ✅ **SEO + SPA**: Server-rendered HTML with automatic client hydration
|
|
270
|
-
- ✅ **Security boundaries**: Only explicitly declared data reaches the client
|
|
461
|
+
{{#unless condition}}
|
|
462
|
+
Content when false
|
|
463
|
+
{{/unless}}
|
|
464
|
+
```
|
|
271
465
|
|
|
272
|
-
|
|
466
|
+
**Truthy/Falsy:**
|
|
467
|
+
- Falsy: `nil`, `null`, `false`, `""`, `0`, `"false"`
|
|
468
|
+
- Truthy: All other values
|
|
273
469
|
|
|
274
|
-
|
|
470
|
+
### Loops
|
|
471
|
+
|
|
472
|
+
```handlebars
|
|
473
|
+
{{#each items}}
|
|
474
|
+
{{@index}} <!-- 0-based index -->
|
|
475
|
+
{{@first}} <!-- true if first item -->
|
|
476
|
+
{{@last}} <!-- true if last item -->
|
|
477
|
+
{{this}} <!-- current item (if primitive) -->
|
|
478
|
+
{{name}} <!-- item.name (if object) -->
|
|
479
|
+
{{/each}}
|
|
480
|
+
```
|
|
481
|
+
|
|
482
|
+
### Partials
|
|
483
|
+
|
|
484
|
+
```handlebars
|
|
485
|
+
{{> header}} <!-- Include templates/header.rue -->
|
|
486
|
+
{{> components/nav}} <!-- Include templates/components/nav.rue -->
|
|
487
|
+
```
|
|
488
|
+
|
|
489
|
+
### Layouts
|
|
275
490
|
|
|
276
491
|
```xml
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
"name": "{{user.name}}" <!-- ✅ Exposed to client -->
|
|
282
|
-
}
|
|
283
|
-
<!-- ❌ user.secret_key not declared, won't reach client -->
|
|
284
|
-
}
|
|
285
|
-
</data>
|
|
492
|
+
<!-- templates/pages/home.rue -->
|
|
493
|
+
<schema lang="js-zod" window="data" layout="layouts/main">
|
|
494
|
+
const schema = z.object({ page: z.string() });
|
|
495
|
+
</schema>
|
|
286
496
|
|
|
287
497
|
<template>
|
|
288
|
-
<h1>
|
|
289
|
-
<p>{{user.name}}</p> <!-- ✅ Can access user object methods -->
|
|
290
|
-
<p>{{user.secret_key}}</p> <!-- ✅ Server-side only, not in <data> -->
|
|
498
|
+
<h1>Home Page Content</h1>
|
|
291
499
|
</template>
|
|
292
500
|
```
|
|
293
501
|
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
502
|
+
```xml
|
|
503
|
+
<!-- templates/layouts/main.rue -->
|
|
504
|
+
<schema lang="js-zod" window="layoutData">
|
|
505
|
+
const schema = z.object({ siteName: z.string() });
|
|
506
|
+
</schema>
|
|
299
507
|
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
508
|
+
<template>
|
|
509
|
+
<!DOCTYPE html>
|
|
510
|
+
<html>
|
|
511
|
+
<head>
|
|
512
|
+
<title>{{server.siteName}}</title>
|
|
513
|
+
</head>
|
|
514
|
+
<body>
|
|
515
|
+
<header>{{> components/header}}</header>
|
|
516
|
+
<main>
|
|
517
|
+
<!-- Page content injected here -->
|
|
518
|
+
</main>
|
|
519
|
+
<footer>{{> components/footer}}</footer>
|
|
520
|
+
</body>
|
|
521
|
+
</html>
|
|
522
|
+
</template>
|
|
523
|
+
```
|
|
305
524
|
|
|
306
|
-
|
|
525
|
+
## Schema Tooling
|
|
307
526
|
|
|
308
|
-
|
|
309
|
-
```html
|
|
310
|
-
<!-- Server-rendered HTML -->
|
|
311
|
-
<div id="app">
|
|
312
|
-
<nav>...</nav>
|
|
313
|
-
<main><h1>User Dashboard</h1><p>Welcome back, Alice!</p></main>
|
|
314
|
-
</div>
|
|
527
|
+
Rhales provides rake tasks for schema management:
|
|
315
528
|
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
</script>
|
|
529
|
+
```bash
|
|
530
|
+
# Generate JSON schemas from .rue templates
|
|
531
|
+
rake rhales:schema:generate TEMPLATES_DIR=./templates
|
|
532
|
+
|
|
533
|
+
# Validate existing JSON schemas
|
|
534
|
+
rake rhales:schema:validate
|
|
323
535
|
|
|
324
|
-
|
|
325
|
-
|
|
536
|
+
# Show schema statistics
|
|
537
|
+
rake rhales:schema:stats TEMPLATES_DIR=./templates
|
|
326
538
|
```
|
|
327
539
|
|
|
328
|
-
|
|
540
|
+
**Example output:**
|
|
329
541
|
|
|
330
|
-
|
|
542
|
+
```
|
|
543
|
+
Schema Statistics
|
|
544
|
+
============================================================
|
|
545
|
+
Templates directory: templates
|
|
546
|
+
|
|
547
|
+
Total .rue files: 25
|
|
548
|
+
Files with <schema>: 25
|
|
549
|
+
Files without <schema>: 0
|
|
550
|
+
|
|
551
|
+
By language:
|
|
552
|
+
js-zod: 25
|
|
553
|
+
```
|
|
554
|
+
|
|
555
|
+
## Framework Integration
|
|
556
|
+
|
|
557
|
+
### Rails
|
|
331
558
|
|
|
332
559
|
```ruby
|
|
333
560
|
# config/initializers/rhales.rb
|
|
@@ -338,91 +565,79 @@ end
|
|
|
338
565
|
|
|
339
566
|
# app/controllers/application_controller.rb
|
|
340
567
|
class ApplicationController < ActionController::Base
|
|
341
|
-
def render_rhales(template_name,
|
|
342
|
-
view = Rhales::View.new(request,
|
|
343
|
-
view.render(template_name
|
|
568
|
+
def render_rhales(template_name, client: {}, server: {})
|
|
569
|
+
view = Rhales::View.new(request, client: client, server: server)
|
|
570
|
+
view.render(template_name)
|
|
344
571
|
end
|
|
345
572
|
end
|
|
346
573
|
|
|
347
574
|
# In your controller
|
|
348
575
|
def dashboard
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
recent_products: Product.recent.limit(5),
|
|
353
|
-
cart: current_user.cart,
|
|
354
|
-
enabled_features: Feature.enabled_for(current_user)
|
|
576
|
+
html = render_rhales('dashboard',
|
|
577
|
+
client: { user: current_user.name, items: @items },
|
|
578
|
+
server: { page_title: 'Dashboard' }
|
|
355
579
|
)
|
|
580
|
+
render html: html.html_safe
|
|
356
581
|
end
|
|
357
582
|
```
|
|
358
583
|
|
|
359
|
-
|
|
584
|
+
### Roda
|
|
360
585
|
|
|
361
586
|
```ruby
|
|
362
587
|
# app.rb
|
|
363
|
-
require '
|
|
588
|
+
require 'roda'
|
|
364
589
|
require 'rhales'
|
|
365
590
|
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
config.default_locale = 'en'
|
|
369
|
-
end
|
|
591
|
+
class App < Roda
|
|
592
|
+
plugin :render
|
|
370
593
|
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
view.render(template_name, data)
|
|
594
|
+
Rhales.configure do |config|
|
|
595
|
+
config.template_paths = ['templates']
|
|
596
|
+
config.default_locale = 'en'
|
|
375
597
|
end
|
|
376
|
-
end
|
|
377
598
|
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
599
|
+
route do |r|
|
|
600
|
+
r.on 'dashboard' do
|
|
601
|
+
view = Rhales::View.new(
|
|
602
|
+
request,
|
|
603
|
+
client: { user: current_user.name },
|
|
604
|
+
server: { page_title: 'Dashboard' }
|
|
605
|
+
)
|
|
606
|
+
view.render('dashboard')
|
|
607
|
+
end
|
|
608
|
+
end
|
|
387
609
|
end
|
|
388
610
|
```
|
|
389
611
|
|
|
390
|
-
|
|
612
|
+
### Sinatra
|
|
391
613
|
|
|
392
614
|
```ruby
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
615
|
+
require 'sinatra'
|
|
616
|
+
require 'rhales'
|
|
617
|
+
|
|
618
|
+
Rhales.configure do |config|
|
|
619
|
+
config.template_paths = ['templates']
|
|
620
|
+
config.default_locale = 'en'
|
|
399
621
|
end
|
|
400
622
|
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
view
|
|
405
|
-
view.render(template_name, data)
|
|
623
|
+
helpers do
|
|
624
|
+
def render_rhales(template_name, client: {}, server: {})
|
|
625
|
+
view = Rhales::View.new(request, client: client, server: server)
|
|
626
|
+
view.render(template_name)
|
|
406
627
|
end
|
|
407
628
|
end
|
|
408
629
|
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
page_title: 'Dashboard'
|
|
413
|
-
user: current_user,
|
|
414
|
-
recent_products: Product.recent,
|
|
415
|
-
cart: current_user&.cart,
|
|
416
|
-
enabled_features: settings.features
|
|
630
|
+
get '/dashboard' do
|
|
631
|
+
render_rhales('dashboard',
|
|
632
|
+
client: { user: 'Alice' },
|
|
633
|
+
server: { page_title: 'Dashboard' }
|
|
417
634
|
)
|
|
418
|
-
render :dashboard
|
|
419
635
|
end
|
|
420
636
|
```
|
|
421
637
|
|
|
422
|
-
|
|
638
|
+
### Grape
|
|
423
639
|
|
|
424
640
|
```ruby
|
|
425
|
-
# config.ru or initializer
|
|
426
641
|
require 'grape'
|
|
427
642
|
require 'rhales'
|
|
428
643
|
|
|
@@ -431,734 +646,337 @@ Rhales.configure do |config|
|
|
|
431
646
|
config.default_locale = 'en'
|
|
432
647
|
end
|
|
433
648
|
|
|
434
|
-
# api.rb
|
|
435
649
|
class MyAPI < Grape::API
|
|
436
650
|
helpers do
|
|
437
|
-
def render_rhales(template_name,
|
|
438
|
-
# Create mock request/session for Grape
|
|
651
|
+
def render_rhales(template_name, client: {}, server: {})
|
|
439
652
|
mock_request = OpenStruct.new(env: env)
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
view = Rhales::View.new(mock_request, mock_session, current_user, 'en')
|
|
443
|
-
view.render(template_name, data)
|
|
653
|
+
view = Rhales::View.new(mock_request, client: client, server: server)
|
|
654
|
+
view.render(template_name)
|
|
444
655
|
end
|
|
445
656
|
end
|
|
446
657
|
|
|
447
658
|
get '/dashboard' do
|
|
448
659
|
content_type 'text/html'
|
|
449
660
|
render_rhales('dashboard',
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
recent_products: [],
|
|
453
|
-
cart: {},
|
|
454
|
-
enabled_features: { api_v2: true }
|
|
661
|
+
client: { user: 'Alice' },
|
|
662
|
+
server: { page_title: 'Dashboard' }
|
|
455
663
|
)
|
|
456
664
|
end
|
|
457
665
|
end
|
|
458
666
|
```
|
|
459
667
|
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
```ruby
|
|
463
|
-
# app.rb
|
|
464
|
-
require 'roda'
|
|
465
|
-
require 'rhales'
|
|
466
|
-
|
|
467
|
-
class App < Roda
|
|
468
|
-
plugin :render
|
|
469
|
-
|
|
470
|
-
Rhales.configure do |config|
|
|
471
|
-
config.template_paths = ['templates']
|
|
472
|
-
config.default_locale = 'en'
|
|
473
|
-
end
|
|
474
|
-
|
|
475
|
-
def render_rhales(template_name, data = {})
|
|
476
|
-
view = Rhales::View.new(request, session, current_user, 'en')
|
|
477
|
-
view.render(template_name, data)
|
|
478
|
-
end
|
|
479
|
-
|
|
480
|
-
route do |r|
|
|
481
|
-
r.on 'dashboard' do
|
|
482
|
-
@dashboard_html = render_rhales('dashboard',
|
|
483
|
-
page_title: 'Dashboard',
|
|
484
|
-
user: current_user,
|
|
485
|
-
recent_products: [],
|
|
486
|
-
cart: session[:cart],
|
|
487
|
-
enabled_features: FEATURES
|
|
488
|
-
)
|
|
489
|
-
view('dashboard')
|
|
490
|
-
end
|
|
491
|
-
end
|
|
492
|
-
end
|
|
493
|
-
```
|
|
494
|
-
|
|
495
|
-
### 6. Basic Usage
|
|
496
|
-
|
|
497
|
-
```ruby
|
|
498
|
-
# Create a view instance
|
|
499
|
-
view = Rhales::View.new(request, session, current_user, locale)
|
|
500
|
-
|
|
501
|
-
# Render a template with rich data for frontend hydration
|
|
502
|
-
html = view.render('dashboard',
|
|
503
|
-
page_title: 'User Dashboard',
|
|
504
|
-
user: current_user,
|
|
505
|
-
recent_products: Product.recent.limit(5),
|
|
506
|
-
cart: current_user.cart,
|
|
507
|
-
enabled_features: Feature.enabled_for(current_user)
|
|
508
|
-
)
|
|
509
|
-
|
|
510
|
-
# Or use the convenience method
|
|
511
|
-
html = Rhales.render('dashboard',
|
|
512
|
-
request: request,
|
|
513
|
-
session: session,
|
|
514
|
-
user: current_user,
|
|
515
|
-
page_title: 'User Dashboard',
|
|
516
|
-
recent_products: products,
|
|
517
|
-
cart: cart_data,
|
|
518
|
-
enabled_features: features
|
|
519
|
-
)
|
|
520
|
-
```
|
|
668
|
+
## Content Security Policy (CSP)
|
|
521
669
|
|
|
522
|
-
|
|
670
|
+
Rhales provides **security by default** with automatic CSP support.
|
|
523
671
|
|
|
524
|
-
|
|
672
|
+
### Default CSP Configuration
|
|
525
673
|
|
|
526
674
|
```ruby
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
end
|
|
531
|
-
|
|
532
|
-
def anonymous?
|
|
533
|
-
@user.nil?
|
|
534
|
-
end
|
|
535
|
-
|
|
536
|
-
def theme_preference
|
|
537
|
-
@user&.theme || 'light'
|
|
538
|
-
end
|
|
539
|
-
|
|
540
|
-
def user_id
|
|
541
|
-
@user&.id
|
|
542
|
-
end
|
|
543
|
-
|
|
544
|
-
def role?(role)
|
|
545
|
-
@user&.roles&.include?(role)
|
|
546
|
-
end
|
|
675
|
+
Rhales.configure do |config|
|
|
676
|
+
config.csp_enabled = true # Default: true
|
|
677
|
+
config.auto_nonce = true # Default: true
|
|
547
678
|
end
|
|
548
|
-
|
|
549
|
-
# Use with Rhales
|
|
550
|
-
user_adapter = MyAuthAdapter.new(current_user)
|
|
551
|
-
view = Rhales::View.new(request, session, user_adapter)
|
|
552
|
-
```
|
|
553
|
-
|
|
554
|
-
## Template Syntax
|
|
555
|
-
|
|
556
|
-
Rhales uses a Handlebars-style template syntax:
|
|
557
|
-
|
|
558
|
-
### Variables
|
|
559
|
-
- `{{variable}}` - HTML-escaped output
|
|
560
|
-
- `{{{variable}}}` - Raw output (use carefully!)
|
|
561
|
-
|
|
562
|
-
### Conditionals
|
|
563
|
-
```erb
|
|
564
|
-
{{#if condition}}
|
|
565
|
-
Content when true
|
|
566
|
-
{{/if}}
|
|
567
|
-
|
|
568
|
-
{{#unless condition}}
|
|
569
|
-
Content when false
|
|
570
|
-
{{/unless}}
|
|
571
|
-
```
|
|
572
|
-
|
|
573
|
-
### Iteration
|
|
574
|
-
```erb
|
|
575
|
-
{{#each items}}
|
|
576
|
-
<div>{{name}} - {{@index}}</div>
|
|
577
|
-
{{/each}}
|
|
578
|
-
```
|
|
579
|
-
|
|
580
|
-
### Partials
|
|
581
|
-
```erb
|
|
582
|
-
{{> header}}
|
|
583
|
-
{{> navigation}}
|
|
584
679
|
```
|
|
585
680
|
|
|
586
|
-
|
|
681
|
+
### Using Nonces in Templates
|
|
587
682
|
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
Injects scripts before the closing `</body>` tag. Safe and reliable for all scenarios.
|
|
594
|
-
|
|
595
|
-
```ruby
|
|
596
|
-
config.hydration.injection_strategy = :late
|
|
597
|
-
```
|
|
598
|
-
|
|
599
|
-
#### `:early` (Mount Point Optimization)
|
|
600
|
-
Injects scripts immediately before frontend mount points (`#app`, `#root`, etc.) for improved Time-to-Interactive.
|
|
601
|
-
|
|
602
|
-
```ruby
|
|
603
|
-
config.hydration.injection_strategy = :early
|
|
604
|
-
config.hydration.mount_point_selectors = ['#app', '#root', '[data-mount]']
|
|
605
|
-
config.hydration.fallback_to_late = true
|
|
606
|
-
```
|
|
607
|
-
|
|
608
|
-
#### `:earliest` (Head Section Injection)
|
|
609
|
-
Injects scripts in the HTML head section for maximum performance, after meta tags and stylesheets.
|
|
610
|
-
|
|
611
|
-
```ruby
|
|
612
|
-
config.hydration.injection_strategy = :earliest
|
|
613
|
-
config.hydration.fallback_to_late = true
|
|
614
|
-
```
|
|
615
|
-
|
|
616
|
-
### Link-Based Strategies (API Endpoints)
|
|
617
|
-
|
|
618
|
-
These strategies generate separate API endpoints for hydration data, enabling better caching, parallel loading, and reduced HTML payload sizes.
|
|
619
|
-
|
|
620
|
-
#### `:preload` (High Priority Loading)
|
|
621
|
-
Generates `<link rel="preload">` tags with immediate script execution for critical data.
|
|
622
|
-
|
|
623
|
-
```ruby
|
|
624
|
-
config.hydration.injection_strategy = :preload
|
|
625
|
-
config.hydration.api_endpoint_path = '/api/hydration'
|
|
626
|
-
config.hydration.link_crossorigin = true
|
|
627
|
-
```
|
|
628
|
-
|
|
629
|
-
#### `:prefetch` (Future Page Optimization)
|
|
630
|
-
Generates `<link rel="prefetch">` tags for data that will be needed on subsequent page loads.
|
|
683
|
+
```handlebars
|
|
684
|
+
<script nonce="{{request.nonce}}">
|
|
685
|
+
// Inline JavaScript with automatic nonce
|
|
686
|
+
console.log('Secure execution');
|
|
687
|
+
</script>
|
|
631
688
|
|
|
632
|
-
|
|
633
|
-
|
|
689
|
+
<style nonce="{{request.nonce}}">
|
|
690
|
+
/* Inline styles with automatic nonce */
|
|
691
|
+
.component { color: blue; }
|
|
692
|
+
</style>
|
|
634
693
|
```
|
|
635
694
|
|
|
636
|
-
|
|
637
|
-
Generates `<link rel="modulepreload">` tags with ES module imports for modern applications.
|
|
695
|
+
### Custom CSP Policies
|
|
638
696
|
|
|
639
697
|
```ruby
|
|
640
|
-
|
|
698
|
+
Rhales.configure do |config|
|
|
699
|
+
config.csp_policy = {
|
|
700
|
+
'default-src' => ["'self'"],
|
|
701
|
+
'script-src' => ["'self'", "'nonce-{{nonce}}'", 'https://cdn.example.com'],
|
|
702
|
+
'style-src' => ["'self'", "'nonce-{{nonce}}'"],
|
|
703
|
+
'img-src' => ["'self'", 'data:', 'https://images.example.com'],
|
|
704
|
+
'connect-src' => ["'self'", 'https://api.example.com']
|
|
705
|
+
}
|
|
706
|
+
end
|
|
641
707
|
```
|
|
642
708
|
|
|
643
|
-
|
|
644
|
-
Loads data only when mount points become visible using Intersection Observer API.
|
|
645
|
-
|
|
646
|
-
```ruby
|
|
647
|
-
config.hydration.injection_strategy = :lazy
|
|
648
|
-
config.hydration.lazy_mount_selector = '#app'
|
|
649
|
-
```
|
|
709
|
+
### Framework CSP Header Setup
|
|
650
710
|
|
|
651
|
-
####
|
|
652
|
-
Generates basic link references with manual loading functions for custom hydration logic.
|
|
711
|
+
#### Rails
|
|
653
712
|
|
|
654
713
|
```ruby
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
### Strategy Performance Comparison
|
|
659
|
-
|
|
660
|
-
| Strategy | Time-to-Interactive | Caching | Parallel Loading | Best Use Case |
|
|
661
|
-
|----------|-------------------|---------|------------------|---------------|
|
|
662
|
-
| `:late` | Standard | Basic | No | Legacy compatibility |
|
|
663
|
-
| `:early` | Improved | Basic | No | SPA mount point optimization |
|
|
664
|
-
| `:earliest` | Excellent | Basic | No | Critical path optimization |
|
|
665
|
-
| `:preload` | Excellent | Advanced | Yes | High-priority data |
|
|
666
|
-
| `:prefetch` | Standard | Advanced | Yes | Multi-page apps |
|
|
667
|
-
| `:modulepreload` | Excellent | Advanced | Yes | Modern ES modules |
|
|
668
|
-
| `:lazy` | Variable | Advanced | Yes | Below-fold content |
|
|
669
|
-
| `:link` | Manual | Advanced | Yes | Custom implementations |
|
|
670
|
-
|
|
671
|
-
### API Endpoint Setup
|
|
672
|
-
|
|
673
|
-
For link-based strategies, you'll need to set up API endpoints in your application:
|
|
714
|
+
class ApplicationController < ActionController::Base
|
|
715
|
+
after_action :set_csp_header
|
|
674
716
|
|
|
675
|
-
|
|
676
|
-
# Rails example
|
|
677
|
-
class HydrationController < ApplicationController
|
|
678
|
-
def show
|
|
679
|
-
template_name = params[:template]
|
|
680
|
-
endpoint = Rhales::HydrationEndpoint.new(rhales_config, current_context)
|
|
681
|
-
|
|
682
|
-
case request.format
|
|
683
|
-
when :json
|
|
684
|
-
result = endpoint.render_json(template_name)
|
|
685
|
-
when :js
|
|
686
|
-
result = endpoint.render_module(template_name)
|
|
687
|
-
else
|
|
688
|
-
result = endpoint.render_json(template_name)
|
|
689
|
-
end
|
|
717
|
+
private
|
|
690
718
|
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
719
|
+
def set_csp_header
|
|
720
|
+
csp_header = request.env['csp_header']
|
|
721
|
+
response.headers['Content-Security-Policy'] = csp_header if csp_header
|
|
694
722
|
end
|
|
695
723
|
end
|
|
696
|
-
|
|
697
|
-
# routes.rb
|
|
698
|
-
get '/api/hydration/:template', to: 'hydration#show'
|
|
699
|
-
get '/api/hydration/:template.js', to: 'hydration#show', defaults: { format: :js }
|
|
700
724
|
```
|
|
701
725
|
|
|
702
|
-
####
|
|
703
|
-
|
|
704
|
-
For applications using custom frameworks or middleware, here's a complete controller implementation from a production Rack-based application:
|
|
726
|
+
#### Roda
|
|
705
727
|
|
|
706
728
|
```ruby
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
def rhales_hydration
|
|
714
|
-
publically do
|
|
715
|
-
template_name = params[:template]
|
|
716
|
-
|
|
717
|
-
# Build Rhales context from your app's current state
|
|
718
|
-
context = Rhales::Context.for_view(req, sess, cust, locale)
|
|
719
|
-
endpoint = Rhales::HydrationEndpoint.new(Rhales.configuration, context)
|
|
720
|
-
|
|
721
|
-
# Handle different formats
|
|
722
|
-
result = case req.env['HTTP_ACCEPT']
|
|
723
|
-
when /application\/javascript/
|
|
724
|
-
endpoint.render_module(template_name)
|
|
725
|
-
else
|
|
726
|
-
endpoint.render_json(template_name)
|
|
727
|
-
end
|
|
728
|
-
|
|
729
|
-
# Set response content type and headers
|
|
730
|
-
res['Content-Type'] = result[:content_type]
|
|
731
|
-
result[:headers]&.each { |key, value| res[key] = value }
|
|
732
|
-
|
|
733
|
-
# Return the content
|
|
734
|
-
res.body = result[:content]
|
|
735
|
-
end
|
|
736
|
-
end
|
|
737
|
-
end
|
|
729
|
+
class App < Roda
|
|
730
|
+
def render_with_csp(template_name, **data)
|
|
731
|
+
result = render_rhales(template_name, **data)
|
|
732
|
+
csp_header = request.env['csp_header']
|
|
733
|
+
response.headers['Content-Security-Policy'] = csp_header if csp_header
|
|
734
|
+
result
|
|
738
735
|
end
|
|
739
736
|
end
|
|
740
|
-
|
|
741
|
-
# Route configuration (framework-specific)
|
|
742
|
-
# GET /api/hydration/:template -> data#rhales_hydration
|
|
743
737
|
```
|
|
744
738
|
|
|
745
|
-
|
|
746
|
-
- **Framework-agnostic**: Uses `req`, `res`, `sess`, `cust`, `locale` from your framework
|
|
747
|
-
- **Context creation**: Manually creates `Rhales::Context.for_view` with app objects
|
|
748
|
-
- **Global configuration**: Uses `Rhales.configuration` instead of passing custom config
|
|
749
|
-
- **Direct response handling**: Sets headers and body directly instead of using `render`
|
|
750
|
-
- **Custom format detection**: Uses `req.env['HTTP_ACCEPT']` instead of `request.format`
|
|
739
|
+
## Logging
|
|
751
740
|
|
|
752
|
-
|
|
741
|
+
Rhales provides production logging for security auditing and debugging:
|
|
753
742
|
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
{
|
|
758
|
-
"apiUrl": "{{api_base_url}}",
|
|
759
|
-
"user": {{user}},
|
|
760
|
-
"csrfToken": "{{csrf_token}}"
|
|
761
|
-
}
|
|
762
|
-
</data>
|
|
763
|
-
```
|
|
764
|
-
|
|
765
|
-
Generates:
|
|
766
|
-
```html
|
|
767
|
-
<script id="rsfc-data-abc123" type="application/json">
|
|
768
|
-
{"apiUrl":"https://api.example.com","user":{"id":123},"csrfToken":"token"}
|
|
769
|
-
</script>
|
|
770
|
-
<script nonce="nonce123">
|
|
771
|
-
window.myData = JSON.parse(document.getElementById('rsfc-data-abc123').textContent);
|
|
772
|
-
</script>
|
|
773
|
-
```
|
|
774
|
-
|
|
775
|
-
#### Link-Based Hydration (`:preload` strategy)
|
|
776
|
-
```erb
|
|
777
|
-
<data window="myData">
|
|
778
|
-
{
|
|
779
|
-
"apiUrl": "{{api_base_url}}",
|
|
780
|
-
"user": {{user}},
|
|
781
|
-
"csrfToken": "{{csrf_token}}"
|
|
782
|
-
}
|
|
783
|
-
</data>
|
|
743
|
+
```ruby
|
|
744
|
+
# Configure logger
|
|
745
|
+
Rhales.logger = Rails.logger # or Logger.new($stdout)
|
|
784
746
|
```
|
|
785
747
|
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
.then(r => r.json())
|
|
792
|
-
.then(data => {
|
|
793
|
-
window.myData = data;
|
|
794
|
-
window.dispatchEvent(new CustomEvent('rhales:hydrated', {
|
|
795
|
-
detail: { target: 'myData', data: data }
|
|
796
|
-
}));
|
|
797
|
-
})
|
|
798
|
-
.catch(err => console.error('Rhales hydration error:', err));
|
|
799
|
-
</script>
|
|
800
|
-
```
|
|
748
|
+
**Logged Events:**
|
|
749
|
+
- View rendering (template, layout, partials, timing, hydration size)
|
|
750
|
+
- Security warnings (unescaped variables, schema mismatches)
|
|
751
|
+
- Errors with context (line numbers, sections, full messages)
|
|
752
|
+
- Performance insights (cache hits, compilation timing)
|
|
801
753
|
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
window.dispatchEvent(new CustomEvent('rhales:hydrated', {
|
|
809
|
-
detail: { target: 'myData', data: data }
|
|
810
|
-
}));
|
|
811
|
-
</script>
|
|
754
|
+
```ruby
|
|
755
|
+
# Example log output
|
|
756
|
+
INFO View rendered: template=dashboard layout=main partials=[header,footer] duration_ms=15.2
|
|
757
|
+
WARN Hydration schema mismatch: template=user_profile missing=[email] extra=[]
|
|
758
|
+
ERROR Template not found: template=missing_partial parent=dashboard
|
|
759
|
+
DEBUG Template cache hit: template=header
|
|
812
760
|
```
|
|
813
761
|
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
#### From Basic to Enhanced Hydration
|
|
762
|
+
## Testing
|
|
817
763
|
|
|
818
|
-
|
|
764
|
+
### Test Configuration
|
|
819
765
|
|
|
820
766
|
```ruby
|
|
821
|
-
#
|
|
822
|
-
|
|
823
|
-
# Basic configuration
|
|
824
|
-
end
|
|
767
|
+
# test/test_helper.rb or spec/spec_helper.rb
|
|
768
|
+
require 'rhales'
|
|
825
769
|
|
|
826
|
-
# After (explicit strategy selection)
|
|
827
770
|
Rhales.configure do |config|
|
|
828
|
-
|
|
829
|
-
config.
|
|
830
|
-
config.
|
|
831
|
-
config.
|
|
832
|
-
config.hydration.api_cache_enabled = true
|
|
771
|
+
config.default_locale = 'en'
|
|
772
|
+
config.app_environment = 'test'
|
|
773
|
+
config.cache_templates = false
|
|
774
|
+
config.template_paths = ['test/fixtures/templates']
|
|
833
775
|
end
|
|
834
776
|
```
|
|
835
777
|
|
|
836
|
-
|
|
778
|
+
### Testing Context
|
|
837
779
|
|
|
838
780
|
```ruby
|
|
839
|
-
#
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
class HydrationController < ApplicationController
|
|
845
|
-
def show
|
|
846
|
-
template_name = params[:template]
|
|
847
|
-
endpoint = Rhales::HydrationEndpoint.new(rhales_config, current_context)
|
|
848
|
-
result = endpoint.render_json(template_name)
|
|
781
|
+
# Minimal context for testing
|
|
782
|
+
context = Rhales::Context.minimal(
|
|
783
|
+
client: { user: 'Test' },
|
|
784
|
+
server: { page_title: 'Test Page' }
|
|
785
|
+
)
|
|
849
786
|
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
headers: result[:headers]
|
|
853
|
-
end
|
|
854
|
-
end
|
|
787
|
+
expect(context.get('user')).to eq('Test')
|
|
788
|
+
expect(context.get('page_title')).to eq('Test Page')
|
|
855
789
|
```
|
|
856
790
|
|
|
857
|
-
|
|
791
|
+
### Testing Templates
|
|
858
792
|
|
|
859
|
-
```
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
793
|
+
```ruby
|
|
794
|
+
# Test inline template
|
|
795
|
+
template = '{{#if active}}Active{{else}}Inactive{{/if}}'
|
|
796
|
+
result = Rhales.render_template(template, active: true)
|
|
797
|
+
expect(result).to eq('Active')
|
|
863
798
|
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
799
|
+
# Test .rue file
|
|
800
|
+
mock_request = OpenStruct.new(env: {})
|
|
801
|
+
view = Rhales::View.new(mock_request, client: { message: 'Hello' })
|
|
802
|
+
html = view.render('test_template')
|
|
803
|
+
expect(html).to include('Hello')
|
|
869
804
|
```
|
|
870
805
|
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
#### Common Issues
|
|
874
|
-
|
|
875
|
-
**1. Link-based strategies not working**
|
|
876
|
-
- Ensure API endpoints are set up correctly
|
|
877
|
-
- Check that `config.hydration.api_endpoint_path` matches your routes
|
|
878
|
-
- Verify CORS settings if loading from different domains
|
|
879
|
-
|
|
880
|
-
**2. Mount points not detected with `:early` strategy**
|
|
881
|
-
- Check that your HTML contains valid mount point selectors (`#app`, `#root`, etc.)
|
|
882
|
-
- Verify `config.hydration.mount_point_selectors` includes your selectors
|
|
883
|
-
- Enable fallback: `config.hydration.fallback_to_late = true`
|
|
806
|
+
## Migration from v0.4 to v0.5
|
|
884
807
|
|
|
885
|
-
|
|
886
|
-
- Ensure nonces are properly configured: `config.auto_nonce = true`
|
|
887
|
-
- Add API endpoint domains to CSP `connect-src` directive
|
|
888
|
-
- Check that `crossorigin` attribute is properly configured
|
|
808
|
+
### Breaking Changes
|
|
889
809
|
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
-
|
|
893
|
-
-
|
|
810
|
+
1. **`<data>` sections removed** → Use `<schema>` sections
|
|
811
|
+
2. **Parameters removed:**
|
|
812
|
+
- `sess` → Access via `request.session`
|
|
813
|
+
- `cust` → Access via `request.user`
|
|
814
|
+
- `props:` → Use `client:`
|
|
815
|
+
- `app_data:` → Use `server:`
|
|
816
|
+
- `locale` → Set via `request.env['rhales.locale']`
|
|
817
|
+
3. **Context layer renamed:** `app` → `request`
|
|
894
818
|
|
|
895
|
-
|
|
896
|
-
- Ensure JavaScript is not blocked by CSP
|
|
897
|
-
- Check browser console for script errors
|
|
898
|
-
- Verify API endpoints return valid JSON responses
|
|
819
|
+
### Migration Steps
|
|
899
820
|
|
|
900
|
-
|
|
821
|
+
#### 1. Update Ruby Code
|
|
901
822
|
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
</data>
|
|
909
|
-
|
|
910
|
-
<!-- pages/home.rue -->
|
|
911
|
-
<data window="appData"> <!-- ❌ Collision detected! -->
|
|
912
|
-
{"page": "home", "features": ["feature1"]}
|
|
913
|
-
</data>
|
|
914
|
-
```
|
|
915
|
-
|
|
916
|
-
This raises a helpful error:
|
|
917
|
-
```
|
|
918
|
-
Window attribute collision detected
|
|
823
|
+
```ruby
|
|
824
|
+
# v0.4 (REMOVED)
|
|
825
|
+
view = Rhales::View.new(req, session, customer, 'en',
|
|
826
|
+
props: { user: customer.name },
|
|
827
|
+
app_data: { page_title: 'Dashboard' }
|
|
828
|
+
)
|
|
919
829
|
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
830
|
+
# v0.5 (Current)
|
|
831
|
+
view = Rhales::View.new(req,
|
|
832
|
+
client: { user: customer.name },
|
|
833
|
+
server: { page_title: 'Dashboard' }
|
|
834
|
+
)
|
|
923
835
|
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
2. Enable merging: <data window="appData" merge="deep">
|
|
836
|
+
# Set locale in request
|
|
837
|
+
req.env['rhales.locale'] = 'en'
|
|
927
838
|
```
|
|
928
839
|
|
|
929
|
-
|
|
840
|
+
#### 2. Convert Data to Schema
|
|
930
841
|
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
<!-- layouts/main.rue -->
|
|
935
|
-
<data window="appData">
|
|
842
|
+
```xml
|
|
843
|
+
<!-- v0.4 (REMOVED) -->
|
|
844
|
+
<data window="data">
|
|
936
845
|
{
|
|
937
|
-
"user":
|
|
938
|
-
"
|
|
846
|
+
"user": "{{user.name}}",
|
|
847
|
+
"count": {{items.count}}
|
|
939
848
|
}
|
|
940
849
|
</data>
|
|
941
850
|
|
|
942
|
-
<!--
|
|
943
|
-
<
|
|
944
|
-
{
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
}
|
|
948
|
-
</
|
|
851
|
+
<!-- v0.5 (Current) -->
|
|
852
|
+
<schema lang="js-zod" window="data">
|
|
853
|
+
const schema = z.object({
|
|
854
|
+
user: z.string(),
|
|
855
|
+
count: z.number()
|
|
856
|
+
});
|
|
857
|
+
</schema>
|
|
949
858
|
```
|
|
950
859
|
|
|
951
|
-
|
|
860
|
+
**Key difference:** In v0.5, pass resolved values in `client:` hash instead of relying on template interpolation in JSON.
|
|
952
861
|
|
|
953
|
-
|
|
954
|
-
```javascript
|
|
955
|
-
// Layout: {"user": {...}, "csrf": "abc"}
|
|
956
|
-
// Page: {"page": {...}, "user": {...}} // ❌ Error: key conflict
|
|
957
|
-
```
|
|
862
|
+
#### 3. Update Context References
|
|
958
863
|
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
// Result: {"user": {"name": "John", "role": "admin", "email": "john@example.com"}}
|
|
964
|
-
```
|
|
864
|
+
```handlebars
|
|
865
|
+
<!-- v0.4 (REMOVED) -->
|
|
866
|
+
{{app.nonce}}
|
|
867
|
+
{{app.csrf_token}}
|
|
965
868
|
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
// Page: {"user": {"name": "Jane"}} // ❌ Error: value conflict
|
|
869
|
+
<!-- v0.5 (Current) -->
|
|
870
|
+
{{request.nonce}}
|
|
871
|
+
{{request.csrf_token}}
|
|
970
872
|
```
|
|
971
873
|
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
Rhales provides **security by default** with automatic CSP header generation and nonce management.
|
|
975
|
-
|
|
976
|
-
### Automatic CSP Protection
|
|
977
|
-
|
|
978
|
-
CSP is **enabled by default** when you configure Rhales:
|
|
874
|
+
#### 4. Update Backend Data Passing
|
|
979
875
|
|
|
980
876
|
```ruby
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
end
|
|
986
|
-
```
|
|
987
|
-
|
|
988
|
-
### Default Security Policy
|
|
989
|
-
|
|
990
|
-
Rhales ships with a secure default CSP policy:
|
|
877
|
+
# v0.4: Template interpolation
|
|
878
|
+
view = Rhales::View.new(req, sess, cust, 'en',
|
|
879
|
+
props: { user: cust } # Object reference, interpolated in <data>
|
|
880
|
+
)
|
|
991
881
|
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
'connect-src' => ["'self'"],
|
|
1000
|
-
'base-uri' => ["'self'"],
|
|
1001
|
-
'form-action' => ["'self'"],
|
|
1002
|
-
'frame-ancestors' => ["'none'"],
|
|
1003
|
-
'object-src' => ["'none'"],
|
|
1004
|
-
'upgrade-insecure-requests' => []
|
|
1005
|
-
}
|
|
882
|
+
# v0.5: Resolved values upfront
|
|
883
|
+
view = Rhales::View.new(req,
|
|
884
|
+
client: {
|
|
885
|
+
user: cust.name, # Resolved value
|
|
886
|
+
userId: cust.id # Resolved value
|
|
887
|
+
}
|
|
888
|
+
)
|
|
1006
889
|
```
|
|
1007
890
|
|
|
1008
|
-
|
|
891
|
+
## Performance Optimization
|
|
1009
892
|
|
|
1010
|
-
|
|
893
|
+
### Optional: Oj for Faster JSON Processing
|
|
1011
894
|
|
|
1012
|
-
|
|
1013
|
-
<!-- In your .rue templates -->
|
|
1014
|
-
<script nonce="{{app.nonce}}">
|
|
1015
|
-
// Your inline JavaScript with automatic nonce
|
|
1016
|
-
console.log('Secure script execution');
|
|
1017
|
-
</script>
|
|
895
|
+
Rhales includes optional support for [Oj](https://github.com/ohler55/oj), a high-performance JSON library that provides:
|
|
1018
896
|
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
```
|
|
897
|
+
- **10-20x faster JSON parsing** compared to stdlib
|
|
898
|
+
- **5-10x faster JSON generation** compared to stdlib
|
|
899
|
+
- **Lower memory usage** for large data payloads
|
|
900
|
+
- **Full compatibility** with stdlib JSON API
|
|
1024
901
|
|
|
1025
|
-
|
|
902
|
+
#### Installation
|
|
1026
903
|
|
|
1027
|
-
|
|
904
|
+
Add to your Gemfile:
|
|
1028
905
|
|
|
1029
906
|
```ruby
|
|
1030
|
-
|
|
1031
|
-
def dashboard
|
|
1032
|
-
view = Rhales::View.new(request, session, current_user, 'en')
|
|
1033
|
-
html = view.render('dashboard', user: current_user)
|
|
1034
|
-
|
|
1035
|
-
# CSP header automatically added to response:
|
|
1036
|
-
# Content-Security-Policy: default-src 'self'; script-src 'self' 'nonce-abc123'; ...
|
|
1037
|
-
end
|
|
907
|
+
gem 'oj', '~> 3.13'
|
|
1038
908
|
```
|
|
1039
909
|
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
Customize the CSP policy for your specific needs:
|
|
910
|
+
Then run:
|
|
1043
911
|
|
|
1044
|
-
```
|
|
1045
|
-
|
|
1046
|
-
config.csp_policy = {
|
|
1047
|
-
'default-src' => ["'self'"],
|
|
1048
|
-
'script-src' => ["'self'", "'nonce-{{nonce}}'", 'https://cdn.example.com'],
|
|
1049
|
-
'style-src' => ["'self'", "'nonce-{{nonce}}'", 'https://fonts.googleapis.com'],
|
|
1050
|
-
'img-src' => ["'self'", 'data:', 'https://images.example.com'],
|
|
1051
|
-
'connect-src' => ["'self'", 'https://api.example.com'],
|
|
1052
|
-
'font-src' => ["'self'", 'https://fonts.gstatic.com'],
|
|
1053
|
-
# Add your own directives...
|
|
1054
|
-
}
|
|
1055
|
-
end
|
|
912
|
+
```bash
|
|
913
|
+
bundle install
|
|
1056
914
|
```
|
|
1057
915
|
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
#### Rails
|
|
1061
|
-
```ruby
|
|
1062
|
-
# app/controllers/application_controller.rb
|
|
1063
|
-
class ApplicationController < ActionController::Base
|
|
1064
|
-
after_action :set_csp_header
|
|
1065
|
-
|
|
1066
|
-
private
|
|
916
|
+
That's it! Rhales automatically detects Oj at load time and uses it for all JSON operations.
|
|
1067
917
|
|
|
1068
|
-
|
|
1069
|
-
csp_header = request.env['csp_header']
|
|
1070
|
-
response.headers['Content-Security-Policy'] = csp_header if csp_header
|
|
1071
|
-
end
|
|
1072
|
-
end
|
|
1073
|
-
```
|
|
1074
|
-
|
|
1075
|
-
#### Sinatra
|
|
1076
|
-
```ruby
|
|
1077
|
-
helpers do
|
|
1078
|
-
def render_with_csp(template_name, data = {})
|
|
1079
|
-
result = render_rhales(template_name, data)
|
|
1080
|
-
csp_header = request.env['csp_header']
|
|
1081
|
-
headers['Content-Security-Policy'] = csp_header if csp_header
|
|
1082
|
-
result
|
|
1083
|
-
end
|
|
1084
|
-
end
|
|
1085
|
-
```
|
|
918
|
+
**Note:** The backend is selected once when Rhales loads. To ensure Oj is used, require it before Rhales:
|
|
1086
919
|
|
|
1087
|
-
#### Roda
|
|
1088
920
|
```ruby
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
csp_header = request.env['csp_header']
|
|
1093
|
-
response.headers['Content-Security-Policy'] = csp_header if csp_header
|
|
1094
|
-
result
|
|
1095
|
-
end
|
|
1096
|
-
end
|
|
921
|
+
# Gemfile or application initialization
|
|
922
|
+
require 'oj' # Load Oj first
|
|
923
|
+
require 'rhales' # Rhales will detect and use Oj
|
|
1097
924
|
```
|
|
1098
925
|
|
|
1099
|
-
|
|
926
|
+
Most bundler setups handle this automatically, but explicit ordering ensures optimal performance.
|
|
1100
927
|
|
|
1101
|
-
|
|
1102
|
-
- **Automatic nonce management**: No manual nonce coordination needed
|
|
1103
|
-
- **Template integration**: Nonces automatically available in templates
|
|
1104
|
-
- **Framework agnostic**: Works with any Ruby web framework
|
|
1105
|
-
- **Customizable policies**: Adapt CSP rules to your application needs
|
|
1106
|
-
- **Zero configuration**: Secure defaults work immediately
|
|
928
|
+
#### Verification
|
|
1107
929
|
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
If you need to disable CSP for specific environments:
|
|
930
|
+
Check which backend is active:
|
|
1111
931
|
|
|
1112
932
|
```ruby
|
|
1113
|
-
Rhales.
|
|
1114
|
-
|
|
1115
|
-
config.auto_nonce = false # Disable automatic nonce generation
|
|
1116
|
-
end
|
|
933
|
+
Rhales::JSONSerializer.backend
|
|
934
|
+
# => :oj (if available) or :json (stdlib)
|
|
1117
935
|
```
|
|
1118
936
|
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
Rhales includes comprehensive test helpers and is framework-agnostic:
|
|
1122
|
-
|
|
1123
|
-
```ruby
|
|
1124
|
-
# test/test_helper.rb or spec/spec_helper.rb
|
|
1125
|
-
require 'rhales'
|
|
937
|
+
#### Performance Impact
|
|
1126
938
|
|
|
1127
|
-
Rhales
|
|
1128
|
-
config.default_locale = 'en'
|
|
1129
|
-
config.app_environment = 'test'
|
|
1130
|
-
config.cache_templates = false
|
|
1131
|
-
config.template_paths = ['test/templates'] # or wherever your test templates are
|
|
1132
|
-
end
|
|
939
|
+
For typical Rhales applications with hydration data:
|
|
1133
940
|
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
941
|
+
| Operation | stdlib JSON | Oj | Improvement |
|
|
942
|
+
|-----------|-------------|-----|-------------|
|
|
943
|
+
| Parse 100KB payload | ~50ms | ~3ms | **16x faster** |
|
|
944
|
+
| Generate 100KB payload | ~30ms | ~5ms | **6x faster** |
|
|
945
|
+
| Memory usage | Baseline | -20% | **Lower** |
|
|
1137
946
|
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
947
|
+
**Recommendation:** Install Oj for production applications with:
|
|
948
|
+
- Large hydration payloads (>10KB)
|
|
949
|
+
- High-traffic endpoints (>100 req/sec)
|
|
950
|
+
- Complex nested data structures
|
|
1142
951
|
|
|
1143
|
-
|
|
1144
|
-
mock_request = OpenStruct.new(env: {})
|
|
1145
|
-
mock_session = {}
|
|
1146
|
-
view = Rhales::View.new(mock_request, mock_session, nil, 'en')
|
|
1147
|
-
html = view.render('test_template', message: 'Hello World')
|
|
1148
|
-
```
|
|
952
|
+
Oj provides the most benefit for data-heavy templates and high-concurrency scenarios.
|
|
1149
953
|
|
|
1150
954
|
## Development
|
|
1151
955
|
|
|
1152
|
-
After checking out the repo, run:
|
|
1153
|
-
|
|
1154
956
|
```bash
|
|
957
|
+
# Clone repository
|
|
958
|
+
git clone https://github.com/onetimesecret/rhales.git
|
|
959
|
+
cd rhales
|
|
960
|
+
|
|
961
|
+
# Install dependencies
|
|
1155
962
|
bundle install
|
|
1156
|
-
|
|
963
|
+
|
|
964
|
+
# Run tests
|
|
965
|
+
bundle exec rspec spec/rhales/
|
|
966
|
+
|
|
967
|
+
# Run with documentation format
|
|
968
|
+
bundle exec rspec spec/rhales/ --format documentation
|
|
969
|
+
|
|
970
|
+
# Build gem
|
|
971
|
+
gem build rhales.gemspec
|
|
972
|
+
|
|
973
|
+
# Install locally
|
|
974
|
+
gem install ./rhales-0.5.0.gem
|
|
1157
975
|
```
|
|
1158
976
|
|
|
1159
977
|
## Contributing
|
|
1160
978
|
|
|
1161
|
-
1. Fork it
|
|
979
|
+
1. Fork it (https://github.com/onetimesecret/rhales/fork)
|
|
1162
980
|
2. Create your feature branch (`git checkout -b my-new-feature`)
|
|
1163
981
|
3. Commit your changes (`git commit -am 'Add some feature'`)
|
|
1164
982
|
4. Push to the branch (`git push origin my-new-feature`)
|
|
@@ -1170,11 +988,11 @@ The gem is available as open source under the [MIT License](https://opensource.o
|
|
|
1170
988
|
|
|
1171
989
|
## AI Development Assistance
|
|
1172
990
|
|
|
1173
|
-
Rhales was developed with assistance from AI tools
|
|
991
|
+
Rhales was developed with assistance from AI tools:
|
|
1174
992
|
|
|
1175
|
-
- **Claude Sonnet 4** - Architecture design, code generation,
|
|
1176
|
-
- **Claude Desktop & Claude Code** - Interactive development
|
|
1177
|
-
- **GitHub Copilot** - Code completion and refactoring
|
|
993
|
+
- **Claude Sonnet 4.5** - Architecture design, code generation, documentation
|
|
994
|
+
- **Claude Desktop & Claude Code** - Interactive development and debugging
|
|
995
|
+
- **GitHub Copilot** - Code completion and refactoring
|
|
1178
996
|
- **Qodo Merge Pro** - Code review and quality improvements
|
|
1179
997
|
|
|
1180
|
-
I remain responsible for all design decisions and the final code.
|
|
998
|
+
I remain responsible for all design decisions and the final code. Being transparent about development tools as AI becomes more integrated into our workflows.
|