ruact 0.0.8 → 0.0.9

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 (32) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +35 -1
  3. data/lib/generators/ruact/install/install_generator.rb +264 -5
  4. data/lib/generators/ruact/install/templates/Procfile.dev.tt +3 -0
  5. data/lib/generators/ruact/install/templates/globals.css.tt +20 -0
  6. data/lib/generators/ruact/install/templates/initializer.rb.tt +9 -0
  7. data/lib/generators/ruact/install/templates/package.json.tt +7 -1
  8. data/lib/generators/ruact/install/templates/tsconfig.json.tt +18 -0
  9. data/lib/generators/ruact/scaffold/scaffold_shadcn_preflight.rb +10 -0
  10. data/lib/ruact/configuration.rb +73 -0
  11. data/lib/ruact/controller/document_rendering.rb +210 -0
  12. data/lib/ruact/controller.rb +5 -46
  13. data/lib/ruact/doctor.rb +37 -5
  14. data/lib/ruact/layout_source.rb +59 -0
  15. data/lib/ruact/version.rb +1 -1
  16. data/lib/ruact/view_helper.rb +10 -1
  17. data/lib/ruact.rb +1 -0
  18. data/spec/fixtures/story_7_9_views/controller_request_spec_support/exploding_layout_demo/show.html.erb +3 -0
  19. data/spec/fixtures/story_7_9_views/controller_request_spec_support/ghost_layout_demo/show.html.erb +3 -0
  20. data/spec/fixtures/story_7_9_views/controller_request_spec_support/layout_demo/show.html.erb +3 -0
  21. data/spec/fixtures/story_7_9_views/controller_request_spec_support/rootless_layout_demo/show.html.erb +3 -0
  22. data/spec/fixtures/story_7_9_views/controller_request_spec_support/unwired_layout_demo/show.html.erb +3 -0
  23. data/spec/fixtures/story_7_9_views/layouts/bare_host.html.erb +16 -0
  24. data/spec/fixtures/story_7_9_views/layouts/exploding_host.html.erb +24 -0
  25. data/spec/fixtures/story_7_9_views/layouts/rootless_host.html.erb +15 -0
  26. data/spec/fixtures/story_7_9_views/layouts/ruact_host.html.erb +17 -0
  27. data/spec/ruact/controller_request_spec.rb +203 -0
  28. data/spec/ruact/doctor_spec.rb +81 -6
  29. data/spec/ruact/install_generator_spec.rb +442 -70
  30. data/spec/ruact/layout_source_spec.rb +108 -0
  31. data/spec/ruact/scaffold_generator_spec.rb +14 -0
  32. metadata +18 -4
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f9a45065af0d01daed8548084a9667fb066f9e8c19a685f99b0f101a8e825ca0
4
- data.tar.gz: 99206ecd7d61a44b4cdfe52bb5b40b046f66e2938f7bf060e603b98ef17971b8
3
+ metadata.gz: f1722a96137257e2263f7aee2e2315c4a197f634fad94096da555de6d7a50af9
4
+ data.tar.gz: 34d485a11f2b0cf893770bddbf4c48fa757d908f0f24e0c64e03d2f612bd351f
5
5
  SHA512:
