Reference

Template Expressions

Meno's in-memory model stores dynamic values as {{expr}} templates. When a page or component is written to disk as .astro, the codec renders each template as a real JavaScript expression in the markup. When you hand-edit a .astro file and save it through Meno, the parser turns those expressions back into {{expr}} templates. This page is the reference for that two-way mapping: which expressions are supported, where they can appear, and how the conversion works.

The rule is simple: a template {{expr}} in the model becomes a JSX expression {expr} in markup, and a bare JSX expression you write by hand becomes a {{expr}} template on the next save. The expression engine evaluates a small, well-defined subset of JavaScript — identifiers, member access, ternaries, a few operators, arrays. Anything outside that subset is kept verbatim as authored JS rather than treated as an editable binding.

Whole-string vs mixed templates

How a template renders depends on whether the value is *exactly* one template or a string with text around it.

  • A string that is exactly one template ("{{expr}}") renders as the bare expression {expr}.

  • A string with embedded templates ("Hi {{name}}!") renders as a backtick template literal ` Hi ${name}! `.

Model value

Markup

Notes

"{{title}}"

{title}

Whole-string → bare expression

"{{user.name}}"

{user.name}

Member chain

"Hi {{name}}!"

` Hi ${name}! `

Mixed string → backtick literal

"{{a}} / {{b}}"

` ${a} / ${b} `

Multiple templates in one string

"Hello"

Hello (or {"Hello"})

Plain text, no template

To re-introduce a template by hand, write the JSX expression directly: a bare identifier, member chain, or ternary inside {…}, or a backtick literal with ${…} interpolation for mixed text. The parser converts a whole-string expression back to a {{expr}} template and a backtick literal back to the mixed "…{{expr}}…" form. Both round-trip to the same model value.

In text children, a sole text child with no braces or angle brackets may stay raw (<h1>Hello</h1>), while text that has siblings or special characters is emitted as a {"…"} expression so adjacent text nodes stay distinct on parse. Both forms parse back to the same text.

<p class={style({ base: { color: "var(--text)" } })}>
  {"Even "}
  <span class={style({ base: { fontStyle: "italic" } })}>great products</span>
  {" when users don’t know what to do next."}
</p>

Where templates appear

Template expressions are resolved in every value position of the dialect:

  • Text children<span>{item.title}</span> from "{{item.title}}".

  • HTML attributes<img src={item.cover} alt={item.title} /> from "{{item.cover}}" / "{{item.title}}".

  • Component props<Card title={post.title} /> from a "{{post.title}}" prop value.

  • **href** — <Link href={post.url}>…</Link> from a "{{post.url}}" href.

  • List sources{ list(items, { limit: 6 }).map(…) }, where items is the unwrapped template source. See Lists.

  • **if conditions** — {visible && ( … )} from a node's if: "{{visible}}".

  • **Embed html** — <Embed html={item.svg} /> from "{{item.svg}}".

Where a template *can't* appear as a literal JSX position — most notably an HTML tag name — it is hoisted to the frontmatter instead (see Dynamic tags below).

Supported expressions

The engine evaluates and round-trips this subset. Stay inside it and your hand-written {…} becomes an editable binding; step outside it and the expression is kept verbatim (see What is not a template).

  • Bare identifier{text}, {isOpen}. A component prop, a loop variable, or a CMS binding in scope.

  • Member / dot-chain{item.title}, {cms.author.name}, {post.category.label}. Any depth.

  • Ternary{isOpen ? 1 : 0}, {featured ? "Featured" : ""}. Used heavily for conditional values.

  • Operators — comparison and logical operators the evaluator supports, e.g. {a === b}, {count > 0}.

  • Array — an array literal of supported expressions.

<h2 class={style({ base: { fontWeight: "500" } })}>{text}</h2>
<span>{item.title}</span>
<span>{cms.author.name}</span>
<time datetime={item.publishedAt}>{item.publishedAt}</time>

A ternary is the idiomatic way to drive a value off a boolean prop. Real example, from src/components/ui/NavDropdown.astro, a dropdown menu whose visibility is bound to an isOpen prop:

<button data-el="dropdown-toggle">{text}</button>
<ul class={style({
    base: {
      opacity: "{{isOpen ? 1 : 0}}",
      visibility: "{{isOpen ? 'visible' : 'hidden'}}",
      pointerEvents: "{{isOpen ? 'auto' : 'none'}}"
    }
  })}><slot /></ul>

Note the templates here live *inside* the style({…}) object — see the style note for why they stay as {{…}} string literals rather than becoming ${…}.

Conditionals

A node's if controls whether it renders. In markup a conditional node is wrapped as {cond && ( … )}:

if value

Markup

true or absent

no wrapper

false

{false && ( … )}

"{{visible}}"

{visible && ( … )}

a BooleanMapping

{when({…}) && ( … )}

{visible && (
  <div class={style({ base: { padding: "16px" } })}>Only when visible</div>
)}

