ruact 0.0.8 → 0.0.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +35 -1
- data/lib/generators/ruact/install/install_generator.rb +264 -5
- data/lib/generators/ruact/install/templates/Procfile.dev.tt +3 -0
- data/lib/generators/ruact/install/templates/globals.css.tt +20 -0
- data/lib/generators/ruact/install/templates/initializer.rb.tt +9 -0
- data/lib/generators/ruact/install/templates/package.json.tt +7 -1
- data/lib/generators/ruact/install/templates/tsconfig.json.tt +18 -0
- data/lib/generators/ruact/scaffold/scaffold_shadcn_preflight.rb +10 -0
- data/lib/ruact/configuration.rb +73 -0
- data/lib/ruact/controller/document_rendering.rb +210 -0
- data/lib/ruact/controller.rb +5 -46
- data/lib/ruact/doctor.rb +37 -5
- data/lib/ruact/layout_source.rb +59 -0
- data/lib/ruact/version.rb +1 -1
- data/lib/ruact/view_helper.rb +10 -1
- data/lib/ruact.rb +1 -0
- data/spec/fixtures/story_7_9_views/controller_request_spec_support/exploding_layout_demo/show.html.erb +3 -0
- data/spec/fixtures/story_7_9_views/controller_request_spec_support/ghost_layout_demo/show.html.erb +3 -0
- data/spec/fixtures/story_7_9_views/controller_request_spec_support/layout_demo/show.html.erb +3 -0
- data/spec/fixtures/story_7_9_views/controller_request_spec_support/rootless_layout_demo/show.html.erb +3 -0
- data/spec/fixtures/story_7_9_views/controller_request_spec_support/unwired_layout_demo/show.html.erb +3 -0
- data/spec/fixtures/story_7_9_views/layouts/bare_host.html.erb +16 -0
- data/spec/fixtures/story_7_9_views/layouts/exploding_host.html.erb +24 -0
- data/spec/fixtures/story_7_9_views/layouts/rootless_host.html.erb +15 -0
- data/spec/fixtures/story_7_9_views/layouts/ruact_host.html.erb +17 -0
- data/spec/ruact/controller_request_spec.rb +203 -0
- data/spec/ruact/doctor_spec.rb +81 -6
- data/spec/ruact/install_generator_spec.rb +442 -70
- data/spec/ruact/layout_source_spec.rb +108 -0
- data/spec/ruact/scaffold_generator_spec.rb +14 -0
- metadata +18 -4
|
@@ -88,22 +88,6 @@ RSpec.describe Ruact do # rubocop:disable RSpec/SpecFilePathFormat
|
|
|
88
88
|
:injected
|
|
89
89
|
end
|
|
90
90
|
|
|
91
|
-
# Reproduces inject_layout_shell logic from the generator
|
|
92
|
-
def inject_layout_shell(dest_root)
|
|
93
|
-
layout_file = File.join(dest_root, "app/views/layouts/application.html.erb")
|
|
94
|
-
return :missing unless File.exist?(layout_file)
|
|
95
|
-
|
|
96
|
-
content = File.read(layout_file)
|
|
97
|
-
return :already_present if content.include?("ruact: root")
|
|
98
|
-
|
|
99
|
-
modified = content.sub(
|
|
100
|
-
" </body>",
|
|
101
|
-
" <%# ruact: root %>\n <div id=\"root\"></div>\n </body>"
|
|
102
|
-
)
|
|
103
|
-
File.write(layout_file, modified)
|
|
104
|
-
:injected
|
|
105
|
-
end
|
|
106
|
-
|
|
107
91
|
describe "ApplicationController injection (AC#1, AC#3)" do
|
|
108
92
|
let(:controller_content) do
|
|
109
93
|
"class ApplicationController < ActionController::Base\nend\n"
|
|
@@ -137,60 +121,6 @@ RSpec.describe Ruact do # rubocop:disable RSpec/SpecFilePathFormat
|
|
|
137
121
|
end
|
|
138
122
|
end
|
|
139
123
|
|
|
140
|
-
describe "Layout injection (AC#1, AC#3)" do
|
|
141
|
-
let(:layout_content) do
|
|
142
|
-
<<~HTML
|
|
143
|
-
<!DOCTYPE html>
|
|
144
|
-
<html>
|
|
145
|
-
<body>
|
|
146
|
-
<%= yield %>
|
|
147
|
-
</body>
|
|
148
|
-
</html>
|
|
149
|
-
HTML
|
|
150
|
-
end
|
|
151
|
-
|
|
152
|
-
it "injects the RSC root div before </body>" do
|
|
153
|
-
write_file("app/views/layouts/application.html.erb", layout_content)
|
|
154
|
-
result = inject_layout_shell(tmpdir)
|
|
155
|
-
|
|
156
|
-
expect(result).to eq(:injected)
|
|
157
|
-
content = read_file("app/views/layouts/application.html.erb")
|
|
158
|
-
expect(content).to include('<div id="root"></div>')
|
|
159
|
-
expect(content).to include("ruact: root")
|
|
160
|
-
end
|
|
161
|
-
|
|
162
|
-
it "returns :already_present on second run (idempotent, AC#3)" do
|
|
163
|
-
content_with_marker = layout_content.sub(
|
|
164
|
-
" </body>",
|
|
165
|
-
" <%# ruact: root %>\n <div id=\"root\"></div>\n </body>"
|
|
166
|
-
)
|
|
167
|
-
write_file("app/views/layouts/application.html.erb", content_with_marker)
|
|
168
|
-
|
|
169
|
-
result = inject_layout_shell(tmpdir)
|
|
170
|
-
expect(result).to eq(:already_present)
|
|
171
|
-
end
|
|
172
|
-
|
|
173
|
-
it "does not duplicate the root div when run twice (AC#3)" do
|
|
174
|
-
write_file("app/views/layouts/application.html.erb", layout_content)
|
|
175
|
-
inject_layout_shell(tmpdir)
|
|
176
|
-
inject_layout_shell(tmpdir)
|
|
177
|
-
|
|
178
|
-
content = read_file("app/views/layouts/application.html.erb")
|
|
179
|
-
occurrences = content.scan('<div id="root">').size
|
|
180
|
-
expect(occurrences).to eq(1)
|
|
181
|
-
end
|
|
182
|
-
|
|
183
|
-
it "places the root div before the closing </body> tag" do
|
|
184
|
-
write_file("app/views/layouts/application.html.erb", layout_content)
|
|
185
|
-
inject_layout_shell(tmpdir)
|
|
186
|
-
|
|
187
|
-
content = read_file("app/views/layouts/application.html.erb")
|
|
188
|
-
root_pos = content.index('<div id="root">')
|
|
189
|
-
body_pos = content.index("</body>")
|
|
190
|
-
expect(root_pos).to be < body_pos
|
|
191
|
-
end
|
|
192
|
-
end
|
|
193
|
-
|
|
194
124
|
describe "vite.config.js template (AC#2)" do
|
|
195
125
|
let(:template_path) do
|
|
196
126
|
File.expand_path(
|
|
@@ -1174,4 +1104,446 @@ RSpec.describe Ruact do # rubocop:disable RSpec/SpecFilePathFormat
|
|
|
1174
1104
|
end
|
|
1175
1105
|
end
|
|
1176
1106
|
end
|
|
1107
|
+
|
|
1108
|
+
# The `--shadcn` prerequisites. The gap these close is concrete: shadcn's CLI
|
|
1109
|
+
# aborts on a ruact app with "No Tailwind CSS configuration found" and "Could
|
|
1110
|
+
# not find valid path aliases", because a ruact app ships neither Tailwind nor
|
|
1111
|
+
# a tsconfig.json. Without them `ruact:scaffold --shadcn` emitted components
|
|
1112
|
+
# whose classes resolved to nothing — styled markup with no styles.
|
|
1113
|
+
describe "install generator — --shadcn prerequisites" do
|
|
1114
|
+
require "stringio"
|
|
1115
|
+
require "generators/ruact/install/install_generator"
|
|
1116
|
+
|
|
1117
|
+
let(:app_root) { Dir.mktmpdir("ruact_install_shadcn") }
|
|
1118
|
+
|
|
1119
|
+
after { FileUtils.rm_rf(app_root) }
|
|
1120
|
+
|
|
1121
|
+
def build_generator(root, opts = {})
|
|
1122
|
+
Ruact::Generators::InstallGenerator.new([], opts, destination_root: root)
|
|
1123
|
+
end
|
|
1124
|
+
|
|
1125
|
+
def silently
|
|
1126
|
+
original = $stdout
|
|
1127
|
+
$stdout = StringIO.new
|
|
1128
|
+
yield
|
|
1129
|
+
ensure
|
|
1130
|
+
$stdout = original
|
|
1131
|
+
end
|
|
1132
|
+
|
|
1133
|
+
def read(relative)
|
|
1134
|
+
File.read(File.join(app_root, relative))
|
|
1135
|
+
end
|
|
1136
|
+
|
|
1137
|
+
describe "with --shadcn", :aggregate_failures do
|
|
1138
|
+
let(:generator) { build_generator(app_root, shadcn: true) }
|
|
1139
|
+
|
|
1140
|
+
it "writes the Tailwind entry shadcn points components.json at" do
|
|
1141
|
+
silently { generator.create_shadcn_prerequisites }
|
|
1142
|
+
|
|
1143
|
+
expect(File).to exist(File.join(app_root, "app/javascript/styles/globals.css"))
|
|
1144
|
+
expect(read("app/javascript/styles/globals.css")).to include('@import "tailwindcss"')
|
|
1145
|
+
end
|
|
1146
|
+
|
|
1147
|
+
it "writes the tsconfig import alias shadcn probes for" do
|
|
1148
|
+
silently { generator.create_shadcn_prerequisites }
|
|
1149
|
+
|
|
1150
|
+
tsconfig = JSON.parse(read("tsconfig.json"))
|
|
1151
|
+
expect(tsconfig.dig("compilerOptions", "paths", "@/*")).to eq(["app/javascript/*"])
|
|
1152
|
+
end
|
|
1153
|
+
|
|
1154
|
+
it "creates the builds directory Propshaft serves the compiled stylesheet from" do
|
|
1155
|
+
silently { generator.create_shadcn_prerequisites }
|
|
1156
|
+
|
|
1157
|
+
expect(File).to exist(File.join(app_root, "app/assets/builds/.keep"))
|
|
1158
|
+
end
|
|
1159
|
+
|
|
1160
|
+
it "declares Tailwind in package.json with a build:css script" do
|
|
1161
|
+
silently { generator.create_package_json }
|
|
1162
|
+
|
|
1163
|
+
pkg = JSON.parse(read("package.json"))
|
|
1164
|
+
expect(pkg.dig("devDependencies", "tailwindcss")).to be_a(String)
|
|
1165
|
+
expect(pkg.dig("devDependencies", "@tailwindcss/cli")).to be_a(String)
|
|
1166
|
+
expect(pkg.dig("devDependencies", "tw-animate-css")).to be_a(String)
|
|
1167
|
+
expect(pkg.dig("scripts", "build:css")).to include("app/assets/builds/tailwind.css")
|
|
1168
|
+
end
|
|
1169
|
+
|
|
1170
|
+
it "adds a css watch process so bin/dev rebuilds the stylesheet" do
|
|
1171
|
+
silently { generator.create_launch_files }
|
|
1172
|
+
|
|
1173
|
+
expect(read("Procfile.dev")).to match(/^css: .*tailwind\.css --watch$/)
|
|
1174
|
+
end
|
|
1175
|
+
|
|
1176
|
+
it "gitignores the compiled stylesheet (a build artifact of globals.css)" do
|
|
1177
|
+
File.write(File.join(app_root, ".gitignore"), "/log/*\n")
|
|
1178
|
+
silently { generator.append_gitignore_entries }
|
|
1179
|
+
|
|
1180
|
+
expect(read(".gitignore")).to include("app/assets/builds/tailwind.css")
|
|
1181
|
+
end
|
|
1182
|
+
|
|
1183
|
+
# The one thing a reader cannot guess: current shadcn defaults to Base UI,
|
|
1184
|
+
# but the components the scaffold generates import Radix primitives.
|
|
1185
|
+
it "prints the two shadcn commands, pinning --base radix" do
|
|
1186
|
+
expect { generator.send(:show_shadcn_next_steps) }
|
|
1187
|
+
.to output(/npx shadcn@latest init --base radix/).to_stdout
|
|
1188
|
+
end
|
|
1189
|
+
|
|
1190
|
+
it "prints the complete add-list, so the scaffold pre-flight cannot fail on a gap" do
|
|
1191
|
+
expect { generator.send(:show_shadcn_next_steps) }
|
|
1192
|
+
.to output(/add button input textarea switch select label badge table alert-dialog dropdown-menu/)
|
|
1193
|
+
.to_stdout
|
|
1194
|
+
end
|
|
1195
|
+
end
|
|
1196
|
+
|
|
1197
|
+
# Codex review finding: the layout-migration branch was pinned only by the
|
|
1198
|
+
# REPRODUCTION helper above, which cannot see the real generator's anchor
|
|
1199
|
+
# regex. Invoking the actual generator caught that a layout written with
|
|
1200
|
+
# single quotes matched nothing while the generator still printed a success
|
|
1201
|
+
# message. These specs drive `InstallGenerator#inject_layout_shell` itself.
|
|
1202
|
+
# Thor registers every PUBLIC instance method of a generator as a command and
|
|
1203
|
+
# runs it in declaration order. A helper left public therefore executes on
|
|
1204
|
+
# its own, out of context — this has bitten three times in this branch: a
|
|
1205
|
+
# message printed twice, a `--shadcn`-only notice firing on the default
|
|
1206
|
+
# path, and a helper taking a required argument being invoked with none.
|
|
1207
|
+
# Pin the command list so the next one is caught here rather than in an app.
|
|
1208
|
+
describe "Thor command surface" do
|
|
1209
|
+
it "registers only the install steps, never the private helpers" do
|
|
1210
|
+
expect(Ruact::Generators::InstallGenerator.commands.keys).to contain_exactly(
|
|
1211
|
+
"create_initializer",
|
|
1212
|
+
"inject_controller_concern",
|
|
1213
|
+
"inject_layout_shell",
|
|
1214
|
+
"create_shadcn_prerequisites",
|
|
1215
|
+
"create_components_directory",
|
|
1216
|
+
"create_server_functions_directory",
|
|
1217
|
+
"append_gitignore_entries",
|
|
1218
|
+
"prime_server_functions_codegen",
|
|
1219
|
+
"create_vite_config",
|
|
1220
|
+
"create_package_json",
|
|
1221
|
+
"create_launch_files",
|
|
1222
|
+
"advise_plumbing_migration",
|
|
1223
|
+
"create_agents_md",
|
|
1224
|
+
"install_javascript_dependencies",
|
|
1225
|
+
"show_post_install_message"
|
|
1226
|
+
)
|
|
1227
|
+
end
|
|
1228
|
+
|
|
1229
|
+
it "has no command requiring arguments (Thor invokes them with none)" do
|
|
1230
|
+
requiring_args = Ruact::Generators::InstallGenerator.commands.keys.reject do |name|
|
|
1231
|
+
Ruact::Generators::InstallGenerator.instance_method(name).arity.zero?
|
|
1232
|
+
end
|
|
1233
|
+
|
|
1234
|
+
expect(requiring_args).to be_empty
|
|
1235
|
+
end
|
|
1236
|
+
end
|
|
1237
|
+
|
|
1238
|
+
# Migrating an existing app is the path that matters most here, and Thor's
|
|
1239
|
+
# `template` handles it badly: it raises an overwrite CONFLICT, offering a
|
|
1240
|
+
# choice between losing every setting the app already had and ending up
|
|
1241
|
+
# half-migrated (layout edited, `config.layout` still off) in silence.
|
|
1242
|
+
describe "create_initializer on an app that already has one", :aggregate_failures do
|
|
1243
|
+
def write_initializer(body)
|
|
1244
|
+
path = File.join(app_root, "config/initializers/ruact.rb")
|
|
1245
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
1246
|
+
File.write(path, body)
|
|
1247
|
+
path
|
|
1248
|
+
end
|
|
1249
|
+
|
|
1250
|
+
it "adds config.layout without touching the app's existing settings" do
|
|
1251
|
+
path = write_initializer(<<~RUBY)
|
|
1252
|
+
Ruact.configure do |config|
|
|
1253
|
+
config.strict_serialization = true
|
|
1254
|
+
config.max_upload_bytes = 25 * 1024 * 1024
|
|
1255
|
+
end
|
|
1256
|
+
RUBY
|
|
1257
|
+
|
|
1258
|
+
silently { build_generator(app_root).create_initializer }
|
|
1259
|
+
|
|
1260
|
+
result = File.read(path)
|
|
1261
|
+
expect(result).to include("config.layout = true")
|
|
1262
|
+
expect(result).to include("config.strict_serialization = true")
|
|
1263
|
+
expect(result).to include("config.max_upload_bytes = 25 * 1024 * 1024")
|
|
1264
|
+
end
|
|
1265
|
+
|
|
1266
|
+
# `config.layout = false` is a deliberate choice (keep the built-in
|
|
1267
|
+
# shell). Re-running install must not quietly reverse it.
|
|
1268
|
+
it "leaves an explicit opt-out alone" do
|
|
1269
|
+
path = write_initializer("Ruact.configure do |config|\n config.layout = false\nend\n")
|
|
1270
|
+
|
|
1271
|
+
silently { build_generator(app_root).create_initializer }
|
|
1272
|
+
|
|
1273
|
+
expect(File.read(path)).to include("config.layout = false")
|
|
1274
|
+
expect(File.read(path)).not_to include("config.layout = true")
|
|
1275
|
+
end
|
|
1276
|
+
|
|
1277
|
+
it "is idempotent" do
|
|
1278
|
+
path = write_initializer("Ruact.configure do |config|\nend\n")
|
|
1279
|
+
|
|
1280
|
+
silently { build_generator(app_root).create_initializer }
|
|
1281
|
+
silently { build_generator(app_root).create_initializer }
|
|
1282
|
+
|
|
1283
|
+
expect(File.read(path).scan("config.layout").size).to eq(1)
|
|
1284
|
+
end
|
|
1285
|
+
|
|
1286
|
+
# Never guess at an initializer we do not recognise — say what to add,
|
|
1287
|
+
# because the silent outcome is an app whose CSS never reaches the page.
|
|
1288
|
+
it "tells the user what to add when it cannot find the configure block" do
|
|
1289
|
+
write_initializer("# hand-rolled\nRuact.config\n")
|
|
1290
|
+
|
|
1291
|
+
output = capture_generator_output { build_generator(app_root).create_initializer }
|
|
1292
|
+
|
|
1293
|
+
expect(output).to include("could not find the Ruact.configure block")
|
|
1294
|
+
expect(output).to include("config.layout = true")
|
|
1295
|
+
end
|
|
1296
|
+
|
|
1297
|
+
def capture_generator_output
|
|
1298
|
+
original = $stdout
|
|
1299
|
+
$stdout = StringIO.new
|
|
1300
|
+
yield
|
|
1301
|
+
$stdout.string
|
|
1302
|
+
ensure
|
|
1303
|
+
$stdout = original
|
|
1304
|
+
end
|
|
1305
|
+
end
|
|
1306
|
+
|
|
1307
|
+
describe "inject_layout_shell — the REAL generator", :aggregate_failures do
|
|
1308
|
+
def write_layout(body)
|
|
1309
|
+
path = File.join(app_root, "app/views/layouts/application.html.erb")
|
|
1310
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
1311
|
+
File.write(path, body)
|
|
1312
|
+
path
|
|
1313
|
+
end
|
|
1314
|
+
|
|
1315
|
+
def layout_after_run(body)
|
|
1316
|
+
path = write_layout(body)
|
|
1317
|
+
silently { build_generator(app_root).inject_layout_shell }
|
|
1318
|
+
File.read(path)
|
|
1319
|
+
end
|
|
1320
|
+
|
|
1321
|
+
# The fresh-install path. Previously covered only by a REPRODUCTION of the
|
|
1322
|
+
# generator's logic, which twice drifted from the generator itself and hid
|
|
1323
|
+
# real defects; the reproduction is gone and these drive the real thing.
|
|
1324
|
+
it "adds both the root div and the asset call to a layout that has neither" do
|
|
1325
|
+
path = write_layout(<<~HTML)
|
|
1326
|
+
<html>
|
|
1327
|
+
<body>
|
|
1328
|
+
<%= yield %>
|
|
1329
|
+
</body>
|
|
1330
|
+
</html>
|
|
1331
|
+
HTML
|
|
1332
|
+
|
|
1333
|
+
silently { build_generator(app_root).inject_layout_shell }
|
|
1334
|
+
|
|
1335
|
+
result = File.read(path)
|
|
1336
|
+
expect(result).to include(%(<div id="root"></div>))
|
|
1337
|
+
expect(result).to include("<%= ruact_js_assets %>")
|
|
1338
|
+
expect(result).to include("ruact: root")
|
|
1339
|
+
end
|
|
1340
|
+
|
|
1341
|
+
it "places them inside the body, before the closing tag" do
|
|
1342
|
+
path = write_layout("<html>\n <body>\n <%= yield %>\n </body>\n</html>\n")
|
|
1343
|
+
|
|
1344
|
+
silently { build_generator(app_root).inject_layout_shell }
|
|
1345
|
+
|
|
1346
|
+
result = File.read(path)
|
|
1347
|
+
expect(result.index(%(<div id="root">))).to be < result.index("</body>")
|
|
1348
|
+
expect(result.index(%(<div id="root">))).to be < result.index("ruact_js_assets")
|
|
1349
|
+
end
|
|
1350
|
+
|
|
1351
|
+
it "is a no-op on a layout that is already migrated" do
|
|
1352
|
+
path = write_layout(<<~HTML)
|
|
1353
|
+
<html>
|
|
1354
|
+
<body>
|
|
1355
|
+
<%# ruact: root %>
|
|
1356
|
+
<div id="root"></div>
|
|
1357
|
+
<%= ruact_js_assets %>
|
|
1358
|
+
</body>
|
|
1359
|
+
</html>
|
|
1360
|
+
HTML
|
|
1361
|
+
before = File.read(path)
|
|
1362
|
+
|
|
1363
|
+
silently { build_generator(app_root).inject_layout_shell }
|
|
1364
|
+
|
|
1365
|
+
expect(File.read(path)).to eq(before)
|
|
1366
|
+
end
|
|
1367
|
+
|
|
1368
|
+
it "migrates a layout whose root div uses single quotes" do
|
|
1369
|
+
result = layout_after_run(<<~HTML)
|
|
1370
|
+
<html>
|
|
1371
|
+
<body>
|
|
1372
|
+
<%# ruact: root %>
|
|
1373
|
+
<div id='root'></div>
|
|
1374
|
+
</body>
|
|
1375
|
+
</html>
|
|
1376
|
+
HTML
|
|
1377
|
+
|
|
1378
|
+
expect(result).to include("<%= ruact_js_assets %>")
|
|
1379
|
+
end
|
|
1380
|
+
|
|
1381
|
+
it "migrates a layout whose root div carries extra attributes" do
|
|
1382
|
+
result = layout_after_run(<<~HTML)
|
|
1383
|
+
<html>
|
|
1384
|
+
<body>
|
|
1385
|
+
<%# ruact: root %>
|
|
1386
|
+
<div class="app" id="root" data-turbo="false"></div>
|
|
1387
|
+
</body>
|
|
1388
|
+
</html>
|
|
1389
|
+
HTML
|
|
1390
|
+
|
|
1391
|
+
expect(result).to include("<%= ruact_js_assets %>")
|
|
1392
|
+
end
|
|
1393
|
+
|
|
1394
|
+
it "migrates a layout with the marker and the div on one line" do
|
|
1395
|
+
result = layout_after_run(
|
|
1396
|
+
%(<html><body><%# ruact: root %><div id="root"></div></body></html>\n)
|
|
1397
|
+
)
|
|
1398
|
+
|
|
1399
|
+
expect(result).to include("<%= ruact_js_assets %>")
|
|
1400
|
+
end
|
|
1401
|
+
|
|
1402
|
+
it "is idempotent — a second run adds nothing" do
|
|
1403
|
+
path = write_layout(<<~HTML)
|
|
1404
|
+
<html>
|
|
1405
|
+
<body>
|
|
1406
|
+
<%# ruact: root %>
|
|
1407
|
+
<div id="root"></div>
|
|
1408
|
+
</body>
|
|
1409
|
+
</html>
|
|
1410
|
+
HTML
|
|
1411
|
+
silently { build_generator(app_root).inject_layout_shell }
|
|
1412
|
+
silently { build_generator(app_root).inject_layout_shell }
|
|
1413
|
+
|
|
1414
|
+
expect(File.read(path).scan("ruact_js_assets").size).to eq(1)
|
|
1415
|
+
end
|
|
1416
|
+
|
|
1417
|
+
# Round-2 finding: the anchor matched any attribute ENDING in `id`, so a
|
|
1418
|
+
# layout with `data-id="root"` and no real mount point got the helper
|
|
1419
|
+
# injected and a success message. It now reads as "no root present" and
|
|
1420
|
+
# the generator writes a proper root + helper instead of warning — the
|
|
1421
|
+
# look-alike div is left alone, being none of its business.
|
|
1422
|
+
it "does not mistake a look-alike attribute for the React root" do
|
|
1423
|
+
path = write_layout(<<~HTML)
|
|
1424
|
+
<html>
|
|
1425
|
+
<body>
|
|
1426
|
+
<%# ruact: root %>
|
|
1427
|
+
<div data-id="root"></div>
|
|
1428
|
+
</body>
|
|
1429
|
+
</html>
|
|
1430
|
+
HTML
|
|
1431
|
+
|
|
1432
|
+
silently { build_generator(app_root).inject_layout_shell }
|
|
1433
|
+
|
|
1434
|
+
result = File.read(path)
|
|
1435
|
+
expect(result).to match(%r{<div id="root"></div>})
|
|
1436
|
+
expect(result).to include("<%= ruact_js_assets %>")
|
|
1437
|
+
end
|
|
1438
|
+
|
|
1439
|
+
# The mirror of the legacy migration: the call is there, the mount target
|
|
1440
|
+
# is not. Injecting the whole block would duplicate the helper.
|
|
1441
|
+
it "adds only the missing root when the layout already calls the helper" do
|
|
1442
|
+
path = write_layout(<<~HTML)
|
|
1443
|
+
<html>
|
|
1444
|
+
<body>
|
|
1445
|
+
<%= yield %>
|
|
1446
|
+
<%= ruact_js_assets %>
|
|
1447
|
+
</body>
|
|
1448
|
+
</html>
|
|
1449
|
+
HTML
|
|
1450
|
+
|
|
1451
|
+
silently { build_generator(app_root).inject_layout_shell }
|
|
1452
|
+
|
|
1453
|
+
result = File.read(path)
|
|
1454
|
+
expect(result).to match(%r{<div id="root"></div>})
|
|
1455
|
+
expect(result.scan("ruact_js_assets").size).to eq(1)
|
|
1456
|
+
expect(result.index(%(<div id="root">))).to be < result.index("ruact_js_assets")
|
|
1457
|
+
end
|
|
1458
|
+
|
|
1459
|
+
# A root that is NOT an empty paired div (a spinner placeholder, say) has
|
|
1460
|
+
# nothing safe to inject after, so the generator says so instead of
|
|
1461
|
+
# guessing where the call belongs.
|
|
1462
|
+
it "warns when the root div is not the empty form it knows how to anchor on" do
|
|
1463
|
+
write_layout(<<~HTML)
|
|
1464
|
+
<html>
|
|
1465
|
+
<body>
|
|
1466
|
+
<%# ruact: root %>
|
|
1467
|
+
<div id="root">loading…</div>
|
|
1468
|
+
</body>
|
|
1469
|
+
</html>
|
|
1470
|
+
HTML
|
|
1471
|
+
|
|
1472
|
+
output = capture_generator_output { build_generator(app_root).inject_layout_shell }
|
|
1473
|
+
|
|
1474
|
+
expect(output).to include("could not locate the React root div")
|
|
1475
|
+
end
|
|
1476
|
+
|
|
1477
|
+
# Round-2 finding: the "already migrated, skip" guard was a substring
|
|
1478
|
+
# match, so a TODO note about the helper made the generator skip an app
|
|
1479
|
+
# that was never migrated.
|
|
1480
|
+
it "does not treat a TODO comment naming the helper as already migrated" do
|
|
1481
|
+
path = write_layout(<<~HTML)
|
|
1482
|
+
<html>
|
|
1483
|
+
<body>
|
|
1484
|
+
<%# TODO: add ruact_js_assets %>
|
|
1485
|
+
<%# ruact: root %>
|
|
1486
|
+
<div id="root"></div>
|
|
1487
|
+
</body>
|
|
1488
|
+
</html>
|
|
1489
|
+
HTML
|
|
1490
|
+
|
|
1491
|
+
silently { build_generator(app_root).inject_layout_shell }
|
|
1492
|
+
|
|
1493
|
+
expect(File.read(path)).to include("<%= ruact_js_assets %>")
|
|
1494
|
+
end
|
|
1495
|
+
|
|
1496
|
+
# Silence here is the dangerous outcome: the app keeps rendering through
|
|
1497
|
+
# ruact's CSS-less shell and nothing ever says why.
|
|
1498
|
+
it "says so LOUDLY when it cannot find the root div, instead of claiming success" do
|
|
1499
|
+
write_layout(<<~HTML)
|
|
1500
|
+
<html>
|
|
1501
|
+
<body>
|
|
1502
|
+
<%# ruact: root %>
|
|
1503
|
+
<span id="root"></span>
|
|
1504
|
+
</body>
|
|
1505
|
+
</html>
|
|
1506
|
+
HTML
|
|
1507
|
+
|
|
1508
|
+
output = capture_generator_output { build_generator(app_root).inject_layout_shell }
|
|
1509
|
+
|
|
1510
|
+
expect(output).to include("could not locate the React root div")
|
|
1511
|
+
expect(output).not_to include("added ruact_js_assets")
|
|
1512
|
+
end
|
|
1513
|
+
|
|
1514
|
+
def capture_generator_output
|
|
1515
|
+
original = $stdout
|
|
1516
|
+
$stdout = StringIO.new
|
|
1517
|
+
yield
|
|
1518
|
+
$stdout.string
|
|
1519
|
+
ensure
|
|
1520
|
+
$stdout = original
|
|
1521
|
+
end
|
|
1522
|
+
end
|
|
1523
|
+
|
|
1524
|
+
describe "without --shadcn (the default path is untouched)", :aggregate_failures do
|
|
1525
|
+
let(:generator) { build_generator(app_root) }
|
|
1526
|
+
|
|
1527
|
+
it "writes no Tailwind entry and no tsconfig" do
|
|
1528
|
+
silently { generator.create_shadcn_prerequisites }
|
|
1529
|
+
|
|
1530
|
+
expect(File).not_to exist(File.join(app_root, "app/javascript/styles/globals.css"))
|
|
1531
|
+
expect(File).not_to exist(File.join(app_root, "tsconfig.json"))
|
|
1532
|
+
end
|
|
1533
|
+
|
|
1534
|
+
it "leaves package.json free of Tailwind" do
|
|
1535
|
+
silently { generator.create_package_json }
|
|
1536
|
+
|
|
1537
|
+
pkg = JSON.parse(read("package.json"))
|
|
1538
|
+
expect(pkg["devDependencies"].keys).not_to include("tailwindcss", "@tailwindcss/cli")
|
|
1539
|
+
expect(pkg["scripts"]).not_to have_key("build:css")
|
|
1540
|
+
end
|
|
1541
|
+
|
|
1542
|
+
it "leaves Procfile.dev at the two processes it always had" do
|
|
1543
|
+
silently { generator.create_launch_files }
|
|
1544
|
+
|
|
1545
|
+
expect(read("Procfile.dev")).not_to include("css:")
|
|
1546
|
+
end
|
|
1547
|
+
end
|
|
1548
|
+
end
|
|
1177
1549
|
end
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "spec_helper"
|
|
4
|
+
require "ruact"
|
|
5
|
+
|
|
6
|
+
# The single definition of "is this layout migrated?", shared by the runtime and
|
|
7
|
+
# by `ruact:install`. Every example here is a shape that fooled an earlier,
|
|
8
|
+
# looser check — a NAME where only a CALL counts, or a substring where only an
|
|
9
|
+
# attribute counts.
|
|
10
|
+
RSpec.describe Ruact::LayoutSource do
|
|
11
|
+
describe ".wired?" do
|
|
12
|
+
it "accepts a plain output tag" do
|
|
13
|
+
expect(described_class.wired?("<%= ruact_js_assets %>")).to be true
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
it "accepts the raw-output form and tight whitespace" do
|
|
17
|
+
expect(described_class.wired?("<%==ruact_js_assets%>")).to be true
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
it "accepts a call with an argument" do
|
|
21
|
+
expect(described_class.wired?("<%= ruact_js_assets(payload) %>")).to be true
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Round-1 finding: a mention in prose read as wired.
|
|
25
|
+
it "rejects a mention inside an ERB comment" do
|
|
26
|
+
expect(described_class.wired?("<%# remember to add ruact_js_assets here %>")).to be false
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Round-2 finding, and the sharper version of the same mistake: a genuinely
|
|
30
|
+
# COMMENTED-OUT call. ERB comments do not nest, so this emits nothing — but
|
|
31
|
+
# a regex looking only for `<%=` sees a call and reports the layout ready,
|
|
32
|
+
# which sends the runtime off to render an unmigrated layout.
|
|
33
|
+
it "rejects a commented-out call" do
|
|
34
|
+
expect(described_class.wired?("<%# <%= ruact_js_assets %> %>")).to be false
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
it "rejects a multi-line comment that wraps a call" do
|
|
38
|
+
source = <<~ERB
|
|
39
|
+
<%#
|
|
40
|
+
disabled for now:
|
|
41
|
+
<%= ruact_js_assets %>
|
|
42
|
+
%>
|
|
43
|
+
ERB
|
|
44
|
+
expect(described_class.wired?(source)).to be false
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
it "still sees a real call that follows a comment mentioning it" do
|
|
48
|
+
expect(described_class.wired?("<%# ruact_js_assets %>\n<%= ruact_js_assets %>")).to be true
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Round-3 finding: ERB's trim-mode comment (`<%-# ... -%>`) is a comment in
|
|
52
|
+
# Erubi too — verified by compiling it — but the stripper only knew `<%#`,
|
|
53
|
+
# so a call disabled this way read as wired.
|
|
54
|
+
it "rejects a call disabled with a trim-mode comment" do
|
|
55
|
+
expect(described_class.wired?("<%-# <%= ruact_js_assets %> -%>")).to be false
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Round-3 finding, the other direction: forbidding `%` before the name to
|
|
59
|
+
# avoid crossing tag boundaries also rejected legitimate calls. A false
|
|
60
|
+
# "unwired" is safer than a false "wired", but it is still a wrong answer —
|
|
61
|
+
# the app would silently keep ruact's CSS-less shell.
|
|
62
|
+
it "accepts a call whose expression contains a percent sign" do
|
|
63
|
+
expect(described_class.wired?(%(<%= raw("100%") + ruact_js_assets %>))).to be true
|
|
64
|
+
expect(described_class.wired?("<%= ruact_js_assets if 50 % 2 == 0 %>")).to be true
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
it "still refuses to match across a tag boundary" do
|
|
68
|
+
expect(described_class.wired?("<%= something %> then a bare ruact_js_assets mention")).to be false
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
it "rejects a layout that never names it" do
|
|
72
|
+
expect(described_class.wired?("<html><body><%= yield %></body></html>")).to be false
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
describe ".root?" do
|
|
77
|
+
it "accepts the emitted form" do
|
|
78
|
+
expect(described_class.root?(%(<div id="root"></div>))).to be true
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
it "accepts single quotes, extra attributes and unquoted values" do
|
|
82
|
+
expect(described_class.root?(%(<div id='root'></div>))).to be true
|
|
83
|
+
expect(described_class.root?(%(<div class="a" id="root" data-x="1"></div>))).to be true
|
|
84
|
+
expect(described_class.root?(%(<div id=root></div>))).to be true
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Round-2 finding: `data-id="root"` is not a mount point, and a document
|
|
88
|
+
# carrying one but no real root gives React nothing to mount into.
|
|
89
|
+
it "rejects a look-alike attribute" do
|
|
90
|
+
expect(described_class.root?(%(<body data-id="root"></body>))).to be false
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
it "rejects a different id that merely starts with root" do
|
|
94
|
+
expect(described_class.root?(%(<div id="rootish"></div>))).to be false
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# The anchor `ruact:install` injects after.
|
|
99
|
+
describe "::ROOT_ELEMENT" do
|
|
100
|
+
it "anchors on a whole empty root div" do
|
|
101
|
+
expect(described_class::ROOT_ELEMENT).to match(%(<div id="root"></div>))
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
it "does not anchor on a look-alike attribute" do
|
|
105
|
+
expect(described_class::ROOT_ELEMENT).not_to match(%(<div data-id="root"></div>))
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
@@ -1136,6 +1136,20 @@ RSpec.describe Ruact::Generators::ScaffoldGenerator, :story_10_1 do # rubocop:di
|
|
|
1136
1136
|
expect(minimal).to eq(%w[button input label badge table alert-dialog dropdown-menu])
|
|
1137
1137
|
end
|
|
1138
1138
|
|
|
1139
|
+
# `ruact:install --shadcn` runs before any resource exists, so it prints
|
|
1140
|
+
# the SUPERSET constant instead of this per-resource list. If a template
|
|
1141
|
+
# ever imports a primitive the constant does not carry, the printed
|
|
1142
|
+
# `npx shadcn add` line would be short one component and the scaffold
|
|
1143
|
+
# would fail its own pre-flight — so pin the relationship.
|
|
1144
|
+
it "keeps the per-resource list a subset of the superset install prints" do
|
|
1145
|
+
superset = described_class::ShadcnPreflight::ALL_SHADCN_COMPONENTS
|
|
1146
|
+
richest = build(%w[Post title:string body:text published:boolean author:references])
|
|
1147
|
+
.required_shadcn_components
|
|
1148
|
+
|
|
1149
|
+
expect(richest).to all(be_in(superset))
|
|
1150
|
+
expect(superset).to match_array(richest)
|
|
1151
|
+
end
|
|
1152
|
+
|
|
1139
1153
|
it "maps each component to its app/javascript/components/ui/<name>.tsx file" do
|
|
1140
1154
|
gen = build(default_args)
|
|
1141
1155
|
expect(gen.shadcn_component_file("table").to_s)
|