kopular 0.13.0 → 0.14.1

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 CHANGED
@@ -1,15 +1,18 @@
1
1
  # Kopular — LLM reference
2
2
 
3
3
  Complete reference for generating correct Kopular code. This is a spec, not a tutorial —
4
- see `README.md` for narrative/rationale. Kopular is 8 files total; this covers all of
5
- them. For the host language, see KopScript's own `LLM.md` in the `Kop` repo (or its
4
+ see `README.md` for narrative/rationale. Kopular is 10 files total; this covers all of
5
+ them. For the host language, see KopScript's own `LLM.md` in the `KopScript` repo (or its
6
6
  published `LLM.md` on the `kopscript` npm package) — that reference is a prerequisite,
7
7
  not repeated here.
8
8
 
9
9
  Published as npm `kopular`. Entry points: `kopular` / `kopular/component` (Component),
10
+ `kopular/velement` (VElement — what `Render()` returns; see "Component" below),
10
11
  `kopular/router` (Router), `kopular/dom` (ambient DOM bindings), `kopular/directives`
11
12
  (If), `kopular/http` (Http), `kopular/forms` (FormField, Validators), `kopular/testing`
12
- (runKopularApp, runKopularFixture — see below). Also ships a bin, `kp` `npx kp new
13
+ (runKopularApp, runKopularFixture — see below). `kopular/vdom` (the diff/patch engine
14
+ behind `Update()`) is internal — nothing outside `component.ks` needs to import it
15
+ directly. Also ships a bin, `kp` — `npx kp new
13
16
  <dir>` scaffolds a new project (the `extern` bindings below, plus the vendor/serve
14
17
  scripts needed to run in a browser) rather than requiring it be reconstructed by hand;
15
18
  prefer it over hand-writing the section below for a new project. Templates (`template
@@ -22,9 +25,25 @@ there is no `kopular/template` entry point to import.
22
25
  included) always goes through `extern`, re-describing exactly the members you use:
23
26
 
24
27
  ```ks
28
+ extern class VElement {
29
+ static VElement Create(string tag);
30
+ string TextContent { get; set; }
31
+ string ClassName { get; set; }
32
+ string Id { get; set; }
33
+ string Value { get; set; }
34
+ string RawHtml { get; set; }
35
+ (Event) => void OnClick { get; set; }
36
+ (Event) => void OnInput { get; set; }
37
+ (Event) => void OnBlur { get; set; }
38
+ (Event) => void OnChange { get; set; }
39
+ void AppendChild(VElement child);
40
+ void SetAttr(string name, string value);
41
+ } from "kopular/velement";
42
+
25
43
  extern class Component {
26
44
  constructor();
27
- virtual Element Render(); // `virtual` here is what lets your subclass `override` it
45
+ virtual VElement Render(); // `virtual` here is what lets your subclass `override` it
46
+ virtual void AfterRender(Element root); // see "Component" below
28
47
  void Mount(Element parent);
29
48
  void Update();
30
49
  } from "kopular/component";
@@ -36,7 +55,7 @@ extern class Router {
36
55
  void Mount(Element parent);
37
56
  } from "kopular/router";
38
57
 
39
- extern Element If(bool condition, () => Element whenTrue, () => Element whenFalse) from "kopular/directives";
58
+ extern VElement If(bool condition, () => VElement whenTrue, () => VElement whenFalse) from "kopular/directives";
40
59
 
41
60
  extern class Response {
42
61
  bool ok { get; }
@@ -86,7 +105,7 @@ Kopular's own copy in `dom.ks` isn't reachable across the package boundary; rede
86
105
  handful of members you actually use. See KopularDemo's `src/kopular_bindings.ks` for a
87
106
  complete, real example of both.
88
107
 
89
- ## `Component` (`component.ks`)
108
+ ## `Component` (`component.ks`, `velement.ks`, `vdom.ks`) — real vdom diffing
90
109
 
91
110
  ```ks
