Conditionals

Branch at send time with SML If and Else primitives, predicate grammar, missing-variable semantics, and compiled markers.

SML has one control-flow primitive: <If>, with an optional <Else>. It selects between two branches of markup per recipient, at send time, not at compile time. This is the only conditional logic SML has; there's no loop, no arbitrary expression, and no per-item repetition (that's <For>, deferred past v1).

<Else> is a child of <If>, not a sibling, and it must come last:

<If test="plan == 'pro' and seats > 5">
  <Text>Your team plan covers {{seats}} seats.</Text>
  <Else>
    <Text>Thanks for being a pro customer, {{firstName}}.</Text>
  </Else>
</If>

Content model

<If> accepts the same block children as <Container>/<Section> (<Row>, <Heading>, <Text>, <Button>, <Image>, <Divider>, <Spacer>, and nested <If>), plus at most one <Else>, and if present, <Else> must be the last child:

// Compile error (else-not-last): <Else> must be the last child of <If>
<If test="plan == 'pro'">
  <Else><Text>Free plan</Text></Else>
  <Text>Pro plan</Text>
</If>
// Compile error (duplicate-else): only one <Else> is allowed inside <If>
<If test="plan == 'pro'">
  <Text>Pro plan</Text>
  <Else><Text>A</Text></Else>
  <Else><Text>B</Text></Else>
</If>

