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 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/testing`
13
- (runKopularApp, runKopularFixture see below). `kopular/vdom` (the diff/patch engine
14
- behind `Update()`) is internal nothing outside `component.ks` needs to import it
15
- directly. Also ships a bin, `kp` `npx kp new
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
- **`Element`/`Document`/`Event` are NOT part of Kopular's own exports** — they're plain
28
- ambient browser globals (`extern class Element { ... };`, no `from` clause), genuinely
29
- present at runtime with no import needed, but Kopular has no re-exportable copy of them
30
- to reach for: `kopular/dom` is Kopular's own *internal* ambient binding, itself made of
31
- erased `extern` declarations with nothing real behind them at runtime, so `extern class
32
- Element { ... } from "kopular/dom";` doesn't work — there's no `Element` symbol actually
33
- living in that module to bind to. Every consuming project re-declares its own
34
- Element/Document/Event, same as this one does. **Copy the block below rather than
35
- hand-rolling a smaller one from scratch and adding members as compile errors demand
36
- them** a real, complete first-attempt implementation of a Kopular app hit the exact
37
- same missing property (`Element.value`) twice from two independently-trimmed subsets,
38
- because the compile error only ever names the one member actually touched, never warns
39
- that a *sibling* feature (a template's `placeholder="..."` attribute, a `[(value)]`
40
- binding's generated `e.target.value` read) will need one you didn't happen to write by
41
- hand:
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), the four fixed named
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 structural directive per element.
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; `extraFiles` is only for a real dependency with
709
- some OTHER extension (e.g. a `.json` config file). `runKopularFixture(source, options)` is the sibling export for an inline fixture
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
- (`FormField<T>`/`Validators` and `kp new` see "FormField<T> / Validators" and "Starting
824
- a new project" above are real, shipped features; they used to be listed here as gaps
825
- before those landed and this list wasn't updated at the time. Leaving this parenthetical
826
- rather than quietly deleting it, as a reminder to keep this list in sync going forward.)
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 (`^0.1.0`) this repo doesn't need KopScript
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.0",
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
- Match(fullPath) {
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
- private PageProvider Match(string fullPath) {
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);