ruact 0.0.4 → 0.0.6

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 (132) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +54 -2
  3. data/.rubocop_todo.yml +3 -115
  4. data/CHANGELOG.md +68 -17
  5. data/bench/server_functions_dispatch_bench.rb +109 -142
  6. data/bench/server_functions_dispatch_bench.results.md +29 -0
  7. data/docs/internal/decisions/server-functions-api.md +402 -0
  8. data/lib/generators/ruact/install/install_generator.rb +310 -25
  9. data/lib/generators/ruact/install/templates/Procfile.dev.tt +2 -0
  10. data/lib/generators/ruact/install/templates/dev.tt +16 -0
  11. data/lib/generators/ruact/install/templates/package.json.tt +17 -0
  12. data/lib/generators/ruact/install/templates/vite.config.js.tt +3 -1
  13. data/lib/generators/ruact/scaffold/scaffold_attribute.rb +114 -0
  14. data/lib/generators/ruact/scaffold/scaffold_form_helpers.rb +114 -0
  15. data/lib/generators/ruact/scaffold/scaffold_generator.rb +491 -0
  16. data/lib/generators/ruact/scaffold/scaffold_shadcn_preflight.rb +229 -0
  17. data/lib/generators/ruact/scaffold/templates/components/DeleteDialog.tsx.tt +104 -0
  18. data/lib/generators/ruact/scaffold/templates/components/Form.tsx.tt +212 -0
  19. data/lib/generators/ruact/scaffold/templates/components/List.tsx.tt +338 -0
  20. data/lib/generators/ruact/scaffold/templates/components/agnostic/DeleteDialog.tsx.tt +92 -0
  21. data/lib/generators/ruact/scaffold/templates/components/agnostic/Form.tsx.tt +174 -0
  22. data/lib/generators/ruact/scaffold/templates/components/agnostic/List.tsx.tt +253 -0
  23. data/lib/generators/ruact/scaffold/templates/controller.rb.tt +130 -0
  24. data/lib/generators/ruact/scaffold/templates/queries/application_query.rb.tt +13 -0
  25. data/lib/generators/ruact/scaffold/templates/queries/query.rb.tt +59 -0
  26. data/lib/generators/ruact/scaffold/templates/request_spec.rb.tt +77 -0
  27. data/lib/generators/ruact/scaffold/templates/views/edit.html.erb.tt +7 -0
  28. data/lib/generators/ruact/scaffold/templates/views/index.html.erb.tt +6 -0
  29. data/lib/generators/ruact/scaffold/templates/views/new.html.erb.tt +5 -0
  30. data/lib/generators/ruact/scaffold/templates/views/show.html.erb.tt +16 -0
  31. data/lib/ruact/client_manifest.rb +37 -36
  32. data/lib/ruact/component_contract.rb +115 -0
  33. data/lib/ruact/configuration.rb +80 -28
  34. data/lib/ruact/controller.rb +69 -421
  35. data/lib/ruact/doctor.rb +133 -4
  36. data/lib/ruact/erb_preprocessor.rb +65 -12
  37. data/lib/ruact/erb_preprocessor_hook.rb +4 -1
  38. data/lib/ruact/errors.rb +30 -44
  39. data/lib/ruact/html_converter.rb +22 -1
  40. data/lib/ruact/railtie.rb +56 -200
  41. data/lib/ruact/server.rb +28 -6
  42. data/lib/ruact/server_functions/codegen.rb +49 -188
  43. data/lib/ruact/server_functions/codegen_v2.rb +23 -6
  44. data/lib/ruact/server_functions/codegen_v2_query_params.rb +140 -0
  45. data/lib/ruact/server_functions/error_payload.rb +1 -1
  46. data/lib/ruact/server_functions/error_rendering.rb +22 -29
  47. data/lib/ruact/server_functions/name_bridge.rb +14 -16
  48. data/lib/ruact/server_functions/query_source.rb +35 -8
  49. data/lib/ruact/server_functions/route_source.rb +3 -4
  50. data/lib/ruact/server_functions/snapshot.rb +12 -139
  51. data/lib/ruact/server_functions/validation_errors.rb +70 -0
  52. data/lib/ruact/server_functions.rb +21 -25
  53. data/lib/ruact/signed_references.rb +162 -0
  54. data/lib/ruact/string_distance.rb +72 -0
  55. data/lib/ruact/validation_errors_collector.rb +139 -0
  56. data/lib/ruact/version.rb +1 -1
  57. data/lib/ruact/view_helper.rb +102 -0
  58. data/lib/ruact.rb +19 -19
  59. data/lib/tasks/ruact.rake +10 -53
  60. data/spec/fixtures/story_7_9_views/controller_request_spec_support/errors_demo/new.html.erb +3 -0
  61. data/spec/ruact/client_manifest_spec.rb +36 -0
  62. data/spec/ruact/component_contract_spec.rb +119 -0
  63. data/spec/ruact/configuration_spec.rb +51 -34
  64. data/spec/ruact/controller_request_spec.rb +264 -0
  65. data/spec/ruact/controller_spec.rb +63 -326
  66. data/spec/ruact/doctor_spec.rb +201 -0
  67. data/spec/ruact/erb_preprocessor_hook_spec.rb +4 -1
  68. data/spec/ruact/erb_preprocessor_spec.rb +127 -0
  69. data/spec/ruact/errors_spec.rb +0 -45
  70. data/spec/ruact/html_converter_spec.rb +50 -0
  71. data/spec/ruact/install_generator_spec.rb +591 -4
  72. data/spec/ruact/query_request_spec.rb +109 -1
  73. data/spec/ruact/scaffold_generator_spec.rb +1835 -0
  74. data/spec/ruact/server_bucket_request_spec.rb +142 -0
  75. data/spec/ruact/server_function_name_spec.rb +1 -1
  76. data/spec/ruact/server_functions/codegen_spec.rb +158 -269
  77. data/spec/ruact/server_functions/name_bridge_spec.rb +9 -9
  78. data/spec/ruact/server_functions/query_source_spec.rb +51 -0
  79. data/spec/ruact/server_functions/railtie_integration_spec.rb +71 -268
  80. data/spec/ruact/server_functions/rake_spec.rb +29 -29
  81. data/spec/ruact/server_functions/snapshot_spec.rb +53 -213
  82. data/spec/ruact/server_rescue_request_spec.rb +6 -6
  83. data/spec/ruact/server_spec.rb +8 -9
  84. data/spec/ruact/signed_references_spec.rb +164 -0
  85. data/spec/ruact/string_distance_spec.rb +38 -0
  86. data/spec/ruact/validation_errors_spec.rb +116 -0
  87. data/spec/ruact/view_helper_spec.rb +79 -0
  88. data/spec/spec_helper.rb +0 -5
  89. data/vendor/javascript/ruact-server-functions-runtime/index.d.ts +44 -47
  90. data/vendor/javascript/ruact-server-functions-runtime/index.js +151 -107
  91. data/vendor/javascript/ruact-server-functions-runtime/index.test.mjs +72 -75
  92. data/vendor/javascript/ruact-server-functions-runtime/package.json +2 -2
  93. data/vendor/javascript/ruact-server-functions-runtime/usequery.test.mjs +187 -0
  94. data/vendor/javascript/vite-plugin-ruact/bootstrap.test.mjs +96 -0
  95. data/vendor/javascript/vite-plugin-ruact/index.js +353 -7
  96. data/vendor/javascript/vite-plugin-ruact/manifest-contract.test.mjs +182 -0
  97. data/vendor/javascript/vite-plugin-ruact/package-lock.json +16 -0
  98. data/vendor/javascript/vite-plugin-ruact/package.json +3 -1
  99. data/vendor/javascript/vite-plugin-ruact/registry.test.mjs +199 -0
  100. data/vendor/javascript/vite-plugin-ruact/runtime/bootstrap.jsx +68 -0
  101. data/vendor/javascript/vite-plugin-ruact/runtime/flight-client.js +235 -0
  102. data/vendor/javascript/vite-plugin-ruact/runtime/ruact-router.js +473 -0
  103. data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.mjs +114 -139
  104. data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.test.mjs +199 -262
  105. data/vendor/javascript/vite-plugin-ruact/tsconfig.json +18 -0
  106. data/vendor/javascript/vite-plugin-ruact/tsconfig.scaffold-agnostic.json +18 -0
  107. data/vendor/javascript/vite-plugin-ruact/tsconfig.scaffold.json +20 -0
  108. data/vendor/javascript/vite-plugin-ruact/type-tests/emitted-module.test-d.ts +23 -0
  109. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/PostDeleteDialog.tsx +90 -0
  110. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/PostForm.tsx +238 -0
  111. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/PostList.tsx +339 -0
  112. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/PostDeleteDialog.tsx +85 -0
  113. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/PostForm.tsx +216 -0
  114. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/PostList.tsx +269 -0
  115. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/ambient.d.ts +78 -0
  116. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/ambient.d.ts +166 -0
  117. data/vendor/javascript/vite-plugin-ruact/type-tests/typed-query.test-d.ts +48 -0
  118. data/vendor/javascript/vite-plugin-ruact/type-tests/usequery.test-d.ts +26 -0
  119. metadata +55 -15
  120. data/lib/generators/ruact/install/templates/application.jsx.tt +0 -51
  121. data/lib/ruact/server_action.rb +0 -131
  122. data/lib/ruact/server_functions/endpoint_controller.rb +0 -237
  123. data/lib/ruact/server_functions/registry.rb +0 -148
  124. data/lib/ruact/server_functions/registry_entry.rb +0 -26
  125. data/lib/ruact/server_functions/standalone_context.rb +0 -103
  126. data/lib/ruact/server_functions/standalone_dispatcher.rb +0 -178
  127. data/spec/ruact/server_functions/csrf_request_spec.rb +0 -380
  128. data/spec/ruact/server_functions/dispatch_request_spec.rb +0 -819
  129. data/spec/ruact/server_functions/registry_spec.rb +0 -199
  130. data/spec/ruact/server_functions/standalone_action_spec.rb +0 -224
  131. data/spec/ruact/server_functions/standalone_context_spec.rb +0 -142
  132. data/spec/ruact/server_functions/standalone_dispatcher_spec.rb +0 -273
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "pathname"
3
4
  require "rails/generators"
