hibiki_rails 0.1.0 → 0.2.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 +4 -4
- data/README.md +43 -212
- data/app/assets/javascripts/hibiki.js +25 -5
- data/lib/generators/hibiki/rails/phlex/phlex_generator.rb +1 -1
- data/lib/generators/hibiki/rails/phlex/templates/channel.rb.tt +1 -1
- data/lib/generators/hibiki/rails/phlex/templates/component.rb.tt +1 -1
- data/lib/generators/hibiki/rails/phlex/templates/island_component.rb.tt +3 -3
- data/lib/hibiki/rails/channel.rb +16 -0
- data/lib/hibiki/rails/helpers.rb +47 -0
- data/lib/hibiki/rails/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 62779990991fb78726bc665b7ad93a93dabee5b49892fa99f6486ad42dc41679
|
|
4
|
+
data.tar.gz: 9cac4fa0143ab6b5f031d9ea0d9d213a1505108e1aa06894773d59f8594b6023
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: bf6340bdef27fd3c320fff3a8d7fb90967d883783a965e61a90ccbae5d536b7bdcdfcba536d827089d3b389af1c07439ff7031ee11792c95a579752c8b235706
|
|
7
|
+
data.tar.gz: a9337451b21ecd955590683648f8a6d824bafa703ce096937387cff0fafaedf5ee5a959089558f7bf02037b00d39e40fd9fcab1bf3176cd976dcd1b957ab6443
|
data/README.md
CHANGED
|
@@ -1,246 +1,77 @@
|
|
|
1
1
|
# hibiki_rails
|
|
2
2
|
|
|
3
3
|
Rails glue for [hibiki](https://github.com/planetaska/hibiki):
|
|
4
|
-
connection-scoped signal graphs over ActionCable, pushing re-rendered HTML
|
|
5
|
-
to the page — either through Turbo Streams, or over the channel's own
|
|
6
|
-
subscription to the gem's packaged client (see "The packaged client").
|
|
4
|
+
connection-scoped signal graphs over ActionCable, pushing re-rendered HTML to the page — either through Turbo Streams, or over the channel's own subscription to the gem's packaged client (see [The JS client](https://planetaska.github.io/hibiki/the-js-client/)).
|
|
7
5
|
|
|
8
6
|
```
|
|
9
7
|
cable action arrives → mutate signals → effects render partials →
|
|
10
8
|
Turbo Streams broadcast → Turbo morphs the DOM
|
|
11
9
|
```
|
|
12
10
|
|
|
13
|
-
A graph lives per cable connection (in practice: per browser tab), built
|
|
14
|
-
when the channel subscribes and disposed when it unsubscribes. Effects
|
|
15
|
-
subscribe to whatever signals they read; when an action writes a signal,
|
|
16
|
-
exactly the affected effects re-render and broadcast.
|
|
11
|
+
A graph lives per cable connection (in practice: per browser tab), built when the channel subscribes and disposed when it unsubscribes. Effects subscribe to whatever signals they read; when an action writes a signal, exactly the affected effects re-render and broadcast.
|
|
17
12
|
|
|
18
|
-
|
|
19
|
-
be extracted to its own repository once hibiki 0.1.0 ships and this API
|
|
20
|
-
stabilizes. Rails >= 8.0 (what's tested), Ruby >= 3.4.
|
|
13
|
+
Supports Rails >= 7.1, Ruby >= 3.4.
|
|
21
14
|
|
|
22
|
-
##
|
|
15
|
+
## Rails quick start
|
|
16
|
+
|
|
17
|
+
### Installation
|
|
18
|
+
|
|
19
|
+
**Step 1** - Install the gem (`hibiki_rails` depends on the core `hibiki` gem)
|
|
23
20
|
|
|
24
21
|
```ruby
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
# Runs once on the graph's own thread, inside Hibiki.root.
|
|
29
|
-
def build_graph
|
|
30
|
-
@count = Hibiki::State.new(0)
|
|
31
|
-
@step = Hibiki::State.new(1)
|
|
32
|
-
doubled = Hibiki::Derived.new { @count.value * 2 }
|
|
33
|
-
|
|
34
|
-
Hibiki::Effect.new do
|
|
35
|
-
broadcast_replace target: "count", partial: "counter/count",
|
|
36
|
-
locals: { count: @count.value, doubled: doubled.value }
|
|
37
|
-
end
|
|
38
|
-
Hibiki::Effect.new do
|
|
39
|
-
broadcast_replace target: "step", partial: "counter/step",
|
|
40
|
-
locals: { step: @step.value }
|
|
41
|
-
end
|
|
42
|
-
end
|
|
43
|
-
|
|
44
|
-
# Actions are plain methods that touch signals directly: each one runs
|
|
45
|
-
# on the graph thread inside one Hibiki.batch, so N writes still mean
|
|
46
|
-
# one re-run per affected effect.
|
|
47
|
-
def increment = @count.value += @step.value
|
|
48
|
-
def burst = 10.times { @count.value += 1 }
|
|
49
|
-
end
|
|
22
|
+
# Gemfile
|
|
23
|
+
gem "hibiki"
|
|
24
|
+
gem "hibiki_rails"
|
|
50
25
|
```
|
|
51
26
|
|
|
52
|
-
|
|
53
|
-
matching stream:
|
|
27
|
+
Or `gem install hibiki hibiki_rails`.
|
|
54
28
|
|
|
55
|
-
|
|
56
|
-
<div data-controller="counter" data-counter-cid-value="<%= @cid %>">
|
|
57
|
-
<%= turbo_stream_from "counter", @cid %>
|
|
58
|
-
<%= render "count", count: 0, doubled: 0 %> <%# placeholder, see below %>
|
|
59
|
-
...
|
|
60
|
-
</div>
|
|
61
|
-
```
|
|
29
|
+
**Step 2** - Run the install generator
|
|
62
30
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
`cid`) to derive identity differently.
|
|
66
|
-
|
|
67
|
-
## What the concern does
|
|
68
|
-
|
|
69
|
-
- `subscribed` — rejects without a `cid` param, then runs `build_graph`
|
|
70
|
-
inside `Hibiki.root` on a dedicated worker thread (a `GraphActor`).
|
|
71
|
-
ActionCable dispatches on a thread pool with no per-channel ordering;
|
|
72
|
-
hibiki's threading model is confinement — so cable threads only enqueue,
|
|
73
|
-
and the graph lives on exactly one thread.
|
|
74
|
-
- every action — the whole body is posted to that thread wrapped in one
|
|
75
|
-
`Hibiki.batch`. `rescue_from` still applies (it runs on the graph
|
|
76
|
-
thread); what it doesn't handle goes to `Rails.error` (source
|
|
77
|
-
`"hibiki_rails"`).
|
|
78
|
-
- `unsubscribed` — disposes the root (running `on_cleanup` hooks) and
|
|
79
|
-
stops the worker, draining what was already queued.
|
|
80
|
-
- dev reloading — an Engine hook disposes every live graph before code
|
|
81
|
-
reloads (stale effects would run old class versions forever); cable
|
|
82
|
-
clients auto-reconnect and rebuild. Graph state resets on reload, like
|
|
83
|
-
any remount.
|
|
84
|
-
|
|
85
|
-
## Broadcast helpers
|
|
86
|
-
|
|
87
|
-
Available inside effects (all bound to `stream_name`):
|
|
88
|
-
|
|
89
|
-
- `broadcast_replace(target:, **rendering)` — `partial:`/`locals:`,
|
|
90
|
-
`html:`, or anything Turbo's renderer accepts.
|
|
91
|
-
- `broadcast_morph(target:, **rendering)` — replace via Turbo 8 morphing
|
|
92
|
-
(keeps focus/scroll).
|
|
93
|
-
- `broadcast_refresh` — tell the page to refresh itself.
|
|
94
|
-
- `broadcast_refresh_effect(wait: 0.25) { ...read signals... }` — the
|
|
95
|
-
morph-everything style: tracks whatever the block reads and answers
|
|
96
|
-
changes with a debounced refresh, one per burst of actions rather than
|
|
97
|
-
one per action.
|
|
98
|
-
|
|
99
|
-
## The packaged client
|
|
100
|
-
|
|
101
|
-
The gem vendors its own JavaScript, turbo-rails-style: the engine puts
|
|
102
|
-
`hibiki.js` on the app's asset path and merges the `"hibiki-rails"` pin into the
|
|
103
|
-
import map, so importmap-rails apps have no install step beyond
|
|
104
|
-
registering the controller — `bin/rails g hibiki:rails:install` does it
|
|
105
|
-
(plus the `Helpers` include below, the `ApplicationCable` boilerplate,
|
|
106
|
-
and the `@rails/actioncable` pin — a stock app has neither until its
|
|
107
|
-
first `rails g channel`), or create the one-line shim yourself:
|
|
108
|
-
|
|
109
|
-
```js
|
|
110
|
-
// app/javascript/controllers/hibiki_controller.js
|
|
111
|
-
export { default } from "hibiki-rails" // registers as "hibiki" — the helpers hardcode that identifier
|
|
31
|
+
```sh
|
|
32
|
+
bin/rails g hibiki:rails:install
|
|
112
33
|
```
|
|
113
34
|
|
|
114
|
-
The
|
|
115
|
-
eager-load it from the controllers directory, jsbundling apps get the
|
|
116
|
-
matching import/register pair in `controllers/index.js` (the install
|
|
117
|
-
generator appends it), and because it is derived from a real controller
|
|
118
|
-
file, `bin/rails stimulus:manifest:update` regenerates it instead of
|
|
119
|
-
dropping it.
|
|
35
|
+
The install generator detects whether your app uses an import map:
|
|
120
36
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
37
|
+
- For importmap apps, installation is fully automatic — you are done.
|
|
38
|
+
- For apps with a JS bundler (esbuild, vite, bun, ...), also install the companion JS client (published as an npm package) with **one of**:
|
|
39
|
+
- `npm install hibiki-rails`
|
|
40
|
+
- `yarn add hibiki-rails`
|
|
41
|
+
- `bun add hibiki-rails`
|
|
42
|
+
- or the equivalent for your setup
|
|
125
43
|
|
|
126
|
-
|
|
127
|
-
DOM subtree bound to one channel subscription. Islands are stamped with
|
|
128
|
-
the opt-in `Hibiki::Rails::Helpers` — include it where you want the bare
|
|
129
|
-
names (`ApplicationHelper` for ERB, individual Phlex components); the gem
|
|
130
|
-
never includes it for you:
|
|
44
|
+
### Using the generator
|
|
131
45
|
|
|
132
|
-
|
|
133
|
-
<%= tag.div(**hibiki_island(TodosChannel, cid: @cid)) do %>
|
|
134
|
-
<%= render TodoList.new %> <%# placeholder; replaced by DOM id %>
|
|
135
|
-
<%= tag.form(**on(:add, event: :submit)) do %>
|
|
136
|
-
<input type="text" name="title">
|
|
137
|
-
<button>add</button>
|
|
138
|
-
<% end %>
|
|
139
|
-
<% end %>
|
|
140
|
-
```
|
|
46
|
+
You can create reactive components easily with the provided generators.
|
|
141
47
|
|
|
142
|
-
|
|
143
|
-
identified by the page's `cid`.
|
|
144
|
-
- `on(action, event:, with:)` — forward a DOM event (`:click` default,
|
|
145
|
-
`:change`, `:submit`) as a channel action, with `with:` as its payload.
|
|
146
|
-
A changed control also sends `{ name => value }`; a submitted form sends
|
|
147
|
-
its FormData and is reset after performing.
|
|
48
|
+
Create your first reactive component by running:
|
|
148
49
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
50
|
+
```sh
|
|
51
|
+
# Replace [your_view_path] with your desired view path,
|
|
52
|
+
# e.g. counters, posts, users/profile...
|
|
53
|
+
bin/rails g hibiki:rails:stimulus counter [your_view_path]
|
|
152
54
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
Hibiki::Phlex.render_effect(@list) { |html| transmit({ html: }) }
|
|
157
|
-
end
|
|
55
|
+
# For example, this creates "counter" component partials
|
|
56
|
+
# inside app/views/static_pages
|
|
57
|
+
bin/rails g hibiki:rails:stimulus counter static_pages
|
|
158
58
|
```
|
|
159
59
|
|
|
160
|
-
|
|
161
|
-
before the server ever runs `build_graph` — the effects' first transmits
|
|
162
|
-
always land: no Turbo stream, no connected-wait, and the server-rendered
|
|
163
|
-
initial HTML is only a paint-avoidance placeholder. One rule carries over
|
|
164
|
-
from any replace-fragment design: never transmit a fragment containing
|
|
165
|
-
the input the user is currently typing in.
|
|
166
|
-
|
|
167
|
-
The `data-hibiki-*` attributes the helpers emit are a private contract
|
|
168
|
-
with the vendored JS — they version together; don't hand-write them in
|
|
169
|
-
app code. The protocol itself is Stimulus-free (Stimulus only hosts the
|
|
170
|
-
controller lifecycle), so a hand-rolled client can drive the same
|
|
171
|
-
attributes: `toy-phlex/` in the parent repo does it in ~40 lines. The
|
|
172
|
-
helper interface's shape is inspired by
|
|
173
|
-
[phlex-reactive](https://phlex-reactive.zoolutions.llc)'s `on(...)`
|
|
174
|
-
actions.
|
|
175
|
-
|
|
176
|
-
## Generators
|
|
177
|
-
|
|
178
|
-
Each supported shape has a generator that scaffolds it as a *working*
|
|
179
|
-
mini-example — one state, one derived, one action, one effect; run it,
|
|
180
|
-
render the output from any page, click `+1`, watch it live-update — meant
|
|
181
|
-
to be reshaped in place, not filled in from scratch:
|
|
60
|
+
This creates a minimal working reactive component in the given view path.
|
|
182
61
|
|
|
183
|
-
|
|
184
|
-
bin/rails g hibiki:rails:install # one-time wiring: register line,
|
|
185
|
-
# Helpers include, ApplicationCable
|
|
186
|
-
# boilerplate + actioncable pin
|
|
187
|
-
# (idempotent)
|
|
188
|
-
bin/rails g hibiki:rails:stimulus NAME [VIEW_PATH] # channel + ChannelController
|
|
189
|
-
# subclass + view partial
|
|
190
|
-
bin/rails g hibiki:rails:island NAME [VIEW_PATH] # channel + helpers-stamped view
|
|
191
|
-
# partial, no per-component JS
|
|
192
|
-
bin/rails g hibiki:rails:phlex NAME # channel + Phlex component +
|
|
193
|
-
# island wrapper (needs hibiki_phlex)
|
|
194
|
-
```
|
|
62
|
+
### Render the reactive component
|
|
195
63
|
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
The `stimulus` shape works with zero wiring; `island` and `phlex` need the
|
|
201
|
-
one-time `hibiki:rails:install` (they print a hint when it's missing).
|
|
202
|
-
Namespaced names work (`admin/counter` pins `static channel` where the
|
|
203
|
-
Stimulus identifier can't infer it). In apps without an importmap
|
|
204
|
-
(jsbundling/vite), where `controllers/index.js` has no eager loader, the
|
|
205
|
-
`stimulus` generator also appends the controller's import/register pair
|
|
206
|
-
to it — the same lines `stimulus:manifest:update` would emit.
|
|
207
|
-
|
|
208
|
-
## The initial-state pattern (Turbo transport)
|
|
209
|
-
|
|
210
|
-
Islands on the Turbo-broadcast transport instead (the "Usage" example
|
|
211
|
-
above) have an ordering problem the transmit transport doesn't: the
|
|
212
|
-
graph's effects do their first run inside `subscribed` — usually before
|
|
213
|
-
the page's `turbo_stream_from` subscription has confirmed — so the first
|
|
214
|
-
broadcast would be lost. Fix the ordering on the client with the packaged
|
|
215
|
-
`streamConnected` helper: wait for Turbo to stamp the `connected`
|
|
216
|
-
attribute on the stream source, then subscribe the graph channel.
|
|
217
|
-
|
|
218
|
-
```js
|
|
219
|
-
// in the Stimulus controller driving the channel
|
|
220
|
-
import { streamConnected } from "hibiki-rails"
|
|
221
|
-
|
|
222
|
-
async connect() {
|
|
223
|
-
this.consumer = createConsumer()
|
|
224
|
-
await streamConnected(this.element.querySelector("turbo-cable-stream-source"))
|
|
225
|
-
this.subscription = this.consumer.subscriptions.create(
|
|
226
|
-
{ channel: "CounterChannel", cid: this.cidValue }, {}
|
|
227
|
-
)
|
|
228
|
-
}
|
|
64
|
+
The generated components are just Rails partials (or Phlex components, if you used the Phlex generator), so you can render one anywhere like any other partial:
|
|
65
|
+
|
|
66
|
+
```erb
|
|
67
|
+
<%= render "static_pages/counter" %>
|
|
229
68
|
```
|
|
230
69
|
|
|
231
|
-
|
|
232
|
-
paint-avoidance placeholder — the first broadcast always lands and
|
|
233
|
-
replaces it, so it doesn't have to match the graph's initial state.
|
|
70
|
+
Congratulations! Now you have your first reactive component!
|
|
234
71
|
|
|
235
|
-
##
|
|
72
|
+
## Documentation
|
|
236
73
|
|
|
237
|
-
|
|
238
|
-
thread.
|
|
239
|
-
2. `Hibiki.error_handler = ->(error, effect) { ... }` — app-level routing
|
|
240
|
-
for effect errors raised during a flush (the gem does not set this).
|
|
241
|
-
3. The graph worker's per-job rescue — everything unhandled lands in
|
|
242
|
-
`Rails.error.report(..., source: "hibiki_rails")`. Override per channel
|
|
243
|
-
via `build_graph_actor` and `GraphActor.new(on_error:)`.
|
|
74
|
+
Documentation site: <https://planetaska.github.io/hibiki/rails-introduction/>
|
|
244
75
|
|
|
245
76
|
## Development
|
|
246
77
|
|
|
@@ -248,5 +79,5 @@ replaces it, so it doesn't have to match the graph's initial state.
|
|
|
248
79
|
bundle exec rake # specs + rubocop (same as CI)
|
|
249
80
|
```
|
|
250
81
|
|
|
251
|
-
The spec suite boots a minimal inline Rails app (`spec/support/dummy_app.rb`)
|
|
252
|
-
|
|
82
|
+
The spec suite boots a minimal inline Rails app (`spec/support/dummy_app.rb`).
|
|
83
|
+
|
|
@@ -26,7 +26,9 @@
|
|
|
26
26
|
//
|
|
27
27
|
// Both shapes speak both transports. Transmit: the server's render
|
|
28
28
|
// effects `transmit({ html: })` fragments that are swapped in by their
|
|
29
|
-
// root DOM id
|
|
29
|
+
// root DOM id, and `transmit_value` messages that update every
|
|
30
|
+
// data-hibiki-value placeholder; `received` is registered at subscribe
|
|
31
|
+
// time — before the
|
|
30
32
|
// server runs build_graph — so the effects' first transmits always land
|
|
31
33
|
// (the server-rendered initial HTML is only a paint-avoidance
|
|
32
34
|
// placeholder). Turbo broadcasts: when the controller's element contains
|
|
@@ -44,6 +46,8 @@
|
|
|
44
46
|
// data-hibiki-cid-value="<per-page-load id>"
|
|
45
47
|
// controls data-hibiki-on="<event>-><action>" e.g. "click->increment"
|
|
46
48
|
// data-hibiki-with='{"index":3}' optional JSON payload
|
|
49
|
+
// value sites data-hibiki-value="<name>" reactive-value placeholder;
|
|
50
|
+
// the server's transmit_value message updates every match
|
|
47
51
|
//
|
|
48
52
|
// Register the generic controller under the identifier "hibiki" (the
|
|
49
53
|
// helpers hardcode it):
|
|
@@ -94,10 +98,26 @@ export class ChannelController extends Controller {
|
|
|
94
98
|
this.subscription.perform(action, payload)
|
|
95
99
|
}
|
|
96
100
|
|
|
97
|
-
// Server → DOM (transmit transport)
|
|
98
|
-
//
|
|
99
|
-
//
|
|
100
|
-
|
|
101
|
+
// Server → DOM (transmit transport). Two message shapes:
|
|
102
|
+
//
|
|
103
|
+
// { value: { name, text } } — a reactive value (transmit_value): write
|
|
104
|
+
// the text into every [data-hibiki-value=name] placeholder, document-
|
|
105
|
+
// wide (a value may render outside its island; names are page-unique).
|
|
106
|
+
// textContent assignment keeps values text-only and preserves each
|
|
107
|
+
// site's own tag/classes, so per-placeholder styling survives updates.
|
|
108
|
+
//
|
|
109
|
+
// { html } — a fragment: swap it in by its root id.
|
|
110
|
+
//
|
|
111
|
+
// Anything else is not ours to interpret. Subclasses may override, but
|
|
112
|
+
// should call super (or handle `value`) to keep reactive values live.
|
|
113
|
+
received({ html, value }) {
|
|
114
|
+
if (value) {
|
|
115
|
+
const selector = `[data-hibiki-value="${CSS.escape(value.name)}"]`
|
|
116
|
+
for (const site of document.querySelectorAll(selector)) {
|
|
117
|
+
site.textContent = value.text
|
|
118
|
+
}
|
|
119
|
+
return
|
|
120
|
+
}
|
|
101
121
|
if (!html) return
|
|
102
122
|
const template = document.createElement("template")
|
|
103
123
|
template.innerHTML = html
|
|
@@ -8,7 +8,7 @@ class <%= class_name %>Channel < ApplicationCable::Channel
|
|
|
8
8
|
include Hibiki::Rails::Channel
|
|
9
9
|
|
|
10
10
|
def build_graph
|
|
11
|
-
@component =
|
|
11
|
+
@component = Components::<%= class_name %>.new
|
|
12
12
|
|
|
13
13
|
# First run is the dependency-collecting initial render.
|
|
14
14
|
Hibiki::Phlex.render_effect(@component) { |html| transmit({ html: }) }
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
# read as ordinary method calls (no .value anywhere), Rerenderable lets
|
|
6
6
|
# the channel's render effect re-render this same instance, and Helpers
|
|
7
7
|
# stamps the client's wire protocol.
|
|
8
|
-
class
|
|
8
|
+
class Components::<%= class_name %> < Phlex::HTML
|
|
9
9
|
include Hibiki::Reactive
|
|
10
10
|
include Hibiki::Phlex::Rerenderable
|
|
11
11
|
include Hibiki::Rails::Helpers
|
|
@@ -3,15 +3,15 @@
|
|
|
3
3
|
# The island wrapper: one channel subscription per render, identified by
|
|
4
4
|
# a per-page-load cid. Render from any page:
|
|
5
5
|
#
|
|
6
|
-
# <%%= render
|
|
7
|
-
class
|
|
6
|
+
# <%%= render Components::<%= class_name %>Island.new %>
|
|
7
|
+
class Components::<%= class_name %>Island < Phlex::HTML
|
|
8
8
|
include Hibiki::Rails::Helpers
|
|
9
9
|
|
|
10
10
|
def view_template
|
|
11
11
|
div(**hibiki_island(<%= class_name %>Channel, cid: SecureRandom.uuid)) do
|
|
12
12
|
# A throwaway instance as a paint-avoidance placeholder; the
|
|
13
13
|
# channel's long-lived instance takes over from its first transmit.
|
|
14
|
-
render
|
|
14
|
+
render Components::<%= class_name %>.new
|
|
15
15
|
end
|
|
16
16
|
end
|
|
17
17
|
end
|
data/lib/hibiki/rails/channel.rb
CHANGED
|
@@ -92,6 +92,22 @@ module Hibiki
|
|
|
92
92
|
raise NotImplementedError, "#{self.class} must implement #build_graph"
|
|
93
93
|
end
|
|
94
94
|
|
|
95
|
+
# The channel half of a single reactive value (see Helpers#reactive for
|
|
96
|
+
# the placeholder half): wraps the block in an effect that transmits
|
|
97
|
+
# `{ value: { name:, text: } }` — the packaged client writes the text
|
|
98
|
+
# into every `[data-hibiki-value=NAME]` placeholder on the page (part
|
|
99
|
+
# of the data-hibiki wire contract, versioned with the client). Call
|
|
100
|
+
# from #build_graph; the block tracks whatever signals it reads (state,
|
|
101
|
+
# derived, or an expression over several). Values are text, never
|
|
102
|
+
# markup: the client assigns textContent, so nothing is interpreted as
|
|
103
|
+
# HTML. Returns the effect, owned by the graph root like any other.
|
|
104
|
+
def transmit_value(name, &compute)
|
|
105
|
+
name = Helpers.value_name(name)
|
|
106
|
+
Hibiki::Effect.new do
|
|
107
|
+
transmit({ value: { name:, text: compute.call.to_s } })
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
95
111
|
# Per-page-load graph identity, supplied by the page's subscription
|
|
96
112
|
# (each tab is its own graph). Override to derive identity elsewhere.
|
|
97
113
|
def cid = params[:cid]
|
data/lib/hibiki/rails/helpers.rb
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "cgi/escape"
|
|
3
4
|
require "json"
|
|
4
5
|
|
|
5
6
|
module Hibiki
|
|
@@ -25,7 +26,30 @@ module Hibiki
|
|
|
25
26
|
# The emitted attribute names are a private contract between these
|
|
26
27
|
# helpers and the gem's JS — they version together; don't hand-write
|
|
27
28
|
# them in app code.
|
|
29
|
+
#
|
|
30
|
+
# #reactive is the exception to the splat shape: it returns a complete
|
|
31
|
+
# placeholder element (`<%= reactive :doubled, 0 %>` in ERB). Phlex
|
|
32
|
+
# components splat instead: `span(**reactive_attrs(:doubled))`.
|
|
28
33
|
module Helpers
|
|
34
|
+
# A reactive value's name lands in an attribute selector on both
|
|
35
|
+
# halves, so keep it to safe characters; the placeholder tag name
|
|
36
|
+
# lands in raw markup, so allowlist it too.
|
|
37
|
+
VALUE_NAME = /\A[a-z][a-z0-9_-]*\z/i
|
|
38
|
+
VALUE_TAG = /\A[a-z][a-z0-9-]*\z/i
|
|
39
|
+
private_constant :VALUE_NAME, :VALUE_TAG
|
|
40
|
+
|
|
41
|
+
# The shared name validator for both halves of a reactive value (the
|
|
42
|
+
# view-side data-hibiki-value placeholder and the channel's
|
|
43
|
+
# #transmit_value message).
|
|
44
|
+
def self.value_name(name)
|
|
45
|
+
name = name.to_s
|
|
46
|
+
unless VALUE_NAME.match?(name)
|
|
47
|
+
raise ArgumentError,
|
|
48
|
+
"reactive value name #{name.inspect} must match #{VALUE_NAME.inspect}"
|
|
49
|
+
end
|
|
50
|
+
name
|
|
51
|
+
end
|
|
52
|
+
|
|
29
53
|
# The island root: one channel subscription per island, identified by
|
|
30
54
|
# a per-page-load cid (each tab is its own graph). `channel` is the
|
|
31
55
|
# channel class or its name as a string.
|
|
@@ -45,6 +69,29 @@ module Hibiki
|
|
|
45
69
|
data[:hibiki_with] = JSON.generate(with) unless with.nil?
|
|
46
70
|
{ data: }
|
|
47
71
|
end
|
|
72
|
+
|
|
73
|
+
# Placeholder for a single reactive value: `<%= reactive :doubled, 0 %>`
|
|
74
|
+
# paints `<span data-hibiki-value="doubled">0</span>`; the channel's
|
|
75
|
+
# `transmit_value(:doubled) { ... }` keeps it fresh. The same value may
|
|
76
|
+
# be placed any number of times, anywhere on the page — every
|
|
77
|
+
# placeholder updates (the client matches document-wide, so a value can
|
|
78
|
+
# render outside its island too). Names must be page-unique across
|
|
79
|
+
# channels. Only the placeholder text is server-rendered: each site
|
|
80
|
+
# keeps its own tag, classes, and attributes across updates.
|
|
81
|
+
def reactive(name, placeholder = "", tag_name: :span)
|
|
82
|
+
tag_name = tag_name.to_s
|
|
83
|
+
unless VALUE_TAG.match?(tag_name)
|
|
84
|
+
raise ArgumentError,
|
|
85
|
+
"reactive value tag #{tag_name.inspect} must match #{VALUE_TAG.inspect}"
|
|
86
|
+
end
|
|
87
|
+
html = %(<#{tag_name} data-hibiki-value="#{Helpers.value_name(name)}">) +
|
|
88
|
+
%(#{CGI.escapeHTML(placeholder.to_s)}</#{tag_name}>)
|
|
89
|
+
html.respond_to?(:html_safe) ? html.html_safe : html
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# The value's attributes, for stamping the placeholder yourself — the
|
|
93
|
+
# Phlex form of #reactive: `span(**reactive_attrs(:doubled)) { "0" }`.
|
|
94
|
+
def reactive_attrs(name) = { data: { hibiki_value: Helpers.value_name(name) } }
|
|
48
95
|
end
|
|
49
96
|
end
|
|
50
97
|
end
|
data/lib/hibiki/rails/version.rb
CHANGED