hanami-turbo 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 25698bfc47dcbbad1d9fe915efc56b9baff1286e5d8f74137baeb704f90d3562
4
+ data.tar.gz: b9daa12a59ac36e46a98a23d2e5558ec2af132db1a18d8ee8372a64ffd112dba
5
+ SHA512:
6
+ metadata.gz: 2bf61aa86174a526b17b5a08b48d888b75b424c172d89f2fffca79bddaa7e8c1e0ca0fa310312851097dfd2d96b845ace92fd856e866cf38be756f5e0f1494db
7
+ data.tar.gz: c92a9bd136e2b83dfce19c839d5cc038ff61e51e9c3b890bf9ccc86997dfca4d20eb4dc3af951e1e57d537abecd24aec57cd6d0a92ec250006dfc19a86cd64a9
data/CHANGELOG.md ADDED
@@ -0,0 +1,7 @@
1
+ # Changelog
2
+
3
+ ## Unreleased
4
+
5
+ - Initial release.
6
+ - Add `button_to`, for one-button forms with Turbo confirmations (`data: {turbo_confirm: "..."}`).
7
+ - Support custom stream actions: include a module into `Hanami::Turbo::StreamBuilder`. Actions may have no target, and `turbo_stream_action_tag` matches turbo-rails.
data/LICENSE.md ADDED
@@ -0,0 +1,9 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright © 2026 Andrea Fomera
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
6
+
7
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
8
+
9
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,415 @@
1
+ # hanami-turbo
2
+
3
+ [Hotwire Turbo](https://turbo.hotwired.dev) support for Hanami apps.
4
+
5
+ - Renders `.turbo_stream` templates for Turbo Stream requests (without a layout), falling back to
6
+ HTML when a view has no stream template.
7
+ - A `turbo_stream` builder, `turbo_frame_tag`, and `dom_id`/`dom_class` helpers for templates.
8
+ - `button_to`, for buttons that submit (with Turbo confirmations, like Rails).
9
+ - [Custom stream actions](#custom-stream-actions), added like in turbo-rails
10
+ (`turbo_stream_action_tag` included).
11
+ - `303 See Other` redirects after non-GET requests, as Turbo requires.
12
+ - Optional lighter layouts for Turbo Frame requests.
13
+
14
+ Built against the main branches of Hanami and its gems. See
15
+ [hanami-turbo-example](../hanami-turbo-example) for a complete app using every feature.
16
+
17
+ ## Setup
18
+
19
+ ```ruby
20
+ # Gemfile
21
+ gem "hanami-turbo"
22
+ ```
23
+
24
+ ```ruby
25
+ # config/app.rb
26
+ require "hanami"
27
+ require "hanami/turbo"
28
+
29
+ module Bookshelf
30
+ class App < Hanami::App
31
+ Hanami::Turbo.install(config)
32
+ end
33
+ end
34
+ ```
35
+
36
+ `install` registers the `turbo_stream` format (`text/vnd.turbo-stream.html`) and adds it to your
37
+ accepted formats. If your app accepts any format (the default), it keeps doing so.
38
+
39
+ Then add Turbo to your JavaScript, and the CSRF meta tags to your layout, so Turbo can send the
40
+ token with requests made from links and buttons:
41
+
42
+ ```sh
43
+ npm install @hotwired/turbo
44
+ ```
45
+
46
+ ```js
47
+ // app/assets/js/app.js
48
+ import "@hotwired/turbo";
49
+ ```
50
+
51
+ ```erb
52
+ <%# app/templates/layouts/app.html.erb, inside <head> %>
53
+ <%= csrf_meta_tags&.html_safe %>
54
+ <%= javascript_tag "app", defer: true %>
55
+ ```
56
+
57
+ Load the script in `<head>` (with `defer`), not at the end of `<body>`: Turbo replaces the body on
58
+ each visit, and would otherwise evaluate it again.
59
+
60
+ On Hanami main, `csrf_meta_tags` returns a string that isn't marked HTML safe, so it is escaped
61
+ unless you call `html_safe` as above. (Sessions must be enabled for CSRF tokens to exist.)
62
+
63
+ ## Turbo Streams
64
+
65
+ Turbo form submissions accept stream responses. When a view has a `.turbo_stream` template, it is
66
+ rendered for these requests, with no layout:
67
+
68
+ ```erb
69
+ <%# app/templates/books/create.turbo_stream.erb %>
70
+ <%= turbo_stream.prepend "books", partial: "books/book", locals: {book:} %>
71
+ <%= turbo_stream.update "books_count", books_count.to_s %>
72
+ <%= turbo_stream.replace "new_book" do %>
73
+ <%= render "books/form" %>
74
+ <% end %>
75
+ ```
76
+
77
+ Views without a `.turbo_stream` template render HTML as usual, so you can add stream templates only
78
+ where you want them.
79
+
80
+ Partials render in the stream format when one exists (`_book.turbo_stream.erb`), and otherwise as
81
+ HTML, so the same `_book.html.erb` serves full pages and streams.
82
+
83
+ The builder supports every Turbo action: `append`, `prepend`, `replace`, `update`, `before`,
84
+ `after`, `remove` and `refresh`, plus `*_all` variants that take a CSS selector (`update_all
85
+ ".count", "3"`), `method: :morph`, and `action(:custom_action, target, ...)` for custom actions.
86
+ Targets can be strings or records.
87
+
88
+ A common shape for actions that respond to Turbo with streams, and redirect everyone else:
89
+
90
+ ```ruby
91
+ def handle(request, response)
92
+ book = book_repo.create(request.params[:book])
93
+
94
+ if request.turbo_stream?
95
+ response.render(view, book:) # renders create.turbo_stream.erb
96
+ else
97
+ response.redirect_to routes.path(:books)
98
+ end
99
+ end
100
+ ```
101
+
102
+ Error responses that weren't rendered from a stream template (such as `halt 404`) are sent as HTML
103
+ even when the request negotiated a stream, so Turbo shows them instead of silently ignoring them.
104
+
105
+ Custom actions (your own, beyond Turbo's built-ins) are covered in
106
+ [Custom stream actions](#custom-stream-actions) below.
107
+
108
+ To pick a format yourself, pass it to `render`; explicit options always win:
109
+
110
+ ```ruby
111
+ res.render(view, format: :html)
112
+ ```
113
+
114
+ ## Custom stream actions
115
+
116
+ Turbo's built-in actions cover changing the DOM, but you can add your own, such as showing a toast,
117
+ highlighting an element, or logging to the console. A custom action has two halves:
118
+
119
+ 1. **Ruby**: a method on the `turbo_stream` builder that renders `<turbo-stream action="...">`.
120
+ 2. **JavaScript**: a function on `Turbo.StreamActions` that runs when that element arrives.
121
+
122
+ This works the way it does in turbo-rails, and modules written for turbo-rails can be reused as they
123
+ are (see [Porting from turbo-rails](#porting-from-turbo-rails)).
124
+
125
+ ### 1. Define the actions
126
+
127
+ Write a module of methods, and include it into `Hanami::Turbo::StreamBuilder`:
128
+
129
+ ```ruby
130
+ # config/turbo_stream_actions.rb
131
+ module Bookshelf
132
+ module TurboStreamActions
133
+ # No target: the JavaScript decides where the toast goes.
134
+ def toast(message, level: "info")
135
+ action(:toast, nil, message:, level:)
136
+ end
137
+
138
+ # A target: a DOM id string, or a record (turned into an id with dom_id).
139
+ def highlight(target)
140
+ action(:highlight, target)
141
+ end
142
+
143
+ # A target and content, rendered from a partial.
144
+ def flash_message(target, message)
145
+ action(:flash_message, target, partial: "shared/flash", locals: {message:})
146
+ end
147
+ end
148
+ end
149
+
150
+ Hanami::Turbo::StreamBuilder.include(Bookshelf::TurboStreamActions)
151
+ ```
152
+
153
+ Require it from your app class, so it's loaded before anything renders, whether in the server, the
154
+ console, or tests:
155
+
156
+ ```ruby
157
+ # config/app.rb
158
+ require "hanami"
159
+ require "hanami/turbo"
160
+ require_relative "turbo_stream_actions"
161
+ ```
162
+
163
+ Keep this file out of `app/` and `lib/<your_app>/`: those are autoloaded, and the module is included
164
+ once, at boot. Like a Rails initializer, changes need a server restart (code reloading won't pick
165
+ them up).
166
+
167
+ ### 2. Use them in stream templates
168
+
169
+ ```erb
170
+ <%# app/templates/books/create.turbo_stream.erb %>
171
+ <%= turbo_stream.prepend "books", partial: "books/book", locals: {book:} %>
172
+ <%= turbo_stream.toast "Added #{book.title}", level: "success" %>
173
+ <%= turbo_stream.highlight book %>
174
+ ```
175
+
176
+ This renders:
177
+
178
+ ```html
179
+ <turbo-stream action="prepend" target="books"><template>...</template></turbo-stream>
180
+ <turbo-stream action="toast" message="Added Dune" level="success"></turbo-stream>
181
+ <turbo-stream action="highlight" target="book_42"></turbo-stream>
182
+ ```
183
+
184
+ For a one-off action, you don't need a module. Use `action` directly, or the
185
+ `turbo_stream_action_tag` view helper:
186
+
187
+ ```erb
188
+ <%= turbo_stream.action :scroll_to, book, behavior: "smooth" %>
189
+ <%= turbo_stream_action_tag :console_log, message: "Hello" %>
190
+ ```
191
+
192
+ ### 3. Handle them in JavaScript
193
+
194
+ Register a handler for each action on `StreamActions`. Inside it, `this` is the `<turbo-stream>`
195
+ element:
196
+
197
+ ```js
198
+ // app/assets/js/stream_actions.js, imported from app.js
199
+ import { StreamActions } from "@hotwired/turbo";
200
+
201
+ StreamActions.toast = function () {
202
+ const toast = document.createElement("div");
203
+ toast.className = `toast toast-${this.getAttribute("level")}`;
204
+ toast.setAttribute("role", "status");
205
+ toast.textContent = this.getAttribute("message");
206
+ document.getElementById("toasts").append(toast);
207
+ setTimeout(() => toast.remove(), 4000);
208
+ };
209
+
210
+ StreamActions.highlight = function () {
211
+ this.targetElements.forEach((element) => element.classList.add("highlight"));
212
+ };
213
+
214
+ StreamActions.flash_message = function () {
215
+ this.targetElements.forEach((element) => element.replaceChildren(this.templateContent));
216
+ };
217
+ ```
218
+
219
+ Useful properties of the stream element:
220
+
221
+ | | |
222
+ | --- | --- |
223
+ | `this.getAttribute("name")` | an attribute you passed from Ruby |
224
+ | `this.targetElements` | the elements matching `target` (by id) or `targets` (by CSS selector) |
225
+ | `this.templateContent` | a copy of the `<template>` content, if any |
226
+
227
+ Import the handlers in your entry point (`import "./stream_actions.js"`), so they're registered
228
+ before any stream arrives. If no handler is registered for an action, Turbo raises an error in the
229
+ browser console.
230
+
231
+ ### The building blocks
232
+
233
+ Your methods (and templates) can use these builder methods:
234
+
235
+ | Method | Renders |
236
+ | --- | --- |
237
+ | `action(name, target, content = nil, **options, &block)` | an action for one target. `target` may be `nil`. |
238
+ | `action_all(name, targets, content = nil, **options, &block)` | an action for every element matching the `targets` CSS selector. |
239
+ | `turbo_stream_action_tag(name, target: nil, targets: nil, template: nil, **attributes)` | the turbo-rails equivalent, with keyword arguments. |
240
+
241
+ Inside your methods, `scope` is the scope of the template being rendered, so you can use its
242
+ helpers, `routes` and locals, just as turbo-rails methods use `@view_context`:
243
+
244
+ ```ruby
245
+ def toast_with_link(message, book)
246
+ action(:toast, nil, message:, href: scope.routes.path(:book, id: book.id))
247
+ end
248
+ ```
249
+
250
+ How the arguments become HTML:
251
+
252
+ - **Content** goes in a `<template>`, from (in order of precedence) a block, `partial:` with
253
+ `locals:`, or the `content` argument. Strings are HTML-escaped unless marked safe
254
+ (`.html_safe`). Partials are rendered as stream partials if they exist (`_flash.turbo_stream.erb`),
255
+ and otherwise as HTML (`_flash.html.erb`).
256
+ - **A `<template>` is added only when there's content.** Turbo's built-in content actions (`append`,
257
+ `update`, etc.) always get one, since Turbo expects it.
258
+ - **Other keyword arguments become attributes**, with their names used as given. `request_id: 1`
259
+ renders `request_id="1"`, so for a dashed name use a string key (`"request-id": 1`). A `data:` hash
260
+ becomes `data-*` attributes, with keys dasherized and non-string values encoded as JSON.
261
+ Attributes with `nil` values are left out, and `true` renders as `"true"`.
262
+ - **`partial:`, `locals:` and `method:` are reserved**: the first two supply content, and `method:`
263
+ is the attribute Turbo uses for morphing (`method: :morph`).
264
+
265
+ ### Testing
266
+
267
+ Custom actions render plain HTML, so request specs can check for them like any other stream:
268
+
269
+ ```ruby
270
+ it "shows a toast after creating a book" do
271
+ post "/books", {book: {title: "Dune", author: "Frank Herbert"}},
272
+ {"HTTP_ACCEPT" => "text/vnd.turbo-stream.html, text/html"}
273
+
274
+ expect(last_response.body).to include(
275
+ %(<turbo-stream action="toast" message="Added Dune" level="success"></turbo-stream>)
276
+ )
277
+ end
278
+ ```
279
+
280
+ The JavaScript half needs a browser test (for example with Capybara or Playwright).
281
+
282
+ ### Porting from turbo-rails
283
+
284
+ | turbo-rails | hanami-turbo |
285
+ | --- | --- |
286
+ | `Turbo::Streams::TagBuilder.prepend(MyActions)` | `Hanami::Turbo::StreamBuilder.include(MyActions)` |
287
+ | Module in `config/initializers/` | Module in `config/`, required from `config/app.rb` |
288
+ | `turbo_stream_action_tag :name, target:, targets:, template:, **attributes` | the same |
289
+ | `action(:name, target, content)` in TagBuilder methods | the same, plus `partial:`/`locals:` and blocks |
290
+ | `@view_context` in TagBuilder methods | `scope`: the template's scope, with its helpers and `routes` |
291
+ | `Turbo.StreamActions.name = function () { ... }` | the same (it's Turbo's JavaScript API) |
292
+
293
+ ## Turbo Frames
294
+
295
+ ```erb
296
+ <%= turbo_frame_tag "tray" do %>...<% end %>
297
+ <%= turbo_frame_tag book %> <%# id="book_42" %>
298
+ <%= turbo_frame_tag book, :comments, src: routes.path(:book_comments, id: book.id), loading: :lazy %>
299
+ ```
300
+
301
+ Turbo only uses the matching `<turbo-frame>` from a frame request's response. To skip rendering the
302
+ full layout for these requests:
303
+
304
+ ```ruby
305
+ Hanami::Turbo.config.frame_layout = false # no layout
306
+ Hanami::Turbo.config.frame_layout = "frame" # or a lighter layout, e.g. one with just <head>
307
+ ```
308
+
309
+ In actions, `req.turbo_frame` returns the requesting frame's id, and `req.turbo_frame?` and
310
+ `req.turbo_stream?` tell you what kind of Turbo request you have.
311
+
312
+ ## Buttons, confirmations and non-GET links
313
+
314
+ `button_to` renders a one-button form (with the CSRF token, and `_method` for PATCH, PUT and DELETE),
315
+ like Rails. Attributes go on the button; `form:` sets the form's own attributes, and `params:` adds
316
+ hidden fields.
317
+
318
+ ```erb
319
+ <%= button_to "Delete", routes.path(:book, id: book.id),
320
+ method: :delete,
321
+ data: {turbo_confirm: "Delete #{book.title}?"} %>
322
+
323
+ <%= button_to "Archive", routes.path(:archive_book, id: book.id), form: {class: "inline"}, params: {return_to: "/books"} %>
324
+ ```
325
+
326
+ Turbo asks for confirmation before submitting any form, or form button, with `data-turbo-confirm`.
327
+ You can put it on the button (as above) or the form (`form: {data: {turbo_confirm: "..."}}`), as
328
+ Rails apps do.
329
+
330
+ Links can make non-GET requests through Turbo, with or without confirmation, using `link_to`:
331
+
332
+ ```erb
333
+ <%= link_to "Delete", routes.path(:book, id: book.id),
334
+ data: {turbo_method: :delete, turbo_confirm: "Are you sure?"} %>
335
+ ```
336
+
337
+ Turbo sends these as real `DELETE` (etc.) requests, with the CSRF token from `csrf_meta_tags` in an
338
+ `X-CSRF-Token` header. Prefer `button_to` for anything destructive: unlike links, buttons also work
339
+ without JavaScript.
340
+
341
+ ### Custom confirmation dialogs
342
+
343
+ By default Turbo uses the browser's `confirm()`. To show your own dialog, set
344
+ `Turbo.config.forms.confirm` to a function returning a promise of true or false:
345
+
346
+ ```erb
347
+ <%# in the layout %>
348
+ <dialog id="confirm-dialog">
349
+ <form method="dialog">
350
+ <p id="confirm-message"></p>
351
+ <button value="cancel">Cancel</button>
352
+ <button value="confirm" data-confirm-button>Confirm</button>
353
+ </form>
354
+ </dialog>
355
+ ```
356
+
357
+ ```js
358
+ import * as Turbo from "@hotwired/turbo";
359
+
360
+ Turbo.config.forms.confirm = (message, form, submitter) => {
361
+ const dialog = document.getElementById("confirm-dialog");
362
+ dialog.querySelector("#confirm-message").textContent = message;
363
+ dialog.querySelector("[data-confirm-button]").textContent =
364
+ submitter?.dataset.turboConfirmButton || form.dataset.turboConfirmButton || "Confirm";
365
+ dialog.showModal();
366
+
367
+ return new Promise((resolve) => {
368
+ dialog.addEventListener("close", () => resolve(dialog.returnValue === "confirm"), { once: true });
369
+ });
370
+ };
371
+ ```
372
+
373
+ The `data-turbo-confirm-button` attribute is a convention from this snippet (not part of Turbo)
374
+ for customising the confirm button's label per button:
375
+
376
+ ```erb
377
+ <%= button_to "Delete", path, method: :delete,
378
+ data: {turbo_confirm: "Delete this book?", turbo_confirm_button: "Delete book"} %>
379
+ ```
380
+
381
+ (On Turbo 7, use `Turbo.setConfirmMethod(fn)` instead; Turbo 8 deprecates it.)
382
+
383
+ ## DOM ids
384
+
385
+ `dom_id` and `dom_class` name records after their class, for use as frame ids and stream targets:
386
+
387
+ ```erb
388
+ <%= dom_id(book) %> <%# "book_42" %>
389
+ <%= dom_id(book, :edit) %> <%# "edit_book_42" %>
390
+ <%= dom_id(Book.new) %> <%# "new_book" %>
391
+ ```
392
+
393
+ Decorated parts also respond to `dom_id` and `dom_class` directly: `<li id="<%= book.dom_id %>">`.
394
+
395
+ ## Forms and redirects
396
+
397
+ Turbo expects:
398
+
399
+ - **A redirect after a successful submission.** For non-GET requests, `res.redirect_to` now defaults
400
+ to `303 See Other`. Browsers repeat PATCH and DELETE requests when following a 302, so this is
401
+ required for those; for POST it behaves the same as a 302. An explicit `status:` is always kept.
402
+ To turn this off: `Hanami::Turbo.config.see_other_redirects = false`.
403
+ - **A 4xx status when re-rendering a form with errors.** Turbo ignores a `200` response to a form
404
+ submission, so set `res.status = 422` before rendering.
405
+
406
+ ## How it works
407
+
408
+ The gem extends Hanami at public extension points where it can: it prepends `Response#render` and
409
+ `Response#redirect_to`, includes helpers into the standard Hanami view helpers, and adds methods to
410
+ `Request` and `View::Part`. The few calls into hanami-view's private API (checking a template
411
+ exists, and rendering a partial in another format) are kept in `Hanami::Turbo::ViewInternals`.
412
+
413
+ ## License
414
+
415
+ MIT
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hanami
4
+ module Turbo
5
+ # Turbo-aware finishing for `Hanami::Action`.
6
+ #
7
+ # @api private
8
+ module Action
9
+ private
10
+
11
+ # Sends error responses as HTML unless they really are Turbo Streams.
12
+ #
13
+ # A Turbo form submission negotiates the stream format before the action runs, so a `halt 404`
14
+ # (or any other error response not rendered from a stream template) would otherwise go out as
15
+ # a stream. Turbo would treat it as empty and do nothing. As HTML, Turbo shows it instead.
16
+ #
17
+ # This prepends `Action#finish`, which is private API, but is also how Hanami's own app
18
+ # integration hooks into actions.
19
+ def finish(request, response, halted)
20
+ super.tap do
21
+ response.format = :html if response.turbo_stream_mismatch?
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
27
+
28
+ Hanami::Action.prepend(Hanami::Turbo::Action)
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hanami
4
+ module Turbo
5
+ # Builds DOM ids and classes for records, for use as Turbo Frame ids and Turbo Stream targets.
6
+ #
7
+ # Records may be view parts (their value is used) or any object responding to `#id`. The name
8
+ # comes from the record's class, so a `Bookshelf::Structs::Book` with id 42 has the DOM id
9
+ # `"book_42"`.
10
+ #
11
+ # @api public
12
+ module DomId
13
+ NEW = "new"
14
+ JOIN = "_"
15
+
16
+ module_function
17
+
18
+ # Returns the DOM id for a record.
19
+ #
20
+ # @example
21
+ # dom_id(book) # => "book_42"
22
+ # dom_id(book, :comments) # => "comments_book_42"
23
+ # dom_id(Book.new) # => "new_book"
24
+ # dom_id(Book.new, :edit) # => "edit_book"
25
+ # dom_id("custom_target") # => "custom_target"
26
+ #
27
+ # @param record [Object, String, Symbol] the record, or a ready-made id
28
+ # @param prefix [String, Symbol, nil] optional prefix
29
+ #
30
+ # @return [String]
31
+ #
32
+ # @api public
33
+ def dom_id(record, prefix = nil, inflector: Turbo.inflector)
34
+ return record.to_s if record.is_a?(String) || record.is_a?(Symbol)
35
+
36
+ value = unwrap(record)
37
+ id = value.id if value.respond_to?(:id)
38
+
39
+ if id.nil?
40
+ dom_class(record, prefix || NEW, inflector:)
41
+ else
42
+ [dom_class(record, prefix, inflector:), id].join(JOIN)
43
+ end
44
+ end
45
+
46
+ # Returns the DOM class for a record (its singular name, with an optional prefix).
47
+ #
48
+ # @param record [Object] the record
49
+ # @param prefix [String, Symbol, nil] optional prefix
50
+ #
51
+ # @return [String]
52
+ #
53
+ # @api public
54
+ def dom_class(record, prefix = nil, inflector: Turbo.inflector)
55
+ name = record_name(record, inflector)
56
+ prefix ? [prefix, name].join(JOIN) : name
57
+ end
58
+
59
+ # @api private
60
+ def record_name(record, inflector)
61
+ value = unwrap(record)
62
+ klass = value.is_a?(Class) ? value : value.class
63
+
64
+ # Anonymous classes (and plain hashes or structs from ad hoc data) have no useful class name,
65
+ # so fall back to the part's name, when there is one.
66
+ if klass.name.nil? || [Hash, Struct, Data, Object].include?(klass)
67
+ return inflector.singularize(record._name.to_s) if record.respond_to?(:_name)
68
+
69
+ raise ArgumentError, "cannot infer a DOM id name for #{value.inspect}"
70
+ end
71
+
72
+ inflector.underscore(inflector.demodulize(klass.name))
73
+ end
74
+
75
+ # @api private
76
+ def unwrap(record)
77
+ record.is_a?(Hanami::View::Part) ? record._value : record
78
+ end
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,129 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hanami
4
+ module Turbo
5
+ # View helpers for Turbo, included in the standard Hanami helpers (so available in templates,
6
+ # scopes, and via `helpers` in parts).
7
+ #
8
+ # @api public
9
+ module Helpers
10
+ # Returns a builder for `<turbo-stream>` elements.
11
+ #
12
+ # @return [StreamBuilder]
13
+ #
14
+ # @see StreamBuilder
15
+ #
16
+ # @api public
17
+ def turbo_stream
18
+ StreamBuilder.new(self)
19
+ end
20
+
21
+ # Returns a `<turbo-stream>` element, like turbo-rails' helper of the same name.
22
+ #
23
+ # @see StreamBuilder#turbo_stream_action_tag
24
+ #
25
+ # @api public
26
+ def turbo_stream_action_tag(...)
27
+ turbo_stream.turbo_stream_action_tag(...)
28
+ end
29
+
30
+ # Returns a `<turbo-frame>` element.
31
+ #
32
+ # The id may be a string, or a record (with an optional prefix), passed to {#dom_id}.
33
+ #
34
+ # @example
35
+ # turbo_frame_tag "tray" { "..." }
36
+ # turbo_frame_tag book # id="book_42"
37
+ # turbo_frame_tag book, :comments # id="comments_book_42"
38
+ # turbo_frame_tag "details", src: "/books/1/details", loading: :lazy
39
+ #
40
+ # @api public
41
+ def turbo_frame_tag(record_or_id, prefix = nil, src: nil, target: nil, loading: nil, **attributes, &block)
42
+ id =
43
+ if prefix.nil? && (record_or_id.is_a?(String) || record_or_id.is_a?(Symbol))
44
+ record_or_id.to_s
45
+ else
46
+ dom_id(record_or_id, prefix)
47
+ end
48
+
49
+ tag.turbo_frame(id:, src:, target:, loading:, **attributes, &block)
50
+ end
51
+
52
+ # Returns a form containing a single button, for actions that shouldn't be links (anything that
53
+ # changes data). Like Rails' `button_to`.
54
+ #
55
+ # The form includes the CSRF token and, for methods other than GET and POST, a `_method` field.
56
+ # Attributes are given to the button, so `data: {turbo_confirm: "..."}` asks Turbo to confirm
57
+ # before submitting. Use `form:` for the form's own attributes.
58
+ #
59
+ # @example
60
+ # button_to "Delete", routes.path(:book, id: book.id), method: :delete,
61
+ # data: {turbo_confirm: "Delete #{book.title}?"}
62
+ #
63
+ # button_to routes.path(:archive_book, id: book.id), form: {class: "inline"} do
64
+ # "<span>Archive</span>".html_safe
65
+ # end
66
+ #
67
+ # @param content [String, nil] button content (omit when giving a block)
68
+ # @param url [String] the form's action
69
+ # @param method [Symbol, String] HTTP method (default: POST)
70
+ # @param form [Hash] attributes for the form (default class: `"button_to"`)
71
+ # @param params [Hash] extra hidden fields to submit
72
+ # @param attributes [Hash] attributes for the button
73
+ #
74
+ # @return [String] the form
75
+ #
76
+ # @api public
77
+ def button_to(content = nil, url = nil, method: :post, form: {}, params: {}, **attributes, &block)
78
+ if block
79
+ raise ArgumentError, "give either content or a block, not both" if url
80
+
81
+ url = content
82
+ content = nil
83
+ end
84
+ raise ArgumentError, "a URL is required" unless url
85
+
86
+ form_attributes = {class: "button_to", **form}
87
+
88
+ # `values:` and `params:` are form_for's sources for field values; this form has no fields.
89
+ form_for(url, method:, values: {}, params: {}, **form_attributes) do |f|
90
+ hidden_fields = params.map { |name, value|
91
+ tag.input(type: "hidden", name: name.to_s, value: value.to_s)
92
+ }
93
+
94
+ button =
95
+ if block
96
+ f.button(type: "submit", **attributes, &block)
97
+ else
98
+ f.button(content, type: "submit", **attributes)
99
+ end
100
+
101
+ (hidden_fields.join + button).html_safe
102
+ end
103
+ end
104
+
105
+ # @see DomId.dom_id
106
+ #
107
+ # @api public
108
+ def dom_id(record, prefix = nil)
109
+ DomId.dom_id(record, prefix, inflector: _turbo_inflector)
110
+ end
111
+
112
+ # @see DomId.dom_class
113
+ #
114
+ # @api public
115
+ def dom_class(record, prefix = nil)
116
+ DomId.dom_class(record, prefix, inflector: _turbo_inflector)
117
+ end
118
+
119
+ private
120
+
121
+ def _turbo_inflector
122
+ respond_to?(:_inflector, true) ? _inflector : Turbo.inflector
123
+ end
124
+ end
125
+ end
126
+ end
127
+
128
+ Hanami::View::Scope.include(Hanami::Turbo::Helpers)
129
+ Hanami::Extensions::View::StandardHelpers.include(Hanami::Turbo::Helpers)
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hanami
4
+ module Turbo
5
+ # DOM id methods for view parts.
6
+ #
7
+ # @example
8
+ # <article id="<%= book.dom_id %>">
9
+ #
10
+ # @api public
11
+ module Part
12
+ # @see DomId.dom_id
13
+ #
14
+ # @api public
15
+ def dom_id(prefix = nil)
16
+ DomId.dom_id(self, prefix, inflector: _rendering.inflector)
17
+ end
18
+
19
+ # @see DomId.dom_class
20
+ #
21
+ # @api public
22
+ def dom_class(prefix = nil)
23
+ DomId.dom_class(self, prefix, inflector: _rendering.inflector)
24
+ end
25
+ end
26
+ end
27
+ end
28
+
29
+ Hanami::View::Part.include(Hanami::Turbo::Part)
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hanami
4
+ module Turbo
5
+ # Turbo-specific methods for `Hanami::Action::Request`.
6
+ #
7
+ # @api public
8
+ module Request
9
+ TURBO_FRAME_HEADER = "HTTP_TURBO_FRAME"
10
+ ACCEPT_HEADER = "HTTP_ACCEPT"
11
+
12
+ # Returns the id of the Turbo Frame that made this request, if any.
13
+ #
14
+ # @return [String, nil]
15
+ #
16
+ # @api public
17
+ def turbo_frame
18
+ get_header(TURBO_FRAME_HEADER)
19
+ end
20
+
21
+ # Returns true if this request was made by a Turbo Frame.
22
+ #
23
+ # @return [Boolean]
24
+ #
25
+ # @api public
26
+ def turbo_frame?
27
+ !turbo_frame.nil?
28
+ end
29
+
30
+ # Returns true if the request explicitly accepts Turbo Stream responses.
31
+ #
32
+ # Turbo adds the stream media type to the `Accept` header of form submissions. Unlike
33
+ # `#accept?`, this does not match wildcard `Accept` headers.
34
+ #
35
+ # @return [Boolean]
36
+ #
37
+ # @api public
38
+ def turbo_stream?
39
+ get_header(ACCEPT_HEADER).to_s.include?(MEDIA_TYPE)
40
+ end
41
+ end
42
+ end
43
+ end
44
+
45
+ Hanami::Action::Request.include(Hanami::Turbo::Request)
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hanami
4
+ module Turbo
5
+ # Turbo-aware rendering and redirects for `Hanami::Action::Response`.
6
+ #
7
+ # @api public
8
+ module Response
9
+ # Renders the view, choosing its format and layout for Turbo requests.
10
+ #
11
+ # - For Turbo Stream responses, renders the view's `.turbo_stream` template without a layout.
12
+ # If the view has no such template, switches the response to HTML and renders that instead,
13
+ # so Turbo never receives HTML labelled as a stream.
14
+ # - For Turbo Frame requests, renders with {Turbo.config.frame_layout}, when one is set.
15
+ #
16
+ # Any `format:` or `layout:` given explicitly takes precedence.
17
+ #
18
+ # @api public
19
+ def render(view, **input)
20
+ options = _turbo_render_options(view, input)
21
+ @_turbo_stream_rendered = (input[:format] || options[:format]) == FORMAT
22
+
23
+ super(view, **options, **input)
24
+ end
25
+
26
+ # Redirects to the given URL, defaulting to `303 See Other` for non-GET requests.
27
+ #
28
+ # @see Turbo.config.see_other_redirects
29
+ #
30
+ # @api public
31
+ def redirect_to(url, status: nil)
32
+ status ||= SEE_OTHER if _turbo_see_other?
33
+
34
+ status ? super(url, status:) : super(url)
35
+ end
36
+
37
+ # Returns true if the response is labelled a Turbo Stream but its body is not one.
38
+ #
39
+ # This happens for error responses that weren't rendered from a stream template, such as
40
+ # `halt 404`. Turbo would treat these as empty streams and silently do nothing.
41
+ #
42
+ # @api private
43
+ def turbo_stream_mismatch?
44
+ return false unless format == FORMAT && status >= 400
45
+ return false if @_turbo_stream_rendered
46
+
47
+ # Only inspect buffered bodies; leave streamed ones (e.g. from `send_file`) alone.
48
+ return false unless body.is_a?(Array)
49
+
50
+ !body.first.to_s.lstrip.start_with?("<turbo-stream")
51
+ end
52
+
53
+ SEE_OTHER = 303
54
+ private_constant :SEE_OTHER
55
+
56
+ private
57
+
58
+ def _turbo_render_options(view, input)
59
+ options = {}
60
+
61
+ if !input.key?(:format) && format == FORMAT
62
+ return {format: FORMAT, layout: false} if ViewInternals.template?(view, FORMAT)
63
+
64
+ # No stream template, so render HTML. Turbo handles HTML responses to form submissions
65
+ # (including those made inside frames) like any other page.
66
+ self.format = :html
67
+ end
68
+
69
+ if request&.turbo_frame? && !Turbo.config.frame_layout.nil? && input[:format] != FORMAT
70
+ options[:layout] = Turbo.config.frame_layout
71
+ end
72
+
73
+ options
74
+ end
75
+
76
+ def _turbo_see_other?
77
+ Turbo.config.see_other_redirects && request && !request.get? && !request.head?
78
+ end
79
+ end
80
+ end
81
+ end
82
+
83
+ Hanami::Action::Response.prepend(Hanami::Turbo::Response)
@@ -0,0 +1,169 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hanami
4
+ module Turbo
5
+ # Builds `<turbo-stream>` elements. Returned by the `turbo_stream` view helper.
6
+ #
7
+ # Each action takes a target (a DOM id string, or a record passed through {DomId.dom_id}), and
8
+ # its content as a string, a block, or a partial to render.
9
+ #
10
+ # Partials are rendered in the current format when such a partial exists (e.g.
11
+ # `_book.turbo_stream.erb`), and otherwise as HTML, so the same `_book.html.erb` partial can be
12
+ # used for full page renders and for streams.
13
+ #
14
+ # Add your own stream actions by including a module, as turbo-rails apps do with
15
+ # `Turbo::Streams::TagBuilder`. Methods can call {#action}, {#action_all}, or
16
+ # {#turbo_stream_action_tag}, so modules written for turbo-rails work unchanged:
17
+ #
18
+ # module MyApp::TurboStreamActions
19
+ # def toast(message, level: "info")
20
+ # action(:toast, nil, message:, level:)
21
+ # end
22
+ # end
23
+ #
24
+ # Hanami::Turbo::StreamBuilder.include(MyApp::TurboStreamActions)
25
+ #
26
+ # Then handle the action in JavaScript via `Turbo.StreamActions`.
27
+ #
28
+ # @example In a `.turbo_stream.erb` template
29
+ # <%= turbo_stream.append "books", partial: "books/book", locals: {book:} %>
30
+ # <%= turbo_stream.replace book do %>
31
+ # <p><%= book.title %></p>
32
+ # <% end %>
33
+ # <%= turbo_stream.remove book %>
34
+ #
35
+ # @api public
36
+ class StreamBuilder
37
+ # Actions that take content.
38
+ CONTENT_ACTIONS = %i[append prepend replace update before after].freeze
39
+
40
+ # @api private
41
+ def initialize(scope)
42
+ @scope = scope
43
+ end
44
+
45
+ CONTENT_ACTIONS.each do |name|
46
+ # Targets one element by DOM id.
47
+ define_method(name) do |target, content = nil, **options, &block|
48
+ action(name, target, content, **options, &block)
49
+ end
50
+
51
+ # Targets all elements matching a CSS selector.
52
+ define_method(:"#{name}_all") do |targets, content = nil, **options, &block|
53
+ action_all(name, targets, content, **options, &block)
54
+ end
55
+ end
56
+
57
+ # Removes the target element.
58
+ #
59
+ # @api public
60
+ def remove(target)
61
+ action(:remove, target)
62
+ end
63
+
64
+ # Removes all elements matching the CSS selector.
65
+ #
66
+ # @api public
67
+ def remove_all(targets)
68
+ action_all(:remove, targets)
69
+ end
70
+
71
+ # Asks the page to refresh itself (Turbo 8 page refreshes).
72
+ #
73
+ # @param request_id [String, nil] identifies the request, so the page that made it can ignore
74
+ # the refresh
75
+ #
76
+ # @api public
77
+ def refresh(request_id: nil, **attributes)
78
+ stream_tag(action: :refresh, "request-id": request_id, **attributes)
79
+ end
80
+
81
+ # Builds a stream element for any action, including custom ones.
82
+ #
83
+ # Any other keyword arguments become attributes of the `<turbo-stream>` element (except
84
+ # `partial:`, `locals:` and `method:`, which are used as described below). A `<template>` is
85
+ # included when there is content, and always for Turbo's built-in content actions.
86
+ #
87
+ # @param name [Symbol, String] the stream action
88
+ # @param target [Object, nil] DOM id string, a record, or nil for actions without a target
89
+ # @param content [String, nil] content for the element's `<template>`
90
+ # @param partial [String, nil] partial to render as the content
91
+ # @param locals [Hash] locals for the partial
92
+ # @param method [Symbol, nil] `:morph` to morph rather than replace (for `replace` and `update`)
93
+ #
94
+ # @api public
95
+ def action(name, target, content = nil, **options, &block)
96
+ target_attributes = target.nil? ? {} : {target: DomId.dom_id(target, inflector: inflector)}
97
+ stream(name, target_attributes, content, **options, &block)
98
+ end
99
+
100
+ # Builds a stream element for any action, targeting all elements matching a CSS selector.
101
+ #
102
+ # @api public
103
+ def action_all(name, targets, content = nil, **options, &block)
104
+ stream(name, {targets: targets}, content, **options, &block)
105
+ end
106
+
107
+ # Builds a stream element, with the same signature as turbo-rails' `turbo_stream_action_tag`.
108
+ #
109
+ # @example
110
+ # turbo_stream_action_tag :console_log, message: "Hello"
111
+ # turbo_stream_action_tag :notify, target: book, template: "<b>Saved</b>".html_safe
112
+ #
113
+ # @param name [Symbol, String] the stream action
114
+ # @param target [Object, nil] DOM id string or record
115
+ # @param targets [String, nil] CSS selector, instead of a target
116
+ # @param template [String, nil] content for the element's `<template>`
117
+ # @param attributes [Hash] other attributes for the `<turbo-stream>` element
118
+ #
119
+ # @api public
120
+ def turbo_stream_action_tag(name, target: nil, targets: nil, template: nil, **attributes)
121
+ if targets
122
+ action_all(name, targets, template, **attributes)
123
+ else
124
+ action(name, target, template, **attributes)
125
+ end
126
+ end
127
+
128
+ private
129
+
130
+ # The scope of the template being rendered. Custom action methods can use it for helpers,
131
+ # `routes` and locals (the equivalent of `@view_context` in turbo-rails).
132
+ #
133
+ # @api public
134
+ attr_reader :scope
135
+
136
+ def stream(name, target_attributes, content = nil, partial: nil, locals: {}, method: nil, **attributes, &block)
137
+ content = render_partial(partial, locals) if partial
138
+ content = block.call if block
139
+
140
+ if name.to_sym != :remove && (!content.nil? || CONTENT_ACTIONS.include?(name.to_sym))
141
+ template = scope.tag.template(content.to_s)
142
+ end
143
+
144
+ stream_tag(template, action: name, **target_attributes, method:, **attributes)
145
+ end
146
+
147
+ def render_partial(name, locals)
148
+ rendering = scope._rendering
149
+ format = rendering.format
150
+
151
+ if format == :html || ViewInternals.partial?(rendering, name, format)
152
+ return scope.render(name, **locals)
153
+ end
154
+
155
+ html_rendering = ViewInternals.with_format(rendering, :html, scope._context)
156
+ partial_locals = locals.empty? ? scope._locals : locals
157
+ html_rendering.partial(name, scope.class.new(locals: partial_locals, rendering: html_rendering))
158
+ end
159
+
160
+ def stream_tag(content = nil, **attributes)
161
+ scope.tag.turbo_stream(content.to_s.html_safe, **attributes.compact)
162
+ end
163
+
164
+ def inflector
165
+ scope.respond_to?(:_inflector, true) ? scope.send(:_inflector) : Turbo.inflector
166
+ end
167
+ end
168
+ end
169
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hanami
4
+ module Turbo
5
+ VERSION = "0.1.0"
6
+ end
7
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hanami
4
+ module Turbo
5
+ # The only place hanami-turbo reaches into hanami-view's private API.
6
+ #
7
+ # hanami-view has no public way to ask whether a template exists, or to render with a different
8
+ # format partway through a render. Keeping these calls together makes them easy to audit, and
9
+ # to replace if hanami-view gains public equivalents.
10
+ #
11
+ # @api private
12
+ module ViewInternals
13
+ module_function
14
+
15
+ # Returns true if the view has a template for the format.
16
+ def template?(view, format)
17
+ !view.rendering(format: format).renderer.send(:lookup, view.config.template, format).nil?
18
+ end
19
+
20
+ # Returns true if the rendering can find the partial in the format.
21
+ def partial?(rendering, name, format)
22
+ renderer = rendering.renderer
23
+ !renderer.send(:lookup, renderer.send(:name_for_partial, name), format).nil?
24
+ end
25
+
26
+ # Returns a copy of the rendering using a different format, sharing its config and context.
27
+ def with_format(rendering, format, context)
28
+ Hanami::View::Rendering.new(
29
+ config_data: rendering.renderer.send(:config_data),
30
+ format: format,
31
+ context: context
32
+ )
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry/configurable"
4
+ require "dry/inflector"
5
+ require "hanami"
6
+ require "hanami/action"
7
+ require "hanami/view"
8
+
9
+ require_relative "turbo/version"
10
+
11
+ module Hanami
12
+ # Hotwire Turbo support for Hanami apps.
13
+ #
14
+ # Requiring this file extends `Hanami::Action::Request`, `Hanami::Action::Response`,
15
+ # `Hanami::View::Part` and the standard Hanami view helpers. Call {.install} from your app class to
16
+ # register the Turbo Stream format.
17
+ #
18
+ # @example
19
+ # # config/app.rb
20
+ # require "hanami"
21
+ # require "hanami/turbo"
22
+ #
23
+ # module Bookshelf
24
+ # class App < Hanami::App
25
+ # Hanami::Turbo.install(config)
26
+ # end
27
+ # end
28
+ #
29
+ # @api public
30
+ module Turbo
31
+ extend Dry::Configurable
32
+
33
+ # The format name for Turbo Stream responses.
34
+ FORMAT = :turbo_stream
35
+
36
+ # The media type Turbo sends in `Accept` headers and expects on stream responses.
37
+ MEDIA_TYPE = "text/vnd.turbo-stream.html"
38
+
39
+ # @!attribute [rw] see_other_redirects
40
+ # Whether `res.redirect_to` defaults to `303 See Other` for non-GET requests. Turbo requires a
41
+ # 303 after PATCH, PUT and DELETE form submissions, since browsers repeat the original method
42
+ # when following a 302. An explicitly given `status:` is always respected.
43
+ #
44
+ # Defaults to true.
45
+ setting :see_other_redirects, default: true
46
+
47
+ # @!attribute [rw] frame_layout
48
+ # The layout to use when rendering HTML for a request made by a Turbo Frame (one with a
49
+ # `Turbo-Frame` header). Turbo only uses the matching `<turbo-frame>` from these responses, so
50
+ # rendering the full layout is wasted work.
51
+ #
52
+ # `nil` (the default) leaves the view's layout unchanged. `false` renders no layout. A string
53
+ # names a layout within the view's `layouts_dir`.
54
+ setting :frame_layout, default: nil
55
+
56
+ # Registers the `:turbo_stream` format with the given app config.
57
+ #
58
+ # `:turbo_stream` is added to the app's accepted formats, so that Turbo's `Accept` header
59
+ # negotiates a stream response. Apps that accept any format (the default) also get the `:all`
60
+ # format, which keeps them accepting any format, so installing Turbo never causes new 406
61
+ # responses.
62
+ #
63
+ # @param config [Hanami::Config] the app's config
64
+ #
65
+ # @return [void]
66
+ #
67
+ # @api public
68
+ def self.install(config)
69
+ formats = config.actions.formats
70
+ formats.register(FORMAT, MEDIA_TYPE)
71
+
72
+ # Assign `accepted` directly rather than using `accept`, which would make the first format the
73
+ # default response format.
74
+ formats.accepted = (formats.accepted.empty? ? [:all] : formats.accepted) | [FORMAT]
75
+ end
76
+
77
+ # @api private
78
+ def self.inflector
79
+ @inflector ||= Dry::Inflector.new
80
+ end
81
+ end
82
+ end
83
+
84
+ require_relative "turbo/view_internals"
85
+ require_relative "turbo/dom_id"
86
+ require_relative "turbo/request"
87
+ require_relative "turbo/response"
88
+ require_relative "turbo/action"
89
+ require_relative "turbo/stream_builder"
90
+ require_relative "turbo/helpers"
91
+ require_relative "turbo/part"
metadata ADDED
@@ -0,0 +1,96 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: hanami-turbo
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Andrea Fomera
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: hanami
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '2.3'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '2.3'
26
+ - !ruby/object:Gem::Dependency
27
+ name: hanami-action
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '2.3'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '2.3'
40
+ - !ruby/object:Gem::Dependency
41
+ name: hanami-view
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: '2.3'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: '2.3'
54
+ description: Turbo Stream rendering, Turbo Frame helpers, and Turbo-friendly redirects
55
+ for Hanami
56
+ email:
57
+ - afomera@hey.com
58
+ executables: []
59
+ extensions: []
60
+ extra_rdoc_files: []
61
+ files:
62
+ - CHANGELOG.md
63
+ - LICENSE.md
64
+ - README.md
65
+ - lib/hanami/turbo.rb
66
+ - lib/hanami/turbo/action.rb
67
+ - lib/hanami/turbo/dom_id.rb
68
+ - lib/hanami/turbo/helpers.rb
69
+ - lib/hanami/turbo/part.rb
70
+ - lib/hanami/turbo/request.rb
71
+ - lib/hanami/turbo/response.rb
72
+ - lib/hanami/turbo/stream_builder.rb
73
+ - lib/hanami/turbo/version.rb
74
+ - lib/hanami/turbo/view_internals.rb
75
+ homepage: https://hanamirb.org
76
+ licenses:
77
+ - MIT
78
+ metadata: {}
79
+ rdoc_options: []
80
+ require_paths:
81
+ - lib
82
+ required_ruby_version: !ruby/object:Gem::Requirement
83
+ requirements:
84
+ - - ">="
85
+ - !ruby/object:Gem::Version
86
+ version: '3.3'
87
+ required_rubygems_version: !ruby/object:Gem::Requirement
88
+ requirements:
89
+ - - ">="
90
+ - !ruby/object:Gem::Version
91
+ version: '0'
92
+ requirements: []
93
+ rubygems_version: 4.0.20
94
+ specification_version: 4
95
+ summary: Hotwire Turbo support for Hanami apps
96
+ test_files: []