kopular 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LLM.md ADDED
@@ -0,0 +1,198 @@
1
+ # Kopular — LLM reference
2
+
3
+ Complete reference for generating correct Kopular code. This is a spec, not a tutorial —
4
+ see `README.md` for narrative/rationale. Kopular is 4 files total; this covers all of
5
+ them. For the host language, see KopScript's own `LLM.md` in the `Kop` repo (or its
6
+ published `LLM.md` on the `kopscript` npm package) — that reference is a prerequisite,
7
+ not repeated here.
8
+
9
+ Published as npm `kopular`. Entry points: `kopular` / `kopular/component` (Component),
10
+ `kopular/router` (Router), `kopular/dom` (ambient DOM bindings), `kopular/directives` (If).
11
+
12
+ ## Consuming Kopular from your own KopScript project
13
+
14
+ `using` only resolves same-project relative paths — reaching into an npm package (Kopular
15
+ included) always goes through `extern`, re-describing exactly the members you use:
16
+
17
+ ```ks
18
+ extern class Component {
19
+ constructor();
20
+ virtual Element Render(); // `virtual` here is what lets your subclass `override` it
21
+ void Mount(Element parent);
22
+ void Update();
23
+ } from "kopular/component";
24
+
25
+ extern class Router {
26
+ constructor(Component notFoundPage);
27
+ void AddRoute(string path, Component page);
28
+ void Navigate(string path);
29
+ void Mount(Element parent);
30
+ } from "kopular/router";
31
+
32
+ extern Element If(bool condition, () => Element whenTrue, () => Element whenFalse) from "kopular/directives";
33
+ ```
34
+
35
+ You also need your own ambient DOM `extern` block (`document`, `Element`, `Event`, ...) —
36
+ Kopular's own copy in `dom.ks` isn't reachable across the package boundary; redeclare the
37
+ handful of members you actually use. See KopularDemo's `src/kopular_bindings.ks` for a
38
+ complete, real example of both.
39
+
40
+ ## `Component` (`component.ks`)
41
+
42
+ ```ks
43
+ class MyWidget : Component {
44
+ private number count;
45
+
46
+ constructor() : base() {
47
+ this.count = 0;
48
+ }
49
+
50
+ public override Element Render() {
51
+ Element el = document.createElement("div");
52
+ el.textContent = "Count: " + this.count;
53
+ return el;
54
+ }
55
+
56
+ public void Bump() {
57
+ this.count = this.count + 1;
58
+ this.Update(); // re-runs Render(), swaps the old root for the new one
59
+ }
60
+ }
61
+
62
+ MyWidget w = new MyWidget();
63
+ w.Mount(document.body); // first Render() + append
64
+ w.Bump(); // re-render
65
+ ```
66
+
67
+ - `Render()`: `virtual`, override it to build a fresh DOM subtree from current state.
68
+ Called by both `Mount()` and `Update()`. **No template language, no diffing** — every
69
+ call rebuilds the whole subtree from scratch.
70
+ - `Mount(parent)`: calls `Render()` once, appends the result to `parent`, remembers both
71
+ for `Update()` to use later.
72
+ - `Update()` (protected — called from within the component, not externally): calls
73
+ `Render()` again and `replaceChild`s the old root with the new one.
74
+ - **Known limitation**: if a *parent* component's `Update()` runs while it has mounted
75
+ children, those children are NOT automatically re-mounted into the new parent tree —
76
+ `Component` only handles a single component's own re-render cycle, not tree
77
+ reconciliation. Compose independent components into stable slots (see `Router`'s own
78
+ pattern of keeping page instances alive) to avoid this rather than nesting components
79
+ that both re-render.
80
+
81
+ ## `Router` (`router.ks`)
82
+
83
+ Real URLs via the History API (`pushState`/`popstate`), not hash routing.
84
+
85
+ ```ks
86
+ class NotFoundPage : Component {
87
+ public override Element Render() { /* ... */ }
88
+ }
89
+ class HomePage : Component {
90
+ private Router Nav;
91
+ constructor(Router nav) : base() { this.Nav = nav; }
92
+ public override Element Render() {
93
+ Element btn = document.createElement("button");
94
+ btn.addEventListener("click", (Event e) => { this.Nav.Navigate("/about"); });
95
+ return btn;
96
+ }
97
+ }
98
+
99
+ Router nav = new Router(new NotFoundPage()); // NotFoundPage REQUIRED up front — no null route
100
+ nav.AddRoute("/", new HomePage(nav)); // pages built once, kept alive for Router's lifetime
101
+ nav.AddRoute("/about", new AboutPage(nav));
102
+ nav.Mount(document.body);
103
+ nav.Navigate("/about"); // pushState + immediate re-render
104
+ ```
105
+
106
+ - Constructor takes the fallback page (`Component`), required — there's no nullable
107
+ "no match" representation.
108
+ - `AddRoute(path, page)` takes an **already-constructed `Component` instance**, not a
109
+ factory — every registered page is built once and stays alive for the Router's whole
110
+ lifetime, so a page's own `state<T>` fields survive navigating away and back.
111
+ - `Navigate(path)` calls `history.pushState` then re-renders immediately. A browser
112
+ back/forward triggers re-render via a `popstate` listener registered in the
113
+ constructor — `Navigate()` itself doesn't rely on that event.
114
+ - Needs a server that falls back to the app shell for any unrecognized path (a plain
115
+ static server has nothing to serve at `/about` on direct load/refresh).
116
+
117
+ ## `If` (`directives.ks`) — the `*ngIf` equivalent
118
+
119
+ ```ks
120
+ Element If(bool condition, () => Element whenTrue, () => Element whenFalse)
121
+ ```
122
+
123
+ ```ks
124
+ root.appendChild(If(this.IsLoggedIn, () => this.BuildProfile(), () => this.BuildLogin()));
125
+ ```
126
+
127
+ Both branches always required (no null "nothing" value); only the branch actually taken
128
+ is called — the other lambda never runs. `*ngFor` and `*ngSwitch` need no Kopular helper
129
+ at all:
130
+
131
+ ```ks
132
+ // *ngFor — plain array method
133
+ this.Items.ForEach((Item item) => { list.appendChild(this.BuildItemRow(item)); });
134
+
135
+ // *ngSwitch — plain KopScript `match` expression (exhaustiveness-checked, unlike *ngSwitch)
136
+ root.appendChild(match this.Status {
137
+ "loading" => this.BuildSpinner(),
138
+ "error" => this.BuildError(),
139
+ _ => this.BuildContent()
140
+ });
141
+ ```
142
+
143
+ No keyed/reuse-existing-DOM-nodes diffing (the performance angle of `*ngFor trackBy`) —
144
+ that needs comparing old/new data by a caller key, generic over item type, and KopScript
145
+ has no generics. Not planned as a workaround; would need real language-level generics
146
+ first.
147
+
148
+ ## Dependency injection — no container, no decorators
149
+
150
+ There is no injector, no `@Injectable`, no provider tokens. "Injecting" a service is
151
+ passing it as a constructor argument — the compiler enforces it (a missing/mistyped
152
+ dependency is a build error). For an app with more than a couple of services, use one
153
+ plain "composition root" class (not a `Component`, never touches the DOM) that builds
154
+ the whole service/page graph exactly once and hands the finished pieces to whatever
155
+ needs them:
156
+
157
+ ```ks
158
+ class AppContainer {
159
+ public Router Nav;
160
+ constructor() {
161
+ CounterService counter = new CounterService(); // shared singleton: pass the same
162
+ this.Nav = new Router(new NotFoundPage()); // instance wherever it should be
163
+ this.Nav.AddRoute("/", new HomePage(this.Nav, counter));
164
+ }
165
+ }
166
+
167
+ class RoutedApp : Component {
168
+ private Router Nav;
169
+ constructor(AppContainer services) : base() { this.Nav = services.Nav; }
170
+ public override Element Render() {
171
+ Element el = document.createElement("div");
172
+ this.Nav.Mount(el);
173
+ return el;
174
+ }
175
+ }
176
+
177
+ RoutedApp app = new RoutedApp(new AppContainer());
178
+ app.Mount(document.body);
179
+ ```
180
+
181
+ A service needing its own state/logic is a plain class — no base class, no
182
+ decorators, nothing framework-specific:
183
+
184
+ ```ks
185
+ class CounterService {
186
+ public state<number> Count;
187
+ constructor() { this.Count = state(0); }
188
+ public void Increment() { this.Count.Value = this.Count.Value + 1; }
189
+ }
190
+ ```
191
+
192
+ ## Does not exist
193
+
194
+ DI container/injector · decorators (`@Injectable`, `@Component`, ...) · a template
195
+ language/DSL — everything is imperative `Render()` code against plain DOM bindings ·
196
+ vdom diffing / reconciliation beyond a single component's own re-render · pipes ·
197
+ animations · forms/validation module · HTTP client · a CLI/scaffolding tool (`ng
198
+ generate`-equivalent) · SSR.
package/README.md CHANGED
@@ -1,3 +1,5 @@
1
+ <img src="https://cdn.jsdelivr.net/npm/kopular@latest/assets/logo.png" width="96" height="96" alt="Kopular logo">
2
+
1
3
  # Kopular