<Else> itself takes the same block children as <If> (minus another <Else>. there's no else if; nest a second <If> inside <Else> for that):

<If test="plan == 'pro'">
  <Text>Pro plan.</Text>
  <Else>
    <If test="plan == 'trial'">
      <Text>Trial plan.</Text>
      <Else>
        <Text>Free plan.</Text>
      </Else>
    </If>
  </Else>
</If>

<If> nests freely inside itself and inside its own <Else>, the compiler keys each <If>/<Else> pair by the <If> node's own id, so nesting doesn't need depth counting.

The predicate grammar

The test attribute is a closed grammar, not a JavaScript expression, not a subset of one. It's parsed and evaluated by one shared module (packages/markup/src/predicate.ts) used identically by the compiler (artifact markers), the checks engine, send-time branch selection, and the canvas's branch preview, so the semantics can't fork between them.

expr       := or
or         := and ("or" and)*
and        := unary ("and" unary)*
unary      := "not"? primary
primary    := "(" expr ")" | comparison | name
comparison := name op literal          op ∈ { == != > >= < <= }
literal    := 'single-quoted string' | number
name       := [A-Za-z_]\w*

Concretely:

  • Names are flat identifiers matching [A-Za-z_]\w*, the same variable names {{}} interpolation uses. No paths: user.plan == 'pro' is a compile error (predicate-path-not-supported), predicates only ever look up a top-level key.
  • String literals are single-quoted ('pro', not "pro") and have no escape syntax, there's no way to put a literal ' inside one. They also can't contain --, because the compiler embeds predicates verbatim inside HTML comment markers in the artifact, and -- would terminate the comment early (predicate-string-double-dash).
  • Number literals are optionally negative, optionally decimal (5, -1, 9.99), no scientific notation, no arithmetic.
  • Comparison operators: == != > >= < <=. There's no ===/!==, SML has one equality operator per direction.
  • Boolean composition: and, or, not, and parenthesized groups, with the usual precedence (not binds tightest, then and, then or). All three are reserved words, not, and, and or can't be used as variable names in a predicate.
  • Nothing else: no arithmetic (seats + 1 > 5), no function calls, no method calls, no member access, no ternary. If you need more than a boolean combination of comparisons, compute the derived value server-side and pass it as its own variable.

A bare name with no comparison, test="renewalSoon", means present and non-empty, the same "truthy" semantics as everywhere else in SML.

<If test="renewalSoon">
  <Text class="text-sm text-gray-500">Your renewal is coming up.</Text>
</If>

The parser canonicalizes whatever you write into a normalized form at parse time, consistent single-space spacing around operators/keywords, and only the parentheses the precedence actually requires. test="plan=='pro'and seats>5" and test=" plan == 'pro' and seats > 5 " both print back as plan == 'pro' and seats > 5. This means print ∘ parse is a fixpoint, which is what makes the editor's canvas ⇄ source round-trip lossless.

What compiles vs. what doesn't

Valid test values, each compiles cleanly:

testMeaning
"plan"plan is present and non-empty
"plan == 'pro'"string equality
"seats >= 5"numeric comparison
"plan == 'pro' and seats > 5"conjunction
"plan == 'pro' or plan == 'trial'"disjunction
"not trial"negation
"(plan == 'pro' or plan == 'trial') and not expired"grouped, mixed

Invalid test values and the diagnostic each one raises:

testDiagnostic
"user.plan == 'pro'"predicate-path-not-supported, no paths, flat names only
"plan == \"pro\""predicate-unexpected-character, double-quoted strings aren't part of the grammar
"seats + 1 > 5"predicate-unexpected-character, + isn't part of the grammar; no arithmetic
"plan.toUpperCase() == 'PRO'"predicate-path-not-supported, no method calls, no paths
""predicate-empty, write e.g. plan == 'pro'

See the error catalog for the full set of predicate diagnostics, each with a span and a fix.

Send-time, not build-time

This is the one thing to get right about <If>: it is not evaluated when the template compiles. Compiling a template with an <If> produces one artifact that contains both branches, delimited by HTML comment markers keyed by the node's id:

<!--sml:if:n4 plan == 'pro' and seats > 5--> ...branch A html... <!--sml:else:n4--> ...branch B html... <!--sml:endif:n4-->

(the <!--sml:else:n4--> segment is omitted when there's no <Else>.) The exact same markers appear in the generated plain-text alternative, so branch selection runs identically on both artifacts. This is why the artifact stays content-addressed and cacheable across every recipient of a send, compiling doesn't need to know who's receiving the email, so one compile serves the whole send.

Branch selection happens downstream, per recipient, in the send path, the same place {{}} interpolation happens, and it runs before interpolation substitutes values in. The selected branch's markup is spliced in, the other branch and all markers are stripped, and then {{}}/${} placeholders are replaced with that recipient's values. Sent mail never contains sml:if markers, if you ever see one in a delivered email, that's a pipeline bug, not expected behavior.

Practically, this means:

  • Both branches must independently be valid SML, the compiler validates and compiles both, since either one might ship to a real recipient.
  • Checks run against both branches. In particular, the Gmail-clipping byte check (the ~102KB truncation point Gmail imposes on the visible message) uses the worst-case branch combination across every <If> in the document, not whichever branch you happen to be previewing.
  • There's no partial-evaluation or dead-code elimination: if a predicate can never be true given the declared variable schema, that's a checks warning, not a compile-time constant fold. The compiler doesn't know your data.

Missing-variable semantics

Predicates never throw or error at send time over missing data, a variable that isn't present in the recipient's payload just makes the relevant part of the expression evaluate to false:

  • A bare name (test="renewalSoon") is false when the variable is missing, null, or an empty string, same "present and non-empty" rule as always.
  • A comparison (test="plan == 'pro'", test="plan != 'pro'") is false when the variable is missing or null, for both == and !=. Absence isn't a value that differs from the literal, it's no value at all, so neither direction of equality holds.
  • A numeric comparison against a non-numeric value (test="seats > 5" where seats is "many") is also false, a type mismatch, not an error.

There's no "unknown"/"undefined" third state visible to the predicate, every leaf collapses to a boolean, so <If>/<Else> always selects exactly one branch. If a template depends on a variable that a particular send doesn't supply, the practical effect is that the <Else> branch (or nothing, if there's no <Else>) renders for that recipient, worth testing explicitly with the test-send form, which surfaces every variable the predicate references the same way {{}} interpolation does.

Artifact markers, at a high level

You don't normally see sml:if markers, they live entirely inside the compiled HTML/plain-text artifacts, between compile and send. They matter if you're debugging a stored artifact or building tooling against one:

  • Markers are keyed by the <If> node's own id (n4 in the example above), assigned deterministically at parse time in document order, the same id scheme the editor uses for DOM ⇄ AST mapping.
  • Because markers key on id rather than nesting depth, nested <If>s resolve by exact-id match; there's no stack-based depth tracking required to find the matching endif.
  • Marker resolution trims a resolved branch's edge whitespace (branch boundaries are always block-level, so this is safe) and collapses runs of 3+ blank lines left behind by dropped branches down to one.
  • An artifact with sml:if markers still present has not gone through send-time resolution, that's the signal a caller can check for.
  • Primitives, <If>/<Else> attribute and content-model reference alongside every other tag.
  • Variables, the {{name}}/${name} interpolation that runs after branch selection, and how variable references (including ones inside test) are collected for the editor's variables rail.
  • Error catalog, every predicate parse diagnostic, with an example and a fix.

On this page