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.
@@ -0,0 +1,240 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+
5
+ module Ruact
6
+ # Story 17.0f (FR117) — the document belongs to whoever rendered it.
7
+ #
8
+ # The ruact router intercepts every same-origin link and form: it cannot know,
9
+ # from the browser, whether the destination is a ruact page. Asked with
10
+ # `Accept: text/x-component`, an ordinary Rails action answers 200 HTML, which
11
+ # the router cannot render — a dead click, silently (spike 2026-09-12, S2). And
12
+ # a form that crossed to an ordinary action had already RUN it by the time the
13
+ # router saw the HTML, so it could not be retried (S9b).
14
+ #
15
+ # The server can know, before anything runs. For a request the router sent
16
+ # (`Ruact-Request: 1`, a header only the router sends), {Middleware} asks
17
+ # {Classifier} whether the route it would reach is a ruact page. If not, it
18
+ # answers `Ruact-Boundary: native` WITHOUT calling the app, and the router
19
+ # hands the navigation — or the form submission — to the browser. The action
20
+ # then runs exactly once, natively, and its real response (a 422 with the
21
+ # validation errors included) reaches the user.
22
+ #
23
+ # Measured in the 2026-09-16 spike (playgrounds/nav-islands): ~0.1 ms median
24
+ # per router request; every fixture below held.
25
+ module NavigationBoundary
26
+ # Response header the router reads.
27
+ HEADER = "ruact-boundary"
28
+ # Its only value today: "not a ruact page — let the browser do it".
29
+ NATIVE = "native"
30
+
31
+ # The Rack middleware the Railtie installs. Remove it with
32
+ # `config.middleware.delete Ruact::NavigationBoundary::Middleware`.
33
+ class Middleware
34
+ def initialize(app, classifier: Classifier.new)
35
+ @app = app
36
+ @classifier = classifier
37
+ end
38
+
39
+ def call(env)
40
+ return @app.call(env) unless env["HTTP_RUACT_REQUEST"] == "1"
41
+ return native_response if @classifier.classify(env) == :native
42
+
43
+ @app.call(env)
44
+ end
45
+
46
+ private
47
+
48
+ # `Vary`, because the same URL answers differently with and without the
49
+ # router's header; `no-store`, because this answer is about routing, not
50
+ # content, and must never be served from a cache to a browser navigation.
51
+ #
52
+ # Built through Rack's own header class so a later middleware looking up
53
+ # `Cache-Control` finds this one on Rack 2 (Rails 7.0) as well as Rack 3.
54
+ def native_response
55
+ [200, native_headers, []]
56
+ end
57
+
58
+ def native_headers
59
+ headers = defined?(Rack::Headers) ? Rack::Headers.new : Rack::Utils::HeaderHash.new
60
+ headers[HEADER] = NATIVE
61
+ headers["content-type"] = "text/plain; charset=utf-8"
62
+ headers["cache-control"] = "no-store"
63
+ headers["vary"] = "Ruact-Request"
64
+ headers
65
+ end
66
+ end
67
+
68
+ # Decides, from the route table alone, where a request would land.
69
+ #
70
+ # Mirrors what Rails' own router does on `serve` — not just `recognize`:
71
+ # lambda and object constraints are evaluated IN ROUTE ORDER, with the same
72
+ # cascade to the next matching route (`recognize` alone skips them; they
73
+ # live on `Mapper::Constraints` and only run on `serve`). Descends into
74
+ # mounted engines. Nothing it calls executes an action.
75
+ #
76
+ # Ties break towards letting the request through: a wrong :native would
77
+ # skip ruact on a ruact page, a wrong :ruact would bring the dead click back,
78
+ # but :pass only costs what ruact cost before this existed.
79
+ class Classifier
80
+ # @param router [ActionDispatch::Journey::Router, nil] defaults to the
81
+ # application's, resolved lazily (routes reload in development)
82
+ def initialize(router = nil)
83
+ @router = router
84
+ end
85
+
86
+ # @param env [Hash] the Rack env
87
+ # @return [Symbol] `:ruact`, `:native`, or `:pass` (could not tell)
88
+ def classify(env)
89
+ request = ActionDispatch::Request.new(env.dup)
90
+ verdict_in(@router || application_router, request) || :pass
91
+ rescue Ruact::ConfigurationError => e
92
+ # A misdeclared `ruact_pages` is the app's bug to see, not a routing
93
+ # doubt: say so where it shows, then let the request through.
94
+ Rails.logger&.warn("[ruact] #{e.message}")
95
+ :pass
96
+ rescue StandardError => e
97
+ Rails.logger&.debug do
98
+ "[ruact] navigation boundary could not classify #{env['PATH_INFO']}: #{e.class}: #{e.message}"
99
+ end
100
+ :pass
101
+ end
102
+
103
+ private
104
+
105
+ # Rails 8 routes load lazily in development and test: the route set loads
106
+ # itself on `call`, but not when its `router` is read — and this runs
107
+ # BEFORE the app is called, so the first request after a boot could see
108
+ # an empty table.
109
+ def application_router
110
+ app = Rails.application
111
+ app.reload_routes_unless_loaded if app.respond_to?(:reload_routes_unless_loaded)
112
+ app.routes.router
113
+ end
114
+
115
+ # @return [Symbol, nil] nil when nothing in THIS router answers — the
116
+ # caller keeps scanning, the way Rails cascades past an engine (or a Rack
117
+ # app at "/") whose own routes do not match
118
+ def verdict_in(router, request, in_engine: false)
119
+ root_app = false
120
+ router.recognize(request) do |route, params|
121
+ app = route.app
122
+ if app.is_a?(ActionDispatch::Routing::Mapper::Constraints)
123
+ request.path_parameters = params
124
+ next unless app.matches?(request) # `serve` would cascade to the next route
125
+
126
+ app = app.app
127
+ end
128
+
129
+ if mounted_rack_app?(route, app)
130
+ # A Rack app mounted at "/" (Grape, Sinatra) passes on what it does
131
+ # not know with `X-Cascade: pass`, and Rails moves on: keep looking.
132
+ # Mounted anywhere else, it owns its prefix — Sidekiq at /sidekiq is
133
+ # not reclassified by a catch-all route drawn after it.
134
+ if route.path.spec.to_s == "/"
135
+ root_app = true
136
+ next
137
+ end
138
+ return :native
139
+ end
140
+
141
+ verdict = verdict_for(route, app, request, params, in_engine: in_engine)
142
+ return verdict if verdict # an engine with no matching route cascades: keep looking
143
+ end
144
+ root_app ? :native : nil
145
+ end
146
+
147
+ def mounted_rack_app?(route, app)
148
+ !route.dispatcher? && !app.is_a?(ActionDispatch::Routing::Redirect) && !engine?(app)
149
+ end
150
+
151
+ def engine?(app)
152
+ app.respond_to?(:routes) && app.routes.respond_to?(:router)
153
+ end
154
+
155
+ def verdict_for(route, app, request, params, in_engine:)
156
+ return action_verdict(request, params[:controller], params[:action], in_engine) if route.dispatcher?
157
+ # A route that redirects answers the router itself; there is no "no
158
+ # match" for it, so an unclassifiable one passes rather than cascading.
159
+ return redirect_verdict(app, request, params) || :pass if app.is_a?(ActionDispatch::Routing::Redirect)
160
+
161
+ # Inside `recognize`'s block the request's path_info is already relative
162
+ # to the mount point, which is what the engine's own router expects.
163
+ verdict_in(app.routes.router, request, in_engine: true)
164
+ end
165
+
166
+ # Same origin: the fetch follows the redirect and the target is
167
+ # classified when it arrives. Another origin: the fetch cannot follow it
168
+ # (CORS), so the browser has to — native.
169
+ #
170
+ # A 307/308 on a form re-sends the POST to the target; if that target is
171
+ # not ruact, the router could only answer with a GET there and the POST
172
+ # would never run. The browser's own submit follows it correctly: native.
173
+ #
174
+ # A `redirect { |params, req| … }` BLOCK is application code — it may
175
+ # query the database — and is never run here: it passes through.
176
+ EVALUATED_REDIRECTS = %w[ActionDispatch::Routing::PathRedirect ActionDispatch::Routing::OptionRedirect].freeze
177
+ private_constant :EVALUATED_REDIRECTS
178
+
179
+ def redirect_verdict(redirect, request, params)
180
+ return :native if [307, 308].include?(redirect.status) && !(request.get? || request.head?)
181
+ return :pass unless EVALUATED_REDIRECTS.include?(redirect.class.name)
182
+
183
+ target = URI.parse(redirect.path(params, request).to_s)
184
+ same_origin = target.host == request.host && (target.port || request.port) == request.port
185
+ return :pass if target.host.nil? || same_origin
186
+
187
+ :native
188
+ rescue StandardError
189
+ :pass
190
+ end
191
+
192
+ # GET/HEAD: ruact renders the page only when `default_render` would —
193
+ # the same class-level predicate, never a copy of it. That is what keeps a
194
+ # Devise-like controller (it inherits the app's ruact controller, its
195
+ # templates live in the engine) native.
196
+ #
197
+ # Anything else: including the concern is enough — unless the controller
198
+ # declares its pages (`ruact_pages`, Story 17.0g), and then only for the
199
+ # declared actions. A `create` has no template, and answers the router
200
+ # with a Flight redirect row (Ruact::Controller#redirect_to) — the Story
201
+ # 13.3 redirect-back has to stay in place, not become a full page load.
202
+ #
203
+ # Inside a mounted ENGINE, a non-GET is native even when its controller
204
+ # inherits the app's ruact controller: that is Devise's shape, and its
205
+ # `destroy` answers a non-navigational request with a bare 204 — the user
206
+ # is signed out and the router has nothing to render. An engine's actions
207
+ # speak the engine's protocol, not ruact's. Its GET pages still count when
208
+ # the APP provides the template (the way apps override engine views).
209
+ #
210
+ # The same holds for a controller a GEM defines even when its routes are
211
+ # drawn into the app's own table — `devise_for` mounts no engine, and
212
+ # `Devise::SessionsController#destroy` is the sign-out the rule exists
213
+ # for. So a non-GET is ruact only when the controller is the app's own:
214
+ # defined under `Rails.root/app`.
215
+ def action_verdict(request, controller, action, in_engine)
216
+ klass = "#{controller.to_s.camelize}Controller".safe_constantize
217
+ return :pass unless klass.is_a?(Class)
218
+ return :native unless klass.include?(Ruact::Controller)
219
+ return non_get_verdict(klass, action, in_engine) unless request.get? || request.head?
220
+
221
+ klass.ruact_page?(action) ? :ruact : :native
222
+ end
223
+
224
+ # Story 17.0g — a controller that DECLARED its pages (`ruact_pages`) is ruact
225
+ # only for those: a `create` whose `new` is a plain Rails page re-renders
226
+ # plain HTML on a validation error, which the router could not show.
227
+ def non_get_verdict(klass, action, in_engine)
228
+ return :native if in_engine || !app_owned?(klass)
229
+ return :native if klass.__ruact_pages && !klass.ruact_page_action?(action)
230
+
231
+ :ruact
232
+ end
233
+
234
+ def app_owned?(klass)
235
+ file = Object.const_source_location(klass.name)&.first
236
+ !file.nil? && File.expand_path(file).start_with?("#{Rails.root.join('app')}/")
237
+ end
238
+ end
239
+ end
240
+ end
data/lib/ruact/railtie.rb CHANGED
@@ -16,6 +16,36 @@ module Ruact
16
16
  require_relative "routing"
