ruact 0.0.12 → 0.0.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +59 -1
  3. data/README.md +4 -4
  4. data/lib/generators/ruact/install/install_generator.rb +418 -128
  5. data/lib/generators/ruact/install/templates/AGENTS.md.tt +14 -13
  6. data/lib/generators/ruact/install/templates/Procfile.dev.tt +1 -1
  7. data/lib/generators/ruact/install/templates/initializer.rb.tt +29 -7
  8. data/lib/generators/ruact/install/templates/package.json.tt +4 -4
  9. data/lib/generators/ruact/install/templates/tsconfig.json.tt +3 -0
  10. data/lib/generators/ruact/layout/layout_generator.rb +52 -0
  11. data/lib/generators/ruact/scaffold/scaffold_generator.rb +39 -12
  12. data/lib/generators/ruact/scaffold/scaffold_shadcn_preflight.rb +4 -3
  13. data/lib/generators/ruact/scaffold/templates/components/List.tsx.tt +8 -8
  14. data/lib/generators/ruact/scaffold/templates/components/agnostic/List.tsx.tt +8 -8
  15. data/lib/generators/ruact/scaffold/templates/controller.rb.tt +5 -3
  16. data/lib/generators/ruact/scaffold/templates/queries/query.rb.tt +2 -2
  17. data/lib/generators/ruact/scaffold/templates/views/index.html.erb.tt +1 -1
  18. data/lib/ruact/configuration.rb +71 -17
  19. data/lib/ruact/controller/document_rendering.rb +72 -16
  20. data/lib/ruact/controller/page_rendering.rb +134 -0
  21. data/lib/ruact/controller/pages.rb +116 -0
  22. data/lib/ruact/controller.rb +78 -11
  23. data/lib/ruact/doctor.rb +233 -25
  24. data/lib/ruact/layout_source.rb +29 -7
  25. data/lib/ruact/navigation_boundary.rb +240 -0
  26. data/lib/ruact/railtie.rb +30 -0
  27. data/lib/ruact/routing.rb +24 -6
  28. data/lib/ruact/server.rb +10 -1
  29. data/lib/ruact/version.rb +1 -1
  30. data/lib/ruact/view_helper.rb +158 -1
  31. data/lib/ruact/views/layouts/ruact.html.erb +32 -0
  32. data/lib/ruact.rb +29 -0
  33. data/vendor/javascript/vite-plugin-ruact/ruact-router.test.mjs +433 -0
  34. data/vendor/javascript/vite-plugin-ruact/runtime/ruact-router.js +170 -7
  35. data/vendor/javascript/vite-plugin-ruact/tsconfig.scaffold-agnostic.json +1 -1
  36. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/PostList.tsx +3 -3
  37. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/PostList.tsx +3 -3
  38. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/ambient.d.ts +4 -4
  39. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/ambient.d.ts +4 -4
  40. metadata +8 -2
data/lib/ruact/routing.rb CHANGED
@@ -24,9 +24,9 @@ module Ruact
24
24
  # The path segment reuses {Ruact::ServerFunctions::NameBridge} verbatim
25
25
  # (D4): `def search_users` → `GET /q/searchUsers`, named
26
26
  # `ruact_query_searchUsers`. Invalid or JS-reserved method names raise
27
- # {Ruact::ConfigurationError} at route-draw time; two query classes mounting
28
- # the same method name collide on the route NAME / path and fail Rails' own
29
- # duplicate checks — both are loud boot failures, never request-time
27
+ # {Ruact::ConfigurationError} at route-draw time, and so do two query
28
+ # classes mounting the same method name (they would collide on the route
29
+ # name and on the export) — both are loud boot failures, never request-time
30
30
  # surprises.
31
31
  #
32
32
  # The generated dispatch controller PRESERVES the query class's namespace
@@ -56,11 +56,29 @@ module Ruact
56
56
 
57
57
  query_class.public_instance_methods(false).each do |query_method|
58
58
  js_identifier = ServerFunctions::NameBridge.to_js_identifier(query_method)
59
- mapper.get("#{prefix}/#{js_identifier}",
60
- to: "#{target}##{query_method}",
61
- as: :"ruact_query_#{js_identifier}")
59
+ begin
60
+ mapper.get("#{prefix}/#{js_identifier}",
61
+ to: "#{target}##{query_method}",
62
+ as: :"ruact_query_#{js_identifier}")
63
+ rescue ArgumentError => e
64
+ raise unless e.message.include?("already in use")
65
+
66
+ raise_query_name_taken!(query_class, query_method, "#{prefix}/#{js_identifier}")
67
+ end
62
68
  end
