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
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module <%= namespace %>
4
+ module Actions
5
+ module InertiaExample
6
+ class Show < <%= namespace %>::Action
7
+ include InertiaHanami::Action
8
+
9
+ def handle(_request, _response)
10
+ inertia_render("InertiaExample/Show", props: { greeting: "Hello from inertia_hanami" })
11
+ end
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1 @@
1
+ <%%# Inertia renders this page client-side; the layout's inertia_root(page:) carries the data. %>
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module <%= namespace %>
4
+ module Views
5
+ module InertiaExample
6
+ class Show < <%= namespace %>::View
7
+ expose :page, layout: true
8
+ expose :ssr_head, layout: true
9
+ expose :ssr_body, layout: true
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,214 @@
1
+ # frozen_string_literal: true
2
+
3
+ module InertiaHanami
4
+ # Include-able into `Hanami::Action` subclasses to speak the Inertia
5
+ # protocol: skips Hanami's automatic view rendering for Inertia XHR
6
+ # requests, renders the Inertia page envelope, accumulates shared props,
7
+ # and handles the external-redirect (409 + X-Inertia-Location) case.
8
+ #
9
+ # Follows Hanami's own composition idiom (`include Inertia::Action`)
10
+ # rather than requiring a subclass of a framework-specific base class.
11
+ module Action
12
+ # Session key used to stash validation errors across a redirect, mirroring
13
+ # inertia-rails' `session[:inertia_errors]`. Cleared as soon as it's read
14
+ # into the next response's `errors` prop.
15
+ INERTIA_ERRORS_SESSION_KEY = "inertia_errors"
16
+
17
+ def self.included(action_class)
18
+ super
19
+
20
+ action_class.extend(ClassMethods)
21
+ action_class.include(InstanceMethods)
22
+
23
+ action_class.append_before do |req, res|
24
+ @inertia_context[:request] = req
25
+ @inertia_context[:response] = res
26
+ end
27
+
28
+ share_errors_and_flash(action_class)
29
+ end
30
+
31
+ # Auto-shared on every request: `errors` (from the session, stashed via
32
+ # `share_inertia_errors`) and `flash` (from Hanami's flash). Registered
33
+ # before any `inertia_share` calls in the including class's own body, so
34
+ # those can override either key. No-ops entirely when the app hasn't
35
+ # enabled sessions (`session_enabled?` defaults to `false` on
36
+ # `Hanami::Action`).
37
+ def self.share_errors_and_flash(action_class)
38
+ action_class.inertia_share do
39
+ next {} unless session_enabled?
40
+
41
+ { errors: inertia_errors_prop, flash: inertia_flash_prop }.compact
42
+ end
43
+ end
44
+ private_class_method :share_errors_and_flash
45
+
46
+ # Class-level `inertia_share` macro, inherited down subclasses.
47
+ module ClassMethods
48
+ def inertia_shared_props
49
+ inherited_inertia_shared_props.merge(@inertia_shared_props ||= {})
50
+ end
51
+
52
+ def inertia_shared_blocks
53
+ inherited_inertia_shared_blocks + (@inertia_shared_blocks ||= [])
54
+ end
55
+
56
+ def inertia_share(**props, &block)
57
+ (@inertia_shared_props ||= {}).merge!(props)
58
+ (@inertia_shared_blocks ||= []) << block if block
59
+ end
60
+
61
+ # Class-level default for whether pages rendered by this action (and
62
+ # its subclasses, unless they override it) should have their Inertia
63
+ # history entry encrypted client-side. Falls back to the global
64
+ # `Hanami.app["inertia.config"].encrypt_history` default when unset
65
+ # anywhere in the ancestry (see `inertia_encrypt_history?`).
66
+ def encrypt_history(value: true)
67
+ @inertia_encrypt_history = value
68
+ end
69
+
70
+ def inertia_encrypt_history?
71
+ return @inertia_encrypt_history if defined?(@inertia_encrypt_history)
72
+
73
+ inherited_inertia_encrypt_history?
74
+ end
75
+
76
+ private
77
+
78
+ def inherited_inertia_shared_props
79
+ superclass.respond_to?(:inertia_shared_props) ? superclass.inertia_shared_props : {}
80
+ end
81
+
82
+ def inherited_inertia_shared_blocks
83
+ superclass.respond_to?(:inertia_shared_blocks) ? superclass.inertia_shared_blocks : []
84
+ end
85
+
86
+ def inherited_inertia_encrypt_history?
87
+ superclass.respond_to?(:inertia_encrypt_history?) ? superclass.inertia_encrypt_history? : nil
88
+ end
89
+ end
90
+
91
+ # Instance-level Inertia API mixed into the including action class.
92
+ module InstanceMethods
93
+ # Runs before the object is frozen (Hanami::Action freezes instances at
94
+ # the end of #initialize), so @inertia_context can hold a mutable Hash
95
+ # to stash the request/response for later use by frozen instance methods.
96
+ #
97
+ # Deliberately `(**kwargs)` and not `(...)`: dry-auto_inject's
98
+ # MethodParameters treats `(...)` as a full signature rather than a
99
+ # pass-through to skip past (mirroring ROM's delegation convention), so
100
+ # with `(...)` it stops here instead of reaching `Hanami::Action#initialize`
101
+ # - any `Deps[]` dependency keyword then gets forwarded all the way down
102
+ # and rejected as unknown. `(**kwargs)` is recognized as pass-through.
103
+ def initialize(**kwargs)
104
+ @inertia_context = {}
105
+ super(**kwargs)
106
+ end
107
+
108
+ def auto_render?(res)
109
+ return false if RequestContext.new(res.env).inertia?
110
+
111
+ super
112
+ end
113
+
114
+ def inertia_share(**props, &block)
115
+ instance_props = (@inertia_context[:instance_shared_props] ||= {})
116
+ instance_props.merge!(props)
117
+ (@inertia_context[:instance_shared_blocks] ||= []) << block if block
118
+ end
119
+
120
+ # Instance-level override of the class's `encrypt_history` default,
121
+ # for a single action instance.
122
+ def encrypt_history(value: true)
123
+ @inertia_context[:instance_encrypt_history] = value
124
+ end
125
+
126
+ # Marks the next `inertia_render` call's response as `clearHistory:
127
+ # true`, telling the client to wipe any encrypted history it has
128
+ # stored (e.g. call this before redirecting on logout).
129
+ def clear_history
130
+ @inertia_context[:instance_clear_history] = true
131
+ end
132
+
133
+ def inertia_render(component, props: {}, url: nil, version: nil,
134
+ encrypt_history: inertia_history_encrypted?, clear_history: inertia_history_cleared?)
135
+ Renderer.new(
136
+ request: @inertia_context[:request],
137
+ response: @inertia_context[:response],
138
+ component: component,
139
+ props: inertia_collected_props.merge(props),
140
+ url: url,
141
+ version: version || Hanami.app["inertia.config"].version,
142
+ encrypt_history: encrypt_history,
143
+ clear_history: clear_history
144
+ ).render
145
+ end
146
+
147
+ def inertia_location(url)
148
+ request = @inertia_context[:request]
149
+ response = @inertia_context[:response]
150
+
151
+ if RequestContext.new(request.env).inertia?
152
+ response.headers["X-Inertia-Location"] = url
153
+ halt(409)
154
+ else
155
+ response.redirect_to(url)
156
+ end
157
+ end
158
+
159
+ # Stashes `errors` in the session so they surface as the `errors` prop
160
+ # on the next request's Inertia page (e.g. after a validation failure
161
+ # redirect). Analogous to inertia-rails' `redirect_to ..., inertia: {
162
+ # errors: ... }`.
163
+ def share_inertia_errors(errors)
164
+ hash = errors.respond_to?(:to_h) ? errors.to_h : errors
165
+ @inertia_context[:request].session[INERTIA_ERRORS_SESSION_KEY] = hash
166
+ end
167
+
168
+ private
169
+
170
+ def inertia_history_encrypted?
171
+ instance_value = @inertia_context[:instance_encrypt_history]
172
+ return instance_value unless instance_value.nil?
173
+
174
+ class_value = self.class.inertia_encrypt_history?
175
+ return class_value unless class_value.nil?
176
+
177
+ Hanami.app["inertia.config"].encrypt_history
178
+ end
179
+
180
+ def inertia_history_cleared?
181
+ @inertia_context[:instance_clear_history] || false
182
+ end
183
+
184
+ def inertia_collected_props
185
+ props = self.class.inertia_shared_props.dup
186
+ self.class.inertia_shared_blocks.each { |block| props.merge!(instance_exec(&block)) }
187
+ props.merge!(@inertia_context[:instance_shared_props]) if @inertia_context[:instance_shared_props]
188
+ (@inertia_context[:instance_shared_blocks] || []).each { |block| props.merge!(instance_exec(&block)) }
189
+ props
190
+ end
191
+
192
+ # Reads and clears any errors stashed via `share_inertia_errors`, so
193
+ # they're delivered exactly once. Falls back to an empty hash when
194
+ # `always_include_errors_hash` is enabled, or is omitted (nil) entirely
195
+ # otherwise.
196
+ def inertia_errors_prop
197
+ stashed = @inertia_context[:request].session.delete(INERTIA_ERRORS_SESSION_KEY)
198
+ return Props::Always.new(block: -> { stashed }) if stashed
199
+
200
+ return nil unless Hanami.app["inertia.config"].always_include_errors_hash
201
+
202
+ Props::Always.new(block: -> { {} })
203
+ end
204
+
205
+ # Shares the current request's flash messages, when there are any.
206
+ def inertia_flash_prop
207
+ flash = @inertia_context[:response].flash.now
208
+ return nil if flash.empty?
209
+
210
+ Props::Always.new(block: -> { flash })
211
+ end
212
+ end
213
+ end
214
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "pathname"
5
+
6
+ module InertiaHanami
7
+ # Derives an Inertia asset version string from hanami-assets' assets.json
8
+ # manifest, mirroring the role ViteRuby.digest plays for inertia-rails.
9
+ module AssetVersion
10
+ MANIFEST_FILENAME = "assets.json"
11
+
12
+ # Returns a SHA256 hex digest of the assets.json manifest under `assets_root`,
13
+ # or nil if no manifest is present (e.g. assets not yet compiled).
14
+ def self.digest(assets_root)
15
+ return nil if assets_root.nil?
16
+
17
+ manifest_path = Pathname(assets_root).join(MANIFEST_FILENAME)
18
+ return nil unless manifest_path.file?
19
+
20
+ Digest::SHA256.file(manifest_path).hexdigest
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "generators/inertia_hanami/install_generator"
4
+
5
+ module InertiaHanami
6
+ module CLI
7
+ module Commands
8
+ # `hanami generate inertia:install` - scaffolds the provider, middleware
9
+ # wiring, layout, view helper, and a sample page for inertia_hanami.
10
+ class Install < Hanami::CLI::Commands::App::Command
11
+ option :force, type: :flag, default: false, desc: "Overwrite existing files during generation"
12
+ option :framework, default: "react", values: InertiaHanami::Generators::InstallGenerator::FRAMEWORK_PACKAGES.keys,
13
+ desc: "Frontend framework for @inertiajs/* package.json guidance"
14
+
15
+ example [
16
+ %( (scaffolds provider, layout, helper, sample page, react npm guidance)),
17
+ %(--framework=vue (npm guidance targets @inertiajs/vue3 instead)),
18
+ %(--force (overwrite files this command previously generated))
19
+ ]
20
+
21
+ def call(force:, framework:, **)
22
+ InertiaHanami::Generators::InstallGenerator.new(
23
+ fs: fs, inflector: inflector, out: out
24
+ ).call(base_path: app.root, namespace: app.namespace, framework: framework, force: force)
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Registers `hanami generate inertia:install` with the Hanami CLI.
4
+ #
5
+ # Only loaded when `Hanami::CLI` is already defined (see
6
+ # `inertia_hanami.rb`) - i.e. when this gem is required from Bundler's
7
+ # `:cli` group, the same convention `hanami-reloader` and `hanami-rspec`
8
+ # use to hook into `hanami generate`/`hanami server` without hanami-cli
9
+ # needing to know about third-party gems in advance.
10
+
11
+ require "hanami/cli"
12
+ require "inertia_hanami/cli/commands/install"
13
+
14
+ if Hanami::CLI.within_hanami_app?
15
+ Hanami::CLI.register("generate inertia:install", InertiaHanami::CLI::Commands::Install)
16
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry/configurable"
4
+
5
+ module InertiaHanami
6
+ class Configuration
7
+ include Dry::Configurable
8
+
9
+ setting :version, default: nil
10
+ setting :root_view, default: "app"
11
+ setting :root_dom_id, default: "app"
12
+ setting :component_path_resolver, default: ->(component) { component }
13
+ setting :always_include_errors_hash, default: false
14
+ setting :encrypt_history, default: false
15
+
16
+ setting :ssr do
17
+ setting :enabled, default: false
18
+ setting :url, default: "http://localhost:13714"
19
+ setting :raise_on_error, default: false
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "cgi"
4
+ require "json"
5
+ require "hanami/view/html"
6
+
7
+ module InertiaHanami
8
+ # View helper that renders the Inertia root element for the initial
9
+ # full-page load. Include into a view/scope class, or call directly from
10
+ # an ERB template/layout.
11
+ module Helper
12
+ module_function
13
+
14
+ # Renders a `<script data-page="app" type="application/json">` tag
15
+ # holding the page JSON, plus the empty `<div id="app">` mount point.
16
+ # `@inertiajs/react|vue3|svelte`'s `createInertiaApp` (since Inertia
17
+ # v3) only reads the initial page from that script tag - it no longer
18
+ # falls back to a `data-page` attribute on the mount div - so this is
19
+ # the only form current clients will actually pick up.
20
+ def inertia_root(page:, id: nil)
21
+ root_id = CGI.escapeHTML((id || Hanami.app["inertia.config"].root_dom_id).to_s)
22
+ # Marked html_safe so Hanami::View's ERB engine doesn't re-escape the
23
+ # tags themselves. A `<script>` element's content is raw text per the
24
+ # HTML parsing spec - entities inside it are never decoded - so the
25
+ # JSON must NOT be HTML-entity-escaped (that would corrupt it before
26
+ # JSON.parse ever sees it); the only real risk is a literal
27
+ # `</script` sequence prematurely closing the tag, guarded separately.
28
+ <<~HTML.html_safe
29
+ <script data-page="#{root_id}" type="application/json">#{escape_script_content(page.to_json)}</script>
30
+ <div id="#{root_id}"></div>
31
+ HTML
32
+ end
33
+
34
+ def escape_script_content(json)
35
+ json.gsub("</", '<\/')
36
+ end
37
+
38
+ # Renders the `<head>` markup returned by the SSR server. Belongs inside
39
+ # the layout's `<head>` tag.
40
+ def inertia_ssr_head(head)
41
+ head.to_s.html_safe
42
+ end
43
+
44
+ # Renders the `<body>` markup returned by the SSR server, in place of
45
+ # the CSR `inertia_root` div.
46
+ def inertia_ssr_body(body)
47
+ body.to_s.html_safe
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rack/request"
4
+ require "rack/utils"
5
+
6
+ module InertiaHanami
7
+ module Middleware
8
+ # Bridges Inertia's client-side CSRF handshake to Hanami's own
9
+ # Hanami::Action::CSRFProtection, without reimplementing either side:
10
+ #
11
+ # - Inertia's HTTP client automatically reads an XSRF-TOKEN cookie and
12
+ # echoes it back as an X-XSRF-TOKEN header on every request.
13
+ # - Hanami::Action::CSRFProtection stores its challenge token in the
14
+ # session and only checks for it via the `_csrf_token` param or an
15
+ # X-CSRF-Token header — it never exposes the token as a cookie.
16
+ #
17
+ # This middleware translates between the two: an incoming X-XSRF-TOKEN
18
+ # header is copied into X-CSRF-Token before the request reaches the
19
+ # action, and once the action has minted/reused the session's CSRF
20
+ # token, it's mirrored into a readable XSRF-TOKEN cookie on the way out.
21
+ class Csrf
22
+ COOKIE_NAME = "XSRF-TOKEN"
23
+ # Hanami::Action::Request::Session#[]= always stringifies keys before
24
+ # writing to the underlying rack.session store, regardless of the
25
+ # symbol Hanami::Action::CSRFProtection itself indexes with - so the
26
+ # token lands under this string key, not :_csrf_token.
27
+ SESSION_KEY = "_csrf_token"
28
+
29
+ def initialize(app)
30
+ @app = app
31
+ end
32
+
33
+ def call(env)
34
+ bridge_incoming_token(env)
35
+
36
+ status, headers, body = @app.call(env)
37
+
38
+ expose_outgoing_token(env, headers)
39
+
40
+ [status, headers, body]
41
+ end
42
+
43
+ private
44
+
45
+ def bridge_incoming_token(env)
46
+ token = env["HTTP_X_XSRF_TOKEN"]
47
+ env["HTTP_X_CSRF_TOKEN"] ||= token if token
48
+ end
49
+
50
+ def expose_outgoing_token(env, headers)
51
+ session = env["rack.session"]
52
+ token = session && session[SESSION_KEY]
53
+ return unless token
54
+
55
+ request = Rack::Request.new(env)
56
+ Rack::Utils.set_cookie_header!(
57
+ headers, COOKIE_NAME,
58
+ { value: token, path: "/", secure: request.ssl?, same_site: :lax }
59
+ )
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rack/request"
4
+ require "uri"
5
+
6
+ module InertiaHanami
7
+ module Middleware
8
+ # Plain Rack middleware implementing the Inertia protocol's redirect
9
+ # mechanics:
10
+ #
11
+ # - 301/302 redirects issued in response to a PUT/PATCH/DELETE request are
12
+ # rewritten to 303, so the client's follow-up GET doesn't resubmit the
13
+ # original request body.
14
+ # - Redirects to an external origin are rewritten to a 409 response with
15
+ # an X-Inertia-Location header instead of a normal redirect, since
16
+ # Inertia's XHR-driven visits can't follow cross-origin redirects
17
+ # themselves; the client reads that header and performs a full
18
+ # browser visit instead.
19
+ #
20
+ # Both rewrites only apply to Inertia requests (X-Inertia header) —
21
+ # ordinary browser navigations are left untouched.
22
+ class Redirects
23
+ REWRITTEN_STATUSES = [301, 302].freeze
24
+ BODY_RESUBMITTING_METHODS = %w[PUT PATCH DELETE].freeze
25
+
26
+ def initialize(app)
27
+ @app = app
28
+ end
29
+
30
+ def call(env)
31
+ status, headers, body = @app.call(env)
32
+
33
+ request_context = RequestContext.new(env)
34
+ return [status, headers, body] unless request_context.inertia?
35
+
36
+ location = headers["Location"] || headers["location"]
37
+ return [status, headers, body] unless redirect?(status) && location
38
+
39
+ if external?(env, location)
40
+ return [409, { "X-Inertia-Location" => location }, [""]]
41
+ end
42
+
43
+ if REWRITTEN_STATUSES.include?(status) && BODY_RESUBMITTING_METHODS.include?(env["REQUEST_METHOD"])
44
+ status = 303
45
+ end
46
+
47
+ [status, headers, body]
48
+ end
49
+
50
+ private
51
+
52
+ def redirect?(status)
53
+ status.to_i.between?(300, 399)
54
+ end
55
+
56
+ def external?(env, location)
57
+ target = URI.parse(location)
58
+ return false if target.host.nil?
59
+
60
+ request = Rack::Request.new(env)
61
+ target.scheme != request.scheme || target.host != request.host || target.port != request.port
62
+ rescue URI::InvalidURIError
63
+ false
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rack/request"
4
+
5
+ module InertiaHanami
6
+ module Middleware
7
+ # Plain Rack middleware implementing the Inertia protocol's asset
8
+ # versioning handshake: on a GET Inertia request whose X-Inertia-Version
9
+ # header doesn't match the server's configured version, respond with a
10
+ # 409 + X-Inertia-Location (empty body) instead of calling downstream,
11
+ # so the client performs a full browser visit and picks up new assets.
12
+ class Version
13
+ def initialize(app)
14
+ @app = app
15
+ end
16
+
17
+ def call(env)
18
+ request_context = RequestContext.new(env)
19
+
20
+ if stale?(env, request_context)
21
+ request = Rack::Request.new(env)
22
+ return [409, { "X-Inertia-Location" => request.url }, [""]]
23
+ end
24
+
25
+ @app.call(env)
26
+ end
27
+
28
+ private
29
+
30
+ def stale?(env, request_context)
31
+ env["REQUEST_METHOD"] == "GET" &&
32
+ request_context.inertia? &&
33
+ request_context.version != Hanami.app["inertia.config"].version
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module InertiaHanami
4
+ # Resolves a props structure (plain values, Procs, Props::Base wrappers, and
5
+ # nested hashes thereof) against a Hanami action instance, so prop blocks can
6
+ # access the action's params/session/deps via instance_exec.
7
+ class PropEvaluator
8
+ def initialize(action)
9
+ @action = action
10
+ end
11
+
12
+ def evaluate(props)
13
+ case props
14
+ when Hash
15
+ props.transform_values { |value| evaluate(value) }
16
+ when Props::Base
17
+ props.with(block: resolved_block(props.block))
18
+ when Proc
19
+ evaluate(@action.instance_exec(&props))
20
+ else
21
+ props
22
+ end
23
+ end
24
+
25
+ private
26
+
27
+ def resolved_block(block)
28
+ value = evaluate(@action.instance_exec(&block))
29
+ -> { value }
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module InertiaHanami
4
+ # Prop wrapper classes controlling how individual props are resolved and
5
+ # included in the Inertia response (partial reloads, deferred loading, etc).
6
+ module Props
7
+ # Base class for all prop wrappers. Built on Ruby's `Data`, so wrappers are
8
+ # immutable value objects with structural equality and no ActiveSupport
9
+ # dependency.
10
+ class Base < Data
11
+ def resolve
12
+ block.call
13
+ end
14
+ end
15
+
16
+ # A prop that is only included in the response when explicitly requested
17
+ # by the client (via a partial reload).
18
+ Optional = Base.define(:block)
19
+
20
+ # A prop that is always included in the response, even during a partial
21
+ # reload that would otherwise exclude it.
22
+ Always = Base.define(:block)
23
+
24
+ # A prop that is loaded in a subsequent request after the initial page
25
+ # load, batched together by `group`.
26
+ Defer = Base.define(:group, :block) do
27
+ def initialize(block:, group: "default")
28
+ super
29
+ end
30
+ end
31
+
32
+ # A prop that is evaluated only once and cached by the client.
33
+ Once = Base.define(:key, :fresh, :expires_in, :block) do
34
+ def initialize(block:, key: nil, fresh: false, expires_in: nil)
35
+ super
36
+ end
37
+
38
+ # Calculates the expiration timestamp in milliseconds.
39
+ #
40
+ # @return [Integer, nil] the expiration time as a Unix timestamp in milliseconds, or nil if no expiration
41
+ def expires_at
42
+ return unless expires_in
43
+
44
+ ((Time.now + expires_in).to_f * 1_000).to_i
45
+ end
46
+ end
47
+
48
+ # A prop whose value is merged with the existing client-side prop of the
49
+ # same name, instead of replacing it outright.
50
+ Merge = Base.define(:block, :deep_merge, :match_on) do
51
+ def initialize(block:, deep_merge: false, match_on: nil)
52
+ super
53
+ end
54
+ end
55
+
56
+ # A prop driving the client's infinite-scroll feature: merged (appended
57
+ # or prepended, per the X-Inertia-Infinite-Scroll-Merge-Intent header)
58
+ # instead of replacing the existing client-side prop, with pagination
59
+ # metadata surfaced via the response's `scrollProps` map.
60
+ Scroll = Base.define(:block, :match_on, :page_name, :previous_page, :next_page, :current_page) do
61
+ # rubocop:disable Metrics/ParameterLists -- one kwarg per client-visible scrollProps field
62
+ def initialize(block:, match_on: nil, page_name: "page", previous_page: nil, next_page: nil, current_page: nil)
63
+ super
64
+ end
65
+ # rubocop:enable Metrics/ParameterLists
66
+ end
67
+ end
68
+ end