Complete documentation

Build reactive HTML entirely from Node.js.

A practical guide to every major feature, its parameters, return values, defaults, and the browser/server boundary.

Quick start

npm install @trebor/buildhtml
const { page } = require('@trebor/buildhtml');

const doc = page('Hello');
doc.h1().text('Hello world');
doc.p('A complete page from one focused Node.js API.');

const html = doc.render();

page(title, options?) creates a configured Document with a title, responsive viewport, CSS reset, and language. Use new Document(options?) when you want to configure those manually.

Module formats

The package ships CommonJS and ESM. Every named export is importable from the ESM entry point:

import { page, Document, metrics } from '@trebor/buildhtml';

const doc = page('Hello');
doc.h1('Hello from ESM');

responseCache is not a named ESM export. It is a live accessor that returns a new cache whenever configure({ cacheLimit }) changes the limit, and a static ESM binding would freeze the value captured at import time. Reach it through the default export instead: buildhtml.responseCache.

Import a single area when you do not need the whole surface:

const { renderTemplate } = require('@trebor/buildhtml/template');
const { createCachedRenderer } = require('@trebor/buildhtml/middleware');
const { components } = require('@trebor/buildhtml/components');
const { compileLiveList } = require('@trebor/buildhtml/live');
const { configure } = require('@trebor/buildhtml/config');
const { metrics } = require('@trebor/buildhtml/metrics');

Those subpaths and the package root are the supported entry points. Reaching into lib/ directly is not supported and fails to resolve. Node.js 18 or newer is required.

OptionTypeDefaultPurpose
langstring'en'Root HTML language.
viewportbooleantrueSet false to omit the responsive viewport meta tag.
resetCssbooleantrueSet false to omit the built-in reset.
noncestringnoneCSP nonce for generated inline scripts and styles.
cachebooleanfalseEnables document render caching when a cache key is also set.
cacheKeystringnoneStable key for the document render cache.

Accessible field helper

field(label, options?) creates a labelled input with a unique default ID and returns { group, label, input }. Separate fields can share one state key without duplicating their HTML IDs.

doc.states({ email: '' });
const { input } = doc.field('Email', {
  type: 'email',
  name: 'email',
  bind: 'email',
  attrs: { autocomplete: 'email', required: true }
});
input.placeholder('you@example.com');
OptionPurpose
typeInput type; omitted from the markup when not given.
idExplicit input and label target ID; otherwise generated uniquely.
nameSubmitted form name.
bindState key for two-way input binding.
groupClassWrapper class; no class is added when not given.
attrsAdditional input attributes.

The form and layout helpers build structure only. They add no class, style, or attribute you did not pass, so nothing collides with your stylesheet. The one exception is the generated ID pairing each <label for> with its input.

1. The mental model

buildhtml is a compiler, not a framework. You describe a page with JavaScript on the server, and you get a finished HTML string.

your server JS  →  buildhtml  →  complete HTML  +  optional generated browser JS

Three things follow from that, and they explain most of the API:

  • A page that uses no reactive API ships no JavaScript. Not a small runtime — none.
  • A page that does use reactivity carries only the code for the bindings it used, generated for that page. There is no shared bundle.
  • Callbacks are serialized as source text, so they cannot capture anything from your server file. Section 12 covers what that means in practice.

Install it:

npm install @trebor/buildhtml

Node 18+. Works from both CommonJS and ESM.

2. Documents and elements

There are two ways to start a page.

page(title, options?) gives you a document with sensible defaults — a charset, a viewport meta tag, and a small CSS reset:

const { page } = require('@trebor/buildhtml');

const doc = page('My first page');
doc.h1('Hello');
doc.p('Rendered on the server.');

const html = doc.render();

new Document(options?) gives you a bare document you configure yourself:

const { Document } = require('@trebor/buildhtml');

const doc = new Document();
doc.title('Manual setup').lang('en').viewport().resetCss();
doc.h1('Hello');

const html = doc.render();

title is a method, not a constructor option. new Document({ title: 'X' }) is silently ignored — the option does not exist. Use doc.title('X'). The TypeScript declarations reject the object form; plain JavaScript will not warn you.

Every call that creates an element returns that Element, so you can keep working on it:

const { page } = require('@trebor/buildhtml');

const doc = page('Elements');
const box = doc.div();          // returns the <div> Element
box.addClass('card');
box.child('h2').text('Title');  // child() creates and returns a nested element
box.child('p').text('Body');

const html = doc.render();

doc.div() creates a top-level element in the document body. element.child() creates one inside that element. Both return the new element.

3. Tag shortcuts

Rather than child('h1') everywhere, 41 common tags have a shortcut. They exist on both Document and Element:

div span section header footer main nav article aside form
ul ol table thead tbody tfoot tr li th td caption
details summary dialog pre code blockquote
h1 h2 h3 h4 h5 h6
p strong small label legend em b i

Each takes either text or a setup function:

const { page } = require('@trebor/buildhtml');

const doc = page('Shortcuts');

doc.h1('Text form');                       // <h1>Text form</h1>

doc.section((s) => {                       // setup-function form
  s.h2('Nested');
  s.p('The callback receives the new element.');
});

const html = doc.render();

The setup-function form is how you build depth without a pile of intermediate variables.

A few tags take arguments instead of text, because they need them:

const { page } = require('@trebor/buildhtml');

const doc = page('Special shortcuts');
doc.a('/about', 'About us');               // a(href, text)
doc.img('/logo.png', 'Company logo');      // img(src, alt)
doc.button('Save');                        // button(text)
doc.input('email', { name: 'email' });     // input(type, attrs)
doc.textarea({ name: 'bio', rows: 4 });    // textarea(attrs)
doc.select([                               // select(options, attrs)
  { value: 'uk', text: 'United Kingdom' },
  { value: 'fr', text: 'France', selected: true },
]);
doc.hr();
doc.br();

const html = doc.render();

select() options are objects with value, and optionally text, selected, and disabled. A nullish entry is skipped.

4. Text, escaping, and raw HTML

text() escapes. This is the default because it is the safe thing:

const { page } = require('@trebor/buildhtml');

const doc = page('Escaping');
doc.p().text('<script>alert(1)</script>');

const html = doc.render();
// contains &lt;script&gt;, never a live <script> tag

append() also escapes. appendUnsafe() does not — use it only for markup you produced yourself:

const { page } = require('@trebor/buildhtml');

const doc = page('Raw');
const box = doc.div();
box.append('<em>escaped</em>');        // shows the tags as text
box.appendUnsafe('<em>trusted</em>');  // renders as emphasis

const html = doc.render();

The name is deliberately unpleasant. If the string came from a user, a database, or an API, use text() or append().

5. Attributes

attr(name, value) sets anything, and there are shortcuts for the common ones:

const { page } = require('@trebor/buildhtml');

const doc = page('Attributes');

doc.div()
  .id('main')
  .attr('data-role', 'container')
  .setAttrs({ 'data-a': '1', 'data-b': '2' })   // several at once
  .data('user', '42')                            // data-user="42"
  .aria('label', 'Main content');                // aria-label

doc.input('text')
  .name('email')
  .placeholder('you@example.com')
  .autocomplete('email')
  .required()                                    // boolean attributes take no argument
  .maxLength(120);

const html = doc.render();

Boolean attributes — required, readonly, autofocus, multiple, checked, selected, disabled, hidden — are called with no arguments.

Event handler attributes are rejected. attr('onclick', ...) never reaches the output; use .onClick() from section 13 instead.

URL-bearing attributes (href, src, action, formaction, cite, poster, xlink:href) are sanitized, so a javascript: URL becomes #.

6. Classes and CSS

Classes have several helpers, and the argument order of two of them catches people out:

const { page } = require('@trebor/buildhtml');

const doc = page('Classes');
const isActive = true;

doc.div()
  .addClass('card')
  .toggleClass(isActive, 'is-active')      // (condition, name) — condition FIRST
  .classIf(isActive, 'on', 'off')          // (condition, trueClass, falseClass)
  .classMap({ wide: true, tall: false });  // { className: condition }

const html = doc.render();

toggleClass(condition, name) takes the condition first. Calling toggleClass('is-active', true) applies nothing at all, silently.

css() takes a style object and produces a scoped class, not an inline style. Identical rules are emitted once and shared:

It also accepts nested blocks: a key beginning with & is a selector pattern in which & becomes the generated class (&:hover, & .child, &.active), and a key beginning with @media, @supports or @container wraps its block in that at-rule. Both flatten into separate rules rather than emitting native CSS nesting, so the output parses everywhere. Adding a nested block changes the generated class, because the class covers the whole rule set.

const { page } = require('@trebor/buildhtml');

const doc = page('Scoped CSS');
doc.div().css({ color: 'crimson', padding: '16px' });
doc.div().css({ color: 'crimson', padding: '16px' });  // same rules, same class

const html = doc.render();
// one generated class, one rule in <style>, both divs share it

Class names are deterministic. A generated class name is a hash of its declarations, so the same rules always produce the same name — across elements, across renders and across processes. Declaration order therefore does not change it: { color, margin } and { margin, color } are one class. Ordering is canonical between property families and preserved within one, so { marginTop, margin } keeps its order — reversing it would invert which declaration wins the cascade.

