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.
Files changed (116) hide show
  1. checksums.yaml +4 -4
  2. data/.github/renovate.json5 +52 -0
  3. data/.github/workflows/ci.yml +123 -0
  4. data/.github/workflows/claude-code-review.yml +69 -0
  5. data/.github/workflows/claude.yml +49 -0
  6. data/.github/workflows/code-smells.yml +146 -0
  7. data/.github/workflows/ruby-lint.yml +78 -0
  8. data/.github/workflows/yardoc.yml +126 -0
  9. data/.gitignore +55 -0
  10. data/.pr_agent.toml +63 -0
  11. data/.pre-commit-config.yaml +89 -0
  12. data/.prettierignore +8 -0
  13. data/.prettierrc +38 -0
  14. data/.reek.yml +98 -0
  15. data/.rubocop.yml +428 -0
  16. data/.serena/.gitignore +3 -0
  17. data/.yardopts +56 -0
  18. data/CHANGELOG.md +44 -0
  19. data/CLAUDE.md +1 -2
  20. data/Gemfile +29 -0
  21. data/Gemfile.lock +189 -0
  22. data/README.md +706 -589
  23. data/Rakefile +46 -0
  24. data/debug_context.rb +25 -0
  25. data/demo/rhales-roda-demo/.gitignore +7 -0
  26. data/demo/rhales-roda-demo/Gemfile +32 -0
  27. data/demo/rhales-roda-demo/Gemfile.lock +151 -0
  28. data/demo/rhales-roda-demo/MAIL.md +405 -0
  29. data/demo/rhales-roda-demo/README.md +376 -0
  30. data/demo/rhales-roda-demo/RODA-TEMPLATE-ENGINES.md +192 -0
  31. data/demo/rhales-roda-demo/Rakefile +49 -0
  32. data/demo/rhales-roda-demo/app.rb +325 -0
  33. data/demo/rhales-roda-demo/bin/rackup +26 -0
  34. data/demo/rhales-roda-demo/config.ru +13 -0
  35. data/demo/rhales-roda-demo/db/migrate/001_create_rodauth_tables.rb +266 -0
  36. data/demo/rhales-roda-demo/db/migrate/002_create_rodauth_password_tables.rb +79 -0
  37. data/demo/rhales-roda-demo/db/migrate/003_add_admin_account.rb +68 -0
  38. data/demo/rhales-roda-demo/templates/change_login.rue +31 -0
  39. data/demo/rhales-roda-demo/templates/change_password.rue +36 -0
  40. data/demo/rhales-roda-demo/templates/close_account.rue +31 -0
  41. data/demo/rhales-roda-demo/templates/create_account.rue +40 -0
  42. data/demo/rhales-roda-demo/templates/dashboard.rue +150 -0
  43. data/demo/rhales-roda-demo/templates/home.rue +78 -0
  44. data/demo/rhales-roda-demo/templates/layouts/main.rue +168 -0
  45. data/demo/rhales-roda-demo/templates/login.rue +65 -0
  46. data/demo/rhales-roda-demo/templates/logout.rue +25 -0
  47. data/demo/rhales-roda-demo/templates/reset_password.rue +26 -0
  48. data/demo/rhales-roda-demo/templates/verify_account.rue +27 -0
  49. data/demo/rhales-roda-demo/test_full_output.rb +27 -0
  50. data/demo/rhales-roda-demo/test_simple.rb +24 -0
  51. data/docs/.gitignore +9 -0
  52. data/docs/architecture/data-flow.md +499 -0
  53. data/examples/dashboard-with-charts.rue +271 -0
  54. data/examples/form-with-validation.rue +180 -0
  55. data/examples/simple-page.rue +61 -0
  56. data/examples/vue.rue +136 -0
  57. data/generate-json-schemas.ts +158 -0
  58. data/json_schemer_migration_summary.md +172 -0
  59. data/lib/rhales/adapters/base_auth.rb +2 -0
  60. data/lib/rhales/adapters/base_request.rb +2 -0
  61. data/lib/rhales/adapters/base_session.rb +2 -0
  62. data/lib/rhales/adapters.rb +7 -0
  63. data/lib/rhales/configuration.rb +161 -1
  64. data/lib/rhales/core/context.rb +354 -0
  65. data/lib/rhales/{rue_document.rb → core/rue_document.rb} +59 -43
  66. data/lib/rhales/{template_engine.rb → core/template_engine.rb} +80 -33
  67. data/lib/rhales/core/view.rb +529 -0
  68. data/lib/rhales/{view_composition.rb → core/view_composition.rb} +81 -9
  69. data/lib/rhales/core.rb +9 -0
  70. data/lib/rhales/errors/hydration_collision_error.rb +2 -0
  71. data/lib/rhales/errors.rb +2 -0
  72. data/lib/rhales/hydration/earliest_injection_detector.rb +153 -0
  73. data/lib/rhales/hydration/hydration_data_aggregator.rb +487 -0
  74. data/lib/rhales/hydration/hydration_endpoint.rb +215 -0
  75. data/lib/rhales/hydration/hydration_injector.rb +175 -0
  76. data/lib/rhales/{hydration_registry.rb → hydration/hydration_registry.rb} +2 -0
  77. data/lib/rhales/hydration/hydrator.rb +102 -0
  78. data/lib/rhales/hydration/link_based_injection_detector.rb +195 -0
  79. data/lib/rhales/hydration/mount_point_detector.rb +109 -0
  80. data/lib/rhales/hydration/safe_injection_validator.rb +103 -0
  81. data/lib/rhales/hydration.rb +13 -0
  82. data/lib/rhales/{refinements → integrations/refinements}/require_refinements.rb +7 -13
  83. data/lib/rhales/{tilt.rb → integrations/tilt.rb} +26 -18
  84. data/lib/rhales/integrations.rb +6 -0
  85. data/lib/rhales/middleware/json_responder.rb +191 -0
  86. data/lib/rhales/middleware/schema_validator.rb +300 -0
  87. data/lib/rhales/middleware.rb +6 -0
  88. data/lib/rhales/parsers/handlebars_parser.rb +2 -0
  89. data/lib/rhales/parsers/rue_format_parser.rb +55 -36
  90. data/lib/rhales/parsers.rb +9 -0
  91. data/lib/rhales/{csp.rb → security/csp.rb} +27 -3
  92. data/lib/rhales/utils/json_serializer.rb +114 -0
  93. data/lib/rhales/utils/logging_helpers.rb +75 -0
  94. data/lib/rhales/utils/schema_extractor.rb +132 -0
  95. data/lib/rhales/utils/schema_generator.rb +194 -0
  96. data/lib/rhales/utils.rb +40 -0
  97. data/lib/rhales/version.rb +5 -1
  98. data/lib/rhales.rb +47 -19
  99. data/lib/tasks/rhales_schema.rake +197 -0
  100. data/package.json +10 -0
  101. data/pnpm-lock.yaml +345 -0
  102. data/pnpm-workspace.yaml +2 -0
  103. data/proofs/error_handling.rb +79 -0
  104. data/proofs/expanded_object_inheritance.rb +82 -0
  105. data/proofs/partial_context_scoping_fix.rb +168 -0
  106. data/proofs/ui_context_partial_inheritance.rb +236 -0
  107. data/rhales.gemspec +14 -6
  108. data/schema_vs_data_comparison.md +254 -0
  109. data/test_direct_access.rb +36 -0
  110. metadata +142 -18
  111. data/CLAUDE.locale.txt +0 -7
  112. data/lib/rhales/context.rb +0 -240
  113. data/lib/rhales/hydration_data_aggregator.rb +0 -220
  114. data/lib/rhales/hydrator.rb +0 -141
  115. data/lib/rhales/parsers/handlebars-grammar-review.txt +0 -39
  116. 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.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.
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 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.
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
- 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.
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
- - **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
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 this line to your application's Gemfile:
34
+ Add to your Gemfile:
26
35
 
