kopular 0.4.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 +84 -5
- package/README.md +122 -8
- package/bin/kp.mjs +316 -0
- package/package.json +9 -4
- package/src/forms.js +81 -0
- package/src/forms.ks +77 -0
package/LLM.md
CHANGED
|
@@ -1,14 +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
|
|
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).
|
|
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.
|
|
12
15
|
|
|
13
16
|
## Consuming Kopular from your own KopScript project
|
|
14
17
|
|
|
@@ -44,8 +47,38 @@ extern class Http {
|
|
|
44
47
|
static task<Response> Patch(string url, string jsonBody);
|
|
45
48
|
static task<Response> Delete(string url);
|
|
46
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";
|
|
47
76
|
```
|
|
48
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
|
+
|
|
49
82
|
You also need your own ambient DOM `extern` block (`document`, `Element`, `Event`, ...) —
|
|
50
83
|
Kopular's own copy in `dom.ks` isn't reachable across the package boundary; redeclare the
|
|
51
84
|
handful of members you actually use. See KopularDemo's `src/kopular_bindings.ks` for a
|
|
@@ -125,8 +158,23 @@ nav.Navigate("/about"); // pushState + immediate re-ren
|
|
|
125
158
|
- `Navigate(path)` calls `history.pushState` then re-renders immediately. A browser
|
|
126
159
|
back/forward triggers re-render via a `popstate` listener registered in the
|
|
127
160
|
constructor — `Navigate()` itself doesn't rely on that event.
|
|
128
|
-
-
|
|
129
|
-
|
|
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.
|
|
130
178
|
|
|
131
179
|
## `If` (`directives.ks`) — the `*ngIf` equivalent
|
|
132
180
|
|
|
@@ -173,7 +221,8 @@ r.status // number
|
|
|
173
221
|
await r.text(); // task<string> — the raw body, nothing more
|
|
174
222
|
```
|
|
175
223
|
|
|
176
|
-
**No typed JSON deserialization** —
|
|
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)`.
|
|
177
226
|
Get a typed response by describing its shape as its own `extern class` and parsing with
|
|
178
227
|
a per-shape `extern ... as "JSON.parse"` (unchecked, same trust model as every other
|
|
179
228
|
`extern`):
|
|
@@ -191,6 +240,36 @@ hypothetical `Delete`-with-a-body) need one for `{ method, headers, body }`, whi
|
|
|
191
240
|
KopScript categorically cannot construct — Kopular ships one small hand-written JS
|
|
192
241
|
function (`http_runtime.js`, not compiled from `.ks`) that does, for exactly that reason.
|
|
193
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
|
+
|
|
194
273
|
## Dependency injection — no container, no decorators
|
|
195
274
|
|
|
196
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.
|
|
30
|
-
|
|
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 —
|
|
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
|
|
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:
|
|
213
|
-
|
|
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,11 +278,77 @@ 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
|
+
|
|
324
|
+
## Starting a new project: `kp new`
|
|
325
|
+
|
|
326
|
+
Everything in the next section — the `extern` bindings, plus a `vendor/kopular/` copy of
|
|
327
|
+
this package's browser files and an import map pointing at it (a browser can't resolve a
|
|
328
|
+
bare specifier like `"kopular/component"` the way Node's own module resolution does) — is
|
|
329
|
+
boilerplate every Kopular project needs verbatim. Generate it instead of reconstructing it
|
|
330
|
+
by hand (or from memory, if you're an AI agent):
|
|
331
|
+
|
|
332
|
+
```bash
|
|
333
|
+
npx kp new my-app
|
|
334
|
+
cd my-app
|
|
335
|
+
npm install
|
|
336
|
+
npm start # builds, vendors kopular's browser files, and serves at :8080
|
|
337
|
+
```
|
|
338
|
+
|
|
339
|
+
This scaffolds a real, working `Component` (`src/counter.ks` — the same Counter shown
|
|
340
|
+
above), the ambient DOM/Kopular `extern` bindings it needs (`src/kopular_bindings.ks`),
|
|
341
|
+
and a `README.md` that points an AI agent at this package's own `LLM.md` before it starts
|
|
342
|
+
generating code. `kp` ships from this package (not from `kopscript`'s own `ks` CLI) since
|
|
343
|
+
scaffolding a *Kopular* app is a framework concern, not a language one — `ks` stays a
|
|
344
|
+
pure-language tool with no framework knowledge baked in.
|
|
345
|
+
|
|
238
346
|
## Using Kopular from another KopScript project
|
|
239
347
|
|
|
240
348
|
KopScript's own `using "./path";` only resolves relative paths within a project — it has
|
|
241
349
|
no package-import mechanism yet. Cross-package consumption goes through `extern`
|
|
242
|
-
instead, the same way KopScript already describes any other JS/npm dependency
|
|
350
|
+
instead, the same way KopScript already describes any other JS/npm dependency
|
|
351
|
+
(`kp new` above generates exactly this, if you'd rather not hand-write it):
|
|
243
352
|
|
|
244
353
|
```ks
|
|
245
354
|
extern class Component {
|
|
@@ -268,6 +377,11 @@ Marking `Render()` `virtual` in the `extern` declaration is what lets a real sub
|
|
|
268
377
|
for a full working example (components, a service, and routing, all consuming Kopular
|
|
269
378
|
this way).
|
|
270
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
|
+
|
|
271
385
|
## Getting started (developing Kopular itself)
|
|
272
386
|
|
|
273
387
|
```bash
|
package/bin/kp.mjs
ADDED
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Kopular's own scaffolding CLI — separate from kopscript's `ks` (build/run/
|
|
3
|
+
// watch/check), which stays a pure-language tool with no knowledge of any
|
|
4
|
+
// framework built on top of it. `kp new` generates a *Kopular* app skeleton
|
|
5
|
+
// (a Component, the ambient extern bindings its Render()/Mount() calls
|
|
6
|
+
// need, and the vendor/serve scripts a Kopular app needs to run in a real
|
|
7
|
+
// browser), so it lives here instead — in the package that actually knows
|
|
8
|
+
// what a Kopular app looks like.
|
|
9
|
+
//
|
|
10
|
+
// Hand-written plain JS, not compiled from a .ks source, the same as
|
|
11
|
+
// src/http_runtime.js: filesystem scaffolding isn't a Kopular Component, and
|
|
12
|
+
// KopScript has no object-literal syntax to build the file-content map this
|
|
13
|
+
// needs anyway.
|
|
14
|
+
import { writeFileSync, mkdirSync, existsSync, readdirSync, readFileSync } from "node:fs";
|
|
15
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
16
|
+
import { fileURLToPath } from "node:url";
|
|
17
|
+
|
|
18
|
+
const packageRoot = join(dirname(fileURLToPath(import.meta.url)), "..");
|
|
19
|
+
const kopularPkg = JSON.parse(readFileSync(join(packageRoot, "package.json"), "utf-8"));
|
|
20
|
+
// Read the ranges to generate from Kopular's own package.json rather than
|
|
21
|
+
// hardcoding them a second time here — this file only has to change when the
|
|
22
|
+
// *shape* of a scaffolded project changes, not on every kopular/kopscript release.
|
|
23
|
+
const KOPULAR_RANGE = `^${kopularPkg.version}`;
|
|
24
|
+
const KOPSCRIPT_RANGE = kopularPkg.devDependencies.kopscript;
|
|
25
|
+
|
|
26
|
+
function packageJsonTemplate(name) {
|
|
27
|
+
return `{
|
|
28
|
+
"name": "${name}",
|
|
29
|
+
"version": "0.1.0",
|
|
30
|
+
"type": "module",
|
|
31
|
+
"private": true,
|
|
32
|
+
"scripts": {
|
|
33
|
+
"build": "ks build src/app.ks && npm run vendor",
|
|
34
|
+
"vendor": "node scripts/vendor-kopular.mjs",
|
|
35
|
+
"serve": "node scripts/serve.mjs . 8080",
|
|
36
|
+
"start": "npm run build && npm run serve"
|
|
37
|
+
},
|
|
38
|
+
"dependencies": {
|
|
39
|
+
"kopular": "${KOPULAR_RANGE}"
|
|
40
|
+
},
|
|
41
|
+
"devDependencies": {
|
|
42
|
+
"kopscript": "${KOPSCRIPT_RANGE}"
|
|
43
|
+
},
|
|
44
|
+
"engines": {
|
|
45
|
+
"node": ">=18"
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const INDEX_HTML_TEMPLATE = `<!doctype html>
|
|
52
|
+
<html lang="en">
|
|
53
|
+
<head>
|
|
54
|
+
<meta charset="utf-8" />
|
|
55
|
+
<title>Kopular app</title>
|
|
56
|
+
</head>
|
|
57
|
+
<body>
|
|
58
|
+
<!--
|
|
59
|
+
Compiled output imports Kopular's classes as bare specifiers
|
|
60
|
+
("kopular/component", "kopular/router") — real Node module resolution
|
|
61
|
+
understands that natively via node_modules, but a browser needs an
|
|
62
|
+
explicit import map, since "kopular/component" isn't a URL on its own.
|
|
63
|
+
This points at vendor/kopular/ (see scripts/vendor-kopular.mjs), not
|
|
64
|
+
node_modules/kopular/src/ directly, so the browser never has to be
|
|
65
|
+
served the whole node_modules tree just to reach two files inside it.
|
|
66
|
+
-->
|
|
67
|
+
<script type="importmap">
|
|
68
|
+
{
|
|
69
|
+
"imports": {
|
|
70
|
+
"kopular/component": "/vendor/kopular/component.js",
|
|
71
|
+
"kopular/router": "/vendor/kopular/router.js"
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
</script>
|
|
75
|
+
<script type="module" src="/src/app.js"></script>
|
|
76
|
+
</body>
|
|
77
|
+
</html>
|
|
78
|
+
`;
|
|
79
|
+
|
|
80
|
+
const GITIGNORE_TEMPLATE = `node_modules/
|
|
81
|
+
*.js
|
|
82
|
+
/vendor/
|
|
83
|
+
.DS_Store
|
|
84
|
+
`;
|
|
85
|
+
|
|
86
|
+
function readmeTemplate(name) {
|
|
87
|
+
return `# ${name}
|
|
88
|
+
|
|
89
|
+
A [KopScript](https://www.npmjs.com/package/kopscript) + [Kopular](https://www.npmjs.com/package/kopular) app, scaffolded with \`kp new\`.
|
|
90
|
+
|
|
91
|
+
## Commands
|
|
92
|
+
|
|
93
|
+
- \`npm install\`
|
|
94
|
+
- \`npm run build\` — compiles \`src/*.ks\` to JS and vendors Kopular's browser files into \`vendor/\`
|
|
95
|
+
- \`npm run serve\` — serves the app at http://localhost:8080/
|
|
96
|
+
- \`npm start\` — both of the above
|
|
97
|
+
|
|
98
|
+
## Where to go next
|
|
99
|
+
|
|
100
|
+
- \`src/counter.ks\` is a real, working Kopular \`Component\` — start there.
|
|
101
|
+
- \`src/kopular_bindings.ks\` declares the ambient DOM and Kopular types this project builds on (see the comments inside it for why these have to be redeclared per-project rather than imported).
|
|
102
|
+
- For routing, dependency injection ("Pure DI" / a composition root), structural directives, or the \`Http\` client, see Kopular's own README and \`node_modules/kopular/LLM.md\`.
|
|
103
|
+
- For the full language reference, see \`node_modules/kopscript/LLM.md\`.
|
|
104
|
+
|
|
105
|
+
**Working with an AI agent on this project?** Point it at \`node_modules/kopscript/LLM.md\` and \`node_modules/kopular/LLM.md\` first — they're dense, example-verified references written specifically to be loaded as context, not narrative docs.
|
|
106
|
+
`;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const SERVE_MJS_TEMPLATE = `#!/usr/bin/env node
|
|
110
|
+
// Minimal static file server for previewing the compiled app in a real
|
|
111
|
+
// browser. Kept dependency-free on purpose — browsers won't load ES module
|
|
112
|
+
// imports over file://, so anything with \`using\`/\`extern\`-based imports
|
|
113
|
+
// needs to be served over http to actually run.
|
|
114
|
+
import { createServer } from "node:http";
|
|
115
|
+
import { readFile } from "node:fs/promises";
|
|
116
|
+
import { extname, join, resolve } from "node:path";
|
|
117
|
+
|
|
118
|
+
const root = resolve(process.argv[2] ?? ".");
|
|
119
|
+
const port = Number(process.argv[3] ?? 8080);
|
|
120
|
+
|
|
121
|
+
const MIME_TYPES = {
|
|
122
|
+
".html": "text/html; charset=utf-8",
|
|
123
|
+
".js": "text/javascript; charset=utf-8",
|
|
124
|
+
".mjs": "text/javascript; charset=utf-8",
|
|
125
|
+
".css": "text/css; charset=utf-8",
|
|
126
|
+
".json": "application/json; charset=utf-8",
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
const server = createServer(async (req, res) => {
|
|
130
|
+
try {
|
|
131
|
+
let pathname = decodeURIComponent(new URL(req.url, "http://localhost").pathname);
|
|
132
|
+
if (pathname === "/") pathname = "/index.html";
|
|
133
|
+
let filePath = join(root, pathname);
|
|
134
|
+
if (!filePath.startsWith(root)) {
|
|
135
|
+
res.writeHead(403).end("Forbidden");
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
let data;
|
|
140
|
+
try {
|
|
141
|
+
data = await readFile(filePath);
|
|
142
|
+
} catch (err) {
|
|
143
|
+
// SPA fallback: a path with no file extension is a client-side route
|
|
144
|
+
// Router would handle once the page loads, not a missing asset — serve
|
|
145
|
+
// the app shell instead of 404ing. A path that *does* have an
|
|
146
|
+
// extension (a genuinely missing .js/.css/...) still 404s normally.
|
|
147
|
+
if (extname(pathname)) throw err;
|
|
148
|
+
filePath = join(root, "index.html");
|
|
149
|
+
data = await readFile(filePath);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
res.writeHead(200, { "Content-Type": MIME_TYPES[extname(filePath)] ?? "application/octet-stream" });
|
|
153
|
+
res.end(data);
|
|
154
|
+
} catch {
|
|
155
|
+
res.writeHead(404).end("Not found");
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
server.listen(port, () => {
|
|
160
|
+
console.log(\`Serving \${root} at http://localhost:\${port}/\`);
|
|
161
|
+
});
|
|
162
|
+
`;
|
|
163
|
+
|
|
164
|
+
const VENDOR_KOPULAR_MJS_TEMPLATE = `#!/usr/bin/env node
|
|
165
|
+
// Copies the Kopular files the browser actually needs out of node_modules
|
|
166
|
+
// into a small local vendor/ directory — the import map in index.html points
|
|
167
|
+
// here instead of into node_modules directly, since shipping (or even
|
|
168
|
+
// locally serving) the whole node_modules tree just to get a few files out
|
|
169
|
+
// of it is unnecessary.
|
|
170
|
+
//
|
|
171
|
+
// dom.js isn't in the import map itself, but component.js and router.js each
|
|
172
|
+
// import it internally via a relative "./dom.js" — Kopular's own compiled
|
|
173
|
+
// files reference each other as siblings, so all three have to land in the
|
|
174
|
+
// same vendor/kopular/ directory together, not just the two the import map
|
|
175
|
+
// names explicitly.
|
|
176
|
+
import { copyFileSync, mkdirSync } from "node:fs";
|
|
177
|
+
import { dirname, join } from "node:path";
|
|
178
|
+
import { fileURLToPath } from "node:url";
|
|
179
|
+
|
|
180
|
+
const root = join(dirname(fileURLToPath(import.meta.url)), "..");
|
|
181
|
+
const outDir = join(root, "vendor", "kopular");
|
|
182
|
+
mkdirSync(outDir, { recursive: true });
|
|
183
|
+
|
|
184
|
+
for (const file of ["dom.js", "component.js", "router.js"]) {
|
|
185
|
+
copyFileSync(join(root, "node_modules", "kopular", "src", file), join(outDir, file));
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
console.log(\`Vendored kopular/{dom,component,router}.js into \${outDir}\`);
|
|
189
|
+
`;
|
|
190
|
+
|
|
191
|
+
// Ambient DOM bindings, plus Kopular's own classes redeclared as `extern` —
|
|
192
|
+
// copied verbatim from a real project's working copy. `using` only resolves
|
|
193
|
+
// relative paths within a project (KopScript has no package-import mechanism
|
|
194
|
+
// yet), so it can't reach across the node_modules boundary into Kopular's
|
|
195
|
+
// own .ks sources — every consuming project redeclares this ambient surface
|
|
196
|
+
// once, here.
|
|
197
|
+
const KOPULAR_BINDINGS_KS_TEMPLATE = `// Ambient DOM bindings — describing standing browser globals, not anything
|
|
198
|
+
// Kopular itself exports, so this is just describing the platform.
|
|
199
|
+
extern class Event {
|
|
200
|
+
Element target { get; }
|
|
201
|
+
void preventDefault();
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
extern class Element {
|
|
205
|
+
string textContent { get; set; }
|
|
206
|
+
string innerHTML { get; set; }
|
|
207
|
+
string id { get; set; }
|
|
208
|
+
string className { get; set; }
|
|
209
|
+
string href { get; set; }
|
|
210
|
+
string src { get; set; }
|
|
211
|
+
string alt { get; set; }
|
|
212
|
+
void appendChild(Element child);
|
|
213
|
+
void replaceChild(Element newChild, Element oldChild);
|
|
214
|
+
void addEventListener(string eventType, (Event) => void handler);
|
|
215
|
+
void removeEventListener(string eventType, (Event) => void handler);
|
|
216
|
+
Element querySelector(string selector);
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
extern class Document {
|
|
220
|
+
Element createElement(string tagName);
|
|
221
|
+
Element getElementById(string id);
|
|
222
|
+
Element body { get; }
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
extern Document document;
|
|
226
|
+
|
|
227
|
+
// Kopular's real classes, consumed from the real published "kopular" npm
|
|
228
|
+
// package. \`virtual\` on Render() is what lets a class here \`override\` it.
|
|
229
|
+
extern class Component {
|
|
230
|
+
constructor();
|
|
231
|
+
virtual Element Render();
|
|
232
|
+
void Mount(Element parent);
|
|
233
|
+
void Update();
|
|
234
|
+
} from "kopular/component";
|
|
235
|
+
|
|
236
|
+
extern class Router {
|
|
237
|
+
constructor(Component notFoundPage);
|
|
238
|
+
void AddRoute(string path, Component page);
|
|
239
|
+
void Navigate(string path);
|
|
240
|
+
void Mount(Element parent);
|
|
241
|
+
} from "kopular/router";
|
|
242
|
+
`;
|
|
243
|
+
|
|
244
|
+
// The same Counter shown in Kopular's own README/LLM.md — real, verified
|
|
245
|
+
// example code, not a placeholder.
|
|
246
|
+
const COUNTER_KS_TEMPLATE = `using "./kopular_bindings";
|
|
247
|
+
|
|
248
|
+
class Counter : Component {
|
|
249
|
+
private state<number> Count;
|
|
250
|
+
|
|
251
|
+
constructor() : base() {
|
|
252
|
+
this.Count = state(0);
|
|
253
|
+
this.Count.Subscribe((number v) => this.Update());
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
public override Element Render() {
|
|
257
|
+
Element button = document.createElement("button");
|
|
258
|
+
button.textContent = "Count: " + this.Count.Value;
|
|
259
|
+
button.addEventListener("click", (Event e) => {
|
|
260
|
+
this.Count.Value = this.Count.Value + 1;
|
|
261
|
+
});
|
|
262
|
+
return button;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
`;
|
|
266
|
+
|
|
267
|
+
const APP_KS_TEMPLATE = `using "./kopular_bindings";
|
|
268
|
+
using "./counter";
|
|
269
|
+
|
|
270
|
+
// The app's root: mounts straight to document.body since there's nothing
|
|
271
|
+
// else in the shell yet. Add a Router and a composition root (see Kopular's
|
|
272
|
+
// README on "Pure DI") once there's more than one page.
|
|
273
|
+
Counter app = new Counter();
|
|
274
|
+
app.Mount(document.body);
|
|
275
|
+
`;
|
|
276
|
+
|
|
277
|
+
function scaffoldProject(dirPath) {
|
|
278
|
+
const name = basename(dirPath);
|
|
279
|
+
if (existsSync(dirPath) && readdirSync(dirPath).length > 0) {
|
|
280
|
+
console.error(`kp: '${dirPath}' already exists and is not empty`);
|
|
281
|
+
process.exitCode = 1;
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
mkdirSync(join(dirPath, "src"), { recursive: true });
|
|
286
|
+
mkdirSync(join(dirPath, "scripts"), { recursive: true });
|
|
287
|
+
|
|
288
|
+
writeFileSync(join(dirPath, "package.json"), packageJsonTemplate(name), "utf-8");
|
|
289
|
+
writeFileSync(join(dirPath, "index.html"), INDEX_HTML_TEMPLATE, "utf-8");
|
|
290
|
+
writeFileSync(join(dirPath, ".gitignore"), GITIGNORE_TEMPLATE, "utf-8");
|
|
291
|
+
writeFileSync(join(dirPath, "README.md"), readmeTemplate(name), "utf-8");
|
|
292
|
+
writeFileSync(join(dirPath, "scripts", "serve.mjs"), SERVE_MJS_TEMPLATE, "utf-8");
|
|
293
|
+
writeFileSync(join(dirPath, "scripts", "vendor-kopular.mjs"), VENDOR_KOPULAR_MJS_TEMPLATE, "utf-8");
|
|
294
|
+
writeFileSync(join(dirPath, "src", "kopular_bindings.ks"), KOPULAR_BINDINGS_KS_TEMPLATE, "utf-8");
|
|
295
|
+
writeFileSync(join(dirPath, "src", "counter.ks"), COUNTER_KS_TEMPLATE, "utf-8");
|
|
296
|
+
writeFileSync(join(dirPath, "src", "app.ks"), APP_KS_TEMPLATE, "utf-8");
|
|
297
|
+
|
|
298
|
+
console.log(`Created ${name} in ${dirPath}`);
|
|
299
|
+
console.log("");
|
|
300
|
+
console.log("Next steps:");
|
|
301
|
+
console.log(` cd ${name}`);
|
|
302
|
+
console.log(" npm install");
|
|
303
|
+
console.log(" npm start");
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function main() {
|
|
307
|
+
const [command, dir] = process.argv.slice(2);
|
|
308
|
+
if (command !== "new" || !dir) {
|
|
309
|
+
console.error("Usage: kp new <project-directory>");
|
|
310
|
+
process.exitCode = 1;
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
scaffoldProject(resolve(dir));
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
main();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "kopular",
|
|
3
|
-
"version": "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",
|
|
@@ -16,27 +16,32 @@
|
|
|
16
16
|
"ui"
|
|
17
17
|
],
|
|
18
18
|
"main": "./src/component.js",
|
|
19
|
+
"bin": {
|
|
20
|
+
"kp": "./bin/kp.mjs"
|
|
21
|
+
},
|
|
19
22
|
"exports": {
|
|
20
23
|
".": "./src/component.js",
|
|
21
24
|
"./component": "./src/component.js",
|
|
22
25
|
"./router": "./src/router.js",
|
|
23
26
|
"./dom": "./src/dom.js",
|
|
24
27
|
"./directives": "./src/directives.js",
|
|
25
|
-
"./http": "./src/http.js"
|
|
28
|
+
"./http": "./src/http.js",
|
|
29
|
+
"./forms": "./src/forms.js"
|
|
26
30
|
},
|
|
27
31
|
"files": [
|
|
28
32
|
"src",
|
|
33
|
+
"bin",
|
|
29
34
|
"assets",
|
|
30
35
|
"LLM.md"
|
|
31
36
|
],
|
|
32
37
|
"scripts": {
|
|
33
|
-
"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",
|
|
34
39
|
"prepublishOnly": "npm run build",
|
|
35
40
|
"test": "vitest run",
|
|
36
41
|
"test:watch": "vitest"
|
|
37
42
|
},
|
|
38
43
|
"devDependencies": {
|
|
39
|
-
"kopscript": "^0.
|
|
44
|
+
"kopscript": "^0.4.1",
|
|
40
45
|
"@types/jsdom": "^30.0.0",
|
|
41
46
|
"@types/node": "^20.14.0",
|
|
42
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
|
+
}
|