Class: Ruact::ErbPreprocessor

Inherits:
Object
  • Object
show all
Defined in:
lib/ruact/erb_preprocessor.rb

Overview

Transforms ERB source before Ruby evaluation.

It handles one thing: PascalCase component tags with {expr} props.

<LikeButton postId={@post.id} initialCount={5} />

becomes a placeholder that evaluates the props as Ruby:

<%= __rsc_component__("LikeButton", { "postId" => @post.id, "initialCount" => 5 }) %>

The placeholder is replaced by an HTML comment with a unique token:

<!-- __RSC_COMPONENT_0__ -->

The actual ClientReference + props are registered in the binding and collected by HtmlConverter after the ERB renders.

Constant Summary collapse

COMPONENT_TAG_RE =

Matches a PascalCase opening tag with optional attributes and optional self-closing. Examples:

<Button />
<LikeButton postId={@post.id} initialCount={5} />
<Dialog open={true}>
%r{<([A-Z][A-Za-z0-9]*)(\s[^>]*)?\s*/?>}
SUSPENSE_OPEN_RE =

Matches <Suspense …> opening tags (handled before general PascalCase processing).

/<Suspense\b([^>]*?)>/m
SUSPENSE_CLOSE_RE =
%r{</Suspense>}
PROP_RE =

Matches a {ruby_expr} attribute value — captures everything between the braces. We use a simple bracket-depth counter approach during scanning instead of regex because expressions can contain nested braces: {foo.bar({ a: 1 })}.

/\b([a-zA-Z_][a-zA-Z0-9_]*)=\{/

Class Method Summary collapse

Instance Method Summary collapse

Class Method Details

.transform(source) ⇒ Object

Transform ERB source, replacing component tags with ERB placeholders. Returns the transformed source string.



38
39
40
# File 'lib/ruact/erb_preprocessor.rb', line 38

def self.transform(source)
  new.transform(source)
end

Instance Method Details

#transform(source) ⇒ Object



42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
# File 'lib/ruact/erb_preprocessor.rb', line 42

def transform(source)
  # Step 1: transform <Suspense> paired tags into <rsc-suspense> HTML elements.
  # This runs before the general component regex so Suspense isn't treated as a component.
  result = source
           .gsub(SUSPENSE_OPEN_RE) do
             attrs    = ::Regexp.last_match(1)
             fallback = extract_string_attr(attrs, "fallback") || ""
             escaped  = fallback.gsub('"', "&quot;")
             %(<rsc-suspense data-rsc-fallback="#{escaped}">)
           end
    .gsub(SUSPENSE_CLOSE_RE, "</rsc-suspense>")

  # Step 2: transform remaining PascalCase self-closing / opening component tags.
  result.gsub(COMPONENT_TAG_RE) do |match|
    component_name = ::Regexp.last_match(1)
    attrs_string   = ::Regexp.last_match(2).to_s.strip
    match_start    = ::Regexp.last_match.begin(0)
    line           = result[0...match_start].count("\n") + 1

    begin
      props_ruby = parse_props(attrs_string)
      props_hash = props_ruby.empty? ? "{}" : "{ #{props_ruby} }"
      %(<%= __rsc_component__(#{component_name.inspect}, #{props_hash}) %>)
    rescue PreprocessorError => e
      raise PreprocessorError, "#{e.message} at line #{line}: #{match.strip}"
    end
  end
end