6
- metadata.gz: d303e5aa55881c70b88a6a7f132450712c8f35adb7b00f0172d3503784dbe82b1373ae7ecc30db05a98832f2c2154134e49bbba43ba403497cdc61b90401b91a
7
- data.tar.gz: 8b8fcc40cca7167dece277333fdd56562a62800d150af0be85a50033fc6a2ea3dc8aeebf7650dbb220891f73a843a80d2b0becd1f1d7483b4a2afa6af8a1404e
6
+ metadata.gz: 770a14ec78795d73afed6bc044f2616167e5cd4a6b173489ad9c8e8c1df893534b54e77f5515e59a3fecbd54a4d2763eaaa3a920cbf40fc7594922e7e831a912
7
+ data.tar.gz: 41fc153c5d7470df08b7ca02b2c2da2a2ae0c74c717186119ed2eaa4504b903da1cfab43754a21f738b40541afc4ed98534f05fdcf4e3ba3c317f963b351b27b
data/CHANGELOG.md CHANGED
@@ -7,6 +7,38 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.0.9] - 2026-08-12
11
+
12
+ ### Added
13
+
14
+ - **`rails generate ruact:install --shadcn` — the `--shadcn` scaffold path now has a working setup.** `ruact:scaffold --shadcn` emits components dressed in Tailwind classes, but nothing in ruact ever wired Tailwind, so the generated CRUD rendered **unstyled**: the classes had nothing to resolve against. Worse, the documented next step did not work either — shadcn's own CLI **refuses to initialize** in a ruact app, aborting with *"No Tailwind CSS configuration found"* and *"Could not find valid path aliases"*, because a fresh ruact app ships neither Tailwind nor a `tsconfig.json`.
15
+
16
+ The new flag emits exactly those prerequisites, each verified against the real shadcn CLI: `app/javascript/styles/globals.css` (the Tailwind entry shadcn appends its design tokens to, and points `components.json` at), a `tsconfig.json` carrying the `@/*` → `app/javascript/*` alias (the bundled Vite plugin already registers the same alias for the *bundler*; this is what makes it resolve for *TypeScript*, and therefore for shadcn's probe and your editor), `app/assets/builds/` with the compiled stylesheet gitignored, the `build:css` script, and a `css` process in `Procfile.dev` so `bin/dev` rebuilds the stylesheet alongside Rails and Vite.
17
+
18
+ It then **prints** the two `npx shadcn` commands instead of running them — they hit the network, and `shadcn init` is interactive, so automating them is neither safe (the scaffold generator's "never auto-run npx/npm" rule) nor possible. The printed `init` line pins **`--base radix`**, which is the part nobody would guess: current shadcn defaults to **Base UI**, while the components `ruact:scaffold --shadcn` generates import **Radix** primitives — accepting the default gets you a component library the scaffold cannot use. The printed `add` list is the complete primitive superset, pinned by a spec as a superset of whatever a given resource narrows to, so the two generators cannot drift.
19
+
20
+ **The default (agnostic) path is untouched** — no Tailwind, no `tsconfig.json`, and a byte-identical `package.json` and `Procfile.dev`, all pinned by specs.
21
+
22
+ Verified end to end twice from `rails new`: the generated CRUD renders as a styled shadcn table with the `useQuery` search filtering rows live. Note that shadcn's CLI is currently **4.x** while `shadcn_compatible_versions` defaults to `[1, 2]`, so the scaffold's version pre-flight emits its (non-blocking) warning; the generated components do work against 4.x.
23
+
24
+ ### Fixed
25
+
26
+ - **A ruact page can now carry your app's CSS — the Rails layout owns the document.** `ruact_render` rendered the view with `layout: false` and then wrapped the Flight payload in a hardcoded HTML shell whose `<head>` holds only `charset`, `viewport`, the CSRF meta tag and the title `Rails RSC`. That shell has **no stylesheet slot and never uses your layout**, so `stylesheet_link_tag` — and with it favicons, fonts, analytics and every `<head>`-writing gem — could not reach a ruact-rendered page **at all**. The visible consequence: `rails generate ruact:scaffold --shadcn` emitted components whose Tailwind classes had nothing to resolve against, so the generated CRUD was **unstyled by construction**; the docs' instruction to put shadcn theme variables in `app/assets/stylesheets/application.css` pointed at a file that provably never reached the browser; and Epic 12 (`ruact_meta` → tags in `<head>`) had no surface to write into. The default agnostic scaffold was affected too — it renders plain semantic HTML and only *looked* acceptable because browsers style a bare `<table>`.
27
+
28
+ A non-Flight HTML response is now rendered **through the host app's own layout**, with the React root's bootstrap tags supplied by the layout's `<%= ruact_js_assets %>` call (which, called with no argument, picks up the render's Flight payload). The Flight wire shape (`text/x-component`) is untouched — this only changes the full document a browser gets on a normal navigation.
29
+
30
+ Controlled by the new **`Ruact.config.layout`** — `false` by default, `true` (or a layout name) to opt in. `rails generate ruact:install` now writes **both halves of that opt-in in the same run**: `config.layout = true` in the generated initializer, and `<%= ruact_js_assets %>` next to the React root in your layout.
31
+
32
+ **The default is `false`, so an existing app is untouched until it opts in** — not because ruact detects anything, but because it does not look. An earlier cut of this change tried to infer whether your layout was ready by inspecting it; three review rounds each found another template shape that fooled the inference (a mention in a comment, a commented-out call, a trim-mode comment), and each wrong answer decided how every page in the app rendered. "Does this template call this method?" is not a question pattern-matching can answer reliably, so it is no longer asked. One explicit line beats a clever guess for a setting this load-bearing. (`:auto` is rejected with a message naming its replacement, so an initializer carrying it forward cannot be silently reinterpreted.)
33
+
34
+ Once opted in, a layout that cannot mount the app is a configuration error rather than a silent blank page: ruact checks the rendered document for both the assets block and a `<div id="root">`, raising in development/test and logging-and-degrading to the shell in production. A controller with no resolvable layout (API-shaped, or `layout false`) degrades quietly instead — that is a normal Rails choice, not a mistake.
35
+
36
+ **To migrate an existing app:** add `<%= ruact_js_assets %>` next to the `<div id="root"></div>` in `app/views/layouts/application.html.erb`, or re-run `rails generate ruact:install` (which now injects both, and adds only the missing call to a layout that already has the root — tolerating single quotes, extra attributes and a same-line marker, and telling you loudly if it cannot find the root div rather than reporting a success it did not perform). `rails ruact:doctor` reports the half-migrated state as a **warning** naming the one-line fix — where it previously reported a root-only layout as a pass.
37
+
38
+ **Known limitation:** a ruact view is rendered in its own pass (it produces the component tree), so `content_for` declared *inside the view* does not reach the layout. Set document metadata from the controller.
39
+
40
+ `rails ruact:doctor` reports the two halves separately, because they are different fixes: a layout missing the root or the helper **fails**, and a ready layout with `config.layout = false` **warns** and names the setting.
41
+
10
42
  ## [0.0.8] - 2026-07-11
11
43
 
12
44
  ### Changed
@@ -192,6 +224,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
192
224
  - **CI matrix** — GitHub Actions: RSpec across Ruby 3.2 × 3.3 × Rails 7.0 × 7.1 × 7.2 × 8.0; RuboCop; YARD docs; memory benchmark; E2E system tests against React 19.0.0 and 19.x (Capybara + Cuprite); non-blocking React@next job with auto-issue on failure.
193
225
  - **E2E test app** — `e2e/` Rails app (no DB, in-memory Post model) with full CRUD system tests validating the complete request cycle.
194
226
 
195
- [Unreleased]: https://github.com/luizcg/ruact/compare/v0.0.7...HEAD
227
+ [Unreleased]: https://github.com/luizcg/ruact/compare/v0.0.9...HEAD
228
+ [0.0.9]: https://github.com/luizcg/ruact/releases/tag/v0.0.9
229
+ [0.0.8]: https://github.com/luizcg/ruact/releases/tag/v0.0.8
196
230
  [0.0.7]: https://github.com/luizcg/ruact/releases/tag/v0.0.7
197
231
  [0.0.6]: https://github.com/luizcg/ruact/releases/tag/v0.0.6
@@ -12,7 +12,10 @@ module Ruact
12
12
  # Performs the following actions:
13
13
  # 1. Creates config/initializers/ruact.rb
14
14
  # 2. Injects `include Ruact::Controller` into ApplicationController
15
- # 3. Injects the React root div into app/views/layouts/application.html.erb
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.
16
19
  # 4. Creates app/javascript/components/.keep
17
20
  # 5. Creates vite.config.js (or shows manual instructions if one exists)
18
21
  # 6. Creates package.json (react/react-dom/vite/@vitejs/plugin-react) so a
@@ -25,6 +28,10 @@ module Ruact
25
28
  # verification commands in context by default (Story 15.1, FR105).
26
29
  # 9. Runs `npm install` so JavaScript dependencies are ready (FR101);
27
30
  # skippable via --skip-npm.
31
+ # 10. With `--shadcn`: also emits the prerequisites shadcn's own CLI refuses
32
+ # to initialize without (a Tailwind entry + a `tsconfig.json` import
33
+ # alias), wires the `css` build process, and prints the two `npx shadcn`
34
+ # commands it deliberately does not run.
28
35
  #
29
36
  # Story 14.2 (FR104) — the generator no longer writes a bootstrap entry into
30
37
  # the user's tree. ruact's React entry is served as the virtual module
@@ -48,8 +55,32 @@ module Ruact
48
55
  default: false,
49
56
  desc: "Skip running npm install (for CI or non-npm package managers)"
50
57
 
58
+ # Prepares the app for the `ruact:scaffold --shadcn` path. shadcn's own
59
+ # CLI refuses to initialize without BOTH Tailwind and a TypeScript import
60
+ # alias, and a ruact app has neither — it ships no Tailwind and no
61
+ # `tsconfig.json` at all. That left `--shadcn` scaffolding into an app
62
+ # where the generated components' classes resolved to nothing: markup with
63
+ # no styling. This flag emits exactly the prerequisites (verified against
64
+ # the real shadcn CLI) and then PRINTS the two `npx shadcn` commands
65
+ # rather than running them — they hit the network and `shadcn init` is
66
+ # interactive, so automating them is neither safe nor possible.
67
+ class_option :shadcn,
68
+ type: :boolean,
69
+ default: false,
70
+ desc: "Also wire Tailwind + a TS import alias, the prerequisites for `ruact:scaffold --shadcn`"
71
+
72
+ # A fresh app gets the whole initializer. An app that ALREADY has one gets
73
+ # a surgical injection instead of `template`'s overwrite prompt, which
74
+ # offered a bad choice on the one path that matters most — migrating an
75
+ # existing app: overwrite and lose every setting the app had
76
+ # (`strict_serialization`, `manifest_path`, the SGID defaults…), or skip
77
+ # and end up half-migrated, with the layout edited but `config.layout`
78
+ # still off and nothing saying so except `ruact:doctor`.
51
79
  def create_initializer
52
- template "initializer.rb.tt", "config/initializers/ruact.rb"
80
+ path = Pathname(destination_root).join("config/initializers/ruact.rb")
81
+ return template "initializer.rb.tt", "config/initializers/ruact.rb" unless path.exist?
82
+
83
+ inject_layout_setting(path)
53
84
  end
54
85
 
55
86
  def inject_controller_concern
@@ -67,21 +98,93 @@ module Ruact
67
98
  after: /class ApplicationController.*\n/
68
99
  end
69
100
 
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.
70
109
  def inject_layout_shell
71
110
  layout_file = "app/views/layouts/application.html.erb"
72
111
  return unless File.exist?(Pathname(destination_root).join(layout_file))
73
112
 
74
113
  content = File.read(Pathname(destination_root).join(layout_file))
75
- if content.include?("ruact: root")
76
- say_status "skip", "Rails RSC root already present in layout", :yellow
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
77
125
  return
78
126
  end
79
127
 
128
+ # Migration path for an app installed before the layout owned the
129
+ # document: the root is already there, only the asset call is missing.
130
+ #
131
+ # The anchor matches the ROOT DIV itself rather than the marker-then-div
132
+ # pair, and tolerates the ways a real layout is written — single or
133
+ # double quotes, extra attributes, any attribute order, CRLF, and the
134
+ # marker on the same line. The earlier anchor required the exact emitted
135
+ # formatting, so a hand-edited layout silently matched nothing. The
136
+ # attribute boundary in `ROOT_ELEMENT` is what keeps `data-id="root"`
137
+ # from being mistaken for the mount point.
138
+ if Ruact::LayoutSource.root?(content)
139
+ return migrate_layout(layout_file,
140
+ "\n <%= ruact_js_assets %>",
141
+ after: Ruact::LayoutSource::ROOT_ELEMENT,
142
+ success: "added ruact_js_assets to the existing layout root")
143
+ end
144
+
145
+ # The mirror case: the helper is there but the mount target is not, so
146
+ # the root goes in just BEFORE the call (React needs the node in the
147
+ # document, and keeping the pair adjacent matches what a fresh install
148
+ # writes). Injecting the whole block instead would duplicate the helper.
149
+ if Ruact::LayoutSource.wired?(content)
150
+ return migrate_layout(layout_file,
151
+ "<%# ruact: root %>\n <div id=\"root\"></div>\n ",
152
+ before: Ruact::LayoutSource::ASSETS_CALL,
153
+ success: "added the React root div next to the existing ruact_js_assets")
154
+ end
155
+
80
156
  inject_into_file layout_file,
81
- "\n <%# ruact: root %>\n <div id=\"root\"></div>\n",
157
+ "\n <%# ruact: root %>\n <div id=\"root\"></div>\n <%= ruact_js_assets %>\n",
82
158
  before: " </body>"
83
159
  end
84
160
 
161
+ # `--shadcn` only. Two files, both of them things shadcn's CLI checks for
162
+ # and refuses to proceed without ("No Tailwind CSS configuration found" /
163
+ # "Could not find valid path aliases"), verified against shadcn 4.x:
164
+ #
165
+ # app/javascript/styles/globals.css — the Tailwind entry. shadcn appends
166
+ # its design tokens here, which is why components.json points at it.
167
+ # tsconfig.json — the `@/*` → `app/javascript/*` alias. The Vite plugin
168
+ # already registers the same alias for the BUNDLER, so components
169
+ # resolve at runtime today; this is what makes it resolve for
170
+ # TypeScript (and therefore for shadcn's alias probe and your editor).
171
+ #
172
+ # Both are guarded: an app that already has them keeps its own.
173
+ def create_shadcn_prerequisites
174
+ return unless shadcn?
175
+
176
+ create_guarded_file "app/javascript/styles/globals.css", "globals.css.tt"
177
+ create_guarded_file "tsconfig.json", "tsconfig.json.tt"
178
+ # Propshaft only serves directories that exist; the built stylesheet is
179
+ # generated, so the directory ships with a .keep and the artifact is
180
+ # gitignored (see append_gitignore_entries).
181
+ empty_directory "app/assets/builds"
182
+ create_file "app/assets/builds/.keep" unless
183
+ File.exist?(Pathname(destination_root).join("app/assets/builds/.keep"))
184
+
185
+ warn_unless_layout_links_builds
186
+ end
187
+
85
188
  def create_components_directory
86
189
  empty_directory "app/javascript/components"
87
190
  create_file "app/javascript/components/.keep" unless
@@ -106,6 +209,10 @@ module Ruact
106
209
  "app/javascript/.ruact/server-functions.ts",
107
210
  "tmp/cache/ruact/"
108
211
  ]