17
17
  end
18
18
 
19
+ # Story 17.0b — make the gem's own layout (`layouts/ruact`) findable by name.
20
+ #
21
+ # APPEND, not prepend: the gem's views go BEHIND the app's and every
22
+ # engine's, so an app that ejects the layout (`rails generate ruact:layout`)
23
+ # wins by view-path order and `layouts/application` always stays the app's.
24
+ # Shadowing the app's layout was measured and rejected (spike 2026-09-10:
25
+ # every page loaded React and lost the app's <title>), and the prepend
26
+ # version failed SILENTLY from this very hook — which is why the order this
27
+ # produces is asserted by a spec that boots a real app through this Railtie
28
+ # (spec/ruact/gem_layout_boot_spec.rb), in development and production.
29
+ #
30
+ # `respond_to?` mirrors Rails' own `add_view_paths`: the hook also fires for
31
+ # controller classes that carry no view paths.
32
+ initializer "ruact.view_paths" do
33
+ ActiveSupport.on_load(:action_controller) do
34
+ append_view_path(Ruact.views_path) if respond_to?(:append_view_path)
35
+ end
36
+ end
37
+
38
+ # Story 17.0f (FR117) — answer the ruact router before any action runs when
39
+ # the route it asked for is not a ruact page. See Ruact::NavigationBoundary.
40
+ #
41
+ # `use` appends: the middleware sits after Rack::MethodOverride when the app
42
+ # has it, so a form's `_method=delete` is already DELETE when the verb is
43
+ # read, and an app without it (API-shaped) does not fail to boot the way
44
+ # `insert_after Rack::MethodOverride` would.
45
+ initializer "ruact.navigation_boundary" do |app|
46
+ app.config.middleware.use Ruact::NavigationBoundary::Middleware
47
+ end
48
+
19
49
  rake_tasks { load File.expand_path("../tasks/ruact.rake", __dir__) }
