ruact 0.0.12 → 0.0.14

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 (40) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +59 -1
  3. data/README.md +4 -4
  4. data/lib/generators/ruact/install/install_generator.rb +418 -128
  5. data/lib/generators/ruact/install/templates/AGENTS.md.tt +14 -13
  6. data/lib/generators/ruact/install/templates/Procfile.dev.tt +1 -1
  7. data/lib/generators/ruact/install/templates/initializer.rb.tt +29 -7
  8. data/lib/generators/ruact/install/templates/package.json.tt +4 -4
  9. data/lib/generators/ruact/install/templates/tsconfig.json.tt +3 -0
  10. data/lib/generators/ruact/layout/layout_generator.rb +52 -0
  11. data/lib/generators/ruact/scaffold/scaffold_generator.rb +39 -12
  12. data/lib/generators/ruact/scaffold/scaffold_shadcn_preflight.rb +4 -3
  13. data/lib/generators/ruact/scaffold/templates/components/List.tsx.tt +8 -8
  14. data/lib/generators/ruact/scaffold/templates/components/agnostic/List.tsx.tt +8 -8
  15. data/lib/generators/ruact/scaffold/templates/controller.rb.tt +5 -3
  16. data/lib/generators/ruact/scaffold/templates/queries/query.rb.tt +2 -2
  17. data/lib/generators/ruact/scaffold/templates/views/index.html.erb.tt +1 -1
  18. data/lib/ruact/configuration.rb +71 -17
  19. data/lib/ruact/controller/document_rendering.rb +72 -16
  20. data/lib/ruact/controller/page_rendering.rb +134 -0
  21. data/lib/ruact/controller/pages.rb +116 -0
  22. data/lib/ruact/controller.rb +78 -11
  23. data/lib/ruact/doctor.rb +233 -25
  24. data/lib/ruact/layout_source.rb +29 -7
  25. data/lib/ruact/navigation_boundary.rb +240 -0
  26. data/lib/ruact/railtie.rb +30 -0
  27. data/lib/ruact/routing.rb +24 -6
  28. data/lib/ruact/server.rb +10 -1
  29. data/lib/ruact/version.rb +1 -1
  30. data/lib/ruact/view_helper.rb +158 -1
  31. data/lib/ruact/views/layouts/ruact.html.erb +32 -0
  32. data/lib/ruact.rb +29 -0
  33. data/vendor/javascript/vite-plugin-ruact/ruact-router.test.mjs +433 -0
  34. data/vendor/javascript/vite-plugin-ruact/runtime/ruact-router.js +170 -7
  35. data/vendor/javascript/vite-plugin-ruact/tsconfig.scaffold-agnostic.json +1 -1
  36. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/PostList.tsx +3 -3
  37. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/PostList.tsx +3 -3
  38. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/ambient.d.ts +4 -4
  39. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/ambient.d.ts +4 -4
  40. metadata +8 -2
@@ -7,11 +7,15 @@ module Ruact
7
7
  # Flight body an in-app navigation gets.
8
8
  #
9
9
  # Split out of `Ruact::Controller` because it answers one self-contained
10
- # question ("who owns the `<head>`?") whose answer is load-bearing: the host
11
- # app's layout does, because `stylesheet_link_tag`, favicons, fonts,
12
- # analytics and every `<head>`-writing gem live there. `#ruact_html_shell`
13
- # is the fallback for an app whose layout has not been migrated — it is
14
- # deliberately minimal and has NO stylesheet slot.
10
+ # question: who owns the `<head>`? `Ruact.config.layout` decides.
11
+ # `rails generate ruact:install` writes `"ruact"` — the layout the gem
12
+ # ships (Story 17.0b), which links the client-component CSS and the app's
13
+ # stylesheets named in `config.layout_stylesheets`, and edits no file of
14
+ # the app. `true` renders through the app's own layout instead, so its whole
15
+ # `<head>` (favicons, fonts, analytics, `<head>`-writing gems) reaches the
16
+ # page, at the price of wiring that layout by hand. `#ruact_html_shell` is
17
+ # the `false` fallback — deliberately minimal, with no slot for the app's
18
+ # stylesheets.
15
19
  module DocumentRendering
16
20
  extend ActiveSupport::Concern
