kopular 0.10.0 → 0.11.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/LICENSE +21 -0
- package/LLM.md +50 -7
- package/README.md +97 -23
- package/bin/kp.mjs +15 -11
- package/package.json +11 -5
- package/src/component.js +3 -3
- package/src/forms.js +5 -5
- package/src/router.js +19 -19
- package/src/testing.js +37 -6
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Joe Koppin
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/LLM.md
CHANGED
|
@@ -12,7 +12,9 @@ Published as npm `kopular`. Entry points: `kopular` / `kopular/component` (Compo
|
|
|
12
12
|
(runKopularApp, runKopularFixture — see below). Also ships a bin, `kp` — `npx kp new
|
|
13
13
|
<dir>` scaffolds a new project (the `extern` bindings below, plus the vendor/serve
|
|
14
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
|
+
prefer it over hand-writing the section below for a new project. Templates (`template
|
|
16
|
+
from "./x.html";`, see below) are a KopScript language feature, not a Kopular export —
|
|
17
|
+
there is no `kopular/template` entry point to import.
|
|
16
18
|
|
|
17
19
|
## Consuming Kopular from your own KopScript project
|
|
18
20
|
|
|
@@ -112,8 +114,10 @@ w.Bump(); // re-render
|
|
|
112
114
|
```
|
|
113
115
|
|
|
114
116
|
- `Render()`: `virtual`, override it to build a fresh DOM subtree from current state.
|
|
115
|
-
Called by both `Mount()` and `Update()`. **No
|
|
116
|
-
|
|
117
|
+
Called by both `Mount()` and `Update()`. **No diffing either way** — every call
|
|
118
|
+
rebuilds the whole subtree from scratch. Provide it as a real markup file via
|
|
119
|
+
KopScript's `template from "./x.html";` (see "Templates" below) instead of hand-writing
|
|
120
|
+
it — both produce the exact same method; Kopular needed no code changes to support this.
|
|
117
121
|
- `Mount(parent)`: calls `Render()` once, appends the result to `parent`, remembers both
|
|
118
122
|
for `Update()` to use later.
|
|
119
123
|
- `Update()` (protected — called from within the component, not externally): calls
|
|
@@ -141,6 +145,38 @@ w.Bump(); // re-render
|
|
|
141
145
|
into the component) renders normally again — there's no separate "broken" state to
|
|
142
146
|
reset, `SafeRender()` just tries `Render()` again like any other `Update()`.
|
|
143
147
|
|
|
148
|
+
## Templates — `template from` (see KopScript's own `LLM.md` for the full syntax)
|
|
149
|
+
|
|
150
|
+
```ks
|
|
151
|
+
class Counter : Component {
|
|
152
|
+
public state<number> Count;
|
|
153
|
+
constructor() : base() { this.Count = state(0); }
|
|
154
|
+
public void Increment() { this.Count.Value = this.Count.Value + 1; }
|
|
155
|
+
template from "./counter.html"; // replaces Render() entirely — cannot coexist with a hand-written one
|
|
156
|
+
}
|
|
157
|
+
```
|
|
158
|
+
```html
|
|
159
|
+
<!-- counter.html -->
|
|
160
|
+
<button (click)="Increment()">Count: {{ Count.Value }}</button>
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
- `{{ expr }}` interpolation, `(event)="stmt"`, `[prop]="expr"`, `*if="expr"`,
|
|
164
|
+
`*for="Type varName of expr"` — all real KopScript, checked at compile time, desugared
|
|
165
|
+
to the exact same `document.createElement`/`.appendChild`/`.textContent`/
|
|
166
|
+
`.addEventListener` calls a hand-written `Render()` would use.
|
|
167
|
+
- A `state<T>` field declared directly on the class and referenced directly in the
|
|
168
|
+
template (`Count` above) gets `Subscribe((v) => this.Update())` wired automatically —
|
|
169
|
+
no manual `Subscribe` in the constructor for that field. State reached indirectly
|
|
170
|
+
(through a method, or `this.SomeService.Count`) still needs a manual `Subscribe`, same
|
|
171
|
+
as a hand-written `Render()` always has.
|
|
172
|
+
- One top-level element per template (hard error otherwise); no mixing text and element
|
|
173
|
+
children under one element (no text-node type in `dom.ks`, only `.textContent`); no
|
|
174
|
+
two-way binding, no pipes, at most one structural directive per element.
|
|
175
|
+
- This is entirely a KopScript compiler feature (parsed/desugared before type-checking
|
|
176
|
+
runs) — Kopular's own framework code (`component.ks`, `dom.ks`) is unmodified and
|
|
177
|
+
unaware templates exist; a template-generated `Render()` is indistinguishable from a
|
|
178
|
+
hand-written one to every other part of the framework.
|
|
179
|
+
|
|
144
180
|
## `Router` (`router.ks`)
|
|
145
181
|
|
|
146
182
|
Real URLs via the History API (`pushState`/`popstate`), not hash routing.
|
|
@@ -217,7 +253,12 @@ nav.Navigate("/about"); // pushState + immediate re-ren
|
|
|
217
253
|
"SPA fallback" / "custom 404 → index.html" option — look for that host's own docs on
|
|
218
254
|
single-page-application routing, the terminology is standard across all of them.
|
|
219
255
|
|
|
220
|
-
## `If` (`directives.ks`) — the `*ngIf` equivalent
|
|
256
|
+
## `If` (`directives.ks`) — the `*ngIf` equivalent for a hand-written `Render()`
|
|
257
|
+
|
|
258
|
+
In a **template**, `*if="expr"`/`*for="Type v of expr"` are the direct equivalents (real
|
|
259
|
+
`if`/`for` under the hood — see "Templates" above), no helper needed. This section is for
|
|
260
|
+
a **hand-written** `Render()`, where `if` being a statement (not an expression) means a
|
|
261
|
+
conditional value needs a helper to get one out of it:
|
|
221
262
|
|
|
222
263
|
```ks
|
|
223
264
|
Element If(bool condition, () => Element whenTrue, () => Element whenFalse)
|
|
@@ -229,7 +270,7 @@ root.appendChild(If(this.IsLoggedIn, () => this.BuildProfile(), () => this.Build
|
|
|
229
270
|
|
|
230
271
|
Both branches always required (no null "nothing" value); only the branch actually taken
|
|
231
272
|
is called — the other lambda never runs. `*ngFor` and `*ngSwitch` need no Kopular helper
|
|
232
|
-
at all:
|
|
273
|
+
at all in hand-written `Render()` either:
|
|
233
274
|
|
|
234
275
|
```ks
|
|
235
276
|
// *ngFor — plain array method
|
|
@@ -386,8 +427,10 @@ class CounterService {
|
|
|
386
427
|
|
|
387
428
|
## Does not exist
|
|
388
429
|
|
|
389
|
-
DI container/injector · decorators (`@Injectable`, `@Component`, ...) · a
|
|
390
|
-
|
|
430
|
+
DI container/injector · decorators (`@Injectable`, `@Component`, ...) · a runtime
|
|
431
|
+
template engine or interpreted expression language — templates compile to the same
|
|
432
|
+
imperative `Render()` code as the hand-written form, checked at compile time, not
|
|
433
|
+
interpreted at runtime (see "Templates" above) · two-way binding (`[(ngModel)]`) ·
|
|
391
434
|
vdom diffing / reconciliation beyond a single component's own re-render · pipes ·
|
|
392
435
|
animations · forms/validation module · typed/generic HTTP responses (`Http` returns raw
|
|
393
436
|
text — see above) · a CLI/scaffolding tool (`ng generate`-equivalent) · SSR.
|
package/README.md
CHANGED
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
Kopular is a small component framework for [KopScript](https://dev.azure.com/koppinator/Koppindependence/_git/Kop),
|
|
6
6
|
built to give Angular's separation of concerns — components own UI, services own logic,
|
|
7
7
|
a router owns navigation — without Angular's steepest learning-curve pieces: no RxJS, no
|
|
8
|
-
dependency-injection container,
|
|
8
|
+
dependency-injection container, and templates that are real, compiled, type-checked
|
|
9
|
+
KopScript rather than a separate interpreted template language.
|
|
9
10
|
|
|
10
11
|
Generating Kopular code with an AI coding assistant? Point it at **[`LLM.md`](./LLM.md)**
|
|
11
12
|
— a dense, complete reference designed to be loaded straight into an LLM's context.
|
|
@@ -13,11 +14,19 @@ Generating Kopular code with an AI coding assistant? Point it at **[`LLM.md`](./
|
|
|
13
14
|
## Highlights
|
|
14
15
|
|
|
15
16
|
- **`Component`**: a base class with `virtual Render()` (builds a fresh DOM subtree from
|
|
16
|
-
current state) and `Update()` (swaps the old subtree for the new one). No
|
|
17
|
-
|
|
18
|
-
|
|
17
|
+
current state) and `Update()` (swaps the old subtree for the new one). No diffing either
|
|
18
|
+
way — `Render()` is provided as a real, separate markup file compiled by KopScript's
|
|
19
|
+
`template from` (see "Templates" below), or written by hand as plain imperative DOM
|
|
20
|
+
code against plain DOM bindings, the way you'd write careful vanilla-JS UI code — your
|
|
21
|
+
choice, and both compile to the exact same thing. An optional `virtual
|
|
19
22
|
RenderError(message)` renders a fallback instead of a hard crash if `Render()` throws —
|
|
20
23
|
purely additive; not overriding it keeps today's exact (uncaught) behavior.
|
|
24
|
+
- **Templates, compiled and type-checked, not interpreted**: markup lives in its own
|
|
25
|
+
`.html` file — interpolation (`{{ }}`), event/property bindings (`(click)="..."`,
|
|
26
|
+
`[prop]="..."`), and `*if`/`*for` structural directives — desugared by the KopScript
|
|
27
|
+
compiler into the exact same code a hand-written `Render()` would produce, with
|
|
28
|
+
automatic `Subscribe`/`Update()` wiring for `state<T>` fields referenced directly in the
|
|
29
|
+
markup. See "Templates" below.
|
|
21
30
|
- **Reactive state, no RxJS**: components hold `state<number>`/`state<string>`/... (a
|
|
22
31
|
KopScript language feature — see the [Kop](https://dev.azure.com/koppinator/Koppindependence/_git/Kop)
|
|
23
32
|
repo) and subscribe once, in their constructor, to call `Update()` on change. No
|
|
@@ -30,10 +39,12 @@ Generating Kopular code with an AI coding assistant? Point it at **[`LLM.md`](./
|
|
|
30
39
|
(`pushState`/`popstate`), with route registration as plain method calls, not a config
|
|
31
40
|
DSL. See "Router, and deploying it" below — every deployment target needs its own
|
|
32
41
|
SPA-fallback config, not just local dev.
|
|
33
|
-
- **Structural directives
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
`
|
|
42
|
+
- **Structural directives**: `*ngIf`/`*ngFor`/`*ngSwitch`'s job — build a subtree
|
|
43
|
+
conditionally, repeat one per item, pick one of several cases. In a template, that's
|
|
44
|
+
`*if`/`*for` (real `if`/`for` under the hood — see "Templates"); in a hand-written
|
|
45
|
+
`Render()`, the same job is a plain function call (`If(...)`) or existing KopScript
|
|
46
|
+
expression (`array.ForEach(...)`, `match`) — no special syntax needed there either way.
|
|
47
|
+
See "Structural directives" below.
|
|
37
48
|
- **`Http`**: a thin, static wrapper over the real Fetch API (`Http.Get(url)`,
|
|
38
49
|
`Http.Post(url, jsonBody)`, ...) — no HttpClient injection tokens, no RxJS
|
|
39
50
|
observables/operators. See "HTTP" below.
|
|
@@ -62,6 +73,66 @@ That's the whole framework — eight files, plus the scaffolding CLI. Everything
|
|
|
62
73
|
real app built on top of it)
|
|
63
74
|
lives in a separate consumer repo, [KopularDemo](https://dev.azure.com/koppinator/Koppindependence/_git/KopularDemo).
|
|
64
75
|
|
|
76
|
+
## Templates
|
|
77
|
+
|
|
78
|
+
A component's `Render()` can be a real markup file instead of hand-written imperative DOM
|
|
79
|
+
code — `template from "./x.html";` in the class body, a KopScript language feature (see
|
|
80
|
+
[Kop](https://dev.azure.com/koppinator/Koppindependence/_git/Kop)'s own README/LLM.md for
|
|
81
|
+
the full syntax reference). Kopular itself needed **zero framework code changes** for
|
|
82
|
+
this — the compiler desugars a template straight into calls against the same
|
|
83
|
+
`document.createElement`/`.appendChild`/`.textContent`/`.addEventListener` surface
|
|
84
|
+
`dom.ks` already declares, so a template-generated `Render()` is indistinguishable from
|
|
85
|
+
one you'd write by hand:
|
|
86
|
+
|
|
87
|
+
```ks
|
|
88
|
+
// counter.ks
|
|
89
|
+
class Counter : Component {
|
|
90
|
+
public state<number> Count;
|
|
91
|
+
constructor() : base() { this.Count = state(0); }
|
|
92
|
+
public void Increment() { this.Count.Value = this.Count.Value + 1; }
|
|
93
|
+
template from "./counter.html";
|
|
94
|
+
}
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
```html
|
|
98
|
+
<!-- counter.html -->
|
|
99
|
+
<button (click)="Increment()">Count: {{ Count.Value }}</button>
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Note there's no `this.Count.Subscribe(...)` anywhere — a `state<T>` field referenced
|
|
103
|
+
directly in the template (`Count.Value` above) gets it wired automatically. The exact
|
|
104
|
+
same component, hand-written, needs that `Subscribe` call itself:
|
|
105
|
+
|
|
106
|
+
```ks
|
|
107
|
+
class Counter : Component {
|
|
108
|
+
private state<number> Count;
|
|
109
|
+
|
|
110
|
+
constructor() : base() {
|
|
111
|
+
this.Count = state(0);
|
|
112
|
+
this.Count.Subscribe((number v) => this.Update());
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
public override Element Render() {
|
|
116
|
+
Element button = document.createElement("button");
|
|
117
|
+
button.textContent = "Count: " + this.Count.Value;
|
|
118
|
+
button.addEventListener("click", (Event e) => {
|
|
119
|
+
this.Count.Value = this.Count.Value + 1;
|
|
120
|
+
});
|
|
121
|
+
return button;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
Both produce the same `Render()`, and can be mixed freely across a codebase — nothing
|
|
127
|
+
about `Component`, `Update()`, or any other Kopular API differs between them. The manual
|
|
128
|
+
`Subscribe` call is still exactly what you need the moment state is reached *indirectly*
|
|
129
|
+
— through a method call, or through an injected service's own state
|
|
130
|
+
(`this.Service.Count`, say) — which is why the real, production `Counter` on the
|
|
131
|
+
[KopularDemo](https://dev.azure.com/koppinator/Koppindependence/_git/KopularDemo) site
|
|
132
|
+
(it injects a `CounterService` rather than holding `Count` itself) uses a template but
|
|
133
|
+
still has one manual `Subscribe`. `*if`/`*for` in a template are covered under
|
|
134
|
+
"Structural directives" below, alongside their hand-written-`Render()` equivalents.
|
|
135
|
+
|
|
65
136
|
## Dependency injection: the composition root pattern
|
|
66
137
|
|
|
67
138
|
Kopular has no injector because KopScript has nothing for one to hook into — no
|
|
@@ -202,20 +273,23 @@ standard. For local dev, see KopularDemo's `scripts/serve.mjs`.
|
|
|
202
273
|
## Structural directives
|
|
203
274
|
|
|
204
275
|
Angular's `*ngIf`/`*ngFor`/`*ngSwitch` are template syntax that expands, at compile time,
|
|
205
|
-
into imperative view-container calls.
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
|
212
|
-
|
|
|
213
|
-
| `*
|
|
214
|
-
| `*
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
276
|
+
into imperative view-container calls. In a Kopular **template**, `*if`/`*for` are exactly
|
|
277
|
+
that — real KopScript `if`/`for` statements underneath (see "Templates" above), compiled
|
|
278
|
+
by KopScript itself, not interpreted by Kopular at runtime. In a **hand-written**
|
|
279
|
+
`Render()`, there's no separate directive syntax to reach for: each job maps onto a plain
|
|
280
|
+
expression, and two of the three need nothing new at all.
|
|
281
|
+
|
|
282
|
+
| Angular | Kopular template | Kopular hand-written `Render()` | New code? |
|
|
283
|
+
| ----------------- | ---------------- | -------------------------------------------- | :-------: |
|
|
284
|
+
| `*ngFor` | `*for="Type v of expr"` | `array.ForEach((item) => ...)` | none — already a KopScript array method |
|
|
285
|
+
| `*ngSwitch` | *(not supported — use `*if`, or switch in the backing class)* | `match value { ... }` | none — already a KopScript expression, and exhaustiveness-checked (`*ngSwitch` isn't) |
|
|
286
|
+
| `*ngIf` / `*ngIf-else` | `*if="expr"` | `If(condition, () => ..., () => ...)` | `directives.ks` (hand-written form only — a template's `*if` needs no helper, it's a real `if`) |
|
|
287
|
+
|
|
288
|
+
The rest of this section is about the **hand-written `Render()`** column above — a
|
|
289
|
+
template's `*if`/`*for` need no further explanation, they're covered under "Templates".
|
|
290
|
+
`*ngIf` is the one case in hand-written `Render()` that needs something new: `if` is a
|
|
291
|
+
*statement* in KopScript, so without a helper you'd need a throwaway mutable local just
|
|
292
|
+
to get a conditional value out of it. `If()` is that helper — nothing more than:
|
|
219
293
|
|
|
220
294
|
```ks
|
|
221
295
|
Element If(bool condition, () => Element whenTrue, () => Element whenFalse) {
|
|
@@ -232,7 +306,7 @@ to hand back. Only the branch actually taken runs — the other lambda is never
|
|
|
232
306
|
an explicit empty branch (`() => document.createElement("span")`) costs nothing when
|
|
233
307
|
there's genuinely nothing to show.
|
|
234
308
|
|
|
235
|
-
All three read the same way, right inside `Render()` — no
|
|
309
|
+
All three read the same way, right inside a hand-written `Render()` — no directive
|
|
236
310
|
registration, nothing to import beyond the function itself:
|
|
237
311
|
|
|
238
312
|
```ks
|
package/bin/kp.mjs
CHANGED
|
@@ -97,7 +97,7 @@ A [KopScript](https://www.npmjs.com/package/kopscript) + [Kopular](https://www.n
|
|
|
97
97
|
|
|
98
98
|
## Where to go next
|
|
99
99
|
|
|
100
|
-
- \`src/counter.ks\` is a real, working Kopular \`Component\` — start there.
|
|
100
|
+
- \`src/counter.ks\` + \`src/counter.html\` is a real, working Kopular \`Component\` — start there. Its markup lives in \`counter.html\` (interpolation, \`(click)="..."\`) via KopScript's \`template from\`; \`counter.ks\` holds only state and logic. See \`node_modules/kopscript/LLM.md\`'s "Templates" section for the full binding/directive syntax, and Kopular's own README/LLM.md for the hand-written-\`Render()\` alternative.
|
|
101
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
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
103
|
- For the full language reference, see \`node_modules/kopscript/LLM.md\`.
|
|
@@ -242,28 +242,31 @@ extern class Router {
|
|
|
242
242
|
`;
|
|
243
243
|
|
|
244
244
|
// The same Counter shown in Kopular's own README/LLM.md — real, verified
|
|
245
|
-
// example code, not a placeholder.
|
|
245
|
+
// example code, not a placeholder. Uses a template (see counter.html below)
|
|
246
|
+
// rather than a hand-written Render(), since that's the more common style
|
|
247
|
+
// today; Kopular's/KopScript's own docs show the equivalent hand-written
|
|
248
|
+
// form too, for the case a component needs one (e.g. Subscribe-ing to state
|
|
249
|
+
// reached indirectly, through an injected service).
|
|
246
250
|
const COUNTER_KS_TEMPLATE = `using "./kopular_bindings";
|
|
247
251
|
|
|
248
252
|
class Counter : Component {
|
|
249
|
-
|
|
253
|
+
public state<number> Count;
|
|
250
254
|
|
|
251
255
|
constructor() : base() {
|
|
252
256
|
this.Count = state(0);
|
|
253
|
-
this.Count.Subscribe((number v) => this.Update());
|
|
254
257
|
}
|
|
255
258
|
|
|
256
|
-
public
|
|
257
|
-
|
|
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;
|
|
259
|
+
public void Increment() {
|
|
260
|
+
this.Count.Value = this.Count.Value + 1;
|
|
263
261
|
}
|
|
262
|
+
|
|
263
|
+
template from "./counter.html";
|
|
264
264
|
}
|
|
265
265
|
`;
|
|
266
266
|
|
|
267
|
+
const COUNTER_HTML_TEMPLATE = `<button (click)="Increment()">Count: {{ Count.Value }}</button>
|
|
268
|
+
`;
|
|
269
|
+
|
|
267
270
|
const APP_KS_TEMPLATE = `using "./kopular_bindings";
|
|
268
271
|
using "./counter";
|
|
269
272
|
|
|
@@ -293,6 +296,7 @@ function scaffoldProject(dirPath) {
|
|
|
293
296
|
writeFileSync(join(dirPath, "scripts", "vendor-kopular.mjs"), VENDOR_KOPULAR_MJS_TEMPLATE, "utf-8");
|
|
294
297
|
writeFileSync(join(dirPath, "src", "kopular_bindings.ks"), KOPULAR_BINDINGS_KS_TEMPLATE, "utf-8");
|
|
295
298
|
writeFileSync(join(dirPath, "src", "counter.ks"), COUNTER_KS_TEMPLATE, "utf-8");
|
|
299
|
+
writeFileSync(join(dirPath, "src", "counter.html"), COUNTER_HTML_TEMPLATE, "utf-8");
|
|
296
300
|
writeFileSync(join(dirPath, "src", "app.ks"), APP_KS_TEMPLATE, "utf-8");
|
|
297
301
|
|
|
298
302
|
console.log(`Created ${name} in ${dirPath}`);
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "kopular",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Kopular: a small component framework for KopScript — components, reactive state, constructor-injected services, routing,
|
|
3
|
+
"version": "0.11.1",
|
|
4
|
+
"description": "Kopular: a small component framework for KopScript — components, reactive state, constructor-injected services, routing, real compiled templates, and HTTP, with no DI container",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"author": "Joe Koppin <koppinjo@gmail.com>",
|
|
@@ -9,11 +9,16 @@
|
|
|
9
9
|
"type": "git",
|
|
10
10
|
"url": "https://dev.azure.com/koppinator/Koppindependence/_git/Kopular"
|
|
11
11
|
},
|
|
12
|
+
"homepage": "https://kopular.dev",
|
|
12
13
|
"keywords": [
|
|
13
14
|
"kopscript",
|
|
14
15
|
"framework",
|
|
15
16
|
"components",
|
|
16
|
-
"ui"
|
|
17
|
+
"ui",
|
|
18
|
+
"ai",
|
|
19
|
+
"llm",
|
|
20
|
+
"codegen",
|
|
21
|
+
"agent"
|
|
17
22
|
],
|
|
18
23
|
"main": "./src/component.js",
|
|
19
24
|
"bin": {
|
|
@@ -38,6 +43,7 @@
|
|
|
38
43
|
"scripts": {
|
|
39
44
|
"build": "ks build src/router.ks && ks build src/directives.ks && ks build src/http.ks && ks build src/forms.ks",
|
|
40
45
|
"prepublishOnly": "npm run build",
|
|
46
|
+
"pretest": "npm run build",
|
|
41
47
|
"test": "vitest run",
|
|
42
48
|
"test:watch": "vitest"
|
|
43
49
|
},
|
|
@@ -50,12 +56,12 @@
|
|
|
50
56
|
}
|
|
51
57
|
},
|
|
52
58
|
"devDependencies": {
|
|
53
|
-
"kopscript": "^0.5.0",
|
|
54
59
|
"@types/jsdom": "^30.0.0",
|
|
55
60
|
"@types/node": "^20.14.0",
|
|
56
61
|
"jsdom": "^25.0.1",
|
|
62
|
+
"kopscript": "^0.7.0",
|
|
57
63
|
"typescript": "^5.5.0",
|
|
58
|
-
"vitest": "^
|
|
64
|
+
"vitest": "^4.1.11"
|
|
59
65
|
},
|
|
60
66
|
"engines": {
|
|
61
67
|
"node": ">=18"
|
package/src/component.js
CHANGED
|
@@ -18,14 +18,14 @@ export class Component {
|
|
|
18
18
|
}
|
|
19
19
|
|
|
20
20
|
Mount(parent) {
|
|
21
|
-
|
|
22
|
-
|
|
21
|
+
this.ParentElement = parent;
|
|
22
|
+
this.Root = this.SafeRender();
|
|
23
23
|
parent.appendChild(this.Root);
|
|
24
24
|
}
|
|
25
25
|
|
|
26
26
|
Update() {
|
|
27
27
|
let newRoot = this.SafeRender();
|
|
28
28
|
this.ParentElement.replaceChild(newRoot, this.Root);
|
|
29
|
-
|
|
29
|
+
this.Root = newRoot;
|
|
30
30
|
}
|
|
31
31
|
}
|
package/src/forms.js
CHANGED
|
@@ -15,16 +15,16 @@ class __KopState {
|
|
|
15
15
|
|
|
16
16
|
export class FormField {
|
|
17
17
|
constructor(initial, validate) {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
18
|
+
this.Value = new __KopState(initial);
|
|
19
|
+
this.Error = new __KopState(validate(initial));
|
|
20
|
+
this.Touched = new __KopState(false);
|
|
21
21
|
this.Value.Subscribe((v) => {
|
|
22
|
-
|
|
22
|
+
this.Error.Value = validate(v);
|
|
23
23
|
});
|
|
24
24
|
}
|
|
25
25
|
|
|
26
26
|
Touch() {
|
|
27
|
-
|
|
27
|
+
this.Touched.Value = true;
|
|
28
28
|
}
|
|
29
29
|
|
|
30
30
|
Valid() {
|
package/src/router.js
CHANGED
|
@@ -4,23 +4,23 @@ import { Component } from "./component.js";
|
|
|
4
4
|
export class Router extends Component {
|
|
5
5
|
constructor(notFoundPage) {
|
|
6
6
|
super();
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
7
|
+
this.Paths = [];
|
|
8
|
+
this.Pages = [];
|
|
9
|
+
this.NotFoundPage = notFoundPage;
|
|
10
|
+
this.Param = "";
|
|
11
|
+
this.Guard = (path) => (true);
|
|
12
|
+
this.RedirectPath = "";
|
|
13
13
|
window.addEventListener("popstate", (e) => (this.Update()));
|
|
14
14
|
}
|
|
15
15
|
|
|
16
16
|
AddRoute(path, page) {
|
|
17
|
-
|
|
18
|
-
|
|
17
|
+
this.Paths = [...this.Paths, path];
|
|
18
|
+
this.Pages = [...this.Pages, page];
|
|
19
19
|
}
|
|
20
20
|
|
|
21
21
|
SetGuard(redirectPath, guard) {
|
|
22
|
-
|
|
23
|
-
|
|
22
|
+
this.RedirectPath = redirectPath;
|
|
23
|
+
this.Guard = guard;
|
|
24
24
|
}
|
|
25
25
|
|
|
26
26
|
Navigate(path) {
|
|
@@ -32,39 +32,39 @@ export class Router extends Component {
|
|
|
32
32
|
let effectivePath = path;
|
|
33
33
|
if (!this.Guard(path)) {
|
|
34
34
|
history.pushState("", "", this.RedirectPath);
|
|
35
|
-
|
|
35
|
+
effectivePath = this.RedirectPath;
|
|
36
36
|
}
|
|
37
37
|
let pathSegments = effectivePath.split("/");
|
|
38
38
|
let found = this.NotFoundPage;
|
|
39
39
|
let param = "";
|
|
40
|
-
for (let i = 0; (i < this.Paths.length);
|
|
40
|
+
for (let i = 0; (i < this.Paths.length); i = (i + 1)) {
|
|
41
41
|
let patternSegments = this.Paths[i].split("/");
|
|
42
42
|
if ((patternSegments.length !== pathSegments.length)) {
|
|
43
43
|
continue;
|
|
44
44
|
}
|
|
45
45
|
let matched = true;
|
|
46
46
|
let capturedParam = "";
|
|
47
|
-
for (let j = 0; (j < patternSegments.length);
|
|
47
|
+
for (let j = 0; (j < patternSegments.length); j = (j + 1)) {
|
|
48
48
|
if (patternSegments[j].startsWith(":")) {
|
|
49
|
-
|
|
49
|
+
capturedParam = pathSegments[j];
|
|
50
50
|
} else if ((patternSegments[j] !== pathSegments[j])) {
|
|
51
|
-
|
|
51
|
+
matched = false;
|
|
52
52
|
break;
|
|
53
53
|
}
|
|
54
54
|
}
|
|
55
55
|
if (matched) {
|
|
56
|
-
|
|
57
|
-
|
|
56
|
+
found = this.Pages[i];
|
|
57
|
+
param = capturedParam;
|
|
58
58
|
break;
|
|
59
59
|
}
|
|
60
60
|
}
|
|
61
|
-
|
|
61
|
+
this.Param = param;
|
|
62
62
|
return found;
|
|
63
63
|
}
|
|
64
64
|
|
|
65
65
|
Render() {
|
|
66
66
|
let outlet = document.createElement("div");
|
|
67
|
-
|
|
67
|
+
outlet.className = "router-outlet";
|
|
68
68
|
let path = location.pathname;
|
|
69
69
|
let page = this.Match(path);
|
|
70
70
|
page.Mount(outlet);
|
package/src/testing.js
CHANGED
|
@@ -18,13 +18,26 @@
|
|
|
18
18
|
// who actually calls this, not a weight every `kopular` install pays.
|
|
19
19
|
import { JSDOM } from "jsdom";
|
|
20
20
|
import { compileGraph } from "kopscript";
|
|
21
|
-
import { cpSync, mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from "node:fs";
|
|
22
|
-
import { tmpdir } from "node:os";
|
|
21
|
+
import { cpSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
23
22
|
import { dirname, join } from "node:path";
|
|
24
23
|
import { fileURLToPath } from "node:url";
|
|
25
24
|
|
|
26
25
|
const GLOBAL_KEYS = ["document", "Element", "Event", "location", "history", "window", "fetch"];
|
|
27
26
|
const OWN_SRC_DIR = dirname(fileURLToPath(import.meta.url));
|
|
27
|
+
// A hidden folder at this package's own root — for an external consumer,
|
|
28
|
+
// that's inside their node_modules/kopular/, i.e. still within their
|
|
29
|
+
// project tree. Deliberately NOT os.tmpdir(): Vitest/Vite's own module
|
|
30
|
+
// resolution (used to dynamically `import()` the compiled fixture below)
|
|
31
|
+
// restricts filesystem access to the project's workspace root by default,
|
|
32
|
+
// and the OS temp directory sits outside that boundary entirely — on top
|
|
33
|
+
// of macOS's os.tmpdir() itself being a symlink (/var/folders/... ->
|
|
34
|
+
// /private/var/folders/...), a second, independent reason the same
|
|
35
|
+
// resolution can fail. This bit a real CI run with "Failed to load url
|
|
36
|
+
// kopular/component ... Does the file exist?" — a resolution failure, not
|
|
37
|
+
// a transform error — while passing intermittently elsewhere, which is
|
|
38
|
+
// exactly the signature of a filesystem-access-boundary problem rather
|
|
39
|
+
// than anything wrong with the copied files themselves.
|
|
40
|
+
const TEMP_ROOT = join(OWN_SRC_DIR, "..", ".kopular-testing-tmp");
|
|
28
41
|
|
|
29
42
|
// Copies every .ks/.html file from `srcDir` into `dir`, plus any filename
|
|
30
43
|
// listed in `extraFiles` (e.g. http_runtime.js — a hand-written sibling a
|
|
@@ -40,13 +53,14 @@ function copySources(srcDir, dir, extraFiles) {
|
|
|
40
53
|
|
|
41
54
|
// kopscript's compiled output is genuine ESM (`export class ...`), which
|
|
42
55
|
// Node only interprets correctly given a ".mjs" extension or an ancestor
|
|
43
|
-
// package.json declaring "type": "module" — a
|
|
56
|
+
// package.json declaring "type": "module" — a fresh directory has neither,
|
|
44
57
|
// so the dynamic `import()` below would otherwise fail with "Cannot use
|
|
45
58
|
// import statement outside a module" the moment anything outside this
|
|
46
59
|
// file's own module graph (a real npm package, not code vitest/vite-node
|
|
47
60
|
// itself transforms) tries to load it.
|
|
48
61
|
function mkTempDir() {
|
|
49
|
-
|
|
62
|
+
mkdirSync(TEMP_ROOT, { recursive: true });
|
|
63
|
+
const dir = mkdtempSync(join(TEMP_ROOT, "run-"));
|
|
50
64
|
writeFileSync(join(dir, "package.json"), JSON.stringify({ type: "module" }), "utf-8");
|
|
51
65
|
return dir;
|
|
52
66
|
}
|
|
@@ -57,9 +71,26 @@ function mkTempDir() {
|
|
|
57
71
|
// not via relative `using`. Resolved off this very file's own location, so
|
|
58
72
|
// it's always the real package as installed in the *caller's*
|
|
59
73
|
// node_modules, not a guess at a path.
|
|
74
|
+
//
|
|
75
|
+
// Copies only package.json plus its own "files" allowlist (src, bin,
|
|
76
|
+
// assets, LLM.md) — i.e. exactly what a real `npm install` would put there
|
|
77
|
+
// — not the whole package root. Copying the root wholesale would also drag
|
|
78
|
+
// along Kopular's *own* node_modules/.git/test, producing a nested
|
|
79
|
+
// node_modules/kopular/node_modules/... no real consumer's install ever
|
|
80
|
+
// has; that mismatch is what caused this to resolve fine in some
|
|
81
|
+
// environments and fail with "Does the file exist?" in CI, since a
|
|
82
|
+
// bundler's dependency resolution isn't obligated to behave the same in
|
|
83
|
+
// the presence of a duplicate, unexpected nested node_modules.
|
|
60
84
|
function copyKopularPackage(dir) {
|
|
61
|
-
|
|
62
|
-
|
|
85
|
+
const kopularRoot = join(OWN_SRC_DIR, "..");
|
|
86
|
+
const pkg = JSON.parse(readFileSync(join(kopularRoot, "package.json"), "utf-8"));
|
|
87
|
+
const destRoot = join(dir, "node_modules", "kopular");
|
|
88
|
+
mkdirSync(destRoot, { recursive: true });
|
|
89
|
+
cpSync(join(kopularRoot, "package.json"), join(destRoot, "package.json"));
|
|
90
|
+
for (const entry of pkg.files ?? []) {
|
|
91
|
+
const src = join(kopularRoot, entry);
|
|
92
|
+
if (existsSync(src)) cpSync(src, join(destRoot, entry), { recursive: true });
|
|
93
|
+
}
|
|
63
94
|
}
|
|
64
95
|
|
|
65
96
|
// Shared by both exports below: compiles `entryFileName` (already written
|