Class: Ruact::ErbPreprocessor
- Inherits:
-
Object
- Object
- Ruact::ErbPreprocessor
- 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
-
.transform(source, identifier: nil, registry: :default) ⇒ Object
Transform ERB source, replacing component tags with ERB placeholders.
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('"', """) # 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('"', '"')}") : "" %(<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.} at line #{line}: #{match.strip}" end end end |