kopular 0.3.0 → 0.5.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 +53 -4
- package/README.md +81 -2
- package/bin/kp.mjs +316 -0
- package/package.json +10 -5
- package/src/http.js +25 -0
- package/src/http.ks +52 -0
- package/src/http_runtime.js +15 -0
package/LLM.md
CHANGED
|
@@ -1,13 +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 6 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
|
-
`kopular/router` (Router), `kopular/dom` (ambient DOM bindings), `kopular/directives`
|
|
10
|
+
`kopular/router` (Router), `kopular/dom` (ambient DOM bindings), `kopular/directives`
|
|
11
|
+
(If), `kopular/http` (Http). Also ships a bin, `kp` — `npx kp new <dir>` scaffolds a new
|
|
12
|
+
project (the `extern` bindings below, plus the vendor/serve scripts needed to run in a
|
|
13
|
+
browser) rather than requiring it be reconstructed by hand; prefer it over hand-writing
|
|
14
|
+
the section below for a new project.
|
|
11
15
|
|
|
12
16
|
## Consuming Kopular from your own KopScript project
|
|
13
17
|
|
|
@@ -30,6 +34,19 @@ extern class Router {
|
|
|
30
34
|
} from "kopular/router";
|
|
31
35
|
|
|
32
36
|
extern Element If(bool condition, () => Element whenTrue, () => Element whenFalse) from "kopular/directives";
|
|
37
|
+
|
|
38
|
+
extern class Response {
|
|
39
|
+
bool ok { get; }
|
|
40
|
+
number status { get; }
|
|
41
|
+
task<string> text(); // no `async` on an extern signature — see KopScript's own LLM.md
|
|
42
|
+
};
|
|
43
|
+
extern class Http {
|
|
44
|
+
static task<Response> Get(string url);
|
|
45
|
+
static task<Response> Post(string url, string jsonBody);
|
|
46
|
+
static task<Response> Put(string url, string jsonBody);
|
|
47
|
+
static task<Response> Patch(string url, string jsonBody);
|
|
48
|
+
static task<Response> Delete(string url);
|
|
49
|
+
} from "kopular/http";
|
|
33
50
|
```
|
|
34
51
|
|
|
35
52
|
You also need your own ambient DOM `extern` block (`document`, `Element`, `Event`, ...) —
|
|
@@ -145,6 +162,38 @@ that needs comparing old/new data by a caller key, generic over item type, and K
|
|
|
145
162
|
has no generics. Not planned as a workaround; would need real language-level generics
|
|
146
163
|
first.
|
|
147
164
|
|
|
165
|
+
## `Http` (`http.ks`) — thin wrapper over `fetch`
|
|
166
|
+
|
|
167
|
+
```ks
|
|
168
|
+
Response r = await Http.Get(url); // task<Response>
|
|
169
|
+
Response r = await Http.Post(url, jsonBody); // string body, Content-Type: application/json
|
|
170
|
+
Response r = await Http.Put(url, jsonBody);
|
|
171
|
+
Response r = await Http.Patch(url, jsonBody);
|
|
172
|
+
Response r = await Http.Delete(url); // no body param — DELETE has none
|
|
173
|
+
|
|
174
|
+
r.ok // bool
|
|
175
|
+
r.status // number
|
|
176
|
+
await r.text(); // task<string> — the raw body, nothing more
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
**No typed JSON deserialization** — no generics means no safe `Get<T>(url): task<T>`.
|
|
180
|
+
Get a typed response by describing its shape as its own `extern class` and parsing with
|
|
181
|
+
a per-shape `extern ... as "JSON.parse"` (unchecked, same trust model as every other
|
|
182
|
+
`extern`):
|
|
183
|
+
|
|
184
|
+
```ks
|
|
185
|
+
extern class DogDto { string name { get; } };
|
|
186
|
+
extern DogDto ParseDog(string json) as "JSON.parse";
|
|
187
|
+
|
|
188
|
+
DogDto dog = ParseDog(await (await Http.Get(url)).text());
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
`Get`/`Delete` need no request body, so they bind straight to the real global `fetch` —
|
|
192
|
+
no object literal involved (KopScript has none). `Post`/`Put`/`Patch` (and a
|
|
193
|
+
hypothetical `Delete`-with-a-body) need one for `{ method, headers, body }`, which
|
|
194
|
+
KopScript categorically cannot construct — Kopular ships one small hand-written JS
|
|
195
|
+
function (`http_runtime.js`, not compiled from `.ks`) that does, for exactly that reason.
|
|
196
|
+
|
|
148
197
|
## Dependency injection — no container, no decorators
|
|
149
198
|
|
|
150
199
|
There is no injector, no `@Injectable`, no provider tokens. "Injecting" a service is
|
|
@@ -194,5 +243,5 @@ class CounterService {
|
|
|
194
243
|
DI container/injector · decorators (`@Injectable`, `@Component`, ...) · a template
|
|
195
244
|
language/DSL — everything is imperative `Render()` code against plain DOM bindings ·
|
|
196
245
|
vdom diffing / reconciliation beyond a single component's own re-render · pipes ·
|
|
197
|
-
animations · forms/validation module · HTTP
|
|
198
|
-
generate`-equivalent) · SSR.
|
|
246
|
+
animations · forms/validation module · typed/generic HTTP responses (`Http` returns raw
|
|
247
|
+
text — see above) · a CLI/scaffolding tool (`ng generate`-equivalent) · SSR.
|
package/README.md
CHANGED
|
@@ -33,6 +33,9 @@ Generating Kopular code with an AI coding assistant? Point it at **[`LLM.md`](./
|
|
|
33
33
|
a subtree conditionally, repeat one per item, pick one of several cases — done as plain
|
|
34
34
|
function calls (`If(...)`) and existing KopScript expressions (`array.ForEach(...)`,
|
|
35
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.
|
|
36
39
|
|
|
37
40
|
## What's here
|
|
38
41
|
|
|
@@ -42,8 +45,11 @@ Generating Kopular code with an AI coding assistant? Point it at **[`LLM.md`](./
|
|
|
42
45
|
- `src/router.ks` — the `Router`.
|
|
43
46
|
- `src/directives.ks` — `If()`, the structural-directive equivalents' one genuinely new
|
|
44
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.
|
|
45
51
|
|
|
46
|
-
That's the whole framework —
|
|
52
|
+
That's the whole framework — six files. Everything else (a real app built on top of it)
|
|
47
53
|
lives in a separate consumer repo, [KopularDemo](https://dev.azure.com/koppinator/Koppindependence/_git/KopularDemo).
|
|
48
54
|
|
|
49
55
|
## Dependency injection: the composition root pattern
|
|
@@ -179,11 +185,84 @@ caller-supplied key, generic over the item type — and KopScript has no generic
|
|
|
179
185
|
per list, but that's real vdom-diffing work — already called out as out of scope in
|
|
180
186
|
"Status" below, and not something these three lines take on.
|
|
181
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
|
+
```
|
|
227
|
+
|
|
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).
|
|
237
|
+
|
|
238
|
+
## Starting a new project: `kp new`
|
|
239
|
+
|
|
240
|
+
Everything in the next section — the `extern` bindings, plus a `vendor/kopular/` copy of
|
|
241
|
+
this package's browser files and an import map pointing at it (a browser can't resolve a
|
|
242
|
+
bare specifier like `"kopular/component"` the way Node's own module resolution does) — is
|
|
243
|
+
boilerplate every Kopular project needs verbatim. Generate it instead of reconstructing it
|
|
244
|
+
by hand (or from memory, if you're an AI agent):
|
|
245
|
+
|
|
246
|
+
```bash
|
|
247
|
+
npx kp new my-app
|
|
248
|
+
cd my-app
|
|
249
|
+
npm install
|
|
250
|
+
npm start # builds, vendors kopular's browser files, and serves at :8080
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
This scaffolds a real, working `Component` (`src/counter.ks` — the same Counter shown
|
|
254
|
+
above), the ambient DOM/Kopular `extern` bindings it needs (`src/kopular_bindings.ks`),
|
|
255
|
+
and a `README.md` that points an AI agent at this package's own `LLM.md` before it starts
|
|
256
|
+
generating code. `kp` ships from this package (not from `kopscript`'s own `ks` CLI) since
|
|
257
|
+
scaffolding a *Kopular* app is a framework concern, not a language one — `ks` stays a
|
|
258
|
+
pure-language tool with no framework knowledge baked in.
|
|
259
|
+
|
|
182
260
|
## Using Kopular from another KopScript project
|
|
183
261
|
|
|
184
262
|
KopScript's own `using "./path";` only resolves relative paths within a project — it has
|
|
185
263
|
no package-import mechanism yet. Cross-package consumption goes through `extern`
|
|
186
|
-
instead, the same way KopScript already describes any other JS/npm dependency
|
|
264
|
+
instead, the same way KopScript already describes any other JS/npm dependency
|
|
265
|
+
(`kp new` above generates exactly this, if you'd rather not hand-write it):
|
|
187
266
|
|
|
188
267
|
```ks
|
|
189
268
|
extern class Component {
|
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,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.5.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>",
|
|
@@ -16,26 +16,31 @@
|
|
|
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
|
-
"./directives": "./src/directives.js"
|
|
27
|
+
"./directives": "./src/directives.js",
|
|
28
|
+
"./http": "./src/http.js"
|
|
25
29
|
},
|
|
26
30
|
"files": [
|
|
27
31
|
"src",
|
|
32
|
+
"bin",
|
|
28
33
|
"assets",
|
|
29
34
|
"LLM.md"
|
|
30
35
|
],
|
|
31
36
|
"scripts": {
|
|
32
|
-
"build": "ks build src/router.ks && ks build src/directives.ks",
|
|
37
|
+
"build": "ks build src/router.ks && ks build src/directives.ks && ks build src/http.ks",
|
|
33
38
|
"prepublishOnly": "npm run build",
|
|
34
39
|
"test": "vitest run",
|
|
35
40
|
"test:watch": "vitest"
|
|
36
41
|
},
|
|
37
42
|
"devDependencies": {
|
|
38
|
-
"kopscript": "^0.
|
|
43
|
+
"kopscript": "^0.4.0",
|
|
39
44
|
"@types/jsdom": "^30.0.0",
|
|
40
45
|
"@types/node": "^20.14.0",
|
|
41
46
|
"jsdom": "^25.0.1",
|
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
|
+
}
|