ruact 0.0.9 → 0.0.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f1722a96137257e2263f7aee2e2315c4a197f634fad94096da555de6d7a50af9
4
- data.tar.gz: 34d485a11f2b0cf893770bddbf4c48fa757d908f0f24e0c64e03d2f612bd351f
3
+ metadata.gz: eb5572218264cb747d7e6d89f990946c3eb05b4614469f4d96fd69158aece888
4
+ data.tar.gz: efe3a28deef2028b9470d0d1edbe5770d7289f668b72d842c0a4f4cab8aef47c
5
5
  SHA512:
6
- metadata.gz: 770a14ec78795d73afed6bc044f2616167e5cd4a6b173489ad9c8e8c1df893534b54e77f5515e59a3fecbd54a4d2763eaaa3a920cbf40fc7594922e7e831a912
7
- data.tar.gz: 41fc153c5d7470df08b7ca02b2c2da2a2ae0c74c717186119ed2eaa4504b903da1cfab43754a21f738b40541afc4ed98534f05fdcf4e3ba3c317f963b351b27b
6
+ metadata.gz: 6fd494f658f9e5121f0f2beed2965c6d58926f8c4ec1f4d6b0b3db714d903c9a3a4ecc1492c32e5e3f61dc6bad10ea716e2a9795884cd5319bcc039af4c5dc3b
7
+ data.tar.gz: bd6ac4fcf1042364845221187cb85d64dd3f299bc6873d1a9d7cd796cbf1ec13e95b07692ef55c5dd4c1e861ad9180639d97c5df19e7d2cc2eb4a0d777972fb1
data/CHANGELOG.md CHANGED
@@ -7,6 +7,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.0.10] - 2026-08-25
11
+
12
+ ### Changed
13
+
14
+ - **`README.md` — the gem's public face is no longer `bundle gem` boilerplate.** The file GitHub renders on `github.com/luizcg/ruact`, the file `spec.files` packages *inside* the built `.gem`, and the destination of `source_code_uri` still said *"TODO: Delete this and the text below"*, told the reader to `bundle add UPDATE_WITH_YOUR_GEM_NAME_IMMEDIATELY_AFTER_RELEASE_TO_RUBYGEMS_ORG`, and pointed contributors at `https://github.com/[USERNAME]/ruact`. It is now a first-contact README for v0.0.9: the four-command greenfield quick start (the same sequence the site's landing puts on the clipboard), the ERB-tag/`"use client"` pair, `include Ruact::Server` with its generated typed accessor, a list where every bullet names a shipped artifact, a compatibility table sourced from the CI matrix rather than from optimism, and links out to `ruact.dev` instead of second copies of the guides.
15
+
16
+ It also carries the **AI-tools section deferred here from the docs work** — the one-requirement rule (`"use client"` at the top; a PascalCase *named* export is the thing to check rather than add), the four-step walkthrough, and a pointer to the canonical page — plus one line naming the shipped agent context: the `AGENTS.md` `rails generate ruact:install` writes, `ruact.dev/llms.txt`, and the experimental `-- --json` output of `ruact:doctor` / `ruact:routes`.
17
+
18
+ Two gates now watch it, because nothing did before: `spec/readme_spec.rb` runs inside this repo's own `rspec` job (no boilerplate literals, every relative link resolves on disk, no `path:` gem source a reader could copy, and the quick-start block pinned literally), and the monorepo's command-spine check treats `README.md` as a declared artifact of the one canonical greenfield sequence.
19
+
20
+ ### Added
21
+
22
+ - **A demo at the top of `README.md` — the write→verify loop, recorded rather than described.** The README could argue the mechanism in prose and code but could not show what happens when the boundary is used wrong, which is the part a reader of *"React rendered from Rails templates"* actually doubts. The recording is one loop: an ERB template with `<LikeButton likes={@likes} />` beside the `"use client"` component it resolves to; the component rendering in a browser, its count changing on click; children put inside the tag — the JSX habit — and the next request stopping **server-side, while the template is being processed, before anything reaches the browser**, with `Ruact::ChildrenNotSupportedError` naming the component, the template file and line, and the fix; then the children removed and the page rendering again. Every frame is a capture of the gem running; the loud error is the one shipped in 0.0.9.
23
+
24
+ It is referenced by absolute URL and **nothing binary is committed here** — so neither `gem install ruact` nor a clone of this repository carries a multi-megabyte file, permanently, for a picture. Two things keep it from aging into a lie: the URL belongs to the site's URL contract, and `spec/readme_demo_message_spec.rb` pins the message the recording shows against a fixture the gem itself produced. If the wording changes, that spec goes red and names the recording as the thing to redo.
25
+
26
+ - **`LICENSE.txt`** — the gemspec has declared `spec.license = "MIT"` since the first commit while the repository contained no licence file, so both GitHub and the packaged gem shipped a promise with nothing behind it.
27
+ - **`spec.description` and `metadata["documentation_uri"]` in the gemspec** — RubyGems rendered the one-line `summary` as the whole description and fell through to rubydoc.info for documentation.
28
+
29
+ ### Fixed
30
+
31
+ - **`SECURITY.md` named a version that has never existed.** Its supported-versions table listed `0.1.x`; the released gem is `0.0.9`. Corrected to `0.0.x`.
32
+
10
33
  ## [0.0.9] - 2026-08-12
