kopular 0.23.0 → 0.23.1

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
@@ -447,6 +447,11 @@ nav.Navigate("/about"); // pushState + immediate re-ren
447
447
  - `Navigate(path)` calls `history.pushState` then re-renders immediately. A browser
448
448
  back/forward triggers re-render via a `popstate` listener registered in the
449
449
  constructor — `Navigate()` itself doesn't rely on that event.
450
+ - **A trailing slash normalizes to the same route as without one** (`/about/` == `/about`;
451
+ `/` itself is left alone, not stripped to `""`) — matches every mainstream router's own
452
+ default, and matters for real: a build-time prerendered static route's own
453
+ `<path>/index.html` (see KopularDemo's `scripts/prerender.mjs`) is naturally reached via
454
+ a trailing-slash URL.
450
455
  - **Dynamic route segments**: a pattern segment written `:name` (e.g. `AddRoute("/dogs/:id",
451
456
  ...)`) matches any single non-empty path segment; every other segment must match
452
457
  literally (same segment count required too — `/dogs` does NOT match `/dogs/:id`, except
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`:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kopular",
3
- "version": "0.23.0",
3
+ "version": "0.23.1",
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);