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 |
|---|---|---|
|
| Whole-string → bare expression |
|
| Member chain |
| ` | Mixed string → backtick literal |
| ` | Multiple templates in one string |
|
| 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(…) }, whereitemsis the unwrapped template source. See Lists.**
ifconditions** —{visible && ( … )}from a node'sif: "{{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 && ( … )}:
| Markup |
|---|---|
| no wrapper |
|
|
|
|
a |
|
{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 ispost).
<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.