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.
- checksums.yaml +4 -4
- data/.github/workflows/ci.yml +54 -2
- data/.rubocop_todo.yml +3 -115
- data/CHANGELOG.md +68 -17
- data/bench/server_functions_dispatch_bench.rb +109 -142
- data/bench/server_functions_dispatch_bench.results.md +29 -0
- data/docs/internal/decisions/server-functions-api.md +402 -0
- data/lib/generators/ruact/install/install_generator.rb +310 -25
- data/lib/generators/ruact/install/templates/Procfile.dev.tt +2 -0
- data/lib/generators/ruact/install/templates/dev.tt +16 -0
- data/lib/generators/ruact/install/templates/package.json.tt +17 -0
- data/lib/generators/ruact/install/templates/vite.config.js.tt +3 -1
- data/lib/generators/ruact/scaffold/scaffold_attribute.rb +114 -0
- data/lib/generators/ruact/scaffold/scaffold_form_helpers.rb +114 -0
- data/lib/generators/ruact/scaffold/scaffold_generator.rb +491 -0
- data/lib/generators/ruact/scaffold/scaffold_shadcn_preflight.rb +229 -0
- data/lib/generators/ruact/scaffold/templates/components/DeleteDialog.tsx.tt +104 -0
- data/lib/generators/ruact/scaffold/templates/components/Form.tsx.tt +212 -0
- data/lib/generators/ruact/scaffold/templates/components/List.tsx.tt +338 -0
- data/lib/generators/ruact/scaffold/templates/components/agnostic/DeleteDialog.tsx.tt +92 -0
- data/lib/generators/ruact/scaffold/templates/components/agnostic/Form.tsx.tt +174 -0
- data/lib/generators/ruact/scaffold/templates/components/agnostic/List.tsx.tt +253 -0
- data/lib/generators/ruact/scaffold/templates/controller.rb.tt +130 -0
- data/lib/generators/ruact/scaffold/templates/queries/application_query.rb.tt +13 -0
- data/lib/generators/ruact/scaffold/templates/queries/query.rb.tt +59 -0
- data/lib/generators/ruact/scaffold/templates/request_spec.rb.tt +77 -0
- data/lib/generators/ruact/scaffold/templates/views/edit.html.erb.tt +7 -0
- data/lib/generators/ruact/scaffold/templates/views/index.html.erb.tt +6 -0
- data/lib/generators/ruact/scaffold/templates/views/new.html.erb.tt +5 -0
- data/lib/generators/ruact/scaffold/templates/views/show.html.erb.tt +16 -0
- data/lib/ruact/client_manifest.rb +37 -36
- data/lib/ruact/component_contract.rb +115 -0
- data/lib/ruact/configuration.rb +80 -28
- data/lib/ruact/controller.rb +69 -421
- data/lib/ruact/doctor.rb +133 -4
- data/lib/ruact/erb_preprocessor.rb +65 -12
- data/lib/ruact/erb_preprocessor_hook.rb +4 -1
- data/lib/ruact/errors.rb +30 -44
- data/lib/ruact/html_converter.rb +22 -1
- data/lib/ruact/railtie.rb +56 -200
- data/lib/ruact/server.rb +28 -6
- data/lib/ruact/server_functions/codegen.rb +49 -188
- data/lib/ruact/server_functions/codegen_v2.rb +23 -6
- data/lib/ruact/server_functions/codegen_v2_query_params.rb +140 -0
- data/lib/ruact/server_functions/error_payload.rb +1 -1
- data/lib/ruact/server_functions/error_rendering.rb +22 -29
- data/lib/ruact/server_functions/name_bridge.rb +14 -16
- data/lib/ruact/server_functions/query_source.rb +35 -8
- data/lib/ruact/server_functions/route_source.rb +3 -4
- data/lib/ruact/server_functions/snapshot.rb +12 -139
- data/lib/ruact/server_functions/validation_errors.rb +70 -0
- data/lib/ruact/server_functions.rb +21 -25
- data/lib/ruact/signed_references.rb +162 -0
- data/lib/ruact/string_distance.rb +72 -0
- data/lib/ruact/validation_errors_collector.rb +139 -0
- data/lib/ruact/version.rb +1 -1
- data/lib/ruact/view_helper.rb +102 -0
- data/lib/ruact.rb +19 -19
- data/lib/tasks/ruact.rake +10 -53
- data/spec/fixtures/story_7_9_views/controller_request_spec_support/errors_demo/new.html.erb +3 -0
- data/spec/ruact/client_manifest_spec.rb +36 -0
- data/spec/ruact/component_contract_spec.rb +119 -0
- data/spec/ruact/configuration_spec.rb +51 -34
- data/spec/ruact/controller_request_spec.rb +264 -0
- data/spec/ruact/controller_spec.rb +63 -326
- data/spec/ruact/doctor_spec.rb +201 -0
- data/spec/ruact/erb_preprocessor_hook_spec.rb +4 -1
- data/spec/ruact/erb_preprocessor_spec.rb +127 -0
- data/spec/ruact/errors_spec.rb +0 -45
- data/spec/ruact/html_converter_spec.rb +50 -0
- data/spec/ruact/install_generator_spec.rb +591 -4
- data/spec/ruact/query_request_spec.rb +109 -1
- data/spec/ruact/scaffold_generator_spec.rb +1835 -0
- data/spec/ruact/server_bucket_request_spec.rb +142 -0
- data/spec/ruact/server_function_name_spec.rb +1 -1
- data/spec/ruact/server_functions/codegen_spec.rb +158 -269
- data/spec/ruact/server_functions/name_bridge_spec.rb +9 -9
- data/spec/ruact/server_functions/query_source_spec.rb +51 -0
- data/spec/ruact/server_functions/railtie_integration_spec.rb +71 -268
- data/spec/ruact/server_functions/rake_spec.rb +29 -29
- data/spec/ruact/server_functions/snapshot_spec.rb +53 -213
- data/spec/ruact/server_rescue_request_spec.rb +6 -6
- data/spec/ruact/server_spec.rb +8 -9
- data/spec/ruact/signed_references_spec.rb +164 -0
- data/spec/ruact/string_distance_spec.rb +38 -0
- data/spec/ruact/validation_errors_spec.rb +116 -0
- data/spec/ruact/view_helper_spec.rb +79 -0
- data/spec/spec_helper.rb +0 -5
- data/vendor/javascript/ruact-server-functions-runtime/index.d.ts +44 -47
- data/vendor/javascript/ruact-server-functions-runtime/index.js +151 -107
- data/vendor/javascript/ruact-server-functions-runtime/index.test.mjs +72 -75
- data/vendor/javascript/ruact-server-functions-runtime/package.json +2 -2
- data/vendor/javascript/ruact-server-functions-runtime/usequery.test.mjs +187 -0
- data/vendor/javascript/vite-plugin-ruact/bootstrap.test.mjs +96 -0
- data/vendor/javascript/vite-plugin-ruact/index.js +353 -7
- data/vendor/javascript/vite-plugin-ruact/manifest-contract.test.mjs +182 -0
- data/vendor/javascript/vite-plugin-ruact/package-lock.json +16 -0
- data/vendor/javascript/vite-plugin-ruact/package.json +3 -1
- data/vendor/javascript/vite-plugin-ruact/registry.test.mjs +199 -0
- data/vendor/javascript/vite-plugin-ruact/runtime/bootstrap.jsx +68 -0
- data/vendor/javascript/vite-plugin-ruact/runtime/flight-client.js +235 -0
- data/vendor/javascript/vite-plugin-ruact/runtime/ruact-router.js +473 -0
- data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.mjs +114 -139
- data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.test.mjs +199 -262
- data/vendor/javascript/vite-plugin-ruact/tsconfig.json +18 -0
- data/vendor/javascript/vite-plugin-ruact/tsconfig.scaffold-agnostic.json +18 -0
- data/vendor/javascript/vite-plugin-ruact/tsconfig.scaffold.json +20 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/emitted-module.test-d.ts +23 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/PostDeleteDialog.tsx +90 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/PostForm.tsx +238 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/PostList.tsx +339 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/PostDeleteDialog.tsx +85 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/PostForm.tsx +216 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/PostList.tsx +269 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/ambient.d.ts +78 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/ambient.d.ts +166 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/typed-query.test-d.ts +48 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/usequery.test-d.ts +26 -0
- metadata +55 -15
- data/lib/generators/ruact/install/templates/application.jsx.tt +0 -51
- data/lib/ruact/server_action.rb +0 -131
- data/lib/ruact/server_functions/endpoint_controller.rb +0 -237
- data/lib/ruact/server_functions/registry.rb +0 -148
- data/lib/ruact/server_functions/registry_entry.rb +0 -26
- data/lib/ruact/server_functions/standalone_context.rb +0 -103
- data/lib/ruact/server_functions/standalone_dispatcher.rb +0 -178
- data/spec/ruact/server_functions/csrf_request_spec.rb +0 -380
- data/spec/ruact/server_functions/dispatch_request_spec.rb +0 -819
- data/spec/ruact/server_functions/registry_spec.rb +0 -199
- data/spec/ruact/server_functions/standalone_action_spec.rb +0 -224
- data/spec/ruact/server_functions/standalone_context_spec.rb +0 -142
- 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
|
|
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
|
-
|
|
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
|
-
|
|
123
|
-
|
|
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
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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 "
|
|
133
|
-
say "
|
|
134
|
-
say "
|
|
135
|
-
say "
|
|
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 "
|
|
138
|
-
say "
|
|
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,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
|
-
|
|
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
|