kopular 0.1.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/README.md ADDED
@@ -0,0 +1,87 @@
1
+ # Kopular
2
+
3
+ Kopular is a small component framework for [KopScript](https://dev.azure.com/koppinator/Koppindependence/_git/Kop),
4
+ built to give Angular's separation of concerns — components own UI, services own logic,
5
+ a router owns navigation — without Angular's steepest learning-curve pieces: no RxJS, no
6
+ dependency-injection container, no template DSL.
7
+
8
+ ## Highlights
9
+
10
+ - **`Component`**: a base class with `virtual Render()` (builds a fresh DOM subtree from
11
+ current state) and `Update()` (swaps the old subtree for the new one). No template
12
+ language, no diffing — components build/update the DOM imperatively against plain DOM
13
+ bindings, the way you'd write careful vanilla-JS UI code.
14
+ - **Reactive state, no RxJS**: components hold `state<number>`/`state<string>`/... (a
15
+ KopScript language feature — see the [Kop](https://dev.azure.com/koppinator/Koppindependence/_git/Kop)
16
+ repo) and subscribe once, in their constructor, to call `Update()` on change. No
17
+ Observables, no operators, no manual unsubscribe bookkeeping.
18
+ - **Services, no DI container**: "injecting" a service is just passing it as a
19
+ constructor argument. No injector hierarchy, no provider tokens, no decorators — and a
20
+ service stays fully testable with zero `Component`/DOM involvement, since it's just a
21
+ class.
22
+ - **`Router`**: hash-based (`#/path`, driven by the browser's native `hashchange` event)
23
+ page-swapping, with route registration as plain method calls, not a config DSL. No
24
+ server-side fallback route needed, unlike History-API routing.
25
+
26
+ ## What's here
27
+
28
+ - `src/dom.ks` — ambient DOM bindings (`document`, `Element`, `Event`, `window`,
29
+ `location`) that `component.ks`/`router.ks` are built on.
30
+ - `src/component.ks` — the `Component` base class.
31
+ - `src/router.ks` — the `Router`.
32
+
33
+ That's the whole framework — three files. Everything else (a real app built on top of
34
+ it) lives in a separate consumer repo, [KopularDemo](https://dev.azure.com/koppinator/Koppindependence/_git/KopularDemo).
35
+
36
+ ## Using Kopular from another KopScript project
37
+
38
+ KopScript's own `using "./path";` only resolves relative paths within a project — it has
39
+ no package-import mechanism yet. Cross-package consumption goes through `extern`
40
+ instead, the same way KopScript already describes any other JS/npm dependency:
41
+
42
+ ```ks
43
+ extern class Component {
44
+ constructor();
45
+ virtual Element Render();
46
+ void Mount(Element parent);
47
+ } from "kopular/component";
48
+
49
+ extern class Router {
50
+ constructor(Component notFoundPage);
51
+ void AddRoute(string path, Component page);
52
+ void Navigate(string path);
53
+ } from "kopular/router";
54
+
55
+ class MyWidget : Component {
56
+ public override Element Render() {
57
+ Element el = document.createElement("div");
58
+ el.textContent = "Hello from MyWidget";
59
+ return el;
60
+ }
61
+ }
62
+ ```
63
+
64
+ Marking `Render()` `virtual` in the `extern` declaration is what lets a real subclass
65
+ `override` it — see [KopularDemo](https://dev.azure.com/koppinator/Koppindependence/_git/KopularDemo)
66
+ for a full working example (components, a service, and routing, all consuming Kopular
67
+ this way).
68
+
69
+ ## Getting started (developing Kopular itself)
70
+
71
+ ```bash
72
+ npm install # pulls in kopscript as a devDependency
73
+ npm run build # compiles src/*.ks -> src/*.js (compiled output is gitignored)
74
+ npm test # runs test/kopular.test.ts against a real DOM via jsdom
75
+ ```
76
+
77
+ `kopscript` is a real published dependency (`^0.1.0`) — this repo doesn't need Kop
78
+ checked out as a sibling directory or anything else local to build or test.
79
+
80
+ ## Status
81
+
82
+ v1 / hobby-project scope, same as KopScript itself. Known limitation: `Component` only
83
+ handles a single component's own re-render cycle — if a *parent* re-renders while it has
84
+ mounted children, those children aren't automatically re-mounted into the parent's new
85
+ tree (real reconciliation, the way React/Vue handle this, is real vdom-diffing work well
86
+ beyond v1). Compose independent components into stable slots (see `Router`'s own pattern
87
+ of keeping page instances alive rather than rebuilding them) to avoid the issue.
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "kopular",
3
+ "version": "0.1.0",
4
+ "description": "Kopular: a small component framework for KopScript — components, reactive state, constructor-injected services, and routing, with no template DSL and no DI container",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "Joe Koppin <koppinjo@gmail.com>",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "https://dev.azure.com/koppinator/Koppindependence/_git/Kopular"
11
+ },
12
+ "keywords": ["kopscript", "framework", "components", "ui"],
13
+ "main": "./src/component.js",
14
+ "exports": {
15
+ ".": "./src/component.js",
16
+ "./component": "./src/component.js",
17
+ "./router": "./src/router.js",
18
+ "./dom": "./src/dom.js"
19
+ },
20
+ "files": ["src"],
21
+ "scripts": {
22
+ "build": "ks build src/router.ks",
23
+ "prepublishOnly": "npm run build",
24
+ "test": "vitest run",
25
+ "test:watch": "vitest"
26
+ },
27
+ "devDependencies": {
28
+ "kopscript": "^0.1.0",
29
+ "@types/jsdom": "^30.0.0",
30
+ "@types/node": "^20.14.0",
31
+ "jsdom": "^25.0.1",
32
+ "typescript": "^5.5.0",
33
+ "vitest": "^2.0.0"
34
+ },
35
+ "engines": {
36
+ "node": ">=18"
37
+ }
38
+ }
@@ -0,0 +1,19 @@
1
+ import { Event, Element, Document, Location, Window, document, location, window } from "./dom.js";
2
+
3
+ export class Component {
4
+ Render() {
5
+ return document.createElement("div");
6
+ }
7
+
8
+ Mount(parent) {
9
+ (this.ParentElement = parent);
10
+ (this.Root = this.Render());
11
+ parent.appendChild(this.Root);
12
+ }
13
+
14
+ Update() {
15
+ let newRoot = this.Render();
16
+ this.ParentElement.replaceChild(newRoot, this.Root);
17
+ (this.Root = newRoot);
18
+ }
19
+ }
@@ -0,0 +1,35 @@
1
+ using "./dom";
2
+
3
+ // A minimal component base: subclasses override Render() to imperatively
4
+ // build a fresh DOM tree from current state, and call the inherited
5
+ // Update() whenever that state changes to swap the old tree for a new one.
6
+ // There is deliberately no template language or diffing here — Render()
7
+ // rebuilds its whole subtree every time, the simplest thing that works.
8
+ //
9
+ // Known limitation: if a *parent* component's own Render() re-runs (i.e.
10
+ // something calls Update() on the parent) while it has mounted children,
11
+ // those children are not automatically re-mounted into the parent's new
12
+ // tree — this base class only handles a single component's own re-render
13
+ // cycle, not parent/child reconciliation across one. Composing independent
14
+ // components (each mounted into its own stable slot, as in app.kop) avoids
15
+ // the issue entirely.
16
+ class Component {
17
+ protected Element Root;
18
+ private Element ParentElement;
19
+
20
+ public virtual Element Render() {
21
+ return document.createElement("div");
22
+ }
23
+
24
+ public void Mount(Element parent) {
25
+ this.ParentElement = parent;
26
+ this.Root = this.Render();
27
+ parent.appendChild(this.Root);
28
+ }
29
+
30
+ protected void Update() {
31
+ Element newRoot = this.Render();
32
+ this.ParentElement.replaceChild(newRoot, this.Root);
33
+ this.Root = newRoot;
34
+ }
35
+ }
package/src/dom.js ADDED
@@ -0,0 +1,8 @@
1
+ export const Event = globalThis.Event;
2
+ export const Element = globalThis.Element;
3
+ export const Document = globalThis.Document;
4
+ export const Location = globalThis.Location;
5
+ export const Window = globalThis.Window;
6
+ export const document = globalThis.document;
7
+ export const location = globalThis.location;
8
+ export const window = globalThis.window;
package/src/dom.ks ADDED
@@ -0,0 +1,39 @@
1
+ // Minimal browser DOM bindings. All ambient (no `from` clause) — document,
2
+ // Element, and Event genuinely exist as globals in a browser, no import
3
+ // needed. Member names use the real JS casing exactly (camelCase), since
4
+ // extern declarations describe an existing external contract rather than
5
+ // idiomatic Kop code — there's no per-member rename mechanism. `extern
6
+ // class` declarations end in `;`, like the other two extern forms.
7
+
8
+ extern class Event {
9
+ Element target { get; }
10
+ void preventDefault();
11
+ };
12
+
13
+ extern class Element {
14
+ string textContent { get; set; }
15
+ string id { get; set; }
16
+ string className { get; set; }
17
+ void appendChild(Element child);
18
+ void replaceChild(Element newChild, Element oldChild);
19
+ void addEventListener(string eventType, (Event) => void handler);
20
+ void removeEventListener(string eventType, (Event) => void handler);
21
+ };
22
+
23
+ extern class Document {
24
+ Element createElement(string tagName);
25
+ Element getElementById(string id);
26
+ Element body { get; }
27
+ };
28
+
29
+ extern class Location {
30
+ string hash { get; set; }
31
+ };
32
+
33
+ extern class Window {
34
+ void addEventListener(string eventType, (Event) => void handler);
35
+ };
36
+
37
+ extern Document document;
38
+ extern Location location;
39
+ extern Window window;
package/src/router.js ADDED
@@ -0,0 +1,40 @@
1
+ import { Event, Element, Document, Location, Window, document, location, window } from "./dom.js";
2
+ import { Component } from "./component.js";
3
+
4
+ export class Router extends Component {
5
+ constructor(notFoundPage) {
6
+ super();
7
+ (this.Paths = []);
8
+ (this.Pages = []);
9
+ (this.NotFoundPage = notFoundPage);
10
+ window.addEventListener("hashchange", (e) => (this.Update()));
11
+ }
12
+
13
+ AddRoute(path, page) {
14
+ (this.Paths = [...this.Paths, path]);
15
+ (this.Pages = [...this.Pages, page]);
16
+ }
17
+
18
+ Navigate(path) {
19
+ (location.hash = ("#" + path));
20
+ }
21
+
22
+ Match(path) {
23
+ let found = this.NotFoundPage;
24
+ for (let i = 0; (i < this.Paths.length); (i = (i + 1))) {
25
+ if ((this.Paths[i] === path)) {
26
+ (found = this.Pages[i]);
27
+ }
28
+ }
29
+ return found;
30
+ }
31
+
32
+ Render() {
33
+ let outlet = document.createElement("div");
34
+ (outlet.className = "router-outlet");
35
+ let path = location.hash.replaceAll("#", "");
36
+ let page = this.Match(path);
37
+ page.Mount(outlet);
38
+ return outlet;
39
+ }
40
+ }
package/src/router.ks ADDED
@@ -0,0 +1,70 @@
1
+ using "./dom";
2
+ using "./component";
3
+
4
+ // Hash-based routing (`#/path`, driven by the browser's native `hashchange`
5
+ // event) rather than the History API — it needs no server-side fallback
6
+ // (a plain static file server, like scripts/serve.mjs, has nothing special
7
+ // to do for a hash), and there's no `pushState(state, title, url)` signature
8
+ // to model through `extern`. Route registration is imperative (`AddRoute`
9
+ // calls), not a config object — one thing to learn, not a routing DSL.
10
+ //
11
+ // Routes are registered as already-constructed Component instances, not
12
+ // factories — `Component[]`, not `(() => Component)[]` (KopScript's type
13
+ // grammar has no way to write "array of function type" in v1, since a
14
+ // parenthesized function type isn't a general grouping construct). This
15
+ // turns out to be a genuine feature, not just a workaround: each page
16
+ // Component is built once and kept alive for the Router's own lifetime, so
17
+ // a page's own state<T> fields survive navigating away and back — no state
18
+ // gets reset just because a route wasn't showing for a while.
19
+ //
20
+ // No nullable types in v1 means "no route matched" can't be represented as
21
+ // null — a `NotFoundPage` is required up front instead, the same way a
22
+ // `match` expression requires its own `_` wildcard arm.
23
+ class Router : Component {
24
+ private string[] Paths;
25
+ private Component[] Pages;
26
+ private Component NotFoundPage;
27
+
28
+ constructor(Component notFoundPage) : base() {
29
+ this.Paths = [];
30
+ this.Pages = [];
31
+ this.NotFoundPage = notFoundPage;
32
+ window.addEventListener("hashchange", (Event e) => this.Update());
33
+ }
34
+
35
+ public void AddRoute(string path, Component page) {
36
+ this.Paths = this.Paths.Push(path);
37
+ this.Pages = this.Pages.Push(page);
38
+ }
39
+
40
+ // Setting location.hash fires a real 'hashchange' event, which the
41
+ // constructor already subscribed to — no direct call to Update() needed
42
+ // here, the same "set the value, listeners react" shape as state<T>.
43
+ public void Navigate(string path) {
44
+ location.hash = "#" + path;
45
+ }
46
+
47
+ private Component Match(string path) {
48
+ Component found = this.NotFoundPage;
49
+ for (number i = 0; i < this.Paths.Length; i = i + 1) {
50
+ if (this.Paths[i] == path) {
51
+ found = this.Pages[i];
52
+ }
53
+ }
54
+ return found;
55
+ }
56
+
57
+ // Rebuilding a fresh outlet every render and re-Mount()ing the matched
58
+ // page into it means there's nothing to explicitly unmount: Update()
59
+ // (inherited from Component) discards the whole outlet in one
60
+ // replaceChild when it swaps in the new one. The page instance itself
61
+ // isn't rebuilt, just re-attached — see the class comment above.
62
+ public override Element Render() {
63
+ Element outlet = document.createElement("div");
64
+ outlet.className = "router-outlet";
65
+ string path = location.hash.Replace("#", "");
66
+ Component page = this.Match(path);
67
+ page.Mount(outlet);
68
+ return outlet;
69
+ }
70
+ }