Template syntax
Lips templates are HTML. The parser owns the syntax — it is not innerHTML plus regular
expressions — so control-flow elements work anywhere real elements do, including inside
<table>.
Text interpolation
Single braces evaluate an expression and render the result.
<p>Hello, {state.username}!</p>
<p>Total: {state.price * state.quantity}</p>
<p>Joined: {self.formatDate( state.createdAt )}</p>
null and undefined render as an empty string, never as the text "undefined".
Available in every expression:
| Name | What it is |
|---|---|
state |
this component’s reactive state |
input |
inputs passed by the parent |
static |
non-reactive constants (_static in the template) |
context |
shared context values |
self |
the component itself — call its handlers |
Attribute binding
An unquoted value is an expression; a quoted value is a literal.
<!-- literal -->
<div class="card"></div>
<!-- expression -->
<input value=state.query/>
<div id=state.rowId></div>
<!-- expression in parentheses, for anything with spaces -->
<button class=(state.active ? 'tab active' : 'tab')>Tab</button>
<!-- interpolation inside a literal -->
<div class="card {state.active ? 'active' : ''}"></div>
<!-- boolean: absent when falsy -->
<button disabled=!state.valid>Submit</button>
<input type="checkbox" checked=todo.done/>
<!-- negation shorthand -->
<div !hidden></div>
<!-- spread an object of attributes -->
<div ...state.attributes></div>
Attributes bound to false, null or undefined are removed from the element. Spread
attributes also remove keys that disappear from the object.
Conditionals
<if( state.status === 'loading' )>
<spinner/>
</if>
<else-if( state.status === 'error' )>
<error-message error=state.error/>
</else-if>
<else>
<data-table rows=state.rows/>
</else>
<else-if> and <else> must directly follow their <if>. Only one branch renders; switching
branches disposes the old one and its effects.
Lists
<!-- array -->
<for [item] in=state.items>
<li>{item.name}</li>
</for>
<!-- array with index -->
<for [item, index] in=state.items>
<li>#{index + 1}: {item.name}</li>
</for>
<!-- object: key, value, index -->
<for [key, value] in=state.settings>
<dt>{key}</dt><dd>{value}</dd>
</for>
<!-- Map: key, value, index -->
<for [key, layer, i] in=state.layers>
<li>{i}: {layer.name}</li>
</for>
<!-- Set: value, index -->
<for [tag, i] in=state.tags>
<span>{tag}</span>
</for>
<!-- numeric range, inclusive both ends -->
<for [page] from=1 to=state.totalPages>
<button class=(page === state.page && 'active')>{page}</button>
</for>
Keyed lists
By default a list is positional. Add by= to give each row a stable identity, so reordering
moves DOM ranges instead of rewriting them — node identity and nested component state
survive.
<!-- key by a property path -->
<for [todo] in=state.todos by="id">
<todo-row todo=todo/>
</for>
<!-- key by a function -->
<for [row] in=state.rows by=self.rowKey>
<tr><td>{row.label}</td></tr>
</for>
by= goes on the <for> element itself. There is no key attribute on the child — that is a
different feature, used by hot-swap to identify component instances.
Duplicate keys fall back to positional identity for the duplicates, with a console warning.
Switch
<switch( state.role )>
<case is="admin"><admin-panel/></case>
<case is="editor"><editor-panel/></case>
<case is=[ 'viewer', 'guest' ]><read-only/></case>
<default><login-form/></default>
</switch>
A <case> matches a single value or an array of values. <default> renders when nothing
matched.
Async
<async await( self.loadUser( state.id ) )>
<loading>
<spinner/>
</loading>
<then [user]>
<user-card user=user/>
</then>
<catch [error]>
<p class="error">{error.message}</p>
</catch>
</async>
The loading arm renders immediately, then then or catch replaces it. Each arm declares
its own scoped argument in brackets. Out-of-order resolutions are ignored — only the most
recent await can settle the block.
Scoped variables
<!-- one variable -->
<let doubled={ state.n * 2 }/>
<p>{doubled}</p>
<!-- several at once, each visible to the next -->
<let subtotal={ state.price * state.qty }
tax={ subtotal * 0.07 }
total={ subtotal + tax }/>
<!-- constants -->
<const TAX_RATE=0.07 SHIPPING=10/>
Declared names are visible to every binding in the same block, including bindings written
above the <let>. They stay reactive: when a dependency changes, the variable recomputes.
Spreading an object into scope — <let ...state.options/> — is not supported, and reports
LIPS-C013 at compile time. Scope names have to be known when the template is compiled.
Assign the object to one name instead: <let opts={ state.options }/>, then read opts.key.
Events
<!-- named handler -->
<button on-click(save)>Save</button>
<!-- handler with arguments; the DOM event is appended last -->
<button on-click(remove, item.id)>Delete</button>
<!-- inline arrow -->
<button on-click( () => state.count++ )>+</button>
<input on-input( e => state.query = e.target.value )/>
<!-- any DOM event name works -->
<form on-submit(handleSubmit)>
<div on-mouseenter(hover) on-keydown(onKey)>
The handler receives its declared arguments first, then the DOM event:
handler: {
save( event ){ event.preventDefault() },
remove( id, event ){ /* id first, event last */ }
}
Components
<!-- inputs -->
<user-card name="Ada" age=36 admin=state.isAdmin/>
<!-- spread inputs -->
<user-card ...state.user/>
<!-- listen for emitted events -->
<signup-form on-submit(handleSubmit) on-cancel(handleCancel)/>
<!-- slotted content -->
<modal title="Confirm">
<p>Delete this item?</p>
<button on-click(confirm)>Yes</button>
</modal>
<!-- dynamic component from a value -->
<{state.currentView} params=state.params/>
Slots
Content written between a component’s tags is handed to it as input.renderer. The component
decides where it lands:
const modal = {
default: `
<div class="modal">
<header>{input.title}</header>
<div class="body"><{input.renderer}/></div>
</div>`
}
Slot content is evaluated in the parent’s scope — it closes over where it was written, not where it is placed.
Slots can also take arguments. The parent declares the names in brackets on the component tag; the component supplies the values where it places the slot:
// list component — supplies `item` at the placement
default: `
<ul>
<for [row] in=input.items>
<li><{input.renderer} item=row/></li>
</for>
</ul>`
<!-- parent — declares `item`, then uses it in the slot body -->
<item-list [item] items=state.rows>
<span>{item.label}</span>
</item-list>
Macros
Macros are template fragments inlined at compile time — no component instance, no runtime cost.
{
macros: `
<macro [icon, label, active] name="tool">
<li class=(active ? 'tool on' : 'tool')>
<i class="icon-{icon}"></i>
<if( label )><span>{label}</span></if>
</li>
</macro>`,
default: `
<ul>
<tool icon="pen" label="Pen" active/>
<tool icon="hand"/>
</ul>`
}
Every call-site attribute is also available as arguments, whether or not it was declared:
<tool icon="pen" tooltip="Draw" on-click( pick, arguments )/>
Call-site attributes apply in source order, so spreads and explicit values override each other left to right, exactly like a JS object literal:
<tool ...state.defaults icon="pen"/> <!-- icon wins -->
<tool icon="pen" ...state.defaults/> <!-- the spread wins -->
Undeclared arguments are undefined: falsy in conditions, attribute-removing, and rendered as
an empty string.
Macros cannot recurse — a macro that calls itself reports LIPS-C009. Use a component for
recursive structures.
Debugging
<log( state.user )/>
<log('items:', state.items.length )/>
<log> calls console.log during render and renders nothing.
Internationalization
<h1 i18n>Welcome</h1>
<p @format="items_count, { count: state.items.length }"/>
<div no-translate>Brand name</div>
See Internationalization.