ruact 0.0.17 → 0.0.18
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 +27 -1
- data/README.md +1 -1
- data/lib/generators/ruact/install/templates/AGENTS.md.tt +6 -6
- data/lib/ruact/client_manifest.rb +18 -0
- data/lib/ruact/component_contract.rb +4 -2
- data/lib/ruact/erb_preprocessor.rb +240 -137
- data/lib/ruact/errors.rb +15 -15
- data/lib/ruact/html_converter.rb +18 -0
- data/lib/ruact/render_pipeline.rb +6 -5
- data/lib/ruact/version.rb +1 -1
- data/lib/ruact/view_helper.rb +13 -0
- data/vendor/javascript/vite-plugin-ruact/flight-conformance.test.mjs +21 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: afeffed3bf00af43d750b220dbffbdd62758269d578bb649912d7fbee157df86
|
|
4
|
+
data.tar.gz: 2bae60da180b2a5b6c85fe49c3adc42d3431c1cfee7666c9fdbf79b92815049b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 79f5785e73237b512b1fce27e9b085538c57954921bf92544fc8d4f80c2646f27e5e46baa23b1a565beb567b8e4762a108a0f2885b3ec85f9b822216889afadd
|
|
7
|
+
data.tar.gz: 3421862773a8f0669533bd70e39aed531b9ae2c4ce42205bc6939bc6311d76f5c45e4527d39752793bebc343af29ba96ebffd3fd58a6f414bb16fba879cfcbeb
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,31 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.0.18] - 2026-10-07
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **Client components take children from ERB.** Content between a component's opening and closing tags reaches it as `children`, evaluated on the server like the rest of the template: HTML, `<%= %>` output (escaped as usual), loops, conditionals, partials, and other components, the same one included.
|
|
15
|
+
|
|
16
|
+
```erb
|
|
17
|
+
<Card title={@post.title}>
|
|
18
|
+
<p><%= @post.body %></p>
|
|
19
|
+
<LikeButton postId={@post.id} />
|
|
20
|
+
</Card>
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
A component that declares a contract (`__ruactContract`) has to list `children` among its `props` or `slots` to take them. `<Suspense>` works inside a component and around one.
|
|
24
|
+
|
|
25
|
+
- **A misspelled component name is caught where it is written.** When a manifest is available, a template naming a component that does not exist now fails while it compiles, with `Ruact::UnknownComponentError` naming the tag, the template's file and line, and the closest component (`Did you mean "LikeButton"?`). It used to fail at render with no location. Without a manifest the render reports it, as before.
|
|
26
|
+
|
|
27
|
+
### Changed
|
|
28
|
+
|
|
29
|
+
- **Every component tag must close.** `<Dialog open={true}>` with no closing tag used to render as a component without children. It now raises `Ruact::ComponentTagError` at file:line: write `<Dialog open={true} />`, or close it with `</Dialog>`. The same error names a closing tag that closes nothing, and a tag given both content and a `children={...}` prop.
|
|
30
|
+
|
|
31
|
+
### Removed
|
|
32
|
+
|
|
33
|
+
- **`Ruact::ChildrenNotSupportedError`.** It was raised for content between a component's tags, which is now supported. Rescue `Ruact::ComponentTagError` for tags that do not pair, or `Ruact::PreprocessorError` for any template error.
|
|
34
|
+
|
|
10
35
|
## [0.0.17] - 2026-10-06
|
|
11
36
|
|
|
12
37
|
### Changed
|
|
@@ -455,7 +480,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
455
480
|
|
|
456
481
|
- **E2E test app** — `e2e/` Rails app (no DB, in-memory Post model) with full CRUD system tests validating the complete request cycle.
|
|
457
482
|
|
|
458
|
-
[Unreleased]: https://github.com/luizcg/ruact/compare/v0.0.
|
|
483
|
+
[Unreleased]: https://github.com/luizcg/ruact/compare/v0.0.18...HEAD
|
|
484
|
+
[0.0.18]: https://github.com/luizcg/ruact/releases/tag/v0.0.18
|
|
459
485
|
[0.0.17]: https://github.com/luizcg/ruact/releases/tag/v0.0.17
|
|
460
486
|
[0.0.16]: https://github.com/luizcg/ruact/releases/tag/v0.0.16
|
|
461
487
|
[0.0.15]: https://github.com/luizcg/ruact/releases/tag/v0.0.15
|
data/README.md
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
scripted and live with the project's other capture harnesses, and the message the demo
|
|
12
12
|
shows is pinned here by `spec/readme_demo_message_spec.rb`. -->
|
|
13
13
|
<img src="https://ruact.dev/readme-write-verify.gif" width="800"
|
|
14
|
-
alt="An ERB template holding a <LikeButton likes={@likes} /> tag, and the "use client" React component that tag resolves to. The component renders in a browser and its count changes when it is clicked.
|
|
14
|
+
alt="An ERB template holding a <LikeButton likes={@likes} /> tag, and the "use client" React component that tag resolves to. The component renders in a browser and its count changes when it is clicked. The tag's name is then misspelled, and the next request stops server-side with Ruact::UnknownComponentError, which names the tag, the template file and line, and the component it was probably meant to be. The name is corrected and the page renders." />
|
|
15
15
|
|
|
16
16
|
## Quick start
|
|
17
17
|
|
|
@@ -77,14 +77,14 @@ READ IT instead of simulating the name generation. Regenerate after routes/queri
|
|
|
77
77
|
|
|
78
78
|
## Five traps
|
|
79
79
|
|
|
80
|
-
1. **
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
`<Suspense fallback="..."
|
|
80
|
+
1. **Every component tag closes.** `<Badge label={@x} />` has no children;
|
|
81
|
+
`<Card title={@t}>...</Card>` passes the ERB between its tags — HTML, loops,
|
|
82
|
+
partials, other components — to the component as `children`. An opening tag
|
|
83
|
+
that never closes, or a closing tag with no opening, fails LOUDLY with a
|
|
84
|
+
`PreprocessorError` naming file:line. `<Suspense fallback="...">` pairs too.
|
|
85
85
|
2. **`{}` props are Ruby, not JavaScript.** `<Badge label={@post.title} />`
|
|
86
86
|
evaluates `@post.title` as a Ruby expression in the ERB. No JS ternaries,
|
|
87
|
-
no `{...spread}
|
|
87
|
+
no `{...spread}`. A quoted value (`title="Hi"`) is a
|
|
88
88
|
plain string and a bare attribute is `true`, as in JSX.
|
|
89
89
|
3. **One action, two response shapes.** A `Ruact::Server` non-GET action
|
|
90
90
|
answers JSON — its instance variables, or `204`, or `{"$redirect": path}` —
|
|
@@ -89,6 +89,24 @@ module Ruact
|
|
|
89
89
|
end
|
|
90
90
|
end
|
|
91
91
|
|
|
92
|
+
# Story 18-2 — whether +name+ resolves for SOME controller: the shared key,
|
|
93
|
+
# or a co-located one in any folder (`posts/_like_button`). The ERB
|
|
94
|
+
# preprocessor asks while it compiles a template, before it knows which
|
|
95
|
+
# controller will render it, so a co-located component is never refused
|
|
96
|
+
# for being looked up from a shared partial.
|
|
97
|
+
def component?(name)
|
|
98
|
+
return true if include?(name)
|
|
99
|
+
|
|
100
|
+
suffix = "/_#{pascal_to_snake_case(name)}"
|
|
101
|
+
entries_by_name.keys.any? { |key| key.end_with?(suffix) }
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Story 18-2 — the "not found" message {#reference_for} raises, for the
|
|
105
|
+
# preprocessor to put the template's file:line in front of.
|
|
106
|
+
def unknown_component_message(name, controller_path: nil)
|
|
107
|
+
build_unknown_component_message(name, controller_path)
|
|
108
|
+
end
|
|
109
|
+
|
|
92
110
|
# Story 13.5 (FR100) — return the optional component +contract+ Hash for
|
|
93
111
|
# +name+, or +nil+ when the component declared none (or is absent from the
|
|
94
112
|
# manifest). Honors the same co-located/shared +resolve_key+ precedence as
|
|
@@ -103,8 +103,10 @@ module Ruact
|
|
|
103
103
|
# @param detail [String] the violation clause
|
|
104
104
|
# @param where [Hash] keys +component+, +file+, +line+, +snippet+
|
|
105
105
|
def self.raise_error(detail, where)
|
|
106
|
-
location = [where[:file], where[:line]].compact.join(":")
|
|
107
|
-
|
|
106
|
+
location = if where[:file] then [where[:file], where[:line]].compact.join(":")
|
|
107
|
+
elsif where[:line] then "line #{where[:line]}"
|
|
108
|
+
else "(unknown location)"
|
|
109
|
+
end
|
|
108
110
|
message = "ruact: <#{where[:component]}> at #{location} #{detail}."
|
|
109
111
|
message += "\n in: #{where[:snippet]}" if where[:snippet] && !where[:snippet].empty?
|
|
110
112
|
raise ComponentContractError, message
|
|
@@ -14,186 +14,289 @@ module Ruact
|
|
|
14
14
|
# The placeholder is replaced by an HTML comment with a unique token:
|
|
15
15
|
# <!-- __RUACT_0__ -->
|
|
16
16
|
#
|
|
17
|
+
# A component with children (Story 18-2) keeps its content as ERB, inside a
|
|
18
|
+
# wrapper element HtmlConverter turns into the component's `children`:
|
|
19
|
+
#
|
|
20
|
+
# <Card title={@t}><p><%= @body %></p></Card>
|
|
21
|
+
# → <%= __ruact_component_open__("Card", { "title" => @t }) %><p><%= @body %></p></ruact-component>
|
|
22
|
+
#
|
|
17
23
|
# The actual ClientReference + props are registered in the binding and
|
|
18
24
|
# collected by HtmlConverter after the ERB renders.
|
|
19
|
-
class ErbPreprocessor
|
|
25
|
+
class ErbPreprocessor # rubocop:disable Metrics/ClassLength
|
|
20
26
|
# Matches a PascalCase opening tag with optional attributes and optional self-closing.
|
|
21
27
|
# Examples:
|
|
22
28
|
# <Button />
|
|
23
29
|
# <LikeButton postId={@post.id} initialCount={5} />
|
|
24
|
-
# <
|
|
30
|
+
# <Card title={@post.title}>
|
|
25
31
|
COMPONENT_TAG_RE = %r{<([A-Z][A-Za-z0-9]*)(\s[^>]*)?\s*/?>}
|
|
26
32
|
|
|
27
33
|
# Matches <Suspense ...> opening tags (handled before general PascalCase processing).
|
|
28
34
|
SUSPENSE_OPEN_RE = /<Suspense\b([^>]*?)>/m
|
|
29
|
-
SUSPENSE_CLOSE_RE = %r{</Suspense
|
|
30
|
-
|
|
31
|
-
#
|
|
32
|
-
# (`<Card
|
|
33
|
-
#
|
|
34
|
-
#
|
|
35
|
-
|
|
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[^>]*)?>}
|
|
35
|
+
SUSPENSE_CLOSE_RE = %r{</Suspense\s*>}
|
|
36
|
+
|
|
37
|
+
# Matches ANY PascalCase component tag: opening (`<Card>`), self-closing
|
|
38
|
+
# (`<Card />`), or closing (`</Card>`). Capture 1 is the leading slash
|
|
39
|
+
# (present only on a closing tag); capture 2 is the name. The pairing scan
|
|
40
|
+
# walks these left to right with one stack (Story 18-2).
|
|
41
|
+
COMPONENT_ANY_TAG_RE = %r{<(/)?([A-Z][A-Za-z0-9]*)(?:\s[^>]*|/)?>}
|
|
40
42
|
|
|
41
43
|
# Cheap allocation-free probe (`String#match?`) for "is there ANY PascalCase
|
|
42
|
-
# closing tag at all?".
|
|
43
|
-
#
|
|
44
|
-
|
|
45
|
-
CLOSING_TAG_PROBE_RE = %r{</[A-Z][A-Za-z0-9]*>}
|
|
44
|
+
# closing tag at all?". Without one there is nothing to pair, so the
|
|
45
|
+
# template takes the single-pass path; `</Suspense>` matches too — harmless.
|
|
46
|
+
CLOSING_TAG_PROBE_RE = %r{</[A-Z][A-Za-z0-9]*[\s/>]}
|
|
46
47
|
|
|
47
48
|
# Newline-preserving mask for ERB islands (`<% … %>`, `<%= … %>`, `<%# … %>`).
|
|
48
|
-
# The
|
|
49
|
-
# Ruby/ERB string or comment text
|
|
50
|
-
# closing tag (a valid bare `<Dialog>` opening must not error because some
|
|
51
|
-
# unrelated `<% "</Dialog>" %>` appears later).
|
|
49
|
+
# The pairing scan blanks these first so a `</Card>` that lives inside
|
|
50
|
+
# Ruby/ERB string or comment text is never taken for a real closing tag.
|
|
52
51
|
ERB_ISLAND_RE = /<%.*?%>/m
|
|
53
52
|
|
|
53
|
+
# An ERB comment, which renders nothing.
|
|
54
|
+
ERB_COMMENT_RE = /<%#.*?%>/m
|
|
55
|
+
|
|
56
|
+
# The element a paired component's content is wrapped in until
|
|
57
|
+
# {HtmlConverter} turns it into the component's `children`.
|
|
58
|
+
WRAPPER_TAG = "ruact-component"
|
|
59
|
+
|
|
60
|
+
# What each helper the transformed template calls leaves in the HTML, by
|
|
61
|
+
# its render-context token: a component, or the opening of one with
|
|
62
|
+
# children. {RenderPipeline} installs these for a plain-ERB render;
|
|
63
|
+
# ViewHelper writes the same strings in ActionView.
|
|
64
|
+
PLACEHOLDERS = {
|
|
65
|
+
__ruact_component__: ->(token) { "<!-- #{token} -->" },
|
|
66
|
+
__ruact_component_open__: ->(token) { %(<#{WRAPPER_TAG} data-ruact-token="#{token}">) }
|
|
67
|
+
}.freeze
|
|
68
|
+
|
|
54
69
|
# Transform ERB source, replacing component tags with ERB placeholders.
|
|
55
70
|
# Returns the transformed source string.
|
|
56
71
|
#
|
|
57
72
|
# +identifier+ is the template path (forwarded by {ErbPreprocessorHook} as
|
|
58
|
-
# +template.identifier+) so
|
|
59
|
-
#
|
|
60
|
-
#
|
|
61
|
-
#
|
|
62
|
-
#
|
|
73
|
+
# +template.identifier+) so an error can name the call site's file:line.
|
|
74
|
+
# +registry+ is the component contract source — an injectable seam (Story
|
|
75
|
+
# 7.1 explicit-context grain); it defaults to the process-loaded
|
|
76
|
+
# {Ruact.manifest}. Pass +registry: nil+ (or a stub) in specs to control
|
|
77
|
+
# contract lookup; +nil+ forces fail-open (no validation).
|
|
63
78
|
def self.transform(source, identifier: nil, registry: :default)
|
|
64
79
|
new.transform(source, identifier: identifier, registry: registry)
|
|
65
80
|
end
|
|
66
81
|
|
|
67
82
|
def transform(source, identifier: nil, registry: :default)
|
|
68
83
|
# NOTE: +registry+ stays the +:default+ sentinel here. It is resolved to
|
|
69
|
-
# +Ruact.manifest+ LAZILY, only
|
|
70
|
-
#
|
|
71
|
-
#
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
delay_attr = delay ? %( data-ruact-delay="#{delay.gsub('"', '"')}") : ""
|
|
83
|
-
%(<ruact-suspense data-ruact-fallback="#{escaped}"#{delay_attr}>)
|
|
84
|
+
# +Ruact.manifest+ LAZILY, only when a component tag exists — a source
|
|
85
|
+
# with no PascalCase tags must touch the registry not at all (AC2/AC6
|
|
86
|
+
# fast-path invariant).
|
|
87
|
+
@identifier = identifier
|
|
88
|
+
@registry = registry
|
|
89
|
+
|
|
90
|
+
# Step 1: component tags → ERB placeholders. Story 18-2: a tag with a
|
|
91
|
+
# matching closing tag is a component WITH children; its content stays
|
|
92
|
+
# ERB, evaluated in place, inside a wrapper element.
|
|
93
|
+
result = if source.match?(CLOSING_TAG_PROBE_RE)
|
|
94
|
+
rewrite_paired(source)
|
|
95
|
+
else
|
|
96
|
+
rewrite_self_closing(source)
|
|
84
97
|
end
|
|
85
|
-
.gsub(SUSPENSE_CLOSE_RE, "</ruact-suspense>")
|
|
86
98
|
|
|
87
|
-
# Step
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
result.gsub(COMPONENT_TAG_RE) do |match|
|
|
99
|
-
component_name = ::Regexp.last_match(1)
|
|
100
|
-
attrs_string = ::Regexp.last_match(2).to_s.strip
|
|
101
|
-
match_start = ::Regexp.last_match.begin(0)
|
|
102
|
-
line = result[0...match_start].count("\n") + 1
|
|
103
|
-
|
|
104
|
-
begin
|
|
105
|
-
# lazy — only when a tag exists. `resolve_soft` returns the dev-fetched
|
|
106
|
-
# manifest (same source the render path uses, so the boot-race doesn't
|
|
107
|
-
# silence FR100 contract checks in dev) and FAILS OPEN to nil when the
|
|
108
|
-
# manifest is unresolvable (contract validation is opt-in/fail-open;
|
|
109
|
-
# the render path surfaces the clear error). In prod this is the
|
|
110
|
-
# boot-loaded Ruact.manifest, unchanged.
|
|
111
|
-
registry = ManifestResolver.resolve_soft if registry == :default
|
|
112
|
-
pairs = ComponentAttributes.parse(attrs_string)
|
|
113
|
-
validate_contract(registry, component_name, pairs.map(&:first),
|
|
114
|
-
at: { file: identifier, line: line, snippet: match.strip })
|
|
115
|
-
props_ruby = pairs.map { |name, expr| "#{name.inspect} => #{expr}" }.join(", ")
|
|
116
|
-
props_hash = props_ruby.empty? ? "{}" : "{ #{props_ruby} }"
|
|
117
|
-
%(<%= __ruact_component__(#{component_name.inspect}, #{props_hash}) %>)
|
|
118
|
-
rescue ComponentContractError
|
|
119
|
-
# Already carries file:line + offending prop + suggestion — re-raise
|
|
120
|
-
# AS-IS (do NOT append the generic "at line N: snippet" tail).
|
|
121
|
-
raise
|
|
122
|
-
rescue PreprocessorError => e
|
|
123
|
-
raise PreprocessorError, "#{e.message} at line #{line}: #{match.strip}"
|
|
99
|
+
# Step 2: <Suspense> paired tags → <ruact-suspense> HTML elements.
|
|
100
|
+
result
|
|
101
|
+
.gsub(SUSPENSE_OPEN_RE) do
|
|
102
|
+
attrs = ::Regexp.last_match(1)
|
|
103
|
+
fallback = extract_string_attr(attrs, "fallback") || ""
|
|
104
|
+
escaped = fallback.gsub('"', """)
|
|
105
|
+
# Optional `delay="2.5"` — the server-side wait (seconds) before
|
|
106
|
+
# the deferred chunk streams. Forwarded to SuspenseElement#delay.
|
|
107
|
+
delay = extract_string_attr(attrs, "delay")
|
|
108
|
+
delay_attr = delay ? %( data-ruact-delay="#{delay.gsub('"', '"')}") : ""
|
|
109
|
+
%(<ruact-suspense data-ruact-fallback="#{escaped}"#{delay_attr}>)
|
|
124
110
|
end
|
|
125
|
-
|
|
111
|
+
.gsub(SUSPENSE_CLOSE_RE, "</ruact-suspense>")
|
|
126
112
|
end
|
|
127
113
|
|
|
128
114
|
private
|
|
129
115
|
|
|
130
|
-
#
|
|
131
|
-
#
|
|
132
|
-
#
|
|
133
|
-
#
|
|
134
|
-
#
|
|
135
|
-
#
|
|
136
|
-
#
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
116
|
+
# The common path: no PascalCase closing tag anywhere, so every component
|
|
117
|
+
# tag should be self-closing. One gsub, no masking copies — the render
|
|
118
|
+
# benchmark's allocation profile depends on it. An opening tag that is not
|
|
119
|
+
# self-closing hands the whole template to {#rewrite_paired}, which masks
|
|
120
|
+
# ERB: `<%# wrap in <Card> later %>` is a comment, not an unclosed tag.
|
|
121
|
+
# So does any tag inside an ERB island (a commented-out component): the
|
|
122
|
+
# paired path leaves those alone.
|
|
123
|
+
def rewrite_self_closing(source)
|
|
124
|
+
catch(:open_tag) do
|
|
125
|
+
return source.gsub(COMPONENT_TAG_RE) do |match|
|
|
126
|
+
name = ::Regexp.last_match(1)
|
|
127
|
+
next match if name == "Suspense"
|
|
128
|
+
|
|
129
|
+
throw :open_tag unless match.end_with?("/>")
|
|
130
|
+
throw :open_tag if inside_erb?(source, ::Regexp.last_match.begin(0))
|
|
131
|
+
|
|
132
|
+
placeholder(name, ::Regexp.last_match(2).to_s.strip, match,
|
|
133
|
+
line_at(source, ::Regexp.last_match.begin(0)), :self_closing)
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
rewrite_paired(source)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Story 18-2 — pair opening and closing tags with one stack over the
|
|
140
|
+
# template with ERB islands and Suspense blanked (offsets preserved, so
|
|
141
|
+
# every line reported is the template's own), then rewrite each tag in
|
|
142
|
+
# place: a paired opening becomes {ViewHelper#__ruact_component_open__}'s
|
|
143
|
+
# wrapper, its closing `</ruact-component>`, a self-closing tag the usual
|
|
144
|
+
# placeholder. Tags inside ERB text are left alone.
|
|
145
|
+
def rewrite_paired(source)
|
|
146
|
+
scan = mask_suspense(mask_erb(source))
|
|
147
|
+
tags = []
|
|
148
|
+
stack = []
|
|
149
|
+
paired = {}
|
|
159
150
|
|
|
160
151
|
scan.scan(COMPONENT_ANY_TAG_RE) do
|
|
161
|
-
m
|
|
162
|
-
|
|
152
|
+
m = ::Regexp.last_match
|
|
153
|
+
tag = { name: m[2], from: m.begin(0), to: m.end(0) }
|
|
154
|
+
tags << tag
|
|
163
155
|
|
|
164
156
|
if m[1] # a closing tag `</Name>`
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
157
|
+
tag[:closing] = true
|
|
158
|
+
open = stack.pop
|
|
159
|
+
raise_stray(tag[:name], line_at(source, tag[:from])) if open.nil?
|
|
160
|
+
# `<Card><Box></Card>`: the tag left open is the one to fix.
|
|
161
|
+
raise_open_tag(source, open) unless open[:name] == tag[:name]
|
|
162
|
+
|
|
163
|
+
# The value says whether anything sits between the tags: an empty
|
|
164
|
+
# pair passes no children, like `<Card />`. Read from the template
|
|
165
|
+
# itself — ERB between the tags (`<%= @body %>`) is content, an ERB
|
|
166
|
+
# comment is not.
|
|
167
|
+
paired[open[:from]] = !source[open[:to]...tag[:from]].gsub(ERB_COMMENT_RE, "").strip.empty?
|
|
168
|
+
elsif !m[0].end_with?("/>")
|
|
169
|
+
stack << tag
|
|
173
170
|
end
|
|
174
171
|
end
|
|
172
|
+
raise_open_tag(source, stack.first) unless stack.empty?
|
|
173
|
+
|
|
174
|
+
splice(source, tags, paired)
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# +source+ with each tag replaced by its placeholder. +paired+ maps an
|
|
178
|
+
# opening tag's offset to whether its pair holds anything.
|
|
179
|
+
def splice(source, tags, paired)
|
|
180
|
+
out = +""
|
|
181
|
+
cursor = 0
|
|
182
|
+
tags.each do |tag|
|
|
183
|
+
out << source[cursor...tag[:from]]
|
|
184
|
+
out << rewrite_tag(source, tag, paired)
|
|
185
|
+
cursor = tag[:to]
|
|
186
|
+
end
|
|
187
|
+
out << source[cursor..]
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def rewrite_tag(source, tag, paired)
|
|
191
|
+
return "</#{WRAPPER_TAG}>" if tag[:closing]
|
|
192
|
+
|
|
193
|
+
kind = if !paired.key?(tag[:from]) then :self_closing
|
|
194
|
+
elsif paired[tag[:from]] then :with_children
|
|
195
|
+
else :empty_pair
|
|
196
|
+
end
|
|
197
|
+
text = source[tag[:from]...tag[:to]]
|
|
198
|
+
placeholder(tag[:name], text.match(COMPONENT_TAG_RE)[2].to_s.strip, text, line_at(source, tag[:from]), kind)
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# The ERB placeholder for one component tag. Props are parsed (and the
|
|
202
|
+
# opt-in contract checked) here; a pair with content passes `children` too.
|
|
203
|
+
# +kind+ is :self_closing, :empty_pair or :with_children.
|
|
204
|
+
def placeholder(name, attrs_string, match, line, kind)
|
|
205
|
+
children = kind == :with_children
|
|
206
|
+
# lazy — only when a tag exists. `resolve_soft` returns the dev-fetched
|
|
207
|
+
# manifest (same source the render path uses, so the boot-race doesn't
|
|
208
|
+
# silence FR100 contract checks in dev) and FAILS OPEN to nil when the
|
|
209
|
+
# manifest is unresolvable (contract validation is opt-in/fail-open; the
|
|
210
|
+
# render path surfaces the clear error). In prod this is the boot-loaded
|
|
211
|
+
# Ruact.manifest, unchanged.
|
|
212
|
+
@registry = ManifestResolver.resolve_soft if @registry == :default
|
|
213
|
+
pairs = ComponentAttributes.parse(attrs_string)
|
|
214
|
+
names = pairs.map(&:first)
|
|
215
|
+
raise_children_twice(name, line) if children && names.include?("children")
|
|
216
|
+
|
|
217
|
+
check_known!(name, line)
|
|
218
|
+
validate_contract(@registry, name, children ? names + ["children"] : names,
|
|
219
|
+
at: { file: @identifier, line: line, snippet: match.strip })
|
|
220
|
+
props_ruby = pairs.map { |prop, expr| "#{prop.inspect} => #{expr}" }.join(", ")
|
|
221
|
+
props_hash = props_ruby.empty? ? "{}" : "{ #{props_ruby} }"
|
|
222
|
+
helper = kind == :self_closing ? "__ruact_component__" : "__ruact_component_open__"
|
|
223
|
+
%(<%= #{helper}(#{name.inspect}, #{props_hash}) %>)
|
|
224
|
+
rescue ComponentContractError, ComponentTagError, UnknownComponentError
|
|
225
|
+
# Already carry file:line and the fix — re-raise AS-IS (do NOT append the
|
|
226
|
+
# generic "at line N: snippet" tail).
|
|
227
|
+
raise
|
|
228
|
+
rescue PreprocessorError => e
|
|
229
|
+
raise PreprocessorError, "#{e.message} at line #{line}: #{match.strip}"
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
# Story 18-2 — a component the manifest does not have fails here, with the
|
|
233
|
+
# template's file:line, instead of at render with no location. Skipped
|
|
234
|
+
# when there is no manifest to ask (fail open, like the contract check).
|
|
235
|
+
def check_known!(name, line)
|
|
236
|
+
return unless @registry.respond_to?(:component?)
|
|
237
|
+
return if @registry.component?(name)
|
|
238
|
+
|
|
239
|
+
message = @registry.unknown_component_message(name, controller_path: controller_path_from(@identifier))
|
|
240
|
+
# With a close name on offer it is a typo; "did you run the Vite build?"
|
|
241
|
+
# would send the reader the wrong way.
|
|
242
|
+
message = message.lines.grep_v(/Did you run the Vite build/).join.rstrip if message.include?('Did you mean "')
|
|
243
|
+
raise UnknownComponentError, message.sub(/\Aruact: /, "ruact: <#{name}> at #{location(line)} — ")
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
# Whether +offset+ sits inside an ERB island (`<% … %>`): the nearest `<%`
|
|
247
|
+
# before it closes after it. One search back, one short search forward; no
|
|
248
|
+
# allocation.
|
|
249
|
+
def inside_erb?(source, offset)
|
|
250
|
+
opened = source.rindex("<%", offset)
|
|
251
|
+
return false unless opened
|
|
252
|
+
|
|
253
|
+
closed = source.index("%>", opened + 2)
|
|
254
|
+
closed.nil? || closed + 2 > offset
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def line_at(source, offset)
|
|
258
|
+
source[0...offset].count("\n") + 1
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
def location(line)
|
|
262
|
+
@identifier ? "#{@identifier}:#{line}" : "line #{line}"
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
def raise_open_tag(source, tag)
|
|
266
|
+
text = source[tag[:from]...tag[:to]]
|
|
267
|
+
raise_unclosed(tag[:name], line_at(source, tag[:from]),
|
|
268
|
+
attrs: text.match(COMPONENT_TAG_RE)[2].to_s.strip, snippet: text)
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
# A tag that looks open may only be a `>` inside an attribute value
|
|
272
|
+
# (`title="a > b"`, `title="<%= @t %>"`) cutting the tag short; the
|
|
273
|
+
# attribute's own error says that, so it goes first.
|
|
274
|
+
def raise_unclosed(name, line, attrs:, snippet:)
|
|
275
|
+
begin
|
|
276
|
+
ComponentAttributes.parse(attrs)
|
|
277
|
+
rescue PreprocessorError => e
|
|
278
|
+
raise PreprocessorError, "#{e.message} at line #{line}: #{snippet.strip}"
|
|
279
|
+
end
|
|
280
|
+
raise ComponentTagError,
|
|
281
|
+
"ruact: <#{name}> at #{location(line)} is never closed — write `<#{name} ... />` for a " \
|
|
282
|
+
"component without children, or close it with `</#{name}>`."
|
|
283
|
+
end
|
|
175
284
|
|
|
176
|
-
|
|
285
|
+
def raise_stray(name, line)
|
|
286
|
+
raise ComponentTagError,
|
|
287
|
+
"ruact: </#{name}> at #{location(line)} closes nothing — there is no open <#{name}> before it."
|
|
177
288
|
end
|
|
178
289
|
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
def raise_children_error(component, identifier, scan, at)
|
|
184
|
-
line = scan[0...at].count("\n") + 1
|
|
185
|
-
location = [identifier, line].compact.join(":")
|
|
186
|
-
location = "(unknown location)" if location.empty?
|
|
187
|
-
raise ChildrenNotSupportedError,
|
|
188
|
-
"ruact: <#{component}> at #{location} children are not supported " \
|
|
189
|
-
"— pass content as a prop, e.g. `<#{component} content={...} />`."
|
|
290
|
+
def raise_children_twice(name, line)
|
|
291
|
+
raise ComponentTagError,
|
|
292
|
+
"ruact: <#{name}> at #{location(line)} has content between its tags and a `children={...}` " \
|
|
293
|
+
"prop — pass one of them."
|
|
190
294
|
end
|
|
191
295
|
|
|
192
|
-
# Blank out Suspense open/close tags
|
|
193
|
-
#
|
|
194
|
-
#
|
|
195
|
-
#
|
|
196
|
-
# → spaces (same length); newlines kept verbatim.
|
|
296
|
+
# Blank out Suspense open/close tags while preserving EVERY newline and
|
|
297
|
+
# byte offset, so the pairing scan neither sees `<Suspense>...</Suspense>`
|
|
298
|
+
# nor mis-reports a line after a multi-line Suspense opening. Non-newline
|
|
299
|
+
# chars → spaces (same length); newlines kept verbatim.
|
|
197
300
|
def mask_suspense(source)
|
|
198
301
|
source
|
|
199
302
|
.gsub(SUSPENSE_OPEN_RE) { |m| m.gsub(/[^\n]/, " ") }
|
|
@@ -202,7 +305,7 @@ module Ruact
|
|
|
202
305
|
|
|
203
306
|
# Blank ERB islands (position-faithful, see {mask_suspense}) so component
|
|
204
307
|
# tags that appear only inside Ruby/ERB string or comment text are invisible
|
|
205
|
-
# to the
|
|
308
|
+
# to the pairing scan.
|
|
206
309
|
def mask_erb(source)
|
|
207
310
|
source.gsub(ERB_ISLAND_RE) { |m| m.gsub(/[^\n]/, " ") }
|
|
208
311
|
end
|
data/lib/ruact/errors.rb
CHANGED
|
@@ -22,21 +22,21 @@ 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
|
|
26
|
-
#
|
|
27
|
-
#
|
|
28
|
-
#
|
|
29
|
-
#
|
|
30
|
-
#
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
#
|
|
34
|
-
#
|
|
35
|
-
#
|
|
36
|
-
#
|
|
37
|
-
#
|
|
38
|
-
#
|
|
39
|
-
class
|
|
25
|
+
# Story 18-2 — raised by {Ruact::ErbPreprocessor} at preprocess time when a
|
|
26
|
+
# component tag does not pair: an opening tag never closed (write `<Card />`
|
|
27
|
+
# or close it with `</Card>`), a closing tag that closes nothing, or content
|
|
28
|
+
# between the tags together with a `children=` prop. The message carries
|
|
29
|
+
# the tag, file:line and the fix, so the preprocessor re-raises it as is.
|
|
30
|
+
# Replaces Story 15.2's ChildrenNotSupportedError: components take children now.
|
|
31
|
+
class ComponentTagError < PreprocessorError; end
|
|
32
|
+
|
|
33
|
+
# Story 18-2 — raised by {Ruact::ErbPreprocessor} when a template names a
|
|
34
|
+
# client component the manifest does not have (a typo, a missing
|
|
35
|
+
# "use client"). The same message {ClientManifest#reference_for} raises at
|
|
36
|
+
# render — with the closest name — plus the template's file:line, because
|
|
37
|
+
# it is caught while the template compiles. Only when a manifest is
|
|
38
|
+
# available; without one the render path reports it, as before.
|
|
39
|
+
class UnknownComponentError < PreprocessorError; end
|
|
40
40
|
|
|
41
41
|
# Raised when application code attempts to mutate Ruact::Configuration outside
|
|
42
42
|
# of a Ruact.configure block. The configuration is frozen after initialization
|
data/lib/ruact/html_converter.rb
CHANGED
|
@@ -182,6 +182,7 @@ module Ruact
|
|
|
182
182
|
def convert_element(node)
|
|
183
183
|
tag = node.name.downcase
|
|
184
184
|
return convert_suspense_element(node) if tag == "ruact-suspense"
|
|
185
|
+
return convert_component_with_children(node) if tag == ErbPreprocessor::WRAPPER_TAG
|
|
185
186
|
|
|
186
187
|
props = build_props(node, tag)
|
|
187
188
|
children = convert_children(node)
|
|
@@ -190,6 +191,23 @@ module Ruact
|
|
|
190
191
|
Flight::ReactElement.new(type: tag, key: node["data-react-key"], props: props)
|
|
191
192
|
end
|
|
192
193
|
|
|
194
|
+
# Story 18-2 — a component with children: the wrapper the preprocessor put
|
|
195
|
+
# around the content between `<Card>` and `</Card>`. Its children become the
|
|
196
|
+
# component's `children` prop, converted like any element's; an empty pair
|
|
197
|
+
# passes none, like `<Card />`.
|
|
198
|
+
def convert_component_with_children(node)
|
|
199
|
+
token = node["data-ruact-token"]
|
|
200
|
+
entry = @registry.find { |c| c[:token] == token }
|
|
201
|
+
raise Ruact::HtmlConverterError, "ruact: no component registered for #{token.inspect}" unless entry
|
|
202
|
+
|
|
203
|
+
props = entry[:props]
|
|
204
|
+
children = convert_children(node)
|
|
205
|
+
unless children.empty?
|
|
206
|
+
props = props.merge("children" => children.length == 1 ? children.first : children)
|
|
207
|
+
end
|
|
208
|
+
Flight::ReactElement.new(type: entry[:ref], key: nil, props: props)
|
|
209
|
+
end
|
|
210
|
+
|
|
193
211
|
def convert_suspense_element(node)
|
|
194
212
|
fallback_text = node["data-ruact-fallback"] || ""
|
|
195
213
|
fallback = if fallback_text.empty?
|
|
@@ -251,12 +251,13 @@ module Ruact
|
|
|
251
251
|
receiver.instance_variable_set(:@__ruact_render_context__, render_context)
|
|
252
252
|
return if receiver.respond_to?(:__ruact_component__)
|
|
253
253
|
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
254
|
+
ErbPreprocessor::PLACEHOLDERS.each do |helper, placeholder|
|
|
255
|
+
receiver.define_singleton_method(helper) do |name, props = {}|
|
|
256
|
+
ctx = instance_variable_get(:@__ruact_render_context__)
|
|
257
|
+
raise Ruact::Error, "ruact: #{helper} called outside an active render context" if ctx.nil?
|
|
257
258
|
|
|
258
|
-
|
|
259
|
-
|
|
259
|
+
placeholder.call(ctx.register(name, props))
|
|
260
|
+
end
|
|
260
261
|
end
|
|
261
262
|
end
|
|
262
263
|
end
|
data/lib/ruact/version.rb
CHANGED
data/lib/ruact/view_helper.rb
CHANGED
|
@@ -32,6 +32,19 @@ module Ruact
|
|
|
32
32
|
"<!-- #{token} -->".html_safe
|
|
33
33
|
end
|
|
34
34
|
|
|
35
|
+
# Story 18-2 — the opening of a component with children. Registers it like
|
|
36
|
+
# {#__ruact_component__} and opens the wrapper element whose content (the
|
|
37
|
+
# ERB between the tags, evaluated in place) HtmlConverter hands the
|
|
38
|
+
# component as `children`. The preprocessor writes the matching
|
|
39
|
+
# `</ruact-component>`.
|
|
40
|
+
def __ruact_component_open__(name, props = {})
|
|
41
|
+
ctx = @ruact_render_context
|
|
42
|
+
raise Ruact::Error, __ruact_outside_render_message(name) if ctx.nil?
|
|
43
|
+
|
|
44
|
+
token = ctx.register(name, props)
|
|
45
|
+
%(<ruact-component data-ruact-token="#{token}">).html_safe
|
|
46
|
+
end
|
|
47
|
+
|
|
35
48
|
# Story 17.0i — a client component in a template Rails is rendering on its
|
|
36
49
|
# own. Says which component and template, and the two ways to a ruact page:
|
|
37
50
|
# `ruact_render` for this template, or listing the action in the
|
|
@@ -32,7 +32,11 @@ function Bad() {
|
|
|
32
32
|
function LikeButton({ likes, tags }) {
|
|
33
33
|
return h("button", null, `${likes}:${Array.isArray(tags) ? `[${tags.join(",")}]` : String(tags)}`);
|
|
34
34
|
}
|
|
35
|
-
|
|
35
|
+
function Card({ title, children }) {
|
|
36
|
+
return h("section", null, h("h2", null, title), children);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
setModuleRegistry({ "/LikeButton.jsx": { LikeButton }, "/Bad.jsx": { Bad }, "/Card.jsx": { Card } });
|
|
36
40
|
|
|
37
41
|
function streamOf(chunks) {
|
|
38
42
|
return new ReadableStream({
|
|
@@ -103,6 +107,22 @@ describe(`React's Flight client (${MODE}) reads a page the Ruby side rendered`,
|
|
|
103
107
|
});
|
|
104
108
|
});
|
|
105
109
|
|
|
110
|
+
describe(`a client component with children from ERB (${MODE}) — Story 18-2`, () => {
|
|
111
|
+
const fixture = MODE === "development" ? "conformance_children_dev.txt" : "conformance_children.txt";
|
|
112
|
+
|
|
113
|
+
it("renders the server's children inside the component, a nested one included, and logs nothing", async () => {
|
|
114
|
+
const { container, root } = await mount(decode([read(fixture)]));
|
|
115
|
+
expect(container.innerHTML).toBe(
|
|
116
|
+
"<section><h2>Server title</h2><p>from ERB</p>" +
|
|
117
|
+
"<button>1:[only]</button><button>2:[only]</button><button>3:[only]</button>" +
|
|
118
|
+
"<section><h2>inner</h2>nested</section></section>",
|
|
119
|
+
);
|
|
120
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
121
|
+
expect(consoleError).not.toHaveBeenCalled();
|
|
122
|
+
act(() => root.unmount());
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
106
126
|
describe(`text rows (${MODE}) — Story 17-0d`, () => {
|
|
107
127
|
const expected = readJSON("text_framing_expected.json");
|
|
108
128
|
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: ruact
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.0.
|
|
4
|
+
version: 0.0.18
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Luiz Garcia
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-10-
|
|
11
|
+
date: 2026-10-08 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: nokogiri
|