@maleta/blokjs
v0.7.1
Published
Zero-build, zero-dependency, standalone, reactive, lightweight UI framework
Maintainers
Readme
BlokJS
Zero-build, zero-dependency, standalone, reactive, lightweight UI framework. ~31KB minified (~11KB gzipped).
Documentation - Examples - LLM Reference (~4,700 tokens) - npm
Quick start
<script src="https://cdn.jsdelivr.net/npm/@maleta/blokjs/dist/blokjs.min.js"></script>
<div id="app"></div>
<script>
blok.mount('#app', {
state: { count: 0 },
methods: {
inc() { this.count++ },
dec() { this.count-- },
},
view: ($) => ({
div: { children: [
{ h1: { text: $.count } },
{ button: { click: 'dec', text: '-' } },
{ button: { click: 'inc', text: '+' } },
] }
})
})
</script>No virtual DOM. Views are plain JavaScript objects. State is reactive - when count changes, the bound h1 updates automatically.
Features
- Reactive state - fine-grained dependency tracking via ES Proxy
- Components - props, events, slots, lifecycle hooks
- Routing - client-side router with params, guards, and history/hash modes
- Stores - global state with computed properties and async tracking
- Async tracking - automatic
loadinganderrorstate for async methods - Web components - custom events via
on, JS properties viaprops,modelon value-based elements - URL sanitization -
javascript:and dangerous URIs blocked on href/src attributes - Prerendering - optional build step writes the rendered HTML into the page, and
mount()adopts it - Multi-mount - multiple independent app instances, optional full isolation
- Zero dependencies - single script tag, no build step needed
Components
blok.component('TodoItem', {
props: ['todo'],
methods: {
remove() { this.emit('remove', this.todo) }
},
view: ($) => ({
li: { children: [
{ input: { type: 'checkbox', model: $.todo.done } },
{ span: { text: $.todo.text } },
{ button: { click: 'remove', text: 'x' } },
] }
})
})Use components by name in templates. Pass props, listen to events with on_ prefix:
{ each: $.todos, as: 'todo', key: 'id', children: [
{ TodoItem: { todo: $.todo, on_remove: 'handleRemove' } }
] }Stores
Global state shared across components. Async methods get automatic loading/error tracking:
blok.store('auth', {
state: { user: null },
computed: {
isLoggedIn() { return this.user !== null }
},
methods: {
async login(email, password) {
const res = await fetch('/api/login', {
method: 'POST',
body: JSON.stringify({ email, password }),
})
this.user = await res.json()
},
logout() { this.user = null }
}
})// In templates
{ when: $.store.auth.loading.login, children: [
{ p: 'Signing in...' }
] }
{ when: $.store.auth.error.login, children: [
{ p: { class: 'error', text: $.store.auth.error.login } }
] }
// In methods
this.store.auth.login(email, pw)
this.store.auth.isLoggedInblok.store() returns the store, so code outside components can read, write and watch it:
const auth = blok.store('auth') // look up a registered store
const stop = blok.watch(() => auth.$state, state => {
localStorage.setItem('auth', JSON.stringify(state))
}, { deep: true })
auth.$state = { user: null } // assigns each key, like Object.assignRouting
blok.mount('#app', {
routes: [
{ path: '/', component: 'Home' },
{ path: '/product/:id', component: 'ProductDetail' },
{ path: '/admin', component: 'Admin', guard: 'requireAuth' },
{ path: '*', component: 'NotFound' },
],
guards: {
requireAuth(to, from) {
if (!this.store.auth.isLoggedIn) return '/login'
return true
}
},
view: ($) => ({
div: { children: [
{ a: { href: '/', link: true, text: 'Home' } },
{ a: { href: '/admin', link: true, text: 'Admin' } },
{ div: { route: true } },
] }
})
})Access route data in components via this.route.params, this.route.query, and navigate programmatically with this.navigate('/path').
View DSL
Views are plain objects. The $ proxy creates reactive references resolved at render time. A ref is a path, not a value, so JS operators (!, ? :, >) do not see its value: use $.not, the function form, or a computed.
// Conditionals
{ when: $.isLoggedIn, children: [{ p: 'Welcome!' }] }
{ when: $.not.isLoggedIn, children: [{ p: 'Please log in' }] }
// Function form - for expressions with operators
{ when: ($) => !$.isLoggedIn, children: [{ p: 'Please log in' }] }
{ when: ($) => $.count > 0 && $.isReady, children: [{ p: 'Go!' }] }
// Loops
{ each: $.items, as: 'item', key: 'id', children: [
{ li: { text: $.item.name } }
] }
// Two-way binding
{ input: { type: 'text', model: $.search } }
{ select: { model: $.category, children: [...] } }
// JS properties (arrays/objects for web components)
{ 'data-grid': { props: { rows: $.rows } } }
// Classes (string, object, or array)
{ div: { class: { active: $.isActive, disabled: $.isOff } } }
{ li: { class: { active: ($) => $.item.id === $.selectedId } } }
// Events
{ button: { click: 'save', text: 'Save' } }
{ button: { click: $.save, text: 'Save' } } // method ref
{ 'wa-dialog': { on: { 'wa-after-hide': 'onClose' } } }
{ button: { click: 'remove(item)', text: 'x' } }
{ form: { submit: 'handleSubmit', children: [...] } }
// Slots
{ Card: { title: 'Hello', children: [
{ p: 'This goes into the slot' }
] } }Using with Vite
While BlokJS works without a build step, the Vite plugin adds automatic component and store registration from file directories, bundling into optimized output, and hot module replacement during development.
Scaffold a new project:
npm create blokjs my-app
cd my-app
npm install
npx viteOr add to an existing Vite project:
npm install blokjs vite-plugin-blokjs// vite.config.js
import { defineConfig } from 'vite'
import { blokjs } from 'vite-plugin-blokjs'
export default defineConfig({
plugins: [blokjs()],
})// main.js
import { mount } from '@maleta/blokjs'
import 'virtual:blokjs'
mount('#app', {
view: ($) => ({ counter: {} }),
})The plugin auto-discovers files in components/ and stores/ directories, registering each by filename. Files prefixed with _ are ignored.
components/counter.js -> component('counter', ...)
stores/app.js -> store('app', ...)
components/_helper.js -> ignoredUsing with PocketBase
@maleta/blokjs-pocketbase turns a PocketBase collection into a BlokJS store: records load into items, follow realtime changes, and get loading/error tracking like any async store method. pbAuth does the same for the signed-in user.
<script src="https://cdn.jsdelivr.net/npm/@maleta/blokjs/dist/blokjs.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/pocketbase.umd.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@maleta/blokjs-pocketbase/dist/blokjs-pocketbase.min.js"></script>const pb = new PocketBase('/')
blok.store('todos', blokPocketbase.pbCollection(pb, 'todos', { sort: '-created' }))
blok.mount('#app', {
mount() { this.store.todos.connect() },
methods: {
add() { this.store.todos.save({ title: 'New todo' }) },
},
view: ($) => ({ div: { children: [
{ button: { click: 'add', text: 'Add' } },
{ each: $.store.todos.items, as: 'todo', key: 'id', children: [
{ p: { text: $.todo.title } },
] },
] } }),
})See the package README for the full API and examples/pocketbase for a runnable app.
Prerendering
Pages render in the browser, so clients that do not run JavaScript (crawlers, link previews, site reviews) see an empty mount node. blokjs-prerender writes the rendered HTML of each route into the page, and blok.mount() then adopts that HTML instead of rendering it again. The app keeps working without this step.
bunx blokjs-prerender index.html # every route without :param, * or guard
bunx blokjs-prerender index.html --routes / /about --out dist
bunx blokjs-prerender index.html --inline --out dist # one self-contained file per routeIn a server or a custom build script, renderToString from @maleta/blokjs/server returns the same HTML:
import { component, renderToString } from '@maleta/blokjs/server'
const html = renderToString(appOptions, { url: '/about' })
// <div id="app" data-blok-ssr>${html}</div>See the package README for what runs during prerendering and what does not.
Browser Compatibility
BlokJS supports all ES2020-compliant browsers (96%+ global coverage). IE is not supported.
Development
pnpm install
pnpm build # build all packages
pnpm test # run testsLicense
MIT