212
+ # The compiled stylesheet is a build artifact of globals.css, rebuilt by
213
+ # the Procfile's `css` process on every boot — same reasoning as the
214
+ # generated server-functions module above.
215
+ entries << "app/assets/builds/tailwind.css" if shadcn?
109
216
  # Substring matches (`existing.include?(entry)`) were unsafe — they
110
217
  # would skip "tmp/cache/ruact/" when the file already contained
111
218
  # "tmp/cache/ruact/some-cache.bin", leaving the directory itself
@@ -291,6 +398,8 @@ module Ruact
291
398
  say " 2. Start your app: bin/dev"
292
399
  end
293
400
 
401
+ show_shadcn_next_steps if shadcn?
402
+
294
403
  say "\nThen add <MyComponent /> to any ERB view.\n"
295
404
  say "Note: re-run this generator after updating the ruact gem to refresh"
296
405
  say "the bundled Vite plugin path in vite.config.js."
@@ -299,6 +408,96 @@ module Ruact
299
408
 
300
409
  private
301
410
 
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
422
+ else
423
+ say_status "update", success, :green
424
+ end
425
+ end
426
+
427
+ def inject_layout_setting(path)
428
+ content = path.read
429
+
430
+ if content.match?(/^\s*config\.layout\s*=/)
431
+ say_status "skip", "config.layout already set in config/initializers/ruact.rb", :yellow
432
+ return
433
+ end
434
+
435
+ unless content.match?(/Ruact\.configure\s+do\s*\|(\w+)\|/)
436
+ warn_initializer_not_injectable
437
+ return
438
+ end
439
+
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
444
+ end
445
+
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`).
451
+ #
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.
456
+ def warn_unless_layout_links_builds
457
+ layout_path = Pathname(destination_root).join("app/views/layouts/application.html.erb")
458
+ return unless layout_path.exist?
459
+
460
+ content = layout_path.read
461
+ return unless content.include?("stylesheet_link_tag")
462
+ return if content.match?(/stylesheet_link_tag\s+:app\b/) || content.include?("tailwind")
463
+
464
+ say_status "notice", "your layout links stylesheets by name — add the built one:", :yellow
465
+ say ""
466
+ say " <%= stylesheet_link_tag \"tailwind\" %>"
467
+ say ""
468
+ say " `stylesheet_link_tag :app` (the Rails 8 default) would pick up"
469
+ say " app/assets/builds/tailwind.css on its own; a named link does not,"
470
+ say " and the page would render unstyled with no error."
471
+ say ""
472
+ end
473
+
474
+ # The generator does NOT run these: `shadcn init` is interactive (it
475
+ # prompts for a component library and a preset) and both commands hit the
476
+ # network. What it CAN do is spell them exactly, which is the part nobody
477
+ # guesses: current shadcn defaults to **Base UI**, while ruact's generated
478
+ # components import **Radix** primitives — accepting the default gives you
479
+ # a component library the scaffold cannot use. Hence the explicit
480
+ # `--base radix`.
481
+ #
482
+ # The component list is ruact's own authoritative set
483
+ # (ScaffoldGenerator#required_shadcn_components), derived from the
484
+ # templates' imports, so the two generators cannot drift.
485
+ def show_shadcn_next_steps
486
+ say ""
487
+ say "shadcn prerequisites are in place (Tailwind entry, tsconfig alias, css process)."
488
+ say "Two commands remain — they are interactive and hit the network, so run them yourself:"
489
+ say ""
490
+ say " npx shadcn@latest init --base radix"
491
+ say " npx shadcn@latest add #{shadcn_add_list}"
492
+ say ""
493
+ say " (--base radix matters: shadcn now defaults to Base UI, but the components"
494
+ say " `ruact:scaffold --shadcn` generates import Radix primitives.)"
495
+ say " (init also asks you to pick a style preset — any of them works.)"
496
+ say ""
497
+ say "Then scaffold a resource:"
498
+ say " bin/rails generate ruact:scaffold Post title:string body:text --shadcn"
499
+ end
500
+
302
501
  # Story 15.1 — the exact marker tokens delimiting the ruact-managed
303
502
  # section of AGENTS.md. For a prose file the only safe idempotency key is
304
503
  # an explicit marker pair (`append_gitignore_entries`-style exact-line
@@ -463,6 +662,66 @@ module Ruact
463
662
  # Story 14.6 — a valid, lowercase npm "name" for the generated package.json,
464
663
  # derived from the app directory. npm names must be lowercase and contain
465
664
  # 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
677
+
678
+ # The initializer exists but is not the shape we know how to edit (someone
679
+ # rewrote it, or wrapped the configure call). Never guess at it — say what
680
+ # to add, so the app cannot end up half-migrated in silence.
681
+ def warn_initializer_not_injectable
682
+ say_status "skip", "could not find the Ruact.configure block to update", :red
683
+ say ""
684
+ say " Add this line inside `Ruact.configure` in config/initializers/ruact.rb:"
685
+ say ""
686
+ say " config.layout = true"
687
+ 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."
690
+ say ""
691
+ end
692
+
693
+ def shadcn?
694
+ options[:shadcn]
695
+ end
696
+
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
703
+ 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:"
706
+ say ""
707
+ say " <div id=\"root\"></div>"
708
+ say " <%= ruact_js_assets %>"
709
+ say ""
710
+ say " Without it your app's CSS cannot reach a ruact-rendered page."
711
+ say ""
712
+ end
713
+
714
+ # The superset the scaffold generator narrows per resource. Loaded lazily
715
+ # (and only under `--shadcn`) so a plain install never pays for the
716
+ # scaffold generator's load, and so a failure to reach it degrades to the
717
+ # literal list rather than aborting an otherwise-successful install.
718
+ def shadcn_add_list
719
+ require_relative "../scaffold/scaffold_shadcn_preflight"
720
+ ScaffoldGenerator::ShadcnPreflight::ALL_SHADCN_COMPONENTS.join(" ")
721
+ rescue StandardError
722
+ "button input textarea switch select label badge table alert-dialog dropdown-menu"
723
+ end
724
+
466
725
  def app_package_name
467
726
  base = File.basename(File.expand_path(destination_root))
468
727
  sanitized = base.downcase.gsub(/[^a-z0-9._-]/, "-").squeeze("-").gsub(/\A-+|-+\z/, "")
@@ -1,2 +1,5 @@
1
1
  web: bin/rails server -p 3000
2
2
  vite: npm run dev
3
+ <% if shadcn? -%>
4
+ css: npx @tailwindcss/cli -i app/javascript/styles/globals.css -o app/assets/builds/tailwind.css --watch
5
+ <% end -%>
@@ -0,0 +1,20 @@
1
+ /* Tailwind entry — the SOURCE, not the asset the browser gets.
2
+ *
3
+ * Built into app/assets/builds/tailwind.css by the `css` process in
4
+ * Procfile.dev, which Propshaft then serves and the layout links:
5
+ *
6
+ * this file ──build──> app/assets/builds/tailwind.css
7
+ * ──Propshaft──> /assets/tailwind-<digest>.css
8
+ * ──stylesheet_link_tag in app/views/layouts──> the page
9
+ *
10
+ * `npx shadcn init` appends its design tokens (the CSS variables the
11
+ * components reference by name) below this import — that is why
12
+ * components.json points its `tailwind.css` at this file.
13
+ */
14
+ @import "tailwindcss";
15
+
16
+ /* Tailwind v4 declares its class scan here instead of in a config file.
17
+ Components carry most of the classes; the ERB views carry the page-level
18
+ ones, so both paths are needed. */
19
+ @source "../components";
20
+ @source "../../views";
@@ -1,6 +1,15 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  Ruact.configure do |config|
4
+ # Render ruact pages through your app's own layout, so the document `<head>`
5
+ # is yours: `stylesheet_link_tag`, favicons, fonts, analytics and any gem that
6
+ # writes into `<head>` all reach a ruact page. Requires the layout to call
7
+ # `<%%= ruact_js_assets %>` (this generator adds it next to the React root).
8
+ #
9
+ # Set to false to use ruact's built-in minimal shell instead — it has no
10
+ # stylesheet slot, so your app's CSS will NOT reach a ruact-rendered page.
11
+ config.layout = true
12
+
4
13
  # Path to the react-client-manifest.json generated by the Vite plugin.
5
14
  # Defaults to Rails.root.join("public/react-client-manifest.json").
6
15
  # config.manifest_path = Rails.root.join("public", "react-client-manifest.json")
@@ -4,7 +4,8 @@
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "dev": "vite",
7
- "build": "vite build"
7
+ "build": "vite build"<% if shadcn? %>,
8
+ "build:css": "@tailwindcss/cli -i app/javascript/styles/globals.css -o app/assets/builds/tailwind.css --minify"<% end %>
8
9
  },
9
10
  "dependencies": {
10
11
  "react": "^19.0.0",
@@ -12,6 +13,11 @@
12
13
  },
13
14
  "devDependencies": {
14
15
  "@vitejs/plugin-react": "^4.3.4",
16
+ <% if shadcn? -%>
17
+ "@tailwindcss/cli": "^4.0.0",
18
+ "tailwindcss": "^4.0.0",
19
+ "tw-animate-css": "^1.0.0",
20
+ <% end -%>
15
21
  "vite": "^6.0.7"
16
22
  }
17
23
  }
@@ -0,0 +1,18 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2020",
4
+ "lib": ["ES2020", "DOM", "DOM.Iterable"],
5
+ "module": "ESNext",
6
+ "moduleResolution": "bundler",
7
+ "jsx": "react-jsx",
8
+ "strict": true,
9
+ "noEmit": true,
10
+ "esModuleInterop": true,
11
+ "skipLibCheck": true,
12
+ "baseUrl": ".",
13
+ "paths": {
14
+ "@/*": ["app/javascript/*"]
15
+ }
16
+ },
17
+ "include": ["app/javascript/**/*.ts", "app/javascript/**/*.tsx"]
18
+ }
@@ -47,6 +47,16 @@ module Ruact
47
47
  # List/DeleteDialog primitives. EVERY entry is a plain `npx shadcn add`
48
48
  # primitive — there is NO `data-table` recipe and NO `@tanstack/react-table`
49
49
  # dependency (Story 10.2b removed the engine; the List is a plain `table`).
50
+ # The COMPLETE primitive set — the union of everything any generated
51
+ # resource can import. `#required_shadcn_components` NARROWS this per
52
+ # resource (the input-family entries depend on the attribute types),
53
+ # but `ruact:install --shadcn` runs before any resource exists, so it
54
+ # prints this superset. A spec pins the narrowed list as a subset of
55
+ # this one, so the two cannot drift apart.
56
+ ALL_SHADCN_COMPONENTS = %w[
57
+ button input textarea switch select label badge table alert-dialog dropdown-menu
58
+ ].freeze
59
+
50
60
  def required_shadcn_components
