inertia-rage 0.0.1

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.
@@ -0,0 +1,161 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "net/http"
5
+
6
+ module Inertia
7
+ ##
8
+ # Manages frontend asset integration for Inertia responses.
9
+ #
10
+ class Frontend
11
+ class << self
12
+ # Returns the root directory of the frontend application.
13
+ #
14
+ # Uses {Configuration#frontend_path} if set, otherwise searches for a
15
+ # Vite config file in common locations to determine where the frontend
16
+ # source lives.
17
+ #
18
+ # @return [Pathname] path to the frontend root directory
19
+ # @raise [RuntimeError] if no Vite config file is found
20
+ def root
21
+ @root ||= Inertia.config.frontend_path || begin
22
+ vite_config = Rage.root.glob(["*/vite.config.{js,ts,mjs,mts}", "app/*/vite.config.{js,ts,mjs,mts}"]).first
23
+ raise "Vite config not found" unless vite_config
24
+
25
+ vite_config.dirname
26
+ end
27
+ end
28
+
29
+ # Returns the directory containing built frontend assets.
30
+ #
31
+ # Uses {Configuration#build_path} if set, otherwise defaults to the
32
+ # `dist` directory inside the frontend root.
33
+ #
34
+ # @return [Pathname] path to the build output directory
35
+ # @raise [RuntimeError] if no Vite config file is found
36
+ def dist
37
+ @dist ||= Inertia.config.build_path || root.join("dist")
38
+ end
39
+
40
+ # Returns a version identifier for the frontend assets.
41
+ #
42
+ # Computes an MD5 hash of the Vite manifest or index.html to detect
43
+ # when assets have changed, enabling Inertia's asset versioning.
44
+ #
45
+ # @return [String] MD5 hex digest of the manifest file
46
+ # @raise [RuntimeError] if no Vite config file is found
47
+ def version
48
+ @version ||= begin
49
+ manifest = dist.glob([".vite/manifest.json", "index.html"]).first
50
+ Digest::MD5.file(manifest.to_s).hexdigest if manifest
51
+ end
52
+ end
53
+
54
+ # Returns the command prefix for executing npm packages.
55
+ #
56
+ # Detects the package manager by checking for lock files and returns
57
+ # the appropriate command to run package binaries.
58
+ #
59
+ # @return [String] command prefix (e.g., "npx", "pnpm exec", "yarn")
60
+ # @raise [RuntimeError] if no supported package manager is detected
61
+ def package_runner
62
+ @package_runner ||= if root.join("package-lock.json").exist?
63
+ "npx"
64
+ elsif root.join("pnpm-lock.yaml").exist?
65
+ "pnpm exec"
66
+ elsif root.join("bun.lockb").exist? || root.join("bun.lock").exist?
67
+ "bun x --bun"
68
+ elsif root.join("yarn.lock").exist?
69
+ "yarn"
70
+ elsif root.join("deno.lock").exist?
71
+ "deno x"
72
+ else
73
+ raise "No supported package manager detected"
74
+ end
75
+
76
+ @package_runner
77
+ end
78
+
79
+ # Renders the HTML layout with the Inertia page object embedded.
80
+ #
81
+ # In development, it fetches the layout from the Vite dev server and
82
+ # rewrites relative asset paths to absolute URLs. In production, it
83
+ # reads the pre-built layout from disk and caches it.
84
+ #
85
+ # @param data [Hash] the Inertia page object to embed
86
+ # @return [String] HTML document with page data injected
87
+ def render_layout(data)
88
+ if Rage.env.development?
89
+ build_dynamic_layout(data)
90
+ else
91
+ build_static_layout(data)
92
+ end
93
+ end
94
+
95
+ private
96
+
97
+ # Fetches the layout from Vite dev server and rewrites asset URLs.
98
+ #
99
+ # Transforms relative paths in src/href attributes and ES module imports
100
+ # to point to the Vite dev server.
101
+ #
102
+ # @param data [Hash] the Inertia page object to embed
103
+ # @return [String] HTML with rewritten URLs and page data
104
+ def build_dynamic_layout(data)
105
+ config = Inertia.config.dev_server
106
+ dev_server_url = "http://#{config.host}:#{config.port}"
107
+
108
+ retries = 0
109
+ begin
110
+ layout = Net::HTTP.get(URI(dev_server_url))
111
+ rescue Errno::ECONNREFUSED, Errno::EBADF
112
+ raise if (retries += 1) > 5
113
+ sleep 0.2
114
+ retry
115
+ end
116
+
117
+ layout.gsub!(/(src|href)=(["'])\/([^"']+)\2/) do
118
+ "#{$1}=\"#{dev_server_url}/#{$3}\""
119
+ end
120
+
121
+ layout.gsub!(/from\s*(["'])\/([^"']+)\1/) do
122
+ "from \"#{dev_server_url}/#{$2}\""
123
+ end
124
+
125
+ inject_page_data(layout, data)
126
+ end
127
+
128
+ # Returns the cached static layout with page data.
129
+ #
130
+ # @param data [Hash] the Inertia page object to embed
131
+ # @return [String] HTML with page data injected
132
+ # @raise [RuntimeError] if index.html does not exist in build path
133
+ def build_static_layout(data)
134
+ @layout ||= begin
135
+ layout = dist.join("index.html")
136
+ raise "Production layout not found at #{layout}. Ensure the frontend has been built" unless layout.exist?
137
+ layout.read
138
+ end
139
+
140
+ inject_page_data(@layout, data)
141
+ end
142
+
143
+ # Injects the page object JSON into the HTML body.
144
+ #
145
+ # @param layout [String] the HTML layout template
146
+ # @param data [Hash] the Inertia page object
147
+ # @return [String] HTML with page data script tag inserted after <body>
148
+ def inject_page_data(layout, data)
149
+ json_data = data.to_json
150
+ json_data.gsub!("<", '\u003c') if json_data.include?("<")
151
+
152
+ layout.sub(/<body([^>]*)>/i) do |body_tag|
153
+ <<~HTML
154
+ #{body_tag}
155
+ <script data-page="app" type="application/json">#{json_data}</script>
156
+ HTML
157
+ end
158
+ end
159
+ end
160
+ end
161
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rage"
4
+
5
+ require_relative "props"
6
+ require_relative "frontend"
7
+ require_relative "renderer"
8
+ require_relative "request_context"
9
+ require_relative "protocol_builder"
10
+ require_relative "controller_helpers"
11
+ require_relative "configuration"
12
+ require_relative "middleware/version"
13
+ require_relative "middleware/assets"
14
+ require_relative "rage_extension"
15
+ require_relative "version"
16
+
17
+ module Inertia
18
+ # Configures the Inertia integration using a block.
19
+ #
20
+ # The block is evaluated in the context of the {Configuration} instance,
21
+ # allowing direct access to configuration setters.
22
+ #
23
+ # @yield Evaluates the block in the configuration context
24
+ # @example
25
+ # Inertia.configure do |config|
26
+ # config.frontend_path = "client"
27
+ # config.build_on_start = false
28
+ #
29
+ # config.dev_server.host = "0.0.0.0"
30
+ # config.dev_server.port = 3000
31
+ # end
32
+ def self.configure
33
+ yield config
34
+ end
35
+
36
+ # Returns the global configuration instance.
37
+ #
38
+ # @return [Configuration] the configuration object
39
+ def self.config
40
+ @config ||= Configuration.new
41
+ end
42
+
43
+ # @!group Props
44
+ # Creates a deferred prop that will be loaded in a subsequent request after the initial page load.
45
+ #
46
+ # @param group [String] the group name for batching deferred props together
47
+ # @yield the block that returns the prop value when evaluated
48
+ # @return [Props::Deferred] a deferred prop wrapper
49
+ # @example
50
+ # render inertia: "Users/Index", props: {
51
+ # users: Inertia.deferred { User.all }
52
+ # }
53
+ def self.deferred(group: "default", &block)
54
+ Props::Deferred.new(group:, block:)
55
+ end
56
+
57
+ # Creates a prop that is evaluated only once and cached by the frontend.
58
+ #
59
+ # @param key [String, nil] a unique cache key for the prop (auto-generated if `nil`)
60
+ # @param fresh [Boolean] forces re-evaluation when `true`
61
+ # @param expires_in [Integer, ActiveSupport::Duration, nil] cache expiration time in seconds
62
+ # @yield the block that returns the prop value when evaluated
63
+ # @return [Props::Once] a once-evaluated prop wrapper
64
+ # @example
65
+ # render inertia: "Dashboard", props: {
66
+ # stats: Inertia.once(expires_in: 300) { Stats.calculate }
67
+ # }
68
+ def self.once(key: nil, fresh: false, expires_in: nil, &block)
69
+ Props::Once.new(key:, fresh:, expires_in:, block:)
70
+ end
71
+
72
+ # Creates a prop that is only included when explicitly requested by the client.
73
+ #
74
+ # @yield the block that returns the prop value when evaluated
75
+ # @return [Props::Optional] an optional prop wrapper
76
+ # @example
77
+ # render inertia: "Users/Show", props: {
78
+ # user: user,
79
+ # permissions: Inertia.optional { user.permissions }
80
+ # }
81
+ def self.optional(&block)
82
+ Props::Optional.new(block:)
83
+ end
84
+ # @!endgroup
85
+
86
+ autoload :ViteDevServer, "inertia/vite_dev_server"
87
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Inertia
4
+ module Middleware
5
+ ##
6
+ # Serves built Vite assets in production using `x-sendfile` headers.
7
+ #
8
+ class Assets
9
+ EMPTY_BODY = [].freeze
10
+
11
+ def initialize(app)
12
+ @app = app
13
+ end
14
+
15
+ def call(env)
16
+ method = env["REQUEST_METHOD"]
17
+ return @app.call(env) unless method == "GET" || method == "HEAD"
18
+
19
+ raw_path = env["PATH_INFO"]
20
+ return @app.call(env) unless raw_path.start_with?("/assets/")
21
+
22
+ [
23
+ 200,
24
+ {
25
+ "x-sendfile" => raw_path.delete_prefix("/assets"),
26
+ "x-sendfile-root" => assets_root,
27
+ "cache-control" => "public, max-age=31536000, immutable"
28
+ },
29
+ EMPTY_BODY
30
+ ]
31
+ end
32
+
33
+ private
34
+
35
+ def assets_root
36
+ @assets_root ||= Frontend.dist.join("assets").realpath.to_s
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Inertia
4
+ module Middleware
5
+ ##
6
+ # Rack middleware that enforces Inertia asset version consistency.
7
+ #
8
+ # For GET requests carrying `X-Inertia-Version`, compares the client version
9
+ # with {Frontend.version}. A mismatch returns `409` with `X-Inertia-Location`,
10
+ # prompting the client to reload the current URL with the latest assets.
11
+ #
12
+ class Version
13
+ def initialize(app)
14
+ @app = app
15
+ end
16
+
17
+ def call(env)
18
+ return @app.call(env) unless env["REQUEST_METHOD"] == "GET"
19
+
20
+ client_version = env["HTTP_X_INERTIA_VERSION"]
21
+
22
+ if client_version.nil? || client_version == server_version
23
+ @app.call(env)
24
+ else
25
+ [409, { "x-inertia-location" => Rack::Request.new(env).url }, []]
26
+ end
27
+ end
28
+
29
+ private
30
+
31
+ def server_version
32
+ @server_version ||= Frontend.version
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Inertia
4
+ # Contains prop wrapper classes.
5
+ module Props
6
+ # Base class for all prop wrappers.
7
+ class Base < Data
8
+ def resolve
9
+ block.call
10
+ end
11
+ end
12
+
13
+ # A prop that is loaded in a subsequent request after the initial page load.
14
+ Deferred = Base.define(:group, :block)
15
+
16
+ # A prop that is evaluated only once.
17
+ Once = Base.define(:key, :fresh, :expires_in, :block) do
18
+ # Calculates the expiration timestamp in milliseconds.
19
+ #
20
+ # @return [Integer, nil] the expiration time as Unix timestamp in milliseconds, or nil if no expiration
21
+ def expires_at
22
+ return unless expires_in
23
+
24
+ ((Time.now + expires_in).to_f * 1_000).to_i
25
+ end
26
+ end
27
+
28
+ # A prop that is only included in the response when explicitly requested by the client.
29
+ Optional = Base.define(:block)
30
+ end
31
+ end
@@ -0,0 +1,209 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Inertia
4
+ ##
5
+ # Builds the Inertia page object for a rendered component.
6
+ #
7
+ # ProtocolBuilder resolves the server-side props hash into the shape expected
8
+ # by the Inertia protocol. It applies partial reload filtering, evaluates lazy
9
+ # values, walks nested hashes and arrays, and collects protocol metadata such
10
+ # as `deferredProps` and `onceProps`.
11
+ #
12
+ # Nested prop paths are tracked with dot notation, e.g. `auth.user.name`, so
13
+ # request headers can target either a top-level prop or a nested value.
14
+ #
15
+ class ProtocolBuilder
16
+ # Internal marker returned by resolver methods when a prop should be
17
+ # left out of the page object.
18
+ OMIT = Object.new
19
+ private_constant :OMIT
20
+
21
+ # Page props should contain an `errors` object that defaults to {}
22
+ EMPTY_ERRORS = {}.freeze
23
+ private_constant :EMPTY_ERRORS
24
+
25
+ # Creates a new protocol builder.
26
+ #
27
+ # @param component [String] the Inertia component name being rendered
28
+ # @param props [Hash] the props provided by the controller
29
+ # @param context [RequestContext] request-specific partial reload context
30
+ def initialize(component, props, context:)
31
+ @props = props
32
+ @context = context
33
+
34
+ @response = { component:, url: context.url, version: Frontend.version }
35
+ end
36
+
37
+ # Builds the page object hash.
38
+ #
39
+ # @return [Hash] the Inertia page object payload
40
+ def call
41
+ props = resolve_props
42
+ props[:errors] = EMPTY_ERRORS unless props.has_key?(:errors)
43
+
44
+ @response[:props] = props
45
+ @response[:deferredProps] = @deferred_props_metadata if @deferred_props_metadata
46
+ @response[:onceProps] = @once_props_metadata if @once_props_metadata
47
+
48
+ @response
49
+ end
50
+
51
+ private
52
+
53
+ # Resolves a hash of props into protocol-ready response props.
54
+ #
55
+ # `parent_was_resolved` is set when traversing a hash or array returned by a
56
+ # lazy prop. In that case, the parent prop was already selected for this
57
+ # response, so partial reload `only` filtering should not prune children from
58
+ # the computed value.
59
+ #
60
+ # This flag does not mean every child was explicitly requested. Deferred and
61
+ # optional props inside the computed value are included on matching partial
62
+ # reloads, but remain deferred/omitted on initial loads. Once props still use
63
+ # their own path/key for cache exclusion, so resolving a parent does not by
64
+ # itself bypass `X-Inertia-Except-Once-Props`.
65
+ #
66
+ # @param props [Hash] props to resolve at the current nesting level
67
+ # @param path [String, nil] dot-notated path prefix for nested props
68
+ # @param parent_was_resolved [Boolean] whether this level came from a lazy prop
69
+ # @return [Hash] resolved props for this nesting level
70
+ def resolve_props(props = @props, path: nil, parent_was_resolved: false)
71
+ props.each_with_object({}) do |(prop_name, prop), response|
72
+ prop_name_with_path = prop_with_path(prop_name, path)
73
+
74
+ prop_status = @context.prop_status(prop_name_with_path)
75
+ next if prop_status == :excluded && !parent_was_resolved
76
+
77
+ is_explicitly_requested = (prop_status == :requested)
78
+ should_include_from_resolved_parent = parent_was_resolved && @context.partial_render?
79
+ should_include_on_demand_prop = is_explicitly_requested || should_include_from_resolved_parent
80
+
81
+ resolved = if prop.respond_to?(:call)
82
+ # lazy props
83
+ resolve_lazy_prop(prop, path: prop_name_with_path)
84
+
85
+ elsif prop.is_a?(Inertia::Props::Deferred)
86
+ if should_include_on_demand_prop
87
+ # the deferred prop was requested
88
+ prop.resolve
89
+ else
90
+ # let the client know there're deferred props
91
+ deferred_props_metadata[prop.group] << prop_name_with_path
92
+ OMIT
93
+ end
94
+
95
+ elsif prop.is_a?(Inertia::Props::Once)
96
+ key = prop.key || prop_name_with_path
97
+
98
+ # include metadata unless explicitly excluded (`parent_was_resolved` path)
99
+ once_props_metadata[key] = { prop: prop_name_with_path, expiresAt: prop.expires_at } unless prop_status == :excluded
100
+ # resolve the prop value only if requested
101
+ is_explicitly_requested || prop.fresh || !@context.once_prop_excluded?(key) ? prop.resolve : OMIT
102
+
103
+ elsif prop.is_a?(Inertia::Props::Optional)
104
+ # excluded on initial page load
105
+ should_include_on_demand_prop ? prop.resolve : OMIT
106
+
107
+ elsif prop.is_a?(Hash)
108
+ # nested hash props
109
+ if prop.empty?
110
+ prop
111
+ else
112
+ nested_prop = resolve_props(prop, path: prop_name_with_path, parent_was_resolved:)
113
+ nested_prop.empty? ? OMIT : nested_prop
114
+ end
115
+
116
+ elsif prop.is_a?(Array)
117
+ # nested array props
118
+ resolve_array_prop(prop, path: prop_name_with_path, parent_was_resolved:)
119
+
120
+ else
121
+ # scalar prop
122
+ prop
123
+ end
124
+
125
+ # chained props and props returned from lazy props
126
+ if resolved.is_a?(Inertia::Props::Base)
127
+ prop = resolved
128
+ redo
129
+ end
130
+
131
+ response[prop_name] = resolved unless resolved.equal?(OMIT)
132
+ end
133
+ end
134
+
135
+ # Evaluates a lazy prop and recursively resolves nested data it returns.
136
+ #
137
+ # A lazy prop can return a plain scalar value, a hash, or an array. Hashes and
138
+ # arrays are traversed with `parent_was_resolved: true` so nested
139
+ # optional/deferred props follow the resolved-parent rules described in `#resolve_props`.
140
+ #
141
+ # @param prop [#call] lazy value to evaluate
142
+ # @param path [String] dot-notated path of the lazy prop
143
+ # @return [Object] resolved value
144
+ def resolve_lazy_prop(prop, path:)
145
+ resolved = prop.call
146
+
147
+ if resolved.is_a?(Hash)
148
+ resolve_props(resolved, path:, parent_was_resolved: true)
149
+ elsif resolved.is_a?(Array)
150
+ resolve_array_prop(resolved, path:, parent_was_resolved: true)
151
+ else
152
+ resolved
153
+ end
154
+ end
155
+
156
+ # Resolves arrays that may contain nested hashes or lazy values.
157
+ #
158
+ # Array indices are included in generated paths so metadata can still point
159
+ # to the nested prop location, e.g. `items.0.author`.
160
+ #
161
+ # @param prop [Array] array value to resolve
162
+ # @param path [String] dot-notated path of the array prop
163
+ # @param parent_was_resolved [Boolean] whether the array came from a lazy prop
164
+ # @return [Array] resolved array
165
+ def resolve_array_prop(prop, path:, parent_was_resolved:)
166
+ prop.each_with_index.each_with_object([]) do |(nested_prop, i), response|
167
+ resolved = if nested_prop.is_a?(Hash)
168
+ resolve_props(nested_prop, path: "#{path}.#{i}", parent_was_resolved:)
169
+ elsif nested_prop.respond_to?(:call)
170
+ resolve_lazy_prop(nested_prop, path: "#{path}.#{i}")
171
+ else
172
+ nested_prop
173
+ end
174
+
175
+ # drop hash items whose contents were fully filtered out
176
+ next if nested_prop.is_a?(Hash) && !nested_prop.empty? && resolved.empty?
177
+
178
+ response << resolved
179
+ end
180
+ end
181
+
182
+ # Builds a dot-notated path for a prop at the current nesting level.
183
+ #
184
+ # @param prop_name [String, Symbol] prop key at the current level
185
+ # @param path [String, nil] parent path
186
+ # @return [String] full dot-notated prop path
187
+ def prop_with_path(prop_name, path)
188
+ if path
189
+ "#{path}.#{prop_name}"
190
+ else
191
+ prop_name.to_s
192
+ end
193
+ end
194
+
195
+ # Lazily initializes deferred prop metadata grouped by deferred group name.
196
+ #
197
+ # @return [Hash{String=>Array<String>}] deferred prop paths by group
198
+ def deferred_props_metadata
199
+ @deferred_props_metadata ||= Hash.new { |h, k| h[k] = [] }
200
+ end
201
+
202
+ # Lazily initializes once prop metadata keyed by once cache key.
203
+ #
204
+ # @return [Hash{String=>Hash}] once prop metadata
205
+ def once_props_metadata
206
+ @once_props_metadata ||= {}
207
+ end
208
+ end
209
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Inertia
4
+ ##
5
+ # Integrates the gem with Rage - the extension serves as the glue between Inertia
6
+ # and Rage, configuring rendering, asset handling, and controller helpers.
7
+ #
8
+ class RageExtension < Rage::Extension
9
+ configure do
10
+ # Register custom renderer to enable `render inertia: ...` syntax in controllers
11
+ config.renderer(:inertia) do |component, props: {}|
12
+ Renderer.call(component, props, controller: self)
13
+ end
14
+
15
+ # Automatically start Vite dev server in development mode
16
+ config.daemons << ViteDevServer if Rage.env.development?
17
+
18
+ # In production, serve prebuilt static assets via the public file server and Assets middleware
19
+ unless Rage.env.development?
20
+ config.public_file_server.enabled = true
21
+
22
+ config.middleware.allow_outside_request_fiber! do
23
+ config.middleware.insert_before 0, Middleware::Assets
24
+ end
25
+ end
26
+
27
+ # Enable automatic generation of `new` and `edit` routes via resource helpers
28
+ config.router.form_actions = true
29
+ # Verify asset version consistency between client and server
30
+ config.middleware.use Middleware::Version
31
+ end
32
+
33
+ # Include ControllerHelpers to add support for `redirect_to` and other Inertia-specific methods
34
+ initializer "inertia.rage.controller_helpers" do
35
+ RageController::API.include ControllerHelpers
36
+ RageController::API.extend ControllerHelpers::ClassMethods
37
+ end
38
+
39
+ # Prebuild frontend assets before launching the server in production
40
+ before_server_start do
41
+ if Inertia.config.build_on_start?
42
+ puts "INFO: Building frontend"
43
+ puts ""
44
+ system("#{Frontend.package_runner} vite build", chdir: Frontend.root) || abort("ERROR: Frontend build failed")
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Inertia
4
+ ##
5
+ # Renders Inertia responses from controller actions.
6
+ #
7
+ # This class enables the `render inertia:` DSL in controllers:
8
+ #
9
+ # # Explicit component name
10
+ # render inertia: "Users/Index", props: { users: User.all }
11
+ #
12
+ # # Inferred component name
13
+ # render inertia: { users: User.all } # => "UsersController#index" infers "Users/Index"
14
+ #
15
+ # For XHR requests with the `X-Inertia` header, returns a JSON response
16
+ # containing the Inertia page object. For standard requests, delegates to
17
+ # {Frontend.render_layout} to embed the page data in an HTML shell.
18
+ #
19
+ class Renderer
20
+ # Renders an Inertia response for the given component and props.
21
+ #
22
+ # This is the entry point for Inertia's custom rendering. It wires together
23
+ # {RequestContext} (partial reload filtering) and {ProtocolBuilder} (page object
24
+ # construction), and handles both XHR and initial page load responses.
25
+ #
26
+ # @param component [String, Hash] the Inertia component name, or props hash when using component name inference
27
+ # @param props [Hash] the props to pass to the component (ignored when `component` is a Hash)
28
+ # @param controller [RageController::API] the controller instance handling the request
29
+ # @return [String] JSON response for XHR requests, HTML for initial loads
30
+ def self.call(component, props, controller:)
31
+ request, headers = controller.request, controller.headers
32
+
33
+ unless component.is_a?(String)
34
+ raise ArgumentError, "props must be nil when using inferred component names" if props&.any?
35
+
36
+ props = component
37
+
38
+ component_path = controller.class.name.delete_suffix("Controller")
39
+ component_path.gsub!("::", "/")
40
+ action_name = controller.action_name
41
+ component_name = action_name.include?("_") ? action_name.split("_").map!(&:capitalize).join : action_name.capitalize
42
+
43
+ component = "#{component_path}/#{component_name}"
44
+ end
45
+
46
+ if shared_data = controller.inertia_shared_data
47
+ props = shared_data.merge(props)
48
+ end
49
+
50
+ context = RequestContext.new(request, component:)
51
+ data = ProtocolBuilder.new(component, props, context:).call
52
+
53
+ if headers["vary"]
54
+ headers["vary"] += ", x-inertia"
55
+ else
56
+ headers["vary"] = "x-inertia"
57
+ end
58
+
59
+ if request.env["HTTP_X_INERTIA"]
60
+ headers["x-inertia"] = "true"
61
+ headers["content-type"] = "application/json; charset=utf-8"
62
+ data.to_json
63
+ else
64
+ headers["content-type"] = "text/html; charset=utf-8"
65
+ Frontend.render_layout(data)
66
+ end
67
+ end
68
+ end
69
+ end