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>}
COMPONENT_ANY_TAG_RE =

Story 15.2 (FR106) — matches ANY PascalCase component tag: opening (<Card>), self-closing (<Card />), or closing (</Card>). Capture 1 is the leading slash (present only on a closing tag); capture 2 is the name. The loud-children detection scans these left-to-right with a stack: an opening pushes, a self-closing (/>) is ignored, and a closing that pops a matching opening means that opening had children (paired usage) → loud error. A single linear pass (no backreference/lazy backtracking) keeps the component-dense fast path linear regardless of how many tags go unclosed.

%r{<(/)?([A-Z][A-Za-z0-9]*)(?:\s[^>]*)?>}
CLOSING_TAG_PROBE_RE =

Cheap allocation-free probe (String#match?) for "is there ANY PascalCase closing tag at all?". The loud-children scan only matters when one exists, so this gates the (copy-heavy) mask/scan work off the common all-self- closing fast path. </Suspense> matches too — harmless, it gets masked.

%r{</[A-Z][A-Za-z0-9]*>}
ERB_ISLAND_RE =

Newline-preserving mask for ERB islands (<% … %>, <%= … %>, <%# … %>). The loud-children scan blanks these first so a </Card> that lives inside Ruby/ERB string or comment text can never be mistaken for a real component closing tag (a valid bare <Dialog> opening must not error because some unrelated <% "</Dialog>" %> appears later).

/<%.*?%>/m
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).



68
69
70
# File 'lib/ruact/erb_preprocessor.rb', line 68

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



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
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
# File 'lib/ruact/erb_preprocessor.rb', line 72

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 1.5 (Story 15.2 / FR106): before the general component pass, fail
  # loudly if any PascalCase component tag is used with children (a matching
  # closing tag). Silent degradation of `<Card>Hello</Card>` — the #1
  # predictable JSX-habit mistake — becomes a self-contained, re-raised-as-is
  # PreprocessorError naming the fix. It scans the ORIGINAL +source+ (so
  # file:line is exact) and masks Suspense (the one legitimate paired
  # PascalCase tag) newline-for-newline, so `<Suspense>...</Suspense>` can
  # never trip and every reported line matches the template verbatim.
  detect_children!(source, identifier)

  # 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