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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 78c8550850bcded6e5585918c33d4520131a019520f4def020e9e5701a47c7ae
4
- data.tar.gz: ab49c696c75137128062c6f9c7fa8b2569b8f918818bcf50f209d3939675af59
3
+ metadata.gz: afeffed3bf00af43d750b220dbffbdd62758269d578bb649912d7fbee157df86
4
+ data.tar.gz: 2bae60da180b2a5b6c85fe49c3adc42d3431c1cfee7666c9fdbf79b92815049b
5
5
  SHA512:
6
- metadata.gz: a4dda8e8c229d941a6d8e852704837259a45653002b2c7465764ae219c58fbaa4188d4490745efe88327ceb736094ccc265b6e38f2cc38d008388bf7cb5d1e88
7
- data.tar.gz: 976492c9e23c34d777071ec0fa313ae146d942ba1ade21d939de380e012a0cedc4028c9e997dabe96518ad9b7b00bbd48a4c162eef9270969ba9e2bf5ce4e3a6
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.17...HEAD
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 &lt;LikeButton likes=&#123;@likes&#125; /&gt; tag, and the &quot;use client&quot; React component that tag resolves to. The component renders in a browser and its count changes when it is clicked. Children are then put inside the tag — the JSX habit — and the next request stops server-side with Ruact::ChildrenNotSupportedError, which names the component, the template file and line, and the fix. The children come out again and the page renders." />
14
+ alt="An ERB template holding a &lt;LikeButton likes=&#123;@likes&#125; /&gt; tag, and the &quot;use client&quot; React component that tag resolves to. The component renders in a browser and its count changes when it is clicked. The tag&#39;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. **Component tags take no children.** Client component tags are self-closing
81
- ONLY: `<Card content={@body} />`, never `<Card>...</Card>`. Children (a
82
- matching closing tag) fail LOUDLY with a `PreprocessorError` at preprocess
83
- time — pass content as a prop instead. Sole exception: the built-in
84
- `<Suspense fallback="...">...</Suspense>` pair.
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}`, no JSX children. A quoted value (`title="Hi"`) is a
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
- location = "(unknown location)" if location.empty?
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
- # <Dialog open={true}>
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
- # 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[^>]*)?>}
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?". 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]*>}
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 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).
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 a Story 13.5 contract violation can name the
59
- # call site's file:line. +registry+ is the component contract source — an
60
- # injectable seam (Story 7.1 explicit-context grain); it defaults to the
61
- # process-loaded {Ruact.manifest}. Pass +registry: nil+ (or a stub) in
62
- # specs to control contract lookup; +nil+ forces fail-open (no validation).
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 inside the component-tag block below — a
70
- # source with no PascalCase tags must touch the registry not at all
71
- # (AC2/AC6 fast-path invariant).
72
- # Step 1: transform <Suspense> paired tags into <ruact-suspense> HTML elements.
73
- # This runs before the general component regex so Suspense isn't treated as a component.
74
- result = source
75
- .gsub(SUSPENSE_OPEN_RE) do
76
- attrs = ::Regexp.last_match(1)
77
- fallback = extract_string_attr(attrs, "fallback") || ""
78
- escaped = fallback.gsub('"', "&quot;")
79
- # Optional `delay="2.5"` — the server-side wait (seconds) before
80
- # the deferred chunk streams. Forwarded to SuspenseElement#delay.
81
- delay = extract_string_attr(attrs, "delay")
82
- delay_attr = delay ? %( data-ruact-delay="#{delay.gsub('"', '&quot;')}") : ""
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 1.5 (Story 15.2 / FR106): before the general component pass, fail
88
- # loudly if any PascalCase component tag is used with children (a matching
89
- # closing tag). Silent degradation of `<Card>Hello</Card>` — the #1
90
- # predictable JSX-habit mistake — becomes a self-contained, re-raised-as-is
91
- # PreprocessorError naming the fix. It scans the ORIGINAL +source+ (so
92
- # file:line is exact) and masks Suspense (the one legitimate paired
93
- # PascalCase tag) newline-for-newline, so `<Suspense>...</Suspense>` can
94
- # never trip and every reported line matches the template verbatim.
95
- detect_children!(source, identifier)
96
-
97
- # Step 2: transform remaining PascalCase self-closing / opening component tags.
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('"', "&quot;")
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('"', '&quot;')}") : ""
109
+ %(<ruact-suspense data-ruact-fallback="#{escaped}"#{delay_attr}>)
124
110
  end
125
- end
111
+ .gsub(SUSPENSE_CLOSE_RE, "</ruact-suspense>")
126
112
  end
127
113
 
128
114
  private
129
115
 
130
- # Story 15.2 (FR106) — raise a loud, self-contained {ChildrenNotSupportedError}
131
- # on the FIRST PascalCase component tag used with children (a matching closing
132
- # tag). +source+ is the ORIGINAL template text; +identifier+ is the template
133
- # path (from {ErbPreprocessorHook}). Suspense — the one legitimate paired
134
- # PascalCase tag — is masked newline-for-newline first, so it never trips AND
135
- # every byte position (hence every reported line) still lines up with the raw
136
- # template. The line uses the same idiom as Step 2 (`count("\n") + 1`) on the
137
- # OPENING tag's offset. Message mirrors {ComponentContract.raise_error} shape
138
- # so both loud preprocess errors read identically. A no-op when no pair is
139
- # present — the fast path stays byte-identical.
140
- def detect_children!(source, identifier)
141
- # Fast path: a children pair REQUIRES a literal PascalCase closing tag, so
142
- # a source without one (the common all-self-closing case) can never trip —
143
- # bail before allocating anything. `match?` builds no MatchData, and this
144
- # skips the mask/scan copies entirely, keeping the hot render/preprocess
145
- # path's allocation profile flat (the benchmark renders only self-closing
146
- # components, so it must stay at baseline).
147
- return unless source.match?(CLOSING_TAG_PROBE_RE)
148
-
149
- # ERB islands first, then Suspense — both blank their text newline-for-
150
- # newline so byte offsets (hence reported lines) still match the raw
151
- # template, while neither ERB string text nor the legitimate Suspense pair
152
- # can be seen by the tag scan.
153
- scan = mask_suspense(mask_erb(source))
154
- # PER-NAME open stacks (name → [offsets]) so a closing tag checks for a
155
- # matching open in O(1) via `open_ats[name].last`, keeping the whole scan
156
- # linear even under thousands of stray/unmatched PascalCase closing tags
157
- # (a global stack + `rindex` was quadratic — Codex Round 3).
158
- open_ats = Hash.new { |h, k| h[k] = [] }
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 = ::Regexp.last_match
162
- name = m[2]
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
- at = open_ats[name].last
166
- next unless at # stray close with no open → literal text, ignore
167
-
168
- raise_children_error(name, identifier, scan, at)
169
- elsif m[0].end_with?("/>") # self-closing → carries no children
170
- next
171
- else # an opening tag `<Name ...>` — record the NEAREST open of this name
172
- open_ats[name] << m.begin(0)
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
- nil
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
- # Raise the self-contained {ChildrenNotSupportedError}. +at+ is the OPENING
180
- # tag's byte offset in +scan+ (position-faithful to the raw source), so the
181
- # line uses the same idiom as Step 2. Message mirrors
182
- # {ComponentContract.raise_error} so both loud preprocess errors read alike.
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 (the one legitimate paired PascalCase
193
- # tag) while preserving EVERY newline and byte offset, so the loud-children
194
- # scan neither trips on `<Suspense>...</Suspense>` nor mis-reports a line when
195
- # a multi-line Suspense opening precedes the offending tag. Non-newline chars
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 loud-children tag scan.
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 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
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
@@ -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
- receiver.define_singleton_method(:__ruact_component__) do |name, props = {}|
255
- ctx = instance_variable_get(:@__ruact_render_context__)
256
- raise Ruact::Error, "ruact: __ruact_component__ called outside an active render context" if ctx.nil?
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
- token = ctx.register(name, props)
259
- "<!-- #{token} -->"
259
+ placeholder.call(ctx.register(name, props))
260
+ end
260
261
  end
261
262
  end
262
263
  end
data/lib/ruact/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Ruact
4
- VERSION = "0.0.17"
4
+ VERSION = "0.0.18"
5
5
  end
@@ -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
- setModuleRegistry({ "/LikeButton.jsx": { LikeButton }, "/Bad.jsx": { Bad } });
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.17
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-06 00:00:00.000000000 Z
11
+ date: 2026-10-08 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: nokogiri