Identical rules are emitted once. Rules are stored under their class name, so a thousand components sharing a rule emit it once, and a rule shared between an element with extra rules and one without is still emitted once.

Validation. Property names, selectors, pseudo-class arguments and at-rule preludes are validated before reaching the stylesheet. Anything able to end a declaration or close the <style> element is dropped and reported in development, never rewritten — silently removing a ; would emit a declaration you did not write. Values are sanitised rather than dropped.

For an actual inline style, use style(property, value). For document-wide CSS, use the head methods in section 10.

State-dependent styling has its own helpers:

const { page } = require('@trebor/buildhtml');

const doc = page('Pseudo states');
doc.button('Hover me')
  .css({ background: '#eee' })
  .hover({ background: '#ddd' })
  .focusCss({ outline: '2px solid #09f' })
  .active({ transform: 'scale(0.98)' })
  .media('(min-width: 600px)', { padding: '12px 24px' });

const html = doc.render();

7. Layout helpers

These emit the display mode they are named for, plus whatever you pass — and nothing else.

const { page } = require('@trebor/buildhtml');

const doc = page('Layout');

doc.grid(3, [
  (cell) => cell.p('One'),
  (cell) => cell.p('Two'),
  (cell) => cell.p('Three'),
], '16px');                                  // grid(columns, items, gap)

doc.flex([
  (col) => col.p('Left'),
  (col) => col.p('Right'),
], { gap: '8px', align: 'center', justify: 'space-between' });

doc.stack([(el) => el.p('Top'), (el) => el.p('Bottom')], '8px');  // column flex
doc.row([(el) => el.p('A'), (el) => el.p('B')], '8px');           // row flex
doc.center((el) => el.p('Centred'));
doc.container((el) => el.p('Constrained'), '960px');
doc.spacer('32px');
doc.divider({ color: '#e0e0e0', margin: '16px 0' });
doc.columns(2, [(c) => c.p('Col 1'), (c) => c.p('Col 2')], '16px');

const html = doc.render();

Since 2.0.0 these inject no design values. grid() has no default gap, container() no default width, divider() no default colour. If you call divider() with no options you get a plain <hr>. Pass what you want.

8. Data helpers: lists and tables

list(items, renderer?, tag?) builds a list, one <li> per item:

const { page } = require('@trebor/buildhtml');

const doc = page('Lists');

doc.list(['Alpha', 'Beta', 'Gamma']);              // <ul> with three <li>
doc.list(['One', 'Two'], null, 'ol');              // ordered instead

doc.list(
  [{ name: 'Ada', role: 'Engineer' }],
  (li, item) => {                                   // (li, item, index)
    li.strong(item.name);
    li.span(` — ${item.role}`);
  }
);

const html = doc.render();

ol() is a tag shortcut and takes text, not an array. ol(['a','b']) renders <ol>a,b</ol>. To get list items from an array, use list(items, null, 'ol').

dataTable(headers, rows, options?) accepts two row shapes:

const { page } = require('@trebor/buildhtml');

const doc = page('Tables');

doc.dataTable(['Name', 'Role'], [           // array rows: positional cells
  ['Ada', 'Engineer'],
  ['Grace', 'Admiral'],
]);

doc.dataTable(['name', 'role'], [           // object rows: headers select keys
  { name: 'Ada', role: 'Engineer' },
]);

doc.dataTable(null, [                       // autoHeaders: keys of the first row
  { name: 'Ada', role: 'Engineer' },
], { autoHeaders: true, class: 'data-table' });

const html = doc.render();

A null row renders an empty <tr>. A non-array headers value is ignored.

each() and when() help you stay in one chain:

const { page } = require('@trebor/buildhtml');

const doc = page('Control flow');
const items = ['a', 'b'];
const showFooter = true;

doc.each(items, (self, item, index) => self.p(`${index}: ${item}`));
doc.when(showFooter, (self) => self.footer((f) => f.small('Footer')));

const html = doc.render();

9. Forms

field() is the most complete helper. It wires the label to the input with a generated id and hands back all three elements:

const { page } = require('@trebor/buildhtml');

const doc = page('Forms');

doc.form((form) => {
  // field() returns { group, label, input }
  const email = form.field('Email address', { type: 'email', name: 'email' });
  email.input.required().placeholder('you@example.com');

  const bio = form.field('Short bio', { type: 'text', name: 'bio' });
  bio.group.addClass('form-row');

  form.button('Save').attr('type', 'submit');
});

const html = doc.render();

The other form helpers, each returning the wrapper element:

const { page } = require('@trebor/buildhtml');

const doc = page('Form helpers');

doc.formGroup('Username', 'text', { name: 'username' });
doc.checkbox('terms', 'I accept the terms', false);   // (name, label, checked)
doc.radio('plan', [                                    // (name, options)
  { value: 'free', label: 'Free' },
  { value: 'pro', label: 'Pro', checked: true },
]);
doc.fieldset('Address', (fs) => {
  fs.formGroup('Street', 'text', { name: 'street' });
});
doc.hiddenInput('csrf', 'token-value');

const html = doc.render();

These add no class names. Since 2.0.0 the form helpers emit structure and the generated label[for]/id pairing only. Style them via groupClass on field(), or addClass() on the returned element.

10. The document head

const { page } = require('@trebor/buildhtml');

const doc = page('Head');

doc.title('Page title')
  .lang('en')
  .charset('UTF-8')
  .favicon('/favicon.ico')
  .canonical('https://example.test/page')
  .meta('description', 'What this page is about')
  .noindex();

doc.ogTags({ title: 'Share title', description: 'Share text' });
doc.twitterCard({ card: 'summary' });
doc.jsonLd({ '@type': 'Article', headline: 'Example' });

doc.preconnect('https://fonts.example.test');
doc.preload('/fonts/body.woff2', 'font');
doc.prefetch('/next-page.js');

doc.h1('Body content');
const html = doc.render();

Document-wide styling also lives here:

const { page } = require('@trebor/buildhtml');

const doc = page('Document styling');

doc.cssVars({ brand: '#0099ff', text: '#111' });
doc.globalCss('body', { margin: 0, color: 'var(--text)' });
doc.sharedClass('badge', { padding: '2px 6px', borderRadius: '4px' });
doc.sharedClass('pill', { display: 'inline-block' });
doc.keyframes('fade', { from: { opacity: 0 }, to: { opacity: 1 } });
doc.mediaQuery('(min-width: 768px)', { body: { fontSize: '18px' } });
doc.darkMode({ body: { background: '#111', color: '#eee' } });
doc.print({ nav: { display: 'none' } });

doc.h1('Styled');
const html = doc.render();

sharedClass() takes a bare class name, not a selector — it adds the leading dot itself. A name that is not a valid CSS identifier is dropped rather than rewritten, because silently turning .badge into badge would invent a rule you did not write. In development the rejection is reported as [sharedClass] Ignored invalid CSS name; production stays quiet. globalCss() and mediaQuery() do take full selectors.

defineClass() is deprecated: with no third argument it is sharedClass(), and with true it is globalCss(). Call the one you mean.

11. Tree operations

Every element can be moved, wrapped, or removed — at the top level or nested:

const { page } = require('@trebor/buildhtml');

const doc = page('Tree');

const anchor = doc.div().id('anchor');
anchor.before('before text');       // a string sibling is escaped
anchor.after('after text');

const wrapper = anchor.wrap('section');   // returns the NEW wrapper
wrapper.addClass('wrapped');

const temp = doc.div().id('temp');
temp.remove();                       // gone from the output

const box = doc.div();
box.child('p').text('one');
box.child('p').text('two');
box.findAll('p').length;             // 2
box.find('p');                       // first match
box.findById('anchor');              // by id, within this subtree

const html = doc.render();

clone() copies a subtree, empty() clears children, replaceWith() swaps a tag in place, and prependChild()/insertAt() control position.

Two read-only helpers answer questions about the tree before you render it: isEmpty() and elementCount(). Call them before render(), which clears the body.

const { Document } = require('@trebor/buildhtml');

const doc = new Document();
doc.isEmpty();        // true
doc.elementCount();   // 0

doc.p('one');
doc.p('two');
doc.isEmpty();        // false
doc.elementCount();   // 2

12. Portals and slots

Two ways to put content somewhere other than where you wrote it.

portal(targetId) renders the element in place, then moves it under another element in the browser once the page loads. Use it for dialogs, toasts, and dropdowns that must escape a clipping or stacking context but belong with their trigger in your source.

const { page } = require('@trebor/buildhtml');

const doc = page('Portal');

doc.div().id('dialog').text('I move on load')
   .portal('overlay-root');
doc.div().id('overlay-root');

const html = doc.render();

The element is served inside its original parent, so the page is complete without JavaScript; the generated script then appends it to the target. If the target id does not exist at load time the element simply stays where it was rendered — no error, no missing content.

slot(name) marks a placeholder, and fillSlot(name, fn) fills it later. This lets you build a structure once and populate it further down, without holding a reference to the inner element.

const { Document } = require('@trebor/buildhtml');

const doc = new Document();