4
5
  require "ruact"
5
6
 
@@ -13,23 +14,45 @@ module Ruact
13
14
  # 3. Injects the React root div into app/views/layouts/application.html.erb
14
15
  # 4. Creates app/javascript/components/.keep
15
16
  # 5. Creates vite.config.js (or shows manual instructions if one exists)
16
- # 6. Creates app/javascript/application.jsx (or skips if one exists)
17
+ # 6. Creates package.json (react/react-dom/vite/@vitejs/plugin-react) so a
18
+ # fresh app has JS deps to install (Story 14.6, FR101).
19
+ # 7. Creates Procfile.dev + bin/dev (foreman) so the single `bin/dev`
20
+ # command boots BOTH Rails and the Vite dev server (Story 14.6, Epic 14
21
+ # DoD — a ruact app needs both processes).
22
+ # 8. Runs `npm install` so JavaScript dependencies are ready (FR101);
23
+ # skippable via --skip-npm.
24
+ #
25
+ # Story 14.2 (FR104) — the generator no longer writes a bootstrap entry into
26
+ # the user's tree. ruact's React entry is served as the virtual module
27
+ # `virtual:ruact/bootstrap` by the bundled Vite plugin (the generated
28
+ # `vite.config` input points at it), so a fresh install leaves
29
+ # `app/javascript/` with only the user's `components/` (plus the gitignored,
30
+ # typed `.ruact/server-functions.ts`).
17
31
  #