20
50
 
21
51
  # Load the client manifest at boot (and on each code reload in development).
data/lib/ruact/server.rb CHANGED
@@ -277,12 +277,21 @@ module Ruact
277
277
  _ensure_url_is_http_header_safe(location)
278
278
  location = _enforce_open_redirect_protection(location, allow_other_host: allow_other_host)
279
279
 
280
+ # Story 13.3 (FR98) — registered errors ride flash to the page the
281
+ # runtime navigates to (a router Flight GET: no layout prints them). A
282
+ # function call never reaches Ruact::Controller#redirect_to's Flight path,
283
+ # so the stash lives here too (Story 17.0g review R3). Same-origin only,
284
+ # like that path: after a redirect to another origin the flash entry
285
+ # would wait in the session for whatever page the user opens next.
286
+ path = __ruact_redirect_path(location)
287
+ __ruact_stash_errors_in_flash if path.start_with?("/") && !path.start_with?("//")
288
+
280
289
  # Story 15.0 (F6) — a Bucket-2 `redirect_to` is a ruact-owned response
281
290
  # (`$redirect`; registered errors ride flash), not an injection opt-out.
282
291
  @__ruact_function_response_owned = true
283
292
  # Story 15.5 (FR109) — mark the `$redirect` sub-shape for the dev log.