63
69
  end
70
+
71
+ # Rails' own message ("Invalid route name, already in use") names a route
72
+ # the app never wrote. Name the query, the method and the way out.
73
+ def raise_query_name_taken!(query_class, query_method, path)
74
+ raise Ruact::ConfigurationError,
75
+ "#{query_class}##{query_method} cannot be mounted: GET #{path} is already mounted " \
76
+ "in these routes — by another query class that also defines `#{query_method}`, or " \
77
+ "by `ruact_queries #{query_class}` appearing twice. Query names share one namespace " \
78
+ "(one route and one export of @/.ruact/server-functions per name): remove the " \
79
+ "duplicate mount, or rename one of the methods (the scaffold names its search " \
80
+ "after the resource: search_posts, search_comments)."
81
+ end
64
82
  end
65
83
  end
66
84
  end
data/lib/ruact/server.rb CHANGED
@@ -277,12 +277,21 @@ module Ruact
277
277
  _ensure_url_is_http_header_safe(location)
278
278
  location = _enforce_open_redirect_protection(location, allow_other_host: allow_other_host)
279
279
 
280
+ # Story 13.3 (FR98) — registered errors ride flash to the page the
281
+ # runtime navigates to (a router Flight GET: no layout prints them). A
282
+ # function call never reaches Ruact::Controller#redirect_to's Flight path,
283
+ # so the stash lives here too (Story 17.0g review R3). Same-origin only,
284
+ # like that path: after a redirect to another origin the flash entry
285
+ # would wait in the session for whatever page the user opens next.
286
+ path = __ruact_redirect_path(location)
287
+ __ruact_stash_errors_in_flash if path.start_with?("/") && !path.start_with?("//")
288
+
280
289
  # Story 15.0 (F6) — a Bucket-2 `redirect_to` is a ruact-owned response
281
290
  # (`$redirect`; registered errors ride flash), not an injection opt-out.
282
291
  @__ruact_function_response_owned = true
283
292
  # Story 15.5 (FR109) — mark the `$redirect` sub-shape for the dev log.
284
293
  @__ruact_function_redirect = true
285
- render json: { "$redirect" => __ruact_redirect_path(location) }
294
+ render json: { "$redirect" => path }
286
295
  end
287
296
 
288
297
  private
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.12"
4
+ VERSION = "0.0.14"
5
5
  end
@@ -25,12 +25,71 @@ module Ruact
25
25
  # placeholder in the HTML output.
26
26
  def __ruact_component__(name, props = {})
27
27
  ctx = @ruact_render_context
28
- raise Ruact::Error, "ruact: __ruact_component__ called outside a ruact_render flow" if ctx.nil?
28
+ raise Ruact::Error, __ruact_outside_render_message(name) if ctx.nil?
29
29
 
30
30
  token = ctx.register(name, props)
31
31
  "<!-- #{token} -->".html_safe
32
32
  end
33
33
 
