kopular 0.8.0 → 0.9.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 +18 -9
- package/README.md +21 -4
- package/package.json +2 -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
|
|
@@ -174,6 +173,16 @@ nav.Navigate("/about"); // pushState + immediate re-ren
|
|
|
174
173
|
`Render()` finishes swapping the matched page into the outlet, so the very first route
|
|
175
174
|
that matches a page nothing has `Mount()`ed yet fires that page's subscribed listener
|
|
176
175
|
while its inherited `Update()` still has no `ParentElement` to `replaceChild` into.
|
|
176
|
+
- **Navigation guards**: `SetGuard(redirectPath, (string) => bool guard)` — `guard` is
|
|
177
|
+
called with the target path before every navigation (including a direct load/refresh);
|
|
178
|
+
returning `false` redirects to `redirectPath` (via `pushState`, so the URL updates too —
|
|
179
|
+
a refresh on the blocked path lands on the redirect again, not back on the rejected
|
|
180
|
+
page). One guard for the whole `Router`, not per-route — `guard` itself decides which
|
|
181
|
+
paths it cares about (`if (path == "/admin") { return loggedIn.Value; } return true;`).
|
|
182
|
+
Defaults to always-allow (a real `(string path) => true` function, not `null` — a
|
|
183
|
+
nullable *function type* has the same "can't parenthesize for postfix `?`" problem as
|
|
184
|
+
an array of one) until `SetGuard` is called. `redirectPath` is never itself
|
|
185
|
+
guard-checked — pick one `guard` always allows.
|
|
177
186
|
- **Every deployment target needs its own SPA/history-fallback config — this is
|
|
178
187
|
unavoidable, not a Kopular gap.** A direct load or refresh at `/about` is a plain HTTP
|
|
179
188
|
request that reaches your host *before* any JS (Router included) has run, so no
|
package/README.md
CHANGED
|
@@ -159,6 +159,24 @@ with no extra step. No `Subscribe()` needed on it. Deliberately just one dynamic
|
|
|
159
159
|
per route for now — no multiple params (`/dogs/:id/toys/:toyId`), no wildcards, no query
|
|
160
160
|
string parsing — each a real, separate extension, not an oversight.
|
|
161
161
|
|
|
162
|
+
**Navigation guards**: protect a route (or any set of routes) behind a check —
|
|
163
|
+
`SetGuard` takes a redirect path plus a single `(string) => bool` checked before every
|
|
164
|
+
navigation, including a direct load/refresh:
|
|
165
|
+
|
|
166
|
+
```ks
|
|
167
|
+
nav.SetGuard("/login", (string path) => {
|
|
168
|
+
if (path == "/admin") { return authService.IsLoggedIn.Value; }
|
|
169
|
+
return true;
|
|
170
|
+
});
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
One guard for the whole `Router`, not per-route — the guard function itself decides which
|
|
174
|
+
paths it cares about, the same "a function, not a config object" style `Http`/DI already
|
|
175
|
+
use. Defaults to always-allow when `SetGuard` is never called. Redirecting updates the URL
|
|
176
|
+
too (via `pushState`), so refreshing a blocked path lands on the redirect again rather than
|
|
177
|
+
back on the page the guard just rejected — pick a `redirectPath` the guard itself always
|
|
178
|
+
allows, or it loops.
|
|
179
|
+
|
|
162
180
|
**Deploying a Router-based app needs SPA/history-fallback configured on whatever you
|
|
163
181
|
deploy to — this is true of every client-side router in every framework, not a Kopular
|
|
164
182
|
gap.** A direct load or a refresh at `/about` is a plain HTTP request that reaches your
|
|
@@ -395,10 +413,9 @@ Marking `Render()` `virtual` in the `extern` declaration is what lets a real sub
|
|
|
395
413
|
for a full working example (components, a service, and routing, all consuming Kopular
|
|
396
414
|
this way).
|
|
397
415
|
|
|
398
|
-
`extern class`
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
above uses for typed JSON).
|
|
416
|
+
`extern class` can carry its own `<T>` (kopscript >= 0.5.0), so a generic export like
|
|
417
|
+
`FormField<T>` describes the same way a real generic class does — see `LLM.md`'s
|
|
418
|
+
`FormField<T>`/`Validators` section for the full example.
|
|
402
419
|
|
|
403
420
|
## Testing your own app: `kopular/testing`
|
|
404
421
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "kopular",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.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/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) {
|