rhales 0.3.0
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 +7 -0
- data/CLAUDE.locale.txt +7 -0
- data/CLAUDE.md +90 -0
- data/LICENSE.txt +21 -0
- data/README.md +881 -0
- data/lib/rhales/adapters/base_auth.rb +106 -0
- data/lib/rhales/adapters/base_request.rb +97 -0
- data/lib/rhales/adapters/base_session.rb +93 -0
- data/lib/rhales/configuration.rb +156 -0
- data/lib/rhales/context.rb +240 -0
- data/lib/rhales/csp.rb +94 -0
- data/lib/rhales/errors/hydration_collision_error.rb +85 -0
- data/lib/rhales/errors.rb +36 -0
- data/lib/rhales/hydration_data_aggregator.rb +220 -0
- data/lib/rhales/hydration_registry.rb +58 -0
- data/lib/rhales/hydrator.rb +141 -0
- data/lib/rhales/parsers/handlebars-grammar-review.txt +39 -0
- data/lib/rhales/parsers/handlebars_parser.rb +727 -0
- data/lib/rhales/parsers/rue_format_parser.rb +385 -0
- data/lib/rhales/refinements/require_refinements.rb +236 -0
- data/lib/rhales/rue_document.rb +304 -0
- data/lib/rhales/template_engine.rb +353 -0
- data/lib/rhales/tilt.rb +214 -0
- data/lib/rhales/version.rb +6 -0
- data/lib/rhales/view.rb +412 -0
- data/lib/rhales/view_composition.rb +165 -0
- data/lib/rhales.rb +57 -0
- data/rhales.gemspec +46 -0
- metadata +78 -0
data/README.md
ADDED
@@ -0,0 +1,881 @@
|
|
1
|
+
# Rhales - Ruby Single File Components
|
2
|
+
|
3
|
+
> [!CAUTION]
|
4
|
+
> **Early Development Release** - Rhales is in active development (v0.1.0). The API may change between versions. While functional and tested, it's recommended for experimental use and contributions rather than production applications. Please report issues and provide feedback through GitHub.
|
5
|
+
|
6
|
+
Rhales is a framework for building server-rendered components with client-side data hydration using `.rue` files called RSFCs (Ruby Single File Components). Similar to Vue.js single file components but designed for Ruby applications.
|
7
|
+
|
8
|
+
About the name:
|
9
|
+
It all started with a simple mustache template many years ago. The successor to mustache, "Handlebars" is a visual analog for a mustache and successor to the format. "Two Whales Kissing" is another visual analog for a mustache and since we're working with Ruby we could call that, "Two Whales Kissing for Ruby", which is very long. Rhales combines Ruby and Whales into a one-word name for our library. It's a perfect name with absolutely no ambiguity or risk of confusion with other gems.
|
10
|
+
|
11
|
+
## Features
|
12
|
+
|
13
|
+
- **Server-side template rendering** with Handlebars-style syntax
|
14
|
+
- **Client-side data hydration** with secure JSON injection
|
15
|
+
- **Window collision detection** prevents silent data overwrites
|
16
|
+
- **Explicit merge strategies** for controlled data sharing (shallow, deep, strict)
|
17
|
+
- **Clear security boundaries** between server context and client data
|
18
|
+
- **Partial support** for component composition
|
19
|
+
- **Pluggable authentication adapters** for any auth system
|
20
|
+
- **Security-first design** with XSS protection and automatic CSP generation
|
21
|
+
- **Dependency injection** for testability and flexibility
|
22
|
+
|
23
|
+
## Installation
|
24
|
+
|
25
|
+
Add this line to your application's Gemfile:
|
26
|
+
|
27
|
+
```ruby
|
28
|
+
gem 'rhales'
|
29
|
+
```
|
30
|
+
|
31
|
+
And then execute:
|
32
|
+
|
33
|
+
```bash
|
34
|
+
bundle install
|
35
|
+
```
|
36
|
+
|
37
|
+
Or install it yourself as:
|
38
|
+
|
39
|
+
```bash
|
40
|
+
gem install rhales
|
41
|
+
```
|
42
|
+
|
43
|
+
## Quick Start
|
44
|
+
|
45
|
+
### 1. Configure Rhales
|
46
|
+
|
47
|
+
```ruby
|
48
|
+
# Configure Rhales in your application initialization
|
49
|
+
Rhales.configure do |config|
|
50
|
+
config.default_locale = 'en'
|
51
|
+
config.template_paths = ['templates'] # or 'app/templates', 'views/templates', etc.
|
52
|
+
config.features = { dark_mode: true }
|
53
|
+
config.site_host = 'example.com'
|
54
|
+
|
55
|
+
# CSP configuration (enabled by default)
|
56
|
+
config.csp_enabled = true # Enable automatic CSP header generation
|
57
|
+
config.auto_nonce = true # Automatically generate nonces
|
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
|
+
}
|
64
|
+
end
|
65
|
+
```
|
66
|
+
|
67
|
+
### 2. Create a Simple Component
|
68
|
+
|
69
|
+
Create a `.rue` file in your templates directory:
|
70
|
+
|
71
|
+
```rue
|
72
|
+
<!-- templates/hello.rue -->
|
73
|
+
<data>
|
74
|
+
{
|
75
|
+
"greeting": "{{greeting}}",
|
76
|
+
"user_name": "{{user.name}}"
|
77
|
+
}
|
78
|
+
</data>
|
79
|
+
|
80
|
+
<template>
|
81
|
+
<div class="hello-component">
|
82
|
+
<h1>{{greeting}}, {{user.name}}!</h1>
|
83
|
+
<p>Welcome to Rhales RSFC!</p>
|
84
|
+
</div>
|
85
|
+
</template>
|
86
|
+
|
87
|
+
<logic>
|
88
|
+
# Simple greeting component
|
89
|
+
</logic>
|
90
|
+
```
|
91
|
+
|
92
|
+
### 3. Render in Your Application
|
93
|
+
|
94
|
+
```ruby
|
95
|
+
# In your controller/route handler
|
96
|
+
view = Rhales::View.new(request, session, user, 'en',
|
97
|
+
props: {
|
98
|
+
greeting: 'Hello',
|
99
|
+
user: { name: 'World' }
|
100
|
+
}
|
101
|
+
)
|
102
|
+
|
103
|
+
html = view.render('hello')
|
104
|
+
# Returns HTML with embedded JSON for client-side hydration
|
105
|
+
```
|
106
|
+
|
107
|
+
## Context and Data Model
|
108
|
+
|
109
|
+
Rhales uses a **two-layer data model** for template rendering:
|
110
|
+
|
111
|
+
### 1. App Data (Framework Layer)
|
112
|
+
All framework-provided data is available under the `app` namespace:
|
113
|
+
|
114
|
+
```handlebars
|
115
|
+
<!-- Framework data through app namespace -->
|
116
|
+
{{app.csrf_token}} <!-- CSRF token for forms -->
|
117
|
+
{{app.nonce}} <!-- CSP nonce for scripts -->
|
118
|
+
{{app.authenticated}} <!-- Authentication state -->
|
119
|
+
{{app.environment}} <!-- Current environment -->
|
120
|
+
{{app.features.dark_mode}} <!-- Feature flags -->
|
121
|
+
{{app.theme_class}} <!-- Current theme -->
|
122
|
+
```
|
123
|
+
|
124
|
+
**Available App Variables:**
|
125
|
+
- `app.api_base_url` - Base URL for API calls
|
126
|
+
- `app.authenticated` - Whether user is authenticated
|
127
|
+
- `app.csrf_token` - CSRF token for forms
|
128
|
+
- `app.development` - Whether in development mode
|
129
|
+
- `app.environment` - Current environment (production/staging/dev)
|
130
|
+
- `app.features` - Feature flags hash
|
131
|
+
- `app.nonce` - CSP nonce for inline scripts
|
132
|
+
- `app.theme_class` - Current theme CSS class
|
133
|
+
|
134
|
+
### 2. Props Data (Application Layer)
|
135
|
+
Your application-specific data passed to each view:
|
136
|
+
|
137
|
+
```handlebars
|
138
|
+
<!-- Application data -->
|
139
|
+
{{user.name}} <!-- Direct access -->
|
140
|
+
{{page_title}} <!-- Props take precedence -->
|
141
|
+
{{#if user.admin?}}
|
142
|
+
<a href="/admin">Admin Panel</a>
|
143
|
+
{{/if}}
|
144
|
+
```
|
145
|
+
|
146
|
+
### Server Templates: Full Context Access
|
147
|
+
Templates have complete access to all server-side data:
|
148
|
+
- User objects and authentication state
|
149
|
+
- Database connections and internal APIs
|
150
|
+
- Configuration values and secrets
|
151
|
+
- Request metadata (CSRF tokens, nonces)
|
152
|
+
|
153
|
+
```handlebars
|
154
|
+
<!-- Full server access in templates -->
|
155
|
+
{{#if user.admin?}}
|
156
|
+
<a href="/admin">Admin Panel</a>
|
157
|
+
{{/if}}
|
158
|
+
<div class="{{app.theme_class}}">{{user.full_name}}</div>
|
159
|
+
```
|
160
|
+
|
161
|
+
### Client Data: Explicit Allowlist
|
162
|
+
Only data declared in `<data>` sections reaches the browser:
|
163
|
+
|
164
|
+
```rue
|
165
|
+
<data>
|
166
|
+
{
|
167
|
+
"display_name": "{{user.name}}",
|
168
|
+
"preferences": {
|
169
|
+
"theme": "{{user.theme}}"
|
170
|
+
}
|
171
|
+
}
|
172
|
+
</data>
|
173
|
+
```
|
174
|
+
|
175
|
+
Becomes:
|
176
|
+
```javascript
|
177
|
+
window.data = {
|
178
|
+
"display_name": "John Doe",
|
179
|
+
"preferences": { "theme": "dark" }
|
180
|
+
}
|
181
|
+
// No access to user.admin?, internal APIs, etc.
|
182
|
+
```
|
183
|
+
|
184
|
+
This creates a **REST API-like boundary** where you explicitly declare what data crosses the server-to-client security boundary.
|
185
|
+
|
186
|
+
For complete details, see [Context and Data Boundaries Documentation](docs/CONTEXT_AND_DATA_BOUNDARIES.md).
|
187
|
+
config.site_ssl_enabled = true
|
188
|
+
end
|
189
|
+
```
|
190
|
+
|
191
|
+
### 2. Create a .rue file
|
192
|
+
|
193
|
+
Create `templates/dashboard.rue` - notice how the `<data>` section defines exactly what your frontend app will receive:
|
194
|
+
|
195
|
+
```xml
|
196
|
+
<data window="appState" schema="@/src/types/app-state.d.ts">
|
197
|
+
{
|
198
|
+
"user": {
|
199
|
+
"id": "{{user.id}}",
|
200
|
+
"name": "{{user.name}}",
|
201
|
+
"email": "{{user.email}}",
|
202
|
+
"preferences": {{user.preferences}}
|
203
|
+
},
|
204
|
+
"products": {{recent_products}},
|
205
|
+
"cart": {
|
206
|
+
"items": {{cart.items}},
|
207
|
+
"total": "{{cart.total}}"
|
208
|
+
},
|
209
|
+
"api": {
|
210
|
+
"baseUrl": "{{api_base_url}}",
|
211
|
+
"csrfToken": "{{csrf_token}}"
|
212
|
+
},
|
213
|
+
"features": {{enabled_features}}
|
214
|
+
}
|
215
|
+
</data>
|
216
|
+
|
217
|
+
<template>
|
218
|
+
<!doctype html>
|
219
|
+
<html lang="{{locale}}" class="{{theme_class}}">
|
220
|
+
<head>
|
221
|
+
<title>{{page_title}}</title>
|
222
|
+
<meta name="csrf-token" content="{{csrf_token}}">
|
223
|
+
</head>
|
224
|
+
<body>
|
225
|
+
<!-- Critical: The mounting point for your frontend framework -->
|
226
|
+
<div id="app">
|
227
|
+
<!-- Server-rendered content for SEO and initial load -->
|
228
|
+
<nav>{{> navigation}}</nav>
|
229
|
+
<main>
|
230
|
+
<h1>{{page_title}}</h1>
|
231
|
+
{{#if user}}
|
232
|
+
<p>Welcome back, {{user.name}}!</p>
|
233
|
+
{{else}}
|
234
|
+
<p>Please sign in to continue.</p>
|
235
|
+
{{/if}}
|
236
|
+
</main>
|
237
|
+
</div>
|
238
|
+
|
239
|
+
<!--
|
240
|
+
RSFC automatically generates hydration scripts:
|
241
|
+
- <script type="application/json" id="app-state-data">{...}</script>
|
242
|
+
- <script>window.appState = JSON.parse(...);</script>
|
243
|
+
-->
|
244
|
+
|
245
|
+
<!-- Your frontend framework takes over from here -->
|
246
|
+
<script nonce="{{nonce}}" type="module" src="/assets/app.js"></script>
|
247
|
+
</body>
|
248
|
+
</html>
|
249
|
+
</template>
|
250
|
+
```
|
251
|
+
|
252
|
+
### 3. The Manifold: Server-to-SPA Handoff
|
253
|
+
|
254
|
+
This example demonstrates Rhales' core value proposition: **eliminating the coordination gap** between server state and frontend frameworks.
|
255
|
+
|
256
|
+
**What you get:**
|
257
|
+
- ✅ **Declarative data contract**: The `<data>` section explicitly defines what your frontend receives
|
258
|
+
- ✅ **Type safety ready**: Schema reference points to TypeScript definitions
|
259
|
+
- ✅ **Zero coordination overhead**: No separate API design needed for initial state
|
260
|
+
- ✅ **SEO + SPA**: Server-rendered HTML with automatic client hydration
|
261
|
+
- ✅ **Security boundaries**: Only explicitly declared data reaches the client
|
262
|
+
|
263
|
+
### 4. RSFC Security Model
|
264
|
+
|
265
|
+
**Key Principle: The security boundary is at the server-to-client handoff, not within server-side rendering.**
|
266
|
+
|
267
|
+
```xml
|
268
|
+
<data>
|
269
|
+
{
|
270
|
+
"message": "{{greeting}}", <!-- ✅ Exposed to client -->
|
271
|
+
"user": {
|
272
|
+
"name": "{{user.name}}" <!-- ✅ Exposed to client -->
|
273
|
+
}
|
274
|
+
<!-- ❌ user.secret_key not declared, won't reach client -->
|
275
|
+
}
|
276
|
+
</data>
|
277
|
+
|
278
|
+
<template>
|
279
|
+
<h1>{{greeting}}</h1> <!-- ✅ Full server context access -->
|
280
|
+
<p>{{user.name}}</p> <!-- ✅ Can access user object methods -->
|
281
|
+
<p>{{user.secret_key}}</p> <!-- ✅ Server-side only, not in <data> -->
|
282
|
+
</template>
|
283
|
+
```
|
284
|
+
|
285
|
+
**Template Section (`<template>`):**
|
286
|
+
- ✅ **Full server context access** - like ERB, HAML, or any server-side template
|
287
|
+
- ✅ **Can call object methods** - `{{user.full_name}}`, `{{products.count}}`, etc.
|
288
|
+
- ✅ **Rich server-side logic** - access to full business objects and their capabilities
|
289
|
+
- ✅ **Private by default** - nothing in templates reaches the client unless explicitly declared
|
290
|
+
|
291
|
+
**Data Section (`<data>`):**
|
292
|
+
- ✅ **Explicit client allowlist** - only declared variables reach the browser
|
293
|
+
- ✅ **JSON serialization boundary** - like designing a REST API endpoint
|
294
|
+
- ✅ **Type safety foundation** - can validate against schemas
|
295
|
+
- ❌ **Cannot expose secrets** - `user.secret_key` won't reach client unless declared
|
296
|
+
|
297
|
+
This design gives you the flexibility of full server-side templating while maintaining explicit control over what data reaches the client.
|
298
|
+
|
299
|
+
**Generated output:**
|
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>
|
306
|
+
|
307
|
+
<!-- Automatic client hydration -->
|
308
|
+
<script id="app-state-data" type="application/json">
|
309
|
+
{"user":{"id":123,"name":"Alice","email":"alice@example.com","preferences":{...}},"products":[...],"cart":{...},"api":{...},"features":{...}}
|
310
|
+
</script>
|
311
|
+
<script nonce="abc123">
|
312
|
+
window.appState = JSON.parse(document.getElementById('app-state-data').textContent);
|
313
|
+
</script>
|
314
|
+
|
315
|
+
<!-- Your Vue/React/Svelte app mounts here with full state -->
|
316
|
+
<script nonce="abc123" type="module" src="/assets/app.js"></script>
|
317
|
+
```
|
318
|
+
|
319
|
+
### 5. Framework Integration
|
320
|
+
|
321
|
+
#### Rails
|
322
|
+
|
323
|
+
```ruby
|
324
|
+
# config/initializers/rhales.rb
|
325
|
+
Rhales.configure do |config|
|
326
|
+
config.template_paths = ['app/templates']
|
327
|
+
config.default_locale = 'en'
|
328
|
+
end
|
329
|
+
|
330
|
+
# app/controllers/application_controller.rb
|
331
|
+
class ApplicationController < ActionController::Base
|
332
|
+
def render_rhales(template_name, data = {})
|
333
|
+
view = Rhales::View.new(request, session, current_user, I18n.locale)
|
334
|
+
view.render(template_name, data)
|
335
|
+
end
|
336
|
+
end
|
337
|
+
|
338
|
+
# In your controller
|
339
|
+
def dashboard
|
340
|
+
@dashboard_html = render_rhales('dashboard',
|
341
|
+
page_title: 'User Dashboard',
|
342
|
+
user: current_user,
|
343
|
+
recent_products: Product.recent.limit(5),
|
344
|
+
cart: current_user.cart,
|
345
|
+
enabled_features: Feature.enabled_for(current_user)
|
346
|
+
)
|
347
|
+
end
|
348
|
+
```
|
349
|
+
|
350
|
+
#### Sinatra
|
351
|
+
|
352
|
+
```ruby
|
353
|
+
# app.rb
|
354
|
+
require 'sinatra'
|
355
|
+
require 'rhales'
|
356
|
+
|
357
|
+
Rhales.configure do |config|
|
358
|
+
config.template_paths = ['templates']
|
359
|
+
config.default_locale = 'en'
|
360
|
+
end
|
361
|
+
|
362
|
+
helpers do
|
363
|
+
def render_rhales(template_name, data = {})
|
364
|
+
view = Rhales::View.new(request, session, current_user, 'en')
|
365
|
+
view.render(template_name, data)
|
366
|
+
end
|
367
|
+
end
|
368
|
+
|
369
|
+
get '/dashboard' do
|
370
|
+
@dashboard_html = render_rhales('dashboard',
|
371
|
+
page_title: 'Dashboard',
|
372
|
+
user: current_user,
|
373
|
+
recent_products: Product.recent,
|
374
|
+
cart: session[:cart] || {},
|
375
|
+
enabled_features: FEATURES
|
376
|
+
)
|
377
|
+
erb :dashboard
|
378
|
+
end
|
379
|
+
```
|
380
|
+
|
381
|
+
#### Padrino
|
382
|
+
|
383
|
+
```ruby
|
384
|
+
# config/apps.rb
|
385
|
+
Padrino.configure_apps do
|
386
|
+
Rhales.configure do |config|
|
387
|
+
config.template_paths = ['app/templates']
|
388
|
+
config.default_locale = 'en'
|
389
|
+
end
|
390
|
+
end
|
391
|
+
|
392
|
+
# app/helpers/application_helper.rb
|
393
|
+
module ApplicationHelper
|
394
|
+
def render_rhales(template_name, data = {})
|
395
|
+
view = Rhales::View.new(request, session, current_user, locale)
|
396
|
+
view.render(template_name, data)
|
397
|
+
end
|
398
|
+
end
|
399
|
+
|
400
|
+
# app/controllers/application_controller.rb
|
401
|
+
get :dashboard do
|
402
|
+
@dashboard_html = render_rhales('dashboard',
|
403
|
+
page_title: 'Dashboard',
|
404
|
+
user: current_user,
|
405
|
+
recent_products: Product.recent,
|
406
|
+
cart: current_user&.cart,
|
407
|
+
enabled_features: settings.features
|
408
|
+
)
|
409
|
+
render :dashboard
|
410
|
+
end
|
411
|
+
```
|
412
|
+
|
413
|
+
#### Grape
|
414
|
+
|
415
|
+
```ruby
|
416
|
+
# config.ru or initializer
|
417
|
+
require 'grape'
|
418
|
+
require 'rhales'
|
419
|
+
|
420
|
+
Rhales.configure do |config|
|
421
|
+
config.template_paths = ['templates']
|
422
|
+
config.default_locale = 'en'
|
423
|
+
end
|
424
|
+
|
425
|
+
# api.rb
|
426
|
+
class MyAPI < Grape::API
|
427
|
+
helpers do
|
428
|
+
def render_rhales(template_name, data = {})
|
429
|
+
# Create mock request/session for Grape
|
430
|
+
mock_request = OpenStruct.new(env: env)
|
431
|
+
mock_session = {}
|
432
|
+
|
433
|
+
view = Rhales::View.new(mock_request, mock_session, current_user, 'en')
|
434
|
+
view.render(template_name, data)
|
435
|
+
end
|
436
|
+
end
|
437
|
+
|
438
|
+
get '/dashboard' do
|
439
|
+
content_type 'text/html'
|
440
|
+
render_rhales('dashboard',
|
441
|
+
page_title: 'API Dashboard',
|
442
|
+
user: current_user,
|
443
|
+
recent_products: [],
|
444
|
+
cart: {},
|
445
|
+
enabled_features: { api_v2: true }
|
446
|
+
)
|
447
|
+
end
|
448
|
+
end
|
449
|
+
```
|
450
|
+
|
451
|
+
#### Roda
|
452
|
+
|
453
|
+
```ruby
|
454
|
+
# app.rb
|
455
|
+
require 'roda'
|
456
|
+
require 'rhales'
|
457
|
+
|
458
|
+
class App < Roda
|
459
|
+
plugin :render
|
460
|
+
|
461
|
+
Rhales.configure do |config|
|
462
|
+
config.template_paths = ['templates']
|
463
|
+
config.default_locale = 'en'
|
464
|
+
end
|
465
|
+
|
466
|
+
def render_rhales(template_name, data = {})
|
467
|
+
view = Rhales::View.new(request, session, current_user, 'en')
|
468
|
+
view.render(template_name, data)
|
469
|
+
end
|
470
|
+
|
471
|
+
route do |r|
|
472
|
+
r.on 'dashboard' do
|
473
|
+
@dashboard_html = render_rhales('dashboard',
|
474
|
+
page_title: 'Dashboard',
|
475
|
+
user: current_user,
|
476
|
+
recent_products: [],
|
477
|
+
cart: session[:cart],
|
478
|
+
enabled_features: FEATURES
|
479
|
+
)
|
480
|
+
view('dashboard')
|
481
|
+
end
|
482
|
+
end
|
483
|
+
end
|
484
|
+
```
|
485
|
+
|
486
|
+
### 6. Basic Usage
|
487
|
+
|
488
|
+
```ruby
|
489
|
+
# Create a view instance
|
490
|
+
view = Rhales::View.new(request, session, current_user, locale)
|
491
|
+
|
492
|
+
# Render a template with rich data for frontend hydration
|
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
|
+
)
|
500
|
+
|
501
|
+
# Or use the convenience method
|
502
|
+
html = Rhales.render('dashboard',
|
503
|
+
request: request,
|
504
|
+
session: session,
|
505
|
+
user: current_user,
|
506
|
+
page_title: 'User Dashboard',
|
507
|
+
recent_products: products,
|
508
|
+
cart: cart_data,
|
509
|
+
enabled_features: features
|
510
|
+
)
|
511
|
+
```
|
512
|
+
|
513
|
+
## Authentication Adapters
|
514
|
+
|
515
|
+
Rhales supports pluggable authentication adapters. Implement the `Rhales::Adapters::BaseAuth` interface:
|
516
|
+
|
517
|
+
```ruby
|
518
|
+
class MyAuthAdapter < Rhales::Adapters::BaseAuth
|
519
|
+
def initialize(user)
|
520
|
+
@user = user
|
521
|
+
end
|
522
|
+
|
523
|
+
def anonymous?
|
524
|
+
@user.nil?
|
525
|
+
end
|
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
|
538
|
+
end
|
539
|
+
|
540
|
+
# Use with Rhales
|
541
|
+
user_adapter = MyAuthAdapter.new(current_user)
|
542
|
+
view = Rhales::View.new(request, session, user_adapter)
|
543
|
+
```
|
544
|
+
|
545
|
+
## Template Syntax
|
546
|
+
|
547
|
+
Rhales uses a Handlebars-style template syntax:
|
548
|
+
|
549
|
+
### Variables
|
550
|
+
- `{{variable}}` - HTML-escaped output
|
551
|
+
- `{{{variable}}}` - Raw output (use carefully!)
|
552
|
+
|
553
|
+
### Conditionals
|
554
|
+
```erb
|
555
|
+
{{#if condition}}
|
556
|
+
Content when true
|
557
|
+
{{/if}}
|
558
|
+
|
559
|
+
{{#unless condition}}
|
560
|
+
Content when false
|
561
|
+
{{/unless}}
|
562
|
+
```
|
563
|
+
|
564
|
+
### Iteration
|
565
|
+
```erb
|
566
|
+
{{#each items}}
|
567
|
+
<div>{{name}} - {{@index}}</div>
|
568
|
+
{{/each}}
|
569
|
+
```
|
570
|
+
|
571
|
+
### Partials
|
572
|
+
```erb
|
573
|
+
{{> header}}
|
574
|
+
{{> navigation}}
|
575
|
+
```
|
576
|
+
|
577
|
+
## Data Hydration
|
578
|
+
|
579
|
+
The `<data>` section creates client-side JavaScript:
|
580
|
+
|
581
|
+
```erb
|
582
|
+
<data window="myData">
|
583
|
+
{
|
584
|
+
"apiUrl": "{{api_base_url}}",
|
585
|
+
"user": {{user}},
|
586
|
+
"csrfToken": "{{csrf_token}}"
|
587
|
+
}
|
588
|
+
</data>
|
589
|
+
```
|
590
|
+
|
591
|
+
Generates:
|
592
|
+
```html
|
593
|
+
<script id="rsfc-data-abc123" type="application/json">
|
594
|
+
{"apiUrl":"https://api.example.com","user":{"id":123},"csrfToken":"token"}
|
595
|
+
</script>
|
596
|
+
<script nonce="nonce123">
|
597
|
+
window.myData = JSON.parse(document.getElementById('rsfc-data-abc123').textContent);
|
598
|
+
</script>
|
599
|
+
```
|
600
|
+
|
601
|
+
### Window Collision Detection
|
602
|
+
|
603
|
+
Rhales automatically detects when multiple templates try to use the same window attribute, preventing silent data overwrites:
|
604
|
+
|
605
|
+
```erb
|
606
|
+
<!-- layouts/main.rue -->
|
607
|
+
<data window="appData">
|
608
|
+
{"user": "{{user.name}}", "csrf": "{{csrf_token}}"}
|
609
|
+
</data>
|
610
|
+
|
611
|
+
<!-- pages/home.rue -->
|
612
|
+
<data window="appData"> <!-- ❌ Collision detected! -->
|
613
|
+
{"page": "home", "features": ["feature1"]}
|
614
|
+
</data>
|
615
|
+
```
|
616
|
+
|
617
|
+
This raises a helpful error:
|
618
|
+
```
|
619
|
+
Window attribute collision detected
|
620
|
+
|
621
|
+
Attribute: 'appData'
|
622
|
+
First defined: layouts/main.rue:1
|
623
|
+
Conflict with: pages/home.rue:1
|
624
|
+
|
625
|
+
Quick fixes:
|
626
|
+
1. Rename one: <data window="homeData">
|
627
|
+
2. Enable merging: <data window="appData" merge="deep">
|
628
|
+
```
|
629
|
+
|
630
|
+
### Merge Strategies
|
631
|
+
|
632
|
+
When you intentionally want to share data between templates, use explicit merge strategies:
|
633
|
+
|
634
|
+
```erb
|
635
|
+
<!-- layouts/main.rue -->
|
636
|
+
<data window="appData">
|
637
|
+
{
|
638
|
+
"user": {"name": "{{user.name}}", "role": "{{user.role}}"},
|
639
|
+
"csrf": "{{csrf_token}}"
|
640
|
+
}
|
641
|
+
</data>
|
642
|
+
|
643
|
+
<!-- pages/home.rue with deep merge -->
|
644
|
+
<data window="appData" merge="deep">
|
645
|
+
{
|
646
|
+
"user": {"email": "{{user.email}}"}, <!-- Merged with layout user -->
|
647
|
+
"page": {"title": "Home", "features": {{features.to_json}}}
|
648
|
+
}
|
649
|
+
</data>
|
650
|
+
```
|
651
|
+
|
652
|
+
#### Available Merge Strategies
|
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
|
+
```
|
659
|
+
|
660
|
+
**`merge="deep"`** - Recursive merge, last value wins on conflicts:
|
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
|
+
```
|
666
|
+
|
667
|
+
**`merge="strict"`** - Recursive merge, throws error on any conflict:
|
668
|
+
```javascript
|
669
|
+
// Layout: {"user": {"name": "John"}}
|
670
|
+
// Page: {"user": {"name": "Jane"}} // ❌ Error: value conflict
|
671
|
+
```
|
672
|
+
|
673
|
+
## Content Security Policy (CSP)
|
674
|
+
|
675
|
+
Rhales provides **security by default** with automatic CSP header generation and nonce management.
|
676
|
+
|
677
|
+
### Automatic CSP Protection
|
678
|
+
|
679
|
+
CSP is **enabled by default** when you configure Rhales:
|
680
|
+
|
681
|
+
```ruby
|
682
|
+
Rhales.configure do |config|
|
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
|
687
|
+
```
|
688
|
+
|
689
|
+
### Default Security Policy
|
690
|
+
|
691
|
+
Rhales ships with a secure default CSP policy:
|
692
|
+
|
693
|
+
```ruby
|
694
|
+
{
|
695
|
+
'default-src' => ["'self'"],
|
696
|
+
'script-src' => ["'self'", "'nonce-{{nonce}}'"],
|
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' => []
|
706
|
+
}
|
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>
|
719
|
+
|
720
|
+
<style nonce="{{app.nonce}}">
|
721
|
+
/* Your inline styles with automatic nonce */
|
722
|
+
.component { color: blue; }
|
723
|
+
</style>
|
724
|
+
```
|
725
|
+
|
726
|
+
### Framework Integration
|
727
|
+
|
728
|
+
CSP headers are automatically set during view rendering:
|
729
|
+
|
730
|
+
```ruby
|
731
|
+
# Your framework code (Rails, Sinatra, Roda, etc.)
|
732
|
+
def dashboard
|
733
|
+
view = Rhales::View.new(request, session, current_user, 'en')
|
734
|
+
html = view.render('dashboard', user: current_user)
|
735
|
+
|
736
|
+
# CSP header automatically added to response:
|
737
|
+
# Content-Security-Policy: default-src 'self'; script-src 'self' 'nonce-abc123'; ...
|
738
|
+
end
|
739
|
+
```
|
740
|
+
|
741
|
+
### Custom CSP Policies
|
742
|
+
|
743
|
+
Customize the CSP policy for your specific needs:
|
744
|
+
|
745
|
+
```ruby
|
746
|
+
Rhales.configure do |config|
|
747
|
+
config.csp_policy = {
|
748
|
+
'default-src' => ["'self'"],
|
749
|
+
'script-src' => ["'self'", "'nonce-{{nonce}}'", 'https://cdn.example.com'],
|
750
|
+
'style-src' => ["'self'", "'nonce-{{nonce}}'", 'https://fonts.googleapis.com'],
|
751
|
+
'img-src' => ["'self'", 'data:', 'https://images.example.com'],
|
752
|
+
'connect-src' => ["'self'", 'https://api.example.com'],
|
753
|
+
'font-src' => ["'self'", 'https://fonts.gstatic.com'],
|
754
|
+
# Add your own directives...
|
755
|
+
}
|
756
|
+
end
|
757
|
+
```
|
758
|
+
|
759
|
+
### Per-Framework CSP Setup
|
760
|
+
|
761
|
+
#### Rails
|
762
|
+
```ruby
|
763
|
+
# app/controllers/application_controller.rb
|
764
|
+
class ApplicationController < ActionController::Base
|
765
|
+
after_action :set_csp_header
|
766
|
+
|
767
|
+
private
|
768
|
+
|
769
|
+
def set_csp_header
|
770
|
+
csp_header = request.env['csp_header']
|
771
|
+
response.headers['Content-Security-Policy'] = csp_header if csp_header
|
772
|
+
end
|
773
|
+
end
|
774
|
+
```
|
775
|
+
|
776
|
+
#### Sinatra
|
777
|
+
```ruby
|
778
|
+
helpers do
|
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
|
+
```
|
787
|
+
|
788
|
+
#### Roda
|
789
|
+
```ruby
|
790
|
+
class App < Roda
|
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
|
798
|
+
```
|
799
|
+
|
800
|
+
### CSP Benefits
|
801
|
+
|
802
|
+
- **Security by default**: Protection against XSS attacks out of the box
|
803
|
+
- **Automatic nonce management**: No manual nonce coordination needed
|
804
|
+
- **Template integration**: Nonces automatically available in templates
|
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
|
808
|
+
|
809
|
+
### Disabling CSP
|
810
|
+
|
811
|
+
If you need to disable CSP for specific environments:
|
812
|
+
|
813
|
+
```ruby
|
814
|
+
Rhales.configure do |config|
|
815
|
+
config.csp_enabled = false # Disable CSP header generation
|
816
|
+
config.auto_nonce = false # Disable automatic nonce generation
|
817
|
+
end
|
818
|
+
```
|
819
|
+
|
820
|
+
## Testing
|
821
|
+
|
822
|
+
Rhales includes comprehensive test helpers and is framework-agnostic:
|
823
|
+
|
824
|
+
```ruby
|
825
|
+
# test/test_helper.rb or spec/spec_helper.rb
|
826
|
+
require 'rhales'
|
827
|
+
|
828
|
+
Rhales.configure do |config|
|
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
|
834
|
+
|
835
|
+
# Test context creation
|
836
|
+
context = Rhales::Context.minimal(props: { user: { name: 'Test' } })
|
837
|
+
expect(context.get('user.name')).to eq('Test')
|
838
|
+
|
839
|
+
# Test template rendering
|
840
|
+
template = '{{#if authenticated}}Welcome{{/if}}'
|
841
|
+
result = Rhales.render_template(template, authenticated: true)
|
842
|
+
expect(result).to eq('Welcome')
|
843
|
+
|
844
|
+
# Test full template files
|
845
|
+
mock_request = OpenStruct.new(env: {})
|
846
|
+
mock_session = {}
|
847
|
+
view = Rhales::View.new(mock_request, mock_session, nil, 'en')
|
848
|
+
html = view.render('test_template', message: 'Hello World')
|
849
|
+
```
|
850
|
+
|
851
|
+
## Development
|
852
|
+
|
853
|
+
After checking out the repo, run:
|
854
|
+
|
855
|
+
```bash
|
856
|
+
bundle install
|
857
|
+
bundle exec rspec
|
858
|
+
```
|
859
|
+
|
860
|
+
## Contributing
|
861
|
+
|
862
|
+
1. Fork it
|
863
|
+
2. Create your feature branch (`git checkout -b my-new-feature`)
|
864
|
+
3. Commit your changes (`git commit -am 'Add some feature'`)
|
865
|
+
4. Push to the branch (`git push origin my-new-feature`)
|
866
|
+
5. Create a new Pull Request
|
867
|
+
|
868
|
+
## License
|
869
|
+
|
870
|
+
The gem is available as open source under the [MIT License](https://opensource.org/licenses/MIT).
|
871
|
+
|
872
|
+
## AI Development Assistance
|
873
|
+
|
874
|
+
Rhales was developed with assistance from AI tools. The following tools provided significant help with architecture design, code generation, and documentation:
|
875
|
+
|
876
|
+
- **Claude Sonnet 4** - Architecture design, code generation, and documentation
|
877
|
+
- **Claude Desktop & Claude Code** - Interactive development sessions and debugging
|
878
|
+
- **GitHub Copilot** - Code completion and refactoring assistance
|
879
|
+
- **Qodo Merge Pro** - Code review and quality improvements
|
880
|
+
|
881
|
+
I remain responsible for all design decisions and the final code. I believe in being transparent about development tools, especially as AI becomes more integrated into our workflows.
|