27
36
  ```ruby
28
37
  gem 'rhales'
29
38
  ```
30
39
 
31
- And then execute:
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
- # Configure Rhales in your application initialization
51
+ # config/initializers/rhales.rb or similar
49
52
  Rhales.configure do |config|
50
53
  config.default_locale = 'en'
51
- config.template_paths = ['templates'] # or 'app/templates', 'views/templates', etc.
54
+ config.template_paths = ['templates']
52
55
  config.features = { dark_mode: true }
53
56
  config.site_host = 'example.com'
54
57
 
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
- }
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 Simple Component
67
+ ### 2. Create a .rue Component
68
68
 
69
- Create a `.rue` file in your templates directory:
69
+ Create `templates/hello.rue`:
70
70
 
71
- ```rue
72
- <!-- templates/hello.rue -->
73
- <data>
74
- {
75
- "greeting": "{{greeting}}",
76
- "user_name": "{{user.name}}"
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}}, {{user.name}}!</h1>
83
- <p>Welcome to Rhales RSFC!</p>
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(request, session, user, 'en',
97
- props: {
95
+ view = Rhales::View.new(
96
+ request,
97
+ client: {
98
98
  greeting: 'Hello',
99
- user: { name: 'World' }
99
+ userName: 'World'
100
100
  }
101
101
  )
102
102
 
103
103
  html = view.render('hello')
104
- # Returns HTML with embedded JSON for client-side hydration
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 **two-layer data model** for template rendering:
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
- ### 1. App Data (Framework Layer)
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
- <!-- 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:
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
- <!-- 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}}
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
- 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)
253
+
254
+ Templates have access to ALL context layers:
152
255
 
153
256
  ```handlebars
