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
@@ -11,11 +11,14 @@ module Ruact
11
11
  #
12
12
  # Performs the following actions:
13
13
  # 1. Creates config/initializers/ruact.rb
14
- # 2. Injects `include Ruact::Controller` into ApplicationController
15
- # 3. Injects the React root div AND `ruact_js_assets` into
16
- # app/views/layouts/application.html.erb, so the app's own layout owns
17
- # the document (and its `<head>` — stylesheets, fonts, meta — reaches a
18
- # ruact page). See Ruact::Configuration#layout.
14
+ # 2. Changes no controller (island mode, Story 17.0g) — unless `--app`, which
15
+ # injects `include Ruact::Controller` into ApplicationController
16
+ # 3. Edits NO layout of the app. ruact pages render through the layout the
17
+ # gem ships (`config.layout = "ruact"`, Story 17.0b), which links the
18
+ # client-component CSS and the app stylesheets named in
19
+ # `config.layout_stylesheets`. An app that already chose its own layout
20
+ # (`config.layout = true` or another name) gets the missing lines PRINTED,
21
+ # never written. See Ruact::Configuration#layout.
19
22
  # 4. Creates app/javascript/components/.keep
20
23
  # 5. Creates vite.config.js (or shows manual instructions if one exists)
21
24
  # 6. Creates package.json (react/react-dom/vite/@vitejs/plugin-react) so a
@@ -64,6 +67,15 @@ module Ruact
64
67
  # the real shadcn CLI) and then PRINTS the two `npx shadcn` commands
65
68
  # rather than running them — they hit the network and `shadcn init` is
66
69
  # interactive, so automating them is neither safe nor possible.
70
+ # Story 17.0g (FR116) — the install changes no controller by default
71
+ # (island mode): a page becomes ruact when its controller includes
72
+ # `Ruact::Controller`. `--app` is the explicit way to the other mode.
73
+ class_option :app,
74
+ type: :boolean,
75
+ default: false,
76
+ desc: "Whole-app mode: include Ruact::Controller in ApplicationController, so EVERY action " \
77
+ "with an .html.erb template renders through ruact (and your own layout owns the document)"
78
+
67
79
  class_option :shadcn,
68
80
  type: :boolean,
69
81
  default: false,
@@ -74,7 +86,7 @@ module Ruact
74
86
  # offered a bad choice on the one path that matters most — migrating an
75
87
  # existing app: overwrite and lose every setting the app had
76
88
  # (`strict_serialization`, `manifest_path`, the SGID defaults…), or skip
77
- # and end up half-migrated, with the layout edited but `config.layout`
89
+ # and end up half-migrated, with `config.layout`
78
90
  # still off and nothing saying so except `ruact:doctor`.
79
91
  def create_initializer
80
92
  path = Pathname(destination_root).join("config/initializers/ruact.rb")
@@ -83,79 +95,61 @@ module Ruact
83
95
  inject_layout_setting(path)
84
96
  end
85
97
 
98
+ # Story 17.0g (FR116) — ONLY under `--app`. Including the concern in
99
+ # ApplicationController makes every action with a template a ruact page:
100
+ # installed into an existing app to try one screen, that converted all of
101
+ # them (a Turbo Frame came back as a React element). An app that already
102
+ # has it — installed before this — keeps it: removing a line of the app's
103
+ # is not the install's call, so it says what mode the app is in instead.
86
104
  def inject_controller_concern
87
105
  controller_file = "app/controllers/application_controller.rb"
88
- return unless File.exist?(Pathname(destination_root).join(controller_file))
106
+ path = Pathname(destination_root).join(controller_file)
107
+ unless path.exist?
108
+ if app?
109
+ say_status "notice", "no #{controller_file} — add `include Ruact::Controller` to the controller your " \
110
+ "pages inherit from", :yellow
111
+ end
112
+ return
113
+ end
89
114
 
90
- content = File.read(Pathname(destination_root).join(controller_file))
91
- if content.include?("Ruact::Controller")
92
- say_status "skip", "Ruact::Controller already included in ApplicationController", :yellow
115
+ if path.read.match?(Ruact::CONTROLLER_INCLUDE)
116
+ advise_whole_app_install unless app?
93
117
  return
94
118
  end
119
+ return unless app?
95
120
 
96
121
  inject_into_file controller_file,
97
122
  "\n include Ruact::Controller\n",
