Built-in components
These tags are part of the template language. Except for <router>, they are compiled — they
create no component instance and cost nothing at runtime beyond the work they describe.
<if> · <else-if> · <else>
<if( state.loggedIn )>
<dashboard/>
</if>
<else>
<login-form/>
</else>
A full chain:
<if( state.status === 'loading' )>
<spinner/>
</if>
<else-if( state.status === 'error' )>
<error-message error=state.error/>
</else-if>
<else-if( !state.rows.length )>
<empty-state message="Nothing here yet"/>
</else-if>
<else>
<data-table rows=state.rows/>
</else>
Exactly one branch is live at a time. Switching branches destroys the previous one — its
effects are disposed, its nested components run onDestroy.
<for>
<!-- array -->
<for [item] in=state.items>
<li>{item.name}</li>
</for>
<!-- array with index -->
<for [item, i] in=state.items>
<li>#{i}: {item.name}</li>
</for>
<!-- object → key, value, index -->
<for [key, value, i] in=state.settings>
<dt>{key}</dt><dd>{value}</dd>
</for>
<!-- Map → key, value, index -->
<for [id, layer] in=state.layers>
<li>{layer.name}</li>
</for>
<!-- Set → value, index -->
<for [tag] in=state.tags>
<span class="tag">{tag}</span>
</for>
<!-- numeric range, both ends inclusive; counts down if to < from -->
<for [n] from=1 to=5>
<span>{n}</span>
</for>
| Source | Bracket arguments |
|---|---|
| Array | [ item, index ] |
Map |
[ key, value, index ] |
Set |
[ value, index ] |
| Object | [ key, value, index ] |
from/to |
[ value ] |
Keying with by
<for [todo] in=state.todos by="id">
<todo-row todo=todo/>
</for>
<for [row] in=state.rows by=self.rowKey>
<tr><td>{row.label}</td></tr>
</for>
by takes either a property path as a literal string, or an expression resolving to a
function called with ( item, index ).
Without by, rows are identified by position: reordering rewrites their content. With by,
Lips moves the existing DOM range instead — node identity and nested component state survive
the reorder. Keys are naturally derived for Map and object sources.
<switch> · <case> · <default>
<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>
is accepts a single value or an array of values. Without a matching case, <default>
renders — or nothing, if there is none.
<async> · <loading> · <then> · <catch>
<async await( self.fetchUser( state.userId ) )>
<loading>
<spinner/>
</loading>
<then [user]>
<user-card user=user/>
</then>
<catch [error]>
<p class="error">{error.message}</p>
</catch>
</async>
<loading>renders immediately, if present- the awaited expression is evaluated
- on resolve,
<then>replaces it, receiving the value - on reject,
<catch>replaces it, receiving the error
If the awaited expression re-evaluates before the previous promise settles, the stale result is discarded — no flicker, no out-of-order render.
<let> · <const>
<let doubled={ state.n * 2 }/>
<p>{doubled}</p>
<let subtotal={ state.price * state.qty }
tax={ subtotal * 0.07 }
total={ subtotal + tax }/>
<const TAX_RATE=0.07 CURRENCY="EUR"/>
Declared names are block-scoped and visible to every binding in that block — including bindings written above the declaration. They recompute reactively when their dependencies change.
<let ...state.options/> is not supported and reports LIPS-C013. A compiled expression
has to know its scope names at compile time, and the keys of a spread are only known at
runtime. Bind the object to a single name instead:
<let opts={ state.options }/>
<p>{opts.title}</p>
<log>
<log( state.user )/>
<log('rows:', state.rows.length, state.filter )/>
Calls console.log during render, renders nothing. Useful for inspecting scope inside a
<for> or a macro body.
<router>
The only built-in that is a real component. It resolves the current URL against a route table and renders the matching page.
<router global routes=static.routes
on-before(beforeNavigate)
on-after(afterNavigate)
on-not-found(notFound)/>
{
_static: {
routes: [
{ path: '/', template: Home, default: true },
{ path: '/about', template: About },
{ path: '/users/:id', template: UserDetail },
{ path: '/blog/:category/:slug', template: BlogPost }
]
}
}
Each template is an ordinary component template object.
| Input | Meaning |
|---|---|
routes |
array of { path, template, default? } |
global |
sync with the browser URL and history |
A page receives params and query as inputs:
// UserDetail
default: `<h1>User {input.params.id}</h1>`
Path segments beginning with : become named params and are percent-decoded. The query string
is parsed with URLSearchParams.
Events
| Event | Payload |
|---|---|
on-before |
{ fromState, toState } — before navigating away |
on-after |
{ fromState, toState } — after the page resolves |
on-not-found |
the unmatched path |
Navigating
With global, the router pushes history entries and listens for popstate. It also publishes
a navigate function into context, so any component can route:
handler: {
goToProfile(){ this.context.navigate('/users/42') }
}
<router> needs the template compiler, so it is only available from the full @lipsjs/lips
entry — not from @lipsjs/lips/runtime. That build tree-shakes it out entirely.