Skip to content

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\n to \n or 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 the RAW lexer 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
& &
< &lt;
> &gt;
" &quot;
' &#x27;

The single quote uses the hexadecimal numeric character reference &#x27;, not the decimal &#39; and not the named &apos; (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/name binds 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/name binds 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 @local read 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.