51
61
  components = ["button"]
52
62
  components << "input" if form_uses_input?
@@ -24,6 +24,7 @@ module Ruact
24
24
  signed_global_id_default_purpose
25
25
  signed_global_id_default_expires_in
26
26
  shadcn_compatible_versions
27
+ layout
27
28
  ].freeze
28
29
 
29
30
  # @!attribute [r] manifest_path
@@ -135,6 +136,47 @@ module Ruact
135
136
  # suppress the warning — the documented "override" path.
136
137
  # @example Allow shadcn v3 once you have verified it
137
138
  # Ruact.configure { |c| c.shadcn_compatible_versions = [1, 2, 3] }
139
+ #
140
+ # @!attribute [r] layout
141
+ # @return [Boolean, String] Which document wrapper a ruact page's HTML
142
+ # response is rendered into. The Flight response shape
143
+ # (`text/x-component`) is never affected — this is only about the
144
+ # full-document render a browser gets on a normal navigation.
145
+ #
146
+ # - `false` (default) — render the gem's built-in minimal shell.
147
+ # - `true` — render through the controller's normal Rails layout.
148
+ # - a String — render through that named layout (e.g. `"ruact"`).
149
+ #
150
+ # The layout path exists because the document `<head>` belongs to the
151
+ # host app: `stylesheet_link_tag`, favicons, fonts, analytics and any
152
+ # `<head>`-writing gem only reach the page when Rails' own layout owns
153
+ # the document. The built-in shell carries no stylesheet slot, so under
154
+ # the `false` default a ruact page renders with no app CSS at all —
155
+ # which is why `rails generate ruact:install` writes `config.layout =
156
+ # true` into the generated initializer and adds `<%= ruact_js_assets %>`
157
+ # to your layout in the same run.
158
+ #
159
+ # **This setting is deliberately explicit — there is no auto-detection.**
160
+ # ruact used to try to infer whether your layout was ready by inspecting
161
+ # it. Deciding that reliably means answering "does this template call
162
+ # this method?", which cannot be done by pattern-matching a template
163
+ # language: three review rounds each found another shape that fooled it
164
+ # (a mention in a comment, a commented-out call, a trim-mode comment),
165
+ # and each wrong answer governed how every page in the app rendered.
166
+ # One explicit line is worth more than a clever guess here.
167
+ #
168
+ # A layout is ready when it calls `<%= ruact_js_assets %>` (which emits
169
+ # the React root's bootstrap entry tags and the per-render Flight
170
+ # payload) next to a `<div id="root"></div>`. If it does not, ruact says
171
+ # so loudly in development rather than serving a blank page, and
172
+ # `rails ruact:doctor` reports it.
173
+ # @note A ruact view is rendered in its own pass (it produces the component
174
+ # tree), so `content_for` declared inside the view does NOT reach the
175
+ # layout. Set document metadata from the controller instead.
176
+ # @example Let your layout own the document (what ruact:install writes)
177
+ # Ruact.configure { |c| c.layout = true }
178
+ # @example Use a dedicated layout for ruact pages only
179
+ # Ruact.configure { |c| c.layout = "ruact" }
138
180
  ATTRIBUTES.each do |attr|
