{ } Lips v0.2.0

Core concepts

The compile pipeline

Every template goes through the same four stages, whether at runtime or at build time:

template string ──parse──▶ AST ──compile──▶ IR ──render──▶ DOM

The IR is a serializable description of the template: static HTML skeletons, a table of expressions, and integer paths pointing at the nodes each binding owns. Rendering clones a skeleton and walks those paths — it never parses HTML again, and never re-reads your template.

Because the IR is plain JSON it can be precompiled, stored, or shipped over the network.

Component architecture

A component is a plain object. Nothing is a class, and nothing needs a build step.

const userProfile = {
  // reactive data owned by this component
  state: { editing: false },

  // non-reactive constants
  _static: { roles: [ 'admin', 'editor' ] },

  // context fields this component wants to observe
  context: [ 'theme' ],

  // methods + lifecycle hooks, bound to the component
  handler: {
    toggleEdit(){ this.state.editing = !this.state.editing },
    save(){
      this.state.editing = false
      this.emit('save', this.input.user)
    }
  },

  // the template
  default: `
    <div class="profile">
      <h2>{input.user.name}</h2>

      <if( state.editing )>
        <form on-submit(save)>
          <button type="submit">Save</button>
        </form>
      </if>
      <else>
        <p>{input.user.email}</p>
        <button on-click(toggleEdit)>Edit</button>
      </else>
    </div>`,

  // scoped CSS
  stylesheet: `.profile { padding: 1rem }`
}

Every key is optional except default (or a precompiled ir).

Key Purpose
default template source
ir precompiled IR — replaces default
state reactive, component-owned data
_static constants; reading them never subscribes
context context field names that trigger onContext
handler methods and lifecycle hooks
macros <macro> definitions inlined at compile time
stylesheet scoped CSS

The template key is _static, but inside a handler you read it as this.static. The underscore only avoids colliding with the JS static keyword in module exports.

Reactivity

State is a per-key signal store. Reading a key inside a binding subscribes that binding to that key. Writing the key notifies exactly those subscribers — nothing else runs.

const counter = {
  state: { count: 0, label: 'Clicks' },

  handler: {
    increment(){ this.state.count++ }
  },

  default: `
    <div>
      <h3>{state.label}</h3>
      <p>Count: {state.count}</p>
      <button on-click(increment)>+</button>
    </div>`
}

Clicking runs one text update. The <h3> bound to state.label is never visited: it is not compared, not re-created, not diffed. There is no component re-run to memoize away.

Deep reactivity

Nested objects and arrays are tracked too, so in-place mutation works:

this.state.user.address.city = 'Lomé'
this.state.items[0].name = 'Updated'
this.state.todos.push({ id: 3, text: 'New' })

Array mutators (push, splice, sort, …) are applied atomically — one operation produces one update, not one per internal write.

Reactive Map and Set

Map and Set are first-class reactive state. Mutating them notifies; values read out of them are wrapped in turn, so a nested tree of Maps stays reactive at any depth.

const tree = {
  state: {
    layers: new Map([
      [ 'a', { name: 'Group A', children: new Map() } ]
    ])
  },

  handler: {
    add(){ this.state.layers.set('b', { name: 'Layer B' }) },
    remove( key ){ this.state.layers.delete( key ) }
  },

  default: `
    <ul>
      <for [key, layer] in=state.layers>
        <li>{layer.name}</li>
      </for>
    </ul>`
}

Collection identity is stable across components: if a parent passes state.layers down as an input, a .set() in the parent reaches the child rendering the same Map.

Static values

Anything that never changes belongs in _static, not state. Reading it creates no subscription:

{
  _static: { pageSize: 25, api: 'https://api.example.com' },
  handler: {
    async load(){
      const res = await fetch(`${this.static.api}/items`)
      return res.json()
    }
  }
}

Inputs

A parent passes data down as attributes; the child reads them from input.

<user-card name="Ada" age=36 admin=state.isAdmin/>
{
  default: `
    <div>
      <h3>{input.name}</h3>
      <if( input.admin )><span class="badge">admin</span></if>
    </div>`,

  handler: {
    onInput( input ){
      // called once at creation, with the input object
    }
  }
}

Inputs are reactive: when the parent’s expression re-evaluates, the child’s bindings that read that key update. The child never re-runs.

Lifecycle

{
  handler: {
    onCreate(){},          // instance created, before first render
    onInput( input ){},    // initial input received
    onMount(){},           // first render complete, nodes exist
    onRender(){},          // after every render, including the first
    onUpdate(){},          // after a state/input driven update
    onAttach(){},          // nodes are live in the document
    onDetach(){},          // nodes were removed from the document
    onContext(){},         // an observed context field changed
    onError( error ){},    // a binding or handler threw
    onDestroy(){}          // instance torn down
  }
}

The order at creation is:

onCreate ──▶ onInput ──▶ render ──▶ onMount ──▶ onRender ──▶ onAttach

onAttach and onDetach fire by ownership, not by observing the whole document: a component’s own render decides when its children are considered attached.

onError acts as a boundary — if a binding or a handler throws and the component defines onError, it receives the error instead of the exception reaching the console.

Events

DOM events

<button on-click(handleClick)>Click</button>
<button on-click(remove, item.id)>Delete</button>
<button on-click( () => state.count++ )>Inline</button>

Declared arguments come first; the DOM event object is appended last:

handler: {
  handleClick( event ){ /* … */ },
  remove( id, event ){ event.preventDefault() }
}

Component events

A child emits; the parent listens with on-* on the tag.

// child
handler: {
  submit(){ this.emit('submit', { name: this.state.name }) }
}
<!-- parent -->
<signup-form on-submit(handleSubmit)/>
// parent
handler: {
  handleSubmit( data ){ console.log( data ) }
}

Every component also carries a small event bus of its own — on, once, off, emit — and emits its own lifecycle events on it:

handler: {
  onCreate(){
    this.on('component:attached', () => this.bindDragHandles() )
    this.on('component:detached', () => this.releaseDragHandles() )
  }
}

Emitted lifecycle events: component:mount, component:attached, component:detached, component:destroy.

Reaching the DOM

this.node returns the component’s live root elements as an array. It is the handle you give to a third-party library.

handler: {
  onMount(){
    const [ root ] = this.node
    root.querySelector('.chart') && renderChart( root )
  }
}

this.node is an array of DOM Elements — plain DOM, no jQuery-like wrapper. Use standard querySelector, classList, and addEventListener.

Next