@dolphy-app/extension-sdk 0.4.0 → 0.6.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/README.md +28 -14
- package/dist/client.d.ts +40 -0
- package/dist/client.js +67 -0
- package/dist/define-entry-BTz2F3qv.js +22 -0
- package/dist/define-entry-lsuxzKdD.d.ts +77 -0
- package/dist/index-I_CXC6Lj.d.ts +1958 -0
- package/dist/index.d.ts +7 -111
- package/dist/index.js +5 -86
- package/dist/react.d.ts +51 -0
- package/dist/react.js +112 -0
- package/dist/rpc.d.ts +28 -0
- package/dist/rpc.js +37 -0
- package/dist/testing.d.ts +184 -241
- package/dist/testing.js +574 -464
- package/docs/debugging.md +82 -64
- package/docs/no-build.md +84 -48
- package/docs/quick-start.md +78 -56
- package/docs/recipe-command-panel.md +265 -118
- package/docs/recipe-event-storage.md +188 -128
- package/docs/recipe-exercise-type.md +271 -183
- package/docs/recipe-hooks.md +158 -0
- package/docs/recipe-import-export.md +48 -53
- package/docs/recipe-mountable.md +322 -0
- package/docs/recipe-react.md +352 -0
- package/docs/recipe-rpc-and-app.md +366 -0
- package/docs/recipe-settings.md +122 -100
- package/docs/recipe-theme.md +72 -40
- package/docs/recipe-when-dependencies.md +188 -82
- package/package.json +29 -7
- package/dist/answer-element-BOQcuxYh.js +0 -114
- package/dist/answer-view-D6wnyThb.d.ts +0 -28
- package/dist/runtime.d.ts +0 -17
- package/dist/runtime.js +0 -24
- package/docs/recipe-ui-kit.md +0 -172
|
@@ -20,49 +20,31 @@ File `extension.json` (events):
|
|
|
20
20
|
"name": "Day streak",
|
|
21
21
|
"description": "Counts the days in a row with a closed attempt and shows the streak.",
|
|
22
22
|
"author": "your-github-login",
|
|
23
|
-
"
|
|
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
|
-
}
|
|
23
|
+
"tags": ["learning"]
|
|
33
24
|
}
|
|
34
25
|
```
|
|
35
26
|
|
|
36
|
-
|
|
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.
|
|
27
|
+
Nothing about events is in the manifest: `server.on` is the subscription.
|
|
43
28
|
|
|
44
|
-
## The
|
|
29
|
+
## The server part
|
|
45
30
|
|
|
46
31
|
File `src/index.ts` (events):
|
|
47
32
|
|
|
48
33
|
```ts
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
inActivate,
|
|
53
|
-
notify,
|
|
54
|
-
openPanel,
|
|
55
|
-
} from '@dolphy-app/extension-sdk';
|
|
56
|
-
import type { ExtensionPanels } from '@dolphy-app/extension-sdk';
|
|
34
|
+
export { client } from './client.ts';
|
|
35
|
+
export { server } from './server.ts';
|
|
36
|
+
```
|
|
57
37
|
|
|
38
|
+
File `src/streak.ts` (events):
|
|
39
|
+
|
|
40
|
+
```ts
|
|
58
41
|
// a `type`, not an `interface`: an interface has no index signature and is
|
|
59
|
-
// not JSON for `
|
|
42
|
+
// not JSON for `server.storage`
|
|
60
43
|
export type Streak = {
|
|
61
44
|
days: number;
|
|
62
45
|
last: string;
|
|
63
46
|
};
|
|
64
47
|
|
|
65
|
-
const KEY = 'streak';
|
|
66
48
|
const DAY_MS = 86_400_000;
|
|
67
49
|
|
|
68
50
|
const dayOf = (at: number): string => new Date(at).toISOString().slice(0, 10);
|
|
@@ -76,69 +58,111 @@ export const advance = (streak: Streak | undefined, at: number): Streak => {
|
|
|
76
58
|
streak !== undefined && dayOf(Date.parse(streak.last) + DAY_MS) === day;
|
|
77
59
|
return { days: continues ? streak.days + 1 : 1, last: day };
|
|
78
60
|
};
|
|
61
|
+
```
|
|
79
62
|
|
|
80
|
-
|
|
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
|
-
});
|
|
63
|
+
File `src/server.ts` (events):
|
|
92
64
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
65
|
+
```ts
|
|
66
|
+
import { defineServer, notify, openPanel } from '@dolphy-app/extension-sdk';
|
|
67
|
+
import { advance } from './streak.ts';
|
|
68
|
+
import type { Streak } from './streak.ts';
|
|
69
|
+
|
|
70
|
+
const KEY = 'streak';
|
|
98
71
|
|
|
99
|
-
|
|
100
|
-
|
|
72
|
+
// runs in the extension host: every call registers a contribution
|
|
73
|
+
export const server = defineServer((s) => {
|
|
74
|
+
// delivered asynchronously, once per recorded attempt
|
|
75
|
+
s.on('attempt.closed', async ({ at, outcome }) => {
|
|
76
|
+
if (outcome === 'gave-up') return;
|
|
77
|
+
const streak = await s.storage.get<Streak>(KEY);
|
|
78
|
+
await s.storage.set(KEY, advance(streak, at));
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
// data for the panel: hidden from the palette, the panel calls it
|
|
82
|
+
s.registerCommand({
|
|
83
|
+
id: 'acme.hello.data',
|
|
84
|
+
title: 'Streak data',
|
|
85
|
+
palette: false,
|
|
86
|
+
run: async () =>
|
|
87
|
+
(await s.storage.get<Streak>(KEY)) ?? { days: 0, last: '' },
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
s.registerCommand({
|
|
91
|
+
id: 'acme.hello.show',
|
|
92
|
+
title: { en: 'Show the streak', ru: 'Показать серию' },
|
|
93
|
+
category: 'Streak',
|
|
94
|
+
run: async () => {
|
|
95
|
+
const streak = await s.storage.get<Streak>(KEY);
|
|
101
96
|
if (streak === undefined) {
|
|
102
97
|
return notify('No streak yet: finish your first exercise.');
|
|
103
98
|
}
|
|
104
99
|
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
100
|
},
|
|
129
|
-
})
|
|
130
|
-
}
|
|
101
|
+
});
|
|
102
|
+
});
|
|
131
103
|
```
|
|
132
104
|
|
|
133
|
-
- `
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
105
|
+
- `server.on(name, handler)` subscribes to a learning event: `session.started`,
|
|
106
|
+
`session.finished` or `attempt.closed`. The payloads carry ids, the grade, the
|
|
107
|
+
outcome and the time, never the learner's answer or the exercise text. One
|
|
108
|
+
handler per event. Delivery is asynchronous, in order and at most once; a
|
|
109
|
+
handler gets 2 seconds, the queue holds 100 events per extension, and a
|
|
110
|
+
failing handler only reaches the log.
|
|
111
|
+
- `server.storage` is JSON under string keys, private to the extension, kept
|
|
112
|
+
across restarts and updates. The value must be JSON: that is why `Streak` is a
|
|
138
113
|
`type`, not an `interface`. Ceilings: key 128 characters, value 64 KiB, 256
|
|
139
114
|
keys, 1 MiB in total; exceeding one throws `StorageQuotaError`.
|
|
140
|
-
- `advance` is a pure function
|
|
141
|
-
`acme.hello.data` is how the panel reads the stored value.
|
|
115
|
+
- `advance` is a pure function in its own file, so the test can import it. The
|
|
116
|
+
hidden command `acme.hello.data` is how the panel reads the stored value.
|
|
117
|
+
|
|
118
|
+
## The client part
|
|
119
|
+
|
|
120
|
+
File `src/client.ts` (events):
|
|
121
|
+
|
|
122
|
+
```ts
|
|
123
|
+
import { defineClient } from '@dolphy-app/extension-sdk';
|
|
124
|
+
import { StreakPanel } from './streak-panel.ts';
|
|
125
|
+
|
|
126
|
+
// runs in the app window: the panel is a Vue component the app draws
|
|
127
|
+
export const client = defineClient((c) => {
|
|
128
|
+
c.addPanel({
|
|
129
|
+
id: 'acme.hello.view',
|
|
130
|
+
title: { en: 'Streak', ru: 'Серия' },
|
|
131
|
+
component: StreakPanel,
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
File `src/streak-panel.ts` (events):
|
|
137
|
+
|
|
138
|
+
```ts
|
|
139
|
+
import { usePanel } from '@dolphy-app/extension-sdk/client';
|
|
140
|
+
import { defineComponent, h, ref, watchEffect } from 'vue';
|
|
141
|
+
import type { Streak } from './streak.ts';
|
|
142
|
+
|
|
143
|
+
// the only way to the data is `panel.call` to the commands of the server
|
|
144
|
+
export const StreakPanel = defineComponent({
|
|
145
|
+
setup() {
|
|
146
|
+
const panel = usePanel();
|
|
147
|
+
const text = ref('');
|
|
148
|
+
// the command opens the panel again with new properties: ask again
|
|
149
|
+
watchEffect(async () => {
|
|
150
|
+
void panel.props;
|
|
151
|
+
const streak = (await panel.call('acme.hello.data')) as Streak;
|
|
152
|
+
text.value =
|
|
153
|
+
streak.days === 0
|
|
154
|
+
? 'No streak yet.'
|
|
155
|
+
: `Streak: ${streak.days} days, last day ${streak.last}`;
|
|
156
|
+
});
|
|
157
|
+
return () => h('p', text.value);
|
|
158
|
+
},
|
|
159
|
+
});
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
- The panel is a Vue component registered with `client.addPanel`: `usePanel()`
|
|
163
|
+
gives it `call` for the commands of the server part and the reactive `props`.
|
|
164
|
+
Reading `panel.props` inside `watchEffect` makes the panel ask again when the
|
|
165
|
+
`show` command opens it with new properties.
|
|
142
166
|
|
|
143
167
|
## The tests
|
|
144
168
|
|
|
@@ -146,15 +170,20 @@ File `test/index.test.ts` (events):
|
|
|
146
170
|
|
|
147
171
|
```ts
|
|
148
172
|
// @vitest-environment happy-dom
|
|
149
|
-
import
|
|
173
|
+
import { PANEL_HANDLE_KEY } from '@dolphy-app/extension-sdk';
|
|
174
|
+
import type {
|
|
175
|
+
JsonValue,
|
|
176
|
+
LearningEventPayloads,
|
|
177
|
+
PanelHandle,
|
|
178
|
+
} from '@dolphy-app/extension-sdk';
|
|
150
179
|
import {
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
loadEvents,
|
|
154
|
-
loadPanel,
|
|
180
|
+
createTestClient,
|
|
181
|
+
createTestServer,
|
|
155
182
|
} from '@dolphy-app/extension-sdk/testing';
|
|
156
183
|
import { afterEach, describe, expect, it } from 'vitest';
|
|
157
|
-
import {
|
|
184
|
+
import { createApp, h, nextTick, shallowReactive } from 'vue';
|
|
185
|
+
import { client, server } from '../src/index.ts';
|
|
186
|
+
import { StreakPanel } from '../src/streak-panel.ts';
|
|
158
187
|
|
|
159
188
|
const disposables: { dispose(): unknown }[] = [];
|
|
160
189
|
afterEach(async () => {
|
|
@@ -173,44 +202,65 @@ const attempt = (day: string, outcome: Attempt['outcome'] = 'passed'): Attempt =
|
|
|
173
202
|
at: Date.parse(`${day}T12:00:00Z`),
|
|
174
203
|
});
|
|
175
204
|
|
|
176
|
-
const
|
|
177
|
-
const
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
205
|
+
const start = async () => {
|
|
206
|
+
const running = await createTestServer(server, { extensionId: 'acme.hello' });
|
|
207
|
+
disposables.push(running);
|
|
208
|
+
return running;
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
// draws the panel the way the app does: the handle is provided to the component
|
|
212
|
+
const mountPanel = async (call: PanelHandle['call']) => {
|
|
213
|
+
const handle = shallowReactive({
|
|
214
|
+
panelId: 'acme.hello.view',
|
|
215
|
+
props: undefined as JsonValue | undefined,
|
|
216
|
+
context: { courseId: null },
|
|
217
|
+
call,
|
|
181
218
|
});
|
|
182
|
-
const
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
219
|
+
const host = document.createElement('div');
|
|
220
|
+
document.body.append(host);
|
|
221
|
+
const app = createApp({ render: () => h(StreakPanel) });
|
|
222
|
+
app.provide(PANEL_HANDLE_KEY, handle);
|
|
223
|
+
app.mount(host);
|
|
224
|
+
disposables.push({
|
|
225
|
+
dispose: () => {
|
|
226
|
+
app.unmount();
|
|
227
|
+
host.remove();
|
|
228
|
+
},
|
|
186
229
|
});
|
|
187
|
-
|
|
188
|
-
|
|
230
|
+
// the panel asks a command: wait for the reply, then for the redraw
|
|
231
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
232
|
+
await nextTick();
|
|
233
|
+
return host;
|
|
189
234
|
};
|
|
190
235
|
|
|
191
236
|
describe('acme.hello: events and storage', () => {
|
|
237
|
+
it('subscribes to attempt.closed', async () => {
|
|
238
|
+
const running = await start();
|
|
239
|
+
expect(running.registration.events).toEqual(['attempt.closed']);
|
|
240
|
+
});
|
|
241
|
+
|
|
192
242
|
it('counts consecutive days, ignores a repeat on the same day', async () => {
|
|
193
|
-
const
|
|
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({
|
|
243
|
+
const running = await start();
|
|
244
|
+
await running.events.emit('attempt.closed', attempt('2026-10-01'));
|
|
245
|
+
await running.events.emit('attempt.closed', attempt('2026-10-01'));
|
|
246
|
+
await running.events.emit('attempt.closed', attempt('2026-10-02'));
|
|
247
|
+
expect(await running.storage.get('streak')).toEqual({
|
|
198
248
|
days: 2,
|
|
199
249
|
last: '2026-10-02',
|
|
200
250
|
});
|
|
201
251
|
});
|
|
202
252
|
|
|
203
253
|
it('a skipped day starts over; giving up leaves the streak alone', async () => {
|
|
204
|
-
const
|
|
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({
|
|
254
|
+
const running = await start();
|
|
255
|
+
await running.events.emit('attempt.closed', attempt('2026-10-01'));
|
|
256
|
+
await running.events.emit('attempt.closed', attempt('2026-10-02'));
|
|
257
|
+
await running.events.emit('attempt.closed', attempt('2026-10-03', 'gave-up'));
|
|
258
|
+
expect(await running.storage.get('streak')).toEqual({
|
|
209
259
|
days: 2,
|
|
210
260
|
last: '2026-10-02',
|
|
211
261
|
});
|
|
212
|
-
await events.emit('attempt.closed', attempt('2026-10-05'));
|
|
213
|
-
expect(await storage.get('streak')).toEqual({
|
|
262
|
+
await running.events.emit('attempt.closed', attempt('2026-10-05'));
|
|
263
|
+
expect(await running.storage.get('streak')).toEqual({
|
|
214
264
|
days: 1,
|
|
215
265
|
last: '2026-10-05',
|
|
216
266
|
});
|
|
@@ -219,46 +269,56 @@ describe('acme.hello: events and storage', () => {
|
|
|
219
269
|
|
|
220
270
|
describe('acme.hello: commands and panel', () => {
|
|
221
271
|
it('without a streak the show command notifies, the data command returns zeros', async () => {
|
|
222
|
-
const
|
|
223
|
-
expect(await commands.run('acme.hello.show')).toMatchObject({
|
|
224
|
-
|
|
272
|
+
const running = await start();
|
|
273
|
+
expect(await running.commands.run('acme.hello.show')).toMatchObject({
|
|
274
|
+
kind: 'notify',
|
|
275
|
+
});
|
|
276
|
+
expect(await running.commands.run('acme.hello.data')).toEqual({
|
|
225
277
|
kind: 'data',
|
|
226
278
|
value: { days: 0, last: '' },
|
|
227
279
|
});
|
|
228
280
|
});
|
|
229
281
|
|
|
230
282
|
it('with a streak the show command opens the panel with the days', async () => {
|
|
231
|
-
const
|
|
232
|
-
await events.emit('attempt.closed', attempt('2026-10-01'));
|
|
233
|
-
expect(await commands.run('acme.hello.show')).toEqual({
|
|
283
|
+
const running = await start();
|
|
284
|
+
await running.events.emit('attempt.closed', attempt('2026-10-01'));
|
|
285
|
+
expect(await running.commands.run('acme.hello.show')).toEqual({
|
|
234
286
|
kind: 'openPanel',
|
|
235
287
|
panelId: 'acme.hello.view',
|
|
236
288
|
props: { days: 1 },
|
|
237
289
|
});
|
|
238
290
|
});
|
|
239
291
|
|
|
292
|
+
it('the client adds the panel the show command opens', async () => {
|
|
293
|
+
const running = await createTestClient(client, { extensionId: 'acme.hello' });
|
|
294
|
+
disposables.push(running);
|
|
295
|
+
expect(running.panels.map((panel) => panel.id)).toEqual(['acme.hello.view']);
|
|
296
|
+
});
|
|
297
|
+
|
|
240
298
|
it('the panel shows what the data command returns', async () => {
|
|
241
|
-
const
|
|
242
|
-
await events.emit('attempt.closed', attempt('2026-10-01'));
|
|
243
|
-
const panel = await
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
return result.kind === 'data' ? result.value : undefined;
|
|
247
|
-
},
|
|
299
|
+
const running = await start();
|
|
300
|
+
await running.events.emit('attempt.closed', attempt('2026-10-01'));
|
|
301
|
+
const panel = await mountPanel(async (commandId) => {
|
|
302
|
+
const result = await running.commands.run(commandId);
|
|
303
|
+
return result.kind === 'data' ? (result.value as JsonValue) : undefined;
|
|
248
304
|
});
|
|
249
|
-
|
|
250
|
-
expect(panel.container.querySelector('p')?.textContent).toBe(
|
|
305
|
+
expect(panel.querySelector('p')?.textContent).toBe(
|
|
251
306
|
'Streak: 1 days, last day 2026-10-01',
|
|
252
307
|
);
|
|
253
308
|
});
|
|
254
309
|
});
|
|
255
310
|
```
|
|
256
311
|
|
|
257
|
-
`
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
312
|
+
`createTestServer(server, { extensionId })` starts `server`, and
|
|
313
|
+
`running.events.emit(name, payload)` delivers an event the way the host does and
|
|
314
|
+
awaits the handler; `running.registration.events` lists the subscriptions.
|
|
315
|
+
`running.storage` is in memory with the same ceilings as the app, and the test
|
|
316
|
+
reads what the handler wrote. Unlike the host, the harness does not swallow a
|
|
317
|
+
handler's failure and does not count the 2 seconds.
|
|
318
|
+
|
|
319
|
+
The panel is mounted with `createApp` in `happy-dom`;
|
|
320
|
+
`app.provide(PANEL_HANDLE_KEY, handle)` gives it a handle whose `call` runs the
|
|
321
|
+
real command handlers of the running server.
|
|
262
322
|
|
|
263
323
|
## Try and ship
|
|
264
324
|
|