18
32
  # Run: rails generate ruact:install
19
- class InstallGenerator < Rails::Generators::Base
33
+ class InstallGenerator < Rails::Generators::Base # rubocop:disable Metrics/ClassLength
20
34
  source_root File.expand_path("templates", __dir__)
21
35
 
22
36
  desc "Installs ruact into the current Rails application"
23
37
 
38
+ # Story 14.1 (FR101) — one-command install. By default the generator
39
+ # runs `npm install` after writing every file so a fresh app is runnable
40
+ # straight away. `--skip-npm` opts out (CI, or a non-npm package manager —
41
+ # run the manager's install manually, then `bin/dev`).
42
+ class_option :skip_npm,
43
+ type: :boolean,
44
+ default: false,
45
+ desc: "Skip running npm install (for CI or non-npm package managers)"
46
+
24
47
  def create_initializer
25
48
  template "initializer.rb.tt", "config/initializers/ruact.rb"
26
49
  end
27
50
 
28
51
  def inject_controller_concern
29
52
  controller_file = "app/controllers/application_controller.rb"
30
- return unless File.exist?(destination_root.join(controller_file))
53
+ return unless File.exist?(Pathname(destination_root).join(controller_file))
31
54
 
32
- content = File.read(destination_root.join(controller_file))
55
+ content = File.read(Pathname(destination_root).join(controller_file))
33
56
  if content.include?("Ruact::Controller")
34
57
  say_status "skip", "Ruact::Controller already included in ApplicationController", :yellow
35
58
  return
@@ -42,9 +65,9 @@ module Ruact
42
65
 
43
66
  def inject_layout_shell
44
67
  layout_file = "app/views/layouts/application.html.erb"
45
- return unless File.exist?(destination_root.join(layout_file))
68
+ return unless File.exist?(Pathname(destination_root).join(layout_file))
46
69
 
47
- content = File.read(destination_root.join(layout_file))
70
+ content = File.read(Pathname(destination_root).join(layout_file))
48
71
  if content.include?("ruact: root")
49
72
  say_status "skip", "Rails RSC root already present in layout", :yellow
50
73
  return
@@ -58,7 +81,7 @@ module Ruact
58
81
  def create_components_directory
59
82
  empty_directory "app/javascript/components"
60
83
  create_file "app/javascript/components/.keep" unless
61
- File.exist?(destination_root.join("app/javascript/components/.keep"))
84
+ File.exist?(Pathname(destination_root).join("app/javascript/components/.keep"))
62
85
  end
63
86
 
64
87
  # Story 8.0a — scaffold the directory the codegen writes into and add the
@@ -68,18 +91,15 @@ module Ruact
68
91
  def create_server_functions_directory