154
- <!-- Full server access in templates -->
155
- {{#if user.admin?}}
156
- <a href="/admin">Admin Panel</a>
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
- ```rue
165
- <data>
166
- {
167
- "display_name": "{{user.name}}",
168
- "preferences": {
169
- "theme": "{{user.theme}}"
170
- }
171
- }
172
- </data>
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
- Becomes:
280
+ **Result on client:**
281
+
176
282
  ```javascript
177
283
  window.data = {
178
- "display_name": "John Doe",
179
- "preferences": { "theme": "dark" }
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 server-to-client security boundary.
290
+ This creates a **REST API-like boundary** where you explicitly declare what data crosses the security boundary.
185
291
 
186
- For complete details, see [Context and Data Boundaries Documentation](docs/CONTEXT_AND_DATA_BOUNDARIES.md).
187
- config.site_ssl_enabled = true
188
- end
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
- ### 2. Create a .rue file
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
- Create `templates/dashboard.rue` - notice how the `<data>` section defines exactly what your frontend app will receive:
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
- <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>
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
- <!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>
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
- RSFC automatically generates hydration scripts:
241
- - <script type="application/json" id="app-state-data">{...}</script>
242
- - <script>window.appState = JSON.parse(...);</script>
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
- <!-- Your frontend framework takes over from here -->
246
- <script nonce="{{nonce}}" type="module" src="/assets/app.js"></script>
247
- </body>
248
- </html>
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
- ### 3. The Manifold: Server-to-SPA Handoff
409
+ ### Generated HTML
253
410
 
254
- This example demonstrates Rhales' core value proposition: **eliminating the coordination gap** between server state and frontend frameworks.
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
- **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
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
- ### 4. RSFC Security Model
484
+ ```handlebars
485
+ {{> header}} <!-- Include templates/header.rue -->
486
+ {{> components/nav}} <!-- Include templates/components/nav.rue -->
487
+ ```
264
488
 
265
- **Key Principle: The security boundary is at the server-to-client handoff, not within server-side rendering.**
489
+ ### Layouts
266
490
 
267
491
  ```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>
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>{{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> -->
498
+ <h1>Home Page Content</h1>
282
499
  </template>
283
500
  ```
284
501
 
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
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
- **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
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
- This design gives you the flexibility of full server-side templating while maintaining explicit control over what data reaches the client.
525
+ ## Schema Tooling
298
526
 
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>
527
+ Rhales provides rake tasks for schema management:
306
528
 
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>
529
+ ```bash
530
+ # Generate JSON schemas from .rue templates
531
+ rake rhales:schema:generate TEMPLATES_DIR=./templates
314
532
 
315
- <!-- Your Vue/React/Svelte app mounts here with full state -->
316
- <script nonce="abc123" type="module" src="/assets/app.js"></script>
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
- ### 5. Framework Integration
540
+ **Example output:**
320
541
 
321
- #### Rails
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, data = {})
333
- view = Rhales::View.new(request, session, current_user, I18n.locale)
334
- view.render(template_name, data)
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
- @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)
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
- #### Sinatra
584
+ ### Roda
351
585
 
352
586
  ```ruby
353
587
  # app.rb
354
- require 'sinatra'
588
+ require 'roda'
355
589
  require 'rhales'
356
590
 
357
- Rhales.configure do |config|
358
- config.template_paths = ['templates']
359
- config.default_locale = 'en'
360
- end
591
+ class App < Roda
592
+ plugin :render
361
593
 
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)
594
+ Rhales.configure do |config|
595
+ config.template_paths = ['templates']
596
+ config.default_locale = 'en'
366
597
  end
367
- end
368
598
 
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
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
- #### Padrino
612
+ ### Sinatra
382
613
 
383
614
  ```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
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
- # 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)
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
- # 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
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
- #### Grape
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, data = {})
429
- # Create mock request/session for Grape
651
+ def render_rhales(template_name, client: {}, server: {})
430
652
  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)
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
- page_title: 'API Dashboard',
442
- user: current_user,
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
- #### Roda
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
- Rhales.configure do |config|
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
- def render_rhales(template_name, data = {})
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
- 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
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
- ### 6. Basic Usage
681
+ ### Using Nonces in Templates
487
682
 
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
- )
683
+ ```handlebars
684
+ <script nonce="{{request.nonce}}">
685
+ // Inline JavaScript with automatic nonce
686
+ console.log('Secure execution');
687
+ </script>
500
688
 
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
- )
689
+ <style nonce="{{request.nonce}}">
690
+ /* Inline styles with automatic nonce */
691
+ .component { color: blue; }
692
+ </style>
511
693
  ```
512
694
 
513
- ## Authentication Adapters
514
-
515
- Rhales supports pluggable authentication adapters. Implement the `Rhales::Adapters::BaseAuth` interface:
695
+ ### Custom CSP Policies
516
696
 
517
697
  ```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
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
- ## Template Syntax
709
+ ### Framework CSP Header Setup
546
710
 
547
- Rhales uses a Handlebars-style template syntax:
711
+ #### Rails
548
712
 
549
- ### Variables
550
- - `{{variable}}` - HTML-escaped output
551
- - `{{{variable}}}` - Raw output (use carefully!)
713
+ ```ruby
714
+ class ApplicationController < ActionController::Base
715
+ after_action :set_csp_header
552
716
 
553
- ### Conditionals
554
- ```erb
555
- {{#if condition}}
556
- Content when true
557
- {{/if}}
717
+ private
558
718
 
559
- {{#unless condition}}
560
- Content when false
561
- {{/unless}}
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
- ### Iteration
565
- ```erb
566
- {{#each items}}
567
- <div>{{name}} - {{@index}}</div>
568
- {{/each}}
569
- ```
726
+ #### Roda
570
727
 
571
- ### Partials
572
- ```erb
573
- {{> header}}
574
- {{> navigation}}
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
- ## Data Hydration
739
+ ## Logging
578
740
 
579
- The `<data>` section creates client-side JavaScript:
741
+ Rhales provides production logging for security auditing and debugging:
580
742
 
581
- ```erb
582
- <data window="myData">
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
- 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>
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
- ### Window Collision Detection
762
+ ## Testing
602
763
 
603
- Rhales automatically detects when multiple templates try to use the same window attribute, preventing silent data overwrites:
764
+ ### Test Configuration
604
765
 
605
- ```erb
606
- <!-- layouts/main.rue -->
607
- <data window="appData">
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
- <!-- pages/home.rue -->
612
- <data window="appData"> <!-- ❌ Collision detected! -->
613
- {"page": "home", "features": ["feature1"]}
614
- </data>
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
- This raises a helpful error:
618
- ```
619
- Window attribute collision detected
778
+ ### Testing Context
620
779
 
621
- Attribute: 'appData'
622
- First defined: layouts/main.rue:1
623
- Conflict with: pages/home.rue:1
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
- Quick fixes:
626
- 1. Rename one: <data window="homeData">
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
- ### Merge Strategies
631
-
632
- When you intentionally want to share data between templates, use explicit merge strategies:
791
+ ### Testing Templates
633
792
 
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>
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
- <!-- 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>
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
- #### 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
- ```
806
+ ## Migration from v0.4 to v0.5
659
807
 
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
- ```
808
+ ### Breaking Changes
666
809
 
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
- ```
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
- ## Content Security Policy (CSP)
819
+ ### Migration Steps
674
820
 
675
- Rhales provides **security by default** with automatic CSP header generation and nonce management.
821
+ #### 1. Update Ruby Code
676
822
 
677
- ### Automatic CSP Protection
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
- CSP is **enabled by default** when you configure Rhales:
830
+ # v0.5 (Current)
831
+ view = Rhales::View.new(req,
832
+ client: { user: customer.name },
833
+ server: { page_title: 'Dashboard' }
834
+ )
680
835
 
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
836
+ # Set locale in request
837
+ req.env['rhales.locale'] = 'en'
687
838
  ```
688
839
 
689
- ### Default Security Policy
840
+ #### 2. Convert Data to Schema
690
841
 
691
- Rhales ships with a secure default CSP policy:
692
-
693
- ```ruby
842
+ ```xml
843
+ <!-- v0.4 (REMOVED) -->
844
+ <data window="data">
694
845
  {
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' => []
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
- <style nonce="{{app.nonce}}">
721
- /* Your inline styles with automatic nonce */
722
- .component { color: blue; }
723
- </style>
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
- ### Framework Integration
860
+ **Key difference:** In v0.5, pass resolved values in `client:` hash instead of relying on template interpolation in JSON.
727
861
 
728
- CSP headers are automatically set during view rendering:
862
+ #### 3. Update Context References
729
863
 
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)
864
+ ```handlebars
865
+ <!-- v0.4 (REMOVED) -->
866
+ {{app.nonce}}
867
+ {{app.csrf_token}}
735
868
 
736
- # CSP header automatically added to response:
737
- # Content-Security-Policy: default-src 'self'; script-src 'self' 'nonce-abc123'; ...
738
- end
869
+ <!-- v0.5 (Current) -->
870
+ {{request.nonce}}
871
+ {{request.csrf_token}}
739
872
  ```
740
873
 
741
- ### Custom CSP Policies
742
-
743
- Customize the CSP policy for your specific needs:
874
+ #### 4. Update Backend Data Passing
744
875
 
745
876
  ```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...
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
- end
888
+ )
757
889
  ```
758
890
 
759
- ### Per-Framework CSP Setup
891
+ ## Performance Optimization
760
892
 
761
- #### Rails
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
- private
895
+ Rhales includes optional support for [Oj](https://github.com/ohler55/oj), a high-performance JSON library that provides:
768
896
 
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
- ```
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
- #### 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
- ```
902
+ #### Installation
903
+
904
+ Add to your Gemfile:
787
905
 
788
- #### Roda
789
906
  ```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
907
+ gem 'oj', '~> 3.13'
798
908
  ```
799
909
 
800
- ### CSP Benefits
910
+ Then run:
801
911
 
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
912
+ ```bash
913
+ bundle install
914
+ ```
808
915
 
809
- ### Disabling CSP
916
+ That's it! Rhales automatically detects Oj at load time and uses it for all JSON operations.
810
917
 
811
- If you need to disable CSP for specific environments:
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
- Rhales.configure do |config|
815
- config.csp_enabled = false # Disable CSP header generation
816
- config.auto_nonce = false # Disable automatic nonce generation
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
- ## Testing
926
+ Most bundler setups handle this automatically, but explicit ordering ensures optimal performance.
821
927
 
822
- Rhales includes comprehensive test helpers and is framework-agnostic:
928
+ #### Verification
929
+
930
+ Check which backend is active:
823
931
 
824
932
  ```ruby
825
- # test/test_helper.rb or spec/spec_helper.rb
826
- require 'rhales'
933
+ Rhales::JSONSerializer.backend
934
+ # => :oj (if available) or :json (stdlib)
935
+ ```
827
936
 
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
937
+ #### Performance Impact
834
938
 
835
- # Test context creation
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
- # Test template rendering
840
- template = '{{#if authenticated}}Welcome{{/if}}'
841
- result = Rhales.render_template(template, authenticated: true)
842
- expect(result).to eq('Welcome')
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
- # 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
- ```
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
- ## Development
952
+ Oj provides the most benefit for data-heavy templates and high-concurrency scenarios.
852
953
 
853
- After checking out the repo, run:
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
- bundle exec rspec
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. The following tools provided significant help with architecture design, code generation, and documentation:
991
+ Rhales was developed with assistance from AI tools:
875
992
 
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
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. I believe in being transparent about development tools, especially as AI becomes more integrated into our workflows.
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.