rhales 0.3.0 → 0.5.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.github/renovate.json5 +52 -0
- data/.github/workflows/ci.yml +123 -0
- data/.github/workflows/claude-code-review.yml +69 -0
- data/.github/workflows/claude.yml +49 -0
- data/.github/workflows/code-smells.yml +146 -0
- data/.github/workflows/ruby-lint.yml +78 -0
- data/.github/workflows/yardoc.yml +126 -0
- data/.gitignore +55 -0
- data/.pr_agent.toml +63 -0
- data/.pre-commit-config.yaml +89 -0
- data/.prettierignore +8 -0
- data/.prettierrc +38 -0
- data/.reek.yml +98 -0
- data/.rubocop.yml +428 -0
- data/.serena/.gitignore +3 -0
- data/.yardopts +56 -0
- data/CHANGELOG.md +44 -0
- data/CLAUDE.md +1 -2
- data/Gemfile +29 -0
- data/Gemfile.lock +189 -0
- data/README.md +706 -589
- data/Rakefile +46 -0
- data/debug_context.rb +25 -0
- data/demo/rhales-roda-demo/.gitignore +7 -0
- data/demo/rhales-roda-demo/Gemfile +32 -0
- data/demo/rhales-roda-demo/Gemfile.lock +151 -0
- data/demo/rhales-roda-demo/MAIL.md +405 -0
- data/demo/rhales-roda-demo/README.md +376 -0
- data/demo/rhales-roda-demo/RODA-TEMPLATE-ENGINES.md +192 -0
- data/demo/rhales-roda-demo/Rakefile +49 -0
- data/demo/rhales-roda-demo/app.rb +325 -0
- data/demo/rhales-roda-demo/bin/rackup +26 -0
- data/demo/rhales-roda-demo/config.ru +13 -0
- data/demo/rhales-roda-demo/db/migrate/001_create_rodauth_tables.rb +266 -0
- data/demo/rhales-roda-demo/db/migrate/002_create_rodauth_password_tables.rb +79 -0
- data/demo/rhales-roda-demo/db/migrate/003_add_admin_account.rb +68 -0
- data/demo/rhales-roda-demo/templates/change_login.rue +31 -0
- data/demo/rhales-roda-demo/templates/change_password.rue +36 -0
- data/demo/rhales-roda-demo/templates/close_account.rue +31 -0
- data/demo/rhales-roda-demo/templates/create_account.rue +40 -0
- data/demo/rhales-roda-demo/templates/dashboard.rue +150 -0
- data/demo/rhales-roda-demo/templates/home.rue +78 -0
- data/demo/rhales-roda-demo/templates/layouts/main.rue +168 -0
- data/demo/rhales-roda-demo/templates/login.rue +65 -0
- data/demo/rhales-roda-demo/templates/logout.rue +25 -0
- data/demo/rhales-roda-demo/templates/reset_password.rue +26 -0
- data/demo/rhales-roda-demo/templates/verify_account.rue +27 -0
- data/demo/rhales-roda-demo/test_full_output.rb +27 -0
- data/demo/rhales-roda-demo/test_simple.rb +24 -0
- data/docs/.gitignore +9 -0
- data/docs/architecture/data-flow.md +499 -0
- data/examples/dashboard-with-charts.rue +271 -0
- data/examples/form-with-validation.rue +180 -0
- data/examples/simple-page.rue +61 -0
- data/examples/vue.rue +136 -0
- data/generate-json-schemas.ts +158 -0
- data/json_schemer_migration_summary.md +172 -0
- data/lib/rhales/adapters/base_auth.rb +2 -0
- data/lib/rhales/adapters/base_request.rb +2 -0
- data/lib/rhales/adapters/base_session.rb +2 -0
- data/lib/rhales/adapters.rb +7 -0
- data/lib/rhales/configuration.rb +161 -1
- data/lib/rhales/core/context.rb +354 -0
- data/lib/rhales/{rue_document.rb → core/rue_document.rb} +59 -43
- data/lib/rhales/{template_engine.rb → core/template_engine.rb} +80 -33
- data/lib/rhales/core/view.rb +529 -0
- data/lib/rhales/{view_composition.rb → core/view_composition.rb} +81 -9
- data/lib/rhales/core.rb +9 -0
- data/lib/rhales/errors/hydration_collision_error.rb +2 -0
- data/lib/rhales/errors.rb +2 -0
- data/lib/rhales/hydration/earliest_injection_detector.rb +153 -0
- data/lib/rhales/hydration/hydration_data_aggregator.rb +487 -0
- data/lib/rhales/hydration/hydration_endpoint.rb +215 -0
- data/lib/rhales/hydration/hydration_injector.rb +175 -0
- data/lib/rhales/{hydration_registry.rb → hydration/hydration_registry.rb} +2 -0
- data/lib/rhales/hydration/hydrator.rb +102 -0
- data/lib/rhales/hydration/link_based_injection_detector.rb +195 -0
- data/lib/rhales/hydration/mount_point_detector.rb +109 -0
- data/lib/rhales/hydration/safe_injection_validator.rb +103 -0
- data/lib/rhales/hydration.rb +13 -0
- data/lib/rhales/{refinements → integrations/refinements}/require_refinements.rb +7 -13
- data/lib/rhales/{tilt.rb → integrations/tilt.rb} +26 -18
- data/lib/rhales/integrations.rb +6 -0
- data/lib/rhales/middleware/json_responder.rb +191 -0
- data/lib/rhales/middleware/schema_validator.rb +300 -0
- data/lib/rhales/middleware.rb +6 -0
- data/lib/rhales/parsers/handlebars_parser.rb +2 -0
- data/lib/rhales/parsers/rue_format_parser.rb +55 -36
- data/lib/rhales/parsers.rb +9 -0
- data/lib/rhales/{csp.rb → security/csp.rb} +27 -3
- data/lib/rhales/utils/json_serializer.rb +114 -0
- data/lib/rhales/utils/logging_helpers.rb +75 -0
- data/lib/rhales/utils/schema_extractor.rb +132 -0
- data/lib/rhales/utils/schema_generator.rb +194 -0
- data/lib/rhales/utils.rb +40 -0
- data/lib/rhales/version.rb +5 -1
- data/lib/rhales.rb +47 -19
- data/lib/tasks/rhales_schema.rake +197 -0
- data/package.json +10 -0
- data/pnpm-lock.yaml +345 -0
- data/pnpm-workspace.yaml +2 -0
- data/proofs/error_handling.rb +79 -0
- data/proofs/expanded_object_inheritance.rb +82 -0
- data/proofs/partial_context_scoping_fix.rb +168 -0
- data/proofs/ui_context_partial_inheritance.rb +236 -0
- data/rhales.gemspec +14 -6
- data/schema_vs_data_comparison.md +254 -0
- data/test_direct_access.rb +36 -0
- metadata +142 -18
- data/CLAUDE.locale.txt +0 -7
- data/lib/rhales/context.rb +0 -240
- data/lib/rhales/hydration_data_aggregator.rb +0 -220
- data/lib/rhales/hydrator.rb +0 -141
- data/lib/rhales/parsers/handlebars-grammar-review.txt +0 -39
- data/lib/rhales/view.rb +0 -412
data/README.md
CHANGED
|
@@ -1,91 +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
|
-
- **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
|
|
21
30
|
- **Dependency injection** for testability and flexibility
|
|
22
31
|
|
|
23
32
|
## Installation
|
|
24
33
|
|
|
25
|
-
Add
|
|
34
|
+
Add to your Gemfile:
|
|
26
35
|
|
|
27
36
|
```ruby
|
|
28
37
|
gem 'rhales'
|
|
29
38
|
```
|
|
30
39
|
|
|
31
|
-
|
|
40
|
+
Then execute:
|
|
32
41
|
|
|
33
42
|
```bash
|
|
34
43
|
bundle install
|
|
35
44
|
```
|
|
36
45
|
|
|
37
|
-
Or install it yourself as:
|
|
38
|
-
|
|
39
|
-
```bash
|
|
40
|
-
gem install rhales
|
|
41
|
-
```
|
|
42
|
-
|
|
43
46
|
## Quick Start
|
|
44
47
|
|
|
45
48
|
### 1. Configure Rhales
|
|
46
49
|
|
|
47
50
|
```ruby
|
|
48
|
-
#
|
|
51
|
+
# config/initializers/rhales.rb or similar
|
|
49
52
|
Rhales.configure do |config|
|
|
50
53
|
config.default_locale = 'en'
|
|
51
|
-
config.template_paths = ['templates']
|
|
54
|
+
config.template_paths = ['templates']
|
|
52
55
|
config.features = { dark_mode: true }
|
|
53
56
|
config.site_host = 'example.com'
|
|
54
57
|
|
|
55
|
-
# CSP configuration
|
|
56
|
-
config.csp_enabled = true
|
|
57
|
-
config.auto_nonce = true
|
|
58
|
-
config.csp_policy = { # Customize CSP policy (optional)
|
|
59
|
-
'default-src' => ["'self'"],
|
|
60
|
-
'script-src' => ["'self'", "'nonce-{{nonce}}'"],
|
|
61
|
-
'style-src' => ["'self'", "'nonce-{{nonce}}'", "'unsafe-hashes'"]
|
|
62
|
-
# ... more directives
|
|
63
|
-
}
|
|
58
|
+
# CSP configuration
|
|
59
|
+
config.csp_enabled = true
|
|
60
|
+
config.auto_nonce = true
|
|
64
61
|
end
|
|
62
|
+
|
|
63
|
+
# Optional: Configure logger
|
|
64
|
+
Rhales.logger = Rails.logger
|
|
65
65
|
```
|
|
66
66
|
|
|
67
|
-
### 2. Create a
|
|
67
|
+
### 2. Create a .rue Component
|
|
68
68
|
|
|
69
|
-
Create
|
|
69
|
+
Create `templates/hello.rue`:
|
|
70
70
|
|
|
71
|
-
```
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
</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>
|
|
79
78
|
|
|
80
79
|
<template>
|
|
81
80
|
<div class="hello-component">
|
|
82
|
-
<h1>{{greeting}}, {{
|
|
83
|
-
<p>Welcome to Rhales
|
|
81
|
+
<h1>{{greeting}}, {{userName}}!</h1>
|
|
82
|
+
<p>Welcome to Rhales v0.5</p>
|
|
84
83
|
</div>
|
|
85
84
|
</template>
|
|
86
85
|
|
|
87
86
|
<logic>
|
|
88
|
-
# Simple greeting component
|
|
87
|
+
# Simple greeting component demonstrating schema-based hydration
|
|
89
88
|
</logic>
|
|
90
89
|
```
|
|
91
90
|
|
|
@@ -93,232 +92,469 @@ Create a `.rue` file in your templates directory:
|
|
|
93
92
|
|
|
94
93
|
```ruby
|
|
95
94
|
# In your controller/route handler
|
|
96
|
-
view = Rhales::View.new(
|
|
97
|
-
|
|
95
|
+
view = Rhales::View.new(
|
|
96
|
+
request,
|
|
97
|
+
client: {
|
|
98
98
|
greeting: 'Hello',
|
|
99
|
-
|
|
99
|
+
userName: 'World'
|
|
100
100
|
}
|
|
101
101
|
)
|
|
102
102
|
|
|
103
103
|
html = view.render('hello')
|
|
104
|
-
# 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
|
+
})
|
|
105
168
|
```
|
|
106
169
|
|
|
107
170
|
## Context and Data Model
|
|
108
171
|
|
|
109
|
-
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)
|
|
110
175
|
|
|
111
|
-
|
|
112
|
-
All framework-provided data is available under the `app` namespace:
|
|
176
|
+
Framework-provided data available under the `request` namespace:
|
|
113
177
|
|
|
114
178
|
```handlebars
|
|
115
|
-
<!--
|
|
116
|
-
{{
|
|
117
|
-
{{
|
|
118
|
-
{{
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
- `
|
|
126
|
-
- `
|
|
127
|
-
- `
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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 -->
|
|
183
|
+
```
|
|
184
|
+
|
|
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)
|
|
192
|
+
|
|
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
|
+
```
|
|
136
207
|
|
|
137
208
|
```handlebars
|
|
138
|
-
<!--
|
|
139
|
-
{{
|
|
140
|
-
{{page_title}} <!-- Props take precedence -->
|
|
141
|
-
{{#if user.admin?}}
|
|
142
|
-
<a href="/admin">Admin Panel</a>
|
|
143
|
-
{{/if}}
|
|
209
|
+
{{server.page_title}} <!-- Available in templates -->
|
|
210
|
+
{{server.vite_assets_html}} <!-- Server-side only -->
|
|
144
211
|
```
|
|
145
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
|
+
)
|
|
225
|
+
```
|
|
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
|
+
|
|
146
252
|
### Server Templates: Full Context Access
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
- Database connections and internal APIs
|
|
150
|
-
- Configuration values and secrets
|
|
151
|
-
- Request metadata (CSRF tokens, nonces)
|
|
253
|
+
|
|
254
|
+
Templates have access to ALL context layers:
|
|
152
255
|
|
|
153
256
|
```handlebars
|
|
154
|
-
<!--
|
|
155
|
-
{{#if
|
|
156
|
-
<
|
|
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>
|
|
157
263
|
{{/if}}
|
|
158
|
-
<div class="{{app.theme_class}}">{{user.full_name}}</div>
|
|
159
264
|
```
|
|
160
265
|
|
|
161
266
|
### Client Data: Explicit Allowlist
|
|
162
|
-
Only data declared in `<data>` sections reaches the browser:
|
|
163
267
|
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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>
|
|
173
278
|
```
|
|
174
279
|
|
|
175
|
-
|
|
280
|
+
**Result on client:**
|
|
281
|
+
|
|
176
282
|
```javascript
|
|
177
283
|
window.data = {
|
|
178
|
-
|
|
179
|
-
|
|
284
|
+
user: "Alice",
|
|
285
|
+
userId: 123
|
|
286
|
+
// admin_notes, secret_key NOT included (never declared in schema)
|
|
180
287
|
}
|
|
181
|
-
// No access to user.admin?, internal APIs, etc.
|
|
182
288
|
```
|
|
183
289
|
|
|
184
|
-
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.
|
|
185
291
|
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|
|
189
309
|
```
|
|
190
310
|
|
|
191
|
-
|
|
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
|
|
192
316
|
|
|
193
|
-
|
|
317
|
+
The schema is a **contract validator**, not a **data filter**.
|
|
318
|
+
|
|
319
|
+
## Complete Example: Dashboard
|
|
320
|
+
|
|
321
|
+
### Backend (Ruby)
|
|
322
|
+
|
|
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)
|
|
194
350
|
|
|
195
351
|
```xml
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
"api": {
|
|
210
|
-
"baseUrl": "{{api_base_url}}",
|
|
211
|
-
"csrfToken": "{{csrf_token}}"
|
|
212
|
-
},
|
|
213
|
-
"features": {{enabled_features}}
|
|
214
|
-
}
|
|
215
|
-
</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>
|
|
216
365
|
|
|
217
366
|
<template>
|
|
218
|
-
|
|
219
|
-
<
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
<
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
</main>
|
|
237
|
-
</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>
|
|
238
385
|
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
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);
|
|
244
391
|
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
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>
|
|
249
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>
|
|
250
407
|
```
|
|
251
408
|
|
|
252
|
-
###
|
|
409
|
+
### Generated HTML
|
|
253
410
|
|
|
254
|
-
|
|
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 -->
|
|
450
|
+
```
|
|
451
|
+
|
|
452
|
+
### Conditionals
|
|
453
|
+
|
|
454
|
+
```handlebars
|
|
455
|
+
{{#if condition}}
|
|
456
|
+
Content when true
|
|
457
|
+
{{else}}
|
|
458
|
+
Content when false
|
|
459
|
+
{{/if}}
|
|
255
460
|
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
461
|
+
{{#unless condition}}
|
|
462
|
+
Content when false
|
|
463
|
+
{{/unless}}
|
|
464
|
+
```
|
|
465
|
+
|
|
466
|
+
**Truthy/Falsy:**
|
|
467
|
+
- Falsy: `nil`, `null`, `false`, `""`, `0`, `"false"`
|
|
468
|
+
- Truthy: All other values
|
|
469
|
+
|
|
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
|
|
262
483
|
|
|
263
|
-
|
|
484
|
+
```handlebars
|
|
485
|
+
{{> header}} <!-- Include templates/header.rue -->
|
|
486
|
+
{{> components/nav}} <!-- Include templates/components/nav.rue -->
|
|
487
|
+
```
|
|
264
488
|
|
|
265
|
-
|
|
489
|
+
### Layouts
|
|
266
490
|
|
|
267
491
|
```xml
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
"name": "{{user.name}}" <!-- ✅ Exposed to client -->
|
|
273
|
-
}
|
|
274
|
-
<!-- ❌ user.secret_key not declared, won't reach client -->
|
|
275
|
-
}
|
|
276
|
-
</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>
|
|
277
496
|
|
|
278
497
|
<template>
|
|
279
|
-
<h1>
|
|
280
|
-
<p>{{user.name}}</p> <!-- ✅ Can access user object methods -->
|
|
281
|
-
<p>{{user.secret_key}}</p> <!-- ✅ Server-side only, not in <data> -->
|
|
498
|
+
<h1>Home Page Content</h1>
|
|
282
499
|
</template>
|
|
283
500
|
```
|
|
284
501
|
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
502
|
+
```xml
|
|
503
|
+
<!-- templates/layouts/main.rue -->
|
|
504
|
+
<schema lang="js-zod" window="layoutData">
|
|
505
|
+
const schema = z.object({ siteName: z.string() });
|
|
506
|
+
</schema>
|
|
290
507
|
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
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
|
+
```
|
|
296
524
|
|
|
297
|
-
|
|
525
|
+
## Schema Tooling
|
|
298
526
|
|
|
299
|
-
|
|
300
|
-
```html
|
|
301
|
-
<!-- Server-rendered HTML -->
|
|
302
|
-
<div id="app">
|
|
303
|
-
<nav>...</nav>
|
|
304
|
-
<main><h1>User Dashboard</h1><p>Welcome back, Alice!</p></main>
|
|
305
|
-
</div>
|
|
527
|
+
Rhales provides rake tasks for schema management:
|
|
306
528
|
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
</script>
|
|
311
|
-
<script nonce="abc123">
|
|
312
|
-
window.appState = JSON.parse(document.getElementById('app-state-data').textContent);
|
|
313
|
-
</script>
|
|
529
|
+
```bash
|
|
530
|
+
# Generate JSON schemas from .rue templates
|
|
531
|
+
rake rhales:schema:generate TEMPLATES_DIR=./templates
|
|
314
532
|
|
|
315
|
-
|
|
316
|
-
|
|
533
|
+
# Validate existing JSON schemas
|
|
534
|
+
rake rhales:schema:validate
|
|
535
|
+
|
|
536
|
+
# Show schema statistics
|
|
537
|
+
rake rhales:schema:stats TEMPLATES_DIR=./templates
|
|
317
538
|
```
|
|
318
539
|
|
|
319
|
-
|
|
540
|
+
**Example output:**
|
|
320
541
|
|
|
321
|
-
|
|
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
|
|
322
558
|
|
|
323
559
|
```ruby
|
|
324
560
|
# config/initializers/rhales.rb
|
|
@@ -329,91 +565,79 @@ end
|
|
|
329
565
|
|
|
330
566
|
# app/controllers/application_controller.rb
|
|
331
567
|
class ApplicationController < ActionController::Base
|
|
332
|
-
def render_rhales(template_name,
|
|
333
|
-
view = Rhales::View.new(request,
|
|
334
|
-
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)
|
|
335
571
|
end
|
|
336
572
|
end
|
|
337
573
|
|
|
338
574
|
# In your controller
|
|
339
575
|
def dashboard
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
recent_products: Product.recent.limit(5),
|
|
344
|
-
cart: current_user.cart,
|
|
345
|
-
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' }
|
|
346
579
|
)
|
|
580
|
+
render html: html.html_safe
|
|
347
581
|
end
|
|
348
582
|
```
|
|
349
583
|
|
|
350
|
-
|
|
584
|
+
### Roda
|
|
351
585
|
|
|
352
586
|
```ruby
|
|
353
587
|
# app.rb
|
|
354
|
-
require '
|
|
588
|
+
require 'roda'
|
|
355
589
|
require 'rhales'
|
|
356
590
|
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
config.default_locale = 'en'
|
|
360
|
-
end
|
|
591
|
+
class App < Roda
|
|
592
|
+
plugin :render
|
|
361
593
|
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
view.render(template_name, data)
|
|
594
|
+
Rhales.configure do |config|
|
|
595
|
+
config.template_paths = ['templates']
|
|
596
|
+
config.default_locale = 'en'
|
|
366
597
|
end
|
|
367
|
-
end
|
|
368
598
|
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
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
|
|
378
609
|
end
|
|
379
610
|
```
|
|
380
611
|
|
|
381
|
-
|
|
612
|
+
### Sinatra
|
|
382
613
|
|
|
383
614
|
```ruby
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
615
|
+
require 'sinatra'
|
|
616
|
+
require 'rhales'
|
|
617
|
+
|
|
618
|
+
Rhales.configure do |config|
|
|
619
|
+
config.template_paths = ['templates']
|
|
620
|
+
config.default_locale = 'en'
|
|
390
621
|
end
|
|
391
622
|
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
view
|
|
396
|
-
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)
|
|
397
627
|
end
|
|
398
628
|
end
|
|
399
629
|
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
page_title: 'Dashboard'
|
|
404
|
-
user: current_user,
|
|
405
|
-
recent_products: Product.recent,
|
|
406
|
-
cart: current_user&.cart,
|
|
407
|
-
enabled_features: settings.features
|
|
630
|
+
get '/dashboard' do
|
|
631
|
+
render_rhales('dashboard',
|
|
632
|
+
client: { user: 'Alice' },
|
|
633
|
+
server: { page_title: 'Dashboard' }
|
|
408
634
|
)
|
|
409
|
-
render :dashboard
|
|
410
635
|
end
|
|
411
636
|
```
|
|
412
637
|
|
|
413
|
-
|
|
638
|
+
### Grape
|
|
414
639
|
|
|
415
640
|
```ruby
|
|
416
|
-
# config.ru or initializer
|
|
417
641
|
require 'grape'
|
|
418
642
|
require 'rhales'
|
|
419
643
|
|
|
@@ -422,444 +646,337 @@ Rhales.configure do |config|
|
|
|
422
646
|
config.default_locale = 'en'
|
|
423
647
|
end
|
|
424
648
|
|
|
425
|
-
# api.rb
|
|
426
649
|
class MyAPI < Grape::API
|
|
427
650
|
helpers do
|
|
428
|
-
def render_rhales(template_name,
|
|
429
|
-
# Create mock request/session for Grape
|
|
651
|
+
def render_rhales(template_name, client: {}, server: {})
|
|
430
652
|
mock_request = OpenStruct.new(env: env)
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
view = Rhales::View.new(mock_request, mock_session, current_user, 'en')
|
|
434
|
-
view.render(template_name, data)
|
|
653
|
+
view = Rhales::View.new(mock_request, client: client, server: server)
|
|
654
|
+
view.render(template_name)
|
|
435
655
|
end
|
|
436
656
|
end
|
|
437
657
|
|
|
438
658
|
get '/dashboard' do
|
|
439
659
|
content_type 'text/html'
|
|
440
660
|
render_rhales('dashboard',
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
recent_products: [],
|
|
444
|
-
cart: {},
|
|
445
|
-
enabled_features: { api_v2: true }
|
|
661
|
+
client: { user: 'Alice' },
|
|
662
|
+
server: { page_title: 'Dashboard' }
|
|
446
663
|
)
|
|
447
664
|
end
|
|
448
665
|
end
|
|
449
666
|
```
|
|
450
667
|
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
```ruby
|
|
454
|
-
# app.rb
|
|
455
|
-
require 'roda'
|
|
456
|
-
require 'rhales'
|
|
457
|
-
|
|
458
|
-
class App < Roda
|
|
459
|
-
plugin :render
|
|
668
|
+
## Content Security Policy (CSP)
|
|
460
669
|
|
|
461
|
-
|
|
462
|
-
config.template_paths = ['templates']
|
|
463
|
-
config.default_locale = 'en'
|
|
464
|
-
end
|
|
670
|
+
Rhales provides **security by default** with automatic CSP support.
|
|
465
671
|
|
|
466
|
-
|
|
467
|
-
view = Rhales::View.new(request, session, current_user, 'en')
|
|
468
|
-
view.render(template_name, data)
|
|
469
|
-
end
|
|
672
|
+
### Default CSP Configuration
|
|
470
673
|
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
user: current_user,
|
|
476
|
-
recent_products: [],
|
|
477
|
-
cart: session[:cart],
|
|
478
|
-
enabled_features: FEATURES
|
|
479
|
-
)
|
|
480
|
-
view('dashboard')
|
|
481
|
-
end
|
|
482
|
-
end
|
|
674
|
+
```ruby
|
|
675
|
+
Rhales.configure do |config|
|
|
676
|
+
config.csp_enabled = true # Default: true
|
|
677
|
+
config.auto_nonce = true # Default: true
|
|
483
678
|
end
|
|
484
679
|
```
|
|
485
680
|
|
|
486
|
-
###
|
|
681
|
+
### Using Nonces in Templates
|
|
487
682
|
|
|
488
|
-
```
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
html = view.render('dashboard',
|
|
494
|
-
page_title: 'User Dashboard',
|
|
495
|
-
user: current_user,
|
|
496
|
-
recent_products: Product.recent.limit(5),
|
|
497
|
-
cart: current_user.cart,
|
|
498
|
-
enabled_features: Feature.enabled_for(current_user)
|
|
499
|
-
)
|
|
683
|
+
```handlebars
|
|
684
|
+
<script nonce="{{request.nonce}}">
|
|
685
|
+
// Inline JavaScript with automatic nonce
|
|
686
|
+
console.log('Secure execution');
|
|
687
|
+
</script>
|
|
500
688
|
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
user: current_user,
|
|
506
|
-
page_title: 'User Dashboard',
|
|
507
|
-
recent_products: products,
|
|
508
|
-
cart: cart_data,
|
|
509
|
-
enabled_features: features
|
|
510
|
-
)
|
|
689
|
+
<style nonce="{{request.nonce}}">
|
|
690
|
+
/* Inline styles with automatic nonce */
|
|
691
|
+
.component { color: blue; }
|
|
692
|
+
</style>
|
|
511
693
|
```
|
|
512
694
|
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
Rhales supports pluggable authentication adapters. Implement the `Rhales::Adapters::BaseAuth` interface:
|
|
695
|
+
### Custom CSP Policies
|
|
516
696
|
|
|
517
697
|
```ruby
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
def theme_preference
|
|
528
|
-
@user&.theme || 'light'
|
|
529
|
-
end
|
|
530
|
-
|
|
531
|
-
def user_id
|
|
532
|
-
@user&.id
|
|
533
|
-
end
|
|
534
|
-
|
|
535
|
-
def role?(role)
|
|
536
|
-
@user&.roles&.include?(role)
|
|
537
|
-
end
|
|
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
|
+
}
|
|
538
706
|
end
|
|
539
|
-
|
|
540
|
-
# Use with Rhales
|
|
541
|
-
user_adapter = MyAuthAdapter.new(current_user)
|
|
542
|
-
view = Rhales::View.new(request, session, user_adapter)
|
|
543
707
|
```
|
|
544
708
|
|
|
545
|
-
|
|
709
|
+
### Framework CSP Header Setup
|
|
546
710
|
|
|
547
|
-
|
|
711
|
+
#### Rails
|
|
548
712
|
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
713
|
+
```ruby
|
|
714
|
+
class ApplicationController < ActionController::Base
|
|
715
|
+
after_action :set_csp_header
|
|
552
716
|
|
|
553
|
-
|
|
554
|
-
```erb
|
|
555
|
-
{{#if condition}}
|
|
556
|
-
Content when true
|
|
557
|
-
{{/if}}
|
|
717
|
+
private
|
|
558
718
|
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
719
|
+
def set_csp_header
|
|
720
|
+
csp_header = request.env['csp_header']
|
|
721
|
+
response.headers['Content-Security-Policy'] = csp_header if csp_header
|
|
722
|
+
end
|
|
723
|
+
end
|
|
562
724
|
```
|
|
563
725
|
|
|
564
|
-
|
|
565
|
-
```erb
|
|
566
|
-
{{#each items}}
|
|
567
|
-
<div>{{name}} - {{@index}}</div>
|
|
568
|
-
{{/each}}
|
|
569
|
-
```
|
|
726
|
+
#### Roda
|
|
570
727
|
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
728
|
+
```ruby
|
|
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
|
|
735
|
+
end
|
|
736
|
+
end
|
|
575
737
|
```
|
|
576
738
|
|
|
577
|
-
##
|
|
739
|
+
## Logging
|
|
578
740
|
|
|
579
|
-
|
|
741
|
+
Rhales provides production logging for security auditing and debugging:
|
|
580
742
|
|
|
581
|
-
```
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
"apiUrl": "{{api_base_url}}",
|
|
585
|
-
"user": {{user}},
|
|
586
|
-
"csrfToken": "{{csrf_token}}"
|
|
587
|
-
}
|
|
588
|
-
</data>
|
|
743
|
+
```ruby
|
|
744
|
+
# Configure logger
|
|
745
|
+
Rhales.logger = Rails.logger # or Logger.new($stdout)
|
|
589
746
|
```
|
|
590
747
|
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
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)
|
|
753
|
+
|
|
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
|
|
599
760
|
```
|
|
600
761
|
|
|
601
|
-
|
|
762
|
+
## Testing
|
|
602
763
|
|
|
603
|
-
|
|
764
|
+
### Test Configuration
|
|
604
765
|
|
|
605
|
-
```
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
{"user": "{{user.name}}", "csrf": "{{csrf_token}}"}
|
|
609
|
-
</data>
|
|
766
|
+
```ruby
|
|
767
|
+
# test/test_helper.rb or spec/spec_helper.rb
|
|
768
|
+
require 'rhales'
|
|
610
769
|
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
770
|
+
Rhales.configure do |config|
|
|
771
|
+
config.default_locale = 'en'
|
|
772
|
+
config.app_environment = 'test'
|
|
773
|
+
config.cache_templates = false
|
|
774
|
+
config.template_paths = ['test/fixtures/templates']
|
|
775
|
+
end
|
|
615
776
|
```
|
|
616
777
|
|
|
617
|
-
|
|
618
|
-
```
|
|
619
|
-
Window attribute collision detected
|
|
778
|
+
### Testing Context
|
|
620
779
|
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
780
|
+
```ruby
|
|
781
|
+
# Minimal context for testing
|
|
782
|
+
context = Rhales::Context.minimal(
|
|
783
|
+
client: { user: 'Test' },
|
|
784
|
+
server: { page_title: 'Test Page' }
|
|
785
|
+
)
|
|
624
786
|
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
2. Enable merging: <data window="appData" merge="deep">
|
|
787
|
+
expect(context.get('user')).to eq('Test')
|
|
788
|
+
expect(context.get('page_title')).to eq('Test Page')
|
|
628
789
|
```
|
|
629
790
|
|
|
630
|
-
###
|
|
631
|
-
|
|
632
|
-
When you intentionally want to share data between templates, use explicit merge strategies:
|
|
791
|
+
### Testing Templates
|
|
633
792
|
|
|
634
|
-
```
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
"csrf": "{{csrf_token}}"
|
|
640
|
-
}
|
|
641
|
-
</data>
|
|
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')
|
|
642
798
|
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
{
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
}
|
|
649
|
-
</data>
|
|
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')
|
|
650
804
|
```
|
|
651
805
|
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
**`merge="shallow"`** - Top-level key merge, throws error on conflicts:
|
|
655
|
-
```javascript
|
|
656
|
-
// Layout: {"user": {...}, "csrf": "abc"}
|
|
657
|
-
// Page: {"page": {...}, "user": {...}} // ❌ Error: key conflict
|
|
658
|
-
```
|
|
806
|
+
## Migration from v0.4 to v0.5
|
|
659
807
|
|
|
660
|
-
|
|
661
|
-
```javascript
|
|
662
|
-
// Layout: {"user": {"name": "John", "role": "admin"}}
|
|
663
|
-
// Page: {"user": {"email": "john@example.com"}}
|
|
664
|
-
// Result: {"user": {"name": "John", "role": "admin", "email": "john@example.com"}}
|
|
665
|
-
```
|
|
808
|
+
### Breaking Changes
|
|
666
809
|
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
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`
|
|
672
818
|
|
|
673
|
-
|
|
819
|
+
### Migration Steps
|
|
674
820
|
|
|
675
|
-
|
|
821
|
+
#### 1. Update Ruby Code
|
|
676
822
|
|
|
677
|
-
|
|
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
|
+
)
|
|
678
829
|
|
|
679
|
-
|
|
830
|
+
# v0.5 (Current)
|
|
831
|
+
view = Rhales::View.new(req,
|
|
832
|
+
client: { user: customer.name },
|
|
833
|
+
server: { page_title: 'Dashboard' }
|
|
834
|
+
)
|
|
680
835
|
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
# CSP is enabled by default with secure settings
|
|
684
|
-
config.csp_enabled = true # Default: true
|
|
685
|
-
config.auto_nonce = true # Default: true
|
|
686
|
-
end
|
|
836
|
+
# Set locale in request
|
|
837
|
+
req.env['rhales.locale'] = 'en'
|
|
687
838
|
```
|
|
688
839
|
|
|
689
|
-
|
|
840
|
+
#### 2. Convert Data to Schema
|
|
690
841
|
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
842
|
+
```xml
|
|
843
|
+
<!-- v0.4 (REMOVED) -->
|
|
844
|
+
<data window="data">
|
|
694
845
|
{
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
'style-src' => ["'self'", "'nonce-{{nonce}}'", "'unsafe-hashes'"],
|
|
698
|
-
'img-src' => ["'self'", 'data:'],
|
|
699
|
-
'font-src' => ["'self'"],
|
|
700
|
-
'connect-src' => ["'self'"],
|
|
701
|
-
'base-uri' => ["'self'"],
|
|
702
|
-
'form-action' => ["'self'"],
|
|
703
|
-
'frame-ancestors' => ["'none'"],
|
|
704
|
-
'object-src' => ["'none'"],
|
|
705
|
-
'upgrade-insecure-requests' => []
|
|
846
|
+
"user": "{{user.name}}",
|
|
847
|
+
"count": {{items.count}}
|
|
706
848
|
}
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
### Automatic Nonce Generation
|
|
710
|
-
|
|
711
|
-
Rhales automatically generates and manages CSP nonces:
|
|
712
|
-
|
|
713
|
-
```erb
|
|
714
|
-
<!-- In your .rue templates -->
|
|
715
|
-
<script nonce="{{app.nonce}}">
|
|
716
|
-
// Your inline JavaScript with automatic nonce
|
|
717
|
-
console.log('Secure script execution');
|
|
718
|
-
</script>
|
|
849
|
+
</data>
|
|
719
850
|
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
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>
|
|
724
858
|
```
|
|
725
859
|
|
|
726
|
-
|
|
860
|
+
**Key difference:** In v0.5, pass resolved values in `client:` hash instead of relying on template interpolation in JSON.
|
|
727
861
|
|
|
728
|
-
|
|
862
|
+
#### 3. Update Context References
|
|
729
863
|
|
|
730
|
-
```
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
html = view.render('dashboard', user: current_user)
|
|
864
|
+
```handlebars
|
|
865
|
+
<!-- v0.4 (REMOVED) -->
|
|
866
|
+
{{app.nonce}}
|
|
867
|
+
{{app.csrf_token}}
|
|
735
868
|
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
869
|
+
<!-- v0.5 (Current) -->
|
|
870
|
+
{{request.nonce}}
|
|
871
|
+
{{request.csrf_token}}
|
|
739
872
|
```
|
|
740
873
|
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
Customize the CSP policy for your specific needs:
|
|
874
|
+
#### 4. Update Backend Data Passing
|
|
744
875
|
|
|
745
876
|
```ruby
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
#
|
|
877
|
+
# v0.4: Template interpolation
|
|
878
|
+
view = Rhales::View.new(req, sess, cust, 'en',
|
|
879
|
+
props: { user: cust } # Object reference, interpolated in <data>
|
|
880
|
+
)
|
|
881
|
+
|
|
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
|
|
755
887
|
}
|
|
756
|
-
|
|
888
|
+
)
|
|
757
889
|
```
|
|
758
890
|
|
|
759
|
-
|
|
891
|
+
## Performance Optimization
|
|
760
892
|
|
|
761
|
-
|
|
762
|
-
```ruby
|
|
763
|
-
# app/controllers/application_controller.rb
|
|
764
|
-
class ApplicationController < ActionController::Base
|
|
765
|
-
after_action :set_csp_header
|
|
893
|
+
### Optional: Oj for Faster JSON Processing
|
|
766
894
|
|
|
767
|
-
|
|
895
|
+
Rhales includes optional support for [Oj](https://github.com/ohler55/oj), a high-performance JSON library that provides:
|
|
768
896
|
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
end
|
|
774
|
-
```
|
|
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
|
|
775
901
|
|
|
776
|
-
####
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
def render_with_csp(template_name, data = {})
|
|
780
|
-
result = render_rhales(template_name, data)
|
|
781
|
-
csp_header = request.env['csp_header']
|
|
782
|
-
headers['Content-Security-Policy'] = csp_header if csp_header
|
|
783
|
-
result
|
|
784
|
-
end
|
|
785
|
-
end
|
|
786
|
-
```
|
|
902
|
+
#### Installation
|
|
903
|
+
|
|
904
|
+
Add to your Gemfile:
|
|
787
905
|
|
|
788
|
-
#### Roda
|
|
789
906
|
```ruby
|
|
790
|
-
|
|
791
|
-
def render_with_csp(template_name, data = {})
|
|
792
|
-
result = render_rhales(template_name, data)
|
|
793
|
-
csp_header = request.env['csp_header']
|
|
794
|
-
response.headers['Content-Security-Policy'] = csp_header if csp_header
|
|
795
|
-
result
|
|
796
|
-
end
|
|
797
|
-
end
|
|
907
|
+
gem 'oj', '~> 3.13'
|
|
798
908
|
```
|
|
799
909
|
|
|
800
|
-
|
|
910
|
+
Then run:
|
|
801
911
|
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
- **Framework agnostic**: Works with any Ruby web framework
|
|
806
|
-
- **Customizable policies**: Adapt CSP rules to your application needs
|
|
807
|
-
- **Zero configuration**: Secure defaults work immediately
|
|
912
|
+
```bash
|
|
913
|
+
bundle install
|
|
914
|
+
```
|
|
808
915
|
|
|
809
|
-
|
|
916
|
+
That's it! Rhales automatically detects Oj at load time and uses it for all JSON operations.
|
|
810
917
|
|
|
811
|
-
|
|
918
|
+
**Note:** The backend is selected once when Rhales loads. To ensure Oj is used, require it before Rhales:
|
|
812
919
|
|
|
813
920
|
```ruby
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
end
|
|
921
|
+
# Gemfile or application initialization
|
|
922
|
+
require 'oj' # Load Oj first
|
|
923
|
+
require 'rhales' # Rhales will detect and use Oj
|
|
818
924
|
```
|
|
819
925
|
|
|
820
|
-
|
|
926
|
+
Most bundler setups handle this automatically, but explicit ordering ensures optimal performance.
|
|
821
927
|
|
|
822
|
-
|
|
928
|
+
#### Verification
|
|
929
|
+
|
|
930
|
+
Check which backend is active:
|
|
823
931
|
|
|
824
932
|
```ruby
|
|
825
|
-
|
|
826
|
-
|
|
933
|
+
Rhales::JSONSerializer.backend
|
|
934
|
+
# => :oj (if available) or :json (stdlib)
|
|
935
|
+
```
|
|
827
936
|
|
|
828
|
-
|
|
829
|
-
config.default_locale = 'en'
|
|
830
|
-
config.app_environment = 'test'
|
|
831
|
-
config.cache_templates = false
|
|
832
|
-
config.template_paths = ['test/templates'] # or wherever your test templates are
|
|
833
|
-
end
|
|
937
|
+
#### Performance Impact
|
|
834
938
|
|
|
835
|
-
|
|
836
|
-
context = Rhales::Context.minimal(props: { user: { name: 'Test' } })
|
|
837
|
-
expect(context.get('user.name')).to eq('Test')
|
|
939
|
+
For typical Rhales applications with hydration data:
|
|
838
940
|
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
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** |
|
|
843
946
|
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
html = view.render('test_template', message: 'Hello World')
|
|
849
|
-
```
|
|
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
|
|
850
951
|
|
|
851
|
-
|
|
952
|
+
Oj provides the most benefit for data-heavy templates and high-concurrency scenarios.
|
|
852
953
|
|
|
853
|
-
|
|
954
|
+
## Development
|
|
854
955
|
|
|
855
956
|
```bash
|
|
957
|
+
# Clone repository
|
|
958
|
+
git clone https://github.com/onetimesecret/rhales.git
|
|
959
|
+
cd rhales
|
|
960
|
+
|
|
961
|
+
# Install dependencies
|
|
856
962
|
bundle install
|
|
857
|
-
|
|
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
|
|
858
975
|
```
|
|
859
976
|
|
|
860
977
|
## Contributing
|
|
861
978
|
|
|
862
|
-
1. Fork it
|
|
979
|
+
1. Fork it (https://github.com/onetimesecret/rhales/fork)
|
|
863
980
|
2. Create your feature branch (`git checkout -b my-new-feature`)
|
|
864
981
|
3. Commit your changes (`git commit -am 'Add some feature'`)
|
|
865
982
|
4. Push to the branch (`git push origin my-new-feature`)
|
|
@@ -871,11 +988,11 @@ The gem is available as open source under the [MIT License](https://opensource.o
|
|
|
871
988
|
|
|
872
989
|
## AI Development Assistance
|
|
873
990
|
|
|
874
|
-
Rhales was developed with assistance from AI tools
|
|
991
|
+
Rhales was developed with assistance from AI tools:
|
|
875
992
|
|
|
876
|
-
- **Claude Sonnet 4** - Architecture design, code generation,
|
|
877
|
-
- **Claude Desktop & Claude Code** - Interactive development
|
|
878
|
-
- **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
|
|
879
996
|
- **Qodo Merge Pro** - Code review and quality improvements
|
|
880
997
|
|
|
881
|
-
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.
|