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.
Files changed (61) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +59 -1
  3. data/docs/internal/decisions/server-functions-api.md +55 -0
  4. data/lib/generators/ruact/install/install_generator.rb +378 -6
  5. data/lib/generators/ruact/install/templates/AGENTS.md.tt +159 -0
  6. data/lib/generators/ruact/install/templates/Procfile.dev.tt +3 -0
  7. data/lib/generators/ruact/install/templates/globals.css.tt +20 -0
  8. data/lib/generators/ruact/install/templates/initializer.rb.tt +9 -0
  9. data/lib/generators/ruact/install/templates/package.json.tt +7 -1
  10. data/lib/generators/ruact/install/templates/tsconfig.json.tt +18 -0
  11. data/lib/generators/ruact/scaffold/scaffold_shadcn_preflight.rb +10 -0
  12. data/lib/ruact/configuration.rb +73 -0
  13. data/lib/ruact/controller/document_rendering.rb +210 -0
  14. data/lib/ruact/controller.rb +14 -46
  15. data/lib/ruact/doctor.rb +102 -12
  16. data/lib/ruact/erb_preprocessor.rb +113 -0
  17. data/lib/ruact/errors.rb +16 -0
  18. data/lib/ruact/layout_source.rb +59 -0
  19. data/lib/ruact/manifest_resolver.rb +2 -2
  20. data/lib/ruact/serializable.rb +98 -6
  21. data/lib/ruact/server.rb +163 -0
  22. data/lib/ruact/server_functions/introspection.rb +81 -0
  23. data/lib/ruact/server_functions.rb +26 -4
  24. data/lib/ruact/testing/component_query.rb +113 -0
  25. data/lib/ruact/testing/flight_extractor.rb +221 -0
  26. data/lib/ruact/testing/flight_structure_diff.rb +267 -0
  27. data/lib/ruact/testing/flight_wire_parser.rb +138 -0
  28. data/lib/ruact/testing.rb +90 -0
  29. data/lib/ruact/version.rb +1 -1
  30. data/lib/ruact/view_helper.rb +10 -1
  31. data/lib/ruact.rb +1 -0
  32. data/lib/tasks/ruact.rake +55 -2
  33. data/spec/fixtures/story_7_9_views/controller_request_spec_support/exploding_layout_demo/show.html.erb +3 -0
  34. data/spec/fixtures/story_7_9_views/controller_request_spec_support/ghost_layout_demo/show.html.erb +3 -0
  35. data/spec/fixtures/story_7_9_views/controller_request_spec_support/layout_demo/show.html.erb +3 -0
  36. data/spec/fixtures/story_7_9_views/controller_request_spec_support/rootless_layout_demo/show.html.erb +3 -0
  37. data/spec/fixtures/story_7_9_views/controller_request_spec_support/unwired_layout_demo/show.html.erb +3 -0
  38. data/spec/fixtures/story_7_9_views/layouts/bare_host.html.erb +16 -0
  39. data/spec/fixtures/story_7_9_views/layouts/exploding_host.html.erb +24 -0
  40. data/spec/fixtures/story_7_9_views/layouts/rootless_host.html.erb +15 -0
  41. data/spec/fixtures/story_7_9_views/layouts/ruact_host.html.erb +17 -0
  42. data/spec/ruact/controller_request_spec.rb +203 -0
  43. data/spec/ruact/doctor_spec.rb +222 -6
  44. data/spec/ruact/erb_preprocessor_spec.rb +145 -0
  45. data/spec/ruact/install_generator_spec.rb +727 -70
  46. data/spec/ruact/layout_source_spec.rb +108 -0
  47. data/spec/ruact/manifest_resolver_spec.rb +15 -0
  48. data/spec/ruact/scaffold_generator_spec.rb +14 -0
  49. data/spec/ruact/serializable_spec.rb +126 -0
  50. data/spec/ruact/server_bucket_request_spec.rb +291 -0
  51. data/spec/ruact/server_functions/introspection_spec.rb +135 -0
  52. data/spec/ruact/tasks_json_introspection_spec.rb +141 -0
  53. data/spec/ruact/testing/have_ruact_component_spec.rb +170 -0
  54. data/spec/ruact/testing/no_production_load_spec.rb +41 -0
  55. data/spec/support/flight_wire_parser.rb +12 -126
  56. data/spec/support/matchers/flight_fixture_matcher.rb +7 -258
  57. data/vendor/javascript/ruact-server-functions-runtime/index.d.ts +25 -0
  58. data/vendor/javascript/ruact-server-functions-runtime/index.js +104 -7
  59. data/vendor/javascript/ruact-server-functions-runtime/index.test.mjs +173 -0
  60. data/vendor/javascript/vite-plugin-ruact/type-tests/auto-revalidate.test-d.ts +25 -0
  61. metadata +30 -4
@@ -5,6 +5,7 @@ require "socket"
5
5
  require "uri"
6
6
  require_relative "view_helper"
7
7
  require_relative "validation_errors_collector"
