@dolphy-app/extension-sdk 0.4.0 → 0.5.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 +262 -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
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
# Recipe: hooks before a session and a batch
|
|
2
|
+
|
|
3
|
+
Step into the learning loop. A `before` hook runs before the engine does
|
|
4
|
+
something and may change its input or cancel it. This recipe reorders the
|
|
5
|
+
exercises of a session so that reviews come before new material and the most
|
|
6
|
+
forgotten come first, and refuses to
|
|
7
|
+
start a session in the small hours. There is no template for it; start from
|
|
8
|
+
`blank` and replace the files below, which are checked as a whole project. See
|
|
9
|
+
[quick-start.md](quick-start.md) for the commands. Events, which only observe,
|
|
10
|
+
are in [recipe-event-storage.md](recipe-event-storage.md).
|
|
11
|
+
|
|
12
|
+
## The manifest
|
|
13
|
+
|
|
14
|
+
File `extension.json` (hooks):
|
|
15
|
+
|
|
16
|
+
```json
|
|
17
|
+
{
|
|
18
|
+
"$schema": "./node_modules/@dolphy-app/extension-api/dist/extension.schema.json",
|
|
19
|
+
"id": "acme.hello",
|
|
20
|
+
"version": "0.1.0",
|
|
21
|
+
"apiVersion": 1,
|
|
22
|
+
"name": "Reviews first",
|
|
23
|
+
"description": "Puts reviews before new exercises and keeps sessions out of the small hours.",
|
|
24
|
+
"author": "your-github-login",
|
|
25
|
+
"tags": ["learning"]
|
|
26
|
+
}
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Nothing about hooks is in the manifest: `server.before` is the registration.
|
|
30
|
+
|
|
31
|
+
## The code
|
|
32
|
+
|
|
33
|
+
File `src/index.ts` (hooks):
|
|
34
|
+
|
|
35
|
+
```ts
|
|
36
|
+
import { defineServer } from '@dolphy-app/extension-sdk';
|
|
37
|
+
|
|
38
|
+
const REVIEW_FIRST = { review: 0, remediation: 1, new: 2 } as const;
|
|
39
|
+
|
|
40
|
+
export const server = defineServer((s) => {
|
|
41
|
+
// may only cancel: throw to stop the session from starting
|
|
42
|
+
s.before('session.start', ({ now }) => {
|
|
43
|
+
if (new Date(now).getHours() < 6) {
|
|
44
|
+
throw new Error('Sessions are closed until six in the morning');
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
// gets the batch the engine built and returns the one to use
|
|
49
|
+
// reviews before new material, the most forgotten first within a group
|
|
50
|
+
s.before('practice.batch', ({ exerciseIds, reasons, memory }) => {
|
|
51
|
+
const items = exerciseIds
|
|
52
|
+
.map((id, index) => ({
|
|
53
|
+
id,
|
|
54
|
+
reason: reasons[index] ?? 'new',
|
|
55
|
+
// no attempts yet: nothing to forget
|
|
56
|
+
retrievability: memory[index]?.retrievability ?? 1,
|
|
57
|
+
}))
|
|
58
|
+
.sort(
|
|
59
|
+
(a, b) =>
|
|
60
|
+
REVIEW_FIRST[a.reason] - REVIEW_FIRST[b.reason] ||
|
|
61
|
+
a.retrievability - b.retrievability,
|
|
62
|
+
);
|
|
63
|
+
return {
|
|
64
|
+
exerciseIds: items.map(({ id }) => id),
|
|
65
|
+
reasons: items.map(({ reason }) => reason),
|
|
66
|
+
};
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
- `server.before(name, handler)` registers a hook. The names are a closed list:
|
|
72
|
+
`session.start` and `practice.batch`; another name fails the registration.
|
|
73
|
+
One handler per name; the call returns a `Disposable`.
|
|
74
|
+
- `session.start` gets `{ now }` (epoch milliseconds) and returns nothing. The
|
|
75
|
+
only way to act is to throw: the session is not created and the learner sees
|
|
76
|
+
the message of your error.
|
|
77
|
+
- `practice.batch` gets `{ sessionId, exerciseIds, reasons, memory, source }`
|
|
78
|
+
(`sessionId` is `null` while the session does not exist yet, as in the daily
|
|
79
|
+
plan before it starts).
|
|
80
|
+
`reasons[i]` (`review`, `new` or `remediation`) belongs to `exerciseIds[i]`;
|
|
81
|
+
`source` is `batch` for `practice.getBatch` and `plan` for the daily plan the
|
|
82
|
+
window builds a session from. `memory[i]` is what the engine remembers of
|
|
83
|
+
`exerciseIds[i]`, read-only: `retrievability` (0..1 chance to recall now),
|
|
84
|
+
`lastAttemptAt` (epoch milliseconds), `attempts`, and the FSRS `stability`
|
|
85
|
+
(days) and `difficulty`. An exercise with no attempts has `attempts: 0` and
|
|
86
|
+
`null` in the other fields. Return `{ exerciseIds, reasons }` of the same
|
|
87
|
+
length. You may reorder, drop and add exercises; every id must exist in the
|
|
88
|
+
library, at most 500 ids.
|
|
89
|
+
- Extensions with a hook run one after another in the order of their ids; each
|
|
90
|
+
gets the result of the previous one. A handler has 30 seconds.
|
|
91
|
+
- An error, a timeout or an answer the engine rejects (a missing exercise,
|
|
92
|
+
unequal lengths, too many ids) cancels the whole operation: no session, no
|
|
93
|
+
batch, nothing written to the journal. The caller gets the engine error
|
|
94
|
+
`EXTENSION_HOOK_FAILED` with the extension id and your message, and the window
|
|
95
|
+
shows it like any other failure to start a session.
|
|
96
|
+
|
|
97
|
+
## The test
|
|
98
|
+
|
|
99
|
+
File `test/index.test.ts` (hooks):
|
|
100
|
+
|
|
101
|
+
```ts
|
|
102
|
+
import { createTestServer } from '@dolphy-app/extension-sdk/testing';
|
|
103
|
+
import { expect, it } from 'vitest';
|
|
104
|
+
import { server } from '../src/index.ts';
|
|
105
|
+
|
|
106
|
+
const start = () => createTestServer(server, { extensionId: 'acme.hello' });
|
|
107
|
+
|
|
108
|
+
it('puts reviews before new exercises', async () => {
|
|
109
|
+
const running = await start();
|
|
110
|
+
const batch = await running.hook('practice.batch', {
|
|
111
|
+
sessionId: 's1',
|
|
112
|
+
exerciseIds: ['c::l::a', 'c::l::b', 'c::l::c'],
|
|
113
|
+
reasons: ['new', 'review', 'remediation'],
|
|
114
|
+
memory: [
|
|
115
|
+
{ retrievability: null, lastAttemptAt: null, attempts: 0, stability: null, difficulty: null },
|
|
116
|
+
{ retrievability: 0.8, lastAttemptAt: 1_000, attempts: 3, stability: 9, difficulty: 4 },
|
|
117
|
+
{ retrievability: 0.3, lastAttemptAt: 2_000, attempts: 1, stability: 2, difficulty: 6 },
|
|
118
|
+
],
|
|
119
|
+
source: 'batch',
|
|
120
|
+
});
|
|
121
|
+
expect(batch).toEqual({
|
|
122
|
+
exerciseIds: ['c::l::b', 'c::l::c', 'c::l::a'],
|
|
123
|
+
reasons: ['review', 'remediation', 'new'],
|
|
124
|
+
});
|
|
125
|
+
await running.dispose();
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('puts the most forgotten exercises first within a group', async () => {
|
|
129
|
+
const running = await start();
|
|
130
|
+
const batch = await running.hook('practice.batch', {
|
|
131
|
+
sessionId: 's1',
|
|
132
|
+
exerciseIds: ['c::l::a', 'c::l::b', 'c::l::c'],
|
|
133
|
+
reasons: ['new', 'review', 'review'],
|
|
134
|
+
memory: [
|
|
135
|
+
{ retrievability: null, lastAttemptAt: null, attempts: 0, stability: null, difficulty: null },
|
|
136
|
+
{ retrievability: 0.8, lastAttemptAt: 1_000, attempts: 3, stability: 9, difficulty: 4 },
|
|
137
|
+
{ retrievability: 0.3, lastAttemptAt: 2_000, attempts: 1, stability: 2, difficulty: 6 },
|
|
138
|
+
],
|
|
139
|
+
source: 'batch',
|
|
140
|
+
});
|
|
141
|
+
expect(batch.exerciseIds).toEqual(['c::l::c', 'c::l::b', 'c::l::a']);
|
|
142
|
+
expect(batch.reasons).toEqual(['review', 'review', 'new']);
|
|
143
|
+
await running.dispose();
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('refuses to start a session in the small hours', async () => {
|
|
147
|
+
const running = await start();
|
|
148
|
+
const night = new Date(2026, 9, 7, 3).getTime();
|
|
149
|
+
await expect(running.hook('session.start', { now: night })).rejects.toThrow(
|
|
150
|
+
'Sessions are closed until six in the morning',
|
|
151
|
+
);
|
|
152
|
+
const morning = new Date(2026, 9, 7, 9).getTime();
|
|
153
|
+
await expect(
|
|
154
|
+
running.hook('session.start', { now: morning }),
|
|
155
|
+
).resolves.toBeUndefined();
|
|
156
|
+
await running.dispose();
|
|
157
|
+
});
|
|
158
|
+
```
|
|
@@ -20,34 +20,19 @@ File `extension.json` (import-export):
|
|
|
20
20
|
"name": "Hello cards",
|
|
21
21
|
"description": "Import flashcards from a CSV file and export a course back to CSV.",
|
|
22
22
|
"author": "your-github-login",
|
|
23
|
-
"tags": ["content"]
|
|
24
|
-
"contributes": {
|
|
25
|
-
"importers": [
|
|
26
|
-
{ "id": "acme.hello.import", "title": "Cards from CSV", "accept": [".csv"] }
|
|
27
|
-
],
|
|
28
|
-
"exporters": [
|
|
29
|
-
{ "id": "acme.hello.export", "title": "Course to CSV", "scope": "course" }
|
|
30
|
-
]
|
|
31
|
-
}
|
|
23
|
+
"tags": ["content"]
|
|
32
24
|
}
|
|
33
25
|
```
|
|
34
26
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
(the default, the handler gets the file as a UTF-8 string) or `bytes` (a
|
|
38
|
-
`Uint8Array`). An exporter has `scope`: `course` or `progress`.
|
|
39
|
-
- No permission is needed: the user choosing the file is the consent, and your
|
|
40
|
-
code never sees a path. A `progress` exporter reads `ctx.stats` and needs the
|
|
41
|
-
`learning.stats` permission.
|
|
42
|
-
- The importer appears in the palette as "Import: Cards from CSV", the exporter
|
|
43
|
-
as "Export: Course to CSV", and both have buttons in Settings → Library.
|
|
27
|
+
The manifest holds the identity only; the importer and the exporter are
|
|
28
|
+
registered by the code.
|
|
44
29
|
|
|
45
30
|
## The code
|
|
46
31
|
|
|
47
32
|
File `src/index.ts` (import-export):
|
|
48
33
|
|
|
49
34
|
```ts
|
|
50
|
-
import {
|
|
35
|
+
import { defineServer } from '@dolphy-app/extension-sdk';
|
|
51
36
|
import type {
|
|
52
37
|
CourseExportInput,
|
|
53
38
|
TextImportInput,
|
|
@@ -62,11 +47,15 @@ const courseIdOf = (fileName: string): string =>
|
|
|
62
47
|
.replace(/[^a-z0-9]+/g, '-')
|
|
63
48
|
.replace(/^-|-$/g, '') || 'cards';
|
|
64
49
|
|
|
65
|
-
export const
|
|
66
|
-
|
|
50
|
+
export const server = defineServer((s) => {
|
|
51
|
+
s.registerImporter({
|
|
52
|
+
id: 'acme.hello.import',
|
|
53
|
+
title: { en: 'Cards from CSV', ru: 'Карточки из CSV' },
|
|
54
|
+
accept: ['.csv'],
|
|
55
|
+
input: 'text',
|
|
67
56
|
// one line "front,back" is one flashcard; the paths are relative to the
|
|
68
57
|
// new course directory the app creates
|
|
69
|
-
|
|
58
|
+
run: ({ name, text }: TextImportInput) => {
|
|
70
59
|
const rows = text.split(/\r?\n/).filter((line) => line.trim() !== '');
|
|
71
60
|
if (rows.length === 0) throw new Error('The file has no rows');
|
|
72
61
|
const course = courseIdOf(name);
|
|
@@ -109,11 +98,15 @@ export const host = defineExtension({
|
|
|
109
98
|
});
|
|
110
99
|
return { files };
|
|
111
100
|
},
|
|
112
|
-
}
|
|
113
|
-
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
s.registerExporter({
|
|
104
|
+
id: 'acme.hello.export',
|
|
105
|
+
title: { en: 'Course to CSV', ru: 'Курс в CSV' },
|
|
106
|
+
scope: 'course',
|
|
114
107
|
// the snapshot holds the text files of the chosen course, paths relative
|
|
115
108
|
// to the course directory
|
|
116
|
-
|
|
109
|
+
run: ({ title, files }: CourseExportInput) => {
|
|
117
110
|
const cell = (text: string): string => text.trim().replace(/\s+/g, ' ');
|
|
118
111
|
const fronts = Object.keys(files)
|
|
119
112
|
.filter((path) => path.endsWith('/front.md'))
|
|
@@ -127,10 +120,20 @@ export const host = defineExtension({
|
|
|
127
120
|
text: `${rows.join('\n')}\n`,
|
|
128
121
|
};
|
|
129
122
|
},
|
|
130
|
-
}
|
|
123
|
+
});
|
|
131
124
|
});
|
|
132
125
|
```
|
|
133
126
|
|
|
127
|
+
- `server.registerImporter({ id, title, accept, input, run })`: `title` (up to 60
|
|
128
|
+
characters) is a `LocalizedText`; `accept` is 1–8 unique lower-case file
|
|
129
|
+
extensions such as `.csv`; `input` is `text` (the handler gets the file as a
|
|
130
|
+
UTF-8 string) or `bytes` (a `Uint8Array`). `server.registerExporter({ id,
|
|
131
|
+
title, scope, run })` has a `scope`: `course` or `progress`. At most 8 of each
|
|
132
|
+
per extension.
|
|
133
|
+
- The user choosing the file is the consent, and your code never sees a path. A
|
|
134
|
+
`progress` exporter reads `server.stats`.
|
|
135
|
+
- The importer appears in the palette as "Import: Cards from CSV", the exporter
|
|
136
|
+
as "Export: Course to CSV", and both have buttons in Settings → Library.
|
|
134
137
|
- An importer handler gets `{ name, text }` (or `{ name, bytes }`) and returns
|
|
135
138
|
`{ files: Record<path, text> }`: the files of a new directory in the library
|
|
136
139
|
(`imported/<extension id>-<file name>`). At most 5000 files, 2 MiB each and
|
|
@@ -147,30 +150,24 @@ export const host = defineExtension({
|
|
|
147
150
|
and is at most 120 characters. The app asks the user where to save it.
|
|
148
151
|
- A handler has 30 seconds. Throw an `Error` to refuse: its message reaches the
|
|
149
152
|
user, and nothing is written.
|
|
150
|
-
- A handler that needs `ctx` is registered in `activate` with
|
|
151
|
-
`ctx.importers.register(id, handler)` / `ctx.exporters.register(id, handler)`
|
|
152
|
-
(`inActivate` in the record, as for commands).
|
|
153
153
|
|
|
154
154
|
## The tests
|
|
155
155
|
|
|
156
156
|
File `test/index.test.ts` (import-export):
|
|
157
157
|
|
|
158
158
|
```ts
|
|
159
|
-
import {
|
|
160
|
-
loadExporters,
|
|
161
|
-
loadImporters,
|
|
162
|
-
} from '@dolphy-app/extension-sdk/testing';
|
|
159
|
+
import { createTestServer } from '@dolphy-app/extension-sdk/testing';
|
|
163
160
|
import { describe, expect, it } from 'vitest';
|
|
164
|
-
import {
|
|
161
|
+
import { server } from '../src/index.ts';
|
|
165
162
|
|
|
166
163
|
const CSV = 'hola,hello\nadiós,goodbye\n';
|
|
167
164
|
|
|
165
|
+
const start = () => createTestServer(server, { extensionId: 'acme.hello' });
|
|
166
|
+
|
|
168
167
|
describe('acme.hello: importer', () => {
|
|
169
168
|
it('turns every row into a flashcard of one lesson', async () => {
|
|
170
|
-
const
|
|
171
|
-
|
|
172
|
-
});
|
|
173
|
-
const { files } = await importers.run('acme.hello.import', {
|
|
169
|
+
const running = await start();
|
|
170
|
+
const { files } = await running.importer('acme.hello.import').run({
|
|
174
171
|
name: 'Spanish basics.csv',
|
|
175
172
|
text: CSV,
|
|
176
173
|
});
|
|
@@ -185,26 +182,24 @@ describe('acme.hello: importer', () => {
|
|
|
185
182
|
'spanish-basics/course_manifest.json',
|
|
186
183
|
]);
|
|
187
184
|
expect(files['spanish-basics/cards/c2/front.md']).toBe('adiós\n');
|
|
188
|
-
await
|
|
185
|
+
await running.dispose();
|
|
189
186
|
});
|
|
190
187
|
|
|
191
188
|
it('refuses a row without an answer', async () => {
|
|
192
|
-
const
|
|
193
|
-
declaredImporters: [{ id: 'acme.hello.import' }],
|
|
194
|
-
});
|
|
189
|
+
const running = await start();
|
|
195
190
|
await expect(
|
|
196
|
-
|
|
191
|
+
running
|
|
192
|
+
.importer('acme.hello.import')
|
|
193
|
+
.run({ name: 'x.csv', text: 'hola\n' }),
|
|
197
194
|
).rejects.toThrow('Row 1');
|
|
198
|
-
await
|
|
195
|
+
await running.dispose();
|
|
199
196
|
});
|
|
200
197
|
});
|
|
201
198
|
|
|
202
199
|
describe('acme.hello: exporter', () => {
|
|
203
200
|
it('writes the cards of the snapshot back to CSV', async () => {
|
|
204
|
-
const
|
|
205
|
-
|
|
206
|
-
});
|
|
207
|
-
const result = await exporters.run('acme.hello.export', {
|
|
201
|
+
const running = await start();
|
|
202
|
+
const result = await running.exporter('acme.hello.export').run({
|
|
208
203
|
scope: 'course',
|
|
209
204
|
courseId: 'deck',
|
|
210
205
|
title: 'Deck / Spanish',
|
|
@@ -221,18 +216,18 @@ describe('acme.hello: exporter', () => {
|
|
|
221
216
|
filename: 'Deck - Spanish.csv',
|
|
222
217
|
text: 'hola,hello\nadiós,goodbye\ndiez,ten\n',
|
|
223
218
|
});
|
|
224
|
-
await
|
|
219
|
+
await running.dispose();
|
|
225
220
|
});
|
|
226
221
|
});
|
|
227
222
|
```
|
|
228
223
|
|
|
229
|
-
`
|
|
230
|
-
handler with the rules of the host: the `text`/`bytes` form
|
|
231
|
-
|
|
224
|
+
`running.importer(id).run(input)` and `running.exporter(id).run(input)` run a
|
|
225
|
+
handler with the rules of the host: the `text`/`bytes` form the importer
|
|
226
|
+
declares, the `scope` the exporter declares, the size of the input and the
|
|
232
227
|
shape and limits of the result. A broken result rejects with
|
|
233
228
|
`invalid import result: …` / `invalid export result: …`, so the test fails the
|
|
234
229
|
way the app would refuse it. For a `progress` exporter pass
|
|
235
|
-
`stats: createMemoryStats(…)`.
|
|
230
|
+
`stats: createMemoryStats(…)` to `createTestServer`.
|
|
236
231
|
|
|
237
232
|
## Try and ship
|
|
238
233
|
|
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
# Recipe: a component of any framework
|
|
2
|
+
|
|
3
|
+
The app draws a panel, an injection, an answer view or a markdown block with
|
|
4
|
+
Vue, or with a `Mountable`: an object with `mount(el, ctx)` that draws into the
|
|
5
|
+
element the app gives it and returns the cleanup. Nothing in a `Mountable`
|
|
6
|
+
depends on Vue, so it is the base for Svelte, Solid, Lit or plain DOM. This
|
|
7
|
+
recipe builds a panel on plain DOM, the smallest case, and tests it with
|
|
8
|
+
`mountForTest`. React has a preset built on the same contract:
|
|
9
|
+
[recipe-react.md](recipe-react.md). The command and panel basics are in
|
|
10
|
+
[recipe-command-panel.md](recipe-command-panel.md); the contracts use `zod`, so
|
|
11
|
+
the project lists `zod` in its `dependencies`.
|
|
12
|
+
|
|
13
|
+
## The manifest
|
|
14
|
+
|
|
15
|
+
File `extension.json` (mountable):
|
|
16
|
+
|
|
17
|
+
```json
|
|
18
|
+
{
|
|
19
|
+
"id": "acme.counter",
|
|
20
|
+
"version": "1.0.0",
|
|
21
|
+
"apiVersion": 1
|
|
22
|
+
}
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## The parts
|
|
26
|
+
|
|
27
|
+
File `src/index.ts` (mountable):
|
|
28
|
+
|
|
29
|
+
```ts
|
|
30
|
+
export { client } from './client.ts';
|
|
31
|
+
export { server } from './server.ts';
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
File `src/shared/rpc.ts` (mountable):
|
|
35
|
+
|
|
36
|
+
```ts
|
|
37
|
+
import { defineRpc } from '@dolphy-app/extension-sdk';
|
|
38
|
+
import { z } from 'zod';
|
|
39
|
+
|
|
40
|
+
export const sayHello = defineRpc({
|
|
41
|
+
name: 'greeting.say-hello',
|
|
42
|
+
input: z.object({ name: z.string() }),
|
|
43
|
+
output: z.object({ text: z.string() }),
|
|
44
|
+
});
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
File `src/server.ts` (mountable):
|
|
48
|
+
|
|
49
|
+
```ts
|
|
50
|
+
import { defineServer } from '@dolphy-app/extension-sdk';
|
|
51
|
+
import { sayHello } from './shared/rpc.ts';
|
|
52
|
+
|
|
53
|
+
export const server = defineServer((s) => {
|
|
54
|
+
s.handle(sayHello, async ({ name }) => ({ text: `Hello, ${name}!` }));
|
|
55
|
+
});
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## The component
|
|
59
|
+
|
|
60
|
+
File `src/hello-panel.ts` (mountable):
|
|
61
|
+
|
|
62
|
+
```ts
|
|
63
|
+
import { defineMountable } from '@dolphy-app/extension-sdk';
|
|
64
|
+
import type { PanelHandle, PanelProps } from '@dolphy-app/extension-sdk';
|
|
65
|
+
import { sayHello } from './shared/rpc.ts';
|
|
66
|
+
|
|
67
|
+
// plain DOM: the element is yours, any framework mounts into it the same way
|
|
68
|
+
export const HelloPanel = defineMountable<PanelProps, PanelHandle>(
|
|
69
|
+
(el, ctx) => {
|
|
70
|
+
const title = document.createElement('h2');
|
|
71
|
+
const text = document.createElement('p');
|
|
72
|
+
const button = document.createElement('button');
|
|
73
|
+
button.type = 'button';
|
|
74
|
+
button.textContent = 'Ask the server';
|
|
75
|
+
el.append(title, text, button);
|
|
76
|
+
|
|
77
|
+
const nameOf = ({ props }: PanelProps) =>
|
|
78
|
+
typeof props === 'string' ? props : 'world';
|
|
79
|
+
const draw = (props: PanelProps) => {
|
|
80
|
+
title.textContent = `Hello, ${nameOf(props)}!`;
|
|
81
|
+
};
|
|
82
|
+
draw(ctx.props);
|
|
83
|
+
// the app opens the panel again with new properties
|
|
84
|
+
const stopProps = ctx.onProps(draw);
|
|
85
|
+
|
|
86
|
+
// the window is light or dark; the element is inside it
|
|
87
|
+
const paint = ({ dark }: { dark: boolean }) => {
|
|
88
|
+
el.dataset.dark = String(dark);
|
|
89
|
+
};
|
|
90
|
+
paint(ctx.theme);
|
|
91
|
+
const stopTheme = ctx.onTheme(paint);
|
|
92
|
+
|
|
93
|
+
// the server part answers a contract; an error is shown in place of the panel
|
|
94
|
+
const ask = () => {
|
|
95
|
+
ctx.callRpc(sayHello, { name: nameOf(ctx.props) }).then(
|
|
96
|
+
(answer) => {
|
|
97
|
+
if (!ctx.signal.aborted) text.textContent = answer.text;
|
|
98
|
+
},
|
|
99
|
+
(error: unknown) => ctx.reportError(error),
|
|
100
|
+
);
|
|
101
|
+
};
|
|
102
|
+
button.addEventListener('click', ask);
|
|
103
|
+
|
|
104
|
+
return () => {
|
|
105
|
+
stopProps();
|
|
106
|
+
stopTheme();
|
|
107
|
+
button.removeEventListener('click', ask);
|
|
108
|
+
el.replaceChildren();
|
|
109
|
+
};
|
|
110
|
+
},
|
|
111
|
+
);
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
File `src/client.ts` (mountable):
|
|
115
|
+
|
|
116
|
+
```ts
|
|
117
|
+
import { defineClient } from '@dolphy-app/extension-sdk';
|
|
118
|
+
import { HelloPanel } from './hello-panel.ts';
|
|
119
|
+
|
|
120
|
+
export const client = defineClient((c) => {
|
|
121
|
+
c.addPanel({
|
|
122
|
+
id: 'acme.counter.view',
|
|
123
|
+
title: 'Hello',
|
|
124
|
+
component: HelloPanel,
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
- `defineMountable<Props, Handle>(mount)` builds the object and gives it the
|
|
130
|
+
brand that `isMountable(value)` checks. `mount(el, ctx)` may return the
|
|
131
|
+
cleanup or a promise of it. The app calls the cleanup when it removes the
|
|
132
|
+
element: a route change, an extension turned off or removed, an injection
|
|
133
|
+
whose target is gone.
|
|
134
|
+
- `Props` is the type of `ctx.props`: `PanelProps` (`panelId`, `props`,
|
|
135
|
+
`context`) in a panel, `InjectionProps` (`target`, `position`) in an injection,
|
|
136
|
+
`AnswerViewProps` in an answer view and `MarkdownBlockProps` (`source`,
|
|
137
|
+
`language`) in a markdown renderer. `Handle` is the type of `ctx.handle`:
|
|
138
|
+
`PanelHandle` in a panel, `InjectionHandle` in an injection and `undefined`
|
|
139
|
+
elsewhere.
|
|
140
|
+
- The same component fits `client.addPanel`, `client.addInjection`,
|
|
141
|
+
`client.addAnswerView` and `client.addMarkdownRenderer` as its `component`,
|
|
142
|
+
where a Vue component fits.
|
|
143
|
+
|
|
144
|
+
## The context
|
|
145
|
+
|
|
146
|
+
`ctx` (`MountContext`) is everything a Vue component gets from `usePanel`,
|
|
147
|
+
`useApp`, `useEngine` and `useRpc`, without Vue:
|
|
148
|
+
|
|
149
|
+
| Field | What it is |
|
|
150
|
+
| ----------------------- | ------------------------------------------------------------------------------------------------------ |
|
|
151
|
+
| `props`, `onProps(fn)` | The current props, a snapshot that is never changed in place, and a listener for the next one. |
|
|
152
|
+
| `theme`, `onTheme(fn)` | `{ id, dark }` of the window and a listener for a change. |
|
|
153
|
+
| `locale`, `onLocale(fn)` | `'en'` or `'ru'` and a listener for a change. |
|
|
154
|
+
| `emit(event, payload?)` | Sends an event to the app. Only an answer view has events (see below); elsewhere it does nothing. |
|
|
155
|
+
| `app`, `engine` | The window API (`AppApi`) and the engine client, the same objects as `client.app` and `client.engine`. |
|
|
156
|
+
| `callRpc(contract, input)` | Calls the server part: validates the input and the answer with the contract; a failure rejects. |
|
|
157
|
+
| `extensionId` | The id of this extension. |
|
|
158
|
+
| `signal` | An `AbortSignal` aborted when the element is removed: pass it to `fetch`, check it after an `await`. |
|
|
159
|
+
| `reportError(error)` | Shows the card "Extension <name>: <error>" with a "Retry" button in place of the component. |
|
|
160
|
+
| `handle` | The panel handle (`panelId`, `props`, `context`, `call(commandId, args?)`) or the injection handle. |
|
|
161
|
+
|
|
162
|
+
Every `on…` returns the function that stops listening; call it in the cleanup.
|
|
163
|
+
An exception that `mount` throws is reported the same way as `reportError`.
|
|
164
|
+
|
|
165
|
+
An answer view tells the app about the answer with `emit`:
|
|
166
|
+
|
|
167
|
+
<!-- fragment -->
|
|
168
|
+
|
|
169
|
+
```ts
|
|
170
|
+
import { defineMountable } from '@dolphy-app/extension-sdk';
|
|
171
|
+
import type { AnswerChange, AnswerViewProps } from '@dolphy-app/extension-api';
|
|
172
|
+
|
|
173
|
+
export const Choice = defineMountable<AnswerViewProps<string[], string>>(
|
|
174
|
+
(el, ctx) => {
|
|
175
|
+
const select = document.createElement('select');
|
|
176
|
+
select.append(...ctx.props.view.map((option) => new Option(option)));
|
|
177
|
+
select.disabled = ctx.props.disabled;
|
|
178
|
+
select.addEventListener('change', () => {
|
|
179
|
+
const change: AnswerChange<string> = {
|
|
180
|
+
value: select.value,
|
|
181
|
+
complete: true,
|
|
182
|
+
};
|
|
183
|
+
ctx.emit('change', change);
|
|
184
|
+
});
|
|
185
|
+
el.append(select);
|
|
186
|
+
return () => el.replaceChildren();
|
|
187
|
+
},
|
|
188
|
+
);
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
`emit('change', { value, complete })` reports the current answer and
|
|
192
|
+
`emit('submit')` asks the app to check it.
|
|
193
|
+
|
|
194
|
+
## The tests
|
|
195
|
+
|
|
196
|
+
File `test/index.test.ts` (mountable):
|
|
197
|
+
|
|
198
|
+
```ts
|
|
199
|
+
// @vitest-environment happy-dom
|
|
200
|
+
import { isMountable } from '@dolphy-app/extension-sdk';
|
|
201
|
+
import type { ExtensionEngine, PanelProps } from '@dolphy-app/extension-sdk';
|
|
202
|
+
import {
|
|
203
|
+
createTestClient,
|
|
204
|
+
createTestServer,
|
|
205
|
+
mountForTest,
|
|
206
|
+
} from '@dolphy-app/extension-sdk/testing';
|
|
207
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
208
|
+
import { client, server } from '../src/index.ts';
|
|
209
|
+
import { HelloPanel } from '../src/hello-panel.ts';
|
|
210
|
+
import { sayHello } from '../src/shared/rpc.ts';
|
|
211
|
+
|
|
212
|
+
const disposables: { dispose(): unknown }[] = [];
|
|
213
|
+
afterEach(async () => {
|
|
214
|
+
await Promise.all(disposables.splice(0).map((item) => item.dispose()));
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
describe('acme.counter: server', () => {
|
|
218
|
+
it('answers the contract', async () => {
|
|
219
|
+
const running = await createTestServer(server, {
|
|
220
|
+
extensionId: 'acme.counter',
|
|
221
|
+
});
|
|
222
|
+
disposables.push(running);
|
|
223
|
+
expect(await running.rpc(sayHello, { name: 'Ada' })).toEqual({
|
|
224
|
+
text: 'Hello, Ada!',
|
|
225
|
+
});
|
|
226
|
+
});
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
describe('acme.counter: client', () => {
|
|
230
|
+
it('adds the panel as a mountable', async () => {
|
|
231
|
+
const running = await createTestClient(client, {
|
|
232
|
+
extensionId: 'acme.counter',
|
|
233
|
+
});
|
|
234
|
+
disposables.push(running);
|
|
235
|
+
expect(running.panels.map((panel) => panel.id)).toEqual([
|
|
236
|
+
'acme.counter.view',
|
|
237
|
+
]);
|
|
238
|
+
expect(isMountable(running.panels[0]?.component)).toBe(true);
|
|
239
|
+
});
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
const panelProps = (props: PanelProps['props']): PanelProps => ({
|
|
243
|
+
panelId: 'acme.counter.view',
|
|
244
|
+
props,
|
|
245
|
+
context: { courseId: null },
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
// the engine of the window: `ctx.callRpc` goes through `extensions.invokeRpc`
|
|
249
|
+
const engineAnswering = (
|
|
250
|
+
answer: (request: { name: string; input: unknown }) => Promise<unknown>,
|
|
251
|
+
) => ({ extensions: { invokeRpc: answer } }) as unknown as ExtensionEngine;
|
|
252
|
+
|
|
253
|
+
const mountPanel = async (
|
|
254
|
+
props: PanelProps['props'],
|
|
255
|
+
engine: ExtensionEngine,
|
|
256
|
+
) => {
|
|
257
|
+
const mounted = await mountForTest(HelloPanel, {
|
|
258
|
+
props: panelProps(props),
|
|
259
|
+
handle: { ...panelProps(props), call: async () => undefined },
|
|
260
|
+
engine,
|
|
261
|
+
extensionId: 'acme.counter',
|
|
262
|
+
});
|
|
263
|
+
disposables.push({ dispose: () => mounted.unmount() });
|
|
264
|
+
return mounted;
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
describe('acme.counter: panel', () => {
|
|
268
|
+
it('greets, follows the props and the theme, asks the server', async () => {
|
|
269
|
+
const asked: unknown[] = [];
|
|
270
|
+
const mounted = await mountPanel(
|
|
271
|
+
'Ada',
|
|
272
|
+
engineAnswering(async (request) => {
|
|
273
|
+
asked.push(request.input);
|
|
274
|
+
return { text: 'Hello from the server' };
|
|
275
|
+
}),
|
|
276
|
+
);
|
|
277
|
+
expect(mounted.el.querySelector('h2')?.textContent).toBe('Hello, Ada!');
|
|
278
|
+
expect(mounted.el.dataset.dark).toBe('false');
|
|
279
|
+
|
|
280
|
+
mounted.setProps(panelProps('Grace'));
|
|
281
|
+
mounted.setTheme({ id: 'night', dark: true });
|
|
282
|
+
expect(mounted.el.querySelector('h2')?.textContent).toBe('Hello, Grace!');
|
|
283
|
+
expect(mounted.el.dataset.dark).toBe('true');
|
|
284
|
+
|
|
285
|
+
mounted.el.querySelector('button')?.click();
|
|
286
|
+
await vi.waitFor(() =>
|
|
287
|
+
expect(mounted.el.querySelector('p')?.textContent).toBe(
|
|
288
|
+
'Hello from the server',
|
|
289
|
+
),
|
|
290
|
+
);
|
|
291
|
+
expect(asked).toEqual([{ name: 'Grace' }]);
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
it('reports a failed call and cleans up on unmount', async () => {
|
|
295
|
+
const mounted = await mountPanel(
|
|
296
|
+
undefined,
|
|
297
|
+
engineAnswering(async () => {
|
|
298
|
+
throw new Error('server is down');
|
|
299
|
+
}),
|
|
300
|
+
);
|
|
301
|
+
mounted.el.querySelector('button')?.click();
|
|
302
|
+
await vi.waitFor(() => expect(mounted.errors).toHaveLength(1));
|
|
303
|
+
|
|
304
|
+
await mounted.unmount();
|
|
305
|
+
expect(mounted.ctx.signal.aborted).toBe(true);
|
|
306
|
+
expect(mounted.el.childElementCount).toBe(0);
|
|
307
|
+
});
|
|
308
|
+
});
|
|
309
|
+
```
|
|
310
|
+
|
|
311
|
+
`mountForTest(mountable, { props, handle, engine, … })` mounts the component
|
|
312
|
+
into a new `<div>` on a context the test controls. `setProps`, `setTheme` and
|
|
313
|
+
`setLocale` change the context and call the `on…` listeners; `emitted` lists
|
|
314
|
+
the `ctx.emit` calls as `[event, payload]`, `errors` the `ctx.reportError`
|
|
315
|
+
calls; `unmount()` aborts `ctx.signal` and runs the cleanup. Without `app` and
|
|
316
|
+
`engine` any use of them throws, so pass the ones the component touches. The
|
|
317
|
+
`engine` is also what `ctx.callRpc` talks to, as `useRpc` does in a window.
|
|
318
|
+
|
|
319
|
+
## Try and ship
|
|
320
|
+
|
|
321
|
+
Run `pnpm dev`, start the app with `DOLPHY_DEV_EXTENSIONS` and open the "Hello"
|
|
322
|
+
panel from the sidebar menu.
|