@panyam/tsappkit 0.6.1 → 0.6.3

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@panyam/tsappkit",
3
- "version": "0.6.1",
3
+ "version": "0.6.3",
4
4
  "description": "TypeScript application toolkit with component lifecycle management, event system, UI utilities, and documentation site components",
5
5
  "main": "dist/index.js",
6
6
  "module": "dist/index.mjs",
@@ -59,7 +59,7 @@
59
59
  "sass": "^1.77.0",
60
60
  "tsup": "^8.0.0",
61
61
  "typescript": "^5.0.0",
62
- "vitest": "^4.1.11"
62
+ "vitest": "^5.0.3"
63
63
  },
64
64
  "peerDependencies": {
65
65
  "ace-builds": "^1.0.0"
package/src/index.ts CHANGED
@@ -32,6 +32,7 @@ export { IslandPage } from './page/IslandPage';
32
32
  export { readSpec, SPEC_ELEMENT_ID } from './page/spec';
33
33
  export type { IslandSpec, PageSpec, SpecExtension } from './page/spec';
34
34
  export { lazy, mountIslands } from './page/mount';
35
+ export { IslandOverlay } from './page/overlay';
35
36
  export type { IslandFactory, LazyIsland, MountOptions, Registry } from './page/mount';
36
37
  export { parseLoad, scheduleMount } from './page/load';
37
38
  export type { LoadEnv, LoadStrategy } from './page/load';
@@ -4,6 +4,7 @@ import type { LCMComponent } from "../LCMComponent";
4
4
  import { LifecycleController } from "../LifecycleController";
5
5
  import { parseLoad, scheduleMount, type LoadStrategy } from "./load";
6
6
  import { mountIslands, type Registry } from "./mount";
7
+ import { IslandOverlay } from "./overlay";
7
8
  import { readSpec, SPEC_ELEMENT_ID, type PageSpec } from "./spec";
8
9
 
9
10
  /**
@@ -24,6 +25,10 @@ import { readSpec, SPEC_ELEMENT_ID, type PageSpec } from "./spec";
24
25
  * setupDependencies and activate. A deferred island mustn't be something
25
26
  * another island or the page needs at startup: nothing waits for it.
26
27
  *
28
+ * With `?islands` in the URL (or showIslandOverlay overridden), each slot is
29
+ * outlined and labelled with its island's name, load strategy and state; see
30
+ * IslandOverlay.
31
+ *
27
32
  * A page with no readable `#page-spec` mounts nothing and warns. Subclasses
28
33
  * that override initializeSpecificComponents call super and add to what it
29
34
  * returns.
@@ -46,10 +51,18 @@ export abstract class IslandPage<Ctx, Ext extends object = {}> extends BasePage
46
51
  console.warn(`page spec: no readable #${SPEC_ELEMENT_ID} on this page, so nothing is mounted`);
47
52
  return [];
48
53
  }
54
+ const findSlot = (slot: string) => document.querySelector<HTMLElement>(`[data-slot="${slot}"]`);
55
+ const overlay = this.showIslandOverlay() ? new IslandOverlay() : undefined;
56
+ if (overlay) {
57
+ for (const island of spec.islands) {
58
+ const el = findSlot(island.slot);
59
+ if (el) overlay.waiting(island, el);
60
+ }
61
+ }
49
62
  return mountIslands(
50
63
  spec,
51
64
  this.registry(),
52
- (slot) => document.querySelector<HTMLElement>(`[data-slot="${slot}"]`),
65
+ findSlot,
53
66
  () => this.makeContext(spec),
54
67
  this.eventBus,
55
68
  (message) => console.warn(message),
@@ -60,10 +73,26 @@ export abstract class IslandPage<Ctx, Ext extends object = {}> extends BasePage
60
73
  .initializeFromRoot(component)
61
74
  .catch((err) => console.warn(`page spec: island "${island.name}" failed to start: ${err instanceof Error ? err.message : String(err)}`));
62
75
  },
76
+ onMount: overlay && ((_c, island, el) => overlay.mounted(island, el)),
77
+ onSkip:
78
+ overlay &&
79
+ ((island, reason) => {
80
+ const el = findSlot(island.slot);
81
+ if (el) overlay.failed(island, el, reason);
82
+ }),
63
83
  },
64
84
  );
65
85
  }
66
86
 
87
+ /**
88
+ * Whether to draw the island overlay. The default is true when the URL has
89
+ * an `islands` query parameter (`/game?islands`); a subclass can tie it to
90
+ * its own debug setting instead.
91
+ */
92
+ protected showIslandOverlay(): boolean {
93
+ return new URLSearchParams(window.location.search).has("islands");
94
+ }
95
+
67
96
  /**
68
97
  * Waits for `strategy` and then calls `mount`. Defaults to scheduleMount
69
98
  * against the window; a subclass or test can replace how the waiting is done.
package/src/page/mount.ts CHANGED
@@ -48,6 +48,18 @@ export interface MountOptions<El, C> {
48
48
  defer?: (island: IslandSpec, el: El, mount: () => void) => void;
49
49
  /** Gets what the factory built for each island mounted later: through `defer`, or from a lazy entry. */
50
50
  onLateMount?: (component: C, island: IslandSpec) => void;
51
+ /**
52
+ * Gets every island that mounts, eager or late, with its slot, as it
53
+ * mounts (before onLateMount for a late one). IslandPage's debug overlay
54
+ * uses it to show when each island arrived.
55
+ */
56
+ onMount?: (component: C, island: IslandSpec, el: El) => void;
57
+ /**
58
+ * Gets every island that won't mount, with a short reason ("not in the
59
+ * registry", "no slot", "failed to load", "no factory", "factory threw"),
60
+ * alongside the message `log` gets.
61
+ */
62
+ onSkip?: (island: IslandSpec, reason: string) => void;
51
63
  }
