ruact 0.0.11 → 0.0.13
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 +147 -48
- data/CONTRIBUTING.md +233 -0
- data/README.md +18 -8
- data/RELEASING.md +125 -139
- data/lib/generators/ruact/install/install_generator.rb +305 -126
- data/lib/generators/ruact/install/templates/AGENTS.md.tt +14 -13
- data/lib/generators/ruact/install/templates/initializer.rb.tt +29 -7
- 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/templates/controller.rb.tt +5 -3
- data/lib/ruact/configuration.rb +65 -13
- 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/packaging.rb +68 -0
- data/lib/ruact/railtie.rb +30 -0
- 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/flight-client.test.mjs +321 -0
- data/vendor/javascript/vite-plugin-ruact/package-lock.json +11 -0
- data/vendor/javascript/vite-plugin-ruact/package.json +1 -0
- data/vendor/javascript/vite-plugin-ruact/ruact-router.test.mjs +433 -0
- data/vendor/javascript/vite-plugin-ruact/runtime/flight-client.js +14 -3
- data/vendor/javascript/vite-plugin-ruact/runtime/ruact-router.js +170 -7
- metadata +12 -107
- data/.codecov.yml +0 -31
- data/.github/workflows/ci.yml +0 -284
- data/.github/workflows/server-functions-bench.yml +0 -54
- data/.rubocop.yml +0 -107
- data/.rubocop_todo.yml +0 -63
- data/Rakefile +0 -10
- data/bench/server_functions_dispatch_bench.rb +0 -276
- data/bench/server_functions_dispatch_bench.results.md +0 -150
- data/docs/internal/README.md +0 -9
- data/docs/internal/decisions/server-functions-api.md +0 -2236
- data/spec/benchmarks/baseline.json +0 -12
- data/spec/benchmarks/render_pipeline_benchmark_spec.rb +0 -109
- data/spec/fixtures/flight/README.md +0 -136
- data/spec/fixtures/flight/array.txt +0 -1
- data/spec/fixtures/flight/as_json_object.txt +0 -2
- data/spec/fixtures/flight/bigint.txt +0 -1
- data/spec/fixtures/flight/boolean_false.txt +0 -1
- data/spec/fixtures/flight/boolean_true.txt +0 -1
- data/spec/fixtures/flight/client_component_with_props.txt +0 -2
- data/spec/fixtures/flight/client_reference.txt +0 -2
- data/spec/fixtures/flight/hash.txt +0 -1
- data/spec/fixtures/flight/infinity.txt +0 -1
- data/spec/fixtures/flight/nan.txt +0 -1
- data/spec/fixtures/flight/negative_infinity.txt +0 -1
- data/spec/fixtures/flight/nil.txt +0 -1
- data/spec/fixtures/flight/number_float.txt +0 -1
- data/spec/fixtures/flight/number_integer.txt +0 -1
- data/spec/fixtures/flight/react_element_no_props.txt +0 -1
- data/spec/fixtures/flight/redirect_row.txt +0 -1
- data/spec/fixtures/flight/serializable_object.txt +0 -2
- data/spec/fixtures/flight/string_basic.txt +0 -1
- data/spec/fixtures/flight/string_dollar_escape.txt +0 -1
- data/spec/fixtures/flight/undefined.txt +0 -1
- data/spec/fixtures/readme/children-error.html.erb +0 -3
- data/spec/fixtures/readme/children-error.txt +0 -1
- data/spec/fixtures/story_7_9_views/controller_request_spec_support/demo/show.html.erb +0 -3
- data/spec/fixtures/story_7_9_views/controller_request_spec_support/errors_demo/new.html.erb +0 -3
- data/spec/fixtures/story_7_9_views/controller_request_spec_support/exploding_layout_demo/show.html.erb +0 -3
- data/spec/fixtures/story_7_9_views/controller_request_spec_support/ghost_layout_demo/show.html.erb +0 -3
- data/spec/fixtures/story_7_9_views/controller_request_spec_support/layout_demo/show.html.erb +0 -3
- data/spec/fixtures/story_7_9_views/controller_request_spec_support/rootless_layout_demo/show.html.erb +0 -3
- data/spec/fixtures/story_7_9_views/controller_request_spec_support/unwired_layout_demo/show.html.erb +0 -3
- data/spec/fixtures/story_7_9_views/layouts/bare_host.html.erb +0 -16
- data/spec/fixtures/story_7_9_views/layouts/exploding_host.html.erb +0 -24
- data/spec/fixtures/story_7_9_views/layouts/rootless_host.html.erb +0 -15
- data/spec/fixtures/story_7_9_views/layouts/ruact_host.html.erb +0 -17
- data/spec/readme_demo_message_spec.rb +0 -67
- data/spec/readme_spec.rb +0 -282
- data/spec/ruact/client_manifest_spec.rb +0 -270
- data/spec/ruact/component_contract_spec.rb +0 -119
- data/spec/ruact/configuration_spec.rb +0 -518
- data/spec/ruact/controller_request_spec.rb +0 -671
- data/spec/ruact/controller_spec.rb +0 -343
- data/spec/ruact/doctor_spec.rb +0 -769
- data/spec/ruact/erb_preprocessor_hook_spec.rb +0 -55
- data/spec/ruact/erb_preprocessor_spec.rb +0 -361
- data/spec/ruact/errors_spec.rb +0 -93
- data/spec/ruact/flight/renderer_spec.rb +0 -133
- data/spec/ruact/flight/serializer_spec.rb +0 -494
- data/spec/ruact/html_converter_spec.rb +0 -375
- data/spec/ruact/install_generator_spec.rb +0 -1549
- data/spec/ruact/layout_source_spec.rb +0 -108
- data/spec/ruact/manifest_resolver_spec.rb +0 -174
- data/spec/ruact/query_request_spec.rb +0 -706
- data/spec/ruact/query_spec.rb +0 -105
- data/spec/ruact/railtie_spec.rb +0 -155
- data/spec/ruact/render_context_spec.rb +0 -58
- data/spec/ruact/render_pipeline_concurrency_spec.rb +0 -78
- data/spec/ruact/render_pipeline_spec.rb +0 -928
- data/spec/ruact/scaffold_generator_spec.rb +0 -1849
- data/spec/ruact/serializable_spec.rb +0 -179
- data/spec/ruact/server_bucket_request_spec.rb +0 -785
- data/spec/ruact/server_function_name_spec.rb +0 -53
- data/spec/ruact/server_functions/backtrace_cleaner_spec.rb +0 -63
- data/spec/ruact/server_functions/bucket_two_payload_spec.rb +0 -200
- data/spec/ruact/server_functions/codegen_spec.rb +0 -397
- data/spec/ruact/server_functions/error_payload_spec.rb +0 -222
- data/spec/ruact/server_functions/error_suggestion_spec.rb +0 -79
- data/spec/ruact/server_functions/introspection_spec.rb +0 -135
- data/spec/ruact/server_functions/name_bridge_spec.rb +0 -212
- data/spec/ruact/server_functions/query_context_spec.rb +0 -72
- data/spec/ruact/server_functions/query_source_spec.rb +0 -193
- data/spec/ruact/server_functions/railtie_integration_spec.rb +0 -215
- data/spec/ruact/server_functions/rake_spec.rb +0 -86
- data/spec/ruact/server_functions/route_source_spec.rb +0 -202
- data/spec/ruact/server_functions/snapshot_spec.rb +0 -96
- data/spec/ruact/server_functions/snapshot_writer_spec.rb +0 -71
- data/spec/ruact/server_rescue_request_spec.rb +0 -416
- data/spec/ruact/server_spec.rb +0 -179
- data/spec/ruact/server_upload_request_spec.rb +0 -311
- data/spec/ruact/signed_references_spec.rb +0 -164
- data/spec/ruact/string_distance_spec.rb +0 -38
- data/spec/ruact/tasks_json_introspection_spec.rb +0 -141
- data/spec/ruact/testing/have_ruact_component_spec.rb +0 -170
- data/spec/ruact/testing/no_production_load_spec.rb +0 -41
- data/spec/ruact/validation_errors_spec.rb +0 -116
- data/spec/ruact/view_helper_spec.rb +0 -131
- data/spec/spec_helper.rb +0 -77
- data/spec/support/fixtures/pixel.png +0 -0
- data/spec/support/flight_wire_parser.rb +0 -21
- data/spec/support/flight_wire_parser_spec.rb +0 -93
- data/spec/support/matchers/flight_fixture_matcher.rb +0 -130
- data/spec/support/matchers/flight_fixture_matcher_spec.rb +0 -250
- data/spec/support/rails_stub.rb +0 -115
data/lib/ruact/controller.rb
CHANGED
|
@@ -6,15 +6,21 @@ require "uri"
|
|
|
6
6
|
require_relative "view_helper"
|
|
7
7
|
require_relative "validation_errors_collector"
|
|
8
8
|
require_relative "controller/document_rendering"
|
|
9
|
+
require_relative "controller/pages"
|
|
10
|
+
require_relative "controller/page_rendering"
|
|
9
11
|
|
|
10
12
|
module Ruact
|
|
11
|
-
# Include in
|
|
13
|
+
# Include in the controllers whose pages render through ruact (island mode,
|
|
14
|
+
# the install default — Story 17.0g), or in ApplicationController to render
|
|
15
|
+
# the whole app through it (`rails generate ruact:install --app`).
|
|
12
16
|
#
|
|
13
|
-
# class
|
|
17
|
+
# class ProductsController < ApplicationController
|
|
14
18
|
# include Ruact::Controller
|
|
15
19
|
# end
|
|
16
20
|
#
|
|
17
|
-
# After that, any action whose view is a .html.erb file
|
|
21
|
+
# After that, any action whose view is a .html.erb file (narrowed with
|
|
22
|
+
# `ruact_pages only:` / `except:`, see Ruact::Controller::Pages) will
|
|
23
|
+
# automatically:
|
|
18
24
|
# - Respond to text/x-component requests with a raw Flight payload
|
|
19
25
|
# - Respond to text/html requests with an HTML shell + inline Flight payload
|
|
20
26
|
module Controller
|
|
@@ -31,6 +37,40 @@ module Ruact
|
|
|
31
37
|
# Who owns the `<head>` — the host app's layout, with ruact's built-in shell
|
|
32
38
|
# as the un-migrated fallback. See Ruact::Controller::DocumentRendering.
|
|
33
39
|
include Ruact::Controller::DocumentRendering
|
|
40
|
+
# Which actions are ruact pages — the whole controller, or the ones it
|
|
41
|
+
# declares with `ruact_pages` (Story 17.0g). See Ruact::Controller::Pages.
|
|
42
|
+
include Ruact::Controller::Pages
|
|
43
|
+
# An explicit `render` of a ruact page goes through ruact (Story 17.0i).
|
|
44
|
+
# See Ruact::Controller::PageRendering.
|
|
45
|
+
include Ruact::Controller::PageRendering
|
|
46
|
+
|
|
47
|
+
# Story 17.0f — "is this action a ruact PAGE?", answered at CLASS level so the
|
|
48
|
+
# navigation boundary (Ruact::NavigationBoundary) can ask it before any action
|
|
49
|
+
# runs, and so it and `default_render` read ONE definition rather than two
|
|
50
|
+
# that could drift (the 2026-09-16 spike's classifier kept its own copy).
|
|
51
|
+
class_methods do
|
|
52
|
+
# The template `default_render` looks for — the literal
|
|
53
|
+
# `Rails.root/app/views/<controller_path>/<action>.html.erb`, not the view
|
|
54
|
+
# path lookup, which is what keeps an engine's own templates (Devise's)
|
|
55
|
+
# out. `controller_path`, not the class name with "_controller" cut out
|
|
56
|
+
# of it: `RemoteControllersController` used to look in
|
|
57
|
+
# `remotes_controller/`.
|
|
58
|
+
#
|
|
59
|
+
# @param action [String, Symbol]
|
|
60
|
+
# @return [Pathname]
|
|
61
|
+
def ruact_template_path(action)
|
|
62
|
+
Rails.root.join("app", "views", controller_path, "#{action}.html.erb")
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# A GET to `action` renders through ruact: it is a page, and it either
|
|
66
|
+
# has its template or was declared by name (it renders another one).
|
|
67
|
+
#
|
|
68
|
+
# @param action [String, Symbol]
|
|
69
|
+
# @return [Boolean]
|
|
70
|
+
def ruact_page?(action)
|
|
71
|
+
ruact_page_action?(action) && (ruact_declared_page?(action) || File.exist?(ruact_template_path(action)))
|
|
72
|
+
end
|
|
73
|
+
end
|
|
34
74
|
|
|
35
75
|
private
|
|
36
76
|
|
|
@@ -38,7 +78,7 @@ module Ruact
|
|
|
38
78
|
# controller a public instance method is exposed as a routable action. Demote
|
|
39
79
|
# the mixed-in helper methods so they are never callable as actions (they are
|
|
40
80
|
# only ever invoked internally by `ruact_html_shell`).
|
|
41
|
-
private :ruact_js_assets, :__ruact_component__
|
|
81
|
+
private :ruact_js_assets, :ruact_head_assets, :__ruact_component__
|
|
42
82
|
|
|
43
83
|
# Resolves the manifest for this render. In PRODUCTION this is the boot-time
|
|
44
84
|
# cached +Ruact.manifest+ (set by Railtie#config.to_prepare) — no per-request
|
|
@@ -102,11 +142,26 @@ module Ruact
|
|
|
102
142
|
# +template+: logical template name (e.g. "posts/custom"), or nil to use
|
|
103
143
|
# the current action's default template.
|
|
104
144
|
# +locals+: hash of local variables to pass to the template.
|
|
105
|
-
|
|
145
|
+
# +status+: the response status, as `render` takes it (Story 17.0i) —
|
|
146
|
+
# `:unprocessable_entity`, `422`… — in the HTML document and the
|
|
147
|
+
# Flight payload alike. Omitted, the response keeps the status it
|
|
148
|
+
# has (200).
|
|
149
|
+
def ruact_render(template: nil, locals: {}, status: nil)
|
|
150
|
+
__ruact_render(template: template, locals: locals, status: status)
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# `ruact_render`, plus the lookup details an explicit `render` carries
|
|
154
|
+
# (`variants:`, `locale:`, `formats:`, `handlers:` — Story 17.0i review R3):
|
|
155
|
+
# the page renders with the variant or locale Rails would have used.
|
|
156
|
+
def __ruact_render(template: nil, locals: {}, status: nil, details: {})
|
|
106
157
|
# Story 13.3 (FR98, AC4) — seed the collector from a redirect-back flash
|
|
107
158
|
# before the view evaluates, so `errors={ruact_errors}` surfaces surviving
|
|
108
159
|
# errors (no-op on a plain render — `ruact_errors` then returns `{}`).
|
|
109
160
|
__ruact_read_errors_from_flash
|
|
161
|
+
# Resolved the way `render status:` resolves it (Rack::Utils.status_code),
|
|
162
|
+
# and set BEFORE anything is written: the streamed Flight response sends
|
|
163
|
+
# its headers on the first row.
|
|
164
|
+
self.status = status if status
|
|
110
165
|
|
|
111
166
|
pipeline = RenderPipeline.new(ruact_manifest, controller_path: controller_path, logger: logger)
|
|
112
167
|
streaming = ruact_request? && self.class.ancestors.include?(ActionController::Live)
|
|
@@ -126,7 +181,7 @@ module Ruact
|
|
|
126
181
|
# (NFR8). See Story 7.9 / Bug 7.8-B.
|
|
127
182
|
with_render_context do |render_context|
|
|
128
183
|
opts = template ? { template: template } : { action: action_name }
|
|
129
|
-
html = render_to_string(opts.merge(layout: false, locals: locals))
|
|
184
|
+
html = render_to_string(opts.merge(details).merge(layout: false, locals: locals))
|
|
130
185
|
emit_ruact_response(pipeline, html, render_context, streaming: streaming)
|
|
131
186
|
end
|
|
132
187
|
end
|
|
@@ -188,7 +243,8 @@ module Ruact
|
|
|
188
243
|
end
|
|
189
244
|
|
|
190
245
|
# Overrides Rails redirect_to for RSC requests: emits a Flight redirect row
|
|
191
|
-
# (`0
|
|
246
|
+
# (`0:` followed by a JSON object with `redirectUrl` and `redirectType: "push"`)
|
|
247
|
+
# instead of a 302 response.
|
|
192
248
|
# This allows the client-side router to handle the navigation without an extra
|
|
193
249
|
# HTTP round-trip. Non-RSC requests and external-origin redirects fall through
|
|
194
250
|
# to the standard Rails implementation.
|
|
@@ -217,6 +273,10 @@ module Ruact
|
|
|
217
273
|
# Story 13.3 (FR98, AC4) — the Inertia "redirect back with errors" path:
|
|
218
274
|
# stash any `ruact_errors`-registered errors in flash so they survive this
|
|
219
275
|
# Flight redirect and re-render as an `errors` prop (no-op when untouched).
|
|
276
|
+
# Only here: the next request is the router's Flight GET, which renders
|
|
277
|
+
# no layout. A plain 302 (a form outside `ruact_pages`, Story 17.0g) is
|
|
278
|
+
# followed by a full page whose layout may print every flash entry — that
|
|
279
|
+
# action is plain Rails and re-renders its errors the Rails way.
|
|
220
280
|
__ruact_stash_errors_in_flash
|
|
221
281
|
|
|
222
282
|
# Story 15.5 (FR109) — mark the Flight-redirect sub-shape for the dev log.
|
|
@@ -231,14 +291,21 @@ module Ruact
|
|
|
231
291
|
request.headers["Ruact-Request"] == "1"
|
|
232
292
|
end
|
|
233
293
|
|
|
294
|
+
# Rails keeps `redirect_to` PUBLIC, and this module's body is private from
|
|
295
|
+
# `private` above: the `responders` gem (Devise's `respond_with`) calls
|
|
296
|
+
# `controller.redirect_to` with an explicit receiver (Story 17.0i). Public
|
|
297
|
+
# is not routable here — Rails excludes its own public methods from
|
|
298
|
+
# `action_methods`.
|
|
299
|
+
public :redirect_to
|
|
300
|
+
|
|
301
|
+
# Implicit rendering needs the action's OWN template: an action declared a
|
|
302
|
+
# page without one renders itself (`ruact_render(template: …)`).
|
|
234
303
|
def ruact_template_exists?
|
|
235
|
-
File.exist?(default_template_path)
|
|
304
|
+
self.class.ruact_page_action?(action_name) && File.exist?(default_template_path)
|
|
236
305
|
end
|
|
237
306
|
|
|
238
307
|
def default_template_path
|
|
239
|
-
|
|
240
|
-
controller = self.class.name.underscore.sub("_controller", "")
|
|
241
|
-
Rails.root.join("app", "views", controller, "#{action}.html.erb")
|
|
308
|
+
self.class.ruact_template_path(action_name)
|
|
242
309
|
end
|
|
243
310
|
end
|
|
244
311
|
end
|
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
|