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>}- 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
-
.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).
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('"', """) # 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 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.} at line #{line}: #{match.strip}" end end end |