ruact 0.0.13 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 962dae09686957b84e9e34d12809714978e95e6392265b7fd7f64a7028dd86f5
4
- data.tar.gz: ecad09e13bcc52fe30bff4ece70e14527e0c501166d2d487625c9a92ada3a2fd
3
+ metadata.gz: '048ef204d79fa3c03ff9ab08c43e5d9d1fc1f42c0e92c7cf7311b510bc879b0a'
4
+ data.tar.gz: 67fdef7b25ac3ac2ea552eed361a45a62710bf28145ae7892d0e1f46f462aa47
5
5
  SHA512:
6
- metadata.gz: 87a00a254896d68831e62bb892932c29b0668628232757f692b22c00f1a188de0db7d2ba9600e199e6f46aa1997b73ebe7fed0e6ef193a6d4b060b7fc410b2cf
7
- data.tar.gz: f90d5ba0da42e90e7031cff6d93ce648229655a5eea726a8572664354291aa974254f5696596a339942dedf7630506292618c48323a70cf7302c247eae7cb6b3
6
+ metadata.gz: cd6d63eaaa47a7cde090700c44ba53fe8cf9b29d82cc05ce2f672b98b01dd83dd82cb046b109cd47a328594344b1950f21d75fee5338870a0df5be438e86843e
7
+ data.tar.gz: 44b2880d6879617b00adc9a8ae070a1f722e147a782592c4b527e98dbe9af42d925f45c68daca3272df28a95c7a0c89d762164bf205c9da2dbf195e142e8e88d
data/CHANGELOG.md CHANGED
@@ -7,6 +7,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.0.14] - 2026-10-02
11
+
12
+ ### Fixed
13
+
14
+ - **Scaffolding a second resource stopped the app from booting.** Every `rails generate ruact:scaffold` wrote a query with a method named `search`. Query names share one namespace: each name gets one `GET /q/<name>` route and one export of `@/.ruact/server-functions`. So the second resource drew `ruact_query_search` again, and Rails refused to load the routes with "Invalid route name, already in use: 'ruact_query_search'", a route the app never wrote. The scaffold now names the method after the resource: `PostsQuery#search_posts`, served at `GET /q/searchPosts` and imported as `searchPosts`. Two query classes that define the same method — or one query class mounted twice — now raise `Ruact::ConfigurationError` naming the query, the path and the fix. The new name can meet one thing the bare `search` could not: a custom collection action named `search` on the same controller (`post :search, on: :collection`) also derives `searchPosts`, and boot stops with ruact's name-collision error, which points to `ruact_function_name` to rename one. Apps scaffolded before this release keep their `search` and work as they are. The first scaffold of a *new* resource in such an app no longer collides with it.
15
+
16
+ - **`rails generate ruact:install --shadcn` on an app that already had ruact installed left Tailwind out, then said the setup was in place.** That is the order Getting Started gives: install ruact first, add shadcn later. On the second run `package.json` and `Procfile.dev` already existed, so the generator skipped both. Tailwind was never declared and the `css` process was never added. `npx shadcn@latest init` then stopped with "No Tailwind CSS configuration found", right after the generator had printed that the prerequisites were in place. Under `--shadcn` the generator now adds what is missing to both files: the Tailwind `devDependencies` and the `build:css` script in `package.json`, the `css` process in `Procfile.dev`. It keeps every version and script you already declare; `package.json` is rewritten as 2-space JSON when something is added. When it cannot complete the setup, it says the setup is not in place and names what to do: a `package.json` it cannot parse, a Tailwind older than 4, or a `Procfile.dev` that already runs a `css` process or another Tailwind watcher — such as tailwindcss-rails' `bin/rails tailwindcss:watch`, which compiles its own entry rather than the `globals.css` shadcn writes its theme into. Without `--force`, `rails destroy ruact:install --shadcn` leaves an existing `package.json` in place.
17
+
18
+ - **Under `--shadcn`, `bin/dev` stopped as soon as it started when no terminal was attached.** The `css` process ran `@tailwindcss/cli --watch`, which stops watching and exits 0 when its input closes. Foreman then stops Rails and Vite as well. A coding agent, CI, Docker or an IDE task runner starts `bin/dev` without a terminal, so the app was gone before its first request, with no error in the output. The process now runs with `--watch=always`. To fix an app generated before this release, change `--watch` to `--watch=always` on the `css:` line of `Procfile.dev`.
19
+
20
+ - **The `ruact:scaffold --shadcn` pre-flight printed `npx shadcn@latest init` without `--base radix`.** The current shadcn defaults to Base UI, but the components the scaffold generates import Radix. An app set up from that message got components whose imports do not resolve. The message now prints `--base radix`, as the install generator already did.
21
+
22
+ - **Two generator messages linked to pages that do not exist.** The unknown-attribute-type error and the shadcn version warning pointed at a documentation path inside this repository, where no such file exists. They now link to ruact.dev.
23
+
24
+ ### Changed
25
+
26
+ - **The scaffold's list prop is named after the resource.** `<CommentList posts={rows} />` was a leftover from the reference app the templates came from, and every resource got a `posts` prop. It is now `<CommentList comments={rows} />`, `<BlogPostList blogPosts={rows} />`, and so on. Inside the component the prop is bound to a fixed local, so a model whose plural is one of the List's own names (`Row`) or one a module cannot declare (`Argument`) still compiles. A `Post` scaffold's view is unchanged.
27
+
28
+ - **shadcn 4 is in the default `shadcn_compatible_versions`** (now `[1, 2, 4]`). It is the current major, so every app that followed the setup steps saw `ruact:scaffold --shadcn` warn that its shadcn was not tested. shadcn 4 was run end to end with this release: `init --base radix`, the full `add` list, `ruact:scaffold --shadcn`, and the form, table and delete dialog checked in a browser.
29
+
10
30
  ## [0.0.13] - 2026-09-27