69
92
  empty_directory "app/javascript/.ruact"
70
93
  create_file "app/javascript/.ruact/.gitkeep" unless
71
- File.exist?(destination_root.join("app/javascript/.ruact/.gitkeep"))
94
+ File.exist?(Pathname(destination_root).join("app/javascript/.ruact/.gitkeep"))
72
95
  end
73
96
 
74
97
  def append_gitignore_entries
75
- gitignore = destination_root.join(".gitignore")
98
+ gitignore = Pathname(destination_root).join(".gitignore")
76
99
  return unless gitignore.exist?
77
100
 
78
101
  entries = [
79
102
  "app/javascript/.ruact/server-functions.ts",
80
- # Story 9.3 — the route-driven (v2) parallel inspection target is also
81
- # generated output, never source.
82
- "app/javascript/.ruact/server-functions.next.ts",
83
103
  "tmp/cache/ruact/"
84
104
  ]
85
105
  # Substring matches (`existing.include?(entry)`) were unsafe — they
@@ -105,13 +125,17 @@ module Ruact
105
125
  end
106
126
 
107
127
  def create_vite_config
108
- vite_config_file = destination_root.join("vite.config.js")
128
+ vite_config_file = Pathname(destination_root).join("vite.config.js")
109
129
 
110
- if vite_config_file.exist?
130
+ # `--force` must actually regenerate (the message below promises it). The
131
+ # guard skips the template ONLY when the file exists AND force was not
132
+ # passed; with --force we fall through to `template`, which overwrites.
133
+ if vite_config_file.exist? && !options[:force]
111
134
  say_status "notice", "vite.config.js already exists — add the plugin manually:", :yellow
112
135
  say " 1. At the top of vite.config.js, add:"
113
136
  say " import ruact from '#{Ruact.vite_plugin_path}';"
114
137
  say " 2. In the plugins array, add: ruact()"
138
+ say " 3. Set build.rollupOptions.input to '#{Ruact.bootstrap_virtual_id}'"
115
139
  say ""
116
140
  say " Re-run `rails generate ruact:install --force` to overwrite vite.config.js."
117
141
  else
@@ -119,23 +143,284 @@ module Ruact
119
143
  end
120
144
  end
121
145
 
