kopular 0.6.1 → 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 +68 -14
- package/README.md +74 -8
- package/package.json +12 -3
- package/src/router.js +33 -1
- package/src/router.ks +72 -1
- package/src/testing.js +166 -0
package/LLM.md
CHANGED
|
@@ -1,17 +1,18 @@
|
|
|
1
1
|
# Kopular — LLM reference
|
|
2
2
|
|
|
3
3
|
Complete reference for generating correct Kopular code. This is a spec, not a tutorial —
|
|
4
|
-
see `README.md` for narrative/rationale. Kopular is
|
|
4
|
+
see `README.md` for narrative/rationale. Kopular is 8 files total; this covers all of
|
|
5
5
|
them. For the host language, see KopScript's own `LLM.md` in the `Kop` repo (or its
|
|
6
6
|
published `LLM.md` on the `kopscript` npm package) — that reference is a prerequisite,
|
|
7
7
|
not repeated here.
|
|
8
8
|
|
|
9
9
|
Published as npm `kopular`. Entry points: `kopular` / `kopular/component` (Component),
|
|
10
10
|
`kopular/router` (Router), `kopular/dom` (ambient DOM bindings), `kopular/directives`
|
|
11
|
-
(If), `kopular/http` (Http), `kopular/forms` (FormField, Validators)
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
11
|
+
(If), `kopular/http` (Http), `kopular/forms` (FormField, Validators), `kopular/testing`
|
|
12
|
+
(runKopularApp, runKopularFixture — see below). Also ships a bin, `kp` — `npx kp new
|
|
13
|
+
<dir>` scaffolds a new project (the `extern` bindings below, plus the vendor/serve
|
|
14
|
+
scripts needed to run in a browser) rather than requiring it be reconstructed by hand;
|
|
15
|
+
prefer it over hand-writing the section below for a new project.
|
|
15
16
|
|
|
16
17
|
## Consuming Kopular from your own KopScript project
|
|
17
18
|
|
|
@@ -58,21 +59,20 @@ extern class Validators {
|
|
|
58
59
|
} from "kopular/forms";
|
|
59
60
|
```
|
|
60
61
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
Describe one concrete instantiation per `T` you actually need instead, aliasing the same
|
|
64
|
-
real export with `as` (the same trust-based, per-shape approach `Http`'s typed-JSON gap
|
|
65
|
-
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:
|
|
66
64
|
|
|
67
65
|
```ks
|
|
68
|
-
extern class
|
|
69
|
-
constructor(
|
|
70
|
-
state<
|
|
66
|
+
extern class FormField<T> {
|
|
67
|
+
constructor(T initial, (T) => string? validate);
|
|
68
|
+
state<T> Value;
|
|
71
69
|
state<string?> Error;
|
|
72
70
|
state<bool> Touched;
|
|
73
71
|
void Touch();
|
|
74
72
|
bool Valid();
|
|
75
|
-
} from "kopular/forms"
|
|
73
|
+
} from "kopular/forms";
|
|
74
|
+
|
|
75
|
+
FormField<string> email = new FormField<string>("", (string v) => Validators.Email(v));
|
|
76
76
|
```
|
|
77
77
|
|
|
78
78
|
`Value`/`Error`/`Touched` are declared as bare properties (`state<T> Value;`, no
|
|
@@ -158,6 +158,31 @@ nav.Navigate("/about"); // pushState + immediate re-ren
|
|
|
158
158
|
- `Navigate(path)` calls `history.pushState` then re-renders immediately. A browser
|
|
159
159
|
back/forward triggers re-render via a `popstate` listener registered in the
|
|
160
160
|
constructor — `Navigate()` itself doesn't rely on that event.
|
|
161
|
+
- **Dynamic route segments**: a pattern segment written `:name` (e.g. `AddRoute("/dogs/:id",
|
|
162
|
+
...)`) matches any single non-empty path segment; every other segment must match
|
|
163
|
+
literally (same segment count required too — `/dogs` does NOT match `/dogs/:id`).
|
|
164
|
+
The captured value is `Router.Param` — a plain `string` field (**not** `state<T>`; no
|
|
165
|
+
`Subscribe()` needed — see below), `""` when the matched route has no `:` segment.
|
|
166
|
+
Just one dynamic segment per route in v1: no `/dogs/:id/toys/:toyId`, no wildcards, no
|
|
167
|
+
query string parsing.
|
|
168
|
+
- **Why `Param` is a plain field, not `state<T>`**: `Render()` already rebuilds a fresh
|
|
169
|
+
outlet and re-`Mount()`s the matched page on every `Navigate()`/`popstate`, which
|
|
170
|
+
re-runs that page's own `Render()` (reading the fresh `Param`) with no extra step.
|
|
171
|
+
Making it `state<T>` and having a page `Subscribe()` to it — the pattern every *other*
|
|
172
|
+
piece of state in this framework uses — actually crashes: `Match()` sets it *before*
|
|
173
|
+
`Render()` finishes swapping the matched page into the outlet, so the very first route
|
|
174
|
+
that matches a page nothing has `Mount()`ed yet fires that page's subscribed listener
|
|
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.
|
|
161
186
|
- **Every deployment target needs its own SPA/history-fallback config — this is
|
|
162
187
|
unavoidable, not a Kopular gap.** A direct load or refresh at `/about` is a plain HTTP
|
|
163
188
|
request that reaches your host *before* any JS (Router included) has run, so no
|
|
@@ -270,6 +295,35 @@ function, not a list.
|
|
|
270
295
|
call in your own `Render()`, the same as any other event handler; there is no
|
|
271
296
|
`[(ngModel)]`-equivalent.
|
|
272
297
|
|
|
298
|
+
## `kopular/testing` (`testing.js`, hand-written JS, not compiled from `.ks`)
|
|
299
|
+
|
|
300
|
+
```ts
|
|
301
|
+
import { runKopularApp } from "kopular/testing";
|
|
302
|
+
|
|
303
|
+
const { window, cleanup } = await runKopularApp(srcDir, "app.ks", {
|
|
304
|
+
includeKopularPackage: true, // app consumes Kopular via `extern`, not relative `using`
|
|
305
|
+
extraFiles: ["http_runtime.js"], // non-.ks/.html siblings a `using` graph needs at runtime
|
|
306
|
+
fetchMock: (...args) => Promise.resolve({ ok: true, status: 200, text: () => Promise.resolve("body") }),
|
|
307
|
+
url: "http://localhost/",
|
|
308
|
+
});
|
|
309
|
+
try {
|
|
310
|
+
window.document.querySelector(...)
|
|
311
|
+
} finally {
|
|
312
|
+
cleanup(); // always call, even on a thrown assertion — restores globalThis + deletes the temp dir
|
|
313
|
+
}
|
|
314
|
+
```
|
|
315
|
+
|
|
316
|
+
Compiles `entryFileName` (plus everything else in `srcDir` it `using`s) via kopscript's
|
|
317
|
+
`compileGraph`, binds jsdom onto `globalThis` (`document`/`Element`/`Event`/`location`/
|
|
318
|
+
`history`/`window`/`fetch`) for the compiled ambient `extern` declarations to find, and
|
|
319
|
+
runs it. `runKopularFixture(source, options)` is the sibling export for an inline fixture
|
|
320
|
+
string instead of a real file (used by Kopular's own test suite; copies Kopular's *own*
|
|
321
|
+
`.ks` sources alongside the fixture, so `using "./component"` resolves — only meaningful
|
|
322
|
+
for testing Kopular itself, not an external consumer, which should use `runKopularApp`
|
|
323
|
+
with `includeKopularPackage: true` instead).
|
|
324
|
+
|
|
325
|
+
`jsdom` is an optional peer dependency — add it to your own project to use this.
|
|
326
|
+
|
|
273
327
|
## Dependency injection — no container, no decorators
|
|
274
328
|
|
|
275
329
|
There is no injector, no `@Injectable`, no provider tokens. "Injecting" a service is
|
package/README.md
CHANGED
|
@@ -50,11 +50,13 @@ Generating Kopular code with an AI coding assistant? Point it at **[`LLM.md`](./
|
|
|
50
50
|
is its one companion file — the single hand-written (not compiled from `.ks`) file in
|
|
51
51
|
Kopular, and why is explained in its own header comment.
|
|
52
52
|
- `src/forms.ks` — `FormField<T>` and `Validators` (see "Forms" below).
|
|
53
|
-
- `
|
|
54
|
-
|
|
55
|
-
|
|
53
|
+
- `src/testing.js` — `runKopularApp`/`runKopularFixture` (see "Testing your own app"
|
|
54
|
+
below); hand-written for the same reason as `http_runtime.js` — filesystem/process
|
|
55
|
+
orchestration isn't a Kopular `Component`.
|
|
56
|
+
- `bin/kp.mjs` — the `kp new` scaffolding CLI (see "Starting a new project" below); also
|
|
57
|
+
hand-written, same reason.
|
|
56
58
|
|
|
57
|
-
That's the whole framework —
|
|
59
|
+
That's the whole framework — eight files, plus the scaffolding CLI. Everything else (a
|
|
58
60
|
real app built on top of it)
|
|
59
61
|
lives in a separate consumer repo, [KopularDemo](https://dev.azure.com/koppinator/Koppindependence/_git/KopularDemo).
|
|
60
62
|
|
|
@@ -141,6 +143,40 @@ Real URLs via the History API (`pushState`/`popstate`), not `#/about` hash routi
|
|
|
141
143
|
`state<T>` survives navigating away and back — see "Dependency injection" above for how
|
|
142
144
|
the whole page graph typically gets built once, in a composition root.
|
|
143
145
|
|
|
146
|
+
**Dynamic route segments**: a path segment written `:name` (e.g. `/dogs/:id`) matches any
|
|
147
|
+
single non-empty segment, captured into `Router.Param`:
|
|
148
|
+
|
|
149
|
+
```ks
|
|
150
|
+
nav.AddRoute("/dogs/:id", new DogPage(nav));
|
|
151
|
+
// inside DogPage.Render():
|
|
152
|
+
el.textContent = "Dog #" + this.Nav.Param;
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
`Param` is a plain `string`, deliberately not `state<T>` — Router's own `Render()`
|
|
156
|
+
already rebuilds a fresh outlet and re-`Mount()`s the matched page on every
|
|
157
|
+
`Navigate()`/`popstate`, which re-runs that page's `Render()` (reading the fresh `Param`)
|
|
158
|
+
with no extra step. No `Subscribe()` needed on it. Deliberately just one dynamic segment
|
|
159
|
+
per route for now — no multiple params (`/dogs/:id/toys/:toyId`), no wildcards, no query
|
|
160
|
+
string parsing — each a real, separate extension, not an oversight.
|
|
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
|
+
|
|
144
180
|
**Deploying a Router-based app needs SPA/history-fallback configured on whatever you
|
|
145
181
|
deploy to — this is true of every client-side router in every framework, not a Kopular
|
|
146
182
|
gap.** A direct load or a refresh at `/about` is a plain HTTP request that reaches your
|
|
@@ -377,10 +413,40 @@ Marking `Render()` `virtual` in the `extern` declaration is what lets a real sub
|
|
|
377
413
|
for a full working example (components, a service, and routing, all consuming Kopular
|
|
378
414
|
this way).
|
|
379
415
|
|
|
380
|
-
`extern class`
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
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.
|
|
419
|
+
|
|
420
|
+
## Testing your own app: `kopular/testing`
|
|
421
|
+
|
|
422
|
+
A `Component`/`Router` graph can only be exercised end-to-end by actually compiling its
|
|
423
|
+
`.ks` sources and running the result against a real DOM — there's no way to unit test one
|
|
424
|
+
otherwise. Doing that by hand is a real ~50-line dance (a fresh temp dir per test, since
|
|
425
|
+
Node's ESM module cache means re-importing the same compiled path twice never re-runs an
|
|
426
|
+
ambient extern binding's top-level code — silently binding every later test to the first
|
|
427
|
+
test's jsdom instance — compiling via `kopscript`'s `compileGraph`, binding jsdom onto
|
|
428
|
+
`globalThis` for `document`/`Element`/... to find, then restoring it). Both Kopular's own
|
|
429
|
+
test suite and KopularDemo's used to hand-roll this independently; `kopular/testing` is
|
|
430
|
+
that dance, written once:
|
|
431
|
+
|
|
432
|
+
```ts
|
|
433
|
+
import { runKopularApp } from "kopular/testing";
|
|
434
|
+
|
|
435
|
+
const { window, cleanup } = await runKopularApp(join(__dirname, "..", "src"), "app.ks", {
|
|
436
|
+
includeKopularPackage: true, // your app consumes Kopular via `extern`, not relative `using`
|
|
437
|
+
});
|
|
438
|
+
try {
|
|
439
|
+
expect(window.document.querySelector("h1")?.textContent).toBe("Hello");
|
|
440
|
+
} finally {
|
|
441
|
+
cleanup(); // always — even on a thrown assertion — or the next test inherits these globals
|
|
442
|
+
}
|
|
443
|
+
```
|
|
444
|
+
|
|
445
|
+
`jsdom` is an **optional peer dependency** — installing `kopular` alone doesn't pull it
|
|
446
|
+
in; only a project that actually calls `runKopularApp` needs it added too. See
|
|
447
|
+
`Kopular/test/kopular.test.ts` (`runKopularFixture`, the sibling export used for testing
|
|
448
|
+
Kopular's own source against inline fixtures) and KopularDemo's
|
|
449
|
+
`test/routed_app.test.ts` for two real, different call sites.
|
|
384
450
|
|
|
385
451
|
## Getting started (developing Kopular itself)
|
|
386
452
|
|
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",
|
|
@@ -26,7 +26,8 @@
|
|
|
26
26
|
"./dom": "./src/dom.js",
|
|
27
27
|
"./directives": "./src/directives.js",
|
|
28
28
|
"./http": "./src/http.js",
|
|
29
|
-
"./forms": "./src/forms.js"
|
|
29
|
+
"./forms": "./src/forms.js",
|
|
30
|
+
"./testing": "./src/testing.js"
|
|
30
31
|
},
|
|
31
32
|
"files": [
|
|
32
33
|
"src",
|
|
@@ -40,8 +41,16 @@
|
|
|
40
41
|
"test": "vitest run",
|
|
41
42
|
"test:watch": "vitest"
|
|
42
43
|
},
|
|
44
|
+
"peerDependencies": {
|
|
45
|
+
"jsdom": "^25.0.0"
|
|
46
|
+
},
|
|
47
|
+
"peerDependenciesMeta": {
|
|
48
|
+
"jsdom": {
|
|
49
|
+
"optional": true
|
|
50
|
+
}
|
|
51
|
+
},
|
|
43
52
|
"devDependencies": {
|
|
44
|
-
"kopscript": "^0.
|
|
53
|
+
"kopscript": "^0.5.0",
|
|
45
54
|
"@types/jsdom": "^30.0.0",
|
|
46
55
|
"@types/node": "^20.14.0",
|
|
47
56
|
"jsdom": "^25.0.1",
|
package/src/router.js
CHANGED
|
@@ -7,6 +7,9 @@ export class Router extends Component {
|
|
|
7
7
|
(this.Paths = []);
|
|
8
8
|
(this.Pages = []);
|
|
9
9
|
(this.NotFoundPage = notFoundPage);
|
|
10
|
+
(this.Param = "");
|
|
11
|
+
(this.Guard = (path) => (true));
|
|
12
|
+
(this.RedirectPath = "");
|
|
10
13
|
window.addEventListener("popstate", (e) => (this.Update()));
|
|
11
14
|
}
|
|
12
15
|
|
|
@@ -15,18 +18,47 @@ export class Router extends Component {
|
|
|
15
18
|
(this.Pages = [...this.Pages, page]);
|
|
16
19
|
}
|
|
17
20
|
|
|
21
|
+
SetGuard(redirectPath, guard) {
|
|
22
|
+
(this.RedirectPath = redirectPath);
|
|
23
|
+
(this.Guard = guard);
|
|
24
|
+
}
|
|
25
|
+
|
|
18
26
|
Navigate(path) {
|
|
19
27
|
history.pushState("", "", path);
|
|
20
28
|
this.Update();
|
|
21
29
|
}
|
|
22
30
|
|
|
23
31
|
Match(path) {
|
|
32
|
+
let effectivePath = path;
|
|
33
|
+
if (!this.Guard(path)) {
|
|
34
|
+
history.pushState("", "", this.RedirectPath);
|
|
35
|
+
(effectivePath = this.RedirectPath);
|
|
36
|
+
}
|
|
37
|
+
let pathSegments = effectivePath.split("/");
|
|
24
38
|
let found = this.NotFoundPage;
|
|
39
|
+
let param = "";
|
|
25
40
|
for (let i = 0; (i < this.Paths.length); (i = (i + 1))) {
|
|
26
|
-
|
|
41
|
+
let patternSegments = this.Paths[i].split("/");
|
|
42
|
+
if ((patternSegments.length !== pathSegments.length)) {
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
let matched = true;
|
|
46
|
+
let capturedParam = "";
|
|
47
|
+
for (let j = 0; (j < patternSegments.length); (j = (j + 1))) {
|
|
48
|
+
if (patternSegments[j].startsWith(":")) {
|
|
49
|
+
(capturedParam = pathSegments[j]);
|
|
50
|
+
} else if ((patternSegments[j] !== pathSegments[j])) {
|
|
51
|
+
(matched = false);
|
|
52
|
+
break;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
if (matched) {
|
|
27
56
|
(found = this.Pages[i]);
|
|
57
|
+
(param = capturedParam);
|
|
58
|
+
break;
|
|
28
59
|
}
|
|
29
60
|
}
|
|
61
|
+
(this.Param = param);
|
|
30
62
|
return found;
|
|
31
63
|
}
|
|
32
64
|
|
package/src/router.ks
CHANGED
|
@@ -28,10 +28,41 @@ class Router : Component {
|
|
|
28
28
|
private Component[] Pages;
|
|
29
29
|
private Component NotFoundPage;
|
|
30
30
|
|
|
31
|
+
// The current route's dynamic segment (e.g. "42" for a "/dogs/:id" route
|
|
32
|
+
// matching "/dogs/42"), or "" if the matched route has none.
|
|
33
|
+
//
|
|
34
|
+
// A plain field, deliberately **not** state<T> — Render() below always
|
|
35
|
+
// rebuilds a fresh outlet and re-Mount()s the matched page into it on
|
|
36
|
+
// every Navigate()/popstate, which already re-runs that page's own
|
|
37
|
+
// Render() (reading the fresh Param) with no extra step. Subscribing to
|
|
38
|
+
// it, the way a page reacts to its *own* state<T>, would fire during
|
|
39
|
+
// Match() itself — before Render() has finished swapping the matched page
|
|
40
|
+
// in — which crashes the very first time a route no page has been
|
|
41
|
+
// Mount()ed into yet matches, since a not-yet-mounted Component's
|
|
42
|
+
// inherited Update() has no ParentElement to replaceChild into.
|
|
43
|
+
public string Param;
|
|
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
|
+
|
|
31
59
|
constructor(Component notFoundPage) : base() {
|
|
32
60
|
this.Paths = [];
|
|
33
61
|
this.Pages = [];
|
|
34
62
|
this.NotFoundPage = notFoundPage;
|
|
63
|
+
this.Param = "";
|
|
64
|
+
this.Guard = (string path) => true;
|
|
65
|
+
this.RedirectPath = "";
|
|
35
66
|
// 'popstate' only fires on browser back/forward (or history.go/back/
|
|
36
67
|
// forward) — never on pushState itself, unlike hashchange firing
|
|
37
68
|
// whenever location.hash is set. Navigate() below calls Update()
|
|
@@ -40,23 +71,63 @@ class Router : Component {
|
|
|
40
71
|
window.addEventListener("popstate", (Event e) => this.Update());
|
|
41
72
|
}
|
|
42
73
|
|
|
74
|
+
// A path segment written ":name" (e.g. "dogs/:id") matches any single
|
|
75
|
+
// non-empty segment; every other segment must match literally. Comparing
|
|
76
|
+
// segment-by-segment (rather than the whole string at once) is what makes
|
|
77
|
+
// a static route's own matching still exactly as strict as a plain `==`
|
|
78
|
+
// was before — same segment count, same literal text throughout.
|
|
43
79
|
public void AddRoute(string path, Component page) {
|
|
44
80
|
this.Paths = this.Paths.Push(path);
|
|
45
81
|
this.Pages = this.Pages.Push(page);
|
|
46
82
|
}
|
|
47
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
|
+
|
|
48
94
|
public void Navigate(string path) {
|
|
49
95
|
history.pushState("", "", path);
|
|
50
96
|
this.Update();
|
|
51
97
|
}
|
|
52
98
|
|
|
53
99
|
private Component Match(string path) {
|
|
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("/");
|
|
54
107
|
Component found = this.NotFoundPage;
|
|
108
|
+
string param = "";
|
|
55
109
|
for (number i = 0; i < this.Paths.Length; i = i + 1) {
|
|
56
|
-
|
|
110
|
+
string[] patternSegments = this.Paths[i].Split("/");
|
|
111
|
+
if (patternSegments.Length != pathSegments.Length) { continue; }
|
|
112
|
+
|
|
113
|
+
bool matched = true;
|
|
114
|
+
string capturedParam = "";
|
|
115
|
+
for (number j = 0; j < patternSegments.Length; j = j + 1) {
|
|
116
|
+
if (patternSegments[j].StartsWith(":")) {
|
|
117
|
+
capturedParam = pathSegments[j];
|
|
118
|
+
} else if (patternSegments[j] != pathSegments[j]) {
|
|
119
|
+
matched = false;
|
|
120
|
+
break;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (matched) {
|
|
57
125
|
found = this.Pages[i];
|
|
126
|
+
param = capturedParam;
|
|
127
|
+
break;
|
|
58
128
|
}
|
|
59
129
|
}
|
|
130
|
+
this.Param = param;
|
|
60
131
|
return found;
|
|
61
132
|
}
|
|
62
133
|
|
package/src/testing.js
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
// A real Kopular app can only be exercised end-to-end by actually compiling
|
|
2
|
+
// its .ks sources and running the result in a DOM — there's no way to unit
|
|
3
|
+
// test a Component/Router graph without one. Every test suite that's tried
|
|
4
|
+
// this so far (Kopular's own, and KopularDemo's) ended up hand-rolling the
|
|
5
|
+
// same ~50-line dance: a fresh temp dir per test (Node's ESM module cache
|
|
6
|
+
// means re-importing the same compiled path twice never re-runs an ambient
|
|
7
|
+
// extern binding's top-level code — silently binding every test after the
|
|
8
|
+
// first to the first test's jsdom instance), compiling via kopscript's
|
|
9
|
+
// compileGraph, binding jsdom onto globalThis for the ambient `document`/
|
|
10
|
+
// `Element`/... extern declarations to find, then restoring/cleaning up.
|
|
11
|
+
// This is that dance, written once.
|
|
12
|
+
//
|
|
13
|
+
// Hand-written plain JS, not compiled from a .ks source, the same as
|
|
14
|
+
// http_runtime.js and bin/kp.mjs: filesystem/process orchestration isn't a
|
|
15
|
+
// Kopular Component.
|
|
16
|
+
//
|
|
17
|
+
// `jsdom` is a peer dependency (optional) — only installed by a consumer
|
|
18
|
+
// who actually calls this, not a weight every `kopular` install pays.
|
|
19
|
+
import { JSDOM } from "jsdom";
|
|
20
|
+
import { compileGraph } from "kopscript";
|
|
21
|
+
import { cpSync, mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from "node:fs";
|
|
22
|
+
import { tmpdir } from "node:os";
|
|
23
|
+
import { dirname, join } from "node:path";
|
|
24
|
+
import { fileURLToPath } from "node:url";
|
|
25
|
+
|
|
26
|
+
const GLOBAL_KEYS = ["document", "Element", "Event", "location", "history", "window", "fetch"];
|
|
27
|
+
const OWN_SRC_DIR = dirname(fileURLToPath(import.meta.url));
|
|
28
|
+
|
|
29
|
+
// Copies every .ks/.html file from `srcDir` into `dir`, plus any filename
|
|
30
|
+
// listed in `extraFiles` (e.g. http_runtime.js — a hand-written sibling a
|
|
31
|
+
// compiled `using` graph needs at runtime, which the compiler wouldn't know
|
|
32
|
+
// to copy on its own).
|
|
33
|
+
function copySources(srcDir, dir, extraFiles) {
|
|
34
|
+
for (const name of readdirSync(srcDir)) {
|
|
35
|
+
if (name.endsWith(".ks") || name.endsWith(".html") || extraFiles.includes(name)) {
|
|
36
|
+
cpSync(join(srcDir, name), join(dir, name));
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// kopscript's compiled output is genuine ESM (`export class ...`), which
|
|
42
|
+
// Node only interprets correctly given a ".mjs" extension or an ancestor
|
|
43
|
+
// package.json declaring "type": "module" — a bare os.tmpdir() has neither,
|
|
44
|
+
// so the dynamic `import()` below would otherwise fail with "Cannot use
|
|
45
|
+
// import statement outside a module" the moment anything outside this
|
|
46
|
+
// file's own module graph (a real npm package, not code vitest/vite-node
|
|
47
|
+
// itself transforms) tries to load it.
|
|
48
|
+
function mkTempDir() {
|
|
49
|
+
const dir = mkdtempSync(join(tmpdir(), "kopular-testing-"));
|
|
50
|
+
writeFileSync(join(dir, "package.json"), JSON.stringify({ type: "module" }), "utf-8");
|
|
51
|
+
return dir;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Copies this real, installed `kopular` package into the temp dir's own
|
|
55
|
+
// node_modules — for an entry that consumes Kopular the way a separate
|
|
56
|
+
// project does, through `extern ... from "kopular/..."` bare specifiers,
|
|
57
|
+
// not via relative `using`. Resolved off this very file's own location, so
|
|
58
|
+
// it's always the real package as installed in the *caller's*
|
|
59
|
+
// node_modules, not a guess at a path.
|
|
60
|
+
function copyKopularPackage(dir) {
|
|
61
|
+
mkdirSync(join(dir, "node_modules"), { recursive: true });
|
|
62
|
+
cpSync(join(OWN_SRC_DIR, ".."), join(dir, "node_modules", "kopular"), { recursive: true });
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Shared by both exports below: compiles `entryFileName` (already written
|
|
66
|
+
// into `dir`, alongside everything it `using`s) and runs it against a fresh
|
|
67
|
+
// jsdom instance.
|
|
68
|
+
async function compileAndRun(dir, entryFileName, options) {
|
|
69
|
+
const entry = join(dir, entryFileName);
|
|
70
|
+
const result = compileGraph(entry);
|
|
71
|
+
if (!result.success) {
|
|
72
|
+
rmSync(dir, { recursive: true, force: true });
|
|
73
|
+
const messages = [...result.modules.values()]
|
|
74
|
+
.filter((m) => m.diagnostics.hasErrors)
|
|
75
|
+
.map((m) => m.diagnostics.format(m.source, m.absPath))
|
|
76
|
+
.join("\n\n");
|
|
77
|
+
throw new Error(messages || "Compilation failed");
|
|
78
|
+
}
|
|
79
|
+
for (const [absPath, js] of result.outputs) {
|
|
80
|
+
writeFileSync(absPath.replace(/\.ks$/, ".js"), js, "utf-8");
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const dom = new JSDOM("<!doctype html><html><body></body></html>", { url: options.url ?? "http://localhost/" });
|
|
84
|
+
const g = globalThis;
|
|
85
|
+
const previous = {};
|
|
86
|
+
for (const key of GLOBAL_KEYS) previous[key] = g[key];
|
|
87
|
+
g.document = dom.window.document;
|
|
88
|
+
g.Element = dom.window.Element;
|
|
89
|
+
g.Event = dom.window.Event;
|
|
90
|
+
g.location = dom.window.location;
|
|
91
|
+
g.history = dom.window.history;
|
|
92
|
+
g.window = dom.window;
|
|
93
|
+
if (options.fetchMock) g.fetch = options.fetchMock;
|
|
94
|
+
|
|
95
|
+
let cleanedUp = false;
|
|
96
|
+
const cleanup = () => {
|
|
97
|
+
if (cleanedUp) return;
|
|
98
|
+
cleanedUp = true;
|
|
99
|
+
for (const key of GLOBAL_KEYS) g[key] = previous[key];
|
|
100
|
+
rmSync(dir, { recursive: true, force: true });
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
try {
|
|
104
|
+
await import(entry.replace(/\.ks$/, ".js"));
|
|
105
|
+
} catch (err) {
|
|
106
|
+
cleanup();
|
|
107
|
+
throw err;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return { window: dom.window, cleanup };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Compiles a real KopScript app — `entryFileName` plus everything else in
|
|
115
|
+
* `srcDir` it `using`s — and runs it against a fresh jsdom instance. For a
|
|
116
|
+
* real project's own src/ directory, the way KopularDemo's test suite (or
|
|
117
|
+
* any app consuming `kopular/...` via `extern`) would use it.
|
|
118
|
+
*
|
|
119
|
+
* @param {string} srcDir - directory containing the entry file and
|
|
120
|
+
* everything it `using`s.
|
|
121
|
+
* @param {string} entryFileName - the entry .ks file's name within srcDir.
|
|
122
|
+
* @param {object} [options]
|
|
123
|
+
* @param {boolean} [options.includeKopularPackage] - copy the real
|
|
124
|
+
* installed `kopular` package into a temp node_modules/, for an entry
|
|
125
|
+
* that consumes Kopular via `extern ... from "kopular/..."` rather than
|
|
126
|
+
* relative `using`. Default false.
|
|
127
|
+
* @param {string[]} [options.extraFiles] - extra filenames (not .ks/.html)
|
|
128
|
+
* to copy from srcDir, e.g. ["http_runtime.js"].
|
|
129
|
+
* @param {(...args: unknown[]) => unknown} [options.fetchMock] - replaces
|
|
130
|
+
* the global `fetch` for the duration of the run, for testing `Http`.
|
|
131
|
+
* @param {string} [options.url] - the jsdom document's URL. Default
|
|
132
|
+
* "http://localhost/".
|
|
133
|
+
* @returns {Promise<{ window: import("jsdom").DOMWindow, cleanup: () => void }>}
|
|
134
|
+
* `cleanup()` restores globals and deletes the temp dir — call it in an
|
|
135
|
+
* `afterEach`/`finally`, always, even on a thrown error, or the next test
|
|
136
|
+
* silently inherits this one's globals.
|
|
137
|
+
*/
|
|
138
|
+
export async function runKopularApp(srcDir, entryFileName, options = {}) {
|
|
139
|
+
const dir = mkTempDir();
|
|
140
|
+
copySources(srcDir, dir, options.extraFiles ?? []);
|
|
141
|
+
if (options.includeKopularPackage) copyKopularPackage(dir);
|
|
142
|
+
return compileAndRun(dir, entryFileName, options);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Like `runKopularApp`, but for a fixture written inline as a string rather
|
|
147
|
+
* than a real file on disk — for testing Kopular's *own* source files
|
|
148
|
+
* (`using "./component"`, `using "./router"`, ...) against a tiny
|
|
149
|
+
* throwaway component, the way Kopular's own test suite does. Copies
|
|
150
|
+
* Kopular's own .ks sources (resolved from this very file's own directory)
|
|
151
|
+
* alongside the fixture, so a relative `using "./dom"` in `source` resolves
|
|
152
|
+
* correctly — this only makes sense for testing Kopular itself, not for an
|
|
153
|
+
* external consumer (which would `extern ... from "kopular/..."` instead;
|
|
154
|
+
* see `runKopularApp` for that case).
|
|
155
|
+
*
|
|
156
|
+
* @param {string} source - the entry file's full KopScript source.
|
|
157
|
+
* @param {object} [options] - same as `runKopularApp`, plus:
|
|
158
|
+
* @param {string} [options.entryFileName] - default "main.ks".
|
|
159
|
+
*/
|
|
160
|
+
export async function runKopularFixture(source, options = {}) {
|
|
161
|
+
const dir = mkTempDir();
|
|
162
|
+
copySources(OWN_SRC_DIR, dir, options.extraFiles ?? ["http_runtime.js"]);
|
|
163
|
+
const entryFileName = options.entryFileName ?? "main.ks";
|
|
164
|
+
writeFileSync(join(dir, entryFileName), source, "utf-8");
|
|
165
|
+
return compileAndRun(dir, entryFileName, options);
|
|
166
|
+
}
|