284
293
  @__ruact_function_redirect = true
285
- render json: { "$redirect" => __ruact_redirect_path(location) }
294
+ render json: { "$redirect" => path }
286
295
  end
287
296
 
288
297
  private
data/lib/ruact/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Ruact
4
- VERSION = "0.0.12"
4
+ VERSION = "0.0.13"
5
5
  end
@@ -25,12 +25,71 @@ module Ruact
25
25
  # placeholder in the HTML output.
26
26
  def __ruact_component__(name, props = {})
27
27
  ctx = @ruact_render_context
28
- raise Ruact::Error, "ruact: __ruact_component__ called outside a ruact_render flow" if ctx.nil?
28
+ raise Ruact::Error, __ruact_outside_render_message(name) if ctx.nil?
29
29
 
30
30
  token = ctx.register(name, props)
31
31
  "<!-- #{token} -->".html_safe
32
32
  end
33
33
 
34
+ # Story 17.0i — a client component in a template Rails is rendering on its
35
+ # own. Says which component and template, and the two ways to a ruact page:
36
+ # `ruact_render` for this template, or listing the action in the
37
+ # controller's `ruact_pages`. The file and line come from the
38
+ # `ActionView::Template::Error` Rails wraps this in.
39
+ def __ruact_outside_render_message(name)
40
+ template = @current_template&.virtual_path
41
+ where = template ? "\"#{template}\"" : "this template"
42
+ # The helper is in every view. A controller action without the concern
43
+ # has no `ruact_render` to call; a mailer (its view's `controller`) or a
44
+ # `Controller.render` that ran no action cannot render through ruact at all.
45
+ owner = respond_to?(:controller) ? controller : nil
46
+ declined = owner&.instance_variable_get(:@__ruact_declined_render_options)
47
+ return __ruact_declined_options_message(name, where, declined) if declined.present?
48
+
49
+ if owner && !(defined?(Ruact::Controller) && owner.class.include?(Ruact::Controller) &&
50
+ __ruact_page_request?(owner))
51
+ return __ruact_not_a_page_message(name, where) unless __ruact_page_request?(owner)
52
+
53
+ return "ruact: <#{name} /> is a client component, and #{where} is being rendered by " \
54
+ "#{owner.class.name}, which does not include Ruact::Controller. Add " \
55
+ "`include Ruact::Controller` to it: its pages then render through ruact."
56
+ end
57
+
58
+ # A partial or a layout is not a page: rendering it as one is not the fix.
59
+ call = if template.nil? || File.basename(template).start_with?("_") || template.start_with?("layouts/")
60
+ "`ruact_render(template: …, status: …)` for the page that renders it"
61
+ else
62
+ "`ruact_render(template: \"#{template}\", status: …)`"
63
+ end
64
+ "ruact: <#{name} /> is a client component, and #{where} is being rendered by Rails, outside ruact. " \
65
+ "Render it through ruact: #{call} — or, when it is this controller's page and the controller " \
66
+ "declares `ruact_pages`, add the action there (`render :action` then goes through ruact)."
67
+ end
68
+
69
+ # A controller answering a request with an action — not a mailer, not an
70
+ # `ActionController::Renderer` (no action ran).
71
+ def __ruact_page_request?(owner)
72
+ defined?(ActionController::Metal) && owner.is_a?(ActionController::Metal) && owner.action_name.present?
73
+ end
74
+
75
+ # Story 17.0i review R3 — the page was ruact's, but `render` carried an
76
+ # option ruact does not take, so Rails rendered it.
77
+ def __ruact_declined_options_message(name, where, options)
78
+ named = options.map { |option| "`#{option}:`" }.join(", ")
79
+ "ruact: <#{name} /> is a client component, and #{where} is a ruact page, but its `render` got " \
80
+ "#{named} — not a render option ruact takes — so Rails rendered it outside ruact. Remove it " \
81
+ "(a flash message goes in `flash.now`), or render the page with `ruact_render`."
82
+ end
83
+
84
+ def __ruact_not_a_page_message(name, where)
85
+ "ruact: <#{name} /> is a client component, and #{where} is being rendered outside a request to a " \
86
+ "ruact controller (a mailer, or `Controller.render`). Client components render only in a page a " \
87
+ "ruact controller renders."
88
+ end
89
+
90
+ private :__ruact_outside_render_message, :__ruact_page_request?, :__ruact_not_a_page_message,
91
+ :__ruact_declined_options_message
92
+
34
93
  # Story 14.2 (FR104) — emits ruact's full JavaScript asset block: the
