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 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
@@ -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` 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).
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.8.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.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/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.Render());
22
+ (this.Root = this.SafeRender());
11
23
  parent.appendChild(this.Root);
12
24
  }
13
25
 
14
26
  Update() {
15
- let newRoot = this.Render();
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.Render();
46
+ this.Root = this.SafeRender();
27
47
  parent.appendChild(this.Root);
28
48
  }
29
49
 
30
50
  protected void Update() {
31
- Element newRoot = this.Render();
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 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) {