kopular 0.11.5 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LLM.md CHANGED
@@ -220,11 +220,24 @@ nav.Navigate("/about"); // pushState + immediate re-ren
220
220
  constructor — `Navigate()` itself doesn't rely on that event.
221
221
  - **Dynamic route segments**: a pattern segment written `:name` (e.g. `AddRoute("/dogs/:id",
222
222
  ...)`) matches any single non-empty path segment; every other segment must match
223
- literally (same segment count required too — `/dogs` does NOT match `/dogs/:id`).
224
- The captured value is `Router.Param` a plain `string` field (**not** `state<T>`; no
225
- `Subscribe()` needed see below), `""` when the matched route has no `:` segment.
226
- Just one dynamic segment per route in v1: no `/dogs/:id/toys/:toyId`, no wildcards, no
227
- query string parsing.
223
+ literally (same segment count required too — `/dogs` does NOT match `/dogs/:id`, except
224
+ a trailing `*` wildcard segment, see below). The FIRST captured value is always
225
+ `Router.Param` — a plain `string` field (**not** `state<T>`; no `Subscribe()` needed
226
+ see below), `""` when the matched route has no `:` segment.
227
+ - **More than one dynamic segment, and a trailing wildcard**: `AddRoute("/dogs/:id/toys/:toyId",
228
+ ...)` captures both; read either by name via `Router.Params(name)` (`Params("id")`,
229
+ `Params("toyId")`) rather than the single `Param` field — `Param` still holds the first
230
+ one either way. `AddRoute("/files/*", ...)` matches one or more remaining segments as a
231
+ single captured group, joined back with `"/"`, available as `Router.Params("*")`
232
+ (`/files/2026/reports/q1.pdf` -> `Params("*") == "2026/reports/q1.pdf"`). A route with a
233
+ trailing `*` only needs *at least* as many path segments as its static prefix, not an
234
+ exact count — the one exception to the "same segment count required" rule above.
235
+ - **Query strings**: `Router.Query(key)` returns the current URL's query-string value for
236
+ `key` (`""` if absent), independent of which route matched — never part of route
237
+ *matching* itself in v1, every route sees whatever query string is actually in the URL.
238
+ `"/search?sort=name"` -> `Query("sort") == "name"`. Values are **not percent-decoded** —
239
+ no `decodeURIComponent` binding exists yet, a deliberate v1 cut; `%20`/`+` arrive
240
+ exactly as written in the URL, not converted to a space.
228
241
  - **Why `Param` is a plain field, not `state<T>`**: `Render()` already rebuilds a fresh
229
242
  outlet and re-`Mount()`s the matched page on every `Navigate()`/`popstate`, which
230
243
  re-runs that page's own `Render()` (reading the fresh `Param`) with no extra step.
package/README.md CHANGED
@@ -217,7 +217,8 @@ Real URLs via the History API (`pushState`/`popstate`), not `#/about` hash routi
217
217
  the whole page graph typically gets built once, in a composition root.
218
218
 
219
219
  **Dynamic route segments**: a path segment written `:name` (e.g. `/dogs/:id`) matches any
220
- single non-empty segment, captured into `Router.Param`:
220
+ single non-empty segment. With just one per route, its value is captured into
221
+ `Router.Param`:
221
222
 
