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
data/README.md ADDED
@@ -0,0 +1,417 @@
1
+ # InertiaHanami
2
+
3
+ Server-side adapter implementing the [Inertia.js protocol](https://inertiajs.com/) for the
4
+ [Hanami](https://hanamirb.org) web framework.
5
+
6
+ ## Installation
7
+
8
+ Add the gem to your Hanami app's Gemfile in **both** the default and `:cli` groups. The `:cli`
9
+ group is required for the `hanami generate inertia:install` command below to be available -
10
+ Bundler's `Hanami::CLI::Bundler.require(:cli)` is what loads it before `hanami` dispatches
11
+ commands:
12
+
13
+ ```ruby
14
+ gem "inertia_hanami", groups: [:default, :cli]
15
+ ```
16
+
17
+ Then run:
18
+
19
+ ```bash
20
+ bundle install
21
+ bundle exec hanami generate inertia:install
22
+ ```
23
+
24
+ This scaffolds:
25
+
26
+ - `config/providers/inertia.rb` - registers the gem's configuration with the app container.
27
+ - `config.middleware.use InertiaHanami::Middleware::Version` / `::Redirects` / `::Csrf` in
28
+ `config/app.rb`.
29
+ - `app/templates/layouts/app.html.erb` - the initial full-page-load layout, rendering
30
+ `<%= inertia_root(page: page) %>`.
31
+ - `app/views/helpers.rb` - includes `InertiaHanami::Helper` so `inertia_root` is callable
32
+ unqualified from templates.
33
+ - A sample page (`app/actions/inertia_example/show.rb`, `app/views/inertia_example/show.rb`,
34
+ `app/templates/inertia_example/show.html.erb`) plus a matching route in `config/routes.rb`.
35
+ - `@inertiajs/*` frontend packages merged into `package.json` (or printed as `npm install`
36
+ guidance if no `package.json` exists yet).
37
+
38
+ Pass `--framework=vue` or `--framework=svelte` to target a different Inertia client adapter
39
+ (defaults to `react`), and `--force` to overwrite files it previously generated. Run
40
+ `bundle exec hanami generate inertia:install --help` for details.
41
+
42
+ Finally, run `npm install` (or yarn/pnpm) to install the frontend packages. Wiring up the JS
43
+ entrypoint (Vite/`hanami-assets` config) is outside this gem's scope - it only manages the
44
+ Ruby/server side of the Inertia protocol.
45
+
46
+ ### Additional app setup the generator doesn't do for you
47
+
48
+ - **JSON body parsing.** Inertia's client sends `application/json` request bodies for
49
+ `post`/`put`/`patch`/`delete` visits and form submissions (except when uploading files, which
50
+ switch to `multipart/form-data`). Hanami doesn't parse JSON bodies into `params` out of the box,
51
+ so without this, `request.params` will be empty and any `params do ... end` contract will fail
52
+ validation on every request. Add to `config/app.rb`:
53
+
54
+ ```ruby
55
+ require "hanami/middleware/body_parser"
56
+
57
+ module MyApp
58
+ class App < Hanami::App
59
+ config.middleware.use Hanami::Middleware::BodyParser, :json
60
+ # ...
61
+ end
62
+ end
63
+ ```
64
+
65
+ - **Sessions.** `errors`, `flash`, and CSRF protection are all no-ops until
66
+ `config.actions.sessions` is configured (see [Actions - Sessions](https://hanakai.org/learn/hanami/actions/sessions/)
67
+ for the setup, plus a `session_secret` app setting). `InertiaHanami::Middleware::Csrf` in
68
+ particular does nothing useful without sessions enabled.
69
+
70
+ - **Client package versions.** This gem speaks the current Inertia protocol, which as of Inertia
71
+ v3 embeds the initial page via a `<script data-page="app" type="application/json">` tag rather
72
+ than a `data-page` attribute on the root `<div>` - `inertia_root` renders both. If you pin an
73
+ older `@inertiajs/*` client version, check that it still reads the script-tag form; very old
74
+ client versions only supported the div-attribute form and won't hydrate.
75
+
76
+ ## Usage
77
+
78
+ Include `InertiaHanami::Action` in an action to speak the Inertia protocol from it. This skips
79
+ Hanami's automatic view rendering for Inertia XHR requests, renders the Inertia page envelope for
80
+ both XHR and full-page-load requests, and gives you `inertia_render`, `inertia_share`,
81
+ `inertia_location`, and friends:
82
+
83
+ ```ruby
84
+ module MyApp
85
+ module Actions
86
+ module Dashboard
87
+ class Show < MyApp::Action
88
+ include InertiaHanami::Action
89
+
90
+ def handle(req, res)
91
+ inertia_render "Dashboard/Show", props: { name: "Ada" }
92
+ end
93
+ end
94
+ end
95
+ end
96
+ end
97
+ ```
98
+
99
+ `inertia_render` accepts `component:` (implicit, first positional arg), `props:`, `url:` (defaults
100
+ to the current request URL), `version:` (defaults to the configured asset version), and
101
+ `encrypt_history:` / `clear_history:` (see below). It's usually cleanest to include
102
+ `InertiaHanami::Action` once in a shared base action class rather than in every action.
103
+
104
+ ### Sharing props across actions
105
+
106
+ `inertia_share` (class-level) declares props merged into every `inertia_render` call from that
107
+ action class and its subclasses - handy on a base action for data every page needs:
108
+
109
+ ```ruby
110
+ class ApplicationAction < MyApp::Action
111
+ include InertiaHanami::Action
112
+
113
+ inertia_share app_name: "My App"
114
+
115
+ # Block form is instance_exec'd at render time, so it can call other
116
+ # action instance methods (current_user, session, etc.).
117
+ inertia_share do
118
+ { current_user: current_user&.to_h }
119
+ end
120
+ end
121
+ ```
122
+
123
+ Call `inertia_share` again (with or without a block) from inside `#handle` to add props scoped to
124
+ that single request instance only, without affecting other instances of the action:
125
+
126
+ ```ruby
127
+ def handle(req, res)
128
+ inertia_share breadcrumbs: build_breadcrumbs(req)
129
+ inertia_render "Posts/Show"
130
+ end
131
+ ```
132
+
133
+ Both class- and instance-level shared props are merged in before the `props:` you pass directly to
134
+ `inertia_render`, so a per-render prop always wins over a shared one with the same key.
135
+
136
+ ### Errors and flash
137
+
138
+ Two props are auto-shared on every `inertia_render` call (only when the app has sessions enabled):
139
+
140
+ - `flash` - the current request's flash messages, when there are any.
141
+ - `errors` - validation errors stashed via `share_inertia_errors`, delivered once and then cleared
142
+ from the session. Mirrors inertia-rails' `redirect_to ..., inertia: { errors: ... }`:
143
+
144
+ ```ruby
145
+ def handle(req, res)
146
+ form = PostForm.new(req.params)
147
+ return inertia_render("Posts/New") if req.get?
148
+
149
+ if form.invalid?
150
+ share_inertia_errors(form.errors)
151
+ res.redirect_to(routes.path(:new_post))
152
+ return
153
+ end
154
+
155
+ # ...
156
+ end
157
+ ```
158
+
159
+ Set `config.always_include_errors_hash = true` (see Configuration below) to always send an
160
+ `errors: {}` prop even when nothing was stashed, instead of omitting the key entirely.
161
+
162
+ ### External redirects
163
+
164
+ Inertia's XHR-driven visits can't follow a redirect to a different origin. `inertia_location(url)`
165
+ handles this: on an Inertia request it sets `X-Inertia-Location` and responds `409` so the client
166
+ performs a full browser visit; on a non-Inertia request it's a normal redirect:
167
+
168
+ ```ruby
169
+ def handle(req, res)
170
+ inertia_location("https://example.com/checkout")
171
+ end
172
+ ```
173
+
174
+ ### Encrypted history
175
+
176
+ When enabled, the client encrypts its Inertia history state (useful for pages with sensitive data
177
+ you don't want recoverable via the browser's back button after logout). Set the default globally
178
+ via `config.encrypt_history` (see Configuration below), override it per action class with
179
+ `encrypt_history`, or per instance/request by calling the instance method inside `#handle`:
180
+
181
+ ```ruby
182
+ class Settings::Show < MyApp::Action
183
+ include InertiaHanami::Action
184
+
185
+ encrypt_history # defaults to true; pass value: false to opt an action out
186
+ end
187
+ ```
188
+
189
+ `clear_history` marks the *next* `inertia_render` call's response as `clearHistory: true`, telling
190
+ the client to wipe any encrypted history it has stored - call it before redirecting on logout:
191
+
192
+ ```ruby
193
+ def handle(req, res)
194
+ clear_history
195
+ res.redirect_to(routes.path(:root))
196
+ end
197
+ ```
198
+
199
+ ### Configuration
200
+
201
+ Configured in `config/providers/inertia.rb` (scaffolded by the install generator):
202
+
203
+ ```ruby
204
+ Hanami.app.register_provider(:inertia, namespace: true) do
205
+ start do
206
+ configure do |config|
207
+ config.version = nil # default: digest of assets.json, if present
208
+ config.root_view = "app" # default
209
+ config.root_dom_id = "app" # default
210
+ config.component_path_resolver = ->(component) { component } # default: identity
211
+ config.always_include_errors_hash = false # default
212
+ config.encrypt_history = false # default
213
+ end
214
+ end
215
+ end
216
+ ```
217
+
218
+ - `version` - the asset version string sent to the client and checked against
219
+ `X-Inertia-Version` by `InertiaHanami::Middleware::Version` (a mismatch triggers a full reload
220
+ so the client picks up new assets). Defaults to a SHA256 digest of hanami-assets'
221
+ `assets.json` manifest when present, or `nil` otherwise.
222
+ - `root_view` - the view rendered for the initial full-page (non-Inertia) load.
223
+ - `root_dom_id` - the `id` of the div `inertia_root` renders, matching the client's mount point.
224
+ - `component_path_resolver` - a callable mapping the string passed to `inertia_render` to the
225
+ actual client-side component path, if you want the two to differ.
226
+ - `always_include_errors_hash` - see Errors and flash above.
227
+ - `encrypt_history` - see Encrypted history above.
228
+ - `ssr.*` - see Server-side rendering (SSR) below.
229
+
230
+ ### Props
231
+
232
+ Wrap a value in one of `InertiaHanami::Props`' wrapper classes to control how it's resolved and
233
+ included in the response. There's no factory-method DSL - construct them directly with `.new`:
234
+
235
+ ```ruby
236
+ def handle(req, res)
237
+ inertia_render "Users/Index", props: {
238
+ # Only sent when explicitly requested via a partial reload (`only:`).
239
+ stats: InertiaHanami::Props::Optional.new(block: -> { expensive_stats }),
240
+
241
+ # Always sent, even during a partial reload that would otherwise exclude it.
242
+ permissions: InertiaHanami::Props::Always.new(block: -> { current_user.permissions }),
243
+
244
+ # Loaded in a follow-up request after the initial page load. Props sharing the same
245
+ # `group:` (default `"default"`) are batched into one follow-up request.
246
+ notifications: InertiaHanami::Props::Defer.new(group: "sidebar", block: -> { fetch_notifications }),
247
+
248
+ # Resolved once and cached client-side; the client tells the server what it already has
249
+ # via X-Inertia-Except-Once-Props, so the block isn't re-run on subsequent visits.
250
+ locale_options: InertiaHanami::Props::Once.new(block: -> { available_locales }),
251
+
252
+ # Merged into the existing client-side prop instead of replacing it.
253
+ comments: InertiaHanami::Props::Merge.new(match_on: "id", block: -> { Comment.recent })
254
+ }
255
+ end
256
+ ```
257
+
258
+ - `Once#key:` - the cache key reported in `onceProps`; defaults to the prop's dot-path.
259
+ - `Once#fresh:` - when `true`, always re-resolves and re-sends the prop even if the client
260
+ reports it as cached (bypasses `X-Inertia-Except-Once-Props`). Defaults to `false`.
261
+ - `Once#expires_in:` - a number of seconds after which the client should treat its cached copy
262
+ as stale and ask for it again. Defaults to `nil` (never expires).
263
+ - `Merge#deep_merge:` - deep-merges Hashes instead of the default shallow/array-append merge.
264
+ - `Merge#match_on:` - a dot-path (relative to the prop) identifying items by key during a merge,
265
+ so updates replace existing items instead of duplicating them (e.g. `"id"` for an array of
266
+ records).
267
+
268
+ #### Infinite scroll
269
+
270
+ `InertiaHanami::Props::Scroll` drives the client's infinite-scroll feature: it merges into the
271
+ existing client-side prop (appending on `fetchNext`, prepending on `fetchPrevious`, per the
272
+ `X-Inertia-Infinite-Scroll-Merge-Intent` request header the client sends) and reports pagination
273
+ metadata via the response's `scrollProps` map, which the client reads to know whether there's a
274
+ next/previous page to fetch:
275
+
276
+ ```ruby
277
+ def handle(req, res)
278
+ page = req.params[:page].to_i.nonzero? || 1
279
+ paginated = Post.page(page)
280
+
281
+ inertia_render "Posts/Index", props: {
282
+ posts: InertiaHanami::Props::Scroll.new(
283
+ match_on: "id",
284
+ current_page: paginated.current_page,
285
+ previous_page: paginated.prev_page,
286
+ next_page: paginated.next_page,
287
+ block: -> { paginated.to_a }
288
+ )
289
+ }
290
+ end
291
+ ```
292
+
293
+ - `page_name:` - the request param name the client increments as it scrolls (defaults to
294
+ `"page"`; must match the param your action reads, `req.params[:page]` above).
295
+ - `previous_page:` / `next_page:` - the page identifier to request next in each direction, or
296
+ `nil` when there's nothing more to load in that direction (the client stops fetching once
297
+ `nil`).
298
+ - `current_page:` - the page identifier just loaded, echoed back to the client.
299
+ - `match_on:` - same de-duping semantics as `Merge#match_on:` above; near-essential for infinite
300
+ scroll so re-fetched items replace rather than duplicate existing ones.
301
+
302
+ ### CSRF protection
303
+
304
+ CSRF is handled automatically once `config.actions.sessions` is configured and
305
+ `InertiaHanami::Middleware::Csrf` is registered (both done for you by
306
+ `hanami generate inertia:install`) - no app code required.
307
+
308
+ Hanami's own `Hanami::Action::CSRFProtection` (auto-included on every action when sessions are
309
+ enabled) stores its challenge token in the session and checks for it via an `X-CSRF-Token`
310
+ header. Inertia's client, on the other hand, automatically reads an `XSRF-TOKEN` cookie and
311
+ echoes it back as `X-XSRF-TOKEN` on every request - it never sends `X-CSRF-Token`. Neither side
312
+ needs to change to talk to the other; `InertiaHanami::Middleware::Csrf` just translates between
313
+ them: it mirrors the session's CSRF token into a readable `XSRF-TOKEN` cookie on responses, and
314
+ copies an incoming `X-XSRF-TOKEN` header into `X-CSRF-Token` before the request reaches the
315
+ action, so Hanami's own verification passes without either the client or the action needing to
316
+ know about the other's naming convention.
317
+
318
+ ### Server-side rendering (SSR)
319
+
320
+ By default, the initial page load is client-side rendered: the layout emits a
321
+ `<script data-page="app" type="application/json">` tag holding the page JSON plus an empty
322
+ `<div id="app">`, and the JS app hydrates it in the browser. Enabling SSR renders that markup up
323
+ front, on the server, by delegating to a separately-run Node process.
324
+
325
+ Configure it in `config/providers/inertia.rb`:
326
+
327
+ ```ruby
328
+ Hanami.app.register_provider(:inertia, namespace: true) do
329
+ # ...
330
+ configure do |config|
331
+ config.ssr.enabled = true
332
+ config.ssr.url = "http://localhost:13714" # default
333
+ config.ssr.raise_on_error = false # default: fall back to CSR on SSR failure
334
+ end
335
+ end
336
+ ```
337
+
338
+ - `ssr.enabled` - turn SSR on for full-page (non-`X-Inertia`) requests. Disabled by default.
339
+ - `ssr.url` - base URL of the Node SSR server. `InertiaHanami::SSRRenderer` POSTs the Inertia
340
+ page JSON to `#{ssr.url}/render`.
341
+ - `ssr.raise_on_error` - when the SSR server is unreachable or errors, `false` (default) silently
342
+ falls back to CSR for that request; `true` re-raises so the failure surfaces instead of being
343
+ masked.
344
+
345
+ Responses are cached in-process by a digest of the page JSON, so re-rendering an unchanged page
346
+ within the same process skips the HTTP round-trip.
347
+
348
+ **Running the Node SSR server.** This gem does not run or supervise the Node process for you -
349
+ unlike inertia-rails' bundled Puma plugin, there is no process-management integration here. Run it
350
+ as an independent process, exposing a `POST /render` endpoint that accepts the Inertia page JSON
351
+ and returns `{"head": "...", "body": "..."}` (an array of strings for `head` is also accepted). See
352
+ [inertia-rails' SSR server setup](https://inertia-rails.dev/guide/server-side-rendering) for the
353
+ JS-side implementation - the wire protocol is the same. For example, with a `package.json` script:
354
+
355
+ ```bash
356
+ node ssr/server.js # listens on the port configured via ssr.url, e.g. 13714
357
+ ```
358
+
359
+ Start it alongside your Hanami app (a `Procfile` entry, systemd unit, or `foreman start` are all
360
+ reasonable choices) before enabling `ssr.enabled` in production.
361
+
362
+ ## Testing
363
+
364
+ Require the RSpec matchers in your `spec/spec_helper.rb`:
365
+
366
+ ```ruby
367
+ require "inertia_hanami/testing/rspec"
368
+ ```
369
+
370
+ Then, in a request spec that `include`s `Rack::Test::Methods`:
371
+
372
+ ```ruby
373
+ RSpec.describe "GET /" do
374
+ include Rack::Test::Methods
375
+
376
+ def app = MyApp::App
377
+
378
+ it "renders the dashboard" do
379
+ get "/", {}, { "HTTP_X_INERTIA" => "true" }
380
+
381
+ expect(inertia).to be_inertia_response
382
+ expect(inertia).to render_component("Dashboard/Show")
383
+ expect(inertia).to have_props(name: "Ada")
384
+ expect(inertia).to have_exact_props(name: "Ada", role: "admin")
385
+ expect(inertia).to have_no_prop(:secret)
386
+ end
387
+ end
388
+ ```
389
+
390
+ `inertia_reload_only(*props)`, `inertia_reload_except(*props)`, and
391
+ `inertia_load_deferred_props(group = nil)` re-issue a GET against the last request's path
392
+ with the appropriate `X-Inertia-Partial-*` headers, so you can assert on the result of a
393
+ partial reload:
394
+
395
+ ```ruby
396
+ get "/", {}, { "HTTP_X_INERTIA" => "true" }
397
+ inertia_reload_only("name")
398
+ expect(inertia).to have_exact_props(name: "Ada")
399
+ ```
400
+
401
+ ## Development
402
+
403
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake test` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
404
+
405
+ To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
406
+
407
+ ## Contributing
408
+
409
+ Bug reports and pull requests are welcome on GitHub at https://github.com/kamalogudah/inertia_hanami. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](https://github.com/kamalogudah/inertia_hanami/blob/main/CODE_OF_CONDUCT.md).
410
+
411
+ ## License
412
+
413
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
414
+
415
+ ## Code of Conduct
416
+
417
+ Everyone interacting in the InertiaHanami project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/kamalogudah/inertia_hanami/blob/main/CODE_OF_CONDUCT.md).
data/Rakefile ADDED
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "minitest/test_task"
5
+
6
+ Minitest::TestTask.create
7
+
8
+ require "rubocop/rake_task"
9
+
10
+ RuboCop::RakeTask.new
11
+
12
+ require "rspec/core/rake_task"
13
+
14
+ RSpec::Core::RakeTask.new(:spec)
15
+
16
+ task default: %i[test rubocop]
@@ -0,0 +1,216 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "erb"
4
+ require "json"
5
+ require "dry/files"
6
+
7
+ module InertiaHanami
8
+ module Generators
9
+ # Scaffolds everything a Hanami app needs to start using inertia_hanami:
10
+ # the provider, middleware wiring, layout, view helper, a sample page,
11
+ # and @inertiajs/* package.json guidance.
12
+ #
13
+ # Mirrors the shape of hanami-cli's own generators (`fs:`, `inflector:`,
14
+ # `out:` injected, `#call` performs the work), so it composes naturally
15
+ # with `Hanami::CLI::Commands::App::Command` subclasses.
16
+ class InstallGenerator
17
+ TEMPLATES_DIR = File.expand_path("templates", __dir__)
18
+
19
+ FRAMEWORK_PACKAGES = {
20
+ "react" => %w[@inertiajs/react react react-dom],
21
+ "vue" => %w[@inertiajs/vue3 vue],
22
+ "svelte" => %w[@inertiajs/svelte svelte]
23
+ }.freeze
24
+
25
+ MIDDLEWARE = {
26
+ "version" => "Version",
27
+ "redirects" => "Redirects",
28
+ "csrf" => "Csrf"
29
+ }.freeze
30
+
31
+ def initialize(fs:, inflector:, out:)
32
+ @fs = fs
33
+ @inflector = inflector
34
+ @out = out
35
+ end
36
+
37
+ def call(base_path:, namespace:, framework: "react", force: false)
38
+ unless FRAMEWORK_PACKAGES.key?(framework.to_s)
39
+ raise ArgumentError, "unknown framework #{framework.inspect}, expected one of #{FRAMEWORK_PACKAGES.keys}"
40
+ end
41
+
42
+ @base_path = base_path.to_s
43
+ @namespace = namespace.to_s
44
+ @framework = framework.to_s
45
+ @force = force
46
+
47
+ generate_provider
48
+ generate_middleware
49
+ generate_layout
50
+ generate_helpers
51
+ generate_sample_page
52
+ generate_package_json_guidance
53
+
54
+ out.puts
55
+ out.puts "Next steps:"
56
+ out.puts " - Run `npm install` (or yarn/pnpm) to install the @inertiajs/* packages."
57
+ out.puts " - Wire up your JS entrypoint (Vite/hanami-assets config) - inertia_hanami " \
58
+ "does not manage frontend bundling."
59
+ end
60
+
61
+ private
62
+
63
+ attr_reader :fs, :inflector, :out, :base_path, :namespace, :framework, :force
64
+
65
+ def path(*parts)
66
+ fs.join(base_path, *parts)
67
+ end
68
+
69
+ def render(template)
70
+ erb_binding = binding
71
+ erb_binding.local_variable_set(:namespace, @namespace)
72
+ ERB.new(File.read(File.join(TEMPLATES_DIR, template)), trim_mode: "-").result(erb_binding)
73
+ end
74
+
75
+ def create(relative_path, content, force: self.force)
76
+ full_path = path(relative_path)
77
+
78
+ if fs.exist?(full_path) && !force
79
+ out.puts " skip #{relative_path} (already exists)"
80
+ return false
81
+ end
82
+
83
+ fs.write(full_path, content)
84
+ out.puts " create #{relative_path}"
85
+ true
86
+ end
87
+
88
+ def generate_provider
89
+ create("config/providers/inertia.rb", render("provider.rb.erb"))
90
+ end
91
+
92
+ def generate_middleware
93
+ app_file = path("config/app.rb")
94
+
95
+ unless fs.exist?(app_file)
96
+ out.puts " skip config/app.rb (not found - register the middleware manually)"
97
+ return
98
+ end
99
+
100
+ content = fs.read(app_file)
101
+
102
+ if middleware_registered?(content)
103
+ out.puts " skip config/app.rb (middleware already registered)"
104
+ return
105
+ end
106
+
107
+ inject_middleware_requires(app_file, content)
108
+ fs.inject_line_at_class_bottom(app_file, /class .* < Hanami::App/, middleware_use_lines)
109
+ out.puts " update config/app.rb"
110
+ rescue Dry::Files::MissingTargetError
111
+ out.puts " skip config/app.rb (unrecognized format - add the middleware manually: " \
112
+ "#{middleware_use_lines.join(" / ")})"
113
+ end
114
+
115
+ def middleware_registered?(content)
116
+ MIDDLEWARE.each_value.all? { |klass| content.include?(middleware_class(klass)) }
117
+ end
118
+
119
+ def inject_middleware_requires(app_file, content)
120
+ MIDDLEWARE.each_key do |name|
121
+ require_line = %(require "inertia_hanami/middleware/#{name}")
122
+ fs.inject_line_before(app_file, /^module /, require_line) unless content.include?(require_line)
123
+ end
124
+ end
125
+
126
+ def middleware_use_lines
127
+ MIDDLEWARE.each_value.map { |klass| "config.middleware.use #{middleware_class(klass)}" }
128
+ end
129
+
130
+ def middleware_class(klass)
131
+ "InertiaHanami::Middleware::#{klass}"
132
+ end
133
+
134
+ def generate_layout
135
+ create("app/templates/layouts/app.html.erb", render("layout.html.erb.erb"))
136
+ end
137
+
138
+ def generate_helpers
139
+ helpers_file = path("app/views/helpers.rb")
140
+
141
+ unless fs.exist?(helpers_file)
142
+ create("app/views/helpers.rb", render("helpers.rb.erb"))
143
+ return
144
+ end
145
+
146
+ content = fs.read(helpers_file)
147
+
148
+ if content.include?("InertiaHanami::Helper")
149
+ out.puts " skip app/views/helpers.rb (already includes InertiaHanami::Helper)"
150
+ return
151
+ end
152
+
153
+ unless content.include?('require "inertia_hanami/helper"')
154
+ fs.inject_line_before(helpers_file, /^module /, 'require "inertia_hanami/helper"')
155
+ end
156
+ fs.inject_line_at_class_bottom(helpers_file, /module Helpers/, "include InertiaHanami::Helper")
157
+ out.puts " update app/views/helpers.rb"
158
+ rescue Dry::Files::MissingTargetError
159
+ out.puts " skip app/views/helpers.rb (unrecognized format - add " \
160
+ "`include InertiaHanami::Helper` to it manually)"
161
+ end
162
+
163
+ def generate_sample_page
164
+ create("app/actions/inertia_example/show.rb", render("sample_action.rb.erb"))
165
+ create("app/views/inertia_example/show.rb", render("sample_view.rb.erb"))
166
+ create("app/templates/inertia_example/show.html.erb", render("sample_template.html.erb.erb"))
167
+
168
+ routes_file = path("config/routes.rb")
169
+ unless fs.exist?(routes_file)
170
+ out.puts " skip config/routes.rb (not found - add the route manually)"
171
+ return
172
+ end
173
+
174
+ content = fs.read(routes_file)
175
+ if content.include?('"inertia_example.show"')
176
+ out.puts " skip config/routes.rb (route already present)"
177
+ return
178
+ end
179
+
180
+ fs.inject_line_at_class_bottom(
181
+ routes_file, /class .* < Hanami::Routes/,
182
+ %(get "/inertia-example", to: "inertia_example.show")
183
+ )
184
+ out.puts " update config/routes.rb"
185
+ rescue Dry::Files::MissingTargetError
186
+ out.puts " skip config/routes.rb (unrecognized format - add the route manually: " \
187
+ 'get "/inertia-example", to: "inertia_example.show")'
188
+ end
189
+
190
+ def generate_package_json_guidance
191
+ packages = FRAMEWORK_PACKAGES.fetch(framework)
192
+ package_json_file = path("package.json")
193
+
194
+ unless fs.exist?(package_json_file)
195
+ out.puts
196
+ out.puts "No package.json found - install the frontend packages yourself:"
197
+ out.puts " npm install #{packages.join(" ")}"
198
+ return
199
+ end
200
+
201
+ data = JSON.parse(fs.read(package_json_file))
202
+ dependencies = (data["dependencies"] ||= {})
203
+ added = packages.reject { |pkg| dependencies.key?(pkg) }
204
+
205
+ if added.empty?
206
+ out.puts " skip package.json (@inertiajs/* dependencies already present)"
207
+ return
208
+ end
209
+
210
+ added.each { |pkg| dependencies[pkg] = "latest" }
211
+ fs.write(package_json_file, "#{JSON.pretty_generate(data)}\n")
212
+ out.puts " update package.json (added #{added.join(", ")})"
213
+ end
214
+ end
215
+ end
216
+ end
@@ -0,0 +1,12 @@
1
+ # auto_register: false
2
+ # frozen_string_literal: true
3
+
4
+ require "inertia_hanami/helper"
5
+
6
+ module <%= namespace %>
7
+ module Views
8
+ module Helpers
9
+ include InertiaHanami::Helper
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,15 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <title><%= namespace %></title>
6
+ <%%= inertia_ssr_head(ssr_head) if ssr_head %>
7
+ </head>
8
+ <body>
9
+ <%% if ssr_body %>
10
+ <%%= inertia_ssr_body(ssr_body) %>
11
+ <%% else %>
12
+ <%%= inertia_root(page: page) %>
13
+ <%% end %>
14
+ </body>
15
+ </html>
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "inertia_hanami/provider"
4
+
5
+ Hanami.app.register_provider(:inertia, namespace: true, source: InertiaHanami::Provider)