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.
Files changed (40) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +59 -1
  3. data/README.md +4 -4
  4. data/lib/generators/ruact/install/install_generator.rb +418 -128
  5. data/lib/generators/ruact/install/templates/AGENTS.md.tt +14 -13
  6. data/lib/generators/ruact/install/templates/Procfile.dev.tt +1 -1
  7. data/lib/generators/ruact/install/templates/initializer.rb.tt +29 -7
  8. data/lib/generators/ruact/install/templates/package.json.tt +4 -4
  9. data/lib/generators/ruact/install/templates/tsconfig.json.tt +3 -0
  10. data/lib/generators/ruact/layout/layout_generator.rb +52 -0
  11. data/lib/generators/ruact/scaffold/scaffold_generator.rb +39 -12
  12. data/lib/generators/ruact/scaffold/scaffold_shadcn_preflight.rb +4 -3
  13. data/lib/generators/ruact/scaffold/templates/components/List.tsx.tt +8 -8
  14. data/lib/generators/ruact/scaffold/templates/components/agnostic/List.tsx.tt +8 -8
  15. data/lib/generators/ruact/scaffold/templates/controller.rb.tt +5 -3
  16. data/lib/generators/ruact/scaffold/templates/queries/query.rb.tt +2 -2
  17. data/lib/generators/ruact/scaffold/templates/views/index.html.erb.tt +1 -1
  18. data/lib/ruact/configuration.rb +71 -17
  19. data/lib/ruact/controller/document_rendering.rb +72 -16
  20. data/lib/ruact/controller/page_rendering.rb +134 -0
  21. data/lib/ruact/controller/pages.rb +116 -0
  22. data/lib/ruact/controller.rb +78 -11
  23. data/lib/ruact/doctor.rb +233 -25
  24. data/lib/ruact/layout_source.rb +29 -7
  25. data/lib/ruact/navigation_boundary.rb +240 -0
  26. data/lib/ruact/railtie.rb +30 -0
  27. data/lib/ruact/routing.rb +24 -6
  28. data/lib/ruact/server.rb +10 -1
  29. data/lib/ruact/version.rb +1 -1
  30. data/lib/ruact/view_helper.rb +158 -1
  31. data/lib/ruact/views/layouts/ruact.html.erb +32 -0
  32. data/lib/ruact.rb +29 -0
  33. data/vendor/javascript/vite-plugin-ruact/ruact-router.test.mjs +433 -0
  34. data/vendor/javascript/vite-plugin-ruact/runtime/ruact-router.js +170 -7
  35. data/vendor/javascript/vite-plugin-ruact/tsconfig.scaffold-agnostic.json +1 -1
  36. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/PostList.tsx +3 -3
  37. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/PostList.tsx +3 -3
  38. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/ambient.d.ts +4 -4
  39. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/ambient.d.ts +4 -4
  40. 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. Injects `include Ruact::Controller` into ApplicationController
15
- # 3. Injects the React root div AND `ruact_js_assets` into
16
- # app/views/layouts/application.html.erb, so the app's own layout owns
17
- # the document (and its `<head>` — stylesheets, fonts, meta — reaches a
18
- # ruact page). See Ruact::Configuration#layout.
14
+ # 2. Changes no controller (island mode, Story 17.0g) — unless `--app`, which
15
+ # injects `include Ruact::Controller` into ApplicationController
16
+ # 3. Edits NO layout of the app. ruact pages render through the layout the
17
+ # gem ships (`config.layout = "ruact"`, Story 17.0b), which links the
18
+ # client-component CSS and the app stylesheets named in
19
+ # `config.layout_stylesheets`. An app that already chose its own layout
20
+ # (`config.layout = true` or another name) gets the missing lines PRINTED,
21
+ # never written. See Ruact::Configuration#layout.
19
22
  # 4. Creates app/javascript/components/.keep
20
23
  # 5. Creates vite.config.js (or shows manual instructions if one exists)
21
24
  # 6. Creates package.json (react/react-dom/vite/@vitejs/plugin-react) so a
@@ -64,6 +67,15 @@ module Ruact
64
67
  # the real shadcn CLI) and then PRINTS the two `npx shadcn` commands
65
68
  # rather than running them — they hit the network and `shadcn init` is
66
69
  # interactive, so automating them is neither safe nor possible.
70
+ # Story 17.0g (FR116) — the install changes no controller by default
71
+ # (island mode): a page becomes ruact when its controller includes
72
+ # `Ruact::Controller`. `--app` is the explicit way to the other mode.
73
+ class_option :app,
74
+ type: :boolean,
75
+ default: false,
76
+ desc: "Whole-app mode: include Ruact::Controller in ApplicationController, so EVERY action " \
77
+ "with an .html.erb template renders through ruact (and your own layout owns the document)"
78
+
67
79
  class_option :shadcn,