52
64
 
53
65
  /**
@@ -80,16 +92,20 @@ export function mountIslands<Ctx, El, C, B>(
80
92
  options: MountOptions<El, C> = {},
81
93
  ): C[] {
82
94
  const out: C[] = [];
95
+ const skip = (island: IslandSpec, reason: string, message: string) => {
96
+ log(message);
97
+ options.onSkip?.(island, reason);
98
+ };
83
99
  let ctx: Ctx | undefined;
84
100
  for (const island of spec.islands) {
85
101
  const entry = Object.prototype.hasOwnProperty.call(registry, island.name) ? registry[island.name] : undefined;
86
102
  if (!entry) {
87
- log(`page spec: no island called "${island.name}" in this page's registry`);
103
+ skip(island, "not in the registry", `page spec: no island called "${island.name}" in this page's registry`);
88
104
  continue;
89
105
  }
90
106
  const el = findSlot(island.slot);
91
107
  if (el === null) {
92
- log(`page spec: island "${island.name}" wants slot "${island.slot}", which isn't on the page`);
108
+ skip(island, "no slot", `page spec: island "${island.name}" wants slot "${island.slot}", which isn't on the page`);
93
109
  continue;
94
110
  }
95
111
  const build = (factory: IslandFactory<Ctx, El, C, B>): C | undefined => {
@@ -97,14 +113,16 @@ export function mountIslands<Ctx, El, C, B>(
97
113
  ctx ??= context();
98
114
  return factory(el, island, ctx, bus);
99
115
  } catch (err) {
100
- log(`page spec: island "${island.name}" failed to mount: ${message(err)}`);
116
+ skip(island, "factory threw", `page spec: island "${island.name}" failed to mount: ${message(err)}`);
101
117
  return undefined;
102
118
  }
103
119
  };
104
120
  const mountLate = () => {
105
121
  const late = (factory: IslandFactory<Ctx, El, C, B>) => {
106
122
  const c = build(factory);
107
- if (c !== undefined) options.onLateMount?.(c, island);
123
+ if (c === undefined) return;
124
+ options.onMount?.(c, island, el);
125
+ options.onLateMount?.(c, island);
108
126
  };
109
127
  if (typeof entry === "function") {
110
128
  late(entry);
@@ -115,9 +133,9 @@ export function mountIslands<Ctx, El, C, B>(
115
133
  (m) => {
116
134
  const factory = typeof m === "function" ? m : m?.default;
117
135
  if (typeof factory === "function") late(factory);
118
- else log(`page spec: island "${island.name}" loaded, but its module has no factory (a default export or the function itself)`);
136
+ else skip(island, "no factory", `page spec: island "${island.name}" loaded, but its module has no factory (a default export or the function itself)`);
119
137
  },
120
- (err) => log(`page spec: island "${island.name}" failed to load: ${message(err)}`),
138
+ (err) => skip(island, "failed to load", `page spec: island "${island.name}" failed to load: ${message(err)}`),
121
139
  );
122
140
  };
123
141
  const strategy = parseLoad(island.load);
@@ -133,7 +151,10 @@ export function mountIslands<Ctx, El, C, B>(
133
151
  continue;
134
152
  }
135
153
  const c = build(entry);
136
- if (c !== undefined) out.push(c);
154
+ if (c !== undefined) {
155
+ out.push(c);
156
+ options.onMount?.(c, island, el);
157
+ }
137
158
  }
138
159
  return out;
139
160
  }
@@ -0,0 +1,63 @@
1
+ import type { IslandSpec } from "./spec";
2
+
3
+ const STYLE_ID = "island-overlay-style";
4
+
5
+ // The label is drawn by ::after from an attribute, so the overlay adds no
6
+ // children to a slot the island owns, and an island clearing its slot on
7
+ // mount (SolidIsland does) doesn't take the label with it.
8
+ const CSS = `
9
+ [data-island-debug] { position: relative; outline: 2px dashed #1565c0; outline-offset: -2px; }
10
+ [data-island-debug]::after {
11
+ content: attr(data-island-debug); position: absolute; top: 0; right: 0; z-index: 2147483647;
12
+ font: 11px/1.4 ui-monospace, monospace; padding: 1px 6px; background: #1565c0; color: #fff; pointer-events: none;
13
+ }
14
+ [data-island-state="mounted"] { outline-color: #2e7d32; }
15
+ [data-island-state="mounted"]::after { background: #2e7d32; }
16
+ [data-island-state="failed"] { outline-color: #c62828; }
17
+ [data-island-state="failed"]::after { background: #c62828; }
18
+ `;
19
+
20
+ /**
21
+ * Outlines each island's slot and labels it with its name, slot, load
22
+ * strategy and state: `hero · top · eager · mounted 212 ms`,
23
+ * `below · bottom · visible · waiting`, or `ghost · foot · eager · not in the
24
+ * registry`. Times are from navigation start (performance.now()).
25
+ *
26
+ * IslandPage turns it on with `?islands` in the URL (see
27
+ * showIslandOverlay). It's a development aid: while it's on, every labelled
28
+ * slot is `position: relative`, which can move an island's absolutely
29
+ * positioned content.
30
+ */
31
+ export class IslandOverlay {
32
+ constructor(
33
+ private readonly doc: Document = document,
34
+ private readonly now: () => number = () => performance.now(),
35
+ ) {
36
+ if (!doc.getElementById(STYLE_ID)) {
37
+ const style = doc.createElement("style");
38
+ style.id = STYLE_ID;
39
+ style.textContent = CSS;
40
+ doc.head.appendChild(style);
41
+ }
42
+ }
43
+
44
+ /** The island has a slot and hasn't mounted yet. */
45
+ waiting(island: IslandSpec, el: HTMLElement): void {
46
+ this.label(island, el, "waiting", "waiting");
47
+ }
48
+
49
+ /** The island has just mounted. */
50
+ mounted(island: IslandSpec, el: HTMLElement): void {
51
+ this.label(island, el, "mounted", `mounted ${Math.round(this.now())} ms`);
52
+ }
53
+
54
+ /** The island won't mount, for `reason`. */
55
+ failed(island: IslandSpec, el: HTMLElement, reason: string): void {
56
+ this.label(island, el, "failed", reason);
57
+ }
58
+
59
+ private label(island: IslandSpec, el: HTMLElement, state: string, text: string): void {
60
+ el.dataset.islandState = state;
61
+ el.dataset.islandDebug = [island.name, island.slot, island.load || "eager", text].join(" · ");
62
+ }
63
+ }