ruact 0.0.8 → 0.0.9
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 +35 -1
- data/lib/generators/ruact/install/install_generator.rb +264 -5
- data/lib/generators/ruact/install/templates/Procfile.dev.tt +3 -0
- data/lib/generators/ruact/install/templates/globals.css.tt +20 -0
- data/lib/generators/ruact/install/templates/initializer.rb.tt +9 -0
- data/lib/generators/ruact/install/templates/package.json.tt +7 -1
- data/lib/generators/ruact/install/templates/tsconfig.json.tt +18 -0
- data/lib/generators/ruact/scaffold/scaffold_shadcn_preflight.rb +10 -0
- data/lib/ruact/configuration.rb +73 -0
- data/lib/ruact/controller/document_rendering.rb +210 -0
- data/lib/ruact/controller.rb +5 -46
- data/lib/ruact/doctor.rb +37 -5
- data/lib/ruact/layout_source.rb +59 -0
- data/lib/ruact/version.rb +1 -1
- data/lib/ruact/view_helper.rb +10 -1
- data/lib/ruact.rb +1 -0
- data/spec/fixtures/story_7_9_views/controller_request_spec_support/exploding_layout_demo/show.html.erb +3 -0
- data/spec/fixtures/story_7_9_views/controller_request_spec_support/ghost_layout_demo/show.html.erb +3 -0
- data/spec/fixtures/story_7_9_views/controller_request_spec_support/layout_demo/show.html.erb +3 -0
- data/spec/fixtures/story_7_9_views/controller_request_spec_support/rootless_layout_demo/show.html.erb +3 -0
- data/spec/fixtures/story_7_9_views/controller_request_spec_support/unwired_layout_demo/show.html.erb +3 -0
- data/spec/fixtures/story_7_9_views/layouts/bare_host.html.erb +16 -0
- data/spec/fixtures/story_7_9_views/layouts/exploding_host.html.erb +24 -0
- data/spec/fixtures/story_7_9_views/layouts/rootless_host.html.erb +15 -0
- data/spec/fixtures/story_7_9_views/layouts/ruact_host.html.erb +17 -0
- data/spec/ruact/controller_request_spec.rb +203 -0
- data/spec/ruact/doctor_spec.rb +81 -6
- data/spec/ruact/install_generator_spec.rb +442 -70
- data/spec/ruact/layout_source_spec.rb +108 -0
- data/spec/ruact/scaffold_generator_spec.rb +14 -0
- metadata +18 -4
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ruact
|
|
4
|
+
module Controller
|
|
5
|
+
# How a ruact response becomes a full HTML DOCUMENT — the wrapper a browser
|
|
6
|
+
# gets on a normal navigation, as opposed to the raw `text/x-component`
|
|
7
|
+
# Flight body an in-app navigation gets.
|
|
8
|
+
#
|
|
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.
|
|
15
|
+
module DocumentRendering
|
|
16
|
+
extend ActiveSupport::Concern
|
|
17
|
+
|
|
18
|
+
# Proof that a host layout called `ruact_js_assets`: the helper always
|
|
19
|
+
# emits the `__FLIGHT_DATA` bootstrap script when a payload is present,
|
|
20
|
+
# and `render_ruact_document` always supplies one. Matching on the payload
|
|
21
|
+
# script (rather than on the entry `<script src>`) keeps the check true in
|
|
22
|
+
# BOTH dev and production, whose entry tags differ.
|
|
23
|
+
RUACT_ASSETS_MARKER = "__FLIGHT_DATA"
|
|
24
|
+
|
|
25
|
+
# What counts as a mount target and what counts as a real call both live in
|
|
26
|
+
# {Ruact::LayoutSource}, shared with `ruact:install` so the runtime and the
|
|
27
|
+
# generator can never disagree about whether a layout is migrated.
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
# Emit the full HTML document a browser gets on a normal navigation.
|
|
32
|
+
#
|
|
33
|
+
# `Ruact.config.layout` decides, and it is EXPLICIT — ruact does not try
|
|
34
|
+
# to infer whether your layout is ready. It used to: three review rounds
|
|
35
|
+
# each found another template shape that fooled the inference (a mention
|
|
36
|
+
# in a comment, a commented-out call, a trim-mode comment), and each wrong
|
|
37
|
+
# answer governed how every page in the app rendered. Answering "does this
|
|
38
|
+
# template call this method?" is not something pattern-matching can do
|
|
39
|
+
# reliably, so the question is no longer asked. See
|
|
40
|
+
# Ruact::Configuration#layout.
|
|
41
|
+
#
|
|
42
|
+
# - `false` (the default) → the built-in shell. Byte-identical to ruact's
|
|
43
|
+
# behaviour before the layout path existed, with no detection in the
|
|
44
|
+
# 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.
|
|
48
|
+
#
|
|
49
|
+
# An opted-in layout that does not actually emit the assets would produce
|
|
50
|
+
# a blank page, so the rendered document is checked before it is
|
|
51
|
+
# committed: that is a configuration error, raised in development/test and
|
|
52
|
+
# logged-and-degraded in production rather than served to real traffic.
|
|
53
|
+
def render_ruact_document(payload)
|
|
54
|
+
layout = Ruact.config.layout
|
|
55
|
+
return render html: ruact_html_shell(payload).html_safe, layout: false if layout == false
|
|
56
|
+
|
|
57
|
+
unless ruact_layout_resolvable?(layout)
|
|
58
|
+
__ruact_handle_unready_layout(layout, :missing)
|
|
59
|
+
return render html: ruact_html_shell(payload).html_safe, layout: false
|
|
60
|
+
end
|
|
61
|
+
|
|
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
|
|
66
|
+
document = render_to_string(html: "".html_safe, layout: layout)
|
|
67
|
+
|
|
68
|
+
if ruact_document_mountable?(document)
|
|
69
|
+
render html: document.html_safe, layout: false
|
|
70
|
+
else
|
|
71
|
+
__ruact_handle_unready_layout(layout, :unwired)
|
|
72
|
+
render html: ruact_html_shell(payload).html_safe, layout: false
|
|
73
|
+
end
|
|
74
|
+
ensure
|
|
75
|
+
remove_instance_variable(:@ruact_flight_payload) if instance_variable_defined?(:@ruact_flight_payload)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# A document is only usable if BOTH halves are present: the payload/bootstrap
|
|
79
|
+
# block AND something to mount into. Checking the assets alone accepts a
|
|
80
|
+
# layout that calls `ruact_js_assets` in `<head>` but never got the root div
|
|
81
|
+
# — React then boots with no mount target and the page is silently blank.
|
|
82
|
+
def ruact_document_mountable?(document)
|
|
83
|
+
document.include?(RUACT_ASSETS_MARKER) && Ruact::LayoutSource.root?(document)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Is there a layout to render into at all? Asking FIRST matters: passing
|
|
87
|
+
# `layout: true` to a controller with no resolvable layout raises
|
|
88
|
+
# `ArgumentError("There was no default layout for ...")`, which would turn
|
|
89
|
+
# every page of an API-shaped or `layout false` controller into a 500 in
|
|
90
|
+
# an app that opted in globally. `_default_layout`'s `require_layout`
|
|
91
|
+
# argument defaults to false precisely so it can be used as a probe — it
|
|
92
|
+
# returns nil instead of raising, and honours `action_has_layout?`.
|
|
93
|
+
#
|
|
94
|
+
# `NameError` is deliberately NOT swallowed: `_default_layout` re-raises it
|
|
95
|
+
# as "Could not render layout: ..." to surface a broken layout resolver
|
|
96
|
+
# (`layout -> { MissingConstant::LAYOUT }`). Turning that into "no layout"
|
|
97
|
+
# would hide the developer's bug behind a silently degraded page.
|
|
98
|
+
def ruact_layout_resolvable?(layout)
|
|
99
|
+
return ruact_layout_exists?(layout) if layout.is_a?(String)
|
|
100
|
+
|
|
101
|
+
resolved = _default_layout(lookup_context, [:html], [])
|
|
102
|
+
return false if resolved.nil? || resolved == false
|
|
103
|
+
# `_default_layout` returns a Template for the conventional lookup but a
|
|
104
|
+
# bare String path for a controller-declared `layout "name"` — and it
|
|
105
|
+
# returns that String WITHOUT checking the template exists. Treating
|
|
106
|
+
# "not nil" as resolvable therefore sent a declared-but-missing layout
|
|
107
|
+
# into `render_to_string`, where it raised `MissingTemplate` instead of
|
|
108
|
+
# taking the intended degrade-to-shell path.
|
|
109
|
+
return ruact_layout_exists?(resolved) if resolved.is_a?(String)
|
|
110
|
+
|
|
111
|
+
true
|
|
112
|
+
rescue NameError
|
|
113
|
+
raise
|
|
114
|
+
rescue StandardError
|
|
115
|
+
false
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def ruact_layout_exists?(name)
|
|
119
|
+
lookup_context.exists?(name.to_s.delete_prefix("layouts/"), ["layouts"])
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def __ruact_handle_unready_layout(_layout, reason)
|
|
123
|
+
detail =
|
|
124
|
+
if reason == :missing
|
|
125
|
+
"no layout could be resolved for it"
|
|
126
|
+
else
|
|
127
|
+
"the layout it rendered emitted no `ruact_js_assets` output (or no " \
|
|
128
|
+
"`<div id=\"root\"></div>` to mount into), which would have been a blank page"
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
message = <<~MSG.strip
|
|
132
|
+
ruact: #{controller_path}##{action_name} fell back to ruact's built-in HTML shell — #{detail}.
|
|
133
|
+
`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.
|
|
135
|
+
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.
|
|
138
|
+
MSG
|
|
139
|
+
|
|
140
|
+
# A MISSING layout is a legitimate per-controller choice — an API-shaped
|
|
141
|
+
# controller, or one that declared `layout false`, inside an app that
|
|
142
|
+
# opted in globally. Raising there would break a normal Rails pattern,
|
|
143
|
+
# so it degrades to the shell and says so where a developer will see it.
|
|
144
|
+
#
|
|
145
|
+
# An UNWIRED layout is different: the developer pointed ruact at a
|
|
146
|
+
# layout that cannot mount the app, which is their bug to see — loudly
|
|
147
|
+
# in development, and degraded (never blank) in production.
|
|
148
|
+
if reason == :missing
|
|
149
|
+
logger&.info(message) if __ruact_local_env?
|
|
150
|
+
return
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
raise Ruact::Error, message if __ruact_local_env?
|
|
154
|
+
|
|
155
|
+
logger&.error(message)
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def __ruact_local_env?
|
|
159
|
+
Rails.env.development? || Rails.env.test?
|
|
160
|
+
rescue StandardError
|
|
161
|
+
false
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def ruact_html_shell(flight_payload)
|
|
165
|
+
# Story 14.2 — the JS asset block (entry `<script>` tags + `__FLIGHT_DATA`)
|
|
166
|
+
# is delegated to the single `Ruact::ViewHelper#ruact_js_assets`
|
|
167
|
+
# implementation. The bootstrap entry script is a deferred ES module, so it
|
|
168
|
+
# runs after the inline `__FLIGHT_DATA` classic script has populated the
|
|
169
|
+
# queue regardless of source order — emitting the whole block in `<body>`
|
|
170
|
+
# is correct.
|
|
171
|
+
<<~HTML
|
|
172
|
+
<!DOCTYPE html>
|
|
173
|
+
<html lang="en">
|
|
174
|
+
<head>
|
|
175
|
+
<meta charset="UTF-8" />
|
|
176
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
177
|
+
#{ruact_csrf_meta_tag}
|
|
178
|
+
<title>Rails RSC</title>
|
|
179
|
+
</head>
|
|
180
|
+
<body>
|
|
181
|
+
<div id="root"></div>
|
|
182
|
+
#{ruact_js_assets(flight_payload)}
|
|
183
|
+
</body>
|
|
184
|
+
</html>
|
|
185
|
+
HTML
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# Story 8.3 review R7 — emits `<meta name="csrf-token" content="...">`
|
|
189
|
+
# into the shell so the JS runtime's `<meta>` lookup can forward a
|
|
190
|
+
# valid `X-CSRF-Token` on every server-function (mutation) call. Without
|
|
191
|
+
# this, hosts that route `ruact_render` through the gem's HTML shell (the
|
|
192
|
+
# standard path) have no token in the document and the host's
|
|
193
|
+
# `protect_from_forgery` rejects every non-GET server function.
|
|
194
|
+
#
|
|
195
|
+
# Returns an empty string when CSRF protection isn't available
|
|
196
|
+
# (non-Rails specs, or hosts that have deliberately stripped
|
|
197
|
+
# `form_authenticity_token` from the controller surface).
|
|
198
|
+
def ruact_csrf_meta_tag
|
|
199
|
+
return "" unless respond_to?(:form_authenticity_token, true)
|
|
200
|
+
|
|
201
|
+
token = form_authenticity_token
|
|
202
|
+
return "" if token.nil? || token.empty?
|
|
203
|
+
|
|
204
|
+
%(<meta name="csrf-token" content="#{ERB::Util.html_escape(token)}" />)
|
|
205
|
+
rescue StandardError
|
|
206
|
+
""
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
end
|
data/lib/ruact/controller.rb
CHANGED
|
@@ -5,6 +5,7 @@ require "socket"
|
|
|
5
5
|
require "uri"
|
|
6
6
|
require_relative "view_helper"
|
|
7
7
|
require_relative "validation_errors_collector"
|
|
8
|
+
require_relative "controller/document_rendering"
|
|
8
9
|
|
|
9
10
|
module Ruact
|
|
10
11
|
# Include in ApplicationController to enable RSC rendering.
|
|
@@ -27,6 +28,9 @@ module Ruact
|
|
|
27
28
|
# and so the shared `vite_dev_running?` / `vite_manifest_entry` helpers are
|
|
28
29
|
# reachable here without duplication.
|
|
29
30
|
include Ruact::ViewHelper
|
|
31
|
+
# Who owns the `<head>` — the host app's layout, with ruact's built-in shell
|
|
32
|
+
# as the un-migrated fallback. See Ruact::Controller::DocumentRendering.
|
|
33
|
+
include Ruact::Controller::DocumentRendering
|
|
30
34
|
|
|
31
35
|
private
|
|
32
36
|
|
|
@@ -178,7 +182,7 @@ module Ruact
|
|
|
178
182
|
if ruact_request?
|
|
179
183
|
render plain: payload, content_type: "text/x-component"
|
|
180
184
|
else
|
|
181
|
-
|
|
185
|
+
render_ruact_document(payload)
|
|
182
186
|
end
|
|
183
187
|
end
|
|
184
188
|
end
|
|
@@ -236,50 +240,5 @@ module Ruact
|
|
|
236
240
|
controller = self.class.name.underscore.sub("_controller", "")
|
|
237
241
|
Rails.root.join("app", "views", controller, "#{action}.html.erb")
|
|
238
242
|
end
|
|
239
|
-
|
|
240
|
-
def ruact_html_shell(flight_payload)
|
|
241
|
-
# Story 14.2 — the JS asset block (entry `<script>` tags + `__FLIGHT_DATA`)
|
|
242
|
-
# is delegated to the single `Ruact::ViewHelper#ruact_js_assets`
|
|
243
|
-
# implementation. The bootstrap entry script is a deferred ES module, so it
|
|
244
|
-
# runs after the inline `__FLIGHT_DATA` classic script has populated the
|
|
245
|
-
# queue regardless of source order — emitting the whole block in `<body>`
|
|
246
|
-
# is correct.
|
|
247
|
-
<<~HTML
|
|
248
|
-
<!DOCTYPE html>
|
|
249
|
-
<html lang="en">
|
|
250
|
-
<head>
|
|
251
|
-
<meta charset="UTF-8" />
|
|
252
|
-
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
253
|
-
#{ruact_csrf_meta_tag}
|
|
254
|
-
<title>Rails RSC</title>
|
|
255
|
-
</head>
|
|
256
|
-
<body>
|
|
257
|
-
<div id="root"></div>
|
|
258
|
-
#{ruact_js_assets(flight_payload)}
|
|
259
|
-
</body>
|
|
260
|
-
</html>
|
|
261
|
-
HTML
|
|
262
|
-
end
|
|
263
|
-
|
|
264
|
-
# Story 8.3 review R7 — emits `<meta name="csrf-token" content="...">`
|
|
265
|
-
# into the shell so the JS runtime's `<meta>` lookup can forward a
|
|
266
|
-
# valid `X-CSRF-Token` on every server-function (mutation) call. Without
|
|
267
|
-
# this, hosts that route `ruact_render` through the gem's HTML shell (the
|
|
268
|
-
# standard path) have no token in the document and the host's
|
|
269
|
-
# `protect_from_forgery` rejects every non-GET server function.
|
|
270
|
-
#
|
|
271
|
-
# Returns an empty string when CSRF protection isn't available
|
|
272
|
-
# (non-Rails specs, or hosts that have deliberately stripped
|
|
273
|
-
# `form_authenticity_token` from the controller surface).
|
|
274
|
-
def ruact_csrf_meta_tag
|
|
275
|
-
return "" unless respond_to?(:form_authenticity_token, true)
|
|
276
|
-
|
|
277
|
-
token = form_authenticity_token
|
|
278
|
-
return "" if token.nil? || token.empty?
|
|
279
|
-
|
|
280
|
-
%(<meta name="csrf-token" content="#{ERB::Util.html_escape(token)}" />)
|
|
281
|
-
rescue StandardError
|
|
282
|
-
""
|
|
283
|
-
end
|
|
284
243
|
end
|
|
285
244
|
end
|
data/lib/ruact/doctor.rb
CHANGED
|
@@ -171,14 +171,46 @@ module Ruact
|
|
|
171
171
|
end
|
|
172
172
|
end
|
|
173
173
|
|
|
174
|
+
# Two independent halves have to line up, and BOTH are silent when wrong:
|
|
175
|
+
# the layout has to call `ruact_js_assets`, and `Ruact.config.layout` has to
|
|
176
|
+
# be on. Miss either and ruact renders its built-in shell — which carries no
|
|
177
|
+
# stylesheet, so the app's own CSS never reaches a ruact page and nothing
|
|
178
|
+
# errors. Reporting each half separately is the point: "add one line" and
|
|
179
|
+
# "flip one setting" are different fixes.
|
|
180
|
+
#
|
|
181
|
+
# `Ruact::LayoutSource` is the SHARED definition of "calls the helper" —
|
|
182
|
+
# the runtime and `ruact:install` read it too, so this check cannot drift
|
|
183
|
+
# into disagreeing with what actually happens at render time (it did:
|
|
184
|
+
# a `<%# TODO: add ruact_js_assets %>` comment used to pass here).
|
|
174
185
|
def check_layout
|
|
175
186
|
path = Rails.root.join("app", "views", "layouts", "application.html.erb")
|
|
176
|
-
|
|
177
|
-
[:
|
|
178
|
-
|
|
179
|
-
[:fail, "React shell missing from application.html.erb",
|
|
180
|
-
"Run rails generate ruact:install to add the React shell to application.html.erb."]
|
|
187
|
+
unless File.exist?(path)
|
|
188
|
+
return [:fail, "React shell missing from application.html.erb",
|
|
189
|
+
"Run rails generate ruact:install to add the React shell to application.html.erb."]
|
|
181
190
|
end
|
|
191
|
+
|
|
192
|
+
content = File.read(path)
|
|
193
|
+
has_root = Ruact::LayoutSource.root?(content)
|
|
194
|
+
has_assets = Ruact::LayoutSource.wired?(content)
|
|
195
|
+
opted_in = Ruact.config.layout != false
|
|
196
|
+
|
|
197
|
+
return layout_unwired_result unless has_root && has_assets
|
|
198
|
+
return layout_not_opted_in_result unless opted_in
|
|
199
|
+
|
|
200
|
+
[:pass, "layout owns the document (React root + ruact_js_assets, config.layout on)"]
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def layout_unwired_result
|
|
204
|
+
[:fail, "layout is missing the React root and/or the ruact_js_assets call",
|
|
205
|
+
"Add <%= ruact_js_assets %> next to <div id=\"root\"></div> in " \
|
|
206
|
+
"app/views/layouts/application.html.erb (or re-run rails generate ruact:install). " \
|
|
207
|
+
"Without both, ruact renders its built-in shell and your app's CSS never reaches the page."]
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def layout_not_opted_in_result
|
|
211
|
+
[:warn, "layout is ready but Ruact.config.layout is false",
|
|
212
|
+
"Your layout calls ruact_js_assets, but ruact is still rendering its built-in shell " \
|
|
213
|
+
"(which has no stylesheet). Set `config.layout = true` in config/initializers/ruact.rb."]
|
|
182
214
|
end
|
|
183
215
|
|
|
184
216
|
def check_streaming
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ruact
|
|
4
|
+
# Answers one question in ONE place: does this layout actually wire ruact up?
|
|
5
|
+
#
|
|
6
|
+
# Two callers need that answer and must not disagree about it — the runtime
|
|
7
|
+
# (`Ruact::Controller::DocumentRendering`, deciding whether it is safe to
|
|
8
|
+
# render through the host layout) and `ruact:install` (deciding whether the
|
|
9
|
+
# layout still needs migrating). When they each carried their own notion of
|
|
10
|
+
# "present", they drifted: the generator skipped a layout as already-migrated
|
|
11
|
+
# on a `<%# TODO: add ruact_js_assets %>` comment, while the runtime read the
|
|
12
|
+
# same layout as unwired. Both were string-matching a NAME where only a CALL
|
|
13
|
+
# counts.
|
|
14
|
+
module LayoutSource
|
|
15
|
+
# ERB comments are stripped before anything else. A commented-out call —
|
|
16
|
+
# `<%# <%= ruact_js_assets %> %>`, which a developer produces the moment
|
|
17
|
+
# they disable it while debugging — emits NOTHING, so counting it as wired
|
|
18
|
+
# would report a layout as migrated when it is not. The `-?` covers ERB's
|
|
19
|
+
# trim-mode forms (`<%-#` / `-%>`), which Erubi treats as comments too and
|
|
20
|
+
# an earlier version of this pattern missed.
|
|
21
|
+
ERB_COMMENT = /<%-?#.*?-?%>/m
|
|
22
|
+
|
|
23
|
+
# An ERB OUTPUT tag calling the helper: `<%= ruact_js_assets %>` and the raw
|
|
24
|
+
# `<%== ... %>` form, with or without arguments or surrounding whitespace.
|
|
25
|
+
# Scanning stops at the tag's own `%>` rather than forbidding `%` outright —
|
|
26
|
+
# `<%= raw("100%") + ruact_js_assets %>` is a legitimate call that a
|
|
27
|
+
# `[^%]*` pattern rejected, while still never matching a bare mention that
|
|
28
|
+
# merely follows some other tag.
|
|
29
|
+
ASSETS_CALL = /<%=+(?:(?!%>).)*?\bruact_js_assets\b/m
|
|
30
|
+
|
|
31
|
+
# The React mount target, as an attribute rather than as a substring. The
|
|
32
|
+
# lookbehind is what stops `data-id="root"` (and any other `*-id`) from
|
|
33
|
+
# counting: those are not the mount point, and a document that has one but
|
|
34
|
+
# no real root gives React nothing to mount into. Unquoted `id=root` is
|
|
35
|
+
# valid HTML and is accepted.
|
|
36
|
+
ROOT_ATTRIBUTE = /(?<![-\w])id\s*=\s*(?:"root"|'root'|root(?=[\s>]))/
|
|
37
|
+
|
|
38
|
+
# The whole element, for a generator that needs something to inject AFTER.
|
|
39
|
+
ROOT_ELEMENT = %r{<div\s[^>]*#{ROOT_ATTRIBUTE.source}[^>]*>\s*</div>}
|
|
40
|
+
|
|
41
|
+
class << self
|
|
42
|
+
# Does this ERB source actually CALL `ruact_js_assets`?
|
|
43
|
+
def wired?(source)
|
|
44
|
+
without_comments(source).match?(ASSETS_CALL)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Does this markup carry a React mount target? Used on RENDERED HTML by
|
|
48
|
+
# the runtime and on ERB source by the generator; the attribute shape is
|
|
49
|
+
# the same either way.
|
|
50
|
+
def root?(markup)
|
|
51
|
+
markup.to_s.match?(ROOT_ATTRIBUTE)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def without_comments(source)
|
|
55
|
+
source.to_s.gsub(ERB_COMMENT, "")
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
data/lib/ruact/version.rb
CHANGED
data/lib/ruact/view_helper.rb
CHANGED
|
@@ -44,12 +44,21 @@ module Ruact
|
|
|
44
44
|
# (no drift). Available in every view via the railtie's
|
|
45
45
|
# `ActionView::Base.include(Ruact::ViewHelper)`.
|
|
46
46
|
#
|
|
47
|
+
# Called from a LAYOUT the argument is normally omitted: the payload for the
|
|
48
|
+
# render in flight is picked up from `@ruact_flight_payload`, which
|
|
49
|
+
# `Ruact::Controller#render_ruact_document` sets on the controller and Rails
|
|
50
|
+
# copies to the view (the name does not match the `/\A@_/` protected-ivar
|
|
51
|
+
# filter). On a plain Rails page — no ruact render in flight — that ivar is
|
|
52
|
+
# absent and the helper emits only the entry tags, exactly as before.
|
|
53
|
+
#
|
|
47
54
|
# @param flight_payload [String, nil] the per-render Flight wire payload to
|
|
48
|
-
# inline as `__FLIGHT_DATA
|
|
55
|
+
# inline as `__FLIGHT_DATA`. Omit inside a layout to use the render in
|
|
56
|
+
# flight; an explicit argument always wins.
|
|
49
57
|
# @return [ActiveSupport::SafeBuffer] the asset markup, html_safe
|
|
50
58
|
# @example In a layout
|
|
51
59
|
# <%= ruact_js_assets %>
|
|
52
60
|
def ruact_js_assets(flight_payload = nil)
|
|
61
|
+
flight_payload ||= @ruact_flight_payload
|
|
53
62
|
parts = []
|
|
54
63
|
parts << ruact_flight_data_script(flight_payload) unless flight_payload.nil?
|
|
55
64
|
parts << ruact_vite_tags
|
data/lib/ruact.rb
CHANGED
|
@@ -7,6 +7,7 @@ require_relative "ruact/serializable"
|
|
|
7
7
|
require_relative "ruact/signed_references"
|
|
8
8
|
require_relative "ruact/flight"
|
|
9
9
|
require_relative "ruact/string_distance"
|
|
10
|
+
require_relative "ruact/layout_source"
|
|
10
11
|
require_relative "ruact/component_contract"
|
|
11
12
|
require_relative "ruact/erb_preprocessor"
|
|
12
13
|
require_relative "ruact/render_context"
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
<%# An UNMIGRATED host layout: it has the React root but never calls
|
|
2
|
+
`ruact_js_assets`, so rendering the page through it would produce a
|
|
3
|
+
document with no bootstrap entry and no Flight payload — a blank page.
|
|
4
|
+
ruact must detect that and fall back to its built-in shell instead. %>
|
|
5
|
+
<!DOCTYPE html>
|
|
6
|
+
<html>
|
|
7
|
+
<head>
|
|
8
|
+
<link rel="stylesheet" href="/host-app.css" />
|
|
9
|
+
<title>Host App Title</title>
|
|
10
|
+
</head>
|
|
11
|
+
<body>
|
|
12
|
+
<%= yield %>
|
|
13
|
+
<%# ruact: root %>
|
|
14
|
+
<div id="root"></div>
|
|
15
|
+
</body>
|
|
16
|
+
</html>
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
<%# An UNMIGRATED layout that cannot survive being rendered on a ruact page.
|
|
2
|
+
It reads an ivar that only a plain Rails action would have set — exactly the
|
|
3
|
+
shape of a real pre-migration app, whose layout has never once run on a
|
|
4
|
+
ruact-rendered page. If ruact renders this speculatively just to discover it
|
|
5
|
+
lacks `ruact_js_assets`, the app 500s on a page that used to work.
|
|
6
|
+
|
|
7
|
+
NOTE: this comment MENTIONS the helper name on purpose — a readiness check
|
|
8
|
+
that greps for the bare string counts this layout as wired and renders it. %>
|
|
9
|
+
<%# <%= ruact_js_assets %> %>
|
|
10
|
+
<%# ...and the line above is a genuinely COMMENTED-OUT call, which is the
|
|
11
|
+
sharper version of the same trap: ERB comments do not nest, so it emits
|
|
12
|
+
nothing, but a check that only looks for `<%=` sees a call. Both shapes must
|
|
13
|
+
read as UNMIGRATED, or this layout gets rendered and 500s below. %>
|
|
14
|
+
<!DOCTYPE html>
|
|
15
|
+
<html>
|
|
16
|
+
<head>
|
|
17
|
+
<title><%= @page_title.upcase %></title>
|
|
18
|
+
</head>
|
|
19
|
+
<body>
|
|
20
|
+
<%= yield %>
|
|
21
|
+
<%# ruact: root %>
|
|
22
|
+
<div id="root"></div>
|
|
23
|
+
</body>
|
|
24
|
+
</html>
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
<%# A layout that calls `ruact_js_assets` but never got the React root div.
|
|
2
|
+
The payload and the bootstrap both ship, so an assets-only readiness check
|
|
3
|
+
accepts it — and the browser boots React with nothing to mount into,
|
|
4
|
+
producing a silently blank page. %>
|
|
5
|
+
<!DOCTYPE html>
|
|
6
|
+
<html>
|
|
7
|
+
<head>
|
|
8
|
+
<link rel="stylesheet" href="/host-app.css" />
|
|
9
|
+
<title>Host App Title</title>
|
|
10
|
+
</head>
|
|
11
|
+
<body>
|
|
12
|
+
<%= yield %>
|
|
13
|
+
<%= ruact_js_assets %>
|
|
14
|
+
</body>
|
|
15
|
+
</html>
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
<%# A MIGRATED host layout: the app owns the document. The stylesheet link is
|
|
2
|
+
the whole point — it is the thing ruact's built-in shell has no slot for,
|
|
3
|
+
so a spec asserting it appears in the response is asserting that the host
|
|
4
|
+
app's CSS actually reaches a ruact page. %>
|
|
5
|
+
<!DOCTYPE html>
|
|
6
|
+
<html>
|
|
7
|
+
<head>
|
|
8
|
+
<link rel="stylesheet" href="/host-app.css" />
|
|
9
|
+
<title>Host App Title</title>
|
|
10
|
+
</head>
|
|
11
|
+
<body>
|
|
12
|
+
<%= yield %>
|
|
13
|
+
<%# ruact: root %>
|
|
14
|
+
<div id="root"></div>
|
|
15
|
+
<%= ruact_js_assets %>
|
|
16
|
+
</body>
|
|
17
|
+
</html>
|