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