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 CHANGED
@@ -59,21 +59,20 @@ extern class Validators {
59
59
  } from "kopular/forms";
60
60
  ```
61
61
 
62
- **`extern class` has no `<T>` syntax** `extern class FormField<T> { ... }` is a parse
63
- error (`ExternClassDecl` has no `typeParam`, unlike a real `ClassDecl`/`InterfaceDecl`).
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 StringField {
70
- constructor(string initial, (string) => string? validate);
71
- state<string> Value;
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" as "FormField";
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` has no `<T>` syntax, so a generic export like `FormField<T>` can't be
399
- described directly this way — see `LLM.md`'s `FormField<T>`/`Validators` section for the
400
- per-concrete-type workaround (the same trust-based, per-shape approach the "HTTP" section
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.8.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.4.1",
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 pathSegments = path.split("/");
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[] pathSegments = path.Split("/");
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) {