Template calls that remember their place and update from there.
Temtie is an immediate-mode template runtime. You call tagged templates from
ordinary JavaScript, those calls record a target's next shape, and commit()
applies the recorded pass to the medium.
No virtual tree, no diff over trees, no compiler, no proxies:
a template call site is its identity, and plain control flow decides what renders.
import { mount, capture, commit, reset } from 'temtie/core.js';
import { dom } from 'temtie/builder/dom.js';
const app = mount(document.body, { builders: { html: dom } });
let items = ['first', 'second'];
function render() {
const list = capture();
app.html`
<section>
<h1>Items</h1>
<ul>${list}</ul>
</section>
`;
reset(list); // emptiness is staged too: no items, no rows
for (const item of items) {
list.html`<li #key${item}>${item}</li>`;
}
commit(app);
}
render();The render language is JavaScript: loops, branches, functions, generators — whatever produces the calls produces the page.
- Call sites keep identity. The same tagged-template call site visited
again finds its own segment of the medium and updates it in place. In
loops,
#key${value}pins a segment to a value instead of a position. - Render records. Channel calls stage a target's next pass. Nothing touches the medium while you render — a pass is data until it commits.
- Commit applies, and settles everything.
commit(target)flushes the staged pass, parents before children, and one law covers every ending: what a pass did not stage leaves the medium and is disposed.
Between record and commit there are two controls: skip() voids a staged
pass while the committed output stands, and reset() stages emptiness.
mount(container, { builders }) creates a target seated on a container.
const app = mount(document.body, { builders: { html: dom } });
app.html`<main>Hello</main>`;
commit(app);capture() creates a free target reference. It gets builder channels when it
is interpolated into a template slot.
const body = capture();
app.html`<main>${body}</main>`;
body.html`<h1>Dashboard</h1>`;
commit(app);Captured targets are the composition primitive. They let a parent own layout while a child owns its own update cycle:
const shell = mount(document.body, { builders: { html: dom } });
const clock = capture();
shell.html`<header>${clock}</header><main>...</main>`;
commit(shell);
setInterval(() => {
clock.html`<time>${new Date().toLocaleTimeString()}</time>`;
commit(clock);
}, 1000);Commit flushes staged targets downward from the one passed, so the shell does not re-run when the clock commits.
One consequence of "commit settles everything": disposal is scoped to staged
passes, so a target you do not call keeps its committed output. A loop that
may run zero times stages nothing on its target — stage emptiness first, as
the opening example does with reset(list), and the loop's calls rebuild
over it while reusing every segment they borrow.
Child slots write child content:
root.html`<p>${message}</p>`;Attribute slots write properties or attributes:
root.html`<button disabled=${locked}>Save</button>`;Spread slots write prop bags:
root.html`<input ...${props}>`;Use a fresh spread object when values change; mutating and reusing the same bag is invisible to the diffing step.
Captures can occupy child slots and prop slots, which allows passing content as props:
const pane = capture();
root.html`<section>${pane}</section>`;
pane.html`<p>child target</p>`;
root.html`<widget view=${pane}></widget>`;
// the prop receives pane's outputSpread slots cannot host targets or value contracts.
skip(target) voids an uncommitted pass. The committed output remains, and
nothing staged by the skipped pass is released.
try {
content.html`<article>${smh}</article>`;
thisThrows();
} catch (error) {
skip(content);
errors.html`<pre>${error.message}</pre>`;
content.html`${smh}`; // nested targets can be recovered
}
commit(app);reset(target) stages emptiness. The next commit(target) clears that
target. If you render into it again before committing, segments can still be
borrowed — reset-then-render is a full rebuild that keeps identity.
reset(list);
commit(list); // list is now emptyTemtie renders through builders and never touches a medium directly. A
builder owns parsing products, node and range movement, property writes, and
child writes — the whole contract is the Builder interface in types.d.ts.
Anything that can create, move, and write nodes can be a universe.
import { DOMBuilder, SVGBuilder, dom, svg } from 'temtie/builder/dom.js';
const app = mount(document.body, {
builders: { html: dom, svg },
});
const frame = document.querySelector('iframe');
const frameApp = mount(frame.contentDocument.body, {
builders: {
html: new DOMBuilder(frame.contentDocument),
svg: new SVGBuilder(frame.contentDocument),
},
});
app.html`<button>${label}</button>`;
app.svg`<svg><circle r=${4}></circle></svg>`;
commit(app);Each builder key becomes a channel on every target in that universe.
DOMBuilder.voidElements and SVGBuilder.voidElements are mutable sets. Copy
one in a subclass when a document vocabulary needs its own void elements.
The markup medium follows the same split: MarkupBuilder is the class and
markup is its ready-to-use default instance.
The parser is intentionally small and strict — malformed templates throw a
SyntaxError at compile, naming the offense.
- Dynamic attributes are unquoted:
class=${name}. - Quoted dynamic attributes are rejected:
class="${name}". - Spread props use
...${props}inside an opening tag. #key${value}keys the segment; it belongs on a root element of the template and never reaches the medium.- Text in child slots is text, not HTML.
- Whether
<input>may omit its closing is the builder's call (isVoidElement);DOMBuilderknows HTML's void set.
Templates compile once per call site per builder and are cached.
The runtime narrates itself. debug.js is an event hub every phase reports
to — mount, call, stage, claim, write, move, drop, commit, dispose. While
nothing listens, the guarded event objects are not created.
import debug from 'temtie/debug.js';
const off = debug.on((event) => console.log(event.seq, event.type));A production entry can permanently detach observers and reject future ones for that module instance:
debug.disable();Events carry seq and cause, so their synchronous contexts nest. Explicit
start/end pairs can be measured: meter() turns them into
performance.measure entries for the profiler.
debug.meter({ 'temtie:commit': ['commit', 'settled'] });The full event and hub types are in types.d.ts.
Creates a mounted target and returns its instance. builders maps channel
names to builder objects.
Creates a free target reference. It receives channels once it is interpolated into a slot that can host it.
Settles the recorded pass for a target and staged or moved children under it.
Voids the target's currently staged pass. Committed output stands.
Stages an empty pass. The next commit clears the target.
The typed surface in types.d.ts also covers the Builder contract, the
debug hub, and the template compiler (compileTemplate, parseTemplate,
clearTemplateCache from parser.js).
MIT