17
21
 
@@ -42,15 +46,26 @@ module Ruact
42
46
  # - `false` (the default) → the built-in shell. Byte-identical to ruact's
43
47
  # behaviour before the layout path existed, with no detection in the
44
48
  # way, so an app that has not opted in cannot be affected by any of this.
45
- # - `true` / a String → render through the app's layout. `ruact:install`
46
- # writes both halves of that opt-in together: `config.layout = true` in
47
- # the initializer AND `<%= ruact_js_assets %>` in the layout.
49
+ # - `"ruact"` (what `ruact:install` writes) → the layout the gem ships,
50
+ # found by name through the view path the Railtie appends. An app that
51
+ # ejects it (`rails generate ruact:layout`) wins by view-path order.
52
+ # - `true` / another String → the app's own layout, which has to call
53
+ # `<%= ruact_js_assets %>` next to a `<div id="root"></div>` (and
54
+ # `<%= ruact_head_assets %>` in `<head>`); the install prints those lines
55
+ # rather than editing the file.
48
56
  #
49
57
  # An opted-in layout that does not actually emit the assets would produce
50
58
  # a blank page, so the rendered document is checked before it is
51
59
  # committed: that is a configuration error, raised in development/test and
52
60
  # logged-and-degraded in production rather than served to real traffic.
53
61
  def render_ruact_document(payload)
62
+ __ruact_warn_turbo_form_submission if __ruact_local_env?
63
+ # Set for EVERY branch, the shell included: besides carrying the payload
64
+ # to a layout's zero-argument `ruact_js_assets`, it is how
65
+ # `ruact_head_assets` knows this document is ruact's and emits the Turbo
66
+ # meta (Story 17.0f). Removed in `ensure`, so a later plain-Rails render
67
+ # through the same layout never inherits it.
68
+ @ruact_flight_payload = payload
54
69
  layout = Ruact.config.layout
55
70
  return render html: ruact_html_shell(payload).html_safe, layout: false if layout == false
56
71
 
@@ -59,10 +74,10 @@ module Ruact
59
74
  return render html: ruact_html_shell(payload).html_safe, layout: false
60
75
  end
61
76
 
62
- # Copied to the view by Rails' `view_assigns` plumbing (the name does not
63
- # match the `/\A@_/` protected-ivar filter) — that is how the layout's
64
- # zero-argument `ruact_js_assets` reaches THIS render's Flight payload.
65
- @ruact_flight_payload = payload
77
+ # `@ruact_flight_payload` is copied to the view by Rails' `view_assigns`
78
+ # plumbing (the name does not match the `/\A@_/` protected-ivar filter) —
79
+ # that is how the layout's zero-argument `ruact_js_assets` reaches THIS
80
+ # render's Flight payload.
66
81
  document = render_to_string(html: "".html_safe, layout: layout)
67
82
 
68
83
  if ruact_document_mountable?(document)
@@ -119,7 +134,9 @@ module Ruact
119
134
  lookup_context.exists?(name.to_s.delete_prefix("layouts/"), ["layouts"])
120
135
  end
121
136
 
122
- def __ruact_handle_unready_layout(_layout, reason)
137
+ def __ruact_handle_unready_layout(layout, reason)
138
+ return __ruact_handle_missing_gem_layout if reason == :missing && layout == Ruact::GEM_LAYOUT
139
+
123
140
  detail =
124
141
  if reason == :missing
125
142
  "no layout could be resolved for it"
@@ -131,10 +148,10 @@ module Ruact
131
148
  message = <<~MSG.strip
132
149
  ruact: #{controller_path}##{action_name} fell back to ruact's built-in HTML shell — #{detail}.
133
150
  `Ruact.config.layout` is set, so this is a configuration error, not a default:
134
- the built-in shell has no stylesheet slot, so your app's CSS does not reach this page.
151
+ the built-in shell has none of your stylesheets, so your app's CSS does not reach this page.
135
152
  Add `<%= ruact_js_assets %>` next to the `<div id="root"></div>` in your layout
