kopular 0.5.0 → 0.8.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
@@ -1,17 +1,18 @@
1
1
  # Kopular — LLM reference
2
2
 
3
3
  Complete reference for generating correct Kopular code. This is a spec, not a tutorial —
4
- see `README.md` for narrative/rationale. Kopular is 6 files total; this covers all of
4
+ see `README.md` for narrative/rationale. Kopular is 8 files total; this covers all of
5
5
  them. For the host language, see KopScript's own `LLM.md` in the `Kop` repo (or its
6
6
  published `LLM.md` on the `kopscript` npm package) — that reference is a prerequisite,
7
7
  not repeated here.
8
8
 
9
9
  Published as npm `kopular`. Entry points: `kopular` / `kopular/component` (Component),
10
10
  `kopular/router` (Router), `kopular/dom` (ambient DOM bindings), `kopular/directives`
11
- (If), `kopular/http` (Http). Also ships a bin, `kp` — `npx kp new <dir>` scaffolds a new
12
- project (the `extern` bindings below, plus the vendor/serve scripts needed to run in a
13
- browser) rather than requiring it be reconstructed by hand; prefer it over hand-writing
14
- the section below for a new project.
11
+ (If), `kopular/http` (Http), `kopular/forms` (FormField, Validators), `kopular/testing`
12
+ (runKopularApp, runKopularFixture see below). Also ships a bin, `kp` `npx kp new
13
+ <dir>` scaffolds a new project (the `extern` bindings below, plus the vendor/serve
14
+ scripts needed to run in a browser) rather than requiring it be reconstructed by hand;
15
+ prefer it over hand-writing the section below for a new project.
15
16
 
16
17
  ## Consuming Kopular from your own KopScript project
17
18
 
@@ -47,8 +48,38 @@ extern class Http {
47
48
  static task<Response> Patch(string url, string jsonBody);
48
49
  static task<Response> Delete(string url);
49
50
  } from "kopular/http";
51
+
52
+ extern class Validators {
53
+ static string? Required(string value);
54
+ static string? MinLength(string value, number min);
55
+ static string? MaxLength(string value, number max);
56
+ static string? Email(string value);
57
+ static string? Min(number value, number min);
58
+ static string? Max(number value, number max);
59
+ } from "kopular/forms";
60
+ ```
61
+
62
+ **`extern class` has no `<T>` syntax** — `extern class FormField<T> { ... }` is a parse
63
+ error (`ExternClassDecl` has no `typeParam`, unlike a real `ClassDecl`/`InterfaceDecl`).
64
+ Describe one concrete instantiation per `T` you actually need instead, aliasing the same
65
+ real export with `as` (the same trust-based, per-shape approach `Http`'s typed-JSON gap
66
+ above already uses) — e.g. for a `FormField<string>`:
67
+
68
+ ```ks
69
+ extern class StringField {
70
+ constructor(string initial, (string) => string? validate);
71
+ state<string> Value;
72
+ state<string?> Error;
73
+ state<bool> Touched;
74
+ void Touch();
75
+ bool Valid();
76
+ } from "kopular/forms" as "FormField";
50
77
  ```
51
78
 
79
+ `Value`/`Error`/`Touched` are declared as bare properties (`state<T> Value;`, no
80
+ `{ get; }`) — `extern class` supports a plain field declaration for exactly this case,
81
+ not just get/set accessor pairs.
82
+
52
83
  You also need your own ambient DOM `extern` block (`document`, `Element`, `Event`, ...) —
53
84
  Kopular's own copy in `dom.ks` isn't reachable across the package boundary; redeclare the
54
85
  handful of members you actually use. See KopularDemo's `src/kopular_bindings.ks` for a
@@ -128,8 +159,38 @@ nav.Navigate("/about"); // pushState + immediate re-ren
128
159
  - `Navigate(path)` calls `history.pushState` then re-renders immediately. A browser
129
160
  back/forward triggers re-render via a `popstate` listener registered in the
130
161
  constructor — `Navigate()` itself doesn't rely on that event.
131
- - Needs a server that falls back to the app shell for any unrecognized path (a plain
132
- static server has nothing to serve at `/about` on direct load/refresh).
162
+ - **Dynamic route segments**: a pattern segment written `:name` (e.g. `AddRoute("/dogs/:id",
163
+ ...)`) matches any single non-empty path segment; every other segment must match
164
+ literally (same segment count required too — `/dogs` does NOT match `/dogs/:id`).
165
+ The captured value is `Router.Param` — a plain `string` field (**not** `state<T>`; no
166
+ `Subscribe()` needed — see below), `""` when the matched route has no `:` segment.
167
+ Just one dynamic segment per route in v1: no `/dogs/:id/toys/:toyId`, no wildcards, no
168
+ query string parsing.
169
+ - **Why `Param` is a plain field, not `state<T>`**: `Render()` already rebuilds a fresh
170
+ outlet and re-`Mount()`s the matched page on every `Navigate()`/`popstate`, which
171
+ re-runs that page's own `Render()` (reading the fresh `Param`) with no extra step.
172
+ Making it `state<T>` and having a page `Subscribe()` to it — the pattern every *other*
173
+ piece of state in this framework uses — actually crashes: `Match()` sets it *before*
174
+ `Render()` finishes swapping the matched page into the outlet, so the very first route
175
+ that matches a page nothing has `Mount()`ed yet fires that page's subscribed listener
176
+ while its inherited `Update()` still has no `ParentElement` to `replaceChild` into.
177
+ - **Every deployment target needs its own SPA/history-fallback config — this is
178
+ unavoidable, not a Kopular gap.** A direct load or refresh at `/about` is a plain HTTP
179
+ request that reaches your host *before* any JS (Router included) has run, so no
180
+ client-side router in any language/framework can intercept it; the host itself has to
181
+ respond with the app shell for any route it doesn't have a literal file for. Configure
182
+ this on every host you deploy to, not just in local dev:
183
+ - Local dev (`ks watch` + a static file server): see KopularDemo's `scripts/serve.mjs`
184
+ — falls back to `index.html` only for an extension-less path, so a genuinely missing
185
+ `.js`/`.css` still 404s.
186
+ - Cloudflare Workers (what KopularDemo itself deploys to): `wrangler.jsonc`'s
187
+ `assets.not_found_handling: "single-page-application"`. Coarser than `serve.mjs` —
188
+ it falls back for *any* unmatched request, extension or not, so a typo'd asset URL
189
+ silently serves the app shell instead of 404ing (confirmed via `wrangler dev`; no
190
+ config short of a custom Worker distinguishes the two cases).
191
+ - Any other static host (Netlify, Vercel, S3+CloudFront, nginx, ...) has an equivalent
192
+ "SPA fallback" / "custom 404 → index.html" option — look for that host's own docs on
193
+ single-page-application routing, the terminology is standard across all of them.
133
194
 
134
195
  ## `If` (`directives.ks`) — the `*ngIf` equivalent
135
196
 
@@ -176,7 +237,8 @@ r.status // number
176
237
  await r.text(); // task<string> — the raw body, nothing more
177
238
  ```