A BooleanMapping is a boolean derived from a prop and a value map; it renders through the when() runtime helper:

{when({ _mapping: true, prop: "variant", values: { primary: true, ghost: false } }) && (
  <span class={style({ base: { fontWeight: "600" } })}>Primary only</span>
)}

For an element the wrapper is cond && ( <markup> ); for an expression node (a nested list or conditional) it is cond && (expr). The condition is a template-position expression, so it stays a bare {{visible}} binding — it is not wrapped in i18n() the way value positions are. See Node Types for the full list of nodes that accept an if.

Dynamic tags

An HTML tag name can contain a template — for example a heading whose level is bound to a size prop ("h{{size}}"). A template can't be a literal JSX tag, so the codec hoists it to a frontmatter const and references that const as the tag:

---
const Tag_0 = `h${size}`;
---
<Tag_0 class={style({ base: { fontWeight: "500" } })}>{text}</Tag_0>

Tag_0 (the name is generated, counting up if several dynamic tags appear) holds the resolved tag string at render time, and <Tag_0>…</Tag_0> uses it. On parse the codec records Tag_0 → "h{{size}}" and restores the original templated tag, so the dynamic tag round-trips. When you author one by hand, declare the const Tag_N = \…\` in the frontmatter and reference it as the element tag — do not write <h{size}>` directly.

CMS-data bindings wrap in i18n()

On a CMS template page and inside a collection list, the item data the markup binds to is raw entry data — fields that may be internationalized values ({ _i18n: true, en: "…", pl: "…" }). A bare {cms.title} over an i18n field would render [object Object]. So in these scopes, a CMS-data binding is wrapped in the i18n() runtime helper, which resolves the value for the active locale and is identity for plain values. The wrap is always safe.

  • On a CMS template page: {{cms.title}} renders as {i18n(cms.title)}.

  • Inside a collection loop: {{item.title}} renders as {i18n(item.title)} (or {i18n(post.title)} if the loop variable is post).

<h1>{i18n(cms.title)}</h1>
<span>{`By ${i18n(cms.author.name)}`}</span>

The wrap applies in value positions — text children, attributes, component props, href, embed html — in both forms: whole-template ({{cms.title}}{i18n(cms.title)}) and interpolation ("By {{cms.author}}" → ` By ${i18n(cms.author)} ). It does **not** apply in non-value positions: list sources, if conditions, dynamic tags, and templates nested inside structured-prop literals or style({…})` values keep the bare expression.

Real example, from this project's src/pages/docs/[slug].astro (a docs collection template page), showing both the cms root and a collection-loop variable wrapped:

<Heading text={i18n(cms.title)} tag="1" size="1" cms={cms} />

{docs_categoriesList.map((category, categoryIndex) => (
  <div class={style({ base: { marginBottom: "20px" } })}>
    <span>{i18n(category.name)}</span>
    <input id={`cat-toggle-${i18n(category._id)}`} type="checkbox" />
  </div>
))}

When you hand-write a binding, you don't add the wrap yourself — {i18n(<chain>)} always parses back to the {{<chain>}} template, and the codec re-adds the wrap on the next save only where the root is a CMS binding in scope. Write {{cms.title}} (or {cms.title} in markup) and let the codec place the wrap.

A rich-text CMS field is the exception: it is a structured document, not a string, so it never renders as a text interpolation. Bound as a text child, it renders through <Fragment set:html={richTextWithComponents(cms.body, cmsComponents)} />. See CMS for the full rich-text and reference-field story.

What is not a template

The expression engine only understands the supported subset above. An expression it can't evaluate — a function or method call, an index access, arithmetic that isn't a plain operator — is kept verbatim as authored JavaScript. It still builds and round-trips byte-for-byte, but it is *not* an editable binding: the editor won't expose it as a prop link or template.

<span>{(product.price * 0.8).toFixed(2)}</span>
<span>{items.map((i) => i.label).join(", ")}</span>

Both of the above are preserved exactly as written; neither becomes a {{…}} template. This is the boundary between an editable binding and raw code — if you want a value to be editable in the visual editor, keep it inside the supported subset (compute the value in CMS data or a prop, then bind to it).

A note on templates inside style()

Templates that appear *inside* a style({…}) object value are not converted to ${…} interpolation — they stay as {{…}} string literals inside the style argument. The style() object is treated as an opaque literal that round-trips verbatim; only attribute, child, and href positions get the template-to-JS conversion. That's why the NavDropdown example above keeps opacity: "{{isOpen ? 1 : 0}}" as a string rather than ` opacity: ${isOpen ? 1 : 0} `. See Style Properties for how style objects are written.


For the node properties that accept templates and conditionals, see Node Types; for list iteration and where item / loop variables come from, see Lists; for CMS bindings, reference fields, and rich text, see CMS; and for the round-trip rules when editing .astro by hand, see Hand-editing.

Building the future of digital experiences, one website at a time

Product

Resources

Comparisons

© 2026 Meno. All rights reserved.