const card = doc.section().addClass('card');
card.h2('Title');
card.div().slot('content');

// Later, without a reference to the div itself:
card.fillSlot('content', (slot) => {
  slot.p('Filled in afterwards');
});

const html = doc.render();

A slot renders as an ordinary element carrying data-slot="content", so it is styleable and inspectable. fillSlot() searches the subtree it is called on, which is why card.fillSlot(...) finds a slot nested inside card.

13. Reusable templates

template(name, fn) registers a named builder on the document, and useTemplate(name, vars) runs it. It is the lightest way to repeat a structure without registering a component.

const { Document } = require('@trebor/buildhtml');

const doc = new Document();

doc.template('greeting', (d, vars) => {
  d.p('Hello ' + vars.name);
});

doc.useTemplate('greeting', { name: 'Ada' });
doc.useTemplate('greeting', { name: 'Grace' });

// <p>Hello Ada</p><p>Hello Grace</p>
const html = doc.render();

The builder receives the document and the variables object. Templates are scoped to the document they were registered on, so they do not leak between requests. Reach for component() instead when you want something registered globally and reusable across documents.

14. Serializing a document

toJSON() turns a document into a plain, serializable definition, and fromJSON() rebuilds one from it. Use it to cache a built page, store a layout, or hand a page definition between processes.

const { Document } = require('@trebor/buildhtml');

const doc = new Document();
doc.h1('Report');
doc.p('Body');

// A plain object, safe to JSON.stringify.
const definition = doc.toJSON();

const restored = new Document();
restored.fromJSON(definition);
// <h1>Report</h1><p>Body</p>
const html = restored.render();

State, bindings, events, and lifecycle hooks all survive the round trip — the restored document compiles the same client script, apart from regenerated element ids. Call toJSON() before render(), which clears the body.

fromJSON() on input you did not produce is untrusted input. Serialized callbacks and compiled CSS are re-validated against the same rules a live function passes, and anything that fails is dropped and recorded. See the JSON reference for the full key list and the trustedCss rule.

15. Reactivity: state and bindings

This is the part worth understanding properly.

Declare state on the document, then bind elements to it:

const { page } = require('@trebor/buildhtml');

const doc = page('Counter');
doc.states({ count: 0 });

doc.h1().bind('count', (count) => `Count: ${count}`);
doc.button('+1').onClick(function () {
  State.count++;
});

const html = doc.render();

doc.states({ ... }) declares the browser-side state and its initial values. bind(key, fn) re-renders the element's text whenever that key changes.

How callbacks actually work

buildhtml calls Function.prototype.toString() on your callback and embeds the source text in the page. The closure is never sent. Three consequences:

Callbacks capture nothing from your server file.

const { page } = require('@trebor/buildhtml');

const doc = page('Capture');
doc.states({ count: 0 });

const serverValue = 42;
doc.button('Broken').onClick(function () {
  State.count = serverValue;      // ReferenceError in the browser
});

const report = doc.validate();
// report.warnings includes W_CALLBACK_CAPTURE naming "serverValue"

Run doc.validate() before shipping and it tells you exactly which variable is unavailable. Inside a callback you may use State, the callback's own parameters and locals, and standard browser globals.

Server secrets cannot leak through a callback. Closing over const apiKey embeds the identifier, never the value. The channel that does serialize server values is doc.states({ ... }) — those values are written into the page as data, so treat state as public.