2
4
 
3
5
  Kopular is a small component framework for [KopScript](https://dev.azure.com/koppinator/Koppindependence/_git/Kop),
@@ -5,6 +7,9 @@ built to give Angular's separation of concerns — components own UI, services o
5
7
  a router owns navigation — without Angular's steepest learning-curve pieces: no RxJS, no
6
8
  dependency-injection container, no template DSL.
7
9
 
10
+ Generating Kopular code with an AI coding assistant? Point it at **[`LLM.md`](./LLM.md)**
11
+ — a dense, complete reference designed to be loaded straight into an LLM's context.
12
+
8
13
  ## Highlights
9
14
 
10
15
  - **`Component`**: a base class with `virtual Render()` (builds a fresh DOM subtree from
@@ -24,16 +29,155 @@ dependency-injection container, no template DSL.
24
29
  DSL. Needs a server that falls back to the app shell for unrecognized paths — see
25
30
  [KopularDemo](https://dev.azure.com/koppinator/Koppindependence/_git/KopularDemo)'s
26
31
  `scripts/serve.mjs`.
32
+ - **Structural directives, no template DSL**: `*ngIf`/`*ngFor`/`*ngSwitch`'s job — build
33
+ a subtree conditionally, repeat one per item, pick one of several cases — done as plain
34
+ function calls (`If(...)`) and existing KopScript expressions (`array.ForEach(...)`,
35
+ `match`), not special template syntax. See "Structural directives" below.
27
36
 
28
37
  ## What's here
29
38
 
30
39
  - `src/dom.ks` — ambient DOM bindings (`document`, `Element`, `Event`, `window`,
31
- `location`) that `component.ks`/`router.ks` are built on.
40
+ `location`) that `component.ks`/`router.ks`/`directives.ks` are built on.
32
41
  - `src/component.ks` — the `Component` base class.
33
42
  - `src/router.ks` — the `Router`.
43
+ - `src/directives.ks` — `If()`, the structural-directive equivalents' one genuinely new
44
+ piece (see below).
45
+
46
+ That's the whole framework — four files. Everything else (a real app built on top of it)
47
+ lives in a separate consumer repo, [KopularDemo](https://dev.azure.com/koppinator/Koppindependence/_git/KopularDemo).
48
+
49
+ ## Dependency injection: the composition root pattern
50
+
51
+ Kopular has no injector because KopScript has nothing for one to hook into — no
52
+ decorators, no reflection, no generics for a type-safe `Resolve<T>()`. Instead, the
53
+ whole app's service/page graph gets built exactly once, by hand, in one place: a plain
54
+ class with no `Component` base and no framework code in it at all, sometimes called an
55
+ **app container** or (in the wider DI literature) a **composition root**. Everything
56
+ else just takes what it needs as constructor arguments and never constructs its own
57
+ dependencies.
58
+
59
+ ```ks
60
+ // app_container.ks — the one place that decides what's shared and builds
61
+ // the graph, in dependency order.
62
+ class AppContainer {
63
+ public Router Nav;
64
+
65
+ constructor() {
66
+ // Built once, passed to every page that needs it below — that's the
67
+ // whole mechanism for a shared singleton. A page that constructed its
68
+ // own `new CounterService()` instead would get an independent one; the
69
+ // difference is which variable gets passed in, not a config flag.
70
+ CounterService counter = new CounterService();
71
+
72
+ this.Nav = new Router(new NotFoundPage());
73
+ this.Nav.AddRoute("/", new HomePage(this.Nav, counter));
74
+ this.Nav.AddRoute("/about", new AboutPage(this.Nav));
75
+ }
76
+ }
77
+
78
+ // routed_app.ks — the root Component. Takes the already-built graph; never
79
+ // builds one of its own.
80
+ class RoutedApp : Component {
81
+ private Router Nav;
82
+
83
+ constructor(AppContainer services) : base() {
84
+ this.Nav = services.Nav;
85
+ }
86
+
87
+ public override Element Render() {
88
+ Element container = document.createElement("div");
89
+ this.Nav.Mount(container);
90
+ return container;
91
+ }
92
+ }
93
+
94
+ RoutedApp app = new RoutedApp(new AppContainer());
95
+ app.Mount(document.body);
96
+ ```
97
+
98
+ This is sometimes called "Pure DI" — the same benefits a container gives you (nothing
99
+ hardcodes its own dependencies, everything is swappable in a test) with none of a
100
+ container's cost:
101
+
102
+ - **Compile-time checked.** A missing or mistyped dependency is `expected N arguments,
103
+ got M` from the KopScript compiler, not a `NullInjectorError` your users hit at
104
+ runtime after a container fails to resolve something.
105
+ - **Fully legible.** The entire dependency graph is ordinary, readable code in one file
106
+ — grep for `new` in the composition root and you've read the whole wiring diagram.
107
+ Nothing is constructed by a framework inspecting metadata behind the scenes.
108
+ - **No new concepts.** If you already know how to call a constructor, you already know
109
+ Kopular's DI story — there's no separate injector API, provider syntax, or
110
+ injection-token vocabulary to learn.
111
+
112
+ See [KopularDemo](https://dev.azure.com/koppinator/Koppindependence/_git/KopularDemo)'s
113
+ `src/app_container.ks` and `src/routed_app.ks` for the real, working version this
114
+ example is drawn from.
115
+
116
+ ## Structural directives
117
+
118
+ Angular's `*ngIf`/`*ngFor`/`*ngSwitch` are template syntax that expands, at compile time,
119
+ into imperative view-container calls. Kopular has no template compiler to expand
120
+ anything into (`Render()` is already imperative — see `component.ks`), so there's no
121
+ special syntax here either: each one maps onto a plain expression, and two of the three
122
+ need nothing new at all.
123
+
124
+ | Angular | Kopular | New code? |
125
+ | ----------------- | ------------------------------------------- | :-------: |
126
+ | `*ngFor` | `array.ForEach((item) => ...)` | none — already a KopScript array method |
127
+ | `*ngSwitch` | `match value { ... }` | none — already a KopScript expression, and exhaustiveness-checked (`*ngSwitch` isn't) |
128
+ | `*ngIf` / `*ngIf-else` | `If(condition, () => ..., () => ...)` | `directives.ks` |
129
+
130
+ `*ngIf` is the one case that needs something new: `if` is a *statement* in KopScript, so
131
+ without a helper you'd need a throwaway mutable local just to get a conditional value out
132
+ of it. `If()` is that helper — nothing more than:
133
+
134
+ ```ks
135
+ Element If(bool condition, () => Element whenTrue, () => Element whenFalse) {
136
+ if (condition) {
137
+ return whenTrue();
138
+ }
139
+ return whenFalse();
140
+ }
141
+ ```
142
+
143
+ Both branches are required (same reasoning `Router` uses for requiring a `NotFoundPage`
144
+ up front — see `router.ks`): v1 has no nullable types, so "render nothing" has no value
145
+ to hand back. Only the branch actually taken runs — the other lambda is never called, so
146
+ an explicit empty branch (`() => document.createElement("span")`) costs nothing when
147
+ there's genuinely nothing to show.
148
+
149
+ All three read the same way, right inside `Render()` — no template file, no directive
150
+ registration, nothing to import beyond the function itself:
151
+
152
+ ```ks
153
+ public override Element Render() {
154
+ Element root = document.createElement("div");
155
+
156
+ // *ngIf
157
+ root.appendChild(If(this.User.IsLoggedIn, () => this.BuildProfile(), () => this.BuildLoginButton()));
158
+
159
+ // *ngFor
160
+ Element list = document.createElement("ul");
161
+ this.Items.ForEach((Item item) => { list.appendChild(this.BuildItemRow(item)); });
162
+ root.appendChild(list);
163
+
164
+ // *ngSwitch
165
+ root.appendChild(match this.Status {
166
+ "loading" => this.BuildSpinner(),
167
+ "error" => this.BuildError(),
168
+ _ => this.BuildContent()
169
+ });
170
+
171
+ return root;
172
+ }
173
+ ```
34
174
 
35
- That's the whole framework three files. Everything else (a real app built on top of
36
- it) lives in a separate consumer repo, [KopularDemo](https://dev.azure.com/koppinator/Koppindependence/_git/KopularDemo).
175
+ Why not a general reuse-existing-DOM-nodes diffing layer, the way `*ngFor trackBy`
176
+ avoids rebuilding unchanged rows? That needs comparing old and new *data* items by a
177
+ caller-supplied key, generic over the item type — and KopScript has no generics (no
178
+ `class Foo<T>`, no `T Resolve<T>()`). A one-off keyed-diff helper could be hand-written
179
+ per list, but that's real vdom-diffing work — already called out as out of scope in
180
+ "Status" below, and not something these three lines take on.
37
181
 
38
182
  ## Using Kopular from another KopScript project
39
183
 
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kopular",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Kopular: a small component framework for KopScript — components, reactive state, constructor-injected services, and routing, with no template DSL and no DI container",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -20,19 +20,22 @@
20
20
  ".": "./src/component.js",
21
21
  "./component": "./src/component.js",
22
22
  "./router": "./src/router.js",
23
- "./dom": "./src/dom.js"
23
+ "./dom": "./src/dom.js",
24
+ "./directives": "./src/directives.js"
24
25
  },
25
26
  "files": [
26
- "src"
27
+ "src",
28
+ "assets",
29
+ "LLM.md"
27
30
  ],
28
31
  "scripts": {
29
- "build": "ks build src/router.ks",
32
+ "build": "ks build src/router.ks && ks build src/directives.ks",
30
33
  "prepublishOnly": "npm run build",
31
34
  "test": "vitest run",
32
35
  "test:watch": "vitest"
33
36
  },
34
37
  "devDependencies": {
35
- "kopscript": "^0.1.0",
38
+ "kopscript": "^0.2.0",
36
39
  "@types/jsdom": "^30.0.0",
37
40
  "@types/node": "^20.14.0",
38
41
  "jsdom": "^25.0.1",
@@ -0,0 +1,8 @@
1
+ import { Event, Element, Document, Location, History, Window, document, location, history, window } from "./dom.js";
2
+
3
+ export function If(condition, whenTrue, whenFalse) {
4
+ if (condition) {
5
+ return whenTrue();
6
+ }
7
+ return whenFalse();
8
+ }
@@ -0,0 +1,30 @@
1
+ using "./dom";
2
+
3
+ // Kopular's structural-directive equivalents — see the README's
4
+ // "Structural directives" section for the full *ngIf/*ngFor/*ngSwitch
5
+ // mapping. There's no template language here (Kopular doesn't have one, by
6
+ // design — see component.ks), so these are just plain functions: call them
7
+ // like any other expression from inside Render(), the same way you'd call
8
+ // document.createElement.
9
+ //
10
+ // *ngFor and *ngSwitch need nothing new — `array.ForEach(...)` and
11
+ // KopScript's own `match` expression already cover them (and `match` is
12
+ // exhaustiveness-checked, which *ngSwitch isn't). `If` below is the one
13
+ // piece the language doesn't already give you as an expression: `if` is a
14
+ // statement in KopScript, so without this you'd need a throwaway mutable
15
+ // local to get a conditional value.
16
+
17
+ // The *ngIf equivalent — conditionally build one of two subtrees, as an
18
+ // expression. Both branches are required: v1 has no nullable types, so
19
+ // "render nothing" has no value to return — the same reasoning Router uses
20
+ // for requiring a NotFoundPage up front (see router.ks) rather than letting
21
+ // "no match" be null. Only the branch actually taken runs; the other
22
+ // lambda is never called, so an explicit empty branch (e.g.
23
+ // `() => document.createElement("span")`) costs nothing when there's
24
+ // genuinely nothing to show.
25
+ Element If(bool condition, () => Element whenTrue, () => Element whenFalse) {
26
+ if (condition) {
27
+ return whenTrue();
28
+ }
29
+ return whenFalse();
30
+ }