{ } Lips v0.2.0

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.