kopular
v1.4.3
Published
Kopular: a small component framework for KopScript — components, reactive state, constructor-injected services, routing, real compiled templates, and HTTP, with no DI container
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. Every real DOM event handler Kopular attaches (OnClick/OnInput/OnBlur/OnChange) batches theUpdate()calls made while it runs into one render per component, applied once the handler returns, fully synchronously — a click handler that mutates more than one field only ever renders once, reading every field's final value, regardless of which statement happens to trigger it. SeeLLM.md's "Component" section for the full rationale and the real bug this closes.- 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 for thestate<T>fields the markup reads, directly or through the component's own methods. 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 lives on another
object — 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.
Scoped component styles
A class-body styles from "./x.css"; — also a KopScript language feature (see
KopScript's own
README/LLM.md for the full syntax/CSS-parsing reference) — scopes a real stylesheet so it
only matches elements that class itself renders, never a sibling's or a child's:
class Widget : Component {
constructor() : base() { }
template from "./widget.html";
styles from "./widget.css";
}/* widget.css */
.title { color: royalblue; }Unlike template from, this did need real framework code — ScopedStyles.Inject
(vdom.ks) is the runtime half, and using "kopular"; brings it in along with everything
else — do not write your own extern class ScopedStyles ... from "kopular/vdom";, which
is now a real KS4002 Declaration 'ScopedStyles' conflicts with a name brought in by
'using'. (Before package using existed, every Kopular export did need a hand-written
extern in your project; that is what using "kopular"; replaced.) It's idempotent,
injecting one real <style> per component type into document.head the first time any
instance of that type is constructed
(dedup is per-type, not per-instance — every instance's constructor calls Inject with
the same compile-time id/rewritten-css, so only the first actually creates a tag).
Never removed once injected — a scoped stylesheet is global infrastructure for as long as
the page lives, not per-instance content Teardown() would ever clean up. document.head
(dom.ks) is new too, added specifically for this.
Independent of template from — works with a hand-written Render() as well, which gets
a protected string ScopeId; field to apply manually (template from elements get their
data-kop-scope attribute automatically, so this is only needed by hand-written code):
public override VElement Render() {
VElement el = VElement.Create("div");
el.ClassName = "title";
el.SetAttr("data-kop-scope", this.ScopeId);
return el;
}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");
}
// VElement.Mount(this.Nav) in Render() (see "Nested component composition"
// below) would work here too, but a single, always-present root slot has
// no reordering or removal to get right, so the older, simpler
// AfterRender is just as correct — it's 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.
A trailing slash matches the same route as without one (/about/ == /about, / is left
alone) — real-world links and directory-style static hosting (a build-time prerendered
route's own /about/index.html, most concretely — see KopularDemo's own
scripts/prerender.mjs) routinely produce trailing-slash URLs, and every mainstream
router normalizes this the same way.
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 Render() already
re-embeds the matched page into its outlet (via VElement.Mount) 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.
Lazy routes (real code-splitting): AddRoute takes an already-built page — its whole
JS chunk loads eagerly, with everything else, at startup. AddLazyRoute takes a loader
instead, so that chunk is only ever fetched the first time its route actually matches:
nav.AddLazyRoute("/dogs", LoadDogsPage);LoadDogsPage is a () => task<Component> — a real dynamic import(), since KopScript's
own syntax has no expression for one. Write a tiny hand-written loader (not compiled from
.ks) and reach it via a relative extern (see KopScript's own README.md/LLM.md
"extern" section for the full mechanics — this needed a real compiler fix,
[email protected], to work reliably):
// dogs_page_loader.js — hand-written
export async function LoadDogsPage() {
const { DogsPage } = await import("./dogs_page.js");
return new DogsPage();
}extern task<Component> LoadDogsPage() from "./dogs_page_loader";The outlet shows a plain loading placeholder (<div class="router-loading">Loading...</div>
by default; override protected virtual VElement BuildLoadingPlaceholder() on a class
extending Router to customize it) while the fetch is in flight, then the real page once
it resolves — and, like an eager page, it's only ever fetched once: navigating away and
back reuses the same already-loaded instance, keeping whatever state it built up. A lazy
and an eager route mix freely in the same Router; nothing about AddRoute's own existing
signature changes.
Building and testing a lazy route: dogs_page.ks is deliberately not using'd from
your app's own entry — that's what keeps it out of the eager bundle — which also means your
normal ks build src/app.ks never compiles it, since ks build only walks the using
graph reachable from the entry you give it. Build it as its own separate entry too:
ks build src/app.ks && ks build src/dogs_page.ks (already-compiled shared dependencies
just get written again with identical output, so this is safe to add). For tests,
kopular/testing's runKopularApp (0.24.0+) compiles any other real .ks file present in
your srcDir that the entry doesn't reach — a lazy-route target is exactly that — so
AddLazyRoute needs no special test setup at all.
AllPaths() returns every registered path (both AddRoute and AddLazyRoute), in
registration order — for a caller that needs to enumerate real routes (a build-time static
prerender step, most likely) without hand-maintaining a second, driftable list next to the
composition root's own AddRoute calls.
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.
Nested component composition
Everything above builds content into a VElement tree — a real, independent child
Component (its own fields, its own Render(), its own reactive state<T>) is a
different thing to embed than a plain element. VElement.Mount(component) wraps one as a
slot the diff engine treats like any other content: created on first render, patched in
place (not torn down and rebuilt) across a re-render as long as the same instance still
occupies that slot, reordered by .Id exactly like any other keyed child, and torn down
— calling OnUnmount() — the moment it's replaced or removed:
class TodoItem : Component {
public string Id;
private string Text;
constructor(string id, string text) : base() {
this.Id = id;
this.Text = text;
}
public override VElement Render() {
VElement li = VElement.Create("li");
li.TextContent = this.Text;
return li;
}
}
class TodoList : Component {
private TodoItem[] Items;
constructor() : base() { this.Items = []; }
public override VElement Render() {
VElement ul = VElement.Create("ul");
this.Items.ForEach((TodoItem item) => {
VElement slot = VElement.Mount(item);
slot.Id = item.Id; // stable key — the same convention as any other list, above
ul.AppendChild(slot);
});
return ul;
}
public void Add(string id, string text) {
this.Items = this.Items.Push(new TodoItem(id, text));
this.Update();
}
public void Remove(string id) {
this.Items = this.Items.Filter((TodoItem i) => i.Id != id);
this.Update(); // the removed TodoItem's OnUnmount() fires here
}
}Each TodoItem is a genuinely independent Component — it can hold its own local
state<T>, subscribe to a shared service, or mount further children of its own (Update()
called from inside one re-renders just that item's own subtree, without disturbing its
siblings, exactly like the plain-VElement keyed-list case above). Override OnUnmount()
to release anything a removed instance was holding onto — most commonly, calling the
unsubscribe handle state<T>.Subscribe now returns (see kopscript's own LLM.md) for a
subscription made in the constructor:
class TodoItem : Component {
private () => void UnsubscribeShared;
constructor(SharedService shared) : base() {
this.UnsubscribeShared = shared.Count.Subscribe((number v) => this.Update());
}
protected override void OnUnmount() {
this.UnsubscribeShared();
}
}A component built once at app startup and never removed (every page Router manages
today, most real apps' top-level structure) never needs OnUnmount() at all — it's there
for the case a real dynamic list like TodoList above actually needs: a subscription that
must stop, not just outlive an app that was going to unload anyway. Removing a component
that itself mounted further children tears the whole subtree down, however many levels
deep — a middle component overriding its own OnUnmount() doesn't skip its children's.
Router's own outlet uses this mechanism too — its Render() embeds the matched page via
VElement.Mount, not the older AfterRender-based pattern still shown in "Router, and
deploying it" below for mounting the Router itself into an app's root container (a
single, always-present slot, where there's nothing this mechanism would add). Reach for
VElement.Mount for anything with real add/remove/reorder: a list of components, a modal
that comes and goes, anything genuinely dynamic. A template gets the same capability via
*mount="expr" — see "Templates" in kopscript's own README/LLM.md for the syntax; it
desugars to exactly this call.
Content projection — a parent giving a child what to render
React's children prop and Angular's <ng-content> both let a parent hand a child
arbitrary markup to render at a spot the child itself decides. Kopular needs no separate
mechanism for this at all: pass a () => VElement into the child's constructor, the same
as any other constructor argument, and have the child call it from inside its own
Render():
class Card : Component {
private () => VElement ContentBuilder;
constructor(() => VElement contentBuilder) : base() {
this.ContentBuilder = contentBuilder;
}
public override VElement Render() {
VElement div = VElement.Create("div");
div.ClassName = "card";
div.AppendChild(this.ContentBuilder());
return div;
}
}
// Usage, from inside the parent's own Render() — the parent decides what's inside the
// card, Card decides the chrome around it. `this` only means anything in a method, so
// that is where the callback is built:
class Page : Component {
constructor() : base() { }
public override VElement Render() {
Card card = new Card(() => this.BuildCardBody());
return VElement.Mount(card);
}
private VElement BuildCardBody() { return VElement.Create("p"); }
}Because the callback is invoked fresh on every one of Card's own renders (not a value
captured once and frozen), and it's a closure over the parent's own this, the projected
content reflects the parent's current state every time — bump a field the parent's own
BuildCardBody() reads, call the parent's Update(), and Card's own next patch shows the
new value, patched in place like any other content. No new API: this is just
VElement.Mount plus an ordinary function-typed constructor argument, the same "no hidden
magic, explicit constructor args" pattern dependency injection above already uses.
More than one projection point works the same way — one named callback per slot, the same
fixed-named-slot convention VElement's own OnClick/OnInput/OnBlur/OnChange already
use rather than a generic, unordered bag:
class Panel : Component {
private () => VElement HeaderBuilder;
private () => VElement BodyBuilder;
constructor(() => VElement headerBuilder, () => VElement bodyBuilder) : base() {
this.HeaderBuilder = headerBuilder;
this.BodyBuilder = bodyBuilder;
}
public override VElement Render() {
VElement div = VElement.Create("div");
VElement header = VElement.Create("header");
header.AppendChild(this.HeaderBuilder());
div.AppendChild(header);
VElement body = VElement.Create("section");
body.AppendChild(this.BodyBuilder());
div.AppendChild(body);
return div;
}
}Raw HTML, and its real security boundary
VElement.RawHtml sets a real, opaque innerHTML — an undiffed leaf, in place of
TextContent/children, for a raw-markup-plus-one-delegated-listener pattern (this site's
own header.ks/nav.ks use it exactly this way):
VElement header = VElement.Create("header");
header.RawHtml = SiteHeaderHtml; // a raw string ... from "./header.html"; constant
header.OnClick = (Event e) => { }; // one delegated listener over the whole subtreeIt's unescaped, real innerHTML — never assign it anything reachable from user input.
Every real use in this ecosystem is a raw string ... from "<path>.html"; compile-time
constant (see KopScript's own docs) — genuinely static markup, baked in at build time,
never a runtime value. Nothing in the type system stops el.RawHtml =
someFetchedString; or el.RawHtml = formField.Value.Value; from compiling — both are a
real XSS hole if that content is ever attacker-influenced. Use TextContent (always
escaped) for any dynamic string; RawHtml is for static markup only.
HTTP
using "kopular";
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 "kopular";
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:
VElement emailInput = VElement.Create("input");
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.
Computed values
React's useMemo/Angular signals' computed() equivalent — a read-only value derived
from one or more state<T> sources, recomputed and re-notified whenever a source changes:
using "kopular";
state<number> price = state(10);
state<number> qty = state(2);
Computed2<number, number, number> total = new Computed2<number, number, number>(
price, qty, (number p, number q) => p * q);
print(total.Value.Value); // 20
total.Value.Subscribe((number v) => print("total: " + v));
price.Value = 15; // prints "total: 30"Value is state<R>, not a bare R — KopScript's property grammar has no custom-getter
syntax (only auto-implemented { get; }/{ get; set; }), so a computed value can't expose
a property that recomputes itself on read. state<R> already is the right shape for
"read the current value, or subscribe to it changing" — total.Value.Value/
total.Value.Subscribe(...) are the exact same two members any other state<T> has, not a
parallel API to learn.
Dependencies are explicit, not automatically tracked the way Angular signals infer their
own by watching which signals get read during evaluation — that needs a live "currently
evaluating" context threaded through every read, a different kind of feature from anything
else here. Naming each source directly is one honest step short of automatic, consistent
with explicit constructor arguments everywhere else in Kopular (dependency injection,
routing). Computed1<A, R> covers one source; Computed2<A, B, R> covers two — fixed
arity for the same reason CombineValidators2/CombineValidators3 (above) are: KopScript
has no array-of-function-values type, and no way to express "N sources of N different
types" without one type parameter per source. Add a Computed3<A, B, C, R> the same way if
a real derivation ever needs three.
Async data — Resource<T>
The loading/success/failure shape almost every real app needs around a task<T> — most
commonly wrapping an Http call — as reactive state<T> a Component can render and
Subscribe to, instead of hand-rolling the same three fields and try/catch every time:
using "kopular";
class DogsPage : Component {
private Resource<Response> dogs;
constructor() : base() {
this.dogs = new Resource<Response>(Http.Get("https://dog.ceo/api/breeds/list/all"));
this.dogs.Status.Subscribe((AsyncStatus s) => this.Update());
}
}public override VElement Render() {
return match this.dogs.Status.Value {
AsyncStatus.Loading => this.BuildSpinner(),
AsyncStatus.Success => this.BuildList(this.dogs.Data.Value),
AsyncStatus.Failure => this.BuildError(this.dogs.Error.Value)
};
}Status starts at AsyncStatus.Loading the moment Resource is constructed — the
operation is already in flight (Http.Get(...) above is called before Resource ever
sees it; a task<T> value can only be produced by calling an async function, and
Resource's own constructor isn't one, so it always receives an already-started
operation, never starts one itself). Its internal handling of that task is genuinely
fire-and-forget from the constructor's point of view (there's no way to construct a
task value outside an async function body to await it there instead — see KopScript's
own "Async" docs) — Status/Data/Error transition together, exactly once, whichever
branch the task actually takes. match over AsyncStatus gets the same real
exhaustiveness checking any other enum match does — leaving out a case is a compile
error, not a runtime blank screen.
Starting a new project: kp new
npx kp new my-app scaffolds a real, working project: a Counter component
(src/counter.ks), an index.html with every Kopular module already mapped, and the
vendor/serve scripts needed to run in a browser (a browser can't resolve a bare specifier
like "kopular/component" the way Node's own module resolution does, so vendor-kopular.mjs
copies Kopular's runtime into vendor/ and index.html's import map points there).
npx kp new my-app
cd my-app
npm install
npm start # builds, vendors kopular's browser files, and serves at :8080Its README.md points an AI agent at this package's own GUIDE.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
using "kopular"; ([email protected]+) resolves this package's own KopScript declarations
through node_modules — one line, every Kopular type in scope, with each file's own
compiled output importing only what it actually references:
using "kopular";
class MyWidget : Component {
public override VElement Render() {
VElement el = VElement.Create("div");
el.TextContent = "Hello from MyWidget";
return el;
}
}See KopularDemo for a full working example (components, a service, and routing).
Before [email protected], every project hand-declared its own copy of Kopular's extern
bindings instead — using couldn't reach into a package yet. That still works (kp new
above can generate it), and is still the right call if you want a visibly trimmed subset —
see LLM.md's "Consuming Kopular from your own KopScript project" for the complete,
copy-ready block, Element/Document/Event included.
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 (see package.json's own devDependencies for
the exact version this repo currently requires — not restated by hand here, since it
changes far more often than this paragraph does) — 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() diffs and patches real DOM
(see "Component" above) rather than replacing a whole subtree on every re-render, and a
live child Component can be embedded directly in a parent's own tree via
VElement.Mount(child) — see "Nested component composition" above — so a parent's
re-render really can create/patch/reorder/tear down nested children declaratively now,
closing what used to be documented here as the framework's biggest reconciliation gap. A
template (.html) embeds one too, via *mount="expr" ([email protected]+), the same way
*if/*for embed structural logic — see "Templates" in kopscript's own docs. A list
child (Mounted or plain) without a stable
VElement.Id 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>).
