The static profile
The subset of TSX a template body may use, what each form compiles to, and what is rejected with its own diagnostic.
The static profile is the subset of TSX Samva reads without running it. It is the set of forms coding agents already write for React, so an agent needs no special instruction to stay inside it. Anything outside it is a compile error that names the fix.
Accepted forms
| Form | Example | Compiles to |
|---|---|---|
| Text binding | {input.name} | A value from the input, escaped as text |
| Attribute binding | href={input.trackingUrl} | A value from the input, escaped for the attribute |
| Template string | `Order ${input.orderId}` | Concatenation |
| Conditional | {input.trackingUrl && <Button ... />} | An if |
| Either/or | {input.vip ? <Gold /> : <Standard />} | An if with an else |
| Conditions | ===, !==, <, >, <=, >=, !, &&, ||, .length | A predicate over the input |
| Loop | {input.items.map((item, i) => <Row />)} | An each over the list |
| Filtered loop or count | input.steps.filter((step) => step.done).length | An each with a where predicate, or a count |
| Grid | <Columns each={input.deals} per={2}>{(deal) => <Column />}</Columns> | An each in rows of per cells |
| Formatter | fmt.money(input.total, input.currency) | A formatter call |
| Partial | <OrderRow item={item} /> | The partial's body, inlined at compile time |
| Conditional class | className={input.vip ? "bg-amber-100" : "bg-white"} | An if on the attribute |
| Destructured parameters | body: ({ name, items }) => ... | The same bindings |
| Arithmetic | item.price * item.qty | An expression, allowed inside a formatter argument or a condition |
A .filter callback takes the item and one condition written in the same grammar as && and ?:.
It chains before .map(...) or .length. A <Columns each per> lays a list out in rows of per
cells, or one row without per. Its child is a function from an item to a Column. A Column
with no width takes an equal share of the row, and a last row with fewer items keeps that share
instead of stretching.
Rejected forms
Each of these is a diagnostic with its own code:
- Calls other than
fmt.*and.map, such asitems.reduce(...),Date.now(), orMath.random(). - Statements in a body (
const,if, loops). A body is one expression. - Imports other than
@samva/markupand files inside the project. - Hooks, state, effects, event handlers, and
dangerouslySetInnerHTML. - Class names built at run time, such as
"text-" + size, because Tailwind cannot see them. Choose between literal classes instead:input.vip ? "bg-amber-100" : "bg-white". - A partial that renders itself.
There is no escape hatch. Logic the profile cannot express is computed by the caller and sent in the
payload. In the order example, total is one such
field.
Optional fields must be guarded
Reading an optional field outside a guard is an error, because a send that omits it would render a
blank. Guard it with && or ?::
import { defineTemplate } from "@samva/markup";
import { Email, Section } from "@samva/markup/email/components";
import { jsonSchema } from "@samva/markup/input-schema";
export default defineTemplate({
id: "guarded",
schema: jsonSchema<{ name: string; note?: string }>({
type: "object",
properties: { name: { type: "string" }, note: { type: "string" } },
required: ["name"],
additionalProperties: false,
}),
fixtures: { default: { name: "Ada", note: "Thanks for waiting." }, bare: { name: "Ada" } },
email: {
subject: (input) => `Hello ${input.name}`,
body: (input) => (
<Email>
<Section>
<p>Hi {input.name}</p>
{input.note ? <p>{input.note}</p> : <p>We will be in touch.</p>}
</Section>
</Email>
),
},
});Writing <p>{input.note}</p> outside the guard fails with unguarded-optional.
The IR
Compiling produces the template's IR, a versioned JSON tree. Its node kinds are:
| Node | Meaning |
|---|---|
el | An element with static attributes and inline styles, Tailwind already resolved |
bind | A value from the input, escaped for its context |
if / else | A predicate over the input |
each | Children repeated for each item in an input list |
format | A formatter call with its arguments |
A publication pins the IR it was built with, so publishing again is the only way an edit reaches a send. Rendering is a pure function of the IR and the input, so a preview, a snapshot, and a delivered message of the same publication agree.
Related documentation
Authoring in TSX
Create a template project, define a template with a schema and fixtures, preview and check it locally, then publish an exact commit.
Template inputs
Declare a template's input schema and fixtures, bind fields in the body, format values with fmt, and choose the locale and time zone per send.