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.
Files changed (39) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +59 -1
  3. data/LICENSE.txt +21 -0
  4. data/README.md +124 -18
  5. data/SECURITY.md +1 -1
  6. data/lib/generators/ruact/install/install_generator.rb +264 -5
  7. data/lib/generators/ruact/install/templates/Procfile.dev.tt +3 -0
  8. data/lib/generators/ruact/install/templates/globals.css.tt +20 -0
  9. data/lib/generators/ruact/install/templates/initializer.rb.tt +9 -0
  10. data/lib/generators/ruact/install/templates/package.json.tt +7 -1
  11. data/lib/generators/ruact/install/templates/tsconfig.json.tt +18 -0
  12. data/lib/generators/ruact/scaffold/scaffold_shadcn_preflight.rb +10 -0
  13. data/lib/ruact/configuration.rb +73 -0
  14. data/lib/ruact/controller/document_rendering.rb +210 -0
  15. data/lib/ruact/controller.rb +5 -46
  16. data/lib/ruact/doctor.rb +37 -5
  17. data/lib/ruact/layout_source.rb +59 -0
  18. data/lib/ruact/version.rb +1 -1
  19. data/lib/ruact/view_helper.rb +10 -1
  20. data/lib/ruact.rb +1 -0
  21. data/spec/fixtures/readme/children-error.html.erb +3 -0
  22. data/spec/fixtures/readme/children-error.txt +1 -0
  23. data/spec/fixtures/story_7_9_views/controller_request_spec_support/exploding_layout_demo/show.html.erb +3 -0
  24. data/spec/fixtures/story_7_9_views/controller_request_spec_support/ghost_layout_demo/show.html.erb +3 -0
  25. data/spec/fixtures/story_7_9_views/controller_request_spec_support/layout_demo/show.html.erb +3 -0
  26. data/spec/fixtures/story_7_9_views/controller_request_spec_support/rootless_layout_demo/show.html.erb +3 -0
  27. data/spec/fixtures/story_7_9_views/controller_request_spec_support/unwired_layout_demo/show.html.erb +3 -0
  28. data/spec/fixtures/story_7_9_views/layouts/bare_host.html.erb +16 -0
  29. data/spec/fixtures/story_7_9_views/layouts/exploding_host.html.erb +24 -0
  30. data/spec/fixtures/story_7_9_views/layouts/rootless_host.html.erb +15 -0
  31. data/spec/fixtures/story_7_9_views/layouts/ruact_host.html.erb +17 -0
  32. data/spec/readme_demo_message_spec.rb +67 -0
  33. data/spec/readme_spec.rb +282 -0
  34. data/spec/ruact/controller_request_spec.rb +203 -0
  35. data/spec/ruact/doctor_spec.rb +81 -6
  36. data/spec/ruact/install_generator_spec.rb +442 -70
  37. data/spec/ruact/layout_source_spec.rb +108 -0
  38. data/spec/ruact/scaffold_generator_spec.rb +14 -0
  39. metadata +30 -5
