kopular 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LLM.md +244 -0
- package/README.md +203 -3
- package/assets/logo.png +0 -0
- package/package.json +10 -6
- package/src/directives.js +8 -0
- package/src/directives.ks +30 -0
- package/src/http.js +25 -0
- package/src/http.ks +52 -0
- package/src/http_runtime.js +15 -0
package/LLM.md
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
# Kopular — LLM reference
|
|
2
|
+
|
|
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
|
|
5
|
+
them. For the host language, see KopScript's own `LLM.md` in the `Kop` repo (or its
|
|
6
|
+
published `LLM.md` on the `kopscript` npm package) — that reference is a prerequisite,
|
|
7
|
+
not repeated here.
|
|
8
|
+
|
|
9
|
+
Published as npm `kopular`. Entry points: `kopular` / `kopular/component` (Component),
|
|
10
|
+
`kopular/router` (Router), `kopular/dom` (ambient DOM bindings), `kopular/directives`
|
|
11
|
+
(If), `kopular/http` (Http).
|
|
12
|
+
|
|
13
|
+
## Consuming Kopular from your own KopScript project
|
|
14
|
+
|
|
15
|
+
`using` only resolves same-project relative paths — reaching into an npm package (Kopular
|
|
16
|
+
included) always goes through `extern`, re-describing exactly the members you use:
|
|
17
|
+
|
|
18
|
+
```ks
|
|
19
|
+
extern class Component {
|
|
20
|
+
constructor();
|
|
21
|
+
virtual Element Render(); // `virtual` here is what lets your subclass `override` it
|
|
22
|
+
void Mount(Element parent);
|
|
23
|
+
void Update();
|
|
24
|
+
} from "kopular/component";
|
|
25
|
+
|
|
26
|
+
extern class Router {
|
|
27
|
+
constructor(Component notFoundPage);
|
|
28
|
+
void AddRoute(string path, Component page);
|
|
29
|
+
void Navigate(string path);
|
|
30
|
+
void Mount(Element parent);
|
|
31
|
+
} from "kopular/router";
|
|
32
|
+
|
|
33
|
+
extern Element If(bool condition, () => Element whenTrue, () => Element whenFalse) from "kopular/directives";
|
|
34
|
+
|
|
35
|
+
extern class Response {
|
|
36
|
+
bool ok { get; }
|
|
37
|
+
number status { get; }
|
|
38
|
+
task<string> text(); // no `async` on an extern signature — see KopScript's own LLM.md
|
|
39
|
+
};
|
|
40
|
+
extern class Http {
|
|
41
|
+
static task<Response> Get(string url);
|
|
42
|
+
static task<Response> Post(string url, string jsonBody);
|
|
43
|
+
static task<Response> Put(string url, string jsonBody);
|
|
44
|
+
static task<Response> Patch(string url, string jsonBody);
|
|
45
|
+
static task<Response> Delete(string url);
|
|
46
|
+
} from "kopular/http";
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
You also need your own ambient DOM `extern` block (`document`, `Element`, `Event`, ...) —
|
|
50
|
+
Kopular's own copy in `dom.ks` isn't reachable across the package boundary; redeclare the
|
|
51
|
+
handful of members you actually use. See KopularDemo's `src/kopular_bindings.ks` for a
|
|
52
|
+
complete, real example of both.
|
|
53
|
+
|
|
54
|
+
## `Component` (`component.ks`)
|
|
55
|
+
|
|
56
|
+
```ks
|
|
57
|
+
class MyWidget : Component {
|
|
58
|
+
private number count;
|
|
59
|
+
|
|
60
|
+
constructor() : base() {
|
|
61
|
+
this.count = 0;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
public override Element Render() {
|
|
65
|
+
Element el = document.createElement("div");
|
|
66
|
+
el.textContent = "Count: " + this.count;
|
|
67
|
+
return el;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
public void Bump() {
|
|
71
|
+
this.count = this.count + 1;
|
|
72
|
+
this.Update(); // re-runs Render(), swaps the old root for the new one
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
MyWidget w = new MyWidget();
|
|
77
|
+
w.Mount(document.body); // first Render() + append
|
|
78
|
+
w.Bump(); // re-render
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
- `Render()`: `virtual`, override it to build a fresh DOM subtree from current state.
|
|
82
|
+
Called by both `Mount()` and `Update()`. **No template language, no diffing** — every
|
|
83
|
+
call rebuilds the whole subtree from scratch.
|
|
84
|
+
- `Mount(parent)`: calls `Render()` once, appends the result to `parent`, remembers both
|
|
85
|
+
for `Update()` to use later.
|
|
86
|
+
- `Update()` (protected — called from within the component, not externally): calls
|
|
87
|
+
`Render()` again and `replaceChild`s the old root with the new one.
|
|
88
|
+
- **Known limitation**: if a *parent* component's `Update()` runs while it has mounted
|
|
89
|
+
children, those children are NOT automatically re-mounted into the new parent tree —
|
|
90
|
+
`Component` only handles a single component's own re-render cycle, not tree
|
|
91
|
+
reconciliation. Compose independent components into stable slots (see `Router`'s own
|
|
92
|
+
pattern of keeping page instances alive) to avoid this rather than nesting components
|
|
93
|
+
that both re-render.
|
|
94
|
+
|
|
95
|
+
## `Router` (`router.ks`)
|
|
96
|
+
|
|
97
|
+
Real URLs via the History API (`pushState`/`popstate`), not hash routing.
|
|
98
|
+
|
|
99
|
+
```ks
|
|
100
|
+
class NotFoundPage : Component {
|
|
101
|
+
public override Element Render() { /* ... */ }
|
|
102
|
+
}
|
|
103
|
+
class HomePage : Component {
|
|
104
|
+
private Router Nav;
|
|
105
|
+
constructor(Router nav) : base() { this.Nav = nav; }
|
|
106
|
+
public override Element Render() {
|
|
107
|
+
Element btn = document.createElement("button");
|
|
108
|
+
btn.addEventListener("click", (Event e) => { this.Nav.Navigate("/about"); });
|
|
109
|
+
return btn;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
Router nav = new Router(new NotFoundPage()); // NotFoundPage REQUIRED up front — no null route
|
|
114
|
+
nav.AddRoute("/", new HomePage(nav)); // pages built once, kept alive for Router's lifetime
|
|
115
|
+
nav.AddRoute("/about", new AboutPage(nav));
|
|
116
|
+
nav.Mount(document.body);
|
|
117
|
+
nav.Navigate("/about"); // pushState + immediate re-render
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
- Constructor takes the fallback page (`Component`), required — there's no nullable
|
|
121
|
+
"no match" representation.
|
|
122
|
+
- `AddRoute(path, page)` takes an **already-constructed `Component` instance**, not a
|
|
123
|
+
factory — every registered page is built once and stays alive for the Router's whole
|
|
124
|
+
lifetime, so a page's own `state<T>` fields survive navigating away and back.
|
|
125
|
+
- `Navigate(path)` calls `history.pushState` then re-renders immediately. A browser
|
|
126
|
+
back/forward triggers re-render via a `popstate` listener registered in the
|
|
127
|
+
constructor — `Navigate()` itself doesn't rely on that event.
|
|
128
|
+
- Needs a server that falls back to the app shell for any unrecognized path (a plain
|
|
129
|
+
static server has nothing to serve at `/about` on direct load/refresh).
|
|
130
|
+
|
|
131
|
+
## `If` (`directives.ks`) — the `*ngIf` equivalent
|
|
132
|
+
|
|
133
|
+
```ks
|
|
134
|
+
Element If(bool condition, () => Element whenTrue, () => Element whenFalse)
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
```ks
|
|
138
|
+
root.appendChild(If(this.IsLoggedIn, () => this.BuildProfile(), () => this.BuildLogin()));
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
Both branches always required (no null "nothing" value); only the branch actually taken
|
|
142
|
+
is called — the other lambda never runs. `*ngFor` and `*ngSwitch` need no Kopular helper
|
|
143
|
+
at all:
|
|
144
|
+
|
|
145
|
+
```ks
|
|
146
|
+
// *ngFor — plain array method
|
|
147
|
+
this.Items.ForEach((Item item) => { list.appendChild(this.BuildItemRow(item)); });
|
|
148
|
+
|
|
149
|
+
// *ngSwitch — plain KopScript `match` expression (exhaustiveness-checked, unlike *ngSwitch)
|
|
150
|
+
root.appendChild(match this.Status {
|
|
151
|
+
"loading" => this.BuildSpinner(),
|
|
152
|
+
"error" => this.BuildError(),
|
|
153
|
+
_ => this.BuildContent()
|
|
154
|
+
});
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
No keyed/reuse-existing-DOM-nodes diffing (the performance angle of `*ngFor trackBy`) —
|
|
158
|
+
that needs comparing old/new data by a caller key, generic over item type, and KopScript
|
|
159
|
+
has no generics. Not planned as a workaround; would need real language-level generics
|
|
160
|
+
first.
|
|
161
|
+
|
|
162
|
+
## `Http` (`http.ks`) — thin wrapper over `fetch`
|
|
163
|
+
|
|
164
|
+
```ks
|
|
165
|
+
Response r = await Http.Get(url); // task<Response>
|
|
166
|
+
Response r = await Http.Post(url, jsonBody); // string body, Content-Type: application/json
|
|
167
|
+
Response r = await Http.Put(url, jsonBody);
|
|
168
|
+
Response r = await Http.Patch(url, jsonBody);
|
|
169
|
+
Response r = await Http.Delete(url); // no body param — DELETE has none
|
|
170
|
+
|
|
171
|
+
r.ok // bool
|
|
172
|
+
r.status // number
|
|
173
|
+
await r.text(); // task<string> — the raw body, nothing more
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
**No typed JSON deserialization** — no generics means no safe `Get<T>(url): task<T>`.
|
|
177
|
+
Get a typed response by describing its shape as its own `extern class` and parsing with
|
|
178
|
+
a per-shape `extern ... as "JSON.parse"` (unchecked, same trust model as every other
|
|
179
|
+
`extern`):
|
|
180
|
+
|
|
181
|
+
```ks
|
|
182
|
+
extern class DogDto { string name { get; } };
|
|
183
|
+
extern DogDto ParseDog(string json) as "JSON.parse";
|
|
184
|
+
|
|
185
|
+
DogDto dog = ParseDog(await (await Http.Get(url)).text());
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
`Get`/`Delete` need no request body, so they bind straight to the real global `fetch` —
|
|
189
|
+
no object literal involved (KopScript has none). `Post`/`Put`/`Patch` (and a
|
|
190
|
+
hypothetical `Delete`-with-a-body) need one for `{ method, headers, body }`, which
|
|
191
|
+
KopScript categorically cannot construct — Kopular ships one small hand-written JS
|
|
192
|
+
function (`http_runtime.js`, not compiled from `.ks`) that does, for exactly that reason.
|
|
193
|
+
|
|
194
|
+
## Dependency injection — no container, no decorators
|
|
195
|
+
|
|
196
|
+
There is no injector, no `@Injectable`, no provider tokens. "Injecting" a service is
|
|
197
|
+
passing it as a constructor argument — the compiler enforces it (a missing/mistyped
|
|
198
|
+
dependency is a build error). For an app with more than a couple of services, use one
|
|
199
|
+
plain "composition root" class (not a `Component`, never touches the DOM) that builds
|
|
200
|
+
the whole service/page graph exactly once and hands the finished pieces to whatever
|
|
201
|
+
needs them:
|
|
202
|
+
|
|
203
|
+
```ks
|
|
204
|
+
class AppContainer {
|
|
205
|
+
public Router Nav;
|
|
206
|
+
constructor() {
|
|
207
|
+
CounterService counter = new CounterService(); // shared singleton: pass the same
|
|
208
|
+
this.Nav = new Router(new NotFoundPage()); // instance wherever it should be
|
|
209
|
+
this.Nav.AddRoute("/", new HomePage(this.Nav, counter));
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
class RoutedApp : Component {
|
|
214
|
+
private Router Nav;
|
|
215
|
+
constructor(AppContainer services) : base() { this.Nav = services.Nav; }
|
|
216
|
+
public override Element Render() {
|
|
217
|
+
Element el = document.createElement("div");
|
|
218
|
+
this.Nav.Mount(el);
|
|
219
|
+
return el;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
RoutedApp app = new RoutedApp(new AppContainer());
|
|
224
|
+
app.Mount(document.body);
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
A service needing its own state/logic is a plain class — no base class, no
|
|
228
|
+
decorators, nothing framework-specific:
|
|
229
|
+
|
|
230
|
+
```ks
|
|
231
|
+
class CounterService {
|
|
232
|
+
public state<number> Count;
|
|
233
|
+
constructor() { this.Count = state(0); }
|
|
234
|
+
public void Increment() { this.Count.Value = this.Count.Value + 1; }
|
|
235
|
+
}
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
## Does not exist
|
|
239
|
+
|
|
240
|
+
DI container/injector · decorators (`@Injectable`, `@Component`, ...) · a template
|
|
241
|
+
language/DSL — everything is imperative `Render()` code against plain DOM bindings ·
|
|
242
|
+
vdom diffing / reconciliation beyond a single component's own re-render · pipes ·
|
|
243
|
+
animations · forms/validation module · typed/generic HTTP responses (`Http` returns raw
|
|
244
|
+
text — see above) · a CLI/scaffolding tool (`ng generate`-equivalent) · SSR.
|
package/README.md
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
<img src="https://cdn.jsdelivr.net/npm/kopular@latest/assets/logo.png" width="96" height="96" alt="Kopular logo">
|
|
2
|
+
|
|
1
3
|
# Kopular
|
|
2
4
|
|
|
3
5
|
Kopular is a small component framework for [KopScript](https://dev.azure.com/koppinator/Koppindependence/_git/Kop),
|
|
@@ -5,6 +7,9 @@ built to give Angular's separation of concerns — components own UI, services o
|
|
|
5
7
|
a router owns navigation — without Angular's steepest learning-curve pieces: no RxJS, no
|
|
6
8
|
dependency-injection container, no template DSL.
|
|
7
9
|
|
|
10
|
+
Generating Kopular code with an AI coding assistant? Point it at **[`LLM.md`](./LLM.md)**
|
|
11
|
+
— a dense, complete reference designed to be loaded straight into an LLM's context.
|
|
12
|
+
|
|
8
13
|
## Highlights
|
|
9
14
|
|
|
10
15
|
- **`Component`**: a base class with `virtual Render()` (builds a fresh DOM subtree from
|
|
@@ -24,16 +29,211 @@ dependency-injection container, no template DSL.
|
|
|
24
29
|
DSL. Needs a server that falls back to the app shell for unrecognized paths — see
|
|
25
30
|
[KopularDemo](https://dev.azure.com/koppinator/Koppindependence/_git/KopularDemo)'s
|
|
26
31
|
`scripts/serve.mjs`.
|
|
32
|
+
- **Structural directives, no template DSL**: `*ngIf`/`*ngFor`/`*ngSwitch`'s job — build
|
|
33
|
+
a subtree conditionally, repeat one per item, pick one of several cases — done as plain
|
|
34
|
+
function calls (`If(...)`) and existing KopScript expressions (`array.ForEach(...)`,
|
|
35
|
+
`match`), not special template syntax. See "Structural directives" below.
|
|
36
|
+
- **`Http`**: a thin, static wrapper over the real Fetch API (`Http.Get(url)`,
|
|
37
|
+
`Http.Post(url, jsonBody)`, ...) — no HttpClient injection tokens, no RxJS
|
|
38
|
+
observables/operators. See "HTTP" below.
|
|
27
39
|
|
|
28
40
|
## What's here
|
|
29
41
|
|
|
30
42
|
- `src/dom.ks` — ambient DOM bindings (`document`, `Element`, `Event`, `window`,
|
|
31
|
-
`location`) that `component.ks`/`router.ks` are built on.
|
|
43
|
+
`location`) that `component.ks`/`router.ks`/`directives.ks` are built on.
|
|
32
44
|
- `src/component.ks` — the `Component` base class.
|
|
33
45
|
- `src/router.ks` — the `Router`.
|
|
46
|
+
- `src/directives.ks` — `If()`, the structural-directive equivalents' one genuinely new
|
|
47
|
+
piece (see below).
|
|
48
|
+
- `src/http.ks` — `Http`, a thin wrapper over `fetch` (see below). `src/http_runtime.js`
|
|
49
|
+
is its one companion file — the single hand-written (not compiled from `.ks`) file in
|
|
50
|
+
Kopular, and why is explained in its own header comment.
|
|
51
|
+
|
|
52
|
+
That's the whole framework — six files. Everything else (a real app built on top of it)
|
|
53
|
+
lives in a separate consumer repo, [KopularDemo](https://dev.azure.com/koppinator/Koppindependence/_git/KopularDemo).
|
|
54
|
+
|
|
55
|
+
## Dependency injection: the composition root pattern
|
|
56
|
+
|
|
57
|
+
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
|
|
59
|
+
whole app's service/page graph gets built exactly once, by hand, in one place: a plain
|
|
60
|
+
class with no `Component` base and no framework code in it at all, sometimes called an
|
|
61
|
+
**app container** or (in the wider DI literature) a **composition root**. Everything
|
|
62
|
+
else just takes what it needs as constructor arguments and never constructs its own
|
|
63
|
+
dependencies.
|
|
64
|
+
|
|
65
|
+
```ks
|
|
66
|
+
// app_container.ks — the one place that decides what's shared and builds
|
|
67
|
+
// the graph, in dependency order.
|
|
68
|
+
class AppContainer {
|
|
69
|
+
public Router Nav;
|
|
70
|
+
|
|
71
|
+
constructor() {
|
|
72
|
+
// Built once, passed to every page that needs it below — that's the
|
|
73
|
+
// whole mechanism for a shared singleton. A page that constructed its
|
|
74
|
+
// own `new CounterService()` instead would get an independent one; the
|
|
75
|
+
// difference is which variable gets passed in, not a config flag.
|
|
76
|
+
CounterService counter = new CounterService();
|
|
77
|
+
|
|
78
|
+
this.Nav = new Router(new NotFoundPage());
|
|
79
|
+
this.Nav.AddRoute("/", new HomePage(this.Nav, counter));
|
|
80
|
+
this.Nav.AddRoute("/about", new AboutPage(this.Nav));
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// routed_app.ks — the root Component. Takes the already-built graph; never
|
|
85
|
+
// builds one of its own.
|
|
86
|
+
class RoutedApp : Component {
|
|
87
|
+
private Router Nav;
|
|
88
|
+
|
|
89
|
+
constructor(AppContainer services) : base() {
|
|
90
|
+
this.Nav = services.Nav;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
public override Element Render() {
|
|
94
|
+
Element container = document.createElement("div");
|
|
95
|
+
this.Nav.Mount(container);
|
|
96
|
+
return container;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
RoutedApp app = new RoutedApp(new AppContainer());
|
|
101
|
+
app.Mount(document.body);
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
This is sometimes called "Pure DI" — the same benefits a container gives you (nothing
|
|
105
|
+
hardcodes its own dependencies, everything is swappable in a test) with none of a
|
|
106
|
+
container's cost:
|
|
107
|
+
|
|
108
|
+
- **Compile-time checked.** A missing or mistyped dependency is `expected N arguments,
|
|
109
|
+
got M` from the KopScript compiler, not a `NullInjectorError` your users hit at
|
|
110
|
+
runtime after a container fails to resolve something.
|
|
111
|
+
- **Fully legible.** The entire dependency graph is ordinary, readable code in one file
|
|
112
|
+
— grep for `new` in the composition root and you've read the whole wiring diagram.
|
|
113
|
+
Nothing is constructed by a framework inspecting metadata behind the scenes.
|
|
114
|
+
- **No new concepts.** If you already know how to call a constructor, you already know
|
|
115
|
+
Kopular's DI story — there's no separate injector API, provider syntax, or
|
|
116
|
+
injection-token vocabulary to learn.
|
|
117
|
+
|
|
118
|
+
See [KopularDemo](https://dev.azure.com/koppinator/Koppindependence/_git/KopularDemo)'s
|
|
119
|
+
`src/app_container.ks` and `src/routed_app.ks` for the real, working version this
|
|
120
|
+
example is drawn from.
|
|
121
|
+
|
|
122
|
+
## Structural directives
|
|
123
|
+
|
|
124
|
+
Angular's `*ngIf`/`*ngFor`/`*ngSwitch` are template syntax that expands, at compile time,
|
|
125
|
+
into imperative view-container calls. Kopular has no template compiler to expand
|
|
126
|
+
anything into (`Render()` is already imperative — see `component.ks`), so there's no
|
|
127
|
+
special syntax here either: each one maps onto a plain expression, and two of the three
|
|
128
|
+
need nothing new at all.
|
|
129
|
+
|
|
130
|
+
| Angular | Kopular | New code? |
|
|
131
|
+
| ----------------- | ------------------------------------------- | :-------: |
|
|
132
|
+
| `*ngFor` | `array.ForEach((item) => ...)` | none — already a KopScript array method |
|
|
133
|
+
| `*ngSwitch` | `match value { ... }` | none — already a KopScript expression, and exhaustiveness-checked (`*ngSwitch` isn't) |
|
|
134
|
+
| `*ngIf` / `*ngIf-else` | `If(condition, () => ..., () => ...)` | `directives.ks` |
|
|
135
|
+
|
|
136
|
+
`*ngIf` is the one case that needs something new: `if` is a *statement* in KopScript, so
|
|
137
|
+
without a helper you'd need a throwaway mutable local just to get a conditional value out
|
|
138
|
+
of it. `If()` is that helper — nothing more than:
|
|
139
|
+
|
|
140
|
+
```ks
|
|
141
|
+
Element If(bool condition, () => Element whenTrue, () => Element whenFalse) {
|
|
142
|
+
if (condition) {
|
|
143
|
+
return whenTrue();
|
|
144
|
+
}
|
|
145
|
+
return whenFalse();
|
|
146
|
+
}
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
Both branches are required (same reasoning `Router` uses for requiring a `NotFoundPage`
|
|
150
|
+
up front — see `router.ks`): v1 has no nullable types, so "render nothing" has no value
|
|
151
|
+
to hand back. Only the branch actually taken runs — the other lambda is never called, so
|
|
152
|
+
an explicit empty branch (`() => document.createElement("span")`) costs nothing when
|
|
153
|
+
there's genuinely nothing to show.
|
|
154
|
+
|
|
155
|
+
All three read the same way, right inside `Render()` — no template file, no directive
|
|
156
|
+
registration, nothing to import beyond the function itself:
|
|
157
|
+
|
|
158
|
+
```ks
|
|
159
|
+
public override Element Render() {
|
|
160
|
+
Element root = document.createElement("div");
|
|
161
|
+
|
|
162
|
+
// *ngIf
|
|
163
|
+
root.appendChild(If(this.User.IsLoggedIn, () => this.BuildProfile(), () => this.BuildLoginButton()));
|
|
164
|
+
|
|
165
|
+
// *ngFor
|
|
166
|
+
Element list = document.createElement("ul");
|
|
167
|
+
this.Items.ForEach((Item item) => { list.appendChild(this.BuildItemRow(item)); });
|
|
168
|
+
root.appendChild(list);
|
|
169
|
+
|
|
170
|
+
// *ngSwitch
|
|
171
|
+
root.appendChild(match this.Status {
|
|
172
|
+
"loading" => this.BuildSpinner(),
|
|
173
|
+
"error" => this.BuildError(),
|
|
174
|
+
_ => this.BuildContent()
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
return root;
|
|
178
|
+
}
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
Why not a general reuse-existing-DOM-nodes diffing layer, the way `*ngFor trackBy`
|
|
182
|
+
avoids rebuilding unchanged rows? That needs comparing old and new *data* items by a
|
|
183
|
+
caller-supplied key, generic over the item type — and KopScript has no generics (no
|
|
184
|
+
`class Foo<T>`, no `T Resolve<T>()`). A one-off keyed-diff helper could be hand-written
|
|
185
|
+
per list, but that's real vdom-diffing work — already called out as out of scope in
|
|
186
|
+
"Status" below, and not something these three lines take on.
|
|
187
|
+
|
|
188
|
+
## HTTP
|
|
189
|
+
|
|
190
|
+
```ks
|
|
191
|
+
using "./http";
|
|
192
|
+
|
|
193
|
+
Response r = await Http.Get("/api/dogs");
|
|
194
|
+
if (r.ok) {
|
|
195
|
+
string body = await r.text();
|
|
196
|
+
print(body);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
await Http.Post("/api/dogs", "{\"name\":\"Rex\"}");
|
|
200
|
+
await Http.Put("/api/dogs/1", "{\"name\":\"Rexy\"}");
|
|
201
|
+
await Http.Patch("/api/dogs/1", "{\"name\":\"Max\"}");
|
|
202
|
+
await Http.Delete("/api/dogs/1");
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
`Http` is a thin, static wrapper over the real Fetch API — `Get`/`Post`/`Put`/`Patch`/
|
|
206
|
+
`Delete`, each returning `task<Response>` (`.ok`, `.status`, `async text()`). No
|
|
207
|
+
`HttpClient` to inject, no RxJS `Observable`/operators, no interceptors — call it from
|
|
208
|
+
anywhere, including straight out of a service's own methods.
|
|
209
|
+
|
|
210
|
+
**No typed JSON deserialization** — `Response.text()` gets you the raw body, nothing
|
|
211
|
+
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
|
|
214
|
+
typed response, describe its shape as its own `extern class` and parse it yourself with
|
|
215
|
+
a per-shape `extern ... as "JSON.parse"` declaration — the same trust-based approach
|
|
216
|
+
`extern` already uses for everything else, not a new mechanism:
|
|
217
|
+
|
|
218
|
+
```ks
|
|
219
|
+
extern class DogDto {
|
|
220
|
+
string name { get; }
|
|
221
|
+
};
|
|
222
|
+
extern DogDto ParseDog(string json) as "JSON.parse";
|
|
223
|
+
|
|
224
|
+
string body = await (await Http.Get("/api/dogs/1")).text();
|
|
225
|
+
DogDto dog = ParseDog(body); // unchecked, like a TypeScript `as DogDto` cast
|
|
226
|
+
```
|
|
34
227
|
|
|
35
|
-
|
|
36
|
-
|
|
228
|
+
**Why `Post`/`Put`/`Patch`/`Delete` aren't just `extern` bindings straight to `fetch`,
|
|
229
|
+
the way `Get` is**: setting a request method/body/headers means passing `fetch` a second
|
|
230
|
+
argument that's a plain JS object literal (`{ method, headers, body }`) — and KopScript
|
|
231
|
+
has no object-literal syntax at all, so it can't construct one. `src/http_runtime.js` is
|
|
232
|
+
one small hand-written function that does, and `Get`/`Delete`-with-no-body skip it
|
|
233
|
+
entirely (`fetch(url)` alone needs no options object, so `Get` binds straight to the
|
|
234
|
+
real global). It's the one file in this package not compiled from `.ks` — everywhere
|
|
235
|
+
else avoids the problem by only wrapping JS APIs that take plain positional arguments
|
|
236
|
+
(see `dom.ks`'s `addEventListener(string, handler)`, never an options-object-taking API).
|
|
37
237
|
|
|
38
238
|
## Using Kopular from another KopScript project
|
|
39
239
|
|
package/assets/logo.png
ADDED
|
Binary file
|
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, and
|
|
3
|
+
"version": "0.4.0",
|
|
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",
|
|
7
7
|
"author": "Joe Koppin <koppinjo@gmail.com>",
|
|
@@ -20,19 +20,23 @@
|
|
|
20
20
|
".": "./src/component.js",
|
|
21
21
|
"./component": "./src/component.js",
|
|
22
22
|
"./router": "./src/router.js",
|
|
23
|
-
"./dom": "./src/dom.js"
|
|
23
|
+
"./dom": "./src/dom.js",
|
|
24
|
+
"./directives": "./src/directives.js",
|
|
25
|
+
"./http": "./src/http.js"
|
|
24
26
|
},
|
|
25
27
|
"files": [
|
|
26
|
-
"src"
|
|
28
|
+
"src",
|
|
29
|
+
"assets",
|
|
30
|
+
"LLM.md"
|
|
27
31
|
],
|
|
28
32
|
"scripts": {
|
|
29
|
-
"build": "ks build src/router.ks",
|
|
33
|
+
"build": "ks build src/router.ks && ks build src/directives.ks && ks build src/http.ks",
|
|
30
34
|
"prepublishOnly": "npm run build",
|
|
31
35
|
"test": "vitest run",
|
|
32
36
|
"test:watch": "vitest"
|
|
33
37
|
},
|
|
34
38
|
"devDependencies": {
|
|
35
|
-
"kopscript": "^0.
|
|
39
|
+
"kopscript": "^0.3.0",
|
|
36
40
|
"@types/jsdom": "^30.0.0",
|
|
37
41
|
"@types/node": "^20.14.0",
|
|
38
42
|
"jsdom": "^25.0.1",
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
using "./dom";
|
|
2
|
+
|
|
3
|
+
// Kopular's structural-directive equivalents — see the README's
|
|
4
|
+
// "Structural directives" section for the full *ngIf/*ngFor/*ngSwitch
|
|
5
|
+
// mapping. There's no template language here (Kopular doesn't have one, by
|
|
6
|
+
// design — see component.ks), so these are just plain functions: call them
|
|
7
|
+
// like any other expression from inside Render(), the same way you'd call
|
|
8
|
+
// document.createElement.
|
|
9
|
+
//
|
|
10
|
+
// *ngFor and *ngSwitch need nothing new — `array.ForEach(...)` and
|
|
11
|
+
// KopScript's own `match` expression already cover them (and `match` is
|
|
12
|
+
// exhaustiveness-checked, which *ngSwitch isn't). `If` below is the one
|
|
13
|
+
// piece the language doesn't already give you as an expression: `if` is a
|
|
14
|
+
// statement in KopScript, so without this you'd need a throwaway mutable
|
|
15
|
+
// local to get a conditional value.
|
|
16
|
+
|
|
17
|
+
// The *ngIf equivalent — conditionally build one of two subtrees, as an
|
|
18
|
+
// expression. Both branches are required: v1 has no nullable types, so
|
|
19
|
+
// "render nothing" has no value to return — the same reasoning Router uses
|
|
20
|
+
// for requiring a NotFoundPage up front (see router.ks) rather than letting
|
|
21
|
+
// "no match" be null. Only the branch actually taken runs; the other
|
|
22
|
+
// lambda is never called, so an explicit empty branch (e.g.
|
|
23
|
+
// `() => document.createElement("span")`) costs nothing when there's
|
|
24
|
+
// genuinely nothing to show.
|
|
25
|
+
Element If(bool condition, () => Element whenTrue, () => Element whenFalse) {
|
|
26
|
+
if (condition) {
|
|
27
|
+
return whenTrue();
|
|
28
|
+
}
|
|
29
|
+
return whenFalse();
|
|
30
|
+
}
|
package/src/http.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export const Response = globalThis.Response;
|
|
2
|
+
export const FetchUrl = globalThis.fetch;
|
|
3
|
+
import { requestWithBody as RequestWithBody } from "./http_runtime.js";
|
|
4
|
+
export { RequestWithBody };
|
|
5
|
+
export class Http {
|
|
6
|
+
static async Get(url) {
|
|
7
|
+
return await FetchUrl(url);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
static async Delete(url) {
|
|
11
|
+
return await RequestWithBody(url, "DELETE", null, "application/json");
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
static async Post(url, jsonBody) {
|
|
15
|
+
return await RequestWithBody(url, "POST", jsonBody, "application/json");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
static async Put(url, jsonBody) {
|
|
19
|
+
return await RequestWithBody(url, "PUT", jsonBody, "application/json");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
static async Patch(url, jsonBody) {
|
|
23
|
+
return await RequestWithBody(url, "PATCH", jsonBody, "application/json");
|
|
24
|
+
}
|
|
25
|
+
}
|
package/src/http.ks
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// A thin wrapper over the real Fetch API — no object literals (KopScript
|
|
2
|
+
// has no syntax for one), no generics, no automatic JSON deserialization.
|
|
3
|
+
// `Response.Text()` gets you the raw body; for a typed JSON response,
|
|
4
|
+
// describe the shape as its own `extern class` and parse it with a
|
|
5
|
+
// per-shape `extern ... as "JSON.parse"` declaration (see Kopular's README)
|
|
6
|
+
// — the same trust-based approach `extern` already uses for everything
|
|
7
|
+
// else, not a new mechanism.
|
|
8
|
+
|
|
9
|
+
extern class Response {
|
|
10
|
+
bool ok { get; }
|
|
11
|
+
number status { get; }
|
|
12
|
+
task<string> text();
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
// GET/HEAD/DELETE-without-a-body need no options object at all, so they
|
|
16
|
+
// bind straight to the real global `fetch` — no runtime helper involved.
|
|
17
|
+
extern task<Response> FetchUrl(string url) as "fetch";
|
|
18
|
+
|
|
19
|
+
// POST/PUT/PATCH (and DELETE-with-a-body) need to set a method/body/
|
|
20
|
+
// headers, which does need an options object — the one thing in this file
|
|
21
|
+
// that isn't a direct, unassisted binding to a real JS global. See
|
|
22
|
+
// http_runtime.js for why, and for the only hand-written JS in this
|
|
23
|
+
// package. A relative path, not a package-name one: this is Kopular
|
|
24
|
+
// referencing its own sibling file (which isn't part of Kopular's public
|
|
25
|
+
// API — only Http's static methods below are), not a consumer reaching
|
|
26
|
+
// into Kopular from outside.
|
|
27
|
+
extern task<Response> RequestWithBody(string url, string method, string? body, string contentType) from "./http_runtime.js" as "requestWithBody";
|
|
28
|
+
|
|
29
|
+
// Static methods, not free functions, purely so call sites read as
|
|
30
|
+
// `Http.Get(url)` / `Http.Post(url, body)` — there's no instance state here
|
|
31
|
+
// to justify a real object.
|
|
32
|
+
class Http {
|
|
33
|
+
public static async task<Response> Get(string url) {
|
|
34
|
+
return await FetchUrl(url);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
public static async task<Response> Delete(string url) {
|
|
38
|
+
return await RequestWithBody(url, "DELETE", null, "application/json");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
public static async task<Response> Post(string url, string jsonBody) {
|
|
42
|
+
return await RequestWithBody(url, "POST", jsonBody, "application/json");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
public static async task<Response> Put(string url, string jsonBody) {
|
|
46
|
+
return await RequestWithBody(url, "PUT", jsonBody, "application/json");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
public static async task<Response> Patch(string url, string jsonBody) {
|
|
50
|
+
return await RequestWithBody(url, "PATCH", jsonBody, "application/json");
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// The one hand-written file in Kopular — every other .js file here is
|
|
2
|
+
// compiled from a same-named .ks source. KopScript has no object-literal
|
|
3
|
+
// syntax, so it can't construct `fetch`'s second (options) argument itself;
|
|
4
|
+
// every other Kopular binding avoids this by only wrapping JS APIs that take
|
|
5
|
+
// plain positional arguments (see dom.ks). `fetch(url)` alone needs no
|
|
6
|
+
// options object at all (that's a plain `extern` in http.ks), but a request
|
|
7
|
+
// with a body/headers does — this function exists so http.ks has something
|
|
8
|
+
// with a real, callable, options-object-free signature to bind to.
|
|
9
|
+
export function requestWithBody(url, method, body, contentType) {
|
|
10
|
+
return fetch(url, {
|
|
11
|
+
method,
|
|
12
|
+
headers: body === null ? undefined : { "Content-Type": contentType },
|
|
13
|
+
body: body === null ? undefined : body,
|
|
14
|
+
});
|
|
15
|
+
}
|