34
+ # Story 17.0i — a client component in a template Rails is rendering on its
35
+ # own. Says which component and template, and the two ways to a ruact page:
36
+ # `ruact_render` for this template, or listing the action in the
37
+ # controller's `ruact_pages`. The file and line come from the
38
+ # `ActionView::Template::Error` Rails wraps this in.
39
+ def __ruact_outside_render_message(name)
40
+ template = @current_template&.virtual_path
41
+ where = template ? "\"#{template}\"" : "this template"
42
+ # The helper is in every view. A controller action without the concern
43
+ # has no `ruact_render` to call; a mailer (its view's `controller`) or a
44
+ # `Controller.render` that ran no action cannot render through ruact at all.
45
+ owner = respond_to?(:controller) ? controller : nil
46
+ declined = owner&.instance_variable_get(:@__ruact_declined_render_options)
47
+ return __ruact_declined_options_message(name, where, declined) if declined.present?
48
+
49
+ if owner && !(defined?(Ruact::Controller) && owner.class.include?(Ruact::Controller) &&
50
+ __ruact_page_request?(owner))
51
+ return __ruact_not_a_page_message(name, where) unless __ruact_page_request?(owner)
52
+
53
+ return "ruact: <#{name} /> is a client component, and #{where} is being rendered by " \
54
+ "#{owner.class.name}, which does not include Ruact::Controller. Add " \
55
+ "`include Ruact::Controller` to it: its pages then render through ruact."
56
+ end
57
+
58
+ # A partial or a layout is not a page: rendering it as one is not the fix.
59
+ call = if template.nil? || File.basename(template).start_with?("_") || template.start_with?("layouts/")
60
+ "`ruact_render(template: …, status: …)` for the page that renders it"
61
+ else
62
+ "`ruact_render(template: \"#{template}\", status: …)`"
63
+ end
64
+ "ruact: <#{name} /> is a client component, and #{where} is being rendered by Rails, outside ruact. " \
65
+ "Render it through ruact: #{call} — or, when it is this controller's page and the controller " \
66
+ "declares `ruact_pages`, add the action there (`render :action` then goes through ruact)."
67
+ end
68
+
69
+ # A controller answering a request with an action — not a mailer, not an
70
+ # `ActionController::Renderer` (no action ran).
71
+ def __ruact_page_request?(owner)
72
+ defined?(ActionController::Metal) && owner.is_a?(ActionController::Metal) && owner.action_name.present?
73
+ end
74
+
75
+ # Story 17.0i review R3 — the page was ruact's, but `render` carried an
76
+ # option ruact does not take, so Rails rendered it.
77
+ def __ruact_declined_options_message(name, where, options)
78
+ named = options.map { |option| "`#{option}:`" }.join(", ")
79
+ "ruact: <#{name} /> is a client component, and #{where} is a ruact page, but its `render` got " \
80
+ "#{named} — not a render option ruact takes — so Rails rendered it outside ruact. Remove it " \
81
+ "(a flash message goes in `flash.now`), or render the page with `ruact_render`."
82
+ end
83
+
84
+ def __ruact_not_a_page_message(name, where)
85
+ "ruact: <#{name} /> is a client component, and #{where} is being rendered outside a request to a " \
86
+ "ruact controller (a mailer, or `Controller.render`). Client components render only in a page a " \
87
+ "ruact controller renders."
88
+ end
89
+
90
+ private :__ruact_outside_render_message, :__ruact_page_request?, :__ruact_not_a_page_message,
91
+ :__ruact_declined_options_message
92
+
34
93
  # Story 14.2 (FR104) — emits ruact's full JavaScript asset block: the
35
94
  # dev/prod bootstrap entry `<script>` tags (re-targeting the virtual entry
36
95
  # `virtual:ruact/bootstrap` — in dev the react-refresh preamble + `@vite/client`
@@ -65,8 +124,94 @@ module Ruact
65
124
  parts.join("\n").html_safe
66
125
  end
67
126
 