222
223
  ```ks
223
224
  nav.AddRoute("/dogs/:id", new DogPage(nav));
@@ -228,9 +229,33 @@ el.textContent = "Dog #" + this.Nav.Param;
228
229
  `Param` is a plain `string`, deliberately not `state<T>` — Router's own `Render()`
229
230
  already rebuilds a fresh outlet and re-`Mount()`s the matched page on every
230
231
  `Navigate()`/`popstate`, which re-runs that page's `Render()` (reading the fresh `Param`)
231
- with no extra step. No `Subscribe()` needed on it. Deliberately just one dynamic segment
232
- per route for now — no multiple params (`/dogs/:id/toys/:toyId`), no wildcards, no query
233
- string parsing each a real, separate extension, not an oversight.
232
+ with no extra step. No `Subscribe()` needed on it.
233
+
234
+ **More than one dynamic segment**, and a trailing **wildcard** segment, both work too —
235
+ read each by name via `Router.Params(name)` instead (`Param` above still holds the
236
+ *first* captured value either way, so existing single-segment code needs no change):
237
+
238
+ ```ks
239
+ nav.AddRoute("/dogs/:id/toys/:toyId", new ToyPage(nav));
240
+ // inside ToyPage.Render():
241
+ el.textContent = "Dog " + this.Nav.Params("id") + " / Toy " + this.Nav.Params("toyId");
242
+
243
+ nav.AddRoute("/files/*", new FilesPage(nav));
244
+ // "/files/2026/reports/q1.pdf" -> Params("*") == "2026/reports/q1.pdf"
245
+ ```
246
+
247
+ **Query strings** are always available via `Router.Query(key)`, independent of which
248
+ route matched (never part of route *matching* itself):
249
+
250
+ ```ks
251
+ // "/search?sort=name&order=asc"
252
+ this.Nav.Query("sort") // "name"
253
+ this.Nav.Query("missing") // "" — not present
254
+ ```
255
+
256
+ Query values are **not percent-decoded** — a deliberate v1 cut (no `decodeURIComponent`
257
+ binding exists yet); a value containing `%20` or `+` for a space arrives exactly as
258
+ written in the URL.
234
259
 
235
260
  **Navigation guards**: protect a route (or any set of routes) behind a check —
236
261
  `SetGuard` takes a redirect path plus a single `(string) => bool` checked before every
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kopular",
3
- "version": "0.11.5",
3
+ "version": "0.12.0",
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",
@@ -59,7 +59,7 @@
59
59
  "@types/jsdom": "^30.0.0",
60
60
  "@types/node": "^20.14.0",
61
61
  "jsdom": "^25.0.1",
62
- "kopscript": "^0.7.0",
62
+ "kopscript": "^0.16.0",
63
63
  "typescript": "^5.5.0",
64
64
  "vitest": "^4.1.11"
65
65
  },
package/src/component.js CHANGED
@@ -37,3 +37,5 @@ export class Component {
37
37
  this.Root = newRoot;
38
38
  }
39
39
  }
40
+
41
+ //# sourceMappingURL=component.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"component.js","sources":["component.ks"],"sourcesContent":["using \"./dom\";\n\n// A minimal component base: subclasses override Render() to imperatively\n// build a fresh DOM tree from current state, and call the inherited\n// Update() whenever that state changes to swap the old tree for a new one.\n// There is deliberately no template language or diffing here — Render()\n// rebuilds its whole subtree every time, the simplest thing that works.\n//\n// Known limitation: if a *parent* component's own Render() re-runs (i.e.\n// something calls Update() on the parent) while it has mounted children,\n// those children are not automatically re-mounted into the parent's new\n// tree — this base class only handles a single component's own re-render\n// cycle, not parent/child reconciliation across one. Composing independent\n// components (each mounted into its own stable slot, as in app.kop) avoids\n// the issue entirely.\nclass Component {\n protected Element Root;\n private Element ParentElement;\n // Set true only once Mount() actually runs. A page Component is commonly\n // constructed eagerly (e.g. Router.AddRoute takes an already-built\n // instance — see Router's own header comment) long before it's ever\n // Mount()ed, and two sibling pages sharing one injected service's\n // state<T> (Pure DI — both Subscribe() the same field) both get notified\n // on any change regardless of which one is actually the currently-routed,\n // mounted page. Update() guards on this so that notification is a safe\n // no-op for the unmounted one, instead of a crash (see Update() below).\n private bool IsMounted;\n\n constructor() {\n this.IsMounted = false;\n }\n\n public virtual Element Render() {\n return document.createElement(\"div\");\n }\n\n // Overridden to render a fallback UI when Render() throws — a page bug,\n // an unhandled rejected Http call, anything — instead of leaving\n // Mount()/Update() to propagate the exception uncaught, which would\n // otherwise crash whatever triggered the render (a click handler, a\n // Router navigation) with nothing shown to the user at all. Default just\n // re-throws, so anything that doesn't override this keeps today's exact\n // behavior — this is purely additive, opt-in error recovery, not a\n // behavior change for existing components.\n protected virtual Element RenderError(string message) {\n throw message;\n }\n\n private Element SafeRender() {\n try {\n return this.Render();\n } catch (string message) {\n return this.RenderError(message);\n }\n }\n\n public void Mount(Element parent) {\n this.ParentElement = parent;\n this.Root = this.SafeRender();\n parent.appendChild(this.Root);\n this.IsMounted = true;\n }\n\n // A no-op, not an error, when called before Mount() — see the class-level\n // comment on IsMounted for exactly when this happens for real (a\n // shared-service state change reaching a sibling page that isn't the one\n // currently routed/mounted). Nothing is lost: SafeRender() would only be\n // thrown away unread since there's no live DOM parent to put it in, and\n // Mount() itself always calls SafeRender() fresh whenever this component\n // does become the routed page, picking up whatever the current state is\n // at that point.\n protected void Update() {\n if (!this.IsMounted) {\n return;\n }\n Element newRoot = this.SafeRender();\n this.ParentElement.replaceChild(newRoot, this.Root);\n this.Root = newRoot;\n }\n}\n"],"names":[],"mappings":";;AAeA;EAaE;IACiB;;;EAGF;IACb;;;EAWgB;IAChB;;;EAGM;IACN;MACE;;MAEA;;;;EAIG;IACc;IACT;IACQ;IACH;;;EAWP;IACR;MACE;;IAEF;IAC+B;IACrB"}
package/src/directives.js CHANGED
@@ -6,3 +6,5 @@ export function If(condition, whenTrue, whenFalse) {
6
6
  }
7
7
  return whenFalse();
8
8
  }
9
+
10
+ //# sourceMappingURL=directives.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"directives.js","sources":["directives.ks"],"sourcesContent":["using \"./dom\";\n\n// Kopular's structural-directive equivalents — see the README's\n// \"Structural directives\" section for the full *ngIf/*ngFor/*ngSwitch\n// mapping. There's no template language here (Kopular doesn't have one, by\n// design — see component.ks), so these are just plain functions: call them\n// like any other expression from inside Render(), the same way you'd call\n// document.createElement.\n//\n// *ngFor and *ngSwitch need nothing new — `array.ForEach(...)` and\n// KopScript's own `match` expression already cover them (and `match` is\n// exhaustiveness-checked, which *ngSwitch isn't). `If` below is the one\n// piece the language doesn't already give you as an expression: `if` is a\n// statement in KopScript, so without this you'd need a throwaway mutable\n// local to get a conditional value.\n\n// The *ngIf equivalent — conditionally build one of two subtrees, as an\n// expression. Both branches are required: v1 has no nullable types, so\n// \"render nothing\" has no value to return — the same reasoning Router uses\n// for requiring a NotFoundPage up front (see router.ks) rather than letting\n// \"no match\" be null. Only the branch actually taken runs; the other\n// lambda is never called, so an explicit empty branch (e.g.\n// `() => document.createElement(\"span\")`) costs nothing when there's\n// genuinely nothing to show.\nElement If(bool condition, () => Element whenTrue, () => Element whenFalse) {\n if (condition) {\n return whenTrue();\n }\n return whenFalse();\n}\n"],"names":[],"mappings":";;AAwBA;EACE;IACE;;EAEF"}
package/src/dom.js CHANGED
@@ -8,3 +8,5 @@ export const document = globalThis.document;
8
8
  export const location = globalThis.location;
