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.
Files changed (137) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +147 -48
  3. data/CONTRIBUTING.md +233 -0
  4. data/README.md +18 -8
  5. data/RELEASING.md +125 -139
  6. data/lib/generators/ruact/install/install_generator.rb +305 -126
  7. data/lib/generators/ruact/install/templates/AGENTS.md.tt +14 -13
  8. data/lib/generators/ruact/install/templates/initializer.rb.tt +29 -7
  9. data/lib/generators/ruact/install/templates/tsconfig.json.tt +3 -0
  10. data/lib/generators/ruact/layout/layout_generator.rb +52 -0
  11. data/lib/generators/ruact/scaffold/templates/controller.rb.tt +5 -3
  12. data/lib/ruact/configuration.rb +65 -13
  13. data/lib/ruact/controller/document_rendering.rb +72 -16
  14. data/lib/ruact/controller/page_rendering.rb +134 -0
  15. data/lib/ruact/controller/pages.rb +116 -0
  16. data/lib/ruact/controller.rb +78 -11
  17. data/lib/ruact/doctor.rb +233 -25
  18. data/lib/ruact/layout_source.rb +29 -7
  19. data/lib/ruact/navigation_boundary.rb +240 -0
  20. data/lib/ruact/packaging.rb +68 -0
  21. data/lib/ruact/railtie.rb +30 -0
  22. data/lib/ruact/server.rb +10 -1
  23. data/lib/ruact/version.rb +1 -1
  24. data/lib/ruact/view_helper.rb +158 -1
  25. data/lib/ruact/views/layouts/ruact.html.erb +32 -0
  26. data/lib/ruact.rb +29 -0
  27. data/vendor/javascript/vite-plugin-ruact/flight-client.test.mjs +321 -0
  28. data/vendor/javascript/vite-plugin-ruact/package-lock.json +11 -0
  29. data/vendor/javascript/vite-plugin-ruact/package.json +1 -0
  30. data/vendor/javascript/vite-plugin-ruact/ruact-router.test.mjs +433 -0
  31. data/vendor/javascript/vite-plugin-ruact/runtime/flight-client.js +14 -3
  32. data/vendor/javascript/vite-plugin-ruact/runtime/ruact-router.js +170 -7
  33. metadata +12 -107
  34. data/.codecov.yml +0 -31
  35. data/.github/workflows/ci.yml +0 -284
  36. data/.github/workflows/server-functions-bench.yml +0 -54
  37. data/.rubocop.yml +0 -107
  38. data/.rubocop_todo.yml +0 -63
  39. data/Rakefile +0 -10
  40. data/bench/server_functions_dispatch_bench.rb +0 -276
  41. data/bench/server_functions_dispatch_bench.results.md +0 -150
  42. data/docs/internal/README.md +0 -9
  43. data/docs/internal/decisions/server-functions-api.md +0 -2236
  44. data/spec/benchmarks/baseline.json +0 -12
  45. data/spec/benchmarks/render_pipeline_benchmark_spec.rb +0 -109
  46. data/spec/fixtures/flight/README.md +0 -136
  47. data/spec/fixtures/flight/array.txt +0 -1
  48. data/spec/fixtures/flight/as_json_object.txt +0 -2
  49. data/spec/fixtures/flight/bigint.txt +0 -1
  50. data/spec/fixtures/flight/boolean_false.txt +0 -1
  51. data/spec/fixtures/flight/boolean_true.txt +0 -1
  52. data/spec/fixtures/flight/client_component_with_props.txt +0 -2
  53. data/spec/fixtures/flight/client_reference.txt +0 -2
  54. data/spec/fixtures/flight/hash.txt +0 -1
  55. data/spec/fixtures/flight/infinity.txt +0 -1
  56. data/spec/fixtures/flight/nan.txt +0 -1
  57. data/spec/fixtures/flight/negative_infinity.txt +0 -1
  58. data/spec/fixtures/flight/nil.txt +0 -1
  59. data/spec/fixtures/flight/number_float.txt +0 -1
  60. data/spec/fixtures/flight/number_integer.txt +0 -1
  61. data/spec/fixtures/flight/react_element_no_props.txt +0 -1
  62. data/spec/fixtures/flight/redirect_row.txt +0 -1
  63. data/spec/fixtures/flight/serializable_object.txt +0 -2
  64. data/spec/fixtures/flight/string_basic.txt +0 -1
  65. data/spec/fixtures/flight/string_dollar_escape.txt +0 -1
  66. data/spec/fixtures/flight/undefined.txt +0 -1
  67. data/spec/fixtures/readme/children-error.html.erb +0 -3
  68. data/spec/fixtures/readme/children-error.txt +0 -1
  69. data/spec/fixtures/story_7_9_views/controller_request_spec_support/demo/show.html.erb +0 -3
  70. data/spec/fixtures/story_7_9_views/controller_request_spec_support/errors_demo/new.html.erb +0 -3
  71. data/spec/fixtures/story_7_9_views/controller_request_spec_support/exploding_layout_demo/show.html.erb +0 -3
  72. data/spec/fixtures/story_7_9_views/controller_request_spec_support/ghost_layout_demo/show.html.erb +0 -3
  73. data/spec/fixtures/story_7_9_views/controller_request_spec_support/layout_demo/show.html.erb +0 -3
  74. data/spec/fixtures/story_7_9_views/controller_request_spec_support/rootless_layout_demo/show.html.erb +0 -3
  75. data/spec/fixtures/story_7_9_views/controller_request_spec_support/unwired_layout_demo/show.html.erb +0 -3
  76. data/spec/fixtures/story_7_9_views/layouts/bare_host.html.erb +0 -16
  77. data/spec/fixtures/story_7_9_views/layouts/exploding_host.html.erb +0 -24
  78. data/spec/fixtures/story_7_9_views/layouts/rootless_host.html.erb +0 -15
  79. data/spec/fixtures/story_7_9_views/layouts/ruact_host.html.erb +0 -17
  80. data/spec/readme_demo_message_spec.rb +0 -67
  81. data/spec/readme_spec.rb +0 -282
  82. data/spec/ruact/client_manifest_spec.rb +0 -270
  83. data/spec/ruact/component_contract_spec.rb +0 -119
  84. data/spec/ruact/configuration_spec.rb +0 -518
  85. data/spec/ruact/controller_request_spec.rb +0 -671
  86. data/spec/ruact/controller_spec.rb +0 -343
  87. data/spec/ruact/doctor_spec.rb +0 -769
  88. data/spec/ruact/erb_preprocessor_hook_spec.rb +0 -55
  89. data/spec/ruact/erb_preprocessor_spec.rb +0 -361
  90. data/spec/ruact/errors_spec.rb +0 -93
  91. data/spec/ruact/flight/renderer_spec.rb +0 -133
  92. data/spec/ruact/flight/serializer_spec.rb +0 -494
  93. data/spec/ruact/html_converter_spec.rb +0 -375
  94. data/spec/ruact/install_generator_spec.rb +0 -1549
  95. data/spec/ruact/layout_source_spec.rb +0 -108
  96. data/spec/ruact/manifest_resolver_spec.rb +0 -174
  97. data/spec/ruact/query_request_spec.rb +0 -706
  98. data/spec/ruact/query_spec.rb +0 -105
  99. data/spec/ruact/railtie_spec.rb +0 -155
  100. data/spec/ruact/render_context_spec.rb +0 -58
  101. data/spec/ruact/render_pipeline_concurrency_spec.rb +0 -78
  102. data/spec/ruact/render_pipeline_spec.rb +0 -928
  103. data/spec/ruact/scaffold_generator_spec.rb +0 -1849
  104. data/spec/ruact/serializable_spec.rb +0 -179
  105. data/spec/ruact/server_bucket_request_spec.rb +0 -785
  106. data/spec/ruact/server_function_name_spec.rb +0 -53
  107. data/spec/ruact/server_functions/backtrace_cleaner_spec.rb +0 -63
  108. data/spec/ruact/server_functions/bucket_two_payload_spec.rb +0 -200
  109. data/spec/ruact/server_functions/codegen_spec.rb +0 -397
  110. data/spec/ruact/server_functions/error_payload_spec.rb +0 -222
  111. data/spec/ruact/server_functions/error_suggestion_spec.rb +0 -79
  112. data/spec/ruact/server_functions/introspection_spec.rb +0 -135
  113. data/spec/ruact/server_functions/name_bridge_spec.rb +0 -212
  114. data/spec/ruact/server_functions/query_context_spec.rb +0 -72
  115. data/spec/ruact/server_functions/query_source_spec.rb +0 -193
  116. data/spec/ruact/server_functions/railtie_integration_spec.rb +0 -215
  117. data/spec/ruact/server_functions/rake_spec.rb +0 -86
  118. data/spec/ruact/server_functions/route_source_spec.rb +0 -202
  119. data/spec/ruact/server_functions/snapshot_spec.rb +0 -96
  120. data/spec/ruact/server_functions/snapshot_writer_spec.rb +0 -71
  121. data/spec/ruact/server_rescue_request_spec.rb +0 -416
  122. data/spec/ruact/server_spec.rb +0 -179
  123. data/spec/ruact/server_upload_request_spec.rb +0 -311
  124. data/spec/ruact/signed_references_spec.rb +0 -164
  125. data/spec/ruact/string_distance_spec.rb +0 -38
  126. data/spec/ruact/tasks_json_introspection_spec.rb +0 -141
  127. data/spec/ruact/testing/have_ruact_component_spec.rb +0 -170
  128. data/spec/ruact/testing/no_production_load_spec.rb +0 -41
  129. data/spec/ruact/validation_errors_spec.rb +0 -116
  130. data/spec/ruact/view_helper_spec.rb +0 -131
  131. data/spec/spec_helper.rb +0 -77
  132. data/spec/support/fixtures/pixel.png +0 -0
  133. data/spec/support/flight_wire_parser.rb +0 -21
  134. data/spec/support/flight_wire_parser_spec.rb +0 -93
  135. data/spec/support/matchers/flight_fixture_matcher.rb +0 -130
  136. data/spec/support/matchers/flight_fixture_matcher_spec.rb +0 -250
  137. data/spec/support/rails_stub.rb +0 -115
