ruact 0.0.8 → 0.0.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +59 -1
- data/LICENSE.txt +21 -0
- data/README.md +124 -18
- data/SECURITY.md +1 -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/readme/children-error.html.erb +3 -0
- data/spec/fixtures/readme/children-error.txt +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/readme_demo_message_spec.rb +67 -0
- data/spec/readme_spec.rb +282 -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 +30 -5
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/* Tailwind entry — the SOURCE, not the asset the browser gets.
|
|
2
|
+
*
|
|
3
|
+
* Built into app/assets/builds/tailwind.css by the `css` process in
|
|
4
|
+
* Procfile.dev, which Propshaft then serves and the layout links:
|
|
5
|
+
*
|
|
6
|
+
* this file ──build──> app/assets/builds/tailwind.css
|
|
7
|
+
* ──Propshaft──> /assets/tailwind-<digest>.css
|
|
8
|
+
* ──stylesheet_link_tag in app/views/layouts──> the page
|
|
9
|
+
*
|
|
10
|
+
* `npx shadcn init` appends its design tokens (the CSS variables the
|
|
11
|
+
* components reference by name) below this import — that is why
|
|
12
|
+
* components.json points its `tailwind.css` at this file.
|
|
13
|
+
*/
|
|
14
|
+
@import "tailwindcss";
|
|
15
|
+
|
|
16
|
+
/* Tailwind v4 declares its class scan here instead of in a config file.
|
|
17
|
+
Components carry most of the classes; the ERB views carry the page-level
|
|
18
|
+
ones, so both paths are needed. */
|
|
19
|
+
@source "../components";
|
|
20
|
+
@source "../../views";
|
|
@@ -1,6 +1,15 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
Ruact.configure do |config|
|
|
4
|
+
# Render ruact pages through your app's own layout, so the document `<head>`
|
|
5
|
+
# is yours: `stylesheet_link_tag`, favicons, fonts, analytics and any gem that
|
|
6
|
+
# writes into `<head>` all reach a ruact page. Requires the layout to call
|
|
7
|
+
# `<%%= ruact_js_assets %>` (this generator adds it next to the React root).
|
|
8
|
+
#
|
|
9
|
+
# Set to false to use ruact's built-in minimal shell instead — it has no
|
|
10
|
+
# stylesheet slot, so your app's CSS will NOT reach a ruact-rendered page.
|
|
11
|
+
config.layout = true
|
|
12
|
+
|
|
4
13
|
# Path to the react-client-manifest.json generated by the Vite plugin.
|
|
5
14
|
# Defaults to Rails.root.join("public/react-client-manifest.json").
|
|
6
15
|
# config.manifest_path = Rails.root.join("public", "react-client-manifest.json")
|
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
"type": "module",
|
|
5
5
|
"scripts": {
|
|
6
6
|
"dev": "vite",
|
|
7
|
-
"build": "vite build"
|
|
7
|
+
"build": "vite build"<% if shadcn? %>,
|
|
8
|
+
"build:css": "@tailwindcss/cli -i app/javascript/styles/globals.css -o app/assets/builds/tailwind.css --minify"<% end %>
|
|
8
9
|
},
|
|
9
10
|
"dependencies": {
|
|
10
11
|
"react": "^19.0.0",
|
|
@@ -12,6 +13,11 @@
|
|
|
12
13
|
},
|
|
13
14
|
"devDependencies": {
|
|
14
15
|
"@vitejs/plugin-react": "^4.3.4",
|
|
16
|
+
<% if shadcn? -%>
|
|
17
|
+
"@tailwindcss/cli": "^4.0.0",
|
|
18
|
+
"tailwindcss": "^4.0.0",
|
|
19
|
+
"tw-animate-css": "^1.0.0",
|
|
20
|
+
<% end -%>
|
|
15
21
|
"vite": "^6.0.7"
|
|
16
22
|
}
|
|
17
23
|
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2020",
|
|
4
|
+
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
|
5
|
+
"module": "ESNext",
|
|
6
|
+
"moduleResolution": "bundler",
|
|
7
|
+
"jsx": "react-jsx",
|
|
8
|
+
"strict": true,
|
|
9
|
+
"noEmit": true,
|
|
10
|
+
"esModuleInterop": true,
|
|
11
|
+
"skipLibCheck": true,
|
|
12
|
+
"baseUrl": ".",
|
|
13
|
+
"paths": {
|
|
14
|
+
"@/*": ["app/javascript/*"]
|
|
15
|
+
}
|
|
16
|
+
},
|
|
17
|
+
"include": ["app/javascript/**/*.ts", "app/javascript/**/*.tsx"]
|
|
18
|
+
}
|
|
@@ -47,6 +47,16 @@ module Ruact
|
|
|
47
47
|
# List/DeleteDialog primitives. EVERY entry is a plain `npx shadcn add`
|
|
48
48
|
# primitive — there is NO `data-table` recipe and NO `@tanstack/react-table`
|
|
49
49
|
# dependency (Story 10.2b removed the engine; the List is a plain `table`).
|
|
50
|
+
# The COMPLETE primitive set — the union of everything any generated
|
|
51
|
+
# resource can import. `#required_shadcn_components` NARROWS this per
|
|
52
|
+
# resource (the input-family entries depend on the attribute types),
|
|
53
|
+
# but `ruact:install --shadcn` runs before any resource exists, so it
|
|
54
|
+
# prints this superset. A spec pins the narrowed list as a subset of
|
|
55
|
+
# this one, so the two cannot drift apart.
|
|
56
|
+
ALL_SHADCN_COMPONENTS = %w[
|
|
57
|
+
button input textarea switch select label badge table alert-dialog dropdown-menu
|
|
58
|
+
].freeze
|
|
59
|
+
|
|
50
60
|
def required_shadcn_components
|
|
51
61
|
components = ["button"]
|
|
52
62
|
components << "input" if form_uses_input?
|
data/lib/ruact/configuration.rb
CHANGED
|
@@ -24,6 +24,7 @@ module Ruact
|
|
|
24
24
|
signed_global_id_default_purpose
|
|
25
25
|
signed_global_id_default_expires_in
|
|
26
26
|
shadcn_compatible_versions
|
|
27
|
+
layout
|
|
27
28
|
].freeze
|
|
28
29
|
|
|
29
30
|
# @!attribute [r] manifest_path
|
|
@@ -135,6 +136,47 @@ module Ruact
|
|
|
135
136
|
# suppress the warning — the documented "override" path.
|
|
136
137
|
# @example Allow shadcn v3 once you have verified it
|
|
137
138
|
# Ruact.configure { |c| c.shadcn_compatible_versions = [1, 2, 3] }
|
|
139
|
+
#
|
|
140
|
+
# @!attribute [r] layout
|
|
141
|
+
# @return [Boolean, String] Which document wrapper a ruact page's HTML
|
|
142
|
+
# response is rendered into. The Flight response shape
|
|
143
|
+
# (`text/x-component`) is never affected — this is only about the
|
|
144
|
+
# full-document render a browser gets on a normal navigation.
|
|
145
|
+
#
|
|
146
|
+
# - `false` (default) — render the gem's built-in minimal shell.
|
|
147
|
+
# - `true` — render through the controller's normal Rails layout.
|
|
148
|
+
# - a String — render through that named layout (e.g. `"ruact"`).
|
|
149
|
+
#
|
|
150
|
+
# The layout path exists because the document `<head>` belongs to the
|
|
151
|
+
# host app: `stylesheet_link_tag`, favicons, fonts, analytics and any
|
|
152
|
+
# `<head>`-writing gem only reach the page when Rails' own layout owns
|
|
153
|
+
# the document. The built-in shell carries no stylesheet slot, so under
|
|
154
|
+
# the `false` default a ruact page renders with no app CSS at all —
|
|
155
|
+
# which is why `rails generate ruact:install` writes `config.layout =
|
|
156
|
+
# true` into the generated initializer and adds `<%= ruact_js_assets %>`
|
|
157
|
+
# to your layout in the same run.
|
|
158
|
+
#
|
|
159
|
+
# **This setting is deliberately explicit — there is no auto-detection.**
|
|
160
|
+
# ruact used to try to infer whether your layout was ready by inspecting
|
|
161
|
+
# it. Deciding that reliably means answering "does this template call
|
|
162
|
+
# this method?", which cannot be done by pattern-matching a template
|
|
163
|
+
# language: three review rounds each found another shape that fooled it
|
|
164
|
+
# (a mention in a comment, a commented-out call, a trim-mode comment),
|
|
165
|
+
# and each wrong answer governed how every page in the app rendered.
|
|
166
|
+
# One explicit line is worth more than a clever guess here.
|
|
167
|
+
#
|
|
168
|
+
# A layout is ready when it calls `<%= ruact_js_assets %>` (which emits
|
|
169
|
+
# the React root's bootstrap entry tags and the per-render Flight
|
|
170
|
+
# payload) next to a `<div id="root"></div>`. If it does not, ruact says
|
|
171
|
+
# so loudly in development rather than serving a blank page, and
|
|
172
|
+
# `rails ruact:doctor` reports it.
|
|
173
|
+
# @note A ruact view is rendered in its own pass (it produces the component
|
|
174
|
+
# tree), so `content_for` declared inside the view does NOT reach the
|
|
175
|
+
# layout. Set document metadata from the controller instead.
|
|
176
|
+
# @example Let your layout own the document (what ruact:install writes)
|
|
177
|
+
# Ruact.configure { |c| c.layout = true }
|
|
178
|
+
# @example Use a dedicated layout for ruact pages only
|
|
179
|
+
# Ruact.configure { |c| c.layout = "ruact" }
|
|
138
180
|
ATTRIBUTES.each do |attr|
|
|
139
181
|
attr_reader attr
|
|
140
182
|
|
|
@@ -188,6 +230,7 @@ module Ruact
|
|
|
188
230
|
@signed_global_id_default_purpose = nil
|
|
189
231
|
@signed_global_id_default_expires_in = nil
|
|
190
232
|
@shadcn_compatible_versions = [1, 2]
|
|
233
|
+
@layout = false
|
|
191
234
|
end
|
|
192
235
|
end
|
|
193
236
|
|
|
@@ -249,7 +292,37 @@ module Ruact
|
|
|
249
292
|
when :query_route_prefix then validate_query_route_prefix!(value)
|
|
250
293
|
when :query_parent_controller then validate_query_parent_controller!(value)
|
|
251
294
|
when :shadcn_compatible_versions then validate_shadcn_compatible_versions!(value)
|
|
295
|
+
when :layout then validate_layout!(value)
|
|
296
|
+
end
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
# The value selects a render strategy by identity (`true` / `false`) or
|
|
300
|
+
# names a layout (String). Anything else would otherwise be treated as "not
|
|
301
|
+
# false" and reach `render_to_string(layout: <value>)`, where a Symbol
|
|
302
|
+
# layout name or a stray nil surfaces as a confusing first-request 500
|
|
303
|
+
# instead of a boot-time error. `nil` is rejected on purpose: "no layout" is
|
|
304
|
+
# spelled `false`, so a nil left over from a conditional in the initializer
|
|
305
|
+
# is a mistake, not a silent shell fallback.
|
|
306
|
+
def validate_layout!(value)
|
|
307
|
+
return if [true, false].include?(value)
|
|
308
|
+
return if value.is_a?(String) && !value.empty?
|
|
309
|
+
|
|
310
|
+
# `:auto` is rejected BY NAME because it used to be the default. An app
|
|
311
|
+
# carrying it forward from an older initializer must be told it is gone,
|
|
312
|
+
# not have it silently reinterpreted as "some truthy value".
|
|
313
|
+
if value == :auto
|
|
314
|
+
raise Ruact::ConfigurationError,
|
|
315
|
+
"Ruact::Configuration#layout no longer supports :auto — it inferred whether your " \
|
|
316
|
+
"layout was ready by inspecting it, which could not be done reliably. " \
|
|
317
|
+
"Set `true` to render through your app's layout (make sure it calls " \
|
|
318
|
+
"`<%= ruact_js_assets %>`), or `false` for ruact's built-in shell."
|
|
252
319
|
end
|
|
320
|
+
|
|
321
|
+
raise Ruact::ConfigurationError,
|
|
322
|
+
"Ruact::Configuration#layout must be true, false, or a non-empty String layout name; " \
|
|
323
|
+
"got #{value.inspect} (#{value.class.name}). " \
|
|
324
|
+
"true renders through your app's layout (which must call ruact_js_assets); " \
|
|
325
|
+
"false uses ruact's built-in shell."
|
|
253
326
|
end
|
|
254
327
|
|
|
255
328
|
def validate_max_upload_bytes!(value)
|
|
@@ -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 @@
|
|
|
1
|
+
ruact: <LikeButton> at app/views/home/index.html.erb:3 children are not supported — pass content as a prop, e.g. `<LikeButton content={...} />`.
|