rhales 0.4.0 → 0.5.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.github/renovate.json5 +52 -0
- data/.github/workflows/ci.yml +123 -0
- data/.github/workflows/claude-code-review.yml +69 -0
- data/.github/workflows/claude.yml +49 -0
- data/.github/workflows/code-smells.yml +146 -0
- data/.github/workflows/ruby-lint.yml +78 -0
- data/.github/workflows/yardoc.yml +126 -0
- data/.gitignore +55 -0
- data/.pr_agent.toml +63 -0
- data/.pre-commit-config.yaml +89 -0
- data/.prettierignore +8 -0
- data/.prettierrc +38 -0
- data/.reek.yml +98 -0
- data/.rubocop.yml +428 -0
- data/.serena/.gitignore +3 -0
- data/.yardopts +56 -0
- data/CHANGELOG.md +44 -0
- data/CLAUDE.md +1 -1
- data/Gemfile +29 -0
- data/Gemfile.lock +189 -0
- data/README.md +686 -868
- data/Rakefile +46 -0
- data/debug_context.rb +25 -0
- data/demo/rhales-roda-demo/.gitignore +7 -0
- data/demo/rhales-roda-demo/Gemfile +32 -0
- data/demo/rhales-roda-demo/Gemfile.lock +151 -0
- data/demo/rhales-roda-demo/MAIL.md +405 -0
- data/demo/rhales-roda-demo/README.md +376 -0
- data/demo/rhales-roda-demo/RODA-TEMPLATE-ENGINES.md +192 -0
- data/demo/rhales-roda-demo/Rakefile +49 -0
- data/demo/rhales-roda-demo/app.rb +325 -0
- data/demo/rhales-roda-demo/bin/rackup +26 -0
- data/demo/rhales-roda-demo/config.ru +13 -0
- data/demo/rhales-roda-demo/db/migrate/001_create_rodauth_tables.rb +266 -0
- data/demo/rhales-roda-demo/db/migrate/002_create_rodauth_password_tables.rb +79 -0
- data/demo/rhales-roda-demo/db/migrate/003_add_admin_account.rb +68 -0
- data/demo/rhales-roda-demo/templates/change_login.rue +31 -0
- data/demo/rhales-roda-demo/templates/change_password.rue +36 -0
- data/demo/rhales-roda-demo/templates/close_account.rue +31 -0
- data/demo/rhales-roda-demo/templates/create_account.rue +40 -0
- data/demo/rhales-roda-demo/templates/dashboard.rue +150 -0
- data/demo/rhales-roda-demo/templates/home.rue +78 -0
- data/demo/rhales-roda-demo/templates/layouts/main.rue +168 -0
- data/demo/rhales-roda-demo/templates/login.rue +65 -0
- data/demo/rhales-roda-demo/templates/logout.rue +25 -0
- data/demo/rhales-roda-demo/templates/reset_password.rue +26 -0
- data/demo/rhales-roda-demo/templates/verify_account.rue +27 -0
- data/demo/rhales-roda-demo/test_full_output.rb +27 -0
- data/demo/rhales-roda-demo/test_simple.rb +24 -0
- data/docs/.gitignore +9 -0
- data/docs/architecture/data-flow.md +499 -0
- data/examples/dashboard-with-charts.rue +271 -0
- data/examples/form-with-validation.rue +180 -0
- data/examples/simple-page.rue +61 -0
- data/examples/vue.rue +136 -0
- data/generate-json-schemas.ts +158 -0
- data/json_schemer_migration_summary.md +172 -0
- data/lib/rhales/adapters/base_auth.rb +2 -0
- data/lib/rhales/adapters/base_request.rb +2 -0
- data/lib/rhales/adapters/base_session.rb +2 -0
- data/lib/rhales/adapters.rb +7 -0
- data/lib/rhales/configuration.rb +47 -0
- data/lib/rhales/core/context.rb +354 -0
- data/lib/rhales/{rue_document.rb → core/rue_document.rb} +56 -38
- data/lib/rhales/{template_engine.rb → core/template_engine.rb} +66 -59
- data/lib/rhales/{view.rb → core/view.rb} +112 -135
- data/lib/rhales/{view_composition.rb → core/view_composition.rb} +78 -8
- data/lib/rhales/core.rb +9 -0
- data/lib/rhales/errors/hydration_collision_error.rb +2 -0
- data/lib/rhales/errors.rb +2 -0
- data/lib/rhales/{earliest_injection_detector.rb → hydration/earliest_injection_detector.rb} +4 -0
- data/lib/rhales/hydration/hydration_data_aggregator.rb +487 -0
- data/lib/rhales/{hydration_endpoint.rb → hydration/hydration_endpoint.rb} +16 -12
- data/lib/rhales/{hydration_injector.rb → hydration/hydration_injector.rb} +4 -0
- data/lib/rhales/{hydration_registry.rb → hydration/hydration_registry.rb} +2 -0
- data/lib/rhales/hydration/hydrator.rb +102 -0
- data/lib/rhales/{link_based_injection_detector.rb → hydration/link_based_injection_detector.rb} +4 -0
- data/lib/rhales/{mount_point_detector.rb → hydration/mount_point_detector.rb} +4 -0
- data/lib/rhales/{safe_injection_validator.rb → hydration/safe_injection_validator.rb} +4 -0
- data/lib/rhales/hydration.rb +13 -0
- data/lib/rhales/{refinements → integrations/refinements}/require_refinements.rb +3 -1
- data/lib/rhales/{tilt.rb → integrations/tilt.rb} +22 -15
- data/lib/rhales/integrations.rb +6 -0
- data/lib/rhales/middleware/json_responder.rb +191 -0
- data/lib/rhales/middleware/schema_validator.rb +300 -0
- data/lib/rhales/middleware.rb +6 -0
- data/lib/rhales/parsers/handlebars_parser.rb +2 -0
- data/lib/rhales/parsers/rue_format_parser.rb +9 -7
- data/lib/rhales/parsers.rb +9 -0
- data/lib/rhales/{csp.rb → security/csp.rb} +27 -3
- data/lib/rhales/utils/json_serializer.rb +114 -0
- data/lib/rhales/utils/logging_helpers.rb +75 -0
- data/lib/rhales/utils/schema_extractor.rb +132 -0
- data/lib/rhales/utils/schema_generator.rb +194 -0
- data/lib/rhales/utils.rb +40 -0
- data/lib/rhales/version.rb +3 -1
- data/lib/rhales.rb +41 -24
- data/lib/tasks/rhales_schema.rake +197 -0
- data/package.json +10 -0
- data/pnpm-lock.yaml +345 -0
- data/pnpm-workspace.yaml +2 -0
- data/proofs/error_handling.rb +79 -0
- data/proofs/expanded_object_inheritance.rb +82 -0
- data/proofs/partial_context_scoping_fix.rb +168 -0
- data/proofs/ui_context_partial_inheritance.rb +236 -0
- data/rhales.gemspec +14 -6
- data/schema_vs_data_comparison.md +254 -0
- data/test_direct_access.rb +36 -0
- metadata +141 -23
- data/CLAUDE.locale.txt +0 -7
- data/lib/rhales/context.rb +0 -239
- data/lib/rhales/hydration_data_aggregator.rb +0 -221
- data/lib/rhales/hydrator.rb +0 -141
- data/lib/rhales/parsers/handlebars-grammar-review.txt +0 -39
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
<!-- examples/dashboard-with-charts.rue -->
|
|
2
|
+
|
|
3
|
+
<!--
|
|
4
|
+
Example: Dashboard with Complex Data Hydration
|
|
5
|
+
|
|
6
|
+
This demonstrates:
|
|
7
|
+
- Complex nested data structures with Zod v4 schema
|
|
8
|
+
- Arrays and objects for chart data
|
|
9
|
+
- Conditional rendering based on data availability
|
|
10
|
+
- Integration with client-side charting libraries
|
|
11
|
+
- Proper separation of client vs server data
|
|
12
|
+
-->
|
|
13
|
+
|
|
14
|
+
<schema lang="js-zod" window="dashboardData">
|
|
15
|
+
const schema = z.object({
|
|
16
|
+
user: z.object({
|
|
17
|
+
id: z.number(),
|
|
18
|
+
name: z.string(),
|
|
19
|
+
role: z.enum(['admin', 'user', 'guest'])
|
|
20
|
+
}),
|
|
21
|
+
stats: z.object({
|
|
22
|
+
totalUsers: z.number(),
|
|
23
|
+
activeUsers: z.number(),
|
|
24
|
+
revenue: z.number(),
|
|
25
|
+
growth: z.number()
|
|
26
|
+
}),
|
|
27
|
+
chartData: z.object({
|
|
28
|
+
labels: z.array(z.string()),
|
|
29
|
+
datasets: z.array(z.object({
|
|
30
|
+
label: z.string(),
|
|
31
|
+
data: z.array(z.number()),
|
|
32
|
+
backgroundColor: z.string().optional(),
|
|
33
|
+
borderColor: z.string().optional()
|
|
34
|
+
}))
|
|
35
|
+
}),
|
|
36
|
+
recentActivity: z.array(z.object({
|
|
37
|
+
id: z.number(),
|
|
38
|
+
user: z.string(),
|
|
39
|
+
action: z.string(),
|
|
40
|
+
timestamp: z.number()
|
|
41
|
+
})),
|
|
42
|
+
permissions: z.object({
|
|
43
|
+
canEdit: z.boolean(),
|
|
44
|
+
canDelete: z.boolean(),
|
|
45
|
+
canExport: z.boolean()
|
|
46
|
+
})
|
|
47
|
+
});
|
|
48
|
+
</schema>
|
|
49
|
+
|
|
50
|
+
<template>
|
|
51
|
+
<!DOCTYPE html>
|
|
52
|
+
<html lang="{{request.locale}}">
|
|
53
|
+
<head>
|
|
54
|
+
<meta charset="utf-8">
|
|
55
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
56
|
+
<title>{{server.pageTitle}}</title>
|
|
57
|
+
<style>
|
|
58
|
+
body { font-family: system-ui, sans-serif; margin: 0; padding: 20px; background: #f5f5f5; }
|
|
59
|
+
.header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 2rem; }
|
|
60
|
+
.stats-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 1rem; margin-bottom: 2rem; }
|
|
61
|
+
.stat-card { background: white; padding: 1.5rem; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,0.1); }
|
|
62
|
+
.stat-value { font-size: 2rem; font-weight: bold; color: #0066cc; }
|
|
63
|
+
.stat-label { color: #666; margin-top: 0.5rem; }
|
|
64
|
+
.chart-container { background: white; padding: 1.5rem; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,0.1); margin-bottom: 2rem; }
|
|
65
|
+
.activity-list { background: white; padding: 1.5rem; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,0.1); }
|
|
66
|
+
.activity-item { padding: 1rem; border-bottom: 1px solid #eee; }
|
|
67
|
+
.activity-item:last-child { border-bottom: none; }
|
|
68
|
+
.actions { display: flex; gap: 0.5rem; }
|
|
69
|
+
.btn { padding: 0.5rem 1rem; border: none; border-radius: 4px; cursor: pointer; font-size: 0.875rem; }
|
|
70
|
+
.btn-primary { background: #0066cc; color: white; }
|
|
71
|
+
.btn-danger { background: #dc3545; color: white; }
|
|
72
|
+
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
|
73
|
+
</style>
|
|
74
|
+
<!-- Chart.js for visualization -->
|
|
75
|
+
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
|
|
76
|
+
</head>
|
|
77
|
+
<body>
|
|
78
|
+
<div class="header">
|
|
79
|
+
<div>
|
|
80
|
+
<h1>{{server.pageTitle}}</h1>
|
|
81
|
+
<p>Welcome back, {{client.user.name}} ({{client.user.role}})</p>
|
|
82
|
+
</div>
|
|
83
|
+
<div class="actions">
|
|
84
|
+
{{#if client.permissions.canExport}}
|
|
85
|
+
<button class="btn btn-primary" onclick="exportData()">Export Data</button>
|
|
86
|
+
{{/if}}
|
|
87
|
+
</div>
|
|
88
|
+
</div>
|
|
89
|
+
|
|
90
|
+
<!-- Stats Overview -->
|
|
91
|
+
<div class="stats-grid">
|
|
92
|
+
<div class="stat-card">
|
|
93
|
+
<div class="stat-value">{{client.stats.totalUsers}}</div>
|
|
94
|
+
<div class="stat-label">Total Users</div>
|
|
95
|
+
</div>
|
|
96
|
+
<div class="stat-card">
|
|
97
|
+
<div class="stat-value">{{client.stats.activeUsers}}</div>
|
|
98
|
+
<div class="stat-label">Active Users</div>
|
|
99
|
+
</div>
|
|
100
|
+
<div class="stat-card">
|
|
101
|
+
<div class="stat-value">${{client.stats.revenue}}</div>
|
|
102
|
+
<div class="stat-label">Revenue</div>
|
|
103
|
+
</div>
|
|
104
|
+
<div class="stat-card">
|
|
105
|
+
<div class="stat-value">{{client.stats.growth}}%</div>
|
|
106
|
+
<div class="stat-label">Growth</div>
|
|
107
|
+
</div>
|
|
108
|
+
</div>
|
|
109
|
+
|
|
110
|
+
<!-- Chart -->
|
|
111
|
+
<div class="chart-container">
|
|
112
|
+
<h2>Activity Over Time</h2>
|
|
113
|
+
<canvas id="activityChart"></canvas>
|
|
114
|
+
</div>
|
|
115
|
+
|
|
116
|
+
<!-- Recent Activity -->
|
|
117
|
+
<div class="activity-list">
|
|
118
|
+
<h2>Recent Activity</h2>
|
|
119
|
+
{{#if client.recentActivity}}
|
|
120
|
+
{{#each client.recentActivity}}
|
|
121
|
+
<div class="activity-item">
|
|
122
|
+
<strong>{{user}}</strong> {{action}}
|
|
123
|
+
<span style="color: #666; font-size: 0.875rem;">
|
|
124
|
+
({{@index}} items ago)
|
|
125
|
+
</span>
|
|
126
|
+
{{#if ../client.permissions.canDelete}}
|
|
127
|
+
<button class="btn btn-danger" onclick="deleteActivity({{id}})">Delete</button>
|
|
128
|
+
{{/if}}
|
|
129
|
+
</div>
|
|
130
|
+
{{/each}}
|
|
131
|
+
{{else}}
|
|
132
|
+
<p>No recent activity</p>
|
|
133
|
+
{{/if}}
|
|
134
|
+
</div>
|
|
135
|
+
|
|
136
|
+
<!-- Client-side JavaScript -->
|
|
137
|
+
<script nonce="{{request.nonce}}">
|
|
138
|
+
// Access hydrated dashboard data
|
|
139
|
+
const data = window.dashboardData;
|
|
140
|
+
|
|
141
|
+
// Initialize chart with hydrated data
|
|
142
|
+
const ctx = document.getElementById('activityChart').getContext('2d');
|
|
143
|
+
const chart = new Chart(ctx, {
|
|
144
|
+
type: 'line',
|
|
145
|
+
data: {
|
|
146
|
+
labels: data.chartData.labels,
|
|
147
|
+
datasets: data.chartData.datasets
|
|
148
|
+
},
|
|
149
|
+
options: {
|
|
150
|
+
responsive: true,
|
|
151
|
+
plugins: {
|
|
152
|
+
legend: { position: 'top' }
|
|
153
|
+
},
|
|
154
|
+
scales: {
|
|
155
|
+
y: { beginAtZero: true }
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
// Export function (only available if user has permission)
|
|
161
|
+
function exportData() {
|
|
162
|
+
const csv = generateCSV(data.recentActivity);
|
|
163
|
+
downloadCSV(csv, 'dashboard-export.csv');
|
|
164
|
+
console.log('Data exported by user:', data.user.name);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function generateCSV(activities) {
|
|
168
|
+
const headers = ['ID', 'User', 'Action', 'Timestamp'];
|
|
169
|
+
const rows = activities.map(a => [a.id, a.user, a.action, a.timestamp]);
|
|
170
|
+
return [headers, ...rows].map(row => row.join(',')).join('\n');
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function downloadCSV(csv, filename) {
|
|
174
|
+
const blob = new Blob([csv], { type: 'text/csv' });
|
|
175
|
+
const url = URL.createObjectURL(blob);
|
|
176
|
+
const a = document.createElement('a');
|
|
177
|
+
a.href = url;
|
|
178
|
+
a.download = filename;
|
|
179
|
+
a.click();
|
|
180
|
+
URL.revokeObjectURL(url);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Delete function (only available if user has permission)
|
|
184
|
+
function deleteActivity(id) {
|
|
185
|
+
if (!data.permissions.canDelete) {
|
|
186
|
+
alert('You do not have permission to delete activities');
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (confirm('Are you sure you want to delete this activity?')) {
|
|
191
|
+
fetch(`/api/activities/${id}`, {
|
|
192
|
+
method: 'DELETE',
|
|
193
|
+
headers: {
|
|
194
|
+
'Content-Type': 'application/json',
|
|
195
|
+
'X-CSRF-Token': '{{request.csrf_token}}'
|
|
196
|
+
}
|
|
197
|
+
})
|
|
198
|
+
.then(r => r.json())
|
|
199
|
+
.then(result => {
|
|
200
|
+
console.log('Activity deleted:', id);
|
|
201
|
+
location.reload();
|
|
202
|
+
})
|
|
203
|
+
.catch(err => console.error('Delete failed:', err));
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
</script>
|
|
207
|
+
</body>
|
|
208
|
+
</html>
|
|
209
|
+
</template>
|
|
210
|
+
|
|
211
|
+
<logic>
|
|
212
|
+
# Dashboard with Charts Example
|
|
213
|
+
#
|
|
214
|
+
# Backend Usage (Ruby):
|
|
215
|
+
#
|
|
216
|
+
# view = Rhales::View.new(
|
|
217
|
+
# request,
|
|
218
|
+
# client: {
|
|
219
|
+
# user: {
|
|
220
|
+
# id: current_user.id,
|
|
221
|
+
# name: current_user.name,
|
|
222
|
+
# role: current_user.role
|
|
223
|
+
# },
|
|
224
|
+
# stats: {
|
|
225
|
+
# totalUsers: User.count,
|
|
226
|
+
# activeUsers: User.active.count,
|
|
227
|
+
# revenue: Order.total_revenue,
|
|
228
|
+
# growth: Analytics.growth_percentage
|
|
229
|
+
# },
|
|
230
|
+
# chartData: {
|
|
231
|
+
# labels: last_7_days.map(&:to_s),
|
|
232
|
+
# datasets: [
|
|
233
|
+
# {
|
|
234
|
+
# label: 'Active Users',
|
|
235
|
+
# data: last_7_days.map { |date| User.active_on(date).count },
|
|
236
|
+
# backgroundColor: 'rgba(54, 162, 235, 0.2)',
|
|
237
|
+
# borderColor: 'rgba(54, 162, 235, 1)'
|
|
238
|
+
# }
|
|
239
|
+
# ]
|
|
240
|
+
# },
|
|
241
|
+
# recentActivity: Activity.recent(10).map { |a|
|
|
242
|
+
# {
|
|
243
|
+
# id: a.id,
|
|
244
|
+
# user: a.user.name,
|
|
245
|
+
# action: a.action_description,
|
|
246
|
+
# timestamp: a.created_at.to_i
|
|
247
|
+
# }
|
|
248
|
+
# },
|
|
249
|
+
# permissions: {
|
|
250
|
+
# canEdit: current_user.can?(:edit),
|
|
251
|
+
# canDelete: current_user.can?(:delete),
|
|
252
|
+
# canExport: current_user.can?(:export)
|
|
253
|
+
# }
|
|
254
|
+
# },
|
|
255
|
+
# server: {
|
|
256
|
+
# pageTitle: 'Admin Dashboard',
|
|
257
|
+
# # Server-only data (not sent to client)
|
|
258
|
+
# internalNotes: 'Premium customer - handle with care',
|
|
259
|
+
# auditLog: AuditLog.for_user(current_user)
|
|
260
|
+
# }
|
|
261
|
+
# )
|
|
262
|
+
#
|
|
263
|
+
# html = view.render('dashboard-with-charts')
|
|
264
|
+
#
|
|
265
|
+
# Key Features:
|
|
266
|
+
# - Complex nested data validated with Zod schema
|
|
267
|
+
# - Permission-based UI rendering (buttons only shown if allowed)
|
|
268
|
+
# - Client-side chart initialization with hydrated data
|
|
269
|
+
# - CSRF protection for DELETE requests
|
|
270
|
+
# - Clear separation: sensitive audit logs stay in server layer
|
|
271
|
+
</logic>
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
<!-- examples/form-with-validation.rue -->
|
|
2
|
+
|
|
3
|
+
<!--
|
|
4
|
+
Example: Form with CSRF Protection and Client-Side Validation
|
|
5
|
+
|
|
6
|
+
This demonstrates:
|
|
7
|
+
- CSRF token handling for secure form submissions
|
|
8
|
+
- Schema validation for form configuration
|
|
9
|
+
- Conditional rendering based on authentication
|
|
10
|
+
- Client-side validation with hydrated data
|
|
11
|
+
-->
|
|
12
|
+
|
|
13
|
+
<schema lang="js-zod" window="formData">
|
|
14
|
+
const schema = z.object({
|
|
15
|
+
formConfig: z.object({
|
|
16
|
+
action: z.string().url(),
|
|
17
|
+
method: z.enum(['GET', 'POST', 'PUT', 'PATCH', 'DELETE']),
|
|
18
|
+
maxLength: z.number(),
|
|
19
|
+
required: z.array(z.string())
|
|
20
|
+
}),
|
|
21
|
+
user: z.object({
|
|
22
|
+
name: z.string(),
|
|
23
|
+
email: z.string().email()
|
|
24
|
+
}).nullable(),
|
|
25
|
+
validationMessages: z.object({
|
|
26
|
+
required: z.string(),
|
|
27
|
+
email: z.string(),
|
|
28
|
+
maxLength: z.string()
|
|
29
|
+
})
|
|
30
|
+
});
|
|
31
|
+
</schema>
|
|
32
|
+
|
|
33
|
+
<template>
|
|
34
|
+
<!DOCTYPE html>
|
|
35
|
+
<html lang="{{request.locale}}">
|
|
36
|
+
<head>
|
|
37
|
+
<meta charset="utf-8">
|
|
38
|
+
<title>{{server.pageTitle}}</title>
|
|
39
|
+
<style>
|
|
40
|
+
.form-group { margin-bottom: 1rem; }
|
|
41
|
+
.error { color: red; font-size: 0.875rem; }
|
|
42
|
+
label { display: block; margin-bottom: 0.25rem; font-weight: bold; }
|
|
43
|
+
input, textarea { width: 100%; padding: 0.5rem; }
|
|
44
|
+
button { padding: 0.5rem 1rem; background: #0066cc; color: white; border: none; cursor: pointer; }
|
|
45
|
+
button:hover { background: #0052a3; }
|
|
46
|
+
</style>
|
|
47
|
+
</head>
|
|
48
|
+
<body>
|
|
49
|
+
<h1>{{server.pageTitle}}</h1>
|
|
50
|
+
|
|
51
|
+
{{#if request.authenticated?}}
|
|
52
|
+
<form id="contact-form" action="{{client.formConfig.action}}" method="{{client.formConfig.method}}">
|
|
53
|
+
<!-- CSRF protection -->
|
|
54
|
+
<input type="hidden" name="_csrf" value="{{request.csrf_token}}">
|
|
55
|
+
|
|
56
|
+
<div class="form-group">
|
|
57
|
+
<label for="name">Name *</label>
|
|
58
|
+
<input
|
|
59
|
+
type="text"
|
|
60
|
+
id="name"
|
|
61
|
+
name="name"
|
|
62
|
+
value="{{client.user.name}}"
|
|
63
|
+
required
|
|
64
|
+
maxlength="{{client.formConfig.maxLength}}">
|
|
65
|
+
<span class="error" id="name-error"></span>
|
|
66
|
+
</div>
|
|
67
|
+
|
|
68
|
+
<div class="form-group">
|
|
69
|
+
<label for="email">Email *</label>
|
|
70
|
+
<input
|
|
71
|
+
type="email"
|
|
72
|
+
id="email"
|
|
73
|
+
name="email"
|
|
74
|
+
value="{{client.user.email}}"
|
|
75
|
+
required>
|
|
76
|
+
<span class="error" id="email-error"></span>
|
|
77
|
+
</div>
|
|
78
|
+
|
|
79
|
+
<div class="form-group">
|
|
80
|
+
<label for="message">Message *</label>
|
|
81
|
+
<textarea
|
|
82
|
+
id="message"
|
|
83
|
+
name="message"
|
|
84
|
+
rows="5"
|
|
85
|
+
required
|
|
86
|
+
maxlength="{{client.formConfig.maxLength}}"></textarea>
|
|
87
|
+
<span class="error" id="message-error"></span>
|
|
88
|
+
</div>
|
|
89
|
+
|
|
90
|
+
<button type="submit">Send Message</button>
|
|
91
|
+
</form>
|
|
92
|
+
|
|
93
|
+
<!-- Client-side validation using Zod -->
|
|
94
|
+
<script nonce="{{request.nonce}}">
|
|
95
|
+
const form = document.getElementById('contact-form');
|
|
96
|
+
const config = window.formData.formConfig;
|
|
97
|
+
const messages = window.formData.validationMessages;
|
|
98
|
+
|
|
99
|
+
// Define Zod schema for form validation
|
|
100
|
+
const formSchema = z.object({
|
|
101
|
+
name: z.string()
|
|
102
|
+
.min(1, messages.required)
|
|
103
|
+
.max(config.maxLength, messages.maxLength.replace('{max}', config.maxLength)),
|
|
104
|
+
email: z.string()
|
|
105
|
+
.min(1, messages.required)
|
|
106
|
+
.email(messages.email),
|
|
107
|
+
message: z.string()
|
|
108
|
+
.min(1, messages.required)
|
|
109
|
+
.max(config.maxLength, messages.maxLength.replace('{max}', config.maxLength))
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
form.addEventListener('submit', (e) => {
|
|
113
|
+
// Clear previous errors
|
|
114
|
+
document.querySelectorAll('.error').forEach(el => el.textContent = '');
|
|
115
|
+
|
|
116
|
+
// Collect form data
|
|
117
|
+
const formData = {
|
|
118
|
+
name: form.elements['name'].value,
|
|
119
|
+
email: form.elements['email'].value,
|
|
120
|
+
message: form.elements['message'].value
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
// Validate using Zod
|
|
124
|
+
const result = formSchema.safeParse(formData);
|
|
125
|
+
|
|
126
|
+
if (!result.success) {
|
|
127
|
+
e.preventDefault();
|
|
128
|
+
|
|
129
|
+
// Display validation errors
|
|
130
|
+
result.error.issues.forEach(issue => {
|
|
131
|
+
const fieldName = issue.path[0];
|
|
132
|
+
const errorElement = document.getElementById(fieldName + '-error');
|
|
133
|
+
if (errorElement) {
|
|
134
|
+
errorElement.textContent = issue.message;
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
</script>
|
|
140
|
+
{{else}}
|
|
141
|
+
<p>Please <a href="/login">log in</a> to submit the form.</p>
|
|
142
|
+
{{/if}}
|
|
143
|
+
</body>
|
|
144
|
+
</html>
|
|
145
|
+
</template>
|
|
146
|
+
|
|
147
|
+
<logic>
|
|
148
|
+
# Form with Validation Example
|
|
149
|
+
#
|
|
150
|
+
# Backend Usage (Ruby):
|
|
151
|
+
#
|
|
152
|
+
# view = Rhales::View.new(
|
|
153
|
+
# request,
|
|
154
|
+
# client: {
|
|
155
|
+
# formConfig: {
|
|
156
|
+
# action: '/api/contact',
|
|
157
|
+
# method: 'POST',
|
|
158
|
+
# maxLength: 500,
|
|
159
|
+
# required: ['name', 'email', 'message']
|
|
160
|
+
# },
|
|
161
|
+
# user: current_user ? { name: current_user.name, email: current_user.email } : nil,
|
|
162
|
+
# validationMessages: {
|
|
163
|
+
# required: 'This field is required',
|
|
164
|
+
# email: 'Please enter a valid email address',
|
|
165
|
+
# maxLength: 'Maximum length is {max} characters'
|
|
166
|
+
# }
|
|
167
|
+
# },
|
|
168
|
+
# server: {
|
|
169
|
+
# pageTitle: 'Contact Us'
|
|
170
|
+
# }
|
|
171
|
+
# )
|
|
172
|
+
#
|
|
173
|
+
# html = view.render('form-with-validation')
|
|
174
|
+
#
|
|
175
|
+
# Key Security Features:
|
|
176
|
+
# - CSRF token automatically provided via request.csrf_token
|
|
177
|
+
# - CSP nonce for inline scripts via request.nonce
|
|
178
|
+
# - Schema validation ensures form config matches expected structure
|
|
179
|
+
# - Only safe, public user data (name, email) is sent to client
|
|
180
|
+
</logic>
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
<!-- examples/simple-page.rue -->
|
|
2
|
+
|
|
3
|
+
<!--
|
|
4
|
+
Example: Simple Page - Minimal Rhales Example
|
|
5
|
+
|
|
6
|
+
This is the simplest possible Rhales template demonstrating:
|
|
7
|
+
- Basic schema definition with Zod v4
|
|
8
|
+
- Simple template with variable interpolation
|
|
9
|
+
- Three-layer context access (request, server, client)
|
|
10
|
+
|
|
11
|
+
Perfect for beginners learning Rhales fundamentals.
|
|
12
|
+
-->
|
|
13
|
+
|
|
14
|
+
<schema lang="js-zod" window="pageData">
|
|
15
|
+
const schema = z.object({
|
|
16
|
+
message: z.string(),
|
|
17
|
+
count: z.number()
|
|
18
|
+
});
|
|
19
|
+
</schema>
|
|
20
|
+
|
|
21
|
+
<template>
|
|
22
|
+
<!DOCTYPE html>
|
|
23
|
+
<html lang="{{request.locale}}">
|
|
24
|
+
<head>
|
|
25
|
+
<meta charset="utf-8">
|
|
26
|
+
<title>{{server.pageTitle}}</title>
|
|
27
|
+
</head>
|
|
28
|
+
<body>
|
|
29
|
+
<h1>{{client.message}}</h1>
|
|
30
|
+
<p>Count: {{client.count}}</p>
|
|
31
|
+
|
|
32
|
+
<!-- Access hydrated data in client-side JavaScript -->
|
|
33
|
+
<script nonce="{{request.nonce}}">
|
|
34
|
+
console.log('Message:', window.pageData.message);
|
|
35
|
+
console.log('Count:', window.pageData.count);
|
|
36
|
+
</script>
|
|
37
|
+
</body>
|
|
38
|
+
</html>
|
|
39
|
+
</template>
|
|
40
|
+
|
|
41
|
+
<logic>
|
|
42
|
+
# Simple Page Example
|
|
43
|
+
#
|
|
44
|
+
# Backend Usage (Ruby):
|
|
45
|
+
#
|
|
46
|
+
# view = Rhales::View.new(
|
|
47
|
+
# request,
|
|
48
|
+
# client: {
|
|
49
|
+
# message: 'Hello, Rhales!',
|
|
50
|
+
# count: 42
|
|
51
|
+
# },
|
|
52
|
+
# server: {
|
|
53
|
+
# pageTitle: 'Simple Page Example'
|
|
54
|
+
# }
|
|
55
|
+
# )
|
|
56
|
+
#
|
|
57
|
+
# html = view.render('simple-page')
|
|
58
|
+
#
|
|
59
|
+
# The client data is automatically validated against the schema
|
|
60
|
+
# and serialized to window.pageData for client-side JavaScript access.
|
|
61
|
+
</logic>
|
data/examples/vue.rue
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
<!-- examples/vue.rue -->
|
|
2
|
+
|
|
3
|
+
<!--
|
|
4
|
+
Example: Vue.js SPA Layout with Server-Side Rendering + Client Hydration
|
|
5
|
+
|
|
6
|
+
This demonstrates Rhales' core strength: seamless server-to-SPA handoff.
|
|
7
|
+
|
|
8
|
+
The <schema> section defines a Zod v4 schema in plain JavaScript that specifies
|
|
9
|
+
exactly what your Vue app receives. The window variable name is configurable via
|
|
10
|
+
the window="..." attribute (e.g., window="appState" → window.appState).
|
|
11
|
+
|
|
12
|
+
The <template> provides SEO-friendly server-rendered HTML that Vue can mount onto
|
|
13
|
+
for client-side functionality.
|
|
14
|
+
|
|
15
|
+
Key features demonstrated:
|
|
16
|
+
- Schema validation with Zod v4
|
|
17
|
+
- Automatic JSON hydration script generation
|
|
18
|
+
- Conditional asset loading (dev vs production)
|
|
19
|
+
- CSP-compliant nonce handling
|
|
20
|
+
- Three-layer context access (request, server, client)
|
|
21
|
+
-->
|
|
22
|
+
|
|
23
|
+
<schema lang="js-zod" window="__ONETIME_STATE__">
|
|
24
|
+
const schema = z.object({
|
|
25
|
+
ui: z.object({
|
|
26
|
+
theme: z.string(),
|
|
27
|
+
locale: z.string()
|
|
28
|
+
}),
|
|
29
|
+
authentication: z.object({
|
|
30
|
+
authenticated: z.boolean(),
|
|
31
|
+
userId: z.string().nullable()
|
|
32
|
+
}),
|
|
33
|
+
user: z.object({
|
|
34
|
+
name: z.string(),
|
|
35
|
+
email: z.string(),
|
|
36
|
+
accountSince: z.number().nullable()
|
|
37
|
+
}).nullable(),
|
|
38
|
+
features: z.object({
|
|
39
|
+
darkMode: z.boolean(),
|
|
40
|
+
notifications: z.boolean()
|
|
41
|
+
}),
|
|
42
|
+
apiBaseUrl: z.string().url()
|
|
43
|
+
});
|
|
44
|
+
</schema>
|
|
45
|
+
|
|
46
|
+
<template>
|
|
47
|
+
<!doctype html>
|
|
48
|
+
<html lang="{{client.ui.locale}}" class="{{client.ui.theme}}">
|
|
49
|
+
<head>
|
|
50
|
+
<meta charset="utf-8">
|
|
51
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
52
|
+
<title>{{server.pageTitle}}</title>
|
|
53
|
+
|
|
54
|
+
<!-- Dark mode detection (runs before page render to prevent FOUC) -->
|
|
55
|
+
<script nonce="{{request.nonce}}">
|
|
56
|
+
if (localStorage.theme === 'dark' || (!('theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
|
|
57
|
+
document.documentElement.classList.add('dark')
|
|
58
|
+
} else {
|
|
59
|
+
document.documentElement.classList.remove('dark')
|
|
60
|
+
}
|
|
61
|
+
</script>
|
|
62
|
+
|
|
63
|
+
{{#if server.viteAssetsHtml}}
|
|
64
|
+
{{{server.viteAssetsHtml}}}
|
|
65
|
+
{{/if}}
|
|
66
|
+
</head>
|
|
67
|
+
|
|
68
|
+
<body class="font-serif bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100">
|
|
69
|
+
<div id="app">
|
|
70
|
+
<!-- Server-rendered placeholder for SEO/initial load -->
|
|
71
|
+
{{#if client.authentication.authenticated}}
|
|
72
|
+
<div class="loading-state">
|
|
73
|
+
<p>Welcome back, {{client.user.name}}!</p>
|
|
74
|
+
<p>Loading your dashboard...</p>
|
|
75
|
+
</div>
|
|
76
|
+
{{else}}
|
|
77
|
+
<div class="landing-page">
|
|
78
|
+
<h1>Welcome to Our App</h1>
|
|
79
|
+
<p>Please log in to continue</p>
|
|
80
|
+
</div>
|
|
81
|
+
{{/if}}
|
|
82
|
+
</div>
|
|
83
|
+
|
|
84
|
+
<!-- Client hydration data is automatically injected by Rhales hydrator -->
|
|
85
|
+
<!-- Result: window.__ONETIME_STATE__ = { ui: {...}, authentication: {...}, ... } -->
|
|
86
|
+
|
|
87
|
+
<!-- Vue application bootstrap -->
|
|
88
|
+
{{#if server.frontendHost}}
|
|
89
|
+
<!-- Development: Vite dev server -->
|
|
90
|
+
<script nonce="{{request.nonce}}" type="module" src="{{server.frontendHost}}/src/main.ts"></script>
|
|
91
|
+
{{else}}
|
|
92
|
+
<!-- Production: Built assets -->
|
|
93
|
+
<script nonce="{{request.nonce}}" type="module" src="/assets/main.js"></script>
|
|
94
|
+
{{/if}}
|
|
95
|
+
</body>
|
|
96
|
+
</html>
|
|
97
|
+
</template>
|
|
98
|
+
|
|
99
|
+
<logic>
|
|
100
|
+
# Vue.js SPA Integration Example
|
|
101
|
+
#
|
|
102
|
+
# Backend Usage (Ruby):
|
|
103
|
+
#
|
|
104
|
+
# view = Rhales::View.new(
|
|
105
|
+
# request,
|
|
106
|
+
# client: {
|
|
107
|
+
# ui: { theme: user.theme || 'light', locale: I18n.locale },
|
|
108
|
+
# authentication: { authenticated: logged_in?, userId: current_user&.id },
|
|
109
|
+
# user: current_user&.to_client_data,
|
|
110
|
+
# features: { darkMode: true, notifications: true },
|
|
111
|
+
# apiBaseUrl: ENV['API_BASE_URL']
|
|
112
|
+
# },
|
|
113
|
+
# server: {
|
|
114
|
+
# pageTitle: 'My App',
|
|
115
|
+
# viteAssetsHtml: vite_javascript_tag('application'),
|
|
116
|
+
# frontendHost: ENV['VITE_DEV_SERVER_URL'] # nil in production
|
|
117
|
+
# }
|
|
118
|
+
# )
|
|
119
|
+
#
|
|
120
|
+
# html = view.render('vue')
|
|
121
|
+
#
|
|
122
|
+
# Frontend Usage (Vue 3):
|
|
123
|
+
#
|
|
124
|
+
# // src/main.ts
|
|
125
|
+
# import { createApp } from 'vue'
|
|
126
|
+
# import App from './App.vue'
|
|
127
|
+
#
|
|
128
|
+
# // Access hydrated state (window variable name matches <schema window="...">)
|
|
129
|
+
# const state = window.__ONETIME_STATE__
|
|
130
|
+
#
|
|
131
|
+
# const app = createApp(App, {
|
|
132
|
+
# initialState: state
|
|
133
|
+
# })
|
|
134
|
+
#
|
|
135
|
+
# app.mount('#app')
|
|
136
|
+
</logic>
|