8
+ require_relative "controller/document_rendering"
8
9
 
9
10
  module Ruact
10
11
  # Include in ApplicationController to enable RSC rendering.
@@ -27,6 +28,9 @@ module Ruact
27
28
  # and so the shared `vite_dev_running?` / `vite_manifest_entry` helpers are
28
29
  # reachable here without duplication.
29
30
  include Ruact::ViewHelper
31
+ # Who owns the `<head>` — the host app's layout, with ruact's built-in shell
32
+ # as the un-migrated fallback. See Ruact::Controller::DocumentRendering.
33
+ include Ruact::Controller::DocumentRendering
30
34
 
31
35
  private
32
36
 
@@ -157,6 +161,12 @@ module Ruact
157
161
  # (matching the legacy `#from_html` ordering) before any streaming
158
162
  # response headers are mutated.
159
163
  def emit_ruact_response(pipeline, html, render_context, streaming:)
164
+ # Story 15.5 (FR109) — record which page shape ruact rendered so the
165
+ # `Ruact::Server` dev log (`__ruact_log_response_shape!`) can name it and,
166
+ # crucially, stay SILENT on a plain Rails render (this flag is never set
167
+ # there). `@__`-prefixed → never leaks into `view_assigns`.
168
+ @__ruact_negotiated_page = ruact_request? ? :flight : :html_shell
169
+
160
170
  if ruact_request? && streaming
161
171
  enumerator = pipeline.render({ html: html, render_context: render_context }, mode: :stream)
162
172
  response.headers["Content-Type"] = "text/x-component; charset=utf-8"
@@ -172,7 +182,7 @@ module Ruact
172
182
  if ruact_request?
173
183
  render plain: payload, content_type: "text/x-component"
174
184
  else
175
- render html: ruact_html_shell(payload).html_safe, layout: false
185
+ render_ruact_document(payload)
176
186
  end
177
187
  end
178
188
  end
@@ -209,6 +219,9 @@ module Ruact
209
219
  # Flight redirect and re-render as an `errors` prop (no-op when untouched).
210
220
  __ruact_stash_errors_in_flash
211
221
 
222
+ # Story 15.5 (FR109) — mark the Flight-redirect sub-shape for the dev log.
223
+ @__ruact_negotiated_page = :flight_redirect
224
+
212
225
  render plain: "0:#{JSON.generate({ 'redirectUrl' => redirect_url, 'redirectType' => 'push' })}\n",
213
226
  content_type: "text/x-component"
214
227
  end
@@ -227,50 +240,5 @@ module Ruact
227
240
  controller = self.class.name.underscore.sub("_controller", "")
228
241
  Rails.root.join("app", "views", controller, "#{action}.html.erb")
229
242
  end
230
-
231
- def ruact_html_shell(flight_payload)
232
- # Story 14.2 — the JS asset block (entry `<script>` tags + `__FLIGHT_DATA`)
233
- # is delegated to the single `Ruact::ViewHelper#ruact_js_assets`
234
- # implementation. The bootstrap entry script is a deferred ES module, so it
235
- # runs after the inline `__FLIGHT_DATA` classic script has populated the
236
- # queue regardless of source order — emitting the whole block in `<body>`
237
- # is correct.
238
- <<~HTML
239
- <!DOCTYPE html>
240
- <html lang="en">
241
- <head>
242
- <meta charset="UTF-8" />
243
- <meta name="viewport" content="width=device-width, initial-scale=1" />
244
- #{ruact_csrf_meta_tag}
245
- <title>Rails RSC</title>
246
- </head>
247
- <body>
248
- <div id="root"></div>
249
- #{ruact_js_assets(flight_payload)}
250
- </body>
251
- </html>
252
- HTML
253
- end
254
-
255
- # Story 8.3 review R7 — emits `<meta name="csrf-token" content="...">`
256
- # into the shell so the JS runtime's `<meta>` lookup can forward a
257
- # valid `X-CSRF-Token` on every server-function (mutation) call. Without
258
- # this, hosts that route `ruact_render` through the gem's HTML shell (the
259
- # standard path) have no token in the document and the host's
260
- # `protect_from_forgery` rejects every non-GET server function.
261
- #
262
- # Returns an empty string when CSRF protection isn't available
263
- # (non-Rails specs, or hosts that have deliberately stripped
264
- # `form_authenticity_token` from the controller surface).
265
- def ruact_csrf_meta_tag
266
- return "" unless respond_to?(:form_authenticity_token, true)
267
-
268
- token = form_authenticity_token
269
- return "" if token.nil? || token.empty?
270
-
271
- %(<meta name="csrf-token" content="#{ERB::Util.html_escape(token)}" />)
272
- rescue StandardError
273
- ""
274
- end
275
243
  end
276
244
  end
data/lib/ruact/doctor.rb CHANGED
@@ -6,7 +6,7 @@ require "pathname"
6
6
  module Ruact
7
7
  # Runs a suite of installation health checks and prints ✓/✗ per check.
