Kopular logo

Kopular

Kopular is a small component framework for KopScript, aiming for 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 compile to real, type-checked code instead of running through a separate interpreted layer. Published as kopular on npm.

kp new — scaffold a project

Every browser-facing Kopular project needs the same ambient extern bindings for the DOM and for Kopular's own classes (KopScript's using can't reach across a package boundary — see "Services, and the composition root" below for why), plus a vendor/serve setup so a browser can actually resolve kopular/component-style bare specifiers. Generate all of it instead of reconstructing it by hand:

npx kp new my-app
cd my-app
npm install
npm start

This is a real, working starting point — a Component using state<T>, not a placeholder — verified by actually building and serving it, not just checking that the files exist. kp ships from the kopular package itself, deliberately separate from KopScript's own ks CLI: scaffolding a Kopular app is a framework concern, and ks stays a pure-language tool with no framework-specific knowledge baked in.

Component

A base class with virtual Render() (describes the current state as a tree of VElement — a lightweight description of a DOM element, not a real one) and Update() (diffs the new tree against the previous one and patches only what changed, reusing real DOM nodes wherever a node's tag stays the same). Render() can be a real markup file (see "Templates" below) or hand-written imperative code building a VElement tree, the way you'd write careful vanilla-JS UI code — both compile to the same thing:

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;
  }
}

An optional virtual RenderError(string message) renders a fallback UI if Render() throws, instead of crashing whatever triggered it uncaught:

protected override VElement RenderError(string message) {
  VElement el = VElement.Create("div");
  el.TextContent = "Something went wrong: " + message;
  return el;
}

Purely additive — a Component that never overrides it behaves exactly as before this existed. VElement has a small, fixed set of named event fields (OnClick/OnInput/OnBlur/OnChange/OnSubmit) rather than a generic addEventListener — event handlers are part of the tree's own data, so the diff engine always knows exactly what to (re)attach when it reuses a real node. Anything not common enough for its own named field (href, src, alt, placeholder, ...) goes through SetAttr(name, value) instead. VElement.Text(content) is a bare text node, for text sitting next to elements in one parent. A list child gets a stable identity across reorders via VElement.Id, used as a real DOM-style key by the patch engine.

Nested component composition

A real, independent child Component — its own fields, its own Render(), its own reactive state — is a different thing to embed than a plain VElement. VElement.Mount(component) wraps one as a slot the diff engine treats like any other content: created on first render, patched in place 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 the mounted component's own OnUnmount() — the moment it's replaced or removed:

this.Widgets.ForEach((CounterWidget w) => {
  VElement slot = VElement.Mount(w);
  slot.Id = "slot-" + w.Label;   // stable key, same convention as any other list
  list.AppendChild(slot);
});

Each CounterWidget is a genuinely independent Component — bumping one only re-renders that widget's own subtree, without disturbing its siblings, and removing one really does call its OnUnmount(). See it running for real, add/remove and all, on the Nested components example page.

A template gets the same capability via *mount="expr" — see "Templates" below.

Content projection

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 decides. Kopular needs no separate mechanism for this: pass a () => VElement into the child's constructor, and call it from inside Render() wherever the projected content belongs — it's invoked fresh on every render, closing over the parent's own this, so it always reflects the parent's current state:

class Panel : Component {
  private () => VElement Body;
  constructor(() => VElement body) : base() { this.Body = body; }
  public override VElement Render() {
    VElement box = VElement.Create("div");
    box.ClassName = "panel";
    box.AppendChild(this.Body());   // the parent's own content, rendered fresh
    return box;
  }
}

// in the parent:
new Panel(() => {
  VElement span = VElement.Create("span");
  span.TextContent = "Count: " + this.Count.Value;
  return span;
});

Templates

template from "./x.html"; in the class body replaces a hand-written Render() with a real markup file — a KopScript language feature (see Language Docs), not new Kopular code: the compiler desugars it straight into calls against the same DOM surface a hand-written Render() already uses.

// 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>

No Subscribe call needed here — a state<T> field referenced directly in the template gets it wired automatically. The same goes for state the template reads through the component's own methods (*for="Todo t of Visible()"), followed transitively. Compare this to the hand-written Counter above, which calls Subscribe itself: that's still exactly what's needed for state on another object, such as an injected service — the way this site's own Home page counter does it (it injects a CounterService rather than holding Count itself, and its template still has one manual Subscribe in the constructor). Bindings are real KopScript, checked at compile time — a mistyped expression in the .html file is the same compile error you'd get anywhere else, reported at its real position in that file. Events are (click), (input), (blur), (change) and (submit) — the last calls preventDefault() for you, so a form never reloads the page. Text and elements mix freely under one element (<label>Email <input></label>). *if="expr" and *for="Type v of expr" cover the structural-directive cases; see "Structural directives" below for their hand-written-Render() equivalents. Both authoring styles produce the exact same Render() and mix freely in the same app. See both actually rendering together on the Notes page.