@@ -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 ApplicationController to enable RSC rendering.
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 ApplicationController < ActionController::Base
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 will automatically:
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
- def ruact_render(template: nil, locals: {})
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:{"redirectUrl":"...","redirectType":"push"}`) instead of a 302 response.
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
- action = action_name
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 flight_middleware].freeze
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 generate ruact:install to fix configuration issues" unless passed
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
- path = Rails.root.join("app", "controllers", "application_controller.rb")
166
- if File.exist?(path) && File.read(path).include?("Ruact::Controller")
167
- [:pass, "Ruact::Controller included in ApplicationController"]
168
- else
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 = Rails.root.join("app", "views", "layouts", "application.html.erb")
187
- unless File.exist?(path)
188
- return [:fail, "React shell missing from application.html.erb",
189
- "Run rails generate ruact:install to add the React shell to application.html.erb."]
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
- content = File.read(path)
193
- has_root = Ruact::LayoutSource.root?(content)
194
- has_assets = Ruact::LayoutSource.wired?(content)
195
- opted_in = Ruact.config.layout != false
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
- return layout_unwired_result unless has_root && has_assets
198
- return layout_not_opted_in_result unless opted_in
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
- [:pass, "layout owns the document (React root + ruact_js_assets, config.layout on)"]
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
- def layout_unwired_result
204
- [:fail, "layout is missing the React root and/or the ruact_js_assets call",
205
- "Add <%= ruact_js_assets %> next to <div id=\"root\"></div> in " \
206
- "app/views/layouts/application.html.erb (or re-run rails generate ruact:install). " \
207
- "Without both, ruact renders its built-in shell and your app's CSS never reaches the page."]
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 no stylesheet). Set `config.layout = true` in config/initializers/ruact.rb."]
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
@@ -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
- # Two callers need that answer and must not disagree about it — the runtime
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) and `ruact:install` (deciding whether the
9
- # layout still needs migrating). When they each carried their own notion of
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(ERB_COMMENT, "")
77
+ source.to_s.gsub(ANY_COMMENT, "")
56
78
  end
57
79
  end
58
80
  end