8
8
  # Extracted from the ruact:doctor Rake task for direct testability (FR27).
9
- class Doctor
9
+ class Doctor # rubocop:disable Metrics/ClassLength
10
10
  CHECKS = %i[manifest vite controller layout streaming legacy_constant serialize_only flight_middleware].freeze
11
11
  # Built via Array#join so the gem-CI `name-propagation` guard does not
12
12
  # match these literals against itself (Story 5.1 review F4 — the doctor
@@ -64,6 +64,14 @@ module Ruact
64
64
  # future status) is treated as a failure (review finding R1).
65
65
  SUCCESS_STATUSES = %i[pass warn].freeze
66
66
 
67
+ # Story 15.3 (FR107) — version of the machine-readable `ruact:doctor --json`
68
+ # document (see {#as_json}). This is an **EXPERIMENTAL / UNSTABLE** contract:
69
+ # `0` signals the shape may change without a major version bump while the
70
+ # agent-facing introspection surface is iterated. Gate any parser on it.
71
+ # Distinct from {Ruact::ServerFunctions::Snapshot::VERSION_V2} (the internal
72
+ # codegen bridge version) — do not conflate the two.
73
+ SCHEMA_VERSION = 0
74
+
67
75
  # @param serialize_only_root [String] directory whose `**/*.rb` is scanned
68
76
  # for the serialize-only tripwire. Defaults to the gem's own `lib/`;
69
77
  # injectable so specs can point it at a fixture tree.
@@ -78,16 +86,61 @@ module Ruact
78
86
 
79
87
  def run
80
88
  puts "[ruact] Health check"
81
- results = CHECKS.map { |check| send(:"check_#{check}") }
82
- results.each { |status, message| puts format_result(status, message) }
89
+ computed = results
90
+ computed.each { |status, message| puts format_result(status, message) }
83
91
  # A :warn must NOT fail the run (Story 13.1 AC3); only :pass / :warn are
84
92
  # success. An unexpected status (rendered `✗`) fails loudly rather than
85
93
  # being silently treated as a pass (review finding R1).
86
- passed = results.all? { |status, _| SUCCESS_STATUSES.include?(status) }
94
+ passed = passed?(computed)
87
95
  puts "Run rails generate ruact:install to fix configuration issues" unless passed
88
96
  passed
89
97
  end
90
98
 
99
+ # Runs every check ONCE and returns the raw result tuples, index-aligned with
100
+ # {CHECKS}. Each tuple is `[status, message]` or `[status, message,
101
+ # remediation]` (the optional 3rd element is a machine-readable fix string,
102
+ # nil when the check has no cleanly separable remediation). Shared by {#run}
103
+ # (human path) and {#as_json} (JSON path) so a check like `check_vite` — which
104
+ # opens a socket — never runs twice. (Story 15.3)
105
+ #
106
+ # @return [Array<Array>] one tuple per check, in {CHECKS} order.
107
+ def results
108
+ CHECKS.map { |check| send(:"check_#{check}") }
109
+ end
110
+
111
+ # @param computed [Array<Array>] result tuples (defaults to a fresh {#results} run).
112
+ # @return [Boolean] true when NO check failed — only `:pass`/`:warn` are
113
+ # success (mirrors {#run}'s rule; an unexpected status fails).
114
+ def passed?(computed = results)
115
+ computed.all? { |status, _| SUCCESS_STATUSES.include?(status) }
116
+ end
117
+
118
+ # Story 15.3 (FR107) — the machine-readable `ruact:doctor --json` document.
119
+ # Reuses the SAME {#results} tuples the human path prints (no double-run), so
120
+ # the JSON never disagrees with the `✓/⚠/✗` output. EXPERIMENTAL shape — gate
121
+ # parsers on `schema_version` (see {SCHEMA_VERSION}). Emits NO prose — the
122
+ # rake task prints ONLY this document in JSON mode.
123
+ #
124
+ # @return [Hash] `{ "schema_version" => Integer, "status" =>
125
+ # "pass"|"fail", "checks" => [{ "name" =>, "status" =>, "message" =>,
126
+ # "remediation" => (String|nil) }] }`.
127
+ def as_json
128
+ computed = results
129
+ {
130
+ "schema_version" => SCHEMA_VERSION,
131
+ "status" => passed?(computed) ? "pass" : "fail",
132
+ "checks" => CHECKS.each_index.map do |i|
133
+ status, message, remediation = computed[i]
134
+ {
135
+ "name" => CHECKS[i].to_s,
136
+ "status" => status.to_s,
137
+ "message" => message,
138
+ "remediation" => remediation
139
+ }
140
+ end
141
+ }
142
+ end
143
+
91
144
  private
92
145
 
93
146
  def check_manifest
@@ -95,7 +148,8 @@ module Ruact
95
148
  if Pathname(path).exist?
96
149
  [:pass, "Manifest found at #{path}"]
97
150
  else