35
94
  # dev/prod bootstrap entry `<script>` tags (re-targeting the virtual entry
36
95
  # `virtual:ruact/bootstrap` — in dev the react-refresh preamble + `@vite/client`
@@ -65,8 +124,94 @@ module Ruact
65
124
  parts.join("\n").html_safe
66
125
  end
67
126
 
127
+ # The `<head>` half of the asset contract: the stylesheets Vite emitted for
128
+ # the client components, linked so they reach the page in production.
129
+ #
130
+ # Vite is an ASSET bundler, not a JS bundler — a `"use client"` component
131
+ # that imports CSS (its own, or one a package ships) produces a stylesheet
132
+ # recorded on the manifest entry beside `file`. Nothing linked it, so the
133
+ # styling was built, digest-stamped, served and never referenced. Only
134
+ # production was affected: the dev server injects that CSS through JS.
135
+ #
136
+ # **Why this is separate from {#ruact_js_assets}, and why it belongs in
137
+ # `<head>`.** The JS helper is injected before `</body>`, and the built-in
138
+ # shell emits it there too — a position that did not matter while it emitted
139
+ # only a `<script>`. A stylesheet there is discovered late and sits AFTER
140
+ # when the browser finds it, and it means third-party CSS outranks the app's
141
+ # own. So ruact declares that it contributes CSS and says WHERE, rather than
142
+ # smuggling it through a helper named for JavaScript.
143
+ #
144
+ # **Cascade.** Call it ABOVE the app's `stylesheet_link_tag`, so the app's
145
+ # own CSS is loaded afterwards. The layout ruact ships (`layouts/ruact`,
146
+ # Story 17.0b) does exactly that; a layout of the app's own does it by hand
147
+ # (`rails generate ruact:install` prints the line, it never edits a layout).
148
+ # Order decides ties only — specificity, `!important` and cascade layers all
149
+ # outrank it — but ties are the common case, and losing them by default is
150
+ # what makes third-party CSS feel like it "takes over".
151
+ #
152
+ # **With the dev server reachable it links no stylesheet**, deliberately:
153
+ # Vite is already injecting the CSS, and linking the file on disk would serve
154
+ # whatever the last build left there. In development WITHOUT the dev server it
155
+ # falls back to the built manifest, matching what `ruact_vite_tags` does.
156
+ #
157
+ # It reads the SAME manifest entry as {#ruact_js_assets}, in the same render,
158
+ # so the script and the stylesheet can never come from different builds.
159
+ #
160
+ # **In a document ruact renders it also emits
161
+ # `<meta name="turbo-visit-control" content="reload">`** (Story 17.0f), in
162
+ # every environment: Turbo Drive then loads a ruact page in full instead of
163
+ # swapping it into its own document. Outside a ruact render — the same layout
164
+ # rendering a plain Rails page — it does not.
165
+ #
166
+ # @return [ActiveSupport::SafeBuffer] the meta (in a ruact document) followed
167
+ # by the `<link>` markup, html_safe; no links in development with Vite
168
+ # running, when no entry exists, or when the entry declares no CSS
169
+ # @example In a layout
170
+ # <head>
171
+ # <%= ruact_head_assets %>
172
+ # <%= stylesheet_link_tag :app %>
173
+ # </head>
174
+ def ruact_head_assets
175
+ tags = []
176
+ tags.push(TURBO_VISIT_CONTROL, TURBO_PREFETCH) if ruact_document?
177
+ tags.concat(ruact_component_stylesheets) unless Rails.env.development? && vite_dev_running?
178
+ tags.join("\n").html_safe
179
+ end
180
+
181
+ # Story 17.0f (FR117) — a document ruact rendered must not be swapped into
182
+ # the page by Turbo Drive: Turbo would keep ITS document, the ruact bootstrap
183
+ # (a module script, evaluated once per document) would not run again, and
184
+ # the two routers would end up fighting over one page — dead links and
185
+ # blank pages after a single round trip (spike 2026-09-12, S3–S7). With this
186
+ # meta Turbo does a full load instead. It does not depend on Vite, so it is
187
+ # emitted in development too.
188
+ TURBO_VISIT_CONTROL = %(<meta name="turbo-visit-control" content="reload">)
189
+
190
+ # Story 17.0f (decided by Luiz, 2026-09-25) — in a document ruact rendered,
191
+ # the ruact router owns every click, but a layout that also loads Turbo 8
192
+ # still lets Turbo PREFETCH links on hover: a GET that runs the destination's
193
+ # action for a click Turbo will never handle (seen in playgrounds/
194
+ # nav-islands). Off in ruact documents; untouched everywhere else.
195
+ TURBO_PREFETCH = %(<meta name="turbo-prefetch" content="false">)
196
+
68
197
  private
