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
@@ -15,7 +15,10 @@ truth" below) instead of guessing.
15
15
 
16
16
  ## Mental model
17
17
 
18
- - A page is a normal Rails controller action rendering a normal `.html.erb`.
18
+ - A page is an action rendering a `.html.erb` in a controller with `include Ruact::Controller`
19
+ (install adds it to none — `--app` puts it on ApplicationController; `ruact:scaffold`
20
+ adds it; `ruact_pages only:` narrows it). The page's renderer owns navigation:
21
+ going between a ruact page and a plain Rails/Turbo page is a full page load.
19
22
  - Interactive components live in `app/javascript/components/` as `"use client"`
20
23
  files, mounted from ERB with a PascalCase self-closing tag:
21
24
  `<LikeButton postId={@post.id} />`.
@@ -66,10 +69,9 @@ object is instantiated.
66
69
 
67
70
  ## Ground truth — read the generated file
68
71
 
69
- `app/javascript/.ruact/server-functions.ts` is regenerated from the route
70
- table (it is gitignored). It is the authoritative list of every accessor and
71
- its typed params — READ THAT FILE instead of simulating the name generation.
72
- Regenerate it after changing routes or queries:
72
+ `app/javascript/.ruact/server-functions.ts` (gitignored, regenerated from the
73
+ route table) is the authoritative list of every accessor and its typed params —
74
+ READ IT instead of simulating the name generation. Regenerate after routes/queries change:
73
75
 
74
76
  bin/rails ruact:server_functions:generate
75
77
 
@@ -92,12 +94,12 @@ Regenerate it after changing routes or queries:
92
94
  (a Flight stream for client-side navigation, an HTML page otherwise). You
93
95
  cannot infer the response shape from the controller body alone — the
94
96
  caller picks it.
95
- 4. **`ruact_errors` requires fall-through.** On the `if @post.save ... else`
96
- path, call `ruact_errors(@post)` and let the action END there — ruact's
97
- implicit render injects `errors: { attribute: [messages] }` into the JSON.
98
- An explicit `render` on that branch opts out of the injection. In the
99
- redirect-back flow, `ruact_errors(@post)` then `redirect_to` carries the
100
- errors through flash to the next render.
97
+ 4. **`ruact_errors` requires fall-through.** On a function call's failed-save
98
+ branch, call `ruact_errors(@post)` and let the action END — the implicit
99
+ render injects `errors: { attribute: [messages] }` into the JSON (an explicit
100
+ `render` opts out). On a page form, `ruact_errors(@post)` then
101
+ `render :new, status: :unprocessable_entity` re-renders the page through ruact
102
+ (view: `errors={ruact_errors}`); `redirect_to` instead carries them via flash.
101
103
  5. **Accessor names are derived, not declared.** `posts#create` → `createPost`,
102
104
  `posts#publish_all` → `publishAllPosts`; query methods camelCase the same
103
105
  way (`search_users` → `searchUsers`). Collisions fail loudly at boot;
@@ -146,8 +148,7 @@ duration, or a deliberate `expires_in: nil` for a non-expiring token.
146
148
  `schema_version` field (currently `0`), do not treat it as a stable contract.
147
149
  - `bin/rails ruact:server_functions:generate` — regenerates the TS module;
148
150
  exits 1 on a naming collision or an invalid name.
149
- - `bin/dev` — boots Rails AND Vite (both are required: Vite serves the client
150
- components and writes the client manifest).
151
+ - `bin/dev` — boots Rails AND Vite (both required: Vite serves the client components).
151
152
  - If your app has TypeScript tooling configured, `npx tsc --noEmit`
152
153
  type-checks call sites against the generated accessor types (a fresh
153
154
  install does not ship a tsconfig).
@@ -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 -%>
@@ -1,14 +1,36 @@
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.
4
+ <% if whole_app? -%>
5
+ # Whole-app mode (`rails generate ruact:install --app`, or an app installed
6
+ # before island mode was the default): ApplicationController
7
+ # includes Ruact::Controller, so every action with an .html.erb template
8
+ # renders through ruact, into your own layout. That layout needs
9
+ # `<%%= ruact_head_assets %>` in <head> and `<%%= ruact_js_assets %>` next to a
10
+ # `<div id="root"></div>` (`rails ruact:doctor` checks).
11
11
  config.layout = true
