kopular 0.12.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/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  # Kopular
4
4
 
5
- Kopular is a small component framework for [KopScript](https://dev.azure.com/koppinator/Koppindependence/_git/Kop),
5
+ Kopular is a small component framework for [KopScript](https://dev.azure.com/koppinator/Koppindependence/_git/KopScript),
6
6
  built to give Angular's separation of concerns — components own UI, services own logic,
7
7
  a router owns navigation — without Angular's steepest learning-curve pieces: no RxJS, no
8
8
  dependency-injection container, and templates that are real, compiled, type-checked
@@ -13,12 +13,14 @@ Generating Kopular code with an AI coding assistant? Point it at **[`LLM.md`](./
13
13
 
14
14
  ## Highlights
15
15
 
16
- - **`Component`**: a base class with `virtual Render()` (builds a fresh DOM subtree from
17
- current state) and `Update()` (swaps the old subtree for the new one). No diffing either
18
- way `Render()` is provided as a real, separate markup file compiled by KopScript's
19
- `template from` (see "Templates" below), or written by hand as plain imperative DOM
20
- code against plain DOM bindings, the way you'd write careful vanilla-JS UI code — your
21
- choice, and both compile to the exact same thing. An optional `virtual
16
+ - **`Component`, with real vdom diffing**: a base class with `virtual Render()` (describes
17
+ the current state as a `VElement` tree a lightweight description of a DOM element, not
18
+ a real one) and `Update()` (diffs the new tree against the previous one and patches only
19
+ what changed, reusing a real DOM node wherever its tag stays the same — not a full
20
+ subtree rebuild). `Render()` is provided as a real, separate markup file compiled by
21
+ KopScript's `template from` (see "Templates" below), or written by hand building a
22
+ `VElement` tree against `kopular/velement`, the way you'd write careful vanilla-JS UI
23
+ code — your choice, and both compile to the exact same thing. An optional `virtual
22
24
  RenderError(message)` renders a fallback instead of a hard crash if `Render()` throws —
23
25
  purely additive; not overriding it keeps today's exact (uncaught) behavior.
24
26
  - **Templates, compiled and type-checked, not interpreted**: markup lives in its own
@@ -28,7 +30,7 @@ Generating Kopular code with an AI coding assistant? Point it at **[`LLM.md`](./
28
30
  automatic `Subscribe`/`Update()` wiring for `state<T>` fields referenced directly in the
29
31
  markup. See "Templates" below.
30
32
  - **Reactive state, no RxJS**: components hold `state<number>`/`state<string>`/... (a
31
- KopScript language feature — see the [Kop](https://dev.azure.com/koppinator/Koppindependence/_git/Kop)
33
+ KopScript language feature — see the [KopScript](https://dev.azure.com/koppinator/Koppindependence/_git/KopScript)
32
34
  repo) and subscribe once, in their constructor, to call `Update()` on change. No
33
35
  Observables, no operators, no manual unsubscribe bookkeeping.
34
36
  - **Services, no DI container**: "injecting" a service is just passing it as a
@@ -55,6 +57,10 @@ Generating Kopular code with an AI coding assistant? Point it at **[`LLM.md`](./
55
57
 
56
58
  - `src/dom.ks` — ambient DOM bindings (`document`, `Element`, `Event`, `window`,
57
59
  `location`) that `component.ks`/`router.ks`/`directives.ks` are built on.
60
+ - `src/velement.ks` — `VElement`, the lightweight description of a DOM element `Render()`
61
+ returns.
62
+ - `src/vdom.ks` — the diff/patch engine (`Materialize`/`Patch`/`PatchChildren`) behind real
63
+ vdom diffing — see "Component" above.
58
64
  - `src/component.ks` — the `Component` base class.
59
65
  - `src/router.ks` — the `Router`.
60
66
  - `src/directives.ks` — `If()`, the structural-directive equivalents' one genuinely new
@@ -69,7 +75,7 @@ Generating Kopular code with an AI coding assistant? Point it at **[`LLM.md`](./
69
75
  - `bin/kp.mjs` — the `kp new` scaffolding CLI (see "Starting a new project" below); also
70
76
  hand-written, same reason.
71
77
 
72
- That's the whole framework — eight files, plus the scaffolding CLI. Everything else (a
78
+ That's the whole framework — ten files, plus the scaffolding CLI. Everything else (a
73
79
  real app built on top of it)
74
80
  lives in a separate consumer repo, [KopularDemo](https://dev.azure.com/koppinator/Koppindependence/_git/KopularDemo).
75
81
 
@@ -77,12 +83,12 @@ lives in a separate consumer repo, [KopularDemo](https://dev.azure.com/koppinato
77
83
 
78
84
  A component's `Render()` can be a real markup file instead of hand-written imperative DOM
79
85
  code — `template from "./x.html";` in the class body, a KopScript language feature (see
80
- [Kop](https://dev.azure.com/koppinator/Koppindependence/_git/Kop)'s own README/LLM.md for
86
+ [KopScript](https://dev.azure.com/koppinator/Koppindependence/_git/KopScript)'s own README/LLM.md for
81
87
  the full syntax reference). Kopular itself needed **zero framework code changes** for
82
88
  this — the compiler desugars a template straight into calls against the same
83
- `document.createElement`/`.appendChild`/`.textContent`/`.addEventListener` surface
84
- `dom.ks` already declares, so a template-generated `Render()` is indistinguishable from
85
- one you'd write by hand:
89
+ `VElement.Create`/`.AppendChild`/`.TextContent`/named event fields `velement.ks` already
90
+ declares, so a template-generated `Render()` is indistinguishable from one you'd write by
91
+ hand:
86
92
 
87
93
  ```ks
88
94
  // counter.ks
@@ -112,12 +118,12 @@ class Counter : Component {
112
118
  this.Count.Subscribe((number v) => this.Update());
113
119
  }
114
120
 
115
- public override Element Render() {
116
- Element button = document.createElement("button");
117
- button.textContent = "Count: " + this.Count.Value;
118
- button.addEventListener("click", (Event e) => {
121
+ public override VElement Render() {
122
+ VElement button = VElement.Create("button");
123
+ button.TextContent = "Count: " + this.Count.Value;
124
+ button.OnClick = (Event e) => {
119
125
  this.Count.Value = this.Count.Value + 1;
120
- });
126
+ };
121
127
  return button;
122
128
  }
123
129
  }
@@ -137,7 +143,7 @@ still has one manual `Subscribe`. `*if`/`*for` in a template are covered under
137
143
 
138
144
  Kopular has no injector because KopScript has nothing for one to hook into — no
139
145
  decorators, no reflection, and no *generic functions* (KopScript's generics are
140
- classes/interfaces only — see the Kop repo) for a type-safe `Resolve<T>()`. Instead, the
146
+ classes/interfaces only — see the KopScript repo) for a type-safe `Resolve<T>()`. Instead, the
141
147
  whole app's service/page graph gets built exactly once, by hand, in one place: a plain
142
148
  class with no `Component` base and no framework code in it at all, sometimes called an
143
149
  **app container** or (in the wider DI literature) a **composition root**. Everything
@@ -172,10 +178,17 @@ class RoutedApp : Component {
172
178
  this.Nav = services.Nav;
173
179
  }
174
180
 
175
- public override Element Render() {
176
- Element container = document.createElement("div");
177
- this.Nav.Mount(container);
178
- return container;
181
+ public override VElement Render() {
182
+ return VElement.Create("div");
183
+ }
184
+
185
+ // Mounting the Router — a live, nested Component — isn't something a
186
+ // VElement tree can express as data (see "Component" above). AfterRender
187
+ // is called with the real DOM node Render()'s tree just became, once
188
+ // Mount()/Update() has actually materialized/patched it — here, that's
189
+ // the container itself.
190
+ protected override void AfterRender(Element root) {
191
+ this.Nav.Mount(root);
179
192
  }
180
193
  }
181
194
 
@@ -223,13 +236,13 @@ single non-empty segment. With just one per route, its value is captured into
223
236
  ```ks
224
237
  nav.AddRoute("/dogs/:id", new DogPage(nav));
225
238
  // inside DogPage.Render():
226
- el.textContent = "Dog #" + this.Nav.Param;
239
+ el.TextContent = "Dog #" + this.Nav.Param;
227
240
  ```
228
241
 
229
- `Param` is a plain `string`, deliberately not `state<T>` — Router's own `Render()`
230
- already rebuilds a fresh outlet and re-`Mount()`s the matched page on every
231
- `Navigate()`/`popstate`, which re-runs that page's `Render()` (reading the fresh `Param`)
232
- with no extra step. No `Subscribe()` needed on it.
242
+ `Param` is a plain `string`, deliberately not `state<T>` — Router's own `AfterRender`
243
+ already re-`Mount()`s the matched page into its outlet on every `Navigate()`/`popstate`,
244
+ which re-runs that page's `Render()` (reading the fresh `Param`) with no extra step. No
245
+ `Subscribe()` needed on it.
233
246
 
234
247
  **More than one dynamic segment**, and a trailing **wildcard** segment, both work too —
235
248
  read each by name via `Router.Params(name)` instead (`Param` above still holds the
@@ -238,7 +251,7 @@ read each by name via `Router.Params(name)` instead (`Param` above still holds t
238
251
  ```ks
239
252
  nav.AddRoute("/dogs/:id/toys/:toyId", new ToyPage(nav));
240
253
  // inside ToyPage.Render():
241
- el.textContent = "Dog " + this.Nav.Params("id") + " / Toy " + this.Nav.Params("toyId");
254
+ el.TextContent = "Dog " + this.Nav.Params("id") + " / Toy " + this.Nav.Params("toyId");
242
255
 
243
256
  nav.AddRoute("/files/*", new FilesPage(nav));
244
257
  // "/files/2026/reports/q1.pdf" -> Params("*") == "2026/reports/q1.pdf"
@@ -317,7 +330,7 @@ template's `*if`/`*for` need no further explanation, they're covered under "Temp
317
330
  to get a conditional value out of it. `If()` is that helper — nothing more than:
318
331
 
319
332
  ```ks
320
- Element If(bool condition, () => Element whenTrue, () => Element whenFalse) {
333
+ VElement If(bool condition, () => VElement whenTrue, () => VElement whenFalse) {
321
334
  if (condition) {
322
335
  return whenTrue();
323
336
  }
@@ -328,26 +341,26 @@ Element If(bool condition, () => Element whenTrue, () => Element whenFalse) {
328
341
  Both branches are required (same reasoning `Router` uses for requiring a `NotFoundPage`
329
342
  up front — see `router.ks`): v1 has no nullable types, so "render nothing" has no value
330
343
  to hand back. Only the branch actually taken runs — the other lambda is never called, so
331
- an explicit empty branch (`() => document.createElement("span")`) costs nothing when
332
- there's genuinely nothing to show.
344
+ an explicit empty branch (`() => VElement.Create("span")`) costs nothing when there's
345
+ genuinely nothing to show.
333
346
 
334
347
  All three read the same way, right inside a hand-written `Render()` — no directive
335
348
  registration, nothing to import beyond the function itself:
336
349
 
337
350
  ```ks
338
- public override Element Render() {
339
- Element root = document.createElement("div");
351
+ public override VElement Render() {
352
+ VElement root = VElement.Create("div");
340
353
 
341
354
  // *ngIf
342
- root.appendChild(If(this.User.IsLoggedIn, () => this.BuildProfile(), () => this.BuildLoginButton()));
355
+ root.AppendChild(If(this.User.IsLoggedIn, () => this.BuildProfile(), () => this.BuildLoginButton()));
343
356
 
344
357
  // *ngFor
345
- Element list = document.createElement("ul");
346
- this.Items.ForEach((Item item) => { list.appendChild(this.BuildItemRow(item)); });
347
- root.appendChild(list);
358
+ VElement list = VElement.Create("ul");
359
+ this.Items.ForEach((Item item) => { list.AppendChild(this.BuildItemRow(item)); });
360
+ root.AppendChild(list);
348
361
 
349
362
  // *ngSwitch
350
- root.appendChild(match this.Status {
363
+ root.AppendChild(match this.Status {
351
364
  "loading" => this.BuildSpinner(),
352
365
  "error" => this.BuildError(),
353
366
  _ => this.BuildContent()
@@ -357,12 +370,12 @@ public override Element Render() {
357
370
  }
358
371
  ```
359
372
 
360
- Why not a general reuse-existing-DOM-nodes diffing layer, the way `*ngFor trackBy`
361
- avoids rebuilding unchanged rows? That needs comparing old and new *data* items by a
362
- caller-supplied key, generic over the item type and KopScript has no generics (no
363
- `class Foo<T>`, no `T Resolve<T>()`). A one-off keyed-diff helper could be hand-written
364
- per list, but that's real vdom-diffing work already called out as out of scope in
365
- "Status" below, and not something these three lines take on.
373
+ Give each `Item`'s `VElement` a stable `.Id` (e.g. the item's own id) to make `*ngFor
374
+ trackBy`-style row reuse automatic `Update()`'s diff engine matches children by `Id`
375
+ across a re-render, reusing a matched child's real DOM node (and anything stateful
376
+ attached to it, like focus) rather than rebuilding it, the same way `*ngFor`'s own
377
+ `trackBy` avoids rebuilding unchanged rows. Without a stable `Id`, a list still renders
378
+ correctly on reorder, but a given item's real node isn't guaranteed to follow its data.
366
379
 
367
380
  ## HTTP
368
381
 
@@ -430,10 +443,11 @@ email.Value.Value = "not-an-email";
430
443
  print(email.Error.Value); // "Must be a valid email"
431
444
  print(email.Valid()); // false
432
445
 
433
- emailInput.addEventListener("input", (Event e) => {
434
- email.Value.Value = emailInput.textContent; // revalidates automatically
435
- });
436
- emailInput.addEventListener("blur", (Event e) => { email.Touch(); });
446
+ // inside a hand-written Render(), building emailInput as a VElement:
447
+ emailInput.OnInput = (Event e) => {
448
+ email.Value.Value = e.target.value; // revalidates automatically
449
+ };
450
+ emailInput.OnBlur = (Event e) => { email.Touch(); };
437
451
  ```
438
452
 
439
453
  `FormField<T>` holds one input's value, error, and touched state as three ordinary
@@ -447,16 +461,29 @@ constructor exactly like `Counter`'s own `state<number>`, to re-render when they
447
461
  A validator is a plain `(T) => string?` — `null` means valid, the same convention
448
462
  KopScript's own nullable types use elsewhere. **There's no array-of-validators
449
463
  constructor parameter** — KopScript has no syntax for an array of function values — so
450
- combining more than one check (as `email` does above) is just an `if`-chain in one
451
- lambda, not a combinator API. `Validators` ships the handful of checks almost every form
452
- needs (`Required`, `MinLength`, `MaxLength`, `Email`, `Min`, `Max`), each returning its
453
- own message; write your own validator function for anything more specific.
464
+ combining more than one check (as `email` does above) is an `if`-chain in one lambda, or,
465
+ for the common case of just chaining a couple of already-built validators with no custom
466
+ logic of their own, the `CombineValidators2`/`CombineValidators3` free functions:
454
467
 
455
- **No two-way data binding** — wiring `Value` to a real `<input>` is the
456
- `addEventListener` call shown above, the same manual pattern `Counter` already uses for
457
- its click handler. This is deliberate, not a missing feature: a magic `[(ngModel)]`-style
458
- binding would be exactly the kind of hidden framework behavior Kopular avoids everywhere
459
- else.
468
+ ```ks
469
+ FormField<string> email = new FormField<string>("",
470
+ CombineValidators2((string v) => Validators.Required(v), (string v) => Validators.Email(v)));
471
+ ```
472
+
473
+ Free functions, not `Validators` methods, and fixed-arity (2 and 3) rather than a general
474
+ `Validators.All(...)` — a class's own static method can't introduce a new type parameter
475
+ beyond the class's own (see "Generics" in `KopScript`'s own docs), so a *generic* combinator has
476
+ to live as a free function instead. `Validators` ships the handful of checks almost every
477
+ form needs (`Required`, `MinLength`, `MaxLength`, `Email`, `Min`, `Max`), each returning
478
+ its own message; write your own validator function for anything more specific.
479
+
480
+ **No two-way data binding** — wiring `Value` to a real `<input>` is the `OnInput`
481
+ assignment shown above, the same manual pattern `Counter` already uses for its click
482
+ handler (`VElement.Value` itself is one-way, host-to-DOM only — reading the *current* DOM
483
+ value back out is always the real event's `e.target.value`, not something Kopular
484
+ mirrors into `Value` for you). This is deliberate, not a missing feature: a magic
485
+ `[(ngModel)]`-style binding would be exactly the kind of hidden framework behavior
486
+ Kopular avoids everywhere else.
460
487
 
461
488
  ## Starting a new project: `kp new`
462
489
 
@@ -488,9 +515,14 @@ instead, the same way KopScript already describes any other JS/npm dependency
488
515
  (`kp new` above generates exactly this, if you'd rather not hand-write it):
489
516
 
490
517
  ```ks
518
+ extern class VElement {
519
+ static VElement Create(string tag);
520
+ string TextContent { get; set; }
521
+ } from "kopular/velement";
522
+
491
523
  extern class Component {
492
524
  constructor();
493
- virtual Element Render();
525
+ virtual VElement Render();
494
526
  void Mount(Element parent);
495
527
  } from "kopular/component";
496
528
 
@@ -501,9 +533,9 @@ extern class Router {
501
533
  } from "kopular/router";
502
534
 
503
535
  class MyWidget : Component {
504
- public override Element Render() {
505
- Element el = document.createElement("div");
506
- el.textContent = "Hello from MyWidget";
536
+ public override VElement Render() {
537
+ VElement el = VElement.Create("div");
538
+ el.TextContent = "Hello from MyWidget";
507
539
  return el;
508
540
  }
509
541
  }
@@ -557,16 +589,22 @@ npm run build # compiles src/*.ks -> src/*.js (compiled output is gitignored)
557
589
  npm test # runs test/kopular.test.ts against a real DOM via jsdom
558
590
  ```
559
591
 
560
- `kopscript` is a real published dependency (`^0.1.0`) — this repo doesn't need Kop
592
+ `kopscript` is a real published dependency (`^0.1.0`) — this repo doesn't need KopScript
561
593
  checked out as a sibling directory or anything else local to build or test.
562
594
 
563
595
  ## Status
564
596
 
565
- v1 / hobby-project scope, same as KopScript itself. Known limitation: `Component` only
566
- handles a single component's own re-render cycle if a *parent* re-renders while it has
567
- mounted children, those children aren't automatically re-mounted into the parent's new
568
- tree (real reconciliation, the way React/Vue handle this, is real vdom-diffing work well
569
- beyond v1). Compose independent components into stable slots (see `Router`'s own pattern
570
- of keeping page instances alive rather than rebuilding them) to avoid the issue.
571
- `Update()` called before `Mount()` is a safe no-op see `LLM.md`'s `Component` section
572
- for exactly when that happens (sibling pages sharing one injected service's `state<T>`).
597
+ v1 / hobby-project scope, same as KopScript itself. `Update()` now diffs and patches
598
+ real DOM (see "Component" above) rather than replacing a whole subtree on every
599
+ re-render but reconciliation is still per-`Component`, not across nested ones: if a
600
+ *parent* Component's own `Render()` output changes shape around a slot where a *nested*
601
+ Component was imperatively `Mount()`ed (via `AfterRender` see `Router`'s own pattern),
602
+ that nested Component isn't automatically re-`Mount()`ed or torn down as part of the
603
+ parent's diff, since a live mounted child isn't something a `VElement` tree can express
604
+ as data. Composing independent components into a stable, unchanging slot the way every
605
+ real use of `AfterRender` in this codebase already does, `Router`'s own outlet included —
606
+ avoids the issue entirely. A list child without a stable `VElement.Id` similarly still
607
+ renders correctly across a reorder, but isn't guaranteed to keep the same real DOM node
608
+ (see "Structural directives" above). `Update()` called before `Mount()` is a safe no-op
609
+ — see `LLM.md`'s `Component` section for exactly when that happens (sibling pages
610
+ sharing one injected service's `state<T>`).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kopular",
3
- "version": "0.12.0",
3
+ "version": "0.14.1",
4
4
  "description": "Kopular: a small component framework for KopScript — components, reactive state, constructor-injected services, routing, real compiled templates, and HTTP, with no DI container",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -29,6 +29,7 @@
29
29
  "./component": "./src/component.js",
30
30
  "./router": "./src/router.js",
31
31
  "./dom": "./src/dom.js",
32
+ "./velement": "./src/velement.js",
32
33
  "./directives": "./src/directives.js",
33
34
  "./http": "./src/http.js",
34
35
  "./forms": "./src/forms.js",
package/src/component.js CHANGED
@@ -1,4 +1,6 @@
1
1
  import { Event, Element, Document, Location, History, Window, document, location, history, window } from "./dom.js";
2
+ import { VElement } from "./velement.js";
3
+ import { Materialize, Patch, PatchChildren } from "./vdom.js";
2
4
 
3
5
  export class Component {
4
6
  constructor() {
@@ -6,7 +8,7 @@ export class Component {
6
8
  }
7
9
 
8
10
  Render() {
9
- return document.createElement("div");
11
+ return VElement.Create("div");
10
12
  }
11
13
 
12
14
  RenderError(message) {
@@ -21,20 +23,26 @@ export class Component {
21
23
  }
22
24
  }
23
25
 
26
+ AfterRender(root) {
27
+ }
28
+
24
29
  Mount(parent) {
25
30
  this.ParentElement = parent;
26
- this.Root = this.SafeRender();
27
- parent.appendChild(this.Root);
31
+ this.Tree = this.SafeRender();
32
+ let root = Materialize(this.Tree);
33
+ parent.appendChild(root);
28
34
  this.IsMounted = true;
35
+ this.AfterRender(root);
29
36
  }
30
37
 
31
38
  Update() {
32
39
  if (!this.IsMounted) {
33
40
  return;
34
41
  }
35
- let newRoot = this.SafeRender();
36
- this.ParentElement.replaceChild(newRoot, this.Root);
37
- this.Root = newRoot;
42
+ let newTree = this.SafeRender();
43
+ let root = Patch(this.ParentElement, this.Tree, newTree);
44
+ this.Tree = newTree;
45
+ this.AfterRender(root);
38
46
  }
39
47
  }
40
48
 
@@ -1 +1 @@
1
- {"version":3,"file":"component.js","sources":["component.ks"],"sourcesContent":["using \"./dom\";\n\n// A minimal component base: subclasses override Render() to imperatively\n// build a fresh DOM tree from current state, and call the inherited\n// Update() whenever that state changes to swap the old tree for a new one.\n// There is deliberately no template language or diffing here Render()\n// rebuilds its whole subtree every time, the simplest thing that works.\n//\n// Known limitation: if a *parent* component's own Render() re-runs (i.e.\n// something calls Update() on the parent) while it has mounted children,\n// those children are not automatically re-mounted into the parent's new\n// tree — this base class only handles a single component's own re-render\n// cycle, not parent/child reconciliation across one. Composing independent\n// components (each mounted into its own stable slot, as in app.kop) avoids\n// the issue entirely.\nclass Component {\n protected Element Root;\n private Element ParentElement;\n // Set true only once Mount() actually runs. A page Component is commonly\n // constructed eagerly (e.g. Router.AddRoute takes an already-built\n // instance — see Router's own header comment) long before it's ever\n // Mount()ed, and two sibling pages sharing one injected service's\n // state<T> (Pure DI — both Subscribe() the same field) both get notified\n // on any change regardless of which one is actually the currently-routed,\n // mounted page. Update() guards on this so that notification is a safe\n // no-op for the unmounted one, instead of a crash (see Update() below).\n private bool IsMounted;\n\n constructor() {\n this.IsMounted = false;\n }\n\n public virtual Element Render() {\n return document.createElement(\"div\");\n }\n\n // Overridden to render a fallback UI when Render() throws — a page bug,\n // an unhandled rejected Http call, anything — instead of leaving\n // Mount()/Update() to propagate the exception uncaught, which would\n // otherwise crash whatever triggered the render (a click handler, a\n // Router navigation) with nothing shown to the user at all. Default just\n // re-throws, so anything that doesn't override this keeps today's exact\n // behavior — this is purely additive, opt-in error recovery, not a\n // behavior change for existing components.\n protected virtual Element RenderError(string message) {\n throw message;\n }\n\n private Element SafeRender() {\n try {\n return this.Render();\n } catch (string message) {\n return this.RenderError(message);\n }\n }\n\n public void Mount(Element parent) {\n this.ParentElement = parent;\n this.Root = this.SafeRender();\n parent.appendChild(this.Root);\n this.IsMounted = true;\n }\n\n // A no-op, not an error, when called before Mount() — see the class-level\n // comment on IsMounted for exactly when this happens for real (a\n // shared-service state change reaching a sibling page that isn't the one\n // currently routed/mounted). Nothing is lost: SafeRender() would only be\n // thrown away unread since there's no live DOM parent to put it in, and\n // Mount() itself always calls SafeRender() fresh whenever this component\n // does become the routed page, picking up whatever the current state is\n // at that point.\n protected void Update() {\n if (!this.IsMounted) {\n return;\n }\n Element newRoot = this.SafeRender();\n this.ParentElement.replaceChild(newRoot, this.Root);\n this.Root = newRoot;\n }\n}\n"],"names":[],"mappings":";;AAeA;EAaE;IACiB;;;EAGF;IACb;;;EAWgB;IAChB;;;EAGM;IACN;MACE;;MAEA;;;;EAIG;IACc;IACT;IACQ;IACH;;;EAWP;IACR;MACE;;IAEF;IAC+B;IACrB"}
1
+ {"version":3,"file":"component.js","sources":["component.ks"],"sourcesContent":["using \"./dom\";\nusing \"./velement\";\nusing \"./vdom\";\n\n// A minimal component base: subclasses override Render() to build a\n// VElement tree describing the current state, and call the inherited\n// Update() whenever that state changes to re-render. Update() DIFFS the\n// new VElement tree against the previous one (see vdom.ks's Patch) and\n// patches only what changed, reusing real DOM nodes wherever their tag\n// stays the same — replacing a whole subtree is now the exception (a\n// changed tag, or no previous tree at all), not the default on every\n// re-render the way it used to be.\n//\n// Known limitation: if a *parent* component's own Render() re-runs (i.e.\n// something calls Update() on the parent) while it has mounted children,\n// those children are not automatically re-mounted into the parent's new\n// tree — this base class only handles a single component's own re-render\n// cycle, not parent/child reconciliation across one. Composing independent\n// components (each mounted into its own stable slot, as in app.kop) avoids\n// the issue entirely.\nclass Component {\n protected VElement Tree;\n private Element ParentElement;\n // Set true only once Mount() actually runs. A page Component is commonly\n // constructed eagerly (e.g. Router.AddRoute takes an already-built\n // instance — see Router's own header comment) long before it's ever\n // Mount()ed, and two sibling pages sharing one injected service's\n // state<T> (Pure DI — both Subscribe() the same field) both get notified\n // on any change regardless of which one is actually the currently-routed,\n // mounted page. Update() guards on this so that notification is a safe\n // no-op for the unmounted one, instead of a crash (see Update() below).\n private bool IsMounted;\n\n constructor() {\n this.IsMounted = false;\n }\n\n public virtual VElement Render() {\n return VElement.Create(\"div\");\n }\n\n // Overridden to render a fallback UI when Render() throws — a page bug,\n // an unhandled rejected Http call, anything — instead of leaving\n // Mount()/Update() to propagate the exception uncaught, which would\n // otherwise crash whatever triggered the render (a click handler, a\n // Router navigation) with nothing shown to the user at all. Default just\n // re-throws, so anything that doesn't override this keeps today's exact\n // behavior — this is purely additive, opt-in error recovery, not a\n // behavior change for existing components.\n protected virtual VElement RenderError(string message) {\n throw message;\n }\n\n private VElement SafeRender() {\n try {\n return this.Render();\n } catch (string message) {\n return this.RenderError(message);\n }\n }\n\n // Called after Mount()/Update() has materialized/patched this\n // component's own VElement tree into `root` — a hook for a component\n // that needs to do additional, imperative work against its OWN\n // now-real root element once it exists. Default is a no-op, so every\n // component that doesn't need this is unaffected. Router is the one\n // real user of this today: mounting/re-mounting its matched child PAGE\n // into the outlet div its own Render() just describes, since a nested\n // Component's own mount lifecycle isn't something a VElement tree can\n // express as data (see the class comment above on parent/child\n // reconciliation being a known, deliberately out-of-scope limitation —\n // this hook is the documented way around it, not a fix to it).\n protected virtual void AfterRender(Element root) {\n }\n\n public void Mount(Element parent) {\n this.ParentElement = parent;\n this.Tree = this.SafeRender();\n Element root = Materialize(this.Tree);\n parent.appendChild(root);\n this.IsMounted = true;\n this.AfterRender(root);\n }\n\n // A no-op, not an error, when called before Mount() — see the class-level\n // comment on IsMounted for exactly when this happens for real (a\n // shared-service state change reaching a sibling page that isn't the one\n // currently routed/mounted). Nothing is lost: SafeRender() would only be\n // thrown away unread since there's no live DOM parent to put it in, and\n // Mount() itself always calls SafeRender() fresh whenever this component\n // does become the routed page, picking up whatever the current state is\n // at that point.\n protected void Update() {\n if (!this.IsMounted) {\n return;\n }\n VElement newTree = this.SafeRender();\n Element root = Patch(this.ParentElement, this.Tree, newTree);\n this.Tree = newTree;\n this.AfterRender(root);\n }\n}\n"],"names":[],"mappings":";;;;AAoBA;EAaE;IACiB;;;EAGF;IACb;;;EAWgB;IAChB;;;EAGM;IACN;MACE;;MAEA;;;;EAec;;;EAGX;IACc;IACT;IACV;IACkB;IACH;IACC;;;EAWR;IACR;MACE;;IAEF;IACA;IACU;IACM"}
package/src/component.ks CHANGED
@@ -1,10 +1,15 @@
1
1
  using "./dom";
2
+ using "./velement";
3
+ using "./vdom";
2
4
 
3
- // A minimal component base: subclasses override Render() to imperatively
4
- // build a fresh DOM tree from current state, and call the inherited
5
- // Update() whenever that state changes to swap the old tree for a new one.
6
- // There is deliberately no template language or diffing here Render()
7
- // rebuilds its whole subtree every time, the simplest thing that works.
5
+ // A minimal component base: subclasses override Render() to build a
6
+ // VElement tree describing the current state, and call the inherited
7
+ // Update() whenever that state changes to re-render. Update() DIFFS the
8
+ // new VElement tree against the previous one (see vdom.ks's Patch) and
9
+ // patches only what changed, reusing real DOM nodes wherever their tag
10
+ // stays the same — replacing a whole subtree is now the exception (a
11
+ // changed tag, or no previous tree at all), not the default on every
12
+ // re-render the way it used to be.
8
13
  //
9
14
  // Known limitation: if a *parent* component's own Render() re-runs (i.e.
10
15
  // something calls Update() on the parent) while it has mounted children,
@@ -14,7 +19,7 @@ using "./dom";
14
19
  // components (each mounted into its own stable slot, as in app.kop) avoids
15
20
  // the issue entirely.
16
21
  class Component {
17
- protected Element Root;
22
+ protected VElement Tree;
18
23
  private Element ParentElement;
19
24
  // Set true only once Mount() actually runs. A page Component is commonly
20
25
  // constructed eagerly (e.g. Router.AddRoute takes an already-built
@@ -30,8 +35,8 @@ class Component {
30
35
  this.IsMounted = false;
31
36
  }
32
37
 
33
- public virtual Element Render() {
34
- return document.createElement("div");
38
+ public virtual VElement Render() {
39
+ return VElement.Create("div");
35
40
  }
36
41
 
37
42
  // Overridden to render a fallback UI when Render() throws — a page bug,
@@ -42,11 +47,11 @@ class Component {
42
47
  // re-throws, so anything that doesn't override this keeps today's exact
43
48
  // behavior — this is purely additive, opt-in error recovery, not a
44
49
  // behavior change for existing components.
45
- protected virtual Element RenderError(string message) {
50
+ protected virtual VElement RenderError(string message) {
46
51
  throw message;
47
52
  }
48
53
 
49
- private Element SafeRender() {
54
+ private VElement SafeRender() {
50
55
  try {
51
56
  return this.Render();
52
57
  } catch (string message) {
@@ -54,11 +59,27 @@ class Component {
54
59
  }
55
60
  }
56
61
 
62
+ // Called after Mount()/Update() has materialized/patched this
63
+ // component's own VElement tree into `root` — a hook for a component
64
+ // that needs to do additional, imperative work against its OWN
65
+ // now-real root element once it exists. Default is a no-op, so every
66
+ // component that doesn't need this is unaffected. Router is the one
67
+ // real user of this today: mounting/re-mounting its matched child PAGE
68
+ // into the outlet div its own Render() just describes, since a nested
69
+ // Component's own mount lifecycle isn't something a VElement tree can
70
+ // express as data (see the class comment above on parent/child
71
+ // reconciliation being a known, deliberately out-of-scope limitation —
72
+ // this hook is the documented way around it, not a fix to it).
73
+ protected virtual void AfterRender(Element root) {
74
+ }
75
+
57
76
  public void Mount(Element parent) {
58
77
  this.ParentElement = parent;
59
- this.Root = this.SafeRender();
60
- parent.appendChild(this.Root);
78
+ this.Tree = this.SafeRender();
79
+ Element root = Materialize(this.Tree);
80
+ parent.appendChild(root);
61
81
  this.IsMounted = true;
82
+ this.AfterRender(root);
62
83
  }
63
84
 
64
85
  // A no-op, not an error, when called before Mount() — see the class-level
@@ -73,8 +94,9 @@ class Component {
73
94
  if (!this.IsMounted) {
74
95
  return;
75
96
  }
76
- Element newRoot = this.SafeRender();
77
- this.ParentElement.replaceChild(newRoot, this.Root);
78
- this.Root = newRoot;
97
+ VElement newTree = this.SafeRender();
98
+ Element root = Patch(this.ParentElement, this.Tree, newTree);
99
+ this.Tree = newTree;
100
+ this.AfterRender(root);
79
101
  }
80
102
  }
package/src/directives.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { Event, Element, Document, Location, History, Window, document, location, history, window } from "./dom.js";
2
+ import { VElement } from "./velement.js";
2
3
 
3
4
  export function If(condition, whenTrue, whenFalse) {
4
5
  if (condition) {
@@ -1 +1 @@
1
- {"version":3,"file":"directives.js","sources":["directives.ks"],"sourcesContent":["using \"./dom\";\n\n// Kopular's structural-directive equivalents — see the README's\n// \"Structural directives\" section for the full *ngIf/*ngFor/*ngSwitch\n// mapping. There's no template language here (Kopular doesn't have one, by\n// design — see component.ks), so these are just plain functions: call them\n// like any other expression from inside Render(), the same way you'd call\n// document.createElement.\n//\n// *ngFor and *ngSwitch need nothing new — `array.ForEach(...)` and\n// KopScript's own `match` expression already cover them (and `match` is\n// exhaustiveness-checked, which *ngSwitch isn't). `If` below is the one\n// piece the language doesn't already give you as an expression: `if` is a\n// statement in KopScript, so without this you'd need a throwaway mutable\n// local to get a conditional value.\n\n// The *ngIf equivalent — conditionally build one of two subtrees, as an\n// expression. Both branches are required: v1 has no nullable types, so\n// \"render nothing\" has no value to return — the same reasoning Router uses\n// for requiring a NotFoundPage up front (see router.ks) rather than letting\n// \"no match\" be null. Only the branch actually taken runs; the other\n// lambda is never called, so an explicit empty branch (e.g.\n// `() => document.createElement(\"span\")`) costs nothing when there's\n// genuinely nothing to show.\nElement If(bool condition, () => Element whenTrue, () => Element whenFalse) {\n if (condition) {\n return whenTrue();\n }\n return whenFalse();\n}\n"],"names":[],"mappings":";;AAwBA;EACE;IACE;;EAEF"}
1
+ {"version":3,"file":"directives.js","sources":["directives.ks"],"sourcesContent":["using \"./dom\";\nusing \"./velement\";\n\n// Kopular's structural-directive equivalents — see the README's\n// \"Structural directives\" section for the full *ngIf/*ngFor/*ngSwitch\n// mapping. There's no template language here (Kopular doesn't have one, by\n// design — see component.ks), so these are just plain functions: call them\n// like any other expression from inside Render(), the same way you'd call\n// VElement.Create.\n//\n// *ngFor and *ngSwitch need nothing new — `array.ForEach(...)` and\n// KopScript's own `match` expression already cover them (and `match` is\n// exhaustiveness-checked, which *ngSwitch isn't). `If` below is the one\n// piece the language doesn't already give you as an expression: `if` is a\n// statement in KopScript, so without this you'd need a throwaway mutable\n// local to get a conditional value.\n\n// The *ngIf equivalent — conditionally build one of two subtrees, as an\n// expression. Both branches are required: v1 has no nullable types, so\n// \"render nothing\" has no value to return — the same reasoning Router uses\n// for requiring a NotFoundPage up front (see router.ks) rather than letting\n// \"no match\" be null. Only the branch actually taken runs; the other\n// lambda is never called, so an explicit empty branch (e.g.\n// `() => VElement.Create(\"span\")`) costs nothing when there's genuinely\n// nothing to show.\nVElement If(bool condition, () => VElement whenTrue, () => VElement whenFalse) {\n if (condition) {\n return whenTrue();\n }\n return whenFalse();\n}\n"],"names":[],"mappings":";;;AAyBA;EACE;IACE;;EAEF"}
package/src/directives.ks CHANGED
@@ -1,11 +1,12 @@
1
1
  using "./dom";
2
+ using "./velement";
2
3
 
3
4
  // Kopular's structural-directive equivalents — see the README's
4
5
  // "Structural directives" section for the full *ngIf/*ngFor/*ngSwitch
5
6
  // mapping. There's no template language here (Kopular doesn't have one, by
6
7
  // design — see component.ks), so these are just plain functions: call them
7
8
  // like any other expression from inside Render(), the same way you'd call
8
- // document.createElement.
9
+ // VElement.Create.
9
10
  //
10
11
  // *ngFor and *ngSwitch need nothing new — `array.ForEach(...)` and
11
12
  // KopScript's own `match` expression already cover them (and `match` is
@@ -20,9 +21,9 @@ using "./dom";
20
21
  // for requiring a NotFoundPage up front (see router.ks) rather than letting
21
22
  // "no match" be null. Only the branch actually taken runs; the other
22
23
  // 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) {
24
+ // `() => VElement.Create("span")`) costs nothing when there's genuinely
25
+ // nothing to show.
26
+ VElement If(bool condition, () => VElement whenTrue, () => VElement whenFalse) {
26
27
  if (condition) {
27
28
  return whenTrue();
28
29
  }
package/src/dom.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"dom.js","sources":["dom.ks"],"sourcesContent":["// Minimal browser DOM bindings. All ambient (no `from` clause) — document,\n// Element, and Event genuinely exist as globals in a browser, no import\n// needed. Member names use the real JS casing exactly (camelCase), since\n// extern declarations describe an existing external contract rather than\n// idiomatic Kop code — there's no per-member rename mechanism. `extern\n// class` declarations end in `;`, like the other two extern forms.\n\nextern class Event {\n Element target { get; }\n void preventDefault();\n};\n\nextern class Element {\n string textContent { get; set; }\n string id { get; set; }\n string className { get; set; }\n void appendChild(Element child);\n void replaceChild(Element newChild, Element oldChild);\n void addEventListener(string eventType, (Event) => void handler);\n void removeEventListener(string eventType, (Event) => void handler);\n};\n\nextern class Document {\n Element createElement(string tagName);\n Element getElementById(string id);\n Element body { get; }\n};\n\nextern class Location {\n string pathname { get; }\n // The real query string including its leading \"?\" (e.g. \"?sort=name\"),\n // or \"\" if the current URL has none — see Router.ParseQuery/Query.\n string search { get; }\n};\n\nextern class History {\n // Param named `historyState`, not `state` — `state` is a KopScript\n // keyword (state<T>), not a valid parameter name.\n void pushState(string historyState, string title, string url);\n};\n\nextern class Window {\n void addEventListener(string eventType, (Event) => void handler);\n};\n\nextern Document document;\nextern Location location;\nextern History history;\nextern Window window;\n"],"names":[],"mappings":"AAOA;AAKA;AAUA;AAMA;AAOA;AAMA;AAIA;AACA;AACA;AACA"}
1
+ {"version":3,"file":"dom.js","sources":["dom.ks"],"sourcesContent":["// Minimal browser DOM bindings. All ambient (no `from` clause) — document,\n// Element, and Event genuinely exist as globals in a browser, no import\n// needed. Member names use the real JS casing exactly (camelCase), since\n// extern declarations describe an existing external contract rather than\n// idiomatic KopScript code — there's no per-member rename mechanism. `extern\n// class` declarations end in `;`, like the other two extern forms.\n\nextern class Event {\n Element target { get; }\n void preventDefault();\n};\n\nextern class Element {\n string textContent { get; set; }\n string id { get; set; }\n string className { get; set; }\n // The one property the vdom patch engine (src/vdom.ks) always sets via\n // direct property assignment, never setAttribute — setAttribute(\"value\",\n // x) sets the DEFAULT value attribute, not the current live one, a real\n // DOM footgun (and the exact property behind the original typing bug\n // this whole diffing effort traces back to).\n string value { get; set; }\n // Only ever assigned by the vdom patch engine for a VElement.RawHtml leaf\n // — never diffed into, an opaque blob the same way `raw string` is.\n string innerHTML { get; set; }\n void appendChild(Element child);\n void replaceChild(Element newChild, Element oldChild);\n // `referenceChild` is nullable — real DOM insertBefore(node, null) means\n // \"append at the end,\" used by the patch engine's child-reordering step.\n void insertBefore(Element newChild, Element? referenceChild);\n void removeChild(Element child);\n // The generic escape hatch VElement.SetAttr's ExtraNames/ExtraValues\n // patch through — real HTML attributes only (href, src, alt,\n // placeholder, ...), never `value` (see above).\n void setAttribute(string name, string value);\n void addEventListener(string eventType, (Event) => void handler);\n void removeEventListener(string eventType, (Event) => void handler);\n};\n\nextern class Document {\n Element createElement(string tagName);\n Element getElementById(string id);\n Element body { get; }\n};\n\nextern class Location {\n string pathname { get; }\n // The real query string including its leading \"?\" (e.g. \"?sort=name\"),\n // or \"\" if the current URL has none — see Router.ParseQuery/Query.\n string search { get; }\n};\n\nextern class History {\n // Param named `historyState`, not `state` — `state` is a KopScript\n // keyword (state<T>), not a valid parameter name.\n void pushState(string historyState, string title, string url);\n};\n\nextern class Window {\n void addEventListener(string eventType, (Event) => void handler);\n};\n\nextern Document document;\nextern Location location;\nextern History history;\nextern Window window;\n"],"names":[],"mappings":"AAOA;AAKA;AA2BA;AAMA;AAOA;AAMA;AAIA;AACA;AACA;AACA"}
package/src/dom.ks CHANGED
@@ -2,7 +2,7 @@
2
2
  // Element, and Event genuinely exist as globals in a browser, no import
3
3
  // needed. Member names use the real JS casing exactly (camelCase), since
4
4
  // extern declarations describe an existing external contract rather than
5
- // idiomatic Kop code — there's no per-member rename mechanism. `extern
5
+ // idiomatic KopScript code — there's no per-member rename mechanism. `extern
6
6
  // class` declarations end in `;`, like the other two extern forms.
7
7
 
8
8
  extern class Event {
@@ -14,8 +14,25 @@ extern class Element {
14
14
  string textContent { get; set; }
15
15
  string id { get; set; }
16
16
  string className { get; set; }
17
+ // The one property the vdom patch engine (src/vdom.ks) always sets via
18
+ // direct property assignment, never setAttribute — setAttribute("value",
19
+ // x) sets the DEFAULT value attribute, not the current live one, a real
20
+ // DOM footgun (and the exact property behind the original typing bug
21
+ // this whole diffing effort traces back to).
22
+ string value { get; set; }
23
+ // Only ever assigned by the vdom patch engine for a VElement.RawHtml leaf
24
+ // — never diffed into, an opaque blob the same way `raw string` is.
25
+ string innerHTML { get; set; }
17
26
  void appendChild(Element child);
18
27
  void replaceChild(Element newChild, Element oldChild);
28
+ // `referenceChild` is nullable — real DOM insertBefore(node, null) means
29
+ // "append at the end," used by the patch engine's child-reordering step.
30
+ void insertBefore(Element newChild, Element? referenceChild);
31
+ void removeChild(Element child);
32
+ // The generic escape hatch VElement.SetAttr's ExtraNames/ExtraValues
33
+ // patch through — real HTML attributes only (href, src, alt,
34
+ // placeholder, ...), never `value` (see above).
35
+ void setAttribute(string name, string value);
19
36
  void addEventListener(string eventType, (Event) => void handler);
20
37
  void removeEventListener(string eventType, (Event) => void handler);
21
38
  };