69
198
 
199
+ # Whether the document being rendered is ruact's. `render_ruact_document`
200
+ # sets `@ruact_flight_payload` for the whole render (copied into the view by
201
+ # Rails), and removes it after. The same layout rendering a plain Rails page
202
+ # — the app's layout, in whole-app mode — does not have it, and must not
203
+ # tell Turbo to reload every visit.
204
+ def ruact_document?
205
+ instance_variable_defined?(:@ruact_flight_payload) && !@ruact_flight_payload.nil?
206
+ end
207
+
208
+ def ruact_component_stylesheets
209
+ entry = vite_manifest_entry(Ruact.bootstrap_virtual_id)
210
+ return [] if entry.nil?
211
+
212
+ Array(entry["css"]).map { |file| %(<link rel="stylesheet" href="/assets/#{file}">) }
213
+ end
214
+
70
215
  # The `__FLIGHT_DATA` inline bootstrap `<script>` — pushes the per-render
71
216
  # Flight payload onto the global queue the bootstrap entry drains on boot.
72
217
  # `</script>` in the payload is escaped to prevent an HTML/XSS breakout
@@ -139,7 +284,19 @@ module Ruact
139
284
  false
140
285
  end
141
286
 
287
+ # Memoized FOR THE DURATION OF ONE RENDER, which is what lets
288
+ # `ruact_head_assets` and `ruact_js_assets` promise they describe the same
289
+ # build. They are separate calls in the template, so without this a deploy
290
+ # landing between them serves one build's stylesheet beside another build's
291
+ # script — verified reachable in review, not hypothetical.
142
292
  def vite_manifest_entry(src_path)
