Skip to content

dy/sprae

Repository files navigation

∴ spræ tests npm bundle size npm

DOM tree microhydration

Sprae is open & minimalistic progressive enhancement framework.
Perfect for small-scale websites, static pages, landings, prototypes, or lightweight UI.
A light alternative to alpine, petite-vue etc (see why).

Usage

<div id="container" :if="user">
  Hello <span :text="user.name">there</span>.
</div>

<script type="module">
  import sprae from './sprae.js' // https://unpkg.com/sprae/dist/sprae.min.js

  // init
  const container = document.querySelector('#container');
  const state = sprae(container, { user: { name: 'friend' } })

  // update
  state.user.name = 'love'
</script>

Sprae evaluates :-directives and evaporates them, returning reactive state for updates.

Standalone

UMD version enables sprae via CDN, as CJS, AMD etc.

<!-- `init` attribute autoinits sprae on document with initial state (optional) -->
<script src="https://cdn.jsdelivr.net/npm/sprae/dist/sprae.umd.js" init="{ user: 'buddy' }"></script>

<script>
  window.sprae(el); // global standalone
</script>

Directives

:if="condition", :else

Control flow of elements.

<span :if="foo">foo</span>
<span :else :if="bar">bar</span>
<span :else>baz</span>

<!-- fragment -->
<template :if="foo">foo <span>bar</span> baz</template>

:each="item, index? in items"

Multiply element.

<!-- :text order matters -->
<ul><li :each="item in items" :text="item"/></ul>

<!-- cases -->
<li :each="item, idx in array" />
<li :each="value, key in object" />
<li :each="count, idx in number" />

<!-- fragment -->
<template :each="item in items">
  <dt :text="item.term"/>
  <dd :text="item.definition"/>
</template>

:text="value"

Set text content of an element.

Welcome, <span :text="user.name">Guest</span>.

<!-- fragment -->
Welcome, <template :text="user.name" />.

:class="value"

Set class value.

<!-- appends class -->
<div class="foo" :class="bar"></div>

<!-- array/object, a-la clsx -->
<div :class="[foo && 'foo', {bar: bar}]"></div>

:style="value"

Set style value.

<!-- extends style -->
<div style="foo: bar" :style="'bar-baz: qux'">

<!-- object -->
<div :style="{barBaz: 'qux'}"></div>

<!-- CSS variable -->
<div :style="{'--bar-baz': qux}"></div>

:value="value"

Set value to/from an input, textarea or select (like alpinejs x-model).

<input :value="value" />
<textarea :value="value" />

<!-- selects right option & handles selected attr -->
<select :value="selected">
  <option :each="i in 5" :value="i" :text="i"></option>
</select>

<!-- handles checked attr -->
<input type="checkbox" :value="item.done" />

:<prop>="value", :="values"

Set any attribute(s).

<label :for="name" :text="name" />

<!-- multiple attributes -->
<input :id:name="name" />

<!-- spread attributes -->
<input :="{ id: name, name, type: 'text', value }" />

:with="values"

Define values for a subtree.

<x :with="{ foo: 'bar' }">
  <y :with="{ baz: 'qux' }" :text="foo + baz"></y>
</x>

:fx="code"

Run effect, not changing any attribute.

<div :fx="a.value ? foo() : bar()" />

<!-- cleanup function -->
<div :fx="id = setInterval(tick, 1000), () => clearInterval(id)" />

:ref="el => (...)"

Get reference to element (instead of this).

<!-- initialize element -->
<textarea :ref="el => (/* onmount */, () => (/* onunmount */))" placeholder="Enter text..."></textarea>

<!-- expose element in (sub)state -->
<li :each="item in items" :with="{li:null}" :ref="el => li = el">
  <input :onfocus..onblur="e => (li.classList.add('editing'), e => li.classList.remove('editing'))"/>
</li>

<!-- set innerHTML -->
<div :ref="el => el.innerHTML = '...'"></div>

:on<event>="handler", :on<in>..on<out>="handler"

Attach event(s) listener with optional modifiers.

<input type="checkbox" :onchange="e => isChecked = e.target.value">

<!-- multiple events -->
<input :value="text" :oninput:onchange="e => text = e.target.value">

<!-- sequence of events -->
<button :onfocus..onblur="e => ( handleFocus(), e => handleBlur())">