127
+ # The `<head>` half of the asset contract: the stylesheets Vite emitted for
128
+ # the client components, linked so they reach the page in production.
129
+ #
130
+ # Vite is an ASSET bundler, not a JS bundler — a `"use client"` component
131
+ # that imports CSS (its own, or one a package ships) produces a stylesheet
132
+ # recorded on the manifest entry beside `file`. Nothing linked it, so the
133
+ # styling was built, digest-stamped, served and never referenced. Only
134
+ # production was affected: the dev server injects that CSS through JS.
135
+ #
136
+ # **Why this is separate from {#ruact_js_assets}, and why it belongs in
137
+ # `<head>`.** The JS helper is injected before `</body>`, and the built-in
138
+ # shell emits it there too — a position that did not matter while it emitted
139
+ # only a `<script>`. A stylesheet there is discovered late and sits AFTER
140
+ # when the browser finds it, and it means third-party CSS outranks the app's
141
+ # own. So ruact declares that it contributes CSS and says WHERE, rather than
142
+ # smuggling it through a helper named for JavaScript.
143
+ #
144
+ # **Cascade.** Call it ABOVE the app's `stylesheet_link_tag`, so the app's
145
+ # own CSS is loaded afterwards. The layout ruact ships (`layouts/ruact`,
146
+ # Story 17.0b) does exactly that; a layout of the app's own does it by hand
147
+ # (`rails generate ruact:install` prints the line, it never edits a layout).
148
+ # Order decides ties only — specificity, `!important` and cascade layers all
149
+ # outrank it — but ties are the common case, and losing them by default is
150
+ # what makes third-party CSS feel like it "takes over".
151
+ #
152
+ # **With the dev server reachable it links no stylesheet**, deliberately:
153
+ # Vite is already injecting the CSS, and linking the file on disk would serve
154
+ # whatever the last build left there. In development WITHOUT the dev server it
155
+ # falls back to the built manifest, matching what `ruact_vite_tags` does.
156
+ #
157
+ # It reads the SAME manifest entry as {#ruact_js_assets}, in the same render,
158
+ # so the script and the stylesheet can never come from different builds.
159
+ #
160
+ # **In a document ruact renders it also emits
161
+ # `<meta name="turbo-visit-control" content="reload">`** (Story 17.0f), in
162
+ # every environment: Turbo Drive then loads a ruact page in full instead of
163
+ # swapping it into its own document. Outside a ruact render — the same layout
164
+ # rendering a plain Rails page — it does not.
165
+ #
166
+ # @return [ActiveSupport::SafeBuffer] the meta (in a ruact document) followed
167
+ # by the `<link>` markup, html_safe; no links in development with Vite
168
+ # running, when no entry exists, or when the entry declares no CSS
169
+ # @example In a layout
170
+ # <head>
171
+ # <%= ruact_head_assets %>
172
+ # <%= stylesheet_link_tag :app %>
173
+ # </head>
174
+ def ruact_head_assets
175
+ tags = []
176
+ tags.push(TURBO_VISIT_CONTROL, TURBO_PREFETCH) if ruact_document?
177
+ tags.concat(ruact_component_stylesheets) unless Rails.env.development? && vite_dev_running?
178
+ tags.join("\n").html_safe
179
+ end
180
+
181
+ # Story 17.0f (FR117) — a document ruact rendered must not be swapped into
182
+ # the page by Turbo Drive: Turbo would keep ITS document, the ruact bootstrap
183
+ # (a module script, evaluated once per document) would not run again, and
184
+ # the two routers would end up fighting over one page — dead links and
185
+ # blank pages after a single round trip (spike 2026-09-12, S3–S7). With this
186
+ # meta Turbo does a full load instead. It does not depend on Vite, so it is
187
+ # emitted in development too.
188
+ TURBO_VISIT_CONTROL = %(<meta name="turbo-visit-control" content="reload">)
189
+
190
+ # Story 17.0f (decided by Luiz, 2026-09-25) — in a document ruact rendered,
191
+ # the ruact router owns every click, but a layout that also loads Turbo 8
192
+ # still lets Turbo PREFETCH links on hover: a GET that runs the destination's
193
+ # action for a click Turbo will never handle (seen in playgrounds/
194
+ # nav-islands). Off in ruact documents; untouched everywhere else.
195
+ TURBO_PREFETCH = %(<meta name="turbo-prefetch" content="false">)
196
+
68
197
  private
69
198
 
199
+ # Whether the document being rendered is ruact's. `render_ruact_document`
200
+ # sets `@ruact_flight_payload` for the whole render (copied into the view by
201
+ # Rails), and removes it after. The same layout rendering a plain Rails page
202
+ # — the app's layout, in whole-app mode — does not have it, and must not
203
+ # tell Turbo to reload every visit.
204
+ def ruact_document?
205
+ instance_variable_defined?(:@ruact_flight_payload) && !@ruact_flight_payload.nil?
206
+ end
207
+
208
+ def ruact_component_stylesheets
209
+ entry = vite_manifest_entry(Ruact.bootstrap_virtual_id)
210
+ return [] if entry.nil?
211
+
212
+ Array(entry["css"]).map { |file| %(<link rel="stylesheet" href="/assets/#{file}">) }
213
+ end
214
+
70
215
  # The `__FLIGHT_DATA` inline bootstrap `<script>` — pushes the per-render
71
216
  # Flight payload onto the global queue the bootstrap entry drains on boot.
72
217
  # `</script>` in the payload is escaped to prevent an HTML/XSS breakout
@@ -139,7 +284,19 @@ module Ruact
139
284
  false
140
285
  end
141
286
 
287
+ # Memoized FOR THE DURATION OF ONE RENDER, which is what lets
288
+ # `ruact_head_assets` and `ruact_js_assets` promise they describe the same
289
+ # build. They are separate calls in the template, so without this a deploy
290
+ # landing between them serves one build's stylesheet beside another build's
291
+ # script — verified reachable in review, not hypothetical.
142
292
  def vite_manifest_entry(src_path)
293
+ @__ruact_manifest_entries ||= {}
294
+ return @__ruact_manifest_entries[src_path] if @__ruact_manifest_entries.key?(src_path)
295
+
296
+ @__ruact_manifest_entries[src_path] = read_vite_manifest_entry(src_path)
297
+ end
298
+
299
+ def read_vite_manifest_entry(src_path)
143
300
  manifest_path = Rails.root.join("public", "assets", ".vite", "manifest.json")
144
301
  return nil unless File.exist?(manifest_path)
145
302
 
