Module: Ruact::ComponentContract

Defined in:
lib/ruact/component_contract.rb

Overview

Story 13.5 (FR100) — compile-time component contract (HEEx-style attr/ slot). Validates a <Component .../> ERB call site against the component's OPT-IN contract at preprocess time, so a missing required prop, a typo'd prop name, or a missing required slot surfaces an error at the call site (file:line) BEFORE the page renders — not as a silent undefined in the browser.

This is the CONSUMER-side mirror of ruact_props (which validates the PRODUCER side at class-load, serializable.rb). It is NAME-level only: prop VALUES are arbitrary render-time Ruby expressions the preprocessor never evaluates, so only names + presence + slots are checkable here (value typing for the server boundary already landed in Story 13.4).

The contract Hash is the one the Vite plugin extracted into the manifest:

{
"props"       => { "postId" => "required", "initialCount" => "optional" },
"slots"       => { "header" => "optional" },   # optional
"passthrough" => false                          # optional
}

Pure + stateless (explicit class methods) so it is trivially testable in isolation and carries no global state.

Class Method Summary collapse

Class Method Details

.validate(component_name:, prop_names:, contract:, at: {}) ⇒ void

This method returns an undefined value.

Validate a call site's prop NAMES against contract. Raises Ruact::ComponentContractError (a PreprocessorError) on the first violation. A nil contract is a no-op (fail open / opt-in).

Parameters:

  • component_name (String)

    e.g. "LikeButton"

  • prop_names (Array<String>)

    names parsed from the call site (e.g. ["postId", "initialCount"] for a <LikeButton> tag with those props)

  • contract (Hash, nil)

    the manifest contract Hash, or nil

  • at (Hash) (defaults to: {})

    call-site location for the message with optional keys file (from template.identifier), line (the 1-based call-site line), and snippet (the offending tag text)

Raises:



41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
# File 'lib/ruact/component_contract.rb', line 41

def self.validate(component_name:, prop_names:, contract:, at: {})
  return if contract.nil?

  props = normalize(contract["props"])
  slots = normalize(contract["slots"])
  given = Array(prop_names).map(&:to_s)
  where = { component: component_name, file: at[:file], line: at[:line], snippet: at[:snippet] }
  unknown = contract["passthrough"] == true ? [] : (given - (props.keys + slots.keys))

  # Priority order so the highest-signal message wins. A near-miss typo is
  # reported FIRST even when it leaves a required prop "missing" — e.g.
  # `<LikeButton postID={1} />` (postID typo of the required postId) should
  # say "unknown prop \"postID\" — did you mean \"postId\"?", not the less
  # helpful "missing required prop \"postId\"" (FR100's canonical typo case).
  check_typo(unknown, props.keys + slots.keys, where: where)
  check_missing_required(props, given, kind: "prop", where: where)
  check_missing_required(slots, given, kind: "slot", where: where)
  check_unknown(unknown.first, where: where) if unknown.any?
end