11
34
 
12
35
  ### Added
@@ -224,7 +247,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
224
247
  - **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.
225
248
  - **E2E test app** — `e2e/` Rails app (no DB, in-memory Post model) with full CRUD system tests validating the complete request cycle.
226
249
 
227
- [Unreleased]: https://github.com/luizcg/ruact/compare/v0.0.9...HEAD
250
+ [Unreleased]: https://github.com/luizcg/ruact/compare/v0.0.10...HEAD
251
+ [0.0.10]: https://github.com/luizcg/ruact/releases/tag/v0.0.10
228
252
  [0.0.9]: https://github.com/luizcg/ruact/releases/tag/v0.0.9
229
253
  [0.0.8]: https://github.com/luizcg/ruact/releases/tag/v0.0.8
230
254
  [0.0.7]: https://github.com/luizcg/ruact/releases/tag/v0.0.7
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Luiz Garcia
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md CHANGED
@@ -1,37 +1,143 @@
1
- # Ruact
1
+ # ruact
2
2
 
3
- [![codecov](https://codecov.io/gh/luizcg/ruact/branch/main/graph/badge.svg?flag=gem)](https://codecov.io/gh/luizcg/ruact)
3
+ **Real React, right in your Rails views.** Write a PascalCase tag in ERB, pass `@post` as a prop, and a React component renders — no hand-written JSON layer, no Node process in production.
4
4
 
5
- TODO: Delete this and the text below, and describe your gem
5
+ [![CI](https://github.com/luizcg/ruact/actions/workflows/ci.yml/badge.svg)](https://github.com/luizcg/ruact/actions/workflows/ci.yml) [![Gem Version](https://badge.fury.io/rb/ruact.svg)](https://badge.fury.io/rb/ruact) [![codecov](https://codecov.io/gh/luizcg/ruact/branch/main/graph/badge.svg?flag=gem)](https://codecov.io/gh/luizcg/ruact)
6
6
 
7
- Welcome to your new gem! In this directory, you'll find the files you need to be able to package up your Ruby library into a gem. Put your Ruby code in the file `lib/ruact`. To experiment with that code, run `bin/console` for an interactive prompt.
7
+ <!-- Packaging (Story 5.2): the demo is a documentation asset, published with the site
8
+ rather than committed here — so neither `gem install ruact` nor a clone of this
9
+ repository carries a multi-megabyte file, permanently, for a picture. The URL belongs
10
+ to the site's URL contract and cannot quietly start 404ing, the capture sources are
11
+ scripted and live with the project's other capture harnesses, and the message the demo
12
+ shows is pinned here by `spec/readme_demo_message_spec.rb`. -->
13
+ <img src="https://ruact.dev/readme-write-verify.gif" width="800"
14
+ alt="An ERB template holding a &lt;LikeButton likes=&#123;@likes&#125; /&gt; tag, and the &quot;use client&quot; React component that tag resolves to. The component renders in a browser and its count changes when it is clicked. Children are then put inside the tag — the JSX habit — and the next request stops server-side with Ruact::ChildrenNotSupportedError, which names the component, the template file and line, and the fix. The children come out again and the page renders." />
8
15
 
9
- ## Installation
16
+ ## Quick start
10
17
 
11
- TODO: Replace `UPDATE_WITH_YOUR_GEM_NAME_IMMEDIATELY_AFTER_RELEASE_TO_RUBYGEMS_ORG` with your gem name right after releasing it to RubyGems.org. Please do not do it earlier due to security reasons. Alternatively, replace this section with instructions to install your gem from git if you don't plan to release to RubyGems.org.
18
+ ```bash
19
+ # 1. A throwaway app to try it in
20
+ rails new myapp --skip-javascript && cd myapp
12
21
 
13
- Install the gem and add to the application's Gemfile by executing:
22
+ # 2. Add the gem
23
+ bundle add ruact
14
24
 
15
- ```bash
16
- bundle add UPDATE_WITH_YOUR_GEM_NAME_IMMEDIATELY_AFTER_RELEASE_TO_RUBYGEMS_ORG
25
+ # 3. Write the config, the layout wiring and an AGENTS.md — then run npm install
26
+ rails generate ruact:install
27
+
28
+ # 4. Rails + Vite, one command
29
+ bin/dev
17
30
  ```
18
31
 
19
- If bundler is not being used to manage dependencies, install the gem by executing:
32
+ That is the whole install. The [Getting Started guide](https://ruact.dev/docs/getting-started) picks it up from here — first component, first scaffold, `ruact:doctor`. Already have an app? Start at step 2, then read [Progressive migration](https://ruact.dev/docs/guides/progressive-migration) — ruact renders one action at a time and leaves the rest of your views alone.
20
33
 
21
- ```bash
22
- gem install UPDATE_WITH_YOUR_GEM_NAME_IMMEDIATELY_AFTER_RELEASE_TO_RUBYGEMS_ORG
34
+ ## How it works
35
+
36
+ ```erb
37
+ <%# app/views/posts/show.html.erb %>
38
+ <PostCard post={@post} author={@author} />
39
+ ```
40
+
41
+ ```tsx
42
+ // app/javascript/components/PostCard.tsx
43
+ "use client"
44
+
45
+ import { useState } from "react"
46
+
47
+ export function PostCard({ post, author }) {
48
+ const [liked, setLiked] = useState(false)
49
+ return (
50
+ <article>
51
+ <h1>{post.title}</h1>
52
+ <p>by {author.name}</p>
53
+ <button onClick={() => setLiked(!liked)}>
54
+ {liked ? "Liked" : "Like"}
55
+ </button>
56
+ </article>
57
+ )
58
+ }
59
+ ```
60
+
61
+ Rails serializes `@post` and `@author` as Ruby values, sends the component tree as a [Flight](https://ruact.dev/docs/concepts/flight-wire-format) payload, and React hydrates it in the browser. No JSON ceremony, no duplicate routes, no Node.js in production.
62
+
63
+ ## Call Rails from React
64
+
65
+ Add one line to a controller and its routed non-GET actions become callable from React at their real routes:
66
+
67
+ ```ruby
68
+ class PostsController < ApplicationController
69
+ include Ruact::Server # ← the only new line
70
+
71
+ def create
72
+ @post = Post.create!(post_params)
73
+ redirect_to @post
74
+ end
75
+ # ...
76
+ end
77
+ ```
78
+
79
+ ```tsx
80
+ import { createPost } from "@/.ruact/server-functions";
81
+
82
+ await createPost({ post: { title: "Hi", body: "…" } });
23
83
  ```
24
84
 
25
- ## Usage
85
+ The verb decides — there is no per-action DSL and no second endpoint. The export name is derived from the route (`posts#create` → `createPost`), and ruact's Vite plugin regenerates the module whenever your routes change. What the call *resolves* is decided by the action you already wrote: this one redirects, so ruact follows the redirect and the call resolves `null`; an action that assigns `@post` instead resolves `{ post: … }`, through the same `ruact_props` allowlist as everything else. Reading works the same way: a `Ruact::Query` class mounted with `ruact_queries` draws one named `GET` route per public method, and React reads it with `useQuery`. Both are documented in [Server functions & queries](https://ruact.dev/docs/api/server-actions).
86
+
87
+ ## What you get
88
+
89
+ Every item below is shipped in this gem at v0.0.9:
90
+
91
+ - **ERB as server components** — `include Ruact::Controller`, then write PascalCase tags in your existing views. [Docs](https://ruact.dev/docs/concepts/erb-as-server-components)
92
+ - **`"use client"`** — the one directive that marks a file as client-side. The bundled Vite plugin scans for it and writes the manifest. [Docs](https://ruact.dev/docs/concepts/use-client)
93
+ - **Server functions and queries** — `include Ruact::Server` and `Ruact::Query` + `useQuery`, both reachable through a typed module generated from your route table. [Docs](https://ruact.dev/docs/api/server-actions)
94
+ - **Props are an allowlist** — `include Ruact::Serializable` + `ruact_props :id, :title`; other columns never cross. [Docs](https://ruact.dev/docs/api/serializable)
95
+ - **Validation errors round-trip** — `ruact_errors(record)` hands React `{ title: ["can't be blank"] }` without a serializer. [Docs](https://ruact.dev/docs/api/server-actions)
96
+ - **Signed record references** — `Ruact.signed_global_id(record, for:, expires_in:)` out, `Ruact.locate_signed(token, for:)` back in; a tampered token is a `400`, not a lookup. [Docs](https://ruact.dev/docs/api/server-actions)
97
+ - **Client-side navigation** — link interception, scroll restoration and redirect-after-POST, derived from your Rails routes. [Docs](https://ruact.dev/docs/concepts/navigation)
98
+ - **A CRUD generator** — `rails generate ruact:scaffold Post title:string body:text` delegates the model, migration and route to Rails' own `resource` generator, then adds the ruact layer. Plain semantic HTML by default; `--shadcn` opts into the Tailwind/shadcn path. It does not run migrations — `rails db:migrate` is still yours. [Docs](https://ruact.dev/docs/api/scaffold)
99
+ - **`bin/rails ruact:doctor`** — eight checks over the manifest, Vite, the layout and streaming; exits `1` when one fails. [Docs](https://ruact.dev/docs/api/ruact-doctor)
100
+ - **One runtime dependency** — `nokogiri`. Rails itself is not a declared dependency of this gem.
101
+
102
+ ## AI tools and coding agents
103
+
104
+ **The only line you have to add to an AI-generated React component is `"use client"` at the top of the file.** One thing to check rather than add: the component needs a PascalCase *named* export, because that name is the tag you write in ERB.
26
105
 
27
- TODO: Write usage instructions here
106
+ 1. Generate the component with whatever AI tool you already use.
107
+ 2. Save it as `app/javascript/components/MyComponent.tsx`.
108
+ 3. Add `"use client"` at the top if it is not already there.
109
+ 4. Call `<MyComponent />` from ERB.
28
110
 
29
- ## Development
111
+ That is the whole adaptation, and it holds for any tool that outputs standard React components — nothing here is pinned to one vendor. The worked example, the traps and the real error messages are on [AI Tools & Agents](https://ruact.dev/docs/guides/ai-tools).
30
112
 
31
- After checking out the repo, run `bin/setup` to install dependencies. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
113
+ For agents driving the app rather than writing one component: `rails generate ruact:install` writes an `AGENTS.md` into your app, [ruact.dev/llms.txt](https://ruact.dev/llms.txt) serves the same context to tools that fetch from the web, and `bin/rails ruact:doctor -- --json` / `bin/rails ruact:routes -- --json` emit machine-readable output (experimental — `schema_version: 0`, and the `--` separator is required).
32
114
 
33
- To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
115
+ ## Compatibility
116
+
117
+ | | Version | Where that comes from |
118
+ |---|---|---|
119
+ | Ruby | >= 3.2 | the gemspec's `required_ruby_version` |
120
+ | Rails | tested against 7.0, 7.1, 7.2 and 8.0 | every commit runs the full CI matrix; the gemspec sets no Rails bound |
121
+ | React | 19.x | the `package.json` the install generator writes |
122
+ | Node.js | >= 20 | the build only — ruact runs no Node process in production |
123
+
124
+ ## Documentation
125
+
126
+ Everything lives at [ruact.dev](https://ruact.dev):
127
+
128
+ - [Getting Started](https://ruact.dev/docs/getting-started) — from `rails new` to a rendered component
129
+ - [Why ruact?](https://ruact.dev/docs/why-ruact) — where it sits next to Hotwire and Inertia
130
+ - [Server functions & queries](https://ruact.dev/docs/api/server-actions) — the full request/response contract
131
+ - [Progressive migration](https://ruact.dev/docs/guides/progressive-migration) — adopting it one action at a time
132
+ - [Testing](https://ruact.dev/docs/guides/testing) — render assertions on the server side
133
+ - [Changelog](CHANGELOG.md) — also published at [ruact.dev/docs/changelog](https://ruact.dev/docs/changelog)
34
134
 
35
135
  ## Contributing
36
136
 
37
- Bug reports and pull requests are welcome on GitHub at https://github.com/[USERNAME]/ruact.
137
+ Bug reports and pull requests are welcome at [github.com/luizcg/ruact/issues](https://github.com/luizcg/ruact/issues).
138
+
139
+ Release process: [RELEASING.md](RELEASING.md). Security policy and private reporting: [SECURITY.md](SECURITY.md).
140
+
141
+ ## License
142
+
143
+ MIT — see [LICENSE.txt](LICENSE.txt).
data/SECURITY.md CHANGED
@@ -6,7 +6,7 @@ Only the latest patch release of the current minor version is actively maintaine
6
6
 
7
7
  | Version | Supported |
8
8
  |---------|-----------|
9
- | 0.1.x | ✅ Yes |
9
+ | 0.0.x | ✅ Yes |
10
10
 
11
11
  Once a new minor version is released, the previous minor version receives security fixes for **90 days** after the new release, then it is no longer supported.
12
12
 
data/lib/ruact/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Ruact
4
- VERSION = "0.0.9"
4
+ VERSION = "0.0.10"
5
5
  end
@@ -0,0 +1,3 @@
1
+ <h1><%= @greeting %></h1>
2
+
3
+ <LikeButton likes={@likes}>Like</LikeButton>
@@ -0,0 +1 @@
1
+ ruact: <LikeButton> at app/views/home/index.html.erb:3 children are not supported — pass content as a prop, e.g. `<LikeButton content={...} />`.
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+ require "pathname"
5
+
6
+ # Story 5.2 — the anti-rot gate for the README's write→verify demo.
7
+ #
8
+ # ⚠️ THE README DEMO SHOWS THIS MESSAGE. If this spec goes red, the recording
9
+ # at https://ruact.dev/readme-write-verify.gif is now showing a message the
10
+ # gem no longer produces — RE-RECORD IT.
11
+ # Sources: design-artifacts/E-Assets/readme-write-verify/ in the planning
12
+ # repository (`./build-app.sh && ./record.sh && ./assemble.sh`).
13
+ #
14
+ # The fixture is PRODUCED BY THE GEM, never transcribed. It was written by
15
+ # running the preprocessor over the committed `.html.erb` beside it:
16
+ #
17
+ # ruby -Ilib -rruact -e 'begin
18
+ # Ruact::ErbPreprocessor.transform(
19
+ # File.read("spec/fixtures/readme/children-error.html.erb"),
20
+ # identifier: "app/views/home/index.html.erb")
21
+ # rescue Ruact::ChildrenNotSupportedError => e
22
+ # File.write("spec/fixtures/readme/children-error.txt", e.message + "\n")
23
+ # end'
24
+ #
25
+ # The identifier is the guide's own template path, so the fixture is verbatim
26
+ # the line the recording shows (modulo the absolute prefix Rails prepends in a
27
+ # real app). The monorepo owns the other half of this gate: the same failure
28
+ # reached through a real Rails render, in
29
+ # docs/examples/getting-started/harness/spec/requests/getting_started_spec.rb.
30
+ # That one cannot live here — the gem repository is public and reads nothing
31
+ # from the private planning repository — which is exactly why this spec exists:
32
+ # the published artifact stays verifiable in its own repository.
33
+ RSpec.describe "the README demo's error message", :story_5_2 do
34
+ let(:root) { Pathname.new(File.expand_path("..", __dir__)) }
35
+ let(:fixtures) { root.join("spec/fixtures/readme") }
36
+ let(:source) { fixtures.join("children-error.html.erb").read }
37
+ let(:expected) { fixtures.join("children-error.txt").read.strip }
38
+
39
+ # The exact call the recording's third beat makes, one layer down: a template
40
+ # whose PascalCase tag has children, compiled with the guide's template path.
41
+ def message
42
+ Ruact::ErbPreprocessor.transform(source, identifier: "app/views/home/index.html.erb")
43
+ raise "expected Ruact::ChildrenNotSupportedError, none was raised"
44
+ rescue Ruact::ChildrenNotSupportedError => e
45
+ e.message
46
+ end
47
+
48
+ it "is byte-for-byte what the gem produces today" do
49
+ expect(message).to eq(expected),
50
+ "the loud-children message changed. The README demo at " \
51
+ "https://ruact.dev/readme-write-verify.gif shows the old one — " \
52
+ "regenerate the fixture, then RE-RECORD the demo."
53
+ end
54
+
55
+ it "names the component, the file:line and the fix — the three things the demo is about" do
56
+ expect(expected).to include("<LikeButton>")
57
+ expect(expected).to include("app/views/home/index.html.erb:3")
58
+ expect(expected).to include("<LikeButton content={...} />")
59
+ end
60
+
61
+ it "is the message the README's demo is claimed to show" do
62
+ readme = root.join("README.md").read
63
+
64
+ expect(readme).to include("https://ruact.dev/readme-write-verify.gif")
65
+ expect(readme).to include("Ruact::ChildrenNotSupportedError")
66
+ end
67
+ end
@@ -0,0 +1,282 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+ require "nokogiri"
5
+ require "pathname"
6
+
7
+ # Story 5.14 — the README gate that lives INSIDE this repository.
8
+ #
9
+ # `README.md` is not an ordinary readme: GitHub renders it on the gem's own
10
+ # repository page, `source_code_uri` points at it, and `spec.files` packages it
11
+ # *inside* the built `.gem`. Until this story it was still `bundle gem`
12
+ # boilerplate ("TODO: Delete this and the text below"), and that boilerplate had
13
+ # already shipped to RubyGems.
14
+ #
15
+ # The planning monorepo has a command-spine gate
16
+ # (`docs/examples/getting-started/scripts/check-commands.mjs`) that reads this
17
+ # file through the `gem/` submodule and checks its quick start against ONE
18
+ # canonical greenfield sequence — the SPINE, which is the single source of
19
+ # truth. But a merge in THIS repository fires nothing over there, so a
20
+ # README-only PR here would stay unchecked until somebody bumped the submodule
21
+ # pointer.
22
+ #
23
+ # Hence the deliberate duplication below: `expected_quick_start` is a literal
24
+ # copy of the quick-start block, owned by this spec, and the monorepo gate
25
+ # verifies that literal against the canonical SPINE. The dependency runs one way
26
+ # only — this spec reads nothing outside the gem repository, because a public
27
+ # artifact that cannot be verified in its own repository is not verifiable.
28
+ RSpec.describe "README.md", :story_5_14 do
29
+ subject(:readme) { root.join("README.md").read }
30
+
31
+ let(:root) { Pathname.new(File.expand_path("..", __dir__)) }
32
+
33
+ # Every literal `bundle gem` leaves behind. Each one was present before this
34
+ # story; any of them coming back means the scaffolding was re-pasted.
35
+ let(:boilerplate) do
36
+ [
37
+ "TODO:",
38
+ "UPDATE_WITH_YOUR_GEM_NAME_IMMEDIATELY_AFTER_RELEASE_TO_RUBYGEMS_ORG",
39
+ "[USERNAME]",
40
+ "Welcome to your new gem",
41
+ "Put your Ruby code in the file",
42
+ "bin/console",
43
+ "bundle exec rake release"
44
+ ]
45
+ end
46
+
47
+ # Harness-only gem sources. They exist so the monorepo's playgrounds can test
48
+ # the working tree; a reader who copies one installs nothing.
49
+ let(:path_gem_sources) do
50
+ [
51
+ /(^|\s)gem\s+["']ruact["']\s*,\s*path:/,
52
+ /--path(\s|=)/
53
+ ]
54
+ end
55
+
56
+ # The quick-start block, byte for byte. Changing it here without changing the
57
+ # monorepo SPINE (or the other way round) turns the monorepo gate red.
58
+ let(:expected_quick_start) do
59
+ <<~BASH
60
+ # 1. A throwaway app to try it in
61
+ rails new myapp --skip-javascript && cd myapp
62
+
63
+ # 2. Add the gem
64
+ bundle add ruact
65
+
66
+ # 3. Write the config, the layout wiring and an AGENTS.md — then run npm install
67
+ rails generate ruact:install
68
+
69
+ # 4. Rails + Vite, one command
70
+ bin/dev
71
+ BASH
72
+ end
73
+
74
+ # `[text](target)` and `![alt](target)`, inline-title form allowed.
75
+ def markdown_link_targets(markdown)
76
+ markdown.scan(/\[[^\]]*\]\(([^)\s]+)(?:\s+"[^"]*")?\)/).flatten
77
+ end
78
+
79
+ # Story 5.2 — the demo reference, pinned BYTE FOR BYTE.
80
+ #
81
+ # The first version of this gate hand-rolled HTML and CommonMark recognition
82
+ # out of regexes, and review corrected it in three consecutive rounds:
83
+ # `\bsrc=` matched `data-src=`, a literal `>` inside an attribute ended the
84
+ # tag early, fences were only recognised at column 0, then unterminated
85
+ # fences were not recognised at all, then unquoted attribute values were
86
+ # invisible. Those are not five bugs. Neither HTML nor CommonMark is a
87
+ # regular language, so that list has no end, and a gate whose coverage is
88
+ # "whatever the regex happens to know this week" is not a gate.
89
+ #
90
+ # Replaced by three statements that are TOTAL:
91
+ #
92
+ # 1. the reference is pinned literally — the same move `expected_quick_start`
93
+ # above already makes for the quick start;
94
+ # 2. the file is allowed EXACTLY ONE raw `<img>`, counted at the byte level;
95
+ # and
96
+ # 3. the parsed document holds exactly one LIVE `<img>` element, equal to
97
+ # the pinned one attribute for attribute.
98
+ #
99
+ # (1) and (2) together say something the old scanner could only approximate:
100
+ # every raw image reference in this README is the pinned one. A second `<img>`
101
+ # anywhere — including inside a fenced example, terminated or not — turns
102
+ # this red and has to be gated deliberately rather than slipping through a
103
+ # blind spot. (3) says the thing bytes cannot: that it renders. The pinned
104
+ # block wrapped in an HTML comment satisfies (1) and (2) and shows nothing.
105
+ #
106
+ # Attributes are then read with Nokogiri, already a runtime dependency of this
107
+ # gem (see the gemspec), so unquoted values, `>` inside a value and `data-src`
108
+ # are PARSED rather than pattern-matched. What that scope does and does not
109
+ # promise is written down in the monorepo's gate inventory,
110
+ # docs/examples/getting-started/README.md § "What is still outside both gates".
111
+ def expected_demo_img
112
+ <<~HTML
113
+ <img src="https://ruact.dev/readme-write-verify.gif" width="800"
114
+ alt="An ERB template holding a &lt;LikeButton likes=&#123;@likes&#125; /&gt; tag, and the &quot;use client&quot; React component that tag resolves to. The component renders in a browser and its count changes when it is clicked. Children are then put inside the tag — the JSX habit — and the next request stops server-side with Ruact::ChildrenNotSupportedError, which names the component, the template file and line, and the fix. The children come out again and the page renders." />
115
+ HTML
116
+ end
117
+
118
+ def demo_src
119
+ "https://ruact.dev/readme-write-verify.gif"
120
+ end
121
+
122
+ # Parsed from the pinned literal, exactly as `expected_quick_start` is the
123
+ # authority for the quick start: one example asserts the README contains
124
+ # these bytes, and the rest read meaning off them. The chain is
125
+ # README -> literal -> parser, and the first link is what makes it honest.
126
+ def demo_node
127
+ Nokogiri::HTML5.fragment(expected_demo_img).at_css("img")
128
+ end
129
+
130
+ # Byte-level and case-insensitive on purpose: no parser, no markdown, nothing
131
+ # to have a blind spot. `<IMG`, `<img\n`, an `<img` inside a fence — all count.
132
+ def raw_img_count(markdown)
133
+ markdown.scan(/<img\b/i).length
134
+ end
135
+
136
+ # …and the same parser turned on the README ITSELF, because the two byte-level
137
+ # facts above are about bytes, not about rendering: wrap the pinned block in
138
+ # an HTML comment and the literal is still present and the raw count is still
139
+ # one, while GitHub shows nothing at all (verified — `include`=true, raw=1,
140
+ # live nodes=0). Nokogiri is what decides whether a tag is an element or a
141
+ # comment, so it is what the "it renders" half of this gate has to ask.
142
+ def readme_img_nodes
143
+ Nokogiri::HTML5.fragment(readme).css("img")
144
+ end
145
+
146
+ # Absolute URLs, anchors and mailto: links are somebody else's problem; on-disk
147
+ # targets are ours.
148
+ def relative(targets)
149
+ targets.reject { |target| target.start_with?("http://", "https://", "#", "mailto:") }
150
+ end
151
+
152
+ def bash_blocks(markdown)
153
+ markdown.scan(/^```bash[ \t]*\n(.*?)^```/m).flatten
154
+ end
155
+
156
+ it "carries none of the `bundle gem` boilerplate" do
157
+ present = boilerplate.select { |literal| readme.include?(literal) }
158
+
159
+ expect(present).to be_empty,
160
+ "README.md still carries bundler scaffolding: #{present.inspect}"
161
+ end
162
+
163
+ it "links only to files that exist in this repository" do
164
+ targets = relative(markdown_link_targets(readme) + [demo_node["src"]])
165
+ missing = targets.reject { |target| root.join(target.split("#").first.to_s).exist? }
166
+
167
+ expect(targets).not_to be_empty, "expected the README to link at least one repo-relative file"
168
+ expect(missing).to be_empty,
169
+ "README.md links files that do not exist in the gem repository: #{missing.inspect}"
170
+ end
171
+
172
+ # Story 5.2 — the demo GIF is a documentation asset hosted with the site, so
173
+ # this repository stays free of binaries: no `.gem` download and no clone
174
+ # pays for it. The consequence is that the reference is an absolute URL, and
175
+ # an absolute URL cannot be resolved on disk here — the monorepo puts it in
176
+ # `website/scripts/verify-urls.mjs`'s PATHS, checked against the deployed
177
+ # site, so a dead image goes red there instead of rotting silently.
178
+ it "carries the write→verify demo reference, byte for byte" do
179
+ expect(readme).to include(expected_demo_img),
180
+ "the demo reference in README.md drifted from the literal this spec pins. " \
181
+ "Change both or neither — and if the recording itself changed, see " \
182
+ "readme_demo_message_spec.rb."
183
+ end
184
+
185
+ # The other half of the pin: because there is exactly ONE raw `<img>` and the
186
+ # example above proves it is the pinned one, "the pinned literal is present"
187
+ # and "every raw image in this file is checked" are the same statement.
188
+ it "carries exactly one raw <img>, so the pin covers every raw image in the file" do
189
+ expect(raw_img_count(readme)).to eq(1),
190
+ "README.md has #{raw_img_count(readme)} raw <img> tags. This gate is " \
191
+ "written for exactly one — the demo, pinned literally. A second image " \
192
+ "must be gated deliberately, not left to a scanner's blind spots."
193
+ end
194
+
195
+ # And the demo is LIVE, not merely present. Bytes inside an HTML comment
196
+ # satisfy both statements above and render nothing; only a parser can tell
197
+ # the difference, so the README itself is parsed and the node it yields must
198
+ # be the pinned one, attribute for attribute.
199
+ it "renders the demo as a real element, not as bytes inside a comment" do
200
+ nodes = readme_img_nodes
201
+
202
+ expect(nodes.length).to eq(1),
203
+ "README.md parses to #{nodes.length} live <img> element(s); the demo must be " \
204
+ "exactly one, and must not be commented out."
205
+ expect(nodes.first.attributes.transform_values(&:value))
206
+ .to eq(demo_node.attributes.transform_values(&:value))
207
+ end
208
+
209
+ it "keeps the demo hosted with the site rather than committed here" do
210
+ expect(demo_node["src"]).to eq(demo_src)
211
+
212
+ # `git ls-files` and not a filesystem glob, because that is precisely what
213
+ # `spec.files` packages (ruact.gemspec) — untracked build output is not the
214
+ # question here, and coverage/ is full of it.
215
+ tracked = IO.popen(%w[git ls-files -z], chdir: root.to_s, err: IO::NULL) do |ls|
216
+ ls.readlines("\x0", chomp: true)
217
+ end
218
+ committed_media = tracked.grep(/\.(gif|mp4|webm|webp)\z/i)
219
+
220
+ expect(committed_media).to be_empty,
221
+ "media committed under gem/ ships inside every `.gem` and stays in the " \
222
+ "clone history forever: #{committed_media.inspect}"
223
+ end
224
+
225
+ # The alt text is the only thing a screen-reader user — or an agent reading
226
+ # the README as text — gets. "demo" is not a description.
227
+ it "describes the demo's arc in its alt text" do
228
+ # Nokogiri resolves the entities, so this reads what a screen reader reads:
229
+ # `&lt;LikeButton` is the tag the demo shows, not four literal characters.
230
+ alt = demo_node["alt"].to_s
231
+
232
+ expect(alt.split.length).to be > 40, "the demo's alt text does not describe the arc: #{alt.inspect}"
233
+ expect(alt).to include("Ruact::ChildrenNotSupportedError")
234
+ expect(alt).not_to match(/\bbuild\b/i),
235
+ "the failure the demo shows happens server-side at render, not at build — " \
236
+ "see Story 5.2 AC4"
237
+ end
238
+
239
+ # Story 5.2, learned the expensive way: YARD parses README.md as the docs'
240
+ # main file and read `{@likes}` in the demo's alt text as a link macro it
241
+ # could not resolve — and `--fail-on-warning` turned that into a red REQUIRED
242
+ # check, for a README edit, in a job whose output says nothing about READMEs.
243
+ # Braces belong in prose as `&#123;`/`&#125;`: GitHub renders them, YARD never
244
+ # sees them.
245
+ #
246
+ # SCOPE, deliberately: this checks the pinned demo block, not the whole file.
247
+ # Knowing which parts of a markdown document YARD linkifies means knowing
248
+ # where the fenced blocks are, and this gate no longer recognises fences —
249
+ # that is the trade this redesign makes. `yard --fail-on-warning` in this
250
+ # repository's own CI is the TOTAL gate; this example exists so the one line
251
+ # that actually tripped it fails locally, in the suite that owns the README,
252
+ # naming the cause.
253
+ it "keeps YARD link macros out of the demo block, which is what reddened the docs job" do
254
+ # `{@ivar}`, `{Class}`, `{Class::Nested}`, `{Class#method}`, `{Class.method}`
255
+ # — the shapes YARD resolves. It does not try to resolve anything with a
256
+ # space in it, which is why the prose's `{ post: … }` elsewhere is safe.
257
+ macros = expected_demo_img.scan(/\{(?:@\w+|[A-Z][\w:.#]*)\}/)
258
+
259
+ expect(macros).to be_empty,
260
+ "the demo block contains #{macros.inspect}, which YARD tries to resolve as a " \
261
+ "link and `yard --fail-on-warning` fails on. Use &#123; / &#125;."
262
+ end
263
+
264
+ it "shows no harness-only `path:` gem source a reader could copy" do
265
+ offenders = path_gem_sources.filter_map { |pattern| pattern.source if readme.match?(pattern) }
266
+
267
+ expect(offenders).to be_empty,
268
+ "README.md shows a harness-only gem source (#{offenders.inspect}) — " \
269
+ "a reader who copies it installs nothing"
270
+ end
271
+
272
+ it "pins the quick start to the canonical greenfield sequence" do
273
+ expect(bash_blocks(readme).first).to eq(expected_quick_start),
274
+ "the README quick start drifted. It is checked against the monorepo's " \
275
+ "canonical SPINE (docs/examples/getting-started/scripts/" \
276
+ "check-commands.mjs); change both or neither."
277
+ end
278
+
279
+ it "keeps the quick start in a ```bash fence, the only language the monorepo gate reads" do
280
+ expect(readme).to include("```bash\n#{expected_quick_start}```")
281
+ end
282
+ end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ruact
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.9
4
+ version: 0.0.10
5
5
  platform: ruby
6
6
  authors:
7
7
  - Luiz Garcia
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-08-12 00:00:00.000000000 Z
11
+ date: 2026-08-25 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: nokogiri
@@ -24,7 +24,12 @@ dependencies:
24
24
  - - "~>"
25
25
  - !ruby/object:Gem::Version
26
26
  version: '1.15'
27
- description:
27
+ description: |
28
+ ruact renders React components straight from your ERB views: write a PascalCase
29
+ tag, pass a Ruby value as a prop, and React hydrates it in the browser over the
30
+ Flight wire format. Server functions and queries are drawn from your existing
31
+ route table, so there is no hand-written JSON layer to keep in sync and no Node
32
+ process in production.
28
33
  email:
29
34
  - luizcg@gmail.com
30
35
  executables: []
@@ -37,6 +42,7 @@ files:
37
42
  - ".rubocop.yml"
38
43
  - ".rubocop_todo.yml"
39
44
  - CHANGELOG.md
45
+ - LICENSE.txt
40
46
  - README.md
41
47
  - RELEASING.md
42
48
  - Rakefile
@@ -156,6 +162,8 @@ files:
156
162
  - spec/fixtures/flight/string_basic.txt
157
163
  - spec/fixtures/flight/string_dollar_escape.txt
158
164
  - spec/fixtures/flight/undefined.txt
165
+ - spec/fixtures/readme/children-error.html.erb
166
+ - spec/fixtures/readme/children-error.txt
159
167
  - spec/fixtures/story_7_9_views/controller_request_spec_support/demo/show.html.erb
160
168
  - spec/fixtures/story_7_9_views/controller_request_spec_support/errors_demo/new.html.erb
161
169
  - spec/fixtures/story_7_9_views/controller_request_spec_support/exploding_layout_demo/show.html.erb
@@ -167,6 +175,8 @@ files:
167
175
  - spec/fixtures/story_7_9_views/layouts/exploding_host.html.erb
168
176
  - spec/fixtures/story_7_9_views/layouts/rootless_host.html.erb
169
177
  - spec/fixtures/story_7_9_views/layouts/ruact_host.html.erb
178
+ - spec/readme_demo_message_spec.rb
179
+ - spec/readme_spec.rb
170
180
  - spec/ruact/client_manifest_spec.rb
171
181
  - spec/ruact/component_contract_spec.rb
172
182
  - spec/ruact/configuration_spec.rb
@@ -265,6 +275,7 @@ metadata:
265
275
  source_code_uri: https://github.com/luizcg/ruact
266
276
  changelog_uri: https://github.com/luizcg/ruact/blob/main/CHANGELOG.md
267
277
  bug_tracker_uri: https://github.com/luizcg/ruact/issues
278
+ documentation_uri: https://ruact.dev
268
279
  rubygems_mfa_required: 'true'
269
280
  post_install_message:
270
281
  rdoc_options: []