<!-- modifiers -->
<button :onclick.throttle-500="handler">Not too often</button>
Modifiers:
  • .once, .passive, .capture – listener options.
  • .prevent, .stop (.immediate) – prevent default or stop (immediate) propagation.
  • .window, .document, .parent, .outside, .self – specify event target.
  • .throttle-<ms>, .debounce-<ms> – defer function call with one of the methods.
  • .<key> – filtered by event.key:
    • .ctrl, .shift, .alt, .meta, .enter, .esc, .tab, .space – direct key
    • .delete – delete or backspace
    • .arrow – up, right, down or left arrow
    • .digit – 0-9
    • .letter – A-Z, a-z or any unicode letter
    • .char – any non-space character
    • .ctrl-<key>, .alt-<key>, .meta-<key>, .shift-<key> – key combinations, eg. .ctrl-alt-delete or .meta-x.
  • .* – any other modifier has no effect, but allows binding multiple handlers to same event (like jQuery event classes).

:data="values"

Set data-* attributes. CamelCase is converted to dash-case.

<input :data="{foo: 1, barBaz: true}" />
<!-- <input data-foo="1" data-bar-baz /> -->

:aria="values"

Set aria-* attributes. Boolean values are stringified.

<input role="combobox" :aria="{
  controls: 'joketypes',
  autocomplete: 'list',
  expanded: false,
  activeOption: 'item1',
  activedescendant: ''
}" />
<!--
<input role="combobox" aria-controls="joketypes" aria-autocomplete="list" aria-expanded="false" aria-active-option="item1" aria-activedescendant>
-->

Signals

Sprae uses preact-flavored signals for reactivity and can take signal values as inputs.
Signals can be switched to any preact/compatible implementation:

import sprae from 'sprae';
import { signal, computed, effect, batch, untracked } from 'sprae/signal';
import * as signals from '@preact/signals-core';

// switch sprae signals to @preact/signals-core
sprae.use(signals);

// use signal as state value
const name = signal('Kitty')
sprae(el, { name });

// update state
name.value = 'Dolly';
Provider Size Feature
ulive 350b Minimal implementation, basic performance, good for small states.
@webreflection/signal 531b Class-based, better performance, good for small-medium states.
usignal 850b Class-based with optimizations, good for medium states.
@preact/signals-core 1.47kb Best performance, good for any states, industry standard.
signal-polyfill 2.5kb Proposal signals. Use via adapter.

Evaluator

Expressions use new Function as default evaluator, which is fast & compact way, but violates "unsafe-eval" CSP. To make eval stricter & safer, as well as sandbox expressions, an alternative evaluator can be used, eg. justin:

import sprae from 'sprae'
import justin from 'subscript/justin'

sprae.use({compile: justin}) // set up justin as default compiler

Justin is minimal JS subset that avoids "unsafe-eval" CSP and provides sandboxing.

Operators:

++ -- ! - + ** * / % && || ??
= < <= > >= == != === !==
<< >> & ^ | ~ ?: . ?. [] ()=>{} in

Primitives:

[] {} "" ''
1 2.34 -5e6 0x7a
true false null undefined NaN

Custom Build

Sprae can be tailored to project needs via sprae/core:

// sprae.custom.js
import sprae, { directive } from 'sprae/core'
import * as signals from '@preact/signals'
import compile from 'subscript'

// standard directives
import 'sprae/directive/default.js'
import 'sprae/directive/if.js'
import 'sprae/directive/text.js'

// custom directive :id="expression"
directive.id = (el, evaluate, state) => {
  return () => el.id = evaluate(state)
}

// configure signals
sprae.use(signals)

// configure compiler
sprae.use({ compile })

Hints

  • To prevent FOUC add <style>[:each],[:if],[:else] {visibility: hidden}</style>.
  • Attributes order matters, eg. <li :each="el in els" :text="el.name"></li> is not the same as <li :text="el.name" :each="el in els"></li>.
  • Mind invalid self-closing tags, eg. <a :text="item" /> will cause an error. Main valid self-closing tags are li, p, dt, dd, option, tr, td, th.
  • Properties prefixed with _ are untracked: let state = sprae(el, {_x:2}); state._x++; // no effect.
  • To destroy state and detach sprae handlers, call element[Symbol.dispose]().
  • State getters/setters work as computed effects, eg. sprae(el, { x:1, get x2(){ return this.x * 2} }).
  • this is not used, to get access to current element use <input :ref="el => (...)" :text="el.value"/>.
  • event is not used, :on* attributes expect a function with event argument :onevt="event => handle()", see #46.
  • key is not used, :each uses direct list mapping instead of dom diffing.
  • await is not supported in attributes, it’s a strong indicator you need to put these methods into state.

Justification

Modern frontend stack is unhealthy, like non-organic processed food. There are alternatives, but:

Sprae holds open & minimalistic philosophy:

  • Slim : API, signals for reactivity.
  • Pluggable directives & configurable internals.
  • Small, safe & performant.
  • Bits of organic sugar.
  • Aims at making developers happy 🫰

Examples

🕉