@panyam/tsappkit 0.0.5 → 0.1.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/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 +8 -3
- package/src/index.ts +7 -0
- package/src/page/IslandPage.test.ts +83 -0
- package/src/page/IslandPage.ts +49 -0
- package/src/page/mount.test.ts +73 -0
- package/src/page/mount.ts +54 -0
- package/src/page/spec.test.ts +65 -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.0
|
|
3
|
+
"version": "0.1.0",
|
|
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",
|
|
@@ -30,7 +30,9 @@
|
|
|
30
30
|
"dev": "tsup --watch",
|
|
31
31
|
"clean": "rm -rf dist",
|
|
32
32
|
"prepublishOnly": "npm run build",
|
|
33
|
-
"typecheck": "tsc --noEmit"
|
|
33
|
+
"typecheck": "tsc --noEmit",
|
|
34
|
+
"test": "vitest run",
|
|
35
|
+
"test:watch": "vitest"
|
|
34
36
|
},
|
|
35
37
|
"keywords": [
|
|
36
38
|
"typescript",
|
|
@@ -44,10 +46,13 @@
|
|
|
44
46
|
"author": "panyam",
|
|
45
47
|
"license": "MIT",
|
|
46
48
|
"devDependencies": {
|
|
49
|
+
"@types/node": "^22.20.5",
|
|
47
50
|
"ace-builds": "^1.43.0",
|
|
51
|
+
"jsdom": "^29.1.1",
|
|
48
52
|
"sass": "^1.77.0",
|
|
49
53
|
"tsup": "^8.0.0",
|
|
50
|
-
"typescript": "^5.0.0"
|
|
54
|
+
"typescript": "^5.0.0",
|
|
55
|
+
"vitest": "^4.1.11"
|
|
51
56
|
},
|
|
52
57
|
"peerDependencies": {
|
|
53
58
|
"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,83 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import type { LCMComponent } from "../LCMComponent";
|
|
3
|
+
import { IslandPage } from "./IslandPage";
|
|
4
|
+
import type { PageSpec } from "./spec";
|
|
5
|
+
|
|
6
|
+
type Ctx = { built: number };
|
|
7
|
+
type Ext = { things: string[] };
|
|
8
|
+
|
|
9
|
+
const mounted: { name: string; el: HTMLElement; config: unknown; ctx: Ctx }[] = [];
|
|
10
|
+
const contexts: (PageSpec & Ext)[] = [];
|
|
11
|
+
|
|
12
|
+
const island = (name: string) => (el: HTMLElement, spec: { config: unknown }, ctx: Ctx) => {
|
|
13
|
+
mounted.push({ name, el, config: spec.config, ctx });
|
|
14
|
+
return { name } as unknown as LCMComponent;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
class TestPage extends IslandPage<Ctx, Ext> {
|
|
18
|
+
protected registry() {
|
|
19
|
+
return { player: island("player"), chat: island("chat") };
|
|
20
|
+
}
|
|
21
|
+
protected readExtension(raw: Record<string, unknown>): Ext {
|
|
22
|
+
return { things: Array.isArray(raw.things) ? raw.things.map(String) : [] };
|
|
23
|
+
}
|
|
24
|
+
protected makeContext(spec: PageSpec & Ext): Ctx {
|
|
25
|
+
contexts.push(spec);
|
|
26
|
+
return { built: contexts.length };
|
|
27
|
+
}
|
|
28
|
+
mount() {
|
|
29
|
+
return this.initializeSpecificComponents();
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function page(spec: string | null, slots: string[]) {
|
|
34
|
+
document.body.innerHTML =
|
|
35
|
+
(spec === null ? "" : `<script type="application/json" id="page-spec">${spec}</script>`) +
|
|
36
|
+
slots.map((s) => `<section data-slot="${s}"></section>`).join("");
|
|
37
|
+
return new TestPage("test");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
describe("IslandPage", () => {
|
|
41
|
+
let warn: ReturnType<typeof vi.spyOn>;
|
|
42
|
+
beforeEach(() => {
|
|
43
|
+
// jsdom has no matchMedia, and BasePage's ThemeManager asks it for the colour scheme.
|
|
44
|
+
window.matchMedia ??= ((query: string) => ({ matches: false, media: query, addEventListener() {}, removeEventListener() {} })) as unknown as typeof window.matchMedia;
|
|
45
|
+
mounted.length = 0;
|
|
46
|
+
contexts.length = 0;
|
|
47
|
+
warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
48
|
+
});
|
|
49
|
+
afterEach(() => warn.mockRestore());
|
|
50
|
+
|
|
51
|
+
it("mounts each island from the spec into its data-slot, sharing one context", () => {
|
|
52
|
+
const spec = JSON.stringify({
|
|
53
|
+
layout: "drawer",
|
|
54
|
+
islands: [
|
|
55
|
+
{ name: "player", slot: "main", config: { a: 1 } },
|
|
56
|
+
{ name: "nobody", slot: "side" },
|
|
57
|
+
{ name: "chat", slot: "drawer" },
|
|
58
|
+
],
|
|
59
|
+
things: ["x"],
|
|
60
|
+
});
|
|
61
|
+
const out = page(spec, ["main", "side", "drawer"]).mount();
|
|
62
|
+
expect(out).toHaveLength(2);
|
|
63
|
+
expect(mounted.map((m) => [m.name, m.el.dataset.slot, m.config])).toEqual([
|
|
64
|
+
["player", "main", { a: 1 }],
|
|
65
|
+
["chat", "drawer", {}],
|
|
66
|
+
]);
|
|
67
|
+
expect(mounted[0].ctx).toBe(mounted[1].ctx);
|
|
68
|
+
expect(contexts).toHaveLength(1);
|
|
69
|
+
expect(contexts[0].things).toEqual(["x"]);
|
|
70
|
+
expect(warn.mock.calls.flat().join("\n")).toMatch(/nobody/);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("mounts nothing, and builds no context, without a readable spec", () => {
|
|
74
|
+
for (const spec of [null, "not json"]) {
|
|
75
|
+
const p = page(spec, ["main"]);
|
|
76
|
+
warn.mockClear();
|
|
77
|
+
expect(p.mount()).toEqual([]);
|
|
78
|
+
expect(warn).toHaveBeenCalledOnce();
|
|
79
|
+
expect(String(warn.mock.calls[0][0])).toMatch(/page-spec/);
|
|
80
|
+
}
|
|
81
|
+
expect(contexts).toHaveLength(0);
|
|
82
|
+
});
|
|
83
|
+
});
|
|
@@ -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,73 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { mountIslands, type Registry } from "./mount";
|
|
3
|
+
import type { PageSpec } from "./spec";
|
|
4
|
+
|
|
5
|
+
type El = { slot: string };
|
|
6
|
+
const ctx = { tag: "ctx" };
|
|
7
|
+
const bus = { tag: "bus" };
|
|
8
|
+
|
|
9
|
+
const spec: PageSpec = {
|
|
10
|
+
layout: "drawer",
|
|
11
|
+
islands: [
|
|
12
|
+
{ name: "player", slot: "main", presentation: "page", config: { a: 1 } },
|
|
13
|
+
{ name: "unknown", slot: "side", config: {} },
|
|
14
|
+
{ name: "chat", slot: "missing", config: {} },
|
|
15
|
+
{ name: "chat", slot: "drawer", presentation: "drawer", config: {} },
|
|
16
|
+
],
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
describe("mountIslands", () => {
|
|
20
|
+
it("mounts each island in its slot with its config and the page's context, in spec order", () => {
|
|
21
|
+
const calls: unknown[][] = [];
|
|
22
|
+
const registry: Registry<typeof ctx, El, string, typeof bus> = {
|
|
23
|
+
player: (el, island, c, b) => (calls.push([el, island, c, b]), "player-component"),
|
|
24
|
+
chat: (el, island) => (calls.push([el, island.presentation]), "chat-component"),
|
|
25
|
+
};
|
|
26
|
+
const logs: string[] = [];
|
|
27
|
+
const out = mountIslands(spec, registry, (slot) => (slot === "missing" ? null : { slot }), () => ctx, bus, (m) => logs.push(m));
|
|
28
|
+
expect(out).toEqual(["player-component", "chat-component"]);
|
|
29
|
+
expect(calls).toEqual([
|
|
30
|
+
[{ slot: "main" }, spec.islands[0], ctx, bus],
|
|
31
|
+
[{ slot: "drawer" }, "drawer"],
|
|
32
|
+
]);
|
|
33
|
+
// The unknown island and the missing slot are logged and skipped.
|
|
34
|
+
expect(logs).toHaveLength(2);
|
|
35
|
+
expect(logs.join("\n")).toMatch(/unknown/);
|
|
36
|
+
expect(logs.join("\n")).toMatch(/missing/);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("doesn't take an island name from the registry's prototype", () => {
|
|
40
|
+
const logs: string[] = [];
|
|
41
|
+
const out = mountIslands({ layout: "a", islands: [{ name: "toString", slot: "main", config: {} }] }, {}, (slot) => ({ slot }), () => ctx, bus, (m) => logs.push(m));
|
|
42
|
+
expect(out).toEqual([]);
|
|
43
|
+
expect(logs).toHaveLength(1);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("carries on when a factory throws", () => {
|
|
47
|
+
const logs: string[] = [];
|
|
48
|
+
const out = mountIslands(
|
|
49
|
+
spec,
|
|
50
|
+
{
|
|
51
|
+
player: () => {
|
|
52
|
+
throw new Error("boom");
|
|
53
|
+
},
|
|
54
|
+
chat: () => "ok",
|
|
55
|
+
},
|
|
56
|
+
(slot) => ({ slot }),
|
|
57
|
+
() => ctx,
|
|
58
|
+
bus,
|
|
59
|
+
(m) => logs.push(m),
|
|
60
|
+
);
|
|
61
|
+
expect(out).toEqual(["ok", "ok"]);
|
|
62
|
+
expect(logs.some((m) => m.includes("boom"))).toBe(true);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("builds the page's context once, and only when there's an island to mount", () => {
|
|
66
|
+
let built = 0;
|
|
67
|
+
const context = () => (built++, ctx);
|
|
68
|
+
mountIslands({ layout: "index", islands: [] }, {}, () => ({ slot: "" }), context, bus, () => {});
|
|
69
|
+
expect(built).toBe(0);
|
|
70
|
+
mountIslands(spec, { player: () => "a", chat: () => "b" }, (slot) => ({ slot }), context, bus, () => {});
|
|
71
|
+
expect(built).toBe(1);
|
|
72
|
+
});
|
|
73
|
+
});
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { describe, expect, it } from "vitest";
|
|
4
|
+
import { readSpec } from "./spec";
|
|
5
|
+
|
|
6
|
+
describe("readSpec", () => {
|
|
7
|
+
it("reads the spec goapplib's page.Spec writes (page/testdata/spec.json)", () => {
|
|
8
|
+
const text = readFileSync(join(__dirname, "../../../page/testdata/spec.json"), "utf8");
|
|
9
|
+
expect(readSpec(text)).toEqual({
|
|
10
|
+
layout: "drawer",
|
|
11
|
+
islands: [
|
|
12
|
+
{ name: "player", slot: "main", presentation: "page", config: { note: "</script>", urls: ["/a.json"] } },
|
|
13
|
+
{ name: "chat", slot: "side", config: {} },
|
|
14
|
+
],
|
|
15
|
+
});
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("is null when there's no spec or it isn't one", () => {
|
|
19
|
+
for (const bad of [null, undefined, "", "not json", "null", "[]", '{"islands":[]}', '{"layout":"drawer"}', '{"layout":3,"islands":[]}']) {
|
|
20
|
+
expect(readSpec(bad), String(bad)).toBeNull();
|
|
21
|
+
}
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("drops islands it couldn't mount and cleans the rest", () => {
|
|
25
|
+
const spec = readSpec(
|
|
26
|
+
JSON.stringify({
|
|
27
|
+
layout: "drawer",
|
|
28
|
+
islands: [
|
|
29
|
+
{ name: "player", slot: "main" },
|
|
30
|
+
{ slot: "orphan" },
|
|
31
|
+
{ name: "x", slot: 'main"] , body' },
|
|
32
|
+
{ name: "chat", slot: "drawer", presentation: 7, config: "nope" },
|
|
33
|
+
"junk",
|
|
34
|
+
],
|
|
35
|
+
}),
|
|
36
|
+
);
|
|
37
|
+
expect(spec).toEqual({
|
|
38
|
+
layout: "drawer",
|
|
39
|
+
islands: [
|
|
40
|
+
{ name: "player", slot: "main", config: {} },
|
|
41
|
+
{ name: "chat", slot: "drawer", config: {} },
|
|
42
|
+
],
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("leaves an app's own fields out unless it reads them", () => {
|
|
47
|
+
expect(readSpec('{"layout":"a","islands":[],"things":[1]}')).toEqual({ layout: "a", islands: [] });
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("merges in what an extension reads, without letting it replace layout or islands", () => {
|
|
51
|
+
const text = JSON.stringify({ layout: "a", islands: [{ name: "p", slot: "main" }], things: [1, "x", 2] });
|
|
52
|
+
const spec = readSpec(text, (raw) => ({
|
|
53
|
+
things: Array.isArray(raw.things) ? raw.things.filter((t): t is number => typeof t === "number") : [],
|
|
54
|
+
layout: "overridden",
|
|
55
|
+
}));
|
|
56
|
+
expect(spec).toEqual({ layout: "a", islands: [{ name: "p", slot: "main", config: {} }], things: [1, 2] });
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("is null when the extension throws", () => {
|
|
60
|
+
const spec = readSpec('{"layout":"a","islands":[]}', () => {
|
|
61
|
+
throw new Error("bad things");
|
|
62
|
+
});
|
|
63
|
+
expect(spec).toBeNull();
|
|
64
|
+
});
|
|
65
|
+
});
|
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
|
+
}
|