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 (notbinds tightest, thenand, thenor). All three are reserved words,not,and, andorcan'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:
test | Meaning |
|---|---|
"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:
test | Diagnostic |
|---|---|
"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") isfalsewhen 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'") isfalsewhen the variable is missing ornull, 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"whereseatsis"many") is alsofalse, 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 (n4in 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 matchingendif. - 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:ifmarkers still present has not gone through send-time resolution, that's the signal a caller can check for.
Related
- 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 insidetest) are collected for the editor's variables rail. - Error catalog, every predicate parse diagnostic, with an example and a fix.