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.

becomes a placeholder that evaluates the props as Ruby:

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

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

<!-- __RUACT_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, identifier: nil, registry: :default) ⇒ Object

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

identifier is the template path (forwarded by Ruact::ErbPreprocessorHook as template.identifier) so a Story 13.5 contract violation can name the call site's file:line. registry is the component contract source — an injectable seam (Story 7.1 explicit-context grain); it defaults to the process-loaded Ruact.manifest. Pass registry: nil (or a stub) in specs to control contract lookup; nil forces fail-open (no validation).



45
46
47
# File 'lib/ruact/erb_preprocessor.rb', line 45

def self.transform(source, identifier: nil, registry: :default)
  new.transform(source, identifier: identifier, registry: registry)
end

Instance Method Details

#transform(source, identifier: nil, registry: :default) ⇒ Object



49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
# File 'lib/ruact/erb_preprocessor.rb', line 49

def transform(source, identifier: nil, registry: :default)
  # NOTE: +registry+ stays the +:default+ sentinel here. It is resolved to
  # +Ruact.manifest+ LAZILY, only inside the component-tag block below — a
  # source with no PascalCase tags must touch the registry not at all
  # (AC2/AC6 fast-path invariant).
  # Step 1: transform <Suspense> paired tags into <ruact-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;")
             # Optional `delay="2.5"` — the server-side wait (seconds) before
             # the deferred chunk streams. Forwarded to SuspenseElement#delay.
             delay      = extract_string_attr(attrs, "delay")
             delay_attr = delay ? %( data-ruact-delay="#{delay.gsub('"', '&quot;')}") : ""
             %(<ruact-suspense data-ruact-fallback="#{escaped}"#{delay_attr}>)
           end
    .gsub(SUSPENSE_CLOSE_RE, "</ruact-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
      # lazy — only when a tag exists. `resolve_soft` returns the dev-fetched
      # manifest (same source the render path uses, so the boot-race doesn't
      # silence FR100 contract checks in dev) and FAILS OPEN to nil when the
      # manifest is unresolvable (contract validation is opt-in/fail-open;
      # the render path surfaces the clear error). In prod this is the
      # boot-loaded Ruact.manifest, unchanged.
      registry = ManifestResolver.resolve_soft if registry == :default
      pairs = parse_prop_pairs(attrs_string)
      validate_contract(registry, component_name, pairs.map(&:first),
                        at: { file: identifier, line: line, snippet: match.strip })
      props_ruby = pairs.map { |name, expr| "#{name.inspect} => #{expr}" }.join(", ")
      props_hash = props_ruby.empty? ? "{}" : "{ #{props_ruby} }"
      %(<%= __ruact_component__(#{component_name.inspect}, #{props_hash}) %>)
    rescue ComponentContractError
      # Already carries file:line + offending prop + suggestion — re-raise
      # AS-IS (do NOT append the generic "at line N: snippet" tail).
      raise
    rescue PreprocessorError => e
      raise PreprocessorError, "#{e.message} at line #{line}: #{match.strip}"
    end
  end
end