kopular
v0.15.0
Published
Kopular: a small component framework for KopScript — components, reactive state, constructor-injected services, routing, real compiled templates, and HTTP, with no DI container
Downloads
2,834
Maintainers
Readme
Kopular
Kopular is a small component framework for KopScript, built to give Angular's separation of concerns — components own UI, services own logic, a router owns navigation — without Angular's steepest learning-curve pieces: no RxJS, no dependency-injection container, and templates that are real, compiled, type-checked KopScript rather than a separate interpreted template language.
Generating Kopular code with an AI coding assistant? Point it at LLM.md
— a dense, complete reference designed to be loaded straight into an LLM's context.
Highlights
Component, with real vdom diffing: a base class withvirtual Render()(describes the current state as aVElementtree — a lightweight description of a DOM element, not a real one) andUpdate()(diffs the new tree against the previous one and patches only what changed, reusing a real DOM node wherever its tag stays the same — not a full subtree rebuild).Render()is provided as a real, separate markup file compiled by KopScript'stemplate from(see "Templates" below), or written by hand building aVElementtree againstkopular/velement, the way you'd write careful vanilla-JS UI code — your choice, and both compile to the exact same thing. An optionalvirtual RenderError(message)renders a fallback instead of a hard crash ifRender()throws — purely additive; not overriding it keeps today's exact (uncaught) behavior.- Templates, compiled and type-checked, not interpreted: markup lives in its own
.htmlfile — interpolation ({{ }}), event/property bindings ((click)="...",[prop]="..."), and*if/*forstructural directives — desugared by the KopScript compiler into the exact same code a hand-writtenRender()would produce, with automaticSubscribe/Update()wiring forstate<T>fields referenced directly in the markup. See "Templates" below. - Reactive state, no RxJS: components hold
state<number>/state<string>/... (a KopScript language feature — see the KopScript repo) and subscribe once, in their constructor, to callUpdate()on change. No Observables, no operators, no manual unsubscribe bookkeeping. - Services, no DI container: "injecting" a service is just passing it as a
constructor argument. No injector hierarchy, no provider tokens, no decorators — and a
service stays fully testable with zero
Component/DOM involvement, since it's just a class. Router: real URLs (/about, not#/about) via the History API (pushState/popstate), with route registration as plain method calls, not a config DSL. See "Router, and deploying it" below — every deployment target needs its own SPA-fallback config, not just local dev.- Structural directives:
*ngIf/*ngFor/*ngSwitch's job — build a subtree conditionally, repeat one per item, pick one of several cases. In a template, that's*if/*for(realif/forunder the hood — see "Templates"); in a hand-writtenRender(), the same job is a plain function call (If(...)) or existing KopScript expression (array.ForEach(...),match) — no special syntax needed there either way. See "Structural directives" below. Http: a thin, static wrapper over the real Fetch API (Http.Get(url),Http.Post(url, jsonBody), ...) — no HttpClient injection tokens, no RxJS observables/operators. See "HTTP" below.FormField<T>: a single input's value/error/touched state, built onstate<T>— no two-way-binding magic, noFormGroupconfig object. See "Forms" below.
What's here
src/dom.ks— ambient DOM bindings (document,Element,Event,window,location) thatcomponent.ks/router.ks/directives.ksare built on.src/velement.ks—VElement, the lightweight description of a DOM elementRender()returns.src/vdom.ks— the diff/patch engine (Materialize/Patch/PatchChildren) behind real vdom diffing — see "Component" above.src/component.ks— theComponentbase class.src/router.ks— theRouter.src/directives.ks—If(), the structural-directive equivalents' one genuinely new piece (see below).src/http.ks—Http, a thin wrapper overfetch(see below).src/http_runtime.jsis its one companion file — the single hand-written (not compiled from.ks) file in Kopular, and why is explained in its own header comment.src/forms.ks—FormField<T>andValidators(see "Forms" below).src/testing.js—runKopularApp/runKopularFixture(see "Testing your own app" below); hand-written for the same reason ashttp_runtime.js— filesystem/process orchestration isn't a KopularComponent.bin/kp.mjs— thekp newscaffolding CLI (see "Starting a new project" below); also hand-written, same reason.
That's the whole framework — ten files, plus the scaffolding CLI. Everything else (a real app built on top of it) lives in a separate consumer repo, KopularDemo.
Templates
A component's Render() can be a real markup file instead of hand-written imperative DOM
code — template from "./x.html"; in the class body, a KopScript language feature (see
KopScript's own README/LLM.md for
the full syntax reference). Kopular itself needed zero framework code changes for
this — the compiler desugars a template straight into calls against the same
VElement.Create/.AppendChild/.TextContent/named event fields velement.ks already
declares, so a template-generated Render() is indistinguishable from one you'd write by
hand:
// counter.ks
class Counter : Component {
public state<number> Count;
constructor() : base() { this.Count = state(0); }
public void Increment() { this.Count.Value = this.Count.Value + 1; }
template from "./counter.html";
}<!-- counter.html -->
<button (click)="Increment()">Count: {{ Count.Value }}</button>Note there's no this.Count.Subscribe(...) anywhere — a state<T> field referenced
directly in the template (Count.Value above) gets it wired automatically. The exact
same component, hand-written, needs that Subscribe call itself:
class Counter : Component {
private state<number> Count;
constructor() : base() {
this.Count = state(0);
this.Count.Subscribe((number v) => this.Update());
}
public override VElement Render() {
VElement button = VElement.Create("button");
button.TextContent = "Count: " + this.Count.Value;
button.OnClick = (Event e) => {
this.Count.Value = this.Count.Value + 1;
};
return button;
}
}Both produce the same Render(), and can be mixed freely across a codebase — nothing
about Component, Update(), or any other Kopular API differs between them. The manual
Subscribe call is still exactly what you need the moment state is reached indirectly
— through a method call, or through an injected service's own state
(this.Service.Count, say) — which is why the real, production Counter on the
KopularDemo site
(it injects a CounterService rather than holding Count itself) uses a template but
still has one manual Subscribe. *if/*for in a template are covered under
"Structural directives" below, alongside their hand-written-Render() equivalents.
Dependency injection: the composition root pattern
Kopular has no injector because KopScript has nothing for one to hook into — no
decorators, no reflection, and no generic functions (KopScript's generics are
classes/interfaces only — see the KopScript repo) for a type-safe Resolve<T>(). Instead, the
whole app's service/page graph gets built exactly once, by hand, in one place: a plain
class with no Component base and no framework code in it at all, sometimes called an
app container or (in the wider DI literature) a composition root. Everything
else just takes what it needs as constructor arguments and never constructs its own
dependencies.
// app_container.ks — the one place that decides what's shared and builds
// the graph, in dependency order.
class AppContainer {
public Router Nav;
constructor() {
// Built once, passed to every page that needs it below — that's the
// whole mechanism for a shared singleton. A page that constructed its
// own `new CounterService()` instead would get an independent one; the
// difference is which variable gets passed in, not a config flag.
CounterService counter = new CounterService();
this.Nav = new Router(new NotFoundPage());
this.Nav.AddRoute("/", new HomePage(this.Nav, counter));
this.Nav.AddRoute("/about", new AboutPage(this.Nav));
}
}
// routed_app.ks — the root Component. Takes the already-built graph; never
// builds one of its own.
class RoutedApp : Component {
private Router Nav;
constructor(AppContainer services) : base() {
this.Nav = services.Nav;
}
public override VElement Render() {
return VElement.Create("div");
}
// Mounting the Router — a live, nested Component — isn't something a
// VElement tree can express as data (see "Component" above). AfterRender
// is called with the real DOM node Render()'s tree just became, once
// Mount()/Update() has actually materialized/patched it — here, that's
// the container itself.
protected override void AfterRender(Element root) {
this.Nav.Mount(root);
}
}
RoutedApp app = new RoutedApp(new AppContainer());
app.Mount(document.body);This is sometimes called "Pure DI" — the same benefits a container gives you (nothing hardcodes its own dependencies, everything is swappable in a test) with none of a container's cost:
- Compile-time checked. A missing or mistyped dependency is
expected N arguments, got Mfrom the KopScript compiler, not aNullInjectorErroryour users hit at runtime after a container fails to resolve something. - Fully legible. The entire dependency graph is ordinary, readable code in one file
— grep for
newin the composition root and you've read the whole wiring diagram. Nothing is constructed by a framework inspecting metadata behind the scenes. - No new concepts. If you already know how to call a constructor, you already know Kopular's DI story — there's no separate injector API, provider syntax, or injection-token vocabulary to learn.
See KopularDemo's
src/app_container.ks and src/routed_app.ks for the real, working version this
example is drawn from.
Router, and deploying it
Router nav = new Router(new NotFoundPage()); // fallback page required up front — no null route
nav.AddRoute("/", new HomePage(nav)); // pages built once, kept alive for Router's lifetime
nav.AddRoute("/about", new AboutPage(nav));
nav.Mount(document.body);
nav.Navigate("/about"); // pushState + immediate re-renderReal URLs via the History API (pushState/popstate), not #/about hash routing.
AddRoute takes an already-constructed Component, not a factory, so a page's own
state<T> survives navigating away and back — see "Dependency injection" above for how
the whole page graph typically gets built once, in a composition root.
Dynamic route segments: a path segment written :name (e.g. /dogs/:id) matches any
single non-empty segment. With just one per route, its value is captured into
Router.Param:
nav.AddRoute("/dogs/:id", new DogPage(nav));
// inside DogPage.Render():
el.TextContent = "Dog #" + this.Nav.Param;Param is a plain string, deliberately not state<T> — Router's own AfterRender
already re-Mount()s the matched page into its outlet on every Navigate()/popstate,
which re-runs that page's Render() (reading the fresh Param) with no extra step. No
Subscribe() needed on it.
More than one dynamic segment, and a trailing wildcard segment, both work too —
read each by name via Router.Params(name) instead (Param above still holds the
first captured value either way, so existing single-segment code needs no change):
nav.AddRoute("/dogs/:id/toys/:toyId", new ToyPage(nav));
// inside ToyPage.Render():
el.TextContent = "Dog " + this.Nav.Params("id") + " / Toy " + this.Nav.Params("toyId");
nav.AddRoute("/files/*", new FilesPage(nav));
// "/files/2026/reports/q1.pdf" -> Params("*") == "2026/reports/q1.pdf"Query strings are always available via Router.Query(key), independent of which
route matched (never part of route matching itself):
// "/search?sort=name&order=asc"
this.Nav.Query("sort") // "name"
this.Nav.Query("missing") // "" — not presentQuery values are not percent-decoded — a deliberate v1 cut (no decodeURIComponent
binding exists yet); a value containing %20 or + for a space arrives exactly as
written in the URL.
Navigation guards: protect a route (or any set of routes) behind a check —
SetGuard takes a redirect path plus a single (string) => bool checked before every
navigation, including a direct load/refresh:
nav.SetGuard("/login", (string path) => {
if (path == "/admin") { return authService.IsLoggedIn.Value; }
return true;
});One guard for the whole Router, not per-route — the guard function itself decides which
paths it cares about, the same "a function, not a config object" style Http/DI already
use. Defaults to always-allow when SetGuard is never called. Redirecting updates the URL
too (via pushState), so refreshing a blocked path lands on the redirect again rather than
back on the page the guard just rejected — pick a redirectPath the guard itself always
allows, or it loops.
Deploying a Router-based app needs SPA/history-fallback configured on whatever you
deploy to — this is true of every client-side router in every framework, not a Kopular
gap. A direct load or a refresh at /about is a plain HTTP request that reaches your
host before any JS has run, so nothing client-side (Router included) can intercept it;
the host has to serve the app shell itself for any route it has no literal file for.
KopularDemo hit exactly this in production (worked when navigated to via a link, 404'd on
refresh) before its Cloudflare Workers config had this set:
// wrangler.jsonc
"assets": {
"directory": "./public",
"not_found_handling": "single-page-application"
}Every static host has an equivalent option (Netlify, Vercel, nginx, ...) — search that
host's docs for "SPA fallback" or "single-page application routing", the terminology is
standard. For local dev, see KopularDemo's scripts/serve.mjs.
Structural directives
Angular's *ngIf/*ngFor/*ngSwitch are template syntax that expands, at compile time,
into imperative view-container calls. In a Kopular template, *if/*for are exactly
that — real KopScript if/for statements underneath (see "Templates" above), compiled
by KopScript itself, not interpreted by Kopular at runtime. In a hand-written
Render(), there's no separate directive syntax to reach for: each job maps onto a plain
expression, and two of the three need nothing new at all.
| Angular | Kopular template | Kopular hand-written Render() | New code? |
| ----------------- | ---------------- | -------------------------------------------- | :-------: |
| *ngFor | *for="Type v of expr" | array.ForEach((item) => ...) | none — already a KopScript array method |
| *ngSwitch | (not supported — use *if, or switch in the backing class) | match value { ... } | none — already a KopScript expression, and exhaustiveness-checked (*ngSwitch isn't) |
| *ngIf / *ngIf-else | *if="expr" | If(condition, () => ..., () => ...) | directives.ks (hand-written form only — a template's *if needs no helper, it's a real if) |
The rest of this section is about the hand-written Render() column above — a
template's *if/*for need no further explanation, they're covered under "Templates".
*ngIf is the one case in hand-written Render() that needs something new: if is a
statement in KopScript, so without a helper you'd need a throwaway mutable local just
to get a conditional value out of it. If() is that helper — nothing more than:
VElement If(bool condition, () => VElement whenTrue, () => VElement whenFalse) {
if (condition) {
return whenTrue();
}
return whenFalse();
}Both branches are required (same reasoning Router uses for requiring a NotFoundPage
up front — see router.ks): v1 has no nullable types, so "render nothing" has no value
to hand back. Only the branch actually taken runs — the other lambda is never called, so
an explicit empty branch (() => VElement.Create("span")) costs nothing when there's
genuinely nothing to show.
All three read the same way, right inside a hand-written Render() — no directive
registration, nothing to import beyond the function itself:
public override VElement Render() {
VElement root = VElement.Create("div");
// *ngIf
root.AppendChild(If(this.User.IsLoggedIn, () => this.BuildProfile(), () => this.BuildLoginButton()));
// *ngFor
VElement list = VElement.Create("ul");
this.Items.ForEach((Item item) => { list.AppendChild(this.BuildItemRow(item)); });
root.AppendChild(list);
// *ngSwitch
root.AppendChild(match this.Status {
"loading" => this.BuildSpinner(),
"error" => this.BuildError(),
_ => this.BuildContent()
});
return root;
}Give each Item's VElement a stable .Id (e.g. the item's own id) to make *ngFor
trackBy-style row reuse automatic — Update()'s diff engine matches children by Id
across a re-render, reusing a matched child's real DOM node (and anything stateful
attached to it, like focus) rather than rebuilding it, the same way *ngFor's own
trackBy avoids rebuilding unchanged rows. Without a stable Id, a list still renders
correctly on reorder, but a given item's real node isn't guaranteed to follow its data.
HTTP
using "./http";
Response r = await Http.Get("/api/dogs");
if (r.ok) {
string body = await r.text();
print(body);
}
await Http.Post("/api/dogs", "{\"name\":\"Rex\"}");
await Http.Put("/api/dogs/1", "{\"name\":\"Rexy\"}");
await Http.Patch("/api/dogs/1", "{\"name\":\"Max\"}");
await Http.Delete("/api/dogs/1");Http is a thin, static wrapper over the real Fetch API — Get/Post/Put/Patch/
Delete, each returning task<Response> (.ok, .status, async text()). No
HttpClient to inject, no RxJS Observable/operators, no interceptors — call it from
anywhere, including straight out of a service's own methods.
No typed JSON deserialization — Response.text() gets you the raw body, nothing
more. This isn't a corner cut for v1; it's a direct consequence of two things KopScript
doesn't have: generic functions/methods (KopScript's generics are classes/interfaces
only, so there's no safe way to write a general task<T> Get<T>(string url)) and
object-literal syntax ({ ... } as a value — see below). If you want a
typed response, describe its shape as its own extern class and parse it yourself with
a per-shape extern ... as "JSON.parse" declaration — the same trust-based approach
extern already uses for everything else, not a new mechanism:
extern class DogDto {
string name { get; }
};
extern DogDto ParseDog(string json) as "JSON.parse";
string body = await (await Http.Get("/api/dogs/1")).text();
DogDto dog = ParseDog(body); // unchecked, like a TypeScript `as DogDto` castWhy Post/Put/Patch/Delete aren't just extern bindings straight to fetch,
the way Get is: setting a request method/body/headers means passing fetch a second
argument that's a plain JS object literal ({ method, headers, body }) — and KopScript
has no object-literal syntax at all, so it can't construct one. src/http_runtime.js is
one small hand-written function that does, and Get/Delete-with-no-body skip it
entirely (fetch(url) alone needs no options object, so Get binds straight to the
real global). It's the one file in this package not compiled from .ks — everywhere
else avoids the problem by only wrapping JS APIs that take plain positional arguments
(see dom.ks's addEventListener(string, handler), never an options-object-taking API).
Forms
using "./forms";
FormField<string> email = new FormField<string>("", (string v) => {
string? required = Validators.Required(v);
if (required != null) { return required; }
return Validators.Email(v);
});
email.Value.Value = "not-an-email";
print(email.Error.Value); // "Must be a valid email"
print(email.Valid()); // false
// inside a hand-written Render(), building emailInput as a VElement:
emailInput.OnInput = (Event e) => {
email.Value.Value = e.target.value; // revalidates automatically
};
emailInput.OnBlur = (Event e) => { email.Touch(); };FormField<T> holds one input's value, error, and touched state as three ordinary
state<T> boxes — .Value (the input's current value, revalidating on every
assignment), .Error (string?, the current validator's message or null), and
.Touched (bool, set by calling .Touch() — typically on blur, so a fresh field with
an invalid initial value like an empty required field doesn't show an error before the
user has typed anything). Subscribe to any of the three from your Component's
constructor exactly like Counter's own state<number>, to re-render when they change.
A validator is a plain (T) => string? — null means valid, the same convention
KopScript's own nullable types use elsewhere. There's no array-of-validators
constructor parameter — KopScript has no syntax for an array of function values — so
combining more than one check (as email does above) is an if-chain in one lambda, or,
for the common case of just chaining a couple of already-built validators with no custom
logic of their own, the CombineValidators2/CombineValidators3 free functions:
FormField<string> email = new FormField<string>("",
CombineValidators2((string v) => Validators.Required(v), (string v) => Validators.Email(v)));Free functions, not Validators methods, and fixed-arity (2 and 3) rather than a general
Validators.All(...) — a class's own static method can't introduce a new type parameter
beyond the class's own (see "Generics" in KopScript's own docs), so a generic combinator has
to live as a free function instead. Validators ships the handful of checks almost every
form needs (Required, MinLength, MaxLength, Email, Min, Max), each returning
its own message; write your own validator function for anything more specific.
No two-way data binding in a hand-written Render() — wiring Value to a real
<input> is the OnInput assignment shown above, the same manual pattern Counter
already uses for its click handler (VElement.Value itself is one-way, host-to-DOM
only — reading the current DOM value back out is always the real event's
e.target.value, not something Kopular mirrors into Value for you). This is
deliberate, not a missing feature: hand-written code already has direct field/handler
access, so there's nothing for a magic binding to save you from writing. A template
gets real sugar for exactly this — [(value)]="Field" desugars to [value]="Field" +
(input)="Field = e.target.value" (see KopScript's own "Templates" docs) — since a
markup file has no equivalent direct access to fall back on.
Starting a new project: kp new
Everything in the next section — the extern bindings, plus a vendor/kopular/ copy of
this package's browser files and an import map pointing at it (a browser can't resolve a
bare specifier like "kopular/component" the way Node's own module resolution does) — is
boilerplate every Kopular project needs verbatim. Generate it instead of reconstructing it
by hand (or from memory, if you're an AI agent):
npx kp new my-app
cd my-app
npm install
npm start # builds, vendors kopular's browser files, and serves at :8080This scaffolds a real, working Component (src/counter.ks — the same Counter shown
above), the ambient DOM/Kopular extern bindings it needs (src/kopular_bindings.ks),
and a README.md that points an AI agent at this package's own LLM.md before it starts
generating code. kp ships from this package (not from kopscript's own ks CLI) since
scaffolding a Kopular app is a framework concern, not a language one — ks stays a
pure-language tool with no framework knowledge baked in.
Using Kopular from another KopScript project
KopScript's own using "./path"; only resolves relative paths within a project — it has
no package-import mechanism yet. Cross-package consumption goes through extern
instead, the same way KopScript already describes any other JS/npm dependency
(kp new above generates exactly this, if you'd rather not hand-write it):
extern class VElement {
static VElement Create(string tag);
string TextContent { get; set; }
} from "kopular/velement";
extern class Component {
constructor();
virtual VElement Render();
void Mount(Element parent);
} from "kopular/component";
extern class Router {
constructor(Component notFoundPage);
void AddRoute(string path, Component page);
void Navigate(string path);
} from "kopular/router";
class MyWidget : Component {
public override VElement Render() {
VElement el = VElement.Create("div");
el.TextContent = "Hello from MyWidget";
return el;
}
}Marking Render() virtual in the extern declaration is what lets a real subclass
override it — see KopularDemo
for a full working example (components, a service, and routing, all consuming Kopular
this way).
extern class can carry its own <T> (kopscript >= 0.5.0), so a generic export like
FormField<T> describes the same way a real generic class does — see LLM.md's
FormField<T>/Validators section for the full example.
Testing your own app: kopular/testing
A Component/Router graph can only be exercised end-to-end by actually compiling its
.ks sources and running the result against a real DOM — there's no way to unit test one
otherwise. Doing that by hand is a real ~50-line dance (a fresh temp dir per test, since
Node's ESM module cache means re-importing the same compiled path twice never re-runs an
ambient extern binding's top-level code — silently binding every later test to the first
test's jsdom instance — compiling via kopscript's compileGraph, binding jsdom onto
globalThis for document/Element/... to find, then restoring it). Both Kopular's own
test suite and KopularDemo's used to hand-roll this independently; kopular/testing is
that dance, written once:
import { runKopularApp } from "kopular/testing";
const { window, cleanup } = await runKopularApp(join(__dirname, "..", "src"), "app.ks", {
includeKopularPackage: true, // your app consumes Kopular via `extern`, not relative `using`
});
try {
expect(window.document.querySelector("h1")?.textContent).toBe("Hello");
} finally {
cleanup(); // always — even on a thrown assertion — or the next test inherits these globals
}jsdom is an optional peer dependency — installing kopular alone doesn't pull it
in; only a project that actually calls runKopularApp needs it added too. See
Kopular/test/kopular.test.ts (runKopularFixture, the sibling export used for testing
Kopular's own source against inline fixtures) and KopularDemo's
test/routed_app.test.ts for two real, different call sites.
Getting started (developing Kopular itself)
npm install # pulls in kopscript as a devDependency
npm run build # compiles src/*.ks -> src/*.js (compiled output is gitignored)
npm test # runs test/kopular.test.ts against a real DOM via jsdomkopscript is a real published dependency (^0.1.0) — this repo doesn't need KopScript
checked out as a sibling directory or anything else local to build or test.
Status
v1 / hobby-project scope, same as KopScript itself. Update() now diffs and patches
real DOM (see "Component" above) rather than replacing a whole subtree on every
re-render — but reconciliation is still per-Component, not across nested ones: if a
parent Component's own Render() output changes shape around a slot where a nested
Component was imperatively Mount()ed (via AfterRender — see Router's own pattern),
that nested Component isn't automatically re-Mount()ed or torn down as part of the
parent's diff, since a live mounted child isn't something a VElement tree can express
as data. Composing independent components into a stable, unchanging slot — the way every
real use of AfterRender in this codebase already does, Router's own outlet included —
avoids the issue entirely. A list child without a stable VElement.Id similarly still
renders correctly across a reorder, but isn't guaranteed to keep the same real DOM node
(see "Structural directives" above). Update() called before Mount() is a safe no-op
— see LLM.md's Component section for exactly when that happens (sibling pages
sharing one injected service's state<T>).