9
9
  export const history = globalThis.history;
10
10
  export const window = globalThis.window;
11
+
12
+ //# sourceMappingURL=dom.js.map
package/src/dom.js.map ADDED
@@ -0,0 +1 @@
1
+ {"version":3,"file":"dom.js","sources":["dom.ks"],"sourcesContent":["// Minimal browser DOM bindings. All ambient (no `from` clause) — document,\n// Element, and Event genuinely exist as globals in a browser, no import\n// needed. Member names use the real JS casing exactly (camelCase), since\n// extern declarations describe an existing external contract rather than\n// idiomatic Kop code — there's no per-member rename mechanism. `extern\n// class` declarations end in `;`, like the other two extern forms.\n\nextern class Event {\n Element target { get; }\n void preventDefault();\n};\n\nextern class Element {\n string textContent { get; set; }\n string id { get; set; }\n string className { get; set; }\n void appendChild(Element child);\n void replaceChild(Element newChild, Element oldChild);\n void addEventListener(string eventType, (Event) => void handler);\n void removeEventListener(string eventType, (Event) => void handler);\n};\n\nextern class Document {\n Element createElement(string tagName);\n Element getElementById(string id);\n Element body { get; }\n};\n\nextern class Location {\n string pathname { get; }\n // The real query string including its leading \"?\" (e.g. \"?sort=name\"),\n // or \"\" if the current URL has none — see Router.ParseQuery/Query.\n string search { get; }\n};\n\nextern class History {\n // Param named `historyState`, not `state` — `state` is a KopScript\n // keyword (state<T>), not a valid parameter name.\n void pushState(string historyState, string title, string url);\n};\n\nextern class Window {\n void addEventListener(string eventType, (Event) => void handler);\n};\n\nextern Document document;\nextern Location location;\nextern History history;\nextern Window window;\n"],"names":[],"mappings":"AAOA;AAKA;AAUA;AAMA;AAOA;AAMA;AAIA;AACA;AACA;AACA"}
package/src/dom.ks CHANGED
@@ -28,6 +28,9 @@ extern class Document {
28
28
 
29
29
  extern class Location {
30
30
  string pathname { get; }
31
+ // The real query string including its leading "?" (e.g. "?sort=name"),
32
+ // or "" if the current URL has none — see Router.ParseQuery/Query.
33
+ string search { get; }
31
34
  };
32
35
 
33
36
  extern class History {
package/src/forms.js CHANGED
@@ -79,3 +79,5 @@ export class Validators {
79
79
  return null;
80
80
  }
81
81
  }
82
+
83
+ //# sourceMappingURL=forms.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"forms.js","sources":["forms.ks"],"sourcesContent":["// FormField<T>: a single form input's value, validation state, and touched\n// flag, built on the same state<T> reactivity Component already uses — no\n// new reactive primitive, and deliberately no DOM binding of its own\n// (wiring Value to a real <input> is one addEventListener call in your own\n// Render(), the same as Counter's own click handler — see Kopular's README).\n//\n// A validator is a plain (T) => string? function: null means valid, the\n// same \"null means nothing to report\" convention KopScript's nullable\n// types already use elsewhere. There's no array of validators — KopScript\n// has no syntax for an array of function values — so combining more than\n// one check is just an if-chain in one lambda:\n//\n// FormField<string> name = new FormField<string>(\"\", (string v) => {\n// string? required = Validators.Required(v);\n// if (required != null) { return required; }\n// return Validators.MaxLength(v, 40);\n// });\nclass FormField<T> {\n public state<T> Value;\n public state<string?> Error;\n public state<bool> Touched;\n\n constructor(T initial, (T) => string? validate) {\n this.Value = state(initial);\n this.Error = state(validate(initial));\n this.Touched = state(false);\n this.Value.Subscribe((T v) => { this.Error.Value = validate(v); });\n }\n\n // Call on blur — separate from Error so a fresh, untouched field with an\n // invalid initial value (e.g. Required on an empty string) doesn't show\n // an error message before the user has had a chance to type anything.\n public void Touch() {\n this.Touched.Value = true;\n }\n\n public bool Valid() {\n return this.Error.Value == null;\n }\n}\n\n// A small set of common checks, each returning an error message or null —\n// not a validation framework, just the handful of checks almost every form\n// needs, so most fields don't have to hand-write string-length arithmetic.\nclass Validators {\n public static string? Required(string value) {\n if (value.Trim().Length == 0) { return \"Required\"; }\n return null;\n }\n\n public static string? MinLength(string value, number min) {\n if (value.Length < min) { return $\"Must be at least {min} characters\"; }\n return null;\n }\n\n public static string? MaxLength(string value, number max) {\n if (value.Length > max) { return $\"Must be at most {max} characters\"; }\n return null;\n }\n\n public static string? Email(string value) {\n return match value {\n r\"^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$\" => null,\n _ => \"Must be a valid email\"\n };\n }\n\n public static string? Min(number value, number min) {\n if (value < min) { return $\"Must be at least {min}\"; }\n return null;\n }\n\n public static string? Max(number value, number max) {\n if (value > max) { return $\"Must be at most {max}\"; }\n return null;\n }\n}\n"],"names":[],"mappings":";;;;;;;;;;;;;;;AAiBA;EAKE;IACa;IACA;IACE;IACO;EAA6B;;;;EAM5C;IACc;;;EAGd;IACL;;;AAOJ;EACgB;IACZ;MAAgC;;IAChC;;;EAGY;IACZ;MAA0B;;IAC1B;;;EAGY;IACZ;MAA0B;;IAC1B;;;EAGY;IACZ;;EACE;;;EACA;;;;;;EAIU;IACZ;MAAmB;;IACnB;;;EAGY;IACZ;MAAmB;;IACnB"}
package/src/http.js CHANGED
@@ -23,3 +23,5 @@ export class Http {
23
23
  return await RequestWithBody(url, "PATCH", jsonBody, "application/json");
24
24
  }
25
25
  }