There is no eval. A callback whose source contains eval(, Function(, document.cookie, .innerHTML =, or a </script> sequence is refused: the handler is dropped and an error logged rather than emitted.

The binding family

const { page } = require('@trebor/buildhtml');

const doc = page('Bindings');
doc.states({ name: 'Ada', visible: true, colour: 'crimson', count: 2 });

doc.p().bind('count', (n) => `Items: ${n}`);          // text
doc.div().bindShow('visible');                         // display on/off
doc.div().bindClass('visible', (v) => (v ? 'on' : 'off'));
doc.div().classWhen('name', 'Ada', 'is-ada');          // (key, expected, class)
doc.div().bindAttr('name', 'data-name');               // attribute
doc.div().bindStyle('colour', (c) => ({ color: c }));  // style object
doc.div().bindProp('name', 'title');                   // DOM property
doc.input('text').bindInput('name');                   // two-way

const html = doc.render();

bindInput is the two-way one: typing updates the state, and a state change updates the field.

16. Events

on(event, fn) handles anything; the on* shortcuts cover the common events:

const { page } = require('@trebor/buildhtml');

const doc = page('Events');
doc.states({ count: 0, text: '' });

doc.button('Click').onClick(function () { State.count++; });
doc.input('text').onInput(function (event) { State.text = event.target.value; });
doc.form((f) => f.button('Submit')).onSubmit(function (event) {
  event.preventDefault();
});
doc.div().on('mouseenter', function () { State.count = 0; });

const html = doc.render();

Shortcuts include onClick, onInput, onChange, onSubmit, onKeydown, onKeyup, onFocus, onBlur, onMouseenter, onMouseleave, onScroll, onDblclick, and the drag and touch events.

Handlers are attached with addEventListener in the generated script. No inline on* attribute is ever written into the HTML, which is what lets a strict CSP work.

setStateOnClick(key, value) is shorthand for the common case:

const { page } = require('@trebor/buildhtml');

const doc = page('Set state');
doc.states({ view: 'list' });
doc.button('Grid view').setStateOnClick('view', 'grid');

const html = doc.render();

17. Computed values and lifecycle

const { page } = require('@trebor/buildhtml');

const doc = page('Computed');
doc.states({ price: 10, quantity: 3 });

doc.span().computed(function () {
  return State.price * State.quantity;
});

doc.div()
  .onMount(function () { this.dataset.ready = 'true'; })
  .onUpdate('price', function (value) { this.dataset.price = String(value); })
  .onDestroy(function () { this.dataset.ready = 'false'; });

doc.oncreate(function () {
  State.price = 12;      // runs once, on page load
});

const html = doc.render();

onMount runs when the element enters the DOM and may return a cleanup function. onUpdate(stateKey, fn) runs when that key changes. Inside all of them this is the element.

18. Reactive lists

liveList(stateKey, itemFn, options?) renders an array and keeps it in sync. itemFn returns a node definition object, not an element:

const { page } = require('@trebor/buildhtml');

const doc = page('Live list');
doc.states({
  tasks: [
    { id: 1, title: 'Write docs', done: false },
    { id: 2, title: 'Ship it', done: true },
  ],
  query: '',
});

doc.input('search').bindInput('query');

doc.div().liveList('tasks', function (task) {
  return {
    tag: 'li',
    text: task.title,
    class: task.done ? 'done' : 'pending',
  };
}, {
  filter: function (task, state) {
    return !state.query || task.title.toLowerCase().includes(state.query.toLowerCase());
  },
  filterKeys: ['query'],
  sort: function (a, b) { return a.title.localeCompare(b.title); },
});

const html = doc.render();

The list renders server-side too, so the first paint is complete HTML. filter and sort run on both sides; filterKeys lists the extra state keys that should trigger a re-render.

A node definition supports tag, text, html, id, class, style, attrs, data, aria, css, on, onMount, and children.

css and style mean the same here as on an element: css compiles to a scoped class shared by every row that uses the same declarations, and style becomes an inline style attribute. The class is computed identically on the server and in the browser, so a row keeps its class when the list re-renders; rules the browser mints during a rebuild are appended to a <style id="_bh-live-css"> element.

19. Views and routing

For tab-like pages where the URL should not change, use views():

const { page } = require('@trebor/buildhtml');

const doc = page('Views');
doc.states({ activePage: 'overview' });

doc.nav((nav) => {
  nav.button('Overview').attr('data-view-nav', 'overview');
  nav.button('Reports').attr('data-view-nav', 'reports');
});

doc.section((s) => s.h2('Overview')).attr('data-view', 'overview');
doc.section((s) => s.h2('Reports')).attr('data-view', 'reports');

doc.views({ stateKey: 'activePage', default: 'overview', activeClass: 'active' });

const html = doc.render();

For real URLs, hashRouter() maps hash patterns to a state value:

const { page } = require('@trebor/buildhtml');

const doc = page('Hash routing');
doc.states({ view: 'home', routeParams: {} });

doc.nav((nav) => {
  nav.a('#home', 'Home');
  nav.a('#users/7', 'User 7');
});

doc.hashRouter({
  stateKey: 'view',
  default: 'home',
  routes: { 'home': 'home', 'users/:id': 'user', '*': 'not-found' },
  navSelector: 'nav a',
  activeStyle: { fontWeight: 'bold' },
});

const html = doc.render();

routes maps a pattern to a state value string, not a function. Named parameters land in the routeParams state key.

historyRouter() takes the same options plus base and linkSelector, and uses the History API instead of the hash:

const { page } = require('@trebor/buildhtml');

const doc = page('History routing');
doc.states({ view: 'home', routeParams: {} });

doc.nav((nav) => nav.a('/app/users/7', 'User 7').attr('data-route', ''));

doc.historyRouter({
  base: '/app',
  stateKey: 'view',
  default: '/',
  routes: { '/': 'home', '/users/:id': 'user', '*': 'not-found' },
});

const html = doc.render();

Your server must serve the same document for every path under base.

20. Components

A component is a function that receives an element and props:

const { page, components } = require('@trebor/buildhtml');

components.register('Card', function (el, props) {
  el.addClass('card');
  el.h3(props.title);
  el.p(props.body);
});

const doc = page('Components');
doc.component('Card', { title: 'Registered', body: 'Looked up by name.' });

doc.use(function (el, props) {          // inline, no registration
  el.strong(props.label);
}, { label: 'Inline component' });

const html = doc.render();

components also offers has, list, unregister, extend, and clear.

21. Serving pages

render() returns a string, so any server works. Native node:http:

const { createServer } = require('node:http');
const { page } = require('@trebor/buildhtml');

const server = createServer((req, res) => {
  const doc = page('Served');
  doc.h1('Hello from node:http');
  const html = doc.render();
  res.writeHead(200, {
    'Content-Type': 'text/html; charset=utf-8',
    'Content-Length': Buffer.byteLength(html),
  });
  res.end(html);
});

Express is the same idea:

const { page } = require('@trebor/buildhtml');

function handler(req, res) {
  const doc = page('Express');
  doc.h1('Hello from Express');
  doc.p(`You asked for ${req.path}`);
  res.type('html').send(doc.render());
}

Other outputs:

const { page } = require('@trebor/buildhtml');

const doc = page('Outputs');
doc.h1('Static file');

const html = doc.render();          // string
const json = doc.toJSON();          // serialisable definition

doc.renderStream() returns a Node Readable for streaming, and doc.save(path) writes the page to disk — useful for static site generation.

render() consumes the document. It clears the body and releases pooled elements, so call it once. Build a fresh document per request; that is also what makes concurrent requests safe.

For caching, createCachedRenderer from the middleware subpath returns an Express-style (req, res, next) middleware that caches rendered HTML by key.

22. Validation

validate() inspects a document before you send it and returns { valid, errors, warnings }:

const { page } = require('@trebor/buildhtml');

const doc = page('Validation');
doc.states({ count: 0 });
doc.img('/a.png', 'Meaningful alt text');
doc.button('Labelled button');

const report = doc.validate();
// report.valid === true, report.errors is empty

const html = doc.render();

It catches duplicate ids, unlabelled controls, images without alt, empty buttons, bindings against undeclared state keys, and — most valuably — W_CALLBACK_CAPTURE for callbacks referencing variables the browser will not have.

Call it before render(), since rendering clears the body.

23. Security notes

What the library does for you:

  • Text, attributes, CSS values, and serialised JSON are escaped.
  • URL attributes are sanitized; javascript:, vbscript: and data:text/html become #, including schemes split by tab, newline, or carriage return.
  • Inline on* attributes are never emitted, and attr('onclick', …) is rejected.
  • Callback sources are screened and parsed before they can reach a page.
  • Pass nonce and it is applied to generated <script> and <style> tags, so a strict CSP works without unsafe-inline:
const { page } = require('@trebor/buildhtml');

const doc = page('CSP', { nonce: 'per-request-nonce' });
doc.states({ n: 0 });
doc.button('+1').onClick(function () { State.n++; });

const html = doc.render();

What remains yours: choosing and sending the CSP header, authentication, authorization, input validation, and keeping secrets out of states().

24. Common mistakes

Calling render() twice. It clears the body; the second call returns a different, emptier document. Render once.

toggleClass('name', true). The condition comes first: toggleClass(true, 'name'). The wrong order applies nothing and reports nothing.

new Document({ title: 'X' }). Not an option. Use doc.title('X').

Capturing a server variable in a callback. It becomes a ReferenceError in the browser. Pass the value through states() instead, and run validate().

Putting a secret in states(). State is serialised into the page. It is public.

ol(['a', 'b']) expecting list items. Tag shortcuts take text. Use list(items, null, 'ol').

Expecting appendUnsafe() to escape. It does not. That is its entire purpose.

How it works

1. Describe

Build elements and behavior with the server-side JavaScript API.

2. Compile

render() escapes markup and compiles only the browser behavior the page uses.

3. Run

The browser receives complete HTML plus the state and event behavior used by the page.

Event handlers, computed bindings, lifecycle hooks, liveList callbacks, and oncreate() are serialized and run in the browser. They may use their arguments, this, browser globals, and State, but cannot capture server-side closure variables.
Server execution                    Browser execution
page(), elements, components   ->  rendered HTML
states(initialValues)          ->  reactive State proxy
bind(key, callback source)     ->  callback runs on state change
onClick(callback source)       ->  callback runs after interaction
server closure variables       X   unavailable unless passed as context

Generate and serve static HTML

Build once during server startup, write the result to disk, and let the server handle it like any other static file.

const fs = require('node:fs');
const path = require('node:path');
const express = require('express');
const { page } = require('@trebor/buildhtml');

const doc = page('Home');
doc.h1('Generated on server startup');

const publicDir = path.join(__dirname, 'public');
fs.mkdirSync(publicDir, { recursive: true });
fs.writeFileSync(path.join(publicDir, 'index.html'), doc.render());

const app = express();
app.use(express.static(publicDir));
app.listen(3000);

Generate per request when content depends on the request. Generate once when every visitor receives the same content.

Recommended larger-project structure

project/
|-- server.js          # HTTP routes, headers, authentication, responses
|-- ui/
|   |-- dashboard.js   # Page construction
|   |-- components.js  # Reusable server components
|   `-- styles.js      # Theme and shared styles
|-- routes/
|   `-- api.js         # JSON endpoints
|-- public/            # Static assets
`-- tests/             # Render, HTTP, and browser tests

BuildHTML constructs UI output. Authentication, authorization, request validation, headers, persistence, and API routing remain server responsibilities.

Document guide

CapabilityDocumentElementNotes
Tag shortcutsYesYesCreates a body element or nested child.
create(tag)YesYesCreates a body element or nested child and returns it.
build(definition)YesYesBuilds into the document body or the element's children.
child(tag)YesYesBody-creation alias on Document; nested-child alias on Element.
Metadata and global CSSYesNoConfigures the document head or whole page.
states(values)YesNoDeclares browser state.
Attributes and bindingsNoYesConfigures an individual element.

Create any HTML element

create(tag) returns the same chainable Element type returned by tag shortcuts. A document adds it to the body; an existing element adds it as a nested child.

const doc = new Document();
const card = doc.create('section')
  .id('account-card')
  .addClass('card')
  .css({ padding: '20px' });

card.create('h2').text('Account');
card.create('custom-status').attr('role', 'status').text('Ready');
CallDestinationReturns
doc.create(tag)Document bodyElement
doc.createElement(tag)Document bodyElement — deprecated, use doc.create(tag)
doc.child(tag)Document bodyElement — deprecated, use doc.create(tag)
element.create(tag)Nested childElement
element.child(tag)Nested childElement

create() does not have a special inner API. Its returned element supports content methods such as text and html; attributes, data, ARIA, and classes; styling and responsive rules; nested creation and build; events; reactive bindings; lifecycle hooks; tree operations; tag shortcuts; and form/layout helpers.

Prefer a named shortcut when the tag is known. Use create(tag) for dynamic tags, custom elements, or tags without a shortcut. Use escaped text() for ordinary content and reserve raw HTML methods for trusted values.

Head, metadata, and resources

doc.title('Dashboard')
  .viewport()
  .charset('UTF-8')
  .favicon('/favicon.ico')
  .canonical('https://example.com/dashboard')
  .preconnect('https://fonts.example.com')
  .addLink('/app.css')
  .addScript('/app.js');
MethodParametersPurpose
titletextSets the document title.
metaname, contentAdds a named meta tag.
addMetaattributesAdds a meta tag from a complete attribute object.
viewportcontent?Adds the responsive viewport meta tag or custom content.
charsetcharset?Sets the character encoding; default UTF-8.
addLinkhrefAdds a stylesheet link.
addScriptsrcAdds an external script.
preloadhref, as, type?Adds a preload resource hint.
prefetchhrefAdds a prefetch hint.
preconnecthrefAdds a preconnect hint.
ogTags{ title, description, image, ... }Adds Open Graph metadata.
twitterCard{ card, site, title, ... }Adds Twitter/X card metadata.
jsonLdschemaObjectAdds JSON-LD structured data.

The Head object

The methods above cover everything most pages need. doc.head exposes the underlying Head object for the rest — it is the same head those methods write to, so the two can be mixed freely. Every method returns the Head for chaining except hasStyles() and render().

doc.head.setNonce(res.locals.cspNonce);
doc.head.globalCss('.card', { padding: '16px' });

if (!doc.head.hasStyles()) doc.head.addStyle('body{margin:0}');
MethodParametersPurpose
setTitletextSets the title. Unlike doc.title() this writes the head directly and does not escape a second time on a fromJSON() round trip.
setCharsetcharsetSets the character encoding.
setNoncenonceSets the CSP nonce applied to generated style and script tags. createCachedRenderer's nonce option calls this per response.
addRawLinkhtmlRaw HTML inserted into the head verbatim, for link tags the typed helpers cannot express. Trusted content only.
globalCssselector, rulesAdds a global rule. The Head-level equivalent of doc.globalCss().
hasStylesnoneReturns true when the head holds any style content. Useful before adding a fallback stylesheet.
addMeta, addLink, addStyle, addScript, addClasssee aboveSame as the matching doc.* methods.
rendernoneRenders the head contents. Unlike doc.render(), this does not consume the document.

CSS and document attributes

doc.lang('en')
  .bodyClass('app')
  .bodyId('dashboard')
  .bodyCss({ backgroundColor: '#07111f' });

doc.cssVars({ primary: '#56d6b3', radius: '8px' });
doc.globalCss('body', { fontFamily: 'system-ui' });
doc.sharedClass('card', { padding: '16px', borderRadius: '8px' });

render() returns the complete HTML string. renderStream() returns a stream that renders on demand and applies real backpressure, but at top-level body node granularity: the head first, then one chunk per node in the body. A subtree renders in a single call, so a page assembled under one root element emits its whole body as one chunk and gains little over render() — put several top-level nodes in the body for incremental delivery. Streaming applies no production minification and does not use the response cache, and scoped <style> follows the body because the head has already been sent. clear() resets body content and per-render state but intentionally keeps head configuration.

Elements

const card = doc.section()
  .id('profile')
  .addClass('card', 'featured')
  .data({ userId: 42 })
  .aria({ label: 'User profile' })
  .css({ padding: '20px' });

card.h2().text('Grace Hopper');
card.a('/users/42', 'View profile');

Text and attribute values are escaped. Use raw(), rawHead(), html(), inlineScript(), or inlineStyle() only with trusted content.

CategoryMethodsParameters
Contenttext, append, appendUnsafe, html, emptyText or trusted HTML; append escapes, appendUnsafe and html do not; empty takes no parameters.
Attributesattr, setAttrs, data, ariaKey/value or an attribute object. class is the one name these ignore — classes come from addClass() and friends, so attr('class', …) has no effect. Inline on* handlers are refused; use on() or a shorthand.
ClassesaddClass, removeClass, toggleClass, classIf, classMapClass names and optional conditions.
CSScss, style, hover, focusCss, pseudo, mediaRules object; pseudo selector or media query where needed.
Treechild, build, prependChild, insertAt, before, after, remove, wrapChild definitions, child/replacement element, and optional index.
Queriesfind, findAll, findById, closestTag, selector-like query, or id depending on method.
Formsrequired, readonly, checked, min, max, patternBoolean state or form constraint value.

Components

const { components } = require('@trebor/buildhtml');

components.register('Card', (el, props) => {
  el.addClass('card');
  el.h2().text(props.title);
  el.p(props.body);
}, { tag: 'article' });

doc.component('Card', { title: 'Hello', body: 'Reusable server UI.' });
OperationParametersResult
components.registername, fn, options?Registers a named component. options.tag selects the root tag.
doc.componentname, props?, overrides?Builds a registered component. overrides.tag replaces the tag the component was registered with.
doc.usefn, props?, tag?Builds an inline component function. tag is the wrapper element name, default 'div'.
components.extendnewName, baseName, extendFn, options?Runs a base component, then its extension.
components.unregisternameRemoves a component registration.

Components execute on the server and produce ordinary elements. They do not independently hydrate or hold browser component state.

Declarative builder and JSON

doc.build({
  tag: 'section',
  class: ['card', 'featured'],
  css: { padding: '16px' },
  children: [
    { tag: 'h2', text: 'From an object' },
    { tag: 'p', text: 'Safe text content' }
  ]
});

Use the same definition format on an existing element to add nested children:

const card = doc.section().addClass('card');

card.build([
  { tag: 'h2', text: 'Nested definition' },
  { tag: 'p', text: 'Added inside the card.' }
]);

Node definition keys

Every node passed to build(), fromJSON(), or a body array accepts these keys. A node with no tag renders a div, and a plain string or number inside children becomes a text node.

KeyTypePurpose
tagstringHTML tag; defaults to div.
textstringText content, escaped.
htmlstringRaw HTML, not escaped. Trusted content only.
classstring or arrayClass names; a string may be space-separated.
classesarrayClass names as an array.
idstringElement id.
cssobjectScoped CSS, compiled into a generated class.
styleobjectInline style attribute.
attrsobjectAttribute map, e.g. { role: 'button', tabindex: 0 }.
dataobjectdata-* attributes, e.g. { userId: 42 }.
ariaobjectaria-* attributes, e.g. { label: 'Close' }.
onobjectEvent map, e.g. { click: fn, input: fn }.
childrenarrayChild node definitions.
componentstringRegistered component name, used instead of tag.
usefunctionComponent function reference, used instead of tag.
propsobjectProps passed to the component.
ifanyFalsy skips the node entirely.
eacharrayIterate; children becomes the per-item template.
itemTemplate(item, index) => NodeDefPer-item template used with each.
bindobjectState binding, { key: 'counter', fn: (val) => ... }.
stateanyElement-local state value.
liveListobject{ stateKey, itemFn, filter?, filterKeys?, sort?, sortKeys?, empty? }.
onMountfunctionClient mount hook.
onUpdateobject or arrayState update hook, { key, fn }.
onDestroyfunctionClient destroy hook.
setup(el) => voidEscape hatch for anything not covered above.

These are what toJSON() emits rather than shapes you would normally author, but fromJSON() accepts them so a snapshot round-trips:

KeyTypePurpose
type + content'text', stringSerialized text node. Valid inside children, where toJSON() emits it — not as a top-level node, which has no parent to receive the text.
cssTextstringAlready-compiled CSS declarations — see the trust note below.
eventsarraySerialized event handlers as source strings.
stateBindingsarraySerialized state bindings.
computedstringSerialized computed-value function source.
lifecyclearraySerialized lifecycle hooks.

Document definition keys

The top level of renderFromJSON() and fromJSON():

KeyTypePurpose
titlestringDocument title.
langstring<html lang>.
charsetstringCharacter set.
viewportboolean or stringfalse omits it; a string sets the content.
resetCssbooleanAdds the reset stylesheet.
faviconstringFavicon href.
canonicalstringCanonical URL.
noindexboolean or 'nofollow'Robots meta.
htmlAttrsobjectExtra attributes on <html>.
bodyAttrsobjectExtra attributes on <body>.
bodyClassstring or arrayBody class names.
bodyCssobjectBody CSS.
metaarrayMeta tag definitions.
linksarrayLink tag definitions.
scriptsarrayScript sources.
cssVarsobjectCSS custom properties.
globalStylesobject{ selector: rules }.
sharedClassesobject{ name: rules }.
keyframesobject{ name: frames }.
darkModeobjectDark-mode rules.
printobjectPrint rules.
ogTagsobjectOpen Graph tags.
twitterCardobjectTwitter card tags.
stateobjectGlobal reactive state.
bodyobject or arrayNode definitions for the body.

toJSON() additionally emits metas, bodyClasses, globalState, styles, classStyles, and oncreateCallbacks. fromJSON() reads both spellings, so meta/metas, bodyClass/bodyClasses, and state/globalState are interchangeable on input. globalStyles accepts the authored { selector: rules } object and the array of compiled rule strings that toJSON() produces.

Restoring untrusted JSON

styles, globalStyles (array form), classStyles, and a node's cssText are already-compiled CSS written into the <style> block verbatim — the same trust level as appendUnsafe().

Because a JSON payload may come from anywhere, fromJSON() rejects any of them containing markup, and re-validates serialized events, computed, lifecycle, and oncreateCallbacks sources against the same rules a live function passes through. Rejected entries are dropped and recorded; they never reach the page.

Set trustedCss: true to skip the CSS markup check when restoring your own snapshot. The decision is made once at the top level and applies to every nested node, so a payload cannot grant itself the exemption. Leave it off for anything you did not produce.

fromJSON() takes a second options argument. Pass { callbacks: false } to drop every serialized callback in the payload — events, on, bind, stateBindings, computed, lifecycle, onMount/onUpdate/onDestroy, liveList, and oncreateCallbacks — rather than screening them:

doc.fromJSON(untrustedDefinition, { callbacks: false });

Screening rejects what it recognises as unsafe; dropping means no callback from that payload reaches the page at all. Like trustedCss, the decision is made once at the top level, so a nested node cannot re-enable callbacks for itself. Note that trustedCss is a key inside the payload while callbacks is an argument you pass, which is why a payload cannot set it.

renderFromJSON() does not accept this option — its third argument configures the Document. To restore untrusted JSON, call doc.fromJSON(def, { callbacks: false }) and render the document yourself.

FunctionParametersReturns
doc.buildnode | node[]The document.
element.buildnode | node[]The element.
doc.fromJSONpageDefinition, options?. { callbacks: false } drops every serialized callback in the payload.Populates the document.
doc.toJSONnoneA serializable document definition.
renderFromJSONdefinition, setup?, options?Complete HTML string. renderJSON is a deprecated alias.

.bhtml templates

---
title "Dashboard"
viewport
---

:reset
div#app.container
  h1 "Welcome #{user.name}"
  ?if user.isAdmin
    button "Admin"
  ul
    ?each item in items
      li "#{item}"
FunctionParametersReturns
compileTemplatesource, variables?A modifiable Document.
renderTemplatesource, variables?Rendered HTML.
compileFilepath, variables?A Document; file reading is synchronous.
renderFilepath, variables?Rendered HTML; file reading is synchronous.
templateEnginepath, options, callbackExpress view-engine callback result.

Interpolation and error recovery

#{} expands in quoted text, attribute values, and data attributes:

a(href="#{url}") "Profile"
p(title="#{user.name}") "Hover me"
div[userId=#{user.id}]

A token with no matching variable is left in place rather than emptied, so an unresolved #{name} is visible in the output instead of silently becoming a blank attribute. Interpolated values are escaped and URL-sanitised exactly like any other attribute — a variable cannot break out of the quotes or smuggle a javascript: scheme into an href. Event values (@click="handler") are not interpolated: they name a function in the variables object.

The parser recovers from a malformed line rather than throwing, so a mistake still produces output, and in development it reports what it dropped as W_TEMPLATE_SYNTAX: an unclosed (, an invalid ?each, an unrecognised ? directive, a :global/:class rule without braces, an invalid tag name, or content it could not parse after a tag. Production stays quiet.

Tag names are lower-case. An uppercase or otherwise invalid tag drops that one line and leaves the rest of the template intact.

Reactive state, bindings, and events

doc.states({ count: 0, open: false, name: '' });

doc.span().bind('count', (value) => `Count: ${value}`);
doc.div('Panel').bindShow('open');
doc.input('text').bindInput('name');
doc.button('+1').onClick(function () {
  State.count++;
});
MethodParameters / callbackBrowser result
statekey, initialValueDefines one global reactive key.
statesobjectDefines multiple reactive keys.
bindkey, fn?(value, State)Updates text content.
bindShowkey, fn?(value, State)Toggles visibility.
bindClasskey, fn(value, State)Sets computed class name.
bindAttrkey, name, fn(value, State)Sets/removes an attribute; null removes it.
bindStylekey, fn(value, State)Applies returned CSS rules.
bindPropkey, name, fn?Assigns a DOM property.
bindInputkeyTwo-way input value synchronization.
oneventName, fn(event, State, element, context), context?, options?Adds an event listener; this is the DOM element. See event options.
oncreatefn()Runs once after the page is ready. The callback is invoked with no arguments — reach state through the State global inside the body. May return a promise.
onMountfn(State)Runs when the element exists; may return cleanup.
onUpdatekey, fn(value, State)Runs after changes, not for the initial value.
onDestroyfn(State)Runs when the element is removed.

Event options

on() and all 26 on<Event>() shorthands take an optional fourth argument. The third argument is context — data handed to your callback — so pass undefined when you only want options.

doc.div().onScroll(function () { State.y = window.scrollY; }, undefined, { passive: true });
doc.form().onSubmit(function () { State.sent = true; }, undefined, { preventDefault: true });
doc.button('Once').onClick(function () { State.n++; }, undefined, { once: true });
OptionWhere it appliesEffect
onceaddEventListenerRemoves the listener after it fires once.
passiveaddEventListenerPromises never to call preventDefault(). Set it on scroll, wheel and touch* handlers — browsers warn when a listener on those is not passive.
captureaddEventListenerFires during the capture phase instead of the bubble phase.
preventDefaultgenerated wrapperCalls event.preventDefault() before your handler runs.
stopPropagationgenerated wrapperCalls event.stopPropagation() before your handler runs.

The first three become addEventListener's third argument. The last two compile into the generated wrapper ahead of your callback, so they save the boilerplate first line of a handler rather than changing what the browser does.

passive and preventDefault contradict each other. A passive listener's preventDefault() is ignored by the browser and logs a console warning — that applies to the generated call exactly as it would to one you wrote yourself. Do not set both on the same handler.

Unknown keys are dropped and every value is coerced to a boolean, so nothing a caller passes is written into the page as code — the compiler only ever emits the literal true. Options restored from fromJSON() are re-normalised the same way. Omitting the argument compiles byte-identical output to a page that never used options.

Only own properties are read, so a flag inherited from a polluted Object.prototype cannot switch an option on for listeners that never asked for it.

While an event listener runs synchronously, event.currentTarget, element, and this refer to its element. Arguments and this persist across await; because currentTarget belongs to the browser event-dispatch lifecycle, use the explicit element argument after awaiting. State is passed explicitly and also remains available as window.State. Returning false has no special meaning; call preventDefault() or stopPropagation() explicitly, or set them as event options.

Nested objects and arrays remain reactive. Assignments, deletions, and mutating array methods notify watchers for the root state key. State must remain JSON-serializable, and that is enforced rather than assumed: a value that cannot survive JSON.stringify — a circular structure, a BigInt — is refused by state(), states() and element.state() when you set it, not at render time. The key is left unset, the page still renders, and the failure is kept as an E_CALLBACK_REGISTRATION error on validate() naming the key. Each key passed to states() is judged on its own.

Typed application state

In TypeScript, pass a state shape to page<State>(). Keys and values are then inferred across nested elements, bindings, events, lifecycle hooks, live lists, routers, and views.

type AppState = {
  activePage: 'overview' | 'projects' | 'account';
  sidebarOpen: boolean;
  count: number;
};

const doc = page<AppState>('Dashboard');
doc.states({ activePage: 'overview', sidebarOpen: false, count: 0 });

doc.span().bind('count', (count, state) => `${count}: ${state.activePage}`);
doc.button('Projects').setStateOnClick('activePage', 'projects');

Calling page() without a generic keeps permissive state typing for backward compatibility.

Client-side fetch()

doc.states({ loading: false, message: '', error: '' });
doc.p().bind('message');
doc.p().bind('error');

doc.button('Load').onClick(async function () {
  State.loading = true;
  State.error = '';
  try {
    const response = await fetch('/api/data');
    if (!response.ok) throw new Error('HTTP ' + response.status);
    State.message = (await response.json()).message;
  } catch (error) {
    State.error = error.message;
  } finally {
    State.loading = false;
  }
});

Because the function runs in the browser, use literal URLs, values in State, or data-* attributes for runtime configuration. Use doc.oncreate(async function () { ... }) to fetch automatically on page load.

SPA features and routing

Reactive lists

const list = doc.liveList('tasks', function (task, index) {
  return {
    tag: 'li',
    text: task.title,
    attrs: { 'data-index': index }
  };
}, {
  filter: function (task, state) {
    return state.view === 'all' || task.status === state.view;
  },
  filterKeys: ['view'],
  sort: function (a, b, state) {
    return state.descending ? b.title.localeCompare(a.title) : a.title.localeCompare(b.title);
  },
  sortKeys: ['descending'],
  empty: { tag: 'p', text: 'No tasks found.', attrs: { role: 'status' } }
});
ParameterTypePurpose
stateKeystringState array to render and watch.
itemFn(item, index) => NodeDefCreates each item on the server and browser.
filter(item, State) => booleanOptional filter used during SSR and browser updates.
filterKeysstring[]Extra state keys that change filtering.
sort(a, b, State) => numberOptional comparator used during SSR and browser updates.
sortKeysstring[]Extra state keys that change ordering.
emptyNodeDef or stringDeclarative content shown when no items remain.

Dashboard views without URL routing

doc.button('Overview').data({ viewNav: 'overview' });
doc.button('Projects').data({ viewNav: 'projects' });
doc.section('Overview content').data({ view: 'overview' });
doc.section('Project content').data({ view: 'projects' });

doc.views({
  stateKey: 'activePage',
  default: 'overview',
  activeClass: 'active'
});

views() synchronizes visibility, active classes, aria-current, click navigation, and reactive state. Its default selectors are [data-view-nav] and [data-view]; use navigation and viewSelector for custom markup.

Hash and History routers

doc.states({ view: 'home', routeParams: {} });
doc.historyRouter({
  base: '/app',
  stateKey: 'view',
  routes: {
    '/': 'home',
    '/users/:id': 'user',
    '*': 'not-found'
  }
});
OptionHash defaultHistory defaultPurpose
stateKey'view''view'Receives matched route value.
default'all''/'Used for an empty URL value.
routesnonenonePattern-to-state map supporting :params and *.
paramsKey'routeParams''routeParams'Receives decoded named parameters.
notFound'not-found''not-found'Value used when no route matches.
navSelectornonenoneLinks receiving active/inactive styles.
activeStylenonenoneRules applied to active link.
inactiveStylenonenoneRules applied to inactive links.
basen/a'/'Prefix removed before History route matching.
linkSelectorn/a'a[data-route]'Opt-in same-origin links intercepted by History routing.
History fallback required: the server must return the same application HTML for direct GET requests such as /app/users/42. Register the fallback after static files and API routes. Hash routing does not require this fallback.

Complete tested routing applications

Run node example/routing.js, then open http://127.0.0.1:3002/hash#users/42 or http://127.0.0.1:3002/app/users/42.

The packaged example/routing.js demonstrates hash routing and a clean-URL server whose API and static routes run before the /app/* HTML fallback. Automated HTTP and browser tests cover parameters, wildcard routes, direct refresh, and back navigation.

Complete tested dashboard

The packaged example/dashboard.js combines responsive layout, dashboard views, reactive filtering and sorting, an accessible empty state, a live list, client fetch, accessible forms, validation, and a zero-dependency Node HTTP server.

Its browser test covers keyboard operation and automatically checks duplicate IDs, control names, image alternatives, heading order, and visible focus.

node example/dashboard.js
# Open http://127.0.0.1:3000

The example builds its HTML once at server startup. Automated render and browser tests execute this exact file so its documented behavior cannot drift.

Complete server-validated form

The packaged example/account-form.js renders an account form per request, uses unique labelled fields, safely redisplays submitted values, reports accessible validation errors, limits request bodies, and handles success and HTTP failure paths without dependencies.

node example/account-form.js
# Open http://127.0.0.1:3001/account

Automated tests execute the real server for GET, invalid and valid POST, unsupported media type, oversized request, missing route, and unsupported method cases. Authentication, authorization, CSRF protection, persistence, and password hashing remain server responsibilities.

Complete authentication interface

The packaged example/auth-interface.js combines sign-in, registration, and account-settings forms with keyboard-accessible switching, responsive layout, unique labelled fields, autocomplete hints, and visible focus.

node example/auth-interface.js
# Open http://127.0.0.1:3004/auth

The placeholder POST routes return 501. Connect them to application-owned authentication, authorization, CSRF protection, rate limiting, validation, persistence, and password hashing. Render and browser tests verify the interface without pretending to provide those server responsibilities.

Express, streaming, and caching

app.get('/dashboard', createCachedRenderer(
  async function (req) {
    return buildDashboard(req.user);
  },
  function (req) {
    return 'dashboard:' + req.user.id + ':' + req.user.locale + ':' + req.user.permissions.join(',');
  }
));
ParameterTypePurpose
builderFn(req) => Document | Promise<Document>Builds on a cache miss.
cacheKeyOrFnstring or functionSelects the cache entry; null/empty skips caching.
options.nonce(req) => stringSets a fresh per-response CSP nonce and bypasses rendered HTML caching.

Concurrent misses for one key share a build. Include every response-changing value in the key—especially user identity, locale, and permissions. Use clearCache(pattern?), getCacheStats(), healthCheck(), and resetPools() for operations.

Nonce responses are not cached: reusing rendered HTML would also reuse its nonce. When options.nonce is configured, BuildHTML renders every response so the CSP header and HTML can use a fresh matching value.

Complete caching and CSP example

node example/production-patterns.js
# Personalized cache: http://127.0.0.1:3003/personalized?user=alice&locale=en
# Per-response nonce: http://127.0.0.1:3003/csp

The packaged example/production-patterns.js demonstrates identity-, permission-, and locale-aware cache keys alongside a separate nonce-protected response. HTTP tests prove cache isolation, header/HTML nonce consistency, and nonce freshness.

Security and limitations

  • Text and attributes are escaped by default.
  • URL helpers reject executable protocols and inline on* attributes are blocked.
  • Generated event listeners use addEventListener, not inline handler attributes.
  • CSP nonces are supported for generated inline scripts and styles.
  • new Function() and eval are rejected inside serialized callbacks.
  • State cannot contain functions, circular references, DOM nodes, Map, Set, or live Date objects.
  • Browser behavior is compiled into the generated page and runs directly against its rendered elements.

Common mistakes

MistakeCorrect approach
Building at the wrong leveldoc.build(definition) adds to the document body; element.build(definition) adds inside that element.
Capturing a server variable in a browser callbackPass JSON-safe callback context, use State, or use showWhen(), classWhen(), and setStateOnClick().
Reusing an HTML ID for controls bound to one state keyState keys can be shared; IDs cannot. Run doc.validate() and fix E_DUPLICATE_ID.
Using raw HTML for ordinary textUse escaped shortcut text or .text(). Only pass trusted content to raw APIs.
Refreshing a History-routed URL returns 404Configure the server to return the application HTML for direct application URLs after API and static routes.
Sharing one cache key across personalized pagesInclude identity, permissions, locale, and every response-changing input in the key.
Passing a secret as callback contextNever serialize secrets; context is embedded in browser-visible HTML.

Compact API reference

Methods are chainable unless their result is described otherwise. A trailing ? marks an optional parameter.

Document

MethodsParameters / result
renderNo parameters → complete HTML string. Consumes the document: it clears the body and releases pooled elements, so call it once and build a fresh document per request.
renderStreamNo parameters → readable HTML stream.
clearResets body, state, and per-render scripts; preserves head.
createtag → new body element. createElement and child are deprecated aliases.
title, charset, langString value.
htmlAttr, bodyAttrkey, value.
bodyId, bodyClass, bodyCssId, class names, or rules.
globalCssselector, rules — takes a full CSS selector. globalStyle is a deprecated alias.
sharedClassname, rules — a bare class name, no leading dot. An invalid CSS identifier is dropped, not rewritten, and reported in development. defineClass is a deprecated alias.
keyframesname, frames.
mediaQueryquery, selectorRules.
supportscondition, selectorRules — @supports feature query.
containerQueryquery, selectorRules — @container. Named to avoid the container() layout helper.
layername, selectorRules? — @layer. An empty layer still fixes the name's cascade position.
layerOrder...names — @layer a, b, c;. Order is preserved exactly.
cssVar, cssVarsname, value or variables object.
darkMode, printSelector-to-rules object.
state, stateskey, value or state object.
build, fromJSONNode/page definition.
toJSONNo parameters → serializable definition.
liveListstateKey, itemFn, options? → container element.
viewsState key, default value, navigation selector, view selector, and active class. Defaults: stateKey: 'activeView', [data-view-nav], [data-view], activeClass: 'active'.
hashRouter, historyRouterRouter options. routes maps a pattern to a state value string, not a function — e.g. { 'users/:id': 'user', '*': 'not-found' }. Defaults: stateKey: 'view', paramsKey: 'routeParams', notFound: 'not-found'; historyRouter adds base: '/' and linkSelector: 'a[data-route]'.
isEmpty, elementCountNo parameters. Inspection helpers for the document body.
comment, rawAdds an HTML comment, or trusted raw markup, to the body.
addStyle, addScript, addLinkA CSS string, a script URL, and a stylesheet URL respectively. addLink always emits rel="stylesheet"; use favicon, preload, or canonical for other link types.
group, useFragmentfn(doc) — run a builder function against the document without creating a wrapper element.
template, useTemplate, stamptemplate(name, fn) registers a reusable builder on the document; useTemplate(name, vars?) applies it; stamp(fragment) inserts a prepared fragment.
save, outputsave(path) writes the page to disk, rendering first if it has not been rendered yet. output() returns the most recent render and does not render on its own — it is '' until render() or save() has run.
clearNo parameters. Empties the document body.

Element

MethodsParameters / result
text, htmlText or trusted HTML value.
attr, data, ariakey, value.
setAttrsAttribute object.
addClass, removeClassOne or more class names.
toggleClass, classIfcondition, name — the condition comes first: toggleClass(true, 'active'), classIf(cond, 'on', 'off').
classMapClass-to-boolean object.
css, styleRules object; style also supports property/value.
hover, focusCss, activeRules object.
pseudoname, rules.
mediaquery, rules.
transitionTransition string or options object.
transformCSS value.
opacity, zIndexCSS value — deprecated, use style(prop, value).
childtag? → new child element.
buildnode | node[] → adds declarative children and returns the element.
eachitems, fn(self, item, index).
whencondition, fn(self).
find, findAll, findById, closestQuery → matching element(s). findById exists on Element only, not on Document.
cloneNo parameters → cloned element.
remove, emptyNo parameters. Both work on top-level elements as well as nested ones.
before, aftersibling — an Element, or a string inserted as escaped text.
wraptag → the new wrapper element, with this element inside it.
replaceWith, prependChild, insertAtTag or element; insertAt takes index, tag.
parent, siblings, nextSibling, prevSiblingNo parameters → related element(s).
childCount, index, isVoid, hasClassInspection. index is the position among siblings; isVoid is true for <br>, <img>, and similar.
append, appendUnsafecontent — appends to this element. append escapes a string; appendUnsafe inserts it as markup, for trusted content only.
toStringNo parameters → this element's HTML, without rendering or consuming the document. Useful for logging a subtree.
renderFragmentNo parameters → { html, css }, not a string. Static markup and CSS only: events, state, bindings, and lifecycle hooks on the subtree are dropped.
portaltargetId — renders the element into the container with that id at runtime instead of in place.
slot, fillSlotslot(name?) marks an insertion point; fillSlot(name, contentFn) populates it.
oneventName, handler.
onClick, onInput, onChange, onSubmitBrowser handler receiving (event, State, element, context); optional context is the second method argument and this is the element. Development mode reports thrown errors and rejected promises.
bind, bindShow, bindClassstateKey, computedFn?, context?. Callbacks receive (value, State, context); context must be JSON-serializable.
showWhenstateKey, expectedValue. Shows the element when state strictly matches the JSON-serializable value.
classWhenstateKey, expectedValue, className. Toggles one class while preserving existing classes.
setStateOnClickstateKey, value. Sets JSON-serializable state on click without a callback closure.
bindAttr, bindPropstateKey, name, computedFn?.
bindStylestateKey, computedFn.
bindInputstateKey.
onMount, onDestroyLifecycle callback. onMount may return a cleanup function; this is the element.
onUpdatestateKey, callback.
bindStatetarget, event, fn, context? — updates state from an event on another element.
computedfn() — derives the element's text from State; re-runs when the keys it reads change.
Other event shortcutsonKeydown, onKeyup, onKeypress, onFocus, onBlur, onMouseenter, onMouseleave, onMousedown, onMouseup, onMousemove, onDblclick, onContextmenu, onScroll, onLoad, onError, onDragstart, onDragend, onDragover, onDrop, onTouchstart, onTouchend, onTouchmove — all take the same handler shape.
Attribute shortcutsid, href, src, type, name, value, placeholder, role, for, title, alt, rel, target, action, method, tabindex, width, height, min, max, step, pattern, accept, rows, cols, minLength, maxLength, autocomplete, contentEditable, draggable.
Boolean attributesrequired, readonly, autofocus, multiple, checked, selected, disabled, hidden — called with no argument.
Style shortcutssize, tooltip, animate, firstChild, lastChild, nthChild. display, position, overflow and cursor are deprecated — use style(prop, value).
pseudoClassname, rules — styles for any pseudo-class (:checked, :focus-visible, :nth-of-type()). The primitive the named helpers are built from.
show, hide, enable, disable, focusNo parameters. These set or remove the rendered attribute — hidden, disabled, autofocus — at build time. For runtime toggling driven by state, use bindShow or bindAttr.
validateNo parameters. Returns { valid, errors, warnings } with stable codes for callback captures, rejected callback registration, duplicate IDs, accessibility, URLs, nesting, state, ineffective document caching, History fallback requirements, and validation attempted after render() already cleared the body. E_CALLBACK_REGISTRATION retains oversized, unsafe, invalid, context-serialization, and non-serializable state-value failures with callback and element context.

Creation shortcuts

Container and text shortcuts accept either optional text or a setup callback: doc.h1('Dashboard') and doc.section(section => section.p('Content')). Text is escaped and each shortcut returns the created element.

div, span, section, header, footer, main, nav, article, aside, form, table, thead, tbody, tfoot, tr, th, td, ul, ol, li, h1–h6, p, a, strong, small, label, caption, legend, em, b, i, button, img, input, select, textarea, details, dialog, pre, code, blockquote, hr, and br create elements.

Complete tag-shortcut signatures

MethodsSignatureReturnsVoidSetup callbackExample
div, span, section, header, footer, main, nav, article, aside, form, ul, ol, table, thead, tbody, tfoot, tr, details, summary, dialog, pre, code, blockquote, h1, h2, h3, h4, h5, h6, li, th, td, p, strong, small, label, caption, legend, em, b, i(textOrSetup?)ElementNoYesdoc.h1('Title')
a(href, text?)ElementNoNodoc.a('/help', 'Help')
button(text?)ElementNoNodoc.button('Save')
img(src, alt?)ElementYesNodoc.img('/logo.svg', 'Logo')
input(type?, attrs = {})ElementYesNodoc.input('email', { required: true })
textarea(attrs = {})ElementNoNodoc.textarea({ name: 'notes' })
select(options = [], attrs = {})ElementNoNodoc.select([{ value: 'en', text: 'English' }])
hr()ElementYesNodoc.hr()
br()Parent objectYesNodoc.p('One').br().text('Two')

Every listed shortcut exists on both Document and Element. Except for br(), each returns the created element. Text is escaped. Omitted optional arguments are omitted from the markup: doc.input() renders <input>, not <input type="text">, and doc.img('/logo.svg') renders no alt. Pass alt explicitly on every image — '' for decorative ones, meaningful text for informative ones.

In development, extra positional arguments produce the stable W_SHORTCUT_ARGUMENT warning rather than disappearing silently. Production remains quiet, while TypeScript rejects the unsupported call during development.

Form helpers

MethodSignatureReturnsProduces
field(label, options = {}){ group, label, input }<div><label for><input id></div>, with the label wired to the input.
formGroup(label, type?, attrs = {})Wrapper elementSame structure as field, but only the wrapper is returned.
checkbox(name, label, checked = false)Wrapper element<div><input type="checkbox" name id><label for></div>.
radio(name, options = [])Wrapper elementOne <div><input type="radio"><label></div> per option. Options are { value, label, checked }, falling back to text then value for the visible label.
fieldset(legend?, setupFn?)<fieldset>Adds a <legend> when one is given; setupFn receives the fieldset.
hiddenInput(name, value)Element<input type="hidden">.

Each of these generates the ID pairing <label for> with its input unless you supply one, via field({ id }) or attrs.id on formGroup().

Layout helpers

MethodSignatureReturnsProduces
grid(columns?, items?, gap?)Elementdisplay: grid. A number becomes repeat(n, 1fr); a string is used as-is.
flex(items?, options = {})Elementdisplay: flex. Options are direction, gap, align, justify, wrap.
stack, row(items?, gap?)Elementflex with flex-direction set to column or row.
center(setupFn?)Elementflex centred on both axes; setupFn receives the element.
container(setupFn?, maxWidth?)ElementA <div> carrying max-width when given, and nothing otherwise. Centre it yourself with css({ margin: '0 auto' }).
spacer(height?)ElementEmpty <div>, with height when given.
divider(options = {})<hr>Bare <hr>. color adds a top border; margin sets spacing.
columns(count, columnFns = [], gap?)ElementA grid with one column <div> per function.

grid(), flex(), stack(), and row() share the same items array, and each entry is handled by type: a function is called with a fresh child <div>, an element is appended as-is, and anything else is stringified into a <div>. Prefer the callback form — an element made with create() is already attached to the element that created it, so passing one as an item appends it a second time rather than moving it.

Every spacing, sizing, and colour argument is optional and emits nothing when omitted. grid(2, items) produces display: grid; grid-template-columns: repeat(2, 1fr) and no gap.

Data helpers

MethodSignatureReturnsProduces
list(items, renderer?, tag = 'ul')The list elementOne <li> per item. renderer(li, item, index) fills it, otherwise the item is stringified.
dataTable(headers?, rows = [], options = {})<table><thead> when headers exist, then a <tbody> row per entry. Accepts array rows or object rows; with object rows headers selects and orders the keys, or { autoHeaders: true } takes them from the first row. { class } adds a class to the table.
each(items, fn)The same element, for chainingNothing on its own; fn(this, item, index) builds each iteration.
when(condition, fn)The same element, for chainingNothing unless condition is truthy, then fn(this) runs.

Styling what a helper returns

Helpers return real elements, so style them at the call site with addClass(), css(), or any other element method. field() returns the group, label, and input separately, so each part is reachable:

const jobCode = form.field('Job Code', {
  groupClass: 'form-group',
  attrs: { autocomplete: 'off' },
});
jobCode.label.addClass('field-label');
jobCode.input.addClass('field-input').css({ width: '100%' });

formGroup(), checkbox(), and radio() return only their wrapper; use field() when you need the label and input references, or reach into the wrapper with find('label') and find('input').

Package exports

Document, Element, Head, page, components, configure, CONFIG, Metrics, metrics, renderFromJSON, template functions, SPA compilers, caching middleware, cache controls, health checks, and pool controls are exported from the package root.

ExportPurpose
page, Document, Element, HeadPage and node constructors. page(title, options?) preconfigures charset, viewport, reset CSS, and language.
renderFromJSONRender a page from a plain definition: renderFromJSON({ title, body }, setup?). renderJSON is a deprecated alias.
compileTemplate, compileFileCompile .bhtml source or a file → a Document, not a render function.
renderTemplate, renderFileCompile and render in one step → HTML string.
parseTemplate, TemplateParserThe parser behind the template functions, for tooling that needs the AST.
templateEngineExpress view-engine adapter: app.engine('bhtml', templateEngine).
compileHashRouter, compileHistoryRouter, compileViews, compileLiveListThe compilers behind doc.hashRouter(), doc.historyRouter(), doc.views(), and doc.liveList(). Call the document methods unless you are building tooling.
createCachedRendererFrom the root or @trebor/buildhtml/middleware. Returns an Express-style (req, res, next) middleware, not a plain memoiser.
clearCache, getCacheStats, responseCache, healthCheck, resetPoolsCache, health, and element-pool controls.
configure, CONFIG, metrics, MetricsRuntime configuration and instrumentation.
componentsRegistry: register, get, has, list, unregister, extend, clear.
Configuration optionPurpose
mode'dev' or 'prod' behavior.
debugIn development mode, exposes window.BuildHTMLDebug.inspect() with state keys, bindings, events, callback counts and sources, rejected callback registrations, and hydration time. It is emitted even when a rejected callback was the page's only browser behavior. Keep it disabled in production.
poolSizeMaximum reusable object pool size.
cacheLimitResponse LRU entry limit.
maxComputedFnSizeMaximum serialized computed callback size.
maxEventFnSizeMaximum serialized event callback size.
enableMetricsEnables runtime counters and timings.