12
+ <% else -%>
13
+ # Island mode: nothing in your controllers changed. A page renders through
14
+ # ruact when its controller has `include Ruact::Controller` — narrow it to
15
+ # some actions with `ruact_pages only: %i[show]`.
16
+ #
17
+ # Ruact pages render through the layout ruact ships (layouts/ruact). It links
18
+ # the CSS your client components import, then the stylesheets below — yours
19
+ # load last, so they win ties — and it edits none of your layouts. What it
20
+ # does NOT bring is the rest of your own layout's <head> or your app's
21
+ # JavaScript; to own that document, copy it into your app with
22
+ # `rails generate ruact:layout`.
23
+ #
24
+ # Set to true to render through your app's own layout instead (it then needs
25
+ # `<%%= ruact_head_assets %>` in <head> and `<%%= ruact_js_assets %>` next to a
26
+ # `<div id="root"></div>`; `rails ruact:doctor` checks), or false for ruact's
27
+ # minimal built-in shell, which carries none of your stylesheets.
28
+ config.layout = "ruact"
29
+
30
+ # The app stylesheets ruact's layout links, as you would pass them to
31
+ # stylesheet_link_tag. [:app] under Propshaft is every stylesheet you have.
32
+ config.layout_stylesheets = <%= detected_layout_stylesheets %>
33
+ <% end -%>
12
34
 
13
35
  # Path to the react-client-manifest.json generated by the Vite plugin.
14
36
  # Defaults to Rails.root.join("public/react-client-manifest.json").
@@ -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
  }
@@ -9,6 +9,9 @@
9
9
  "noEmit": true,
10
10
  "esModuleInterop": true,
11
11
  "skipLibCheck": true,
12
+ // Vite's client types. Without them a client component importing a
13
+ // stylesheet for its side effect has no declaration to resolve against.
14
+ "types": ["vite/client"],
12
15
  "baseUrl": ".",
13
16
  "paths": {
14
17
  "@/*": ["app/javascript/*"]
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+ require "ruact"
5
+
6
+ module Ruact
7
+ module Generators
8
+ # Copies the layout ruact pages render into (`layouts/ruact`, shipped with
9
+ # the gem) into the app, so the app owns it.
10
+ #
11
+ # Nothing needs configuring afterwards: the gem's view path is APPENDED by
12
+ # the Railtie, behind the app's, so `app/views/layouts/ruact.html.erb` wins
13
+ # by view-path order the moment it exists. That is the point of the ejected
14
+ # copy — add the app's JavaScript, fonts, meta tags or anything else
15
+ # `config.layout_stylesheets` cannot express.
16
+ #
17
+ # The cost, said once here and again when the command runs: an ejected
18
+ # layout no longer changes when the gem does.
19
+ #
20
+ # Run: rails generate ruact:layout
21
+ class LayoutGenerator < Rails::Generators::Base
22
+ source_root Ruact.views_path
23
+
24
+ desc "Copies ruact's layout into app/views/layouts/ruact.html.erb so your app owns it"
25
+
26
+ DESTINATION = "app/views/layouts/ruact.html.erb"
27
+
28
+ # `copy_file` already refuses to overwrite silently: an existing file
29
+ # prompts, or is skipped/overwritten under `--skip`/`--force`, which is
30
+ # the Rails convention every other generator follows.
31
+ def copy_layout
32
+ copy_file "layouts/ruact.html.erb", DESTINATION
33
+ end
34
+
35
+ # Only on `generate` (not `destroy`), and phrased for what is true: the copy
36
+ # renders ruact pages while `config.layout` is "ruact" — under `true` or
37
+ # `false` it is not used at all.
38
+ def explain
39
+ return unless behavior == :invoke
40
+
41
+ say ""
42
+ say " #{DESTINATION} renders ruact pages in place of the one ruact ships,"
43
+ say " as long as config.layout is \"ruact\". It will no longer change when you upgrade the gem —"
44
+ say " compare it with #{File.join(Ruact.views_path, 'layouts/ruact.html.erb')}"
45
+ say " after an upgrade. Keep `<%= ruact_head_assets %>` in <head>, and"
46
+ say " `<div id=\"root\"></div>` with `<%= ruact_js_assets %>` in <body>:"
47
+ say " `rails ruact:doctor` checks all three."
48
+ say ""
49
+ end
50
+ end
51
+ end
52
+ end
@@ -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;
@@ -2,9 +2,10 @@
2
2
 
3
3
  # <%= controller_class_name %>Controller — a complete CRUD on the v2 route-driven contract.
4
4
  #
5
- # `include Ruact::Server` (sibling of the Ruact::Controller already on
6
- # ApplicationController via `ruact:install`) gives this controller two
7
- # behaviours at once:
5
+ # `include Ruact::Controller` makes this controller's pages ruact pages — the
6
+ # install no longer puts it on ApplicationController (Story 17.0g: island mode
7
+ # is the default; in whole-app mode, `ruact:install --app`, including it again
8
+ # here is a no-op). `include Ruact::Server` then gives it two behaviours:
8
9
  #
9
10
  # - GET actions (index/show/new/edit) need NO explicit `ruact_render` —
10
11
  # Ruact::Controller#default_render activates the RSC pipeline implicitly
@@ -22,6 +23,7 @@
22
23
  # NOTE: `include Ruact::Server` must come AFTER `protect_from_forgery` (inherited
23
24
  # from ApplicationController) so the CSRF check precedes these actions.
24
25
  class <%= controller_class_name %>Controller < ApplicationController
26
+ include Ruact::Controller
25
27
  include Ruact::Server
26
28
 
27
29
  # --- GET pages (rendered as RSC) -----------------------------------------
@@ -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} />
@@ -9,7 +9,7 @@ module Ruact
9
9
  # `Ruact::ConfigurationError` with the offending attribute, the caller's
