ruact 0.0.12 → 0.0.13

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.
@@ -9,6 +9,9 @@
9
9
  "noEmit": true,
10
10
  "esModuleInterop": true,
11
11
  "skipLibCheck": true,
12
+ // Vite's client types. Without them a client component importing a
13
+ // stylesheet for its side effect has no declaration to resolve against.
14
+ "types": ["vite/client"],
12
15
  "baseUrl": ".",
13
16
  "paths": {
14
17
  "@/*": ["app/javascript/*"]
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+ require "ruact"
5
+
6
+ module Ruact
7
+ module Generators
8
+ # Copies the layout ruact pages render into (`layouts/ruact`, shipped with
9
+ # the gem) into the app, so the app owns it.
10
+ #
11
+ # Nothing needs configuring afterwards: the gem's view path is APPENDED by
12
+ # the Railtie, behind the app's, so `app/views/layouts/ruact.html.erb` wins
13
+ # by view-path order the moment it exists. That is the point of the ejected
14
+ # copy — add the app's JavaScript, fonts, meta tags or anything else
15
+ # `config.layout_stylesheets` cannot express.
16
+ #
17
+ # The cost, said once here and again when the command runs: an ejected
18
+ # layout no longer changes when the gem does.
19
+ #
20
+ # Run: rails generate ruact:layout
21
+ class LayoutGenerator < Rails::Generators::Base
22
+ source_root Ruact.views_path
23
+
24
+ desc "Copies ruact's layout into app/views/layouts/ruact.html.erb so your app owns it"
25
+
26
+ DESTINATION = "app/views/layouts/ruact.html.erb"
27
+
28
+ # `copy_file` already refuses to overwrite silently: an existing file
29
+ # prompts, or is skipped/overwritten under `--skip`/`--force`, which is
30
+ # the Rails convention every other generator follows.
31
+ def copy_layout
32
+ copy_file "layouts/ruact.html.erb", DESTINATION
33
+ end
34
+
35
+ # Only on `generate` (not `destroy`), and phrased for what is true: the copy
36
+ # renders ruact pages while `config.layout` is "ruact" — under `true` or
37
+ # `false` it is not used at all.
38
+ def explain
39
+ return unless behavior == :invoke
40
+
41
+ say ""
42
+ say " #{DESTINATION} renders ruact pages in place of the one ruact ships,"
43
+ say " as long as config.layout is \"ruact\". It will no longer change when you upgrade the gem —"
44
+ say " compare it with #{File.join(Ruact.views_path, 'layouts/ruact.html.erb')}"
45
+ say " after an upgrade. Keep `<%= ruact_head_assets %>` in <head>, and"
46
+ say " `<div id=\"root\"></div>` with `<%= ruact_js_assets %>` in <body>:"
47
+ say " `rails ruact:doctor` checks all three."
48
+ say ""
49
+ end
50
+ end
51
+ end
52
+ end
@@ -2,9 +2,10 @@
2
2
 
3
3
  # <%= controller_class_name %>Controller — a complete CRUD on the v2 route-driven contract.
4
4
  #
5
- # `include Ruact::Server` (sibling of the Ruact::Controller already on
6
- # ApplicationController via `ruact:install`) gives this controller two
7
- # behaviours at once:
5
+ # `include Ruact::Controller` makes this controller's pages ruact pages — the
6
+ # install no longer puts it on ApplicationController (Story 17.0g: island mode
7
+ # is the default; in whole-app mode, `ruact:install --app`, including it again
8
+ # here is a no-op). `include Ruact::Server` then gives it two behaviours:
8
9
  #
9
10
  # - GET actions (index/show/new/edit) need NO explicit `ruact_render` —
10
11
  # Ruact::Controller#default_render activates the RSC pipeline implicitly
@@ -22,6 +23,7 @@
22
23
  # NOTE: `include Ruact::Server` must come AFTER `protect_from_forgery` (inherited
23
24
  # from ApplicationController) so the CSRF check precedes these actions.
24
25
  class <%= controller_class_name %>Controller < ApplicationController
26
+ include Ruact::Controller
25
27
  include Ruact::Server
26
28
 
27
29
  # --- GET pages (rendered as RSC) -----------------------------------------
@@ -9,7 +9,7 @@ module Ruact
9
9
  # `Ruact::ConfigurationError` with the offending attribute, the caller's
10
10
  # file:line, and the suggested fix. Re-calling `Ruact.configure` after boot
11
11
  # replaces the configuration atomically and emits a `[ruact]` warning.
12
- class Configuration
12
+ class Configuration # rubocop:disable Metrics/ClassLength -- an attribute list with a validator per attribute; Doctor and InstallGenerator carry the same disable
13
13
  # The set of public attributes; new attributes added here automatically
14
14
  # inherit the freeze contract via the `define_method` writer below.
15
15
  ATTRIBUTES = %i[
@@ -25,6 +25,7 @@ module Ruact
25
25
  signed_global_id_default_expires_in
26
26
  shadcn_compatible_versions
27
27
  layout
28
+ layout_stylesheets
28
29
  ].freeze
29
30
 
30
31
  # @!attribute [r] manifest_path
@@ -144,17 +145,21 @@ module Ruact
144
145
  # full-document render a browser gets on a normal navigation.
145
146
  #
146
147
  # - `false` (default) — render the gem's built-in minimal shell.
148
+ # - `"ruact"` — render through the layout the gem ships
149
+ # (`lib/ruact/views/layouts/ruact.html.erb`, Story 17.0b): CSRF, CSP,
150
+ # the client-component CSS, then the app stylesheets named in
151
+ # {#layout_stylesheets}. What `rails generate ruact:install` writes.
152
+ # It edits no layout of the app; an app that puts its own
153
+ # `app/views/layouts/ruact.html.erb` in place (`rails generate
154
+ # ruact:layout` copies the gem's) wins by view-path order.
147
155
  # - `true` — render through the controller's normal Rails layout.
148
- # - a String — render through that named layout (e.g. `"ruact"`).
156
+ # - another String — render through that named layout.
149
157
  #
150
- # The layout path exists because the document `<head>` belongs to the
151
- # host app: `stylesheet_link_tag`, favicons, fonts, analytics and any
152
- # `<head>`-writing gem only reach the page when Rails' own layout owns
153
- # the document. The built-in shell carries no stylesheet slot, so under
154
- # the `false` default a ruact page renders with no app CSS at all —
155
- # which is why `rails generate ruact:install` writes `config.layout =
156
- # true` into the generated initializer and adds `<%= ruact_js_assets %>`
157
- # to your layout in the same run.
158
+ # The trade-off between the last two and `"ruact"`: the app's own layout
159
+ # brings its whole `<head>` (favicons, fonts, analytics, `<head>`-writing
160
+ # gems) but has to be wired by hand; the gem's layout needs no wiring but
161
+ # brings only the stylesheets. The built-in shell carries neither — under
162
+ # the `false` default a ruact page renders with no app CSS at all.
158
163
  #
159
164
  # **This setting is deliberately explicit — there is no auto-detection.**
160
165
  # ruact used to try to infer whether your layout was ready by inspecting
@@ -173,10 +178,26 @@ module Ruact
173
178
  # @note A ruact view is rendered in its own pass (it produces the component
174
179
  # tree), so `content_for` declared inside the view does NOT reach the
175
180
  # layout. Set document metadata from the controller instead.
176
- # @example Let your layout own the document (what ruact:install writes)
177
- # Ruact.configure { |c| c.layout = true }
178
- # @example Use a dedicated layout for ruact pages only
181
+ # @example Render through the layout ruact ships (what ruact:install writes)
179
182
  # Ruact.configure { |c| c.layout = "ruact" }
183
+ # @example Let your own application layout own the document
184
+ # Ruact.configure { |c| c.layout = true }
185
+ #
186
+ # @!attribute [r] layout_stylesheets
187
+ # @return [Array<Symbol, String>] The app stylesheets the gem's layout
188
+ # (`layouts/ruact`, used when `layout` is `"ruact"`) links, passed as-is
189
+ # to `stylesheet_link_tag`. They come AFTER the client-component CSS
190
+ # (`ruact_head_assets`), so the app's own CSS loads last and wins ties.
191
+ #
192
+ # Defaults to `[:app]`, what `rails new` 8.x puts in its layout: under
193
+ # Propshaft it expands to every stylesheet on the load path, including a
194
+ # Tailwind build. Under Sprockets `:app` means a file called `app.css`,
195
+ # which is why `rails generate ruact:install` writes `["application"]`
196
+ # there instead. `[]` links none. Anything more than a list of names —
197
+ # a media attribute, fonts, other `<head>` tags — is what ejecting the
198
+ # layout is for: `rails generate ruact:layout`.
199
+ # @example A Sprockets app
200
+ # Ruact.configure { |c| c.layout_stylesheets = ["application"] }
180
201
  ATTRIBUTES.each do |attr|
181
202
  attr_reader attr
182
203
 
@@ -231,6 +252,7 @@ module Ruact
231
252
  @signed_global_id_default_expires_in = nil
232
253
  @shadcn_compatible_versions = [1, 2]
233
254
  @layout = false
255
+ @layout_stylesheets = [:app]
234
256
  end
235
257
  end
236
258
 
@@ -266,6 +288,10 @@ module Ruact
266
288
  # reference, but a caller probing `frozen?` would see the right answer.
267
289
  if value.is_a?(Proc)
268
290
  value.freeze
291
+ elsif value.is_a?(Array)
292
+ # Story 17.0b — `layout_stylesheets` holds Strings; freezing only the
293
+ # Array would leave `Ruact.config.layout_stylesheets.first << "x"` open.
294
+ instance_variable_set("@#{attr}", value.map { |item| item.frozen? ? item : item.dup.freeze }.freeze)
269
295
  else
270
296
  instance_variable_set("@#{attr}", value.dup.freeze)
271
297
  end
@@ -293,6 +319,7 @@ module Ruact
293
319
  when :query_parent_controller then validate_query_parent_controller!(value)
294
320
  when :shadcn_compatible_versions then validate_shadcn_compatible_versions!(value)
295
321
  when :layout then validate_layout!(value)
322
+ when :layout_stylesheets then validate_layout_stylesheets!(value)
296
323
  end
297
324
  end
298
325
 
@@ -325,6 +352,31 @@ module Ruact
325
352
  "false uses ruact's built-in shell."
326
353
  end
327
354
 
355
+ # Story 17.0b — the arguments `layouts/ruact` passes to `stylesheet_link_tag`.
356
+ # Checked at boot because the layout splats them straight into a Rails
357
+ # helper, where a stray nil or Hash would surface as a first-render error
358
+ # instead of a legible configuration one.
359
+ #
360
+ # Propshaft reads `:app` / `:all` ONLY as the first item, and then ignores
361
+ # every other one (`case sources.first when :app then sources = …`): so
362
+ # `[:app, "theme"]` silently drops "theme", and `["reset", :app]` looks for
363
+ # a file named app.css and raises. Either is allowed only on its own.
364
+ def validate_layout_stylesheets!(value)
365
+ if value.is_a?(Array) && value.length > 1 && value.intersect?(%i[app all])
366
+ raise Ruact::ConfigurationError,
367
+ "Ruact::Configuration#layout_stylesheets: :app and :all stand alone — Propshaft reads " \
368
+ "them only as the whole list and drops anything next to them; got #{value.inspect}. " \
369
+ "Use [:app], or list the stylesheets by name."
370
+ end
371
+ return if value.is_a?(Array) && value.all? { |name| name.is_a?(Symbol) || (name.is_a?(String) && !name.empty?) }
372
+
373
+ raise Ruact::ConfigurationError,
374
+ "Ruact::Configuration#layout_stylesheets must be an Array of stylesheet names " \
375
+ "(Symbols or non-empty Strings, as you would pass to stylesheet_link_tag), " \
376
+ "e.g. [:app] or [\"application\"]; got #{value.inspect} (#{value.class.name}). " \
377
+ "Use [] to link none of your app's stylesheets."
378
+ end
379
+
328
380
  def validate_max_upload_bytes!(value)
329
381
  return if value.nil?
330
382
  return if value.is_a?(Integer) && value >= 0
@@ -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