Platform
Use standard web APIs
Real links, History API, native events, standard DOM, ES modules, Node.js, and TypeScript remain visible.
White Label
Framework-independent TypeScript packages for state, rendering, coordination, routing, and project generation.
Architecture
Each package has a defined role, can be used independently, and composes through ordinary JavaScript contracts.
Turns a URL or navigation intent into application intent.
Lets independent modules exchange named messages without importing each other.
Owns observable object, array, or Map state with synchronous mutation events.
Renders state, owns browser lifecycle, and offers the same template concepts on the server.
Platform
Real links, History API, native events, standard DOM, ES modules, Node.js, and TypeScript remain visible.
Boundaries
Introduce a primitive when it gives a concern a clear home. Static content can remain plain JSX; features that do not need routing do not need a router.
Runtime
Model and routing contracts work on browser and server paths. View provides explicit browser and server entrypoints while sharing template and model concepts.
Enhancement
HTML owns semantics and direct navigation. JavaScript enhances behavior. Public content remains meaningful before client code initializes.
Examples
The package references below show the TypeScript modules that execute on this page. The controls underneath each listing are created by those same modules.
Documentation
Use the filters to focus on one package. Each section includes installation, runtime behavior, public API, return values, lifecycle guidance, and an executable source example.
Observable state
One Model API for plain objects, arrays, and Map state. Mutations are synchronous, observable, validation is optional, and the class works without browser globals.
npm install white-label-model
import {Model} from 'white-label-model';Use a plain object for keyed state, an array for ordered state, or a Map for keyed collection state. clear() preserves the current root shape.
initialize()Start the lifecycle. Returns the same Model instance.get()Read the complete observable root state. Returns the object, array, or Map root.get(key)Read one property, index, or Map entry. Returns the matching value or undefined.set(data, silent?)Replace the root state. Returns true when accepted; false for unsupported or validator-rejected data.update(...)Merge object fields or replace/merge one array or Map member. Returns true when accepted; false for invalid input, a missing member, or validator rejection.push(...)Append array values or add Map entries. Returns true when accepted; false for invalid input, object state, or validator rejection.delete(key, silent?)Delete one object property, array index, or Map entry. Returns true when removed; false when missing or validator-rejected.clear(silent?)Reset to an empty value of the current root shape. Returns true.destroy()Clear silently and release listeners. Returns the same Model instance after cleanup.change plus a structured mutate payload with operation, path, old value, new value, and current state.Explicit mutations emit change followed by the operation event (set, update, push, delete, or clear). Assign name and any EventEmitter-compatible mediator to relay events as model:<name>:<event>.
import Mediator from 'white-label-mediator';
import {Model} from 'white-label-model';
type Profile = {
active?: boolean;
name?: string;
};
interface Task {
complete: boolean;
id: number;
title: string;
}
/** Run the exact Model example rendered in the documentation. */
export function initializeModelExample(documentRoot: Document): () => void {
const host = documentRoot.querySelector<HTMLElement>('#model .doc-card__body');
if (!host) {return () => undefined;}
const demo = documentRoot.createElement('section');
demo.className = 'feature-card';
demo.dataset.liveDemo = 'model';
demo.innerHTML = `
<p class="eyebrow">Live in your browser</p>
<h4>Unified observable Model state</h4>
<p>This is the same TypeScript module shown above. It uses local events, mediator relay, object and array Model mutations, lifecycle cleanup, and silent reset state.</p>
<div class="hero__actions">
<button class="button button--primary" type="button" data-demo-action="update">Update profile</button>
<button class="button button--ghost" type="button" data-demo-action="add">Add task</button>
<button class="button button--ghost" type="button" data-demo-action="complete">Complete first</button>
<button class="button button--ghost" type="button" data-demo-action="remove">Remove first</button>
<button class="button button--ghost" type="button" data-demo-action="clear">Clear profile</button>
<button class="button button--ghost" type="button" data-demo-action="reset">Reset</button>
</div>
<pre><code data-demo-output></code></pre>`;
host.append(demo);
const output = demo.querySelector<HTMLElement>('[data-demo-output]')!;
const mediator = new Mediator().initialize();
const profile = new Model<Profile>({name: 'Ada', active: true}).initialize();
const tasks = new Model<Task[]>([
{id: 1, title: 'Read the reference', complete: false}
]).initialize();
const events: string[] = [];
profile.name = 'profile';
profile.mediator = mediator;
const recordProfileChange = (): void => { events.unshift('profile:change'); events.length = Math.min(events.length, 4); render(); };
const recordProfileRelay = (): void => { events.unshift('model:profile:update'); events.length = Math.min(events.length, 4); render(); };
const recordTaskChange = (): void => { events.unshift('tasks:change'); events.length = Math.min(events.length, 4); render(); };
profile.on('change', recordProfileChange);
mediator.on('model:profile:update', recordProfileRelay);
tasks.on('change', recordTaskChange);
function render(): void {
output.textContent = JSON.stringify({
profile: profile.get(),
tasks: tasks.get(),
recentEvents: events.slice(0, 4)
}, null, 2);
}
const updateProfile = (): void => {
const current = profile.get();
profile.update({
name: current.name === 'Ada' ? 'Grace' : 'Ada',
active: !current.active
});
};
const addTask = (): void => {
const current = tasks.get() as Task[];
tasks.push({id: current.length + 1, title: 'Explore another API', complete: false});
};
const completeFirst = (): void => {
const current = tasks.get() as Task[];
if (current[0]) {tasks.update(0, {complete: true});}
};
const removeFirst = (): void => {
const current = tasks.get() as Task[];
if (current[0]) {tasks.delete(0);}
};
const clearProfile = (): void => { profile.clear(); };
const reset = (): void => {
profile.set({name: 'Ada', active: true}, true);
tasks.set([{id: 1, title: 'Read the reference', complete: false}], true);
events.length = 0;
render();
};
const actions: Record<string, () => void> = {
update: updateProfile,
add: addTask,
complete: completeFirst,
remove: removeFirst,
clear: clearProfile,
reset
};
for (const [name, handler] of Object.entries(actions)) {
demo.querySelector<HTMLButtonElement>(`[data-demo-action="${name}"]`)!
.addEventListener('click', handler);
}
render();
return () => {
for (const [name, handler] of Object.entries(actions)) {
demo.querySelector<HTMLButtonElement>(`[data-demo-action="${name}"]`)!
.removeEventListener('click', handler);
}
profile.removeListener('change', recordProfileChange);
mediator.removeListener('model:profile:update', recordProfileRelay);
tasks.removeListener('change', recordTaskChange);
profile.destroy();
tasks.destroy();
mediator.destroy();
demo.remove();
};
}
Rendering + lifecycle
Explicit browser view lifecycle plus a DOM-free server entrypoint. Model binding, delegated browser events, child ownership, batching, and an optional escaped JSX runtime stay small and visible.
import View from 'white-label-view';Use DOM mounting, delegated events, focus-aware updates, children, and optional animation-frame batching.
import View from 'white-label-view/server';Use the portable lifecycle and toString() without requiring window or document.
initialize()Render current state and initialize view lifecycle. Returns the same View instance.render()Synchronously mount, replace, or update the root. Returns the same View; throws TypeError for invalid template roots.requestRender()Render now or coalesce into an animation frame when batching is enabled. Returns the same View.setModel(model?)Move model binding and render current state. Returns the same View after rendering.delegate(scope?)Create a native delegated-event registry. Returns a new registry scoped to the supplied element or current root.addChild(child)Register child ownership. Returns the parent View; throws TypeError for cycles or conflicting ownership.releaseChild(child)Release ownership without destroying the child. Returns the parent View.initializeModelBinding()Subscribe to model change events. Returns undefined in Browser View.destroyModelBinding()Release model subscription and queued work. Returns undefined in Browser View.addListeners()Extension hook after root installation. Returns the same View by default.removeListeners()Extension hook before replacement or destruction. Returns the same View by default.afterMount()Extension hook after insertion and listener setup. Returns the same View by default.destroy()Release listeners, model binding, children, queued work, and DOM root. Returns the same View after cleanup.Delegated-event registry methods on(), off(), and clear() return the registry for chaining.
initialize()Render current state and initialize lifecycle. Returns the same server View instance.render()Render the template into stored HTML. Returns the same server View; throws TypeError for unsupported output.toString()Read the most recently rendered output. Returns the HTML string, or an empty string when no output is stored.setModel(model?)Move model binding and synchronously render new state. Returns the same server View.addChild(child)Register child ownership. Returns the parent server View; throws TypeError for cycles or conflicting ownership.releaseChild(child)Release ownership without destroying the child. Returns the parent server View.initializeModelBinding()Subscribe to model change events. Returns the same server View.destroyModelBinding()Release model subscription. Returns the same server View.destroy()Destroy children, release subscriptions, and clear output. Returns the same server View after cleanup.jsxImportSource: "white-label-view". JSX expressions and attributes are escaped by default. Use raw() only for markup the application already trusts or sanitizes.import {Model} from 'white-label-model';
import View from 'white-label-view';
type ProfileState = {
count: number;
name: string;
};
/** A reusable View with delegated DOM events and explicit model binding. */
class ProfileView extends View {
private get profileModel(): Model<ProfileState> {
return this.model as Model<ProfileState>;
}
private readonly handleIncrement = (): void => {
const current = this.profileModel.get();
this.profileModel.update({count: (current.count ?? 0) + 1});
};
private readonly handleNameInput = (event: Event): void => {
const input = event.target as HTMLInputElement;
this.profileModel.update({name: input.value});
};
override addListeners(): this {
this.delegated.on('click', '[data-increment]', this.handleIncrement);
this.delegated.on('input', '[data-name]', this.handleNameInput);
return this;
}
override removeListeners(): this {
this.delegated.off('click', '[data-increment]', this.handleIncrement);
this.delegated.off('input', '[data-name]', this.handleNameInput);
return this;
}
override template = (data: unknown) => {
const state = data as ProfileState;
return (
<div class="feature-card feature-card--cyan" aria-live="polite">
<strong data-greeting>Hello, {state.name}</strong>
<p data-count>Rendered count: {state.count}</p>
<label>Name <input data-name value={state.name} /></label>
<button class="button button--primary" type="button" data-increment>Increment in the View</button>
</div>
);
};
/** Keep the editable element, focus and composition state while updating only displayed data. */
override render(): this {
if (this.parentElement?.contains(this.element)) {
const root = this.element as Element;
const state = this.profileModel.get();
root.querySelector('[data-greeting]')!.textContent = `Hello, ${state.name}`;
root.querySelector('[data-count]')!.textContent = `Rendered count: ${state.count}`;
const input = root.querySelector<HTMLInputElement>('[data-name]')!;
if (input.value !== state.name) {input.value = state.name ?? '';}
return this;
}
return super.render();
}
}
/** Run the exact View example rendered in the documentation. */
export function initializeViewExample(documentRoot: Document): () => void {
const host = documentRoot.querySelector<HTMLElement>('#view .doc-card__body');
if (!host) {return () => undefined;}
const demo = documentRoot.createElement('section');
demo.className = 'feature-card';
demo.dataset.liveDemo = 'view';
demo.innerHTML = String(
<>
<p class="eyebrow">Live in your browser</p>
<h4>Rendered View + delegated events</h4>
<p>This is the same TypeScript module shown above. The View renders from Model state, re-renders on change, delegates events from its root, and exposes explicit binding lifecycle controls.</p>
<div data-view-mount></div>
<div class="hero__actions">
<button class="button button--ghost" type="button" data-demo-action="pause">Pause model binding</button>
<button class="button button--ghost" type="button" data-demo-action="resume">Resume model binding</button>
<button class="button button--ghost" type="button" data-demo-action="render">Render manually</button>
<button class="button button--ghost" type="button" data-demo-action="reset">Reset</button>
</div>
</>
);
host.append(demo);
const model = new Model<ProfileState>({name: 'Ada', count: 0});
const mount = demo.querySelector<HTMLElement>('[data-view-mount]')!;
const view = new ProfileView({parentElement: mount, model}).initialize();
const pause = (): void => { view.destroyModelBinding(); };
const resume = (): void => { view.initializeModelBinding(); };
const render = (): void => { view.render(); };
const reset = (): void => { model.set({name: 'Ada', count: 0}); };
const actions: Record<string, () => void> = {pause, resume, render, reset};
for (const [name, handler] of Object.entries(actions)) {
demo.querySelector<HTMLButtonElement>(`[data-demo-action="${name}"]`)!
.addEventListener('click', handler);
}
return () => {
for (const [name, handler] of Object.entries(actions)) {
demo.querySelector<HTMLButtonElement>(`[data-demo-action="${name}"]`)!
.removeEventListener('click', handler);
}
view.destroyModelBinding();
view.destroy();
model.destroy();
demo.remove();
};
}
Application events
A small Node-compatible EventEmitter used as an application event bus. It keeps independent models, views, routers, and modules from calling each other directly.
npm install white-label-mediator
import Mediator from 'white-label-mediator';
const mediator = new Mediator();Delivery is synchronous and follows EventEmitter ordering. Event names and payloads belong to the application; TypeScript consumers can provide an event map for compile-time names and tuples.
on(name, callback)Subscribe to a named application event. Returns the same mediator instance for chaining.once(name, callback)Subscribe for one delivery. Returns the same mediator instance for chaining.emit(name, ...payload)Synchronously publish an event. Returns true when at least one listener exists; otherwise false.removeListener(name, callback)Release one owned subscription. Returns the same mediator instance.removeAllListeners(...)Use the standard EventEmitter cleanup contract. Returns the same mediator instance.listenerCount(name)Inspect current listener count. Returns the number of listeners for the event.initialize()Start the lifecycle. Returns the same mediator instance.destroy()Remove every listener owned by the mediator instance. Returns the same mediator after cleanup.removeListener(). Reserve destroy() for the event bus itself leaving the application.import Mediator from 'white-label-mediator';
/** Run the exact Mediator example rendered in the documentation. */
export function initializeMediatorExample(documentRoot: Document): () => void {
const host = documentRoot.querySelector<HTMLElement>('#mediator .doc-card__body');
if (!host) {return () => undefined;}
const demo = documentRoot.createElement('section');
demo.className = 'feature-card';
demo.dataset.liveDemo = 'mediator';
demo.innerHTML = `
<p class="eyebrow">Live in your browser</p>
<h4>Publish / subscribe lifecycle</h4>
<p>This is the same TypeScript module shown above. It demonstrates typed callback payloads, synchronous delivery, once-only listeners, targeted teardown, listener counts, and full mediator destruction.</p>
<div class="hero__actions">
<button class="button button--primary" type="button" data-demo-action="publish">Publish event</button>
<button class="button button--ghost" type="button" data-demo-action="remove">Remove regular listener</button>
<button class="button button--ghost" type="button" data-demo-action="restore">Restore listener</button>
<button class="button button--ghost" type="button" data-demo-action="clear">Clear log</button>
</div>
<div class="feature-card feature-card--green" aria-live="polite" data-demo-output></div>`;
host.append(demo);
const output = demo.querySelector<HTMLElement>('[data-demo-output]')!;
const mediator = new Mediator().initialize();
const messages: string[] = [];
let sequence = 0;
let regularListenerAttached = true;
const render = (): void => {
output.textContent = `${messages[0] ?? 'Waiting for an event'} — regular listeners: ${mediator.listenerCount('demo:message')}`;
};
const receive = (message: string): void => {
messages.unshift(`regular: ${message}`);
messages.length = Math.min(messages.length, 1);
render();
};
const receiveOnce = (value: number): void => {
messages.unshift(`once: first publish was #${value}`);
messages.length = Math.min(messages.length, 1);
render();
};
mediator.on('demo:message', receive);
mediator.once('demo:once', receiveOnce);
const publish = (): void => {
sequence += 1;
mediator.emit('demo:once', sequence);
mediator.emit('demo:message', `message ${sequence} received`);
};
const remove = (): void => {
if (!regularListenerAttached) {return;}
mediator.removeListener('demo:message', receive);
regularListenerAttached = false;
messages.unshift('regular listener removed');
messages.length = Math.min(messages.length, 1);
render();
};
const restore = (): void => {
if (regularListenerAttached) {return;}
mediator.on('demo:message', receive);
regularListenerAttached = true;
messages.unshift('regular listener restored');
messages.length = Math.min(messages.length, 1);
render();
};
const clear = (): void => {
messages.length = 0;
render();
};
const actions: Record<string, () => void> = {publish, remove, restore, clear};
for (const [name, handler] of Object.entries(actions)) {
demo.querySelector<HTMLButtonElement>(`[data-demo-action="${name}"]`)!
.addEventListener('click', handler);
}
render();
return () => {
for (const [name, handler] of Object.entries(actions)) {
demo.querySelector<HTMLButtonElement>(`[data-demo-action="${name}"]`)!
.removeEventListener('click', handler);
}
mediator.removeListener('demo:message', receive);
mediator.destroy();
demo.remove();
};
}
URL → application intent
One route contract across browser and server runtimes. Browser navigation progressively enhances real links; server routing dispatches explicit request URLs without DOM globals.
const router = new Router();
router.routes = {'/products': route};
// initialize() dispatches the current URL and returns this router.
router.initialize();Reads the current URL, enhances eligible data-pushstate links, handles popstate, and manages configured title/focus behavior.
const router = new Router();
router.routes = {'/products': route};
// Pass the request URL explicitly when no browser location exists.
router.initialize('/products/42?color=blue');Dispatches the same route contract while skipping browser-only history, focus, and click behavior.
routesOrdered route table. Functions or lifecycle route objects are supported. Configuration property; no return value.scopeApplication scope passed to route callbacks. Configuration property; no return value.mediatorOptional EventEmitter-compatible source for router:navigate. Configuration property; no return value.initialize(url?)Dispatch current browser URL or explicit server URL and attach applicable listeners. Returns the same Router instance.navigate(url?, data?, isPopState?)Run a matching route and update browser history when appropriate. Returns the same Router on success; false when navigation is rejected.addListeners()Attach browser and optional mediator listeners once. Returns the same Router instance.removeListeners()Release listeners owned by the router. Returns the same Router instance.destroy()Release routing listeners. Returns the same Router instance after cleanup.parseQueryString(query)Decode query parameters, keeping the last duplicate value. Returns a plain key/value object.setLocationData(data?)Rebuild the current location payload. Returns undefined and updates locationData in place.applyPageContext(route)Apply configured title/focus context. Returns the same Router instance.eventPushStateClick(event)Handle an eligible push-state click. Returns the Router when handled, or true when native behavior should continue.eventPopState()Dispatch the authoritative browser history URL. Returns the same Router instance.<a href>. Add data-pushstate to enhance it. Modified clicks, downloads, other targets, and cross-origin URLs keep native browser behavior.import Router from 'white-label-router';
type DemoRoute = (scope: Element | null, location: Router.Location) => void;
/** A route callback and cleanup pair used by the site's real Router instance. */
export interface RouterExample {
route: DemoRoute;
destroy(): void;
}
/** Run the exact Router example rendered in the documentation. */
export function initializeRouterExample(documentRoot: Document, router: Router): RouterExample {
const host = documentRoot.querySelector<HTMLElement>('#router .doc-card__body');
if (!host) {
return {route: () => undefined, destroy: () => undefined};
}
const demo = documentRoot.createElement('section');
demo.className = 'feature-card';
demo.dataset.liveDemo = 'router';
demo.innerHTML = `
<p class="eyebrow">Live in your browser</p>
<h4>History API navigation with real links</h4>
<p>This is the same TypeScript module shown above. The links are progressive-enhancement anchors handled by the site's real Router, while the button demonstrates programmatic navigation and mediator payload data.</p>
<nav class="doc-tabs" aria-label="Router live demo">
<a href="/?package=router&demo=home#router" data-pushstate data-router-demo-link="home">Home</a>
<a href="/?package=router&demo=products#router" data-pushstate data-router-demo-link="products">Products</a>
<a href="/?package=router&demo=settings#router" data-pushstate data-router-demo-link="settings">Settings</a>
</nav>
<div class="hero__actions">
<button class="button button--primary" type="button" data-demo-action="navigate">Navigate to Settings from JavaScript</button>
</div>
<div class="feature-card feature-card--violet" aria-live="polite" data-router-demo-output></div>`;
host.append(demo);
const output = demo.querySelector<HTMLElement>('[data-router-demo-output]')!;
const titles: Record<string, string> = {
home: 'Home dashboard',
products: 'Products route',
settings: 'Settings route'
};
const route: DemoRoute = (_scope, location): void => {
const requested = location.data.query.demo;
const selected = requested === 'products' || requested === 'settings' ? requested : 'home';
const source = location.data.mediator?.source;
const title = titles[selected] ?? selected;
output.textContent = source
? `${title} — navigation source: ${String(source)}`
: title;
for (const link of demo.querySelectorAll<HTMLAnchorElement>('[data-router-demo-link]')) {
if (link.dataset.routerDemoLink === selected) {link.setAttribute('aria-current', 'page');}
else {link.removeAttribute('aria-current');}
}
};
const navigate = (): void => {
router.navigate('/?package=router&demo=settings#router', {source: 'router.navigate()'});
};
const button = demo.querySelector<HTMLButtonElement>('[data-demo-action="navigate"]')!;
button.addEventListener('click', navigate);
const current = new URL(documentRoot.defaultView?.location.href ?? 'https://example.com/');
route(router.scope, {
url: current.pathname + current.search,
data: {
url: [],
mediator: {},
query: Object.fromEntries(current.searchParams)
}
});
return {
route,
destroy() {
button.removeEventListener('click', navigate);
demo.remove();
}
};
}
generator-white-label
The generator composes the White Label packages into a readable TypeScript project with working examples, tests, and build configuration.
Generator repositorynpm install --global generator-white-label
white-label create my-project
cd my-project
npm install
npm testOr run it without a global install:
npx generator-white-label create my-projectimport {createProject} from 'generator-white-label';
// Resolves with undefined after the scaffold and package manifest are written.
await createProject({
destination: new URL('./my-project', import.meta.url).pathname
});createProject() returns Promise<void>. File-system errors reject the promise rather than returning a status value.
Get started
Install individual packages directly, or start from the generator and keep the pieces the application uses.
npm install white-label-model white-label-view white-label-mediator white-label-router// Share only the dependencies each feature actually needs.
const mediator = new Mediator();
const model = new Model({ready: true});
const view = new View({model, parentElement, template});
const router = new Router();npm run lint
npm run typecheck
npm test
npm run coverage
npm run audit