kopular 1.0.0 → 1.0.1

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
@@ -109,6 +109,14 @@ extern class VElement {
109
109
  static VElement Mount(Component component);
110
110
  } from "kopular/velement";
111
111
 
112
+ // Only needed if you use `styles from "./x.css";` in a class body (see
113
+ // "Scoped styles" below) — the compiler splices a call to this into the
114
+ // constructor automatically, but (like every other Kopular export) it
115
+ // still needs its own `extern` declaration; it is NOT auto-imported.
116
+ extern class ScopedStyles {
117
+ static void Inject(string id, string css);
118
+ } from "kopular/vdom";
119
+
112
120
  extern class Component {
113
121
  constructor();
114
122
  virtual VElement Render(); // `virtual` here is what lets your subclass `override` it
@@ -133,6 +141,11 @@ extern class Router {
133
141
  // Every registered path (AddRoute + AddLazyRoute), in registration
134
142
  // order — see "Router" below.
135
143
  string[] AllPaths();
144
+ // Overridable outlet content shown while an AddLazyRoute page's loader
145
+ // is in flight — default: `<div class="router-loading">Loading...</div>`.
146
+ // Only declare this if you actually override it (a class extending
147
+ // Router) — see "Lazy routes" below.
148
+ protected virtual VElement BuildLoadingPlaceholder();
136
149
  void Navigate(string path);
137
150
  void Mount(Element parent);
138
151
  // One guard for the whole Router, not per-route — see "Router" below.
@@ -317,6 +330,11 @@ w.Bump(); // re-render + diff + patch
317
330
  return ul;
318
331
  }
319
332
  ```
333
+ `item.Id` above means exactly what it looks like: for a keyed list of mounted children,
334
+ the mounted `Component` itself needs its own public `Id` (or similarly-named) field/
335
+ property to copy onto `slot.Id` — `VElement.Id` lives on the wrapper slot, not on the
336
+ component, so there's nothing to key by without one. Set it however suits the type
337
+ (constructor param, or a plain field assigned right after construction).
320
338
  The SAME `Mountable` instance still in a slot across a re-render is patched in place
321
339
  (`Update()` inside that child re-renders just its own subtree, siblings untouched); a
322
340
  DIFFERENT instance (or the slot disappearing) tears the old one down first — calling its
@@ -434,7 +452,11 @@ class Widget : Component {
434
452
  - Independent of `template from` — works with a hand-written `Render()` too. The compiler
435
453
  rewrites the referenced `.css` so every selector requires a per-class
436
454
  `data-kop-scope="<id>"` attribute, then splices one `ScopedStyles.Inject(id, css);` call
437
- into the constructor. **Unlike `template from`, this needed real framework code**:
455
+ into the constructor. **This means `ScopedStyles` must be `extern`-declared in your own
456
+ project the same as `Component`/`VElement`/etc. (see the copy-paste block above) — it is
457
+ NOT auto-imported just because you wrote `styles from`.** Forgetting it is a real `KS4048
458
+ Undefined identifier 'ScopedStyles'` at the constructor the compiler spliced the call
459
+ into. **Unlike `template from`, this needed real framework code**:
438
460
  `ScopedStyles` (`vdom.ks`, new) — idempotent, static-array-registry dedup shape same as
439
461
  `Batching`, injects one real `<style>` per component *type* (not per instance) into
440
462
  `document.head` (`dom.ks`'s `head { get; }`, new) the first time any instance is
@@ -532,10 +554,29 @@ nav.Navigate("/about"); // pushState + immediate re-ren
532
554
  extern task<Component> LoadDogsPage() from "./dogs_page_loader";
533
555
  nav.AddLazyRoute("/dogs", LoadDogsPage);
534
556
  ```
535
- The outlet shows a plain loading placeholder (override `BuildLoadingPlaceholder()`) while
536
- the fetch is in flight; the loaded page is cached after the first fetch, same as an eager
537
- page navigating away and back reuses it, no re-fetch. Mixes freely with `AddRoute` in
538
- the same `Router`.
557
+ The outlet shows a plain loading placeholder (`<div class="router-loading">Loading...</div>`
558
+ by default; override `protected virtual VElement BuildLoadingPlaceholder()` on a class
559
+ extending `Router` to customize) while the fetch is in flight; the loaded page is cached
560
+ after the first fetch, same as an eager page — navigating away and back reuses it, no
561
+ re-fetch. Mixes freely with `AddRoute` in the same `Router`.
562
+ - **Building it for real**: the whole point of a lazy route is that `dogs_page.ks` is
563
+ deliberately *not* `using`'d from your app's own entry file — that's what keeps its
564
+ code out of the eager bundle. That also means your normal build command (`ks build
565
+ src/app.ks`) never compiles it, since `ks build`/`compileGraph` only walk the `using`
566
+ graph reachable from the entry you give them. Build the lazy page as its own separate
567
+ entry too, e.g. `ks build src/app.ks && ks build src/dogs_page.ks` in your build
568
+ script (or one `ks build` invocation per lazy route, if you have several) — each
569
+ already-compiled shared dependency just gets written again with identical output, so
570
+ this is safe to add without restructuring anything else.
571
+ - **Testing it**: `kopular/testing`'s `runKopularApp` (kopular 0.24.0+) compiles any
572
+ other real `.ks` file present in your app's own `srcDir` that the entry doesn't
573
+ reach — a lazy-route target is exactly that — so a test exercising `AddLazyRoute`
574
+ needs no special setup: write `dogs_page.ks`/`dogs_page_loader.js` into the same
575
+ directory as your entry file, same as any other page, and the loader's
576
+ `import("./dogs_page.js")` finds a real, freshly-compiled file. (`runKopularFixture`,
577
+ used only by Kopular's own internal test suite, does not do this — it copies
578
+ Kopular's entire framework source tree alongside a fixture, where the same behavior
579
+ would mean recompiling most of the framework on every test.)
539
580
  - **`AllPaths(): string[]`** — every registered path (`AddRoute` + `AddLazyRoute`), in
540
581
  registration order. For enumerating real routes (a build-time prerender step, most
541
582
  likely) without a second, hand-maintained list.
package/README.md CHANGED
@@ -166,8 +166,13 @@ class Widget : Component {
166
166
  ```
167
167
 
168
168
  Unlike `template from`, this **did** need real framework code — `ScopedStyles.Inject`
169
- (`vdom.ks`) is the runtime half: idempotent, injects one real `<style>` per component
170
- *type* into `document.head` the first time any instance of that type is constructed
169
+ (`vdom.ks`) is the runtime half, and (like every other Kopular export) it needs its own
170
+ `extern` declaration in your project `extern class ScopedStyles { static void
171
+ Inject(string id, string css); } from "kopular/vdom";` — it is not auto-imported just
172
+ because you wrote `styles from`; omitting it is a real `KS4048 Undefined identifier
173
+ 'ScopedStyles'` at the constructor the compiler spliced the call into. It's idempotent,
174
+ injecting one real `<style>` per component *type* into `document.head` the first time any
175
+ instance of that type is constructed
171
176
  (dedup is per-type, not per-instance — every instance's constructor calls `Inject` with
172
177
  the same compile-time `id`/rewritten-`css`, so only the first actually creates a tag).
173
178
  Never removed once injected — a scoped stylesheet is global infrastructure for as long as
@@ -368,11 +373,23 @@ export async function LoadDogsPage() {
368
373
  extern task<Component> LoadDogsPage() from "./dogs_page_loader";
369
374
  ```
370
375
 
371
- The outlet shows a plain loading placeholder (override `BuildLoadingPlaceholder()` to
372
- customize it) while the fetch is in flight, then the real page once it resolves — and,
373
- like an eager page, it's only ever fetched once: navigating away and back reuses the same
374
- already-loaded instance, keeping whatever state it built up. A lazy and an eager route mix
375
- freely in the same `Router`; nothing about `AddRoute`'s own existing signature changes.
376
+ The outlet shows a plain loading placeholder (`<div class="router-loading">Loading...</div>`
377
+ by default; override `protected virtual VElement BuildLoadingPlaceholder()` on a class
378
+ extending `Router` to customize it) while the fetch is in flight, then the real page once
379
+ it resolves — and, like an eager page, it's only ever fetched once: navigating away and
380
+ back reuses the same already-loaded instance, keeping whatever state it built up. A lazy
381
+ and an eager route mix freely in the same `Router`; nothing about `AddRoute`'s own existing
382
+ signature changes.
383
+
384
+ **Building and testing a lazy route**: `dogs_page.ks` is deliberately *not* `using`'d from
385
+ your app's own entry — that's what keeps it out of the eager bundle — which also means your
386
+ normal `ks build src/app.ks` never compiles it, since `ks build` only walks the `using`
387
+ graph reachable from the entry you give it. Build it as its own separate entry too:
388
+ `ks build src/app.ks && ks build src/dogs_page.ks` (already-compiled shared dependencies
389
+ just get written again with identical output, so this is safe to add). For tests,
390
+ `kopular/testing`'s `runKopularApp` (0.24.0+) compiles any other real `.ks` file present in
391
+ your `srcDir` that the entry doesn't reach — a lazy-route target is exactly that — so
392
+ `AddLazyRoute` needs no special test setup at all.
376
393
 
377
394
  **`AllPaths()`** returns every registered path (both `AddRoute` and `AddLazyRoute`), in
378
395
  registration order — for a caller that needs to enumerate real routes (a build-time static
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kopular",
3
- "version": "1.0.0",
3
+ "version": "1.0.1",
4
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",
package/src/testing.js CHANGED
@@ -111,19 +111,52 @@ function copyKopularPackage(dir) {
111
111
  // Shared by both exports below: compiles `entryFileName` (already written
112
112
  // into `dir`, alongside everything it `using`s) and runs it against a fresh
113
113
  // jsdom instance.
114
+ //
115
+ // `compileOrphanEntries` additionally compiles any other real `.ks` file
116
+ // present in `dir` that the entry's own `using` graph never reaches. A
117
+ // `Router.AddLazyRoute` target is *exactly* that by construction — it's
118
+ // deliberately kept out of the eager `using` graph so its own JS is only
119
+ // fetched on first navigation (see kopular's own LLM.md "Lazy routes"
120
+ // section) — so without this, its loader shim's `await import("./x.js")`
121
+ // finds nothing on disk here, even though the exact same app builds and
122
+ // runs fine in a real browser (there, the app's own build script is
123
+ // expected to invoke `ks build` on the lazy page too, as its own separate
124
+ // entry point — see the same docs section). Only `runKopularApp` passes
125
+ // this: `runKopularFixture` copies Kopular's *entire* framework source
126
+ // tree alongside a small fixture, where "every .ks file the fixture
127
+ // doesn't reach" would mean recompiling most of the framework on every
128
+ // test.
114
129
  async function compileAndRun(dir, entryFileName, options) {
115
130
  const entry = join(dir, entryFileName);
116
- const result = compileGraph(entry);
117
- if (!result.success) {
131
+ const graphs = [compileGraph(entry)];
132
+
133
+ if (options.compileOrphanEntries) {
134
+ const covered = new Set(graphs[0].outputs.keys());
135
+ for (const name of readdirSync(dir)) {
136
+ if (!name.endsWith(".ks")) continue;
137
+ const absPath = join(dir, name);
138
+ if (absPath === entry || covered.has(absPath)) continue;
139
+ const g = compileGraph(absPath);
140
+ for (const p of g.outputs.keys()) covered.add(p);
141
+ graphs.push(g);
142
+ }
143
+ }
144
+
145
+ const failed = graphs.filter((g) => !g.success);
146
+ if (failed.length > 0) {
118
147
  rmSync(dir, { recursive: true, force: true });
119
- const messages = [...result.modules.values()]
120
- .filter((m) => m.diagnostics.hasErrors)
148
+ const seen = new Set();
149
+ const messages = failed
150
+ .flatMap((g) => [...g.modules.values()])
151
+ .filter((m) => m.diagnostics.hasErrors && !seen.has(m.absPath) && seen.add(m.absPath))
121
152
  .map((m) => m.diagnostics.format(m.source, m.absPath))
122
153
  .join("\n\n");
123
154
  throw new Error(messages || "Compilation failed");
124
155
  }
125
- for (const [absPath, js] of result.outputs) {
126
- writeFileSync(absPath.replace(/\.ks$/, ".js"), js, "utf-8");
156
+ for (const g of graphs) {
157
+ for (const [absPath, js] of g.outputs) {
158
+ writeFileSync(absPath.replace(/\.ks$/, ".js"), js, "utf-8");
159
+ }
127
160
  }
128
161
 
129
162
  const dom = new JSDOM("<!doctype html><html><body></body></html>", { url: options.url ?? "http://localhost/" });
@@ -162,6 +195,11 @@ async function compileAndRun(dir, entryFileName, options) {
162
195
  * real project's own src/ directory, the way KopularDemo's test suite (or
163
196
  * any app consuming `kopular/...` via `extern`) would use it.
164
197
  *
198
+ * Also compiles any other `.ks` file in `srcDir` the entry's `using` graph
199
+ * doesn't reach — a `Router.AddLazyRoute` target is exactly that, by
200
+ * design, so its loader shim's `import("./x.js")` finds a real file here
201
+ * too, with no extra setup needed to test a lazy route.
202
+ *
165
203
  * @param {string} srcDir - directory containing the entry file and
166
204
  * everything it `using`s.
167
205
  * @param {string} entryFileName - the entry .ks file's name within srcDir.
@@ -186,7 +224,7 @@ export async function runKopularApp(srcDir, entryFileName, options = {}) {
186
224
  const dir = mkTempDir();
187
225
  copySources(srcDir, dir, options.extraFiles ?? []);
188
226
  if (options.includeKopularPackage) copyKopularPackage(dir);
189
- return compileAndRun(dir, entryFileName, options);
227
+ return compileAndRun(dir, entryFileName, { ...options, compileOrphanEntries: true });
190
228
  }
191
229
 
192
230
  /**