A constellation of atomic, independently versioned packages for building vanilla JS SPAs. No mandatory framework. No bundled opinions. Each package does one thing.
| Package | Version | Description |
|---|---|---|
texivia-router |
1.0.0 | 1.4 kB framework-agnostic router with compiled regex matching |
texivia-reactive |
1.0.0 | Proxy-based reactive state with batch() |
texivia-component |
1.0.0 | Component factory — @bind, @event, event delegation |
texivia-store |
1.0.0 | Global reactive store |
texivia-fetch |
1.0.0 | Typed fetch wrapper with structured error handling |
Auth packages (texivia-session, texivia-oidc) are planned.
texivia-router (no ecosystem deps)
texivia-reactive (no ecosystem deps)
texivia-fetch (no ecosystem deps)
texivia-component → texivia-reactive
texivia-store → texivia-reactive
texivia-oidc → texivia-store, texivia-fetch (planned)
texivia-session → texivia-store (planned)
Leaf packages have zero intra-ecosystem dependencies and can be used in complete isolation.
npm install texivia-router texivia-component// router.js
import { Router } from 'texivia-router';
import { Home } from './pages/Home.js';
import { Dashboard } from './pages/Dashboard.js';
export const router = new Router([
{ path: '/', view: Home },
{ path: '/dashboard', view: Dashboard,
handler: async () => {
if (!await auth.isAuthenticated()) return '/login';
return true;
}
},
]);
// app.js
import { setupEvents } from 'texivia-component';
import { router } from './router.js';
const app = document.getElementById('app');
let current = null;
document.addEventListener('texivia', ({ detail: { view, params } }) => {
current?.unmount();
current = view(params);
current.mount(app);
});
setupEvents();
router.start();
// pages/Home.js
import { component } from 'texivia-component';
export const Home = component({
state: { count: 0 },
template: ({ count }) => `
<h1>Count: ${count}</h1>
<button @click="inc">+</button>
`,
inc(ctx) { ctx.state.count++; },
});| Property | Type | Description |
|---|---|---|
state |
object |
Initial reactive state |
template |
(state & props) => string |
Returns HTML string |
afterRender |
(container, ctx) => void |
Called after each render |
...methods |
(ctx, event?) => void |
Called by @event directives |
| Property | Description |
|---|---|
ctx.state |
Reactive state — mutate directly to trigger re-render |
ctx.props |
Props passed at instantiation (route params) |
ctx.batch(fn) |
Group writes — one re-render at the end |
ctx.signal |
AbortSignal — aborted on unmount; pass to apiFetch |
| Directive | Description |
|---|---|
@click="method" |
Calls method(ctx, event) on click |
@submit.prevent="method" |
Calls method + e.preventDefault() |
@click.stop="method" |
Calls method + e.stopPropagation() |
@click.once="method" |
Fires once, then becomes a no-op |
@bind="key" |
Two-way binding for inputs, textarea, select |
- A handler runs only for its own event type. The nearest element (target or ancestor) with handlers for that type runs all of them, in template order — e.g.
<input type="checkbox" @bind="on" @change="save">first updateson, then callssave. - Every state change re-renders the whole component (
innerHTML). Exception:@bindwrites the value into state without re-rendering, so the focused field keeps focus and cursor. Other parts of the template show the bound value only after the next render (any other state change). Do not build live previews of bound fields; render them from a method instead (e.g. on@change). @bindHTML-escapes the value it injects. Values you interpolate in the template yourself (${…}) are inserted as HTML — escape untrusted data there.
const { payload, error } = await apiFetch<User>('/api/me', null, {
method: 'GET',
signal: ctx.signal, // cancelled automatically on navigation
});
if (error) {
if (error[2] === 'E_ABORTED') return; // navigated away — not an error
showError(error[2]);
return;
}
use(payload);| Response | Result |
|---|---|
| 2xx with JSON body | { payload, error: null } |
| 2xx with empty body (e.g. 204) | { payload: null, error: null } |
| 2xx with a body that is not JSON | error E_INVALID_JSON |
non-2xx with an ApiError tuple as body |
that tuple |
| any other non-2xx | error E_HTTP_ERROR |
aborted via signal |
error E_ABORTED, status 0 |
| network failure | error E_NETWORK_ERROR, status 500 |
The HTTP status is kept in error[1] for all HTTP responses.
All packages ship ES modules (import) and CommonJS (require), each with matching type declarations.
examples/vanilla-demo— counter, recipe detail (async load + abort), contact form (all@bindvariants)examples/svelte— Svelte 5 (no SvelteKit):microand therecipe-buildersample appexamples/vue— Vue 3:microand therecipe-buildersample app
The Svelte and Vue examples are standalone projects: build the router first (npm run build -w packages/texivia-router), then run npm ci && npm run dev in the example folder.
See docs/adr: why the router and the packages are built the way they are.
npm install # install all workspaces
npm run build # build all packages (tests import texivia-reactive from its dist/)
npm test # run all test suites
npm run dev # start vanilla-demo dev serverReleases are managed with Changesets: run npx changeset to describe a change; merging the generated "Version Packages" pull request publishes the changed packages to npm.
Apache-2.0 — Vadim Ferderer (https://ferderer.de/)