kopular 0.5.0 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LLM.md CHANGED
@@ -1,17 +1,17 @@
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 7 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). Also ships a bin,
12
+ `kp` — `npx kp new <dir>` scaffolds a new project (the `extern` bindings below, plus the
13
+ vendor/serve scripts needed to run in a browser) rather than requiring it be reconstructed
14
+ by hand; prefer it over hand-writing the section below for a new project.
15
15
 
16
16
  ## Consuming Kopular from your own KopScript project
17
17
 
@@ -47,8 +47,38 @@ extern class Http {
47
47
  static task<Response> Patch(string url, string jsonBody);
48
48
  static task<Response> Delete(string url);
49
49
  } from "kopular/http";
50
+
51
+ extern class Validators {
52
+ static string? Required(string value);
53
+ static string? MinLength(string value, number min);
54
+ static string? MaxLength(string value, number max);
55
+ static string? Email(string value);
56
+ static string? Min(number value, number min);
57
+ static string? Max(number value, number max);
58
+ } from "kopular/forms";
59
+ ```
60
+
61
+ **`extern class` has no `<T>` syntax** — `extern class FormField<T> { ... }` is a parse
62
+ error (`ExternClassDecl` has no `typeParam`, unlike a real `ClassDecl`/`InterfaceDecl`).
63
+ Describe one concrete instantiation per `T` you actually need instead, aliasing the same
64
+ real export with `as` (the same trust-based, per-shape approach `Http`'s typed-JSON gap
65
+ above already uses) — e.g. for a `FormField<string>`:
66
+
67
+ ```ks
68
+ extern class StringField {
69
+ constructor(string initial, (string) => string? validate);
70
+ state<string> Value;
71
+ state<string?> Error;
72
+ state<bool> Touched;
73
+ void Touch();
74
+ bool Valid();
75
+ } from "kopular/forms" as "FormField";
50
76
  ```
51
77
 
78
+ `Value`/`Error`/`Touched` are declared as bare properties (`state<T> Value;`, no
79
+ `{ get; }`) — `extern class` supports a plain field declaration for exactly this case,
80
+ not just get/set accessor pairs.
81
+
52
82
  You also need your own ambient DOM `extern` block (`document`, `Element`, `Event`, ...) —
53
83
  Kopular's own copy in `dom.ks` isn't reachable across the package boundary; redeclare the
54
84
  handful of members you actually use. See KopularDemo's `src/kopular_bindings.ks` for a
@@ -128,8 +158,23 @@ nav.Navigate("/about"); // pushState + immediate re-ren
128
158
  - `Navigate(path)` calls `history.pushState` then re-renders immediately. A browser
129
159
  back/forward triggers re-render via a `popstate` listener registered in the
130
160
  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).
161
+ - **Every deployment target needs its own SPA/history-fallback config this is
162
+ unavoidable, not a Kopular gap.** A direct load or refresh at `/about` is a plain HTTP
163
+ request that reaches your host *before* any JS (Router included) has run, so no
164
+ client-side router in any language/framework can intercept it; the host itself has to
165
+ respond with the app shell for any route it doesn't have a literal file for. Configure
166
+ this on every host you deploy to, not just in local dev:
167
+ - Local dev (`ks watch` + a static file server): see KopularDemo's `scripts/serve.mjs`
168
+ — falls back to `index.html` only for an extension-less path, so a genuinely missing
169
+ `.js`/`.css` still 404s.
170
+ - Cloudflare Workers (what KopularDemo itself deploys to): `wrangler.jsonc`'s
171
+ `assets.not_found_handling: "single-page-application"`. Coarser than `serve.mjs` —
172
+ it falls back for *any* unmatched request, extension or not, so a typo'd asset URL
173
+ silently serves the app shell instead of 404ing (confirmed via `wrangler dev`; no
174
+ config short of a custom Worker distinguishes the two cases).
175
+ - Any other static host (Netlify, Vercel, S3+CloudFront, nginx, ...) has an equivalent
176
+ "SPA fallback" / "custom 404 → index.html" option — look for that host's own docs on
177
+ single-page-application routing, the terminology is standard across all of them.
133
178
 
134
179
  ## `If` (`directives.ks`) — the `*ngIf` equivalent
135
180
 
@@ -176,7 +221,8 @@ r.status // number
176
221
  await r.text(); // task<string> — the raw body, nothing more
177
222
  ```
