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.
- checksums.yaml +7 -0
- data/.rspec +1 -0
- data/CHANGELOG.md +18 -0
- data/CODE_OF_CONDUCT.md +10 -0
- data/LICENSE.txt +21 -0
- data/PLAN.md +193 -0
- data/README.md +417 -0
- data/Rakefile +16 -0
- data/lib/generators/inertia_hanami/install_generator.rb +216 -0
- data/lib/generators/inertia_hanami/templates/helpers.rb.erb +12 -0
- data/lib/generators/inertia_hanami/templates/layout.html.erb.erb +15 -0
- data/lib/generators/inertia_hanami/templates/provider.rb.erb +5 -0
- data/lib/generators/inertia_hanami/templates/sample_action.rb.erb +15 -0
- data/lib/generators/inertia_hanami/templates/sample_template.html.erb.erb +1 -0
- data/lib/generators/inertia_hanami/templates/sample_view.rb.erb +13 -0
- data/lib/inertia_hanami/action.rb +214 -0
- data/lib/inertia_hanami/asset_version.rb +23 -0
- data/lib/inertia_hanami/cli/commands/install.rb +29 -0
- data/lib/inertia_hanami/cli.rb +16 -0
- data/lib/inertia_hanami/configuration.rb +22 -0
- data/lib/inertia_hanami/helper.rb +50 -0
- data/lib/inertia_hanami/middleware/csrf.rb +63 -0
- data/lib/inertia_hanami/middleware/redirects.rb +67 -0
- data/lib/inertia_hanami/middleware/version.rb +37 -0
- data/lib/inertia_hanami/prop_evaluator.rb +32 -0
- data/lib/inertia_hanami/props.rb +68 -0
- data/lib/inertia_hanami/protocol_builder.rb +194 -0
- data/lib/inertia_hanami/provider.rb +33 -0
- data/lib/inertia_hanami/renderer.rb +92 -0
- data/lib/inertia_hanami/request_context.rb +75 -0
- data/lib/inertia_hanami/ssr_renderer.rb +66 -0
- data/lib/inertia_hanami/testing/rspec.rb +265 -0
- data/lib/inertia_hanami/version.rb +5 -0
- data/lib/inertia_hanami.rb +24 -0
- data/sig/inertia_hanami.rbs +4 -0
- 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,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>
|