98
123
  after: /class ApplicationController.*\n/
99
124
  end
100
125
 
101
- # The layout owns the document: `stylesheet_link_tag`, favicons, fonts and
102
- # every `<head>`-writing gem only reach a ruact page because Rails' own
103
- # layout renders it (see `Ruact::Configuration#layout`). That requires TWO
104
- # things in the layout — the React root, and `ruact_js_assets` to emit the
105
- # bootstrap entry + this render's Flight payload. The other half of the
106
- # opt-in is `config.layout = true`, which the generated initializer
107
- # carries — a layout with the helper but the setting off (or the reverse)
108
- # keeps rendering through ruact's built-in, CSS-less shell.
109
- def inject_layout_shell
110
- layout_file = "app/views/layouts/application.html.erb"
111
- return unless File.exist?(Pathname(destination_root).join(layout_file))
112
-
113
- content = File.read(Pathname(destination_root).join(layout_file))
114
-
115
- # A CALL, not a mention: `<%# TODO: add ruact_js_assets %>` used to read
116
- # as "already present" here and skip the migration, leaving the app on
117
- # ruact's CSS-less shell with the generator reporting success. Shared
118
- # with the runtime so both agree on what "migrated" means.
119
- # BOTH halves, not just the helper. A layout carrying `ruact_js_assets`
120
- # with no `<div id="root"></div>` was skipped as "already present" — and
121
- # since this generator also turns `config.layout` on, that app then
122
- # raised at render time on a document React could not mount into.
123
- if Ruact::LayoutSource.wired?(content) && Ruact::LayoutSource.root?(content)
124
- say_status "skip", "ruact root + assets already present in layout", :yellow
126
+ # Story 17.0b — the install no longer writes into any layout of the app.
127
+ # An app on the gem's layout needs nothing. An app that already chose its
128
+ # OWN layout (`config.layout = true`, or a name that is not "ruact") has
129
+ # to call three helpers there; this reads that layout — never writes it —
130
+ # and prints each missing line with where it goes. Writing it was the
131
+ # previous design, and matching HTML+ERB with a regex took three review
132
+ # rounds without a correct version.
133
+ def advise_app_layout
134
+ layout = configured_app_layout
135
+ return unless layout
136
+
137
+ path = Pathname(destination_root).join("app/views/layouts/#{layout}.html.erb")
138
+ unless path.exist?
139
+ say_status "notice", "config.layout names #{path.basename}, which does not exist", :yellow
125
140
  return
126
141
  end
127
142
 
128
- # Migration path for an app installed before the layout owned the
129
- # document: the root is already there, only the asset call is missing.
130
- #
131
- # The anchor matches the ROOT DIV itself rather than the marker-then-div
132
- # pair, and tolerates the ways a real layout is written — single or
133
- # double quotes, extra attributes, any attribute order, CRLF, and the
134
- # marker on the same line. The earlier anchor required the exact emitted
135
- # formatting, so a hand-edited layout silently matched nothing. The
136
- # attribute boundary in `ROOT_ELEMENT` is what keeps `data-id="root"`
137
- # from being mistaken for the mount point.
138
- if Ruact::LayoutSource.root?(content)
139
- return migrate_layout(layout_file,
140
- "\n <%= ruact_js_assets %>",
141
- after: Ruact::LayoutSource::ROOT_ELEMENT,
142
- success: "added ruact_js_assets to the existing layout root")
143
- end
144
-
145
- # The mirror case: the helper is there but the mount target is not, so
146
- # the root goes in just BEFORE the call (React needs the node in the
147
- # document, and keeping the pair adjacent matches what a fresh install
148
- # writes). Injecting the whole block instead would duplicate the helper.
149
- if Ruact::LayoutSource.wired?(content)
150
- return migrate_layout(layout_file,
151
- "<%# ruact: root %>\n <div id=\"root\"></div>\n ",
152
- before: Ruact::LayoutSource::ASSETS_CALL,
153
- success: "added the React root div next to the existing ruact_js_assets")
154
- end
143
+ missing = missing_layout_lines(path.read)
144
+ return if missing.empty?
155
145
 
