Meridian handbook
Templates

Components

How to write, call and override a template component with a declared contract of props, slots and attributes that engine-dmo check enforces at every call site.

A component is a reusable piece of markup with a contract. The contract, component.yaml, declares the props the component takes, the slots a caller may fill and whether the caller may add attributes. engine-dmo check holds every call to the contract, on every site, and the engine holds it again at render, so a wrong prop is an error with a file, line and column rather than a page that renders wrong.

Use a component for anything you would otherwise copy between templates: a card, a page header, a notice, a pager, an icon. Use {% include %} only for a fragment of one layout that needs the caller’s whole context.

A first component

Scaffold it. engine-dmo new component writes the contract, the markup and a co-located stylesheet into the shared template root:

engine-dmo new component teaser
created templates/_shared/components/teaser/component.yaml
created templates/_shared/components/teaser/teaser.html
created templates/_shared/components/teaser/teaser.css

Add --site <key> to write it into one site’s own root instead, and --force to replace files that are already there.

Declare its inputs in component.yaml:

# templates/_shared/components/teaser/component.yaml
name: teaser
description: A news story as a card for a grid, its title the one link.
props:
  doc:     { type: article, required: true, description: "The story the card shows" }
  heading: { type: enum, values: [h2, h3], default: h3, description: "The title's level" }
  tone:    { type: enum, values: [plain, feature], default: plain }
  note:    { type: string, description: "A line in place of the summary" }
slots:
  meta: { required: false, description: "Below the title, in place of the date" }
attrs: root

Write the markup. Every declared prop is a variable, slots holds the filled slots, and attrs holds the caller’s attributes:

{# templates/_shared/components/teaser/teaser.html #}
<article class="teaser teaser--{{ tone }}" {{ attrs }}>
  {% if doc.heroImage %}
    {{ doc.heroImage.img("card", class="teaser__img") }}
    {% if doc.heroImage.credit %}<p class="teaser__credit">{{ doc.heroImage.credit }}</p>{% endif %}
  {% endif %}
  <{{ heading }} class="teaser__title"><a href="{{ doc.uri|url }}">{{ doc.title }}</a></{{ heading }}>
  {% if slots.meta %}<p class="teaser__meta">{% slot 'meta' %}{% endslot %}</p>{% endif %}
  {% if note %}<p>{{ note }}</p>{% elif doc.summary %}<p>{{ doc.summary }}</p>{% endif %}
</article>

Call it from any template on the site:

{% for article in items %}
  {% component 'teaser' doc=article heading='h2' %}
{% endfor %}

{% component 'teaser' doc=entry tone='feature' %}
  {% slot 'meta' %}<time datetime="{{ entry.publishedAt|iso }}">{{ entry.publishedAt|date:"medium" }}</time>{% endslot %}
{% endcomponent %}

Run engine-dmo check. It validates the contract, the markup and every call.

Where a component lives

A component is a directory under components/ in a template root:

component.yaml
teaser.html
teaser.css
FileRequiredPurpose
components/<name>/component.yamlYes, somewhere on the site’s chainThe contract
components/<name>/<name>.htmlYesThe markup; its file name repeats the directory name
components/<name>/<name>.css, <name>.jsNoCo-located styles and script, collected into each site’s bundle by the Vite plugin (Frontend build)

The name is the directory name: lower case letters, digits, - and _. Both files resolve along the site’s template chain, so a site root can override a shared component (Overriding a component).

The contract

component.yaml is decoded strictly: an unknown key is an error, so a misspelt requried cannot quietly make a prop optional.

KeyTypeDefaultMeaning
namestringthe directory nameMust equal the directory name when given
descriptionstringnoneOne sentence; shown on hover in the language server and in the generated template API
propsmap of name to propnoneThe inputs a call passes as name=value
slotsmap of name to slotnoneThe regions of markup a call may fill
attrsroot, none or namednoneWhether a call may pass attrs=

Props

KeyTypeDefaultMeaning
typestringnone (required)One of the prop types below
requiredbooleanfalseA call without it is an error
defaultanynoneThe value when a call leaves the prop out; must suit the type
valueslistnoneThe accepted values; only and always for type: enum
descriptionstringnoneShown in hints, hover and the template API

required and default are exclusive. attrs and slots are reserved and cannot be prop names.

Prop typeAccepts
stringA string, or any value that prints as one
numberAn integer or a decimal
booleantrue or false
enumOne of values, compared as text
listA list
mapA map, or an engine value with properties (an entry, an image)
htmlMarkup. Only markup the engine produced (such as ptext output) prints unescaped; a plain string is escaped where it is printed
attrsAn attribute string the engine built, or a map
documentAny document (an entry, a relation’s target, a list item)
anyAnything
a type key of your project, such as article or listingA document of that type; a document of another type is an error

A prop the call leaves out takes its default, or is empty when it has none, so the markup can test any declared prop with {% if note %} without an error.

Slots

KeyTypeDefaultMeaning
requiredbooleanfalseA call that does not fill it is an error
descriptionstringnoneShown in hints and the template API

attrs

ValueMeaning
noneThe default. A call that passes attrs= is an error, since the attributes would be dropped
rootA call may pass attrs=. The markup must print {{ attrs }}, normally on its root element; engine-dmo check reports a component that declares root and never prints it
namedA call may pass attrs=, and the markup decides where to print them. Nothing checks where

Calling a component

{% component 'name' prop=expression prop2=expression %}
  • The name is a quoted string literal, so engine-dmo check can find the contract. A variable name is a parse error.
  • Props are name=expression pairs separated by spaces. A prop passed twice is an error.
  • attrs= is not a prop. It passes the call site’s attributes: a value an engine helper built, such as sigmx.fragment(...), or a map. The template language has no map literal, so in practice attrs= takes a helper’s value:
{% component 'teaser' doc=article attrs=sigmx.fragment('results', on='click') %}

A map passed as attrs prints as escaped name="value" pairs in key order; true prints the bare name, and false or an empty value leaves the attribute out.

A call with slots

A call has a body, closed by {% endcomponent %}, exactly when the first thing after the tag (whitespace aside) is a {% slot %}:

{% component 'notice' tone='warning' %}
  {% slot 'body' %}<p>{{ t("closures.notice") }}</p>{% endslot %}
{% endcomponent %}
  • Every region of the body is a named slot. Text or markup outside a slot is an error, both at check and at render. There is no unnamed default slot.
  • A slot filled twice in one call is an error.
  • A slot’s content renders in the caller’s scope, so it can use the caller’s loop variables and {% set %} names.

Inside the markup

The markup sees:

  • the page’s globals (site, locale, page, t(), path(), url(), assets, images and the rest of the globals);
  • each declared prop: the value passed, else its default, else empty;
  • attrs, the call’s attributes, ready to print;
  • slots, the filled slots by name.

It never sees the caller’s own variables (a loop variable, a {% set %}), so a component cannot come to depend on the page that happens to call it. Pass what it needs as a prop.

Print a slot with a fallback, or test whether it was filled:

{% slot 'meta' %}<span>{{ t("teaser.no_date") }}</span>{% endslot %}
{% if slots.meta %}<p class="teaser__meta">{% slot 'meta' %}{% endslot %}</p>{% endif %}

{% slot 'name' %}fallback{% endslot %} in the markup prints what the call filled, or the fallback when it filled nothing. A {% slot %} the markup prints must be declared in the contract.

Components can call other components.

What is checked

engine-dmo check (and the language server as you type) reports each of these at its line and column, every site along its own chain. The engine refuses the same mistakes at render, so a value the check could not see (a prop computed from a variable) still fails loudly.

MistakeExample message
Unknown propteaser has no prop "colour", with the declared props or a “did you mean”
Missing required proprequired prop doc is missing from the call to teaser
Enum value not in valuesprop heading of teaser: "h4" is not one of h2, h3
Literal of the wrong typeprop size of icon: a string was passed where a number is expected
Document of the wrong typeprop doc of teaser is a article, and a event is passed
Unknown slotunknown slot "footer", with the declared slots
Required slot left emptyrequired slot body is not filled. The check reports it for a call with a body; a call with no body at all is refused at render only
Content outside a slotcomponent notice has content outside a {% slot %} in its call
attrs= on an attrs: none componentattrs passed to teaser, whose contract says attrs: none, so they would be dropped
attrs: root and no {{ attrs }} in the markupcomponent teaser declares attrs: root and its markup never prints {{ attrs }}
A slot printed in the markup and not declaredslot "summary" is not declared in this component's contract
A variable in the markup that is neither a prop nor a global"tone" is not in the template context
A contract key that does not exist, a bad prop type, an enum without values, required with a default, a default of the wrong typeReported at the key in component.yaml
A directory with no contract, or a contract with no markupReported at the directory

All of these are errors (MRD-1006, invalid configuration), and engine-dmo check exits 3.

What is not checked

A prop typed with one of your types (doc: { type: article }) is held to that type at the call, but fields read on it inside the markup are not. {{ doc.photo }} on a type with no photo field passes engine-dmo check and prints nothing at render, with no warning, even with template.strict: true. The same holds for a field read on a loop variable or a query() result. Take field names from engine-dmo describe type <type>, and read the rendered page (engine-dmo render <path>) to see each one print.

Dates have no properties: write {{ entry.publishedAt|iso }} and {{ entry.publishedAt|date:"medium" }}. A property such as entry.publishedAt.iso is not reported and prints nothing.

Overriding a component on one site

A site whose template chain puts its own root before _shared can override a component by adding the same directory to its root (Multisite templates):

templates/
  _shared/components/teaser/component.yaml
  _shared/components/teaser/teaser.html
  town/components/teaser/teaser.html        the town's own markup
  • An override may carry markup alone. It then inherits the contract it overrides.
  • An override may carry its own component.yaml, which must take the same inputs: the same props with the same types, requiredness and enum values, the same slots with the same requiredness, and the same attrs. Descriptions and defaults may differ. Anything else is an error at the override’s contract (component teaser on site town overrides _shared/components/teaser/component.yaml and takes different inputs: ...). A component with different inputs needs a name of its own.
  • The override’s markup may extend the shared markup and replace blocks of it: {% extends "components/teaser/teaser.html" %} inside the override resolves to the next file of that name along the chain, never to itself.

Every call site, on every page and in every shared template, then renders the site’s version, unchanged.

Components and the frontend build

The co-located <name>.css and <name>.js files are not linked by the engine. With the meridian() Vite plugin, each site’s main bundle imports the CSS and JS of every component that resolves for that site, so a site’s override brings its own styles and not the shared ones. engine-dmo assets manifest lists which component directory each site resolves. See Frontend build.

Component styles are not scoped or class-prefixed by the build. Prefix your class names (the scaffold uses c-<name>).

Caching and components

A component inside a {% cache %} block registers the documents passed to it as props, so publishing one of them clears the block. See Caching.