122
- def create_javascript_entry
123
- template "application.jsx.tt", "app/javascript/application.jsx"
146
+ # Story 14.6 (FR101, Epic 14 DoD) — a fresh ruact app needs a package.json
147
+ # declaring its JavaScript dependencies (React + Vite + the React Vite
148
+ # plugin) so the `npm install` that Story 14.1 runs has something to
149
+ # resolve and `bin/dev`'s `npm run dev` (Vite) has a `dev` script. The
150
+ # bundled ruact Vite plugin is NOT a package.json dependency — vite.config
151
+ # imports it by the absolute `Ruact.vite_plugin_path` and it uses only
152
+ # `node:` builtins. Guarded like vite.config.js: an existing package.json
153
+ # is left untouched (the app may already have one) unless --force.
154
+ def create_package_json
155
+ package_json_file = Pathname(destination_root).join("package.json")
156
+
157
+ if package_json_file.exist? && !options[:force]
158
+ say_status "skip", "package.json already exists — ensure it has react, react-dom, " \
159
+ "vite and @vitejs/plugin-react (re-run with --force to overwrite)", :yellow
160
+ return
161
+ end
162
+
163
+ template "package.json.tt", "package.json"
164
+ end
165
+
166
+ # Story 14.6 (Epic 14 DoD) — emit `Procfile.dev` + a foreman-based `bin/dev`
167
+ # so the literal `bin/dev` boots BOTH processes a ruact app needs: Rails
168
+ # (HTML shell + Flight + server functions) and the Vite dev server
169
+ # (React/HMR + the bundled ruact plugin). Without these, `bin/dev` would
170
+ # start only Rails and the React assets would never be served.
171
+ #
172
+ # `Procfile.dev` is guarded (skip if present unless --force) — the app may
173
+ # already drive its own processes through one. `bin/dev`, however, is
174
+ # OWNED by ruact: see `install_foreman_launcher`. `bin/dev` is made
175
+ # executable.
176
+ def create_launch_files
177
+ create_guarded_file "Procfile.dev", "Procfile.dev.tt"
178
+ install_foreman_launcher
179
+ # Ensure bin/dev is executable whether we just wrote it or it pre-existed
180
+ # (a skipped, already-foreman launcher should still be runnable).
181
+ chmod "bin/dev", 0o755, verbose: false if Pathname(destination_root).join("bin/dev").exist?
182
+ end
183
+
184
+ # Story 14.2 (FR104, AC7) — an app upgrading from the earlier layout still
185
+ # has app/javascript/{application.jsx,flight-client.js,ruact-router.js} on
186
+ # disk. The generator never deletes user files, so it prints the exact
187
+ # manual steps to reach the hidden-plumbing layout — otherwise the app is
188
+ # left half-wired, with the stale entry shadowing the virtual bootstrap.
189
+ # No-op on a fresh install (none of those files exist).
190
+ def advise_plumbing_migration
191
+ stale = legacy_plumbing_files
192
+ return if stale.empty?
193
+
194
+ say_status "notice", "earlier ruact layout detected — finish the Story 14.2 migration:", :yellow
195
+ say ""
196
+ say " ruact's bootstrap entry + Flight runtime are now hidden behind the"
197
+ say " virtual module '#{Ruact.bootstrap_virtual_id}' (served from the gem)."
198
+ say " Remove these now-obsolete files so they don't shadow the virtual entry:"
199
+ stale.each { |f| say " - delete #{f}" }
200
+ say ""
201
+ say " Then set your vite.config build input to '#{Ruact.bootstrap_virtual_id}'"
202
+ say " (or re-run with --force to regenerate vite.config.js), and let the"
203
+ say " controller's HTML shell — or the `ruact_js_assets` view helper in your"
204
+ say " layout — emit the entry <script> tags."
205
+ say ""
206
+ end
207
+
208
+ # Story 14.1 (FR101) — install JavaScript dependencies so a fresh app is
209
+ # runnable in one command. Runs LAST among the file-producing actions
210
+ # (after every file is written) so a failure here leaves the generated
211
+ # files in place and reported. Records the outcome in @npm_outcome so
212
+ # show_post_install_message can tell the truth about what happened.
213
+ def install_javascript_dependencies
214
+ if options[:skip_npm]
215
+ @npm_outcome = :skipped
216
+ say_status "skip", "npm install (--skip-npm) — install JS deps manually before bin/dev", :yellow
217
+ return
218
+ end
219
+
220
+ unless npm_on_path?
221
+ @npm_outcome = :unavailable
222
+ warn_npm_unavailable
223
+ return
224
+ end
225
+
226
+ # Thor's `run` returns false on a non-zero exit (Rails generators set
227
+ # exit_on_failure? = false, so a failed `npm install` does NOT raise),
228
+ # and nil under `--pretend` (the command is previewed, not executed).
229
+ # Branch so the post-install message never claims deps are installed
230
+ # when npm failed (AC#3) — and a `--pretend` dry run is NOT misreported
231
+ # as a failure (run_npm_install still prints the previewed command).
232
+ if run_npm_install || options[:pretend]
233
+ @npm_outcome = options[:pretend] ? :pretend : :installed
234
+ else
235
+ @npm_outcome = :failed
236
+ warn_npm_install_failed
237
+ end
124
238
  end
125
239
 
126
240
  def show_post_install_message
241
+ say "\n#{'=' * 60}\n ruact installed successfully!\n#{'=' * 60}\n"
242
+
243
+ if @npm_outcome == :installed
244
+ say "JavaScript dependencies are installed."
245
+ say ""
246
+ say "Next step:"
247
+ say " Start your app: bin/dev"
248
+ else
249
+ say "JavaScript dependencies are not yet installed."
250
+ say ""
251
+ say "Next steps:"
252
+ say " 1. Install JS dependencies: npm install"
253
+ say " 2. Start your app: bin/dev"
254
+ end
255
+
256
+ say "\nThen add <MyComponent /> to any ERB view.\n"
257
+ say "Note: re-run this generator after updating the ruact gem to refresh"
258
+ say "the bundled Vite plugin path in vite.config.js."
127
259
  say ""