68
80
  type: :boolean,
69
81
  default: false,
@@ -74,7 +86,7 @@ module Ruact
74
86
  # offered a bad choice on the one path that matters most — migrating an
75
87
  # existing app: overwrite and lose every setting the app had
76
88
  # (`strict_serialization`, `manifest_path`, the SGID defaults…), or skip
77
- # and end up half-migrated, with the layout edited but `config.layout`
89
+ # and end up half-migrated, with `config.layout`
78
90
  # still off and nothing saying so except `ruact:doctor`.
79
91
  def create_initializer
80
92
  path = Pathname(destination_root).join("config/initializers/ruact.rb")
@@ -83,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
- return unless File.exist?(Pathname(destination_root).join(controller_file))
106
+ path = Pathname(destination_root).join(controller_file)
107
+ unless path.exist?
108
+ if app?
109
+ say_status "notice", "no #{controller_file} — add `include Ruact::Controller` to the controller your " \
110
+ "pages inherit from", :yellow
111
+ end
112
+ return
113
+ end
89
114
 
90
- content = File.read(Pathname(destination_root).join(controller_file))
91
- if content.include?("Ruact::Controller")
92
- say_status "skip", "Ruact::Controller already included in ApplicationController", :yellow
115
+ if path.read.match?(Ruact::CONTROLLER_INCLUDE)
116
+ advise_whole_app_install unless app?
93
117
  return
94
118
  end
119
+ return unless app?
95
120
 
96
121
  inject_into_file controller_file,
97
122
  "\n include Ruact::Controller\n",
98
123
  after: /class ApplicationController.*\n/
99
124
  end
100
125
 
101
- # The layout owns the document: `stylesheet_link_tag`, favicons, fonts and
102
- # every `<head>`-writing gem only reach a ruact page because Rails' own
103
- # layout renders it (see `Ruact::Configuration#layout`). That requires TWO
104
- # things in the layout — the React root, and `ruact_js_assets` to emit the
105
- # bootstrap entry + this render's Flight payload. The other half of the
106
- # opt-in is `config.layout = true`, which the generated initializer
107
- # carries — a layout with the helper but the setting off (or the reverse)
108
- # keeps rendering through ruact's built-in, CSS-less shell.
109
- def inject_layout_shell
110
- layout_file = "app/views/layouts/application.html.erb"
111
- return unless File.exist?(Pathname(destination_root).join(layout_file))
112
-
113
- content = File.read(Pathname(destination_root).join(layout_file))
114
-
115
- # A CALL, not a mention: `<%# TODO: add ruact_js_assets %>` used to read
116
- # as "already present" here and skip the migration, leaving the app on
117
- # ruact's CSS-less shell with the generator reporting success. Shared
118
- # with the runtime so both agree on what "migrated" means.
119
- # BOTH halves, not just the helper. A layout carrying `ruact_js_assets`
120
- # with no `<div id="root"></div>` was skipped as "already present" — and
121
- # since this generator also turns `config.layout` on, that app then
122
- # raised at render time on a document React could not mount into.
123
- if Ruact::LayoutSource.wired?(content) && Ruact::LayoutSource.root?(content)
124
- say_status "skip", "ruact root + assets already present in layout", :yellow
126
+ # Story 17.0b — the install no longer writes into any layout of the app.
127
+ # An app on the gem's layout needs nothing. An app that already chose its
128
+ # OWN layout (`config.layout = true`, or a name that is not "ruact") has
129
+ # to call three helpers there; this reads that layout — never writes it —
130
+ # and prints each missing line with where it goes. Writing it was the
131
+ # previous design, and matching HTML+ERB with a regex took three review
132
+ # rounds without a correct version.
133
+ def advise_app_layout
134
+ layout = configured_app_layout
135
+ return unless layout
136
+
137
+ path = Pathname(destination_root).join("app/views/layouts/#{layout}.html.erb")
138
+ unless path.exist?
139
+ say_status "notice", "config.layout names #{path.basename}, which does not exist", :yellow
125
140
  return
126
141
  end
127
142
 
128
- # Migration path for an app installed before the layout owned the
129
- # document: the root is already there, only the asset call is missing.
130
- #
131
- # The anchor matches the ROOT DIV itself rather than the marker-then-div
132
- # pair, and tolerates the ways a real layout is written — single or
133
- # double quotes, extra attributes, any attribute order, CRLF, and the
134
- # marker on the same line. The earlier anchor required the exact emitted
135
- # formatting, so a hand-edited layout silently matched nothing. The
136
- # attribute boundary in `ROOT_ELEMENT` is what keeps `data-id="root"`
137
- # from being mistaken for the mount point.
138
- if Ruact::LayoutSource.root?(content)
139
- return migrate_layout(layout_file,
140
- "\n <%= ruact_js_assets %>",
141
- after: Ruact::LayoutSource::ROOT_ELEMENT,
142
- success: "added ruact_js_assets to the existing layout root")
143
- end
143
+ missing = missing_layout_lines(path.read)
144
+ return if missing.empty?
144
145
 
