ruact 0.0.7 → 0.0.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +59 -1
- data/docs/internal/decisions/server-functions-api.md +55 -0
- data/lib/generators/ruact/install/install_generator.rb +378 -6
- data/lib/generators/ruact/install/templates/AGENTS.md.tt +159 -0
- data/lib/generators/ruact/install/templates/Procfile.dev.tt +3 -0
- data/lib/generators/ruact/install/templates/globals.css.tt +20 -0
- data/lib/generators/ruact/install/templates/initializer.rb.tt +9 -0
- data/lib/generators/ruact/install/templates/package.json.tt +7 -1
- data/lib/generators/ruact/install/templates/tsconfig.json.tt +18 -0
- data/lib/generators/ruact/scaffold/scaffold_shadcn_preflight.rb +10 -0
- data/lib/ruact/configuration.rb +73 -0
- data/lib/ruact/controller/document_rendering.rb +210 -0
- data/lib/ruact/controller.rb +14 -46
- data/lib/ruact/doctor.rb +102 -12
- data/lib/ruact/erb_preprocessor.rb +113 -0
- data/lib/ruact/errors.rb +16 -0
- data/lib/ruact/layout_source.rb +59 -0
- data/lib/ruact/manifest_resolver.rb +2 -2
- data/lib/ruact/serializable.rb +98 -6
- data/lib/ruact/server.rb +163 -0
- data/lib/ruact/server_functions/introspection.rb +81 -0
- data/lib/ruact/server_functions.rb +26 -4
- data/lib/ruact/testing/component_query.rb +113 -0
- data/lib/ruact/testing/flight_extractor.rb +221 -0
- data/lib/ruact/testing/flight_structure_diff.rb +267 -0
- data/lib/ruact/testing/flight_wire_parser.rb +138 -0
- data/lib/ruact/testing.rb +90 -0
- data/lib/ruact/version.rb +1 -1
- data/lib/ruact/view_helper.rb +10 -1
- data/lib/ruact.rb +1 -0
- data/lib/tasks/ruact.rake +55 -2
- data/spec/fixtures/story_7_9_views/controller_request_spec_support/exploding_layout_demo/show.html.erb +3 -0
- data/spec/fixtures/story_7_9_views/controller_request_spec_support/ghost_layout_demo/show.html.erb +3 -0
- data/spec/fixtures/story_7_9_views/controller_request_spec_support/layout_demo/show.html.erb +3 -0
- data/spec/fixtures/story_7_9_views/controller_request_spec_support/rootless_layout_demo/show.html.erb +3 -0
- data/spec/fixtures/story_7_9_views/controller_request_spec_support/unwired_layout_demo/show.html.erb +3 -0
- data/spec/fixtures/story_7_9_views/layouts/bare_host.html.erb +16 -0
- data/spec/fixtures/story_7_9_views/layouts/exploding_host.html.erb +24 -0
- data/spec/fixtures/story_7_9_views/layouts/rootless_host.html.erb +15 -0
- data/spec/fixtures/story_7_9_views/layouts/ruact_host.html.erb +17 -0
- data/spec/ruact/controller_request_spec.rb +203 -0
- data/spec/ruact/doctor_spec.rb +222 -6
- data/spec/ruact/erb_preprocessor_spec.rb +145 -0
- data/spec/ruact/install_generator_spec.rb +727 -70
- data/spec/ruact/layout_source_spec.rb +108 -0
- data/spec/ruact/manifest_resolver_spec.rb +15 -0
- data/spec/ruact/scaffold_generator_spec.rb +14 -0
- data/spec/ruact/serializable_spec.rb +126 -0
- data/spec/ruact/server_bucket_request_spec.rb +291 -0
- data/spec/ruact/server_functions/introspection_spec.rb +135 -0
- data/spec/ruact/tasks_json_introspection_spec.rb +141 -0
- data/spec/ruact/testing/have_ruact_component_spec.rb +170 -0
- data/spec/ruact/testing/no_production_load_spec.rb +41 -0
- data/spec/support/flight_wire_parser.rb +12 -126
- data/spec/support/matchers/flight_fixture_matcher.rb +7 -258
- data/vendor/javascript/ruact-server-functions-runtime/index.d.ts +25 -0
- data/vendor/javascript/ruact-server-functions-runtime/index.js +104 -7
- data/vendor/javascript/ruact-server-functions-runtime/index.test.mjs +173 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/auto-revalidate.test-d.ts +25 -0
- metadata +30 -4
data/lib/ruact/controller.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
82
|
-
|
|
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 =
|
|
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
|
-
|
|
121
|
-
[:
|
|
122
|
-
|
|
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
|
|
83
|
-
react-client-manifest.json
|
|
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
|
|
data/lib/ruact/serializable.rb
CHANGED
|
@@ -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
|
-
#
|
|
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.
|
|
23
|
-
#
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|