Components
Introduction
A ReeTag is a server-side component invoked with the same hyphenated-name syntax the browser uses for custom web components - think of them as Server Side Custom Elements. A ReeTag is just a .ree file in the components/ directory: no class, no lifecycle, no registration step.
The engine resolves tags at render time on the server. If a matching .ree file exists, the tag is replaced with the rendered component output before the response leaves the server. If no matching file exists, the tag passes through unchanged to the browser - where a real JavaScript web component can pick it up. The two forms share the same hyphenated-name convention without ever colliding.
<site-nav></site-nav> <field-wrapper class="grid">…</field-wrapper>
Per-call data goes on attributes (which can be interpolated) and in the slot.
ReeTags
Any tag whose name matches a file in components/ is rendered by including that file. <my-card> resolves to components/my-card.ree. If no matching file exists, the tag is passed through as a literal HTML element, so ReeTags never collide with real HTML.
A ReeTag compiles to an include that hands the component three things:
- The full parent
props- everything the surrounding template can see (including globals likelang,localized_path,md,tw_merge) is in scope inside the component. props.children- the slot content between the opening and closing tags.props.attributes- the tag's attributes, as a plain object.
<!-- components/info-box.ree -->
<aside class="info-box {= props.attributes.tone }">{~ props.children }</aside>
<!-- usage -->
<info-box tone="warning"> {~ props.notice_html } </info-box>
Attributes can be static or interpolated
An attribute value is either a literal string or an interpolated expression:
- Static:
tone="warning"passes the string"warning". - Interpolated:
tone="{= props.level }"- the engine strips the{= }and evaluatesprops.levelwhere the tag sits, so the component receives the real value, not the literal text. Use{~ … }for raw (non-stringified) values.
Both arrive under props.attributes inside the component (props.attributes.tone). Because the tag is expanded in place, an interpolated attribute can read a surrounding {#each} loop variable - this is how you hand per-item data to a component:
{#each props.rows as row }
<field-cell label="{= row.label }" value="{= row.value }"></field-cell>
{/each}
Slot scope: slot content (between the tags) compiles in an isolated scope that sees only
propsand globals - not page-localconsts or{#each}loop variables. When a component needs per-iteration data, pass it through interpolated attributes (above) rather than the slot.
When to Use {#include} Instead
A ReeTag resolves to an include from components/. When the target lives outside components/ - a partial inside a route folder, or a subdirectory - use {#include} directly, which takes an explicit path and an optional data object merged on top of the parent's props:
{#include('$routes/dashboard/_partials/summary', { totals: props.totals }) }
The merge is Object.assign({}, parent_props, your_obj), so you can override anything the parent had and you don't have to forward props you didn't change.
Writing a Component
A component reads from props.attributes (per-call config) and props.children (slot), plus any inherited parent props it needs. The shipped app-banner is a good model - type comes in as an attribute, the message as the slot:
<!-- components/app-banner.ree -->
{{ const tone = props.attributes.type || "neutral"; }}
<div class="banner banner-{= tone }">{~ props.children }</div>
<!-- usage -->
<app-banner type="red">{= props.form_error }</app-banner>
Note that a component body is itself built from ReeTags - <field-wrapper> and <validation-error> are components used as plain tags. To write your own, drop a file into components/ and reference it by its base name (kebab-case is the convention - field-wrapper, auto-complete, app-banner). There is no file to register it in.
Forms Inline Their Fields
Generated CRUD forms do not call per-field input components. The generator inlines the field markup - <field-wrapper>, <label>, <input>, <validation-error> - directly into form.ree, with the field name baked in. The <validation-error> gets the id error-{name}, which is the convention FormController uses to find it and write the per-field error into it (see Validation):
<field-wrapper class="grid">
<label class="px-3" for="name">{_ labels.name } *</label>
<input type="text" id="name" name="name" value="{= props.name }" required />
<validation-error class="mt-1" id="error-name"></validation-error>
</field-wrapper>
This is why the input markup reads the value directly off props (props.name) and the label from translations ({_ labels.name }) - it lives inline in the page template, not inside a separate component scope.
Composing Components
Components nest freely - a component's body can use other components as ReeTags, passing data through interpolated attributes:
<!-- components/card.ree -->
<article class="card">
<header>
<user-avatar></user-avatar>
<!-- reads props.user, inherited -->
<h3>{= props.attributes.title }</h3>
</header>
{~ props.children }
</article>
<!-- usage: title via attribute, body via slot -->
<card-box title="{= props.headline }"> {~ props.announcement_html } </card-box>
The same merging rules apply at every level - the inner component sees the parent's props plus its own attributes and children.