ruact 0.0.7 → 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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +59 -1
- data/docs/internal/decisions/server-functions-api.md +55 -0
- data/lib/generators/ruact/install/install_generator.rb +378 -6
- data/lib/generators/ruact/install/templates/AGENTS.md.tt +159 -0
- data/lib/generators/ruact/install/templates/Procfile.dev.tt +3 -0
- data/lib/generators/ruact/install/templates/globals.css.tt +20 -0
- data/lib/generators/ruact/install/templates/initializer.rb.tt +9 -0
- data/lib/generators/ruact/install/templates/package.json.tt +7 -1
- data/lib/generators/ruact/install/templates/tsconfig.json.tt +18 -0
- data/lib/generators/ruact/scaffold/scaffold_shadcn_preflight.rb +10 -0
- data/lib/ruact/configuration.rb +73 -0
- data/lib/ruact/controller/document_rendering.rb +210 -0
- data/lib/ruact/controller.rb +14 -46
- data/lib/ruact/doctor.rb +102 -12
- data/lib/ruact/erb_preprocessor.rb +113 -0
- data/lib/ruact/errors.rb +16 -0
- data/lib/ruact/layout_source.rb +59 -0
- data/lib/ruact/manifest_resolver.rb +2 -2
- data/lib/ruact/serializable.rb +98 -6
- data/lib/ruact/server.rb +163 -0
- data/lib/ruact/server_functions/introspection.rb +81 -0
- data/lib/ruact/server_functions.rb +26 -4
- data/lib/ruact/testing/component_query.rb +113 -0
- data/lib/ruact/testing/flight_extractor.rb +221 -0
- data/lib/ruact/testing/flight_structure_diff.rb +267 -0
- data/lib/ruact/testing/flight_wire_parser.rb +138 -0
- data/lib/ruact/testing.rb +90 -0
- data/lib/ruact/version.rb +1 -1
- data/lib/ruact/view_helper.rb +10 -1
- data/lib/ruact.rb +1 -0
- data/lib/tasks/ruact.rake +55 -2
- data/spec/fixtures/story_7_9_views/controller_request_spec_support/exploding_layout_demo/show.html.erb +3 -0
- data/spec/fixtures/story_7_9_views/controller_request_spec_support/ghost_layout_demo/show.html.erb +3 -0
- data/spec/fixtures/story_7_9_views/controller_request_spec_support/layout_demo/show.html.erb +3 -0
- data/spec/fixtures/story_7_9_views/controller_request_spec_support/rootless_layout_demo/show.html.erb +3 -0
- data/spec/fixtures/story_7_9_views/controller_request_spec_support/unwired_layout_demo/show.html.erb +3 -0
- data/spec/fixtures/story_7_9_views/layouts/bare_host.html.erb +16 -0
- data/spec/fixtures/story_7_9_views/layouts/exploding_host.html.erb +24 -0
- data/spec/fixtures/story_7_9_views/layouts/rootless_host.html.erb +15 -0
- data/spec/fixtures/story_7_9_views/layouts/ruact_host.html.erb +17 -0
- data/spec/ruact/controller_request_spec.rb +203 -0
- data/spec/ruact/doctor_spec.rb +222 -6
- data/spec/ruact/erb_preprocessor_spec.rb +145 -0
- data/spec/ruact/install_generator_spec.rb +727 -70
- data/spec/ruact/layout_source_spec.rb +108 -0
- data/spec/ruact/manifest_resolver_spec.rb +15 -0
- data/spec/ruact/scaffold_generator_spec.rb +14 -0
- data/spec/ruact/serializable_spec.rb +126 -0
- data/spec/ruact/server_bucket_request_spec.rb +291 -0
- data/spec/ruact/server_functions/introspection_spec.rb +135 -0
- data/spec/ruact/tasks_json_introspection_spec.rb +141 -0
- data/spec/ruact/testing/have_ruact_component_spec.rb +170 -0
- data/spec/ruact/testing/no_production_load_spec.rb +41 -0
- data/spec/support/flight_wire_parser.rb +12 -126
- data/spec/support/matchers/flight_fixture_matcher.rb +7 -258
- data/vendor/javascript/ruact-server-functions-runtime/index.d.ts +25 -0
- data/vendor/javascript/ruact-server-functions-runtime/index.js +104 -7
- data/vendor/javascript/ruact-server-functions-runtime/index.test.mjs +173 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/auto-revalidate.test-d.ts +25 -0
- metadata +30 -4
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
<!-- ruact:begin -->
|
|
2
|
+
<!-- Managed by `rails generate ruact:install`. Re-run it with --force after a
|
|
3
|
+
gem upgrade to refresh ONLY this marked section; everything you write
|
|
4
|
+
outside the markers is preserved. Kept in sync manually with the online
|
|
5
|
+
copy at https://ruact.dev/llms.txt -->
|
|
6
|
+
|
|
7
|
+
# ruact — conventions for coding agents
|
|
8
|
+
|
|
9
|
+
ruact renders React Server Components from Rails. ERB templates ARE the server
|
|
10
|
+
components (serialized to the React Flight wire format), Rails routes are the
|
|
11
|
+
single source of truth, and there is no Node server — Vite only bundles the
|
|
12
|
+
client components. ruact is NOT Inertia, react-rails, or Next.js: do not
|
|
13
|
+
transplant their patterns. When unsure, read the generated code (see "Ground
|
|
14
|
+
truth" below) instead of guessing.
|
|
15
|
+
|
|
16
|
+
## Mental model
|
|
17
|
+
|
|
18
|
+
- A page is a normal Rails controller action rendering a normal `.html.erb`.
|
|
19
|
+
- Interactive components live in `app/javascript/components/` as `"use client"`
|
|
20
|
+
files, mounted from ERB with a PascalCase self-closing tag:
|
|
21
|
+
`<LikeButton postId={@post.id} />`.
|
|
22
|
+
- Mutations are ordinary non-GET controller actions on a controller that does
|
|
23
|
+
`include Ruact::Server`. Reads are `Ruact::Query` classes in `app/queries/`.
|
|
24
|
+
- ruact generates a typed TypeScript module from the route table; React imports
|
|
25
|
+
server functions from `@/.ruact/server-functions`.
|
|
26
|
+
|
|
27
|
+
## Server functions — the verb rule
|
|
28
|
+
|
|
29
|
+
`include Ruact::Server` makes a controller's routed non-GET actions
|
|
30
|
+
(`POST`/`PATCH`/`PUT`/`DELETE`) callable from React. GET page actions are
|
|
31
|
+
untouched. There is no per-action DSL and no synthetic endpoint: the action IS
|
|
32
|
+
the function, reached at its real route, visible in `bin/rails routes`. The JS
|
|
33
|
+
runtime forwards the CSRF token automatically; keep `include Ruact::Server`
|
|
34
|
+
AFTER `protect_from_forgery` in the ancestor chain.
|
|
35
|
+
|
|
36
|
+
```ruby
|
|
37
|
+
class PostsController < ApplicationController
|
|
38
|
+
include Ruact::Server
|
|
39
|
+
|
|
40
|
+
def create
|
|
41
|
+
@post = Post.create!(post_params) # ivars become the JSON result
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
```tsx
|
|
47
|
+
import { createPost } from "@/.ruact/server-functions";
|
|
48
|
+
const { post } = await createPost({ post: { title: "Hi" } });
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Reads — queries and useQuery
|
|
52
|
+
|
|
53
|
+
Reads are public methods on `Ruact::Query` subclasses in `app/queries/`,
|
|
54
|
+
mounted in `config/routes.rb` with `ruact_queries CatalogQuery` (one named GET
|
|
55
|
+
route per public method, default prefix `/q`). Call them with `useQuery`:
|
|
56
|
+
|
|
57
|
+
```tsx
|
|
58
|
+
import { categories, useQuery } from "@/.ruact/server-functions";
|
|
59
|
+
const { data, loading, error } = useQuery(categories);
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Query params come from the method's keyword arguments and accept ONLY
|
|
63
|
+
`string | number | boolean | null` (arrays/objects are rejected). Queries run
|
|
64
|
+
the host controller's callback chain (authentication etc.) before the query
|
|
65
|
+
object is instantiated.
|
|
66
|
+
|
|
67
|
+
## Ground truth — read the generated file
|
|
68
|
+
|
|
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:
|
|
73
|
+
|
|
74
|
+
bin/rails ruact:server_functions:generate
|
|
75
|
+
|
|
76
|
+
## Five traps
|
|
77
|
+
|
|
78
|
+
1. **Component tags take no children.** Client component tags are self-closing
|
|
79
|
+
ONLY: `<Card content={@body} />`, never `<Card>...</Card>`. Children (a
|
|
80
|
+
matching closing tag) fail LOUDLY with a `PreprocessorError` at preprocess
|
|
81
|
+
time — pass content as a prop instead. Sole exception: the built-in
|
|
82
|
+
`<Suspense fallback="...">...</Suspense>` pair.
|
|
83
|
+
2. **`{}` props are Ruby, not JavaScript.** `<Badge label={@post.title} />`
|
|
84
|
+
evaluates `@post.title` as a Ruby expression in the ERB. No JS ternaries,
|
|
85
|
+
no `{...spread}`, no JSX children. Plain unbraced string props are not
|
|
86
|
+
supported either — always use braces.
|
|
87
|
+
3. **One action, two response shapes.** A `Ruact::Server` non-GET action
|
|
88
|
+
answers JSON — its instance variables, or `204`, or `{"$redirect": path}` —
|
|
89
|
+
when the request's `Accept` header is exactly `application/json`, which is
|
|
90
|
+
what every generated-accessor call sends (including
|
|
91
|
+
`<form action={createPost}>`). Any other request shape renders normally
|
|
92
|
+
(a Flight stream for client-side navigation, an HTML page otherwise). You
|
|
93
|
+
cannot infer the response shape from the controller body alone — the
|
|
94
|
+
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.
|
|
101
|
+
5. **Accessor names are derived, not declared.** `posts#create` → `createPost`,
|
|
102
|
+
`posts#publish_all` → `publishAllPosts`; query methods camelCase the same
|
|
103
|
+
way (`search_users` → `searchUsers`). Collisions fail loudly at boot;
|
|
104
|
+
break an action tie with `ruact_function_name :action, as: "otherName"` —
|
|
105
|
+
a query tie by renaming the query method. Never guess a name — read
|
|
106
|
+
`app/javascript/.ruact/server-functions.ts`.
|
|
107
|
+
|
|
108
|
+
## Serialization is an allowlist
|
|
109
|
+
|
|
110
|
+
Only the fields you name cross to the client. `include Ruact::Serializable` +
|
|
111
|
+
`ruact_props :id, :title` is that allowlist — on a PORO OR an ActiveRecord model
|
|
112
|
+
(lazy AR readers checked on first serialize, not class-load; a typo still raises):
|
|
113
|
+
|
|
114
|
+
```ruby
|
|
115
|
+
class Post < ApplicationRecord
|
|
116
|
+
include Ruact::Serializable
|
|
117
|
+
ruact_props :id, :title # other columns never cross
|
|
118
|
+
end
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
The manual row hash `{ id: p.id, title: p.title }` is the equivalent for a
|
|
122
|
+
no-model shape. An object with no allowlist warns + falls back to `as_json` in
|
|
123
|
+
dev; production (`strict_serialization`) raises, as does `as_json` returning `self`.
|
|
124
|
+
|
|
125
|
+
## Record references — signed and explicit
|
|
126
|
+
|
|
127
|
+
Do not put raw record ids in props and trust them later. Mint a scoped signed
|
|
128
|
+
token and resolve it back:
|
|
129
|
+
|
|
130
|
+
```ruby
|
|
131
|
+
token = Ruact.signed_global_id(@post, for: :editing, expires_in: 1.hour)
|
|
132
|
+
post = Ruact.locate_signed(params[:token], for: :editing) # tampered → 400
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
The purpose (`for:`) is always required; the expiry must be explicit — a
|
|
136
|
+
duration, or a deliberate `expires_in: nil` for a non-expiring token.
|
|
137
|
+
|
|
138
|
+
## Verify your work
|
|
139
|
+
|
|
140
|
+
- `bin/rails ruact:doctor` — checks install/config health; exits 1 on failure.
|
|
141
|
+
Append `-- --json` for the same checks as a machine-readable report.
|
|
142
|
+
- `bin/rails ruact:routes -- --json` — the accessor/route table (each accessor's
|
|
143
|
+
name, kind, verb, path, params) as JSON, from the same route table codegen
|
|
144
|
+
reads; bare `ruact:routes` prints a compact human table.
|
|
145
|
+
- Both `--json` outputs are EXPERIMENTAL — the shape may change; gate on their
|
|
146
|
+
`schema_version` field (currently `0`), do not treat it as a stable contract.
|
|
147
|
+
- `bin/rails ruact:server_functions:generate` — regenerates the TS module;
|
|
148
|
+
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
|
+
- If your app has TypeScript tooling configured, `npx tsc --noEmit`
|
|
152
|
+
type-checks call sites against the generated accessor types (a fresh
|
|
153
|
+
install does not ship a tsconfig).
|
|
154
|
+
- In request specs, `require "ruact/testing"` then assert a page rendered a
|
|
155
|
+
component — `expect(response).to have_ruact_component("PostList").with_props(including("posts"))`
|
|
156
|
+
— no browser. Function-call/query responses are JSON: `JSON.parse(response.body)`.
|
|
157
|
+
|
|
158
|
+
Docs: https://ruact.dev — compact agent reference at https://ruact.dev/llms.txt
|
|
159
|
+
<!-- ruact: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?
|
data/lib/ruact/configuration.rb
CHANGED
|
@@ -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)
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ruact
|
|
4
|
+
module Controller
|
|
5
|
+
# How a ruact response becomes a full HTML DOCUMENT — the wrapper a browser
|
|
6
|
+
# gets on a normal navigation, as opposed to the raw `text/x-component`
|
|
7
|
+
# Flight body an in-app navigation gets.
|
|
8
|
+
#
|
|
9
|
+
# Split out of `Ruact::Controller` because it answers one self-contained
|
|
10
|
+
# question ("who owns the `<head>`?") whose answer is load-bearing: the host
|
|
11
|
+
# app's layout does, because `stylesheet_link_tag`, favicons, fonts,
|
|
12
|
+
# analytics and every `<head>`-writing gem live there. `#ruact_html_shell`
|
|
13
|
+
# is the fallback for an app whose layout has not been migrated — it is
|
|
14
|
+
# deliberately minimal and has NO stylesheet slot.
|
|
15
|
+
module DocumentRendering
|
|
16
|
+
extend ActiveSupport::Concern
|
|
17
|
+
|
|
18
|
+
# Proof that a host layout called `ruact_js_assets`: the helper always
|
|
19
|
+
# emits the `__FLIGHT_DATA` bootstrap script when a payload is present,
|
|
20
|
+
# and `render_ruact_document` always supplies one. Matching on the payload
|
|
21
|
+
# script (rather than on the entry `<script src>`) keeps the check true in
|
|
22
|
+
# BOTH dev and production, whose entry tags differ.
|
|
23
|
+
RUACT_ASSETS_MARKER = "__FLIGHT_DATA"
|
|
24
|
+
|
|
25
|
+
# What counts as a mount target and what counts as a real call both live in
|
|
26
|
+
# {Ruact::LayoutSource}, shared with `ruact:install` so the runtime and the
|
|
27
|
+
# generator can never disagree about whether a layout is migrated.
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
# Emit the full HTML document a browser gets on a normal navigation.
|
|
32
|
+
#
|
|
33
|
+
# `Ruact.config.layout` decides, and it is EXPLICIT — ruact does not try
|
|
34
|
+
# to infer whether your layout is ready. It used to: three review rounds
|
|
35
|
+
# each found another template shape that fooled the inference (a mention
|
|
36
|
+
# in a comment, a commented-out call, a trim-mode comment), and each wrong
|
|
37
|
+
# answer governed how every page in the app rendered. Answering "does this
|
|
38
|
+
# template call this method?" is not something pattern-matching can do
|
|
39
|
+
# reliably, so the question is no longer asked. See
|
|
40
|
+
# Ruact::Configuration#layout.
|
|
41
|
+
#
|
|
42
|
+
# - `false` (the default) → the built-in shell. Byte-identical to ruact's
|
|
43
|
+
# behaviour before the layout path existed, with no detection in the
|
|
44
|
+
# way, so an app that has not opted in cannot be affected by any of this.
|
|
45
|
+
# - `true` / a String → render through the app's layout. `ruact:install`
|
|
46
|
+
# writes both halves of that opt-in together: `config.layout = true` in
|
|
47
|
+
# the initializer AND `<%= ruact_js_assets %>` in the layout.
|
|
48
|
+
#
|
|
49
|
+
# An opted-in layout that does not actually emit the assets would produce
|
|
50
|
+
# a blank page, so the rendered document is checked before it is
|
|
51
|
+
# committed: that is a configuration error, raised in development/test and
|
|
52
|
+
# logged-and-degraded in production rather than served to real traffic.
|
|
53
|
+
def render_ruact_document(payload)
|
|
54
|
+
layout = Ruact.config.layout
|
|
55
|
+
return render html: ruact_html_shell(payload).html_safe, layout: false if layout == false
|
|
56
|
+
|
|
57
|
+
unless ruact_layout_resolvable?(layout)
|
|
58
|
+
__ruact_handle_unready_layout(layout, :missing)
|
|
59
|
+
return render html: ruact_html_shell(payload).html_safe, layout: false
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Copied to the view by Rails' `view_assigns` plumbing (the name does not
|
|
63
|
+
# match the `/\A@_/` protected-ivar filter) — that is how the layout's
|
|
64
|
+
# zero-argument `ruact_js_assets` reaches THIS render's Flight payload.
|
|
65
|
+
@ruact_flight_payload = payload
|
|
66
|
+
document = render_to_string(html: "".html_safe, layout: layout)
|
|
67
|
+
|
|
68
|
+
if ruact_document_mountable?(document)
|
|
69
|
+
render html: document.html_safe, layout: false
|
|
70
|
+
else
|
|
71
|
+
__ruact_handle_unready_layout(layout, :unwired)
|
|
72
|
+
render html: ruact_html_shell(payload).html_safe, layout: false
|
|
73
|
+
end
|
|
74
|
+
ensure
|
|
75
|
+
remove_instance_variable(:@ruact_flight_payload) if instance_variable_defined?(:@ruact_flight_payload)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# A document is only usable if BOTH halves are present: the payload/bootstrap
|
|
79
|
+
# block AND something to mount into. Checking the assets alone accepts a
|
|
80
|
+
# layout that calls `ruact_js_assets` in `<head>` but never got the root div
|
|
81
|
+
# — React then boots with no mount target and the page is silently blank.
|
|
82
|
+
def ruact_document_mountable?(document)
|
|
83
|
+
document.include?(RUACT_ASSETS_MARKER) && Ruact::LayoutSource.root?(document)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Is there a layout to render into at all? Asking FIRST matters: passing
|
|
87
|
+
# `layout: true` to a controller with no resolvable layout raises
|
|
88
|
+
# `ArgumentError("There was no default layout for ...")`, which would turn
|
|
89
|
+
# every page of an API-shaped or `layout false` controller into a 500 in
|
|
90
|
+
# an app that opted in globally. `_default_layout`'s `require_layout`
|
|
91
|
+
# argument defaults to false precisely so it can be used as a probe — it
|
|
92
|
+
# returns nil instead of raising, and honours `action_has_layout?`.
|
|
93
|
+
#
|
|
94
|
+
# `NameError` is deliberately NOT swallowed: `_default_layout` re-raises it
|
|
95
|
+
# as "Could not render layout: ..." to surface a broken layout resolver
|
|
96
|
+
# (`layout -> { MissingConstant::LAYOUT }`). Turning that into "no layout"
|
|
97
|
+
# would hide the developer's bug behind a silently degraded page.
|
|
98
|
+
def ruact_layout_resolvable?(layout)
|
|
99
|
+
return ruact_layout_exists?(layout) if layout.is_a?(String)
|
|
100
|
+
|
|
101
|
+
resolved = _default_layout(lookup_context, [:html], [])
|
|
102
|
+
return false if resolved.nil? || resolved == false
|
|
103
|
+
# `_default_layout` returns a Template for the conventional lookup but a
|
|
104
|
+
# bare String path for a controller-declared `layout "name"` — and it
|
|
105
|
+
# returns that String WITHOUT checking the template exists. Treating
|
|
106
|
+
# "not nil" as resolvable therefore sent a declared-but-missing layout
|
|
107
|
+
# into `render_to_string`, where it raised `MissingTemplate` instead of
|
|
108
|
+
# taking the intended degrade-to-shell path.
|
|
109
|
+
return ruact_layout_exists?(resolved) if resolved.is_a?(String)
|
|
110
|
+
|
|
111
|
+
true
|
|
112
|
+
rescue NameError
|
|
113
|
+
raise
|
|
114
|
+
rescue StandardError
|
|
115
|
+
false
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def ruact_layout_exists?(name)
|
|
119
|
+
lookup_context.exists?(name.to_s.delete_prefix("layouts/"), ["layouts"])
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def __ruact_handle_unready_layout(_layout, reason)
|
|
123
|
+
detail =
|
|
124
|
+
if reason == :missing
|
|
125
|
+
"no layout could be resolved for it"
|
|
126
|
+
else
|
|
127
|
+
"the layout it rendered emitted no `ruact_js_assets` output (or no " \
|
|
128
|
+
"`<div id=\"root\"></div>` to mount into), which would have been a blank page"
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
message = <<~MSG.strip
|
|
132
|
+
ruact: #{controller_path}##{action_name} fell back to ruact's built-in HTML shell — #{detail}.
|
|
133
|
+
`Ruact.config.layout` is set, so this is a configuration error, not a default:
|
|
134
|
+
the built-in shell has no stylesheet slot, so your app's CSS does not reach this page.
|
|
135
|
+
Add `<%= ruact_js_assets %>` next to the `<div id="root"></div>` in your layout
|
|
136
|
+
(`rails generate ruact:install` writes both; `rails ruact:doctor` reports what is missing),
|
|
137
|
+
or set `Ruact.configure { |c| c.layout = false }` to use the built-in shell deliberately.
|
|
138
|
+
MSG
|
|
139
|
+
|
|
140
|
+
# A MISSING layout is a legitimate per-controller choice — an API-shaped
|
|
141
|
+
# controller, or one that declared `layout false`, inside an app that
|
|
142
|
+
# opted in globally. Raising there would break a normal Rails pattern,
|
|
143
|
+
# so it degrades to the shell and says so where a developer will see it.
|
|
144
|
+
#
|
|
145
|
+
# An UNWIRED layout is different: the developer pointed ruact at a
|
|
146
|
+
# layout that cannot mount the app, which is their bug to see — loudly
|
|
147
|
+
# in development, and degraded (never blank) in production.
|
|
148
|
+
if reason == :missing
|
|
149
|
+
logger&.info(message) if __ruact_local_env?
|
|
150
|
+
return
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
raise Ruact::Error, message if __ruact_local_env?
|
|
154
|
+
|
|
155
|
+
logger&.error(message)
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def __ruact_local_env?
|
|
159
|
+
Rails.env.development? || Rails.env.test?
|
|
160
|
+
rescue StandardError
|
|
161
|
+
false
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def ruact_html_shell(flight_payload)
|
|
165
|
+
# Story 14.2 — the JS asset block (entry `<script>` tags + `__FLIGHT_DATA`)
|
|
166
|
+
# is delegated to the single `Ruact::ViewHelper#ruact_js_assets`
|
|
167
|
+
# implementation. The bootstrap entry script is a deferred ES module, so it
|
|
168
|
+
# runs after the inline `__FLIGHT_DATA` classic script has populated the
|
|
169
|
+
# queue regardless of source order — emitting the whole block in `<body>`
|
|
170
|
+
# is correct.
|
|
171
|
+
<<~HTML
|
|
172
|
+
<!DOCTYPE html>
|
|
173
|
+
<html lang="en">
|
|
174
|
+
<head>
|
|
175
|
+
<meta charset="UTF-8" />
|
|
176
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
177
|
+
#{ruact_csrf_meta_tag}
|
|
178
|
+
<title>Rails RSC</title>
|
|
179
|
+
</head>
|
|
180
|
+
<body>
|
|
181
|
+
<div id="root"></div>
|
|
182
|
+
#{ruact_js_assets(flight_payload)}
|
|
183
|
+
</body>
|
|
184
|
+
</html>
|
|
185
|
+
HTML
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# Story 8.3 review R7 — emits `<meta name="csrf-token" content="...">`
|
|
189
|
+
# into the shell so the JS runtime's `<meta>` lookup can forward a
|
|
190
|
+
# valid `X-CSRF-Token` on every server-function (mutation) call. Without
|
|
191
|
+
# this, hosts that route `ruact_render` through the gem's HTML shell (the
|
|
192
|
+
# standard path) have no token in the document and the host's
|
|
193
|
+
# `protect_from_forgery` rejects every non-GET server function.
|
|
194
|
+
#
|
|
195
|
+
# Returns an empty string when CSRF protection isn't available
|
|
196
|
+
# (non-Rails specs, or hosts that have deliberately stripped
|
|
197
|
+
# `form_authenticity_token` from the controller surface).
|
|
198
|
+
def ruact_csrf_meta_tag
|
|
199
|
+
return "" unless respond_to?(:form_authenticity_token, true)
|
|
200
|
+
|
|
201
|
+
token = form_authenticity_token
|
|
202
|
+
return "" if token.nil? || token.empty?
|
|
203
|
+
|
|
204
|
+
%(<meta name="csrf-token" content="#{ERB::Util.html_escape(token)}" />)
|
|
205
|
+
rescue StandardError
|
|
206
|
+
""
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
end
|