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
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f1722a96137257e2263f7aee2e2315c4a197f634fad94096da555de6d7a50af9
|
|
4
|
+
data.tar.gz: 34d485a11f2b0cf893770bddbf4c48fa757d908f0f24e0c64e03d2f612bd351f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 770a14ec78795d73afed6bc044f2616167e5cd4a6b173489ad9c8e8c1df893534b54e77f5515e59a3fecbd54a4d2763eaaa3a920cbf40fc7594922e7e831a912
|
|
7
|
+
data.tar.gz: 41fc153c5d7470df08b7ca02b2c2da2a2ae0c74c717186119ed2eaa4504b903da1cfab43754a21f738b40541afc4ed98534f05fdcf4e3ba3c317f963b351b27b
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,62 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.0.9] - 2026-08-12
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **`rails generate ruact:install --shadcn` — the `--shadcn` scaffold path now has a working setup.** `ruact:scaffold --shadcn` emits components dressed in Tailwind classes, but nothing in ruact ever wired Tailwind, so the generated CRUD rendered **unstyled**: the classes had nothing to resolve against. Worse, the documented next step did not work either — shadcn's own CLI **refuses to initialize** in a ruact app, aborting with *"No Tailwind CSS configuration found"* and *"Could not find valid path aliases"*, because a fresh ruact app ships neither Tailwind nor a `tsconfig.json`.
|
|
15
|
+
|
|
16
|
+
The new flag emits exactly those prerequisites, each verified against the real shadcn CLI: `app/javascript/styles/globals.css` (the Tailwind entry shadcn appends its design tokens to, and points `components.json` at), a `tsconfig.json` carrying the `@/*` → `app/javascript/*` alias (the bundled Vite plugin already registers the same alias for the *bundler*; this is what makes it resolve for *TypeScript*, and therefore for shadcn's probe and your editor), `app/assets/builds/` with the compiled stylesheet gitignored, the `build:css` script, and a `css` process in `Procfile.dev` so `bin/dev` rebuilds the stylesheet alongside Rails and Vite.
|
|
17
|
+
|
|
18
|
+
It then **prints** the two `npx shadcn` commands instead of running them — they hit the network, and `shadcn init` is interactive, so automating them is neither safe (the scaffold generator's "never auto-run npx/npm" rule) nor possible. The printed `init` line pins **`--base radix`**, which is the part nobody would guess: current shadcn defaults to **Base UI**, while the components `ruact:scaffold --shadcn` generates import **Radix** primitives — accepting the default gets you a component library the scaffold cannot use. The printed `add` list is the complete primitive superset, pinned by a spec as a superset of whatever a given resource narrows to, so the two generators cannot drift.
|
|
19
|
+
|
|
20
|
+
**The default (agnostic) path is untouched** — no Tailwind, no `tsconfig.json`, and a byte-identical `package.json` and `Procfile.dev`, all pinned by specs.
|
|
21
|
+
|
|
22
|
+
Verified end to end twice from `rails new`: the generated CRUD renders as a styled shadcn table with the `useQuery` search filtering rows live. Note that shadcn's CLI is currently **4.x** while `shadcn_compatible_versions` defaults to `[1, 2]`, so the scaffold's version pre-flight emits its (non-blocking) warning; the generated components do work against 4.x.
|
|
23
|
+
|
|
24
|
+
### Fixed
|
|
25
|
+
|
|
26
|
+
- **A ruact page can now carry your app's CSS — the Rails layout owns the document.** `ruact_render` rendered the view with `layout: false` and then wrapped the Flight payload in a hardcoded HTML shell whose `<head>` holds only `charset`, `viewport`, the CSRF meta tag and the title `Rails RSC`. That shell has **no stylesheet slot and never uses your layout**, so `stylesheet_link_tag` — and with it favicons, fonts, analytics and every `<head>`-writing gem — could not reach a ruact-rendered page **at all**. The visible consequence: `rails generate ruact:scaffold --shadcn` emitted components whose Tailwind classes had nothing to resolve against, so the generated CRUD was **unstyled by construction**; the docs' instruction to put shadcn theme variables in `app/assets/stylesheets/application.css` pointed at a file that provably never reached the browser; and Epic 12 (`ruact_meta` → tags in `<head>`) had no surface to write into. The default agnostic scaffold was affected too — it renders plain semantic HTML and only *looked* acceptable because browsers style a bare `<table>`.
|
|
27
|
+
|
|
28
|
+
A non-Flight HTML response is now rendered **through the host app's own layout**, with the React root's bootstrap tags supplied by the layout's `<%= ruact_js_assets %>` call (which, called with no argument, picks up the render's Flight payload). The Flight wire shape (`text/x-component`) is untouched — this only changes the full document a browser gets on a normal navigation.
|
|
29
|
+
|
|
30
|
+
Controlled by the new **`Ruact.config.layout`** — `false` by default, `true` (or a layout name) to opt in. `rails generate ruact:install` now writes **both halves of that opt-in in the same run**: `config.layout = true` in the generated initializer, and `<%= ruact_js_assets %>` next to the React root in your layout.
|
|
31
|
+
|
|
32
|
+
**The default is `false`, so an existing app is untouched until it opts in** — not because ruact detects anything, but because it does not look. An earlier cut of this change tried to infer whether your layout was ready by inspecting it; three review rounds each found another template shape that fooled the inference (a mention in a comment, a commented-out call, a trim-mode comment), and each wrong answer decided how every page in the app rendered. "Does this template call this method?" is not a question pattern-matching can answer reliably, so it is no longer asked. One explicit line beats a clever guess for a setting this load-bearing. (`:auto` is rejected with a message naming its replacement, so an initializer carrying it forward cannot be silently reinterpreted.)
|
|
33
|
+
|
|
34
|
+
Once opted in, a layout that cannot mount the app is a configuration error rather than a silent blank page: ruact checks the rendered document for both the assets block and a `<div id="root">`, raising in development/test and logging-and-degrading to the shell in production. A controller with no resolvable layout (API-shaped, or `layout false`) degrades quietly instead — that is a normal Rails choice, not a mistake.
|
|
35
|
+
|
|
36
|
+
**To migrate an existing app:** add `<%= ruact_js_assets %>` next to the `<div id="root"></div>` in `app/views/layouts/application.html.erb`, or re-run `rails generate ruact:install` (which now injects both, and adds only the missing call to a layout that already has the root — tolerating single quotes, extra attributes and a same-line marker, and telling you loudly if it cannot find the root div rather than reporting a success it did not perform). `rails ruact:doctor` reports the half-migrated state as a **warning** naming the one-line fix — where it previously reported a root-only layout as a pass.
|
|
37
|
+
|
|
38
|
+
**Known limitation:** a ruact view is rendered in its own pass (it produces the component tree), so `content_for` declared *inside the view* does not reach the layout. Set document metadata from the controller.
|
|
39
|
+
|
|
40
|
+
`rails ruact:doctor` reports the two halves separately, because they are different fixes: a layout missing the root or the helper **fails**, and a ready layout with `config.layout = false` **warns** and names the setting.
|
|
41
|
+
|
|
42
|
+
## [0.0.8] - 2026-07-11
|
|
43
|
+
|
|
44
|
+
### Changed
|
|
45
|
+
|
|
46
|
+
- **`ManifestError` message is now in English (was Portuguese)** ([Story 15.0](../_bmad-output/implementation-artifacts/15-0-agent-era-hygiene-pass.md)). When the Vite dev server is unreachable AND no `public/react-client-manifest.json` exists on disk, `Ruact::ManifestResolver` now raises `[ruact] Vite dev server unreachable at <url> and no react-client-manifest.json found at <path> — run `bin/dev`.` — same raise site, same `ManifestError` class, same HTTP→file fallback order, same diagnostic content (dev-server URL + manifest path + the `bin/dev` fix); only the language changed. Error messages are prompts — agents (and humans) regex-match them — so the stable tokens (`Vite dev server`, `react-client-manifest.json`, `bin/dev`) are preserved and now spec-pinned.
|
|
47
|
+
|
|
48
|
+
### Added
|
|
49
|
+
|
|
50
|
+
- **`ruact_props` now works directly on an ActiveRecord model — loud check deferred from class-load to first serialize for lazy attributes (Story 13.7)** ([Story 13.7](../_bmad-output/implementation-artifacts/13-7-ruact-props-on-activerecord-models.md)). `include Ruact::Serializable; ruact_props :id, :title` on an `ActiveRecord` model used to raise `ArgumentError: method 'title' is not defined` at **class-load**, because ActiveRecord defines its attribute reader methods **lazily** (on first instance access) — so the eager `method_defined?` check fired before the reader existed, even for a real column. The check is now **hybrid**: a **PORO** is still validated eagerly at class-load (byte-identical — a typo still raises immediately), while for a **lazy-attribute (ActiveRecord) class** the not-yet-defined names are recorded and their loud check is **deferred to the first `ruact_serialize`** (via `respond_to?` on the instance, where the DB is up and the reader exists). **The loud-omission guarantee is preserved** (Epic 13.1/13.2 spine): a bogus/typo'd prop still raises the same clean `ArgumentError` — only the *timing* moved for AR (first render of that model instead of boot). No boot-time DB dependency is introduced (`attribute_names`/`column_names` would need a live connection at class-load — rejected), the gem stays **single-dep `nokogiri`** (the ActiveRecord discriminator `defined?(ActiveRecord::Base) && self < ActiveRecord::Base` references the constant only when the host already defined it), and there is **no new config key, no serializer-pipeline change** (an AR model that includes the module already dispatched to `serialize_serializable` → `ruact_serialize`), and **`strict_serialization` is unaffected** (an AR `Serializable` model serializes identically under strict true/false, as a PORO does). This reverses the doc-only guidance shipped in the AGENTS.md/`llms.txt` serialization-contract fix (gem PR #48): `ruact_props` on an AR model is now a first-class pattern again; the manual row hash (`{ id: p.id, title: p.title }`) remains the equivalent for a no-model / partial shape.
|
|
51
|
+
|
|
52
|
+
- **Auto-revalidate opt-in: one `await` folds a mutation and its in-place refresh into a single call (FR110)** ([Story 15.6](../_bmad-output/implementation-artifacts/15-6-auto-revalidate-opt-in.md)). A mutation whose result is shown *elsewhere* on the page has always needed a manual two-step — `await createPost(fd); await revalidate()` — and the second call is easy to forget, leaving a stale page that reads like a silent bug. There are now two runtime-only, opt-in ways to fold the second call in: an app-wide default via `configureRuactRuntime({ autoRevalidate: true })` (mirrors the existing `defaultHeaders` config), and a per-call `withRefresh(accessor)` wrapper (`await withRefresh(createPost)(fd)`) that forces the refresh for one call and **wins over** the global default. When opted in, a **successful, non-redirecting** mutation `await`s an in-place Flight refresh of the current path (the existing `revalidate()` machinery) **before** the promise resolves, then resolves with the action's JSON result. This is **pure client-side composition** of the two requests that already exist (the POST-JSON mutation + the GET-Flight refresh): **no new wire shape and no inbound Flight deserialization — the serialize-only invariant is untouched.** A `$redirect` response **wins**: the redirect is followed and the refresh is skipped (the destination already re-renders; refreshing the old path would be wrong). A **failed** mutation throws before any refresh runs, so no refresh happens. With no router installed, the descriptive `revalidate()` "wire `setupRouter()`" error surfaces (it is not swallowed) — so a mutation that succeeds but whose refresh rejects **rejects the returned promise**, consistent with `revalidate()`'s loud-by-default stance. Opt-out (default) behavior is **byte-identical** to before, and the generated `.ruact/server-functions.ts` is unchanged — `withRefresh` is imported directly from `ruact/server-functions-runtime` (like `configureRuactRuntime`), so the codegen output and the Ruby↔JS byte-parity fixtures are untouched.
|
|
53
|
+
|
|
54
|
+
- **Dev-only `[ruact]` log line naming the negotiated response shape per request (FR109)** ([Story 15.5](../_bmad-output/implementation-artifacts/15-5-response-shape-legibility-docs-and-dev-log.md)). The single most confusing thing about a `Ruact::Server` action is that the same action, at the same URL, answers different body shapes depending only on the request's `Accept` header + verb — never on the Ruby. In **development only**, `Ruact::Server` now emits exactly one `[ruact]` line per ruact-negotiated request naming the chosen bucket and the deciding signal — e.g. `[ruact] PostsController#create — Accept: application/json + POST → function-call JSON` — so the dual-bucket negotiation is observable at runtime without a debugger. It covers the function-call-JSON bucket (including its `204` and `$redirect` sub-shapes) and, for a page render on a controller that also includes `Ruact::Controller`, the Flight-page and HTML-shell shapes. Log-only: response body, status, and headers are byte-identical in every environment. It is SILENT outside development and on a plain Rails action on the same controller (a response ruact did not negotiate). Mirrors the F6 warning: a private, dev-gated `after_action` beside it, no new config key. The log vocabulary matches the new canonical caller→shape table in the server-actions docs. Coverage is `Ruact::Server`-only for now — queries (separate `GET /q` dispatch) and page-only controllers are documented in the table but not yet logged (deferred).
|
|
55
|
+
|
|
56
|
+
- **Public server-side render-assertion helpers: `have_ruact_component` (FR108)** ([Story 15.4](../_bmad-output/implementation-artifacts/15-4-server-side-render-assertion-helpers.md)). A host request/controller spec can now prove a page rendered a given component with given props against the decoded Flight tree — no browser, no brittle string spelunking on the wire bytes. Load it explicitly (`require "ruact/testing"` from your `spec_helper`/`rails_helper` — it is NOT auto-loaded by `require "ruact"`, so a production boot carries no RSpec dependency), then assert `expect(response).to have_ruact_component("PostList")` or `...have_ruact_component("PostList").with_props(a_hash_including("posts" => ...))`. It accepts an ActionDispatch/Rack response (its `.body` is read) or a raw String, in either page shape — a raw `text/x-component` body or the HTML shell embedding `__FLIGHT_DATA`. The component name resolves against the import row the wire actually carries (its export name or module basename); props are asserted in their serialized wire form (string keys, serialized values) via `===`/`hash_including`. Passing a `Ruact::Server` function-call/query response (plain JSON) raises a clear `Ruact::Testing::NotAFlightResponseError` pointing you at `JSON.parse(response.body)` instead. This is a STABLE public matcher that **wraps, not forks**, the internal Story-7.5 structural parser/diff — those were promoted onto the shipped load path as `Ruact::Testing::FlightWireParser` / `Ruact::Testing::FlightStructureDiff`, and the gem's own `Ruact::Spec` matchers now delegate to that single implementation (one source of truth, no drift). New docs "Testing" page covers all three response shapes (pages, function calls, queries).
|
|
57
|
+
|
|
58
|
+
- **Machine-readable introspection: `ruact:doctor -- --json` and `ruact:routes -- --json` (FR107)** ([Story 15.3](../_bmad-output/implementation-artifacts/15-3-machine-readable-introspection-doctor-routes-json.md)). A coding agent (or CI gate) can now read the two ground-truth surfaces as JSON instead of parsing terminal prose. `ruact:doctor -- --json` emits one document — `schema_version`, an overall `status`, and every health check as `name`/`status`/`message`/`remediation` (a separate, nullable machine-readable fix) — reusing the exact same check results the human path prints (no double-run) and preserving the exit code (0 when all checks pass or warn, 1 on any failure). The new `ruact:routes` task emits the accessor/route table — each entry's `accessor` name, `kind` (action or query), `verb`, `path`, `segments`, and declared `params` (queries from their keyword arguments, actions from their required path segments) — derived from the very same `RouteSource` + `QuerySource` collectors codegen consumes (a shared `Ruact::ServerFunctions.introspect` combine point), so the introspection can never drift from what codegen emits; it writes no bridge or TS file (a CI gate can run it without mutating the tree). Bare `ruact:routes` prints a compact human table. The default human `ruact:doctor` output is unchanged. Both JSON documents are marked EXPERIMENTAL and carry `schema_version: 0` — the shape may change without a major bump while the agent-facing surface is iterated, so parsers should gate on it. The flag is passed after a `--` separator (`bin/rails ruact:doctor -- --json`) so neither Rails nor Rake treats it as an option. The emitted `AGENTS.md`/`llms.txt` "Verify your work" section now references both commands, flagged EXPERIMENTAL.
|
|
59
|
+
|
|
60
|
+
- **Children inside a component tag now fail loudly at preprocess time instead of degrading silently (FR106)** ([Story 15.2](../_bmad-output/implementation-artifacts/15-2-loud-children-error-in-erb-preprocessor.md)). ruact PascalCase component tags are self-closing only — a component receives a props Hash, never a children element tree — but the #1 predictable JSX-habit mistake, `<Card>Hello</Card>`, used to **degrade silently**: the opening tag became a `__ruact_component__("Card", {})` call with no children while the inner content leaked into the surrounding HTML and the `</Card>` passed through as literal garbage. The ERB preprocessor now raises a dedicated `Ruact::ChildrenNotSupportedError` (a `Ruact::PreprocessorError`, so it flows through the same dev error overlay) naming the component, the template `file:line`, and the exact fix — pass content as a prop, e.g. a self-closing `<Card content={...} />`. Detection keys on a **matching PascalCase closing tag** (`</Card>`), NOT on "opening tag lacks `/>`", so a bare non-self-closing opening with no closing tag (`<Dialog open={true}>`) stays a valid call. It runs **after** Suspense normalization (Step 1), so `<Suspense>...</Suspense>` — the one legitimate paired PascalCase tag, already lowercased to `<ruact-suspense>` — never trips. Output is byte-identical for every valid pattern. The shipped `AGENTS.md`/`llms.txt` trap #1 wording is updated from "fails silently" to "fails loudly" now that the artifact exists.
|
|
61
|
+
|
|
62
|
+
- **Dev-only `[ruact]` warning when an action registers `ruact_errors` and then renders explicitly on a function-call request (F6)** ([Story 15.0](../_bmad-output/implementation-artifacts/15-0-agent-era-hygiene-pass.md)). The FR98 `errors` injection rides the implicit `default_render`, so an explicit `render json: {...}` on the failed-save branch of a Bucket-2 (function-call) request silently drops the registered validation errors from the JSON body — a legibility trap for agents following the docs literally. In **development only**, `Ruact::Server` now logs exactly one `[ruact]` warning naming the `controller#action`, explaining the opt-out, and pointing at the fix (fall through, bind `errors={ruact_errors}` on a page render, or `redirect_to`). It never fires on the documented-correct patterns: a Bucket-1 `ruact_errors(record); render :new` page render, a `redirect_to` on either bucket, the fall-through itself, or an action that never touches the collector. Log-only — response bodies and statuses are byte-identical in every environment.
|
|
63
|
+
|
|
64
|
+
- **`rails generate ruact:install` now emits an `AGENTS.md` teaching coding agents the ruact conventions, traps, and verification commands (FR105)** ([Story 15.1](../_bmad-output/implementation-artifacts/15-1-agents-md-install-emission-and-llms-txt.md)). ruact is out-of-distribution for every LLM, so a fresh app now ships the agent context in-repo: the mental model (Rails routes as the single source of truth; ERB as the server component; PascalCase self-closing tags), the verb rule, queries/`useQuery`, the codegen ground truth (`app/javascript/.ruact/server-functions.ts` + `bin/rails ruact:server_functions:generate`), the five traps (children, Ruby-in-braces, Accept-header dual shape, `ruact_errors` fall-through, name derivation), the `ruact_props`/`strict_serialization` allowlist, the SGID helpers, and the verification loop (`bin/rails ruact:doctor`). The emitted content is delimited by `<!-- ruact:begin -->`/`<!-- ruact:end -->` markers and the new `create_agents_md` action is **idempotent and append-aware**: no file → create; a user-authored `AGENTS.md` without markers → the marked section is appended with every pre-existing byte preserved; markers present → skip (re-running install is zero-diff); `--force` refreshes **only** the between-marker content, never user bytes outside it. The same compact reference is served by the docs site at `https://ruact.dev/llms.txt` (kept in sync editorially). New `templates/AGENTS.md.tt` + `:story_15_1` specs (including a ≤160-line template-budget tripwire).
|
|
65
|
+
|
|
10
66
|
## [0.0.7] - 2026-06-30
|
|
11
67
|
|
|
12
68
|
### Fixed
|
|
@@ -168,6 +224,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
168
224
|
- **CI matrix** — GitHub Actions: RSpec across Ruby 3.2 × 3.3 × Rails 7.0 × 7.1 × 7.2 × 8.0; RuboCop; YARD docs; memory benchmark; E2E system tests against React 19.0.0 and 19.x (Capybara + Cuprite); non-blocking React@next job with auto-issue on failure.
|
|
169
225
|
- **E2E test app** — `e2e/` Rails app (no DB, in-memory Post model) with full CRUD system tests validating the complete request cycle.
|
|
170
226
|
|
|
171
|
-
[Unreleased]: https://github.com/luizcg/ruact/compare/v0.0.
|
|
227
|
+
[Unreleased]: https://github.com/luizcg/ruact/compare/v0.0.9...HEAD
|
|
228
|
+
[0.0.9]: https://github.com/luizcg/ruact/releases/tag/v0.0.9
|
|
229
|
+
[0.0.8]: https://github.com/luizcg/ruact/releases/tag/v0.0.8
|
|
172
230
|
[0.0.7]: https://github.com/luizcg/ruact/releases/tag/v0.0.7
|
|
173
231
|
[0.0.6]: https://github.com/luizcg/ruact/releases/tag/v0.0.6
|
|
@@ -2179,3 +2179,58 @@ TS-AST `Props` parse; a Ruby DSL / JSON sidecar; a new slot runtime; moving
|
|
|
2179
2179
|
validation to render time. The **Epic 10 scaffold** consuming contracts and the
|
|
2180
2180
|
**playground build-fails CI proof** are **Story 13.6** (`epics-phase-2.md`).
|
|
2181
2181
|
ADR addendum (2026-06-26).
|
|
2182
|
+
|
|
2183
|
+
### 2026-07-11 — Story 13.7 — `ruact_props` works on ActiveRecord models (deferred loud check for lazy attrs)
|
|
2184
|
+
|
|
2185
|
+
Story 13.7 reopens **option (A)** that `deferred-work.md` #6 parked (the fix-B
|
|
2186
|
+
doc-only route) and makes `include Ruact::Serializable; ruact_props :id, :title`
|
|
2187
|
+
boot **directly on an ActiveRecord model**. It also closes the FR100 producer-side
|
|
2188
|
+
note (*"hoje `ruact_props` valida só o lado produtor, em class-load"*) by making the
|
|
2189
|
+
producer-side check itself AR-aware.
|
|
2190
|
+
|
|
2191
|
+
**The bug.** ActiveRecord defines its attribute reader methods **lazily** (via
|
|
2192
|
+
`define_attribute_methods`, triggered on first instance access / `method_missing`),
|
|
2193
|
+
so at the moment the `ruact_props :title` macro runs, `method_defined?(:title)` is
|
|
2194
|
+
`false` **even for a real column**. The eager class-load check
|
|
2195
|
+
(`serializable.rb`) raised there — an AR model that included the module crashed at
|
|
2196
|
+
boot. (`attribute_names`/`column_names` would tell the truth, but they require a
|
|
2197
|
+
**live DB connection** — with none they raise `ActiveRecord::ConnectionNotDefined`
|
|
2198
|
+
— so any boot-time check based on them adds a class-load DB dependency, a Rails
|
|
2199
|
+
anti-pattern. Rejected.)
|
|
2200
|
+
|
|
2201
|
+
**Decision — HYBRID timing (approach iv, locked by Luiz).** The loud check stays,
|
|
2202
|
+
but **when** it fires now depends on the class:
|
|
2203
|
+
|
|
2204
|
+
- **PORO** — checked **eagerly at class-load** exactly as before. The non-lazy code
|
|
2205
|
+
path is **byte-identical**; a PORO typo still raises `ArgumentError` immediately
|
|
2206
|
+
(the existing PORO spec passes unedited — the C3 regression pin).
|
|
2207
|
+
- **ActiveRecord (lazy-attribute) class** — a name whose reader is not-yet-defined
|
|
2208
|
+
is **recorded** at declaration and its loud check is **deferred to the first
|
|
2209
|
+
`ruact_serialize`**, validated via `instance.respond_to?(attr)` (AR's
|
|
2210
|
+
`respond_to?` returns `true` for a real column, `false` for a bogus name — a
|
|
2211
|
+
genuine typo still raises the **same clean `ArgumentError`**, just at first render
|
|
2212
|
+
of that model instead of at boot). The result is memoized (`respond_to?` runs once
|
|
2213
|
+
per class, not per serialize).
|
|
2214
|
+
|
|
2215
|
+
**Why this satisfies all four constraints.** C1 loud-omission preserved (PORO at
|
|
2216
|
+
boot, AR at first use — still loud, still `ArgumentError`); C2 **no DB dependency at
|
|
2217
|
+
boot** (`respond_to?` runs at first-serialize, when the DB is up); C3 PORO
|
|
2218
|
+
byte-identical; C4 `strict_serialization` untouched (an AR `Serializable` model
|
|
2219
|
+
already dispatches through `serialize_serializable` → `ruact_serialize`, verified
|
|
2220
|
+
under strict true/false).
|
|
2221
|
+
|
|
2222
|
+
**No new dependency / no new config.** The lazy-attribute discriminator is
|
|
2223
|
+
`defined?(ActiveRecord::Base) && self < ActiveRecord::Base` — it references the
|
|
2224
|
+
constant **only when the host already defined it**, so the gem stays single-dep
|
|
2225
|
+
`nokogiri`. **No eager escape hatch** was added (Luiz chose simplicity — no config
|
|
2226
|
+
or macro to force an AR boot check); the natural first-serialize timing is the
|
|
2227
|
+
contract. No serializer-pipeline change (dispatch already routed AR → `ruact_serialize`).
|
|
2228
|
+
|
|
2229
|
+
**Contract note (the one thing that moved).** For an AR model, a typo'd/omitted prop
|
|
2230
|
+
is now caught at the **first render of that model**, not at boot. This is the
|
|
2231
|
+
deliberate, documented cost of not adding a boot DB dependency; loudness itself is
|
|
2232
|
+
unchanged. The AGENTS.md/`llms.txt`/website reversal (`ruact_props` on AR is a
|
|
2233
|
+
first-class pattern again, manual row-hash kept as the no-model equivalent) ships in
|
|
2234
|
+
the same story, resolving `deferred-work.md` #7.
|
|
2235
|
+
|
|
2236
|
+
ADR addendum (2026-07-11).
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "erb"
|
|
3
4
|
require "pathname"
|
|
4
5
|
require "rails/generators"
|
|
5
6
|
require "ruact"
|
|
@@ -11,7 +12,10 @@ module Ruact
|
|
|
11
12
|
# Performs the following actions:
|
|
12
13
|
# 1. Creates config/initializers/ruact.rb
|
|
13
14
|
# 2. Injects `include Ruact::Controller` into ApplicationController
|
|
14
|
-
# 3. Injects the React root div into
|
|
15
|
+
# 3. Injects the React root div AND `ruact_js_assets` into
|
|
16
|
+
# app/views/layouts/application.html.erb, so the app's own layout owns
|
|
17
|
+
# the document (and its `<head>` — stylesheets, fonts, meta — reaches a
|
|
18
|
+
# ruact page). See Ruact::Configuration#layout.
|
|
15
19
|
# 4. Creates app/javascript/components/.keep
|
|
16
20
|
# 5. Creates vite.config.js (or shows manual instructions if one exists)
|
|
17
21
|
# 6. Creates package.json (react/react-dom/vite/@vitejs/plugin-react) so a
|
|
@@ -19,8 +23,15 @@ module Ruact
|
|
|
19
23
|
# 7. Creates Procfile.dev + bin/dev (foreman) so the single `bin/dev`
|
|
20
24
|
# command boots BOTH Rails and the Vite dev server (Story 14.6, Epic 14
|
|
21
25
|
# DoD — a ruact app needs both processes).
|
|
22
|
-
# 8.
|
|
26
|
+
# 8. Emits AGENTS.md (marker-delimited, append-aware, idempotent) so coding
|
|
27
|
+
# agents working in the app have ruact's conventions, traps, and
|
|
28
|
+
# verification commands in context by default (Story 15.1, FR105).
|
|
29
|
+
# 9. Runs `npm install` so JavaScript dependencies are ready (FR101);
|
|
23
30
|
# skippable via --skip-npm.
|
|
31
|
+
# 10. With `--shadcn`: also emits the prerequisites shadcn's own CLI refuses
|
|
32
|
+
# to initialize without (a Tailwind entry + a `tsconfig.json` import
|
|
33
|
+
# alias), wires the `css` build process, and prints the two `npx shadcn`
|
|
34
|
+
# commands it deliberately does not run.
|
|
24
35
|
#
|
|
25
36
|
# Story 14.2 (FR104) — the generator no longer writes a bootstrap entry into
|
|
26
37
|
# the user's tree. ruact's React entry is served as the virtual module
|
|
@@ -44,8 +55,32 @@ module Ruact
|
|
|
44
55
|
default: false,
|
|
45
56
|
desc: "Skip running npm install (for CI or non-npm package managers)"
|
|
46
57
|
|
|
58
|
+
# Prepares the app for the `ruact:scaffold --shadcn` path. shadcn's own
|
|
59
|
+
# CLI refuses to initialize without BOTH Tailwind and a TypeScript import
|
|
60
|
+
# alias, and a ruact app has neither — it ships no Tailwind and no
|
|
61
|
+
# `tsconfig.json` at all. That left `--shadcn` scaffolding into an app
|
|
62
|
+
# where the generated components' classes resolved to nothing: markup with
|
|
63
|
+
# no styling. This flag emits exactly the prerequisites (verified against
|
|
64
|
+
# the real shadcn CLI) and then PRINTS the two `npx shadcn` commands
|
|
65
|
+
# rather than running them — they hit the network and `shadcn init` is
|
|
66
|
+
# interactive, so automating them is neither safe nor possible.
|
|
67
|
+
class_option :shadcn,
|
|
68
|
+
type: :boolean,
|
|
69
|
+
default: false,
|
|
70
|
+
desc: "Also wire Tailwind + a TS import alias, the prerequisites for `ruact:scaffold --shadcn`"
|
|
71
|
+
|
|
72
|
+
# A fresh app gets the whole initializer. An app that ALREADY has one gets
|
|
73
|
+
# a surgical injection instead of `template`'s overwrite prompt, which
|
|
74
|
+
# offered a bad choice on the one path that matters most — migrating an
|
|
75
|
+
# existing app: overwrite and lose every setting the app had
|
|
76
|
+
# (`strict_serialization`, `manifest_path`, the SGID defaults…), or skip
|
|
77
|
+
# and end up half-migrated, with the layout edited but `config.layout`
|
|
78
|
+
# still off and nothing saying so except `ruact:doctor`.
|
|
47
79
|
def create_initializer
|
|
48
|
-
|
|
80
|
+
path = Pathname(destination_root).join("config/initializers/ruact.rb")
|
|
81
|
+
return template "initializer.rb.tt", "config/initializers/ruact.rb" unless path.exist?
|
|
82
|
+
|
|
83
|
+
inject_layout_setting(path)
|
|
49
84
|
end
|
|
50
85
|
|
|
51
86
|
def inject_controller_concern
|
|
@@ -63,21 +98,93 @@ module Ruact
|
|
|
63
98
|
after: /class ApplicationController.*\n/
|
|
64
99
|
end
|
|
65
100
|
|
|
101
|
+
# The layout owns the document: `stylesheet_link_tag`, favicons, fonts and
|
|
102
|
+
# every `<head>`-writing gem only reach a ruact page because Rails' own
|
|
103
|
+
# layout renders it (see `Ruact::Configuration#layout`). That requires TWO
|
|
104
|
+
# things in the layout — the React root, and `ruact_js_assets` to emit the
|
|
105
|
+
# bootstrap entry + this render's Flight payload. The other half of the
|
|
106
|
+
# opt-in is `config.layout = true`, which the generated initializer
|
|
107
|
+
# carries — a layout with the helper but the setting off (or the reverse)
|
|
108
|
+
# keeps rendering through ruact's built-in, CSS-less shell.
|
|
66
109
|
def inject_layout_shell
|
|
67
110
|
layout_file = "app/views/layouts/application.html.erb"
|
|
68
111
|
return unless File.exist?(Pathname(destination_root).join(layout_file))
|
|
69
112
|
|
|
70
113
|
content = File.read(Pathname(destination_root).join(layout_file))
|
|
71
|
-
|
|
72
|
-
|
|
114
|
+
|
|
115
|
+
# A CALL, not a mention: `<%# TODO: add ruact_js_assets %>` used to read
|
|
116
|
+
# as "already present" here and skip the migration, leaving the app on
|
|
117
|
+
# ruact's CSS-less shell with the generator reporting success. Shared
|
|
118
|
+
# with the runtime so both agree on what "migrated" means.
|
|
119
|
+
# BOTH halves, not just the helper. A layout carrying `ruact_js_assets`
|
|
120
|
+
# with no `<div id="root"></div>` was skipped as "already present" — and
|
|
121
|
+
# since this generator also turns `config.layout` on, that app then
|
|
122
|
+
# raised at render time on a document React could not mount into.
|
|
123
|
+
if Ruact::LayoutSource.wired?(content) && Ruact::LayoutSource.root?(content)
|
|
124
|
+
say_status "skip", "ruact root + assets already present in layout", :yellow
|
|
73
125
|
return
|
|
74
126
|
end
|
|
75
127
|
|
|
128
|
+
# Migration path for an app installed before the layout owned the
|
|
129
|
+
# document: the root is already there, only the asset call is missing.
|
|
130
|
+
#
|
|
131
|
+
# The anchor matches the ROOT DIV itself rather than the marker-then-div
|
|
132
|
+
# pair, and tolerates the ways a real layout is written — single or
|
|
133
|
+
# double quotes, extra attributes, any attribute order, CRLF, and the
|
|
134
|
+
# marker on the same line. The earlier anchor required the exact emitted
|
|
135
|
+
# formatting, so a hand-edited layout silently matched nothing. The
|
|
136
|
+
# attribute boundary in `ROOT_ELEMENT` is what keeps `data-id="root"`
|
|
137
|
+
# from being mistaken for the mount point.
|
|
138
|
+
if Ruact::LayoutSource.root?(content)
|
|
139
|
+
return migrate_layout(layout_file,
|
|
140
|
+
"\n <%= ruact_js_assets %>",
|
|
141
|
+
after: Ruact::LayoutSource::ROOT_ELEMENT,
|
|
142
|
+
success: "added ruact_js_assets to the existing layout root")
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# The mirror case: the helper is there but the mount target is not, so
|
|
146
|
+
# the root goes in just BEFORE the call (React needs the node in the
|
|
147
|
+
# document, and keeping the pair adjacent matches what a fresh install
|
|
148
|
+
# writes). Injecting the whole block instead would duplicate the helper.
|
|
149
|
+
if Ruact::LayoutSource.wired?(content)
|
|
150
|
+
return migrate_layout(layout_file,
|
|
151
|
+
"<%# ruact: root %>\n <div id=\"root\"></div>\n ",
|
|
152
|
+
before: Ruact::LayoutSource::ASSETS_CALL,
|
|
153
|
+
success: "added the React root div next to the existing ruact_js_assets")
|
|
154
|
+
end
|
|
155
|
+
|
|
76
156
|
inject_into_file layout_file,
|
|
77
|
-
"\n <%# ruact: root %>\n <div id=\"root\"></div>\n",
|
|
157
|
+
"\n <%# ruact: root %>\n <div id=\"root\"></div>\n <%= ruact_js_assets %>\n",
|
|
78
158
|
before: " </body>"
|
|
79
159
|
end
|
|
80
160
|
|
|
161
|
+
# `--shadcn` only. Two files, both of them things shadcn's CLI checks for
|
|
162
|
+
# and refuses to proceed without ("No Tailwind CSS configuration found" /
|
|
163
|
+
# "Could not find valid path aliases"), verified against shadcn 4.x:
|
|
164
|
+
#
|
|
165
|
+
# app/javascript/styles/globals.css — the Tailwind entry. shadcn appends
|
|
166
|
+
# its design tokens here, which is why components.json points at it.
|
|
167
|
+
# tsconfig.json — the `@/*` → `app/javascript/*` alias. The Vite plugin
|
|
168
|
+
# already registers the same alias for the BUNDLER, so components
|
|
169
|
+
# resolve at runtime today; this is what makes it resolve for
|
|
170
|
+
# TypeScript (and therefore for shadcn's alias probe and your editor).
|
|
171
|
+
#
|
|
172
|
+
# Both are guarded: an app that already has them keeps its own.
|
|
173
|
+
def create_shadcn_prerequisites
|
|
174
|
+
return unless shadcn?
|
|
175
|
+
|
|
176
|
+
create_guarded_file "app/javascript/styles/globals.css", "globals.css.tt"
|
|
177
|
+
create_guarded_file "tsconfig.json", "tsconfig.json.tt"
|
|
178
|
+
# Propshaft only serves directories that exist; the built stylesheet is
|
|
179
|
+
# generated, so the directory ships with a .keep and the artifact is
|
|
180
|
+
# gitignored (see append_gitignore_entries).
|
|
181
|
+
empty_directory "app/assets/builds"
|
|
182
|
+
create_file "app/assets/builds/.keep" unless
|
|
183
|
+
File.exist?(Pathname(destination_root).join("app/assets/builds/.keep"))
|
|
184
|
+
|
|
185
|
+
warn_unless_layout_links_builds
|
|
186
|
+
end
|
|
187
|
+
|
|
81
188
|
def create_components_directory
|
|
82
189
|
empty_directory "app/javascript/components"
|
|
83
190
|
create_file "app/javascript/components/.keep" unless
|
|
@@ -102,6 +209,10 @@ module Ruact
|
|
|
102
209
|
"app/javascript/.ruact/server-functions.ts",
|
|
103
210
|
"tmp/cache/ruact/"
|
|
104
211
|
]
|
|
212
|
+
# The compiled stylesheet is a build artifact of globals.css, rebuilt by
|
|
213
|
+
# the Procfile's `css` process on every boot — same reasoning as the
|
|
214
|
+
# generated server-functions module above.
|
|
215
|
+
entries << "app/assets/builds/tailwind.css" if shadcn?
|
|
105
216
|
# Substring matches (`existing.include?(entry)`) were unsafe — they
|
|
106
217
|
# would skip "tmp/cache/ruact/" when the file already contained
|
|
107
218
|
# "tmp/cache/ruact/some-cache.bin", leaving the directory itself
|
|
@@ -205,6 +316,40 @@ module Ruact
|
|
|
205
316
|
say ""
|
|
206
317
|
end
|
|
207
318
|
|
|
319
|
+
# Story 15.1 (FR105) — emit AGENTS.md so coding agents working in the app
|
|
320
|
+
# have ruact's conventions, traps, and verification commands in context
|
|
321
|
+
# by default. The ruact content is delimited by explicit markers
|
|
322
|
+
# (`<!-- ruact:begin -->` / `<!-- ruact:end -->`) so the action can be
|
|
323
|
+
# append-aware and idempotent:
|
|
324
|
+
#
|
|
325
|
+
# no file → create it (the template IS the section)
|
|
326
|
+
# file without markers → APPEND the marked section, every
|
|
327
|
+
# pre-existing user byte preserved
|
|
328
|
+
# markers present → skip (re-running install is zero-diff)
|
|
329
|
+
# markers present + --force → refresh ONLY the between-marker content —
|
|
330
|
+
# deliberately narrower than the vite.config
|
|
331
|
+
# full-overwrite posture, because a user's
|
|
332
|
+
# AGENTS.md may carry their own project
|
|
333
|
+
# instructions above/below ruact's section.
|
|
334
|
+
#
|
|
335
|
+
# Later stories (15.2 loud children error, 15.3 --json introspection,
|
|
336
|
+
# 15.4 test helpers) evolve the template; `--force` after a gem upgrade
|
|
337
|
+
# is the designed refresh path.
|
|
338
|
+
def create_agents_md
|
|
339
|
+
destination = Pathname(destination_root).join("AGENTS.md")
|
|
340
|
+
|
|
341
|
+
return template("AGENTS.md.tt", "AGENTS.md") unless destination.exist?
|
|
342
|
+
|
|
343
|
+
content = destination.read
|
|
344
|
+
if agents_md_markers_well_formed?(content)
|
|
345
|
+
refresh_or_skip_agents_md_section
|
|
346
|
+
elsif agents_md_markers_broken?(content)
|
|
347
|
+
warn_agents_md_broken_markers
|
|
348
|
+
else
|
|
349
|
+
append_agents_md_section(content)
|
|
350
|
+
end
|
|
351
|
+
end
|
|
352
|
+
|
|
208
353
|
# Story 14.1 (FR101) — install JavaScript dependencies so a fresh app is
|
|
209
354
|
# runnable in one command. Runs LAST among the file-producing actions
|
|
210
355
|
# (after every file is written) so a failure here leaves the generated
|
|
@@ -253,6 +398,8 @@ module Ruact
|
|
|
253
398
|
say " 2. Start your app: bin/dev"
|
|
254
399
|
end
|
|
255
400
|
|
|
401
|
+
show_shadcn_next_steps if shadcn?
|
|
402
|
+
|
|
256
403
|
say "\nThen add <MyComponent /> to any ERB view.\n"
|
|
257
404
|
say "Note: re-run this generator after updating the ruact gem to refresh"
|
|
258
405
|
say "the bundled Vite plugin path in vite.config.js."
|
|
@@ -261,6 +408,171 @@ module Ruact
|
|
|
261
408
|
|
|
262
409
|
private
|
|
263
410
|
|
|
411
|
+
# `inject_into_file` prints "File unchanged!" and carries on when its
|
|
412
|
+
# anchor misses, so reporting success without checking would be a lie —
|
|
413
|
+
# and the app would keep rendering through ruact's CSS-less shell with no
|
|
414
|
+
# clue why. Compare the file around the call and only claim what happened.
|
|
415
|
+
def migrate_layout(layout_file, content, success:, **anchor)
|
|
416
|
+
path = Pathname(destination_root).join(layout_file)
|
|
417
|
+
before = path.read
|
|
418
|
+
inject_into_file layout_file, content, **anchor
|
|
419
|
+
|
|
420
|
+
if path.read == before
|
|
421
|
+
warn_layout_migration_failed
|
|
422
|
+
else
|
|
423
|
+
say_status "update", success, :green
|
|
424
|
+
end
|
|
425
|
+
end
|
|
426
|
+
|
|
427
|
+
def inject_layout_setting(path)
|
|
428
|
+
content = path.read
|
|
429
|
+
|
|
430
|
+
if content.match?(/^\s*config\.layout\s*=/)
|
|
431
|
+
say_status "skip", "config.layout already set in config/initializers/ruact.rb", :yellow
|
|
432
|
+
return
|
|
433
|
+
end
|
|
434
|
+
|
|
435
|
+
unless content.match?(/Ruact\.configure\s+do\s*\|(\w+)\|/)
|
|
436
|
+
warn_initializer_not_injectable
|
|
437
|
+
return
|
|
438
|
+
end
|
|
439
|
+
|
|
440
|
+
inject_into_file "config/initializers/ruact.rb",
|
|
441
|
+
LAYOUT_SETTING_SNIPPET,
|
|
442
|
+
after: /Ruact\.configure\s+do\s*\|\w+\|\n/
|
|
443
|
+
say_status "update", "set config.layout = true (your layout renders ruact pages)", :green
|
|
444
|
+
end
|
|
445
|
+
|
|
446
|
+
# The compiled stylesheet still has to be REQUESTED. Rails 8's default
|
|
447
|
+
# layout links `stylesheet_link_tag :app`, which Propshaft expands over
|
|
448
|
+
# every stylesheet on the load path — so `app/assets/builds/tailwind.css`
|
|
449
|
+
# is picked up with no further wiring (verified against a generated app:
|
|
450
|
+
# the rendered `<head>` carries `/assets/tailwind-<digest>.css`).
|
|
451
|
+
#
|
|
452
|
+
# A layout that instead links stylesheets BY NAME never asks for it, and
|
|
453
|
+
# the failure is silent: Tailwind builds fine, Propshaft serves it fine,
|
|
454
|
+
# and the page is simply unstyled. Warn rather than edit — which
|
|
455
|
+
# stylesheets a layout links is the app's business.
|
|
456
|
+
def warn_unless_layout_links_builds
|
|
457
|
+
layout_path = Pathname(destination_root).join("app/views/layouts/application.html.erb")
|
|
458
|
+
return unless layout_path.exist?
|
|
459
|
+
|
|
460
|
+
content = layout_path.read
|
|
461
|
+
return unless content.include?("stylesheet_link_tag")
|
|
462
|
+
return if content.match?(/stylesheet_link_tag\s+:app\b/) || content.include?("tailwind")
|
|
463
|
+
|
|
464
|
+
say_status "notice", "your layout links stylesheets by name — add the built one:", :yellow
|
|
465
|
+
say ""
|
|
466
|
+
say " <%= stylesheet_link_tag \"tailwind\" %>"
|
|
467
|
+
say ""
|
|
468
|
+
say " `stylesheet_link_tag :app` (the Rails 8 default) would pick up"
|
|
469
|
+
say " app/assets/builds/tailwind.css on its own; a named link does not,"
|
|
470
|
+
say " and the page would render unstyled with no error."
|
|
471
|
+
say ""
|
|
472
|
+
end
|
|
473
|
+
|
|
474
|
+
# The generator does NOT run these: `shadcn init` is interactive (it
|
|
475
|
+
# prompts for a component library and a preset) and both commands hit the
|
|
476
|
+
# network. What it CAN do is spell them exactly, which is the part nobody
|
|
477
|
+
# guesses: current shadcn defaults to **Base UI**, while ruact's generated
|
|
478
|
+
# components import **Radix** primitives — accepting the default gives you
|
|
479
|
+
# a component library the scaffold cannot use. Hence the explicit
|
|
480
|
+
# `--base radix`.
|
|
481
|
+
#
|
|
482
|
+
# The component list is ruact's own authoritative set
|
|
483
|
+
# (ScaffoldGenerator#required_shadcn_components), derived from the
|
|
484
|
+
# templates' imports, so the two generators cannot drift.
|
|
485
|
+
def show_shadcn_next_steps
|
|
486
|
+
say ""
|
|
487
|
+
say "shadcn prerequisites are in place (Tailwind entry, tsconfig alias, css process)."
|
|
488
|
+
say "Two commands remain — they are interactive and hit the network, so run them yourself:"
|
|
489
|
+
say ""
|
|
490
|
+
say " npx shadcn@latest init --base radix"
|
|
491
|
+
say " npx shadcn@latest add #{shadcn_add_list}"
|
|
492
|
+
say ""
|
|
493
|
+
say " (--base radix matters: shadcn now defaults to Base UI, but the components"
|
|
494
|
+
say " `ruact:scaffold --shadcn` generates import Radix primitives.)"
|
|
495
|
+
say " (init also asks you to pick a style preset — any of them works.)"
|
|
496
|
+
say ""
|
|
497
|
+
say "Then scaffold a resource:"
|
|
498
|
+
say " bin/rails generate ruact:scaffold Post title:string body:text --shadcn"
|
|
499
|
+
end
|
|
500
|
+
|
|
501
|
+
# Story 15.1 — the exact marker tokens delimiting the ruact-managed
|
|
502
|
+
# section of AGENTS.md. For a prose file the only safe idempotency key is
|
|
503
|
+
# an explicit marker pair (`append_gitignore_entries`-style exact-line
|
|
504
|
+
# dedup would misfire on edited prose).
|
|
505
|
+
AGENTS_MD_BEGIN_MARKER = "<!-- ruact:begin -->"
|
|
506
|
+
AGENTS_MD_END_MARKER = "<!-- ruact:end -->"
|
|
507
|
+
|
|
508
|
+
# The full marked section, non-greedy so nothing outside the marker pair
|
|
509
|
+
# is ever captured (--force replaces exactly this range).
|
|
510
|
+
AGENTS_MD_SECTION_RE = /#{Regexp.escape(AGENTS_MD_BEGIN_MARKER)}.*?#{Regexp.escape(AGENTS_MD_END_MARKER)}/m
|
|
511
|
+
private_constant :AGENTS_MD_BEGIN_MARKER, :AGENTS_MD_END_MARKER, :AGENTS_MD_SECTION_RE
|
|
512
|
+
|
|
513
|
+
# The rendered ruact section — the full AGENTS.md.tt body, begin marker
|
|
514
|
+
# first line through end marker last line (the template IS the section).
|
|
515
|
+
# Rendered through ERB in the generator's context so the template may
|
|
516
|
+
# interpolate like any other Thor template (today it is fully static).
|
|
517
|
+
def agents_md_section
|
|
518
|
+
@agents_md_section ||= ERB.new(
|
|
519
|
+
File.read(File.expand_path("templates/AGENTS.md.tt", __dir__)),
|
|
520
|
+
trim_mode: "-"
|
|
521
|
+
).result(binding)
|
|
522
|
+
end
|
|
523
|
+
|
|
524
|
+
# Story 15.1 — append the marked ruact section to a user-authored
|
|
525
|
+
# AGENTS.md, separated by exactly one blank line, preserving every
|
|
526
|
+
# pre-existing byte.
|
|
527
|
+
def append_agents_md_section(existing_content)
|
|
528
|
+
separator = existing_content.end_with?("\n") ? "\n" : "\n\n"
|
|
529
|
+
append_to_file "AGENTS.md", "#{separator}#{agents_md_section}"
|
|
530
|
+
end
|
|
531
|
+
|
|
532
|
+
# Story 15.1 — a WELL-FORMED marker pair is present (the caller matched
|
|
533
|
+
# {AGENTS_MD_SECTION_RE}): skip (idempotent re-run) unless --force, which
|
|
534
|
+
# replaces ONLY the content between (and including) the marker pair.
|
|
535
|
+
# Bytes outside the markers are never touched.
|
|
536
|
+
def refresh_or_skip_agents_md_section
|
|
537
|
+
unless options[:force]
|
|
538
|
+
say_status "skip", "AGENTS.md already carries the ruact section " \
|
|
539
|
+
"(re-run with --force to refresh it)", :yellow
|
|
540
|
+
return
|
|
541
|
+
end
|
|
542
|
+
|
|
543
|
+
# Block form so `\`/`\1` sequences in the section are never treated as
|
|
544
|
+
# backreferences by String#gsub.
|
|
545
|
+
gsub_file("AGENTS.md", AGENTS_MD_SECTION_RE) { agents_md_section.chomp }
|
|
546
|
+
end
|
|
547
|
+
|
|
548
|
+
# Story 15.1 (Codex R1/R2) — the ONLY marker state the action manages:
|
|
549
|
+
# exactly one begin marker, exactly one end marker, begin before end.
|
|
550
|
+
# Anything else — a lone marker, end-before-begin, a stray extra marker
|
|
551
|
+
# alongside a valid pair, multiple pairs — makes the section boundary
|
|
552
|
+
# ambiguous, so it is handled as broken (warn + no-op) rather than
|
|
553
|
+
# guessed at.
|
|
554
|
+
def agents_md_markers_well_formed?(content)
|
|
555
|
+
content.scan(AGENTS_MD_BEGIN_MARKER).length == 1 &&
|
|
556
|
+
content.scan(AGENTS_MD_END_MARKER).length == 1 &&
|
|
557
|
+
AGENTS_MD_SECTION_RE.match?(content)
|
|
558
|
+
end
|
|
559
|
+
|
|
560
|
+
# Story 15.1 (Codex R1) — SOME marker text is present but not in the one
|
|
561
|
+
# well-formed shape above. The boundary is ambiguous in BOTH directions —
|
|
562
|
+
# appending would duplicate content next to stray markers, and replacing
|
|
563
|
+
# would have to guess the range — so the only byte-safe move is to warn
|
|
564
|
+
# and leave every byte alone.
|
|
565
|
+
def agents_md_markers_broken?(content)
|
|
566
|
+
content.include?(AGENTS_MD_BEGIN_MARKER) || content.include?(AGENTS_MD_END_MARKER)
|
|
567
|
+
end
|
|
568
|
+
|
|
569
|
+
def warn_agents_md_broken_markers
|
|
570
|
+
say_status "warn", "AGENTS.md has an incomplete ruact marker pair " \
|
|
571
|
+
"(#{AGENTS_MD_BEGIN_MARKER} … #{AGENTS_MD_END_MARKER}) — " \
|
|
572
|
+
"leaving the file untouched; restore both markers (or delete " \
|
|
573
|
+
"the partial ruact section) and re-run", :yellow
|
|
574
|
+
end
|
|
575
|
+
|
|
264
576
|
# Story 14.6 (live clean-room fix) — ruact OWNS `bin/dev`. The foreman
|
|
265
577
|
# launcher is load-bearing: it boots BOTH Rails AND the Vite dev server,
|
|
266
578
|
# and Vite is what writes `public/react-client-manifest.json`. Rails'
|
|
@@ -350,6 +662,66 @@ module Ruact
|
|
|
350
662
|
# Story 14.6 — a valid, lowercase npm "name" for the generated package.json,
|
|
351
663
|
# derived from the app directory. npm names must be lowercase and contain
|
|
352
664
|
# only URL-safe characters; anything else collapses to a hyphen.
|
|
665
|
+
# Kept verbatim in step with the `initializer.rb.tt` template's own
|
|
666
|
+
# `config.layout` block, so a migrated app and a fresh one end up reading
|
|
667
|
+
# the same thing.
|
|
668
|
+
LAYOUT_SETTING_SNIPPET = <<~RUBY
|
|
669
|
+
# Render ruact pages through this app's own layout, so the document `<head>`
|
|
670
|
+
# is yours: stylesheets, favicons, fonts and any gem that writes into
|
|
671
|
+
# `<head>` reach a ruact page. Requires the layout to call
|
|
672
|
+
# `<%= ruact_js_assets %>` (this generator adds it next to the React root).
|
|
673
|
+
config.layout = true
|
|
674
|
+
|
|
675
|
+
RUBY
|
|
676
|
+
private_constant :LAYOUT_SETTING_SNIPPET
|
|
677
|
+
|
|
678
|
+
# The initializer exists but is not the shape we know how to edit (someone
|
|
679
|
+
# rewrote it, or wrapped the configure call). Never guess at it — say what
|
|
680
|
+
# to add, so the app cannot end up half-migrated in silence.
|
|
681
|
+
def warn_initializer_not_injectable
|
|
682
|
+
say_status "skip", "could not find the Ruact.configure block to update", :red
|
|
683
|
+
say ""
|
|
684
|
+
say " Add this line inside `Ruact.configure` in config/initializers/ruact.rb:"
|
|
685
|
+
say ""
|
|
686
|
+
say " config.layout = true"
|
|
687
|
+
say ""
|
|
688
|
+
say " Without it ruact keeps using its built-in shell, which carries no"
|
|
689
|
+
say " stylesheet — your app's CSS will not reach a ruact-rendered page."
|
|
690
|
+
say ""
|
|
691
|
+
end
|
|
692
|
+
|
|
693
|
+
def shadcn?
|
|
694
|
+
options[:shadcn]
|
|
695
|
+
end
|
|
696
|
+
|
|
697
|
+
# Printed when the layout carries the ruact marker but the anchor found no
|
|
698
|
+
# root div to inject after. Silence would be the dangerous outcome: the app
|
|
699
|
+
# keeps rendering through ruact's CSS-less built-in shell, and nothing ever
|
|
700
|
+
# says why.
|
|
701
|
+
def warn_layout_migration_failed
|
|
702
|
+
say_status "skip", "could not locate the React root div in the layout", :red
|
|
703
|
+
say ""
|
|
704
|
+
say " ruact could not add `ruact_js_assets` automatically. Add it by hand,"
|
|
705
|
+
say " just after the root div in app/views/layouts/application.html.erb:"
|
|
706
|
+
say ""
|
|
707
|
+
say " <div id=\"root\"></div>"
|
|
708
|
+
say " <%= ruact_js_assets %>"
|
|
709
|
+
say ""
|
|
710
|
+
say " Without it your app's CSS cannot reach a ruact-rendered page."
|
|
711
|
+
say ""
|
|
712
|
+
end
|
|
713
|
+
|
|
714
|
+
# The superset the scaffold generator narrows per resource. Loaded lazily
|
|
715
|
+
# (and only under `--shadcn`) so a plain install never pays for the
|
|
716
|
+
# scaffold generator's load, and so a failure to reach it degrades to the
|
|
717
|
+
# literal list rather than aborting an otherwise-successful install.
|
|
718
|
+
def shadcn_add_list
|
|
719
|
+
require_relative "../scaffold/scaffold_shadcn_preflight"
|
|
720
|
+
ScaffoldGenerator::ShadcnPreflight::ALL_SHADCN_COMPONENTS.join(" ")
|
|
721
|
+
rescue StandardError
|
|
722
|
+
"button input textarea switch select label badge table alert-dialog dropdown-menu"
|
|
723
|
+
end
|
|
724
|
+
|
|
353
725
|
def app_package_name
|
|
354
726
|
base = File.basename(File.expand_path(destination_root))
|
|
355
727
|
sanitized = base.downcase.gsub(/[^a-z0-9._-]/, "-").squeeze("-").gsub(/\A-+|-+\z/, "")
|