98
- [:fail, "Manifest not found — run vite build"]
151
+ [:fail, "Manifest not found — run vite build",
152
+ "Run vite build (or bin/dev) to generate the client manifest."]
99
153
  end
100
154
  end
101
155
 
@@ -103,7 +157,8 @@ module Ruact
103
157
  TCPSocket.new("localhost", 5173).close
104
158
  [:pass, "Vite accessible at localhost:5173"]
105
159
  rescue Errno::ECONNREFUSED, Errno::EHOSTUNREACH
106
- [:fail, "Vite not accessible at localhost:5173 — run npm run dev"]
160
+ [:fail, "Vite not accessible at localhost:5173 — run npm run dev",
161
+ "Run npm run dev (or bin/dev) to start the Vite dev server."]
107
162
  end
108
163
 
109
164
  def check_controller
@@ -111,17 +166,51 @@ module Ruact
111
166
  if File.exist?(path) && File.read(path).include?("Ruact::Controller")
112
167
  [:pass, "Ruact::Controller included in ApplicationController"]
113
168
  else
114
- [:fail, "Ruact::Controller not included in ApplicationController"]
169
+ [:fail, "Ruact::Controller not included in ApplicationController",
170
+ "Run rails generate ruact:install to include Ruact::Controller in ApplicationController."]
115
171
  end
116
172
  end
117
173
 
174
+ # Two independent halves have to line up, and BOTH are silent when wrong:
175
+ # the layout has to call `ruact_js_assets`, and `Ruact.config.layout` has to
176
+ # be on. Miss either and ruact renders its built-in shell — which carries no
177
+ # stylesheet, so the app's own CSS never reaches a ruact page and nothing
178
+ # errors. Reporting each half separately is the point: "add one line" and
179
+ # "flip one setting" are different fixes.
180
+ #
181
+ # `Ruact::LayoutSource` is the SHARED definition of "calls the helper" —
182
+ # the runtime and `ruact:install` read it too, so this check cannot drift
183
+ # into disagreeing with what actually happens at render time (it did:
184
+ # a `<%# TODO: add ruact_js_assets %>` comment used to pass here).
118
185
  def check_layout
119
186
  path = Rails.root.join("app", "views", "layouts", "application.html.erb")
120
- if File.exist?(path) && File.read(path).include?("ruact: root")
121
- [:pass, "React shell present in application.html.erb"]
122
- else
123
- [:fail, "React shell missing from application.html.erb"]
187
+ unless File.exist?(path)
188
+ return [:fail, "React shell missing from application.html.erb",
189
+ "Run rails generate ruact:install to add the React shell to application.html.erb."]
124
190
  end
191
+
192
+ content = File.read(path)
193
+ has_root = Ruact::LayoutSource.root?(content)
194
+ has_assets = Ruact::LayoutSource.wired?(content)
195
+ opted_in = Ruact.config.layout != false
196
+
197
+ return layout_unwired_result unless has_root && has_assets
198
+ return layout_not_opted_in_result unless opted_in
199
+
200
+ [:pass, "layout owns the document (React root + ruact_js_assets, config.layout on)"]
201
+ end
202
+
203
+ def layout_unwired_result
204
+ [:fail, "layout is missing the React root and/or the ruact_js_assets call",
205
+ "Add <%= ruact_js_assets %> next to <div id=\"root\"></div> in " \
206
+ "app/views/layouts/application.html.erb (or re-run rails generate ruact:install). " \
207
+ "Without both, ruact renders its built-in shell and your app's CSS never reaches the page."]
208
+ end
209
+
210
+ def layout_not_opted_in_result
211
+ [:warn, "layout is ready but Ruact.config.layout is false",
212
+ "Your layout calls ruact_js_assets, but ruact is still rendering its built-in shell " \
213
+ "(which has no stylesheet). Set `config.layout = true` in config/initializers/ruact.rb."]
125
214
  end
126
215
 
127
216
  def check_streaming
@@ -198,7 +287,8 @@ module Ruact
198
287
  [:warn,
199
288
  "#{present.join(', ')} is mounted and may transform `text/x-component` (Flight) responses, " \
200
289
  "breaking the wire contract / streaming. Exclude Flight responses from compression " \
201
- "(don't compress `text/x-component`) or mount it so it does not wrap the Flight routes."]
290
+ "(don't compress `text/x-component`) or mount it so it does not wrap the Flight routes.",
291
+ "Exclude text/x-component from compression, or mount the middleware so it does not wrap Flight routes."]
202
292
  end
203
293
 
204
294
  # Returns the app middleware stack to scan, or nil when unavailable (no
@@ -28,6 +28,29 @@ module Ruact
28
28
  SUSPENSE_OPEN_RE = /<Suspense\b([^>]*?)>/m
29
29
  SUSPENSE_CLOSE_RE = %r{</Suspense>}
30
30
 
