@dolphy-app/extension-sdk 0.2.0 → 0.4.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.
@@ -0,0 +1,211 @@
1
+ # Recipe: a command and a panel
2
+
3
+ A command appears in the command palette and runs your code; a panel is a
4
+ screen in an isolated frame that the command can open. This recipe is the
5
+ `command-panel` template
6
+ (`npx @dolphy-app/create-extension <dir> --id acme.hello --template command-panel`).
7
+ The files below are exactly what the generator writes for the id `acme.hello`.
8
+ See [quick-start.md](quick-start.md) for the commands.
9
+
10
+ ## The manifest
11
+
12
+ File `extension.json` (command-panel):
13
+
14
+ ```json
15
+ {
16
+ "$schema": "./node_modules/@dolphy-app/extension-api/dist/extension.schema.json",
17
+ "id": "acme.hello",
18
+ "version": "0.1.0",
19
+ "apiVersion": 1,
20
+ "name": "Hello panel",
21
+ "description": "Palette commands that greet the learner and open a small panel.",
22
+ "author": "your-github-login",
23
+ "tags": ["productivity"],
24
+ "contributes": {
25
+ "commands": [
26
+ { "id": "acme.hello.hello", "title": "Say hello", "category": "Hello" },
27
+ { "id": "acme.hello.open", "title": "Open the hello panel", "category": "Hello" },
28
+ { "id": "acme.hello.data", "title": "Hello panel data", "palette": false }
29
+ ],
30
+ "panels": [{ "id": "acme.hello.view", "title": "Hello" }]
31
+ }
32
+ }
33
+ ```
34
+
35
+ - `commands[]`: `title` and `category` are what the palette shows. A command
36
+ with `"palette": false` is hidden from the palette: it is a handler only your
37
+ panel calls.
38
+ - `panels[]` declares the screen; its module defaults to `./panel.mjs`, which
39
+ the build writes.
40
+ - No permissions: commands, panels and `notify` need none.
41
+
42
+ ## The code
43
+
44
+ File `src/index.ts` (command-panel):
45
+
46
+ ```ts
47
+ import {
48
+ defineExtension,
49
+ defineExtensionPanel,
50
+ notify,
51
+ openPanel,
52
+ } from '@dolphy-app/extension-sdk';
53
+ import type { ExtensionPanels } from '@dolphy-app/extension-sdk';
54
+
55
+ // extension code: `host` runs in the extension process of the app
56
+ // the ids come from extension.json: a misspelt id or a declared id without a
57
+ // handler fails `pnpm typecheck`
58
+ export const host = defineExtension({
59
+ commands: {
60
+ // palette command: shows a notification
61
+ 'acme.hello.hello': (args) => {
62
+ const name = typeof args === 'string' ? args : 'world';
63
+ return notify(`Hello, ${name}!`);
64
+ },
65
+ // palette command: opens the panel with properties
66
+ 'acme.hello.open': () => openPanel('acme.hello.view', { name: 'Dolphy' }),
67
+ // hidden from the palette (palette: false): the panel asks for data
68
+ 'acme.hello.data': () => ({ message: 'Hello from acme.hello' }),
69
+ },
70
+ });
71
+
72
+ // the panel runs in an isolated frame of the app window: no network, no
73
+ // window.dolphy; the only way out is `ctx.call` to the commands above
74
+ export const panels = {
75
+ 'acme.hello.view': defineExtensionPanel({
76
+ async mount(container, ctx) {
77
+ const doc = container.ownerDocument;
78
+ const title = doc.createElement('h2');
79
+ const line = doc.createElement('p');
80
+ container.append(title, line);
81
+ const name = (props: unknown): string =>
82
+ typeof props === 'object' && props !== null && 'name' in props
83
+ ? String(props.name)
84
+ : 'world';
85
+ title.textContent = `Hello, ${name(ctx.props)}!`;
86
+ // the app opens the panel again with new properties: redraw the title
87
+ ctx.signal.addEventListener(
88
+ 'abort',
89
+ ctx.onProps((props) => {
90
+ title.textContent = `Hello, ${name(props)}!`;
91
+ }),
92
+ );
93
+ const data = (await ctx.call('acme.hello.data')) as { message: string };
94
+ line.textContent = data.message;
95
+ },
96
+ }),
97
+ } satisfies ExtensionPanels;
98
+ ```
99
+
100
+ - `commands` maps every declared id to a handler. The result is what the app
101
+ does next: `notify(text)` shows a notification, `openPanel(id, props)` opens a
102
+ panel with properties, any JSON value is data for the caller, nothing is fine.
103
+ `args` is whatever the caller passes, so check its type.
104
+ - A panel is `defineExtensionPanel({ mount(container, ctx) })`. `ctx` has
105
+ `panelId`, `props`, `signal` (aborted when the panel closes), `onProps(listener)`
106
+ for new properties when the command opens the panel again, and
107
+ `ctx.call(commandId, args)`.
108
+ - A panel runs in an isolated frame with no network and no access to the app;
109
+ `ctx.call` to a declared command is the only way out. That is why the data
110
+ command exists.
111
+ - The build writes `host` to `main.mjs` and `panels` to `panel.mjs`.
112
+
113
+ ## The tests
114
+
115
+ File `test/index.test.ts` (command-panel):
116
+
117
+ ```ts
118
+ // @vitest-environment happy-dom
119
+ import {
120
+ loadCommands,
121
+ loadPanel,
122
+ } from '@dolphy-app/extension-sdk/testing';
123
+ import { afterEach, describe, expect, it } from 'vitest';
124
+ import { host, panels } from '../src/index.ts';
125
+
126
+ const disposables: { dispose(): unknown }[] = [];
127
+ afterEach(async () => {
128
+ await Promise.all(disposables.splice(0).map((item) => item.dispose()));
129
+ });
130
+
131
+ const load = async () => {
132
+ const commands = await loadCommands(host, {
133
+ declaredCommands: ['acme.hello.hello', 'acme.hello.open', 'acme.hello.data'],
134
+ declaredPanels: ['acme.hello.view'],
135
+ });
136
+ disposables.push(commands);
137
+ return commands;
138
+ };
139
+
140
+ describe('acme.hello: commands', () => {
141
+ it('hello greets the name from the arguments, "world" without them', async () => {
142
+ const commands = await load();
143
+ expect(await commands.run('acme.hello.hello', 'Ada')).toEqual({
144
+ kind: 'notify',
145
+ text: 'Hello, Ada!',
146
+ });
147
+ expect(await commands.run('acme.hello.hello')).toEqual({
148
+ kind: 'notify',
149
+ text: 'Hello, world!',
150
+ });
151
+ });
152
+
153
+ it('open asks the app to open the panel with properties', async () => {
154
+ const commands = await load();
155
+ expect(await commands.run('acme.hello.open')).toEqual({
156
+ kind: 'openPanel',
157
+ panelId: 'acme.hello.view',
158
+ props: { name: 'Dolphy' },
159
+ });
160
+ });
161
+
162
+ it('data returns what the panel shows', async () => {
163
+ const commands = await load();
164
+ expect(await commands.run('acme.hello.data')).toEqual({
165
+ kind: 'data',
166
+ value: { message: 'Hello from acme.hello' },
167
+ });
168
+ });
169
+ });
170
+
171
+ describe('acme.hello: panel', () => {
172
+ it('shows the data command reply and follows new properties', async () => {
173
+ const panel = await loadPanel(panels, 'acme.hello.view', {
174
+ props: { name: 'Ada' },
175
+ call: () => ({ message: 'Hello from the test' }),
176
+ });
177
+ disposables.push(panel);
178
+ expect(panel.container.querySelector('h2')?.textContent).toBe('Hello, Ada!');
179
+ expect(panel.container.querySelector('p')?.textContent).toBe(
180
+ 'Hello from the test',
181
+ );
182
+ expect(panel.calls).toEqual([{ commandId: 'acme.hello.data', args: undefined }]);
183
+
184
+ panel.setProps({ name: 'Grace' });
185
+ expect(panel.container.querySelector('h2')?.textContent).toBe(
186
+ 'Hello, Grace!',
187
+ );
188
+ });
189
+
190
+ it('stops listening for properties when the panel closes', async () => {
191
+ const panel = await loadPanel(panels, 'acme.hello.view', {
192
+ call: () => ({ message: 'x' }),
193
+ });
194
+ const heading = panel.container.querySelector('h2');
195
+ panel.dispose();
196
+ expect(panel.aborted).toBe(true);
197
+ panel.setProps({ name: 'Late' });
198
+ expect(heading?.textContent).toBe('Hello, world!');
199
+ });
200
+ });
201
+ ```
202
+
203
+ `loadCommands` runs commands as the host does, with the same rules for results.
204
+ `loadPanel` mounts a panel with a context like the frame's; its `call` option
205
+ answers `ctx.call`, and here it is wired to the real handlers so the panel is
206
+ tested against the command it depends on.
207
+
208
+ ## Try and ship
209
+
210
+ Run `pnpm dev`, start the app with `DOLPHY_DEV_EXTENSIONS`, open the palette and
211
+ run "Open the hello panel".
@@ -0,0 +1,266 @@
1
+ # Recipe: events and storage
2
+
3
+ React to what the learner does and remember it. This recipe is the `events`
4
+ template
5
+ (`npx @dolphy-app/create-extension <dir> --id acme.hello --template events`): a
6
+ day streak counter that listens to closed attempts, keeps the streak in storage
7
+ and shows it in a panel. The files below are exactly what the generator writes
8
+ for the id `acme.hello`. See [quick-start.md](quick-start.md) for the commands.
9
+
10
+ ## The manifest
11
+
12
+ File `extension.json` (events):
13
+
14
+ ```json
15
+ {
16
+ "$schema": "./node_modules/@dolphy-app/extension-api/dist/extension.schema.json",
17
+ "id": "acme.hello",
18
+ "version": "0.1.0",
19
+ "apiVersion": 1,
20
+ "name": "Day streak",
21
+ "description": "Counts the days in a row with a closed attempt and shows the streak.",
22
+ "author": "your-github-login",
23
+ "permissions": ["learning.events"],
24
+ "tags": ["learning"],
25
+ "contributes": {
26
+ "events": [{ "event": "attempt.closed" }],
27
+ "commands": [
28
+ { "id": "acme.hello.show", "title": "Show the streak", "category": "Streak" },
29
+ { "id": "acme.hello.data", "title": "Streak data", "palette": false }
30
+ ],
31
+ "panels": [{ "id": "acme.hello.view", "title": "Streak" }]
32
+ }
33
+ }
34
+ ```
35
+
36
+ - `permissions: ["learning.events"]` and `contributes.events` are both required
37
+ to receive an event. The events are `session.started`, `session.finished` and
38
+ `attempt.closed`. The payloads carry ids, the grade, the outcome and the time,
39
+ never the learner's answer or the exercise text.
40
+ - Mention the permission in your `README.md` and say what it is for: the catalog
41
+ review asks.
42
+ - Storage and settings need no permission.
43
+
44
+ ## The code
45
+
46
+ File `src/index.ts` (events):
47
+
48
+ ```ts
49
+ import {
50
+ defineExtension,
51
+ defineExtensionPanel,
52
+ inActivate,
53
+ notify,
54
+ openPanel,
55
+ } from '@dolphy-app/extension-sdk';
56
+ import type { ExtensionPanels } from '@dolphy-app/extension-sdk';
57
+
58
+ // a `type`, not an `interface`: an interface has no index signature and is
59
+ // not JSON for `ctx.storage`
60
+ export type Streak = {
61
+ days: number;
62
+ last: string;
63
+ };
64
+
65
+ const KEY = 'streak';
66
+ const DAY_MS = 86_400_000;
67
+
68
+ const dayOf = (at: number): string => new Date(at).toISOString().slice(0, 10);
69
+
70
+ // the streak grows when an attempt is closed the day after the last one;
71
+ // the same day changes nothing, a skipped day starts over
72
+ export const advance = (streak: Streak | undefined, at: number): Streak => {
73
+ const day = dayOf(at);
74
+ if (streak?.last === day) return streak;
75
+ const continues =
76
+ streak !== undefined && dayOf(Date.parse(streak.last) + DAY_MS) === day;
77
+ return { days: continues ? streak.days + 1 : 1, last: day };
78
+ };
79
+
80
+ // every event and command declared in extension.json is listed here;
81
+ // `inActivate` means "registered in `activate`": the handlers need `ctx`
82
+ export const host = defineExtension({
83
+ events: { 'attempt.closed': inActivate },
84
+ commands: { 'acme.hello.show': inActivate, 'acme.hello.data': inActivate },
85
+ activate(ctx) {
86
+ // delivered asynchronously, once per recorded attempt
87
+ ctx.events.on('attempt.closed', async ({ at, outcome }) => {
88
+ if (outcome === 'gave-up') return;
89
+ const streak = await ctx.storage.get<Streak>(KEY);
90
+ await ctx.storage.set(KEY, advance(streak, at));
91
+ });
92
+
93
+ // data for the panel: hidden from the palette, the panel calls it
94
+ ctx.commands.register(
95
+ 'acme.hello.data',
96
+ async () => (await ctx.storage.get<Streak>(KEY)) ?? { days: 0, last: '' },
97
+ );
98
+
99
+ ctx.commands.register('acme.hello.show', async () => {
100
+ const streak = await ctx.storage.get<Streak>(KEY);
101
+ if (streak === undefined) {
102
+ return notify('No streak yet: finish your first exercise.');
103
+ }
104
+ return openPanel('acme.hello.view', { days: streak.days });
105
+ });
106
+ },
107
+ });
108
+
109
+ // the panel runs in an isolated frame: no network, the only way out is `ctx.call`
110
+ export const panels = {
111
+ 'acme.hello.view': defineExtensionPanel({
112
+ async mount(container, ctx) {
113
+ const line = container.ownerDocument.createElement('p');
114
+ container.append(line);
115
+ const render = async () => {
116
+ const streak = (await ctx.call('acme.hello.data')) as Streak;
117
+ line.textContent =
118
+ streak.days === 0
119
+ ? 'No streak yet.'
120
+ : `Streak: ${streak.days} days, last day ${streak.last}`;
121
+ };
122
+ // the command opens the panel again with new properties: redraw
123
+ ctx.signal.addEventListener(
124
+ 'abort',
125
+ ctx.onProps(() => void render()),
126
+ );
127
+ await render();
128
+ },
129
+ }),
130
+ } satisfies ExtensionPanels;
131
+ ```
132
+
133
+ - `ctx.events.on(name, handler)`: one handler per event. Delivery is
134
+ asynchronous, in order and at most once; a handler gets 2 seconds, the queue
135
+ holds 100 events per extension, and a failing handler only reaches the log.
136
+ - `ctx.storage` is JSON under string keys, private to the extension, kept across
137
+ restarts and updates. The value must be JSON: that is why `Streak` is a
138
+ `type`, not an `interface`. Ceilings: key 128 characters, value 64 KiB, 256
139
+ keys, 1 MiB in total; exceeding one throws `StorageQuotaError`.
140
+ - `advance` is a pure function exported for the test. The hidden command
141
+ `acme.hello.data` is how the panel reads the stored value.
142
+
143
+ ## The tests
144
+
145
+ File `test/index.test.ts` (events):
146
+
147
+ ```ts
148
+ // @vitest-environment happy-dom
149
+ import type { LearningEventPayloads } from '@dolphy-app/extension-api';
150
+ import {
151
+ createMemoryStorage,
152
+ loadCommands,
153
+ loadEvents,
154
+ loadPanel,
155
+ } from '@dolphy-app/extension-sdk/testing';
156
+ import { afterEach, describe, expect, it } from 'vitest';
157
+ import { host, panels } from '../src/index.ts';
158
+
159
+ const disposables: { dispose(): unknown }[] = [];
160
+ afterEach(async () => {
161
+ await Promise.all(disposables.splice(0).map((item) => item.dispose()));
162
+ });
163
+
164
+ type Attempt = LearningEventPayloads['attempt.closed'];
165
+
166
+ const attempt = (day: string, outcome: Attempt['outcome'] = 'passed'): Attempt => ({
167
+ exerciseId: 'e',
168
+ courseId: 'c',
169
+ lessonId: 'l',
170
+ grade: 4,
171
+ outcome,
172
+ source: 'runner',
173
+ at: Date.parse(`${day}T12:00:00Z`),
174
+ });
175
+
176
+ const load = async () => {
177
+ const storage = createMemoryStorage();
178
+ const events = await loadEvents(host, {
179
+ storage,
180
+ declared: ['attempt.closed'],
181
+ });
182
+ const commands = await loadCommands(host, {
183
+ storage,
184
+ declaredCommands: ['acme.hello.show', 'acme.hello.data'],
185
+ declaredPanels: ['acme.hello.view'],
186
+ });
187
+ disposables.push(events, commands);
188
+ return { storage, events, commands };
189
+ };
190
+
191
+ describe('acme.hello: events and storage', () => {
192
+ it('counts consecutive days, ignores a repeat on the same day', async () => {
193
+ const { storage, events } = await load();
194
+ await events.emit('attempt.closed', attempt('2026-10-01'));
195
+ await events.emit('attempt.closed', attempt('2026-10-01'));
196
+ await events.emit('attempt.closed', attempt('2026-10-02'));
197
+ expect(await storage.get('streak')).toEqual({
198
+ days: 2,
199
+ last: '2026-10-02',
200
+ });
201
+ });
202
+
203
+ it('a skipped day starts over; giving up leaves the streak alone', async () => {
204
+ const { storage, events } = await load();
205
+ await events.emit('attempt.closed', attempt('2026-10-01'));
206
+ await events.emit('attempt.closed', attempt('2026-10-02'));
207
+ await events.emit('attempt.closed', attempt('2026-10-03', 'gave-up'));
208
+ expect(await storage.get('streak')).toEqual({
209
+ days: 2,
210
+ last: '2026-10-02',
211
+ });
212
+ await events.emit('attempt.closed', attempt('2026-10-05'));
213
+ expect(await storage.get('streak')).toEqual({
214
+ days: 1,
215
+ last: '2026-10-05',
216
+ });
217
+ });
218
+ });
219
+
220
+ describe('acme.hello: commands and panel', () => {
221
+ it('without a streak the show command notifies, the data command returns zeros', async () => {
222
+ const { commands } = await load();
223
+ expect(await commands.run('acme.hello.show')).toMatchObject({ kind: 'notify' });
224
+ expect(await commands.run('acme.hello.data')).toEqual({
225
+ kind: 'data',
226
+ value: { days: 0, last: '' },
227
+ });
228
+ });
229
+
230
+ it('with a streak the show command opens the panel with the days', async () => {
231
+ const { events, commands } = await load();
232
+ await events.emit('attempt.closed', attempt('2026-10-01'));
233
+ expect(await commands.run('acme.hello.show')).toEqual({
234
+ kind: 'openPanel',
235
+ panelId: 'acme.hello.view',
236
+ props: { days: 1 },
237
+ });
238
+ });
239
+
240
+ it('the panel shows what the data command returns', async () => {
241
+ const { events, commands } = await load();
242
+ await events.emit('attempt.closed', attempt('2026-10-01'));
243
+ const panel = await loadPanel(panels, 'acme.hello.view', {
244
+ call: async (commandId) => {
245
+ const result = await commands.run(commandId);
246
+ return result.kind === 'data' ? result.value : undefined;
247
+ },
248
+ });
249
+ disposables.push(panel);
250
+ expect(panel.container.querySelector('p')?.textContent).toBe(
251
+ 'Streak: 1 days, last day 2026-10-01',
252
+ );
253
+ });
254
+ });
255
+ ```
256
+
257
+ `loadEvents` activates the extension and gives `emit(name, payload)`, so the test
258
+ sends events the way the host delivers them. `createMemoryStorage` has the same
259
+ ceilings as the app. Pass the same `storage` to `loadEvents` and `loadCommands`
260
+ and both see one state. Unlike the host, the helpers do not swallow a handler's
261
+ failure and do not count the 2 seconds.
262
+
263
+ ## Try and ship
264
+
265
+ Run `pnpm dev`, start the app with `DOLPHY_DEV_EXTENSIONS`, close an attempt in
266
+ a lesson, then run "Show the streak" from the palette.