ruact 0.0.12 → 0.0.14
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/README.md +4 -4
- data/lib/generators/ruact/install/install_generator.rb +418 -128
- data/lib/generators/ruact/install/templates/AGENTS.md.tt +14 -13
- data/lib/generators/ruact/install/templates/Procfile.dev.tt +1 -1
- data/lib/generators/ruact/install/templates/initializer.rb.tt +29 -7
- data/lib/generators/ruact/install/templates/package.json.tt +4 -4
- data/lib/generators/ruact/install/templates/tsconfig.json.tt +3 -0
- data/lib/generators/ruact/layout/layout_generator.rb +52 -0
- data/lib/generators/ruact/scaffold/scaffold_generator.rb +39 -12
- data/lib/generators/ruact/scaffold/scaffold_shadcn_preflight.rb +4 -3
- data/lib/generators/ruact/scaffold/templates/components/List.tsx.tt +8 -8
- data/lib/generators/ruact/scaffold/templates/components/agnostic/List.tsx.tt +8 -8
- data/lib/generators/ruact/scaffold/templates/controller.rb.tt +5 -3
- data/lib/generators/ruact/scaffold/templates/queries/query.rb.tt +2 -2
- data/lib/generators/ruact/scaffold/templates/views/index.html.erb.tt +1 -1
- data/lib/ruact/configuration.rb +71 -17
- data/lib/ruact/controller/document_rendering.rb +72 -16
- data/lib/ruact/controller/page_rendering.rb +134 -0
- data/lib/ruact/controller/pages.rb +116 -0
- data/lib/ruact/controller.rb +78 -11
- data/lib/ruact/doctor.rb +233 -25
- data/lib/ruact/layout_source.rb +29 -7
- data/lib/ruact/navigation_boundary.rb +240 -0
- data/lib/ruact/railtie.rb +30 -0
- data/lib/ruact/routing.rb +24 -6
- data/lib/ruact/server.rb +10 -1
- data/lib/ruact/version.rb +1 -1
- data/lib/ruact/view_helper.rb +158 -1
- data/lib/ruact/views/layouts/ruact.html.erb +32 -0
- data/lib/ruact.rb +29 -0
- data/vendor/javascript/vite-plugin-ruact/ruact-router.test.mjs +433 -0
- data/vendor/javascript/vite-plugin-ruact/runtime/ruact-router.js +170 -7
- data/vendor/javascript/vite-plugin-ruact/tsconfig.scaffold-agnostic.json +1 -1
- data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/PostList.tsx +3 -3
- data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/PostList.tsx +3 -3
- data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/ambient.d.ts +4 -4
- data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/ambient.d.ts +4 -4
- metadata +8 -2
data/lib/ruact/doctor.rb
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "json"
|
|
3
4
|
require "socket"
|
|
4
5
|
require "pathname"
|
|
5
6
|
|
|
@@ -7,7 +8,8 @@ module Ruact
|
|
|
7
8
|
# Runs a suite of installation health checks and prints ✓/✗ per check.
|
|
8
9
|
# Extracted from the ruact:doctor Rake task for direct testability (FR27).
|
|
9
10
|
class Doctor # rubocop:disable Metrics/ClassLength
|
|
10
|
-
CHECKS = %i[manifest vite controller layout streaming legacy_constant serialize_only
|
|
11
|
+
CHECKS = %i[manifest vite controller layout head_assets streaming legacy_constant serialize_only
|
|
12
|
+
flight_middleware].freeze
|
|
11
13
|
# Built via Array#join so the gem-CI `name-propagation` guard does not
|
|
12
14
|
# match these literals against itself (Story 5.1 review F4 — the doctor
|
|
13
15
|
# file participates in the guard with no exclusion).
|
|
@@ -92,7 +94,7 @@ module Ruact
|
|
|
92
94
|
# success. An unexpected status (rendered `✗`) fails loudly rather than
|
|
93
95
|
# being silently treated as a pass (review finding R1).
|
|
94
96
|
passed = passed?(computed)
|
|
95
|
-
puts "Run rails
|
|
97
|
+
puts "Run rails ruact:doctor -- --json for how to fix each failure" unless passed
|
|
96
98
|
passed
|
|
97
99
|
end
|
|
98
100
|
|
|
@@ -161,14 +163,99 @@ module Ruact
|
|
|
161
163
|
"Run npm run dev (or bin/dev) to start the Vite dev server."]
|
|
162
164
|
end
|
|
163
165
|
|
|
166
|
+
# Story 17.0g (FR116) — reports the ADOPTION MODE instead of demanding one.
|
|
167
|
+
#
|
|
168
|
+
# Whole-app (`ruact:install --app`): ApplicationController includes the
|
|
169
|
+
# concern, and every action with a template renders through ruact — the
|
|
170
|
+
# message says how many templates that is, which is the cost `--app` hides.
|
|
171
|
+
# Island (the install default): the concern is on the controllers that
|
|
172
|
+
# render ruact pages. It used to FAIL every island app, because it only
|
|
173
|
+
# looked at ApplicationController. With none yet, a warning: the install
|
|
174
|
+
# worked; there is simply no page.
|
|
175
|
+
#
|
|
176
|
+
# Mechanical, like the layout checks: it reads files, it never renders.
|
|
164
177
|
def check_controller
|
|
165
|
-
|
|
166
|
-
if File.exist?(
|
|
167
|
-
[:pass, "Ruact::Controller
|
|
168
|
-
|
|
169
|
-
[:fail, "Ruact::Controller not included in ApplicationController",
|
|
170
|
-
"Run rails generate ruact:install to include Ruact::Controller in ApplicationController."]
|
|
178
|
+
application = Rails.root.join("app", "controllers", "application_controller.rb")
|
|
179
|
+
if File.exist?(application) && File.read(application).match?(Ruact::CONTROLLER_INCLUDE)
|
|
180
|
+
return [:pass, "whole-app mode: ApplicationController includes Ruact::Controller — " \
|
|
181
|
+
"#{pluralize_count(ruact_page_templates, 'template')} in app/views render through ruact"]
|
|
171
182
|
end
|
|
183
|
+
|
|
184
|
+
count = island_controllers.length
|
|
185
|
+
return island_without_pages_result if count.zero?
|
|
186
|
+
|
|
187
|
+
verb = count == 1 ? "renders" : "render"
|
|
188
|
+
[:pass, "island mode: #{pluralize_count(count, 'controller')} #{verb} ruact pages (include Ruact::Controller)"]
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# Controller files that include the concern themselves. Approximate by
|
|
192
|
+
# design — it reads source, it does not load classes: a controller that
|
|
193
|
+
# gets the concern through a base controller counts through the base.
|
|
194
|
+
# `concerns/` holds modules, not controllers; only the app's ROOT
|
|
195
|
+
# ApplicationController is whole-app mode (an `Admin::ApplicationController`
|
|
196
|
+
# is an island base like any other).
|
|
197
|
+
def island_controllers
|
|
198
|
+
root = Rails.root.join("app", "controllers")
|
|
199
|
+
Dir.glob(root.join("**", "*.rb").to_s).select do |file|
|
|
200
|
+
next false if file == root.join("application_controller.rb").to_s
|
|
201
|
+
next false if file.start_with?("#{root.join('concerns')}/")
|
|
202
|
+
|
|
203
|
+
File.read(file).match?(Ruact::CONTROLLER_INCLUDE)
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# Page templates: `.html.erb` under app/views — not layouts, partials or
|
|
208
|
+
# mailer views. Approximate: `ruact_pages` narrowing is not read.
|
|
209
|
+
def ruact_page_templates
|
|
210
|
+
Dir.glob(Rails.root.join("app", "views", "**", "*.html.erb").to_s).count do |file|
|
|
211
|
+
!file.include?("/app/views/layouts/") && !File.basename(file).start_with?("_") &&
|
|
212
|
+
!File.dirname(file).end_with?("_mailer")
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def pluralize_count(count, noun)
|
|
217
|
+
"#{count} #{noun}#{'s' unless count == 1}"
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def island_without_pages_result
|
|
221
|
+
[:warn,
|
|
222
|
+
"island mode: no controller renders ruact pages yet — add include Ruact::Controller to one, " \
|
|
223
|
+
"or run rails generate ruact:scaffold",
|
|
224
|
+
"The install changes no controller (island mode). A page renders through ruact when its " \
|
|
225
|
+
"controller has `include Ruact::Controller`; narrow it with `ruact_pages only: %i[show]`. " \
|
|
226
|
+
"`rails generate ruact:install --app` puts it on ApplicationController instead (every page)."]
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
# Which document a ruact page actually renders into, decided from
|
|
230
|
+
# `Ruact.config.layout` and the files on disk — never by rendering.
|
|
231
|
+
#
|
|
232
|
+
# - `false` → `[:shell, application.html.erb]`: ruact's built-in shell. The
|
|
233
|
+
# application layout is still read, to report a layout that is wired but
|
|
234
|
+
# switched off.
|
|
235
|
+
# - a String → the app's `app/views/layouts/<name>.html.erb` when it exists
|
|
236
|
+
# (an ejected `ruact` layout included), otherwise the gem's own
|
|
237
|
+
# (`layouts/ruact`, Story 17.0b), otherwise `:missing`. The same order the
|
|
238
|
+
# view paths give Rails: the app's views first, the gem's appended last.
|
|
239
|
+
# The `layouts/` prefix Rails accepts is stripped rather than doubled.
|
|
240
|
+
# - `true` → `application.html.erb`. It cannot resolve a controller's own
|
|
241
|
+
# `layout "admin"`, and every message names the file it read so that
|
|
242
|
+
# limit stays visible.
|
|
243
|
+
#
|
|
244
|
+
# @return [Array(Symbol, Pathname)] `[:shell | :app | :gem | :missing, path]`
|
|
245
|
+
def rendering_layout
|
|
246
|
+
layout = Ruact.config.layout
|
|
247
|
+
application = Rails.root.join("app", "views", "layouts", "application.html.erb")
|
|
248
|
+
return [:shell, application] if layout == false
|
|
249
|
+
return [File.exist?(application) ? :app : :missing, application] unless layout.is_a?(String)
|
|
250
|
+
|
|
251
|
+
name = layout.delete_prefix("layouts/")
|
|
252
|
+
app_file = Rails.root.join("app", "views", "layouts", "#{name}.html.erb")
|
|
253
|
+
return [:app, app_file] if File.exist?(app_file)
|
|
254
|
+
|
|
255
|
+
gem_file = Pathname(Ruact.views_path).join("layouts", "#{name}.html.erb")
|
|
256
|
+
return [:gem, gem_file] if gem_file.exist?
|
|
257
|
+
|
|
258
|
+
[:missing, app_file]
|
|
172
259
|
end
|
|
173
260
|
|
|
174
261
|
# Two independent halves have to line up, and BOTH are silent when wrong:
|
|
@@ -178,39 +265,160 @@ module Ruact
|
|
|
178
265
|
# errors. Reporting each half separately is the point: "add one line" and
|
|
179
266
|
# "flip one setting" are different fixes.
|
|
180
267
|
#
|
|
268
|
+
# Story 17.0b — it reads the layout that RENDERS (see #rendering_layout). It
|
|
269
|
+
# used to read application.html.erb always, which failed a correct fresh
|
|
270
|
+
# install: that one renders through the gem's layout and leaves the app's
|
|
271
|
+
# own untouched, with no React root in it.
|
|
272
|
+
#
|
|
181
273
|
# `Ruact::LayoutSource` is the SHARED definition of "calls the helper" —
|
|
182
274
|
# the runtime and `ruact:install` read it too, so this check cannot drift
|
|
183
275
|
# into disagreeing with what actually happens at render time (it did:
|
|
184
276
|
# a `<%# TODO: add ruact_js_assets %>` comment used to pass here).
|
|
185
277
|
def check_layout
|
|
186
|
-
path =
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
278
|
+
kind, path = rendering_layout
|
|
279
|
+
return [:pass, "ruact pages render through ruact's layout (config.layout = \"#{Ruact::GEM_LAYOUT}\")"] if
|
|
280
|
+
kind == :gem
|
|
281
|
+
return layout_missing_result(path) unless File.exist?(path)
|
|
282
|
+
|
|
283
|
+
missing = missing_layout_pieces(File.read(path))
|
|
284
|
+
return layout_unwired_result(path, missing) unless missing.empty?
|
|
285
|
+
return layout_not_opted_in_result if kind == :shell
|
|
286
|
+
|
|
287
|
+
[:pass, "#{path.basename} owns the document (React root + ruact_js_assets, config.layout on)"]
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
def layout_missing_result(path)
|
|
291
|
+
if Ruact.config.layout.is_a?(String)
|
|
292
|
+
[:fail, "config.layout names #{path.basename}, which exists neither in your app nor in ruact",
|
|
293
|
+
"Create #{path} (with <div id=\"root\"></div>, <%= ruact_js_assets %> and <%= ruact_head_assets %>), " \
|
|
294
|
+
"or set config.layout = \"#{Ruact::GEM_LAYOUT}\" to use the layout ruact ships."]
|
|
295
|
+
else
|
|
296
|
+
[:fail, "React shell missing from #{path.basename}",
|
|
297
|
+
"Create #{path}, or set config.layout = \"#{Ruact::GEM_LAYOUT}\" in config/initializers/ruact.rb " \
|
|
298
|
+
"to render ruact pages through the layout ruact ships."]
|
|
190
299
|
end
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
# Story 17.0b — the CSS half of the asset contract.
|
|
303
|
+
#
|
|
304
|
+
# Vite records client-component stylesheets on the bootstrap manifest entry.
|
|
305
|
+
# If the build produced CSS and the layout never calls `ruact_head_assets`,
|
|
306
|
+
# that CSS is served and never referenced: styling that works in development
|
|
307
|
+
# and silently disappears in production.
|
|
308
|
+
#
|
|
309
|
+
# The decision is MECHANICAL — read the config, the manifest and the layout
|
|
310
|
+
# that renders — and never an inference at render time. Layout auto-detection
|
|
311
|
+
# was removed deliberately (see Ruact::Configuration#layout) and this must not
|
|
312
|
+
# reintroduce it: `LayoutSource.head_wired?` runs through `without_comments`,
|
|
313
|
+
# so a mention inside a comment does not read as wired.
|
|
314
|
+
def check_head_assets
|
|
315
|
+
entry = doctor_manifest_entry
|
|
316
|
+
return head_assets_unreadable_result if entry == :unreadable
|
|
317
|
+
|
|
318
|
+
kind, path = rendering_layout
|
|
319
|
+
return head_assets_unbuilt_result(path) if entry.nil? && kind == :app && !head_wired_file?(path)
|
|
320
|
+
|
|
321
|
+
css = Array(entry && entry["css"])
|
|
322
|
+
return [:pass, "no client-component CSS in the build (nothing to link)"] if css.empty?
|
|
323
|
+
|
|
324
|
+
return [:pass, "client-component CSS present; the built-in shell links it"] if kind == :shell
|
|
325
|
+
return [:pass, "client-component CSS is linked by ruact's layout"] if kind == :gem
|
|
326
|
+
return head_assets_no_layout_result(path) if kind == :missing
|
|
327
|
+
return head_assets_missing_result(css.length, path) unless head_wired_file?(path)
|
|
328
|
+
|
|
329
|
+
[:pass, "client-component CSS is linked (ruact_head_assets in #{path.basename})"]
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
def head_wired_file?(path)
|
|
333
|
+
Ruact::LayoutSource.head_wired?(File.read(path))
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
# No build yet — the normal state in development with the Vite dev server,
|
|
337
|
+
# which injects component CSS itself. A layout of the app's own that never
|
|
338
|
+
# calls the helper passes there and loses that CSS in production, which is
|
|
339
|
+
# exactly what this check exists to catch: a warning, not a pass.
|
|
340
|
+
def head_assets_unbuilt_result(path)
|
|
341
|
+
[:warn,
|
|
342
|
+
"#{path.basename} does not call ruact_head_assets — client-component CSS will not reach production",
|
|
343
|
+
"Add <%= ruact_head_assets %> as the first thing inside <head> in #{path}, above your " \
|
|
344
|
+
"stylesheet_link_tag. There is no production build to check yet, so this is a warning; with " \
|
|
345
|
+
"a build that emits component CSS it is a failure."]
|
|
346
|
+
end
|
|
191
347
|
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
348
|
+
def head_assets_no_layout_result(path)
|
|
349
|
+
[:fail,
|
|
350
|
+
"the build emits client-component CSS but #{path.basename} does not exist " \
|
|
351
|
+
"(Ruact.config.layout points at it)",
|
|
352
|
+
"Ruact.config.layout points at #{path}, which is not there. Create it (or set " \
|
|
353
|
+
"config.layout = \"#{Ruact::GEM_LAYOUT}\") and add <%= ruact_head_assets %> inside its <head> — " \
|
|
354
|
+
"otherwise the stylesheets Vite built for your client components are served and never referenced."]
|
|
355
|
+
end
|
|
196
356
|
|
|
197
|
-
|
|
198
|
-
|
|
357
|
+
# An unreadable manifest is NOT "nothing to link": the same file is parsed at
|
|
358
|
+
# render time, where a parse error raises. Reporting :pass here would mean the
|
|
359
|
+
# doctor is green on an app that 500s.
|
|
360
|
+
def head_assets_unreadable_result
|
|
361
|
+
[:fail,
|
|
362
|
+
"public/assets/.vite/manifest.json exists but is not valid JSON — rebuild your assets",
|
|
363
|
+
"Rebuild your assets (npm run build). ruact reads this file at render time, so a " \
|
|
364
|
+
"truncated or corrupt manifest raises there rather than degrading."]
|
|
365
|
+
end
|
|
199
366
|
|
|
200
|
-
|
|
367
|
+
# The file goes in the MESSAGE, not only in the remediation: `Doctor#run`
|
|
368
|
+
# prints `message` alone, so anything a reader needs in the terminal has to
|
|
369
|
+
# be there. (Story 5.4 found the same asymmetry; the remediation reaches
|
|
370
|
+
# `-- --json` only.)
|
|
371
|
+
def head_assets_missing_result(count, path)
|
|
372
|
+
[:fail,
|
|
373
|
+
"the build emits #{count} client-component stylesheet(s) that nothing links " \
|
|
374
|
+
"— add <%= ruact_head_assets %> to #{path.basename}",
|
|
375
|
+
"Add <%= ruact_head_assets %> as the first thing inside <head> in #{path}, " \
|
|
376
|
+
"ABOVE your stylesheet_link_tag so your own CSS is loaded last and wins ties " \
|
|
377
|
+
"(ruact never edits your layout). " \
|
|
378
|
+
"Without it that CSS is built and served but never referenced - styling that works in " \
|
|
379
|
+
"development and vanishes in production."]
|
|
201
380
|
end
|
|
202
381
|
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
382
|
+
# The bootstrap manifest entry, or nil when there is no build to read. Kept
|
|
383
|
+
# here rather than reaching into the view helper's private lookup.
|
|
384
|
+
def doctor_manifest_entry
|
|
385
|
+
manifest_path = Rails.root.join("public", "assets", ".vite", "manifest.json")
|
|
386
|
+
return nil unless File.exist?(manifest_path)
|
|
387
|
+
|
|
388
|
+
JSON.parse(File.read(manifest_path))[Ruact.bootstrap_virtual_id]
|
|
389
|
+
rescue JSON::ParserError
|
|
390
|
+
:unreadable
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
# The exact lines the layout lacks, named in the MESSAGE (`Doctor#run` prints
|
|
394
|
+
# only the message; the remediation reaches `-- --json` alone).
|
|
395
|
+
def missing_layout_pieces(content)
|
|
396
|
+
missing = []
|
|
397
|
+
missing << %(<div id="root"></div>) unless Ruact::LayoutSource.root?(content)
|
|
398
|
+
missing << "<%= ruact_js_assets %>" unless Ruact::LayoutSource.wired?(content)
|
|
399
|
+
missing
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
def layout_unwired_result(path, missing)
|
|
403
|
+
[:fail, "#{path.basename} is missing #{missing.join(' and ')}",
|
|
404
|
+
"Add #{missing.join(' and ')} to #{path} (the root div in <body>, the helper right after it). " \
|
|
405
|
+
"#{layout_alternative(path)}Without both, ruact renders its built-in shell and your app's CSS never " \
|
|
406
|
+
"reaches the page."]
|
|
407
|
+
end
|
|
408
|
+
|
|
409
|
+
# Suggesting `config.layout = "ruact"` to an app whose file IS the ejected
|
|
410
|
+
# `ruact` layout would be advice to change nothing.
|
|
411
|
+
def layout_alternative(path)
|
|
412
|
+
return "" if path.basename.to_s == "#{Ruact::GEM_LAYOUT}.html.erb"
|
|
413
|
+
|
|
414
|
+
"Or set config.layout = \"#{Ruact::GEM_LAYOUT}\" to use the layout ruact ships. "
|
|
208
415
|
end
|
|
209
416
|
|
|
210
417
|
def layout_not_opted_in_result
|
|
211
418
|
[:warn, "layout is ready but Ruact.config.layout is false",
|
|
212
419
|
"Your layout calls ruact_js_assets, but ruact is still rendering its built-in shell " \
|
|
213
|
-
"(which has
|
|
420
|
+
"(which has none of your stylesheets). Set `config.layout = true` in config/initializers/ruact.rb " \
|
|
421
|
+
"to render through it, or `config.layout = \"#{Ruact::GEM_LAYOUT}\"` for the layout ruact ships."]
|
|
214
422
|
end
|
|
215
423
|
|
|
216
424
|
def check_streaming
|
data/lib/ruact/layout_source.rb
CHANGED
|
@@ -3,10 +3,11 @@
|
|
|
3
3
|
module Ruact
|
|
4
4
|
# Answers one question in ONE place: does this layout actually wire ruact up?
|
|
5
5
|
#
|
|
6
|
-
#
|
|
6
|
+
# Three callers need that answer and must not disagree about it — the runtime
|
|
7
7
|
# (`Ruact::Controller::DocumentRendering`, deciding whether it is safe to
|
|
8
|
-
# render through the host layout)
|
|
9
|
-
# layout
|
|
8
|
+
# render through the host layout), `ruact:install` (reading an app-owned
|
|
9
|
+
# layout to print the lines it lacks — it never writes one) and
|
|
10
|
+
# `ruact:doctor`. When they each carried their own notion of
|
|
10
11
|
# "present", they drifted: the generator skipped a layout as already-migrated
|
|
11
12
|
# on a `<%# TODO: add ruact_js_assets %>` comment, while the runtime read the
|
|
12
13
|
# same layout as unwired. Both were string-matching a NAME where only a CALL
|
|
@@ -20,6 +21,9 @@ module Ruact
|
|
|
20
21
|
# an earlier version of this pattern missed.
|
|
21
22
|
ERB_COMMENT = /<%-?#.*?-?%>/m
|
|
22
23
|
|
|
24
|
+
# Either comment syntax, matched in one left-to-right pass.
|
|
25
|
+
ANY_COMMENT = /<!--.*?-->|<%-?#.*?-?%>/m
|
|
26
|
+
|
|
23
27
|
# An ERB OUTPUT tag calling the helper: `<%= ruact_js_assets %>` and the raw
|
|
24
28
|
# `<%== ... %>` form, with or without arguments or surrounding whitespace.
|
|
25
29
|
# Scanning stops at the tag's own `%>` rather than forbidding `%` outright —
|
|
@@ -28,6 +32,12 @@ module Ruact
|
|
|
28
32
|
# merely follows some other tag.
|
|
29
33
|
ASSETS_CALL = /<%=+(?:(?!%>).)*?\bruact_js_assets\b/m
|
|
30
34
|
|
|
35
|
+
# Story 17.0b — the `<head>` half. A sibling recogniser, not a second
|
|
36
|
+
# mechanism: it runs through the same `without_comments` as the others,
|
|
37
|
+
# because a mention inside a comment reading as "already wired" is the exact
|
|
38
|
+
# failure that got layout auto-detection removed.
|
|
39
|
+
HEAD_ASSETS_CALL = /<%=+(?:(?!%>).)*?\bruact_head_assets\b/m
|
|
40
|
+
|
|
31
41
|
# The React mount target, as an attribute rather than as a substring. The
|
|
32
42
|
# lookbehind is what stops `data-id="root"` (and any other `*-id`) from
|
|
33
43
|
# counting: those are not the mount point, and a document that has one but
|
|
@@ -35,15 +45,17 @@ module Ruact
|
|
|
35
45
|
# valid HTML and is accepted.
|
|
36
46
|
ROOT_ATTRIBUTE = /(?<![-\w])id\s*=\s*(?:"root"|'root'|root(?=[\s>]))/
|
|
37
47
|
|
|
38
|
-
# The whole element, for a generator that needs something to inject AFTER.
|
|
39
|
-
ROOT_ELEMENT = %r{<div\s[^>]*#{ROOT_ATTRIBUTE.source}[^>]*>\s*</div>}
|
|
40
|
-
|
|
41
48
|
class << self
|
|
42
49
|
# Does this ERB source actually CALL `ruact_js_assets`?
|
|
43
50
|
def wired?(source)
|
|
44
51
|
without_comments(source).match?(ASSETS_CALL)
|
|
45
52
|
end
|
|
46
53
|
|
|
54
|
+
# Whether the layout calls `ruact_head_assets` (Story 17.0b).
|
|
55
|
+
def head_wired?(source)
|
|
56
|
+
without_comments(source).match?(HEAD_ASSETS_CALL)
|
|
57
|
+
end
|
|
58
|
+
|
|
47
59
|
# Does this markup carry a React mount target? Used on RENDERED HTML by
|
|
48
60
|
# the runtime and on ERB source by the generator; the attribute shape is
|
|
49
61
|
# the same either way.
|
|
@@ -51,8 +63,18 @@ module Ruact
|
|
|
51
63
|
markup.to_s.match?(ROOT_ATTRIBUTE)
|
|
52
64
|
end
|
|
53
65
|
|
|
66
|
+
# Strips BOTH comment syntaxes a layout can hide a call in.
|
|
67
|
+
#
|
|
68
|
+
# This used to strip only the ERB form, which left `<!-- <%= ruact_js_assets %> -->`
|
|
69
|
+
# reading as a live call: the generator skipped the migration and the doctor
|
|
70
|
+
# passed, on a layout emitting nothing. Story 17.0b found it on the new
|
|
71
|
+
# `head_wired?` and it was equally true of `wired?`.
|
|
72
|
+
#
|
|
73
|
+
# ONE alternation rather than two passes, so a left-to-right scan closes
|
|
74
|
+
# whichever comment opened first — running the HTML pass first let a `<!--`
|
|
75
|
+
# living inside `<%# ... %>` swallow through to a later `-->`.
|
|
54
76
|
def without_comments(source)
|
|
55
|
-
source.to_s.gsub(
|
|
77
|
+
source.to_s.gsub(ANY_COMMENT, "")
|
|
56
78
|
end
|
|
57
79
|
end
|
|
58
80
|
end
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "uri"
|
|
4
|
+
|
|
5
|
+
module Ruact
|
|
6
|
+
# Story 17.0f (FR117) — the document belongs to whoever rendered it.
|
|
7
|
+
#
|
|
8
|
+
# The ruact router intercepts every same-origin link and form: it cannot know,
|
|
9
|
+
# from the browser, whether the destination is a ruact page. Asked with
|
|
10
|
+
# `Accept: text/x-component`, an ordinary Rails action answers 200 HTML, which
|
|
11
|
+
# the router cannot render — a dead click, silently (spike 2026-09-12, S2). And
|
|
12
|
+
# a form that crossed to an ordinary action had already RUN it by the time the
|
|
13
|
+
# router saw the HTML, so it could not be retried (S9b).
|
|
14
|
+
#
|
|
15
|
+
# The server can know, before anything runs. For a request the router sent
|
|
16
|
+
# (`Ruact-Request: 1`, a header only the router sends), {Middleware} asks
|
|
17
|
+
# {Classifier} whether the route it would reach is a ruact page. If not, it
|
|
18
|
+
# answers `Ruact-Boundary: native` WITHOUT calling the app, and the router
|
|
19
|
+
# hands the navigation — or the form submission — to the browser. The action
|
|
20
|
+
# then runs exactly once, natively, and its real response (a 422 with the
|
|
21
|
+
# validation errors included) reaches the user.
|
|
22
|
+
#
|
|
23
|
+
# Measured in the 2026-09-16 spike (playgrounds/nav-islands): ~0.1 ms median
|
|
24
|
+
# per router request; every fixture below held.
|
|
25
|
+
module NavigationBoundary
|
|
26
|
+
# Response header the router reads.
|
|
27
|
+
HEADER = "ruact-boundary"
|
|
28
|
+
# Its only value today: "not a ruact page — let the browser do it".
|
|
29
|
+
NATIVE = "native"
|
|
30
|
+
|
|
31
|
+
# The Rack middleware the Railtie installs. Remove it with
|
|
32
|
+
# `config.middleware.delete Ruact::NavigationBoundary::Middleware`.
|
|
33
|
+
class Middleware
|
|
34
|
+
def initialize(app, classifier: Classifier.new)
|
|
35
|
+
@app = app
|
|
36
|
+
@classifier = classifier
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def call(env)
|
|
40
|
+
return @app.call(env) unless env["HTTP_RUACT_REQUEST"] == "1"
|
|
41
|
+
return native_response if @classifier.classify(env) == :native
|
|
42
|
+
|
|
43
|
+
@app.call(env)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
# `Vary`, because the same URL answers differently with and without the
|
|
49
|
+
# router's header; `no-store`, because this answer is about routing, not
|
|
50
|
+
# content, and must never be served from a cache to a browser navigation.
|
|
51
|
+
#
|
|
52
|
+
# Built through Rack's own header class so a later middleware looking up
|
|
53
|
+
# `Cache-Control` finds this one on Rack 2 (Rails 7.0) as well as Rack 3.
|
|
54
|
+
def native_response
|
|
55
|
+
[200, native_headers, []]
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def native_headers
|
|
59
|
+
headers = defined?(Rack::Headers) ? Rack::Headers.new : Rack::Utils::HeaderHash.new
|
|
60
|
+
headers[HEADER] = NATIVE
|
|
61
|
+
headers["content-type"] = "text/plain; charset=utf-8"
|
|
62
|
+
headers["cache-control"] = "no-store"
|
|
63
|
+
headers["vary"] = "Ruact-Request"
|
|
64
|
+
headers
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Decides, from the route table alone, where a request would land.
|
|
69
|
+
#
|
|
70
|
+
# Mirrors what Rails' own router does on `serve` — not just `recognize`:
|
|
71
|
+
# lambda and object constraints are evaluated IN ROUTE ORDER, with the same
|
|
72
|
+
# cascade to the next matching route (`recognize` alone skips them; they
|
|
73
|
+
# live on `Mapper::Constraints` and only run on `serve`). Descends into
|
|
74
|
+
# mounted engines. Nothing it calls executes an action.
|
|
75
|
+
#
|
|
76
|
+
# Ties break towards letting the request through: a wrong :native would
|
|
77
|
+
# skip ruact on a ruact page, a wrong :ruact would bring the dead click back,
|
|
78
|
+
# but :pass only costs what ruact cost before this existed.
|
|
79
|
+
class Classifier
|
|
80
|
+
# @param router [ActionDispatch::Journey::Router, nil] defaults to the
|
|
81
|
+
# application's, resolved lazily (routes reload in development)
|
|
82
|
+
def initialize(router = nil)
|
|
83
|
+
@router = router
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# @param env [Hash] the Rack env
|
|
87
|
+
# @return [Symbol] `:ruact`, `:native`, or `:pass` (could not tell)
|
|
88
|
+
def classify(env)
|
|
89
|
+
request = ActionDispatch::Request.new(env.dup)
|
|
90
|
+
verdict_in(@router || application_router, request) || :pass
|
|
91
|
+
rescue Ruact::ConfigurationError => e
|
|
92
|
+
# A misdeclared `ruact_pages` is the app's bug to see, not a routing
|
|
93
|
+
# doubt: say so where it shows, then let the request through.
|
|
94
|
+
Rails.logger&.warn("[ruact] #{e.message}")
|
|
95
|
+
:pass
|
|
96
|
+
rescue StandardError => e
|
|
97
|
+
Rails.logger&.debug do
|
|
98
|
+
"[ruact] navigation boundary could not classify #{env['PATH_INFO']}: #{e.class}: #{e.message}"
|
|
99
|
+
end
|
|
100
|
+
:pass
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
private
|
|
104
|
+
|
|
105
|
+
# Rails 8 routes load lazily in development and test: the route set loads
|
|
106
|
+
# itself on `call`, but not when its `router` is read — and this runs
|
|
107
|
+
# BEFORE the app is called, so the first request after a boot could see
|
|
108
|
+
# an empty table.
|
|
109
|
+
def application_router
|
|
110
|
+
app = Rails.application
|
|
111
|
+
app.reload_routes_unless_loaded if app.respond_to?(:reload_routes_unless_loaded)
|
|
112
|
+
app.routes.router
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# @return [Symbol, nil] nil when nothing in THIS router answers — the
|
|
116
|
+
# caller keeps scanning, the way Rails cascades past an engine (or a Rack
|
|
117
|
+
# app at "/") whose own routes do not match
|
|
118
|
+
def verdict_in(router, request, in_engine: false)
|
|
119
|
+
root_app = false
|
|
120
|
+
router.recognize(request) do |route, params|
|
|
121
|
+
app = route.app
|
|
122
|
+
if app.is_a?(ActionDispatch::Routing::Mapper::Constraints)
|
|
123
|
+
request.path_parameters = params
|
|
124
|
+
next unless app.matches?(request) # `serve` would cascade to the next route
|
|
125
|
+
|
|
126
|
+
app = app.app
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
if mounted_rack_app?(route, app)
|
|
130
|
+
# A Rack app mounted at "/" (Grape, Sinatra) passes on what it does
|
|
131
|
+
# not know with `X-Cascade: pass`, and Rails moves on: keep looking.
|
|
132
|
+
# Mounted anywhere else, it owns its prefix — Sidekiq at /sidekiq is
|
|
133
|
+
# not reclassified by a catch-all route drawn after it.
|
|
134
|
+
if route.path.spec.to_s == "/"
|
|
135
|
+
root_app = true
|
|
136
|
+
next
|
|
137
|
+
end
|
|
138
|
+
return :native
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
verdict = verdict_for(route, app, request, params, in_engine: in_engine)
|
|
142
|
+
return verdict if verdict # an engine with no matching route cascades: keep looking
|
|
143
|
+
end
|
|
144
|
+
root_app ? :native : nil
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def mounted_rack_app?(route, app)
|
|
148
|
+
!route.dispatcher? && !app.is_a?(ActionDispatch::Routing::Redirect) && !engine?(app)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def engine?(app)
|
|
152
|
+
app.respond_to?(:routes) && app.routes.respond_to?(:router)
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def verdict_for(route, app, request, params, in_engine:)
|
|
156
|
+
return action_verdict(request, params[:controller], params[:action], in_engine) if route.dispatcher?
|
|
157
|
+
# A route that redirects answers the router itself; there is no "no
|
|
158
|
+
# match" for it, so an unclassifiable one passes rather than cascading.
|
|
159
|
+
return redirect_verdict(app, request, params) || :pass if app.is_a?(ActionDispatch::Routing::Redirect)
|
|
160
|
+
|
|
161
|
+
# Inside `recognize`'s block the request's path_info is already relative
|
|
162
|
+
# to the mount point, which is what the engine's own router expects.
|
|
163
|
+
verdict_in(app.routes.router, request, in_engine: true)
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# Same origin: the fetch follows the redirect and the target is
|
|
167
|
+
# classified when it arrives. Another origin: the fetch cannot follow it
|
|
168
|
+
# (CORS), so the browser has to — native.
|
|
169
|
+
#
|
|
170
|
+
# A 307/308 on a form re-sends the POST to the target; if that target is
|
|
171
|
+
# not ruact, the router could only answer with a GET there and the POST
|
|
172
|
+
# would never run. The browser's own submit follows it correctly: native.
|
|
173
|
+
#
|
|
174
|
+
# A `redirect { |params, req| … }` BLOCK is application code — it may
|
|
175
|
+
# query the database — and is never run here: it passes through.
|
|
176
|
+
EVALUATED_REDIRECTS = %w[ActionDispatch::Routing::PathRedirect ActionDispatch::Routing::OptionRedirect].freeze
|
|
177
|
+
private_constant :EVALUATED_REDIRECTS
|
|
178
|
+
|
|
179
|
+
def redirect_verdict(redirect, request, params)
|
|
180
|
+
return :native if [307, 308].include?(redirect.status) && !(request.get? || request.head?)
|
|
181
|
+
return :pass unless EVALUATED_REDIRECTS.include?(redirect.class.name)
|
|
182
|
+
|
|
183
|
+
target = URI.parse(redirect.path(params, request).to_s)
|
|
184
|
+
same_origin = target.host == request.host && (target.port || request.port) == request.port
|
|
185
|
+
return :pass if target.host.nil? || same_origin
|
|
186
|
+
|
|
187
|
+
:native
|
|
188
|
+
rescue StandardError
|
|
189
|
+
:pass
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# GET/HEAD: ruact renders the page only when `default_render` would —
|
|
193
|
+
# the same class-level predicate, never a copy of it. That is what keeps a
|
|
194
|
+
# Devise-like controller (it inherits the app's ruact controller, its
|
|
195
|
+
# templates live in the engine) native.
|
|
196
|
+
#
|
|
197
|
+
# Anything else: including the concern is enough — unless the controller
|
|
198
|
+
# declares its pages (`ruact_pages`, Story 17.0g), and then only for the
|
|
199
|
+
# declared actions. A `create` has no template, and answers the router
|
|
200
|
+
# with a Flight redirect row (Ruact::Controller#redirect_to) — the Story
|
|
201
|
+
# 13.3 redirect-back has to stay in place, not become a full page load.
|
|
202
|
+
#
|
|
203
|
+
# Inside a mounted ENGINE, a non-GET is native even when its controller
|
|
204
|
+
# inherits the app's ruact controller: that is Devise's shape, and its
|
|
205
|
+
# `destroy` answers a non-navigational request with a bare 204 — the user
|
|
206
|
+
# is signed out and the router has nothing to render. An engine's actions
|
|
207
|
+
# speak the engine's protocol, not ruact's. Its GET pages still count when
|
|
208
|
+
# the APP provides the template (the way apps override engine views).
|
|
209
|
+
#
|
|
210
|
+
# The same holds for a controller a GEM defines even when its routes are
|
|
211
|
+
# drawn into the app's own table — `devise_for` mounts no engine, and
|
|
212
|
+
# `Devise::SessionsController#destroy` is the sign-out the rule exists
|
|
213
|
+
# for. So a non-GET is ruact only when the controller is the app's own:
|
|
214
|
+
# defined under `Rails.root/app`.
|
|
215
|
+
def action_verdict(request, controller, action, in_engine)
|
|
216
|
+
klass = "#{controller.to_s.camelize}Controller".safe_constantize
|
|
217
|
+
return :pass unless klass.is_a?(Class)
|
|
218
|
+
return :native unless klass.include?(Ruact::Controller)
|
|
219
|
+
return non_get_verdict(klass, action, in_engine) unless request.get? || request.head?
|
|
220
|
+
|
|
221
|
+
klass.ruact_page?(action) ? :ruact : :native
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# Story 17.0g — a controller that DECLARED its pages (`ruact_pages`) is ruact
|
|
225
|
+
# only for those: a `create` whose `new` is a plain Rails page re-renders
|
|
226
|
+
# plain HTML on a validation error, which the router could not show.
|
|
227
|
+
def non_get_verdict(klass, action, in_engine)
|
|
228
|
+
return :native if in_engine || !app_owned?(klass)
|
|
229
|
+
return :native if klass.__ruact_pages && !klass.ruact_page_action?(action)
|
|
230
|
+
|
|
231
|
+
:ruact
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def app_owned?(klass)
|
|
235
|
+
file = Object.const_source_location(klass.name)&.first
|
|
236
|
+
!file.nil? && File.expand_path(file).start_with?("#{Rails.root.join('app')}/")
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
end
|
data/lib/ruact/railtie.rb
CHANGED
|
@@ -16,6 +16,36 @@ module Ruact
|
|
|
16
16
|
require_relative "routing"
|
|
17
17
|
end
|
|
18
18
|
|
|
19
|
+
# Story 17.0b — make the gem's own layout (`layouts/ruact`) findable by name.
|
|
20
|
+
#
|
|
21
|
+
# APPEND, not prepend: the gem's views go BEHIND the app's and every
|
|
22
|
+
# engine's, so an app that ejects the layout (`rails generate ruact:layout`)
|
|
23
|
+
# wins by view-path order and `layouts/application` always stays the app's.
|
|
24
|
+
# Shadowing the app's layout was measured and rejected (spike 2026-09-10:
|
|
25
|
+
# every page loaded React and lost the app's <title>), and the prepend
|
|
26
|
+
# version failed SILENTLY from this very hook — which is why the order this
|
|
27
|
+
# produces is asserted by a spec that boots a real app through this Railtie
|
|
28
|
+
# (spec/ruact/gem_layout_boot_spec.rb), in development and production.
|
|
29
|
+
#
|
|
30
|
+
# `respond_to?` mirrors Rails' own `add_view_paths`: the hook also fires for
|
|
31
|
+
# controller classes that carry no view paths.
|
|
32
|
+
initializer "ruact.view_paths" do
|
|
33
|
+
ActiveSupport.on_load(:action_controller) do
|
|
34
|
+
append_view_path(Ruact.views_path) if respond_to?(:append_view_path)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Story 17.0f (FR117) — answer the ruact router before any action runs when
|
|
39
|
+
# the route it asked for is not a ruact page. See Ruact::NavigationBoundary.
|
|
40
|
+
#
|
|
41
|
+
# `use` appends: the middleware sits after Rack::MethodOverride when the app
|
|
42
|
+
# has it, so a form's `_method=delete` is already DELETE when the verb is
|
|
43
|
+
# read, and an app without it (API-shaped) does not fail to boot the way
|
|
44
|
+
# `insert_after Rack::MethodOverride` would.
|
|
45
|
+
initializer "ruact.navigation_boundary" do |app|
|
|
46
|
+
app.config.middleware.use Ruact::NavigationBoundary::Middleware
|
|
47
|
+
end
|
|
48
|
+
|
|
19
49
|
rake_tasks { load File.expand_path("../tasks/ruact.rake", __dir__) }
|
|
20
50
|
|
|
21
51
|
# Load the client manifest at boot (and on each code reload in development).
|