178
239
 
179
- **No typed JSON deserialization** — no generics means no safe `Get<T>(url): task<T>`.
240
+ **No typed JSON deserialization** — KopScript's generics are classes/interfaces only (no
241
+ generic functions/methods), so there's no safe `task<T> Get<T>(string url)`.
180
242
  Get a typed response by describing its shape as its own `extern class` and parsing with
181
243
  a per-shape `extern ... as "JSON.parse"` (unchecked, same trust model as every other
182
244
  `extern`):
@@ -194,6 +256,65 @@ hypothetical `Delete`-with-a-body) need one for `{ method, headers, body }`, whi
194
256
  KopScript categorically cannot construct — Kopular ships one small hand-written JS
195
257
  function (`http_runtime.js`, not compiled from `.ks`) that does, for exactly that reason.
196
258
 
259
+ ## `FormField<T>` / `Validators` (`forms.ks`)
260
+
261
+ ```ks
262
+ FormField<string> email = new FormField<string>("", (string v) => {
263
+ string? required = Validators.Required(v);
264
+ if (required != null) { return required; }
265
+ return Validators.Email(v);
266
+ });
267
+
268
+ email.Value.Value = "x"; // state<T> — revalidates automatically on assignment
269
+ email.Error.Value // string? — current validator's message, or null
270
+ email.Touched.Value // bool — only true after Touch() (call on blur)
271
+ email.Touch();
272
+ email.Valid(); // bool — Error.Value == null
273
+ ```
274
+
275
+ `Validators.Required/MinLength/MaxLength/Email` are `(string) => string?`;
276
+ `Validators.Min/Max` are `(number) => string?`. Each returns an error message or `null`.
277
+
278
+ **No array-of-validators parameter** — KopScript has no array-of-function-values type
279
+ (`((T) => string?)[]` doesn't parse: the parser reads a second `(...) => ...` immediately
280
+ after the first as a nested function type, not an array element type, and errors expecting
281
+ `=>`). Combine checks as an if-chain in one lambda instead (see the `email` example above)
282
+ — this is the same reason `FormField<T>`'s constructor takes exactly one validator
283
+ function, not a list.
284
+
285
+ **No DOM binding** — wiring `.Value` to a real `<input>` is a plain `addEventListener`
286
+ call in your own `Render()`, the same as any other event handler; there is no
287
+ `[(ngModel)]`-equivalent.
288
+
289
+ ## `kopular/testing` (`testing.js`, hand-written JS, not compiled from `.ks`)
290
+
291
+ ```ts
292
+ import { runKopularApp } from "kopular/testing";
293
+
294
+ const { window, cleanup } = await runKopularApp(srcDir, "app.ks", {
295
+ includeKopularPackage: true, // app consumes Kopular via `extern`, not relative `using`
296
+ extraFiles: ["http_runtime.js"], // non-.ks/.html siblings a `using` graph needs at runtime
297
+ fetchMock: (...args) => Promise.resolve({ ok: true, status: 200, text: () => Promise.resolve("body") }),
298
+ url: "http://localhost/",
299
+ });
300
+ try {
301
+ window.document.querySelector(...)
302
+ } finally {
303
+ cleanup(); // always call, even on a thrown assertion — restores globalThis + deletes the temp dir
304
+ }
305
+ ```
306
+
307
+ Compiles `entryFileName` (plus everything else in `srcDir` it `using`s) via kopscript's
308
+ `compileGraph`, binds jsdom onto `globalThis` (`document`/`Element`/`Event`/`location`/
309
+ `history`/`window`/`fetch`) for the compiled ambient `extern` declarations to find, and
310
+ runs it. `runKopularFixture(source, options)` is the sibling export for an inline fixture
311
+ string instead of a real file (used by Kopular's own test suite; copies Kopular's *own*
312
+ `.ks` sources alongside the fixture, so `using "./component"` resolves — only meaningful
313
+ for testing Kopular itself, not an external consumer, which should use `runKopularApp`
314
+ with `includeKopularPackage: true` instead).
315
+
316
+ `jsdom` is an optional peer dependency — add it to your own project to use this.
317
+
197
318
  ## Dependency injection — no container, no decorators
198
319
 
199
320
  There is no injector, no `@Injectable`, no provider tokens. "Injecting" a service is
