kopular 0.8.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LLM.md +34 -9
- package/README.md +24 -5
- package/package.json +2 -2
- package/src/component.js +14 -2
- package/src/component.ks +22 -2
- package/src/router.js +13 -1
- package/src/router.ks +33 -1
package/LLM.md
CHANGED
|
@@ -59,21 +59,20 @@ extern class Validators {
|
|
|
59
59
|
} from "kopular/forms";
|
|
60
60
|
```
|
|
61
61
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
Describe one concrete instantiation per `T` you actually need instead, aliasing the same
|
|
65
|
-
real export with `as` (the same trust-based, per-shape approach `Http`'s typed-JSON gap
|
|
66
|
-
above already uses) — e.g. for a `FormField<string>`:
|
|
62
|
+
`extern class` supports its own `<T>` (kopscript >= 0.5.0), the same rules as a real
|
|
63
|
+
generic class — describe `FormField<T>` generically instead of per concrete type:
|
|
67
64
|
|
|
68
65
|
```ks
|
|
69
|
-
extern class
|
|
70
|
-
constructor(
|
|
71
|
-
state<
|
|
66
|
+
extern class FormField<T> {
|
|
67
|
+
constructor(T initial, (T) => string? validate);
|
|
68
|
+
state<T> Value;
|
|
72
69
|
state<string?> Error;
|
|
73
70
|
state<bool> Touched;
|
|
74
71
|
void Touch();
|
|
75
72
|
bool Valid();
|
|
76
|
-
} from "kopular/forms"
|
|
73
|
+
} from "kopular/forms";
|
|
74
|
+
|
|
75
|
+
FormField<string> email = new FormField<string>("", (string v) => Validators.Email(v));
|
|
77
76
|
```
|
|
78
77
|
|
|
79
78
|
`Value`/`Error`/`Touched` are declared as bare properties (`state<T> Value;`, no
|
|
@@ -125,6 +124,22 @@ w.Bump(); // re-render
|
|
|
125
124
|
reconciliation. Compose independent components into stable slots (see `Router`'s own
|
|
126
125
|
pattern of keeping page instances alive) to avoid this rather than nesting components
|
|
127
126
|
that both re-render.
|
|
127
|
+
- `RenderError(string message)`: `virtual`, called by `Mount()`/`Update()` (via a private
|
|
128
|
+
`SafeRender()` wrapper) when `Render()` throws, instead of letting the exception
|
|
129
|
+
propagate uncaught and crash whatever triggered the render (a click handler, a `Router`
|
|
130
|
+
navigation). The base implementation just `throw`s `message` again — **purely additive,
|
|
131
|
+
opt-in error recovery**; a `Component` that never overrides `RenderError` behaves
|
|
132
|
+
exactly as before this existed. Override it to show a fallback UI instead:
|
|
133
|
+
```ks
|
|
134
|
+
protected override Element RenderError(string message) {
|
|
135
|
+
Element el = document.createElement("div");
|
|
136
|
+
el.textContent = "Something went wrong: " + message;
|
|
137
|
+
return el;
|
|
138
|
+
}
|
|
139
|
+
```
|
|
140
|
+
A later successful `Update()` (e.g. from a "Retry" button in that fallback calling back
|
|
141
|
+
into the component) renders normally again — there's no separate "broken" state to
|
|
142
|
+
reset, `SafeRender()` just tries `Render()` again like any other `Update()`.
|
|
128
143
|
|
|
129
144
|
## `Router` (`router.ks`)
|
|
130
145
|
|
|
@@ -174,6 +189,16 @@ nav.Navigate("/about"); // pushState + immediate re-ren
|
|
|
174
189
|
`Render()` finishes swapping the matched page into the outlet, so the very first route
|
|
175
190
|
that matches a page nothing has `Mount()`ed yet fires that page's subscribed listener
|
|
176
191
|
while its inherited `Update()` still has no `ParentElement` to `replaceChild` into.
|
|
192
|
+
- **Navigation guards**: `SetGuard(redirectPath, (string) => bool guard)` — `guard` is
|
|
193
|
+
called with the target path before every navigation (including a direct load/refresh);
|
|
194
|
+
returning `false` redirects to `redirectPath` (via `pushState`, so the URL updates too —
|
|
195
|
+
a refresh on the blocked path lands on the redirect again, not back on the rejected
|
|
196
|
+
page). One guard for the whole `Router`, not per-route — `guard` itself decides which
|
|
197
|
+
paths it cares about (`if (path == "/admin") { return loggedIn.Value; } return true;`).
|
|
198
|
+
Defaults to always-allow (a real `(string path) => true` function, not `null` — a
|
|
199
|
+
nullable *function type* has the same "can't parenthesize for postfix `?`" problem as
|
|
200
|
+
an array of one) until `SetGuard` is called. `redirectPath` is never itself
|
|
201
|
+
guard-checked — pick one `guard` always allows.
|
|
177
202
|
- **Every deployment target needs its own SPA/history-fallback config — this is
|
|
178
203
|
unavoidable, not a Kopular gap.** A direct load or refresh at `/about` is a plain HTTP
|
|
179
204
|
request that reaches your host *before* any JS (Router included) has run, so no
|
package/README.md
CHANGED
|
@@ -15,7 +15,9 @@ Generating Kopular code with an AI coding assistant? Point it at **[`LLM.md`](./
|
|
|
15
15
|
- **`Component`**: a base class with `virtual Render()` (builds a fresh DOM subtree from
|
|
16
16
|
current state) and `Update()` (swaps the old subtree for the new one). No template
|
|
17
17
|
language, no diffing — components build/update the DOM imperatively against plain DOM
|
|
18
|
-
bindings, the way you'd write careful vanilla-JS UI code.
|
|
18
|
+
bindings, the way you'd write careful vanilla-JS UI code. An optional `virtual
|
|
19
|
+
RenderError(message)` renders a fallback instead of a hard crash if `Render()` throws —
|
|
20
|
+
purely additive; not overriding it keeps today's exact (uncaught) behavior.
|
|
19
21
|
- **Reactive state, no RxJS**: components hold `state<number>`/`state<string>`/... (a
|
|
20
22
|
KopScript language feature — see the [Kop](https://dev.azure.com/koppinator/Koppindependence/_git/Kop)
|
|
21
23
|
repo) and subscribe once, in their constructor, to call `Update()` on change. No
|
|
@@ -159,6 +161,24 @@ with no extra step. No `Subscribe()` needed on it. Deliberately just one dynamic
|
|
|
159
161
|
per route for now — no multiple params (`/dogs/:id/toys/:toyId`), no wildcards, no query
|
|
160
162
|
string parsing — each a real, separate extension, not an oversight.
|
|
161
163
|
|
|
164
|
+
**Navigation guards**: protect a route (or any set of routes) behind a check —
|
|
165
|
+
`SetGuard` takes a redirect path plus a single `(string) => bool` checked before every
|
|
166
|
+
navigation, including a direct load/refresh:
|
|
167
|
+
|
|
168
|
+
```ks
|
|
169
|
+
nav.SetGuard("/login", (string path) => {
|
|
170
|
+
if (path == "/admin") { return authService.IsLoggedIn.Value; }
|
|
171
|
+
return true;
|
|
172
|
+
});
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
One guard for the whole `Router`, not per-route — the guard function itself decides which
|
|
176
|
+
paths it cares about, the same "a function, not a config object" style `Http`/DI already
|
|
177
|
+
use. Defaults to always-allow when `SetGuard` is never called. Redirecting updates the URL
|
|
178
|
+
too (via `pushState`), so refreshing a blocked path lands on the redirect again rather than
|
|
179
|
+
back on the page the guard just rejected — pick a `redirectPath` the guard itself always
|
|
180
|
+
allows, or it loops.
|
|
181
|
+
|
|
162
182
|
**Deploying a Router-based app needs SPA/history-fallback configured on whatever you
|
|
163
183
|
deploy to — this is true of every client-side router in every framework, not a Kopular
|
|
164
184
|
gap.** A direct load or a refresh at `/about` is a plain HTTP request that reaches your
|
|
@@ -395,10 +415,9 @@ Marking `Render()` `virtual` in the `extern` declaration is what lets a real sub
|
|
|
395
415
|
for a full working example (components, a service, and routing, all consuming Kopular
|
|
396
416
|
this way).
|
|
397
417
|
|
|
398
|
-
`extern class`
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
above uses for typed JSON).
|
|
418
|
+
`extern class` can carry its own `<T>` (kopscript >= 0.5.0), so a generic export like
|
|
419
|
+
`FormField<T>` describes the same way a real generic class does — see `LLM.md`'s
|
|
420
|
+
`FormField<T>`/`Validators` section for the full example.
|
|
402
421
|
|
|
403
422
|
## Testing your own app: `kopular/testing`
|
|
404
423
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "kopular",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
4
4
|
"description": "Kopular: a small component framework for KopScript — components, reactive state, constructor-injected services, routing, structural directives, and HTTP, with no template DSL and no DI container",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -50,7 +50,7 @@
|
|
|
50
50
|
}
|
|
51
51
|
},
|
|
52
52
|
"devDependencies": {
|
|
53
|
-
"kopscript": "^0.
|
|
53
|
+
"kopscript": "^0.5.0",
|
|
54
54
|
"@types/jsdom": "^30.0.0",
|
|
55
55
|
"@types/node": "^20.14.0",
|
|
56
56
|
"jsdom": "^25.0.1",
|
package/src/component.js
CHANGED
|
@@ -5,14 +5,26 @@ export class Component {
|
|
|
5
5
|
return document.createElement("div");
|
|
6
6
|
}
|
|
7
7
|
|
|
8
|
+
RenderError(message) {
|
|
9
|
+
throw message;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
SafeRender() {
|
|
13
|
+
try {
|
|
14
|
+
return this.Render();
|
|
15
|
+
} catch (message) {
|
|
16
|
+
return this.RenderError(message);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
8
20
|
Mount(parent) {
|
|
9
21
|
(this.ParentElement = parent);
|
|
10
|
-
(this.Root = this.
|
|
22
|
+
(this.Root = this.SafeRender());
|
|
11
23
|
parent.appendChild(this.Root);
|
|
12
24
|
}
|
|
13
25
|
|
|
14
26
|
Update() {
|
|
15
|
-
let newRoot = this.
|
|
27
|
+
let newRoot = this.SafeRender();
|
|
16
28
|
this.ParentElement.replaceChild(newRoot, this.Root);
|
|
17
29
|
(this.Root = newRoot);
|
|
18
30
|
}
|
package/src/component.ks
CHANGED
|
@@ -21,14 +21,34 @@ class Component {
|
|
|
21
21
|
return document.createElement("div");
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
+
// Overridden to render a fallback UI when Render() throws — a page bug,
|
|
25
|
+
// an unhandled rejected Http call, anything — instead of leaving
|
|
26
|
+
// Mount()/Update() to propagate the exception uncaught, which would
|
|
27
|
+
// otherwise crash whatever triggered the render (a click handler, a
|
|
28
|
+
// Router navigation) with nothing shown to the user at all. Default just
|
|
29
|
+
// re-throws, so anything that doesn't override this keeps today's exact
|
|
30
|
+
// behavior — this is purely additive, opt-in error recovery, not a
|
|
31
|
+
// behavior change for existing components.
|
|
32
|
+
protected virtual Element RenderError(string message) {
|
|
33
|
+
throw message;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
private Element SafeRender() {
|
|
37
|
+
try {
|
|
38
|
+
return this.Render();
|
|
39
|
+
} catch (string message) {
|
|
40
|
+
return this.RenderError(message);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
24
44
|
public void Mount(Element parent) {
|
|
25
45
|
this.ParentElement = parent;
|
|
26
|
-
this.Root = this.
|
|
46
|
+
this.Root = this.SafeRender();
|
|
27
47
|
parent.appendChild(this.Root);
|
|
28
48
|
}
|
|
29
49
|
|
|
30
50
|
protected void Update() {
|
|
31
|
-
Element newRoot = this.
|
|
51
|
+
Element newRoot = this.SafeRender();
|
|
32
52
|
this.ParentElement.replaceChild(newRoot, this.Root);
|
|
33
53
|
this.Root = newRoot;
|
|
34
54
|
}
|
package/src/router.js
CHANGED
|
@@ -8,6 +8,8 @@ export class Router extends Component {
|
|
|
8
8
|
(this.Pages = []);
|
|
9
9
|
(this.NotFoundPage = notFoundPage);
|
|
10
10
|
(this.Param = "");
|
|
11
|
+
(this.Guard = (path) => (true));
|
|
12
|
+
(this.RedirectPath = "");
|
|
11
13
|
window.addEventListener("popstate", (e) => (this.Update()));
|
|
12
14
|
}
|
|
13
15
|
|
|
@@ -16,13 +18,23 @@ export class Router extends Component {
|
|
|
16
18
|
(this.Pages = [...this.Pages, page]);
|
|
17
19
|
}
|
|
18
20
|
|
|
21
|
+
SetGuard(redirectPath, guard) {
|
|
22
|
+
(this.RedirectPath = redirectPath);
|
|
23
|
+
(this.Guard = guard);
|
|
24
|
+
}
|
|
25
|
+
|
|
19
26
|
Navigate(path) {
|
|
20
27
|
history.pushState("", "", path);
|
|
21
28
|
this.Update();
|
|
22
29
|
}
|
|
23
30
|
|
|
24
31
|
Match(path) {
|
|
25
|
-
let
|
|
32
|
+
let effectivePath = path;
|
|
33
|
+
if (!this.Guard(path)) {
|
|
34
|
+
history.pushState("", "", this.RedirectPath);
|
|
35
|
+
(effectivePath = this.RedirectPath);
|
|
36
|
+
}
|
|
37
|
+
let pathSegments = effectivePath.split("/");
|
|
26
38
|
let found = this.NotFoundPage;
|
|
27
39
|
let param = "";
|
|
28
40
|
for (let i = 0; (i < this.Paths.length); (i = (i + 1))) {
|
package/src/router.ks
CHANGED
|
@@ -42,11 +42,27 @@ class Router : Component {
|
|
|
42
42
|
// inherited Update() has no ParentElement to replaceChild into.
|
|
43
43
|
public string Param;
|
|
44
44
|
|
|
45
|
+
// Checked before every navigation, including a direct load/refresh — not
|
|
46
|
+
// just Navigate()/popstate. Deliberately one guard for the whole Router,
|
|
47
|
+
// not per-route: the guard function itself decides which paths it cares
|
|
48
|
+
// about (typically `path.StartsWith("/admin")`-style checks), the same
|
|
49
|
+
// "no config object, just a function" style Http/DI already use, rather
|
|
50
|
+
// than a parallel array of per-route guards (which also can't be written
|
|
51
|
+
// as a type anyway — see AddRoute's own comment on array-of-function
|
|
52
|
+
// types). Defaults to always-allow, not null — a nullable function TYPE
|
|
53
|
+
// has the same "can't parenthesize for a postfix `?`" problem as an
|
|
54
|
+
// array of one, so "no guard configured" is a real function that always
|
|
55
|
+
// returns true, not a null check.
|
|
56
|
+
private (string) => bool Guard;
|
|
57
|
+
private string RedirectPath;
|
|
58
|
+
|
|
45
59
|
constructor(Component notFoundPage) : base() {
|
|
46
60
|
this.Paths = [];
|
|
47
61
|
this.Pages = [];
|
|
48
62
|
this.NotFoundPage = notFoundPage;
|
|
49
63
|
this.Param = "";
|
|
64
|
+
this.Guard = (string path) => true;
|
|
65
|
+
this.RedirectPath = "";
|
|
50
66
|
// 'popstate' only fires on browser back/forward (or history.go/back/
|
|
51
67
|
// forward) — never on pushState itself, unlike hashchange firing
|
|
52
68
|
// whenever location.hash is set. Navigate() below calls Update()
|
|
@@ -65,13 +81,29 @@ class Router : Component {
|
|
|
65
81
|
this.Pages = this.Pages.Push(page);
|
|
66
82
|
}
|
|
67
83
|
|
|
84
|
+
// `guard` is called with the path being navigated to; returning false
|
|
85
|
+
// redirects to `redirectPath` instead (updating the URL via pushState, so
|
|
86
|
+
// a refresh on the blocked path lands on the redirect too, not back on
|
|
87
|
+
// the page the guard just rejected). `redirectPath` itself is never
|
|
88
|
+
// guard-checked — pick one the guard always allows, or it'll loop.
|
|
89
|
+
public void SetGuard(string redirectPath, (string) => bool guard) {
|
|
90
|
+
this.RedirectPath = redirectPath;
|
|
91
|
+
this.Guard = guard;
|
|
92
|
+
}
|
|
93
|
+
|
|
68
94
|
public void Navigate(string path) {
|
|
69
95
|
history.pushState("", "", path);
|
|
70
96
|
this.Update();
|
|
71
97
|
}
|
|
72
98
|
|
|
73
99
|
private Component Match(string path) {
|
|
74
|
-
string
|
|
100
|
+
string effectivePath = path;
|
|
101
|
+
if (!this.Guard(path)) {
|
|
102
|
+
history.pushState("", "", this.RedirectPath);
|
|
103
|
+
effectivePath = this.RedirectPath;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
string[] pathSegments = effectivePath.Split("/");
|
|
75
107
|
Component found = this.NotFoundPage;
|
|
76
108
|
string param = "";
|
|
77
109
|
for (number i = 0; i < this.Paths.Length; i = i + 1) {
|