@panyam/tsappkit 0.3.0 → 0.6.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/dist/index.d.mts +113 -11
- package/dist/index.d.ts +113 -11
- package/dist/index.js +133 -23
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +131 -24
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
- package/src/index.ts +4 -2
- package/src/page/IslandPage.ts +25 -0
- package/src/page/load.ts +86 -0
- package/src/page/mount.ts +105 -16
- package/src/page/spec.ts +7 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@panyam/tsappkit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.1",
|
|
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",
|
package/src/index.ts
CHANGED
|
@@ -31,8 +31,10 @@ export { MobileBottomDrawer } from './MobileBottomDrawer';
|
|
|
31
31
|
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
|
-
export { mountIslands } from './page/mount';
|
|
35
|
-
export type { IslandFactory, Registry } from './page/mount';
|
|
34
|
+
export { lazy, mountIslands } from './page/mount';
|
|
35
|
+
export type { IslandFactory, LazyIsland, MountOptions, Registry } from './page/mount';
|
|
36
|
+
export { parseLoad, scheduleMount } from './page/load';
|
|
37
|
+
export type { LoadEnv, LoadStrategy } from './page/load';
|
|
36
38
|
|
|
37
39
|
// Utilities
|
|
38
40
|
export { isInInputContext, hasModifierKeys, shouldIgnoreShortcut } from './DOMUtils';
|
package/src/page/IslandPage.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { BasePage } from "../BasePage";
|
|
2
2
|
import type { EventBus } from "../EventBus";
|
|
3
3
|
import type { LCMComponent } from "../LCMComponent";
|
|
4
|
+
import { LifecycleController } from "../LifecycleController";
|
|
5
|
+
import { parseLoad, scheduleMount, type LoadStrategy } from "./load";
|
|
4
6
|
import { mountIslands, type Registry } from "./mount";
|
|
5
7
|
import { readSpec, SPEC_ELEMENT_ID, type PageSpec } from "./spec";
|
|
6
8
|
|
|
@@ -15,6 +17,13 @@ import { readSpec, SPEC_ELEMENT_ID, type PageSpec } from "./spec";
|
|
|
15
17
|
* fields in `readExtension`, and they arrive typed as `Ext` on the spec
|
|
16
18
|
* `makeContext` gets.
|
|
17
19
|
*
|
|
20
|
+
* Each island mounts when its `load` says (`eager`, `idle`, `visible`,
|
|
21
|
+
* `media:<query>`; see scheduleMount), and a `lazy` registry entry loads its
|
|
22
|
+
* chunk then. One that mounts later, a lazy eager one included, goes through
|
|
23
|
+
* its own LifecycleController, so it still gets performLocalInit,
|
|
24
|
+
* setupDependencies and activate. A deferred island mustn't be something
|
|
25
|
+
* another island or the page needs at startup: nothing waits for it.
|
|
26
|
+
*
|
|
18
27
|
* A page with no readable `#page-spec` mounts nothing and warns. Subclasses
|
|
19
28
|
* that override initializeSpecificComponents call super and add to what it
|
|
20
29
|
* returns.
|
|
@@ -44,6 +53,22 @@ export abstract class IslandPage<Ctx, Ext extends object = {}> extends BasePage
|
|
|
44
53
|
() => this.makeContext(spec),
|
|
45
54
|
this.eventBus,
|
|
46
55
|
(message) => console.warn(message),
|
|
56
|
+
{
|
|
57
|
+
defer: (island, el, mount) => this.scheduleLoad(parseLoad(island.load) ?? { kind: "eager" }, el, mount),
|
|
58
|
+
onLateMount: (component, island) => {
|
|
59
|
+
new LifecycleController(this.eventBus, LifecycleController.DefaultConfig)
|
|
60
|
+
.initializeFromRoot(component)
|
|
61
|
+
.catch((err) => console.warn(`page spec: island "${island.name}" failed to start: ${err instanceof Error ? err.message : String(err)}`));
|
|
62
|
+
},
|
|
63
|
+
},
|
|
47
64
|
);
|
|
48
65
|
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Waits for `strategy` and then calls `mount`. Defaults to scheduleMount
|
|
69
|
+
* against the window; a subclass or test can replace how the waiting is done.
|
|
70
|
+
*/
|
|
71
|
+
protected scheduleLoad(strategy: LoadStrategy, el: HTMLElement, mount: () => void): void {
|
|
72
|
+
scheduleMount(strategy, el, mount);
|
|
73
|
+
}
|
|
49
74
|
}
|
package/src/page/load.ts
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* When an island mounts: its `load` in the page spec (goapplib's
|
|
3
|
+
* page.Island.Load), read and waited for. Every strategy mounts once; an
|
|
4
|
+
* island isn't unmounted when its media query stops matching.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/** A parsed `load`. An empty or missing one is eager. */
|
|
8
|
+
export type LoadStrategy =
|
|
9
|
+
| { kind: "eager" }
|
|
10
|
+
| { kind: "idle" }
|
|
11
|
+
| { kind: "visible" }
|
|
12
|
+
| { kind: "media"; query: string };
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The strategy `load` names, or null when it isn't one of the forms Go's
|
|
16
|
+
* Validate accepts (`eager`, `idle`, `visible`, `media:<query>`), compared
|
|
17
|
+
* exactly.
|
|
18
|
+
*/
|
|
19
|
+
export function parseLoad(load: string | undefined): LoadStrategy | null {
|
|
20
|
+
switch (load ?? "") {
|
|
21
|
+
case "":
|
|
22
|
+
case "eager":
|
|
23
|
+
return { kind: "eager" };
|
|
24
|
+
case "idle":
|
|
25
|
+
return { kind: "idle" };
|
|
26
|
+
case "visible":
|
|
27
|
+
return { kind: "visible" };
|
|
28
|
+
}
|
|
29
|
+
const query = load!.startsWith("media:") ? load!.slice("media:".length) : "";
|
|
30
|
+
return query.trim() ? { kind: "media", query } : null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The browser APIs scheduleMount waits on, so tests can stand them in.
|
|
35
|
+
* `requestIdleCallback` is optional because Safari doesn't have it.
|
|
36
|
+
*/
|
|
37
|
+
export interface LoadEnv {
|
|
38
|
+
requestIdleCallback?: (cb: () => void) => unknown;
|
|
39
|
+
setTimeout: (cb: () => void, ms?: number) => unknown;
|
|
40
|
+
IntersectionObserver: typeof IntersectionObserver;
|
|
41
|
+
matchMedia: (query: string) => MediaQueryList;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Calls `mount` once, when `strategy` says the island in `el` should mount:
|
|
46
|
+
* at once for eager; when the browser is idle (or on the next task, without
|
|
47
|
+
* requestIdleCallback) for idle; the first time any of `el` enters the
|
|
48
|
+
* viewport for visible; and at once if the query matches, or else on the
|
|
49
|
+
* first change that makes it match, for media.
|
|
50
|
+
*/
|
|
51
|
+
export function scheduleMount(strategy: LoadStrategy, el: Element, mount: () => void, env: LoadEnv = window): void {
|
|
52
|
+
switch (strategy.kind) {
|
|
53
|
+
case "eager":
|
|
54
|
+
mount();
|
|
55
|
+
return;
|
|
56
|
+
case "idle":
|
|
57
|
+
if (env.requestIdleCallback) env.requestIdleCallback(mount);
|
|
58
|
+
else env.setTimeout(mount, 1);
|
|
59
|
+
return;
|
|
60
|
+
case "visible": {
|
|
61
|
+
let done = false;
|
|
62
|
+
const observer = new env.IntersectionObserver((entries) => {
|
|
63
|
+
if (done || !entries.some((e) => e.isIntersecting)) return;
|
|
64
|
+
done = true;
|
|
65
|
+
observer.disconnect();
|
|
66
|
+
mount();
|
|
67
|
+
});
|
|
68
|
+
observer.observe(el);
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
case "media": {
|
|
72
|
+
const mq = env.matchMedia(strategy.query);
|
|
73
|
+
if (mq.matches) {
|
|
74
|
+
mount();
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
const onChange = (e: { matches: boolean }) => {
|
|
78
|
+
if (!e.matches) return;
|
|
79
|
+
mq.removeEventListener("change", onChange);
|
|
80
|
+
mount();
|
|
81
|
+
};
|
|
82
|
+
mq.addEventListener("change", onChange);
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
package/src/page/mount.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { parseLoad } from "./load";
|
|
1
2
|
import type { IslandSpec, PageSpec } from "./spec";
|
|
2
3
|
|
|
3
4
|
/**
|
|
@@ -6,18 +7,65 @@ import type { IslandSpec, PageSpec } from "./spec";
|
|
|
6
7
|
*/
|
|
7
8
|
export type IslandFactory<Ctx, El, C, B> = (el: El, island: IslandSpec, ctx: Ctx, bus: B) => C;
|
|
8
9
|
|
|
9
|
-
|
|
10
|
-
|
|
10
|
+
const LAZY: unique symbol = Symbol("lazy island");
|
|
11
|
+
|
|
12
|
+
/** A registry entry whose factory is loaded when the island mounts. Made by lazy. */
|
|
13
|
+
export interface LazyIsland<Ctx, El, C, B> {
|
|
14
|
+
readonly [LAZY]: () => Promise<IslandFactory<Ctx, El, C, B> | { default: IslandFactory<Ctx, El, C, B> }>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* A registry entry that loads its island's module only when the island
|
|
19
|
+
* mounts: `hero: lazy(() => import("./islands/hero"))`. With esbuild's
|
|
20
|
+
* --splitting each such module is its own chunk, so a page downloads only the
|
|
21
|
+
* islands its spec names, each when its `load` says. `load` resolves to the
|
|
22
|
+
* factory or to a module whose default export is the factory.
|
|
23
|
+
*
|
|
24
|
+
* A lazy island always mounts late, even an eager one, since its chunk
|
|
25
|
+
* arrives after the page has started; goapplib's page.Assets writes
|
|
26
|
+
* modulepreload links for the eager ones so that wait is short.
|
|
27
|
+
*/
|
|
28
|
+
export function lazy<Ctx, El, C, B>(
|
|
29
|
+
load: () => Promise<IslandFactory<Ctx, El, C, B> | { default: IslandFactory<Ctx, El, C, B> }>,
|
|
30
|
+
): LazyIsland<Ctx, El, C, B> {
|
|
31
|
+
return { [LAZY]: load };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The islands an entry can mount, by name: a factory, bundled with the entry,
|
|
36
|
+
* or a lazy entry, loaded as its own chunk when the island mounts.
|
|
37
|
+
*/
|
|
38
|
+
export type Registry<Ctx, El, C, B> = Record<string, IslandFactory<Ctx, El, C, B> | LazyIsland<Ctx, El, C, B>>;
|
|
39
|
+
|
|
40
|
+
/** How mountIslands handles islands that shouldn't mount at once. */
|
|
41
|
+
export interface MountOptions<El, C> {
|
|
42
|
+
/**
|
|
43
|
+
* Gets each island whose `load` isn't eager, with the slot it will mount
|
|
44
|
+
* in; call `mount` when it's time (IslandPage passes scheduleMount). An
|
|
45
|
+
* island with a `load` parseLoad doesn't know is logged and mounted at once
|
|
46
|
+
* instead, so it still shows up.
|
|
47
|
+
*/
|
|
48
|
+
defer?: (island: IslandSpec, el: El, mount: () => void) => void;
|
|
49
|
+
/** Gets what the factory built for each island mounted later: through `defer`, or from a lazy entry. */
|
|
50
|
+
onLateMount?: (component: C, island: IslandSpec) => void;
|
|
51
|
+
}
|
|
11
52
|
|
|
12
53
|
/**
|
|
13
54
|
* Mounts every island in `spec` into the element `findSlot` gives for its
|
|
14
|
-
* slot, and returns what the factories built, in spec order.
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
55
|
+
* slot, and returns what the factories built at once, in spec order. With
|
|
56
|
+
* `options.defer`, an island whose `load` isn't eager is handed to it instead
|
|
57
|
+
* and reported through `options.onLateMount` when it mounts; without it,
|
|
58
|
+
* every island mounts now whatever its `load` says. A lazy entry is loaded
|
|
59
|
+
* when its island would mount and is reported through `onLateMount` too, so
|
|
60
|
+
* it's never in the returned list.
|
|
61
|
+
*
|
|
62
|
+
* `context` builds the page's shared services; it's called once, before the
|
|
63
|
+
* first island mounts (eager or late), and not at all on a page with nothing
|
|
64
|
+
* to mount, so a page without islands doesn't start what they'd share. An
|
|
65
|
+
* island the registry doesn't know, a slot that isn't on the page, or a
|
|
66
|
+
* factory that throws (now or later), or a lazy entry that fails to load, is
|
|
67
|
+
* reported through `log` and skipped,
|
|
68
|
+
* so one bad entry doesn't take the rest of the page down with it.
|
|
21
69
|
*
|
|
22
70
|
* Plain types throughout (no DOM), so it runs under node in tests and on a
|
|
23
71
|
* bare page without BasePage.
|
|
@@ -29,12 +77,13 @@ export function mountIslands<Ctx, El, C, B>(
|
|
|
29
77
|
context: () => Ctx,
|
|
30
78
|
bus: B,
|
|
31
79
|
log: (message: string) => void,
|
|
80
|
+
options: MountOptions<El, C> = {},
|
|
32
81
|
): C[] {
|
|
33
82
|
const out: C[] = [];
|
|
34
83
|
let ctx: Ctx | undefined;
|
|
35
84
|
for (const island of spec.islands) {
|
|
36
|
-
const
|
|
37
|
-
if (!
|
|
85
|
+
const entry = Object.prototype.hasOwnProperty.call(registry, island.name) ? registry[island.name] : undefined;
|
|
86
|
+
if (!entry) {
|
|
38
87
|
log(`page spec: no island called "${island.name}" in this page's registry`);
|
|
39
88
|
continue;
|
|
40
89
|
}
|
|
@@ -43,12 +92,52 @@ export function mountIslands<Ctx, El, C, B>(
|
|
|
43
92
|
log(`page spec: island "${island.name}" wants slot "${island.slot}", which isn't on the page`);
|
|
44
93
|
continue;
|
|
45
94
|
}
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
95
|
+
const build = (factory: IslandFactory<Ctx, El, C, B>): C | undefined => {
|
|
96
|
+
try {
|
|
97
|
+
ctx ??= context();
|
|
98
|
+
return factory(el, island, ctx, bus);
|
|
99
|
+
} catch (err) {
|
|
100
|
+
log(`page spec: island "${island.name}" failed to mount: ${message(err)}`);
|
|
101
|
+
return undefined;
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
const mountLate = () => {
|
|
105
|
+
const late = (factory: IslandFactory<Ctx, El, C, B>) => {
|
|
106
|
+
const c = build(factory);
|
|
107
|
+
if (c !== undefined) options.onLateMount?.(c, island);
|
|
108
|
+
};
|
|
109
|
+
if (typeof entry === "function") {
|
|
110
|
+
late(entry);
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
// The executor turns a loader that throws into a rejection, logged like any other.
|
|
114
|
+
new Promise<Awaited<ReturnType<(typeof entry)[typeof LAZY]>>>((resolve) => resolve(entry[LAZY]())).then(
|
|
115
|
+
(m) => {
|
|
116
|
+
const factory = typeof m === "function" ? m : m?.default;
|
|
117
|
+
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)`);
|
|
119
|
+
},
|
|
120
|
+
(err) => log(`page spec: island "${island.name}" failed to load: ${message(err)}`),
|
|
121
|
+
);
|
|
122
|
+
};
|
|
123
|
+
const strategy = parseLoad(island.load);
|
|
124
|
+
if (strategy === null) {
|
|
125
|
+
log(`page spec: island "${island.name}" has load "${island.load}", which isn't eager, idle, visible or media:<query>; mounting it now`);
|
|
126
|
+
}
|
|
127
|
+
if (options.defer && strategy !== null && strategy.kind !== "eager") {
|
|
128
|
+
options.defer(island, el, mountLate);
|
|
129
|
+
continue;
|
|
51
130
|
}
|
|
131
|
+
if (typeof entry !== "function") {
|
|
132
|
+
mountLate();
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
const c = build(entry);
|
|
136
|
+
if (c !== undefined) out.push(c);
|
|
52
137
|
}
|
|
53
138
|
return out;
|
|
54
139
|
}
|
|
140
|
+
|
|
141
|
+
function message(err: unknown): string {
|
|
142
|
+
return err instanceof Error ? err.message : String(err);
|
|
143
|
+
}
|
package/src/page/spec.ts
CHANGED
|
@@ -15,6 +15,12 @@ export interface IslandSpec {
|
|
|
15
15
|
presentation?: string;
|
|
16
16
|
/** Handed to the factory as is. Always an object. */
|
|
17
17
|
config: Record<string, unknown>;
|
|
18
|
+
/**
|
|
19
|
+
* When it mounts: `eager` (also when absent), `idle`, `visible` or
|
|
20
|
+
* `media:<query>` (see parseLoad). IslandPage waits for it; mountIslands
|
|
21
|
+
* does when given a `defer`.
|
|
22
|
+
*/
|
|
23
|
+
load?: string;
|
|
18
24
|
}
|
|
19
25
|
|
|
20
26
|
export interface PageSpec {
|
|
@@ -61,6 +67,7 @@ export function readSpec<Ext extends object>(text: string | null | undefined, ex
|
|
|
61
67
|
slot: is.slot,
|
|
62
68
|
...(typeof is.presentation === "string" && { presentation: is.presentation }),
|
|
63
69
|
config: isObject(is.config) ? is.config : {},
|
|
70
|
+
...(typeof is.load === "string" && { load: is.load }),
|
|
64
71
|
});
|
|
65
72
|
}
|
|
66
73
|
const spec: PageSpec = { layout: raw.layout, islands };
|