178
223
 
179
- **No typed JSON deserialization** — no generics means no safe `Get<T>(url): task<T>`.
224
+ **No typed JSON deserialization** — KopScript's generics are classes/interfaces only (no
225
+ generic functions/methods), so there's no safe `task<T> Get<T>(string url)`.
180
226
  Get a typed response by describing its shape as its own `extern class` and parsing with
181
227
  a per-shape `extern ... as "JSON.parse"` (unchecked, same trust model as every other
182
228
  `extern`):
@@ -194,6 +240,36 @@ hypothetical `Delete`-with-a-body) need one for `{ method, headers, body }`, whi
194
240
  KopScript categorically cannot construct — Kopular ships one small hand-written JS
195
241
  function (`http_runtime.js`, not compiled from `.ks`) that does, for exactly that reason.
196
242
 
243
+ ## `FormField<T>` / `Validators` (`forms.ks`)
244
+
245
+ ```ks
246
+ FormField<string> email = new FormField<string>("", (string v) => {
247
+ string? required = Validators.Required(v);
248
+ if (required != null) { return required; }
249
+ return Validators.Email(v);
250
+ });
251
+
252
+ email.Value.Value = "x"; // state<T> — revalidates automatically on assignment
253
+ email.Error.Value // string? — current validator's message, or null
254
+ email.Touched.Value // bool — only true after Touch() (call on blur)
255
+ email.Touch();
256
+ email.Valid(); // bool — Error.Value == null
257
+ ```
258
+
259
+ `Validators.Required/MinLength/MaxLength/Email` are `(string) => string?`;
260
+ `Validators.Min/Max` are `(number) => string?`. Each returns an error message or `null`.
261
+
262
+ **No array-of-validators parameter** — KopScript has no array-of-function-values type
263
+ (`((T) => string?)[]` doesn't parse: the parser reads a second `(...) => ...` immediately
264
+ after the first as a nested function type, not an array element type, and errors expecting
265
+ `=>`). Combine checks as an if-chain in one lambda instead (see the `email` example above)
266
+ — this is the same reason `FormField<T>`'s constructor takes exactly one validator
267
+ function, not a list.
268
+
269
+ **No DOM binding** — wiring `.Value` to a real `<input>` is a plain `addEventListener`
270
+ call in your own `Render()`, the same as any other event handler; there is no
271
+ `[(ngModel)]`-equivalent.
272
+
197
273
  ## Dependency injection — no container, no decorators
198
274
 
199
275
  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,20 @@ 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.
52
+ - `src/forms.ks` — `FormField<T>` and `Validators` (see "Forms" below).
53
+ - `bin/kp.mjs` — the `kp new` scaffolding CLI (see "Starting a new project" below); the
54
+ one hand-written (not compiled from `.ks`) file besides `http_runtime.js`, for the same
55
+ reason — filesystem scaffolding isn't a Kopular `Component`.
51
56
 
