Class: Ruact::ClientManifest

Inherits:
Object
  • Object
show all
Defined in:
lib/ruact/client_manifest.rb

Overview

Reads the react-client-manifest.json emitted by the Vite plugin and resolves component names to Flight ClientReferences.

Manifest format (one entry per "use client" export):

{
"LikeButton": {
  "id":     "/assets/LikeButton-abc123.js",
  "chunks": ["/assets/LikeButton-abc123.js"],
  "name":   "LikeButton"
},
"posts/_like_button": {
  "id":     "/assets/posts/_like_button-abc123.js",
  "chunks": ["/assets/posts/_like_button-abc123.js"],
  "name":   "default"
}
}

Story 13.5 (FR100) — an entry MAY carry an optional, purely additive contract field when the component opts in by exporting __ruactContract from its .tsx (the Vite plugin extracts it names-only). Shape:

"LikeButton": {
"id": ..., "name": ..., "chunks": [...],
"contract": {
  "props":       { "postId" => "required", "initialCount" => "optional" },
  "slots":       { "header" => "optional" },          # optional
  "passthrough": false                                 # optional
}
}

A component without the export has NO contract key (back-compatible: every existing manifest + reader is unaffected). #contract_for reads it for the Story 13.5 preprocess-time call-site validator; nil means "no contract → no validation" (fail open).

Class Method Summary collapse

Instance Method Summary collapse

Class Method Details

.from_hash(data) ⇒ Object

Build from an already-parsed Hash (useful in tests). The @reference_cache ivar is initialized eagerly so the freeze + first-lookup path works even when data is empty (otherwise reference_for would raise FrozenError trying to memoize on a frozen instance).



119
120
121
122
123
124
# File 'lib/ruact/client_manifest.rb', line 119

def self.from_hash(data)
  manifest = new
  manifest.instance_variable_set(:@data, data)
  manifest.instance_variable_set(:@reference_cache, {})
  manifest
end

.load(path) ⇒ Object

Load from a file path (JSON). Pre-warms the reference cache and freezes the manifest so it cannot be mutated at runtime (AC#5). Pre-warming is required because Ruby's freeze is shallow: instance variable assignment on a frozen object raises FrozenError, so @reference_cache must already be set before freeze.



106
107
108
109
110
111
112
# File 'lib/ruact/client_manifest.rb', line 106

def self.load(path)
  raw      = File.read(path)
  data     = JSON.parse(raw)
  manifest = from_hash(data)
  data.each_key { |name| manifest.reference_for(name) }
  manifest.freeze
end

Instance Method Details

#contract_for(name, controller_path: nil) ⇒ Hash?

Story 13.5 (FR100) — return the optional component contract Hash for name, or nil when the component declared none (or is absent from the manifest). Honors the same co-located/shared resolve_key precedence as #reference_for, so a co-located component's contract is found when the call site's controller_path is known. A pure read (no memoization, no mutation) — safe on the frozen manifest, never raises for an unknown name (the validator fails open). Consumed by Ruact::ComponentContract from the ERB preprocessor.

Parameters:

  • name (String)

    PascalCase component name (e.g. "LikeButton")

  • controller_path (String, nil) (defaults to: nil)

    e.g. "posts" — biases toward a co-located key ("posts/_like_button") when present.

Returns:

  • (Hash, nil)

    the contract Hash, or nil when none is declared.



96
97
98
99
# File 'lib/ruact/client_manifest.rb', line 96

def contract_for(name, controller_path: nil)
  entry = entries_by_name[resolve_key(name, controller_path)]
  entry && entry["contract"]
end

#include?(name) ⇒ Boolean

Returns true if name is a top-level key in the manifest data. Used by the dual-path resolver to check co-located key existence before fallback.

Returns:

  • (Boolean)


52
53
54
# File 'lib/ruact/client_manifest.rb', line 52

def include?(name)
  entries_by_name.key?(name)
end

#reference_for(name, controller_path: nil) ⇒ Object

Resolve a component name (e.g. "LikeButton") → ClientReference.

When controller_path is provided (e.g. "posts"), the resolver first looks for a co-located key ("posts/_like_button"). If found, it returns that reference; otherwise it falls back to the shared PascalCase key.

Returns the same object for repeated calls with the same resolved key (needed for dedup by object_id in Flight::Serializer).

Raises Ruact::ManifestError when the resolved name is not found. The error message includes a Damerau-Levenshtein closest-match suggestion (Story 7.4) when a manifest entry within distance 2 exists, or a file-path hint suggesting where to add the missing component otherwise. When controller_path is given the closest-match scan biases toward co-located keys so a typo inside posts/show.html.erb surfaces the posts/_like_button suggestion before the shared LikeButton entry.



72
73
74
75
76
77
78
79
80
81
# File 'lib/ruact/client_manifest.rb', line 72

def reference_for(name, controller_path: nil)
  @reference_cache ||= {}
  key = resolve_key(name, controller_path)
  @reference_cache[key] ||= begin
    entry = entries_by_name[key]
    raise ManifestError, build_unknown_component_message(name, controller_path) unless entry

    Flight::ClientReference.new(module_id: entry["id"], export_name: entry["name"])
  end
end

#resolve(module_id, _export_name) ⇒ Object

Used by Flight::Serializer to produce I rows. Returns the metadata array the client expects: [id, name, chunks]



43
44
45
46
47
48
# File 'lib/ruact/client_manifest.rb', line 43

def resolve(module_id, _export_name)
  entry = by_module_id(module_id)
  raise "ClientManifest: no entry for module_id=#{module_id.inspect}" unless entry

  [entry["id"], entry["name"], entry["chunks"]]
end