inertia_hanami 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.
Files changed (36) hide show
  1. checksums.yaml +7 -0
  2. data/.rspec +1 -0
  3. data/CHANGELOG.md +18 -0
  4. data/CODE_OF_CONDUCT.md +10 -0
  5. data/LICENSE.txt +21 -0
  6. data/PLAN.md +193 -0
  7. data/README.md +417 -0
  8. data/Rakefile +16 -0
  9. data/lib/generators/inertia_hanami/install_generator.rb +216 -0
  10. data/lib/generators/inertia_hanami/templates/helpers.rb.erb +12 -0
  11. data/lib/generators/inertia_hanami/templates/layout.html.erb.erb +15 -0
  12. data/lib/generators/inertia_hanami/templates/provider.rb.erb +5 -0
  13. data/lib/generators/inertia_hanami/templates/sample_action.rb.erb +15 -0
  14. data/lib/generators/inertia_hanami/templates/sample_template.html.erb.erb +1 -0
  15. data/lib/generators/inertia_hanami/templates/sample_view.rb.erb +13 -0
  16. data/lib/inertia_hanami/action.rb +214 -0
  17. data/lib/inertia_hanami/asset_version.rb +23 -0
  18. data/lib/inertia_hanami/cli/commands/install.rb +29 -0
  19. data/lib/inertia_hanami/cli.rb +16 -0
  20. data/lib/inertia_hanami/configuration.rb +22 -0
  21. data/lib/inertia_hanami/helper.rb +50 -0
  22. data/lib/inertia_hanami/middleware/csrf.rb +63 -0
  23. data/lib/inertia_hanami/middleware/redirects.rb +67 -0
  24. data/lib/inertia_hanami/middleware/version.rb +37 -0
  25. data/lib/inertia_hanami/prop_evaluator.rb +32 -0
  26. data/lib/inertia_hanami/props.rb +68 -0
  27. data/lib/inertia_hanami/protocol_builder.rb +194 -0
  28. data/lib/inertia_hanami/provider.rb +33 -0
  29. data/lib/inertia_hanami/renderer.rb +92 -0
  30. data/lib/inertia_hanami/request_context.rb +75 -0
  31. data/lib/inertia_hanami/ssr_renderer.rb +66 -0
  32. data/lib/inertia_hanami/testing/rspec.rb +265 -0
  33. data/lib/inertia_hanami/version.rb +5 -0
  34. data/lib/inertia_hanami.rb +24 -0
  35. data/sig/inertia_hanami.rbs +4 -0
  36. metadata +190 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 4c9dd275f387237e18c63c965c3aeb67ec946f4f3b949aaf80ebd381faa99385
