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 +134 -72
- package/README.md +95 -68
- package/package.json +2 -1
- package/src/component.js +14 -6
- package/src/component.js.map +1 -1
- package/src/component.ks +37 -15
- package/src/directives.js +1 -0
- package/src/directives.js.map +1 -1
- package/src/directives.ks +5 -4
- package/src/dom.js.map +1 -1
- package/src/dom.ks +18 -1
- package/src/forms.js.map +1 -1
- package/src/forms.ks +1 -1
- package/src/router.js +9 -4
- package/src/router.js.map +1 -1
- package/src/router.ks +23 -10
- package/src/vdom.js +136 -0
- package/src/vdom.js.map +1 -0
- package/src/vdom.ks +221 -0
- package/src/velement.js +39 -0
- package/src/velement.js.map +1 -0
- package/src/velement.ks +95 -0
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/
|
|
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
|
|
17
|
-
current state
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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 [
|
|
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 —
|
|
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
|
-
[
|
|
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
|
-
`
|
|
84
|
-
|
|
85
|
-
|
|
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
|
|
116
|
-
|
|
117
|
-
button.
|
|
118
|
-
button.
|
|
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
|
|
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
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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.
|
|
239
|
+
el.TextContent = "Dog #" + this.Nav.Param;
|
|
227
240
|
```
|
|
228
241
|
|
|
229
|
-
`Param` is a plain `string`, deliberately not `state<T>` — Router's own `
|
|
230
|
-
already
|
|
231
|
-
|
|
232
|
-
|
|
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.
|
|
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
|
-
|
|
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 (`() =>
|
|
332
|
-
|
|
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
|
|
339
|
-
|
|
351
|
+
public override VElement Render() {
|
|
352
|
+
VElement root = VElement.Create("div");
|
|
340
353
|
|
|
341
354
|
// *ngIf
|
|
342
|
-
root.
|
|
355
|
+
root.AppendChild(If(this.User.IsLoggedIn, () => this.BuildProfile(), () => this.BuildLoginButton()));
|
|
343
356
|
|
|
344
357
|
// *ngFor
|
|
345
|
-
|
|
346
|
-
this.Items.ForEach((Item item) => { list.
|
|
347
|
-
root.
|
|
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.
|
|
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
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
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
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
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
|
|
@@ -458,16 +472,18 @@ FormField<string> email = new FormField<string>("",
|
|
|
458
472
|
|
|
459
473
|
Free functions, not `Validators` methods, and fixed-arity (2 and 3) rather than a general
|
|
460
474
|
`Validators.All(...)` — a class's own static method can't introduce a new type parameter
|
|
461
|
-
beyond the class's own (see "Generics" in `
|
|
475
|
+
beyond the class's own (see "Generics" in `KopScript`'s own docs), so a *generic* combinator has
|
|
462
476
|
to live as a free function instead. `Validators` ships the handful of checks almost every
|
|
463
477
|
form needs (`Required`, `MinLength`, `MaxLength`, `Email`, `Min`, `Max`), each returning
|
|
464
478
|
its own message; write your own validator function for anything more specific.
|
|
465
479
|
|
|
466
|
-
**No two-way data binding** — wiring `Value` to a real `<input>` is the
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
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.
|
|
471
487
|
|
|
472
488
|
## Starting a new project: `kp new`
|
|
473
489
|
|
|
@@ -499,9 +515,14 @@ instead, the same way KopScript already describes any other JS/npm dependency
|
|
|
499
515
|
(`kp new` above generates exactly this, if you'd rather not hand-write it):
|
|
500
516
|
|
|
501
517
|
```ks
|
|
518
|
+
extern class VElement {
|
|
519
|
+
static VElement Create(string tag);
|
|
520
|
+
string TextContent { get; set; }
|
|
521
|
+
} from "kopular/velement";
|
|
522
|
+
|
|
502
523
|
extern class Component {
|
|
503
524
|
constructor();
|
|
504
|
-
virtual
|
|
525
|
+
virtual VElement Render();
|
|
505
526
|
void Mount(Element parent);
|
|
506
527
|
} from "kopular/component";
|
|
507
528
|
|
|
@@ -512,9 +533,9 @@ extern class Router {
|
|
|
512
533
|
} from "kopular/router";
|
|
513
534
|
|
|
514
535
|
class MyWidget : Component {
|
|
515
|
-
public override
|
|
516
|
-
|
|
517
|
-
el.
|
|
536
|
+
public override VElement Render() {
|
|
537
|
+
VElement el = VElement.Create("div");
|
|
538
|
+
el.TextContent = "Hello from MyWidget";
|
|
518
539
|
return el;
|
|
519
540
|
}
|
|
520
541
|
}
|
|
@@ -568,16 +589,22 @@ npm run build # compiles src/*.ks -> src/*.js (compiled output is gitignored)
|
|
|
568
589
|
npm test # runs test/kopular.test.ts against a real DOM via jsdom
|
|
569
590
|
```
|
|
570
591
|
|
|
571
|
-
`kopscript` is a real published dependency (`^0.1.0`) — this repo doesn't need
|
|
592
|
+
`kopscript` is a real published dependency (`^0.1.0`) — this repo doesn't need KopScript
|
|
572
593
|
checked out as a sibling directory or anything else local to build or test.
|
|
573
594
|
|
|
574
595
|
## Status
|
|
575
596
|
|
|
576
|
-
v1 / hobby-project scope, same as KopScript itself.
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
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.
|
|
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
|
|
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.
|
|
27
|
-
|
|
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
|
|
36
|
-
this.ParentElement
|
|
37
|
-
this.
|
|
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
|
|
package/src/component.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"component.js","sources":["component.ks"],"sourcesContent":["using \"./dom\";\n\n// A minimal component base: subclasses override Render() to
|
|
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
|
|
4
|
-
//
|
|
5
|
-
// Update() whenever that state changes to
|
|
6
|
-
//
|
|
7
|
-
//
|
|
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
|
|
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
|
|
34
|
-
return
|
|
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
|
|
50
|
+
protected virtual VElement RenderError(string message) {
|
|
46
51
|
throw message;
|
|
47
52
|
}
|
|
48
53
|
|
|
49
|
-
private
|
|
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.
|
|
60
|
-
|
|
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
|
-
|
|
77
|
-
this.ParentElement
|
|
78
|
-
this.
|
|
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
package/src/directives.js.map
CHANGED
|
@@ -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//
|
|
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
|
-
//
|
|
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
|
-
// `() =>
|
|
24
|
-
//
|
|
25
|
-
|
|
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
|
|
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
|
|
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
|
};
|