11
31
 
12
32
  ### Added
@@ -356,7 +376,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
356
376
  - **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.
357
377
  - **E2E test app** — `e2e/` Rails app (no DB, in-memory Post model) with full CRUD system tests validating the complete request cycle.
358
378
 
359
- [Unreleased]: https://github.com/luizcg/ruact/compare/v0.0.13...HEAD
379
+ [Unreleased]: https://github.com/luizcg/ruact/compare/v0.0.14...HEAD
380
+ [0.0.14]: https://github.com/luizcg/ruact/releases/tag/v0.0.14
360
381
  [0.0.13]: https://github.com/luizcg/ruact/releases/tag/v0.0.13
361
382
  [0.0.12]: https://github.com/luizcg/ruact/releases/tag/v0.0.12
362
383
  [0.0.11]: https://github.com/luizcg/ruact/releases/tag/v0.0.11
@@ -152,6 +152,24 @@ module Ruact
152
152
  say ""
153
153
  end
154
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
+
155
173
  # `--shadcn` only. Two files, both of them things shadcn's CLI checks for
156
174
  # and refuses to proceed without ("No Tailwind CSS configuration found" /
157
175
  # "Could not find valid path aliases"), verified against shadcn 4.x:
@@ -256,10 +274,18 @@ module Ruact
256
274
  # imports it by the absolute `Ruact.vite_plugin_path` and it uses only
257
275
  # `node:` builtins. Guarded like vite.config.js: an existing package.json
258
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.
259
283
  def create_package_json
260
284
  package_json_file = Pathname(destination_root).join("package.json")
261
285
 
262
286
  if package_json_file.exist? && !options[:force]
287
+ return merge_shadcn_package_json(package_json_file) if shadcn?
288
+
263
289
  say_status "skip", "package.json already exists — ensure it has react, react-dom, " \
264
290
  "vite and @vitejs/plugin-react (re-run with --force to overwrite)", :yellow
265
291
  return
@@ -279,7 +305,12 @@ module Ruact
279
305
  # OWNED by ruact: see `install_foreman_launcher`. `bin/dev` is made
280
306
  # executable.
281
307
  def create_launch_files
282
- 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
283
314
  install_foreman_launcher
284
315
  # Ensure bin/dev is executable whether we just wrote it or it pre-existed
285
316
  # (a skipped, already-foreman launcher should still be runnable).
@@ -612,7 +643,13 @@ module Ruact
612
643
  # templates' imports, so the two generators cannot drift.
613
644
  def show_shadcn_next_steps
614
645
  say ""
615
- 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
616
653
  say "Two commands remain — they are interactive and hit the network, so run them yourself:"
617
654
  say ""
618
655
  say " npx shadcn@latest init --base radix"
@@ -890,6 +927,12 @@ module Ruact
890
927
  options[:shadcn]
891
928
  end
892
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
+
893
936
  # The superset the scaffold generator narrows per resource. Loaded lazily
894
937
  # (and only under `--shadcn`) so a plain install never pays for the
895
938
  # scaffold generator's load, and so a failure to reach it degrades to the
@@ -901,6 +944,74 @@ module Ruact
901
944
  "button input textarea switch select label badge table alert-dialog dropdown-menu"
902
945
  end
903
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
+
904
1015
  def app_package_name
905
1016
  base = File.basename(File.expand_path(destination_root))
906
1017
  sanitized = base.downcase.gsub(/[^a-z0-9._-]/, "-").squeeze("-").gsub(/\A-+|-+\z/, "")
@@ -1,5 +1,5 @@
1
1
  web: bin/rails server -p 3000
2
2
  vite: npm run dev
3
3
  <% if shadcn? -%>
4
- css: npx @tailwindcss/cli -i app/javascript/styles/globals.css -o app/assets/builds/tailwind.css --watch
4
+ <%= SHADCN_CSS_PROCESS %>
5
5
  <% end -%>
