@panyam/tsappkit 0.0.5 → 0.1.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 +88 -1
- package/dist/index.d.ts +88 -1
- package/dist/index.js +91 -0
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +88 -1
- package/dist/index.mjs.map +1 -1
- package/package.json +9 -3
- package/src/index.ts +7 -0
- package/src/page/IslandPage.ts +49 -0
- package/src/page/mount.ts +54 -0
- package/src/page/spec.ts +79 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@panyam/tsappkit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.1.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",
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
"files": [
|
|
23
23
|
"dist",
|
|
24
24
|
"src",
|
|
25
|
+
"!src/**/*.test.ts",
|
|
25
26
|
"styles"
|
|
26
27
|
],
|
|
27
28
|
"scripts": {
|
|
@@ -30,7 +31,9 @@
|
|
|
30
31
|
"dev": "tsup --watch",
|
|
31
32
|
"clean": "rm -rf dist",
|
|
32
33
|
"prepublishOnly": "npm run build",
|
|
33
|
-
"typecheck": "tsc --noEmit"
|
|
34
|
+
"typecheck": "tsc --noEmit",
|
|
35
|
+
"test": "vitest run",
|
|
36
|
+
"test:watch": "vitest"
|
|
34
37
|
},
|
|
35
38
|
"keywords": [
|
|
36
39
|
"typescript",
|
|
@@ -44,10 +47,13 @@
|
|
|
44
47
|
"author": "panyam",
|
|
45
48
|
"license": "MIT",
|
|
46
49
|
"devDependencies": {
|
|
50
|
+
"@types/node": "^22.20.5",
|
|
47
51
|
"ace-builds": "^1.43.0",
|
|
52
|
+
"jsdom": "^29.1.1",
|
|
48
53
|
"sass": "^1.77.0",
|
|
49
54
|
"tsup": "^8.0.0",
|
|
50
|
-
"typescript": "^5.0.0"
|
|
55
|
+
"typescript": "^5.0.0",
|
|
56
|
+
"vitest": "^4.1.11"
|
|
51
57
|
},
|
|
52
58
|
"peerDependencies": {
|
|
53
59
|
"ace-builds": "^1.0.0"
|
package/src/index.ts
CHANGED
|
@@ -27,6 +27,13 @@ export { TemplateLoader } from './TemplateLoader';
|
|
|
27
27
|
export { SplashScreen } from './SplashScreen';
|
|
28
28
|
export { MobileBottomDrawer } from './MobileBottomDrawer';
|
|
29
29
|
|
|
30
|
+
// Island pages (islands mounted from a server-written page spec)
|
|
31
|
+
export { IslandPage } from './page/IslandPage';
|
|
32
|
+
export { readSpec, SPEC_ELEMENT_ID } from './page/spec';
|
|
33
|
+
export type { IslandSpec, PageSpec, SpecExtension } from './page/spec';
|
|
34
|
+
export { mountIslands } from './page/mount';
|
|
35
|
+
export type { IslandFactory, Registry } from './page/mount';
|
|
36
|
+
|
|
30
37
|
// Utilities
|
|
31
38
|
export { isInInputContext, hasModifierKeys, shouldIgnoreShortcut } from './DOMUtils';
|
|
32
39
|
export {
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { BasePage } from "../BasePage";
|
|
2
|
+
import type { EventBus } from "../EventBus";
|
|
3
|
+
import type { LCMComponent } from "../LCMComponent";
|
|
4
|
+
import { mountIslands, type Registry } from "./mount";
|
|
5
|
+
import { readSpec, SPEC_ELEMENT_ID, type PageSpec } from "./spec";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* A page whose islands come from the page spec. A subclass says which
|
|
9
|
+
* islands it can mount (`registry`) and builds the services they share
|
|
10
|
+
* (`makeContext`); the spec says which islands this page gets and where.
|
|
11
|
+
*
|
|
12
|
+
* `makeContext` is called once with the spec, before the first island
|
|
13
|
+
* mounts, and not at all on a page with no islands to mount. An app whose
|
|
14
|
+
* spec carries more than islands (its Go type embeds page.Spec) reads those
|
|
15
|
+
* fields in `readExtension`, and they arrive typed as `Ext` on the spec
|
|
16
|
+
* `makeContext` gets.
|
|
17
|
+
*
|
|
18
|
+
* A page with no readable `#page-spec` mounts nothing and warns. Subclasses
|
|
19
|
+
* that override initializeSpecificComponents call super and add to what it
|
|
20
|
+
* returns.
|
|
21
|
+
*/
|
|
22
|
+
export abstract class IslandPage<Ctx, Ext extends object = {}> extends BasePage {
|
|
23
|
+
protected abstract registry(): Registry<Ctx, HTMLElement, LCMComponent, EventBus>;
|
|
24
|
+
protected abstract makeContext(spec: PageSpec & Ext): Ctx;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The app's own fields from the parsed spec. The default reads none.
|
|
28
|
+
* Throwing makes the spec unreadable, so nothing mounts.
|
|
29
|
+
*/
|
|
30
|
+
protected readExtension(raw: Record<string, unknown>): Ext {
|
|
31
|
+
return {} as Ext;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
protected override initializeSpecificComponents(): LCMComponent[] {
|
|
35
|
+
const spec = readSpec(document.getElementById(SPEC_ELEMENT_ID)?.textContent, (raw) => this.readExtension(raw));
|
|
36
|
+
if (!spec) {
|
|
37
|
+
console.warn(`page spec: no readable #${SPEC_ELEMENT_ID} on this page, so nothing is mounted`);
|
|
38
|
+
return [];
|
|
39
|
+
}
|
|
40
|
+
return mountIslands(
|
|
41
|
+
spec,
|
|
42
|
+
this.registry(),
|
|
43
|
+
(slot) => document.querySelector<HTMLElement>(`[data-slot="${slot}"]`),
|
|
44
|
+
() => this.makeContext(spec),
|
|
45
|
+
this.eventBus,
|
|
46
|
+
(message) => console.warn(message),
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { IslandSpec, PageSpec } from "./spec";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Builds one island in `el`. `ctx` is the page's shared services, whatever
|
|
5
|
+
* the app says they are; `bus` is the page's event bus.
|
|
6
|
+
*/
|
|
7
|
+
export type IslandFactory<Ctx, El, C, B> = (el: El, island: IslandSpec, ctx: Ctx, bus: B) => C;
|
|
8
|
+
|
|
9
|
+
/** The islands an entry can mount, by name. An entry bundles only what its registry names. */
|
|
10
|
+
export type Registry<Ctx, El, C, B> = Record<string, IslandFactory<Ctx, El, C, B>>;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Mounts every island in `spec` into the element `findSlot` gives for its
|
|
14
|
+
* slot, and returns what the factories built, in spec order. `context` builds
|
|
15
|
+
* the page's shared services; it's called once, before the first island
|
|
16
|
+
* mounts, and not at all on a page with nothing to mount, so a page without
|
|
17
|
+
* islands doesn't start what they'd share. An island the registry doesn't
|
|
18
|
+
* know, a slot that isn't on the page, or a factory that throws is reported
|
|
19
|
+
* through `log` and skipped, so one bad entry doesn't take the rest of the
|
|
20
|
+
* page down with it.
|
|
21
|
+
*
|
|
22
|
+
* Plain types throughout (no DOM), so it runs under node in tests and on a
|
|
23
|
+
* bare page without BasePage.
|
|
24
|
+
*/
|
|
25
|
+
export function mountIslands<Ctx, El, C, B>(
|
|
26
|
+
spec: PageSpec,
|
|
27
|
+
registry: Registry<Ctx, El, C, B>,
|
|
28
|
+
findSlot: (slot: string) => El | null,
|
|
29
|
+
context: () => Ctx,
|
|
30
|
+
bus: B,
|
|
31
|
+
log: (message: string) => void,
|
|
32
|
+
): C[] {
|
|
33
|
+
const out: C[] = [];
|
|
34
|
+
let ctx: Ctx | undefined;
|
|
35
|
+
for (const island of spec.islands) {
|
|
36
|
+
const factory = Object.prototype.hasOwnProperty.call(registry, island.name) ? registry[island.name] : undefined;
|
|
37
|
+
if (!factory) {
|
|
38
|
+
log(`page spec: no island called "${island.name}" in this page's registry`);
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
const el = findSlot(island.slot);
|
|
42
|
+
if (el === null) {
|
|
43
|
+
log(`page spec: island "${island.name}" wants slot "${island.slot}", which isn't on the page`);
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
try {
|
|
47
|
+
ctx ??= context();
|
|
48
|
+
out.push(factory(el, island, ctx, bus));
|
|
49
|
+
} catch (err) {
|
|
50
|
+
log(`page spec: island "${island.name}" failed to mount: ${err instanceof Error ? err.message : String(err)}`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return out;
|
|
54
|
+
}
|
package/src/page/spec.ts
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The page spec: which islands a page mounts, where, and with what config.
|
|
3
|
+
* The server writes it into the page as JSON (goapplib's page.Spec, through
|
|
4
|
+
* templates/page/Islands.html); this reads it back.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/** The id of the script element the server writes the spec into. */
|
|
8
|
+
export const SPEC_ELEMENT_ID = "page-spec";
|
|
9
|
+
|
|
10
|
+
/** One island: `name` picks its factory, `slot` the element it mounts in (`[data-slot]`). */
|
|
11
|
+
export interface IslandSpec {
|
|
12
|
+
name: string;
|
|
13
|
+
slot: string;
|
|
14
|
+
/** How the layout shows it (the app's own words: "page", "panel", "drawer"), when it draws differently in each. */
|
|
15
|
+
presentation?: string;
|
|
16
|
+
/** Handed to the factory as is. Always an object. */
|
|
17
|
+
config: Record<string, unknown>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface PageSpec {
|
|
21
|
+
/** The arrangement of slots, for state kept per layout. */
|
|
22
|
+
layout: string;
|
|
23
|
+
islands: IslandSpec[];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Reads an app's own fields from the parsed spec, the ones its Go type adds
|
|
28
|
+
* by embedding page.Spec. Gets the whole object; returns what the app wants
|
|
29
|
+
* kept, already checked.
|
|
30
|
+
*/
|
|
31
|
+
export type SpecExtension<Ext> = (raw: Record<string, unknown>) => Ext;
|
|
32
|
+
|
|
33
|
+
// Slot names go into an attribute selector, so they must stay plain (as Go checks).
|
|
34
|
+
const SLOT = /^[a-z][a-z0-9-]*$/;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The spec in `text`, or null when there is none or it isn't one. Islands
|
|
38
|
+
* without a name or with a slot name that isn't plain are dropped, and a
|
|
39
|
+
* config that isn't an object becomes {}, so what comes back can be mounted.
|
|
40
|
+
*
|
|
41
|
+
* `extend`, when given, reads the app's own fields; they're merged into the
|
|
42
|
+
* result, but `layout` and `islands` always come from this reading. If it
|
|
43
|
+
* throws, the spec is unreadable and the result is null.
|
|
44
|
+
*/
|
|
45
|
+
export function readSpec(text: string | null | undefined): PageSpec | null;
|
|
46
|
+
export function readSpec<Ext extends object>(text: string | null | undefined, extend: SpecExtension<Ext>): (PageSpec & Ext) | null;
|
|
47
|
+
export function readSpec<Ext extends object>(text: string | null | undefined, extend?: SpecExtension<Ext>): PageSpec | (PageSpec & Ext) | null {
|
|
48
|
+
if (!text) return null;
|
|
49
|
+
let raw: unknown;
|
|
50
|
+
try {
|
|
51
|
+
raw = JSON.parse(text);
|
|
52
|
+
} catch {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
if (!isObject(raw) || typeof raw.layout !== "string" || !Array.isArray(raw.islands)) return null;
|
|
56
|
+
const islands: IslandSpec[] = [];
|
|
57
|
+
for (const is of raw.islands) {
|
|
58
|
+
if (!isObject(is) || typeof is.name !== "string" || !is.name || typeof is.slot !== "string" || !SLOT.test(is.slot)) continue;
|
|
59
|
+
islands.push({
|
|
60
|
+
name: is.name,
|
|
61
|
+
slot: is.slot,
|
|
62
|
+
...(typeof is.presentation === "string" && { presentation: is.presentation }),
|
|
63
|
+
config: isObject(is.config) ? is.config : {},
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
const spec: PageSpec = { layout: raw.layout, islands };
|
|
67
|
+
if (!extend) return spec;
|
|
68
|
+
let ext: Ext;
|
|
69
|
+
try {
|
|
70
|
+
ext = extend(raw);
|
|
71
|
+
} catch {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
return { ...ext, ...spec };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function isObject(v: unknown): v is Record<string, unknown> {
|
|
78
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
79
|
+
}
|