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,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
|
data/spec/readme_spec.rb
ADDED
|
@@ -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 ``, 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 <LikeButton likes={@likes} /> tag, and the "use client" 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
|
+
# `<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 `{`/`}`: 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 { / }."
|
|
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
|