ruact 0.0.2 → 0.0.4
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/.codecov.yml +31 -0
- data/.github/workflows/ci.yml +160 -94
- data/.github/workflows/server-functions-bench.yml +54 -0
- data/.rubocop.yml +19 -1
- data/.rubocop_todo.yml +175 -0
- data/CHANGELOG.md +88 -5
- data/README.md +2 -0
- data/RELEASING.md +9 -3
- data/bench/server_functions_dispatch_bench.rb +309 -0
- data/bench/server_functions_dispatch_bench.results.md +121 -0
- data/docs/internal/README.md +9 -0
- data/docs/internal/decisions/server-functions-api.md +1779 -0
- data/lib/generators/ruact/install/install_generator.rb +43 -0
- data/lib/generators/ruact/install/templates/application.jsx.tt +1 -1
- data/lib/generators/ruact/install/templates/initializer.rb.tt +1 -1
- data/lib/ruact/client_manifest.rb +125 -12
- data/lib/ruact/configuration.rb +264 -23
- data/lib/ruact/controller.rb +459 -32
- data/lib/ruact/doctor.rb +34 -2
- data/lib/ruact/erb_preprocessor.rb +6 -6
- data/lib/ruact/errors.rb +100 -0
- data/lib/ruact/flight/serializer.rb +2 -2
- data/lib/ruact/html_converter.rb +131 -31
- data/lib/ruact/query.rb +107 -0
- data/lib/ruact/railtie.rb +220 -3
- data/lib/ruact/render_context.rb +30 -0
- data/lib/ruact/render_pipeline.rb +201 -59
- data/lib/ruact/routing.rb +81 -0
- data/lib/ruact/serializable.rb +11 -11
- data/lib/ruact/server.rb +341 -0
- data/lib/ruact/server_action.rb +131 -0
- data/lib/ruact/server_functions/backtrace_cleaner.rb +32 -0
- data/lib/ruact/server_functions/bucket_two_payload.rb +109 -0
- data/lib/ruact/server_functions/codegen.rb +344 -0
- data/lib/ruact/server_functions/codegen_v2.rb +212 -0
- data/lib/ruact/server_functions/endpoint_controller.rb +237 -0
- data/lib/ruact/server_functions/error_payload.rb +93 -0
- data/lib/ruact/server_functions/error_rendering.rb +190 -0
- data/lib/ruact/server_functions/error_suggestion.rb +38 -0
- data/lib/ruact/server_functions/name_bridge.rb +118 -0
- data/lib/ruact/server_functions/query_context.rb +62 -0
- data/lib/ruact/server_functions/query_dispatch.rb +313 -0
- data/lib/ruact/server_functions/query_source.rb +150 -0
- data/lib/ruact/server_functions/registry.rb +148 -0
- data/lib/ruact/server_functions/registry_entry.rb +26 -0
- data/lib/ruact/server_functions/route_source.rb +201 -0
- data/lib/ruact/server_functions/snapshot.rb +195 -0
- data/lib/ruact/server_functions/snapshot_writer.rb +65 -0
- data/lib/ruact/server_functions/standalone_context.rb +103 -0
- data/lib/ruact/server_functions/standalone_dispatcher.rb +178 -0
- data/lib/ruact/server_functions.rb +111 -0
- data/lib/ruact/version.rb +1 -1
- data/lib/ruact/view_helper.rb +17 -9
- data/lib/ruact.rb +85 -6
- data/lib/rubocop/cop/ruact/no_shared_state.rb +1 -1
- data/lib/tasks/benchmark.rake +15 -11
- data/lib/tasks/ruact.rake +81 -0
- data/spec/benchmarks/render_pipeline_benchmark_spec.rb +1 -1
- data/spec/fixtures/flight/README.md +55 -7
- data/spec/fixtures/flight/bigint.txt +1 -0
- data/spec/fixtures/flight/infinity.txt +1 -0
- data/spec/fixtures/flight/nan.txt +1 -0
- data/spec/fixtures/flight/negative_infinity.txt +1 -0
- data/spec/fixtures/flight/undefined.txt +1 -0
- data/spec/fixtures/story_7_9_views/controller_request_spec_support/demo/show.html.erb +3 -0
- data/spec/ruact/client_manifest_spec.rb +108 -0
- data/spec/ruact/configuration_spec.rb +501 -0
- data/spec/ruact/controller_request_spec.rb +204 -0
- data/spec/ruact/controller_spec.rb +427 -39
- data/spec/ruact/doctor_spec.rb +118 -0
- data/spec/ruact/erb_preprocessor_hook_spec.rb +3 -3
- data/spec/ruact/erb_preprocessor_spec.rb +7 -7
- data/spec/ruact/errors_spec.rb +95 -0
- data/spec/ruact/flight/renderer_spec.rb +14 -3
- data/spec/ruact/flight/serializer_spec.rb +129 -88
- data/spec/ruact/html_converter_spec.rb +183 -5
- data/spec/ruact/install_generator_spec.rb +93 -0
- data/spec/ruact/query_request_spec.rb +598 -0
- data/spec/ruact/query_spec.rb +105 -0
- data/spec/ruact/railtie_spec.rb +2 -3
- data/spec/ruact/render_context_spec.rb +58 -0
- data/spec/ruact/render_pipeline_concurrency_spec.rb +78 -0
- data/spec/ruact/render_pipeline_spec.rb +784 -330
- data/spec/ruact/serializable_spec.rb +8 -8
- data/spec/ruact/server_bucket_request_spec.rb +352 -0
- data/spec/ruact/server_function_name_spec.rb +53 -0
- data/spec/ruact/server_functions/backtrace_cleaner_spec.rb +63 -0
- data/spec/ruact/server_functions/bucket_two_payload_spec.rb +200 -0
- data/spec/ruact/server_functions/codegen_spec.rb +508 -0
- data/spec/ruact/server_functions/csrf_request_spec.rb +380 -0
- data/spec/ruact/server_functions/dispatch_request_spec.rb +819 -0
- data/spec/ruact/server_functions/error_payload_spec.rb +222 -0
- data/spec/ruact/server_functions/error_suggestion_spec.rb +79 -0
- data/spec/ruact/server_functions/name_bridge_spec.rb +212 -0
- data/spec/ruact/server_functions/query_context_spec.rb +72 -0
- data/spec/ruact/server_functions/query_source_spec.rb +142 -0
- data/spec/ruact/server_functions/railtie_integration_spec.rb +412 -0
- data/spec/ruact/server_functions/rake_spec.rb +86 -0
- data/spec/ruact/server_functions/registry_spec.rb +199 -0
- data/spec/ruact/server_functions/route_source_spec.rb +202 -0
- data/spec/ruact/server_functions/snapshot_spec.rb +256 -0
- data/spec/ruact/server_functions/snapshot_writer_spec.rb +71 -0
- data/spec/ruact/server_functions/standalone_action_spec.rb +224 -0
- data/spec/ruact/server_functions/standalone_context_spec.rb +142 -0
- data/spec/ruact/server_functions/standalone_dispatcher_spec.rb +273 -0
- data/spec/ruact/server_rescue_request_spec.rb +416 -0
- data/spec/ruact/server_spec.rb +180 -0
- data/spec/ruact/server_upload_request_spec.rb +311 -0
- data/spec/ruact/view_helper_spec.rb +23 -17
- data/spec/spec_helper.rb +52 -1
- data/spec/support/fixtures/pixel.png +0 -0
- data/spec/support/flight_wire_parser.rb +135 -0
- data/spec/support/flight_wire_parser_spec.rb +93 -0
- data/spec/support/matchers/flight_fixture_matcher.rb +356 -0
- data/spec/support/matchers/flight_fixture_matcher_spec.rb +250 -0
- data/spec/support/rails_stub.rb +75 -5
- data/vendor/javascript/ruact-server-functions-runtime/index.d.ts +173 -0
- data/vendor/javascript/ruact-server-functions-runtime/index.js +614 -0
- data/vendor/javascript/ruact-server-functions-runtime/index.test.mjs +827 -0
- data/vendor/javascript/ruact-server-functions-runtime/package.json +29 -0
- data/vendor/javascript/ruact-server-functions-runtime/usequery.test.mjs +181 -0
- data/vendor/javascript/vite-plugin-ruact/index.js +164 -0
- data/vendor/javascript/vite-plugin-ruact/package-lock.json +1429 -0
- data/vendor/javascript/vite-plugin-ruact/package.json +15 -0
- data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.mjs +804 -0
- data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.test.mjs +961 -0
- data/vendor/javascript/vite-plugin-ruact/vitest.config.mjs +21 -0
- metadata +91 -5
- data/lib/ruact/component_registry.rb +0 -31
- data/lib/tasks/rsc.rake +0 -9
data/lib/ruact/errors.rb
CHANGED
|
@@ -11,4 +11,104 @@ module Ruact
|
|
|
11
11
|
|
|
12
12
|
# Raised when the ERB preprocessor encounters a malformed component tag.
|
|
13
13
|
class PreprocessorError < Error; end
|
|
14
|
+
|
|
15
|
+
# Raised when application code attempts to mutate Ruact::Configuration outside
|
|
16
|
+
# of a Ruact.configure block. The configuration is frozen after initialization
|
|
17
|
+
# to prevent runtime drift; see Story 7.3 for the rationale and the decision
|
|
18
|
+
# note for guidance on when re-configuration at runtime is appropriate.
|
|
19
|
+
class ConfigurationError < Error; end
|
|
20
|
+
|
|
21
|
+
# Raised by `Ruact::HtmlConverter.convert` when its input is not a `String`
|
|
22
|
+
# (the only accepted shape). Catches the most common upstream bug — an ERB
|
|
23
|
+
# template, partial, or render path that returned `nil` or a non-String value
|
|
24
|
+
# — at the boundary, before Nokogiri is invoked, so the failing file:line and
|
|
25
|
+
# the expected shape are visible at the top of the backtrace instead of
|
|
26
|
+
# buried under a Nokogiri stack. See Story 7.4 for the rationale.
|
|
27
|
+
class HtmlConverterError < Error; end
|
|
28
|
+
|
|
29
|
+
# Story 8.3 — raised by a standalone server-action block (a module that
|
|
30
|
+
# `extend`s {Ruact::ServerAction}) when its body invokes
|
|
31
|
+
# {Ruact::ServerFunctions::StandaloneContext#current_user} but no
|
|
32
|
+
# {Ruact::Configuration#current_user_resolver} has been configured. The
|
|
33
|
+
# message names both worked examples (Devise + hand-rolled session) so
|
|
34
|
+
# the developer can wire the resolver without leaving the stack trace.
|
|
35
|
+
class CurrentUserNotConfiguredError < Error
|
|
36
|
+
DEFAULT_MESSAGE = "Ruact.current_user requires Ruact.config.current_user_resolver to be set. " \
|
|
37
|
+
"Example (Devise): Ruact.configure { |c| c.current_user_resolver = ->(env) { env['warden']&.user } }. " \
|
|
38
|
+
"Example (hand-rolled session): Ruact.configure { |c| c.current_user_resolver = " \
|
|
39
|
+
"->(env) { User.find_by(id: env['rack.session'][:user_id]) } }."
|
|
40
|
+
|
|
41
|
+
def initialize(message = DEFAULT_MESSAGE)
|
|
42
|
+
super
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Story 8.3 — raised inside a standalone server-action block to surface a
|
|
47
|
+
# non-2xx response without calling `render` (which the StandaloneContext
|
|
48
|
+
# does not expose). The endpoint dispatcher rescues this exception class
|
|
49
|
+
# and renders `status` + `body` verbatim, mirroring how a controller-hosted
|
|
50
|
+
# action would call `render(json: ..., status: ...)`.
|
|
51
|
+
class ActionError < Error
|
|
52
|
+
attr_reader :status, :body
|
|
53
|
+
|
|
54
|
+
# @param status [Symbol, Integer] HTTP status (e.g. :unprocessable_entity, 422).
|
|
55
|
+
# @param body [Object] the response payload. Hash/Array/scalar values are
|
|
56
|
+
# rendered as JSON; nil renders an empty body.
|
|
57
|
+
# @param message [String] optional message; defaults to "ruact action error
|
|
58
|
+
# (status=<status>)" so the exception is still legible in logs.
|
|
59
|
+
def initialize(status:, body: nil, message: nil)
|
|
60
|
+
@status = status
|
|
61
|
+
@body = body
|
|
62
|
+
super(message || "ruact action error (status=#{status.inspect})")
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Story 9.5 (FR88) — raised by the query dispatch controller when a
|
|
67
|
+
# `useQuery` request's parameters violate the kwargs allowlist: a complex
|
|
68
|
+
# value (array / object) where only `string | number | boolean | null` is
|
|
69
|
+
# accepted, a missing required keyword, or an unknown parameter that matches
|
|
70
|
+
# no declared kwarg. Inherits from `Ruact::Error` so the Story 8.4
|
|
71
|
+
# `rescue_from StandardError` chain on the dispatch controller catches it
|
|
72
|
+
# cleanly; `__ruact_status_for` maps it to HTTP 400 (Bad Request). The
|
|
73
|
+
# message names the offending key and the allowlist so the React dev can
|
|
74
|
+
# fix the call site without reading the server source.
|
|
75
|
+
class BadRequestError < Error; end
|
|
76
|
+
|
|
77
|
+
# Story 8.5 — raised by `EndpointController#__ruact_enforce_upload_limit!`
|
|
78
|
+
# when an inbound multipart / urlencoded request's `Content-Length` exceeds
|
|
79
|
+
# `Ruact.config.max_upload_bytes`. The exception inherits from
|
|
80
|
+
# `Ruact::Error` so the Story 8.4 `rescue_from StandardError` chain on
|
|
81
|
+
# `EndpointController` catches it cleanly; the endpoint controller's
|
|
82
|
+
# `__ruact_status_for` maps it to HTTP 413, and `ErrorPayload.build`
|
|
83
|
+
# extracts the `received_bytes` / `limit_bytes` pair into a dev-only
|
|
84
|
+
# `upload_limit` block alongside the four baseline keys.
|
|
85
|
+
#
|
|
86
|
+
# The pair is exposed as `attr_reader` so the structured payload (and host
|
|
87
|
+
# log lines) can show both numbers without re-parsing the message string.
|
|
88
|
+
# Numbers report the WIRE `Content-Length` (which includes multipart
|
|
89
|
+
# boundary overhead — a 9.5 MB file uploaded via multipart will report a
|
|
90
|
+
# `received_bytes` slightly larger than the file size), not the parsed
|
|
91
|
+
# file size — that's what the guard checks, and reporting the same number
|
|
92
|
+
# avoids "why does the error say 9.7 MB when my file is 9.5 MB?" surprise.
|
|
93
|
+
#
|
|
94
|
+
# @example Construction shape (matches how the guard raises)
|
|
95
|
+
# raise Ruact::UploadTooLargeError.new(
|
|
96
|
+
# received_bytes: request.content_length,
|
|
97
|
+
# limit_bytes: Ruact.config.max_upload_bytes
|
|
98
|
+
# )
|
|
99
|
+
class UploadTooLargeError < Error
|
|
100
|
+
attr_reader :received_bytes, :limit_bytes
|
|
101
|
+
|
|
102
|
+
# @param received_bytes [Integer] wire `Content-Length` of the rejected request.
|
|
103
|
+
# @param limit_bytes [Integer] configured `Ruact.config.max_upload_bytes` at reject time.
|
|
104
|
+
# @param message [String] optional override; defaults to a string that
|
|
105
|
+
# names both numbers so the exception is legible without consulting
|
|
106
|
+
# the attr_readers.
|
|
107
|
+
def initialize(received_bytes:, limit_bytes:, message: nil)
|
|
108
|
+
@received_bytes = received_bytes
|
|
109
|
+
@limit_bytes = limit_bytes
|
|
110
|
+
super(message || "Upload exceeded the configured size limit " \
|
|
111
|
+
"(received_bytes=#{received_bytes}, limit_bytes=#{limit_bytes})")
|
|
112
|
+
end
|
|
113
|
+
end
|
|
14
114
|
end
|
|
@@ -166,7 +166,7 @@ module Ruact
|
|
|
166
166
|
# --- Serializable (explicit opt-in, no warning) ---
|
|
167
167
|
|
|
168
168
|
def serialize_serializable(value)
|
|
169
|
-
serialize_model(value.
|
|
169
|
+
serialize_model(value.ruact_serialize)
|
|
170
170
|
end
|
|
171
171
|
|
|
172
172
|
# --- as_json fallback (strict_serialization: false only) ---
|
|
@@ -182,7 +182,7 @@ module Ruact
|
|
|
182
182
|
if data.equal?(value)
|
|
183
183
|
raise Ruact::SerializationError,
|
|
184
184
|
"#{value.class.name}#as_json returned self — would cause infinite recursion. " \
|
|
185
|
-
"Include Ruact::Serializable and declare
|
|
185
|
+
"Include Ruact::Serializable and declare ruact_props instead"
|
|
186
186
|
end
|
|
187
187
|
|
|
188
188
|
attr_names = data.is_a?(Hash) ? data.keys.join(", ") : ""
|
data/lib/ruact/html_converter.rb
CHANGED
|
@@ -8,9 +8,10 @@ module Ruact
|
|
|
8
8
|
# Rules:
|
|
9
9
|
# - HTML attributes → React equivalents (class→className, for→htmlFor, etc.)
|
|
10
10
|
# - data-react-key="x" → becomes the React key on the element
|
|
11
|
-
# - HTML comments matching
|
|
11
|
+
# - HTML comments matching __RUACT_N__ tokens → replaced by client component refs
|
|
12
12
|
# - Text nodes → plain Ruby strings
|
|
13
13
|
# - Multiple root nodes → wrapped in a Fragment (array)
|
|
14
|
+
# rubocop:disable Metrics/ClassLength
|
|
14
15
|
class HtmlConverter
|
|
15
16
|
# HTML attribute → React prop name mapping
|
|
16
17
|
HTML_TO_REACT = {
|
|
@@ -34,8 +35,18 @@ module Ruact
|
|
|
34
35
|
"spellcheck" => "spellCheck"
|
|
35
36
|
}.freeze
|
|
36
37
|
|
|
38
|
+
# Tags whose `value` attribute maps to React's `defaultValue` (uncontrolled).
|
|
39
|
+
DEFAULT_VALUE_TAGS = %w[textarea select].freeze
|
|
40
|
+
|
|
41
|
+
# <input type=...> values for which `value` keeps its name in React (button-like inputs).
|
|
42
|
+
INPUT_BUTTON_TYPES = %w[submit reset button image].freeze
|
|
43
|
+
|
|
37
44
|
# Convert an HTML string into a ReactElement tree.
|
|
38
45
|
# component_registry is an array of { token:, name:, ref: ClientReference, props: Hash }
|
|
46
|
+
#
|
|
47
|
+
# Raises +Ruact::HtmlConverterError+ if +html+ is not a +String+. The
|
|
48
|
+
# validation runs before Nokogiri is invoked, so the caller's file:line
|
|
49
|
+
# appears at the top of the backtrace rather than Nokogiri internals.
|
|
39
50
|
def self.convert(html, component_registry = [])
|
|
40
51
|
new(component_registry).convert(html)
|
|
41
52
|
end
|
|
@@ -44,7 +55,14 @@ module Ruact
|
|
|
44
55
|
@registry = component_registry
|
|
45
56
|
end
|
|
46
57
|
|
|
58
|
+
# Convert an HTML string into a ReactElement tree (instance form).
|
|
59
|
+
#
|
|
60
|
+
# Raises +Ruact::HtmlConverterError+ if +html+ is not a +String+. The
|
|
61
|
+
# validation runs before Nokogiri is invoked, so the caller's file:line
|
|
62
|
+
# appears at the top of the backtrace rather than Nokogiri internals.
|
|
47
63
|
def convert(html)
|
|
64
|
+
validate_html_input!(html)
|
|
65
|
+
|
|
48
66
|
# Wrap in a fragment container so Nokogiri gives us a consistent root.
|
|
49
67
|
# Use HTML4 fragment parser (universally available, no libgumbo needed).
|
|
50
68
|
doc = Nokogiri::HTML::DocumentFragment.parse(html)
|
|
@@ -59,6 +77,80 @@ module Ruact
|
|
|
59
77
|
|
|
60
78
|
private
|
|
61
79
|
|
|
80
|
+
# Story 7.4: validate the +html+ input at the boundary so that a stray nil
|
|
81
|
+
# or non-String value produces a clear ruact-named error pointing at the
|
|
82
|
+
# caller's file:line instead of a Nokogiri stack trace.
|
|
83
|
+
#
|
|
84
|
+
# +String === html+ is used instead of +html.is_a?(String)+ because it
|
|
85
|
+
# delegates to +Module#===+ on +String+ rather than calling a method on
|
|
86
|
+
# +html+. This means even +BasicObject+ instances (which lack +is_a?+,
|
|
87
|
+
# +kind_of?+, and +.class+) raise +Ruact::HtmlConverterError+ here instead
|
|
88
|
+
# of +NoMethodError+. The message-building helper rescues every method
|
|
89
|
+
# call on +html+ for the same reason.
|
|
90
|
+
def validate_html_input!(html)
|
|
91
|
+
return if String === html # rubocop:disable Style/CaseEquality
|
|
92
|
+
|
|
93
|
+
# Walk the stack until we leave html_converter.rb — handles both the
|
|
94
|
+
# class form (HtmlConverter.convert → instance #convert) and the
|
|
95
|
+
# instance form, so the message always points at the application caller.
|
|
96
|
+
gem_file = __FILE__
|
|
97
|
+
stack = caller_locations(1, 10) || []
|
|
98
|
+
app_frames = stack.drop_while { |loc| loc.path == gem_file }
|
|
99
|
+
location = app_frames.first || stack.first
|
|
100
|
+
|
|
101
|
+
message = build_input_error_message(html, location)
|
|
102
|
+
app_backtrace = app_frames.map { |loc| "#{loc.path}:#{loc.lineno}:in `#{loc.label}'" }
|
|
103
|
+
app_backtrace = caller(1) if app_backtrace.empty?
|
|
104
|
+
raise Ruact::HtmlConverterError, message, app_backtrace
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def build_input_error_message(value, location)
|
|
108
|
+
called_from = "Called from: #{location.path}:#{location.lineno}"
|
|
109
|
+
doc_pointer = "See HtmlConverter.convert documentation for the canonical contract."
|
|
110
|
+
|
|
111
|
+
# +nil.equal?(value)+ instead of +value.nil?+: BasicObject has no
|
|
112
|
+
# +nil?+ method, so identity comparison through nil's side is the
|
|
113
|
+
# only safe path.
|
|
114
|
+
if nil.equal?(value)
|
|
115
|
+
<<~MSG.strip
|
|
116
|
+
ruact: HtmlConverter.convert received nil; expected a String of HTML.
|
|
117
|
+
Most likely cause: an ERB template, partial, or render path returned nil instead of an HTML string. Check the call site for a missing yield, an empty respond_to branch, or a partial that returned nil.
|
|
118
|
+
#{called_from}
|
|
119
|
+
#{doc_pointer}
|
|
120
|
+
MSG
|
|
121
|
+
else
|
|
122
|
+
klass_name = safe_class_name(value)
|
|
123
|
+
preview = safe_inspect_preview(value)
|
|
124
|
+
<<~MSG.strip
|
|
125
|
+
ruact: HtmlConverter.convert expected a String of HTML; got #{klass_name}.
|
|
126
|
+
Received: #{preview}
|
|
127
|
+
#{called_from}
|
|
128
|
+
#{doc_pointer}
|
|
129
|
+
MSG
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Rescue around +.class+ so +BasicObject+ instances (and anything else
|
|
134
|
+
# that overrides or lacks +.class+) still produce a readable message.
|
|
135
|
+
def safe_class_name(value)
|
|
136
|
+
value.class.to_s
|
|
137
|
+
rescue StandardError
|
|
138
|
+
"an unknown object"
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Truncate +inspect+ to 80 chars (preview window). Rescue any exception
|
|
142
|
+
# raised by a hostile +inspect+ so the validation never propagates a
|
|
143
|
+
# third-party error class instead of +Ruact::HtmlConverterError+.
|
|
144
|
+
def safe_inspect_preview(value)
|
|
145
|
+
raw = value.inspect
|
|
146
|
+
raw = raw.to_s
|
|
147
|
+
truncated = raw[0, 80]
|
|
148
|
+
truncated += "..." if raw.length > 80
|
|
149
|
+
truncated
|
|
150
|
+
rescue StandardError
|
|
151
|
+
"<inspect raised>"
|
|
152
|
+
end
|
|
153
|
+
|
|
62
154
|
def ignorable?(node)
|
|
63
155
|
node.text? && node.text.strip.empty?
|
|
64
156
|
end
|
|
@@ -70,7 +162,7 @@ module Ruact
|
|
|
70
162
|
text.strip.empty? ? nil : text
|
|
71
163
|
|
|
72
164
|
when Nokogiri::XML::Node::COMMENT_NODE
|
|
73
|
-
# Check if this is
|
|
165
|
+
# Check if this is a Ruact component placeholder
|
|
74
166
|
token = node.content.strip
|
|
75
167
|
entry = @registry.find { |c| c[:token] == token }
|
|
76
168
|
return nil unless entry
|
|
@@ -89,48 +181,55 @@ module Ruact
|
|
|
89
181
|
|
|
90
182
|
def convert_element(node)
|
|
91
183
|
tag = node.name.downcase
|
|
184
|
+
return convert_suspense_element(node) if tag == "ruact-suspense"
|
|
92
185
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
fallback = if fallback_text.empty?
|
|
97
|
-
nil
|
|
98
|
-
else
|
|
99
|
-
Flight::ReactElement.new(type: "p", key: nil, props: { "children" => fallback_text })
|
|
100
|
-
end
|
|
186
|
+
props = build_props(node, tag)
|
|
187
|
+
children = convert_children(node)
|
|
188
|
+
props["children"] = children.length == 1 ? children.first : children unless children.empty?
|
|
101
189
|
|
|
102
|
-
|
|
103
|
-
|
|
190
|
+
Flight::ReactElement.new(type: tag, key: node["data-react-key"], props: props)
|
|
191
|
+
end
|
|
104
192
|
|
|
105
|
-
|
|
106
|
-
|
|
193
|
+
def convert_suspense_element(node)
|
|
194
|
+
fallback_text = node["data-ruact-fallback"] || ""
|
|
195
|
+
fallback = if fallback_text.empty?
|
|
196
|
+
nil
|
|
197
|
+
else
|
|
198
|
+
Flight::ReactElement.new(type: "p", key: nil, props: { "children" => fallback_text })
|
|
199
|
+
end
|
|
107
200
|
|
|
108
|
-
|
|
109
|
-
|
|
201
|
+
child_nodes = convert_children(node)
|
|
202
|
+
children = child_nodes.length == 1 ? child_nodes.first : child_nodes
|
|
203
|
+
|
|
204
|
+
Flight::SuspenseElement.new(fallback: fallback, children: children)
|
|
205
|
+
end
|
|
110
206
|
|
|
207
|
+
def convert_children(node)
|
|
208
|
+
node.children.reject { |n| ignorable?(n) }.filter_map { |n| convert_node(n) }
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def build_props(node, tag)
|
|
212
|
+
props = {}
|
|
111
213
|
node.attributes.each do |attr_name, attr_node|
|
|
112
214
|
next if attr_name == "data-react-key"
|
|
113
215
|
|
|
114
|
-
react_name =
|
|
115
|
-
"defaultValue"
|
|
116
|
-
elsif attr_name == "value" && tag == "input"
|
|
117
|
-
input_type = node["type"]&.downcase
|
|
118
|
-
%w[submit reset button image].include?(input_type) ? "value" : "defaultValue"
|
|
119
|
-
elsif attr_name == "checked" && tag == "input"
|
|
120
|
-
"defaultChecked"
|
|
121
|
-
else
|
|
122
|
-
HTML_TO_REACT[attr_name] || camel_case_data(attr_name)
|
|
123
|
-
end
|
|
216
|
+
react_name = react_attr_name(attr_name, tag, node)
|
|
124
217
|
props[react_name] = attr_name == "style" ? parse_style(attr_node.value) : attr_node.value
|
|
125
218
|
end
|
|
219
|
+
props
|
|
220
|
+
end
|
|
126
221
|
|
|
127
|
-
|
|
222
|
+
def react_attr_name(attr_name, tag, node)
|
|
223
|
+
return "defaultValue" if attr_name == "value" && DEFAULT_VALUE_TAGS.include?(tag)
|
|
224
|
+
return input_value_name(node) if attr_name == "value" && tag == "input"
|
|
225
|
+
return "defaultChecked" if attr_name == "checked" && tag == "input"
|
|
128
226
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
end
|
|
227
|
+
HTML_TO_REACT[attr_name] || camel_case_data(attr_name)
|
|
228
|
+
end
|
|
132
229
|
|
|
133
|
-
|
|
230
|
+
def input_value_name(node)
|
|
231
|
+
input_type = node["type"]&.downcase
|
|
232
|
+
INPUT_BUTTON_TYPES.include?(input_type) ? "value" : "defaultValue"
|
|
134
233
|
end
|
|
135
234
|
|
|
136
235
|
# Converts a CSS inline style string into a React-compatible hash with camelCase keys.
|
|
@@ -156,4 +255,5 @@ module Ruact
|
|
|
156
255
|
parts.first + parts[1..].map(&:capitalize).join
|
|
157
256
|
end
|
|
158
257
|
end
|
|
258
|
+
# rubocop:enable Metrics/ClassLength
|
|
159
259
|
end
|
data/lib/ruact/query.rb
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ruact
|
|
4
|
+
# Story 9.4 (route-driven redesign, Phase B) — base class for v2 server
|
|
5
|
+
# QUERIES. Queries are plain classes under `app/queries/`; each public
|
|
6
|
+
# instance method defined directly on the subclass is one query:
|
|
7
|
+
#
|
|
8
|
+
# # app/queries/application_query.rb
|
|
9
|
+
# class ApplicationQuery < Ruact::Query; end
|
|
10
|
+
#
|
|
11
|
+
# # app/queries/catalog_query.rb
|
|
12
|
+
# class CatalogQuery < ApplicationQuery
|
|
13
|
+
# def categories
|
|
14
|
+
# Category.active.pluck(:id, :name).map { |id, name| { value: id, label: name } }
|
|
15
|
+
# end
|
|
16
|
+
#
|
|
17
|
+
# def my_categories
|
|
18
|
+
# current_user.categories.pluck(:id, :name)
|
|
19
|
+
# end
|
|
20
|
+
# end
|
|
21
|
+
#
|
|
22
|
+
# # config/routes.rb
|
|
23
|
+
# Rails.application.routes.draw do
|
|
24
|
+
# ruact_queries CatalogQuery # GET /q/categories, GET /q/myCategories — visible in `rails routes`
|
|
25
|
+
# end
|
|
26
|
+
#
|
|
27
|
+
# Per the 2026-06-02 ADR addendum (Decision 2), dispatch goes through an
|
|
28
|
+
# internal gem controller that inherits `Ruact.config.query_parent_controller`
|
|
29
|
+
# (default `ApplicationController`) — the host's REAL callback chain
|
|
30
|
+
# (`authenticate_user!`, tenant scoping, Pundit) runs BEFORE the query class
|
|
31
|
+
# is instantiated (FR89). The query instance is fresh per request (NFR8) and
|
|
32
|
+
# receives its execution context via the constructor, so
|
|
33
|
+
# `CatalogQuery.new(fake_context).categories` is unit-testable with no Rails
|
|
34
|
+
# boot.
|
|
35
|
+
#
|
|
36
|
+
# The context accessors below are defined on `Ruact::Query` itself, so they
|
|
37
|
+
# are INHERITED by subclasses — they never appear in a subclass's
|
|
38
|
+
# `public_instance_methods(false)`, which is exactly the set the
|
|
39
|
+
# `ruact_queries` routing macro mounts (one named GET route per method).
|
|
40
|
+
class Query
|
|
41
|
+
# @param context [#current_user, #params, #request, #session] the
|
|
42
|
+
# per-request execution context. In production this is a
|
|
43
|
+
# {Ruact::ServerFunctions::QueryContext} wrapping the dispatching
|
|
44
|
+
# controller; in unit tests any object exposing the four readers works.
|
|
45
|
+
def initialize(context)
|
|
46
|
+
@__ruact_context = context
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# @return [Object, nil] the host's authenticated user, via the dispatching
|
|
50
|
+
# controller's own `current_user` (Devise / Pundit / hand-rolled).
|
|
51
|
+
def current_user
|
|
52
|
+
@__ruact_context.current_user
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# @return [Object] the request params (query-string parameters on a GET).
|
|
56
|
+
def params
|
|
57
|
+
@__ruact_context.params
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# @return [Object] the live request.
|
|
61
|
+
def request
|
|
62
|
+
@__ruact_context.request
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# @return [Object] the host middleware's session.
|
|
66
|
+
def session
|
|
67
|
+
@__ruact_context.session
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
class << self
|
|
71
|
+
# Story 9.4 AC4 / D1 — per-query callback opt-out (resolves ADR open
|
|
72
|
+
# item 1). Mirrors Rails' `skip_before_action` ergonomics; the recorded
|
|
73
|
+
# skips are applied verbatim to THIS query class's generated dispatch
|
|
74
|
+
# controller when `ruact_queries` draws its routes, so the opt-out never
|
|
75
|
+
# leaks to other query classes:
|
|
76
|
+
#
|
|
77
|
+
# class PublicCatalogQuery < ApplicationQuery
|
|
78
|
+
# ruact_skip_before_action :authenticate_user!
|
|
79
|
+
#
|
|
80
|
+
# def categories = Category.pluck(:id, :name)
|
|
81
|
+
# end
|
|
82
|
+
#
|
|
83
|
+
# Options are forwarded to `skip_before_action` untouched — `only:` /
|
|
84
|
+
# `except:` scope the skip to specific query methods (each method is one
|
|
85
|
+
# controller action), `raise: false` tolerates a callback the parent
|
|
86
|
+
# does not define.
|
|
87
|
+
#
|
|
88
|
+
# @param callbacks [Array<Symbol>] callback name(s) to skip.
|
|
89
|
+
# @param options [Hash] forwarded to `skip_before_action` verbatim.
|
|
90
|
+
# @return [void]
|
|
91
|
+
def ruact_skip_before_action(*callbacks, **options)
|
|
92
|
+
__ruact_skipped_callbacks << [callbacks.map(&:to_sym), options]
|
|
93
|
+
nil
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# The recorded `(callbacks, options)` pairs for this query class,
|
|
97
|
+
# consumed by {Ruact::ServerFunctions::QueryDispatch} when the dispatch
|
|
98
|
+
# controller is generated. Per-class (not inherited) — a skip describes
|
|
99
|
+
# the queries of the class that declares it.
|
|
100
|
+
#
|
|
101
|
+
# @return [Array<Array(Array<Symbol>, Hash)>]
|
|
102
|
+
def __ruact_skipped_callbacks
|
|
103
|
+
@__ruact_skipped_callbacks ||= []
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|