10
10
  # file:line, and the suggested fix. Re-calling `Ruact.configure` after boot
11
11
  # replaces the configuration atomically and emits a `[ruact]` warning.
12
- class Configuration
12
+ class Configuration # rubocop:disable Metrics/ClassLength -- an attribute list with a validator per attribute; Doctor and InstallGenerator carry the same disable
13
13
  # The set of public attributes; new attributes added here automatically
14
14
  # inherit the freeze contract via the `define_method` writer below.
15
15
  ATTRIBUTES = %i[
@@ -25,6 +25,7 @@ module Ruact
25
25
  signed_global_id_default_expires_in
26
26
  shadcn_compatible_versions
27
27
  layout
28
+ layout_stylesheets
28
29
  ].freeze
29
30
 
30
31
  # @!attribute [r] manifest_path
@@ -72,7 +73,7 @@ module Ruact
72
73
  # a stream-safety guarantee — Rack's multipart parser will still buffer
73
74
  # bodies up to its own limits before the guard rejects. For very large
74
75
  # uploads route through Active Storage Direct Upload or a presigned S3
75
- # URL; see `website/docs/api/server-actions.md` "File uploads" section.
76
+ # URL; see https://ruact.dev/docs/api/server-actions.html, "File uploads".
76
77
  # @example Raise the limit to 25 MB
77
78
  # Ruact.configure { |c| c.max_upload_bytes = 25 * 1024 * 1024 }
78
79
  # @example Disable the gem-side guard (reverse proxy owns the cap)
@@ -131,11 +132,13 @@ module Ruact
131
132
  # host `package.json`) that is NOT in this list, it emits a warning
132
133
  # (never a hard stop) that the generated components may import from
133
134
  # outdated `@/components/ui/*` paths. Must be a non-empty Array of
134
- # 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).
135
138
  # A dev who has manually verified a newer major adds it here to
136
139
  # suppress the warning — the documented "override" path.
137
140
  # @example Allow shadcn v3 once you have verified it
138
- # Ruact.configure { |c| c.shadcn_compatible_versions = [1, 2, 3] }
141
+ # Ruact.configure { |c| c.shadcn_compatible_versions = [1, 2, 3, 4] }
139
142
  #
140
143
  # @!attribute [r] layout
141
144
  # @return [Boolean, String] Which document wrapper a ruact page's HTML
@@ -144,17 +147,21 @@ module Ruact
144
147
  # full-document render a browser gets on a normal navigation.
145
148
  #
146
149
  # - `false` (default) — render the gem's built-in minimal shell.
150
+ # - `"ruact"` — render through the layout the gem ships
151
+ # (`lib/ruact/views/layouts/ruact.html.erb`, Story 17.0b): CSRF, CSP,
152
+ # the client-component CSS, then the app stylesheets named in
153
+ # {#layout_stylesheets}. What `rails generate ruact:install` writes.
154
+ # It edits no layout of the app; an app that puts its own
155
+ # `app/views/layouts/ruact.html.erb` in place (`rails generate
156
+ # ruact:layout` copies the gem's) wins by view-path order.
147
157
  # - `true` — render through the controller's normal Rails layout.
148
- # - a String — render through that named layout (e.g. `"ruact"`).
158
+ # - another String — render through that named layout.
149
159
  #
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.
160
+ # The trade-off between the last two and `"ruact"`: the app's own layout
161
+ # brings its whole `<head>` (favicons, fonts, analytics, `<head>`-writing
162
+ # gems) but has to be wired by hand; the gem's layout needs no wiring but
163
+ # brings only the stylesheets. The built-in shell carries neither — under
164
+ # the `false` default a ruact page renders with no app CSS at all.
158
165
  #
159
166
  # **This setting is deliberately explicit — there is no auto-detection.**
160
167
  # ruact used to try to infer whether your layout was ready by inspecting
@@ -173,10 +180,26 @@ module Ruact
173
180
  # @note A ruact view is rendered in its own pass (it produces the component
174
181
  # tree), so `content_for` declared inside the view does NOT reach the
175
182
  # 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
183
+ # @example Render through the layout ruact ships (what ruact:install writes)
179
184
  # Ruact.configure { |c| c.layout = "ruact" }