31
+ # Story 15.2 (FR106) — matches ANY PascalCase component tag: opening
32
+ # (`<Card>`), self-closing (`<Card />`), or closing (`</Card>`). Capture 1 is
33
+ # the leading slash (present only on a closing tag); capture 2 is the name.
34
+ # The loud-children detection scans these left-to-right with a stack: an
35
+ # opening pushes, a self-closing (`/>`) is ignored, and a closing that pops a
36
+ # matching opening means that opening had children (paired usage) → loud
37
+ # error. A single linear pass (no backreference/lazy backtracking) keeps the
38
+ # component-dense fast path linear regardless of how many tags go unclosed.
39
+ COMPONENT_ANY_TAG_RE = %r{<(/)?([A-Z][A-Za-z0-9]*)(?:\s[^>]*)?>}
40
+
41
+ # Cheap allocation-free probe (`String#match?`) for "is there ANY PascalCase
42
+ # closing tag at all?". The loud-children scan only matters when one exists,
43
+ # so this gates the (copy-heavy) mask/scan work off the common all-self-
44
+ # closing fast path. `</Suspense>` matches too — harmless, it gets masked.
45
+ CLOSING_TAG_PROBE_RE = %r{</[A-Z][A-Za-z0-9]*>}
46
+
47
+ # Newline-preserving mask for ERB islands (`<% … %>`, `<%= … %>`, `<%# … %>`).
48
+ # The loud-children scan blanks these first so a `</Card>` that lives inside
49
+ # Ruby/ERB string or comment text can never be mistaken for a real component
50
+ # closing tag (a valid bare `<Dialog>` opening must not error because some
51
+ # unrelated `<% "</Dialog>" %>` appears later).
52
+ ERB_ISLAND_RE = /<%.*?%>/m
53
+
31
54
  # Matches a +{ruby_expr}+ attribute value — captures everything between the braces.
32
55
  # We use a simple bracket-depth counter approach during scanning instead of regex
33
56
  # because expressions can contain nested braces: {foo.bar({ a: 1 })}.
@@ -66,6 +89,16 @@ module Ruact
66
89
  end
67
90
  .gsub(SUSPENSE_CLOSE_RE, "</ruact-suspense>")
68
91
 
92
+ # Step 1.5 (Story 15.2 / FR106): before the general component pass, fail
93
+ # loudly if any PascalCase component tag is used with children (a matching
94
+ # closing tag). Silent degradation of `<Card>Hello</Card>` — the #1
95
+ # predictable JSX-habit mistake — becomes a self-contained, re-raised-as-is
96
+ # PreprocessorError naming the fix. It scans the ORIGINAL +source+ (so
97
+ # file:line is exact) and masks Suspense (the one legitimate paired
98
+ # PascalCase tag) newline-for-newline, so `<Suspense>...</Suspense>` can
99
+ # never trip and every reported line matches the template verbatim.
100
+ detect_children!(source, identifier)
101
+
69
102
  # Step 2: transform remaining PascalCase self-closing / opening component tags.
70
103
  result.gsub(COMPONENT_TAG_RE) do |match|
71
104
  component_name = ::Regexp.last_match(1)
@@ -99,6 +132,86 @@ module Ruact
99
132
 
100
133
  private
101
134
 