52
- That's the whole framework — six files. Everything else (a real app built on top of it)
57
+ That's the whole framework — seven files, plus the scaffolding CLI. Everything else (a
58
+ real app built on top of it)
53
59
  lives in a separate consumer repo, [KopularDemo](https://dev.azure.com/koppinator/Koppindependence/_git/KopularDemo).
54
60
 
55
61
  ## Dependency injection: the composition root pattern
56
62
 
57
63
  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
64
+ decorators, no reflection, and no *generic functions* (KopScript's generics are
65
+ classes/interfaces only — see the Kop repo) for a type-safe `Resolve<T>()`. Instead, the
59
66
  whole app's service/page graph gets built exactly once, by hand, in one place: a plain
60
67
  class with no `Component` base and no framework code in it at all, sometimes called an
61
68
  **app container** or (in the wider DI literature) a **composition root**. Everything
@@ -119,6 +126,41 @@ See [KopularDemo](https://dev.azure.com/koppinator/Koppindependence/_git/Kopular
119
126
  `src/app_container.ks` and `src/routed_app.ks` for the real, working version this
120
127
  example is drawn from.
121
128
 
129
+ ## Router, and deploying it
130
+
131
+ ```ks
132
+ Router nav = new Router(new NotFoundPage()); // fallback page required up front — no null route
133
+ nav.AddRoute("/", new HomePage(nav)); // pages built once, kept alive for Router's lifetime
134
+ nav.AddRoute("/about", new AboutPage(nav));
135
+ nav.Mount(document.body);
136
+ nav.Navigate("/about"); // pushState + immediate re-render
137
+ ```
138
+
139
+ Real URLs via the History API (`pushState`/`popstate`), not `#/about` hash routing.
140
+ `AddRoute` takes an already-constructed `Component`, not a factory, so a page's own
141
+ `state<T>` survives navigating away and back — see "Dependency injection" above for how
142
+ the whole page graph typically gets built once, in a composition root.
143
+
144
+ **Deploying a Router-based app needs SPA/history-fallback configured on whatever you
145
+ deploy to — this is true of every client-side router in every framework, not a Kopular
146
+ gap.** A direct load or a refresh at `/about` is a plain HTTP request that reaches your
147
+ host before any JS has run, so nothing client-side (Router included) can intercept it;
148
+ the host has to serve the app shell itself for any route it has no literal file for.
149
+ KopularDemo hit exactly this in production (worked when navigated to via a link, 404'd on
150
+ refresh) before its Cloudflare Workers config had this set:
151
+
152
+ ```jsonc
153
+ // wrangler.jsonc
154
+ "assets": {
155
+ "directory": "./public",
156
+ "not_found_handling": "single-page-application"
157
+ }
158
+ ```
159
+
160
+ Every static host has an equivalent option (Netlify, Vercel, nginx, ...) — search that
161
+ host's docs for "SPA fallback" or "single-page application routing", the terminology is
162
+ standard. For local dev, see KopularDemo's `scripts/serve.mjs`.
163
+
122
164
  ## Structural directives
123
165
 
124
166
  Angular's `*ngIf`/`*ngFor`/`*ngSwitch` are template syntax that expands, at compile time,
@@ -209,8 +251,9 @@ anywhere, including straight out of a service's own methods.
209
251
 
210
252
  **No typed JSON deserialization** — `Response.text()` gets you the raw body, nothing
211
253
  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
254
+ doesn't have: generic *functions/methods* (KopScript's generics are classes/interfaces
255
+ only, so there's no safe way to write a general `task<T> Get<T>(string url)`) and
256
+ object-literal syntax (`{ ... }` as a value — see below). If you want a
214
257
  typed response, describe its shape as its own `extern class` and parse it yourself with
215
258
  a per-shape `extern ... as "JSON.parse"` declaration — the same trust-based approach
216
259
  `extern` already uses for everything else, not a new mechanism:
@@ -235,6 +278,49 @@ real global). It's the one file in this package not compiled from `.ks` — ever
235
278
  else avoids the problem by only wrapping JS APIs that take plain positional arguments
236
279
  (see `dom.ks`'s `addEventListener(string, handler)`, never an options-object-taking API).
237
280
 
281
+ ## Forms
282
+
283
+ ```ks
284
+ using "./forms";
285
+
286
+ FormField<string> email = new FormField<string>("", (string v) => {
287
+ string? required = Validators.Required(v);
288
+ if (required != null) { return required; }
289
+ return Validators.Email(v);
290
+ });
291
+
292
+ email.Value.Value = "not-an-email";
293
+ print(email.Error.Value); // "Must be a valid email"
294
+ print(email.Valid()); // false
295
+
296
+ emailInput.addEventListener("input", (Event e) => {
297
+ email.Value.Value = emailInput.textContent; // revalidates automatically
298
+ });
299
+ emailInput.addEventListener("blur", (Event e) => { email.Touch(); });
300
+ ```
301
+
302
+ `FormField<T>` holds one input's value, error, and touched state as three ordinary
303
+ `state<T>` boxes — `.Value` (the input's current value, revalidating on every
304
+ assignment), `.Error` (`string?`, the current validator's message or `null`), and
305
+ `.Touched` (`bool`, set by calling `.Touch()` — typically on blur, so a fresh field with
306
+ an invalid initial value like an empty required field doesn't show an error before the
307
+ user has typed anything). Subscribe to any of the three from your `Component`'s
308
+ constructor exactly like `Counter`'s own `state<number>`, to re-render when they change.
309
+
310
+ A validator is a plain `(T) => string?` — `null` means valid, the same convention
311
+ KopScript's own nullable types use elsewhere. **There's no array-of-validators
312
+ constructor parameter** — KopScript has no syntax for an array of function values — so
313
+ combining more than one check (as `email` does above) is just an `if`-chain in one
314
+ lambda, not a combinator API. `Validators` ships the handful of checks almost every form
315
+ needs (`Required`, `MinLength`, `MaxLength`, `Email`, `Min`, `Max`), each returning its
316
+ own message; write your own validator function for anything more specific.
317
+
318
+ **No two-way data binding** — wiring `Value` to a real `<input>` is the
319
+ `addEventListener` call shown above, the same manual pattern `Counter` already uses for
320
+ its click handler. This is deliberate, not a missing feature: a magic `[(ngModel)]`-style
321
+ binding would be exactly the kind of hidden framework behavior Kopular avoids everywhere
322
+ else.
323
+
238
324
  ## Starting a new project: `kp new`
239
325
 
240
326
  Everything in the next section — the `extern` bindings, plus a `vendor/kopular/` copy of
@@ -291,6 +377,11 @@ Marking `Render()` `virtual` in the `extern` declaration is what lets a real sub
291
377
  for a full working example (components, a service, and routing, all consuming Kopular
292
378
  this way).
293
379
 
380
+ `extern class` has no `<T>` syntax, so a generic export like `FormField<T>` can't be
381
+ described directly this way — see `LLM.md`'s `FormField<T>`/`Validators` section for the
382
+ per-concrete-type workaround (the same trust-based, per-shape approach the "HTTP" section
383
+ above uses for typed JSON).
384
+
294
385
  ## Getting started (developing Kopular itself)
295
386
 
296
387
  ```bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kopular",
3
- "version": "0.5.0",
3
+ "version": "0.6.1",
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,8 @@
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"
29
30
  },
30
31
  "files": [
31
32
  "src",
@@ -34,13 +35,13 @@
34
35
  "LLM.md"
35
36
  ],
36
37
  "scripts": {
37
- "build": "ks build src/router.ks && ks build src/directives.ks && ks build src/http.ks",
38
+ "build": "ks build src/router.ks && ks build src/directives.ks && ks build src/http.ks && ks build src/forms.ks",
38
39
  "prepublishOnly": "npm run build",
39
40
  "test": "vitest run",
40
41
  "test:watch": "vitest"
41
42
  },
42
43
  "devDependencies": {
43
- "kopscript": "^0.4.0",
44
+ "kopscript": "^0.4.1",
44
45
  "@types/jsdom": "^30.0.0",
45
46
  "@types/node": "^20.14.0",
46
47
  "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
+ }