26
+
27
+ //# sourceMappingURL=http.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"http.js","sources":["http.ks"],"sourcesContent":["// A thin wrapper over the real Fetch API — no object literals (KopScript\n// has no syntax for one), no generics, no automatic JSON deserialization.\n// `Response.Text()` gets you the raw body; for a typed JSON response,\n// describe the shape as its own `extern class` and parse it with a\n// per-shape `extern ... as \"JSON.parse\"` declaration (see Kopular's README)\n// — the same trust-based approach `extern` already uses for everything\n// else, not a new mechanism.\n\nextern class Response {\n bool ok { get; }\n number status { get; }\n task<string> text();\n};\n\n// GET/HEAD/DELETE-without-a-body need no options object at all, so they\n// bind straight to the real global `fetch` — no runtime helper involved.\nextern task<Response> FetchUrl(string url) as \"fetch\";\n\n// POST/PUT/PATCH (and DELETE-with-a-body) need to set a method/body/\n// headers, which does need an options object — the one thing in this file\n// that isn't a direct, unassisted binding to a real JS global. See\n// http_runtime.js for why, and for the only hand-written JS in this\n// package. A relative path, not a package-name one: this is Kopular\n// referencing its own sibling file (which isn't part of Kopular's public\n// API — only Http's static methods below are), not a consumer reaching\n// into Kopular from outside.\nextern task<Response> RequestWithBody(string url, string method, string? body, string contentType) from \"./http_runtime.js\" as \"requestWithBody\";\n\n// Static methods, not free functions, purely so call sites read as\n// `Http.Get(url)` / `Http.Post(url, body)` — there's no instance state here\n// to justify a real object.\nclass Http {\n public static async task<Response> Get(string url) {\n return await FetchUrl(url);\n }\n\n public static async task<Response> Delete(string url) {\n return await RequestWithBody(url, \"DELETE\", null, \"application/json\");\n }\n\n public static async task<Response> Post(string url, string jsonBody) {\n return await RequestWithBody(url, \"POST\", jsonBody, \"application/json\");\n }\n\n public static async task<Response> Put(string url, string jsonBody) {\n return await RequestWithBody(url, \"PUT\", jsonBody, \"application/json\");\n }\n\n public static async task<Response> Patch(string url, string jsonBody) {\n return await RequestWithBody(url, \"PATCH\", jsonBody, \"application/json\");\n }\n}\n"],"names":[],"mappings":"AAQA;AAQA;AAUA;;AAKA;EACsB;IAClB;;;EAGkB;IAClB;;;EAGkB;IAClB;;;EAGkB;IAClB;;;EAGkB;IAClB"}
package/src/router.js CHANGED
@@ -8,6 +8,10 @@ export class Router extends Component {
8
8
  this.Pages = [];
9
9
  this.NotFoundPage = notFoundPage;
10
10
  this.Param = "";
11
+ this.ParamNames = [];
12
+ this.ParamValues = [];
13
+ this.QueryKeys = [];
14
+ this.QueryValues = [];
11
15
  this.Guard = (path) => (true);
12
16
  this.RedirectPath = "";
13
17
  window.addEventListener("popstate", (e) => (this.Update()));
@@ -28,46 +32,113 @@ export class Router extends Component {
28
32
  this.Update();
29
33
  }
30
34
 
31
- Match(path) {
32
- let effectivePath = path;
33
- if (!this.Guard(path)) {
35
+ Params(name) {
36
+ for (let i = 0; (i < this.ParamNames.length); i = (i + 1)) {
37
+ if ((this.ParamNames[i] === name)) {
38
+ return this.ParamValues[i];
39
+ }
40
+ }
41
+ return "";
42
+ }
43
+
44
+ Query(key) {
45
+ for (let i = 0; (i < this.QueryKeys.length); i = (i + 1)) {
46
+ if ((this.QueryKeys[i] === key)) {
47
+ return this.QueryValues[i];
48
+ }
49
+ }
50
+ return "";
51
+ }
52
+
53
+ ParseQuery(pathWithQuery) {
54
+ this.QueryKeys = [];
55
+ this.QueryValues = [];
56
+ let afterMark = pathWithQuery.split("?");
57
+ if ((afterMark.length < 2)) {
58
+ return;
59
+ }
60
+ let rawQuery = afterMark[1];
61
+ if ((rawQuery.length === 0)) {
62
+ return;
63
+ }
64
+ let pairs = rawQuery.split("&");
65
+ for (const pair of pairs) {
66
+ let kv = pair.split("=");
67
+ if ((kv.length === 2)) {
68
+ this.QueryKeys = [...this.QueryKeys, kv[0]];
69
+ this.QueryValues = [...this.QueryValues, kv[1]];
70
+ }
71
+ }
72
+ }
73
+
74
+ Match(fullPath) {
75
+ let effectivePath = fullPath;
76
+ if (!this.Guard(fullPath)) {
34
77
  history.pushState("", "", this.RedirectPath);
35
78
  effectivePath = this.RedirectPath;
36
79
  }
37
- let pathSegments = effectivePath.split("/");
80
+ this.ParseQuery(effectivePath);
81
+ let pathAndQuery = effectivePath.split("?");
82
+ let pathSegments = pathAndQuery[0].split("/");
38
83
  let found = this.NotFoundPage;
39
- let param = "";
84
+ let foundNames = [];
85
+ let foundValues = [];
40
86
  for (let i = 0; (i < this.Paths.length); i = (i + 1)) {
41
87
  let patternSegments = this.Paths[i].split("/");
42
- if ((patternSegments.length !== pathSegments.length)) {
43
- continue;
88
+ let hasWildcard = ((patternSegments.length > 0) && (patternSegments[(patternSegments.length - 1)] === "*"));
89
+ let staticCount = patternSegments.length;
90
+ if (hasWildcard) {
91
+ staticCount = (patternSegments.length - 1);
92
+ }
93
+ if (hasWildcard) {
94
+ if ((pathSegments.length < staticCount)) {
95
+ continue;
96
+ }
97
+ } else {
98
+ if ((patternSegments.length !== pathSegments.length)) {
99
+ continue;
100
+ }
44
101
  }
45
102
  let matched = true;
46
- let capturedParam = "";
47
- for (let j = 0; (j < patternSegments.length); j = (j + 1)) {
103
+ let names = [];
104
+ let values = [];
105
+ for (let j = 0; (j < staticCount); j = (j + 1)) {
48
106
  if (patternSegments[j].startsWith(":")) {
49
- capturedParam = pathSegments[j];
107
+ names = [...names, patternSegments[j].split(":")[1]];
108
+ values = [...values, pathSegments[j]];
50
109
  } else if ((patternSegments[j] !== pathSegments[j])) {
51
110
  matched = false;
52
111
  break;
53
112
  }
54
113
  }
114
+ if ((matched && hasWildcard)) {
115
+ names = [...names, "*"];
116
+ values = [...values, pathSegments.slice(staticCount, pathSegments.length).join("/")];
117
+ }
55
118
  if (matched) {
56
119
  found = this.Pages[i];
57
- param = capturedParam;
120
+ foundNames = names;
121
+ foundValues = values;
58
122
  break;
59
123
  }
60
124
  }
61
- this.Param = param;
125
+ this.ParamNames = foundNames;
126
+ this.ParamValues = foundValues;
127
+ this.Param = "";
128
+ if ((foundValues.length > 0)) {
129
+ this.Param = foundValues[0];
130
+ }
62
131
  return found;
63
132
  }
64
133
 
65
134
  Render() {
66
135
  let outlet = document.createElement("div");
67
136
  outlet.className = "router-outlet";
68
- let path = location.pathname;
137
+ let path = (location.pathname + location.search);
69
138
  let page = this.Match(path);
70
139
  page.Mount(outlet);
71
140
  return outlet;
72
141
  }
73
142
  }
143
+
144
+ //# sourceMappingURL=router.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"router.js","sources":["router.ks"],"sourcesContent":["using \"./dom\";\nusing \"./component\";\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// Routes are registered as already-constructed Component instances, not\n// factories — `Component[]`, not `(() => Component)[]` (KopScript's type\n// grammar has no way to write \"array of function type\" in v1, since a\n// parenthesized function type isn't a general grouping construct). This\n// turns out to be a genuine feature, not just a workaround: each page\n// Component is built once and kept alive for the Router's own lifetime, so\n// a page's own state<T> fields survive navigating away and back — no state\n// gets reset just because a route 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.\nclass Router : Component {\n private string[] Paths;\n private Component[] Pages;\n private Component NotFoundPage;\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.NotFoundPage = notFoundPage;\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(page);\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 Component 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 Component found = this.NotFoundPage;\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 // Rebuilding a fresh outlet every render and re-Mount()ing the matched\n // page into it means there's nothing to explicitly unmount: Update()\n // (inherited from Component) discards the whole outlet in one\n // replaceChild when it swaps in the new one. The page instance itself\n // isn't rebuilt, just re-attached — see the class comment above.\n public override Element Render() {\n Element outlet = document.createElement(\"div\");\n outlet.className = \"router-outlet\";\n string path = location.pathname + location.search;\n Component page = this.Match(path);\n page.Mount(outlet);\n return outlet;\n }\n}\n"],"names":[],"mappings":";;;AAyBA;EAuDE;;IACa;IACA;IACO;IACP;IACK;IACC;IACF;IACE;IACN;IACO;IAMK;;;EAYlB;IACM;IACA;;;EAQN;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;;;EAQc;IACd;IACiB;IACjB;IACA;IACU;IACV"}
package/src/router.ks CHANGED
@@ -28,8 +28,12 @@ class Router : Component {
28
28
  private Component[] Pages;
29
29
  private Component NotFoundPage;
30
30
 
31
- // The current route's dynamic segment (e.g. "42" for a "/dogs/:id" route
32
- // matching "/dogs/42"), or "" if the matched route has none.
31
+ // The FIRST captured dynamic segment's value (e.g. "42" for a
32
+ // "/dogs/:id" route matching "/dogs/42"), or "" if the matched route has
33
+ // none — kept exactly as-is (a plain string, not the general Params()
34
+ // lookup below) so every existing single-dynamic-segment consumer keeps
35
+ // working unchanged. For a route with more than one captured segment,
36
+ // use Params(name) instead; this still holds the first one either way.
33
37
  //
34
38
  // A plain field, deliberately **not** state<T> — Render() below always
35
39
  // rebuilds a fresh outlet and re-Mount()s the matched page into it on
@@ -42,6 +46,24 @@ class Router : Component {
42
46
  // inherited Update() has no ParentElement to replaceChild into.
43
47
  public string Param;
44
48
 
49
+ // Every named dynamic segment captured by whatever route just matched
50
+ // (":id"/":toyId" for "/dogs/:id/toys/:toyId"), plus the pseudo-name "*"
51
+ // for a trailing wildcard segment's captured remainder — see Params()
52
+ // below. Parallel arrays (ParamNames[i] <-> ParamValues[i]), the same
53
+ // convention Paths/Pages above already use — not a Dictionary type,
54
+ // since KopScript has none.
55
+ private string[] ParamNames;
56
+ private string[] ParamValues;
57
+
58
+ // The current URL's query string, parsed once per navigation/popstate —
59
+ // see Query() below. Never part of route *matching* itself in v1: every
60
+ // route sees whatever query string is actually in the URL, matched or
61
+ // not. Values are NOT percent-decoded (a deliberate v1 cut — no
62
+ // decodeURIComponent binding exists yet); a value containing `%20` or
63
+ // `+` for a space arrives exactly as written in the URL.
64
+ private string[] QueryKeys;
65
+ private string[] QueryValues;
66
+
45
67
  // Checked before every navigation, including a direct load/refresh — not
46
68
  // just Navigate()/popstate. Deliberately one guard for the whole Router,
47
69
  // not per-route: the guard function itself decides which paths it cares
@@ -61,6 +83,10 @@ class Router : Component {
61
83
  this.Pages = [];
62
84
  this.NotFoundPage = notFoundPage;
63
85
  this.Param = "";
86
+ this.ParamNames = [];
87
+ this.ParamValues = [];
88
+ this.QueryKeys = [];
89
+ this.QueryValues = [];
64
90
  this.Guard = (string path) => true;
65
91
  this.RedirectPath = "";
66
92
  // 'popstate' only fires on browser back/forward (or history.go/back/
@@ -72,10 +98,14 @@ class Router : Component {
72
98
  }
73
99
 
74
100
  // A path segment written ":name" (e.g. "dogs/:id") matches any single
75
- // non-empty segment; every other segment must match literally. Comparing
76
- // segment-by-segment (rather than the whole string at once) is what makes
77
- // a static route's own matching still exactly as strict as a plain `==`
78
- // was before same segment count, same literal text throughout.
101
+ // non-empty segment; every other segment must match literally, except a
102
+ // trailing "*" segment (e.g. "files/*"), which matches one or more
103
+ // remaining segments as a single captured group (see Params("*")).
104
+ // Comparing segment-by-segment (rather than the whole string at once) is
105
+ // what makes a static route's own matching still exactly as strict as a
106
+ // plain `==` was before — same segment count, same literal text
107
+ // throughout (a trailing "*" route aside, which only ever needs *at
108
+ // least* as many path segments as its static prefix).
79
109
  public void AddRoute(string path, Component page) {
80
110
  this.Paths = this.Paths.Push(path);
81
111
  this.Pages = this.Pages.Push(page);
@@ -96,38 +126,116 @@ class Router : Component {
96
126
  this.Update();
97
127
  }
98
128
 
99
- private Component Match(string path) {
100
- string effectivePath = path;
101
- if (!this.Guard(path)) {
129
+ // The captured value for a named dynamic segment (e.g. "toyId" for
130
+ // ":toyId" in "/dogs/:id/toys/:toyId") or "*" for a trailing wildcard
131
+ // segment's matched remainder — "" if `name` wasn't captured by whatever
132
+ // route just matched, the same "empty string, not null" convention
133
+ // `Param` itself already uses.
134
+ public string Params(string name) {
135
+ for (number i = 0; i < this.ParamNames.Length; i = i + 1) {
136
+ if (this.ParamNames[i] == name) {
137
+ return this.ParamValues[i];
138
+ }
139
+ }
140
+ return "";
141
+ }
142
+
143
+ // The current URL's query-string value for `key` (e.g. "name" for
144
+ // "?sort=name") — "" if `key` isn't present. See QueryKeys/QueryValues'
145
+ // own comment for the percent-decoding cut.
146
+ public string Query(string key) {
147
+ for (number i = 0; i < this.QueryKeys.Length; i = i + 1) {
148
+ if (this.QueryKeys[i] == key) {
149
+ return this.QueryValues[i];
150
+ }
151
+ }
152
+ return "";
153
+ }
154
+
155
+ // KopScript strings have no index-based substring/slice method (only
156
+ // arrays do — see Array.Slice), so splitting on the literal separator and
157
+ // taking the piece after it is the idiomatic way to strip a known
158
+ // single-character prefix here, both for "?" (query string) and ":"
159
+ // (a pattern segment's own leading marker, used below in Match).
160
+ private void ParseQuery(string pathWithQuery) {
161
+ this.QueryKeys = [];
162
+ this.QueryValues = [];
163
+ string[] afterMark = pathWithQuery.Split("?");
164
+ if (afterMark.Length < 2) { return; }
165
+ string rawQuery = afterMark[1];
166
+ if (rawQuery.Length == 0) { return; }
167
+ string[] pairs = rawQuery.Split("&");
168
+ foreach (string pair in pairs) {
169
+ string[] kv = pair.Split("=");
170
+ if (kv.Length == 2) {
171
+ this.QueryKeys = this.QueryKeys.Push(kv[0]);
172
+ this.QueryValues = this.QueryValues.Push(kv[1]);
173
+ }
174
+ }
175
+ }
176
+
177
+ private Component Match(string fullPath) {
178
+ string effectivePath = fullPath;
179
+ if (!this.Guard(fullPath)) {
102
180
  history.pushState("", "", this.RedirectPath);
103
181
  effectivePath = this.RedirectPath;
104
182
  }
105
183
 
106
- string[] pathSegments = effectivePath.Split("/");
184
+ this.ParseQuery(effectivePath);
185
+ string[] pathAndQuery = effectivePath.Split("?");
186
+ string[] pathSegments = pathAndQuery[0].Split("/");
187
+
107
188
  Component found = this.NotFoundPage;
108
- string param = "";
189
+ string[] foundNames = [];
190
+ string[] foundValues = [];
191
+
109
192
  for (number i = 0; i < this.Paths.Length; i = i + 1) {
110
193
  string[] patternSegments = this.Paths[i].Split("/");
111
- if (patternSegments.Length != pathSegments.Length) { continue; }
194
+ bool hasWildcard = patternSegments.Length > 0 && patternSegments[patternSegments.Length - 1] == "*";
195
+
196
+ number staticCount = patternSegments.Length;
197
+ if (hasWildcard) {
198
+ staticCount = patternSegments.Length - 1;
199
+ }
200
+
201
+ if (hasWildcard) {
202
+ if (pathSegments.Length < staticCount) { continue; }
203
+ } else {
204
+ if (patternSegments.Length != pathSegments.Length) { continue; }
205
+ }
112
206
 
113
207
  bool matched = true;
114
- string capturedParam = "";
115
- for (number j = 0; j < patternSegments.Length; j = j + 1) {
208
+ string[] names = [];
209
+ string[] values = [];
210
+ for (number j = 0; j < staticCount; j = j + 1) {
116
211
  if (patternSegments[j].StartsWith(":")) {
117
- capturedParam = pathSegments[j];
212
+ names = names.Push(patternSegments[j].Split(":")[1]);
213
+ values = values.Push(pathSegments[j]);
118
214
  } else if (patternSegments[j] != pathSegments[j]) {
119
215
  matched = false;
120
216
  break;
121
217
  }
122
218
  }
123
219
 
220
+ if (matched && hasWildcard) {
221
+ names = names.Push("*");
222
+ values = values.Push(pathSegments.Slice(staticCount, pathSegments.Length).Join("/"));
223
+ }
224
+
124
225
  if (matched) {
125
226
  found = this.Pages[i];
126
- param = capturedParam;
227
+ foundNames = names;
228
+ foundValues = values;
127
229
  break;
128
230
  }
129
231
  }
130
- this.Param = param;
232
+
233
+ this.ParamNames = foundNames;
234
+ this.ParamValues = foundValues;
235
+ this.Param = "";
236
+ if (foundValues.Length > 0) {
237
+ this.Param = foundValues[0];
238
+ }
131
239
  return found;
132
240
  }
133
241
 
@@ -139,7 +247,7 @@ class Router : Component {
139
247
  public override Element Render() {
140
248
  Element outlet = document.createElement("div");
141
249
  outlet.className = "router-outlet";
142
- string path = location.pathname;
250
+ string path = location.pathname + location.search;
143
251
  Component page = this.Match(path);
144
252
  page.Mount(outlet);
145
253
  return outlet;