kopular 0.4.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.
Files changed (4) hide show
  1. package/LLM.md +4 -1
  2. package/README.md +24 -1
  3. package/bin/kp.mjs +316 -0
  4. package/package.json +6 -2
package/LLM.md CHANGED
@@ -8,7 +8,10 @@ not repeated here.
8
8
 
9
9
  Published as npm `kopular`. Entry points: `kopular` / `kopular/component` (Component),
10
10
  `kopular/router` (Router), `kopular/dom` (ambient DOM bindings), `kopular/directives`
11
- (If), `kopular/http` (Http).
11
+ (If), `kopular/http` (Http). 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.
12
15
 
13
16
  ## Consuming Kopular from your own KopScript project
14
17
 
package/README.md CHANGED
@@ -235,11 +235,34 @@ real global). It's the one file in this package not compiled from `.ks` — ever
235
235
  else avoids the problem by only wrapping JS APIs that take plain positional arguments
236
236
  (see `dom.ks`'s `addEventListener(string, handler)`, never an options-object-taking API).
237
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
+
238
260
  ## Using Kopular from another KopScript project
239
261
 
240
262
  KopScript's own `using "./path";` only resolves relative paths within a project — it has
241
263
  no package-import mechanism yet. Cross-package consumption goes through `extern`
242
- 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):
243
266
 
244
267
  ```ks
245
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,6 +1,6 @@
1
1
  {
2
2
  "name": "kopular",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Kopular: a small component framework for KopScript — components, reactive state, constructor-injected services, routing, structural directives, and HTTP, with no template DSL and no DI container",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -16,6 +16,9 @@
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",
@@ -26,6 +29,7 @@
26
29
  },
27
30
  "files": [
28
31
  "src",
32
+ "bin",
29
33
  "assets",
30
34
  "LLM.md"
31
35
  ],
@@ -36,7 +40,7 @@
36
40
  "test:watch": "vitest"
37
41
  },
38
42
  "devDependencies": {
39
- "kopscript": "^0.3.0",
43
+ "kopscript": "^0.4.0",
40
44
  "@types/jsdom": "^30.0.0",
41
45
  "@types/node": "^20.14.0",
42
46
  "jsdom": "^25.0.1",