135
+ # Story 15.2 (FR106) — raise a loud, self-contained {ChildrenNotSupportedError}
136
+ # on the FIRST PascalCase component tag used with children (a matching closing
137
+ # tag). +source+ is the ORIGINAL template text; +identifier+ is the template
138
+ # path (from {ErbPreprocessorHook}). Suspense — the one legitimate paired
139
+ # PascalCase tag — is masked newline-for-newline first, so it never trips AND
140
+ # every byte position (hence every reported line) still lines up with the raw
141
+ # template. The line uses the same idiom as Step 2 (`count("\n") + 1`) on the
142
+ # OPENING tag's offset. Message mirrors {ComponentContract.raise_error} shape
143
+ # so both loud preprocess errors read identically. A no-op when no pair is
144
+ # present — the fast path stays byte-identical.
145
+ def detect_children!(source, identifier)
146
+ # Fast path: a children pair REQUIRES a literal PascalCase closing tag, so
147
+ # a source without one (the common all-self-closing case) can never trip —
148
+ # bail before allocating anything. `match?` builds no MatchData, and this
149
+ # skips the mask/scan copies entirely, keeping the hot render/preprocess
150
+ # path's allocation profile flat (the benchmark renders only self-closing
151
+ # components, so it must stay at baseline).
152
+ return unless source.match?(CLOSING_TAG_PROBE_RE)
153
+
154
+ # ERB islands first, then Suspense — both blank their text newline-for-
155
+ # newline so byte offsets (hence reported lines) still match the raw
156
+ # template, while neither ERB string text nor the legitimate Suspense pair
157
+ # can be seen by the tag scan.
158
+ scan = mask_suspense(mask_erb(source))
159
+ # PER-NAME open stacks (name → [offsets]) so a closing tag checks for a
160
+ # matching open in O(1) via `open_ats[name].last`, keeping the whole scan
161
+ # linear even under thousands of stray/unmatched PascalCase closing tags
162
+ # (a global stack + `rindex` was quadratic — Codex Round 3).
163
+ open_ats = Hash.new { |h, k| h[k] = [] }
164
+
165
+ scan.scan(COMPONENT_ANY_TAG_RE) do
166
+ m = ::Regexp.last_match
167
+ name = m[2]
168
+
169
+ if m[1] # a closing tag `</Name>`
170
+ at = open_ats[name].last
171
+ next unless at # stray close with no open → literal text, ignore
172
+
173
+ raise_children_error(name, identifier, scan, at)
174
+ elsif m[0].end_with?("/>") # self-closing → carries no children
175
+ next
176
+ else # an opening tag `<Name ...>` — record the NEAREST open of this name
177
+ open_ats[name] << m.begin(0)
178
+ end
179
+ end
180
+
181
+ nil
182
+ end
183
+
184
+ # Raise the self-contained {ChildrenNotSupportedError}. +at+ is the OPENING
185
+ # tag's byte offset in +scan+ (position-faithful to the raw source), so the
186
+ # line uses the same idiom as Step 2. Message mirrors
187
+ # {ComponentContract.raise_error} so both loud preprocess errors read alike.
188
+ def raise_children_error(component, identifier, scan, at)
189
+ line = scan[0...at].count("\n") + 1
190
+ location = [identifier, line].compact.join(":")
191
+ location = "(unknown location)" if location.empty?
192
+ raise ChildrenNotSupportedError,
193
+ "ruact: <#{component}> at #{location} children are not supported " \
194
+ "— pass content as a prop, e.g. `<#{component} content={...} />`."
195
+ end
196
+
197
+ # Blank out Suspense open/close tags (the one legitimate paired PascalCase
198
+ # tag) while preserving EVERY newline and byte offset, so the loud-children
199
+ # scan neither trips on `<Suspense>...</Suspense>` nor mis-reports a line when
200
+ # a multi-line Suspense opening precedes the offending tag. Non-newline chars
201
+ # → spaces (same length); newlines kept verbatim.
202
+ def mask_suspense(source)
203
+ source
204
+ .gsub(SUSPENSE_OPEN_RE) { |m| m.gsub(/[^\n]/, " ") }
205
+ .gsub(SUSPENSE_CLOSE_RE) { |m| m.gsub(/[^\n]/, " ") }
206
+ end
207
+
208
+ # Blank ERB islands (position-faithful, see {mask_suspense}) so component
209
+ # tags that appear only inside Ruby/ERB string or comment text are invisible
210
+ # to the loud-children tag scan.
211
+ def mask_erb(source)
212
+ source.gsub(ERB_ISLAND_RE) { |m| m.gsub(/[^\n]/, " ") }
213
+ end
214
+
102
215
  # Extract a string attribute value (double or single quoted) from an attrs string.
103
216
  def extract_string_attr(attrs, name)