92
111
  class MyWidget : Component {
@@ -96,46 +115,68 @@ class MyWidget : Component {
96
115
  this.count = 0;
97
116
  }
98
117
 
99
- public override Element Render() {
100
- Element el = document.createElement("div");
101
- el.textContent = "Count: " + this.count;
118
+ public override VElement Render() {
119
+ VElement el = VElement.Create("div");
120
+ el.TextContent = "Count: " + this.count;
102
121
  return el;
103
122
  }
104
123
 
105
124
  public void Bump() {
106
125
  this.count = this.count + 1;
107
- this.Update(); // re-runs Render(), swaps the old root for the new one
126
+ this.Update(); // re-runs Render(), diffs it against the previous tree, patches real DOM
108
127
  }
109
128
  }
110
129
 
111
130
  MyWidget w = new MyWidget();
112
- w.Mount(document.body); // first Render() + append
113
- w.Bump(); // re-render
131
+ w.Mount(document.body); // first Render() + materialize + append
132
+ w.Bump(); // re-render + diff + patch
114
133
  ```
115
134
 
116
- - `Render()`: `virtual`, override it to build a fresh DOM subtree from current state.
117
- Called by both `Mount()` and `Update()`. **No diffing either way** — every call
118
- rebuilds the whole subtree from scratch. Provide it as a real markup file via
119
- KopScript's `template from "./x.html";` (see "Templates" below) instead of hand-writing
120
- it — both produce the exact same method; Kopular needed no code changes to support this.
121
- - `Mount(parent)`: calls `Render()` once, appends the result to `parent`, remembers both
122
- for `Update()` to use later.
135
+ - `Render()`: `virtual`, override it to describe the current state as a `VElement` tree —
136
+ a lightweight description of a DOM element (`kopular/velement`), not a real one. Called
137
+ by both `Mount()` and `Update()`. Provide it as a real markup file via KopScript's
138
+ `template from "./x.html";` (see "Templates" below) instead of hand-writing it — both
139
+ produce the exact same method; Kopular needed no framework code changes to support this.
140
+ - `VElement`: `Tag`, `TextContent`/`ClassName`/`Id`/`Value` (direct fields `Value` is the
141
+ one that must be a live DOM *property*, not an attribute: `SetAttr("value", x)` sets the
142
+ default value, not the current one), `RawHtml` (an opaque, undiffed leaf — set instead of
143
+ `TextContent`/children, for a raw-HTML-then-wire-handlers pattern), the four fixed named
144
+ event fields `OnClick`/`OnInput`/`OnBlur`/`OnChange` (each a real no-op by default, never
145
+ null — no nullable function type to fall back on), `AppendChild(child)`, and
146
+ `SetAttr(name, value)` (the escape hatch for any other real HTML attribute — `href`,
147
+ `src`, `alt`, `placeholder`, ...; never `Value`, see above). `Id` doubles as a stable key
148
+ for list-child reconciliation — see `PatchChildren` below.
149
+ - `Mount(parent)`: calls `Render()` once, materializes the returned tree into real DOM
150
+ (`Materialize` in `vdom.ks`), appends it to `parent`, then calls `AfterRender(root)`.
123
151
  - `Update()` (protected — called from within the component, not externally): calls
124
- `Render()` again and `replaceChild`s the old root with the new one.
125
- - **Known limitation**: if a *parent* component's `Update()` runs while it has mounted
126
- children, those children are NOT automatically re-mounted into the new parent tree
127
- `Component` only handles a single component's own re-render cycle, not tree
128
- reconciliation. Compose independent components into stable slots (see `Router`'s own
129
- pattern of keeping page instances alive) to avoid this rather than nesting components
130
- that both re-render.
152
+ `Render()` again and `Patch`es the new tree against the previous one (`vdom.ks`), reusing
153
+ a real DOM node wherever a node's tag stays the same instead of rebuilding it sibling
154
+ nodes untouched by the change keep their exact identity (`===`), not just their content.
155
+ A list child without a stable `Id` still ends up correct after a reorder, but isn't
156
+ guaranteed to keep its own real node (see `PatchChildren`'s keyed-vs-positional matching
157
+ in `vdom.ks`). Then calls `AfterRender(root)`.
158
+ - `AfterRender(root)`: `virtual`, a no-op by default, called at the end of both `Mount()`
159
+ and `Update()` with the real, now-materialized/patched root `Element`. For a component
160
+ that needs to do further imperative work against its own real DOM — most commonly,
161
+ mounting a *nested* `Component` into a slot `Render()`'s tree just describes as data,
162
+ since a live mounted child isn't something `VElement` can express (see `Router`'s own
163
+ use of this to mount its matched page into the outlet, and "Dependency injection" below
164
+ for `RoutedApp` doing the same with `Router` itself).
165
+ - **Known limitation**: reconciliation is per-`Component`, not across nested ones — if a
166
+ *parent* component's own `Render()` output changes shape around a slot where a nested
167
+ Component was `Mount()`ed via `AfterRender`, that nested Component isn't automatically
168
+ re-`Mount()`ed or torn down as part of the parent's diff. Compose independent components
169
+ into a stable, unchanging slot (see `Router`'s own outlet, which never changes tag or
170
+ position) to avoid this rather than nesting components whose *surrounding* structure
171
+ also changes.
131
172
  - **`Update()` before `Mount()` is a safe no-op**, not an error. This matters for two
132
173
  sibling `Component`s (e.g. two `Router` pages) that share one injected service's
133
174
  `state<T>` and both `Subscribe()` it — every route's page is constructed eagerly (see
134
175
  `Router.AddRoute`), so at any given time most of them are constructed but never
135
176
  `Mount()`ed. Changing that shared state fires `Subscribe` on all of them, including the
136
- ones that aren't the currently-routed page — `Update()` just skips the render/replace
137
- for those, since `Mount()` will run a fresh `Render()` anyway whenever one of them
138
- actually becomes routed.
177
+ ones that aren't the currently-routed page — `Update()` just skips the render/patch for
178
+ those, since `Mount()` will run a fresh `Render()` anyway whenever one of them actually
179
+ becomes routed.
139
180
  - `RenderError(string message)`: `virtual`, called by `Mount()`/`Update()` (via a private
140
181
  `SafeRender()` wrapper) when `Render()` throws, instead of letting the exception
141
182
  propagate uncaught and crash whatever triggered the render (a click handler, a `Router`
@@ -143,9 +184,9 @@ w.Bump(); // re-render
143
184
  opt-in error recovery**; a `Component` that never overrides `RenderError` behaves
144
185
  exactly as before this existed. Override it to show a fallback UI instead:
145
186
  ```ks
146
- protected override Element RenderError(string message) {
147
- Element el = document.createElement("div");
148
- el.textContent = "Something went wrong: " + message;
187
+ protected override VElement RenderError(string message) {
188
+ VElement el = VElement.Create("div");
189
+ el.TextContent = "Something went wrong: " + message;
149
190
  return el;
150
191
  }
151
192
  ```
@@ -170,16 +211,20 @@ class Counter : Component {
170
211
 
171
212
  - `{{ expr }}` interpolation, `(event)="stmt"`, `[prop]="expr"`, `*if="expr"`,
172
213
  `*for="Type varName of expr"` — all real KopScript, checked at compile time, desugared
173
- to the exact same `document.createElement`/`.appendChild`/`.textContent`/
174
- `.addEventListener` calls a hand-written `Render()` would use.
214
+ to the exact same `VElement.Create`/`.AppendChild`/`.TextContent`/named-event-field
215
+ calls a hand-written `Render()` would use. `(event)` only accepts `click`/`input`/
216
+ `blur`/`change` — `VElement`'s own fixed set — anything else is a compile error
217
+ (`KS5016`). `[prop]`/static `attr="..."` assign directly for `id`/`className`/`value`;
218
+ anything else goes through `SetAttr` instead.
175
219
  - A `state<T>` field declared directly on the class and referenced directly in the
176
220
  template (`Count` above) gets `Subscribe((v) => this.Update())` wired automatically —
177
221
  no manual `Subscribe` in the constructor for that field. State reached indirectly
178
222
  (through a method, or `this.SomeService.Count`) still needs a manual `Subscribe`, same
179
223
  as a hand-written `Render()` always has.
180
224
  - One top-level element per template (hard error otherwise); no mixing text and element
181
- children under one element (no text-node type in `dom.ks`, only `.textContent`); no
182
- two-way binding, no pipes, at most one structural directive per element.
225
+ children under one element (`VElement` has no text-node sibling concept, only
226
+ `.TextContent`); no two-way binding, no pipes, at most one structural directive per
227
+ element.
183
228
  - This is entirely a KopScript compiler feature (parsed/desugared before type-checking
184
229
  runs) — Kopular's own framework code (`component.ks`, `dom.ks`) is unmodified and
185
230
  unaware templates exist; a template-generated `Render()` is indistinguishable from a
@@ -191,14 +236,14 @@ Real URLs via the History API (`pushState`/`popstate`), not hash routing.
191
236
 
192
237
  ```ks
193
238
  class NotFoundPage : Component {
194
- public override Element Render() { /* ... */ }
239
+ public override VElement Render() { /* ... */ }
195
240
  }
196
241
  class HomePage : Component {
197
242
  private Router Nav;
198
243
  constructor(Router nav) : base() { this.Nav = nav; }
199
- public override Element Render() {
200
- Element btn = document.createElement("button");
201
- btn.addEventListener("click", (Event e) => { this.Nav.Navigate("/about"); });
244
+ public override VElement Render() {
245
+ VElement btn = VElement.Create("button");
246
+ btn.OnClick = (Event e) => { this.Nav.Navigate("/about"); };
202
247
  return btn;
203
248
  }
204
249
  }
@@ -238,14 +283,14 @@ nav.Navigate("/about"); // pushState + immediate re-ren
238
283
  `"/search?sort=name"` -> `Query("sort") == "name"`. Values are **not percent-decoded** —
239
284
  no `decodeURIComponent` binding exists yet, a deliberate v1 cut; `%20`/`+` arrive
240
285
  exactly as written in the URL, not converted to a space.
241
- - **Why `Param` is a plain field, not `state<T>`**: `Render()` already rebuilds a fresh
242
- outlet and re-`Mount()`s the matched page on every `Navigate()`/`popstate`, which
286
+ - **Why `Param` is a plain field, not `state<T>`**: `Router`'s own `AfterRender` already
287
+ re-`Mount()`s the matched page into its outlet on every `Navigate()`/`popstate`, which
243
288
  re-runs that page's own `Render()` (reading the fresh `Param`) with no extra step.
244
289
  Making it `state<T>` and having a page `Subscribe()` to it — the pattern every *other*
245
290
  piece of state in this framework uses — actually crashes: `Match()` sets it *before*
246
- `Render()` finishes swapping the matched page into the outlet, so the very first route
247
- that matches a page nothing has `Mount()`ed yet fires that page's subscribed listener
248
- while its inherited `Update()` still has no `ParentElement` to `replaceChild` into.
291
+ `AfterRender` finishes mounting the matched page into the outlet, so the very first
292
+ route that matches a page nothing has `Mount()`ed yet fires that page's subscribed
293
+ listener while its inherited `Update()` still has no `ParentElement` to patch into.
249
294
  - **Navigation guards**: `SetGuard(redirectPath, (string) => bool guard)` — `guard` is
250
295
  called with the target path before every navigation (including a direct load/refresh);
251
296
  returning `false` redirects to `redirectPath` (via `pushState`, so the URL updates too —
@@ -282,11 +327,11 @@ a **hand-written** `Render()`, where `if` being a statement (not an expression)
282
327
  conditional value needs a helper to get one out of it:
283
328
 
284
329
  ```ks
285
- Element If(bool condition, () => Element whenTrue, () => Element whenFalse)
330
+ VElement If(bool condition, () => VElement whenTrue, () => VElement whenFalse)
286
331
  ```
287
332
 
288
333
  ```ks
289
- root.appendChild(If(this.IsLoggedIn, () => this.BuildProfile(), () => this.BuildLogin()));
334
+ root.AppendChild(If(this.IsLoggedIn, () => this.BuildProfile(), () => this.BuildLogin()));
290
335
  ```
291
336
 
292
337
  Both branches always required (no null "nothing" value); only the branch actually taken
@@ -295,20 +340,22 @@ at all in hand-written `Render()` either:
295
340
 
296
341
  ```ks
297
342
  // *ngFor — plain array method
298
- this.Items.ForEach((Item item) => { list.appendChild(this.BuildItemRow(item)); });
343
+ this.Items.ForEach((Item item) => { list.AppendChild(this.BuildItemRow(item)); });
299
344
 
300
345
  // *ngSwitch — plain KopScript `match` expression (exhaustiveness-checked, unlike *ngSwitch)
301
- root.appendChild(match this.Status {
346
+ root.AppendChild(match this.Status {
302
347
  "loading" => this.BuildSpinner(),
303
348
  "error" => this.BuildError(),
304
349
  _ => this.BuildContent()
305
350
  });
306
351
  ```
307
352
 
308
- No keyed/reuse-existing-DOM-nodes diffing (the performance angle of `*ngFor trackBy`) —
309
- that needs comparing old/new data by a caller key, generic over item type, and KopScript
310
- has no generics. Not planned as a workaround; would need real language-level generics
311
- first.
353
+ **Keyed/reuse-existing-DOM-nodes diffing (the `*ngFor trackBy` angle) exists** give each
354
+ item's `VElement` a stable `.Id` (e.g. the item's own id) and `Update()`'s diff engine
355
+ (`PatchChildren` in `vdom.ks`) matches children by `Id` across a re-render, reusing a
356
+ matched child's real DOM node rather than rebuilding it. Without a stable `Id`, a
357
+ reordered list still renders correctly, but a given item's real node isn't guaranteed to
358
+ follow its data.
312
359
 
313
360
  ## `Http` (`http.ks`) — thin wrapper over `fetch`
314
361
 
@@ -367,7 +414,7 @@ email.Valid(); // bool — Error.Value == null
367
414
  after the first as a nested function type, not an array element type, and errors expecting
368
415
  `=>`). Combine checks as an if-chain in one lambda (see the `email` example above), or via
369
416
  the fixed-arity `CombineValidators2<T>`/`CombineValidators3<T>` free functions (generic,
370
- inference-only — see `Kop`'s own "Generics" docs for why they're *free* functions, not
417
+ inference-only — see `KopScript`'s own "Generics" docs for why they're *free* functions, not
371
418
  `Validators` static methods):
372
419
  ```ks
373
420
  CombineValidators2((string v) => Validators.Required(v), (string v) => Validators.Email(v))
@@ -376,13 +423,10 @@ CombineValidators2((string v) => Validators.Required(v), (string v) => Validator
376
423
  Fixed-arity (2, 3 — add more the same way if a form ever needs to chain further), not a
377
424
  general `Validators.All(...)`, for the same array-of-function-values reason above.
378
425
 
379
- **No DOM binding** — wiring `.Value` to a real `<input>` is a plain `addEventListener`
380
- call in your own `Render()`, the same as any other event handler; there is no
381
- `[(ngModel)]`-equivalent.
382
-
383
- **No DOM binding** — wiring `.Value` to a real `<input>` is a plain `addEventListener`
384
- call in your own `Render()`, the same as any other event handler; there is no
385
- `[(ngModel)]`-equivalent.
426
+ **No DOM binding** — wiring `.Value` to a real `<input>` is a plain `VElement.OnInput`
427
+ assignment in your own `Render()` reading `e.target.value` (the same as any other event
428
+ handler; `VElement.Value` itself is one-way, host-to-DOM only), and reading it back out
429
+ via `.Touch()` on `OnBlur`; there is no `[(ngModel)]`-equivalent.
386
430
 
387
431
  ## `kopular/testing` (`testing.js`, hand-written JS, not compiled from `.ks`)
388
432
 
@@ -435,10 +479,14 @@ class AppContainer {
435
479
  class RoutedApp : Component {
436
480
  private Router Nav;
437
481
  constructor(AppContainer services) : base() { this.Nav = services.Nav; }
438
- public override Element Render() {
439
- Element el = document.createElement("div");
440
- this.Nav.Mount(el);
441
- return el;
482
+ public override VElement Render() {
483
+ return VElement.Create("div");
484
+ }
485
+ // Mounting Router — a live, nested Component — isn't something a VElement
486
+ // tree can express as data; AfterRender gets the real, now-materialized
487
+ // root Element instead (see "Component" above).
488
+ protected override void AfterRender(Element root) {
489
+ this.Nav.Mount(root);
442
490
  }
443
491
  }
444
492
 
@@ -462,8 +510,8 @@ class CounterService {
462
510
  - **`Router.Param` is a plain `string`, not `state<T>` — don't `Subscribe()` to it.** A
463
511
  `state<T>`-based design for it was tried and genuinely crashes: `Navigate()` sets `Param`
464
512
  *before* the newly-matched page finishes mounting, so a page `Subscribe`-ing to it fires
465
- while its own `Update()` still has no `ParentElement` to `replaceChild` into. `Render()`
466
- already re-reads the fresh `Param` on every navigation with no extra step — just read
513
+ while its own `Update()` still has no `ParentElement` to patch into. `Render()` already
514
+ re-reads the fresh `Param` on every navigation with no extra step — just read
467
515
  `this.Nav.Param` directly inside `Render()`.
468
516
  - **Deploying a `Router`-based app needs SPA/history-fallback configured on the actual host**
469
517
  — this bit the real `KopularDemo` production site (worked when navigated to via a link,
@@ -481,9 +529,20 @@ class CounterService {
481
529
  `directives.ks`'s `If()` imported or called; `*if`/`*for` compile to real `if`/`for`
482
530
  directly. Don't mix a template with a hand-written-`Render()` helper call.
483
531
  - **No two-way binding, anywhere.** Wiring a template's `[value]` or a hand-written
484
- `.Value` to a real `<input>` back and forth always needs an explicit
485
- `addEventListener("input", ...)` — there's no `[(ngModel)]`-equivalent to reach for in
486
- either authoring style.
532
+ `.Value` to a real `<input>` back and forth always needs an explicit `OnInput`
533
+ assignment reading `e.target.value` — there's no `[(ngModel)]`-equivalent to reach for
534
+ in either authoring style.
535
+ - **A `VElement` event binding only accepts `click`/`input`/`blur`/`change`** — both in a
536
+ template's `(event)="..."` and a hand-written `OnClick`/`OnInput`/`OnBlur`/`OnChange`
537
+ assignment. There's no generic `addEventListener` on `VElement` (event handlers are part
538
+ of the tree's own data, not wired against a live DOM node until `Materialize`/`Patch`
539
+ runs) — an event Kopular doesn't have a named field for isn't reachable from `Render()`
540
+ at all yet.
541
+ - **Reconciliation doesn't cross a `Mount()`ed-via-`AfterRender` boundary.** Real vdom
542
+ diffing patches one `Component`'s own subtree; a *nested* Component mounted into a slot
543
+ via `AfterRender` (see `Router`'s outlet, or `RoutedApp` mounting `Router` itself) isn't
544
+ automatically re-`Mount()`ed if the *parent's* surrounding tree shape changes — see
545
+ "Component"'s own "Known limitation" above.
487
546
 
488
547
  ## Does not exist
489
548
 
@@ -491,8 +550,11 @@ DI container/injector · decorators (`@Injectable`, `@Component`, ...) · a runt
491
550
  template engine or interpreted expression language — templates compile to the same
492
551
  imperative `Render()` code as the hand-written form, checked at compile time, not
493
552
  interpreted at runtime (see "Templates" above) · two-way binding (`[(ngModel)]`) ·
494
- vdom diffing / reconciliation beyond a single component's own re-render · pipes ·
495
- animations · typed/generic HTTP responses (`Http` returns raw text — see above) · SSR.
553
+ reconciliation across a nested-Component boundary (see "Common mistakes" above — real
554
+ vdom diffing exists *within* one Component's own subtree, via `Update()`/`vdom.ks`) ·
555
+ a generic/arbitrary `VElement` event binding — only `click`/`input`/`blur`/`change` ·
556
+ pipes · animations · typed/generic HTTP responses (`Http` returns raw text — see above) ·
557
+ SSR.
496
558
 
497
559
  (`FormField<T>`/`Validators` and `kp new` — see "FormField<T> / Validators" and "Starting
498
560
  a new project" above — are real, shipped features; they used to be listed here as gaps