185
+ # @example Let your own application layout own the document
186
+ # Ruact.configure { |c| c.layout = true }
187
+ #
188
+ # @!attribute [r] layout_stylesheets
189
+ # @return [Array<Symbol, String>] The app stylesheets the gem's layout
190
+ # (`layouts/ruact`, used when `layout` is `"ruact"`) links, passed as-is
191
+ # to `stylesheet_link_tag`. They come AFTER the client-component CSS
192
+ # (`ruact_head_assets`), so the app's own CSS loads last and wins ties.
193
+ #
194
+ # Defaults to `[:app]`, what `rails new` 8.x puts in its layout: under
195
+ # Propshaft it expands to every stylesheet on the load path, including a
196
+ # Tailwind build. Under Sprockets `:app` means a file called `app.css`,
197
+ # which is why `rails generate ruact:install` writes `["application"]`
198
+ # there instead. `[]` links none. Anything more than a list of names —
199
+ # a media attribute, fonts, other `<head>` tags — is what ejecting the
200
+ # layout is for: `rails generate ruact:layout`.
201
+ # @example A Sprockets app
202
+ # Ruact.configure { |c| c.layout_stylesheets = ["application"] }
180
203
  ATTRIBUTES.each do |attr|
181
204
  attr_reader attr
182
205
 
@@ -229,8 +252,9 @@ module Ruact
229
252
  @query_parent_controller = "ApplicationController"
230
253
  @signed_global_id_default_purpose = nil
231
254
  @signed_global_id_default_expires_in = nil
232
- @shadcn_compatible_versions = [1, 2]
255
+ @shadcn_compatible_versions = [1, 2, 4]
233
256
  @layout = false
257
+ @layout_stylesheets = [:app]
234
258
  end
235
259
  end
236
260
 
@@ -266,6 +290,10 @@ module Ruact
266
290
  # reference, but a caller probing `frozen?` would see the right answer.
267
291
  if value.is_a?(Proc)
268
292
  value.freeze
293
+ elsif value.is_a?(Array)
294
+ # Story 17.0b — `layout_stylesheets` holds Strings; freezing only the
295
+ # Array would leave `Ruact.config.layout_stylesheets.first << "x"` open.
296
+ instance_variable_set("@#{attr}", value.map { |item| item.frozen? ? item : item.dup.freeze }.freeze)
269
297
  else
270
298
  instance_variable_set("@#{attr}", value.dup.freeze)
271
299
  end
@@ -293,6 +321,7 @@ module Ruact
293
321
  when :query_parent_controller then validate_query_parent_controller!(value)
294
322
  when :shadcn_compatible_versions then validate_shadcn_compatible_versions!(value)
295
323
  when :layout then validate_layout!(value)
324
+ when :layout_stylesheets then validate_layout_stylesheets!(value)
296
325
  end
297
326
  end
298
327
 
@@ -325,6 +354,31 @@ module Ruact
325
354
  "false uses ruact's built-in shell."
326
355
  end
327
356
 
357
+ # Story 17.0b — the arguments `layouts/ruact` passes to `stylesheet_link_tag`.
358
+ # Checked at boot because the layout splats them straight into a Rails
359
+ # helper, where a stray nil or Hash would surface as a first-render error
360
+ # instead of a legible configuration one.
361
+ #
362
+ # Propshaft reads `:app` / `:all` ONLY as the first item, and then ignores
363
+ # every other one (`case sources.first when :app then sources = …`): so
364
+ # `[:app, "theme"]` silently drops "theme", and `["reset", :app]` looks for
365
+ # a file named app.css and raises. Either is allowed only on its own.
366
+ def validate_layout_stylesheets!(value)
367
+ if value.is_a?(Array) && value.length > 1 && value.intersect?(%i[app all])
368
+ raise Ruact::ConfigurationError,
369
+ "Ruact::Configuration#layout_stylesheets: :app and :all stand alone — Propshaft reads " \
370
+ "them only as the whole list and drops anything next to them; got #{value.inspect}. " \
371
+ "Use [:app], or list the stylesheets by name."
372
+ end
373
+ return if value.is_a?(Array) && value.all? { |name| name.is_a?(Symbol) || (name.is_a?(String) && !name.empty?) }
374
+
375
+ raise Ruact::ConfigurationError,
376
+ "Ruact::Configuration#layout_stylesheets must be an Array of stylesheet names " \
377
+ "(Symbols or non-empty Strings, as you would pass to stylesheet_link_tag), " \
378
+ "e.g. [:app] or [\"application\"]; got #{value.inspect} (#{value.class.name}). " \
379
+ "Use [] to link none of your app's stylesheets."
380
+ end
381
+
328
382
  def validate_max_upload_bytes!(value)
329
383
  return if value.nil?
330
384
  return if value.is_a?(Integer) && value >= 0