104
217
  m = attrs.match(/\b#{Regexp.escape(name)}\s*=\s*"([^"]*)"/) ||
data/lib/ruact/errors.rb CHANGED
@@ -22,6 +22,22 @@ module Ruact
22
22
  # re-wrapping it with the generic "at line N: snippet" tail.
23
23
  class ComponentContractError < PreprocessorError; end
24
24
 
25
+ # Story 15.2 (FR106) — raised by {Ruact::ErbPreprocessor} at preprocess time
26
+ # when a PascalCase component tag is used with children (a matching closing
27
+ # tag, e.g. `<Card>Hello</Card>`). ruact component tags are self-closing only:
28
+ # a component receives a props Hash, never a children element tree. This is the
29
+ # #1 predictable JSX-habit mistake, and it used to degrade silently (the
30
+ # children leaked into the surrounding HTML while the component rendered with
31
+ # none). Subclasses {PreprocessorError} so it flows through the same dev error
32
+ # overlay (NFR30 lineage) and the hook treats it uniformly — but the distinct
33
+ # class (mirroring {ComponentContractError}) lets the preprocessor re-raise it
34
+ # AS-IS (its message already carries the component name + file:line + the exact
35
+ # fix) and lets the 15.7 capstone assert this trap fails loudly by class. The
36
+ # sole legitimate paired PascalCase tag, `<Suspense>...</Suspense>`, is
37
+ # normalized to `<ruact-suspense>` in Step 1 before this detection runs, so it
38
+ # never trips.
39
+ class ChildrenNotSupportedError < PreprocessorError; end
40
+
25
41
  # Raised when application code attempts to mutate Ruact::Configuration outside
26
42
  # of a Ruact.configure block. The configuration is frozen after initialization
27
43
  # to prevent runtime drift; see Story 7.3 for the rationale and the decision
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ruact
4
+ # Answers one question in ONE place: does this layout actually wire ruact up?
5
+ #
6
+ # Two callers need that answer and must not disagree about it — the runtime
7
+ # (`Ruact::Controller::DocumentRendering`, deciding whether it is safe to
8
+ # render through the host layout) and `ruact:install` (deciding whether the
9
+ # layout still needs migrating). When they each carried their own notion of
10
+ # "present", they drifted: the generator skipped a layout as already-migrated
11
+ # on a `<%# TODO: add ruact_js_assets %>` comment, while the runtime read the
12
+ # same layout as unwired. Both were string-matching a NAME where only a CALL
13
+ # counts.
14
+ module LayoutSource
15
+ # ERB comments are stripped before anything else. A commented-out call —
16
+ # `<%# <%= ruact_js_assets %> %>`, which a developer produces the moment
17
+ # they disable it while debugging — emits NOTHING, so counting it as wired
18
+ # would report a layout as migrated when it is not. The `-?` covers ERB's
19
+ # trim-mode forms (`<%-#` / `-%>`), which Erubi treats as comments too and
20
+ # an earlier version of this pattern missed.
21
+ ERB_COMMENT = /<%-?#.*?-?%>/m
22
+
23
+ # An ERB OUTPUT tag calling the helper: `<%= ruact_js_assets %>` and the raw
24
+ # `<%== ... %>` form, with or without arguments or surrounding whitespace.
25
+ # Scanning stops at the tag's own `%>` rather than forbidding `%` outright —
26
+ # `<%= raw("100%") + ruact_js_assets %>` is a legitimate call that a
27
+ # `[^%]*` pattern rejected, while still never matching a bare mention that
28
+ # merely follows some other tag.
29
+ ASSETS_CALL = /<%=+(?:(?!%>).)*?\bruact_js_assets\b/m
30
+
31
+ # The React mount target, as an attribute rather than as a substring. The
32
+ # lookbehind is what stops `data-id="root"` (and any other `*-id`) from
33
+ # counting: those are not the mount point, and a document that has one but
34
+ # no real root gives React nothing to mount into. Unquoted `id=root` is
35
+ # valid HTML and is accepted.
36
+ ROOT_ATTRIBUTE = /(?<![-\w])id\s*=\s*(?:"root"|'root'|root(?=[\s>]))/
37
+
38
+ # The whole element, for a generator that needs something to inject AFTER.
39
+ ROOT_ELEMENT = %r{<div\s[^>]*#{ROOT_ATTRIBUTE.source}[^>]*>\s*</div>}
40
+
41
+ class << self
42
+ # Does this ERB source actually CALL `ruact_js_assets`?
43
+ def wired?(source)
44
+ without_comments(source).match?(ASSETS_CALL)
45
+ end
46
+
47
+ # Does this markup carry a React mount target? Used on RENDERED HTML by
48
+ # the runtime and on ERB source by the generator; the attribute shape is
49
+ # the same either way.
50
+ def root?(markup)
51
+ markup.to_s.match?(ROOT_ATTRIBUTE)
52
+ end
53
+
54
+ def without_comments(source)
55
+ source.to_s.gsub(ERB_COMMENT, "")
56
+ end
57
+ end
58
+ end
59
+ end
@@ -79,8 +79,8 @@ module Ruact
79
79
  return nil if soft
80
80
 
81
81
  raise ManifestError, <<~MSG.strip
82
- [ruact] Vite dev server inacessível em #{base_url} e nenhum \
83
- react-client-manifest.json encontrado em #{file_path} — rode `bin/dev`.
82
+ [ruact] Vite dev server unreachable at #{base_url} and no \
83
+ react-client-manifest.json found at #{file_path} — run `bin/dev`.
84
84
  MSG
85
85
  end
86
86
 
@@ -5,32 +5,71 @@ module Ruact
5
5
  # client component. Declare which attributes are safe to serialize with
6
6
  # +ruact_props+; only those attributes will be included in the wire payload.
7
7
  #
8
- # @example
8
+ # Works on POROs and on ActiveRecord models alike. For a PORO the loud check
9
+ # fires at class-load; for an ActiveRecord model (whose attribute readers are
10
+ # defined lazily) the same loud check is deferred to the first
11
+ # +ruact_serialize+ — see {ClassMethods#ruact_props}.
12
+ #
13
+ # @example PORO
9
14
  # class Post
10
15
  # include Ruact::Serializable
11
16
  # attr_reader :id, :title, :secret
12
17
  # ruact_props :id, :title # :secret is never sent to the client
13
18
  # end
19
+ #
20
+ # @example ActiveRecord model
21
+ # class Post < ApplicationRecord
22
+ # include Ruact::Serializable
23
+ # ruact_props :id, :title # other columns never cross to the client
24
+ # end
14
25
  module Serializable
15
26
  def self.included(base)
16
27
  base.extend(ClassMethods)
17
28
  base.instance_variable_set(:@ruact_props, [])
29
+ base.instance_variable_set(:@ruact_deferred_props, [])
18
30
  end
19
31
 
20
32
  module ClassMethods
21
33
  # Declare which instance methods should be included in the serialized
22
- # payload. Raises +ArgumentError+ at class-load time if any name has no
23
- # corresponding method defined on the class.
34
+ # payload.
35
+ #
36
+ # The loud-omission guarantee is preserved: a name with no corresponding
37
+ # method still raises a clean +ArgumentError+. Only the *timing* of that
38
+ # check depends on the class:
39
+ #
40
+ # * **PORO** — checked eagerly at class-load (macro-invocation) time, as
41
+ # before. A typo'd/absent prop raises immediately.
42
+ # * **ActiveRecord model** — ActiveRecord defines its attribute reader
43
+ # methods lazily (on first instance access), so at macro-invocation time
44
+ # +method_defined?(:title)+ is +false+ even for a real column. Checking
45
+ # eagerly would either reject a valid model at boot or require a live DB
46
+ # connection at class-load (a Rails anti-pattern). So for a lazy-attribute
47
+ # class the not-yet-defined names are recorded and their loud check is
48
+ # deferred to the first +ruact_serialize+ (via +respond_to?+ on the
49
+ # instance), where the DB is up. A genuine typo still raises the same
50
+ # clean +ArgumentError+ — just at first render of that model, not at boot.
24
51
  #
25
52
  # @param attrs [Array<Symbol>]
53
+ # @raise [ArgumentError] immediately for an undefined prop on a PORO; at
54
+ # first +ruact_serialize+ for an undefined prop on an ActiveRecord model.
26
55
  def ruact_props(*attrs)
56
+ deferred = []
27
57
  attrs.each do |attr|
28
- unless method_defined?(attr)
29
- raise ArgumentError,
30
- "ruact_props: method `#{attr}` is not defined on #{self}"
58
+ next if method_defined?(attr)
59
+
60
+ # Lazy-attribute (ActiveRecord) class: the reader may still appear on
61
+ # first instance access. Record it and check loudly on first serialize
62
+ # instead of failing a valid model at boot.
63
+ if ruact_lazy_attribute_class?
64
+ deferred << attr
65
+ next
31
66
  end
67
+
68
+ raise ArgumentError,
69
+ "ruact_props: method `#{attr}` is not defined on #{self}"
32
70
  end
33
71
  @ruact_props = attrs
72
+ @ruact_deferred_props = deferred
34
73
  end
35
74
 
36
75
  # Returns the list of declared prop names as symbols.
@@ -46,12 +85,65 @@ module Ruact
46
85
  end
47
86
  []
48
87
  end
88
+
89
+ # Names whose eager loud check was deferred to first serialize (lazy
90
+ # ActiveRecord attributes). Read from the same class that declared the
91
+ # props, so subclasses share the parent declaration.
92
+ #
93
+ # @return [Array<Symbol>]
94
+ def ruact_deferred_props_list
95
+ klass = self
96
+ while klass
97
+ if klass.instance_variable_defined?(:@ruact_props)
98
+ return klass.instance_variable_get(:@ruact_deferred_props) || []
99
+ end
100
+
101
+ klass = klass.superclass
102
+ end
103
+ []
104
+ end
105
+
106
+ # Run the deferred loud check once, on first +ruact_serialize+. A recorded
107
+ # name that the instance does not +respond_to?+ (a typo, or a genuinely
108
+ # absent column) raises the same clean +ArgumentError+ the eager path would.
109
+ #
110
+ # Memoization keys on the *validated deferred list itself* (not a bare
111
+ # boolean), so any change to the effective declaration — a re-declaration
112
+ # on this class OR on an ancestor whose props a subclass inherits — is
113
+ # detected and re-validated loudly on the next serialize. Once a given
114
+ # list has been validated it costs one array comparison per serialize.
115
+ #
116
+ # @param instance [Object]
117
+ # @raise [ArgumentError]
118
+ def ruact_validate_deferred_props!(instance)
119
+ deferred = ruact_deferred_props_list
120
+ return if @ruact_deferred_props_validated == deferred
121
+
122
+ deferred.each do |attr|
123
+ next if instance.respond_to?(attr)
124
+
125
+ raise ArgumentError,
126
+ "ruact_props: method `#{attr}` is not defined on #{self}"
127
+ end
128
+ @ruact_deferred_props_validated = deferred
129
+ end
130
+
131
+ # True when this class defines its attribute reader methods lazily, i.e.
132
+ # an ActiveRecord model. Detected WITHOUT a hard ActiveRecord dependency
133
+ # (the gem stays single-dep +nokogiri+): the constant is only referenced
134
+ # when it is already defined in the host.
135
+ #
136
+ # @return [Boolean]
137
+ def ruact_lazy_attribute_class?
138
+ !!(defined?(ActiveRecord::Base) && self < ActiveRecord::Base)
139
+ end
49
140
  end
50
141
 
51
142
  # Serialize only the attributes declared with +ruact_props+.
52
143
  #
53
144
  # @return [Hash{String => Object}]
54
145
  def ruact_serialize
146
+ self.class.ruact_validate_deferred_props!(self)
55
147
  self.class.ruact_props_list.to_h { |attr| [attr.to_s, public_send(attr)] }
56
148
  end
57
149
  end