Implemented as browser-native ES modules in plain JavaScript, the package has no runtime dependencies.
The API is experimental and may change while the design is validated.
The supported module is @thlib/lumi. It exports:
componentrepeattextonpropattrclassTogglestylechild- The
Component,ComponentOptions,MountedComponent, andProjectionContextTypeScript types, plusEventBindingOptions,EventBindingLocation, andEventBindingFrequency
component() returns a definition with mount(). A mounted component exposes
only root, update(), and unmount().
Other source and declaration modules, renderer descriptors, preparation objects, and connected-binding lifecycle shapes are package internals. They exist to implement the public functions and may change without becoming Lumi application APIs.
import {
component,
on,
prop,
} from '@thlib/lumi'
let actualData = {
count: 0,
maximum: 5,
}
function presentCounter(data) {
return {
count: data.count,
counterDisabled: data.count >= data.maximum,
}
}
const slot = document.querySelector('#counter-slot')
const counter = component({
template: document.querySelector('template'),
bindings: [
text('output span', ({data}) => data.count),
prop(
'[data-disabled]',
({data}) => data.counterDisabled,
'disabled',
),
on('[data-action="increment"]', 'click', () => {
actualData = {
...actualData,
count: actualData.count + 1,
}
counter.update(presentCounter(actualData))
}),
],
}).mount(slot)
counter.update(presentCounter(actualData))The template declares the direct value binding and keeps its useful default content:
<template>
<section class="counter">
<output>count is <span>0</span></output>
<button
data-action="increment"
data-disabled
>Increment</button>
</section>
</template>presentCounter() is owned by the counter component. It derives the exact
presentation snapshot required by that component from actual application
data. Derived values such as counterDisabled are recomputed rather than
maintained as a second source of truth.
For a component tree, each component owns the shape and derivation of its own presentation data. A page presentation function composes those component presentation functions into the snapshot passed to the root component. Presentation functions run before rendering; only their results are passed through, with no reference to the actual data that produced them.
The component options identify native DOM, declare data-to-DOM projections,
and register native event handlers. The explicit update() call supplies the
presentation snapshot. State transitions remain ordinary application
JavaScript.
data-disabled and data-action are application-owned hooks. Their names and
values are opaque. The bindings in this quick start project the presentation
snapshot directly with ordinary JavaScript functions.
The action value is likewise resolved by application code, but only as part of
the exact CSS selector [data-action="increment"]. There is no dynamic
function lookup or built-in event dispatcher.
Application conventions, including the optional JSONPath adapter shown below, are not part of the public API.
component creates a reusable definition. It does not clone or modify the
template until an instance is mounted.
The template must contain exactly one root element. That root is the component boundary used for scoped selectors and nested rendering.
const definition = component({
template,
bindings: [],
})The bindings array is optional.
mount deep-clones the template, replaces the target's existing contents with
its single root element, resolves the bindings, and returns a mounted
component. The mounted component owns the target contents for its lifetime. If
connecting a binding fails, Lumi removes the clone and restores the preceding
target contents.
The target and template may be direct querySelector results. A clear error is
thrown if either is missing or the template does not contain exactly one root
element.
update is synchronous and has separate preparation and commit phases.
Preparation evaluates every built-in scalar projection, recursively prepares child components, and calculates the necessary DOM work. When content rules have structural dependencies, the component DOM is imported into an inert document where those dependencies can be resolved. Independent leaf rules use the live DOM only as a read-only preparation view. Preparation does not mutate live DOM or construct another instance of a live custom element. If any projection or validation fails, the prepared work is discarded, the mounted component tree remains unchanged, and a later update is still permitted.
Commit begins only after the complete active component tree prepares successfully. Each binding compares its projected value with the corresponding DOM state and writes only when the value differs. Bindings cache values where the browser cannot change the owned state independently.
Property bindings also compare against the live property on every update.
This lets authoritative data restore a user-mutated value, checked, or
similar browser state. The binding remembers the browser's post-assignment value so
native coercion does not cause a repeated write.
Arbitrary DOM operations are not fully transactional. A native or custom element setter can execute application code, mutate browser state, and then throw. That work cannot be safely rolled back. If commit fails, the mounted component is marked faulted: it may be unmounted, but it cannot be updated again. The application must mount a fresh component boundary.
Rendering remains synchronous and explicit, with no retained subscriptions to the data object.
unmount destroys connected bindings, unmounts owned child components, and
removes the component root. Repeated calls have no effect. Updating after
unmount throws.
Each scalar binding resolves every matching element during each update. The component root itself also participates in selector matching. A selector with no matches is a no-op: its projection does not run and the update continues.
Resolution recursively enters open shadow roots below the component boundary,
so the same bindings and child declarations work for elements owned by a
custom element's open Shadow DOM. Closed shadow roots remain encapsulated and
are not observable to Lumi. Native CSS scoping still applies: Lumi searches
each open tree, but a single selector cannot express an ancestor relationship
across a shadow boundary.
Content-owning rules (text and structural properties) are planned in DOM
ancestor order, independent of declaration order. Descendant selectors are
then resolved against the parent rule's
planned result. This lets a parent create content that a descendant rule
updates in the same render, and naturally skips a descendant removed by its
parent:
text('.person', () => 'Ada')
text('.person .name', () => 'Lovelace')Here the first rule removes .name through native textContent semantics.
The second selector consequently has no matches and performs no update.
Structural property names currently recognized by the planner are
innerHTML, outerHTML, textContent, and innerText.
The projection runs once for each match in the prepared DOM view and
receives that matching element as its second argument. For independent rules
this may be the read-only live match; for structural dependencies it is an
inert planning counterpart. Projection code may inspect stable template
metadata such as data-* attributes, but should not retain the element,
depend on its identity, or mutate it.
In TypeScript, a bare HTML, SVG, or MathML tag selector gives the projection
the corresponding native element type. Complex selectors safely fall back to
Element, as native selector APIs do.
| Binding | Projection result | DOM effect |
|---|---|---|
repeat(selector, project, bindings?) or repeat(selector, project, key, bindings?) |
array or nullish | Repeats the matched template element once per item; an optional key preserves item identity, and optional built-in DOM bindings are scoped to each occurrence |
text(selector, project) |
text value or nullish | Assigns textContent, or preserves it for nullish and invalid values |
prop(selector, project, name) |
any non-nullish value, TrustedHTML for innerHTML/outerHTML, or nullish |
Assigns the native property with Reflect.set, or does nothing for nullish |
attr(selector, name, project) |
string, number, boolean, or nullish | Removes on false, creates an empty attribute on true, sets text, or does nothing for nullish |
classToggle(selector, name, project) |
boolean or nullish | Toggles only the named class, or does nothing for nullish |
style(selector, name, project) |
string or nullish | Sets one inline property, removes it for an empty string, or does nothing for nullish |
Every render projection receives the same context object: data, item,
index, path, and parent; el remains its second argument. repeat is
the only binding that interprets an array structurally. undefined and null
perform no DOM operation and leave the corresponding state unchanged. Invalid
repeat, text, attr, classToggle, and style results also perform no
operation and produce a deduplicated development warning. TrustedHTML property
validation remains a preparation-time error.
Except for HTML injection sinks, prop intentionally does not impose a
generic value restriction. Native and custom properties may legitimately
accept objects and arrays, and the property's setter remains responsible for
accepting or coercing any non-nullish value. Nullish values retain the existing
property as described above. Applications that require a narrower property
contract should validate it in the projection.
Bindings do not replace an element's complete class or style attribute. This allows HTML, CSS, other bindings, and explicitly separate code to own other parts of the same element.
Generic property and attribute bindings reject native event handler names such
as onclick. Attach behavior with on() or the browser's
addEventListener(). They also reject srcdoc.
innerHTML and outerHTML remain available through prop(), but their
projections must return a genuine TrustedHTML object. Lumi authenticates the
value with trustedTypes.isHTML() from the mounted document's realm during
preparation and passes the same object to the native property setter. Ordinary
strings and lookalike objects are rejected before any live DOM write. If that
realm does not expose the Trusted Types API, these property bindings fail
closed.
The application owns the policy and any required sanitization:
const markupPolicy = trustedTypes.createPolicy('application-markup', {
createHTML: value => sanitizeMarkup(value),
})
prop(
'.preview',
({data}) => markupPolicy.createHTML(data.markup),
'innerHTML',
)Replacing the mounted component root itself through outerHTML is still
rejected because it would invalidate the mounted component boundary.
URL-valued properties and attributes such as href, src, and action remain
ordinary strings. Safe sanitization requires knowledge of the application's
trust and resource context. Applications must validate untrusted URLs before
including them in a render snapshot.
Every scalar projection receives (data, element). The second argument is the
matching element in the prepared DOM view for that update. Applications can
inspect it to inject their own metadata conventions; HTML attribute
interpretation is not built in.
on declares one native event relationship inside Lumi-owned DOM. Lumi owns
listener placement and cleanup; the browser owns event behavior. Like every
other binding, the declaration names the DOM it owns first.
on('[data-action="save"]', 'click', (nativeEvent, button) => {
nativeEvent.preventDefault()
save(button.getAttribute('data-id'))
})The handler receives the original native event and the matched element. Bare tag selectors provide the corresponding element type in TypeScript, and known native event names provide their specific event type. The return value is ignored, so cancellation stays explicit.
The options object defaults to:
{
at: 'component',
capture: false,
passive: false,
freq: 'always',
}capture and passive are applied to the native listener Lumi registers.
The default routes matching events through the component's own event boundaries. One managed listener keeps covering matching elements created, repeated, or moved by later updates, and no listener has to be reconnected when repeated elements change.
Lumi reads the event's composed path, restricts it to DOM the component owns, excludes DOM owned internally by nested Lumi components, and invokes each matching binding once with the closest matching path element. The mounted component root participates in matching.
Compatible declarations share one logical router. The grouping key is the
component event boundary, event type, capture, and passive; once is
binding state rather than part of the key.
bindings: [
on('.save', 'click', save),
on('.delete', 'click', remove),
on('[data-track]', 'click', track),
]Bindings sharing a router run in their declaration order. Because they are
callbacks inside one native listener, stopImmediatePropagation() does not
suppress a sibling binding dispatched by the same router; preventDefault()
and stopPropagation() keep their native behavior. Code that needs exactly
independent native listeners should combine the behavior in one handler or use
at: 'elements'.
event.currentTarget is the routing boundary and is never rewritten, because
that would require a wrapped event object. Use the handler's second argument
for the matched element.
on('video', 'ended', finishPlayback, {
at: 'elements',
})maintains native listeners on every matching Lumi-owned element. The plural is intentional: a selector may match zero, one, or many elements, and that set is reconciled after mount and after every successful update. Newly matching elements gain listeners, elements that stop matching lose them, and removed elements are released along with Lumi's references to them. A failed preparation cannot change listener membership.
This is the correct choice when the event does not bubble, when
event.currentTarget must be the matching element, or when native
target-listener ordering and stopImmediatePropagation() behavior matter.
Lumi never translates one event type into another and never silently selects
capture for a non-bubbling type. on('input', 'focus', handler) is not
rewritten as focusin. In development, a component declaration for a
well-known non-bubbling type produces a warning suggesting {at: 'elements'}
or {capture: true}; custom event names remain valid because a CustomEvent
chooses its own bubbles value.
How often a declaration may run is an enum rather than a boolean, and it
replaces the native once listener option:
on('video', 'ended', finishPlayback, {
at: 'elements',
freq: 'once',
})'always' is the default. 'once' means the declaration may invoke its
handler at most once during the lifetime of this mounted component — not once
per matching element, native listener, render, or reattachment. Lumi consumes
the binding immediately before invoking the handler, so reentrant dispatch
cannot reach it again and a throwing handler stays consumed. Consuming an
element binding removes its listeners from every matching element; consuming a
component binding removes only its own route from the shared router. A new
mount of the same declaration starts a fresh once lifetime.
Lumi never passes a native once to a shared router, which would remove the
whole router after one event, and never relies on per-element native once,
which would mean once per element instead of once per declaration.
Selectors are scoped to the component's current DOM, include the component root, and follow open Shadow DOM in the same way as scalar bindings. A component may own more than one event tree, so Lumi maintains compatible routers in reachable open shadow roots too, and still invokes a binding at most once per native event. Closed shadow roots stay encapsulated.
Parent selectors do not match inside a child component's owned subtree; the child mount container itself remains available to the parent. Unmount disconnects every router, removes every element listener, and releases the handler, element, and once state. A handler may unmount its own component, after which the remaining bindings of that component are not invoked.
A routed handler that throws is reported through the host's uncaught-error reporting rather than aborting the other bindings sharing its router. Element listeners keep native listener error behavior.
Lumi manages events only on DOM it owns. window, document, media queries,
and sockets remain ordinary addEventListener() subscriptions owned by the
application.
There is no synthetic event object, event pooling, dynamic function lookup, or
automatic update: the application handler owns its state transition and calls
mounted.update() when needed.
repeat creates one occurrence of the matched element for each projected
array entry. An empty array produces zero elements. An internal non-element
range anchor lets later updates add elements at the same location. A nullish
or non-array result is a recoverable no-op that preserves the region.
repeat('.item', ({data}) => data.items)
text('.item', ({item}) => item.name)For a local reading order, repeat can take its built-in DOM bindings as the
last argument. Without a key projection, this is the third argument. These
bindings resolve their selectors only inside the repeated template element.
Their projections receive that occurrence's context. The repeated element
participates in matching, so :scope selects that element itself:
repeat('.item', ({data}) => data.items, [
text('.name', ({item}) => item.name),
])on and child remain component-level declarations. A repeat binding list
accepts repeat, text, prop, attr, classToggle, and style; all of
them receive the repeated occurrence's context.
Flat bindings remain useful when the template position is already clear:
repeat('.item', ({data}) => data.items)
text('.item .name', ({item}) => item.name)Here context follows the matched DOM position. The nested form instead makes
the owning repeat explicit, so a local .name cannot also match a .name
elsewhere in the component.
<ul>
<li class="item">Default item</li>
</ul>Each entry establishes structural context and preserves the element's descendants. Nested arrays remain ordinary item values until a nested repeat consumes them:
repeat('.group', ({data}) => data.groups)
repeat('.name', ({item: group}) => group)
text('.name', ({item: person}) => person.name)<section class="group">
<span class="name">Default name</span>
</section>Every repeated occurrence has a positional coordinate such as [1, 2].
Ragged nested arrays are valid, and an empty inner array removes only the
elements at that inner level.
By default, Lumi reconciles repeated elements by array position. Reordering changes the data represented by existing positions. Existing positions retain their native DOM nodes. Appends create trailing nodes, and truncation removes trailing nodes.
An explicit key projection keeps an occurrence with an application item when its position changes:
repeat(
'.item',
({data}) => data.items,
({item}) => item.id,
[text('.name', ({item}) => item.name)],
)The key projection receives the new occurrence context. It does not receive
an element because Lumi must compute the key before it selects an existing
element. Lumi compares keys with the same equality rules as a JavaScript
Map. Each key must be unique in its repeat region.
Lumi stores keys in internal occurrence state. It does not add key attributes
to the DOM. An application can use attr if it must expose an item identifier
in markup. Lumi does not infer keys from key or id properties, or from
object identity.
Array cardinality cannot replace the mounted component root, whose public
boundary is one persistent Element. It applies to descendants of that root.
The repeatable target must exist in the component template to provide a
pristine element to clone. Projection and coordinate validation complete
before the live DOM changes.
repeat separates positional cardinality from text. Its projection receives
the nearest occurrence context and returns the items at that template
location. text receives the same context and owns only textContent:
repeat('.group', ({data}) => data.groups)
repeat('.person', ({item}) => item.people)
text('.name', ({item}) => item.name)
text('.currency', ({data}) => data.currency)The context contains data, item, index, path, and parent. At the
component root, item === data, index === 0, path is empty, and parent
is null. Every repeat preserves data, sets item to the current entry, and
extends the positional path.
Only repeat interprets an array structurally. Nested arrays remain ordinary
items until a nested repeat consumes them. A non-array repeat result or a
non-text text result is a recoverable no-op: Lumi preserves the existing DOM
and emits one development warning per mounted declaration and received value
category.
The smallest convention can map application-owned metadata directly to presentation object properties:
import {text} from '@thlib/lumi'
function bindFields() {
return text(
'[data-field]',
({data}, el) => data[el.dataset.field],
)
}<output>count is <span data-field="count">0</span></output>That convention belongs entirely to the application. If it projects a missing,
undefined, or null field, Lumi leaves the corresponding DOM state
unchanged.
The JSONPath counter chooses a more elaborate option by wrapping an external
RFC 9535 implementation in examples/data-path.js. The projection receives
the matched element and injects the application's path behavior:
import {repeat, text} from '@thlib/lumi'
import {jsonPath} from './examples/data-path.js'
function bindDataPaths() {
return [
repeat('[data-repeat]', ({item}, el) => {
return jsonPath(item, el.dataset.repeat)
}),
text('[data-text]', ({item}, el) => {
return jsonPath(item, el.dataset.text)[0]
}),
]
}
const definition = component({
template,
bindings: [
...bindDataPaths(),
],
})The path behavior belongs to JSONPath, not Lumi. The adapter caches parsed
queries and preserves JSONPath's nodelist cardinality. repeat consumes the
whole nodelist while text takes the first result. Paths are evaluated
against the current item, which is the component data outside a repeated
region. Another application may use direct projections, external binding
maps, another metadata convention, or no helper at all.
These factories compose Lumi's public functions; they do not add a second component or rendering lifecycle. Lumi does not reserve their attributes, inspect their path strings, or require applications to use such a convention.
child mounts one nested component into the selected container. The
projection selects the child's data from the parent snapshot:
child('.profile-slot', profileComponent, page => page.profile)The child root is mounted once and persists across parent renders. The container must not initially contain another element.
A binding writes only the DOM sink named by that binding:
- A
repeatdeclaration owns element cardinality and occurrence identity. - A
textdeclaration owns scalartextContent. - A property binding owns one property.
- A class binding owns one class token.
- A style binding owns one style property.
- A child binding owns the element children of its container.
Built-in scalar declarations may overlap. Different sinks on the same element are independent. When multiple built-in declarations write the same sink on the same element, declaration order is deterministic and the last declaration wins. Both writes may occur; authors do not need to deduplicate overlapping selector sets.
Properties and attributes remain explicitly different sink kinds even where
the browser reflects one into the other, such as disabled. If a component
deliberately binds both, they are replayed in declaration order and native DOM
reflection determines the result.
Content rules are the exception to ordinary declaration ordering: an ancestor content rule is prepared and committed before rules selecting its descendants. Rules at the same element retain declaration order. Selectors are re-resolved against the prepared parent result before descendant projections run.
Parent rules do not select inside a subtree owned by child.
Writing content on that container or one of its ancestors would detach a live
nested component, so the update is rejected during preparation.
Unbound state remains under browser or application ownership. Imperative code should not write a property or subtree already owned by a binding.
An error is thrown when:
-
A template has zero or multiple root elements.
-
A
childcontainer selector does not match within the component root. Unmatched scalar selectors are no-ops. -
A child container already contains an element.
-
A parent content rule would replace a child subtree.
-
A property binding attempts to replace its mounted component root through
outerHTML. -
An update is recursive or targets an unmounted or faulted component.
-
A DOM property cannot be assigned.
-
A generic binding targets an event handler or
srcdoc. -
An
innerHTMLorouterHTMLprojection is not genuineTrustedHTML, or the mounted document cannot authenticate it. -
A keyed repeat produces a duplicate key. Lumi validates all keys before it changes the live DOM.
-
An event declaration has a non-string type or selector, a handler that is not callable, options that are not an object, an unsupported option name, or an invalid
at,capture,passive, orfreqvalue. The message identifies the event type, the selector, and the invalid property:Invalid Lumi event binding for "ended" on "video": options.at must be "component" or "elements"
An event selector is validated when the component connects, even when it currently has no matches, so an invalid declaration fails mount atomically.
If application projection code throws, Lumi rethrows an error that identifies
the binding kind, selector, and one-based matched position. A key projection
error also identifies the item position. The original thrown value is
available as its cause.
Selector syntax errors remain native DOMExceptions from the browser.
The current implementation targets modern browsers with native support for ES
modules, <template>, Shadow DOM, selectors, importNode, classList,
addEventListener, Map, Set, and Reflect.
Compatibility shims are not included in the first implementation. They can be added below the public API without changing component definitions.