Extending Components
Building Components From Your Own Data
This is the guide for consuming engines and apps — code that mounts the
component library and wants to render its components from its own data, rather
than from a BOP-fetched deserializer. (To add a new component to the library
itself, see 05_adding_components.md.)
The data shape
Every component declares its data shape once, with data_attributes. That macro
generates two things:
- the
delegatelist the template reads from, and - a nested immutable value record,
Component::Data(a RubyData), reachable asComponent.data.
In production the component is handed a BOP deserializer. Downstream you build a
Component::Data record instead. The component can't tell them apart — both
satisfy the same duck-typed shape — so anything the library renders, you can
render from your own data.
Recommended: .with(...).new(...)
Use the .with builder. It builds the data record and wires it into the
component for you:
GllComponentLibrary::Patterns::TextCardComponent .with(title: category.title, body: category.description, clickable: category_button(category)) .new.with(**attrs) builds the ::Data record and returns the constructed
component, injecting the record under the component's data kwarg — pattern: for
patterns, panel: for panels:
GllComponentLibrary::Patterns::TextCardComponent.with(title: "Hi", body: "...")# => TextCardComponent.new(pattern: TextCardComponent::Data.new(title: "Hi", body: "..."))It works the same for panels, injecting panel::
GllComponentLibrary::Panels::HeroComponent.with(title: "Welcome", subtitle: "...")Partial construction
Set only the fields you need; the rest default to nil. There's no need to pass
every attribute.
GllComponentLibrary::Patterns::TextCardComponent.with(title: "Just a title")Longhand equivalent
.with(...) is sugar for building the record by hand. These are identical:
# with the builderTextCardComponent.with(title:, body:)# longhandTextCardComponent.new(pattern: TextCardComponent.data.new(title:, body:))Reach for the longhand only when you also need to pass a constructor option
(e.g. additional_class:) alongside the inline data — .with covers the data,
the longhand lets you add the rest:
TextCardComponent.new( pattern: TextCardComponent.data.new(title:, body:), additional_class: "featured")Clickables: use the component value shapes
A deserializer instance means exactly one thing — a record fetched from
central. Never inline-build a deserializer as a value object; build the
component's own ::Data shape instead. Buttons and links have dedicated ones,
dispatched by their declared clickable_type:
GllComponentLibrary::Components::ButtonComponent::Data.new(title: "Join", href: "/join")GllComponentLibrary::Components::HyperLinkComponent::Data.new(href: "/categories/1", title: "Billing")Components without .with
A couple of components (Patterns::ImageComponent, Patterns::VideoComponent)
wrap their own input under a non-standard name — they declare
data_attributes ..., to: :image / to: :video and set no data_source. They
are normally rendered through a parent's asset slot, so .with is deliberately
unavailable and raises NotImplementedError. Build their data with .data.new
and pass it as pattern: directly:
GllComponentLibrary::Patterns::VideoComponent.new( pattern: GllComponentLibrary::Patterns::VideoComponent.data.new( autoplay?: true, loop?: true, desktop_video_url: source.desktop_video_url ))Breadcrumbs from an engine
Engine documents subclass Documents::EngineComponent — full chrome, no
Contentful page. To give an engine page a breadcrumb trail, pass it as data;
don't build a Panels::BreadcrumbsComponent panel yourself:
render_page Documents::MyArticleComponent.new(breadcrumbs: [ GllComponentLibrary::Components::HyperLinkComponent::Data.new( href: "/categories/#{category.id}", title: category.title ), GllComponentLibrary::Components::HyperLinkComponent::Data.new( href: "/articles/#{article.id}", title: article.title )]), panels: [ ... ]One trail buys two renderings: the visible breadcrumb strip above your panels
AND the BreadcrumbList JSON-LD in the document head. A trail of one (just
the current page) renders nothing; there is no minimum-length check to write
in the engine.
Anchoring the trail to the page the visitor came from
Rendering any Contentful page remembers its path in the session
(ApplicationController#remember_page_visit — recorded where it is known, a
genuine page render, never inferred from referrers). Engine controllers
inherit last_visited_page, which resolves that memory back to a page (with its
ancestors) or nil. Feed it to the trail and an engine page entered from a
magic panel reads Home > gateway page > your crumbs:
breadcrumbs: GllComponentLibrary::BreadcrumbTrail.to(last_visited_page, tail: crumbs)Pass nil instead of last_visited_page to opt out (plain Home > crumbs).
Crawlers carry no session, so the JSON-LD they see is always the stable short
trail.
The gateway also carries its panels, so an engine can gate trail anchoring on the magic panel the visitor clicked through:
last_visited_page&.magic_panel_of(config_type: :faq_panel)Which mount an href resolves against
Paths from central and paths written by engines both look absolute but are
relative to different mounts. Href carries that fact on the value — tagged
once where the data is born, resolved once at render time, identically for the
visible links and the schema item URLs:
| Your href is a… | Write | Resolves against |
|---|---|---|
bare hand-written path ("/categories/#{id}") |
the string (default) | the serving engine's mount |
| BOP page / article record's href | nothing (tagged :pages by the deserializer) |
the content-page tree's mount |
Rails route helper (category_path(c)) |
Href.resolved(category_path(c)) |
rendered untouched |
Route helpers bake the engine's mount prefix in at generation time, so
Href.resolved stops the join from doubling it (/faqs/faqs/...):
GllComponentLibrary::Components::HyperLinkComponent::Data.new( href: GllComponentLibrary::Href.resolved(category_path(category)), title: category.title)ListComponent::Item and ButtonComponent::Data accept the same — a bare
string resolves against your engine's mount, an Href is honoured as tagged.
Central's content needs nothing: the deserializers tag :pages for you,
including hrefs nested inside embedded hyper_link attribute objects.
Why this way
- One source of truth —
data_attributesnames the shape once; you never hand-roll a struct to feed a component. - Interchangeable — inline
::Datarecords and BOP deserializers are the same duck type, so the component behaves identically whatever the source. - Only set what matters — partial construction keeps call sites to the fields you actually have.