128
- say "=" * 60
129
- say " ruact installed successfully!"
130
- say "=" * 60
260
+ end
261
+
262
+ private
263
+
264
+ # Story 14.6 (live clean-room fix) — ruact OWNS `bin/dev`. The foreman
265
+ # launcher is load-bearing: it boots BOTH Rails AND the Vite dev server,
266
+ # and Vite is what writes `public/react-client-manifest.json`. Rails'
267
+ # own `rails new` writes a `bin/dev` that starts ONLY `rails server` (no
268
+ # Vite) — leaving that default in place makes a freshly-installed ruact
269
+ # app 500 on its first render: Vite never runs, the manifest is never
270
+ # written, and the boot-time manifest load leaves `Ruact.manifest` nil.
271
+ # So unlike `Procfile.dev` (guarded), `bin/dev` is OVERWRITTEN to take
272
+ # ownership — the same posture Story 14.3's scaffold takes over the
273
+ # controller.
274
+ #
275
+ # The overwrite is skipped only when the existing `bin/dev` already drives
276
+ # `Procfile.dev` through a process manager (our foreman launcher from a
277
+ # prior install, or the developer's own foreman/overmind/hivemind setup).
278
+ # That keeps re-running the generator idempotent (invariant 14.1 — no
279
+ # churn on the second run) and never clobbers a deliberate launcher, while
280
+ # still replacing the inert Rails default. Detection is content-based but
281
+ # deliberately STRICTER than a bare `include?("Procfile.dev")` (Codex R1/R2):
282
+ # neither a comment nor an `echo`/`printf` that merely names a foreman
283
+ # command may suppress the overwrite, or the Rails-default-only-
284
+ # `rails server` failure mode could survive. See {#foreman_launcher?}.
285
+ def install_foreman_launcher
286
+ bin_dev = Pathname(destination_root).join("bin/dev")
287
+
288
+ if bin_dev.exist? && foreman_launcher?(bin_dev.read) && !options[:force]
289
+ say_status "skip", "bin/dev already drives Procfile.dev (foreman launcher present)", :yellow
290
+ return
291
+ end
292
+
293
+ template "dev.tt", "bin/dev", force: true
294
+ end
295
+
296
+ # Procfile process managers a foreman-style `bin/dev` may exec. A launcher
297
+ # that INVOKES one of these against `Procfile.dev` already boots Vite (via
298
+ # the same Procfile.dev ruact writes), so it is ruact-compatible.
299
+ PROCFILE_RUNNERS = %w[foreman overmind hivemind node-foreman invoker].freeze
300
+ private_constant :PROCFILE_RUNNERS
301
+
302
+ # A line that INVOKES a known runner as its COMMAND — the runner is the
303
+ # command word, after an optional leading `exec` / `bundle exec`. Anchoring
304
+ # to command position (Codex R3) is what distinguishes a real launcher from
305
+ # a mere mention: an `echo`/`printf`, an assignment like
306
+ # `MSG="… foreman …"`, or a `command -v foreman` test never has the runner
307
+ # in command position, and the trailing shell-token boundary `(?=\s|$)`
308
+ # requires the runner to be a COMPLETE command word — so neither
309
+ # `beforeman` nor `foreman-old`/`foreman.bak` is mistaken for the real
310
+ # `foreman` (Codex R5). (A launcher prefixed with bare env assignments —
311
+ # e.g. `PORT=3000 foreman …` — does NOT match and is simply re-owned by
312
+ # ruact's equivalent foreman launcher; harmless, and far safer than the
313
+ # inverse false-positive that would let the Rails-only `bin/dev` survive.)
314
+ RUNNER_INVOCATION = Regexp.new(
315
+ '\A\s*(?:exec\s+|bundle\s+exec\s+)*' \
316
+ "(?:#{Regexp.union(PROCFILE_RUNNERS).source})" \
317
+ '(?=\s|$)'
318
+ )
319
+ private_constant :RUNNER_INVOCATION
320
+
321
+ # True when `content` actually invokes a Procfile runner against
322
+ # `Procfile.dev` — some non-comment line both invokes a known runner
323
+ # ({RUNNER_INVOCATION}) AND references `Procfile.dev`. The Rails-default
324
+ # `bin/dev` runs only `rails server`, invokes no runner, and is correctly
325
+ # taken over.
326
+ def foreman_launcher?(content)
327
+ content.each_line.any? do |line|
328
+ # Drop any inline shell comment (` #…`) before inspecting the line, so
329
+ # `Procfile.dev` named only in a trailing comment is not mistaken for a
330
+ # real argument (Codex R4) — e.g. `foreman --version # …Procfile.dev`.
331
+ code = line.strip.split(/\s+#/, 2).first.to_s
332
+ next false if code.empty? || code.start_with?("#")
333
+
334
+ code.match?(RUNNER_INVOCATION) && code.include?("Procfile.dev")
335
+ end
336
+ end
337
+
338
+ # Story 14.6 — write a template only when the destination does not already
339
+ # exist (unless --force), printing a skip notice otherwise. Keeps
340
+ # create_launch_files idempotent and non-clobbering.
341
+ def create_guarded_file(destination, template_name)
342
+ if Pathname(destination_root).join(destination).exist? && !options[:force]
343
+ say_status "skip", "#{destination} already exists (re-run with --force to overwrite)", :yellow
344
+ return
345
+ end
346
+
347
+ template template_name, destination
348
+ end
349
+
350
+ # Story 14.6 — a valid, lowercase npm "name" for the generated package.json,
351
+ # derived from the app directory. npm names must be lowercase and contain
352
+ # only URL-safe characters; anything else collapses to a hyphen.
353
+ def app_package_name
354
+ base = File.basename(File.expand_path(destination_root))
355
+ sanitized = base.downcase.gsub(/[^a-z0-9._-]/, "-").squeeze("-").gsub(/\A-+|-+\z/, "")
356
+ sanitized.empty? ? "ruact-app" : sanitized
357
+ end
358
+
359
+ # Story 14.2 (AC7) — the earlier-layout plumbing files that must be removed
360
+ # from the user's tree (the bootstrap entry + the per-app runtime copies).
361
+ # Returns the relative paths that currently exist under destination_root.
362
+ def legacy_plumbing_files
363
+ %w[
364
+ app/javascript/application.jsx
365
+ app/javascript/flight-client.js
366
+ app/javascript/ruact-router.js
367
+ ].select { |rel| File.exist?(Pathname(destination_root).join(rel)) }
368
+ end
369
+
370
+ # The stubbable seam (AC#6): the literal shell-out lives here, isolated
371
+ # so the generator spec can assert/stub it without invoking real npm or
372
+ # hitting the network. Runs in the app root (destination_root) so it
373
+ # picks up the app's package.json. Returns Thor's `run` result (truthy on
374
+ # success, false on a non-zero exit) — captured inside the block so the
375
+ # value is propagated regardless of `inside`'s return semantics.
376
+ def run_npm_install
377
+ result = nil
378
+ inside(destination_root) { result = run "npm install" }
379
+ result
380
+ end
381
+
382
+ # Cross-platform `npm`-on-PATH detection (AC#5). Honors PATHEXT on
383
+ # Windows (npm ships as npm.cmd there); on POSIX the bare `npm` is
384
+ # checked. Stubbed in specs to exercise both branches deterministically
385
+ # without depending on the CI host having Node installed.
386
+ def npm_on_path?
387
+ exts = ENV.fetch("PATHEXT", "").split(File::PATH_SEPARATOR)
388
+ exts = [""] if exts.empty?
389
+
390
+ ENV.fetch("PATH", "").split(File::PATH_SEPARATOR).any? do |dir|
391
+ next false if dir.empty?
392
+
393
+ exts.any? do |ext|
394
+ candidate = File.join(dir, "npm#{ext}")
395
+ File.file?(candidate) && File.executable?(candidate)
396
+ end
397
+ end
398
+ end
399
+
400
+ # Clear, actionable message (AC#5) when npm is unavailable — the written
401
+ # files are NOT rolled back; the developer is pointed at Node or the
402
+ # --skip-npm escape hatch. No raw Errno/NoMethodError stack trace leaks.
403
+ def warn_npm_unavailable
404
+ say_status "warn", "npm not found on PATH — skipping JavaScript dependency install", :yellow
131
405
  say ""
132
- say "Next steps:"
133
- say " 1. Start the Vite dev server: npm run dev"
134
- say " 2. Start Rails: rails server"
135
- say " 3. Add <MyComponent /> to any ERB view"
406
+ say " ruact wrote all of its files, but could NOT install JavaScript"
407
+ say " dependencies: `npm` is not available on your PATH."
408
+ say ""
409
+ say " Install Node >= 20 (https://nodejs.org), then run `npm install`,"
410
+ say " or re-run with `--skip-npm` and install JS deps with your own"
411
+ say " package manager. Then start your app with `bin/dev`."
412
+ say ""
413
+ end
414
+
415
+ # Reported when `npm install` ran but exited non-zero (AC#1/#3) — the
416
+ # written files are kept; the developer is told to resolve the npm error
417
+ # and re-run, and the post-install summary will NOT claim deps installed.
418
+ def warn_npm_install_failed
419
+ say_status "warn", "npm install did not complete successfully", :yellow
136
420
  say ""
137
- say "Note: Re-run this generator after updating the ruact gem"
138
- say "to refresh the bundled Vite plugin path in vite.config.js."
421
+ say " ruact wrote all of its files, but `npm install` reported a failure."
422
+ say " Re-run `npm install` in the app root and resolve the npm error,"
423
+ say " then start your app with `bin/dev`."
139
424
  say ""
140
425
  end
141
426
  end
@@ -0,0 +1,2 @@
1
+ web: bin/rails server -p 3000
2
+ vite: npm run dev
@@ -0,0 +1,16 @@
1
+ #!/usr/bin/env bash
2
+
3
+ # Boots the ruact development stack — both processes from Procfile.dev:
4
+ # web: bin/rails server (HTML shell + Flight stream + server functions)
5
+ # vite: npm run dev (Vite dev server: React/HMR + the bundled ruact plugin)
6
+ # A ruact app needs BOTH: Rails serves the HTML/Flight payload, Vite serves the
7
+ # React client assets. `bin/dev` runs them together via foreman.
8
+
9
+ if ! gem list foreman -i --silent; then
10
+ echo "Installing foreman..."
11
+ gem install foreman
12
+ fi
13
+
14
+ export PORT="${PORT:-3000}"
15
+
16
+ exec foreman start -f Procfile.dev "$@"
@@ -0,0 +1,17 @@
1
+ {
2
+ "name": "<%= app_package_name %>",
3
+ "private": true,
4
+ "type": "module",
5
+ "scripts": {
6
+ "dev": "vite",
7
+ "build": "vite build"
8
+ },
9
+ "dependencies": {
10
+ "react": "^19.0.0",
11
+ "react-dom": "^19.0.0"
12
+ },
13
+ "devDependencies": {
14
+ "@vitejs/plugin-react": "^4.3.4",
15
+ "vite": "^6.0.7"
16
+ }
17
+ }
@@ -14,7 +14,9 @@ export default defineConfig({
14
14
  outDir: 'public/assets',
15
15
  manifest: true,
16
16
  rollupOptions: {
17
- input: 'app/javascript/application.jsx',
17
+ // ruact's bootstrap entry is a virtual module served by the plugin above
18
+ // (Story 14.2) — there is no app/javascript/application.jsx to point at.
19
+ input: '<%= Ruact.bootstrap_virtual_id %>',
18
20
  },
19
21
  },
20
22
 
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators/named_base"
4
+
5
+ module Ruact
6
+ module Generators
7
+ class ScaffoldGenerator < Rails::Generators::NamedBase
8
+ # A single scaffold attribute (name + AR type) with the view-model helpers
9
+ # the templates consume. `references` columns address `<name>_id`. Lives in
10
+ # its own file to keep {ScaffoldGenerator} within its class-length budget;
11
+ # reopens the generator class so `TYPE_MAP` resolves by lexical scope.
12
+ class ScaffoldAttribute
13
+ attr_reader :name, :type
14
+
15
+ def initialize(name, type)
16
+ @name = name
17
+ @type = type
18
+ end
19
+
20
+ # The wire/DB column the controller params + serialized rows use.
21
+ def column_name
22
+ reference? ? "#{name}_id" : name
23
+ end
24
+
25
+ def ts_type
26
+ TYPE_MAP.fetch(type)[:ts]
27
+ end
28
+
29
+ def control
30
+ TYPE_MAP.fetch(type)[:control]
31
+ end
32
+
33
+ def boolean?
34
+ type == "boolean"
35
+ end
36
+
37
+ # date/datetime — the List sorts these columns by epoch time (Story 10.2b).
38
+ def date?
39
+ column_kind == :date
40
+ end
41
+
42
+ # The DataTable cell renderer kind (Story 10.2 AC1):
43
+ # :badge → boolean → Badge "Yes"/"No"
44
+ # :numeric → integer/float/decimal/references → right-aligned numeric
45
+ # :date → date/datetime → locale-formatted
46
+ # :text → string/text → plain text
47
+ def column_kind
48
+ case type
49
+ when "boolean" then :badge
50
+ when "integer", "float", "decimal", "references" then :numeric
51
+ when "date", "datetime" then :date
52
+ else :text
53
+ end
54
+ end
55
+
56
+ # The shadcn Form control this attribute renders as (Story 10.3 AC1):
57
+ # :input → string → <Input type="text">
58
+ # :textarea → text → <Textarea>
59
+ # :switch → boolean → <Switch>
60
+ # :number → integer/float/decimal → <Input type="number">
61
+ # :date → date → <Input type="date">
62
+ # :datetime → datetime → <Input type="datetime-local">
63
+ # :select → references → <Select> of parent options
64
+ # The :input/:number/:date/:datetime kinds all render an <Input>; the
65
+ # concrete `type=` attribute comes from `html_input_type` (FormHelpers).
66
+ def shadcn_control
67
+ case type
68
+ when "text" then :textarea
69
+ when "boolean" then :switch
70
+ when "integer", "float", "decimal" then :number
71
+ when "date" then :date
72
+ when "datetime" then :datetime
73
+ when "references" then :select
74
+ else :input
75
+ end
76
+ end
77
+
78
+ # references only — the parent model constant (`author` → `Author`,
79
+ # `blog_post` → `BlogPost`), used by the controller's options loader.
80
+ def reference_class_name
81
+ name.camelize
82
+ end
83
+
84
+ # references only — the controller ivar / view local holding the capped
85
+ # parent options (`author` → `author_options`).
86
+ def options_ivar
87
+ "#{name}_options"
88
+ end
89
+
90
+ # references only — the camelCase prop the view passes and the Form reads
91
+ # (`author` → `authorOptions`, `blog_post` → `blogPostOptions`).
92
+ def options_prop
93
+ parts = name.split("_")
94
+ "#{(parts.first(1) + parts.drop(1).map(&:capitalize)).join}Options"
95
+ end
96
+
97
+ def reference?
98
+ type == "references"
99
+ end
100
+
101
+ # camelCase JS identifier for the column (`author_id` → `authorId`).
102
+ def js_var
103
+ parts = column_name.split("_")
104
+ (parts.first(1) + parts.drop(1).map(&:capitalize)).join
105
+ end
106
+
107
+ # The React `useState` setter name (`authorId` → `setAuthorId`).
108
+ def js_setter
109
+ "set#{js_var.sub(/\A./, &:upcase)}"
110
+ end
111
+ end
112
+ end
113
+ end
114
+ end