@@ -5,7 +5,7 @@
5
5
  "scripts": {
6
6
  "dev": "vite",
7
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
+ "build:css": "<%= SHADCN_BUILD_CSS_SCRIPT %>"<% end %>
9
9
  },
10
10
  "dependencies": {
11
11
  "react": "^19.0.0",
@@ -14,9 +14,9 @@
14
14
  "devDependencies": {
15
15
  "@vitejs/plugin-react": "^4.3.4",
16
16
  <% if shadcn? -%>
17
- "@tailwindcss/cli": "^4.0.0",
18
- "tailwindcss": "^4.0.0",
19
- "tw-animate-css": "^1.0.0",
17
+ <% SHADCN_DEV_DEPENDENCIES.each do |name, version| -%>
18
+ "<%= name %>": "<%= version %>",
19
+ <% end -%>
20
20
  <% end -%>
21
21
  "vite": "^6.0.7"
22
22
  }
@@ -107,7 +107,7 @@ module Ruact
107
107
 
108
108
  SUPPORTED_TYPES = TYPE_MAP.keys.freeze
109
109
 
110
- # Column types the `search` query's case-insensitive LIKE scope spans —
110
+ # Column types the search query's case-insensitive LIKE scope spans —
111
111
  # matching numeric/date/boolean columns by substring is meaningless.
112
112
  SEARCHABLE_COLUMN_TYPES = %w[string text].freeze
113
113
 
@@ -121,7 +121,7 @@ module Ruact
121
121
  REFERENCE_OPTIONS_LIMIT = 100
122
122
 
123
123
  # Documentation anchor referenced by the unknown-type error message (AC4).
124
- DOCS_POINTER = "https://github.com/luizcg/ruact/blob/main/website/docs/api/scaffold.md#attribute-types"
124
+ DOCS_POINTER = "https://ruact.dev/docs/api/scaffold.html#attribute-types"
125
125
 
126
126
  # Story 10.5 (AC1, AC2, AC4) — the shadcn/ui dependency PRE-FLIGHT: detect
127
127
  # the host's shadcn state (complete / missing / partial) and either proceed
@@ -191,7 +191,7 @@ module Ruact
191
191
  end
192
192
 
193
193
  # AC5 — the client-driven read path. Emits the resource query
194
- # (`<Plural>Query < ApplicationQuery` with a `search(q:)` method) in BOTH
194
+ # (`<Plural>Query < ApplicationQuery` with a `search_<plural>(q:)` method) in BOTH
195
195
  # `.tsx` and `.jsx` modes (the query is server-side Ruby; the language flag
196
196
  # only governs the React component). The `ApplicationQuery` base is created
197
197
  # idempotently — `ruact:install` does NOT ship it, and a second scaffold in
@@ -205,13 +205,13 @@ module Ruact
205
205
  template "queries/application_query.rb.tt", application_query
206
206
  end
207
207
 
208
- # AC5 — mount the resource query so its `search` method becomes the named
209
- # GET route the codegen exports as `search` (consumed by `useQuery`).
208
+ # AC5 — mount the resource query so its `search_<plural>` method becomes the
209
+ # named GET route the codegen exports as `search<Plural>` (consumed by `useQuery`).
210
210
  # Idempotent on re-run: guard on the drawn `ruact_queries <Plural>Query`
211
211
  # line first (sibling of {#add_resource_route}'s `resources :posts` guard).
212
212
  def add_query_route
213
213
  routes_file = Pathname(destination_root).join("config/routes.rb")
214
- if routes_file.exist? && routes_file.read.match?(/^\s*ruact_queries\s+#{Regexp.escape(query_class_name)}\b/)
214
+ if routes_file.exist? && query_already_mounted?(routes_file.read)
215
215
  say_status "skip", "ruact_queries #{query_class_name} already routed", :yellow
216
216
  return
217
217
  end
@@ -406,17 +406,44 @@ module Ruact
406
406
 
407
407
  # The read-side query class — PLURAL, mirroring the golden `PostsQuery`
408
408
  # (file `posts_query.rb`) and Zeitwerk's path↔constant rule. Mounted via
409
- # `ruact_queries <Plural>Query`; its `search` method becomes `GET /q/search`.
409
+ # `ruact_queries <Plural>Query`; its search method becomes `GET /q/searchPosts`.
410
410
  def query_class_name
411
411
  "#{class_name.pluralize}Query"
412
412
  end
413
413
 
414
- # The JS import alias for the query's `search` accessor. The codegen
415
- # exports a generic `search` (from `<Plural>Query#search`); the component
416
- # aliases it `search<Plural>` to avoid a bare-`search` collision — exactly
417
- # as the golden does (`search as searchPosts`).
414
+ # True when a `ruact_queries` line already lists this exact class —
415
+ # alone or among others, ignoring a trailing comment and not mistaking
416
+ # `Legacy::PostsQuery` or `BlogPostsQuery` for `PostsQuery`.
417
+ def query_already_mounted?(routes)
418
+ class_ref = /(?<![:\w])#{Regexp.escape(query_class_name)}\b/
419
+ routes.each_line.any? do |line|
420
+ code = line.sub(/#.*/, "")
421
+ code.match?(/\A\s*ruact_queries\b/) && code.match?(class_ref)
422
+ end
423
+ end
424
+
425
+ # The query's search method, named after the resource (`search_posts`).
426
+ # Query names share ONE namespace — one `GET /q/<name>` route and one
427
+ # export of `@/.ruact/server-functions` per name — so a bare `search`
428
+ # was free for the first resource and broke the boot on the second
429
+ # (`ruact_query_search` drawn twice).
430
+ def query_search_method
431
+ "search_#{plural_table_name}"
432
+ end
433
+
434
+ # The accessor the codegen exports for {#query_search_method}
435
+ # (`searchPosts`); the List imports it under this name, unaliased.
418
436
  def js_search_alias
419
- "search#{class_name.pluralize}"
437
+ Ruact::ServerFunctions::NameBridge.to_js_identifier(query_search_method)
438
+ end
439
+
440
+ # The List's collection prop, named after the resource (`posts`,
441
+ # `comments`, `blogPosts`) rather than fixed to the golden's `posts`.
442
+ # The List binds it to a fixed local (`initialRows`), so a model whose
443
+ # plural matches one of the List's own names (`Row` → `rows`) or one a
444
+ # module cannot declare (`Argument` → `arguments`) still compiles.
445
+ def js_collection_prop
446
+ plural_table_name.camelize(:lower)
420
447
  end
421
448
 
422
449
  # The columns the search `LIKE` scope spans — string/text only (a
@@ -20,7 +20,7 @@ module Ruact
20
20
  # Documentation anchor for the shadcn dependency pre-flight: how to set
21
21
  # up shadcn, and how to override the version-compat warning.
22
22
  SHADCN_DOCS_POINTER =
23
- "https://github.com/luizcg/ruact/blob/main/website/docs/api/scaffold.md#shadcnui-setup"
23
+ "https://ruact.dev/docs/guides/shadcn-ui.html#shadcn-versions"
24
24
 
25
25
  # The pre-flight body (the {ScaffoldGenerator#check_shadcn_setup} Thor
26
26
  # command delegates here). Detect the host's shadcn state, surface the
@@ -141,9 +141,10 @@ module Ruact
141
141
  <<~MSG.chomp
142
142
  ruact:scaffold — shadcn/ui is not set up in this app yet.
143
143
  The generated components import from @/components/ui/*, which does not exist.
144
- Set up shadcn/ui first, then re-run this generator:
144
+ Set up shadcn/ui first (--base radix: the components import Radix primitives,
145
+ and shadcn now defaults to Base UI), then re-run this generator:
145
146
 
146
- npx shadcn@latest init
147
+ npx shadcn@latest init --base radix
147
148
  #{shadcn_add_command(required_shadcn_components)}
148
149
 
149
150
  No files were written (no partial state). Advanced: pass --skip-shadcn-check
@@ -16,7 +16,7 @@
16
16
  // small GENERATED client-side sort. There is NO table-engine dependency: the
17
17
  // scaffold stays dep-free (no react-table runtime), matching the dep-free
18
18
  // Form and the native date inputs. The collection arrives as a SERVER-RENDERED
19
- // prop (`posts`); there is no client query for the initial render. A query only
19
+ // prop (`<%= js_collection_prop %>`); there is no client query for the initial render. A query only
20
20
  // enters when the *client* drives the read: the search box calls
21
21
  // useQuery(<%= js_search_alias %>, { q }) and swaps in filtered rows as you type.
22
22
  // Per-row delete drives a controlled <%= class_name %>DeleteDialog (DELETE
@@ -30,7 +30,7 @@
30
30
  // freshly scaffolded app will not resolve these until 10.5 lands; that is
31
31
  // expected (the end-to-end live demo is Story 10.7).
32
32
  import { useState } from "react";
33
- import { search as <%= js_search_alias %>, destroy<%= class_name %>, useQuery } from "@/.ruact/server-functions";
33
+ import { <%= js_search_alias %>, destroy<%= class_name %>, useQuery } from "@/.ruact/server-functions";
34
34
  import { <%= class_name %>DeleteDialog } from "./<%= class_name %>DeleteDialog";
35
35
  import { Badge } from "@/components/ui/badge";
36
36
  import { Button } from "@/components/ui/button";
@@ -52,11 +52,11 @@ import {
52
52
 
53
53
  type <%= class_name %>Row = { <%= ts_row_fields %> };
54
54
 
55
- // FR100 — opt-in call-site contract: `posts` is required. The index view passes
56
- // `<<%= class_name %>List posts={rows} />` (satisfied); a call site that omits it
55
+ // FR100 — opt-in call-site contract: `<%= js_collection_prop %>` is required. The index view passes
56
+ // `<<%= class_name %>List <%= js_collection_prop %>={rows} />` (satisfied); a call site that omits it
57
57
  // fails at preprocess time, not as a silent `undefined` in the browser.
58
58
  export const __ruactContract = {
59
- props: { posts: "required" },
59
+ props: { <%= js_collection_prop %>: "required" },
60
60
  };
61
61
  <% end -%>
62
62
 
@@ -208,9 +208,9 @@ function RowActions({ record, onDeleted }<% if typescript? %>: {
208
208
  }
209
209
 
210
210
  export function <%= class_name %>List({
211
- posts = [],
211
+ <%= js_collection_prop %>: initialRows = [],
212
212
  emptyLabel = "No <%= plural_name %> yet — create one.",
213
- }<% if typescript? %>: { posts?: <%= class_name %>Row[]; emptyLabel?: string }<% end %>) {
213
+ }<% if typescript? %>: { <%= js_collection_prop %>?: <%= class_name %>Row[]; emptyLabel?: string }<% end %>) {
214
214
  const [q, setQ] = useState("");
215
215
  const searching = q.trim().length > 0;
216
216
 
@@ -229,7 +229,7 @@ export function <%= class_name %>List({
229
229
  // the box is idle and we fall back to the server-rendered rows.
230
230
  const { data: searchData, loading: searchLoading } = useQuery<% if typescript? %><<%= class_name %>Row[]><% end %>(<%= js_search_alias %>, { q: q.trim() });
231
231
 
232
- const source = searching ? searchData ?? [] : posts;
232
+ const source = searching ? searchData ?? [] : initialRows;
233
233
  const rows = removedIds.length === 0 ? source : source.filter((row) => !removedIds.includes(row.id));
234
234
  // Always sort a COPY — never mutate the prop/source array.
235
235
  const sortedRows = sort ? [...rows].sort((a, b) => compareRows(a, b, sort)) : rows;
@@ -8,7 +8,7 @@
8
8
  // <%= class_name %> list — a DESIGN-SYSTEM-AGNOSTIC table (Story 14.4 / FR103). The
9
9
  // default scaffold ships plain, native HTML elements styled by the browser /
10
10
  // Rails-default CSS — NO shadcn/ui, NO Tailwind, NO table-engine dependency. The
11
- // collection arrives as a SERVER-RENDERED prop (`posts`); there is no client query
11
+ // collection arrives as a SERVER-RENDERED prop (`<%= js_collection_prop %>`); there is no client query
12
12
  // for the initial render. A query only enters when the *client* drives the read:
13
13
  // the search box calls useQuery(<%= js_search_alias %>, { q }) and swaps in filtered rows as
14
14
  // you type. Per-row delete drives a controlled <%= class_name %>DeleteDialog (DELETE
@@ -18,17 +18,17 @@
18
18
  // sort/pagination is Phase-3 territory. (The richer shadcn DataTable styling is
19
19
  // the opt-in `--shadcn` path — Story 14.5.)
20
20
  import { useState } from "react";
21
- import { search as <%= js_search_alias %>, destroy<%= class_name %>, useQuery } from "@/.ruact/server-functions";
21
+ import { <%= js_search_alias %>, destroy<%= class_name %>, useQuery } from "@/.ruact/server-functions";
22
22
  import { <%= class_name %>DeleteDialog } from "./<%= class_name %>DeleteDialog";
23
23
  <% if typescript? -%>
24
24
 
25
25
  type <%= class_name %>Row = { <%= ts_row_fields %> };
26
26
 
27
- // FR100 — opt-in call-site contract: `posts` is required. The index view passes
28
- // `<<%= class_name %>List posts={rows} />` (satisfied); a call site that omits it
27
+ // FR100 — opt-in call-site contract: `<%= js_collection_prop %>` is required. The index view passes
28
+ // `<<%= class_name %>List <%= js_collection_prop %>={rows} />` (satisfied); a call site that omits it
29
29
  // fails at preprocess time, not as a silent `undefined` in the browser.
30
30
  export const __ruactContract = {
31
- props: { posts: "required" },
31
+ props: { <%= js_collection_prop %>: "required" },
32
32
  };
33
33
  <% end -%>
34
34
 
@@ -146,9 +146,9 @@ function RowActions({ record, onDeleted }<% if typescript? %>: {
146
146
  }
147
147
 
148
148
  export function <%= class_name %>List({
149
- posts = [],
149
+ <%= js_collection_prop %>: initialRows = [],
150
150
  emptyLabel = "No <%= plural_name %> yet — create one.",
151
- }<% if typescript? %>: { posts?: <%= class_name %>Row[]; emptyLabel?: string }<% end %>) {
151
+ }<% if typescript? %>: { <%= js_collection_prop %>?: <%= class_name %>Row[]; emptyLabel?: string }<% end %>) {
152
152
  const [q, setQ] = useState("");
153
153
  const searching = q.trim().length > 0;
154
154
 
@@ -167,7 +167,7 @@ export function <%= class_name %>List({
167
167
  // the box is idle and we fall back to the server-rendered rows.
168
168
  const { data: searchData, loading: searchLoading } = useQuery<% if typescript? %><<%= class_name %>Row[]><% end %>(<%= js_search_alias %>, { q: q.trim() });
169
169
 
170
- const source = searching ? searchData ?? [] : posts;
170
+ const source = searching ? searchData ?? [] : initialRows;
171
171
  const rows = removedIds.length === 0 ? source : source.filter((row) => !removedIds.includes(row.id));
172
172
  // Always sort a COPY — never mutate the prop/source array.
173
173
  const sortedRows = sort ? [...rows].sort((a, b) => compareRows(a, b, sort)) : rows;
@@ -3,7 +3,7 @@
3
3
  # Read side of the CRUD. Each public instance method becomes one named GET
4
4
  # route when mounted with `ruact_queries <%= query_class_name %>`:
5
5
  #
6
- # GET /q/search → <%= query_class_name %>#search(q:) → useQuery(<%= js_search_alias %>, { q }) (list search box)
6
+ # GET /q/<%= js_search_alias %> → <%= query_class_name %>#<%= query_search_method %>(q:) → useQuery(<%= js_search_alias %>, { q }) (list search box)
7
7
  #
8
8
  # NOTE: there is deliberately NO whole-list query — the index list is
9
9
  # server-rendered as props (see <%= controller_class_name %>Controller#index). A query is only the
@@ -14,7 +14,7 @@ class <%= query_class_name %> < ApplicationQuery
14
14
  # is what justifies a query — the result depends on live client input, not on
15
15
  # what the server already had. Rows are the SAME shape the index serializes, so
16
16
  # the search results and the server-rendered props are interchangeable.
17
- def search(q:)
17
+ def <%= query_search_method %>(q:)
18
18
  term = q.to_s.strip
19
19
  scope =
20
20
  <% if searchable_attributes.empty? -%>
@@ -3,4 +3,4 @@
3
3
  Flight payload: no client query, no loading flash. The ivar is serialized to
4
4
  plain row hashes here in the view (no as_json on the model). %>
5
5
  <%% rows = @<%= plural_name %>.map { |<%= singular_name %>| <%= serialized_row(singular_name) %> } %>
6
- <<%= class_name %>List posts={rows} />
6
+ <<%= class_name %>List <%= js_collection_prop %>={rows} />
@@ -73,7 +73,7 @@ module Ruact
73
73
  # a stream-safety guarantee — Rack's multipart parser will still buffer
74
74
  # bodies up to its own limits before the guard rejects. For very large
75
75
  # uploads route through Active Storage Direct Upload or a presigned S3
76
- # URL; see `website/docs/api/server-actions.md` "File uploads" section.
76
+ # URL; see https://ruact.dev/docs/api/server-actions.html, "File uploads".
77
77
  # @example Raise the limit to 25 MB
78
78
  # Ruact.configure { |c| c.max_upload_bytes = 25 * 1024 * 1024 }
79
79
  # @example Disable the gem-side guard (reverse proxy owns the cap)
@@ -132,11 +132,13 @@ module Ruact
132
132
  # host `package.json`) that is NOT in this list, it emits a warning
133
133
  # (never a hard stop) that the generated components may import from
134
134
  # outdated `@/components/ui/*` paths. Must be a non-empty Array of
135
- # Integers. Default `[1, 2]` (the majors tested at gem-release time).
135
+ # Integers. Default `[1, 2, 4]` (the majors tested at gem-release time;
136
+ # 4 was run end to end on 2026-10-01: init --base radix, the full add
137
+ # list, scaffold --shadcn, form/table/dialog in a browser).
136
138
  # A dev who has manually verified a newer major adds it here to
137
139
  # suppress the warning — the documented "override" path.
138
140
  # @example Allow shadcn v3 once you have verified it
139
- # Ruact.configure { |c| c.shadcn_compatible_versions = [1, 2, 3] }
141
+ # Ruact.configure { |c| c.shadcn_compatible_versions = [1, 2, 3, 4] }
140
142
  #
141
143
  # @!attribute [r] layout
142
144
  # @return [Boolean, String] Which document wrapper a ruact page's HTML
@@ -250,7 +252,7 @@ module Ruact
250
252
  @query_parent_controller = "ApplicationController"
251
253
  @signed_global_id_default_purpose = nil
252
254
  @signed_global_id_default_expires_in = nil
253
- @shadcn_compatible_versions = [1, 2]
255
+ @shadcn_compatible_versions = [1, 2, 4]
254
256
  @layout = false
255
257
  @layout_stylesheets = [:app]
256
258
  end
data/lib/ruact/routing.rb CHANGED
@@ -24,9 +24,9 @@ module Ruact
24
24
  # The path segment reuses {Ruact::ServerFunctions::NameBridge} verbatim
25
25
  # (D4): `def search_users` → `GET /q/searchUsers`, named
26
26
  # `ruact_query_searchUsers`. Invalid or JS-reserved method names raise
27
- # {Ruact::ConfigurationError} at route-draw time; two query classes mounting
28
- # the same method name collide on the route NAME / path and fail Rails' own
29
- # duplicate checks — both are loud boot failures, never request-time
27
+ # {Ruact::ConfigurationError} at route-draw time, and so do two query
28
+ # classes mounting the same method name (they would collide on the route
29
+ # name and on the export) — both are loud boot failures, never request-time
30
30
  # surprises.
31
31
  #
32
32
  # The generated dispatch controller PRESERVES the query class's namespace
@@ -56,11 +56,29 @@ module Ruact
56
56
 
57
57
  query_class.public_instance_methods(false).each do |query_method|
58
58
  js_identifier = ServerFunctions::NameBridge.to_js_identifier(query_method)
59
- mapper.get("#{prefix}/#{js_identifier}",
60
- to: "#{target}##{query_method}",
61
- as: :"ruact_query_#{js_identifier}")
59
+ begin
60
+ mapper.get("#{prefix}/#{js_identifier}",
61
+ to: "#{target}##{query_method}",
62
+ as: :"ruact_query_#{js_identifier}")
63
+ rescue ArgumentError => e
64
+ raise unless e.message.include?("already in use")
65
+
66
+ raise_query_name_taken!(query_class, query_method, "#{prefix}/#{js_identifier}")
67
+ end
62
68
  end
63
69
  end
70
+
71
+ # Rails' own message ("Invalid route name, already in use") names a route
72
+ # the app never wrote. Name the query, the method and the way out.
73
+ def raise_query_name_taken!(query_class, query_method, path)
74
+ raise Ruact::ConfigurationError,
75
+ "#{query_class}##{query_method} cannot be mounted: GET #{path} is already mounted " \
76
+ "in these routes — by another query class that also defines `#{query_method}`, or " \
77
+ "by `ruact_queries #{query_class}` appearing twice. Query names share one namespace " \
78
+ "(one route and one export of @/.ruact/server-functions per name): remove the " \
79
+ "duplicate mount, or rename one of the methods (the scaffold names its search " \
80
+ "after the resource: search_posts, search_comments)."
81
+ end
64
82
  end
65
83
  end
66
84
  end
data/lib/ruact/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Ruact
4
- VERSION = "0.0.13"
4
+ VERSION = "0.0.14"
5
5
  end
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "//": "Story 14.4 (AC5) — type-check the GENERATED design-system-AGNOSTIC `<Model>{List,Form,DeleteDialog}.tsx` fixtures in ISOLATION against an ambient stub (type-tests/scaffold/agnostic/ambient.d.ts) that declares ONLY React + @/.ruact/server-functions and NO @/components/ui/* module. A stray shadcn import would fail to resolve → the typecheck goes red, proving the agnostic default needs zero design-system resolution. Mirrors tsconfig.scaffold.json (the shadcn isolated typecheck), but scoped to the agnostic subdir so the two ambient stubs never merge. Run via `npm run typecheck`.",
3
- "//noImplicitAny": "Relaxed ONLY here: native-element event handler params (e.g. `onChange={(e) => …}`) get no contextual type from the permissive ambient `JSX.IntrinsicElements` (no @types/react / lib:dom). The load-bearing types — PostRow, the typed `search` accessor (FR99), the required `posts` prop, the controlled DeleteDialog contract — are all EXPLICITLY annotated and stay fully checked under `strict`.",
3
+ "//noImplicitAny": "Relaxed ONLY here: native-element event handler params (e.g. `onChange={(e) => …}`) get no contextual type from the permissive ambient `JSX.IntrinsicElements` (no @types/react / lib:dom). The load-bearing types — PostRow, the typed `searchPosts` accessor (FR99), the required `posts` prop, the controlled DeleteDialog contract — are all EXPLICITLY annotated and stay fully checked under `strict`.",
4
4
  "compilerOptions": {
5
5
  "strict": true,
6
6
  "noImplicitAny": false,
@@ -18,7 +18,7 @@
18
18
  // freshly scaffolded app will not resolve these until 10.5 lands; that is
19
19
  // expected (the end-to-end live demo is Story 10.7).
20
20
  import { useState } from "react";
21
- import { search as searchPosts, destroyPost, useQuery } from "@/.ruact/server-functions";
21
+ import { searchPosts, destroyPost, useQuery } from "@/.ruact/server-functions";
22
22
  import { PostDeleteDialog } from "./PostDeleteDialog";
23
23
  import { Badge } from "@/components/ui/badge";
24
24
  import { Button } from "@/components/ui/button";
@@ -194,7 +194,7 @@ function RowActions({ record, onDeleted }: {
194
194
  }
195
195
 
196
196
  export function PostList({
197
- posts = [],
197
+ posts: initialRows = [],
198
198
  emptyLabel = "No posts yet — create one.",
199
199
  }: { posts?: PostRow[]; emptyLabel?: string }) {
200
200
  const [q, setQ] = useState("");
@@ -215,7 +215,7 @@ export function PostList({
215
215
  // the box is idle and we fall back to the server-rendered rows.
216
216
  const { data: searchData, loading: searchLoading } = useQuery<PostRow[]>(searchPosts, { q: q.trim() });
217
217
 
218
- const source = searching ? searchData ?? [] : posts;
218
+ const source = searching ? searchData ?? [] : initialRows;
219
219
  const rows = removedIds.length === 0 ? source : source.filter((row) => !removedIds.includes(row.id));
220
220
  // Always sort a COPY — never mutate the prop/source array.
221
221
  const sortedRows = sort ? [...rows].sort((a, b) => compareRows(a, b, sort)) : rows;
@@ -13,7 +13,7 @@
13
13
  // sort/pagination is Phase-3 territory. (The richer shadcn DataTable styling is
14
14
  // the opt-in `--shadcn` path — Story 14.5.)
15
15
  import { useState } from "react";
16
- import { search as searchPosts, destroyPost, useQuery } from "@/.ruact/server-functions";
16
+ import { searchPosts, destroyPost, useQuery } from "@/.ruact/server-functions";
17
17
  import { PostDeleteDialog } from "./PostDeleteDialog";
18
18
 
19
19
  type PostRow = { id: number; title: string | null; body: string | null; published: boolean | null; views: number | null; published_on: string | null; published_at: string | null; author_id: number | null };
@@ -139,7 +139,7 @@ function RowActions({ record, onDeleted }: {
139
139
  }
140
140
 
141
141
  export function PostList({
142
- posts = [],
142
+ posts: initialRows = [],
143
143
  emptyLabel = "No posts yet — create one.",
144
144
  }: { posts?: PostRow[]; emptyLabel?: string }) {
145
145
  const [q, setQ] = useState("");
@@ -160,7 +160,7 @@ export function PostList({
160
160
  // the box is idle and we fall back to the server-rendered rows.
161
161
  const { data: searchData, loading: searchLoading } = useQuery<PostRow[]>(searchPosts, { q: q.trim() });
162
162
 
163
- const source = searching ? searchData ?? [] : posts;
163
+ const source = searching ? searchData ?? [] : initialRows;
164
164
  const rows = removedIds.length === 0 ? source : source.filter((row) => !removedIds.includes(row.id));
165
165
  // Always sort a COPY — never mutate the prop/source array.
166
166
  const sortedRows = sort ? [...rows].sort((a, b) => compareRows(a, b, sort)) : rows;
@@ -53,10 +53,10 @@ declare module "@/.ruact/server-functions" {
53
53
  // FR88/FR99 wire union — the only param value type a query accessor accepts.
54
54
  type Wire = string | number | boolean | null;
55
55
 
56
- // The codegen exports a generic `search` from `<Plural>Query#search(q:)`,
57
- // typed from the declared `(q:)` kwarg (Story 13.4). The List aliases it
58
- // `search<Plural>`.
59
- export const search: (params: { q: Wire }) => Promise<unknown>;
56
+ // The codegen exports `searchPosts` from `PostsQuery#search_posts(q:)`,
57
+ // typed from the declared `(q:)` kwarg (Story 13.4). The scaffold names the
58
+ // query after the resource: query names share one namespace.
59
+ export const searchPosts: (params: { q: Wire }) => Promise<unknown>;
60
60
 
61
61
  // Action accessors the generated Form imports. Actions are NOT typed by FR99
62
62
  // (only queries are — Story 13.4 decision A), so the accessors take a loose
@@ -39,10 +39,10 @@ declare module "@/.ruact/server-functions" {
39
39
  // FR88/FR99 wire union — the only param value type a query accessor accepts.
40
40
  type Wire = string | number | boolean | null;
41
41
 
42
- // The codegen exports a generic `search` from `<Plural>Query#search(q:)`,
43
- // typed from the declared `(q:)` kwarg (Story 13.4). The List aliases it
44
- // `search<Plural>`.
45
- export const search: (params: { q: Wire }) => Promise<unknown>;
42
+ // The codegen exports `searchPosts` from `PostsQuery#search_posts(q:)` —
43
+ // the scaffold names the query after the resource, because query names
44
+ // share one namespace across every query class.
45
+ export const searchPosts: (params: { q: Wire }) => Promise<unknown>;
46
46
 
47
47
  // Action accessors the generated Form imports (Story 10.3). Actions are NOT
48
48
  // typed by FR99 (only queries are — Story 13.4 decision A), so the accessors
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ruact
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.13
4
+ version: 0.0.14
5
5
  platform: ruby
6
6
  authors:
7
7
  - Luiz Garcia
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-09-28 00:00:00.000000000 Z
11
+ date: 2026-10-02 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: nokogiri