package/README.md CHANGED
@@ -26,9 +26,8 @@ Generating Kopular code with an AI coding assistant? Point it at **[`LLM.md`](./
26
26
  class.
27
27
  - **`Router`**: real URLs (`/about`, not `#/about`) via the History API
28
28
  (`pushState`/`popstate`), with route registration as plain method calls, not a config
29
- DSL. Needs a server that falls back to the app shell for unrecognized paths — see
30
- [KopularDemo](https://dev.azure.com/koppinator/Koppindependence/_git/KopularDemo)'s
31
- `scripts/serve.mjs`.
29
+ DSL. See "Router, and deploying it" below every deployment target needs its own
30
+ SPA-fallback config, not just local dev.
32
31
  - **Structural directives, no template DSL**: `*ngIf`/`*ngFor`/`*ngSwitch`'s job — build
33
32
  a subtree conditionally, repeat one per item, pick one of several cases — done as plain
34
33
  function calls (`If(...)`) and existing KopScript expressions (`array.ForEach(...)`,
@@ -36,6 +35,8 @@ Generating Kopular code with an AI coding assistant? Point it at **[`LLM.md`](./
36
35
  - **`Http`**: a thin, static wrapper over the real Fetch API (`Http.Get(url)`,
37
36
  `Http.Post(url, jsonBody)`, ...) — no HttpClient injection tokens, no RxJS
38
37
  observables/operators. See "HTTP" below.
38
+ - **`FormField<T>`**: a single input's value/error/touched state, built on `state<T>` —
39
+ no two-way-binding magic, no `FormGroup` config object. See "Forms" below.
39
40
 
40
41
  ## What's here
41
42
 
@@ -48,14 +49,22 @@ Generating Kopular code with an AI coding assistant? Point it at **[`LLM.md`](./
48
49
  - `src/http.ks` — `Http`, a thin wrapper over `fetch` (see below). `src/http_runtime.js`
49
50
  is its one companion file — the single hand-written (not compiled from `.ks`) file in
50
51
  Kopular, and why is explained in its own header comment.
51
-
52
- That's the whole framework six files. Everything else (a real app built on top of it)
52
+ - `src/forms.ks` — `FormField<T>` and `Validators` (see "Forms" below).
53
+ - `src/testing.js``runKopularApp`/`runKopularFixture` (see "Testing your own app"
54
+ below); hand-written for the same reason as `http_runtime.js` — filesystem/process
55
+ orchestration isn't a Kopular `Component`.
56
+ - `bin/kp.mjs` — the `kp new` scaffolding CLI (see "Starting a new project" below); also
57
+ hand-written, same reason.
58
+
59
+ That's the whole framework — eight files, plus the scaffolding CLI. Everything else (a
60
+ real app built on top of it)
53
61
  lives in a separate consumer repo, [KopularDemo](https://dev.azure.com/koppinator/Koppindependence/_git/KopularDemo).
54
62
 
55
63
  ## Dependency injection: the composition root pattern
56
64
 
57
65
  Kopular has no injector because KopScript has nothing for one to hook into — no
58
- decorators, no reflection, no generics for a type-safe `Resolve<T>()`. Instead, the
66
+ decorators, no reflection, and no *generic functions* (KopScript's generics are
67
+ classes/interfaces only — see the Kop repo) for a type-safe `Resolve<T>()`. Instead, the
59
68
  whole app's service/page graph gets built exactly once, by hand, in one place: a plain
60
69
  class with no `Component` base and no framework code in it at all, sometimes called an
61
70
  **app container** or (in the wider DI literature) a **composition root**. Everything
@@ -119,6 +128,57 @@ See [KopularDemo](https://dev.azure.com/koppinator/Koppindependence/_git/Kopular
119
128
  `src/app_container.ks` and `src/routed_app.ks` for the real, working version this
120
129
  example is drawn from.
121
130
 
131
+ ## Router, and deploying it
132
+
133
+ ```ks
134
+ Router nav = new Router(new NotFoundPage()); // fallback page required up front — no null route
135
+ nav.AddRoute("/", new HomePage(nav)); // pages built once, kept alive for Router's lifetime
136
+ nav.AddRoute("/about", new AboutPage(nav));
137
+ nav.Mount(document.body);
138
+ nav.Navigate("/about"); // pushState + immediate re-render
139
+ ```
140
+
141
+ Real URLs via the History API (`pushState`/`popstate`), not `#/about` hash routing.
142
+ `AddRoute` takes an already-constructed `Component`, not a factory, so a page's own
143
+ `state<T>` survives navigating away and back — see "Dependency injection" above for how
144
+ the whole page graph typically gets built once, in a composition root.
145
+
146
+ **Dynamic route segments**: a path segment written `:name` (e.g. `/dogs/:id`) matches any
147
+ single non-empty segment, captured into `Router.Param`:
148
+
149
+ ```ks
150
+ nav.AddRoute("/dogs/:id", new DogPage(nav));
151
+ // inside DogPage.Render():
152
+ el.textContent = "Dog #" + this.Nav.Param;
153
+ ```
154
+
155
+ `Param` is a plain `string`, deliberately not `state<T>` — Router's own `Render()`
156
+ already rebuilds a fresh outlet and re-`Mount()`s the matched page on every
157
+ `Navigate()`/`popstate`, which re-runs that page's `Render()` (reading the fresh `Param`)
158
+ with no extra step. No `Subscribe()` needed on it. Deliberately just one dynamic segment
159
+ per route for now — no multiple params (`/dogs/:id/toys/:toyId`), no wildcards, no query
160
+ string parsing — each a real, separate extension, not an oversight.
161
+
162
+ **Deploying a Router-based app needs SPA/history-fallback configured on whatever you
163
+ deploy to — this is true of every client-side router in every framework, not a Kopular
164
+ gap.** A direct load or a refresh at `/about` is a plain HTTP request that reaches your
165
+ host before any JS has run, so nothing client-side (Router included) can intercept it;
166
+ the host has to serve the app shell itself for any route it has no literal file for.
167
+ KopularDemo hit exactly this in production (worked when navigated to via a link, 404'd on
168
+ refresh) before its Cloudflare Workers config had this set:
169
+
170
+ ```jsonc
171
+ // wrangler.jsonc
172
+ "assets": {
173
+ "directory": "./public",
174
+ "not_found_handling": "single-page-application"
175
+ }
176
+ ```
177
+
178
+ Every static host has an equivalent option (Netlify, Vercel, nginx, ...) — search that
179
+ host's docs for "SPA fallback" or "single-page application routing", the terminology is
180
+ standard. For local dev, see KopularDemo's `scripts/serve.mjs`.
181
+
122
182
  ## Structural directives
123
183
 
124
184
  Angular's `*ngIf`/`*ngFor`/`*ngSwitch` are template syntax that expands, at compile time,
@@ -209,8 +269,9 @@ anywhere, including straight out of a service's own methods.
209
269
 
210
270
  **No typed JSON deserialization** — `Response.text()` gets you the raw body, nothing
211
271
  more. This isn't a corner cut for v1; it's a direct consequence of two things KopScript
212
- doesn't have: generics (so there's no safe way to write a general `Get<T>(url):
213
- task<T>`) and object-literal syntax (`{ ... }` as a value see below). If you want a
272
+ doesn't have: generic *functions/methods* (KopScript's generics are classes/interfaces
273
+ only, so there's no safe way to write a general `task<T> Get<T>(string url)`) and
274
+ object-literal syntax (`{ ... }` as a value — see below). If you want a
214
275
  typed response, describe its shape as its own `extern class` and parse it yourself with
215
276
  a per-shape `extern ... as "JSON.parse"` declaration — the same trust-based approach
216
277
  `extern` already uses for everything else, not a new mechanism:
@@ -235,6 +296,49 @@ real global). It's the one file in this package not compiled from `.ks` — ever
235
296
  else avoids the problem by only wrapping JS APIs that take plain positional arguments
236
297
  (see `dom.ks`'s `addEventListener(string, handler)`, never an options-object-taking API).
237
298
 
299
+ ## Forms
300
+
301
+ ```ks
302
+ using "./forms";
303
+
304
+ FormField<string> email = new FormField<string>("", (string v) => {
305
+ string? required = Validators.Required(v);
306
+ if (required != null) { return required; }
307
+ return Validators.Email(v);
308
+ });
309
+
310
+ email.Value.Value = "not-an-email";
311
+ print(email.Error.Value); // "Must be a valid email"
312
+ print(email.Valid()); // false
313
+
314
+ emailInput.addEventListener("input", (Event e) => {
315
+ email.Value.Value = emailInput.textContent; // revalidates automatically
316
+ });
317
+ emailInput.addEventListener("blur", (Event e) => { email.Touch(); });
318
+ ```
319
+
320
+ `FormField<T>` holds one input's value, error, and touched state as three ordinary
321
+ `state<T>` boxes — `.Value` (the input's current value, revalidating on every
322
+ assignment), `.Error` (`string?`, the current validator's message or `null`), and
323
+ `.Touched` (`bool`, set by calling `.Touch()` — typically on blur, so a fresh field with
324
+ an invalid initial value like an empty required field doesn't show an error before the
325
+ user has typed anything). Subscribe to any of the three from your `Component`'s
326
+ constructor exactly like `Counter`'s own `state<number>`, to re-render when they change.
327
+
328
+ A validator is a plain `(T) => string?` — `null` means valid, the same convention
329
+ KopScript's own nullable types use elsewhere. **There's no array-of-validators
330
+ constructor parameter** — KopScript has no syntax for an array of function values — so
331
+ combining more than one check (as `email` does above) is just an `if`-chain in one
332
+ lambda, not a combinator API. `Validators` ships the handful of checks almost every form
333
+ needs (`Required`, `MinLength`, `MaxLength`, `Email`, `Min`, `Max`), each returning its
334
+ own message; write your own validator function for anything more specific.
335
+
336
+ **No two-way data binding** — wiring `Value` to a real `<input>` is the
337
+ `addEventListener` call shown above, the same manual pattern `Counter` already uses for
338
+ its click handler. This is deliberate, not a missing feature: a magic `[(ngModel)]`-style
339
+ binding would be exactly the kind of hidden framework behavior Kopular avoids everywhere
340
+ else.
341
+
238
342
  ## Starting a new project: `kp new`
239
343
 
240
344
  Everything in the next section — the `extern` bindings, plus a `vendor/kopular/` copy of
@@ -291,6 +395,42 @@ Marking `Render()` `virtual` in the `extern` declaration is what lets a real sub
291
395
  for a full working example (components, a service, and routing, all consuming Kopular
292
396
  this way).
293
397
 
398
+ `extern class` has no `<T>` syntax, so a generic export like `FormField<T>` can't be
399
+ described directly this way — see `LLM.md`'s `FormField<T>`/`Validators` section for the
400
+ per-concrete-type workaround (the same trust-based, per-shape approach the "HTTP" section
401
+ above uses for typed JSON).
402
+
403
+ ## Testing your own app: `kopular/testing`
404
+
405
+ A `Component`/`Router` graph can only be exercised end-to-end by actually compiling its
406
+ `.ks` sources and running the result against a real DOM — there's no way to unit test one
407
+ otherwise. Doing that by hand is a real ~50-line dance (a fresh temp dir per test, since
408
+ Node's ESM module cache means re-importing the same compiled path twice never re-runs an
409
+ ambient extern binding's top-level code — silently binding every later test to the first
410
+ test's jsdom instance — compiling via `kopscript`'s `compileGraph`, binding jsdom onto
411
+ `globalThis` for `document`/`Element`/... to find, then restoring it). Both Kopular's own
412
+ test suite and KopularDemo's used to hand-roll this independently; `kopular/testing` is
413
+ that dance, written once:
414
+
415
+ ```ts
416
+ import { runKopularApp } from "kopular/testing";
417
+
418
+ const { window, cleanup } = await runKopularApp(join(__dirname, "..", "src"), "app.ks", {
419
+ includeKopularPackage: true, // your app consumes Kopular via `extern`, not relative `using`
420
+ });
421
+ try {
422
+ expect(window.document.querySelector("h1")?.textContent).toBe("Hello");
423
+ } finally {
424
+ cleanup(); // always — even on a thrown assertion — or the next test inherits these globals
425
+ }
426
+ ```
427
+
428
+ `jsdom` is an **optional peer dependency** — installing `kopular` alone doesn't pull it
429
+ in; only a project that actually calls `runKopularApp` needs it added too. See
430
+ `Kopular/test/kopular.test.ts` (`runKopularFixture`, the sibling export used for testing
431
+ Kopular's own source against inline fixtures) and KopularDemo's
432
+ `test/routed_app.test.ts` for two real, different call sites.
433
+
294
434
  ## Getting started (developing Kopular itself)
295
435
 
296
436
  ```bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kopular",
3
- "version": "0.5.0",
3
+ "version": "0.8.0",
4
4
  "description": "Kopular: a small component framework for KopScript — components, reactive state, constructor-injected services, routing, structural directives, and HTTP, with no template DSL and no DI container",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -25,7 +25,9 @@
25
25
  "./router": "./src/router.js",
26
26
  "./dom": "./src/dom.js",
27
27
  "./directives": "./src/directives.js",
28
- "./http": "./src/http.js"
28
+ "./http": "./src/http.js",
29
+ "./forms": "./src/forms.js",
30
+ "./testing": "./src/testing.js"
29
31
  },
30
32
  "files": [
31
33
  "src",
@@ -34,13 +36,21 @@
34
36
  "LLM.md"
35
37
  ],
36
38
  "scripts": {
37
- "build": "ks build src/router.ks && ks build src/directives.ks && ks build src/http.ks",
39
+ "build": "ks build src/router.ks && ks build src/directives.ks && ks build src/http.ks && ks build src/forms.ks",
38
40
  "prepublishOnly": "npm run build",
39
41
  "test": "vitest run",
40
42
  "test:watch": "vitest"
41
43
  },
44
+ "peerDependencies": {
45
+ "jsdom": "^25.0.0"
46
+ },
47
+ "peerDependenciesMeta": {
48
+ "jsdom": {
49
+ "optional": true
50
+ }
51
+ },
42
52
  "devDependencies": {
43
- "kopscript": "^0.4.0",
53
+ "kopscript": "^0.4.1",
44
54
  "@types/jsdom": "^30.0.0",
45
55
  "@types/node": "^20.14.0",
46
56
  "jsdom": "^25.0.1",
package/src/forms.js ADDED
@@ -0,0 +1,81 @@
1
+ class __KopState {
2
+ constructor(value) {
3
+ this._value = value;
4
+ this._listeners = [];
5
+ }
6
+ get Value() { return this._value; }
7
+ set Value(v) {
8
+ this._value = v;
9
+ for (const listener of this._listeners) listener(v);
10
+ }
11
+ Subscribe(listener) {
12
+ this._listeners.push(listener);
13
+ }
14
+ }
15
+
16
+ export class FormField {
17
+ constructor(initial, validate) {
18
+ (this.Value = new __KopState(initial));
19
+ (this.Error = new __KopState(validate(initial)));
20
+ (this.Touched = new __KopState(false));
21
+ this.Value.Subscribe((v) => {
22
+ (this.Error.Value = validate(v));
23
+ });
24
+ }
25
+
26
+ Touch() {
27
+ (this.Touched.Value = true);
28
+ }
29
+
30
+ Valid() {
31
+ return (this.Error.Value === null);
32
+ }
33
+ }
34
+ export class Validators {
35
+ static Required(value) {
36
+ if ((value.trim().length === 0)) {
37
+ return "Required";
38
+ }
39
+ return null;
40
+ }
41
+
42
+ static MinLength(value, min) {
43
+ if ((value.length < min)) {
44
+ return `Must be at least ${min} characters`;
45
+ }
46
+ return null;
47
+ }
48
+
49
+ static MaxLength(value, max) {
50
+ if ((value.length > max)) {
51
+ return `Must be at most ${max} characters`;
52
+ }
53
+ return null;
54
+ }
55
+
56
+ static Email(value) {
57
+ return (() => {
58
+ const __subject0 = value;
59
+ if (new RegExp("^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$").test(__subject0)) {
60
+ return null;
61
+ }
62
+ else {
63
+ return "Must be a valid email";
64
+ }
65
+ })();
66
+ }
67
+
68
+ static Min(value, min) {
69
+ if ((value < min)) {
70
+ return `Must be at least ${min}`;
71
+ }
72
+ return null;
73
+ }
74
+
75
+ static Max(value, max) {
76
+ if ((value > max)) {
77
+ return `Must be at most ${max}`;
78
+ }
79
+ return null;
80
+ }
81
+ }
package/src/forms.ks ADDED
@@ -0,0 +1,77 @@
1
+ // FormField<T>: a single form input's value, validation state, and touched
2
+ // flag, built on the same state<T> reactivity Component already uses — no
3
+ // new reactive primitive, and deliberately no DOM binding of its own
4
+ // (wiring Value to a real <input> is one addEventListener call in your own
5
+ // Render(), the same as Counter's own click handler — see Kopular's README).
6
+ //
7
+ // A validator is a plain (T) => string? function: null means valid, the
8
+ // same "null means nothing to report" convention KopScript's nullable
9
+ // types already use elsewhere. There's no array of validators — KopScript
10
+ // has no syntax for an array of function values — so combining more than
11
+ // one check is just an if-chain in one lambda:
12
+ //
13
+ // FormField<string> name = new FormField<string>("", (string v) => {
14
+ // string? required = Validators.Required(v);
15
+ // if (required != null) { return required; }
16
+ // return Validators.MaxLength(v, 40);
17
+ // });
18
+ class FormField<T> {
19
+ public state<T> Value;
20
+ public state<string?> Error;
21
+ public state<bool> Touched;
22
+
23
+ constructor(T initial, (T) => string? validate) {
24
+ this.Value = state(initial);
25
+ this.Error = state(validate(initial));
26
+ this.Touched = state(false);
27
+ this.Value.Subscribe((T v) => { this.Error.Value = validate(v); });
28
+ }
29
+
30
+ // Call on blur — separate from Error so a fresh, untouched field with an
31
+ // invalid initial value (e.g. Required on an empty string) doesn't show
32
+ // an error message before the user has had a chance to type anything.
33
+ public void Touch() {
34
+ this.Touched.Value = true;
35
+ }
36
+
37
+ public bool Valid() {
38
+ return this.Error.Value == null;
39
+ }
40
+ }
41
+
42
+ // A small set of common checks, each returning an error message or null —
43
+ // not a validation framework, just the handful of checks almost every form
44
+ // needs, so most fields don't have to hand-write string-length arithmetic.
45
+ class Validators {
46
+ public static string? Required(string value) {
47
+ if (value.Trim().Length == 0) { return "Required"; }
48
+ return null;
49
+ }
50
+
51
+ public static string? MinLength(string value, number min) {
52
+ if (value.Length < min) { return $"Must be at least {min} characters"; }
53
+ return null;
54
+ }
55
+
56
+ public static string? MaxLength(string value, number max) {
57
+ if (value.Length > max) { return $"Must be at most {max} characters"; }
58
+ return null;
59
+ }
60
+
61
+ public static string? Email(string value) {
62
+ return match value {
63
+ r"^[^@\s]+@[^@\s]+\.[^@\s]+$" => null,
64
+ _ => "Must be a valid email"
65
+ };
66
+ }
67
+
68
+ public static string? Min(number value, number min) {
69
+ if (value < min) { return $"Must be at least {min}"; }
70
+ return null;
71
+ }
72
+
73
+ public static string? Max(number value, number max) {
74
+ if (value > max) { return $"Must be at most {max}"; }
75
+ return null;
76
+ }
77
+ }
package/src/router.js CHANGED
@@ -7,6 +7,7 @@ export class Router extends Component {
7
7
  (this.Paths = []);
8
8
  (this.Pages = []);
9
9
  (this.NotFoundPage = notFoundPage);
10
+ (this.Param = "");
10
11
  window.addEventListener("popstate", (e) => (this.Update()));
11
12
  }
12
13
 
@@ -21,12 +22,31 @@ export class Router extends Component {
21
22
  }
22
23
 
23
24
  Match(path) {
25
+ let pathSegments = path.split("/");
24
26
  let found = this.NotFoundPage;
27
+ let param = "";
25
28
  for (let i = 0; (i < this.Paths.length); (i = (i + 1))) {
26
- if ((this.Paths[i] === path)) {
29
+ let patternSegments = this.Paths[i].split("/");
30
+ if ((patternSegments.length !== pathSegments.length)) {
31
+ continue;
32
+ }
33
+ let matched = true;
34
+ let capturedParam = "";
35
+ for (let j = 0; (j < patternSegments.length); (j = (j + 1))) {
36
+ if (patternSegments[j].startsWith(":")) {
37
+ (capturedParam = pathSegments[j]);
38
+ } else if ((patternSegments[j] !== pathSegments[j])) {
39
+ (matched = false);
40
+ break;
41
+ }
42
+ }
43
+ if (matched) {
27
44
  (found = this.Pages[i]);
45
+ (param = capturedParam);
46
+ break;
28
47
  }
29
48
  }
49
+ (this.Param = param);
30
50
  return found;
31
51
  }
32
52
 
package/src/router.ks CHANGED
@@ -28,10 +28,25 @@ 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.
33
+ //
34
+ // A plain field, deliberately **not** state<T> — Render() below always
35
+ // rebuilds a fresh outlet and re-Mount()s the matched page into it on
36
+ // every Navigate()/popstate, which already re-runs that page's own
37
+ // Render() (reading the fresh Param) with no extra step. Subscribing to
38
+ // it, the way a page reacts to its *own* state<T>, would fire during
39
+ // Match() itself — before Render() has finished swapping the matched page
40
+ // in — which crashes the very first time a route no page has been
41
+ // Mount()ed into yet matches, since a not-yet-mounted Component's
42
+ // inherited Update() has no ParentElement to replaceChild into.
43
+ public string Param;
44
+
31
45
  constructor(Component notFoundPage) : base() {
32
46
  this.Paths = [];
33
47
  this.Pages = [];
34
48
  this.NotFoundPage = notFoundPage;
49
+ this.Param = "";
35
50
  // 'popstate' only fires on browser back/forward (or history.go/back/
36
51
  // forward) — never on pushState itself, unlike hashchange firing
37
52
  // whenever location.hash is set. Navigate() below calls Update()
@@ -40,6 +55,11 @@ class Router : Component {
40
55
  window.addEventListener("popstate", (Event e) => this.Update());
41
56
  }
42
57
 
58
+ // A path segment written ":name" (e.g. "dogs/:id") matches any single
59
+ // non-empty segment; every other segment must match literally. Comparing
60
+ // segment-by-segment (rather than the whole string at once) is what makes
61
+ // a static route's own matching still exactly as strict as a plain `==`
62
+ // was before — same segment count, same literal text throughout.
43
63
  public void AddRoute(string path, Component page) {
44
64
  this.Paths = this.Paths.Push(path);
45
65
  this.Pages = this.Pages.Push(page);
@@ -51,12 +71,31 @@ class Router : Component {
51
71
  }
52
72
 
53
73
  private Component Match(string path) {
74
+ string[] pathSegments = path.Split("/");
54
75
  Component found = this.NotFoundPage;
76
+ string param = "";
55
77
  for (number i = 0; i < this.Paths.Length; i = i + 1) {
56
- if (this.Paths[i] == path) {
78
+ string[] patternSegments = this.Paths[i].Split("/");
79
+ if (patternSegments.Length != pathSegments.Length) { continue; }
80
+
81
+ bool matched = true;
82
+ string capturedParam = "";
83
+ for (number j = 0; j < patternSegments.Length; j = j + 1) {
84
+ if (patternSegments[j].StartsWith(":")) {
85
+ capturedParam = pathSegments[j];
86
+ } else if (patternSegments[j] != pathSegments[j]) {
87
+ matched = false;
88
+ break;
89
+ }
90
+ }
91
+
92
+ if (matched) {
57
93
  found = this.Pages[i];
94
+ param = capturedParam;
95
+ break;
58
96
  }
59
97
  }
98
+ this.Param = param;
60
99
  return found;
61
100
  }
62
101
 
package/src/testing.js ADDED
@@ -0,0 +1,166 @@
1
+ // A real Kopular app can only be exercised end-to-end by actually compiling
2
+ // its .ks sources and running the result in a DOM — there's no way to unit
3
+ // test a Component/Router graph without one. Every test suite that's tried
4
+ // this so far (Kopular's own, and KopularDemo's) ended up hand-rolling the
5
+ // same ~50-line dance: a fresh temp dir per test (Node's ESM module cache
6
+ // means re-importing the same compiled path twice never re-runs an ambient
7
+ // extern binding's top-level code — silently binding every test after the
8
+ // first to the first test's jsdom instance), compiling via kopscript's
9
+ // compileGraph, binding jsdom onto globalThis for the ambient `document`/
10
+ // `Element`/... extern declarations to find, then restoring/cleaning up.
11
+ // This is that dance, written once.
12
+ //
13
+ // Hand-written plain JS, not compiled from a .ks source, the same as
14
+ // http_runtime.js and bin/kp.mjs: filesystem/process orchestration isn't a
15
+ // Kopular Component.
16
+ //
17
+ // `jsdom` is a peer dependency (optional) — only installed by a consumer
18
+ // who actually calls this, not a weight every `kopular` install pays.
19
+ import { JSDOM } from "jsdom";
20
+ import { compileGraph } from "kopscript";
21
+ import { cpSync, mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from "node:fs";
22
+ import { tmpdir } from "node:os";
23
+ import { dirname, join } from "node:path";
24
+ import { fileURLToPath } from "node:url";
25
+
26
+ const GLOBAL_KEYS = ["document", "Element", "Event", "location", "history", "window", "fetch"];
27
+ const OWN_SRC_DIR = dirname(fileURLToPath(import.meta.url));
28
+
29
+ // Copies every .ks/.html file from `srcDir` into `dir`, plus any filename
30
+ // listed in `extraFiles` (e.g. http_runtime.js — a hand-written sibling a
31
+ // compiled `using` graph needs at runtime, which the compiler wouldn't know
32
+ // to copy on its own).
33
+ function copySources(srcDir, dir, extraFiles) {
34
+ for (const name of readdirSync(srcDir)) {
35
+ if (name.endsWith(".ks") || name.endsWith(".html") || extraFiles.includes(name)) {
36
+ cpSync(join(srcDir, name), join(dir, name));
37
+ }
38
+ }
39
+ }
40
+
41
+ // kopscript's compiled output is genuine ESM (`export class ...`), which
42
+ // Node only interprets correctly given a ".mjs" extension or an ancestor
43
+ // package.json declaring "type": "module" — a bare os.tmpdir() has neither,
44
+ // so the dynamic `import()` below would otherwise fail with "Cannot use
45
+ // import statement outside a module" the moment anything outside this
46
+ // file's own module graph (a real npm package, not code vitest/vite-node
47
+ // itself transforms) tries to load it.
48
+ function mkTempDir() {
49
+ const dir = mkdtempSync(join(tmpdir(), "kopular-testing-"));
50
+ writeFileSync(join(dir, "package.json"), JSON.stringify({ type: "module" }), "utf-8");
51
+ return dir;
52
+ }
53
+
54
+ // Copies this real, installed `kopular` package into the temp dir's own
55
+ // node_modules — for an entry that consumes Kopular the way a separate
56
+ // project does, through `extern ... from "kopular/..."` bare specifiers,
57
+ // not via relative `using`. Resolved off this very file's own location, so
58
+ // it's always the real package as installed in the *caller's*
59
+ // node_modules, not a guess at a path.
60
+ function copyKopularPackage(dir) {
61
+ mkdirSync(join(dir, "node_modules"), { recursive: true });
62
+ cpSync(join(OWN_SRC_DIR, ".."), join(dir, "node_modules", "kopular"), { recursive: true });
63
+ }
64
+
65
+ // Shared by both exports below: compiles `entryFileName` (already written
66
+ // into `dir`, alongside everything it `using`s) and runs it against a fresh
67
+ // jsdom instance.
68
+ async function compileAndRun(dir, entryFileName, options) {
69
+ const entry = join(dir, entryFileName);
70
+ const result = compileGraph(entry);
71
+ if (!result.success) {
72
+ rmSync(dir, { recursive: true, force: true });
73
+ const messages = [...result.modules.values()]
74
+ .filter((m) => m.diagnostics.hasErrors)
75
+ .map((m) => m.diagnostics.format(m.source, m.absPath))
76
+ .join("\n\n");
77
+ throw new Error(messages || "Compilation failed");
78
+ }
79
+ for (const [absPath, js] of result.outputs) {
80
+ writeFileSync(absPath.replace(/\.ks$/, ".js"), js, "utf-8");
81
+ }
82
+
83
+ const dom = new JSDOM("<!doctype html><html><body></body></html>", { url: options.url ?? "http://localhost/" });
84
+ const g = globalThis;
85
+ const previous = {};
86
+ for (const key of GLOBAL_KEYS) previous[key] = g[key];
87
+ g.document = dom.window.document;
88
+ g.Element = dom.window.Element;
89
+ g.Event = dom.window.Event;
90
+ g.location = dom.window.location;
91
+ g.history = dom.window.history;
92
+ g.window = dom.window;
93
+ if (options.fetchMock) g.fetch = options.fetchMock;
94
+
95
+ let cleanedUp = false;
96
+ const cleanup = () => {
97
+ if (cleanedUp) return;
98
+ cleanedUp = true;
99
+ for (const key of GLOBAL_KEYS) g[key] = previous[key];
100
+ rmSync(dir, { recursive: true, force: true });
101
+ };
102
+
103
+ try {
104
+ await import(entry.replace(/\.ks$/, ".js"));
105
+ } catch (err) {
106
+ cleanup();
107
+ throw err;
108
+ }
109
+
110
+ return { window: dom.window, cleanup };
111
+ }
112
+
113
+ /**
114
+ * Compiles a real KopScript app — `entryFileName` plus everything else in
115
+ * `srcDir` it `using`s — and runs it against a fresh jsdom instance. For a
116
+ * real project's own src/ directory, the way KopularDemo's test suite (or
117
+ * any app consuming `kopular/...` via `extern`) would use it.
118
+ *
119
+ * @param {string} srcDir - directory containing the entry file and
120
+ * everything it `using`s.
121
+ * @param {string} entryFileName - the entry .ks file's name within srcDir.
122
+ * @param {object} [options]
123
+ * @param {boolean} [options.includeKopularPackage] - copy the real
124
+ * installed `kopular` package into a temp node_modules/, for an entry
125
+ * that consumes Kopular via `extern ... from "kopular/..."` rather than
126
+ * relative `using`. Default false.
127
+ * @param {string[]} [options.extraFiles] - extra filenames (not .ks/.html)
128
+ * to copy from srcDir, e.g. ["http_runtime.js"].
129
+ * @param {(...args: unknown[]) => unknown} [options.fetchMock] - replaces
130
+ * the global `fetch` for the duration of the run, for testing `Http`.
131
+ * @param {string} [options.url] - the jsdom document's URL. Default
132
+ * "http://localhost/".
133
+ * @returns {Promise<{ window: import("jsdom").DOMWindow, cleanup: () => void }>}
134
+ * `cleanup()` restores globals and deletes the temp dir — call it in an
135
+ * `afterEach`/`finally`, always, even on a thrown error, or the next test
136
+ * silently inherits this one's globals.
137
+ */
138
+ export async function runKopularApp(srcDir, entryFileName, options = {}) {
139
+ const dir = mkTempDir();
140
+ copySources(srcDir, dir, options.extraFiles ?? []);
141
+ if (options.includeKopularPackage) copyKopularPackage(dir);
142
+ return compileAndRun(dir, entryFileName, options);
143
+ }
144
+
145
+ /**
146
+ * Like `runKopularApp`, but for a fixture written inline as a string rather
147
+ * than a real file on disk — for testing Kopular's *own* source files
148
+ * (`using "./component"`, `using "./router"`, ...) against a tiny
149
+ * throwaway component, the way Kopular's own test suite does. Copies
150
+ * Kopular's own .ks sources (resolved from this very file's own directory)
151
+ * alongside the fixture, so a relative `using "./dom"` in `source` resolves
152
+ * correctly — this only makes sense for testing Kopular itself, not for an
153
+ * external consumer (which would `extern ... from "kopular/..."` instead;
154
+ * see `runKopularApp` for that case).
155
+ *
156
+ * @param {string} source - the entry file's full KopScript source.
157
+ * @param {object} [options] - same as `runKopularApp`, plus:
158
+ * @param {string} [options.entryFileName] - default "main.ks".
159
+ */
160
+ export async function runKopularFixture(source, options = {}) {
161
+ const dir = mkTempDir();
162
+ copySources(OWN_SRC_DIR, dir, options.extraFiles ?? ["http_runtime.js"]);
163
+ const entryFileName = options.entryFileName ?? "main.ks";
164
+ writeFileSync(join(dir, entryFileName), source, "utf-8");
165
+ return compileAndRun(dir, entryFileName, options);
166
+ }