hibiki_rails 0.11.0 → 0.13.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e95a440701dfeb3c02a3f6535b59cc18e51d66569dd76f9d793153f671120edc
4
- data.tar.gz: 14869e53518e44f14089c7a1be15c166a51e9f61ff87867cd9fcbb848b20a3dc
3
+ metadata.gz: dfd1da2a9d64baab573c47345a55fbe7f985c3e6781f2e0d04879043b0889dcc
4
+ data.tar.gz: f012a899f4c6b675b75102f217dc9e37feaa827e4c63097cc1a86de353dce543
5
5
  SHA512:
6
- metadata.gz: 3937c28fbcd8ec47e540a2837b6c48d34b4152553037491a576a67b22679d58578c09c4b63ea316390a1a5d5d49e9c37b29fe277dd8150484a881899ee8f1b00
7
- data.tar.gz: 88ca77fa4732bf3d08f7b207c57ec457bc601afa7111dda3d4660b51d62a1b305b88c9a98b1801cfdc5a29f2723f8d29f5b617b545cd3c9c952e1b3c9aef8329
6
+ metadata.gz: 46110572a666ccd8f7c44114433259ebb901d50d7625ce7ff3468de7c0a34bbc6038766cf611eb977b1126d54fab8dbc018a55aa0a3aaab91553cf43fdcca048
7
+ data.tar.gz: 8b71c3b742e04e93b9e58707dcf7ebd1dda9319a0894108e07a5a560008f24591a43969e3ce1c4572e4c3ad76d0cf4fc025dd9fe3f518141bdf870e58261ab73
data/CHANGELOG.md CHANGED
@@ -4,6 +4,57 @@ The gem and the npm package are released in lockstep and share these version
4
4
  numbers — `app/assets/javascripts/hibiki.js` is a single copy served both ways,
5
5
  so importmap and bundler apps always resolve identical client code.
6
6
 