@@ -0,0 +1,32 @@
1
+ <%# The document a ruact page renders into when `config.layout = "ruact"` (what
2
+ `rails generate ruact:install` writes). It ships with the gem and is found by
3
+ name through a view path the Railtie appends, behind the app's — so an
4
+ `app/views/layouts/ruact.html.erb` wins over it. `rails generate ruact:layout`
5
+ puts a copy there; once copied, that file is the app's and no longer changes
6
+ when the gem does.
7
+
8
+ What reaches the <head>: CSRF and CSP, the stylesheets Vite built for the
9
+ client components, then the app's stylesheets named in
10
+ `config.layout_stylesheets` — in that order, so the app's CSS loads last and
11
+ wins ties. What does NOT: the app's JavaScript (importmap, Turbo, Stimulus)
12
+ and anything else its own layout adds. The page that rendered the document
13
+ owns its navigation; to bring more of the app in, copy this file.
14
+
15
+ The <title> is the app's name on every page: a ruact view renders in its own
16
+ pass, so nothing it sets can reach this layout. A copied layout can title
17
+ pages however it likes. %>
18
+ <!DOCTYPE html>
19
+ <html>
20
+ <head>
21
+ <title><%= (Rails.application.class.module_parent_name || Rails.application.class.name).to_s.titleize %></title>
22
+ <meta name="viewport" content="width=device-width,initial-scale=1">
23
+ <%= csrf_meta_tags %>
24
+ <%= csp_meta_tag %>
25
+ <%= ruact_head_assets %>
26
+ <%= stylesheet_link_tag(*Ruact.config.layout_stylesheets) if Ruact.config.layout_stylesheets.any? %>
27
+ </head>
28
+ <body>
29
+ <div id="root"></div>
30
+ <%= ruact_js_assets %>
31
+ </body>
32
+ </html>
data/lib/ruact.rb CHANGED
@@ -19,10 +19,25 @@ require_relative "ruact/view_helper"
19
19
  require_relative "ruact/erb_preprocessor_hook"
20
20
  require_relative "ruact/server_functions"
21
21
  require_relative "ruact/query"
22
+ # Story 17.0f — loaded here, not only by the Railtie initializer that installs
23
+ # it, so `config.middleware.delete Ruact::NavigationBoundary::Middleware` in
24
+ # config/application.rb can name the constant before initializers run.
25
+ require_relative "ruact/navigation_boundary"
22
26
  # Railtie loads ruact/controller when inside a Rails app
23
27
  require_relative "ruact/railtie" if defined?(Rails)
24
28
 
25
29
  module Ruact
30
+ # Story 17.0b — the name of the layout the gem ships (lib/ruact/views/layouts/
31
+ # ruact.html.erb), and what `rails generate ruact:install` writes into
32
+ # `config.layout`.
33
+ GEM_LAYOUT = "ruact"
34
+
35
+ # Story 17.0g — a real `include` of Ruact::Controller in a Ruby source line,
36
+ # as `ruact:install` and `ruact:doctor` read it: `include Ruact::Controller`,
37
+ # `include(Ruact::Controller)`, `include ::Ruact::Controller`, or among
38
+ # others in one `include`. A mention in a comment does not count.
39
+ CONTROLLER_INCLUDE = /^[ \t]*include\b[^\n#]*?(?<![\w:])(?:::)?Ruact::Controller\b/
40
+
26
41
  class << self
27
42
  attr_accessor :manifest, :streaming_mode
28
43
 
@@ -46,6 +61,20 @@ module Ruact
46
61
  File.expand_path("../vendor/javascript/vite-plugin-ruact/index.js", __dir__)
47
62
  end
48
63
 
64
+ # Story 17.0b — the directory of views the gem ships: today, the
65
+ # `layouts/ruact` document a ruact page renders into when
66
+ # `config.layout = "ruact"`. The Railtie APPENDS it to the controller view
67
+ # paths, so it sits behind the app and every engine — an app's own
68
+ # `app/views/layouts/ruact.html.erb` wins — and `rails generate ruact:layout`
69
+ # copies from here when an app wants to own that file.
70
+ #
71
+ # Under `lib/` on purpose: `app/` is not packaged (see {Ruact::Packaging}).
72
+ #
73
+ # @return [String] absolute path to lib/ruact/views
74
+ def views_path
75
+ File.expand_path("ruact/views", __dir__)
76
+ end
77
+
49
78
  # Story 14.2 (FR104) — the SINGLE source of truth for the bootstrap entry id.
50
79
  # ruact's React entry is served as the virtual module `virtual:ruact/bootstrap`
51
80
  # (by the bundled Vite plugin) instead of a `app/javascript/application.jsx`