Bars Specification 0.1.0 — Rendering¶
How the AST becomes output text. This page covers the core grammar (segments, blocks,
expressions/transforms, scoped variables, partials) plus two clearly-marked informative sections —
the built-in transform roster and the @env namespace — that are implementation-specific extensions
rather than part of the normative language; see Implementation-Defined
Behavior.
Segments¶
A template is a sequence of segments, each corresponding to one alternative of the segment rule in
the embedded grammar:
- Text — everything outside a recognized tag, emitted verbatim. This includes any line
terminators the text contains (LF, CRLF, or a lone CR): a conforming implementation emits them
byte-for-byte and must not canonicalize
\r\nto\nor vice versa. - Interpolation (
{{ exprChain }}) — evaluates the expression chain and HTML-escapes the rendered string before emitting it. - Unescaped interpolation (
{{{ exprChain }}}) — same evaluation, but emits the rendered string with no escaping. - Comment (
{{! ... }}) — its body is not parsed as an expression; it is discarded and produces no output. - Raw block (
{{{{ raw }}}}...{{{{/raw}}}}) — its body passes through completely unparsed (see theRAWlexer mode in the embedded grammar); it cannot itself contain four or more consecutive}characters. - Escaped delimiter (
\{{) — emits a literal{{. This is both an author-written escape and the form the pre-pass rewrites an unterminated opener into.
Escaping is a property of interpolation output only — a block tag ({{#if}}, {{#each}}, …) never
escapes anything itself; escaping applies to the text an interpolation inside the block ultimately
produces.
HTML escaping. Escaped interpolation replaces exactly these five characters; every other character (including all non-ASCII) passes through unchanged:
| Char | Entity |
|---|---|
& |
& |
< |
< |
> |
> |
" |
" |
' |
' |
The single quote uses the hexadecimal numeric character reference ', not the decimal '
and not the named ' (not valid in HTML 4 / older parsers). Escaping is byte-for-byte: a port
that emits a different spelling for the same character does not conform. Replacement is single-pass
over the input — the & a replacement introduces is never itself re-escaped.
An interpolated expression that resolves to nothing (an absent path, or a resolved null) renders
as the literal text "null" — see Data Model. This is distinct
from the falsy treatment the same absence receives inside {{#if}}/{{#unless}} (see
Expressions and transforms below).
Every tag — interpolation (escaped and unescaped), block open/close, {{else}}/{{else if}},
{{!}}, {{=}}, and {{>}} — accepts a ~ trim marker adjacent to either delimiter ({{~ expr }},
{{ expr ~}}, {{~ expr ~}}, and the corresponding forms on every other tag). ~ strips contiguous
Unicode whitespace from the surrounding template text on that side of the tag, matching
Handlebars/Mustache; it never touches an interpolated value, which renders exactly as computed. A
raw block ({{{{ ... }}}}) and an escaped delimiter (\{{) accept no trim marker at all — the grammar
has no trimmed form of either — and their content is real template text for every purpose below: a
tag that would otherwise be standalone on a line it shares with a raw block or an escaped delimiter is
not standalone, exactly as if it shared the line with any other non-whitespace text.
For a block, ~ on the opener's or closer's outer side (the opener's open side, the closer's close
side) strips template text outside the whole block; ~ on the opener's close side or the closer's
open side reaches into the block body, stripping the body's own leading/trailing whitespace. The
same inward/outward split applies to {{else}}/{{else if}}: ~ on either side of an else tag
reaches into the adjacent arm or else-body, stripping across that arm boundary.
Implicit standalone-line stripping¶
A block open/close, {{else}}/{{else if}}, {{!}}, {{=}}, or {{>}} tag whose source line
contains no other non-whitespace content has that line's leading indentation and trailing newline
removed automatically — no ~ required. This is evaluated per tag occurrence: a block's open tag and
close tag are each independently checked against their own line, so (for example) a {{#with}} sharing
its line with body text is not standalone even if the matching {{/with}} sits alone on its own line
and is stripped. Interpolations are exempt (the Mustache rule): only explicit ~ affects an
interpolation's surrounding whitespace, regardless of whether it sits alone on its line.
Standalone detection uses a narrower character set than explicit ~. A line's "indentation" (the
text between the previous newline and the tag) counts as blank only when it is entirely space and tab
— unlike ~, which strips any Unicode whitespace, standalone detection deliberately excludes
characters such as vertical tab or form feed, matching the character set the stripping itself removes.
The one exception is a carriage return immediately before the tag's own trailing newline: it is treated
as part of that \r\n sequence rather than as line content, so CRLF-terminated templates are standalone
exactly as LF-terminated ones are. A tag at the very start or end of a template (or of a partial's own
template) is standalone with respect to the missing side — an absent neighbor counts as blank, the same
as an empty run of text would; the same holds when a nested block's tag directly abuts an enclosing
block's tag, since the enclosing tag's own boundary check treats that side the same way.
That "absent neighbor" allowance is scoped to the edges just described — a template/partial boundary,
or a nested block directly abutting its parent's tag — and does not extend to a block's own empty
inner region. When a block body, an if/else if arm body, or an else body is empty, the block's own
paired tags (its open and close tags, an arm's tags, or an else tag and its neighbor) sit adjacent to
each other on the same line. That adjacency is non-whitespace content, not a blank neighbor, so neither
flanking tag is standalone on the side facing the empty region — distinct from a body of whitespace-only
text (e.g. {{#with y}}\n{{/with}}), whose open and close tags each sit on their own line and remain
standalone.
Standalone stripping removes two different things on two different sides, and both are needed to fully elide a standalone tag's line: the tag's own leading indentation is removed from the preceding text (trailing spaces/tabs only — the newline ending the previous line is left alone, since it is not part of the tag's own line), and the tag's own trailing newline is removed from the following text (through its first newline, inclusive). A consequence: a standalone tag with no following text at all — the last thing in a template — has its indentation removed but keeps the newline that ended the previous line, since there is no following text left to remove a newline from.
Explicit ~ and standalone stripping compose: the net effect on any one side of a tag is "strip if
~ or standalone" — an explicit ~ always produces at least as much stripping as standalone alone
would.
Trimming — both explicit ~ and implicit standalone stripping — is resolved once, as a fixed
transformation of the parsed template's Text segments, before rendering begins; the renderer itself
performs no trimming. A block's own internal boundaries (its body's leading/trailing edges, and any
{{else}}/{{else if}} arm boundaries) are resolved before the block's outer boundaries are checked
against its siblings, so a nested standalone tag is independent of its enclosing tag's own standalone
status. Because trimming is resolved once against the template's AST rather than per render, a
standalone {{#each}}'s open/close lines are stripped from the loop body a single time and that
stripped body is what every iteration renders — the loop does not re-evaluate standalone-ness per
element.
Standalone-partial re-indentation¶
When a {{> key }} tag is standalone (per the rule above) and its line carries leading indentation,
that indentation is captured and re-applied to every line of the partial's own rendered output
(including interior lines), rather than only appearing once before the partial's first line. A nested
partial that is itself standalone within its parent partial's body has its own re-indentation applied
first, so indentation composes outward through nested partial calls. A partial tag that is not
standalone — sharing its line with other template text — is inserted verbatim with no re-indentation.
Normative fixtures: conformance/grammar/interpolation-basic.json, interpolation-trim-variants.json,
block-trim-variants.json, unescaped-interpolation.json, comment.json, raw-block.json,
escaped-delimiter.json; conformance/render/html-escaping.json, raw-block-verbatim.json,
null-context.json, trim-rendering.json, standalone-stripping.json,
crlf-literal-text-preserved.json; conformance/partials/trim-rendering.json,
standalone-indent-rendering.json.
Blocks¶
A block tag pairs an open tag with a matching close tag and wraps a body of nested segments:
| Block | Behavior |
|---|---|
{{#if cond}} … {{else if cond2}} … {{else}} … {{/if}} |
Arms are tried top to bottom; the first arm whose expression is truthy renders and the rest are skipped. {{else}} (no expression) always matches if reached. With no matching arm and no {{else}}, the block renders nothing. |
{{#unless cond}} … {{else}} … {{/unless}} |
The inverse of {{#if}}: the body renders when the expression is falsy; {{else}} renders when it is truthy. Only a single, optional {{else}} is permitted — no else if chain. |
{{#each expr}} … {{else}} … {{/each}} |
Iterates a list (pushing @index, the 0-based position) or a struct (pushing @key, iterated in insertion order) one element at a time. {{else}} renders when the collection is empty, or when the expression resolves to neither a list nor a struct. |
{{#with expr}} … {{/with}} |
Pushes the expression's result as the new current context for the body, without iterating. Has no {{else}} arm. |
Every block push (an {{#each}} iteration or a {{#with}}) adds one frame to the context stack.
../ (prefixing an identifier, @index, or @key) walks back up that stack one level per ..,
before resolving the remainder against the ancestor frame — see Data
Model.
Normative fixtures: conformance/render/if-block.json, unless-block.json, each-block.json,
each-index-key.json, with-block.json.
Expressions and transforms¶
An expression is either a single operand or exactly one binary comparison between two
operands — kBars does not chain multiple operators in a single expression without parentheses. An
operand is a primary value or a parenthesized expression chain (below). A primary is an
identifier (see Data Model), a string literal ("…" or '…',
with escapes \\, \", \', \n, \r, \t, \uXXXX), a number literal
(-?[0-9]+(\.[0-9]+)?), or true/false/null.
Binary operators¶
or and and (logical OR/AND, both sides coerced to truthy, case-insensitive), ==/!=
(equality/inequality), and </<=/>/>= (ordering). Whitespace is required around a binary
operator: {{ a==b }} is invalid, {{ a == b }} is valid — matching the whitespace requirement
around the transform pipe.
Parenthesized operands¶
An operand may be a parenthesized expression chain instead of a bare primary, letting a single
expression combine more than one operator by grouping: {{#if a and (b or c) }},
{{#if (a or b) and (c or d) }}, {{#if a and (b or (c and d)) }}. Whitespace immediately inside
the parentheses is optional ((b or c) and ( b or c ) both parse), and the parenthesized chain may
carry its own transforms ((items | size) > 0). This is grouping only — kBars still does not chain
operators without parentheses, so {{ a and b and c }} remains a parse error regardless of grouping
used elsewhere in the same template.
Equality dispatches by operand type, checking each rule in order until one applies: (1) if either
operand is a boolean primitive, both sides coerce to truthy and compare as booleans; (2) otherwise,
if either operand is a string primitive, both sides render to string and compare as strings; (3)
otherwise, if both operands are numeric, compare as numbers; (4) otherwise, structural equality —
structs are equal when they share the same keys and every value is recursively equal, lists are equal
when they share the same size and every element is recursively equal in order, and anything else
falls back to typed-accessor equality. != is the negation of ==.
Ordering compares as strings (lexicographically) if either operand is a string primitive, as
numbers if both are numeric, and otherwise is a soft failure — code KB-5004
(ComparisonMismatch, see Errors) — evaluating to false.
Truthiness¶
{{#if}}, {{#unless}}, and and/or all coerce their operand using the same rule. A value is
falsy when it is null (an absent context, or a resolved null), a numeric primitive equal to
0.0 (covers 0, 0L, 0.0, -0; NaN is truthy), a boolean primitive false, a string primitive
equal to "", or an empty list. Everything else — including an empty struct {} — is truthy.
Transforms¶
Zero or more pipe-separated transforms may follow an expression, applied left to right:
{{ name | upcase }}, {{ value | upcase | downcase }}. The pipe is the lowest-precedence operator
in the whole expression chain: in {{ a == b | upcase }}, the transform applies to the boolean
result of the comparison, not to b alone. Whitespace around | is required.
A transform name may be followed by : and one or more space-delimited arguments (whitespace after
: is required). Each argument is a literal, a plain identifier (resolved against the current
context), or a parenthesized expression chain ((title | upcase), evaluated in full including its
own transforms) — see the transformArg rule in the embedded grammar.
Whitespace immediately inside a parenthesized argument's parentheses is optional: (title | upcase)
and ( title | upcase ) both parse to the same argument.
Which named transforms exist, and their individual argument/return contracts, is an
implementation-specific extension — see Implementation-Defined
Behavior and Built-in transforms
(informative) below. A transform reference to an unregistered name
is a hard failure, code KB-2001 (UnknownTransform); a built-in transform called with the wrong
argument count is a hard failure, code KB-2002 (TransformArity).
Normative fixtures (core evaluation rules): conformance/render/comparison-operators.json,
equality-operators.json, logical-operators.json, literals-interpolation.json,
literals-blocks.json, parenthesized-logic.json; conformance/grammar/binary-operators.json,
chained-binary-operators-rejected.json, parenthesized-expressions.json,
transform-arguments.json, transform-chain.json, paren-whitespace.json.
Scoped variables¶
{{= target = exprChain }} evaluates exprChain once and binds the result to target, in one of
two namespaces. target must be a bare @env/name or @local/name — a sub-pointer target such as
@env/a/b is a parse/render-time error, code KB-3002 (AssignTargetSubPointer; see
Errors).
@env/namebinds in the render-global namespace. The binding survives{{#with}}/{{#each}}boundaries (so it can accumulate across a loop) and shadows a registered environment property of the same name for the rest of that render, without mutating the environment itself — the shadow is local to the single render call.@local/namebinds in the frame-scoped namespace, read the same way../walks: visible to the frame it was bound in and every ancestor frame, but discarded when its{{#with}}block or{{#each}}iteration ends. An unbound@localread renders empty.
Normative fixtures: conformance/env-properties/assign-local.json, assign-errors.json.
Partials¶
A partial renders a separately-loaded template in place of a {{> key }} tag, supplied by a
loader registered when the rendering environment is built. The loader is invoked lazily — only when
a key is first encountered — and the resolved template is cached on the environment, so the loader is
asked at most once per distinct key across every render that reuses that environment.
A partial key is either a static symbol written literally in the template ({{> memberInfo }})
or a parenthesized expression evaluated at render time and coerced to a string
({{> (which) }}). Whitespace immediately inside the parentheses is optional: {{> (which) }} and
{{> ( which ) }} both resolve the same key. By default a partial renders against the current
context; an optional context argument ({{> memberInfo spouse }}) pushes a new scope for the
partial, exactly like {{#with}}.
Failure matrix¶
| Situation | Result |
|---|---|
No loader registered, but a {{> … }} tag is present |
hard failure, KB-4002 (NoPartialLoader) |
Static symbol key the loader does not know (load returns null) |
hard failure, KB-4001 (UnknownPartial) |
| Dynamic key that resolves to an unknown key | soft failure — emits nothing, reports a soft-fail event |
| Recursion deeper than the configured maximum partial depth | soft failure, KB-5007 (RecursionLimit) — emits nothing, rendering continues |
The maximum recursion depth is configurable; its default value is an implementation-defined constant, not a normative number. Partial parameters (from Handlebars) are not part of Bars; partial blocks are — see Partial blocks below.
Runaway recursion — direct or mutual — is one of several hostile-input shapes a conforming implementation must degrade gracefully on rather than crash; see Adversarial inputs.
Inline partials¶
{{#inline name }} … {{/inline}} defines a partial body inline, under an unquoted identifier name
(no * prefix, unlike Handlebars' {{#*inline "name"}}). The block itself renders nothing; it only
registers name so a {{> name }} reference — static or dynamic — can invoke it.
An inline definition is block-scoped: it is visible for the remainder of the enclosing block
(the segment list the {{#inline}} tag appears in), including textually before its own definition
(the whole block is scanned for definitions before any segment renders, matching Handlebars'
decorator pre-pass), and to any partial invoked from within that block, since the definition lives on
the shared render context rather than being reset at partial boundaries. It goes out of scope once
rendering leaves the enclosing block.
If an inline definition and a loader-resolvable partial share a name, the inline definition shadows the loader — the loader is never consulted for that name while the inline definition is in scope. An inline partial resolves and renders even when no partial loader is registered at all.
Two {{#inline name }} blocks defining the same name in the same enclosing block resolve last-wins:
the later definition is used, and each render occurrence of the enclosing block reports one soft
failure, code KB-5008 (DuplicateDefinition), stamped at the overriding definition's location. A
block that renders N times (for example, inside {{#each}}) reports the event N times.
An inline partial's body is subject to the same recursion-depth guard as a loaded partial (see the
Failure matrix above): a self-referential inline partial soft-fails with
KB-5007 (RecursionLimit) once the configured maximum depth is reached.
Normative fixtures: conformance/partials/inline-partials.json, inline-duplicate-names.json,
inline-standalone-and-trim.json, torture-adversarial-recursion.json.
Partial blocks¶
{{#> key }} … {{/>}} (bare close) or {{#> key }} … {{/> key }} (named close) is a partial
block: key is resolved exactly like a bare {{> key }} reference — a static symbol, a
parenthesized dynamic expression, or either form followed by a context argument — but a resolution
miss renders the block's own body as fallback content instead of failing. This is the failover
half of Handlebars' partial blocks and only that half: Bars has no {{> @partial-block }} re-entry,
so an invoked partial cannot pull the fallback body back in.
Resolution order on the open key is: an in-scope inline definition, then the registered loader. Every
miss along that path — an unregistered static key, an unresolved dynamic key, or no loader registered
at all — renders the fallback, uniformly and without diagnostic: no hard failure (contrast KB-4001
and KB-4002 above, which a bare {{> key }} tag raises for the same misses) and no soft-fail event
(contrast the UnresolvedReference soft failure a bare dynamic-key miss reports). The fallback body
is itself the author's handling of the miss.
A hit renders exactly as the equivalent {{> key }} / {{> key ctx }} would: it is subject to the
same recursion-depth guard and gains a KbTemplateOrigin.Partial template-stack frame. The fallback,
by contrast, is inline template content — rendered in the current context with no depth-guard
increment and no template-stack frame — so a context argument on the open tag ({{#> key ctx }})
applies only to the resolved partial on a hit and never to the fallback.
The named close form's name must equal the open key's literal text, or parsing fails hard with
KB-4003 (PartialBlockCloseMismatch) — a template authoring error, knowable when the template is
written. The bare close form never mismatches, since it names nothing. A named close is only
meaningful paired with a symbol open key: a dynamic open key's literal text (e.g. (which)) can
never equal a bare identifier close name, so a named close paired with a dynamic open key always
fails with KB-4003 — the author must use the bare {{/>}} form for a dynamic key.
Standalone/whitespace handling for {{#> }} / {{/>}} / {{/> key }} is block-style, exactly like
{{#with}}: an opener or closer line containing only that tag and surrounding whitespace has its
indentation and trailing newline stripped. The single-line re-indentation a standalone {{> key }}
leaf applies to its own invocation is not applied to a partial block's hit-path output.
Normative fixtures: conformance/partials/partial-blocks.json, partial-blocks-close-forms.json,
hard-failures.json (partial-block-mismatched-close-name-is-a-hard-failure).
Template stack and source locations¶
A soft-fail event or a hard-failure exception carries a templateStack — the chain of templates
active at the point of failure, outermost-first: the root template, then one entry per partial
reached to get there. A static partial key ({{> key }}) carries no source location at all,
not even an empty templateStack, because the parser has no expression node for a static symbol to
attach a location to; a dynamic key ({{> (expr) }}) does carry its expression's location. This
makes KB-4001 (only reachable through a static key) unconditionally location-less, and makes
KB-4002 and RecursionLimit location-less only when reached through a static key — the same codes
reached through a dynamic key do carry a location. See Errors for the
full location model.
A softFails array in a partials/*.json fixture is matched positionally and by exact count
(unlike a render fixture's expectedSoftFails, which is an order-insensitive set): entry n must
match the n-th soft-fail event emitted, in the order the failing expressions are encountered while
rendering, left to right, outermost to innermost.
Normative fixtures: conformance/partials/basic-rendering.json, context-argument.json,
hard-failures.json, origin-hard-failures.json, origin-soft-failures.json, soft-failures.json.
Built-in transforms (informative)¶
kBars pre-registers 46 built-in transforms across four families — string, math, list, and
date/time — covering operations such as upcase, append, map, and format_date_time. A
baseline roster of transforms is the standard library, normative for full-tier conformance;
core-tier implementations need not provide any. Which transforms an implementation adds beyond the
baseline, and how it may widen a baseline transform, are implementation-defined — as is the
code-point-counting rule's reach, already pinned in Data
Model for any implementation with comparably-named
operations. See Implementation-Defined
Behavior.
A transform (built-in or custom) may report a soft failure instead of throwing, using codes from
the KB-5xxx family: KB-5001 (TypeMismatch, wrong type/shape), KB-5002 (DegenerateValue, a
degenerate input such as a zero divisor), KB-5003 (UnparseableInput, a malformed string that
cannot parse into the required sub-type), or KB-5006 (NoMatch, a search transform found no
matching element). See Errors.
Standard-library fixtures (normative at the full tier): conformance/transforms/*.json (one file per
built-in transform). The full baseline roster, with worked examples per transform, is enumerated in the
Transforms reference.
The @env namespace (informative)¶
Beyond the rendered context, an environment carries properties — read with {{ @env/name }} (or
dot notation {{ @env.name }}), with RFC 6901 sub-pointer resolution against a property's value
({{ @env/foo/bar }}). An unresolved @env reference (an unknown property name, or a sub-pointer
applied to a non-container value) renders empty, not the literal "null" an absent context
lookup falls back to (see Data Model).
A baseline of @env properties (kBars registers now) is the standard library, normative for
full-tier conformance; core-tier implementations need not expose an @env namespace at all. The
seeding clock behind a now-like property, and any properties an implementation registers beyond the
baseline, are implementation-defined. See Implementation-Defined
Behavior. The full baseline roster, with worked examples
per property, is enumerated in the Environment Properties reference.
Standard-library fixtures (normative at the full tier): conformance/env-properties/env-now.json,
scalar.json, struct.json, list.json, missing.json, datetime-property.json.