7
+ ## 0.13.0 — 2026-09-07
8
+
9
+ ### Changed
10
+
11
+ **The scaffold's query object lives in `app/queries`.** `hibiki:rails:scaffold`
12
+ and `scaffold_controller` write `app/queries/book_query.rb` where they wrote
13
+ `app/models/book_query.rb`. The constant is unchanged. `app/queries` is the
14
+ directory the query-object gems (ARQO, querifier, query_delegator) already
15
+ generate into, so a query one of them writes for the same model meets ours as
16
+ a file conflict instead of a second `BookQuery` that Rails loads first. The
17
+ move costs no extra restart: the scaffold already creates `app/forms`, and one
18
+ restart picks up both.
19
+
20
+ - Apps scaffolded earlier need nothing. Move the file with `git mv` when
21
+ convenient; `hibiki:rails:multiselect` and `upload_field` find it in either
22
+ place.
23
+ - A `--force` re-run writes `app/queries`. Rails autoloads `app/models` first,
24
+ so the post-install output asks you to delete the old file.
25
+
26
+ ## 0.12.0 — 2026-09-06
27
+
28
+ ### Added
29
+
30
+ **`island` — the ERB block helper for the island root.** One call
31
+ generates the cid, stamps the root with `hibiki_island`, and derives the
32
+ channel's `turbo_stream_from` inside it:
33
+
34
+ ```erb
35
+ <%= island CounterChannel do |cid| %>
36
+ ...
37
+ <% end %>
38
+ ```
39
+
40
+ - `cid:` defaults to a fresh UUID; the block receives it either way.
41
+ - The stream source is derived from the channel class
42
+ (`turbo_stream_from channel.channel_name, cid`), so renaming the channel
43
+ cannot leave a hand-typed streamable behind. `transport: :transmit`
44
+ leaves it out: for a channel that transmits, or one that overrides
45
+ `stream_name` and writes its own line in the block. (`:broadcast` is the
46
+ default; the option is a named value rather than a boolean so it cannot
47
+ be mistaken for Turbo's own `data-turbo="false"`.)
48
+ - `params:` passes through; `tag_name:` picks the root element; any other
49
+ keyword lands on it, with a `data:` hash merged beneath the island's own
50
+ keys.
51
+ - Class-only (`constantize` a dynamic name at the call site) and ERB-only
52
+ (it needs ActionView's `capture`). Phlex components keep
53
+ `div(**hibiki_island(...))`, which remains the primitive.
54
+
55
+ Generators are unchanged: their output keeps the explicit three-line form.
56
+ No client change; the npm release is the lockstep bump.
57
+
7
58
  ## 0.11.0 — 2026-09-05
8
59
 
9
60
  ### Changed
data/README.md CHANGED
@@ -112,8 +112,8 @@ once per app, shared by every generated resource, and wired into your stylesheet
112
112
  or layout automatically — the post-install output says which, or gives you the
113
113
  line to add when it cannot tell.
114
114
 
115
- Restart the server afterwards: `app/forms/` is new, and Rails works out its
116
- autoload paths at boot.
115
+ Restart the server afterwards: `app/forms/` and `app/queries/` are new, and
116
+ Rails works out its autoload paths at boot.
117
117
 
118
118
  ### Render the reactive component
119
119
 
@@ -98,7 +98,7 @@ module Hibiki
98
98
 
99
99
  # Rows are strict_loading, so the display line raises without these.
100
100
  def inject_query_preload
101
- inject_includes query_path,
101
+ inject_includes existing_query_path,
102
102
  with_existing: /matched_scope\.includes\(([^)]*)\)/,
103
103
  without: "apply_sort(matched_scope)",
104
104
  wrapped: "apply_sort(matched_scope.includes(:#{association_name}))"
@@ -18,7 +18,7 @@ Example:
18
18
  This will create:
19
19
  db/migrate/XXXXXXXXXXXX_create_books.rb
20
20
  app/models/book.rb
21
- app/models/book_query.rb
21
+ app/queries/book_query.rb
22
22
  app/channels/books_channel.rb
23
23
  app/channels/book_channel.rb
24
24
  app/forms/book_form.rb
@@ -28,7 +28,8 @@ Example:
28
28
  And add to config/routes.rb:
29
29
  resources :books
30
30
 
31
- Restart the server afterwards if it is running — app/forms is new.
31
+ Restart the server afterwards if it is running — app/forms and
32
+ app/queries are new.
32
33
 
33
34
  Pass --phlex to emit Phlex components under app/views/books/*.rb instead of
34
35
  ERB templates. The view layer is the only thing it changes; it needs the
@@ -17,7 +17,7 @@ Example:
17
17
  bin/rails generate hibiki:rails:scaffold_controller Book
18
18
 
19
19
  This will create:
20
- app/models/book_query.rb
20
+ app/queries/book_query.rb
21
21
  app/channels/books_channel.rb
22
22
  app/channels/book_channel.rb
23
23
  app/forms/book_form.rb
@@ -86,7 +86,7 @@ module Hibiki
86
86
  # Captured BEFORE anything is written, so post_install can tell which
87
87
  # app/* directories are new — Rails computes autoload paths from that
88
88
  # glob at boot, and a new one needs a restart.
89
- @new_app_dirs = %w[app/forms app/channels app/models app/views].reject { exists?(it) }
89
+ @new_app_dirs = %w[app/forms app/queries app/channels app/models app/views].reject { exists?(it) }
90
90
  end
91
91
 
92
92
  # One file per app, not per resource, and the only thing this generator
@@ -49,19 +49,26 @@ module Hibiki
49
49
  def scaffold_controller_class_name = "#{controller_class_name}Controller"
50
50
 
51
51
  # ---- destinations ----------------------------------------------------
52
- #
53
- # The query object goes in app/models, NOT a new app/queries: Rails
54
- # computes autoload paths from the app/* glob at boot, so a new
55
- # top-level directory is not autoloadable until a server restart.
56
- # app/forms already costs one restart; two would be gratuitous.
57
52
 
58
53
  def view_dir = File.join("app/views", controller_file_path)
59
54
  def collection_channel_path = File.join("app/channels", "#{controller_file_path}_channel.rb")
60
55
  def member_channel_path = File.join("app/channels", *class_path, "#{file_name}_channel.rb")
61
56
  def model_path = File.join("app/models", *class_path, "#{file_name}.rb")
62
- def query_path = File.join("app/models", *class_path, "#{file_name}_query.rb")
57
+ def query_path = File.join("app/queries", *class_path, "#{file_name}_query.rb")
63
58
  def form_path = File.join("app/forms", *class_path, "#{file_name}_form.rb")
64
59
 
60
+ # Scaffolds before 0.13.0 wrote the query object to app/models. The
61
+ # add-on generators read existing_query_path so their injections land
62
+ # in the file the app loads; the scaffold itself always writes query_path.
63
+ def legacy_query_path = File.join("app/models", *class_path, "#{file_name}_query.rb")
64
+
65
+ def existing_query_path
66
+ return query_path if File.exist?(File.join(destination_root, query_path))
67
+ return legacy_query_path if File.exist?(File.join(destination_root, legacy_query_path))
68
+
69
+ query_path
70
+ end
71
+
65
72
  def scaffold_controller_path
66
73
  File.join("app/controllers", "#{controller_file_path}_controller.rb")
67
74
  end
@@ -23,6 +23,7 @@ module Hibiki
23
23
  # stylesheet, a leftover file. None of them is about the resource.
24
24
  def app_notices
25
25
  restart_notice
26
+ stale_query_notice
26
27
  # These live with the code that chose their branch, like
27
28
  # parent_notices — the outcomes are those modules' vocabulary.
28
29
  stylesheet_notice
@@ -49,6 +50,16 @@ module Hibiki
49
50
  "Please restart if the server is running.", :yellow
50
51
  end
51
52
 
53
+ # Scaffolds before 0.13.0 wrote the query object to app/models, which
54
+ # autoloads ahead of app/queries: until the old copy goes, it is the one
55
+ # the app loads. A generator never deletes.
56
+ def stale_query_notice
57
+ return unless exists?(legacy_query_path)
58
+
59
+ say_status :stale, "#{legacy_query_path} has moved to #{query_path}. " \
60
+ "Please delete the old file.", :yellow
61
+ end
62
+
52
63
  def rebuild_css_notice
53
64
  return unless css?
54
65
 
@@ -103,13 +103,14 @@ module Hibiki
103
103
  # Rows are strict_loading and frozen, so the thumbnail raises without
104
104
  # these — on the index AND on show-page repaints (the member channel).
105
105
  def inject_query_preload
106
- return if wired?(query_path, /\.#{with_attached}\b/)
106
+ path = existing_query_path
107
+ return if wired?(path, /\.#{with_attached}\b/)
107
108
 
108
- unless wired?(query_path, QUERY_WINDOW_SCOPE)
109
- return manual_wiring(query_path, " # preload the attachment:\n .#{with_attached}")
109
+ unless wired?(path, QUERY_WINDOW_SCOPE)
110
+ return manual_wiring(path, " # preload the attachment:\n .#{with_attached}")
110
111
  end
111
112
 
112
- gsub_file(query_path, QUERY_WINDOW_SCOPE) do |match|
113
+ gsub_file(path, QUERY_WINDOW_SCOPE) do |match|
113
114
  head, scope, tail = match.match(QUERY_WINDOW_SCOPE).captures
114
115
  "#{head}#{scope}.#{with_attached}#{tail}"
115
116
  end
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "cgi/escape"
4
4
  require "json"
5
+ require "securerandom"
5
6
 
6
7
  module Hibiki
7
8
  module Rails
@@ -24,7 +25,8 @@ module Hibiki
24
25
  # Both helpers return a `{ data: { ... } }` hash: splat it into Phlex
25
26
  # element methods or Rails tag helpers (`tag.div(**hibiki_island(...))`).
26
27
  # When the element needs other attributes on the same `data:` key, merge
27
- # the hashes yourself (Phlex's `mix` does this).
28
+ # the hashes yourself (Phlex's `mix` does this). In ERB, #island wraps
29
+ # the root, the cid, and the Turbo stream source into one block helper.
28
30
  #
29
31
  # The emitted attribute names are a private contract between these
30
32
  # helpers and the gem's JS — they version together; don't hand-write
@@ -53,7 +55,12 @@ module Hibiki
53
55
  # the emitted markup instead of being an invisible default.
54
56
  DEFAULT_INPUT_DEBOUNCE = 250
55
57
 
56
- private_constant :VALUE_NAME, :VALUE_TAG, :EVENT_NAME
58
+ # How an island's channel sends HTML back: Turbo broadcasts to a named
59
+ # stream (the root needs a stream source inside), or transmit down the
60
+ # subscription itself (it does not).
61
+ TRANSPORTS = %i[broadcast transmit].freeze
62
+
63
+ private_constant :VALUE_NAME, :VALUE_TAG, :EVENT_NAME, :TRANSPORTS
57
64
 
58
65
  # The shared name validator for both halves of a reactive value (the
59
66
  # view-side data-hibiki-value placeholder and the channel's
@@ -67,6 +74,23 @@ module Hibiki
67
74
  name
68
75
  end
69
76
 
77
+ # A tag name lands in raw markup, so it is allowlisted wherever a
78
+ # helper takes one.
79
+ def self.tag_name(name, of)
80
+ name = name.to_s
81
+ raise ArgumentError, "#{of} tag #{name.inspect} must match #{VALUE_TAG.inspect}" unless VALUE_TAG.match?(name)
82
+
83
+ name
84
+ end
85
+
86
+ # #island's transport option, allowlisted so a typo names itself.
87
+ def self.transport(value)
88
+ return value if TRANSPORTS.include?(value)
89
+
90
+ raise ArgumentError,
91
+ "island transport #{value.inspect} must be one of #{TRANSPORTS.map(&:inspect).join(', ')}"
92
+ end
93
+
70
94
  # The shared validator for both halves of an `event->action` token.
71
95
  def self.event_name(name)
72
96
  name = name.to_s
@@ -105,6 +129,33 @@ module Hibiki
105
129
  { data: }
106
130
  end
107
131
 
132
+ # The ERB spelling of the island root: a fresh cid, the #hibiki_island
133
+ # root, and the channel's own `turbo_stream_from channel_name, cid`
134
+ # inside it, in one block. The block receives the cid.
135
+ #
136
+ # <%= island CounterChannel do |cid| %>
137
+ # ...
138
+ # <% end %>
139
+ #
140
+ # `transport: :transmit` leaves the stream source out — for a channel
141
+ # that transmits, or one that overrides #stream_name and writes its own
142
+ # turbo_stream_from inside the block. Other keywords land on the root
143
+ # element; a `data:` hash is merged beneath the island's own keys.
144
+ # Class-only: a dynamic name is `"#{kind.camelize}Channel".constantize`
145
+ # at the call site, never taken from a request param. Phlex components
146
+ # keep `div(**hibiki_island(...))`.
147
+ def island(channel, cid: nil, params: nil, transport: :broadcast, tag_name: :div, **attributes,
148
+ &block)
149
+ island_guards!(channel, block)
150
+ tag_name = Helpers.tag_name(tag_name, "island")
151
+ transport = Helpers.transport(transport)
152
+ cid ||= SecureRandom.uuid
153
+ attributes[:data] = attributes[:data].to_h.merge(hibiki_island(channel, cid:, params:)[:data])
154
+ content = capture(cid, &block)
155
+ content = island_stream(channel, cid, content) if transport == :broadcast
156
+ tag.public_send(tag_name, content, **attributes)
157
+ end
158
+
108
159
  # Forward an event on this element as a channel action.
109
160
  #
110
161
  # `event:` names the event, or a list of them — the left side of the
@@ -169,11 +220,7 @@ module Hibiki
169
220
  # channels. Only the placeholder text is server-rendered: each site
170
221
  # keeps its own tag, classes, and attributes across updates.
171
222
  def reactive(name, placeholder = "", tag_name: :span)
172
- tag_name = tag_name.to_s
173
- unless VALUE_TAG.match?(tag_name)
174
- raise ArgumentError,
175
- "reactive value tag #{tag_name.inspect} must match #{VALUE_TAG.inspect}"
176
- end
223
+ tag_name = Helpers.tag_name(tag_name, "reactive value")
177
224
  html = %(<#{tag_name} data-hibiki-value="#{Helpers.value_name(name)}">) +
178
225
  %(#{CGI.escapeHTML(placeholder.to_s)}</#{tag_name}>)
179
226
  html.respond_to?(:html_safe) ? html.html_safe : html
@@ -185,6 +232,25 @@ module Hibiki
185
232
 
186
233
  private
187
234
 
235
+ # #island's preconditions: a block, an ActionView receiver, a channel
236
+ # class.
237
+ def island_guards!(channel, block)
238
+ raise ArgumentError, "island needs a block" unless block
239
+ unless respond_to?(:output_buffer) && respond_to?(:tag)
240
+ raise ArgumentError, "island is ERB-only; use div(**hibiki_island(...)) in a Phlex component"
241
+ end
242
+ return if channel.is_a?(Class) && channel.respond_to?(:channel_name)
243
+
244
+ raise ArgumentError,
245
+ "island takes a channel class, got #{channel.inspect}; " \
246
+ "constantize a dynamic name at the call site"
247
+ end
248
+
249
+ # The broadcast transport's stream source, ahead of the block's content.
250
+ def island_stream(channel, cid, content)
251
+ safe_join([turbo_stream_from(channel.channel_name, cid), content])
252
+ end
253
+
188
254
  # #on's per-control modifiers, kept out of the token grammar. Each is
189
255
  # omitted when it matches the client's own default, so the common call
190
256
  # still stamps exactly one attribute.
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Hibiki
4
4
  module Rails
5
- VERSION = "0.11.0"
5
+ VERSION = "0.13.0"
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: hibiki_rails
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.11.0
4
+ version: 0.13.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - planetaska