[(value)]="Field" is real two-way binding sugar — it desugars to exactly [value]="Field" plus an auto-generated (input)="Field = e.target.value", restricted to value specifically (the one field a user can change through direct interaction). This site's own Notes page uses it for real: <input [(value)]="Draft" /> is the whole binding, no separate input handler needed. A hand-written Render() has no equivalent shorthand — it already has direct field/handler access, so there's nothing to desugar.

*mount="expr" embeds a live child component (see "Nested component composition" above) declaratively, the same way *if/*for embed structural logic — it desugars to VElement.Mount(expr). Unlike *if/*for, it isn't mutually exclusive with them: the common case is a loop mounting one child per item:

<li *for="CounterWidget w of Widgets" *mount="w"></li>

expr must already be a fully-built value — the template doesn't construct it or pass props; however it got built (a field, Pure DI, a loop) stays ordinary KopScript outside the template. id/[id] still sets VElement.Id for keying; anything else on a *mount element is a compile error, since it has nowhere to go — the mounted child's own Render() owns all of its content.

Scoped component styles

styles from "./x.css"; in the class body — also a KopScript language feature — scopes a real stylesheet so it only matches elements that class itself renders, never a sibling's or a child's:

class Counter : Component {
  constructor(CounterService service) : base() { ... }
  template from "./counter_component.html";
  styles from "./counter_component.css";
}
/* counter_component.css */
.counter-button:active { transform: scale(0.97); }

Every element the template builds gets a data-kop-scope="<id>" attribute automatically, and the stylesheet is rewritten at compile time so every selector requires that same attribute — a real CSS tokenizer, not a string replace, so it handles combinators, comma-separated selectors, comments, and pseudo-classes/elements correctly. A hand-written Render() gets a this.ScopeId field to apply manually instead of the automatic template wiring. This site's own Home page counter uses it for real — the button's press animation above is scoped to that one component, not a global class rule.

Services, and the composition root — no DI container

A service is a plain class. "Injecting" it is just passing it as a constructor argument — no injector hierarchy, no provider tokens, no decorators — and it stays fully testable with zero Component/DOM machinery, since it's just a class.

class CounterService {
  public state<number> Count;
  constructor() { this.Count = state(0); }
  public void Increment() { this.Count.Value = this.Count.Value + 1; }
}

Counter c = new Counter(new CounterService());

Past a couple of services, hand-wiring every constructor call inline gets noisy — the fix isn't a container, it's one plain class (sometimes called a composition root) that builds the whole service/page graph exactly once and decides what's shared:

class AppContainer {
  public Router Nav;
  constructor() {
    CounterService counter = new CounterService(); // shared singleton
    this.Nav = new Router(new NotFoundPage());
    this.Nav.AddRoute("/", new HomePage(this.Nav, counter));
  }
}

This is "Pure DI" — the same benefit a container gives you (nothing constructs its own dependencies) with none of the cost: a missing or mistyped dependency is a compiler error, not a runtime "No provider for X." This site's own app_container.ks is exactly this, running for real — the whole dependency graph is one file.

Computed values

Computed1<A, R>/Computed2<A, B, R> derive one state<T> from one or two others, recomputing automatically whenever a source changes — built entirely on state<T>'s own Subscribe, no new framework primitive underneath:

Computed2<number, number, number> total = new Computed2<number, number, number>(
  quantity, price, (number q, number p) => q * p
);
total.Value.Subscribe((number v) => this.Update());   // total.Value is itself a state<number>

Value is exposed as a real state<R>, not a bare R — KopScript's property grammar has no custom-getter syntax, so a computed value can't expose a self-recomputing property directly, but .Value.Value/.Value.Subscribe(...) is the exact same shape as any other state<T>.

Async data — Resource<T>

Loading/success/failure state around a task<T>, so a component doesn't hand-roll the same three-state dance around every Http.Get:

Resource<Response> r = new Resource<Response>(Http.Get(url));   // task already in flight
r.Status.Subscribe((AsyncStatus s) => this.Update());

match r.Status.Value {
  AsyncStatus.Loading => BuildSpinner(),
  AsyncStatus.Success => BuildContent(r.Data.Value),   // T?
  AsyncStatus.Failure => BuildError(r.Error.Value)     // string?
};

The constructor is fire-and-forget internally, matching KopScript's own rule that a task<T> value can't be constructed outside an async function body — callers pass an already-started task (Http.Get(url), not something Resource itself starts).

Structural directives

