ruact 0.0.12 → 0.0.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +59 -1
- data/README.md +4 -4
- data/lib/generators/ruact/install/install_generator.rb +418 -128
- data/lib/generators/ruact/install/templates/AGENTS.md.tt +14 -13
- data/lib/generators/ruact/install/templates/Procfile.dev.tt +1 -1
- data/lib/generators/ruact/install/templates/initializer.rb.tt +29 -7
- data/lib/generators/ruact/install/templates/package.json.tt +4 -4
- data/lib/generators/ruact/install/templates/tsconfig.json.tt +3 -0
- data/lib/generators/ruact/layout/layout_generator.rb +52 -0
- data/lib/generators/ruact/scaffold/scaffold_generator.rb +39 -12
- data/lib/generators/ruact/scaffold/scaffold_shadcn_preflight.rb +4 -3
- data/lib/generators/ruact/scaffold/templates/components/List.tsx.tt +8 -8
- data/lib/generators/ruact/scaffold/templates/components/agnostic/List.tsx.tt +8 -8
- data/lib/generators/ruact/scaffold/templates/controller.rb.tt +5 -3
- data/lib/generators/ruact/scaffold/templates/queries/query.rb.tt +2 -2
- data/lib/generators/ruact/scaffold/templates/views/index.html.erb.tt +1 -1
- data/lib/ruact/configuration.rb +71 -17
- data/lib/ruact/controller/document_rendering.rb +72 -16
- data/lib/ruact/controller/page_rendering.rb +134 -0
- data/lib/ruact/controller/pages.rb +116 -0
- data/lib/ruact/controller.rb +78 -11
- data/lib/ruact/doctor.rb +233 -25
- data/lib/ruact/layout_source.rb +29 -7
- data/lib/ruact/navigation_boundary.rb +240 -0
- data/lib/ruact/railtie.rb +30 -0
- data/lib/ruact/routing.rb +24 -6
- data/lib/ruact/server.rb +10 -1
- data/lib/ruact/version.rb +1 -1
- data/lib/ruact/view_helper.rb +158 -1
- data/lib/ruact/views/layouts/ruact.html.erb +32 -0
- data/lib/ruact.rb +29 -0
- data/vendor/javascript/vite-plugin-ruact/ruact-router.test.mjs +433 -0
- data/vendor/javascript/vite-plugin-ruact/runtime/ruact-router.js +170 -7
- data/vendor/javascript/vite-plugin-ruact/tsconfig.scaffold-agnostic.json +1 -1
- data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/PostList.tsx +3 -3
- data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/PostList.tsx +3 -3
- data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/ambient.d.ts +4 -4
- data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/ambient.d.ts +4 -4
- metadata +8 -2
|
@@ -11,11 +11,14 @@ module Ruact
|
|
|
11
11
|
#
|
|
12
12
|
# Performs the following actions:
|
|
13
13
|
# 1. Creates config/initializers/ruact.rb
|
|
14
|
-
# 2.
|
|
15
|
-
#
|
|
16
|
-
#
|
|
17
|
-
#
|
|
18
|
-
#
|
|
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
|
|
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,81 +95,81 @@ 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
|
-
|
|
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
|
-
|
|
91
|
-
|
|
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
|
-
#
|
|
102
|
-
#
|
|
103
|
-
# layout
|
|
104
|
-
#
|
|
105
|
-
#
|
|
106
|
-
#
|
|
107
|
-
#
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
-
|
|
129
|
-
|
|
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
|
|
143
|
+
missing = missing_layout_lines(path.read)
|
|
144
|
+
return if missing.empty?
|
|
144
145
|
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
before: Ruact::LayoutSource::ASSETS_CALL,
|
|
153
|
-
success: "added the React root div next to the existing ruact_js_assets")
|
|
154
|
-
end
|
|
155
|
-
|
|
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
|
|
|
155
|
+
# What `--shadcn` adds to package.json and Procfile.dev. One source for the
|
|
156
|
+
# templates (fresh install) and the merge below (an app that already has
|
|
157
|
+
# both files), so the two paths cannot drift.
|
|
158
|
+
SHADCN_DEV_DEPENDENCIES = {
|
|
159
|
+
"@tailwindcss/cli" => "^4.0.0",
|
|
160
|
+
"tailwindcss" => "^4.0.0",
|
|
161
|
+
"tw-animate-css" => "^1.0.0"
|
|
162
|
+
}.freeze
|
|
163
|
+
SHADCN_BUILD_CSS_SCRIPT =
|
|
164
|
+
"@tailwindcss/cli -i app/javascript/styles/globals.css -o app/assets/builds/tailwind.css --minify"
|
|
165
|
+
# `--watch=always`, not `--watch`: Tailwind stops watching when stdin
|
|
166
|
+
# closes, and it does exit 0, so foreman then stops Rails and Vite with it.
|
|
167
|
+
# stdin is closed whenever bin/dev runs without a terminal (a coding agent,
|
|
168
|
+
# CI, Docker, an IDE task runner), which made the whole app go down in silence.
|
|
169
|
+
SHADCN_CSS_PROCESS =
|
|
170
|
+
"css: npx @tailwindcss/cli -i app/javascript/styles/globals.css " \
|
|
171
|
+
"-o app/assets/builds/tailwind.css --watch=always"
|
|
172
|
+
|
|
161
173
|
# `--shadcn` only. Two files, both of them things shadcn's CLI checks for
|
|
162
174
|
# and refuses to proceed without ("No Tailwind CSS configuration found" /
|
|
163
175
|
# "Could not find valid path aliases"), verified against shadcn 4.x:
|
|
@@ -262,10 +274,18 @@ module Ruact
|
|
|
262
274
|
# imports it by the absolute `Ruact.vite_plugin_path` and it uses only
|
|
263
275
|
# `node:` builtins. Guarded like vite.config.js: an existing package.json
|
|
264
276
|
# is left untouched (the app may already have one) unless --force.
|
|
277
|
+
#
|
|
278
|
+
# Under `--shadcn` an existing package.json is not skipped but COMPLETED:
|
|
279
|
+
# adding shadcn to an app that already ran the install is the documented
|
|
280
|
+
# path (Getting Started step 7 onward), and skipping here left Tailwind
|
|
281
|
+
# undeclared, so `shadcn init` aborted with "No Tailwind CSS configuration
|
|
282
|
+
# found" right after this generator said the prerequisites were in place.
|
|
265
283
|
def create_package_json
|
|
266
284
|
package_json_file = Pathname(destination_root).join("package.json")
|
|
267
285
|
|
|
268
286
|
if package_json_file.exist? && !options[:force]
|
|
287
|
+
return merge_shadcn_package_json(package_json_file) if shadcn?
|
|
288
|
+
|
|
269
289
|
say_status "skip", "package.json already exists — ensure it has react, react-dom, " \
|
|
270
290
|
"vite and @vitejs/plugin-react (re-run with --force to overwrite)", :yellow
|
|
271
291
|
return
|
|
@@ -285,7 +305,12 @@ module Ruact
|
|
|
285
305
|
# OWNED by ruact: see `install_foreman_launcher`. `bin/dev` is made
|
|
286
306
|
# executable.
|
|
287
307
|
def create_launch_files
|
|
288
|
-
|
|
308
|
+
procfile = Pathname(destination_root).join("Procfile.dev")
|
|
309
|
+
if procfile.exist? && !options[:force] && shadcn?
|
|
310
|
+
append_shadcn_css_process(procfile)
|
|
311
|
+
else
|
|
312
|
+
create_guarded_file "Procfile.dev", "Procfile.dev.tt"
|
|
313
|
+
end
|
|
289
314
|
install_foreman_launcher
|
|
290
315
|
# Ensure bin/dev is executable whether we just wrote it or it pre-existed
|
|
291
316
|
# (a skipped, already-foreman launcher should still be runnable).
|
|
@@ -400,7 +425,9 @@ module Ruact
|
|
|
400
425
|
|
|
401
426
|
show_shadcn_next_steps if shadcn?
|
|
402
427
|
|
|
403
|
-
say "\nThen add <MyComponent /> to any ERB view
|
|
428
|
+
say "\nThen add <MyComponent /> to any ERB view."
|
|
429
|
+
say layout_summary
|
|
430
|
+
say ""
|
|
404
431
|
say "Note: re-run this generator after updating the ruact gem to refresh"
|
|
405
432
|
say "the bundled Vite plugin path in vite.config.js."
|
|
406
433
|
say ""
|
|
@@ -408,53 +435,185 @@ module Ruact
|
|
|
408
435
|
|
|
409
436
|
private
|
|
410
437
|
|
|
411
|
-
#
|
|
412
|
-
#
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
if path.read == before
|
|
421
|
-
warn_layout_migration_failed
|
|
438
|
+
# What the post-install message says about the layout — true for the
|
|
439
|
+
# setting actually in effect, not just for a fresh install.
|
|
440
|
+
def layout_summary
|
|
441
|
+
[adoption_summary, document_summary].join("\n")
|
|
442
|
+
end
|
|
443
|
+
|
|
444
|
+
def adoption_summary
|
|
445
|
+
if whole_app?
|
|
446
|
+
"Whole-app mode: every action with an .html.erb template renders through ruact."
|
|
422
447
|
else
|
|
423
|
-
|
|
448
|
+
"Island mode: nothing changed in your controllers. A page renders through ruact when its\n" \
|
|
449
|
+
"controller has `include Ruact::Controller` (narrow it with `ruact_pages only: %i[show]`),\n" \
|
|
450
|
+
"or run rails generate ruact:scaffold."
|
|
451
|
+
end
|
|
452
|
+
end
|
|
453
|
+
|
|
454
|
+
def document_summary
|
|
455
|
+
own = configured_app_layout
|
|
456
|
+
return "ruact pages render through your #{own} layout (see any lines printed above)." if own
|
|
457
|
+
|
|
458
|
+
value = configured_layout_value
|
|
459
|
+
if value.nil? || value == "false"
|
|
460
|
+
return "ruact pages render through its built-in shell (config.layout is #{value || 'not set'})."
|
|
424
461
|
end
|
|
462
|
+
|
|
463
|
+
"ruact pages render through ruact's own layout — your layouts are untouched.\n" \
|
|
464
|
+
"To customize it: rails generate ruact:layout"
|
|
425
465
|
end
|
|
426
466
|
|
|
467
|
+
# Story 17.0b — what `layouts/ruact` should link, decided ONCE, here, and
|
|
468
|
+
# written into the initializer where the app can see and change it. Never
|
|
469
|
+
# inferred at render time. `:app` is what `rails new` 8.x links and means
|
|
470
|
+
# "every stylesheet" under Propshaft; under Sprockets it would be a file
|
|
471
|
+
# called app.css, which does not exist — an AssetNotFound on the first
|
|
472
|
+
# render — so a Sprockets app gets its conventional "application".
|
|
473
|
+
#
|
|
474
|
+
# Propshaft learned `:app` in 0.9.0 (checked against the released gems:
|
|
475
|
+
# 0.8.0's helper has no `when :app`, 0.9.0's does). Below that, or under
|
|
476
|
+
# Sprockets, the conventional "application" is what exists. With neither
|
|
477
|
+
# pipeline there is nothing to link: `:app` would become a 404 on
|
|
478
|
+
# /stylesheets/app.css.
|
|
479
|
+
PROPSHAFT_STYLESHEETS = "[:app]"
|
|
480
|
+
SPROCKETS_STYLESHEETS = '["application"]'
|
|
481
|
+
NO_PIPELINE_STYLESHEETS = "[]"
|
|
482
|
+
private_constant :PROPSHAFT_STYLESHEETS, :SPROCKETS_STYLESHEETS, :NO_PIPELINE_STYLESHEETS
|
|
483
|
+
|
|
484
|
+
def detected_layout_stylesheets
|
|
485
|
+
@detected_layout_stylesheets ||= begin
|
|
486
|
+
gems = locked_gems
|
|
487
|
+
propshaft = gems[/^ {4}propshaft \((\d+)\.(\d+)/] && [Regexp.last_match(1).to_i, Regexp.last_match(2).to_i]
|
|
488
|
+
if propshaft
|
|
489
|
+
(propshaft <=> [0, 9]) >= 0 ? PROPSHAFT_STYLESHEETS : SPROCKETS_STYLESHEETS
|
|
490
|
+
elsif gems.match?(/^ {4}sprockets-rails \(/)
|
|
491
|
+
SPROCKETS_STYLESHEETS
|
|
492
|
+
elsif gems.empty?
|
|
493
|
+
PROPSHAFT_STYLESHEETS # no lockfile to read: the default, as `rails new` would have
|
|
494
|
+
else
|
|
495
|
+
NO_PIPELINE_STYLESHEETS
|
|
496
|
+
end
|
|
497
|
+
end
|
|
498
|
+
end
|
|
499
|
+
|
|
500
|
+
def locked_gems
|
|
501
|
+
%w[Gemfile.lock gems.locked].each do |name|
|
|
502
|
+
lock = Pathname(destination_root).join(name)
|
|
503
|
+
return lock.read if lock.exist?
|
|
504
|
+
end
|
|
505
|
+
""
|
|
506
|
+
end
|
|
507
|
+
|
|
508
|
+
# The layout the app chose for ruact pages, when it is the app's OWN one:
|
|
509
|
+
# "application" for `config.layout = true`, the name for any other String
|
|
510
|
+
# but "ruact" (the gem's). nil when ruact's layout, the built-in shell, or
|
|
511
|
+
# no initializer is in play. Reads the initializer the app wrote, once, at
|
|
512
|
+
# install time — the same thing a reader of that file would conclude.
|
|
513
|
+
def configured_app_layout
|
|
514
|
+
value = configured_layout_value
|
|
515
|
+
return "application" if value == "true"
|
|
516
|
+
|
|
517
|
+
quoted = value && value[/\A["'](.+)["']\z/, 1]
|
|
518
|
+
name = quoted&.delete_prefix("layouts/")
|
|
519
|
+
return nil if name.nil? || name == Ruact::GEM_LAYOUT
|
|
520
|
+
|
|
521
|
+
name
|
|
522
|
+
end
|
|
523
|
+
|
|
524
|
+
# The raw right-hand side of the LAST `…layout =` in the initializer — the
|
|
525
|
+
# one Ruby leaves in effect — or nil. Anything that is not a literal
|
|
526
|
+
# (`ENV.fetch(...)`, a ternary) comes back as-is and is treated as "not
|
|
527
|
+
# something to advise about", never guessed at.
|
|
528
|
+
def configured_layout_value
|
|
529
|
+
path = Pathname(destination_root).join("config/initializers/ruact.rb")
|
|
530
|
+
return nil unless path.exist?
|
|
531
|
+
|
|
532
|
+
layout_assignments(path.read).last
|
|
533
|
+
end
|
|
534
|
+
|
|
535
|
+
# The right-hand side of every `<receiver>.layout =` outside a comment
|
|
536
|
+
# line, in order — the one-line `Ruact.configure { |c| c.layout = true }`
|
|
537
|
+
# the configuration docs show included. `layout_stylesheets =` never
|
|
538
|
+
# matches: `.layout` must be followed by `=`.
|
|
539
|
+
def layout_assignments(source)
|
|
540
|
+
source.each_line.reject { |line| line.lstrip.start_with?("#") }.flat_map do |line|
|
|
541
|
+
line.scan(/\b\w+\.layout\s*=(?!=)\s*([^\s#;}]+)/).flatten
|
|
542
|
+
end
|
|
543
|
+
end
|
|
544
|
+
|
|
545
|
+
# Each call the app's own layout is missing, with where it goes. Read
|
|
546
|
+
# through Ruact::LayoutSource, so a mention inside a comment is not a call
|
|
547
|
+
# — the same definition the doctor and the runtime use.
|
|
548
|
+
def missing_layout_lines(content)
|
|
549
|
+
missing = []
|
|
550
|
+
missing << ["inside <head>, above your stylesheets", "<%= ruact_head_assets %>"] unless
|
|
551
|
+
Ruact::LayoutSource.head_wired?(content)
|
|
552
|
+
missing << ["inside <body>", %(<div id="root"></div>)] unless Ruact::LayoutSource.root?(content)
|
|
553
|
+
missing << ["right after the root div", "<%= ruact_js_assets %>"] unless Ruact::LayoutSource.wired?(content)
|
|
554
|
+
missing
|
|
555
|
+
end
|
|
556
|
+
|
|
557
|
+
# The block may name its variable anything (`|c|`, `|ruact|`): the setting
|
|
558
|
+
# is found whatever the receiver, and the snippet is written with the
|
|
559
|
+
# block's OWN variable — writing `config.` into a `|c|` block raised
|
|
560
|
+
# NameError at boot. One pattern both finds the block and anchors the
|
|
561
|
+
# injection, so the two cannot disagree (a trailing comment or CRLF used
|
|
562
|
+
# to pass the first check and miss the second, while the generator still
|
|
563
|
+
# reported success); and the file is compared before success is claimed.
|
|
427
564
|
def inject_layout_setting(path)
|
|
428
565
|
content = path.read
|
|
429
566
|
|
|
430
|
-
if content.
|
|
567
|
+
if layout_assignments(content).any?
|
|
431
568
|
say_status "skip", "config.layout already set in config/initializers/ruact.rb", :yellow
|
|
569
|
+
warn_whole_app_on_gem_layout if app? && configured_layout_value == %("#{Ruact::GEM_LAYOUT}")
|
|
432
570
|
return
|
|
433
571
|
end
|
|
434
572
|
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
573
|
+
blocks = content.scan(CONFIGURE_BLOCK)
|
|
574
|
+
# Exactly one multi-line block, or nothing: Thor's injection edits EVERY
|
|
575
|
+
# match, and a one-line block has no line of its own to write after.
|
|
576
|
+
return warn_initializer_not_injectable(content) unless blocks.length == 1
|
|
577
|
+
|
|
578
|
+
inject_into_file "config/initializers/ruact.rb", layout_setting_snippet(blocks.first.first),
|
|
579
|
+
after: CONFIGURE_BLOCK
|
|
580
|
+
return warn_initializer_not_injectable(content) if path.read == content && !options[:pretend]
|
|
439
581
|
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
582
|
+
if whole_app?
|
|
583
|
+
say_status "update", "set config.layout = true (your own layout renders every page)", :green
|
|
584
|
+
else
|
|
585
|
+
say_status "update", "set config.layout = \"ruact\" (ruact pages render through ruact's layout)", :green
|
|
586
|
+
end
|
|
444
587
|
end
|
|
445
588
|
|
|
446
|
-
# The compiled stylesheet still has to be REQUESTED
|
|
447
|
-
# layout
|
|
448
|
-
#
|
|
449
|
-
#
|
|
450
|
-
# the
|
|
589
|
+
# The compiled Tailwind stylesheet still has to be REQUESTED, by whichever
|
|
590
|
+
# layout renders ruact pages.
|
|
591
|
+
#
|
|
592
|
+
# On ruact's own layout that is `config.layout_stylesheets`: `[:app]`
|
|
593
|
+
# (Propshaft) expands over every stylesheet on the load path, so
|
|
594
|
+
# `app/assets/builds/tailwind.css` is picked up with no further wiring. A
|
|
595
|
+
# Sprockets app gets `["application"]` instead, which does not name it.
|
|
451
596
|
#
|
|
452
|
-
#
|
|
453
|
-
#
|
|
454
|
-
#
|
|
455
|
-
#
|
|
597
|
+
# On the app's own layout (`config.layout = true` or another name) it is
|
|
598
|
+
# that layout's `stylesheet_link_tag`: `:app` picks the build up, a link
|
|
599
|
+
# BY NAME does not. Either way the failure is silent — Tailwind builds,
|
|
600
|
+
# the file is served, the page is unstyled — so warn rather than edit.
|
|
456
601
|
def warn_unless_layout_links_builds
|
|
457
|
-
|
|
602
|
+
layout = configured_app_layout
|
|
603
|
+
return warn_unless_app_layout_links_builds(layout) if layout
|
|
604
|
+
return unless detected_layout_stylesheets == SPROCKETS_STYLESHEETS
|
|
605
|
+
|
|
606
|
+
say_status "notice", "add the Tailwind build to the stylesheets ruact's layout links:", :yellow
|
|
607
|
+
say ""
|
|
608
|
+
say " config.layout_stylesheets = [\"application\", \"tailwind\"]"
|
|
609
|
+
say ""
|
|
610
|
+
say " in config/initializers/ruact.rb — `[\"application\"]` alone would leave"
|
|
611
|
+
say " app/assets/builds/tailwind.css built, served and never linked."
|
|
612
|
+
say ""
|
|
613
|
+
end
|
|
614
|
+
|
|
615
|
+
def warn_unless_app_layout_links_builds(layout)
|
|
616
|
+
layout_path = Pathname(destination_root).join("app/views/layouts/#{layout}.html.erb")
|
|
458
617
|
return unless layout_path.exist?
|
|
459
618
|
|
|
460
619
|
content = layout_path.read
|
|
@@ -484,7 +643,13 @@ module Ruact
|
|
|
484
643
|
# templates' imports, so the two generators cannot drift.
|
|
485
644
|
def show_shadcn_next_steps
|
|
486
645
|
say ""
|
|
487
|
-
|
|
646
|
+
if shadcn_gaps.empty?
|
|
647
|
+
say "shadcn prerequisites are in place (Tailwind entry, tsconfig alias, css process)."
|
|
648
|
+
else
|
|
649
|
+
say_status "attention", "shadcn prerequisites are NOT all in place:", :red
|
|
650
|
+
shadcn_gaps.each { |gap| say " - #{gap}" }
|
|
651
|
+
say "Fix these first, then run the two commands below."
|
|
652
|
+
end
|
|
488
653
|
say "Two commands remain — they are interactive and hit the network, so run them yourself:"
|
|
489
654
|
say ""
|
|
490
655
|
say " npx shadcn@latest init --base radix"
|
|
@@ -662,55 +827,112 @@ module Ruact
|
|
|
662
827
|
# Story 14.6 — a valid, lowercase npm "name" for the generated package.json,
|
|
663
828
|
# derived from the app directory. npm names must be lowercase and contain
|
|
664
829
|
# only URL-safe characters; anything else collapses to a hyphen.
|
|
665
|
-
# Kept
|
|
666
|
-
#
|
|
667
|
-
# the
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
830
|
+
# Kept in step with the `initializer.rb.tt` template's own `config.layout`
|
|
831
|
+
# block, so a migrated app and a fresh one end up reading the same thing.
|
|
832
|
+
# A method, not a constant, because the stylesheet list depends on the
|
|
833
|
+
# app's asset pipeline (see #detected_layout_stylesheets).
|
|
834
|
+
def layout_setting_snippet(variable = "config")
|
|
835
|
+
return app_layout_setting_snippet(variable) if whole_app?
|
|
836
|
+
|
|
837
|
+
<<~RUBY
|
|
838
|
+
# Render ruact pages through the layout ruact ships (layouts/ruact). It links
|
|
839
|
+
# the CSS your client components import, then your stylesheets below, and it
|
|
840
|
+
# edits none of your layouts. To change more of its <head> than the
|
|
841
|
+
# stylesheets, copy it into your app: `rails generate ruact:layout`.
|
|
842
|
+
#{variable}.layout = "ruact"
|
|
843
|
+
#{variable}.layout_stylesheets = #{detected_layout_stylesheets}
|
|
844
|
+
|
|
845
|
+
RUBY
|
|
846
|
+
end
|
|
847
|
+
|
|
848
|
+
# Whole-app mode (`--app`): the document is the app's own layout, which
|
|
849
|
+
# then needs the helpers the install prints (see #advise_app_layout).
|
|
850
|
+
def app_layout_setting_snippet(variable)
|
|
851
|
+
<<~RUBY
|
|
852
|
+
# Whole-app mode: every page renders through your own layout, which needs
|
|
853
|
+
# <%= ruact_head_assets %> in <head> and <%= ruact_js_assets %> next to a
|
|
854
|
+
# <div id="root"></div> (rails ruact:doctor checks).
|
|
855
|
+
#{variable}.layout = true
|
|
856
|
+
|
|
857
|
+
RUBY
|
|
858
|
+
end
|
|
677
859
|
|
|
678
860
|
# The initializer exists but is not the shape we know how to edit (someone
|
|
679
861
|
# rewrote it, or wrapped the configure call). Never guess at it — say what
|
|
680
862
|
# to add, so the app cannot end up half-migrated in silence.
|
|
681
|
-
|
|
863
|
+
# The lines are written with the block's OWN variable when one can be read —
|
|
864
|
+
# `config.` pasted into a `{ |c| … }` block is the NameError at boot this
|
|
865
|
+
# generator used to cause.
|
|
866
|
+
def warn_initializer_not_injectable(content = "")
|
|
867
|
+
variable = content[/Ruact\.configure\s*(?:do|\{)\s*\|(\w+)\|/, 1] || "config"
|
|
682
868
|
say_status "skip", "could not find the Ruact.configure block to update", :red
|
|
683
869
|
say ""
|
|
684
|
-
say " Add
|
|
870
|
+
say " Add these lines inside `Ruact.configure` in config/initializers/ruact.rb:"
|
|
685
871
|
say ""
|
|
686
|
-
say "
|
|
872
|
+
say " #{variable}.layout = \"ruact\""
|
|
873
|
+
say " #{variable}.layout_stylesheets = #{detected_layout_stylesheets}"
|
|
687
874
|
say ""
|
|
688
|
-
say " Without
|
|
689
|
-
say "
|
|
875
|
+
say " Without them ruact keeps using its built-in shell, which carries none"
|
|
876
|
+
say " of your stylesheets — your app's CSS will not reach a ruact-rendered page."
|
|
690
877
|
say ""
|
|
691
878
|
end
|
|
692
879
|
|
|
693
|
-
|
|
694
|
-
|
|
880
|
+
# The configure block's opening line, whatever its variable is called: at
|
|
881
|
+
# the start of a line (so a commented-out example above it does not match),
|
|
882
|
+
# followed by nothing but whitespace or a comment (so a one-line block —
|
|
883
|
+
# body and `end` on the same line — does not match and get code written
|
|
884
|
+
# after its `end`, outside it), CRLF included.
|
|
885
|
+
CONFIGURE_BLOCK = /^[ \t]*Ruact\.configure\s+do\s*\|(\w+)\|[ \t]*(?:#[^\r\n]*)?\r?\n/
|
|
886
|
+
private_constant :CONFIGURE_BLOCK
|
|
887
|
+
|
|
888
|
+
def app?
|
|
889
|
+
options[:app]
|
|
695
890
|
end
|
|
696
891
|
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
892
|
+
def application_controller_includes_ruact?
|
|
893
|
+
path = Pathname(destination_root).join("app/controllers/application_controller.rb")
|
|
894
|
+
path.exist? && path.read.match?(Ruact::CONTROLLER_INCLUDE)
|
|
895
|
+
end
|
|
896
|
+
|
|
897
|
+
# Whole-app mode: asked for with `--app`, or already the app's state (an
|
|
898
|
+
# install from before 17.0g put the include on ApplicationController).
|
|
899
|
+
def whole_app?
|
|
900
|
+
app? || application_controller_includes_ruact?
|
|
901
|
+
end
|
|
902
|
+
|
|
903
|
+
# `--app` over an island install: every page now renders through the
|
|
904
|
+
# layout ruact ships, not the app's own — its <head>, fonts and JavaScript
|
|
905
|
+
# would be gone app-wide. Said, not changed: the setting is the app's.
|
|
906
|
+
def warn_whole_app_on_gem_layout
|
|
907
|
+
say_status "notice", "whole-app mode on ruact's own layout (config.layout = \"ruact\")", :yellow
|
|
703
908
|
say ""
|
|
704
|
-
say "
|
|
705
|
-
say "
|
|
909
|
+
say " Every page of the app will now render through the layout ruact ships, without"
|
|
910
|
+
say " your own layout's <head> and JavaScript. For whole-app mode you probably want"
|
|
911
|
+
say " config.layout = true in config/initializers/ruact.rb, with ruact_head_assets and"
|
|
912
|
+
say " ruact_js_assets in your layout (rails ruact:doctor checks)."
|
|
706
913
|
say ""
|
|
707
|
-
|
|
708
|
-
|
|
914
|
+
end
|
|
915
|
+
|
|
916
|
+
def advise_whole_app_install
|
|
917
|
+
say_status "notice", "this app is in whole-app mode (ApplicationController includes Ruact::Controller)", :yellow
|
|
709
918
|
say ""
|
|
710
|
-
say "
|
|
919
|
+
say " Every action with an .html.erb template renders through ruact. The install"
|
|
920
|
+
say " no longer does this by default; your app keeps it. To render only the pages"
|
|
921
|
+
say " you choose instead, remove `include Ruact::Controller` from ApplicationController"
|
|
922
|
+
say " and add it to the controllers whose pages should be ruact."
|
|
711
923
|
say ""
|
|
712
924
|
end
|
|
713
925
|
|
|
926
|
+
def shadcn?
|
|
927
|
+
options[:shadcn]
|
|
928
|
+
end
|
|
929
|
+
|
|
930
|
+
# Prerequisites `--shadcn` could not put in place. Non-empty means
|
|
931
|
+
# show_shadcn_next_steps must not say "in place".
|
|
932
|
+
def shadcn_gaps
|
|
933
|
+
@shadcn_gaps ||= []
|
|
934
|
+
end
|
|
935
|
+
|
|
714
936
|
# The superset the scaffold generator narrows per resource. Loaded lazily
|
|
715
937
|
# (and only under `--shadcn`) so a plain install never pays for the
|
|
716
938
|
# scaffold generator's load, and so a failure to reach it degrades to the
|
|
@@ -722,6 +944,74 @@ module Ruact
|
|
|
722
944
|
"button input textarea switch select label badge table alert-dialog dropdown-menu"
|
|
723
945
|
end
|
|
724
946
|
|
|
947
|
+
# Adds the shadcn devDependencies and the build:css script to an existing
|
|
948
|
+
# package.json. Never overwrites a key the app already has (its own
|
|
949
|
+
# Tailwind version wins). The file is re-serialized with 2-space JSON
|
|
950
|
+
# when something is added. An unparseable file is left alone, loudly.
|
|
951
|
+
def merge_shadcn_package_json(path)
|
|
952
|
+
# `create_file … force: true` would DELETE the whole file under
|
|
953
|
+
# `rails destroy`; this merge has nothing to undo.
|
|
954
|
+
return if behavior == :revoke
|
|
955
|
+
|
|
956
|
+
pkg = JSON.parse(path.read.delete_prefix("\uFEFF"))
|
|
957
|
+
raise JSON::ParserError, "top level is #{pkg.class}, not an object" unless pkg.is_a?(Hash)
|
|
958
|
+
|
|
959
|
+
dev = (pkg["devDependencies"] ||= {})
|
|
960
|
+
scripts = (pkg["scripts"] ||= {})
|
|
961
|
+
flag_tailwind_below_v4(dev["tailwindcss"] || pkg.dig("dependencies", "tailwindcss"))
|
|
962
|
+
added = SHADCN_DEV_DEPENDENCIES.reject { |name, _| dev.key?(name) || pkg.dig("dependencies", name) }
|
|
963
|
+
dev.merge!(added)
|
|
964
|
+
add_script = !scripts.key?("build:css")
|
|
965
|
+
scripts["build:css"] = SHADCN_BUILD_CSS_SCRIPT if add_script
|
|
966
|
+
|
|
967
|
+
if added.empty? && !add_script
|
|
968
|
+
say_status "identical", "package.json (Tailwind already declared)", :blue
|
|
969
|
+
return
|
|
970
|
+
end
|
|
971
|
+
|
|
972
|
+
create_file "package.json", "#{JSON.pretty_generate(pkg)}\n", force: true, verbose: false
|
|
973
|
+
say_status "update", "package.json (+ #{(added.keys + (add_script ? ['build:css'] : [])).join(', ')})", :green
|
|
974
|
+
rescue JSON::ParserError => e
|
|
975
|
+
shadcn_gaps << "package.json could not be parsed (#{e.message.lines.first.strip}) — add " \
|
|
976
|
+
"#{SHADCN_DEV_DEPENDENCIES.keys.join(', ')} to devDependencies and a " \
|
|
977
|
+
"\"build:css\" script (#{SHADCN_BUILD_CSS_SCRIPT}) yourself"
|
|
978
|
+
end
|
|
979
|
+
|
|
980
|
+
# globals.css is written for Tailwind 4 (`@import "tailwindcss"`). An app
|
|
981
|
+
# pinned to an older major keeps its version, and the setup is not in place.
|
|
982
|
+
def flag_tailwind_below_v4(version)
|
|
983
|
+
major = version.to_s[/\d+/]
|
|
984
|
+
return if major.nil? || major.to_i >= 4
|
|
985
|
+
|
|
986
|
+
shadcn_gaps << "package.json pins tailwindcss #{version}; app/javascript/styles/globals.css " \
|
|
987
|
+
"and shadcn's current components need Tailwind 4"
|
|
988
|
+
end
|
|
989
|
+
|
|
990
|
+
# Appends the Tailwind watch process to an existing Procfile.dev, unless a
|
|
991
|
+
# process already builds globals.css. A different `css:` process, or
|
|
992
|
+
# another Tailwind watcher (tailwindcss-rails' compiles its own entry into
|
|
993
|
+
# the same app/assets/builds/tailwind.css), cannot be appended next to: it
|
|
994
|
+
# is reported as a gap, never silently accepted. Comment lines are ignored.
|
|
995
|
+
def append_shadcn_css_process(path)
|
|
996
|
+
content = path.read
|
|
997
|
+
code = content.lines.reject { |line| line.lstrip.start_with?("#") }
|
|
998
|
+
|
|
999
|
+
if code.any? { |line| line.include?("app/javascript/styles/globals.css") }
|
|
1000
|
+
say_status "identical", "Procfile.dev (already builds globals.css)", :blue
|
|
1001
|
+
return
|
|
1002
|
+
end
|
|
1003
|
+
|
|
1004
|
+
if (clash = code.find { |line| line.match?(/\A\s*css\s*:/) || line.include?("tailwind") })
|
|
1005
|
+
shadcn_gaps << "Procfile.dev already runs `#{clash.strip}`, which does not build " \
|
|
1006
|
+
"app/javascript/styles/globals.css — replace it with: #{SHADCN_CSS_PROCESS}"
|
|
1007
|
+
return
|
|
1008
|
+
end
|
|
1009
|
+
|
|
1010
|
+
separator = content.empty? || content.end_with?("\n") ? "" : "\n"
|
|
1011
|
+
append_to_file "Procfile.dev", "#{separator}#{SHADCN_CSS_PROCESS}\n", verbose: false
|
|
1012
|
+
say_status "update", "Procfile.dev (+ css process)", :green
|
|
1013
|
+
end
|
|
1014
|
+
|
|
725
1015
|
def app_package_name
|
|
726
1016
|
base = File.basename(File.expand_path(destination_root))
|
|
727
1017
|
sanitized = base.downcase.gsub(/[^a-z0-9._-]/, "-").squeeze("-").gsub(/\A-+|-+\z/, "")
|