zephyr_rb 1.0.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/README.md +569 -0
- data/dist/zephyrRB.js +3511 -0
- data/lib/cli.rb +228 -0
- data/lib/version.rb +7 -0
- metadata +39 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: e0fc1f31f4d354cdcf713cae10fbb1ec76a8148e76017014fe01413d0be3c581
|
4
|
+
data.tar.gz: 1ca30c3b55b992b9b9ad63d6e97aba12a6b1b717acc83cb733cf58fcf90f123c
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 9d32ac92ee54d0877dc0511dccd3ab2cd4be6ec21a503122b5f39be7f7a99975248474f0c89b43e8d3db0c5ca84783ae6d8efe43dd0be8838d49374c17df9680
|
7
|
+
data.tar.gz: 1d6142bf99d6c2cf8da4cbc42595cdb878444bd8ec21b4ee244b02acdb0894d778066f34d54ea685cff440fdff5d610c5e1e0644cc9ea18e8120c9533b4166dc
|
data/README.md
ADDED
@@ -0,0 +1,569 @@
|
|
1
|
+
# 💎 Zephyr WASM
|
2
|
+
|
3
|
+
**Build reactive web components using Ruby and WebAssembly**
|
4
|
+
|
5
|
+
Zephyr WASM is a lightweight framework for creating interactive web components using Ruby, compiled to WebAssembly and running entirely in the browser. Write your UI logic in Ruby with a declarative template syntax, and let the browser handle the rest.
|
6
|
+
|
7
|
+
## ✨ Features
|
8
|
+
|
9
|
+
- **Pure Ruby** - Write components in idiomatic Ruby
|
10
|
+
- **Reactive State** - Automatic re-rendering on state changes
|
11
|
+
- **Web Components** - Standard custom elements that work anywhere
|
12
|
+
- **Zero Build Step** - Load Ruby files directly in the browser
|
13
|
+
- **Lifecycle Hooks** - `on_connect` and `on_disconnect` callbacks
|
14
|
+
- **Event Handling** - First-class support for DOM events
|
15
|
+
- **Template DSL** - Clean, declarative component templates
|
16
|
+
|
17
|
+
## 🚀 Build ZephyrRB
|
18
|
+
|
19
|
+
run the following script:
|
20
|
+
```bash
|
21
|
+
ruby build.rb
|
22
|
+
```
|
23
|
+
this will generate the zephyrRB.js file
|
24
|
+
|
25
|
+
## 🚀 Quick Start
|
26
|
+
|
27
|
+
### 1. Serve Your Files
|
28
|
+
|
29
|
+
You need an HTTP server (ruby.wasm can't load files via `file://`):
|
30
|
+
|
31
|
+
```bash
|
32
|
+
# Python
|
33
|
+
python3 -m http.server 8000
|
34
|
+
|
35
|
+
# Ruby
|
36
|
+
ruby -run -ehttpd . -p8000
|
37
|
+
|
38
|
+
# Node
|
39
|
+
npx http-server -p 8000
|
40
|
+
```
|
41
|
+
|
42
|
+
### 2. Create Your HTML
|
43
|
+
|
44
|
+
```html
|
45
|
+
<!DOCTYPE html>
|
46
|
+
<html>
|
47
|
+
<head>
|
48
|
+
<title>My Zephyr App</title>
|
49
|
+
</head>
|
50
|
+
<body>
|
51
|
+
<!-- Use your components -->
|
52
|
+
<x-counter initial="5"></x-counter>
|
53
|
+
|
54
|
+
<!-- Load required items from ZephyrRB-->
|
55
|
+
<script src="/dist/zephyrRB.js"></script>
|
56
|
+
</body>
|
57
|
+
</html>
|
58
|
+
```
|
59
|
+
|
60
|
+
### 3. Define Components
|
61
|
+
|
62
|
+
Create `components.rb`:
|
63
|
+
|
64
|
+
```ruby
|
65
|
+
# Counter component
|
66
|
+
ZephyrWasm.component('x-counter') do
|
67
|
+
observed_attributes :initial
|
68
|
+
|
69
|
+
on_connect do
|
70
|
+
count = (self['initial'] || '0').to_i
|
71
|
+
set_state(:count, count)
|
72
|
+
end
|
73
|
+
|
74
|
+
template do |b|
|
75
|
+
comp = self
|
76
|
+
|
77
|
+
b.div(class: 'counter') do
|
78
|
+
b.button(on_click: ->(_) { comp.set_state(:count, comp.state[:count] - 1) }) do
|
79
|
+
b.text('-')
|
80
|
+
end
|
81
|
+
|
82
|
+
b.span { b.text(comp.state[:count]) }
|
83
|
+
|
84
|
+
b.button(on_click: ->(_) { comp.set_state(:count, comp.state[:count] + 1) }) do
|
85
|
+
b.text('+')
|
86
|
+
end
|
87
|
+
end
|
88
|
+
end
|
89
|
+
end
|
90
|
+
```
|
91
|
+
|
92
|
+
### 4. Open in Browser
|
93
|
+
|
94
|
+
Visit `http://localhost:8000` and see your component in action! 🎉
|
95
|
+
|
96
|
+
## 📦 File Structure
|
97
|
+
|
98
|
+
```
|
99
|
+
ZephyrRB/
|
100
|
+
├── src/
|
101
|
+
│ ├── browser.script.iife.js
|
102
|
+
│ ├── component.rb
|
103
|
+
│ ├── components.rb
|
104
|
+
│ ├── dom_builder.rb
|
105
|
+
│ ├── registry.rb
|
106
|
+
│ ├── zephyr-bridge.js
|
107
|
+
│ └── zephyr_wasm.rb
|
108
|
+
├── dist/
|
109
|
+
│ └── zephyrRB.js
|
110
|
+
├── lib/
|
111
|
+
│ ├── cli.rb
|
112
|
+
│ └── version.rb
|
113
|
+
├── README.md
|
114
|
+
├── zephyr_rb.gemspec
|
115
|
+
└── build.rb
|
116
|
+
```
|
117
|
+
|
118
|
+
## 📚 Component API
|
119
|
+
|
120
|
+
### Basic Structure
|
121
|
+
|
122
|
+
```ruby
|
123
|
+
ZephyrWasm.component('x-my-component') do
|
124
|
+
# Declare observed HTML attributes
|
125
|
+
observed_attributes :foo, :bar
|
126
|
+
|
127
|
+
# Lifecycle: called when component is added to DOM
|
128
|
+
on_connect do
|
129
|
+
# Initialize state
|
130
|
+
set_state(:count, 0)
|
131
|
+
end
|
132
|
+
|
133
|
+
# Lifecycle: called when component is removed from DOM
|
134
|
+
on_disconnect do
|
135
|
+
# Cleanup if needed
|
136
|
+
end
|
137
|
+
|
138
|
+
# Define your component's UI
|
139
|
+
template do |b|
|
140
|
+
comp = self # Capture component reference
|
141
|
+
|
142
|
+
b.div(class: 'my-component') do
|
143
|
+
b.h1 { b.text("Hello from Ruby!") }
|
144
|
+
end
|
145
|
+
end
|
146
|
+
end
|
147
|
+
```
|
148
|
+
|
149
|
+
### State Management
|
150
|
+
|
151
|
+
```ruby
|
152
|
+
# Set state (triggers re-render)
|
153
|
+
set_state(:key, value)
|
154
|
+
|
155
|
+
# Read state
|
156
|
+
state[:key]
|
157
|
+
|
158
|
+
# Multiple state updates
|
159
|
+
set_state(:count, 0)
|
160
|
+
set_state(:loading, false)
|
161
|
+
```
|
162
|
+
|
163
|
+
### Attributes
|
164
|
+
|
165
|
+
```ruby
|
166
|
+
# Read HTML attributes
|
167
|
+
value = self['data-id']
|
168
|
+
|
169
|
+
# Write HTML attributes
|
170
|
+
self['data-id'] = 'new-value'
|
171
|
+
|
172
|
+
# Observed attributes automatically update state
|
173
|
+
observed_attributes :user_id
|
174
|
+
|
175
|
+
on_connect do
|
176
|
+
# state[:user_id] is automatically set from the attribute
|
177
|
+
puts state[:user_id]
|
178
|
+
end
|
179
|
+
```
|
180
|
+
|
181
|
+
### Event Handlers
|
182
|
+
|
183
|
+
```ruby
|
184
|
+
template do |b|
|
185
|
+
comp = self
|
186
|
+
|
187
|
+
# Click handler
|
188
|
+
b.button(on_click: ->(_e) { comp.set_state(:clicked, true) }) do
|
189
|
+
b.text('Click me')
|
190
|
+
end
|
191
|
+
|
192
|
+
# Input handler
|
193
|
+
b.tag(:input,
|
194
|
+
type: 'text',
|
195
|
+
on_input: ->(e) { comp.set_state(:value, e[:target][:value].to_s) }
|
196
|
+
)
|
197
|
+
|
198
|
+
# Any DOM event works: on_change, on_submit, on_keydown, etc.
|
199
|
+
end
|
200
|
+
```
|
201
|
+
|
202
|
+
### Template DSL
|
203
|
+
|
204
|
+
```ruby
|
205
|
+
template do |b|
|
206
|
+
comp = self
|
207
|
+
|
208
|
+
# HTML elements (method name = tag name)
|
209
|
+
b.div(class: 'container') do
|
210
|
+
b.h1 { b.text('Title') }
|
211
|
+
b.p { b.text('Paragraph') }
|
212
|
+
end
|
213
|
+
|
214
|
+
# Attributes
|
215
|
+
b.div(id: 'main', class: 'active', data_value: '123')
|
216
|
+
|
217
|
+
# Generic tag method
|
218
|
+
b.tag(:input, type: 'text', placeholder: 'Enter text...')
|
219
|
+
|
220
|
+
# Text nodes
|
221
|
+
b.span { b.text('Hello') }
|
222
|
+
|
223
|
+
# Conditional rendering
|
224
|
+
b.render_if(comp.state[:show]) do
|
225
|
+
b.p { b.text('Visible!') }
|
226
|
+
end
|
227
|
+
|
228
|
+
# List rendering
|
229
|
+
b.render_each(comp.state[:items] || []) do |item|
|
230
|
+
b.li { b.text(item[:name]) }
|
231
|
+
end
|
232
|
+
|
233
|
+
# Boolean properties (checked, disabled, selected)
|
234
|
+
b.tag(:input, type: 'checkbox', checked: true)
|
235
|
+
|
236
|
+
# Inline styles
|
237
|
+
b.div(style: { color: 'red', font_size: '16px' })
|
238
|
+
end
|
239
|
+
```
|
240
|
+
|
241
|
+
## 🎯 Complete Examples
|
242
|
+
|
243
|
+
### Counter with Reset
|
244
|
+
|
245
|
+
```ruby
|
246
|
+
ZephyrWasm.component('x-counter') do
|
247
|
+
observed_attributes :initial
|
248
|
+
|
249
|
+
on_connect do
|
250
|
+
initial = (self['initial'] || '0').to_i
|
251
|
+
set_state(:count, initial)
|
252
|
+
set_state(:initial, initial)
|
253
|
+
end
|
254
|
+
|
255
|
+
template do |b|
|
256
|
+
comp = self
|
257
|
+
count = comp.state[:count] || 0
|
258
|
+
|
259
|
+
b.div(class: 'counter') do
|
260
|
+
b.button(on_click: ->(_) { comp.set_state(:count, count - 1) }) do
|
261
|
+
b.text('-')
|
262
|
+
end
|
263
|
+
|
264
|
+
b.span(class: 'count') { b.text(count) }
|
265
|
+
|
266
|
+
b.button(on_click: ->(_) { comp.set_state(:count, count + 1) }) do
|
267
|
+
b.text('+')
|
268
|
+
end
|
269
|
+
|
270
|
+
b.button(on_click: ->(_) { comp.set_state(:count, comp.state[:initial]) }) do
|
271
|
+
b.text('Reset')
|
272
|
+
end
|
273
|
+
end
|
274
|
+
end
|
275
|
+
end
|
276
|
+
```
|
277
|
+
|
278
|
+
### Toggle Button
|
279
|
+
|
280
|
+
```ruby
|
281
|
+
ZephyrWasm.component('x-toggle') do
|
282
|
+
observed_attributes :label, :checked
|
283
|
+
|
284
|
+
on_connect do
|
285
|
+
set_state(:checked, self['checked'] == 'true')
|
286
|
+
end
|
287
|
+
|
288
|
+
template do |b|
|
289
|
+
comp = self
|
290
|
+
is_checked = comp.state[:checked]
|
291
|
+
label = comp['label'] || 'Toggle'
|
292
|
+
|
293
|
+
b.button(
|
294
|
+
class: is_checked ? 'toggle active' : 'toggle',
|
295
|
+
on_click: ->(_) {
|
296
|
+
new_state = !comp.state[:checked]
|
297
|
+
comp.set_state(:checked, new_state)
|
298
|
+
comp['checked'] = new_state.to_s
|
299
|
+
|
300
|
+
# Dispatch custom event
|
301
|
+
event = JS.global[:CustomEvent].new(
|
302
|
+
'toggle-change',
|
303
|
+
{ bubbles: true, detail: { checked: new_state }.to_js }.to_js
|
304
|
+
)
|
305
|
+
comp.element.call(:dispatchEvent, event)
|
306
|
+
}
|
307
|
+
) do
|
308
|
+
b.text(label)
|
309
|
+
end
|
310
|
+
end
|
311
|
+
end
|
312
|
+
```
|
313
|
+
|
314
|
+
### Todo List
|
315
|
+
|
316
|
+
```ruby
|
317
|
+
ZephyrWasm.component('x-todo-list') do
|
318
|
+
on_connect do
|
319
|
+
set_state(:todos, [])
|
320
|
+
set_state(:input_value, '')
|
321
|
+
end
|
322
|
+
|
323
|
+
template do |b|
|
324
|
+
comp = self
|
325
|
+
|
326
|
+
b.div(class: 'todo-list') do
|
327
|
+
# Input section
|
328
|
+
b.div(class: 'input-group') do
|
329
|
+
b.tag(:input,
|
330
|
+
type: 'text',
|
331
|
+
placeholder: 'Enter a task...',
|
332
|
+
value: comp.state[:input_value] || '',
|
333
|
+
on_input: ->(e) { comp.set_state(:input_value, e[:target][:value].to_s) }
|
334
|
+
)
|
335
|
+
|
336
|
+
b.button(
|
337
|
+
on_click: ->(_) {
|
338
|
+
value = comp.state[:input_value]&.strip
|
339
|
+
if value && !value.empty?
|
340
|
+
todos = (comp.state[:todos] || []).dup
|
341
|
+
todos << {
|
342
|
+
id: JS.global[:Date].new.call(:getTime),
|
343
|
+
text: value,
|
344
|
+
done: false
|
345
|
+
}
|
346
|
+
comp.set_state(:todos, todos)
|
347
|
+
comp.set_state(:input_value, '')
|
348
|
+
end
|
349
|
+
}
|
350
|
+
) { b.text('Add') }
|
351
|
+
end
|
352
|
+
|
353
|
+
# Todo items
|
354
|
+
b.tag(:ul) do
|
355
|
+
todos = comp.state[:todos] || []
|
356
|
+
b.render_each(todos) do |todo|
|
357
|
+
b.tag(:li, class: todo[:done] ? 'done' : '') do
|
358
|
+
b.tag(:input,
|
359
|
+
type: 'checkbox',
|
360
|
+
checked: !!todo[:done],
|
361
|
+
on_change: ->(e) {
|
362
|
+
updated_todos = (comp.state[:todos] || []).map { |t|
|
363
|
+
t[:id] == todo[:id] ? { **t, done: !!e[:target][:checked] } : t
|
364
|
+
}
|
365
|
+
comp.set_state(:todos, updated_todos)
|
366
|
+
}
|
367
|
+
)
|
368
|
+
|
369
|
+
b.span { b.text(todo[:text]) }
|
370
|
+
|
371
|
+
b.button(
|
372
|
+
class: 'delete',
|
373
|
+
on_click: ->(_) {
|
374
|
+
filtered = (comp.state[:todos] || []).reject { |t| t[:id] == todo[:id] }
|
375
|
+
comp.set_state(:todos, filtered)
|
376
|
+
}
|
377
|
+
) { b.text('×') }
|
378
|
+
end
|
379
|
+
end
|
380
|
+
end
|
381
|
+
end
|
382
|
+
end
|
383
|
+
end
|
384
|
+
```
|
385
|
+
|
386
|
+
## 🔧 Advanced Usage
|
387
|
+
|
388
|
+
### Custom Events
|
389
|
+
|
390
|
+
```ruby
|
391
|
+
# Dispatch custom events from your component
|
392
|
+
event = JS.global[:CustomEvent].new(
|
393
|
+
'my-event',
|
394
|
+
{
|
395
|
+
bubbles: true,
|
396
|
+
detail: { foo: 'bar' }.to_js
|
397
|
+
}.to_js
|
398
|
+
)
|
399
|
+
element.call(:dispatchEvent, event)
|
400
|
+
```
|
401
|
+
|
402
|
+
### DOM Queries
|
403
|
+
|
404
|
+
```ruby
|
405
|
+
on_connect do
|
406
|
+
# Query inside component
|
407
|
+
button = query('.my-button')
|
408
|
+
|
409
|
+
# Query all
|
410
|
+
items = query_all('.item')
|
411
|
+
end
|
412
|
+
```
|
413
|
+
|
414
|
+
### Accessing the Element
|
415
|
+
|
416
|
+
```ruby
|
417
|
+
on_connect do
|
418
|
+
# Direct access to the DOM element
|
419
|
+
element[:id] = 'my-component'
|
420
|
+
element.call(:setAttribute, 'data-loaded', 'true')
|
421
|
+
end
|
422
|
+
```
|
423
|
+
|
424
|
+
### Working with JavaScript
|
425
|
+
|
426
|
+
```ruby
|
427
|
+
# Call JavaScript functions
|
428
|
+
JS.global[:console].call(:log, 'Hello from Ruby!')
|
429
|
+
|
430
|
+
# Access global objects
|
431
|
+
date = JS.global[:Date].new
|
432
|
+
timestamp = date.call(:getTime)
|
433
|
+
|
434
|
+
# Call methods on JS objects
|
435
|
+
element.call(:scrollIntoView)
|
436
|
+
```
|
437
|
+
|
438
|
+
## 🎨 Styling
|
439
|
+
|
440
|
+
Add CSS to your HTML:
|
441
|
+
|
442
|
+
```html
|
443
|
+
<style>
|
444
|
+
.counter {
|
445
|
+
display: flex;
|
446
|
+
gap: 1rem;
|
447
|
+
align-items: center;
|
448
|
+
}
|
449
|
+
|
450
|
+
.counter button {
|
451
|
+
padding: 0.5rem 1rem;
|
452
|
+
border: none;
|
453
|
+
border-radius: 4px;
|
454
|
+
background: #667eea;
|
455
|
+
color: white;
|
456
|
+
cursor: pointer;
|
457
|
+
}
|
458
|
+
|
459
|
+
.counter button:hover {
|
460
|
+
background: #5568d3;
|
461
|
+
}
|
462
|
+
</style>
|
463
|
+
```
|
464
|
+
|
465
|
+
## ⚠️ Important Notes
|
466
|
+
|
467
|
+
### Must Use HTTP Server
|
468
|
+
|
469
|
+
Ruby WASM cannot load files via `file://` protocol. Always serve your files:
|
470
|
+
|
471
|
+
```bash
|
472
|
+
python3 -m http.server 8000
|
473
|
+
```
|
474
|
+
|
475
|
+
### Component Names Must Include Hyphen
|
476
|
+
|
477
|
+
Custom element names require a hyphen:
|
478
|
+
|
479
|
+
```ruby
|
480
|
+
# ✅ Good
|
481
|
+
ZephyrWasm.component('x-counter')
|
482
|
+
ZephyrWasm.component('my-button')
|
483
|
+
|
484
|
+
# ❌ Bad
|
485
|
+
ZephyrWasm.component('counter') # Missing hyphen!
|
486
|
+
```
|
487
|
+
|
488
|
+
### Capture Component Reference in Templates
|
489
|
+
|
490
|
+
Always capture `self` as a local variable in templates:
|
491
|
+
|
492
|
+
```ruby
|
493
|
+
template do |b|
|
494
|
+
comp = self # ✅ Capture this!
|
495
|
+
|
496
|
+
b.button(on_click: ->(_) {
|
497
|
+
comp.set_state(:clicked, true) # Use comp, not self
|
498
|
+
})
|
499
|
+
end
|
500
|
+
```
|
501
|
+
|
502
|
+
### Event Handlers Return Procs
|
503
|
+
|
504
|
+
Event handlers should be Ruby procs that will be converted to JS:
|
505
|
+
|
506
|
+
```ruby
|
507
|
+
# ✅ Good
|
508
|
+
on_click: ->(_e) { comp.set_state(:count, 1) }
|
509
|
+
|
510
|
+
# ❌ Bad - don't call .to_js yourself
|
511
|
+
on_click: ->(_e) { comp.set_state(:count, 1) }.to_js
|
512
|
+
```
|
513
|
+
|
514
|
+
## 🐛 Troubleshooting
|
515
|
+
|
516
|
+
### Components Don't Appear
|
517
|
+
|
518
|
+
1. Check browser console for errors
|
519
|
+
2. Ensure you're using an HTTP server (not `file://`)
|
520
|
+
3. Verify all `.rb` files are in the same directory as `index.html`
|
521
|
+
4. Check Network tab for 404 errors
|
522
|
+
|
523
|
+
### "Component Already Registered" Warning
|
524
|
+
|
525
|
+
This happens if you reload without refreshing. It's harmless but you can add:
|
526
|
+
|
527
|
+
```ruby
|
528
|
+
ZephyrWasm::Registry.clear
|
529
|
+
```
|
530
|
+
|
531
|
+
### State Not Updating
|
532
|
+
|
533
|
+
Make sure you're using `set_state` and not modifying state directly:
|
534
|
+
|
535
|
+
```ruby
|
536
|
+
# ✅ Good
|
537
|
+
set_state(:count, state[:count] + 1)
|
538
|
+
|
539
|
+
# ❌ Bad - won't trigger re-render
|
540
|
+
state[:count] += 1
|
541
|
+
```
|
542
|
+
|
543
|
+
## 🏗️ How It Works
|
544
|
+
|
545
|
+
1. **Ruby Files Load**: Browser fetches your `.rb` files via `<script type="text/ruby" src="...">`
|
546
|
+
2. **Components Register**: Ruby code registers component metadata in `window.ZephyrWasmRegistry`
|
547
|
+
3. **Bridge Watches**: `zephyr-bridge.js` uses a Proxy to watch for new components
|
548
|
+
4. **Custom Elements Defined**: Bridge defines custom elements using `setTimeout()` to avoid nested VM calls
|
549
|
+
5. **Component Lifecycle**: When elements connect to DOM, Ruby component instances are created
|
550
|
+
6. **Reactive Rendering**: State changes trigger re-renders using DocumentFragment for efficiency
|
551
|
+
|
552
|
+
## 📄 License
|
553
|
+
|
554
|
+
MIT License - feel free to use in your projects!
|
555
|
+
|
556
|
+
## 🤝 Contributing
|
557
|
+
|
558
|
+
This is an experimental framework. Issues and pull requests welcome!
|
559
|
+
|
560
|
+
## 🙏 Credits
|
561
|
+
|
562
|
+
Built with:
|
563
|
+
- [ruby.wasm](https://github.com/ruby/ruby.wasm) - Ruby in the browser
|
564
|
+
- Web Components - Standard browser APIs
|
565
|
+
- Love for Ruby 💎
|
566
|
+
|
567
|
+
---
|
568
|
+
|
569
|
+
**Happy coding with Ruby and WebAssembly!** 🚀
|