kopular 0.17.0 → 0.17.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 +9 -0
- package/package.json +1 -1
- package/src/component.js +1 -0
- package/src/component.js.map +1 -1
- package/src/component.ks +13 -8
- package/src/router.js +3 -7
- package/src/router.js.map +1 -1
- package/src/router.ks +17 -18
package/LLM.md
CHANGED
|
@@ -98,6 +98,12 @@ extern class VElement {
|
|
|
98
98
|
(Event) => void OnChange { get; set; }
|
|
99
99
|
void AppendChild(VElement child);
|
|
100
100
|
void SetAttr(string name, string value);
|
|
101
|
+
// Embeds a live child Component as this slot's content — see "Nested
|
|
102
|
+
// component composition" below. Declared taking a concrete Component
|
|
103
|
+
// rather than Kopular's own Mountable interface: extern declarations
|
|
104
|
+
// don't model inheritance/interface conformance between separate extern
|
|
105
|
+
// classes, and every real caller passes a Component subclass anyway.
|
|
106
|
+
static VElement Mount(Component component);
|
|
101
107
|
} from "kopular/velement";
|
|
102
108
|
|
|
103
109
|
extern class Component {
|
|
@@ -106,6 +112,9 @@ extern class Component {
|
|
|
106
112
|
virtual void AfterRender(Element root); // see "Component" below
|
|
107
113
|
void Mount(Element parent);
|
|
108
114
|
void Update();
|
|
115
|
+
// Called once when this component is removed from its parent's tree via
|
|
116
|
+
// VElement.Mount above — see "Nested component composition" below.
|
|
117
|
+
virtual void OnUnmount();
|
|
109
118
|
} from "kopular/component";
|
|
110
119
|
|
|
111
120
|
extern class Router {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "kopular",
|
|
3
|
-
"version": "0.17.
|
|
3
|
+
"version": "0.17.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/component.js
CHANGED
package/src/component.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"component.js","sources":["component.ks"],"sourcesContent":["using \"./dom\";\nusing \"./velement\";\nusing \"./vdom\";\n\n// A minimal component base: subclasses override Render() to build a\n// VElement tree describing the current state, and call the inherited\n// Update() whenever that state changes to re-render. Update() DIFFS the\n// new VElement tree against the previous one (see vdom.ks's Patch) and\n// patches only what changed, reusing real DOM nodes wherever their tag\n// stays the same — replacing a whole subtree is now the exception (a\n// changed tag, or no previous tree at all), not the default on every\n// re-render the way it used to be.\n//\n// Nested composition: a live child Component can be embedded directly in a\n// parent's own VElement tree via `VElement.Mount(child)` (velement.ks) —\n// the vdom engine (vdom.ks's Materialize/Patch/PatchChildren) creates,\n// patches in place, reorders (given a stable .Id), and tears down a\n// mounted child declaratively, the same as any other content mode. This\n// is what MountAsChild/PatchAsChild/Teardown below exist for; app code\n// calls Mount(parent)/Update() as before and never touches those three\n// directly.\nclass Component : Flushable, Mountable {\n protected VElement Tree;\n private Element ParentElement;\n // Set true only once Mount() actually runs. A page Component is commonly
|
|
1
|
+
{"version":3,"file":"component.js","sources":["component.ks"],"sourcesContent":["using \"./dom\";\nusing \"./velement\";\nusing \"./vdom\";\n\n// A minimal component base: subclasses override Render() to build a\n// VElement tree describing the current state, and call the inherited\n// Update() whenever that state changes to re-render. Update() DIFFS the\n// new VElement tree against the previous one (see vdom.ks's Patch) and\n// patches only what changed, reusing real DOM nodes wherever their tag\n// stays the same — replacing a whole subtree is now the exception (a\n// changed tag, or no previous tree at all), not the default on every\n// re-render the way it used to be.\n//\n// Nested composition: a live child Component can be embedded directly in a\n// parent's own VElement tree via `VElement.Mount(child)` (velement.ks) —\n// the vdom engine (vdom.ks's Materialize/Patch/PatchChildren) creates,\n// patches in place, reorders (given a stable .Id), and tears down a\n// mounted child declaratively, the same as any other content mode. This\n// is what MountAsChild/PatchAsChild/Teardown below exist for; app code\n// calls Mount(parent)/Update() as before and never touches those three\n// directly.\nclass Component : Flushable, Mountable {\n protected VElement Tree;\n private Element ParentElement;\n // Set true only once Mount()/MountAsChild() actually runs, and back to\n // false once Teardown() does (a mounted-child slot this component\n // occupied was replaced or removed — see velement.ks/vdom.ks). A page\n // Component is commonly constructed eagerly (e.g. Router.AddRoute takes\n // an already-built instance — see Router's own header comment) long\n // before it's ever Mount()ed, and two sibling pages sharing one injected\n // service's state<T> (Pure DI — both Subscribe() the same field) both\n // get notified on any change regardless of which one is actually the\n // currently-routed, mounted page. Update() guards on this so that\n // notification is a safe no-op for the unmounted one, instead of a\n // crash (see Update() below) — and the same guard now also covers a\n // component notified again sometime after being torn down.\n private bool IsMounted;\n\n constructor() {\n this.IsMounted = false;\n }\n\n public virtual VElement Render() {\n return VElement.Create(\"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 VElement RenderError(string message) {\n throw message;\n }\n\n private VElement SafeRender() {\n try {\n return this.Render();\n } catch (string message) {\n return this.RenderError(message);\n }\n }\n\n // Called after Mount()/Update() has materialized/patched this\n // component's own VElement tree into `root` — a hook for a component\n // that needs to do additional, imperative work against its OWN\n // now-real root element once it exists. Default is a no-op, so every\n // component that doesn't need this is unaffected. Router still uses\n // this today for its outlet (a single, always-present slot it manages\n // by hand) — a dynamic parent/child relationship (a list of children,\n // conditional presence) is better served by embedding the child via\n // `VElement.Mount(child)` instead (see the class comment above), which\n // gets real reconciliation for free instead of needing this hook at all.\n protected virtual void AfterRender(Element root) {\n }\n\n public void Mount(Element parent) {\n this.ParentElement = parent;\n this.Tree = this.SafeRender();\n Element root = Materialize(this.Tree, parent);\n parent.appendChild(root);\n this.IsMounted = true;\n this.AfterRender(root);\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 //\n // Batching: while a real DOM event handler Kopular itself attached is\n // still running (Batching.IsActive() — see vdom.ks; every OnClick/\n // OnInput/OnBlur/OnChange listener Materialize/Patch attaches is wrapped\n // in Batching.Run), Update() doesn't render immediately — it registers\n // this component with Batching.Defer and returns. A handler that touches\n // more than one piece of state (or a state<T> write followed by a plain\n // field write Render() also reads) then only ever renders ONCE, once the\n // handler finishes, reading every field's FINAL value for that handler —\n // not once per state<T> write, reading whatever was true at that specific\n // moment. A handler's own statement order no longer matters for what a\n // re-render sees. Fully synchronous — no microtask: by the time the real\n // DOM's own dispatchEvent call returns, every affected component has\n // already re-rendered, the same guarantee React's own synthetic-event\n // batching gives a test's very next assertion. A state<T> write from\n // OUTSIDE a wrapped handler (a setTimeout/setInterval callback, an\n // awaited Http/task continuation, a direct top-level call) is never\n // inside a batch, so Update() still renders immediately there, exactly\n // as before batching existed.\n protected void Update() {\n if (!this.IsMounted) {\n return;\n }\n if (Batching.IsActive()) {\n Batching.Defer(this);\n return;\n }\n this.FlushUpdate();\n }\n\n // Shared by FlushUpdate and PatchAsChild below — re-renders and patches\n // this component's own tree in place against its CURRENT this.ParentElement\n // (Mount()/MountAsChild() already recorded whichever real element that\n // is, top-level or nested), returning the resulting real root node.\n private Element RenderAndPatch() {\n VElement newTree = this.SafeRender();\n Element root = Patch(this.ParentElement, this.Tree, newTree);\n this.Tree = newTree;\n this.AfterRender(root);\n return root;\n }\n\n // The real re-render Update() performs immediately outside a batch, or\n // that Batching.Run flushes once for every component deferred during one.\n // Public only because implementing the Flushable interface (see vdom.ks)\n // requires it — KopScript has no \"internal\" visibility narrower than\n // public, and structural interface conformance requires a public match.\n // Not the intended way to trigger a render from outside this class:\n // calling it directly bypasses batching entirely. Update() is still the\n // real, documented entry point for that.\n public void FlushUpdate() {\n this.RenderAndPatch();\n }\n\n // Mountable's own entry points (velement.ks) — called by the vdom engine\n // (vdom.ks's Materialize/Patch) when this Component is embedded as a\n // live child in an ANCESTOR's own VElement tree via VElement.Mount(this),\n // not meant to be called directly by app code (public only for interface\n // conformance, same reason FlushUpdate is). Mirrors Mount(parent) minus\n // the internal appendChild — the caller (Materialize/Patch) inserts the\n // returned node itself, the same as every other VElement content mode.\n public Element MountAsChild(Element parent) {\n this.ParentElement = parent;\n this.Tree = this.SafeRender();\n Element root = Materialize(this.Tree, parent);\n this.IsMounted = true;\n this.AfterRender(root);\n return root;\n }\n\n public Element PatchAsChild() {\n return this.RenderAndPatch();\n }\n\n // Mountable's teardown entry point — NOT itself meant to be overridden\n // (see OnUnmount below, the real app-facing extension point). Always\n // cascades into this component's own last-rendered tree first, via\n // UnmountTree (vdom.ks), so removing ONE component transitively tears\n // down everything IT mounted too — however many Mounted levels deep —\n // before calling the one real, overridable hook. Public only for the\n // same structural-conformance reason as MountAsChild/PatchAsChild above.\n public void Teardown() {\n UnmountTree(this.Tree);\n this.IsMounted = false;\n this.OnUnmount();\n }\n\n // Override this — not Teardown — to release anything THIS component\n // holds onto when it's removed from its parent's tree: a state<T>\n // Subscribe made in its own constructor (see state<T>'s own returned\n // unsubscribe handle), a timer, anything else that would otherwise keep\n // running for this component's remaining, now-detached lifetime. Called\n // once, automatically, from Teardown() above — Teardown's own cascade\n // into nested Mounted children still happens regardless of whether (or\n // how) a subclass overrides this. Default is a no-op: purely additive,\n // no existing subclass is affected unless it opts in.\n protected virtual void OnUnmount() {\n }\n}\n"],"names":[],"mappings":";;;;AAqBA;EAiBE;IACiB;;;EAGF;IACb;;;EAWgB;IAChB;;;EAGM;IACN;MACE;;MAEA;;;;EAcc;;;EAGX;IACc;IACT;IACV;IACkB;IACH;IACC;;;EA8BR;IACR;MACE;;IAEF;MACgB;MACd;;IAEc;;;EAOV;IACN;IACA;IACU;IACM;IAChB;;;EAWK;IACc;;;EAUd;IACc;IACT;IACV;IACe;IACC;IAChB;;;EAGK;IACL;;;EAUK;IACM;IACI;IACD;;;EAYE"}
|
package/src/component.ks
CHANGED
|
@@ -22,14 +22,18 @@ using "./vdom";
|
|
|
22
22
|
class Component : Flushable, Mountable {
|
|
23
23
|
protected VElement Tree;
|
|
24
24
|
private Element ParentElement;
|
|
25
|
-
// Set true only once Mount() actually runs
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
//
|
|
25
|
+
// Set true only once Mount()/MountAsChild() actually runs, and back to
|
|
26
|
+
// false once Teardown() does (a mounted-child slot this component
|
|
27
|
+
// occupied was replaced or removed — see velement.ks/vdom.ks). A page
|
|
28
|
+
// Component is commonly constructed eagerly (e.g. Router.AddRoute takes
|
|
29
|
+
// an already-built instance — see Router's own header comment) long
|
|
30
|
+
// before it's ever Mount()ed, and two sibling pages sharing one injected
|
|
31
|
+
// service's state<T> (Pure DI — both Subscribe() the same field) both
|
|
32
|
+
// get notified on any change regardless of which one is actually the
|
|
33
|
+
// currently-routed, mounted page. Update() guards on this so that
|
|
34
|
+
// notification is a safe no-op for the unmounted one, instead of a
|
|
35
|
+
// crash (see Update() below) — and the same guard now also covers a
|
|
36
|
+
// component notified again sometime after being torn down.
|
|
33
37
|
private bool IsMounted;
|
|
34
38
|
|
|
35
39
|
constructor() {
|
|
@@ -173,6 +177,7 @@ class Component : Flushable, Mountable {
|
|
|
173
177
|
// same structural-conformance reason as MountAsChild/PatchAsChild above.
|
|
174
178
|
public void Teardown() {
|
|
175
179
|
UnmountTree(this.Tree);
|
|
180
|
+
this.IsMounted = false;
|
|
176
181
|
this.OnUnmount();
|
|
177
182
|
}
|
|
178
183
|
|
package/src/router.js
CHANGED
|
@@ -133,17 +133,13 @@ export class Router extends Component {
|
|
|
133
133
|
}
|
|
134
134
|
|
|
135
135
|
Render() {
|
|
136
|
+
let path = (location.pathname + location.search);
|
|
137
|
+
let page = this.Match(path);
|
|
136
138
|
let outlet = VElement.Create("div");
|
|
137
139
|
outlet.ClassName = "router-outlet";
|
|
140
|
+
outlet.AppendChild(VElement.Mount(page));
|
|
138
141
|
return outlet;
|
|
139
142
|
}
|
|
140
|
-
|
|
141
|
-
AfterRender(root) {
|
|
142
|
-
let path = (location.pathname + location.search);
|
|
143
|
-
let page = this.Match(path);
|
|
144
|
-
root.textContent = "";
|
|
145
|
-
page.Mount(root);
|
|
146
|
-
}
|
|
147
143
|
}
|
|
148
144
|
|
|
149
145
|
//# sourceMappingURL=router.js.map
|
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// 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 // 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 now, rather than rebuilding it. What goes *inside* it (the\n // matched page) is handled imperatively in AfterRender below, not via\n // VElement.Mount (component.ks/velement.ks) — a single, always-present\n // slot has nothing to reorder or conditionally remove, so declarative\n // reconciliation wouldn't add anything here; this predates that\n // mechanism and there's no real reason to migrate it. The page instance\n // itself is always freshly Mount()ed on every navigation (matching this\n // class's original pre-diffing behavior exactly) — a page's own\n // re-renders, once mounted, still go through the normal diffed Update()\n // path when its own state changes; only the *outer* page-switch itself\n // stays a full remount.\n public override VElement Render() {\n VElement outlet = VElement.Create(\"div\");\n outlet.ClassName = \"router-outlet\";\n return outlet;\n }\n\n protected override void AfterRender(Element root) {\n string path = location.pathname + location.search;\n Component page = this.Match(path);\n root.textContent = \"\";\n page.Mount(root);\n }\n}\n"],"names":[],"mappings":";;;;AA0BA;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;;;EAiBc;IACd;IACiB;IACjB;;;EAGiB;IACjB;IACA;IACiB;IACP"}
|
|
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// 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 // 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 Component page = this.Match(path);\n\n VElement outlet = VElement.Create(\"div\");\n outlet.ClassName = \"router-outlet\";\n outlet.AppendChild(VElement.Mount(page));\n return outlet;\n }\n}\n"],"names":[],"mappings":";;;;AA0BA;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;;;EAmBc;IACd;IACA;IAEA;IACiB;IACC;IAClB"}
|
package/src/router.ks
CHANGED
|
@@ -243,27 +243,26 @@ class Router : Component {
|
|
|
243
243
|
// The outlet itself is a plain, unchanging `<div>` — its tag/className
|
|
244
244
|
// never change between navigations, so Component's own diffing (see
|
|
245
245
|
// vdom.ks) reuses the exact same real outlet element across every
|
|
246
|
-
// navigation
|
|
247
|
-
// matched page) is
|
|
248
|
-
//
|
|
249
|
-
//
|
|
250
|
-
//
|
|
251
|
-
//
|
|
252
|
-
//
|
|
253
|
-
//
|
|
254
|
-
// re-renders, once mounted, still go through
|
|
255
|
-
// path when its own state changes
|
|
256
|
-
//
|
|
246
|
+
// navigation, rather than rebuilding it. What goes *inside* it (the
|
|
247
|
+
// matched page) is embedded via VElement.Mount — a single child, so it
|
|
248
|
+
// needs no .Id (there's never more than one to key against; positional
|
|
249
|
+
// matching is unambiguous). Navigating to a DIFFERENT page than last
|
|
250
|
+
// time is a different Mountable instance in the slot, so Patch() tears
|
|
251
|
+
// the old one down (Teardown() — its OnUnmount fires, IsMounted resets)
|
|
252
|
+
// and mounts the new one fresh — matching this class's original
|
|
253
|
+
// behavior exactly (every navigation always freshly Mount()s the
|
|
254
|
+
// matched page; a page's own re-renders, once mounted, still go through
|
|
255
|
+
// the normal diffed Update() path when its own state changes). The one
|
|
256
|
+
// real change from before: a page navigated AWAY from now actually gets
|
|
257
|
+
// torn down (OnUnmount fires) instead of being silently abandoned with
|
|
258
|
+
// its DOM ripped out from under it and IsMounted left permanently true.
|
|
257
259
|
public override VElement Render() {
|
|
260
|
+
string path = location.pathname + location.search;
|
|
261
|
+
Component page = this.Match(path);
|
|
262
|
+
|
|
258
263
|
VElement outlet = VElement.Create("div");
|
|
259
264
|
outlet.ClassName = "router-outlet";
|
|
265
|
+
outlet.AppendChild(VElement.Mount(page));
|
|
260
266
|
return outlet;
|
|
261
267
|
}
|
|
262
|
-
|
|
263
|
-
protected override void AfterRender(Element root) {
|
|
264
|
-
string path = location.pathname + location.search;
|
|
265
|
-
Component page = this.Match(path);
|
|
266
|
-
root.textContent = "";
|
|
267
|
-
page.Mount(root);
|
|
268
|
-
}
|
|
269
268
|
}
|