ruact 0.0.11 → 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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +147 -48
- data/CONTRIBUTING.md +233 -0
- data/README.md +18 -8
- data/RELEASING.md +125 -139
- data/lib/generators/ruact/install/install_generator.rb +305 -126
- data/lib/generators/ruact/install/templates/AGENTS.md.tt +14 -13
- data/lib/generators/ruact/install/templates/initializer.rb.tt +29 -7
- 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/templates/controller.rb.tt +5 -3
- data/lib/ruact/configuration.rb +65 -13
- 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/packaging.rb +68 -0
- data/lib/ruact/railtie.rb +30 -0
- 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/flight-client.test.mjs +321 -0
- data/vendor/javascript/vite-plugin-ruact/package-lock.json +11 -0
- data/vendor/javascript/vite-plugin-ruact/package.json +1 -0
- data/vendor/javascript/vite-plugin-ruact/ruact-router.test.mjs +433 -0
- data/vendor/javascript/vite-plugin-ruact/runtime/flight-client.js +14 -3
- data/vendor/javascript/vite-plugin-ruact/runtime/ruact-router.js +170 -7
- metadata +12 -107
- data/.codecov.yml +0 -31
- data/.github/workflows/ci.yml +0 -284
- data/.github/workflows/server-functions-bench.yml +0 -54
- data/.rubocop.yml +0 -107
- data/.rubocop_todo.yml +0 -63
- data/Rakefile +0 -10
- data/bench/server_functions_dispatch_bench.rb +0 -276
- data/bench/server_functions_dispatch_bench.results.md +0 -150
- data/docs/internal/README.md +0 -9
- data/docs/internal/decisions/server-functions-api.md +0 -2236
- data/spec/benchmarks/baseline.json +0 -12
- data/spec/benchmarks/render_pipeline_benchmark_spec.rb +0 -109
- data/spec/fixtures/flight/README.md +0 -136
- data/spec/fixtures/flight/array.txt +0 -1
- data/spec/fixtures/flight/as_json_object.txt +0 -2
- data/spec/fixtures/flight/bigint.txt +0 -1
- data/spec/fixtures/flight/boolean_false.txt +0 -1
- data/spec/fixtures/flight/boolean_true.txt +0 -1
- data/spec/fixtures/flight/client_component_with_props.txt +0 -2
- data/spec/fixtures/flight/client_reference.txt +0 -2
- data/spec/fixtures/flight/hash.txt +0 -1
- data/spec/fixtures/flight/infinity.txt +0 -1
- data/spec/fixtures/flight/nan.txt +0 -1
- data/spec/fixtures/flight/negative_infinity.txt +0 -1
- data/spec/fixtures/flight/nil.txt +0 -1
- data/spec/fixtures/flight/number_float.txt +0 -1
- data/spec/fixtures/flight/number_integer.txt +0 -1
- data/spec/fixtures/flight/react_element_no_props.txt +0 -1
- data/spec/fixtures/flight/redirect_row.txt +0 -1
- data/spec/fixtures/flight/serializable_object.txt +0 -2
- data/spec/fixtures/flight/string_basic.txt +0 -1
- data/spec/fixtures/flight/string_dollar_escape.txt +0 -1
- data/spec/fixtures/flight/undefined.txt +0 -1
- data/spec/fixtures/readme/children-error.html.erb +0 -3
- data/spec/fixtures/readme/children-error.txt +0 -1
- data/spec/fixtures/story_7_9_views/controller_request_spec_support/demo/show.html.erb +0 -3
- data/spec/fixtures/story_7_9_views/controller_request_spec_support/errors_demo/new.html.erb +0 -3
- data/spec/fixtures/story_7_9_views/controller_request_spec_support/exploding_layout_demo/show.html.erb +0 -3
- data/spec/fixtures/story_7_9_views/controller_request_spec_support/ghost_layout_demo/show.html.erb +0 -3
- data/spec/fixtures/story_7_9_views/controller_request_spec_support/layout_demo/show.html.erb +0 -3
- data/spec/fixtures/story_7_9_views/controller_request_spec_support/rootless_layout_demo/show.html.erb +0 -3
- data/spec/fixtures/story_7_9_views/controller_request_spec_support/unwired_layout_demo/show.html.erb +0 -3
- data/spec/fixtures/story_7_9_views/layouts/bare_host.html.erb +0 -16
- data/spec/fixtures/story_7_9_views/layouts/exploding_host.html.erb +0 -24
- data/spec/fixtures/story_7_9_views/layouts/rootless_host.html.erb +0 -15
- data/spec/fixtures/story_7_9_views/layouts/ruact_host.html.erb +0 -17
- data/spec/readme_demo_message_spec.rb +0 -67
- data/spec/readme_spec.rb +0 -282
- data/spec/ruact/client_manifest_spec.rb +0 -270
- data/spec/ruact/component_contract_spec.rb +0 -119
- data/spec/ruact/configuration_spec.rb +0 -518
- data/spec/ruact/controller_request_spec.rb +0 -671
- data/spec/ruact/controller_spec.rb +0 -343
- data/spec/ruact/doctor_spec.rb +0 -769
- data/spec/ruact/erb_preprocessor_hook_spec.rb +0 -55
- data/spec/ruact/erb_preprocessor_spec.rb +0 -361
- data/spec/ruact/errors_spec.rb +0 -93
- data/spec/ruact/flight/renderer_spec.rb +0 -133
- data/spec/ruact/flight/serializer_spec.rb +0 -494
- data/spec/ruact/html_converter_spec.rb +0 -375
- data/spec/ruact/install_generator_spec.rb +0 -1549
- data/spec/ruact/layout_source_spec.rb +0 -108
- data/spec/ruact/manifest_resolver_spec.rb +0 -174
- data/spec/ruact/query_request_spec.rb +0 -706
- data/spec/ruact/query_spec.rb +0 -105
- data/spec/ruact/railtie_spec.rb +0 -155
- data/spec/ruact/render_context_spec.rb +0 -58
- data/spec/ruact/render_pipeline_concurrency_spec.rb +0 -78
- data/spec/ruact/render_pipeline_spec.rb +0 -928
- data/spec/ruact/scaffold_generator_spec.rb +0 -1849
- data/spec/ruact/serializable_spec.rb +0 -179
- data/spec/ruact/server_bucket_request_spec.rb +0 -785
- data/spec/ruact/server_function_name_spec.rb +0 -53
- data/spec/ruact/server_functions/backtrace_cleaner_spec.rb +0 -63
- data/spec/ruact/server_functions/bucket_two_payload_spec.rb +0 -200
- data/spec/ruact/server_functions/codegen_spec.rb +0 -397
- data/spec/ruact/server_functions/error_payload_spec.rb +0 -222
- data/spec/ruact/server_functions/error_suggestion_spec.rb +0 -79
- data/spec/ruact/server_functions/introspection_spec.rb +0 -135
- data/spec/ruact/server_functions/name_bridge_spec.rb +0 -212
- data/spec/ruact/server_functions/query_context_spec.rb +0 -72
- data/spec/ruact/server_functions/query_source_spec.rb +0 -193
- data/spec/ruact/server_functions/railtie_integration_spec.rb +0 -215
- data/spec/ruact/server_functions/rake_spec.rb +0 -86
- data/spec/ruact/server_functions/route_source_spec.rb +0 -202
- data/spec/ruact/server_functions/snapshot_spec.rb +0 -96
- data/spec/ruact/server_functions/snapshot_writer_spec.rb +0 -71
- data/spec/ruact/server_rescue_request_spec.rb +0 -416
- data/spec/ruact/server_spec.rb +0 -179
- data/spec/ruact/server_upload_request_spec.rb +0 -311
- data/spec/ruact/signed_references_spec.rb +0 -164
- data/spec/ruact/string_distance_spec.rb +0 -38
- data/spec/ruact/tasks_json_introspection_spec.rb +0 -141
- data/spec/ruact/testing/have_ruact_component_spec.rb +0 -170
- data/spec/ruact/testing/no_production_load_spec.rb +0 -41
- data/spec/ruact/validation_errors_spec.rb +0 -116
- data/spec/ruact/view_helper_spec.rb +0 -131
- data/spec/spec_helper.rb +0 -77
- data/spec/support/fixtures/pixel.png +0 -0
- data/spec/support/flight_wire_parser.rb +0 -21
- data/spec/support/flight_wire_parser_spec.rb +0 -93
- data/spec/support/matchers/flight_fixture_matcher.rb +0 -130
- data/spec/support/matchers/flight_fixture_matcher_spec.rb +0 -250
- data/spec/support/rails_stub.rb +0 -115
|
@@ -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
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ruact
|
|
4
|
+
# What goes inside the published `.gem`, as a predicate rather than a list.
|
|
5
|
+
#
|
|
6
|
+
# There is exactly one writer of this rule. `ruact.gemspec` calls it to build
|
|
7
|
+
# `spec.files`, and `bin/release-gate` calls it to decide whether a pull
|
|
8
|
+
# request changed the bytes a consumer installs. A hand-kept path list on
|
|
9
|
+
# either side would be a second copy of the packaging rule, and it would drift
|
|
10
|
+
# from this one the first time the rule changed — which is the whole reason
|
|
11
|
+
# the gate does not carry its own.
|
|
12
|
+
#
|
|
13
|
+
# This file is **not** required from `lib/ruact.rb`. It is build-time code
|
|
14
|
+
# that happens to live under `lib/` so the gemspec can `require_relative` into
|
|
15
|
+
# it; nothing at runtime loads it, and nothing at runtime may come to depend
|
|
16
|
+
# on it.
|
|
17
|
+
#
|
|
18
|
+
# WHAT IT DOES NOT PROMISE
|
|
19
|
+
#
|
|
20
|
+
# That the path exists, is tracked, or is readable. It answers a question
|
|
21
|
+
# about a path's shape and nothing else; both callers hand it paths that
|
|
22
|
+
# `git` has already vouched for.
|
|
23
|
+
module Packaging
|
|
24
|
+
# Directory trees a consumer of the installed gem actually reaches:
|
|
25
|
+
# `lib/` is the library, `sig/` is its RBS surface, and
|
|
26
|
+
# `vendor/javascript/` is public surface too — the bundled Vite plugin and
|
|
27
|
+
# browser runtime are resolved by filesystem path off the installed gem, so
|
|
28
|
+
# a release that left them out would generate imports to files that do not
|
|
29
|
+
# exist on the consumer's disk.
|
|
30
|
+
TREES = %w[lib/ sig/ vendor/javascript/].freeze
|
|
31
|
+
|
|
32
|
+
# `vendor/bundle/` is the local Bundler install directory. It is not under
|
|
33
|
+
# any of {TREES} today; the exclusion is here so that widening a tree above
|
|
34
|
+
# cannot quietly start packaging it.
|
|
35
|
+
EXCLUDED_TREES = %w[vendor/bundle/].freeze
|
|
36
|
+
|
|
37
|
+
# The documents that ship beside the code, expressed as a shape rather than
|
|
38
|
+
# a roster: a tracked file at the top level whose extension is `.md` or
|
|
39
|
+
# `.txt`. That covers the readme, the changelog, the licence, the security
|
|
40
|
+
# policy and both guides, and it keeps covering the next one without anyone
|
|
41
|
+
# remembering to add it here.
|
|
42
|
+
#
|
|
43
|
+
# It also decides the near misses by construction. `ruact.gemspec`,
|
|
44
|
+
# `Gemfile`, `Gemfile.lock`, `Rakefile`, `.rubocop.yml` and `.gitignore` are
|
|
45
|
+
# all top-level and none of them is a document, so none of them ships.
|
|
46
|
+
ROOT_DOCUMENT = %r{\A[^/]+\.(?:md|txt)\z}i
|
|
47
|
+
|
|
48
|
+
# Does this repository-relative path go inside the published gem?
|
|
49
|
+
#
|
|
50
|
+
# @param path [String] a repository-relative path, as `git ls-files` and
|
|
51
|
+
# `git diff --name-only` both emit it (forward slashes, no leading `./`)
|
|
52
|
+
# @return [Boolean] true when the path is part of what a consumer installs
|
|
53
|
+
def self.packaged?(path)
|
|
54
|
+
return false if EXCLUDED_TREES.any? { |tree| path.start_with?(tree) }
|
|
55
|
+
|
|
56
|
+
TREES.any? { |tree| path.start_with?(tree) } || path.match?(ROOT_DOCUMENT)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# The packaged subset of a list of repository-relative paths, order
|
|
60
|
+
# preserved.
|
|
61
|
+
#
|
|
62
|
+
# @param paths [Enumerable<String>] repository-relative paths
|
|
63
|
+
# @return [Array<String>] those of them that go inside the published gem
|
|
64
|
+
def self.packaged_paths(paths)
|
|
65
|
+
paths.select { |path| packaged?(path) }
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
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" =>
|
|
294
|
+
render json: { "$redirect" => path }
|
|
286
295
|
end
|
|
287
296
|
|
|
288
297
|
private
|
data/lib/ruact/version.rb
CHANGED
data/lib/ruact/view_helper.rb
CHANGED
|
@@ -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,
|
|
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`
|