293
+ @__ruact_manifest_entries ||= {}
294
+ return @__ruact_manifest_entries[src_path] if @__ruact_manifest_entries.key?(src_path)
295
+
296
+ @__ruact_manifest_entries[src_path] = read_vite_manifest_entry(src_path)
297
+ end
298
+
299
+ def read_vite_manifest_entry(src_path)
143
300
  manifest_path = Rails.root.join("public", "assets", ".vite", "manifest.json")
144
301
  return nil unless File.exist?(manifest_path)
145
302
 
@@ -0,0 +1,32 @@
1
+ <%# The document a ruact page renders into when `config.layout = "ruact"` (what
2
+ `rails generate ruact:install` writes). It ships with the gem and is found by
3
+ name through a view path the Railtie appends, behind the app's — so an
4
+ `app/views/layouts/ruact.html.erb` wins over it. `rails generate ruact:layout`
5
+ puts a copy there; once copied, that file is the app's and no longer changes
6
+ when the gem does.
7
+
8
+ What reaches the <head>: CSRF and CSP, the stylesheets Vite built for the
9
+ client components, then the app's stylesheets named in
10
+ `config.layout_stylesheets` — in that order, so the app's CSS loads last and
11
+ wins ties. What does NOT: the app's JavaScript (importmap, Turbo, Stimulus)
12
+ and anything else its own layout adds. The page that rendered the document
13
+ owns its navigation; to bring more of the app in, copy this file.
14
+
15
+ The <title> is the app's name on every page: a ruact view renders in its own
16
+ pass, so nothing it sets can reach this layout. A copied layout can title
17
+ pages however it likes. %>
18
+ <!DOCTYPE html>
19
+ <html>
20
+ <head>
21
+ <title><%= (Rails.application.class.module_parent_name || Rails.application.class.name).to_s.titleize %></title>
22
+ <meta name="viewport" content="width=device-width,initial-scale=1">
23
+ <%= csrf_meta_tags %>
24
+ <%= csp_meta_tag %>
25
+ <%= ruact_head_assets %>
26
+ <%= stylesheet_link_tag(*Ruact.config.layout_stylesheets) if Ruact.config.layout_stylesheets.any? %>
27
+ </head>
28
+ <body>
29
+ <div id="root"></div>
30
+ <%= ruact_js_assets %>
31
+ </body>
32
+ </html>
data/lib/ruact.rb CHANGED
@@ -19,10 +19,25 @@ require_relative "ruact/view_helper"
19
19
  require_relative "ruact/erb_preprocessor_hook"