4
+ data.tar.gz: 932b6b557d24d5ccfdcf382070b59e3c6c7de7bbca19ea0b4bb60f049f4b8c20
5
+ SHA512:
6
+ metadata.gz: 14fb6ba4fd996b0b78047aab232392a1087604dc337f0d1bb476cecee2ff05b4074e5ca2f000d02c3d87cac729a7a87bb681edd9d50bd8e300a911c0017d1989
7
+ data.tar.gz: d7b920f79d30cc0473b83a7887a9c99997b27759ed6ff5e29e17e7a7df34bc40209a7309c92e9d749ffa84a3b9c8554d14bc004450986c47a9db3efb8a1a014c
data/.rspec ADDED
@@ -0,0 +1 @@
1
+ --require spec_helper
data/CHANGELOG.md ADDED
@@ -0,0 +1,18 @@
1
+ ## [Unreleased]
2
+
3
+ - Add encrypted history support: `Configuration#encrypt_history` global default, class-level
4
+ `encrypt_history` macro (inherited down subclasses), and instance-level `encrypt_history`/
5
+ `clear_history` overrides for `Action#inertia_render`'s `encryptHistory`/`clearHistory`
6
+ envelope flags.
7
+ - Fix `Props::Once` support end-to-end: `RequestContext` now parses the
8
+ `X-Inertia-Except-Once-Props` header and feeds it into `ProtocolBuilder`, and the `onceProps`
9
+ envelope entries now include the `prop` dot-path the client's caching logic requires (was
10
+ previously omitted, so the once-cache could never engage). `fresh: true` now correctly forces
11
+ re-resolution even when the client reports the prop as cached.
12
+ - Add `Props::Scroll` for the client's infinite-scroll feature: merges (appended or prepended,
13
+ per the `X-Inertia-Infinite-Scroll-Merge-Intent` header) instead of replacing the existing
14
+ prop, and reports pagination metadata via the response's `scrollProps` map.
15
+
16
+ ## [0.1.0] - 2026-08-20
17
+
18
+ - Initial release
@@ -0,0 +1,10 @@
1
+ # Code of Conduct
2
+
3
+ "inertia_hanami" follows [The Ruby Community Conduct Guideline](https://www.ruby-lang.org/en/conduct) in all "collaborative space", which is defined as community communications channels (such as mailing lists, submitted patches, commit comments, etc.):
4
+
5
+ * Participants will be tolerant of opposing views.
6
+ * Participants must ensure that their language and actions are free of personal attacks and disparaging personal remarks.
7
+ * When interpreting the words and actions of others, participants should always assume good intentions.
8
+ * Behaviour which can be reasonably considered harassment will not be tolerated.
9
+
10
+ If you have any concerns about behaviour within this project, please contact us at ["mcpaul2058@gmail.com"](mailto:"mcpaul2058@gmail.com").
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Paul Oguda
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/PLAN.md ADDED
@@ -0,0 +1,193 @@
1
+ # inertia_hanami — Implementation Plan
2
+
3
+ Server-side adapter implementing the [Inertia.js protocol](https://inertiajs.com/docs/v2/installation/community-adapters)
4
+ for [Hanami](https://hanakai.org/hanami) (targeting **Hanami 3.0+**, Ruby 3.3+).
5
+
6
+ No prior Hanami/Inertia integration exists (verified via GitHub/forum search) — this is greenfield.
7
+ Design is synthesized from two existing Ruby adapters:
8
+
9
+ - **[inertia-rails](https://github.com/inertiajs/inertia-rails)** — the reference implementation. Full protocol
10
+ coverage (deferred/merge/once props, SSR, encrypted history, testing helpers). Deeply coupled to Rails/ActiveSupport
11
+ (`ActionController::Renderers`, `deep_merge!`, `HashWithIndifferentAccess`, Rails middleware stack, engine/railtie).
12
+ - **[inertia-rage](https://github.com/rage-rb/inertia-rage)** — proves the protocol is portable to a non-Rails,
13
+ non-ActiveSupport Ruby framework. Zero dependency on `inertia_rails`; clean-room reimplementation. Structurally the
14
+ closer template for us, since Rage (like Hanami) is Rack-based with its own controller/config/plugin conventions
15
+ and no ActiveSupport. It's missing SSR support and only ships RSpec helpers — gaps we should not blindly copy.
16
+
17
+ ## What to take from each, concretely
18
+
19
+ | Concern | Take from | Why |
20
+ |---|---|---|
21
+ | Protocol envelope shape, header names, prop-type taxonomy | inertia-rails | It's the canonical spec; inertia-rage's prop set is a subset |
22
+ | Partial-reload resolution algorithm (only/except/reset/dot-paths) | inertia-rails (`props_resolver.rb`), cross-checked against inertia-rage's `protocol_builder.rb` | Same algorithm, inertia-rage's version is already ActiveSupport-free and shorter — better starting point to port |
23
+ | Prop wrapper classes (`Optional`, `Defer`, `Merge`, `Always`, `Once`) | inertia-rage's `Data`-based implementation | Ruby's builtin `Data` class needs no ActiveSupport; inertia-rails uses plain classes + mixins, functionally equivalent but rage's is more idiomatic modern Ruby |
24
+ | Controller/action integration pattern | Neither directly — Hanami's own `#auto_render?` + module `include` idiom | Hanami actions favor composition (`include Inertia::Action`) over subclassing a framework base class the way both Rails (`ActionController::Base`) and Rage (`RageController::Inertia`) do |
25
+ | Middleware (version-mismatch 409, redirect-to-303 rewrite) | inertia-rails logic, inertia-rage's leaner Rack-only style | Same responsibilities, but implement as plain Rack middleware with no framework hooks, registered via `config.middleware.use` |
26
+ | CSRF handling | **Neither** — reuse Hanami/Rack's own CSRF middleware | inertia-rails uses Rails' token CSRF; inertia-rage hand-rolls `Sec-Fetch-Site` checks. Hanami ships its own session/CSRF story via `hanami-controller` — bridge to that instead of reimplementing either |
27
+ | Configuration mechanism | Hanami's own `register_provider` / container DI | Neither Rails initializers nor Rage's `Rage::Extension` apply; Hanami's provider system is the idiomatic equivalent |
28
+ | Asset versioning | Concept from both (`ssr_bundle`/manifest hash, Vite manifest) | Adapted to `hanami-assets`' `assets.json` manifest instead of Vite's `manifest.json` |
29
+ | SSR | inertia-rails only (inertia-rage has none) | Defer to a later phase; inertia-rails' `SSRRenderer` (POST page JSON to a Node process, cache by content hash) is the pattern to port |
30
+ | Testing helpers | inertia-rails' RSpec matcher set (broader than inertia-rage's) | Port `have_props`, `have_exact_props`, `render_component`, `be_inertia_response`, etc. |
31
+
32
+ ## Target Hanami integration points (from architecture research)
33
+
34
+ - `Hanami::Action#handle(request, response)` is the Rack entrypoint. `response.format=`, `response.body=`,
35
+ `response.headers`, `response[:key]=` (exposures) are all directly settable — no forced rendering step.
36
+ - `#auto_render?(response)` returning `false` is the exact bypass point for JSON-only Inertia XHR responses (avoids
37
+ Hanami auto-invoking the paired `Views::*` class for `X-Inertia: true` requests).
38
+ - Layouts (`config.layout = "app"`, `app/templates/layouts/app.html.erb` convention — confirmed against
39
+ `hanami-cli`'s app generator and `hanami-view`'s `layouts_dir`/`layout` config) are the analog of Rails'
40
+ `application.html.erb` — used only for the **initial full-page load**, rendering `<div id="app" data-page="...">`.
41
+ Note: `app/views/**/*.rb` holds `Hanami::View` subclasses; ERB markup (including layouts) lives under
42
+ `app/templates/**`, not `app/views/**` as an earlier draft of this doc assumed.
43
+ - View helpers meant to be callable unqualified from ERB templates/layouts (e.g. `inertia_root`) must be mixed
44
+ into `<App>::Views::Helpers` (`app/views/helpers.rb`), not into the `View` base class — Hanami's
45
+ `SliceConfiguredHelpers` auto-includes that module into the app's `Scope`/`Part` classes
46
+ (`hanami/extensions/view/slice_configured_helpers.rb`), whereas including into `Hanami::View` itself has no
47
+ effect on template scope.
48
+ - **Action exposures do not automatically become template/layout locals.** `response[:key] = value` only reaches
49
+ a view template as a local if the paired `Hanami::View` subclass declares `expose :key` (or a matching instance
50
+ method); unexposed keys are silently dropped (`Hanami::View::Exposures#call`). Further, **the layout template
51
+ receives a stricter, opt-in subset of the main template's locals** — only exposures declared
52
+ `expose :key, layout: true` are forwarded into the layout's scope (`Exposure#for_layout?` defaults to `false`).
53
+ So `inertia_root(page: page)` in a layout requires `expose :page, layout: true` in the view. (Verified
54
+ empirically against installed `hanami-view` 3.0.2; its own YARD example implying automatic pass-through does
55
+ not match runtime behavior.)
56
+ - `config/providers/*.rb` + `Hanami.app.register_provider` is where gem-level configuration
57
+ (`version`, `ssr_url`, `component_path_resolver`, etc.) gets registered as a container component
58
+ (`"inertia.config"`), consumed via `include Deps["inertia.config"]` — the Hanami equivalent of
59
+ `InertiaRails.configure`.
60
+ - Verified against the installed `hanami` (3.0.2) / `dry-system` (1.2.5) / `dry-configurable` (1.4.0)
61
+ gem source (Hanami's own built-in providers, e.g. `Hanami::Providers::DB`, `Hanami::Providers::I18n`,
62
+ follow the same pattern):
63
+ - **Namespacing is not automatic.** `register("config", obj)` inside a provider named `:inertia`
64
+ only resolves to container key `"inertia.config"` when the provider is registered with
65
+ `namespace: true` (`Hanami.app.register_provider(:inertia, namespace: true, source: InertiaHanami::Provider)`).
66
+ Without it, the key would just be `"config"`.
67
+ - **dry-configurable 1.4 does not delegate setting names onto the including instance.** A class that
68
+ `include Dry::Configurable` and defines `setting :version` does *not* get an instance method
69
+ `#version` — settings are only reachable via `#config` (e.g. `instance.config.version`). Confirmed
70
+ empirically (`instance.respond_to?(:version) # => false`) and in every built-in Hanami provider,
71
+ which reads its own settings as `config.foo`, never `self.foo`.
72
+ - Consequence for this doc's literal usage below (`Hanami.app["inertia.config"].version`) to hold with
73
+ no extra `.config.` hop: the provider must register the `Dry::Configurable::Config` object itself —
74
+ `register("config", InertiaHanami::Configuration.new.config)` — not the `Configuration` instance.
75
+ - `config.middleware.use` in `config/app.rb` registers Rack middleware app-wide; per-route middleware is also
76
+ supported via `use` inside route `scope` blocks.
77
+ - `hanami-assets` compiles to `public/assets/` with a manifest `assets.json` (source → hashed filename) — this
78
+ supplies the Inertia asset **version** string (hash of `assets.json`), same role Vite's `manifest.json` plays for
79
+ inertia-rage.
80
+ - Action testing is DI-based (`Action.new(deps...).call(params)`, no HTTP roundtrip needed) — good fit for unit
81
+ testing the prop-resolution engine directly; `rack-test` (bundled) covers full-stack request specs.
82
+
83
+ ## Gem architecture
84
+
85
+ ```
86
+ lib/inertia_hanami.rb # entrypoint, requires + configure block
87
+ lib/inertia_hanami/version.rb
88
+ lib/inertia_hanami/configuration.rb # `include Dry::Configurable`; settings (version, ssr, root_view,
89
+ # component_path_resolver, root_dom_id...) read via #config, dry-configurable 1.4 style
90
+ lib/inertia_hanami/provider.rb # Hanami::Provider::Source; registers Configuration.new.config as "config" under
91
+ # `register_provider(:inertia, namespace: true, ...)` -> resolves to "inertia.config"
92
+ lib/inertia_hanami/props.rb # Optional/Defer/Merge/Always/Once wrappers, Ruby Data-based (port from inertia-rage)
93
+ lib/inertia_hanami/prop_evaluator.rb # resolves Proc/BaseProp props against action instance
94
+ lib/inertia_hanami/protocol_builder.rb # partial-reload resolution: only/except/reset/dot-paths (port+adapt from inertia-rage)
95
+ lib/inertia_hanami/request_context.rb # parses X-Inertia-Partial-* / X-Inertia-Version headers off Rack env
96
+ lib/inertia_hanami/renderer.rb # builds the page envelope hash, decides JSON vs HTML branch
97
+ lib/inertia_hanami/action.rb # `Inertia::Action` module: include-able into Hanami::Action subclasses
98
+ # - #auto_render?(response) => false when request.inertia?
99
+ # - #inertia_render(component, props: {}, **opts)
100
+ # - #inertia_share(**props) class + instance macro
101
+ # - #inertia_location(url) for external redirects
102
+ lib/inertia_hanami/middleware/version.rb # 409 + X-Inertia-Location on version mismatch (GET only)
103
+ lib/inertia_hanami/middleware/redirects.rb # rewrite 301/302 -> 303 for PUT/PATCH/DELETE inertia requests
104
+ lib/inertia_hanami/ssr_renderer.rb # (phase 3) POST page JSON to Node SSR server, cache by digest
105
+ lib/inertia_hanami/helper.rb # view helper: inertia_root(page) -> data-page div/script tag
106
+ lib/inertia_hanami/errors.rb
107
+ lib/inertia_hanami/testing/rspec.rb # matchers: have_props, have_exact_props, be_inertia_response, render_component...
108
+
109
+ app/templates/layouts/app.html.erb (generator template) -> renders <%= inertia_root(page: page) %>
110
+ app/views/helpers.rb # `include InertiaHanami::Helper` into <App>::Views::Helpers,
111
+ # so `inertia_root` is callable unqualified in templates
112
+ lib/generators/inertia_hanami/install_generator.rb # (phase 2) scaffolds provider, layout, npm deps, example page
113
+ ```
114
+
115
+ ## Protocol mechanics (ported from inertia-rails spec, verified against inertia-rage's leaner implementation)
116
+
117
+ - **Detection**: `request.env['HTTP_X_INERTIA'] == 'true'` (mirrors `ActionDispatch::Request#inertia?`).
118
+ - **Response envelope**: `{component, props, url, version, encryptHistory, clearHistory}` (+ `deferredProps`,
119
+ `mergeProps`, `deepMergeProps`, `matchPropsOn` metadata when relevant props are present).
120
+ - **On Inertia request** (`X-Inertia: true` header present): set `X-Inertia: true` response header,
121
+ `response.format = :json`, `response.body = [page.to_json]`. Skip Hanami's auto view rendering via `#auto_render? => false`.
122
+ - **On initial load** (no `X-Inertia` header): render the layout with `inertia_root(page:)` embedding
123
+ `<div id="app" data-page="#{page.to_json}"></div>`.
124
+ - **Version mismatch**: `Middleware::Version` compares `X-Inertia-Version` request header against
125
+ `Hanami.app["inertia.config"].version` on GET only; mismatch → `409` + `X-Inertia-Location` (empty body), so the
126
+ client does a full browser visit and picks up new assets.
127
+ - **Redirects**: rewrite `301/302` → `303` when the original method was PUT/PATCH/DELETE, so the browser doesn't
128
+ resubmit the body on the client-side follow-up GET. External-origin redirects on an Inertia request become
129
+ `409 + X-Inertia-Location` instead of a normal redirect (SPA can't follow cross-origin redirects via XHR).
130
+ - **Partial reloads**: `X-Inertia-Partial-Component` must match the component about to render, else partial mode is
131
+ ignored. `X-Inertia-Partial-Data` (only) / `X-Inertia-Partial-Except` (except) are comma-separated dot-paths.
132
+ `AlwaysProp` bypasses filtering; `Optional`/`Defer` props are excluded unless explicitly requested; `Once` props
133
+ are excluded once already sent (tracked via `X-Inertia-Reset` / an except-once header) — port inertia-rage's
134
+ `ProtocolBuilder` (~180 lines, already ActiveSupport-free) rather than inertia-rails' more Rails-idiomatic version.
135
+ - **Shared props**: `inertia_share(**hash, &block)` macro accumulates into an instance-level hash, block form
136
+ `instance_exec`'d against the action at render time, merged (shallow, no need to reimplement `deep_merge!` unless
137
+ a real use case demands it — start simple).
138
+
139
+ ## Phased roadmap
140
+
141
+ **Phase 0 — scaffolding & spec baseline**
142
+ - Fill in gemspec (summary, homepage, dependencies: none required at runtime besides Ruby stdlib `json`; dev deps:
143
+ `hanami`, `hanami-controller`, `hanami-view`, `rack-test`, `rspec`).
144
+ - Set up a `spec/dummy_app` (minimal Hanami 3 app) for integration specs, matching how inertia-rails/inertia-rage
145
+ test against real dummy apps rather than mocks.
146
+
147
+ **Phase 1 — MVP protocol (no SSR, no generators)**
148
+ - `Configuration` + provider registration.
149
+ - `Props` wrappers + `PropEvaluator` + `ProtocolBuilder` (ported from inertia-rage, adapted naming).
150
+ - `RequestContext` header parsing.
151
+ - `Renderer` + `Action` module (`inertia_render`, `inertia_share`, `#auto_render?` override).
152
+ - `Middleware::Version`, `Middleware::Redirects`.
153
+ - `Helper#inertia_root` + example layout template.
154
+ - ~~Manual end-to-end test: dummy app renders a component, partial reload works, version-mismatch 409 works.~~ —
155
+ **Verified**: `spec/inertia_hanami/integration_spec.rb` exercises all three against the dummy app (both
156
+ middlewares now registered via `config.middleware.use` in `spec/dummy_app/config/app.rb`).
157
+
158
+ **Phase 2 — DX & polish**
159
+ - Install generator (scaffolds provider file, layout, adds `@inertiajs/*` guidance to package.json, sample page).
160
+ - Asset-version integration: derive `Configuration#version` automatically from `hanami-assets`' `assets.json` digest
161
+ when not explicitly set.
162
+ - RSpec testing helpers (`have_props`, `have_exact_props`, `have_no_prop`, `be_inertia_response`,
163
+ `render_component`, partial-reload request helpers).
164
+ - `flash`/`errors` prop wiring (Hanami session integration) analogous to inertia-rails' `always_include_errors_hash`.
165
+
166
+ **Phase 3 — SSR (optional, follow inertia-rails' design)**
167
+ - `SSRRenderer`: POST `page.to_json` to a configurable `ssr_url` (Node process), expects `{head:, body:}` JSON,
168
+ render into the layout in place of the CSR div, cache by content digest, fall back to CSR on error
169
+ (`ssr_raise_on_error` config toggle). No process-supervision plugin needed initially (unlike inertia-rails' Puma
170
+ plugin) — document running the Node SSR server as a separate process.
171
+
172
+ **Phase 4 — protocol completeness / parity audit**
173
+ - ~~Encrypted history (`encryptHistory`/`clearHistory` session flags).~~ — **Done**: envelope
174
+ plumbing (`Renderer`/`Action#inertia_render` kwargs) already existed; added the sticky
175
+ mechanism — `Configuration#encrypt_history` global default, class-level `encrypt_history`
176
+ macro (inherited down subclasses), instance-level `encrypt_history`/`clear_history`
177
+ overrides, all resolved in `Action::InstanceMethods#inertia_history_encrypted?` /
178
+ `#inertia_history_cleared?`. See `spec/inertia_hanami/action_spec.rb`'s "encrypted history"
179
+ block and `spec/inertia_hanami/configuration_spec.rb`.
180
+ - `Once`/scroll/infinite-scroll props if there's demand.
181
+ - CSRF: confirm Hanami/Rack session middleware already covers the `X-XSRF-TOKEN` ↔ cookie handshake Inertia's
182
+ client expects; add a thin bridge only if a gap is found — don't hand-roll CSRF from scratch like inertia-rage did.
183
+
184
+ ## Open questions to resolve empirically against a real Hanami 3.0 app (docs were incomplete on these)
185
+
186
+ 1. ~~Exact default layout file path/naming convention in Hanami 3.0~~ — **Resolved**: confirmed
187
+ `app/templates/layouts/app.html.erb` (default layout name `"app"`) against `hanami-cli`'s app generator and
188
+ `hanami-view`'s config; see "Target Hanami integration points" above.
189
+ 2. Whether a provider can push onto `config.middleware` at boot time, or whether middleware registration must live
190
+ in `config/app.rb` directly (affects whether the gem can self-register middleware via an install generator vs.
191
+ requiring a manual `config.middleware.use InertiaHanami::Middleware::Version` line).
192
+ 3. Per-route middleware/`use` block scoping semantics — needed if we want an opt-in per-route Inertia mode rather
193
+ than app-wide.