@@ -0,0 +1,3 @@
1
+ <div>
2
+ <DemoButton label={"hello"} />
3
+ </div>
@@ -0,0 +1,3 @@
1
+ <div>
2
+ <DemoButton label={"hello"} />
3
+ </div>
@@ -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>
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+ require "pathname"
5
+
6
+ # Story 5.2 — the anti-rot gate for the README's write→verify demo.
7
+ #
8
+ # ⚠️ THE README DEMO SHOWS THIS MESSAGE. If this spec goes red, the recording
9
+ # at https://ruact.dev/readme-write-verify.gif is now showing a message the
10
+ # gem no longer produces — RE-RECORD IT.
11
+ # Sources: design-artifacts/E-Assets/readme-write-verify/ in the planning
12
+ # repository (`./build-app.sh && ./record.sh && ./assemble.sh`).
13
+ #
14
+ # The fixture is PRODUCED BY THE GEM, never transcribed. It was written by
15
+ # running the preprocessor over the committed `.html.erb` beside it:
16
+ #
17
+ # ruby -Ilib -rruact -e 'begin
18
+ # Ruact::ErbPreprocessor.transform(
19
+ # File.read("spec/fixtures/readme/children-error.html.erb"),
20
+ # identifier: "app/views/home/index.html.erb")
21
+ # rescue Ruact::ChildrenNotSupportedError => e
22
+ # File.write("spec/fixtures/readme/children-error.txt", e.message + "\n")
23
+ # end'
24
+ #
25
+ # The identifier is the guide's own template path, so the fixture is verbatim
26
+ # the line the recording shows (modulo the absolute prefix Rails prepends in a
27
+ # real app). The monorepo owns the other half of this gate: the same failure
28
+ # reached through a real Rails render, in
29
+ # docs/examples/getting-started/harness/spec/requests/getting_started_spec.rb.
30
+ # That one cannot live here — the gem repository is public and reads nothing
31
+ # from the private planning repository — which is exactly why this spec exists:
32
+ # the published artifact stays verifiable in its own repository.
33
+ RSpec.describe "the README demo's error message", :story_5_2 do
34
+ let(:root) { Pathname.new(File.expand_path("..", __dir__)) }
35
+ let(:fixtures) { root.join("spec/fixtures/readme") }
36
+ let(:source) { fixtures.join("children-error.html.erb").read }
37
+ let(:expected) { fixtures.join("children-error.txt").read.strip }
38
+
39
+ # The exact call the recording's third beat makes, one layer down: a template
40
+ # whose PascalCase tag has children, compiled with the guide's template path.
41
+ def message
42
+ Ruact::ErbPreprocessor.transform(source, identifier: "app/views/home/index.html.erb")
43
+ raise "expected Ruact::ChildrenNotSupportedError, none was raised"
44
+ rescue Ruact::ChildrenNotSupportedError => e
45
+ e.message
46
+ end
47
+
48
+ it "is byte-for-byte what the gem produces today" do
49
+ expect(message).to eq(expected),
50
+ "the loud-children message changed. The README demo at " \
51
+ "https://ruact.dev/readme-write-verify.gif shows the old one — " \
52
+ "regenerate the fixture, then RE-RECORD the demo."
53
+ end
54
+
55
+ it "names the component, the file:line and the fix — the three things the demo is about" do
56
+ expect(expected).to include("<LikeButton>")
57
+ expect(expected).to include("app/views/home/index.html.erb:3")
58
+ expect(expected).to include("<LikeButton content={...} />")
59
+ end
60
+
61
+ it "is the message the README's demo is claimed to show" do
62
+ readme = root.join("README.md").read
63
+
64
+ expect(readme).to include("https://ruact.dev/readme-write-verify.gif")
65
+ expect(readme).to include("Ruact::ChildrenNotSupportedError")
66
+ end
67
+ end
@@ -0,0 +1,282 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+ require "nokogiri"
5
+ require "pathname"
6
+
7
+ # Story 5.14 — the README gate that lives INSIDE this repository.
8
+ #
9
+ # `README.md` is not an ordinary readme: GitHub renders it on the gem's own
10
+ # repository page, `source_code_uri` points at it, and `spec.files` packages it
11
+ # *inside* the built `.gem`. Until this story it was still `bundle gem`
12
+ # boilerplate ("TODO: Delete this and the text below"), and that boilerplate had
13
+ # already shipped to RubyGems.
14
+ #
15
+ # The planning monorepo has a command-spine gate
16
+ # (`docs/examples/getting-started/scripts/check-commands.mjs`) that reads this
17
+ # file through the `gem/` submodule and checks its quick start against ONE
18
+ # canonical greenfield sequence — the SPINE, which is the single source of
19
+ # truth. But a merge in THIS repository fires nothing over there, so a
20
+ # README-only PR here would stay unchecked until somebody bumped the submodule
21
+ # pointer.
22
+ #
23
+ # Hence the deliberate duplication below: `expected_quick_start` is a literal
24
+ # copy of the quick-start block, owned by this spec, and the monorepo gate
25
+ # verifies that literal against the canonical SPINE. The dependency runs one way
26
+ # only — this spec reads nothing outside the gem repository, because a public
27
+ # artifact that cannot be verified in its own repository is not verifiable.
28
+ RSpec.describe "README.md", :story_5_14 do
29
+ subject(:readme) { root.join("README.md").read }
30
+
31
+ let(:root) { Pathname.new(File.expand_path("..", __dir__)) }
32
+
33
+ # Every literal `bundle gem` leaves behind. Each one was present before this
34
+ # story; any of them coming back means the scaffolding was re-pasted.
35
+ let(:boilerplate) do
36
+ [
37
+ "TODO:",
38
+ "UPDATE_WITH_YOUR_GEM_NAME_IMMEDIATELY_AFTER_RELEASE_TO_RUBYGEMS_ORG",
39
+ "[USERNAME]",
40
+ "Welcome to your new gem",
41
+ "Put your Ruby code in the file",
42
+ "bin/console",
43
+ "bundle exec rake release"
44
+ ]
45
+ end
46
+
47
+ # Harness-only gem sources. They exist so the monorepo's playgrounds can test
48
+ # the working tree; a reader who copies one installs nothing.
49
+ let(:path_gem_sources) do
50
+ [
51
+ /(^|\s)gem\s+["']ruact["']\s*,\s*path:/,
52
+ /--path(\s|=)/
53
+ ]
54
+ end
55
+
56
+ # The quick-start block, byte for byte. Changing it here without changing the
57
+ # monorepo SPINE (or the other way round) turns the monorepo gate red.
58
+ let(:expected_quick_start) do
59
+ <<~BASH
60
+ # 1. A throwaway app to try it in
61
+ rails new myapp --skip-javascript && cd myapp
62
+
63
+ # 2. Add the gem
64
+ bundle add ruact
65
+
66
+ # 3. Write the config, the layout wiring and an AGENTS.md — then run npm install
67
+ rails generate ruact:install
68
+
69
+ # 4. Rails + Vite, one command
70
+ bin/dev
71
+ BASH
72
+ end
73
+
74
+ # `[text](target)` and `![alt](target)`, inline-title form allowed.
75
+ def markdown_link_targets(markdown)
76
+ markdown.scan(/\[[^\]]*\]\(([^)\s]+)(?:\s+"[^"]*")?\)/).flatten
77
+ end
78
+
79
+ # Story 5.2 — the demo reference, pinned BYTE FOR BYTE.
80
+ #
81
+ # The first version of this gate hand-rolled HTML and CommonMark recognition
82
+ # out of regexes, and review corrected it in three consecutive rounds:
83
+ # `\bsrc=` matched `data-src=`, a literal `>` inside an attribute ended the
84
+ # tag early, fences were only recognised at column 0, then unterminated
85
+ # fences were not recognised at all, then unquoted attribute values were
86
+ # invisible. Those are not five bugs. Neither HTML nor CommonMark is a
87
+ # regular language, so that list has no end, and a gate whose coverage is
88
+ # "whatever the regex happens to know this week" is not a gate.
89
+ #
90
+ # Replaced by three statements that are TOTAL:
91
+ #
92
+ # 1. the reference is pinned literally — the same move `expected_quick_start`
93
+ # above already makes for the quick start;
94
+ # 2. the file is allowed EXACTLY ONE raw `<img>`, counted at the byte level;
95
+ # and
96
+ # 3. the parsed document holds exactly one LIVE `<img>` element, equal to
97
+ # the pinned one attribute for attribute.
98
+ #
99
+ # (1) and (2) together say something the old scanner could only approximate:
100
+ # every raw image reference in this README is the pinned one. A second `<img>`
101
+ # anywhere — including inside a fenced example, terminated or not — turns
102
+ # this red and has to be gated deliberately rather than slipping through a
103
+ # blind spot. (3) says the thing bytes cannot: that it renders. The pinned
104
+ # block wrapped in an HTML comment satisfies (1) and (2) and shows nothing.
105
+ #
106
+ # Attributes are then read with Nokogiri, already a runtime dependency of this
107
+ # gem (see the gemspec), so unquoted values, `>` inside a value and `data-src`
108
+ # are PARSED rather than pattern-matched. What that scope does and does not
109
+ # promise is written down in the monorepo's gate inventory,
110
+ # docs/examples/getting-started/README.md § "What is still outside both gates".
111
+ def expected_demo_img
112
+ <<~HTML
113
+ <img src="https://ruact.dev/readme-write-verify.gif" width="800"
114
+ alt="An ERB template holding a &lt;LikeButton likes=&#123;@likes&#125; /&gt; tag, and the &quot;use client&quot; React component that tag resolves to. The component renders in a browser and its count changes when it is clicked. Children are then put inside the tag — the JSX habit — and the next request stops server-side with Ruact::ChildrenNotSupportedError, which names the component, the template file and line, and the fix. The children come out again and the page renders." />
115
+ HTML
116
+ end
117
+
118
+ def demo_src
119
+ "https://ruact.dev/readme-write-verify.gif"
120
+ end
121
+
122
+ # Parsed from the pinned literal, exactly as `expected_quick_start` is the
123
+ # authority for the quick start: one example asserts the README contains
124
+ # these bytes, and the rest read meaning off them. The chain is
125
+ # README -> literal -> parser, and the first link is what makes it honest.
126
+ def demo_node
127
+ Nokogiri::HTML5.fragment(expected_demo_img).at_css("img")
128
+ end
129
+
130
+ # Byte-level and case-insensitive on purpose: no parser, no markdown, nothing
131
+ # to have a blind spot. `<IMG`, `<img\n`, an `<img` inside a fence — all count.
132
+ def raw_img_count(markdown)
133
+ markdown.scan(/<img\b/i).length
134
+ end
135
+
136
+ # …and the same parser turned on the README ITSELF, because the two byte-level
137
+ # facts above are about bytes, not about rendering: wrap the pinned block in
138
+ # an HTML comment and the literal is still present and the raw count is still
139
+ # one, while GitHub shows nothing at all (verified — `include`=true, raw=1,
140
+ # live nodes=0). Nokogiri is what decides whether a tag is an element or a
141
+ # comment, so it is what the "it renders" half of this gate has to ask.
142
+ def readme_img_nodes
143
+ Nokogiri::HTML5.fragment(readme).css("img")
144
+ end
145
+
146
+ # Absolute URLs, anchors and mailto: links are somebody else's problem; on-disk
147
+ # targets are ours.
148
+ def relative(targets)
149
+ targets.reject { |target| target.start_with?("http://", "https://", "#", "mailto:") }
150
+ end
151
+
152
+ def bash_blocks(markdown)
153
+ markdown.scan(/^```bash[ \t]*\n(.*?)^```/m).flatten
154
+ end
155
+
156
+ it "carries none of the `bundle gem` boilerplate" do
157
+ present = boilerplate.select { |literal| readme.include?(literal) }
158
+
159
+ expect(present).to be_empty,
160
+ "README.md still carries bundler scaffolding: #{present.inspect}"
161
+ end
162
+
163
+ it "links only to files that exist in this repository" do
164
+ targets = relative(markdown_link_targets(readme) + [demo_node["src"]])
165
+ missing = targets.reject { |target| root.join(target.split("#").first.to_s).exist? }
166
+
167
+ expect(targets).not_to be_empty, "expected the README to link at least one repo-relative file"
168
+ expect(missing).to be_empty,
169
+ "README.md links files that do not exist in the gem repository: #{missing.inspect}"
170
+ end
171
+
172
+ # Story 5.2 — the demo GIF is a documentation asset hosted with the site, so
173
+ # this repository stays free of binaries: no `.gem` download and no clone
174
+ # pays for it. The consequence is that the reference is an absolute URL, and
175
+ # an absolute URL cannot be resolved on disk here — the monorepo puts it in
176
+ # `website/scripts/verify-urls.mjs`'s PATHS, checked against the deployed
177
+ # site, so a dead image goes red there instead of rotting silently.
178
+ it "carries the write→verify demo reference, byte for byte" do
179
+ expect(readme).to include(expected_demo_img),
180
+ "the demo reference in README.md drifted from the literal this spec pins. " \
181
+ "Change both or neither — and if the recording itself changed, see " \
182
+ "readme_demo_message_spec.rb."
183
+ end
184
+
185
+ # The other half of the pin: because there is exactly ONE raw `<img>` and the
186
+ # example above proves it is the pinned one, "the pinned literal is present"
187
+ # and "every raw image in this file is checked" are the same statement.
188
+ it "carries exactly one raw <img>, so the pin covers every raw image in the file" do
189
+ expect(raw_img_count(readme)).to eq(1),
190
+ "README.md has #{raw_img_count(readme)} raw <img> tags. This gate is " \
191
+ "written for exactly one — the demo, pinned literally. A second image " \
192
+ "must be gated deliberately, not left to a scanner's blind spots."
193
+ end
194
+
195
+ # And the demo is LIVE, not merely present. Bytes inside an HTML comment
196
+ # satisfy both statements above and render nothing; only a parser can tell
197
+ # the difference, so the README itself is parsed and the node it yields must
198
+ # be the pinned one, attribute for attribute.
199
+ it "renders the demo as a real element, not as bytes inside a comment" do
200
+ nodes = readme_img_nodes
201
+
202
+ expect(nodes.length).to eq(1),
203
+ "README.md parses to #{nodes.length} live <img> element(s); the demo must be " \
204
+ "exactly one, and must not be commented out."
205
+ expect(nodes.first.attributes.transform_values(&:value))
206
+ .to eq(demo_node.attributes.transform_values(&:value))
207
+ end
208
+
209
+ it "keeps the demo hosted with the site rather than committed here" do
210
+ expect(demo_node["src"]).to eq(demo_src)
211
+
212
+ # `git ls-files` and not a filesystem glob, because that is precisely what
213
+ # `spec.files` packages (ruact.gemspec) — untracked build output is not the
214
+ # question here, and coverage/ is full of it.
215
+ tracked = IO.popen(%w[git ls-files -z], chdir: root.to_s, err: IO::NULL) do |ls|
216
+ ls.readlines("\x0", chomp: true)
217
+ end
218
+ committed_media = tracked.grep(/\.(gif|mp4|webm|webp)\z/i)
219
+
220
+ expect(committed_media).to be_empty,
221
+ "media committed under gem/ ships inside every `.gem` and stays in the " \
222
+ "clone history forever: #{committed_media.inspect}"
223
+ end
224
+
225
+ # The alt text is the only thing a screen-reader user — or an agent reading
226
+ # the README as text — gets. "demo" is not a description.
227
+ it "describes the demo's arc in its alt text" do
228
+ # Nokogiri resolves the entities, so this reads what a screen reader reads:
229
+ # `&lt;LikeButton` is the tag the demo shows, not four literal characters.
230
+ alt = demo_node["alt"].to_s
231
+
232
+ expect(alt.split.length).to be > 40, "the demo's alt text does not describe the arc: #{alt.inspect}"
233
+ expect(alt).to include("Ruact::ChildrenNotSupportedError")
234
+ expect(alt).not_to match(/\bbuild\b/i),
235
+ "the failure the demo shows happens server-side at render, not at build — " \
236
+ "see Story 5.2 AC4"
237
+ end
238
+
239
+ # Story 5.2, learned the expensive way: YARD parses README.md as the docs'
240
+ # main file and read `{@likes}` in the demo's alt text as a link macro it
241
+ # could not resolve — and `--fail-on-warning` turned that into a red REQUIRED
242
+ # check, for a README edit, in a job whose output says nothing about READMEs.
243
+ # Braces belong in prose as `&#123;`/`&#125;`: GitHub renders them, YARD never
244
+ # sees them.
245
+ #
246
+ # SCOPE, deliberately: this checks the pinned demo block, not the whole file.
247
+ # Knowing which parts of a markdown document YARD linkifies means knowing
248
+ # where the fenced blocks are, and this gate no longer recognises fences —
249
+ # that is the trade this redesign makes. `yard --fail-on-warning` in this
250
+ # repository's own CI is the TOTAL gate; this example exists so the one line
251
+ # that actually tripped it fails locally, in the suite that owns the README,
252
+ # naming the cause.
253
+ it "keeps YARD link macros out of the demo block, which is what reddened the docs job" do
254
+ # `{@ivar}`, `{Class}`, `{Class::Nested}`, `{Class#method}`, `{Class.method}`
255
+ # — the shapes YARD resolves. It does not try to resolve anything with a
256
+ # space in it, which is why the prose's `{ post: … }` elsewhere is safe.
257
+ macros = expected_demo_img.scan(/\{(?:@\w+|[A-Z][\w:.#]*)\}/)
258
+
259
+ expect(macros).to be_empty,
260
+ "the demo block contains #{macros.inspect}, which YARD tries to resolve as a " \
261
+ "link and `yard --fail-on-warning` fails on. Use &#123; / &#125;."
262
+ end
263
+
264
+ it "shows no harness-only `path:` gem source a reader could copy" do
265
+ offenders = path_gem_sources.filter_map { |pattern| pattern.source if readme.match?(pattern) }
266
+
267
+ expect(offenders).to be_empty,
268
+ "README.md shows a harness-only gem source (#{offenders.inspect}) — " \
269
+ "a reader who copies it installs nothing"
270
+ end
271
+
272
+ it "pins the quick start to the canonical greenfield sequence" do
273
+ expect(bash_blocks(readme).first).to eq(expected_quick_start),
274
+ "the README quick start drifted. It is checked against the monorepo's " \
275
+ "canonical SPINE (docs/examples/getting-started/scripts/" \
276
+ "check-commands.mjs); change both or neither."
277
+ end
278
+
279
+ it "keeps the quick start in a ```bash fence, the only language the monorepo gate reads" do
280
+ expect(readme).to include("```bash\n#{expected_quick_start}```")
281
+ end
282
+ end
@@ -115,6 +115,14 @@ module ControllerRequestSpecSupport
115
115
  get "/errors-demo/new", to: "controller_request_spec_support/errors_demo#new"
116
116
  post "/errors-demo/create", to: "controller_request_spec_support/errors_demo#create"
117
117
  post "/errors-demo/create_valid", to: "controller_request_spec_support/errors_demo#create_valid"
118
+ # The layout owns the document — a page rendered through a migrated
119
+ # host layout, and one through a layout that never calls
120
+ # `ruact_js_assets`.
121
+ get "/layout-demo/show", to: "controller_request_spec_support/layout_demo#show"
122
+ get "/unwired-layout-demo/show", to: "controller_request_spec_support/unwired_layout_demo#show"
123
+ get "/exploding-layout-demo/show", to: "controller_request_spec_support/exploding_layout_demo#show"
124
+ get "/rootless-layout-demo/show", to: "controller_request_spec_support/rootless_layout_demo#show"
125
+ get "/ghost-layout-demo/show", to: "controller_request_spec_support/ghost_layout_demo#show"
118
126
  end
119
127
  end
120
128
  end
@@ -186,6 +194,73 @@ module ControllerRequestSpecSupport
186
194
 
187
195
  def show; end
188
196
  end
197
+
198
+ # The layout owns the document. Both controllers declare a NAMED layout
199
+ # instead of relying on `layouts/application`, so adding them cannot change
200
+ # what every other controller in this file renders (the rest have no
201
+ # resolvable layout and must keep getting ruact's built-in shell).
202
+ class LayoutDemoController < ActionController::Base
203
+ include Ruact::Controller
204
+
205
+ append_view_path File.expand_path("../fixtures/story_7_9_views", __dir__)
206
+ layout "ruact_host"
207
+
208
+ def show
209
+ ruact_render
210
+ end
211
+ end
212
+
213
+ # An unmigrated layout that RAISES if rendered (it reads an ivar a ruact
214
+ # action never sets). With the default config ruact must never execute it.
215
+ class ExplodingLayoutDemoController < ActionController::Base
216
+ include Ruact::Controller
217
+
218
+ append_view_path File.expand_path("../fixtures/story_7_9_views", __dir__)
219
+ layout "exploding_host"
220
+
221
+ def show
222
+ ruact_render
223
+ end
224
+ end
225
+
226
+ # Declares a layout that does not exist. `_default_layout` hands back the
227
+ # String path WITHOUT checking, so "not nil" read as resolvable and the render
228
+ # raised MissingTemplate instead of degrading.
229
+ class GhostLayoutDemoController < ActionController::Base
230
+ include Ruact::Controller
231
+
232
+ append_view_path File.expand_path("../fixtures/story_7_9_views", __dir__)
233
+ layout "no_such_host"
234
+
235
+ def show
236
+ ruact_render
237
+ end
238
+ end
239
+
240
+ # A layout that calls `ruact_js_assets` but has no root div to mount into.
241
+ class RootlessLayoutDemoController < ActionController::Base
242
+ include Ruact::Controller
243
+
244
+ append_view_path File.expand_path("../fixtures/story_7_9_views", __dir__)
245
+ layout "rootless_host"
246
+
247
+ def show
248
+ ruact_render
249
+ end
250
+ end
251
+
252
+ # Same page, but through a layout that never calls `ruact_js_assets` — the
253
+ # state every app installed before the layout owned the document is in.
254
+ class UnwiredLayoutDemoController < ActionController::Base
255
+ include Ruact::Controller
256
+
257
+ append_view_path File.expand_path("../fixtures/story_7_9_views", __dir__)
258
+ layout "bare_host"
259
+
260
+ def show
261
+ ruact_render
262
+ end
263
+ end
189
264
  end
190
265
 
191
266
  # Story 10.0 — write the implicit-render template at file-load time (before the
@@ -251,6 +326,134 @@ module Ruact # rubocop:disable Style/OneClassPerFile
251
326
  end
252
327
  end
253
328
 
329
+ # The bug this closes: a ruact page could not carry ANY of the host app's
330
+ # CSS. `ruact_render` rendered the view with `layout: false` and then wrapped
331
+ # the payload in a hardcoded shell whose `<head>` has no stylesheet slot, so
332
+ # `stylesheet_link_tag` in the app's layout never reached the browser. The
333
+ # generated `--shadcn` scaffold was therefore unstyled by construction, and
334
+ # Epic 12 (`ruact_meta` → tags in `<head>`) had no surface to write into.
335
+ #
336
+ # The opt-in is EXPLICIT (`Ruact.config.layout`). ruact used to infer
337
+ # readiness from the layout itself; three review rounds each found another
338
+ # template shape that fooled the inference, so the question is no longer
339
+ # asked. These examples pin both halves: nothing happens without the config,
340
+ # and everything happens with it.
341
+ describe "the layout owns the document" do
342
+ def configure_layout(value)
343
+ Ruact.configure do |c|
344
+ c.manifest_path = ControllerRequestSpecSupport.manifest_path
345
+ c.layout = value
346
+ end
347
+ end
348
+
349
+ # The whole backward-compatibility story, and it is now structural rather
350
+ # than argued: with the default config ruact never looks at, resolves, or
351
+ # renders a layout at all.
352
+ context "with the default configuration (layout = false)" do
353
+ it "renders the built-in shell, exactly as before the layout path existed" do
354
+ get "/layout-demo/show"
355
+
356
+ expect(last_response.status).to eq(200)
357
+ expect(last_response.body).to include("Rails RSC")
358
+ expect(last_response.body).not_to include("host-app.css")
359
+ expect(last_response.body).to include("DemoButton")
360
+ end
361
+
362
+ # This layout raises if it is rendered (it reads an ivar a ruact action
363
+ # never sets) — the shape of a real pre-migration app. Nothing may
364
+ # execute it.
365
+ it "never executes an unmigrated layout, so a working page cannot become a 500" do
366
+ get "/exploding-layout-demo/show"
367
+
368
+ expect(last_response.status).to(eq(200),
369
+ "expected the layout NOT to be rendered; got " \
370
+ "#{last_response.status} body=#{last_response.body[0, 300]}")
371
+ expect(last_response.body).to include("Rails RSC")
372
+ end
373
+ end
374
+
375
+ context "when the app opts in (layout = true)" do
376
+ before { configure_layout(true) }
377
+
378
+ it "puts the host app's stylesheet on a ruact page (the CSS could not arrive before)" do
379
+ get "/layout-demo/show"
380
+
381
+ expect(last_response.status).to(eq(200),
382
+ "expected 200, got #{last_response.status} " \
383
+ "body=#{last_response.body[0, 400]}")
384
+ expect(last_response.body).to include('<link rel="stylesheet" href="/host-app.css" />')
385
+ end
386
+
387
+ it "keeps the host layout's own <title> instead of ruact's placeholder" do
388
+ get "/layout-demo/show"
389
+
390
+ expect(last_response.body).to include("<title>Host App Title</title>")
391
+ expect(last_response.body).not_to include("Rails RSC")
392
+ end
393
+
394
+ it "still ships the Flight payload and the component, through the layout's ruact_js_assets" do
395
+ get "/layout-demo/show"
396
+
397
+ expect(last_response.body).to include("__FLIGHT_DATA")
398
+ expect(last_response.body).to include("DemoButton")
399
+ end
400
+
401
+ it "does not apply the layout to a Flight request (the wire shape is unchanged)" do
402
+ get "/layout-demo/show", {}, { "HTTP_ACCEPT" => "text/x-component" }
403
+
404
+ expect(last_response.headers["Content-Type"]).to include("text/x-component")
405
+ expect(last_response.body).not_to include("host-app.css")
406
+ expect(last_response.body).to include("DemoButton")
407
+ end
408
+
409
+ # Opting in and pointing ruact at a layout that cannot mount the app is
410
+ # a configuration error, and silence would cost a blank page.
411
+ it "fails loudly when the opted-in layout never calls ruact_js_assets" do
412
+ expect { get "/unwired-layout-demo/show" }
413
+ .to raise_error(Ruact::Error, /ruact_js_assets/)
414
+ end
415
+
416
+ it "fails loudly when the opted-in layout has no root div to mount into" do
417
+ expect { get "/rootless-layout-demo/show" }
418
+ .to raise_error(Ruact::Error, /root/)
419
+ end
420
+
421
+ # A controller that declares a layout which does not exist: Rails'
422
+ # `_default_layout` returns the String path without checking it, so
423
+ # "resolvable" has to mean "the template is actually there".
424
+ it "degrades to the shell when a declared layout does not exist" do
425
+ get "/ghost-layout-demo/show"
426
+
427
+ expect(last_response.status).to(eq(200),
428
+ "expected a degrade, got #{last_response.status} " \
429
+ "body=#{last_response.body[0, 300]}")
430
+ expect(last_response.body).to include("Rails RSC")
431
+ expect(last_response.body).to include("DemoButton")
432
+ end
433
+
434
+ # `layout false` on one controller inside an app that opted in globally
435
+ # is a normal Rails pattern, not a mistake — it must degrade, not raise.
436
+ it "degrades to the shell for a controller with no resolvable layout" do
437
+ get "/demo/show"
438
+
439
+ expect(last_response.status).to eq(200)
440
+ expect(last_response.body).to include("Rails RSC")
441
+ expect(last_response.body).to include("DemoButton")
442
+ end
443
+ end
444
+
445
+ context "when a named layout is configured" do
446
+ before { configure_layout("ruact_host") }
447
+
448
+ it "renders every ruact page through that layout" do
449
+ get "/demo/show"
450
+
451
+ expect(last_response.body).to include("host-app.css")
452
+ expect(last_response.body).to include("DemoButton")
453
+ end
454
+ end
455
+ end
456
+
254
457
  describe "regression guard: render context reaches ViewHelper" do
255
458
  it "ViewHelper#__ruact_component__ does NOT raise the outside-flow error" do
256
459
  # If the handoff regresses, the request returns 500 with this exact