145
- # The mirror case: the helper is there but the mount target is not, so
146
- # the root goes in just BEFORE the call (React needs the node in the
147
- # document, and keeping the pair adjacent matches what a fresh install
148
- # writes). Injecting the whole block instead would duplicate the helper.
149
- if Ruact::LayoutSource.wired?(content)
150
- return migrate_layout(layout_file,
151
- "<%# ruact: root %>\n <div id=\"root\"></div>\n ",
152
- before: Ruact::LayoutSource::ASSETS_CALL,
153
- success: "added the React root div next to the existing ruact_js_assets")
154
- end
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
- create_guarded_file "Procfile.dev", "Procfile.dev.tt"
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.\n"
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
- # `inject_into_file` prints "File unchanged!" and carries on when its
412
- # anchor misses, so reporting success without checking would be a lie —
413
- # and the app would keep rendering through ruact's CSS-less shell with no
414
- # clue why. Compare the file around the call and only claim what happened.
415
- def migrate_layout(layout_file, content, success:, **anchor)
416
- path = Pathname(destination_root).join(layout_file)
417
- before = path.read
418
- inject_into_file layout_file, content, **anchor
419
-
420
- if path.read == before
421
- warn_layout_migration_failed
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
- say_status "update", success, :green
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.match?(/^\s*config\.layout\s*=/)
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
- unless content.match?(/Ruact\.configure\s+do\s*\|(\w+)\|/)
436
- warn_initializer_not_injectable
437
- return
438
- end
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
- inject_into_file "config/initializers/ruact.rb",
441
- LAYOUT_SETTING_SNIPPET,
442
- after: /Ruact\.configure\s+do\s*\|\w+\|\n/
443
- say_status "update", "set config.layout = true (your layout renders ruact pages)", :green
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. Rails 8's default
447
- # layout links `stylesheet_link_tag :app`, which Propshaft expands over
448
- # every stylesheet on the load path — so `app/assets/builds/tailwind.css`
449
- # is picked up with no further wiring (verified against a generated app:
450
- # the rendered `<head>` carries `/assets/tailwind-<digest>.css`).
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
- # A layout that instead links stylesheets BY NAME never asks for it, and
453
- # the failure is silent: Tailwind builds fine, Propshaft serves it fine,
454
- # and the page is simply unstyled. Warn rather than edit — which
455
- # stylesheets a layout links is the app's business.
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
- layout_path = Pathname(destination_root).join("app/views/layouts/application.html.erb")
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
- say "shadcn prerequisites are in place (Tailwind entry, tsconfig alias, css process)."
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 verbatim in step with the `initializer.rb.tt` template's own
666
- # `config.layout` block, so a migrated app and a fresh one end up reading
667
- # the same thing.
668
- LAYOUT_SETTING_SNIPPET = <<~RUBY
669
- # Render ruact pages through this app's own layout, so the document `<head>`
670
- # is yours: stylesheets, favicons, fonts and any gem that writes into
671
- # `<head>` reach a ruact page. Requires the layout to call
672
- # `<%= ruact_js_assets %>` (this generator adds it next to the React root).
673
- config.layout = true
674
-
675
- RUBY
676
- private_constant :LAYOUT_SETTING_SNIPPET
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
- def warn_initializer_not_injectable
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 this line inside `Ruact.configure` in config/initializers/ruact.rb:"
870
+ say " Add these lines inside `Ruact.configure` in config/initializers/ruact.rb:"
685
871
  say ""
686
- say " config.layout = true"
872
+ say " #{variable}.layout = \"ruact\""
873
+ say " #{variable}.layout_stylesheets = #{detected_layout_stylesheets}"
687
874
  say ""
688
- say " Without it ruact keeps using its built-in shell, which carries no"
689
- say " stylesheet — your app's CSS will not reach a ruact-rendered page."
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
- def shadcn?
694
- options[:shadcn]
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
- # Printed when the layout carries the ruact marker but the anchor found no
698
- # root div to inject after. Silence would be the dangerous outcome: the app
699
- # keeps rendering through ruact's CSS-less built-in shell, and nothing ever
700
- # says why.
701
- def warn_layout_migration_failed
702
- say_status "skip", "could not locate the React root div in the layout", :red
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 " ruact could not add `ruact_js_assets` automatically. Add it by hand,"
705
- say " just after the root div in app/views/layouts/application.html.erb:"
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
- say " <div id=\"root\"></div>"
708
- say " <%= ruact_js_assets %>"
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 " Without it your app's CSS cannot reach a ruact-rendered page."
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/, "")