20
20
  require_relative "ruact/server_functions"
21
21
  require_relative "ruact/query"
22
+ # Story 17.0f — loaded here, not only by the Railtie initializer that installs
23
+ # it, so `config.middleware.delete Ruact::NavigationBoundary::Middleware` in
24
+ # config/application.rb can name the constant before initializers run.
25
+ require_relative "ruact/navigation_boundary"
22
26
  # Railtie loads ruact/controller when inside a Rails app
23
27
  require_relative "ruact/railtie" if defined?(Rails)
24
28
 
25
29
  module Ruact
30
+ # Story 17.0b — the name of the layout the gem ships (lib/ruact/views/layouts/
31
+ # ruact.html.erb), and what `rails generate ruact:install` writes into
32
+ # `config.layout`.
33
+ GEM_LAYOUT = "ruact"
34
+
35
+ # Story 17.0g — a real `include` of Ruact::Controller in a Ruby source line,
36
+ # as `ruact:install` and `ruact:doctor` read it: `include Ruact::Controller`,
37
+ # `include(Ruact::Controller)`, `include ::Ruact::Controller`, or among
38
+ # others in one `include`. A mention in a comment does not count.
39
+ CONTROLLER_INCLUDE = /^[ \t]*include\b[^\n#]*?(?<![\w:])(?:::)?Ruact::Controller\b/
40
+
26
41
  class << self
27
42
  attr_accessor :manifest, :streaming_mode
28
43
 
@@ -46,6 +61,20 @@ module Ruact
46
61
  File.expand_path("../vendor/javascript/vite-plugin-ruact/index.js", __dir__)
47
62
  end
48
63
 
64
+ # Story 17.0b — the directory of views the gem ships: today, the
65
+ # `layouts/ruact` document a ruact page renders into when
66
+ # `config.layout = "ruact"`. The Railtie APPENDS it to the controller view
67
+ # paths, so it sits behind the app and every engine — an app's own
68
+ # `app/views/layouts/ruact.html.erb` wins — and `rails generate ruact:layout`
69
+ # copies from here when an app wants to own that file.
70
+ #
71
+ # Under `lib/` on purpose: `app/` is not packaged (see {Ruact::Packaging}).
72
+ #
73
+ # @return [String] absolute path to lib/ruact/views
74
+ def views_path
75
+ File.expand_path("ruact/views", __dir__)
76
+ end
77
+
49
78
  # Story 14.2 (FR104) — the SINGLE source of truth for the bootstrap entry id.
50
79
  # ruact's React entry is served as the virtual module `virtual:ruact/bootstrap`
51
80
  # (by the bundled Vite plugin) instead of a `app/javascript/application.jsx`