kopular 0.23.0 → 0.23.2
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 +88 -32
- package/README.md +29 -1
- package/package.json +1 -1
- package/src/router.js +10 -1
- package/src/router.js.map +1 -1
- package/src/router.ks +20 -1
package/LLM.md
CHANGED
|
@@ -9,10 +9,12 @@ not repeated here.
|
|
|
9
9
|
Published as npm `kopular`. Entry points: `kopular` / `kopular/component` (Component),
|
|
10
10
|
`kopular/velement` (VElement — what `Render()` returns; see "Component" below),
|
|
11
11
|
`kopular/router` (Router), `kopular/dom` (ambient DOM bindings), `kopular/directives`
|
|
12
|
-
(If), `kopular/http` (Http), `kopular/forms` (FormField, Validators), `kopular/
|
|
13
|
-
(
|
|
14
|
-
|
|
15
|
-
|
|
12
|
+
(If), `kopular/http` (Http), `kopular/forms` (FormField, Validators), `kopular/computed`
|
|
13
|
+
(Computed1, Computed2), `kopular/resource` (Resource, AsyncStatus), `kopular/vdom`
|
|
14
|
+
(`ScopedStyles` — the runtime half of `styles from`; everything else in this file, the
|
|
15
|
+
diff/patch engine behind `Update()`, is internal, nothing else here needs importing
|
|
16
|
+
directly), `kopular/testing` (runKopularApp, runKopularFixture — see below). Also ships a
|
|
17
|
+
bin, `kp` — `npx kp new
|
|
16
18
|
<dir>` scaffolds a new project (the `extern` bindings below, plus the vendor/serve
|
|
17
19
|
scripts needed to run in a browser) rather than requiring it be reconstructed by hand;
|
|
18
20
|
prefer it over hand-writing the section below for a new project. Templates (`template
|
|
@@ -24,21 +26,22 @@ there is no `kopular/template` entry point to import.
|
|
|
24
26
|
`using` only resolves same-project relative paths — reaching into an npm package (Kopular
|
|
25
27
|
included) always goes through `extern`, re-describing exactly the members you use.
|
|
26
28
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
29
|
+
**Don't reach for `Element`/`Document`/`Event` via `extern ... from "kopular/dom";`, even
|
|
30
|
+
though that actually works** (`kopular/dom` genuinely re-exports real `globalThis`
|
|
31
|
+
bindings, same as any other Kopular export) — **redeclare your own ambient block
|
|
32
|
+
instead.** The reason isn't that importing wouldn't work; it's that Kopular's own internal
|
|
33
|
+
`dom.ks` only declares what Kopular's own framework code itself touches, a smaller
|
|
34
|
+
surface than a real app typically needs (`Element.value`/`.placeholder`, `.href`/`.src`/
|
|
35
|
+
`.alt`, `.querySelector`, ...) — importing it would silently cap you at that subset with
|
|
36
|
+
no signal you're missing something until you hit a real compile error for each one, one
|
|
37
|
+
at a time. Every consuming project re-declares its own Element/Document/Event, same as
|
|
38
|
+
this one does. **Copy the block below rather than hand-rolling a smaller one from scratch
|
|
39
|
+
and adding members as compile errors demand them** — a real, complete first-attempt
|
|
40
|
+
implementation of a Kopular app hit the exact same missing property (`Element.value`)
|
|
41
|
+
twice from two independently-trimmed subsets, because the compile error only ever names
|
|
42
|
+
the one member actually touched, never warns that a *sibling* feature (a template's
|
|
43
|
+
`placeholder="..."` attribute, a `[(value)]` binding's generated `e.target.value` read)
|
|
44
|
+
will need one you didn't happen to write by hand:
|
|
42
45
|
|
|
43
46
|
```ks
|
|
44
47
|
extern class Event {
|
|
@@ -109,6 +112,10 @@ extern class VElement {
|
|
|
109
112
|
extern class Component {
|
|
110
113
|
constructor();
|
|
111
114
|
virtual VElement Render(); // `virtual` here is what lets your subclass `override` it
|
|
115
|
+
// Renders a fallback UI instead of an uncaught crash if Render() throws
|
|
116
|
+
// — see "Component" below. Purely additive; not overriding it keeps
|
|
117
|
+
// today's exact (uncaught) behavior.
|
|
118
|
+
virtual VElement RenderError(string message);
|
|
112
119
|
virtual void AfterRender(Element root); // see "Component" below
|
|
113
120
|
void Mount(Element parent);
|
|
114
121
|
void Update();
|
|
@@ -120,8 +127,26 @@ extern class Component {
|
|
|
120
127
|
extern class Router {
|
|
121
128
|
constructor(Component notFoundPage);
|
|
122
129
|
void AddRoute(string path, Component page);
|
|
130
|
+
// Real code-splitting — see "Router" below. loader is a real dynamic
|
|
131
|
+
// import(), reached via a hand-written loader shim.
|
|
132
|
+
void AddLazyRoute(string path, () => task<Component> loader);
|
|
133
|
+
// Every registered path (AddRoute + AddLazyRoute), in registration
|
|
134
|
+
// order — see "Router" below.
|
|
135
|
+
string[] AllPaths();
|
|
123
136
|
void Navigate(string path);
|
|
124
137
|
void Mount(Element parent);
|
|
138
|
+
// One guard for the whole Router, not per-route — see "Router" below.
|
|
139
|
+
void SetGuard(string redirectPath, (string) => bool guard);
|
|
140
|
+
// The FIRST captured :name segment from whatever route just matched —
|
|
141
|
+
// "" if the matched route has no dynamic segment. Not state<T>; no
|
|
142
|
+
// Subscribe() needed — see "Common mistakes" below for why.
|
|
143
|
+
string Param { get; }
|
|
144
|
+
// Any captured :name segment (or a trailing * wildcard, as "*") by
|
|
145
|
+
// name, for a route with more than one — see "Router" below.
|
|
146
|
+
string Params(string name);
|
|
147
|
+
// A query-string value by key ("" if absent), independent of which
|
|
148
|
+
// route matched.
|
|
149
|
+
string Query(string key);
|
|
125
150
|
} from "kopular/router";
|
|
126
151
|
|
|
127
152
|
extern VElement If(bool condition, () => VElement whenTrue, () => VElement whenFalse) from "kopular/directives";
|
|
@@ -231,7 +256,11 @@ w.Bump(); // re-render + diff + patch
|
|
|
231
256
|
- `VElement`: `Tag`, `TextContent`/`ClassName`/`Id`/`Value` (direct fields — `Value` is the
|
|
232
257
|
one that must be a live DOM *property*, not an attribute: `SetAttr("value", x)` sets the
|
|
233
258
|
default value, not the current one), `RawHtml` (an opaque, undiffed leaf — set instead of
|
|
234
|
-
`TextContent`/children, for a raw-HTML-then-wire-handlers pattern
|
|
259
|
+
`TextContent`/children, for a raw-HTML-then-wire-handlers pattern — **sets real
|
|
260
|
+
`innerHTML`, unescaped: only ever assign static/developer-authored content — a `raw
|
|
261
|
+
string ... from "<path>.html";` compile-time constant, in every real use in this
|
|
262
|
+
ecosystem today — never anything reachable from user input, a fetched `Http` response
|
|
263
|
+
body, or a `FormField<T>`'s `.Value`, or it's a real XSS hole**), the four fixed named
|
|
235
264
|
event fields `OnClick`/`OnInput`/`OnBlur`/`OnChange` (each a real no-op by default, never
|
|
236
265
|
null — no nullable function type to fall back on), `AppendChild(child)`, and
|
|
237
266
|
`SetAttr(name, value)` (the escape hatch for any other real HTML attribute — `href`,
|
|
@@ -370,6 +399,11 @@ class Counter : Component {
|
|
|
370
399
|
`blur`/`change` — `VElement`'s own fixed set — anything else is a compile error
|
|
371
400
|
(`KS5016`). `[prop]`/static `attr="..."` assign directly for `id`/`className`/`value`;
|
|
372
401
|
anything else goes through `SetAttr` instead.
|
|
402
|
+
- **`*mount="expr"`** embeds a live child Component declaratively — desugars to
|
|
403
|
+
`VElement.Mount(expr)`, the same mechanism a hand-written `Render()` uses (see "Nested
|
|
404
|
+
component composition" below). Unlike `*if`/`*for`, it composes with either — `*for="Row
|
|
405
|
+
r of Rows" *mount="r"` (one mounted child per loop item) is the headline case, not an
|
|
406
|
+
error. See kopscript's own `LLM.md`/`README.md` for the full syntax.
|
|
373
407
|
- A `state<T>` field declared directly on the class and referenced directly in the
|
|
374
408
|
template (`Count` above) gets `Subscribe((v) => this.Update())` wired automatically —
|
|
375
409
|
no manual `Subscribe` in the constructor for that field. State reached indirectly
|
|
@@ -380,7 +414,8 @@ class Counter : Component {
|
|
|
380
414
|
`this.Field` (see KopScript's own LLM.md `KS5017`/`KS5018` for the two rejected cases).
|
|
381
415
|
- One top-level element per template (hard error otherwise); no mixing text and element
|
|
382
416
|
children under one element (`VElement` has no text-node sibling concept, only
|
|
383
|
-
`.TextContent`); no pipes, at most one
|
|
417
|
+
`.TextContent`); no pipes, at most one of `*if`/`*for` per element (`*mount` is
|
|
418
|
+
orthogonal to both — see above — and isn't included in that limit).
|
|
384
419
|
- This is entirely a KopScript compiler feature (parsed/desugared before type-checking
|
|
385
420
|
runs) — Kopular's own framework code (`component.ks`, `dom.ks`) is unmodified and
|
|
386
421
|
unaware templates exist; a template-generated `Render()` is indistinguishable from a
|
|
@@ -447,6 +482,11 @@ nav.Navigate("/about"); // pushState + immediate re-ren
|
|
|
447
482
|
- `Navigate(path)` calls `history.pushState` then re-renders immediately. A browser
|
|
448
483
|
back/forward triggers re-render via a `popstate` listener registered in the
|
|
449
484
|
constructor — `Navigate()` itself doesn't rely on that event.
|
|
485
|
+
- **A trailing slash normalizes to the same route as without one** (`/about/` == `/about`;
|
|
486
|
+
`/` itself is left alone, not stripped to `""`) — matches every mainstream router's own
|
|
487
|
+
default, and matters for real: a build-time prerendered static route's own
|
|
488
|
+
`<path>/index.html` (see KopularDemo's `scripts/prerender.mjs`) is naturally reached via
|
|
489
|
+
a trailing-slash URL.
|
|
450
490
|
- **Dynamic route segments**: a pattern segment written `:name` (e.g. `AddRoute("/dogs/:id",
|
|
451
491
|
...)`) matches any single non-empty path segment; every other segment must match
|
|
452
492
|
literally (same segment count required too — `/dogs` does NOT match `/dogs/:id`, except
|
|
@@ -703,10 +743,14 @@ try {
|
|
|
703
743
|
Compiles `entryFileName` (plus everything else in `srcDir` it `using`s) via kopscript's
|
|
704
744
|
`compileGraph`, binds jsdom onto `globalThis` (`document`/`Element`/`Event`/`location`/
|
|
705
745
|
`history`/`window`/`fetch`) for the compiled ambient `extern` declarations to find, and
|
|
706
|
-
runs it. Every `.ks`/`.html`/`.js` file in `srcDir` is copied along with it — `.js`
|
|
746
|
+
runs it. Every `.ks`/`.html`/`.js`/`.css` file in `srcDir` is copied along with it — `.js`
|
|
707
747
|
included specifically so a hand-written (not compiled) sibling like a `Router.AddLazyRoute`
|
|
708
|
-
loader shim just works with no extra setup
|
|
709
|
-
|
|
748
|
+
loader shim just works with no extra setup, `.css` the same way for a `styles from
|
|
749
|
+
"<path>.css";` stylesheet; `extraFiles` is only for a real dependency with some OTHER
|
|
750
|
+
extension (e.g. a `.json` config file). `runKopularFixture(source, options)` also takes
|
|
751
|
+
an `options.extraSource` (`Record<string, string>` — filename to inline content) for
|
|
752
|
+
supplying a fixture's own auxiliary `.html`/`.css` file without a real file on disk, since
|
|
753
|
+
`runKopularFixture` only ever writes the one entry-file string you pass it. This is the sibling export for an inline fixture
|
|
710
754
|
string instead of a real file (used by Kopular's own test suite; copies Kopular's *own*
|
|
711
755
|
`.ks` sources alongside the fixture, so `using "./component"` resolves — only meaningful
|
|
712
756
|
for testing Kopular itself, not an external consumer, which should use `runKopularApp`
|
|
@@ -764,6 +808,15 @@ class CounterService {
|
|
|
764
808
|
|
|
765
809
|
## Common mistakes (seeded from real generation failures)
|
|
766
810
|
|
|
811
|
+
- **`VElement.RawHtml` sets real `innerHTML`, completely unescaped — never assign it
|
|
812
|
+
anything reachable from user input.** It exists for a raw-HTML-then-wire-a-delegated-
|
|
813
|
+
listener pattern (see `header.ks`/`nav.ks` in KopularDemo), and every real use in this
|
|
814
|
+
ecosystem is a `raw string ... from "<path>.html";` compile-time constant — genuinely
|
|
815
|
+
static content, baked in at build time, never a runtime value. There is nothing in the
|
|
816
|
+
type system stopping `el.RawHtml = someFetchedString;` or `el.RawHtml =
|
|
817
|
+
formField.Value.Value;` from compiling — both are a real XSS hole if that content is
|
|
818
|
+
ever attacker-influenced. Use `TextContent` (always escaped) for any dynamic string;
|
|
819
|
+
`RawHtml` is for static markup only.
|
|
767
820
|
- **`Router.Param` is a plain `string`, not `state<T>` — don't `Subscribe()` to it.** A
|
|
768
821
|
`state<T>`-based design for it was tried and genuinely crashes: `Navigate()` sets `Param`
|
|
769
822
|
*before* the newly-matched page finishes mounting, so a page `Subscribe`-ing to it fires
|
|
@@ -814,13 +867,16 @@ imperative `Render()` code as the hand-written form, checked at compile time, no
|
|
|
814
867
|
interpreted at runtime (see "Templates" above) · two-way binding in a hand-written
|
|
815
868
|
`Render()`, or on anything but `value` even in a template (`[(value)]="Field"` exists —
|
|
816
869
|
see "Templates" above — but it's `value`-only, and templates-only) ·
|
|
817
|
-
embedding a child Component from a *template* (`.html`) — `VElement.Mount(component)`
|
|
818
|
-
works only from a hand-written `Render()` today, see "Component" above ·
|
|
819
870
|
a generic/arbitrary `VElement` event binding — only `click`/`input`/`blur`/`change` ·
|
|
820
871
|
pipes · animations · typed/generic HTTP responses (`Http` returns raw text — see above) ·
|
|
821
|
-
SSR
|
|
822
|
-
|
|
823
|
-
(`
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
872
|
+
live, per-request SSR — no framework-level renderer ships for this, but build-time static
|
|
873
|
+
prerendering is a real, demonstrated pattern built entirely on existing pieces
|
|
874
|
+
(`Router.AllPaths()` + `kopular/testing`'s `runKopularApp`, no Kopular framework code
|
|
875
|
+
needed) — see KopularDemo's own `scripts/prerender.mjs`.
|
|
876
|
+
|
|
877
|
+
(`FormField<T>`/`Validators`, `kp new`, and embedding a child Component from a *template*
|
|
878
|
+
via `*mount="expr"` — see "FormField<T> / Validators", "Starting a new project", and
|
|
879
|
+
"Nested component composition" above — are all real, shipped features; they used to be
|
|
880
|
+
listed here as gaps before those landed and this list wasn't updated at the time. Leaving
|
|
881
|
+
this parenthetical rather than quietly deleting it, as a reminder to keep this list in
|
|
882
|
+
sync going forward.)
|
package/README.md
CHANGED
|
@@ -278,6 +278,12 @@ Real URLs via the History API (`pushState`/`popstate`), not `#/about` hash routi
|
|
|
278
278
|
`state<T>` survives navigating away and back — see "Dependency injection" above for how
|
|
279
279
|
the whole page graph typically gets built once, in a composition root.
|
|
280
280
|
|
|
281
|
+
A trailing slash matches the same route as without one (`/about/` == `/about`, `/` is left
|
|
282
|
+
alone) — real-world links and directory-style static hosting (a build-time prerendered
|
|
283
|
+
route's own `/about/index.html`, most concretely — see KopularDemo's own
|
|
284
|
+
`scripts/prerender.mjs`) routinely produce trailing-slash URLs, and every mainstream
|
|
285
|
+
router normalizes this the same way.
|
|
286
|
+
|
|
281
287
|
**Dynamic route segments**: a path segment written `:name` (e.g. `/dogs/:id`) matches any
|
|
282
288
|
single non-empty segment. With just one per route, its value is captured into
|
|
283
289
|
`Router.Param`:
|
|
@@ -609,6 +615,26 @@ class Panel : Component {
|
|
|
609
615
|
}
|
|
610
616
|
```
|
|
611
617
|
|
|
618
|
+
## Raw HTML, and its real security boundary
|
|
619
|
+
|
|
620
|
+
`VElement.RawHtml` sets a real, opaque `innerHTML` — an undiffed leaf, in place of
|
|
621
|
+
`TextContent`/children, for a raw-markup-plus-one-delegated-listener pattern (this site's
|
|
622
|
+
own `header.ks`/`nav.ks` use it exactly this way):
|
|
623
|
+
|
|
624
|
+
```ks
|
|
625
|
+
VElement header = VElement.Create("header");
|
|
626
|
+
header.RawHtml = SiteHeaderHtml; // a raw string ... from "./header.html"; constant
|
|
627
|
+
header.OnClick = (Event e) => { /* one delegated listener over the whole subtree */ };
|
|
628
|
+
```
|
|
629
|
+
|
|
630
|
+
**It's unescaped, real `innerHTML` — never assign it anything reachable from user input.**
|
|
631
|
+
Every real use in this ecosystem is a `raw string ... from "<path>.html";` compile-time
|
|
632
|
+
constant (see KopScript's own docs) — genuinely static markup, baked in at build time,
|
|
633
|
+
never a runtime value. Nothing in the type system stops `el.RawHtml =
|
|
634
|
+
someFetchedString;` or `el.RawHtml = formField.Value.Value;` from compiling — both are a
|
|
635
|
+
real XSS hole if that content is ever attacker-influenced. Use `TextContent` (always
|
|
636
|
+
escaped) for any dynamic string; `RawHtml` is for static markup only.
|
|
637
|
+
|
|
612
638
|
## HTTP
|
|
613
639
|
|
|
614
640
|
```ks
|
|
@@ -904,7 +930,9 @@ npm run build # compiles src/*.ks -> src/*.js (compiled output is gitignored)
|
|
|
904
930
|
npm test # runs test/kopular.test.ts against a real DOM via jsdom
|
|
905
931
|
```
|
|
906
932
|
|
|
907
|
-
`kopscript` is a real published dependency (
|
|
933
|
+
`kopscript` is a real published dependency (see `package.json`'s own `devDependencies` for
|
|
934
|
+
the exact version this repo currently requires — not restated by hand here, since it
|
|
935
|
+
changes far more often than this paragraph does) — this repo doesn't need KopScript
|
|
908
936
|
checked out as a sibling directory or anything else local to build or test.
|
|
909
937
|
|
|
910
938
|
## Status
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "kopular",
|
|
3
|
-
"version": "0.23.
|
|
3
|
+
"version": "0.23.2",
|
|
4
4
|
"description": "Kopular: a small component framework for KopScript — components, reactive state, constructor-injected services, routing, real compiled templates, and HTTP, with no DI container",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
package/src/router.js
CHANGED
|
@@ -116,7 +116,16 @@ export class Router extends Component {
|
|
|
116
116
|
}
|
|
117
117
|
}
|
|
118
118
|
|
|
119
|
-
|
|
119
|
+
NormalizePath(path) {
|
|
120
|
+
if (((path === "/") || !path.endsWith("/"))) {
|
|
121
|
+
return path;
|
|
122
|
+
}
|
|
123
|
+
let segments = path.split("/");
|
|
124
|
+
return segments.slice(0, (segments.length - 1)).join("/");
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
Match(rawFullPath) {
|
|
128
|
+
let fullPath = this.NormalizePath(rawFullPath);
|
|
120
129
|
let effectivePath = fullPath;
|
|
121
130
|
if (!this.Guard(fullPath)) {
|
|
122
131
|
history.pushState("", "", this.RedirectPath);
|
package/src/router.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"router.js","sources":["router.ks"],"sourcesContent":["using \"./dom\";\nusing \"./component\";\nusing \"./velement\";\n\n// History-API routing (real paths — `/about`, not `#/about`) via\n// `history.pushState`/the browser's native `popstate` event. This needs a\n// server that falls back to the app shell for any path it doesn't\n// recognize as a real file (see KopularDemo's scripts/serve.mjs) — a plain\n// static file server has nothing to serve at `/about` on a direct\n// visit/refresh otherwise, unlike hash-based routing, which never leaves\n// the one HTML file the server actually has. Route registration is\n// imperative (`AddRoute` calls), not a config object — one thing to learn,\n// not a routing DSL.\n//\n// AddRoute registers an already-constructed Component; AddLazyRoute\n// registers a loader instead, for real code-splitting (see PageProvider/\n// LazyPage below) — but neither is stored as a raw function value in an\n// array (`Component[]`/`(() => Component)[]`): KopScript's type grammar\n// has no way to write \"array of function type\" in v1 (a parenthesized\n// function type isn't a general grouping construct), so both cases are\n// wrapped in a PageProvider instance instead, and it's `PageProvider[]`\n// that's actually stored. Either way, once a page is built (eagerly at\n// registration, or on first navigation for a lazy one) it's kept alive\n// for the Router's own lifetime, so its own state<T> fields survive\n// navigating away and back — no state gets reset just because a route\n// wasn't showing for a while.\n//\n// No nullable types in v1 means \"no route matched\" can't be represented as\n// null — a `NotFoundPage` is required up front instead, the same way a\n// `match` expression requires its own `_` wildcard arm.\n// A registered route's page, either already built (EagerPage — every\n// route registered via the original AddRoute) or not fetched yet\n// (LazyPage — AddLazyRoute, real code-splitting: the page's own JS chunk\n// is only ever requested the first time its route actually matches, not\n// eagerly with everything else at startup). Router itself only ever\n// touches a route through this interface, never caring which case it is.\ninterface PageProvider {\n // Synchronous: the resolved Component if one is already available (an\n // EagerPage always has one; a LazyPage does once its own Resolve() has\n // completed at least once), or null if not yet loaded. Router's own\n // Render() calls this on every render to decide whether to mount the\n // real page or show a loading placeholder — never triggers a fetch by\n // itself.\n Component? Peek();\n\n // Produces the resolved Component, fetching it if this is the first\n // call (a LazyPage calls its own loader exactly once, ever, caching the\n // result — every later call, on this or a repeat navigation, returns\n // the SAME cached instance, preserving the same \"a page stays alive and\n // keeps its own state across navigations\" guarantee eager pages already\n // have). An EagerPage already has its Component, so this just returns\n // it immediately.\n task<Component> Resolve();\n}\n\nclass EagerPage : PageProvider {\n private Component ThePage;\n constructor(Component page) { this.ThePage = page; }\n public Component? Peek() { return this.ThePage; }\n public async task<Component> Resolve() { return this.ThePage; }\n}\n\nclass LazyPage : PageProvider {\n private () => task<Component> Loader;\n private Component? CachedValue;\n\n constructor(() => task<Component> loader) {\n this.Loader = loader;\n this.CachedValue = null;\n }\n\n public Component? Peek() {\n return this.CachedValue;\n }\n\n public async task<Component> Resolve() {\n Component? maybeCached = this.CachedValue;\n if (maybeCached != null) {\n Component cached = maybeCached;\n return cached;\n }\n Component loaded = await this.Loader();\n this.CachedValue = loaded;\n return loaded;\n }\n}\n\nclass Router : Component {\n private string[] Paths;\n private PageProvider[] Pages;\n private PageProvider NotFoundProvider;\n // Tracks whichever PageProvider is currently being Resolve()d, if any —\n // compared by reference identity so navigating to a SECOND lazy route\n // while a FIRST one is still loading starts loading the second instead\n // of getting stuck waiting on a load nothing will ever re-render for\n // (Peek() will never return non-null for the route actually showing).\n // Reset to null once that specific load finishes.\n private PageProvider? LoadingProvider;\n\n // The FIRST captured dynamic segment's value (e.g. \"42\" for a\n // \"/dogs/:id\" route matching \"/dogs/42\"), or \"\" if the matched route has\n // none — kept exactly as-is (a plain string, not the general Params()\n // lookup below) so every existing single-dynamic-segment consumer keeps\n // working unchanged. For a route with more than one captured segment,\n // use Params(name) instead; this still holds the first one either way.\n //\n // A plain field, deliberately **not** state<T> — Render() below always\n // rebuilds a fresh outlet and re-Mount()s the matched page into it on\n // every Navigate()/popstate, which already re-runs that page's own\n // Render() (reading the fresh Param) with no extra step. Subscribing to\n // it, the way a page reacts to its *own* state<T>, would fire during\n // Match() itself — before Render() has finished swapping the matched page\n // in — which crashes the very first time a route no page has been\n // Mount()ed into yet matches, since a not-yet-mounted Component's\n // inherited Update() has no ParentElement to replaceChild into.\n public string Param;\n\n // Every named dynamic segment captured by whatever route just matched\n // (\":id\"/\":toyId\" for \"/dogs/:id/toys/:toyId\"), plus the pseudo-name \"*\"\n // for a trailing wildcard segment's captured remainder — see Params()\n // below. Parallel arrays (ParamNames[i] <-> ParamValues[i]), the same\n // convention Paths/Pages above already use — not a Dictionary type,\n // since KopScript has none.\n private string[] ParamNames;\n private string[] ParamValues;\n\n // The current URL's query string, parsed once per navigation/popstate —\n // see Query() below. Never part of route *matching* itself in v1: every\n // route sees whatever query string is actually in the URL, matched or\n // not. Values are NOT percent-decoded (a deliberate v1 cut — no\n // decodeURIComponent binding exists yet); a value containing `%20` or\n // `+` for a space arrives exactly as written in the URL.\n private string[] QueryKeys;\n private string[] QueryValues;\n\n // Checked before every navigation, including a direct load/refresh — not\n // just Navigate()/popstate. Deliberately one guard for the whole Router,\n // not per-route: the guard function itself decides which paths it cares\n // about (typically `path.StartsWith(\"/admin\")`-style checks), the same\n // \"no config object, just a function\" style Http/DI already use, rather\n // than a parallel array of per-route guards (which also can't be written\n // as a type anyway — see AddRoute's own comment on array-of-function\n // types). Defaults to always-allow, not null — a nullable function TYPE\n // has the same \"can't parenthesize for a postfix `?`\" problem as an\n // array of one, so \"no guard configured\" is a real function that always\n // returns true, not a null check.\n private (string) => bool Guard;\n private string RedirectPath;\n\n constructor(Component notFoundPage) : base() {\n this.Paths = [];\n this.Pages = [];\n this.NotFoundProvider = new EagerPage(notFoundPage);\n this.LoadingProvider = null;\n this.Param = \"\";\n this.ParamNames = [];\n this.ParamValues = [];\n this.QueryKeys = [];\n this.QueryValues = [];\n this.Guard = (string path) => true;\n this.RedirectPath = \"\";\n // 'popstate' only fires on browser back/forward (or history.go/back/\n // forward) — never on pushState itself, unlike hashchange firing\n // whenever location.hash is set. Navigate() below calls Update()\n // directly for that reason; this listener only covers the back/forward\n // case, where the URL changes without any of our own code running.\n window.addEventListener(\"popstate\", (Event e) => this.Update());\n }\n\n // A path segment written \":name\" (e.g. \"dogs/:id\") matches any single\n // non-empty segment; every other segment must match literally, except a\n // trailing \"*\" segment (e.g. \"files/*\"), which matches one or more\n // remaining segments as a single captured group (see Params(\"*\")).\n // Comparing segment-by-segment (rather than the whole string at once) is\n // what makes a static route's own matching still exactly as strict as a\n // plain `==` was before — same segment count, same literal text\n // throughout (a trailing \"*\" route aside, which only ever needs *at\n // least* as many path segments as its static prefix).\n public void AddRoute(string path, Component page) {\n this.Paths = this.Paths.Push(path);\n this.Pages = this.Pages.Push(new EagerPage(page));\n }\n\n // Real code-splitting: `loader` is a hand-written JS shim's dynamic\n // `import()`, reached via `extern task<Component> Load...() from\n // \"./loader\";` (see kopscript's own LLM.md/README.md \"extern\" section)\n // — its target module is only ever fetched the first time this route\n // actually matches, not eagerly with every other page at startup. The\n // loaded page still stays alive across navigations exactly like an\n // eager one does (LazyPage caches it after the first Resolve()); only\n // the FIRST time this route is visited pays the fetch cost.\n public void AddLazyRoute(string path, () => task<Component> loader) {\n this.Paths = this.Paths.Push(path);\n this.Pages = this.Pages.Push(new LazyPage(loader));\n }\n\n // Every registered path, in `AddRoute`/`AddLazyRoute` registration order\n // — for a caller that needs to enumerate real routes instead of hand-\n // maintaining a second, driftable list (a build-time static-prerender\n // step, most likely). A copy, not the live backing array — Paths is\n // never mutated after registration in practice, but returning the field\n // directly would let a caller do so anyway.\n public string[] AllPaths() {\n return this.Paths.Slice(0, this.Paths.Length);\n }\n\n // `guard` is called with the path being navigated to; returning false\n // redirects to `redirectPath` instead (updating the URL via pushState, so\n // a refresh on the blocked path lands on the redirect too, not back on\n // the page the guard just rejected). `redirectPath` itself is never\n // guard-checked — pick one the guard always allows, or it'll loop.\n public void SetGuard(string redirectPath, (string) => bool guard) {\n this.RedirectPath = redirectPath;\n this.Guard = guard;\n }\n\n public void Navigate(string path) {\n history.pushState(\"\", \"\", path);\n this.Update();\n }\n\n // The captured value for a named dynamic segment (e.g. \"toyId\" for\n // \":toyId\" in \"/dogs/:id/toys/:toyId\") or \"*\" for a trailing wildcard\n // segment's matched remainder — \"\" if `name` wasn't captured by whatever\n // route just matched, the same \"empty string, not null\" convention\n // `Param` itself already uses.\n public string Params(string name) {\n for (number i = 0; i < this.ParamNames.Length; i = i + 1) {\n if (this.ParamNames[i] == name) {\n return this.ParamValues[i];\n }\n }\n return \"\";\n }\n\n // The current URL's query-string value for `key` (e.g. \"name\" for\n // \"?sort=name\") — \"\" if `key` isn't present. See QueryKeys/QueryValues'\n // own comment for the percent-decoding cut.\n public string Query(string key) {\n for (number i = 0; i < this.QueryKeys.Length; i = i + 1) {\n if (this.QueryKeys[i] == key) {\n return this.QueryValues[i];\n }\n }\n return \"\";\n }\n\n // KopScript strings have no index-based substring/slice method (only\n // arrays do — see Array.Slice), so splitting on the literal separator and\n // taking the piece after it is the idiomatic way to strip a known\n // single-character prefix here, both for \"?\" (query string) and \":\"\n // (a pattern segment's own leading marker, used below in Match).\n private void ParseQuery(string pathWithQuery) {\n this.QueryKeys = [];\n this.QueryValues = [];\n string[] afterMark = pathWithQuery.Split(\"?\");\n if (afterMark.Length < 2) { return; }\n string rawQuery = afterMark[1];\n if (rawQuery.Length == 0) { return; }\n string[] pairs = rawQuery.Split(\"&\");\n foreach (string pair in pairs) {\n string[] kv = pair.Split(\"=\");\n if (kv.Length == 2) {\n this.QueryKeys = this.QueryKeys.Push(kv[0]);\n this.QueryValues = this.QueryValues.Push(kv[1]);\n }\n }\n }\n\n private PageProvider Match(string fullPath) {\n string effectivePath = fullPath;\n if (!this.Guard(fullPath)) {\n history.pushState(\"\", \"\", this.RedirectPath);\n effectivePath = this.RedirectPath;\n }\n\n this.ParseQuery(effectivePath);\n string[] pathAndQuery = effectivePath.Split(\"?\");\n string[] pathSegments = pathAndQuery[0].Split(\"/\");\n\n PageProvider found = this.NotFoundProvider;\n string[] foundNames = [];\n string[] foundValues = [];\n\n for (number i = 0; i < this.Paths.Length; i = i + 1) {\n string[] patternSegments = this.Paths[i].Split(\"/\");\n bool hasWildcard = patternSegments.Length > 0 && patternSegments[patternSegments.Length - 1] == \"*\";\n\n number staticCount = patternSegments.Length;\n if (hasWildcard) {\n staticCount = patternSegments.Length - 1;\n }\n\n if (hasWildcard) {\n if (pathSegments.Length < staticCount) { continue; }\n } else {\n if (patternSegments.Length != pathSegments.Length) { continue; }\n }\n\n bool matched = true;\n string[] names = [];\n string[] values = [];\n for (number j = 0; j < staticCount; j = j + 1) {\n if (patternSegments[j].StartsWith(\":\")) {\n names = names.Push(patternSegments[j].Split(\":\")[1]);\n values = values.Push(pathSegments[j]);\n } else if (patternSegments[j] != pathSegments[j]) {\n matched = false;\n break;\n }\n }\n\n if (matched && hasWildcard) {\n names = names.Push(\"*\");\n values = values.Push(pathSegments.Slice(staticCount, pathSegments.Length).Join(\"/\"));\n }\n\n if (matched) {\n found = this.Pages[i];\n foundNames = names;\n foundValues = values;\n break;\n }\n }\n\n this.ParamNames = foundNames;\n this.ParamValues = foundValues;\n this.Param = \"\";\n if (foundValues.Length > 0) {\n this.Param = foundValues[0];\n }\n return found;\n }\n\n // The outlet itself is a plain, unchanging `<div>` — its tag/className\n // never change between navigations, so Component's own diffing (see\n // vdom.ks) reuses the exact same real outlet element across every\n // navigation, rather than rebuilding it. What goes *inside* it (the\n // matched page) is embedded via VElement.Mount — a single child, so it\n // needs no .Id (there's never more than one to key against; positional\n // matching is unambiguous). Navigating to a DIFFERENT page than last\n // time is a different Mountable instance in the slot, so Patch() tears\n // the old one down (Teardown() — its OnUnmount fires, IsMounted resets)\n // and mounts the new one fresh — matching this class's original\n // behavior exactly (every navigation always freshly Mount()s the\n // matched page; a page's own re-renders, once mounted, still go through\n // the normal diffed Update() path when its own state changes). The one\n // real change from before: a page navigated AWAY from now actually gets\n // torn down (OnUnmount fires) instead of being silently abandoned with\n // its DOM ripped out from under it and IsMounted left permanently true.\n public override VElement Render() {\n string path = location.pathname + location.search;\n PageProvider provider = this.Match(path);\n\n VElement outlet = VElement.Create(\"div\");\n outlet.ClassName = \"router-outlet\";\n\n Component? maybeResolved = provider.Peek();\n if (maybeResolved != null) {\n Component resolved = maybeResolved;\n outlet.AppendChild(VElement.Mount(resolved));\n } else {\n bool alreadyLoadingThisProvider = false;\n PageProvider? maybeLoadingProvider = this.LoadingProvider;\n if (maybeLoadingProvider != null) {\n PageProvider loadingProvider = maybeLoadingProvider;\n if (loadingProvider == provider) {\n alreadyLoadingThisProvider = true;\n }\n }\n if (!alreadyLoadingThisProvider) {\n this.LoadingProvider = provider;\n this.LoadThenUpdate(provider);\n }\n outlet.AppendChild(this.BuildLoadingPlaceholder());\n }\n return outlet;\n }\n\n // Fire-and-forget from Render()'s own point of view — there's no way to\n // construct a task value outside an async function body to await it\n // there instead (see kopscript's own LLM.md \"Async\" docs), the same\n // reason Component's own OnUnmount-cascading Teardown() isn't awaited\n // by its own caller either. Calls this.Update() once the fetch actually\n // lands, so Render() runs again and this time provider.Peek() returns\n // the now-cached page instead of null.\n private async task LoadThenUpdate(PageProvider provider) {\n await provider.Resolve();\n this.LoadingProvider = null;\n this.Update();\n }\n\n // Shown in the outlet while a lazily-loaded route's own page is still\n // being fetched (AddLazyRoute) — never shown for an eager route, which\n // always has a Component to Peek() immediately. Override to customize;\n // default is a plain, unstyled placeholder so a real app always sees\n // SOMETHING rather than a silently blank outlet while a chunk loads.\n protected virtual VElement BuildLoadingPlaceholder() {\n VElement div = VElement.Create(\"div\");\n div.ClassName = \"router-loading\";\n div.TextContent = \"Loading...\";\n return div;\n }\n}\n"],"names":[],"mappings":";;;;AAuDA;EAEE;IAA2C;;;EACpC;IAAoB;;;EACd;IAA4B;;;AAG3C;EAIE;IACc;IACK;;;EAGZ;IACL;;;EAGW;IACX;IACA;MACE;MACA;;IAEF;IACiB;IACjB;;;AAIJ;EA8DE;;IACa;IACA;IACW;IACD;IACV;IACK;IACC;IACF;IACE;IACN;IACO;IAMK;;;EAYlB;IACM;IACA;;;EAWN;IACM;IACA;;;EASN;IACL;;;EAQK;IACa;IACP;;;EAGN;IACY;IACN;;;EAQN;IACL;MACE;QACE;;;IAGJ;;;EAMK;IACL;MACE;QACE;;;IAGJ;;;EAQM;IACS;IACE;IACjB;IACA;MAA4B;;IAC5B;IACA;MAA4B;;IAC5B;IACA;MACE;MACA;QACiB;QACE;;;;;EAKf;IACN;IACA;MACmB;MACH;;IAGD;IACf;IACA;IAEA;IACA;IACA;IAEA;MACE;MACA;MAEA;MACA;QACc;;MAGd;QACE;UAAyC;;;QAEzC;UAAqD;;;MAGvD;MACA;MACA;MACA;QACE;UACQ;UACC;;UAEC;UACR;;;MAIJ;QACQ;QACC;;MAGT;QACQ;QACK;QACC;QACZ;;;IAIY;IACC;IACN;IACX;MACa;;IAEb;;;EAmBc;IACd;IACA;IAEA;IACiB;IAEjB;IACA;MACE;MACkB;;MAElB;MACA;MACA;QACE;QACA;UAC6B;;;MAG/B;QACuB;QACF;;MAEH;;IAEpB;;;EAUY;IACZ;IACqB;IACV;;;EAQK;IAChB;IACc;IACE;IAChB"}
|
|
1
|
+
{"version":3,"file":"router.js","sources":["router.ks"],"sourcesContent":["using \"./dom\";\nusing \"./component\";\nusing \"./velement\";\n\n// History-API routing (real paths — `/about`, not `#/about`) via\n// `history.pushState`/the browser's native `popstate` event. This needs a\n// server that falls back to the app shell for any path it doesn't\n// recognize as a real file (see KopularDemo's scripts/serve.mjs) — a plain\n// static file server has nothing to serve at `/about` on a direct\n// visit/refresh otherwise, unlike hash-based routing, which never leaves\n// the one HTML file the server actually has. Route registration is\n// imperative (`AddRoute` calls), not a config object — one thing to learn,\n// not a routing DSL.\n//\n// AddRoute registers an already-constructed Component; AddLazyRoute\n// registers a loader instead, for real code-splitting (see PageProvider/\n// LazyPage below) — but neither is stored as a raw function value in an\n// array (`Component[]`/`(() => Component)[]`): KopScript's type grammar\n// has no way to write \"array of function type\" in v1 (a parenthesized\n// function type isn't a general grouping construct), so both cases are\n// wrapped in a PageProvider instance instead, and it's `PageProvider[]`\n// that's actually stored. Either way, once a page is built (eagerly at\n// registration, or on first navigation for a lazy one) it's kept alive\n// for the Router's own lifetime, so its own state<T> fields survive\n// navigating away and back — no state gets reset just because a route\n// wasn't showing for a while.\n//\n// No nullable types in v1 means \"no route matched\" can't be represented as\n// null — a `NotFoundPage` is required up front instead, the same way a\n// `match` expression requires its own `_` wildcard arm.\n// A registered route's page, either already built (EagerPage — every\n// route registered via the original AddRoute) or not fetched yet\n// (LazyPage — AddLazyRoute, real code-splitting: the page's own JS chunk\n// is only ever requested the first time its route actually matches, not\n// eagerly with everything else at startup). Router itself only ever\n// touches a route through this interface, never caring which case it is.\ninterface PageProvider {\n // Synchronous: the resolved Component if one is already available (an\n // EagerPage always has one; a LazyPage does once its own Resolve() has\n // completed at least once), or null if not yet loaded. Router's own\n // Render() calls this on every render to decide whether to mount the\n // real page or show a loading placeholder — never triggers a fetch by\n // itself.\n Component? Peek();\n\n // Produces the resolved Component, fetching it if this is the first\n // call (a LazyPage calls its own loader exactly once, ever, caching the\n // result — every later call, on this or a repeat navigation, returns\n // the SAME cached instance, preserving the same \"a page stays alive and\n // keeps its own state across navigations\" guarantee eager pages already\n // have). An EagerPage already has its Component, so this just returns\n // it immediately.\n task<Component> Resolve();\n}\n\nclass EagerPage : PageProvider {\n private Component ThePage;\n constructor(Component page) { this.ThePage = page; }\n public Component? Peek() { return this.ThePage; }\n public async task<Component> Resolve() { return this.ThePage; }\n}\n\nclass LazyPage : PageProvider {\n private () => task<Component> Loader;\n private Component? CachedValue;\n\n constructor(() => task<Component> loader) {\n this.Loader = loader;\n this.CachedValue = null;\n }\n\n public Component? Peek() {\n return this.CachedValue;\n }\n\n public async task<Component> Resolve() {\n Component? maybeCached = this.CachedValue;\n if (maybeCached != null) {\n Component cached = maybeCached;\n return cached;\n }\n Component loaded = await this.Loader();\n this.CachedValue = loaded;\n return loaded;\n }\n}\n\nclass Router : Component {\n private string[] Paths;\n private PageProvider[] Pages;\n private PageProvider NotFoundProvider;\n // Tracks whichever PageProvider is currently being Resolve()d, if any —\n // compared by reference identity so navigating to a SECOND lazy route\n // while a FIRST one is still loading starts loading the second instead\n // of getting stuck waiting on a load nothing will ever re-render for\n // (Peek() will never return non-null for the route actually showing).\n // Reset to null once that specific load finishes.\n private PageProvider? LoadingProvider;\n\n // The FIRST captured dynamic segment's value (e.g. \"42\" for a\n // \"/dogs/:id\" route matching \"/dogs/42\"), or \"\" if the matched route has\n // none — kept exactly as-is (a plain string, not the general Params()\n // lookup below) so every existing single-dynamic-segment consumer keeps\n // working unchanged. For a route with more than one captured segment,\n // use Params(name) instead; this still holds the first one either way.\n //\n // A plain field, deliberately **not** state<T> — Render() below always\n // rebuilds a fresh outlet and re-Mount()s the matched page into it on\n // every Navigate()/popstate, which already re-runs that page's own\n // Render() (reading the fresh Param) with no extra step. Subscribing to\n // it, the way a page reacts to its *own* state<T>, would fire during\n // Match() itself — before Render() has finished swapping the matched page\n // in — which crashes the very first time a route no page has been\n // Mount()ed into yet matches, since a not-yet-mounted Component's\n // inherited Update() has no ParentElement to replaceChild into.\n public string Param;\n\n // Every named dynamic segment captured by whatever route just matched\n // (\":id\"/\":toyId\" for \"/dogs/:id/toys/:toyId\"), plus the pseudo-name \"*\"\n // for a trailing wildcard segment's captured remainder — see Params()\n // below. Parallel arrays (ParamNames[i] <-> ParamValues[i]), the same\n // convention Paths/Pages above already use — not a Dictionary type,\n // since KopScript has none.\n private string[] ParamNames;\n private string[] ParamValues;\n\n // The current URL's query string, parsed once per navigation/popstate —\n // see Query() below. Never part of route *matching* itself in v1: every\n // route sees whatever query string is actually in the URL, matched or\n // not. Values are NOT percent-decoded (a deliberate v1 cut — no\n // decodeURIComponent binding exists yet); a value containing `%20` or\n // `+` for a space arrives exactly as written in the URL.\n private string[] QueryKeys;\n private string[] QueryValues;\n\n // Checked before every navigation, including a direct load/refresh — not\n // just Navigate()/popstate. Deliberately one guard for the whole Router,\n // not per-route: the guard function itself decides which paths it cares\n // about (typically `path.StartsWith(\"/admin\")`-style checks), the same\n // \"no config object, just a function\" style Http/DI already use, rather\n // than a parallel array of per-route guards (which also can't be written\n // as a type anyway — see AddRoute's own comment on array-of-function\n // types). Defaults to always-allow, not null — a nullable function TYPE\n // has the same \"can't parenthesize for a postfix `?`\" problem as an\n // array of one, so \"no guard configured\" is a real function that always\n // returns true, not a null check.\n private (string) => bool Guard;\n private string RedirectPath;\n\n constructor(Component notFoundPage) : base() {\n this.Paths = [];\n this.Pages = [];\n this.NotFoundProvider = new EagerPage(notFoundPage);\n this.LoadingProvider = null;\n this.Param = \"\";\n this.ParamNames = [];\n this.ParamValues = [];\n this.QueryKeys = [];\n this.QueryValues = [];\n this.Guard = (string path) => true;\n this.RedirectPath = \"\";\n // 'popstate' only fires on browser back/forward (or history.go/back/\n // forward) — never on pushState itself, unlike hashchange firing\n // whenever location.hash is set. Navigate() below calls Update()\n // directly for that reason; this listener only covers the back/forward\n // case, where the URL changes without any of our own code running.\n window.addEventListener(\"popstate\", (Event e) => this.Update());\n }\n\n // A path segment written \":name\" (e.g. \"dogs/:id\") matches any single\n // non-empty segment; every other segment must match literally, except a\n // trailing \"*\" segment (e.g. \"files/*\"), which matches one or more\n // remaining segments as a single captured group (see Params(\"*\")).\n // Comparing segment-by-segment (rather than the whole string at once) is\n // what makes a static route's own matching still exactly as strict as a\n // plain `==` was before — same segment count, same literal text\n // throughout (a trailing \"*\" route aside, which only ever needs *at\n // least* as many path segments as its static prefix).\n public void AddRoute(string path, Component page) {\n this.Paths = this.Paths.Push(path);\n this.Pages = this.Pages.Push(new EagerPage(page));\n }\n\n // Real code-splitting: `loader` is a hand-written JS shim's dynamic\n // `import()`, reached via `extern task<Component> Load...() from\n // \"./loader\";` (see kopscript's own LLM.md/README.md \"extern\" section)\n // — its target module is only ever fetched the first time this route\n // actually matches, not eagerly with every other page at startup. The\n // loaded page still stays alive across navigations exactly like an\n // eager one does (LazyPage caches it after the first Resolve()); only\n // the FIRST time this route is visited pays the fetch cost.\n public void AddLazyRoute(string path, () => task<Component> loader) {\n this.Paths = this.Paths.Push(path);\n this.Pages = this.Pages.Push(new LazyPage(loader));\n }\n\n // Every registered path, in `AddRoute`/`AddLazyRoute` registration order\n // — for a caller that needs to enumerate real routes instead of hand-\n // maintaining a second, driftable list (a build-time static-prerender\n // step, most likely). A copy, not the live backing array — Paths is\n // never mutated after registration in practice, but returning the field\n // directly would let a caller do so anyway.\n public string[] AllPaths() {\n return this.Paths.Slice(0, this.Paths.Length);\n }\n\n // `guard` is called with the path being navigated to; returning false\n // redirects to `redirectPath` instead (updating the URL via pushState, so\n // a refresh on the blocked path lands on the redirect too, not back on\n // the page the guard just rejected). `redirectPath` itself is never\n // guard-checked — pick one the guard always allows, or it'll loop.\n public void SetGuard(string redirectPath, (string) => bool guard) {\n this.RedirectPath = redirectPath;\n this.Guard = guard;\n }\n\n public void Navigate(string path) {\n history.pushState(\"\", \"\", path);\n this.Update();\n }\n\n // The captured value for a named dynamic segment (e.g. \"toyId\" for\n // \":toyId\" in \"/dogs/:id/toys/:toyId\") or \"*\" for a trailing wildcard\n // segment's matched remainder — \"\" if `name` wasn't captured by whatever\n // route just matched, the same \"empty string, not null\" convention\n // `Param` itself already uses.\n public string Params(string name) {\n for (number i = 0; i < this.ParamNames.Length; i = i + 1) {\n if (this.ParamNames[i] == name) {\n return this.ParamValues[i];\n }\n }\n return \"\";\n }\n\n // The current URL's query-string value for `key` (e.g. \"name\" for\n // \"?sort=name\") — \"\" if `key` isn't present. See QueryKeys/QueryValues'\n // own comment for the percent-decoding cut.\n public string Query(string key) {\n for (number i = 0; i < this.QueryKeys.Length; i = i + 1) {\n if (this.QueryKeys[i] == key) {\n return this.QueryValues[i];\n }\n }\n return \"\";\n }\n\n // KopScript strings have no index-based substring/slice method (only\n // arrays do — see Array.Slice), so splitting on the literal separator and\n // taking the piece after it is the idiomatic way to strip a known\n // single-character prefix here, both for \"?\" (query string) and \":\"\n // (a pattern segment's own leading marker, used below in Match).\n private void ParseQuery(string pathWithQuery) {\n this.QueryKeys = [];\n this.QueryValues = [];\n string[] afterMark = pathWithQuery.Split(\"?\");\n if (afterMark.Length < 2) { return; }\n string rawQuery = afterMark[1];\n if (rawQuery.Length == 0) { return; }\n string[] pairs = rawQuery.Split(\"&\");\n foreach (string pair in pairs) {\n string[] kv = pair.Split(\"=\");\n if (kv.Length == 2) {\n this.QueryKeys = this.QueryKeys.Push(kv[0]);\n this.QueryValues = this.QueryValues.Push(kv[1]);\n }\n }\n }\n\n // A trailing slash is the same route as without one (`/about/` ==\n // `/about`) — every mainstream router normalizes this, and real\n // real-world links/directory-style static hosting (a build-time\n // prerendered route's own `/about/index.html`, most concretely) produce\n // trailing-slash URLs routinely. `\"/\"` itself is left alone — stripping\n // its own trailing slash would leave an empty string, changing what\n // matches the root route rather than normalizing it. Strings have no\n // substring/slice method (see kopscript's own LLM.md \"Does not exist\"),\n // so this rebuilds via the array methods that do exist instead of\n // trimming the string directly.\n private string NormalizePath(string path) {\n if (path == \"/\" || !path.EndsWith(\"/\")) {\n return path;\n }\n string[] segments = path.Split(\"/\");\n return segments.Slice(0, segments.Length - 1).Join(\"/\");\n }\n\n private PageProvider Match(string rawFullPath) {\n string fullPath = this.NormalizePath(rawFullPath);\n string effectivePath = fullPath;\n if (!this.Guard(fullPath)) {\n history.pushState(\"\", \"\", this.RedirectPath);\n effectivePath = this.RedirectPath;\n }\n\n this.ParseQuery(effectivePath);\n string[] pathAndQuery = effectivePath.Split(\"?\");\n string[] pathSegments = pathAndQuery[0].Split(\"/\");\n\n PageProvider found = this.NotFoundProvider;\n string[] foundNames = [];\n string[] foundValues = [];\n\n for (number i = 0; i < this.Paths.Length; i = i + 1) {\n string[] patternSegments = this.Paths[i].Split(\"/\");\n bool hasWildcard = patternSegments.Length > 0 && patternSegments[patternSegments.Length - 1] == \"*\";\n\n number staticCount = patternSegments.Length;\n if (hasWildcard) {\n staticCount = patternSegments.Length - 1;\n }\n\n if (hasWildcard) {\n if (pathSegments.Length < staticCount) { continue; }\n } else {\n if (patternSegments.Length != pathSegments.Length) { continue; }\n }\n\n bool matched = true;\n string[] names = [];\n string[] values = [];\n for (number j = 0; j < staticCount; j = j + 1) {\n if (patternSegments[j].StartsWith(\":\")) {\n names = names.Push(patternSegments[j].Split(\":\")[1]);\n values = values.Push(pathSegments[j]);\n } else if (patternSegments[j] != pathSegments[j]) {\n matched = false;\n break;\n }\n }\n\n if (matched && hasWildcard) {\n names = names.Push(\"*\");\n values = values.Push(pathSegments.Slice(staticCount, pathSegments.Length).Join(\"/\"));\n }\n\n if (matched) {\n found = this.Pages[i];\n foundNames = names;\n foundValues = values;\n break;\n }\n }\n\n this.ParamNames = foundNames;\n this.ParamValues = foundValues;\n this.Param = \"\";\n if (foundValues.Length > 0) {\n this.Param = foundValues[0];\n }\n return found;\n }\n\n // The outlet itself is a plain, unchanging `<div>` — its tag/className\n // never change between navigations, so Component's own diffing (see\n // vdom.ks) reuses the exact same real outlet element across every\n // navigation, rather than rebuilding it. What goes *inside* it (the\n // matched page) is embedded via VElement.Mount — a single child, so it\n // needs no .Id (there's never more than one to key against; positional\n // matching is unambiguous). Navigating to a DIFFERENT page than last\n // time is a different Mountable instance in the slot, so Patch() tears\n // the old one down (Teardown() — its OnUnmount fires, IsMounted resets)\n // and mounts the new one fresh — matching this class's original\n // behavior exactly (every navigation always freshly Mount()s the\n // matched page; a page's own re-renders, once mounted, still go through\n // the normal diffed Update() path when its own state changes). The one\n // real change from before: a page navigated AWAY from now actually gets\n // torn down (OnUnmount fires) instead of being silently abandoned with\n // its DOM ripped out from under it and IsMounted left permanently true.\n public override VElement Render() {\n string path = location.pathname + location.search;\n PageProvider provider = this.Match(path);\n\n VElement outlet = VElement.Create(\"div\");\n outlet.ClassName = \"router-outlet\";\n\n Component? maybeResolved = provider.Peek();\n if (maybeResolved != null) {\n Component resolved = maybeResolved;\n outlet.AppendChild(VElement.Mount(resolved));\n } else {\n bool alreadyLoadingThisProvider = false;\n PageProvider? maybeLoadingProvider = this.LoadingProvider;\n if (maybeLoadingProvider != null) {\n PageProvider loadingProvider = maybeLoadingProvider;\n if (loadingProvider == provider) {\n alreadyLoadingThisProvider = true;\n }\n }\n if (!alreadyLoadingThisProvider) {\n this.LoadingProvider = provider;\n this.LoadThenUpdate(provider);\n }\n outlet.AppendChild(this.BuildLoadingPlaceholder());\n }\n return outlet;\n }\n\n // Fire-and-forget from Render()'s own point of view — there's no way to\n // construct a task value outside an async function body to await it\n // there instead (see kopscript's own LLM.md \"Async\" docs), the same\n // reason Component's own OnUnmount-cascading Teardown() isn't awaited\n // by its own caller either. Calls this.Update() once the fetch actually\n // lands, so Render() runs again and this time provider.Peek() returns\n // the now-cached page instead of null.\n private async task LoadThenUpdate(PageProvider provider) {\n await provider.Resolve();\n this.LoadingProvider = null;\n this.Update();\n }\n\n // Shown in the outlet while a lazily-loaded route's own page is still\n // being fetched (AddLazyRoute) — never shown for an eager route, which\n // always has a Component to Peek() immediately. Override to customize;\n // default is a plain, unstyled placeholder so a real app always sees\n // SOMETHING rather than a silently blank outlet while a chunk loads.\n protected virtual VElement BuildLoadingPlaceholder() {\n VElement div = VElement.Create(\"div\");\n div.ClassName = \"router-loading\";\n div.TextContent = \"Loading...\";\n return div;\n }\n}\n"],"names":[],"mappings":";;;;AAuDA;EAEE;IAA2C;;;EACpC;IAAoB;;;EACd;IAA4B;;;AAG3C;EAIE;IACc;IACK;;;EAGZ;IACL;;;EAGW;IACX;IACA;MACE;MACA;;IAEF;IACiB;IACjB;;;AAIJ;EA8DE;;IACa;IACA;IACW;IACD;IACV;IACK;IACC;IACF;IACE;IACN;IACO;IAMK;;;EAYlB;IACM;IACA;;;EAWN;IACM;IACA;;;EASN;IACL;;;EAQK;IACa;IACP;;;EAGN;IACY;IACN;;;EAQN;IACL;MACE;QACE;;;IAGJ;;;EAMK;IACL;MACE;QACE;;;IAGJ;;;EAQM;IACS;IACE;IACjB;IACA;MAA4B;;IAC5B;IACA;MAA4B;;IAC5B;IACA;MACE;MACA;QACiB;QACE;;;;;EAef;IACN;MACE;;IAEF;IACA;;;EAGM;IACN;IACA;IACA;MACmB;MACH;;IAGD;IACf;IACA;IAEA;IACA;IACA;IAEA;MACE;MACA;MAEA;MACA;QACc;;MAGd;QACE;UAAyC;;;QAEzC;UAAqD;;;MAGvD;MACA;MACA;MACA;QACE;UACQ;UACC;;UAEC;UACR;;;MAIJ;QACQ;QACC;;MAGT;QACQ;QACK;QACC;QACZ;;;IAIY;IACC;IACN;IACX;MACa;;IAEb;;;EAmBc;IACd;IACA;IAEA;IACiB;IAEjB;IACA;MACE;MACkB;;MAElB;MACA;MACA;QACE;QACA;UAC6B;;;MAG/B;QACuB;QACF;;MAEH;;IAEpB;;;EAUY;IACZ;IACqB;IACV;;;EAQK;IAChB;IACc;IACE;IAChB"}
|
package/src/router.ks
CHANGED
|
@@ -267,7 +267,26 @@ class Router : Component {
|
|
|
267
267
|
}
|
|
268
268
|
}
|
|
269
269
|
|
|
270
|
-
|
|
270
|
+
// A trailing slash is the same route as without one (`/about/` ==
|
|
271
|
+
// `/about`) — every mainstream router normalizes this, and real
|
|
272
|
+
// real-world links/directory-style static hosting (a build-time
|
|
273
|
+
// prerendered route's own `/about/index.html`, most concretely) produce
|
|
274
|
+
// trailing-slash URLs routinely. `"/"` itself is left alone — stripping
|
|
275
|
+
// its own trailing slash would leave an empty string, changing what
|
|
276
|
+
// matches the root route rather than normalizing it. Strings have no
|
|
277
|
+
// substring/slice method (see kopscript's own LLM.md "Does not exist"),
|
|
278
|
+
// so this rebuilds via the array methods that do exist instead of
|
|
279
|
+
// trimming the string directly.
|
|
280
|
+
private string NormalizePath(string path) {
|
|
281
|
+
if (path == "/" || !path.EndsWith("/")) {
|
|
282
|
+
return path;
|
|
283
|
+
}
|
|
284
|
+
string[] segments = path.Split("/");
|
|
285
|
+
return segments.Slice(0, segments.Length - 1).Join("/");
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
private PageProvider Match(string rawFullPath) {
|
|
289
|
+
string fullPath = this.NormalizePath(rawFullPath);
|
|
271
290
|
string effectivePath = fullPath;
|
|
272
291
|
if (!this.Guard(fullPath)) {
|
|
273
292
|
history.pushState("", "", this.RedirectPath);
|