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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +59 -1
- data/README.md +4 -4
- data/lib/generators/ruact/install/install_generator.rb +418 -128
- data/lib/generators/ruact/install/templates/AGENTS.md.tt +14 -13
- data/lib/generators/ruact/install/templates/Procfile.dev.tt +1 -1
- data/lib/generators/ruact/install/templates/initializer.rb.tt +29 -7
- data/lib/generators/ruact/install/templates/package.json.tt +4 -4
- data/lib/generators/ruact/install/templates/tsconfig.json.tt +3 -0
- data/lib/generators/ruact/layout/layout_generator.rb +52 -0
- data/lib/generators/ruact/scaffold/scaffold_generator.rb +39 -12
- data/lib/generators/ruact/scaffold/scaffold_shadcn_preflight.rb +4 -3
- data/lib/generators/ruact/scaffold/templates/components/List.tsx.tt +8 -8
- data/lib/generators/ruact/scaffold/templates/components/agnostic/List.tsx.tt +8 -8
- data/lib/generators/ruact/scaffold/templates/controller.rb.tt +5 -3
- data/lib/generators/ruact/scaffold/templates/queries/query.rb.tt +2 -2
- data/lib/generators/ruact/scaffold/templates/views/index.html.erb.tt +1 -1
- data/lib/ruact/configuration.rb +71 -17
- data/lib/ruact/controller/document_rendering.rb +72 -16
- data/lib/ruact/controller/page_rendering.rb +134 -0
- data/lib/ruact/controller/pages.rb +116 -0
- data/lib/ruact/controller.rb +78 -11
- data/lib/ruact/doctor.rb +233 -25
- data/lib/ruact/layout_source.rb +29 -7
- data/lib/ruact/navigation_boundary.rb +240 -0
- data/lib/ruact/railtie.rb +30 -0
- data/lib/ruact/routing.rb +24 -6
- data/lib/ruact/server.rb +10 -1
- data/lib/ruact/version.rb +1 -1
- data/lib/ruact/view_helper.rb +158 -1
- data/lib/ruact/views/layouts/ruact.html.erb +32 -0
- data/lib/ruact.rb +29 -0
- data/vendor/javascript/vite-plugin-ruact/ruact-router.test.mjs +433 -0
- data/vendor/javascript/vite-plugin-ruact/runtime/ruact-router.js +170 -7
- data/vendor/javascript/vite-plugin-ruact/tsconfig.scaffold-agnostic.json +1 -1
- data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/PostList.tsx +3 -3
- data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/PostList.tsx +3 -3
- data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/ambient.d.ts +4 -4
- data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/ambient.d.ts +4 -4
- 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
|
|
11
|
-
#
|
|
12
|
-
#
|
|
13
|
-
#
|
|
14
|
-
#
|
|
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
|
-
# - `
|
|
46
|
-
#
|
|
47
|
-
#
|
|
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
|
-
#
|
|
63
|
-
# match the `/\A@_/` protected-ivar filter) —
|
|
64
|
-
# zero-argument `ruact_js_assets` reaches THIS
|
|
65
|
-
|
|
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(
|
|
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
|
|
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
|
|
137
|
-
|
|
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
|
data/lib/ruact/controller.rb
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|