*ngIf/*ngFor/*ngSwitch's job. In a template (see "Templates" above), *if/*for are real if/for statements under the hood — no separate directive runtime. In a hand-written Render(), the same job needs no special syntax either: two of the three map onto what KopScript already has.

<!-- in a template -->
<li *for="Item item of Items">{{ item.Name }}</li>
<p *if="Items.Length == 0">No items yet.</p>
// in a hand-written Render()

// *ngFor — a plain array method
items.ForEach((Item item) => { list.AppendChild(BuildItemRow(item)); });

// *ngSwitch — KopScript's own match expression (exhaustiveness-checked, unlike *ngSwitch)
root.AppendChild(match status {
  "loading" => BuildSpinner(),
  "error" => BuildError(),
  _ => BuildContent()
});

// *ngIf — the one case that needs a helper, since `if` is a statement, not an expression
root.AppendChild(If(isLoggedIn, () => BuildProfile(), () => BuildLoginButton()));

If() ships in kopular/directives, for the hand-written form only — a template's *if needs no helper, it's a real if. Both branches of If() are required (no null "nothing" to return) and only the branch actually taken ever runs.

Http — a thin wrapper over fetch

No HttpClient to inject, no RxJS Observables, no interceptors — Http's methods are plain static calls, each returning a task<Response>:

Response r = await Http.Get("/api/dogs");
if (r.ok) {
  string body = await r.text();
}

await Http.Post("/api/dogs", "{\"name\":\"Rex\"}");

No typed JSON deserialization — Response.text() is the raw body, nothing more, since that needs generic functions KopScript doesn't have. Describe the shape as its own extern class and parse it with extern MyShape Parse(string json) as "JSON.parse"; for a typed (unchecked, same trust model as every other extern) result instead — exactly what the Dogs page does with a real Http.Get call to a real public API (dog.ceo) for each dog's photo, typed result and all.

Forms — FormField<T> and Validators

One input's value, error, and touched state as three ordinary state<T> boxes — no FormGroup config object, no two-way-binding directive:

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

Validators ships Required/MinLength/MaxLength/Email/Min/Max, each returning an error message or null. There's no array-of-validators parameter — KopScript has no array-of-function-values type — so combining more than one check is an if-chain in one lambda, like email above, not a combinator API. Wiring .Value to a real <input> is a plain VElement.OnInput assignment reading e.target.value. See it running for real, with live validation errors, on the Dogs page's "Add a dog" form.

Router — real URLs, no config DSL

Real paths (/about, not #/about) via the History API (pushState/popstate). Route registration is a method call, not a config array:

this.Nav = new Router(new NotFoundPage());
this.Nav.AddRoute("/", new HomePage(this.Nav));
this.Nav.AddRoute("/about", new AboutPage());

Routes hold already-constructed Component instances, not factories — a genuine feature, not a workaround: each page is built once and kept alive for the Router's lifetime, so a page's own state<T> survives navigating away and back. A NotFoundPage is required up front — KopScript now has nullable types (T?, see the Language docs), but Router predates them and still leans on "a fallback is always provided" rather than "no match is null." This very site is the working example — try navigating away from Home after bumping the counter, then back.

A path segment written :name matches any single non-empty segment, captured into Router.Param — this is no longer a hypothetical example: the Dogs page's own detail route is real, live /dogs/:id routing:

this.Nav.AddRoute("/dogs/:id", new DogDetailPage(this.Nav, dogs));
// inside DogDetailPage.Render():
Dog? dog = this.Service.Find(this.Nav.Param);

Param is a plain string, deliberately not state<T> — Router's own Render() already rebuilds a fresh outlet and re-Mount()s the matched page on every navigation, which re-runs that page's Render() (reading the fresh Param) with no Subscribe() needed. Just one dynamic segment per route for now — no /dogs/:id/toys/:toyId, no wildcards, no query string parsing.

Lazy routes (real code-splitting): AddLazyRoute(path, loader) is like AddRoute, but loader is a () => task<Component> instead of an already-built page — its JS is only fetched the first time the route actually matches, not eagerly with everything else at startup. loader is a real dynamic import(), reached via a hand-written loader shim and a relative extern:

// settings_page_loader.js
export async function LoadSettingsPage() {
  const { SettingsPage } = await import("./settings_page.js");
  return new SettingsPage();
}
extern task<Component> LoadSettingsPage() from "./settings_page_loader";
this.Nav.AddLazyRoute("/settings", LoadSettingsPage);

The outlet shows a plain loading placeholder (override BuildLoadingPlaceholder()) while the fetch is in flight; the loaded page is cached after the first fetch, same as an eager page — navigating away and back reuses it, no re-fetch. Mixes freely with AddRoute in the same Router.

SetGuard protects a route (or any set of routes) behind a check — one function for the whole Router, not a per-route config, checked before every navigation:

this.Nav.SetGuard("/login", (string path) => {
  if (path == "/admin") { return authService.IsLoggedIn.Value; }
  return true;
});

Returning false redirects to the given path (updating the URL too, so a refresh on the blocked page lands on the redirect again). Defaults to always-allow until SetGuard is called. See it running for real on the Notes page — /notes/archive stays redirected to /notes until at least one note is archived.