156
- inject_into_file layout_file,
157
- "\n <%# ruact: root %>\n <div id=\"root\"></div>\n <%= ruact_js_assets %>\n",
158
- before: " </body>"
146
+ say_status "notice", "your layout renders ruact pages — add these lines to #{path.basename}:", :yellow
147
+ say ""
148
+ missing.each { |where, line| say " #{where}: #{line}" }
149
+ say ""
150
+ say " ruact does not edit your layout. `rails ruact:doctor` checks these lines,"
151
+ say " or set `config.layout = \"ruact\"` to use the layout ruact ships."
152
+ say ""
159
153
  end
160
154
 
161
155
  # `--shadcn` only. Two files, both of them things shadcn's CLI checks for
@@ -400,7 +394,9 @@ module Ruact
400
394
 
401
395
  show_shadcn_next_steps if shadcn?
402
396
 
403
- say "\nThen add <MyComponent /> to any ERB view.\n"
397
+ say "\nThen add <MyComponent /> to any ERB view."
398
+ say layout_summary
399
+ say ""
404
400
  say "Note: re-run this generator after updating the ruact gem to refresh"
405
401
  say "the bundled Vite plugin path in vite.config.js."
406
402
  say ""
@@ -408,53 +404,185 @@ module Ruact
408
404
 
409
405
  private
410
406
 
411
- # `inject_into_file` prints "File unchanged!" and carries on when its
412
- # anchor misses, so reporting success without checking would be a lie —
413
- # and the app would keep rendering through ruact's CSS-less shell with no
414
- # clue why. Compare the file around the call and only claim what happened.
415
- def migrate_layout(layout_file, content, success:, **anchor)
416
- path = Pathname(destination_root).join(layout_file)
417
- before = path.read
418
- inject_into_file layout_file, content, **anchor
419
-
420
- if path.read == before
421
- warn_layout_migration_failed
407
+ # What the post-install message says about the layout — true for the
408
+ # setting actually in effect, not just for a fresh install.
409
+ def layout_summary
410
+ [adoption_summary, document_summary].join("\n")
411
+ end
412
+
413
+ def adoption_summary
414
+ if whole_app?
415
+ "Whole-app mode: every action with an .html.erb template renders through ruact."
422
416
  else