136
- (`rails generate ruact:install` writes both; `rails ruact:doctor` reports what is missing),
137
- or set `Ruact.configure { |c| c.layout = false }` to use the built-in shell deliberately.
153
+ (`rails ruact:doctor` names the layout and the missing line), use the layout ruact ships
154
+ with `config.layout = "ruact"`, or set `config.layout = false` for the built-in shell.
138
155
  MSG
139
156
 
140
157
  # A MISSING layout is a legitimate per-controller choice — an API-shaped
@@ -155,6 +172,44 @@ module Ruact
155
172
  logger&.error(message)
156
173
  end
157
174
 
175
+ # Story 17.0b — `layouts/ruact` ships with the gem and the Railtie appends
176
+ # its view path. Missing means that path never reached this controller: the
177
+ # Railtie did not run, or something REPLACED the view paths instead of
178
+ # adding to them. Not a per-controller choice, so it is logged in
179
+ # production too — a page served without the app's CSS and no trace of why
180
+ # is the failure this layout exists to prevent.
181
+ def __ruact_handle_missing_gem_layout
182
+ message = <<~MSG.strip
183
+ ruact: #{controller_path}##{action_name} fell back to ruact's built-in HTML shell — ruact's own
184
+ layout (`layouts/ruact`, shipped with the gem) could not be found for this controller.
185
+ Its view path is added by the Ruact Railtie: check that the app boots through it, and that
186
+ nothing replaces this controller's view paths (`self.view_paths = …`) instead of adding to them.
187
+ Or copy the layout into your app with `rails generate ruact:layout`.
188
+ MSG
189
+ __ruact_local_env? ? logger&.info(message) : logger&.error(message)
190
+ end
191
+
192
+ # Story 17.0h — a form on a Turbo page that posts to a ruact action. Turbo
193
+ # fetches it and gets back a ruact document, which carries
194
+ # `turbo-visit-control=reload`. What Turbo then does depends on the status
195
+ # and on a surrounding Turbo Frame (a 4xx reloads the form's page, a 2xx
196
+ # is dropped, a 5xx is pasted into the Turbo page), but never shows the
197
+ # answer — the validation errors, what was typed. The browser, submitting
198
+ # natively, would. Said in development and test (decision of Luiz); every
199
+ # Turbo request carries `X-Turbo-Request-Id`.
200
+ def __ruact_warn_turbo_form_submission
201
+ return if request.nil? || request.get? || request.head?
202
+ return if request.headers["X-Turbo-Request-Id"].blank?
203
+
204
+ logger&.warn(<<~MSG.strip)
205
+ [ruact] #{self.class.name}##{action_name} answered a Turbo form submission
206
+ (#{request.request_method} #{request.path}) with a ruact page (#{response.status}). Turbo does not
207
+ show a ruact page as a form's answer — it reloads a page, drops the answer, or pastes this document
208
+ into its own — so validation errors and what was typed are lost. Add data-turbo="false" to that
209
+ form, so the browser submits it and shows the answer.
210
+ MSG
211
+ end
212
+
158
213
  def __ruact_local_env?
159
214
  Rails.env.development? || Rails.env.test?
160
215
  rescue StandardError
@@ -175,6 +230,7 @@ module Ruact
175
230
  <meta charset="UTF-8" />
176
231
  <meta name="viewport" content="width=device-width, initial-scale=1" />
177
232
  #{ruact_csrf_meta_tag}
233
+ #{ruact_head_assets}
178
234
  <title>Rails RSC</title>
179
235
  </head>
180
236
  <body>
@@ -0,0 +1,134 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ruact
4
+ module Controller
5
+ # Story 17.0i — an explicit `render` of a ruact page goes through ruact.
6
+ #
7
+ # `render :new, status: :unprocessable_entity`, the Rails idiom for a failed
8
+ # save, used to answer 500 on a ruact page: Rails rendered the template on
9
+ # its own and the first client component raised. `render` is PUBLIC, as
10
+ # Rails has it — the `responders` gem calls `controller.render`; Rails
11
+ # excludes its own public methods from `action_methods`, so it is not
12
+ # routable.
13
+ module PageRendering
14
+ # Story 17.0i — `render :new, status: :unprocessable_entity`, the Rails idiom
15
+ # for a failed save, on a ruact page. Rails would render the template itself,
16
+ # outside a `ruact_render`, and the first client component in it would raise.
17
+ # So a `render` of a ruact PAGE of this controller — the same predicate
18
+ # `default_render` uses: `ruact_page_action?` and the template in this
19
+ # controller's own folder — goes through `ruact_render`, with its `status:`,
20
+ # `locals:` and `location:`, when that `.html.erb` is the template RAILS
21
+ # would pick for this request: the format it negotiated (a `.json` path,
22
+ # `params[:format]`, the `respond_to` branch, the Accept header) decides, so
23
+ # a scaffold's `format.json { render :show }` still renders `show.json`.
24
+ # Everything else is Rails' own: other renderers (`json:`, `plain:`,
25
+ # `partial:`…, including the `render plain:` / `html:` ruact itself issues),
26
+ # a template of another folder, an action outside `ruact_pages`. `layout:`
27
+ # and `content_type:` do not apply: a ruact page is rendered into
28
+ # `config.layout`, as HTML or Flight, like every ruact page.
29
+ def render(*args, &block)
30
+ page = block ? nil : __ruact_page_render(args)
31
+ return super unless page
32
+
33
+ self.location = url_for(page[:location]) if page[:location]
34
+ # As Rails' own `assigns:` does: instance variables of the view.
35
+ page[:assigns]&.each { |name, value| instance_variable_set(:"@#{name}", value) }
36
+ __ruact_render(**page.slice(:template, :locals, :status, :details))
37
+ ensure
38
+ # The declined-option note explains THIS render only (review R4): a
39
+ # later render in the request — a `rescue_from`'s — must not inherit it.
40
+ if instance_variable_defined?(:@__ruact_declined_render_options)
41
+ remove_instance_variable(:@__ruact_declined_render_options)
42
+ end
43
+ end
44
+
45
+ private
46
+
47
+ # The options of a plain template render — the only `render` ruact takes
48
+ # over. Anything else (`json:`, `plain:`, `partial:`, a registered renderer
49
+ # like `turbo_stream:`, an option a gem adds, like wicked_pdf's `pdf:`, or a
50
+ # key Rails ignores, like `alert:`) means the render is not ruact's to
51
+ # answer — Rails renders it, and the error that follows names the option
52
+ # (Story 17.0i review R3, decision of Luiz).
53
+ # `prefixes:` is not among them (review R4): it names the folders to look
54
+ # in, and a page is this controller's own folder only.
55
+ TEMPLATE_RENDER_OPTIONS = %i[action template locals status location layout content_type
56
+ formats variants variant handlers locale assigns].freeze
57
+ private_constant :TEMPLATE_RENDER_OPTIONS
58
+
59
+ # The `ruact_render` arguments for an explicit `render` of one of this
60
+ # controller's ruact pages, or nil. Rails' own `_normalize_args` reads the
61
+ # arguments (`render :new` → action, `render "posts/new"` → template), on a
62
+ # copy: it hands a Hash argument back as itself.
63
+ def __ruact_page_render(args)
64
+ options = _normalize_args(*args.map { |arg| arg.is_a?(Hash) ? arg.dup : arg })
65
+ action = __ruact_render_target(options)
66
+ return nil unless action && self.class.ruact_page_action?(action)
67
+
68
+ page = self.class.ruact_template_path(action)
69
+ return nil unless File.exist?(page)
70
+ return __ruact_decline(options) unless (options.keys - TEMPLATE_RENDER_OPTIONS).empty?
71
+
72
+ details = __ruact_render_details(options)
73
+ return nil unless __ruact_rails_would_render?(action, details, page)
74
+
75
+ { template: "#{controller_path}/#{action}", locals: options[:locals] || {}, status: options[:status],
76
+ location: options[:location], assigns: options[:assigns], details: details }
77
+ end
78
+
79
+ # A render of a ruact page with an option ruact does not take goes to Rails.
80
+ # When it named the page (`action:` / `template:`), the options are kept so
81
+ # the error that follows — a client component rendered outside ruact — says
82
+ # which ones (`Ruact::ViewHelper#__ruact_component__`). `render json:` names
83
+ # no template: nothing to explain.
84
+ def __ruact_decline(options)
85
+ unknown = options.keys - TEMPLATE_RENDER_OPTIONS
86
+ @__ruact_declined_render_options = unknown if options.key?(:action) || options.key?(:template)
87
+ nil
88
+ end
89
+
90
+ # The lookup details of a render, as Rails' own render passes them: each an
91
+ # Array, a nil ignored; `variant:` is `variants:`.
92
+ def __ruact_render_details(options)
93
+ details = options.slice(:formats, :variants, :handlers, :locale)
94
+ details[:variants] = options[:variant] if options.key?(:variant) && !options.key?(:variants)
95
+ details.compact.transform_values { |value| Array(value) }
96
+ end
97
+
98
+ # Whether Rails, left alone, would render `page` for `action`: the lookup
99
+ # it does itself, with the formats it negotiated for this request (and a
100
+ # render's own `formats:` / `variants:` / `handlers:` / `locale:`). A Flight request
101
+ # negotiates every format, HTML first — the page; a `.json` request or a
102
+ # `format.json` branch negotiates JSON — the JSON template, or Rails' own
103
+ # MissingTemplate.
104
+ def __ruact_rails_would_render?(action, details, page)
105
+ template = lookup_context.find_template(action, [controller_path], false, [], details)
106
+ __ruact_page_template?(template.identifier, action, page)
107
+ rescue ActionView::MissingTemplate
108
+ false
109
+ end
110
+
111
+ # The page itself, or a locale/variant of it Rails picked (`new.pt-BR.html.erb`,
112
+ # `new.html+phone.erb`): the same folder, the same action, HTML, ERB.
113
+ def __ruact_page_template?(identifier, action, page)
114
+ File.identical?(File.dirname(identifier), File.dirname(page)) &&
115
+ File.basename(identifier).match?(/\A#{Regexp.escape(action)}(\.[\w-]+)?\.html(\+[\w-]+)?\.erb\z/)
116
+ end
117
+
118
+ # The action whose template `options` name, when it is one of this
119
+ # controller's: `action: "new"`, `template: "<controller_path>/new"`, or
120
+ # nothing at all (`render status: 422` — the current action's template).
121
+ def __ruact_render_target(options)
122
+ if options.key?(:template)
123
+ directory, name = File.split(options[:template].to_s)
124
+ directory == controller_path ? name : nil
125
+ elsif options.key?(:action)
126
+ name = options[:action].to_s
127
+ name.include?("/") ? nil : name
128
+ else
129
+ action_name
130
+ end
131
+ end
132
+ end
133
+ end
134
+ end
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ruact
4
+ module Controller
5
+ # Story 17.0g (FR116, decision D2) — a ruact "page" is a whole controller or
6
+ # the actions it declares.
7
+ #
8
+ # Including `Ruact::Controller` makes every action with an `.html.erb` a
9
+ # page; `ruact_pages only:` / `except:` narrows that. `default_render` and
10
+ # the navigation boundary (Ruact::NavigationBoundary) both read
11
+ # `ruact_page_action?` — one definition, so what renders through ruact and
12
+ # what the router treats as ruact cannot disagree.
13
+ module Pages
14
+ extend ActiveSupport::Concern
15
+
16
+ included do
17
+ # Story 17.0g — the pages this controller declared with `ruact_pages`, or
18
+ # nil (every action with a template is a page). Inherited, and
19
+ # redeclarable by a subclass.
20
+ class_attribute :__ruact_pages, instance_accessor: false, instance_predicate: false, default: nil
21
+ end
22
+
23
+ class_methods do
24
+ # Story 17.0g (FR116) — narrow which actions of this controller are ruact
25
+ # pages. Without it, every action with a template is one; with it, only
26
+ # the declared actions are, and the rest render as ordinary Rails.
27
+ #
28
+ # An action listed in `only:` is a page even without a template of its
29
+ # own — the way to have `ruact_render(template: "posts/show")` treated as
30
+ # one by the navigation boundary. Names that are not actions of the
31
+ # declaring controller fail loudly the first time one of its pages is
32
+ # looked up: a typo must not quietly become "not a ruact page". A
33
+ # declaration a subclass inherits is not checked against it — on a base
34
+ # controller it is a policy for children that each have some of the
35
+ # actions.
36
+ #
37
+ # @param only [Symbol, String, Array<Symbol, String>] the page actions
38
+ # @param except [Symbol, String, Array<Symbol, String>] every action but these
39
+ # @return [void]
40
+ # @example Only `show` renders through ruact
41
+ # class PostsController < ApplicationController
42
+ # include Ruact::Controller
43
+ # ruact_pages only: %i[show]
44
+ # end
45
+ def ruact_pages(only: nil, except: nil)
46
+ raise ArgumentError, "ruact_pages takes exactly one of only: or except: (#{name})" if only.nil? == except.nil?
47
+
48
+ self.__ruact_pages = { only: only && Array(only).map(&:to_s).freeze,
49
+ except: except && Array(except).map(&:to_s).freeze,
50
+ declared_in: self }.freeze
51
+ end
52
+
53
+ # Whether `action` is a page this controller renders through ruact:
54
+ # every action unless `ruact_pages` narrowed it. Calling `ruact_pages`
55
+ # again replaces the declaration (it does not accumulate). The navigation boundary
56
+ # (Ruact::NavigationBoundary) and `default_render` both read this.
57
+ #
58
+ # @param action [String, Symbol]
59
+ # @return [Boolean]
60
+ # @raise [Ruact::ConfigurationError] when `ruact_pages` names an action
61
+ # this controller does not have
62
+ def ruact_page_action?(action)
63
+ declared = __ruact_pages
64
+ return true if declared.nil?
65
+
66
+ Pages.check_declared!(self, declared) if declared[:declared_in].equal?(self)
67
+ return declared[:only].include?(action.to_s) if declared[:only]
68
+
69
+ !declared[:except].include?(action.to_s)
70
+ end
71
+
72
+ # Listed in `ruact_pages only:` by THIS controller, as a method of it — a
73
+ # page by declaration, template or not (the method renders another one).
74
+ # Everything else needs the template in the controller's own folder,
75
+ # which is what `default_render` renders: a template reached through a
76
+ # parent's folder (an inherited declaration, an inherited action, a
77
+ # template-only name) renders as plain Rails, so the router must not be
78
+ # told it is a ruact page. A subclass that renders another template on
79
+ # purpose redeclares its pages.
80
+ #
81
+ # @param action [String, Symbol]
82
+ # @return [Boolean]
83
+ def ruact_declared_page?(action)
84
+ declared = __ruact_pages
85
+ return false unless declared && declared[:declared_in].equal?(self)
86
+
87
+ Array(declared[:only]).include?(action.to_s) && action_methods.include?(action.to_s)
88
+ end
89
+ end
90
+
91
+ # The typo check, run for the class that DECLARED the pages (see
92
+ # `ruact_pages`). Only `only:` is checked: an `except:` naming a missing
93
+ # action excludes nothing and harms nothing. A template-only action (no
94
+ # method, a view Rails renders implicitly) is an action.
95
+ #
96
+ # @api private
97
+ # @param owner [Class] the controller that declared `declared`
98
+ # @param declared [Hash] a `__ruact_pages` declaration
99
+ # @return [void]
100
+ # @raise [Ruact::ConfigurationError] when `only:` names no action of it
101
+ def self.check_declared!(owner, declared)
102
+ names = Array(declared[:only])
103
+ return if names.empty?
104
+
105
+ unknown = names - owner.action_methods.to_a
106
+ unknown = unknown.reject { |action| File.exist?(owner.ruact_template_path(action)) }
107
+ return if unknown.empty?
108
+
109
+ what = unknown.one? ? "is not an action" : "are not actions"
110
+ raise Ruact::ConfigurationError,
111
+ "#{owner.name} declares ruact_pages for #{unknown.join(', ')}, which #{what} of it " \
112
+ "(no method and no template). Check the spelling in `ruact_pages`."
113
+ end
114
+ end
115
+ end
116
+ end
@@ -6,15 +6,21 @@ require "uri"
6
6
  require_relative "view_helper"
7
7
  require_relative "validation_errors_collector"
8
8
  require_relative "controller/document_rendering"
9
+ require_relative "controller/pages"
10
+ require_relative "controller/page_rendering"
9
11
 
10
12
  module Ruact
11
- # Include in ApplicationController to enable RSC rendering.
13
+ # Include in the controllers whose pages render through ruact (island mode,
14
+ # the install default — Story 17.0g), or in ApplicationController to render
15
+ # the whole app through it (`rails generate ruact:install --app`).
12
16
  #
13
- # class ApplicationController < ActionController::Base
17
+ # class ProductsController < ApplicationController
14
18
  # include Ruact::Controller
15
19
  # end
16
20
  #
17
- # After that, any action whose view is a .html.erb file will automatically:
21
+ # After that, any action whose view is a .html.erb file (narrowed with
22
+ # `ruact_pages only:` / `except:`, see Ruact::Controller::Pages) will
23
+ # automatically:
18
24
  # - Respond to text/x-component requests with a raw Flight payload
19
25
  # - Respond to text/html requests with an HTML shell + inline Flight payload
20
26
  module Controller
@@ -31,6 +37,40 @@ module Ruact
31
37
  # Who owns the `<head>` — the host app's layout, with ruact's built-in shell
32
38
  # as the un-migrated fallback. See Ruact::Controller::DocumentRendering.
33
39
  include Ruact::Controller::DocumentRendering
40
+ # Which actions are ruact pages — the whole controller, or the ones it
41
+ # declares with `ruact_pages` (Story 17.0g). See Ruact::Controller::Pages.
42
+ include Ruact::Controller::Pages
43
+ # An explicit `render` of a ruact page goes through ruact (Story 17.0i).
44
+ # See Ruact::Controller::PageRendering.
45
+ include Ruact::Controller::PageRendering
46
+
47
+ # Story 17.0f — "is this action a ruact PAGE?", answered at CLASS level so the
48
+ # navigation boundary (Ruact::NavigationBoundary) can ask it before any action
49
+ # runs, and so it and `default_render` read ONE definition rather than two
50
+ # that could drift (the 2026-09-16 spike's classifier kept its own copy).
51
+ class_methods do
52
+ # The template `default_render` looks for — the literal
53
+ # `Rails.root/app/views/<controller_path>/<action>.html.erb`, not the view
54
+ # path lookup, which is what keeps an engine's own templates (Devise's)
55
+ # out. `controller_path`, not the class name with "_controller" cut out
56
+ # of it: `RemoteControllersController` used to look in
57
+ # `remotes_controller/`.
58
+ #
59
+ # @param action [String, Symbol]
60
+ # @return [Pathname]
61
+ def ruact_template_path(action)
62
+ Rails.root.join("app", "views", controller_path, "#{action}.html.erb")
63
+ end
64
+
65
+ # A GET to `action` renders through ruact: it is a page, and it either
66
+ # has its template or was declared by name (it renders another one).
67
+ #
68
+ # @param action [String, Symbol]
69
+ # @return [Boolean]
70
+ def ruact_page?(action)
71
+ ruact_page_action?(action) && (ruact_declared_page?(action) || File.exist?(ruact_template_path(action)))
72
+ end
73
+ end
34
74
 
35
75
  private
36
76
 
@@ -38,7 +78,7 @@ module Ruact
38
78
  # controller a public instance method is exposed as a routable action. Demote
39
79
  # the mixed-in helper methods so they are never callable as actions (they are
40
80
  # only ever invoked internally by `ruact_html_shell`).
41
- private :ruact_js_assets, :__ruact_component__
81
+ private :ruact_js_assets, :ruact_head_assets, :__ruact_component__
42
82
 
43
83
  # Resolves the manifest for this render. In PRODUCTION this is the boot-time
44
84
  # cached +Ruact.manifest+ (set by Railtie#config.to_prepare) — no per-request
@@ -102,11 +142,26 @@ module Ruact
102
142
  # +template+: logical template name (e.g. "posts/custom"), or nil to use
103
143
  # the current action's default template.
104
144
  # +locals+: hash of local variables to pass to the template.
105
- def ruact_render(template: nil, locals: {})
145
+ # +status+: the response status, as `render` takes it (Story 17.0i) —
146
+ # `:unprocessable_entity`, `422`… — in the HTML document and the
147
+ # Flight payload alike. Omitted, the response keeps the status it
148
+ # has (200).
149
+ def ruact_render(template: nil, locals: {}, status: nil)
150
+ __ruact_render(template: template, locals: locals, status: status)
151
+ end
152
+
153
+ # `ruact_render`, plus the lookup details an explicit `render` carries
154
+ # (`variants:`, `locale:`, `formats:`, `handlers:` — Story 17.0i review R3):
155
+ # the page renders with the variant or locale Rails would have used.
156
+ def __ruact_render(template: nil, locals: {}, status: nil, details: {})
106
157
  # Story 13.3 (FR98, AC4) — seed the collector from a redirect-back flash
107
158
  # before the view evaluates, so `errors={ruact_errors}` surfaces surviving
108
159
  # errors (no-op on a plain render — `ruact_errors` then returns `{}`).
109
160
  __ruact_read_errors_from_flash
161
+ # Resolved the way `render status:` resolves it (Rack::Utils.status_code),
162
+ # and set BEFORE anything is written: the streamed Flight response sends
163
+ # its headers on the first row.
164
+ self.status = status if status
110
165
 
111
166
  pipeline = RenderPipeline.new(ruact_manifest, controller_path: controller_path, logger: logger)
112
167
  streaming = ruact_request? && self.class.ancestors.include?(ActionController::Live)
@@ -126,7 +181,7 @@ module Ruact
126
181
  # (NFR8). See Story 7.9 / Bug 7.8-B.
127
182
  with_render_context do |render_context|
128
183
  opts = template ? { template: template } : { action: action_name }
129
- html = render_to_string(opts.merge(layout: false, locals: locals))
184
+ html = render_to_string(opts.merge(details).merge(layout: false, locals: locals))
130
185
  emit_ruact_response(pipeline, html, render_context, streaming: streaming)
131
186
  end
132
187
  end
@@ -188,7 +243,8 @@ module Ruact
188
243
  end
189
244
 
190
245
  # Overrides Rails redirect_to for RSC requests: emits a Flight redirect row
191
- # (`0:{"redirectUrl":"...","redirectType":"push"}`) instead of a 302 response.
246
+ # (`0:` followed by a JSON object with `redirectUrl` and `redirectType: "push"`)
247
+ # instead of a 302 response.
192
248
  # This allows the client-side router to handle the navigation without an extra
193
249
  # HTTP round-trip. Non-RSC requests and external-origin redirects fall through
194
250
  # to the standard Rails implementation.
@@ -217,6 +273,10 @@ module Ruact
217
273
  # Story 13.3 (FR98, AC4) — the Inertia "redirect back with errors" path:
218
274
  # stash any `ruact_errors`-registered errors in flash so they survive this
219
275
  # Flight redirect and re-render as an `errors` prop (no-op when untouched).
276
+ # Only here: the next request is the router's Flight GET, which renders
277
+ # no layout. A plain 302 (a form outside `ruact_pages`, Story 17.0g) is
278
+ # followed by a full page whose layout may print every flash entry — that
279
+ # action is plain Rails and re-renders its errors the Rails way.
220
280
  __ruact_stash_errors_in_flash
221
281
 
222
282
  # Story 15.5 (FR109) — mark the Flight-redirect sub-shape for the dev log.
@@ -231,14 +291,21 @@ module Ruact
231
291
  request.headers["Ruact-Request"] == "1"
232
292
  end
233
293
 
294
+ # Rails keeps `redirect_to` PUBLIC, and this module's body is private from
295
+ # `private` above: the `responders` gem (Devise's `respond_with`) calls
296
+ # `controller.redirect_to` with an explicit receiver (Story 17.0i). Public
297
+ # is not routable here — Rails excludes its own public methods from
298
+ # `action_methods`.
299
+ public :redirect_to
300
+
301
+ # Implicit rendering needs the action's OWN template: an action declared a
302
+ # page without one renders itself (`ruact_render(template: …)`).
234
303
  def ruact_template_exists?
235
- File.exist?(default_template_path)
304
+ self.class.ruact_page_action?(action_name) && File.exist?(default_template_path)
236
305
  end
237
306
 
238
307
  def default_template_path
239
- action = action_name
240
- controller = self.class.name.underscore.sub("_controller", "")
241
- Rails.root.join("app", "views", controller, "#{action}.html.erb")
308
+ self.class.ruact_template_path(action_name)
242
309
  end
243
310
  end
244
311
  end