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

FormExampleCompiles to
Text binding{input.name}A value from the input, escaped as text
Attribute bindinghref={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===, !==, <, >, <=, >=, !, &&, ||, .lengthA predicate over the input
Loop{input.items.map((item, i) => <Row />)}An each over the list
Filtered loop or countinput.steps.filter((step) => step.done).lengthAn 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
Formatterfmt.money(input.total, input.currency)A formatter call
Partial<OrderRow item={item} />The partial's body, inlined at compile time
Conditional classclassName={input.vip ? "bg-amber-100" : "bg-white"}An if on the attribute
Destructured parametersbody: ({ name, items }) => ...The same bindings
Arithmeticitem.price * item.qtyAn 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 as items.reduce(...), Date.now(), or Math.random().
  • Statements in a body (const, if, loops). A body is one expression.
  • Imports other than @samva/markup and 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 ?::

templates/guarded.tsx
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:

NodeMeaning
elAn element with static attributes and inline styles, Tailwind already resolved
bindA value from the input, escaped for its context
if / elseA predicate over the input
eachChildren repeated for each item in an input list
formatA 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

On this page