423
- say_status "update", success, :green
417
+ "Island mode: nothing changed in your controllers. A page renders through ruact when its\n" \
418
+ "controller has `include Ruact::Controller` (narrow it with `ruact_pages only: %i[show]`),\n" \
419
+ "or run rails generate ruact:scaffold."
420
+ end
421
+ end
422
+
423
+ def document_summary
424
+ own = configured_app_layout
425
+ return "ruact pages render through your #{own} layout (see any lines printed above)." if own
426
+
427
+ value = configured_layout_value
428
+ if value.nil? || value == "false"
429
+ return "ruact pages render through its built-in shell (config.layout is #{value || 'not set'})."
430
+ end
431
+
432
+ "ruact pages render through ruact's own layout — your layouts are untouched.\n" \
433
+ "To customize it: rails generate ruact:layout"
434
+ end
435
+
436
+ # Story 17.0b — what `layouts/ruact` should link, decided ONCE, here, and
437
+ # written into the initializer where the app can see and change it. Never
438
+ # inferred at render time. `:app` is what `rails new` 8.x links and means
439
+ # "every stylesheet" under Propshaft; under Sprockets it would be a file
440
+ # called app.css, which does not exist — an AssetNotFound on the first
441
+ # render — so a Sprockets app gets its conventional "application".
442
+ #
443
+ # Propshaft learned `:app` in 0.9.0 (checked against the released gems:
444
+ # 0.8.0's helper has no `when :app`, 0.9.0's does). Below that, or under
445
+ # Sprockets, the conventional "application" is what exists. With neither
446
+ # pipeline there is nothing to link: `:app` would become a 404 on
447
+ # /stylesheets/app.css.
448
+ PROPSHAFT_STYLESHEETS = "[:app]"
449
+ SPROCKETS_STYLESHEETS = '["application"]'
450
+ NO_PIPELINE_STYLESHEETS = "[]"
451
+ private_constant :PROPSHAFT_STYLESHEETS, :SPROCKETS_STYLESHEETS, :NO_PIPELINE_STYLESHEETS
452
+
453
+ def detected_layout_stylesheets
454
+ @detected_layout_stylesheets ||= begin
455
+ gems = locked_gems
456
+ propshaft = gems[/^ {4}propshaft \((\d+)\.(\d+)/] && [Regexp.last_match(1).to_i, Regexp.last_match(2).to_i]
457
+ if propshaft
458
+ (propshaft <=> [0, 9]) >= 0 ? PROPSHAFT_STYLESHEETS : SPROCKETS_STYLESHEETS
459
+ elsif gems.match?(/^ {4}sprockets-rails \(/)
460
+ SPROCKETS_STYLESHEETS
461
+ elsif gems.empty?
462
+ PROPSHAFT_STYLESHEETS # no lockfile to read: the default, as `rails new` would have
463
+ else
464
+ NO_PIPELINE_STYLESHEETS
465
+ end
466
+ end
467
+ end
468
+
469
+ def locked_gems
470
+ %w[Gemfile.lock gems.locked].each do |name|
471
+ lock = Pathname(destination_root).join(name)
472
+ return lock.read if lock.exist?
473
+ end
474
+ ""
475
+ end
476
+
477
+ # The layout the app chose for ruact pages, when it is the app's OWN one:
478
+ # "application" for `config.layout = true`, the name for any other String
479
+ # but "ruact" (the gem's). nil when ruact's layout, the built-in shell, or
480
+ # no initializer is in play. Reads the initializer the app wrote, once, at
481
+ # install time — the same thing a reader of that file would conclude.
482
+ def configured_app_layout
483
+ value = configured_layout_value
484
+ return "application" if value == "true"
485
+
486
+ quoted = value && value[/\A["'](.+)["']\z/, 1]
487
+ name = quoted&.delete_prefix("layouts/")
488
+ return nil if name.nil? || name == Ruact::GEM_LAYOUT
489
+
490
+ name
491
+ end
492
+
493
+ # The raw right-hand side of the LAST `…layout =` in the initializer — the
494
+ # one Ruby leaves in effect — or nil. Anything that is not a literal
495
+ # (`ENV.fetch(...)`, a ternary) comes back as-is and is treated as "not
496
+ # something to advise about", never guessed at.
497
+ def configured_layout_value
498
+ path = Pathname(destination_root).join("config/initializers/ruact.rb")
499
+ return nil unless path.exist?
500
+
501
+ layout_assignments(path.read).last
502
+ end
503
+
504
+ # The right-hand side of every `<receiver>.layout =` outside a comment
505
+ # line, in order — the one-line `Ruact.configure { |c| c.layout = true }`
506
+ # the configuration docs show included. `layout_stylesheets =` never
507
+ # matches: `.layout` must be followed by `=`.
508
+ def layout_assignments(source)
509
+ source.each_line.reject { |line| line.lstrip.start_with?("#") }.flat_map do |line|
510
+ line.scan(/\b\w+\.layout\s*=(?!=)\s*([^\s#;}]+)/).flatten
424
511
  end
425
512
  end
426
513
 
514
+ # Each call the app's own layout is missing, with where it goes. Read
515
+ # through Ruact::LayoutSource, so a mention inside a comment is not a call
516
+ # — the same definition the doctor and the runtime use.
517
+ def missing_layout_lines(content)
518
+ missing = []
519
+ missing << ["inside <head>, above your stylesheets", "<%= ruact_head_assets %>"] unless
520
+ Ruact::LayoutSource.head_wired?(content)
521
+ missing << ["inside <body>", %(<div id="root"></div>)] unless Ruact::LayoutSource.root?(content)
522
+ missing << ["right after the root div", "<%= ruact_js_assets %>"] unless Ruact::LayoutSource.wired?(content)
523
+ missing
524
+ end
525
+
526
+ # The block may name its variable anything (`|c|`, `|ruact|`): the setting
527
+ # is found whatever the receiver, and the snippet is written with the
528
+ # block's OWN variable — writing `config.` into a `|c|` block raised
529
+ # NameError at boot. One pattern both finds the block and anchors the
530
+ # injection, so the two cannot disagree (a trailing comment or CRLF used
531
+ # to pass the first check and miss the second, while the generator still
532
+ # reported success); and the file is compared before success is claimed.
427
533
  def inject_layout_setting(path)
428
534
  content = path.read
429
535
 
430
- if content.match?(/^\s*config\.layout\s*=/)
536
+ if layout_assignments(content).any?
431
537
  say_status "skip", "config.layout already set in config/initializers/ruact.rb", :yellow
538
+ warn_whole_app_on_gem_layout if app? && configured_layout_value == %("#{Ruact::GEM_LAYOUT}")
432
539
  return
433
540
  end
434
541
 
435
- unless content.match?(/Ruact\.configure\s+do\s*\|(\w+)\|/)
436
- warn_initializer_not_injectable
437
- return
438
- end
542
+ blocks = content.scan(CONFIGURE_BLOCK)
543
+ # Exactly one multi-line block, or nothing: Thor's injection edits EVERY
544
+ # match, and a one-line block has no line of its own to write after.
545
+ return warn_initializer_not_injectable(content) unless blocks.length == 1
546
+
547
+ inject_into_file "config/initializers/ruact.rb", layout_setting_snippet(blocks.first.first),
548
+ after: CONFIGURE_BLOCK
549
+ return warn_initializer_not_injectable(content) if path.read == content && !options[:pretend]
439
550
 
440
- inject_into_file "config/initializers/ruact.rb",
441
- LAYOUT_SETTING_SNIPPET,
442
- after: /Ruact\.configure\s+do\s*\|\w+\|\n/
443
- say_status "update", "set config.layout = true (your layout renders ruact pages)", :green
551
+ if whole_app?
552
+ say_status "update", "set config.layout = true (your own layout renders every page)", :green
553
+ else
554
+ say_status "update", "set config.layout = \"ruact\" (ruact pages render through ruact's layout)", :green
555
+ end
444
556
  end
445
557
 
446
- # The compiled stylesheet still has to be REQUESTED. Rails 8's default
447
- # layout links `stylesheet_link_tag :app`, which Propshaft expands over
448
- # every stylesheet on the load path — so `app/assets/builds/tailwind.css`
449
- # is picked up with no further wiring (verified against a generated app:
450
- # the rendered `<head>` carries `/assets/tailwind-<digest>.css`).
558
+ # The compiled Tailwind stylesheet still has to be REQUESTED, by whichever
559
+ # layout renders ruact pages.
451
560
  #
452
- # A layout that instead links stylesheets BY NAME never asks for it, and
453
- # the failure is silent: Tailwind builds fine, Propshaft serves it fine,
454
- # and the page is simply unstyled. Warn rather than edit — which
455
- # stylesheets a layout links is the app's business.
561
+ # On ruact's own layout that is `config.layout_stylesheets`: `[:app]`
562
+ # (Propshaft) expands over every stylesheet on the load path, so
563
+ # `app/assets/builds/tailwind.css` is picked up with no further wiring. A
564
+ # Sprockets app gets `["application"]` instead, which does not name it.
565
+ #
566
+ # On the app's own layout (`config.layout = true` or another name) it is
567
+ # that layout's `stylesheet_link_tag`: `:app` picks the build up, a link
568
+ # BY NAME does not. Either way the failure is silent — Tailwind builds,
569
+ # the file is served, the page is unstyled — so warn rather than edit.
456
570
  def warn_unless_layout_links_builds
457
- layout_path = Pathname(destination_root).join("app/views/layouts/application.html.erb")
571
+ layout = configured_app_layout
572
+ return warn_unless_app_layout_links_builds(layout) if layout
573
+ return unless detected_layout_stylesheets == SPROCKETS_STYLESHEETS
574
+
575
+ say_status "notice", "add the Tailwind build to the stylesheets ruact's layout links:", :yellow
576
+ say ""
577
+ say " config.layout_stylesheets = [\"application\", \"tailwind\"]"
578
+ say ""
579
+ say " in config/initializers/ruact.rb — `[\"application\"]` alone would leave"
580
+ say " app/assets/builds/tailwind.css built, served and never linked."
581
+ say ""
582
+ end
583
+
584
+ def warn_unless_app_layout_links_builds(layout)
585
+ layout_path = Pathname(destination_root).join("app/views/layouts/#{layout}.html.erb")
458
586
  return unless layout_path.exist?
459
587
 
460
588
  content = layout_path.read
@@ -662,55 +790,106 @@ module Ruact
662
790
  # Story 14.6 — a valid, lowercase npm "name" for the generated package.json,
663
791
  # derived from the app directory. npm names must be lowercase and contain
664
792
  # only URL-safe characters; anything else collapses to a hyphen.
665
- # Kept verbatim in step with the `initializer.rb.tt` template's own
666
- # `config.layout` block, so a migrated app and a fresh one end up reading
667
- # the same thing.
668
- LAYOUT_SETTING_SNIPPET = <<~RUBY
669
- # Render ruact pages through this app's own layout, so the document `<head>`
670
- # is yours: stylesheets, favicons, fonts and any gem that writes into
671
- # `<head>` reach a ruact page. Requires the layout to call
672
- # `<%= ruact_js_assets %>` (this generator adds it next to the React root).
673
- config.layout = true
674
-
675
- RUBY
676
- private_constant :LAYOUT_SETTING_SNIPPET
793
+ # Kept in step with the `initializer.rb.tt` template's own `config.layout`
794
+ # block, so a migrated app and a fresh one end up reading the same thing.
795
+ # A method, not a constant, because the stylesheet list depends on the
796
+ # app's asset pipeline (see #detected_layout_stylesheets).
797
+ def layout_setting_snippet(variable = "config")
798
+ return app_layout_setting_snippet(variable) if whole_app?
799
+
800
+ <<~RUBY
801
+ # Render ruact pages through the layout ruact ships (layouts/ruact). It links
802
+ # the CSS your client components import, then your stylesheets below, and it
803
+ # edits none of your layouts. To change more of its <head> than the
804
+ # stylesheets, copy it into your app: `rails generate ruact:layout`.
805
+ #{variable}.layout = "ruact"
806
+ #{variable}.layout_stylesheets = #{detected_layout_stylesheets}
807
+
808
+ RUBY
809
+ end
810
+
811
+ # Whole-app mode (`--app`): the document is the app's own layout, which
812
+ # then needs the helpers the install prints (see #advise_app_layout).
813
+ def app_layout_setting_snippet(variable)
814
+ <<~RUBY
815
+ # Whole-app mode: every page renders through your own layout, which needs
816
+ # <%= ruact_head_assets %> in <head> and <%= ruact_js_assets %> next to a
817
+ # <div id="root"></div> (rails ruact:doctor checks).
818
+ #{variable}.layout = true
819
+
820
+ RUBY
821
+ end
677
822
 
678
823
  # The initializer exists but is not the shape we know how to edit (someone
679
824
  # rewrote it, or wrapped the configure call). Never guess at it — say what
680
825
  # to add, so the app cannot end up half-migrated in silence.
681
- def warn_initializer_not_injectable
826
+ # The lines are written with the block's OWN variable when one can be read —
827
+ # `config.` pasted into a `{ |c| … }` block is the NameError at boot this
828
+ # generator used to cause.
829
+ def warn_initializer_not_injectable(content = "")
830
+ variable = content[/Ruact\.configure\s*(?:do|\{)\s*\|(\w+)\|/, 1] || "config"
682
831
  say_status "skip", "could not find the Ruact.configure block to update", :red
683
832
  say ""
684
- say " Add this line inside `Ruact.configure` in config/initializers/ruact.rb:"
833
+ say " Add these lines inside `Ruact.configure` in config/initializers/ruact.rb:"
685
834
  say ""
686
- say " config.layout = true"
835
+ say " #{variable}.layout = \"ruact\""
836
+ say " #{variable}.layout_stylesheets = #{detected_layout_stylesheets}"
687
837
  say ""
688
- say " Without it ruact keeps using its built-in shell, which carries no"
689
- say " stylesheet — your app's CSS will not reach a ruact-rendered page."
838
+ say " Without them ruact keeps using its built-in shell, which carries none"
839
+ say " of your stylesheets — your app's CSS will not reach a ruact-rendered page."
690
840
  say ""
691
841
  end
692
842
 
693
- def shadcn?
694
- options[:shadcn]
843
+ # The configure block's opening line, whatever its variable is called: at
844
+ # the start of a line (so a commented-out example above it does not match),
845
+ # followed by nothing but whitespace or a comment (so a one-line block —
846
+ # body and `end` on the same line — does not match and get code written
847
+ # after its `end`, outside it), CRLF included.
848
+ CONFIGURE_BLOCK = /^[ \t]*Ruact\.configure\s+do\s*\|(\w+)\|[ \t]*(?:#[^\r\n]*)?\r?\n/
849
+ private_constant :CONFIGURE_BLOCK
850
+
851
+ def app?
852
+ options[:app]
853
+ end
854
+
855
+ def application_controller_includes_ruact?
856
+ path = Pathname(destination_root).join("app/controllers/application_controller.rb")
857
+ path.exist? && path.read.match?(Ruact::CONTROLLER_INCLUDE)
695
858
  end
696
859
 
697
- # Printed when the layout carries the ruact marker but the anchor found no
698
- # root div to inject after. Silence would be the dangerous outcome: the app
699
- # keeps rendering through ruact's CSS-less built-in shell, and nothing ever
700
- # says why.
701
- def warn_layout_migration_failed
702
- say_status "skip", "could not locate the React root div in the layout", :red
860
+ # Whole-app mode: asked for with `--app`, or already the app's state (an
861
+ # install from before 17.0g put the include on ApplicationController).
862
+ def whole_app?
863
+ app? || application_controller_includes_ruact?
864
+ end
865
+
866
+ # `--app` over an island install: every page now renders through the
867
+ # layout ruact ships, not the app's own — its <head>, fonts and JavaScript
868
+ # would be gone app-wide. Said, not changed: the setting is the app's.
869
+ def warn_whole_app_on_gem_layout
870
+ say_status "notice", "whole-app mode on ruact's own layout (config.layout = \"ruact\")", :yellow
703
871
  say ""
704
- say " ruact could not add `ruact_js_assets` automatically. Add it by hand,"
705
- say " just after the root div in app/views/layouts/application.html.erb:"
872
+ say " Every page of the app will now render through the layout ruact ships, without"
873
+ say " your own layout's <head> and JavaScript. For whole-app mode you probably want"
874
+ say " config.layout = true in config/initializers/ruact.rb, with ruact_head_assets and"
875
+ say " ruact_js_assets in your layout (rails ruact:doctor checks)."
706
876
  say ""
707
- say " <div id=\"root\"></div>"
708
- say " <%= ruact_js_assets %>"
877
+ end
878
+
879
+ def advise_whole_app_install
880
+ say_status "notice", "this app is in whole-app mode (ApplicationController includes Ruact::Controller)", :yellow
709
881
  say ""
710
- say " Without it your app's CSS cannot reach a ruact-rendered page."
882
+ say " Every action with an .html.erb template renders through ruact. The install"
883
+ say " no longer does this by default; your app keeps it. To render only the pages"
884
+ say " you choose instead, remove `include Ruact::Controller` from ApplicationController"
885
+ say " and add it to the controllers whose pages should be ruact."
711
886
  say ""
712
887
  end
713
888
 
889
+ def shadcn?
890
+ options[:shadcn]
891
+ end
892
+
714
893
  # The superset the scaffold generator narrows per resource. Loaded lazily
715
894
  # (and only under `--shadcn`) so a plain install never pays for the
716
895
  # scaffold generator's load, and so a failure to reach it degrades to the
@@ -15,7 +15,10 @@ truth" below) instead of guessing.
15
15
 
16
16
  ## Mental model
17
17
 
18
- - A page is a normal Rails controller action rendering a normal `.html.erb`.
18
+ - A page is an action rendering a `.html.erb` in a controller with `include Ruact::Controller`
19
+ (install adds it to none — `--app` puts it on ApplicationController; `ruact:scaffold`
20
+ adds it; `ruact_pages only:` narrows it). The page's renderer owns navigation:
21
+ going between a ruact page and a plain Rails/Turbo page is a full page load.
19
22
  - Interactive components live in `app/javascript/components/` as `"use client"`
20
23
  files, mounted from ERB with a PascalCase self-closing tag:
21
24
  `<LikeButton postId={@post.id} />`.
@@ -66,10 +69,9 @@ object is instantiated.
66
69
 
67
70
  ## Ground truth — read the generated file
68
71
 
69
- `app/javascript/.ruact/server-functions.ts` is regenerated from the route
70
- table (it is gitignored). It is the authoritative list of every accessor and
71
- its typed params — READ THAT FILE instead of simulating the name generation.
72
- Regenerate it after changing routes or queries:
72
+ `app/javascript/.ruact/server-functions.ts` (gitignored, regenerated from the
73
+ route table) is the authoritative list of every accessor and its typed params —
74
+ READ IT instead of simulating the name generation. Regenerate after routes/queries change:
73
75
 
74
76
  bin/rails ruact:server_functions:generate
75
77
 
@@ -92,12 +94,12 @@ Regenerate it after changing routes or queries:
92
94
  (a Flight stream for client-side navigation, an HTML page otherwise). You
93
95
  cannot infer the response shape from the controller body alone — the
94
96
  caller picks it.
95
- 4. **`ruact_errors` requires fall-through.** On the `if @post.save ... else`
96
- path, call `ruact_errors(@post)` and let the action END there — ruact's
97
- implicit render injects `errors: { attribute: [messages] }` into the JSON.
98
- An explicit `render` on that branch opts out of the injection. In the
99
- redirect-back flow, `ruact_errors(@post)` then `redirect_to` carries the
100
- errors through flash to the next render.
97
+ 4. **`ruact_errors` requires fall-through.** On a function call's failed-save
98
+ branch, call `ruact_errors(@post)` and let the action END — the implicit
99
+ render injects `errors: { attribute: [messages] }` into the JSON (an explicit
100
+ `render` opts out). On a page form, `ruact_errors(@post)` then
101
+ `render :new, status: :unprocessable_entity` re-renders the page through ruact
102
+ (view: `errors={ruact_errors}`); `redirect_to` instead carries them via flash.
101
103
  5. **Accessor names are derived, not declared.** `posts#create` → `createPost`,
102
104
  `posts#publish_all` → `publishAllPosts`; query methods camelCase the same
103
105
  way (`search_users` → `searchUsers`). Collisions fail loudly at boot;
@@ -146,8 +148,7 @@ duration, or a deliberate `expires_in: nil` for a non-expiring token.
146
148
  `schema_version` field (currently `0`), do not treat it as a stable contract.
147
149
  - `bin/rails ruact:server_functions:generate` — regenerates the TS module;
148
150
  exits 1 on a naming collision or an invalid name.
149
- - `bin/dev` — boots Rails AND Vite (both are required: Vite serves the client
150
- components and writes the client manifest).
151
+ - `bin/dev` — boots Rails AND Vite (both required: Vite serves the client components).
151
152
  - If your app has TypeScript tooling configured, `npx tsc --noEmit`
152
153
  type-checks call sites against the generated accessor types (a fresh
153
154
  install does not ship a tsconfig).
@@ -1,14 +1,36 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  Ruact.configure do |config|
4
- # Render ruact pages through your app's own layout, so the document `<head>`
5
- # is yours: `stylesheet_link_tag`, favicons, fonts, analytics and any gem that
6
- # writes into `<head>` all reach a ruact page. Requires the layout to call
7
- # `<%%= ruact_js_assets %>` (this generator adds it next to the React root).
8
- #
9
- # Set to false to use ruact's built-in minimal shell instead — it has no
10
- # stylesheet slot, so your app's CSS will NOT reach a ruact-rendered page.
4
+ <% if whole_app? -%>
5
+ # Whole-app mode (`rails generate ruact:install --app`, or an app installed
6
+ # before island mode was the default): ApplicationController
7
+ # includes Ruact::Controller, so every action with an .html.erb template
8
+ # renders through ruact, into your own layout. That layout needs
9
+ # `<%%= ruact_head_assets %>` in <head> and `<%%= ruact_js_assets %>` next to a
10
+ # `<div id="root"></div>` (`rails ruact:doctor` checks).
11
11
  config.layout = true
12
+ <% else -%>
13
+ # Island mode: nothing in your controllers changed. A page renders through
14
+ # ruact when its controller has `include Ruact::Controller` — narrow it to
15
+ # some actions with `ruact_pages only: %i[show]`.
16
+ #
17
+ # Ruact pages render through the layout ruact ships (layouts/ruact). It links
18
+ # the CSS your client components import, then the stylesheets below — yours
19
+ # load last, so they win ties — and it edits none of your layouts. What it
20
+ # does NOT bring is the rest of your own layout's <head> or your app's
21
+ # JavaScript; to own that document, copy it into your app with
22
+ # `rails generate ruact:layout`.
23
+ #
24
+ # Set to true to render through your app's own layout instead (it then needs
25
+ # `<%%= ruact_head_assets %>` in <head> and `<%%= ruact_js_assets %>` next to a
26
+ # `<div id="root"></div>`; `rails ruact:doctor` checks), or false for ruact's
27
+ # minimal built-in shell, which carries none of your stylesheets.
28
+ config.layout = "ruact"
29
+
30
+ # The app stylesheets ruact's layout links, as you would pass them to
31
+ # stylesheet_link_tag. [:app] under Propshaft is every stylesheet you have.
32
+ config.layout_stylesheets = <%= detected_layout_stylesheets %>
33
+ <% end -%>
12
34
 
13
35
  # Path to the react-client-manifest.json generated by the Vite plugin.
14
36
  # Defaults to Rails.root.join("public/react-client-manifest.json").