139
181
  attr_reader attr
140
182
 
@@ -188,6 +230,7 @@ module Ruact
188
230
  @signed_global_id_default_purpose = nil
189
231
  @signed_global_id_default_expires_in = nil
190
232
  @shadcn_compatible_versions = [1, 2]
233
+ @layout = false
191
234
  end
192
235
  end
193
236
 
@@ -249,7 +292,37 @@ module Ruact
249
292
  when :query_route_prefix then validate_query_route_prefix!(value)
250
293
  when :query_parent_controller then validate_query_parent_controller!(value)
251
294
  when :shadcn_compatible_versions then validate_shadcn_compatible_versions!(value)
295
+ when :layout then validate_layout!(value)
296
+ end
297
+ end
298
+
299
+ # The value selects a render strategy by identity (`true` / `false`) or
300
+ # names a layout (String). Anything else would otherwise be treated as "not
301
+ # false" and reach `render_to_string(layout: <value>)`, where a Symbol
302
+ # layout name or a stray nil surfaces as a confusing first-request 500
303
+ # instead of a boot-time error. `nil` is rejected on purpose: "no layout" is
304
+ # spelled `false`, so a nil left over from a conditional in the initializer
305
+ # is a mistake, not a silent shell fallback.
306
+ def validate_layout!(value)
307
+ return if [true, false].include?(value)
308
+ return if value.is_a?(String) && !value.empty?
309
+
310
+ # `:auto` is rejected BY NAME because it used to be the default. An app
311
+ # carrying it forward from an older initializer must be told it is gone,
312
+ # not have it silently reinterpreted as "some truthy value".
313
+ if value == :auto
314
+ raise Ruact::ConfigurationError,
315
+ "Ruact::Configuration#layout no longer supports :auto — it inferred whether your " \
316
+ "layout was ready by inspecting it, which could not be done reliably. " \
317
+ "Set `true` to render through your app's layout (make sure it calls " \
318
+ "`<%= ruact_js_assets %>`), or `false` for ruact's built-in shell."
252
319
  end
320
+
321
+ raise Ruact::ConfigurationError,
322
+ "Ruact::Configuration#layout must be true, false, or a non-empty String layout name; " \
323
+ "got #{value.inspect} (#{value.class.name}). " \
324
+ "true renders through your app's layout (which must call ruact_js_assets); " \
325
+ "false uses ruact's built-in shell."
253
326
  end
254
327
 
255
328
  def validate_max_upload_bytes!(value)