@dolphy-app/extension-sdk 0.3.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.
@@ -0,0 +1,236 @@
1
+ # Recipe: an importer and an exporter
2
+
3
+ An importer turns a file the user picked into a new course; an exporter turns a
4
+ course (or the learning progress) into a file the user saves. Your code only
5
+ handles strings: the app shows the file dialogs, checks what you return with the
6
+ course compiler, and writes to disk. This recipe has no template of its own:
7
+ start from `blank` and replace the three files below, which are checked as a
8
+ whole project. See [quick-start.md](quick-start.md) for the commands.
9
+
10
+ ## The manifest
11
+
12
+ File `extension.json` (import-export):
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 cards",
21
+ "description": "Import flashcards from a CSV file and export a course back to CSV.",
22
+ "author": "your-github-login",
23
+ "tags": ["content"]
24
+ }
25
+ ```
26
+
27
+ The manifest holds the identity only; the importer and the exporter are
28
+ registered by the code.
29
+
30
+ ## The code
31
+
32
+ File `src/index.ts` (import-export):
33
+
34
+ ```ts
35
+ import { defineServer } from '@dolphy-app/extension-sdk';
36
+ import type {
37
+ CourseExportInput,
38
+ TextImportInput,
39
+ } from '@dolphy-app/extension-sdk';
40
+
41
+ const json = (value: unknown): string => `${JSON.stringify(value, null, 2)}\n`;
42
+
43
+ const courseIdOf = (fileName: string): string =>
44
+ fileName
45
+ .replace(/\.csv$/i, '')
46
+ .toLowerCase()
47
+ .replace(/[^a-z0-9]+/g, '-')
48
+ .replace(/^-|-$/g, '') || 'cards';
49
+
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',
56
+ // one line "front,back" is one flashcard; the paths are relative to the
57
+ // new course directory the app creates
58
+ run: ({ name, text }: TextImportInput) => {
59
+ const rows = text.split(/\r?\n/).filter((line) => line.trim() !== '');
60
+ if (rows.length === 0) throw new Error('The file has no rows');
61
+ const course = courseIdOf(name);
62
+ const lesson = `${course}::cards`;
63
+ const files: Record<string, string> = {
64
+ [`${course}/course_manifest.json`]: json({
65
+ id: course,
66
+ name: name.replace(/\.csv$/i, ''),
67
+ dependencies: [],
68
+ encompassed: [],
69
+ superseded: [],
70
+ }),
71
+ [`${course}/cards/lesson_manifest.json`]: json({
72
+ id: lesson,
73
+ name: 'Cards',
74
+ course_id: course,
75
+ dependencies: [],
76
+ encompassed: [],
77
+ superseded: [],
78
+ }),
79
+ };
80
+ rows.forEach((row, index) => {
81
+ const comma = row.indexOf(',');
82
+ if (comma <= 0 || comma === row.length - 1) {
83
+ throw new Error(`Row ${index + 1}: expected "front,back"`);
84
+ }
85
+ const dir = `${course}/cards/c${index + 1}`;
86
+ files[`${dir}/exercise_manifest.json`] = json({
87
+ id: `${lesson}::c${index + 1}`,
88
+ name: `Card ${index + 1}`,
89
+ lesson_id: lesson,
90
+ course_id: course,
91
+ exercise_type: 'Declarative',
92
+ exercise_asset: {
93
+ FlashcardAsset: { front_path: 'front.md', back_path: 'back.md' },
94
+ },
95
+ });
96
+ files[`${dir}/front.md`] = `${row.slice(0, comma).trim()}\n`;
97
+ files[`${dir}/back.md`] = `${row.slice(comma + 1).trim()}\n`;
98
+ });
99
+ return { files };
100
+ },
101
+ });
102
+
103
+ s.registerExporter({
104
+ id: 'acme.hello.export',
105
+ title: { en: 'Course to CSV', ru: 'Курс в CSV' },
106
+ scope: 'course',
107
+ // the snapshot holds the text files of the chosen course, paths relative
108
+ // to the course directory
109
+ run: ({ title, files }: CourseExportInput) => {
110
+ const cell = (text: string): string => text.trim().replace(/\s+/g, ' ');
111
+ const fronts = Object.keys(files)
112
+ .filter((path) => path.endsWith('/front.md'))
113
+ .sort((a, b) => a.localeCompare(b, 'en', { numeric: true }));
114
+ const rows = fronts.map((path) => {
115
+ const back = files[`${path.slice(0, -'front.md'.length)}back.md`];
116
+ return `${cell(files[path] ?? '')},${cell(back ?? '')}`;
117
+ });
118
+ return {
119
+ filename: `${title.replace(/[\\/]/g, '-').slice(0, 100)}.csv`,
120
+ text: `${rows.join('\n')}\n`,
121
+ };
122
+ },
123
+ });
124
+ });
125
+ ```
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.
137
+ - An importer handler gets `{ name, text }` (or `{ name, bytes }`) and returns
138
+ `{ files: Record<path, text> }`: the files of a new directory in the library
139
+ (`imported/<extension id>-<file name>`). At most 5000 files, 2 MiB each and
140
+ 20 MiB in all; a path is relative, uses `/`, and has no `..`, empty or
141
+ dot-leading segment, backslash, control character, or case-only duplicate.
142
+ Binary files are not supported: every file is text.
143
+ - The app compiles the tree before it writes anything and shows a summary
144
+ (courses, lessons, exercises) and diagnostics. With an error the user cannot
145
+ import, and nothing is left on disk. Importing the same file name again with
146
+ the same extension replaces the previous directory.
147
+ - An exporter handler gets `{ scope: 'course', courseId, title, files }` (or
148
+ `{ scope: 'progress' }`) and returns `{ filename, text }` or
149
+ `{ filename, bytes }` of at most 20 MiB. The file name has no path separator
150
+ and is at most 120 characters. The app asks the user where to save it.
151
+ - A handler has 30 seconds. Throw an `Error` to refuse: its message reaches the
152
+ user, and nothing is written.
153
+
154
+ ## The tests
155
+
156
+ File `test/index.test.ts` (import-export):
157
+
158
+ ```ts
159
+ import { createTestServer } from '@dolphy-app/extension-sdk/testing';
160
+ import { describe, expect, it } from 'vitest';
161
+ import { server } from '../src/index.ts';
162
+
163
+ const CSV = 'hola,hello\nadiós,goodbye\n';
164
+
165
+ const start = () => createTestServer(server, { extensionId: 'acme.hello' });
166
+
167
+ describe('acme.hello: importer', () => {
168
+ it('turns every row into a flashcard of one lesson', async () => {
169
+ const running = await start();
170
+ const { files } = await running.importer('acme.hello.import').run({
171
+ name: 'Spanish basics.csv',
172
+ text: CSV,
173
+ });
174
+ expect(Object.keys(files).sort()).toEqual([
175
+ 'spanish-basics/cards/c1/back.md',
176
+ 'spanish-basics/cards/c1/exercise_manifest.json',
177
+ 'spanish-basics/cards/c1/front.md',
178
+ 'spanish-basics/cards/c2/back.md',
179
+ 'spanish-basics/cards/c2/exercise_manifest.json',
180
+ 'spanish-basics/cards/c2/front.md',
181
+ 'spanish-basics/cards/lesson_manifest.json',
182
+ 'spanish-basics/course_manifest.json',
183
+ ]);
184
+ expect(files['spanish-basics/cards/c2/front.md']).toBe('adiós\n');
185
+ await running.dispose();
186
+ });
187
+
188
+ it('refuses a row without an answer', async () => {
189
+ const running = await start();
190
+ await expect(
191
+ running
192
+ .importer('acme.hello.import')
193
+ .run({ name: 'x.csv', text: 'hola\n' }),
194
+ ).rejects.toThrow('Row 1');
195
+ await running.dispose();
196
+ });
197
+ });
198
+
199
+ describe('acme.hello: exporter', () => {
200
+ it('writes the cards of the snapshot back to CSV', async () => {
201
+ const running = await start();
202
+ const result = await running.exporter('acme.hello.export').run({
203
+ scope: 'course',
204
+ courseId: 'deck',
205
+ title: 'Deck / Spanish',
206
+ files: {
207
+ 'cards/c1/front.md': 'hola\n',
208
+ 'cards/c1/back.md': 'hello\n',
209
+ 'cards/c10/front.md': 'diez\n',
210
+ 'cards/c10/back.md': 'ten\n',
211
+ 'cards/c2/front.md': 'adiós\n',
212
+ 'cards/c2/back.md': 'goodbye\n',
213
+ },
214
+ });
215
+ expect(result).toEqual({
216
+ filename: 'Deck - Spanish.csv',
217
+ text: 'hola,hello\nadiós,goodbye\ndiez,ten\n',
218
+ });
219
+ await running.dispose();
220
+ });
221
+ });
222
+ ```
223
+
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
227
+ shape and limits of the result. A broken result rejects with
228
+ `invalid import result: …` / `invalid export result: …`, so the test fails the
229
+ way the app would refuse it. For a `progress` exporter pass
230
+ `stats: createMemoryStats(…)` to `createTestServer`.
231
+
232
+ ## Try and ship
233
+
234
+ Run `pnpm dev`, start the app with `DOLPHY_DEV_EXTENSIONS`, open the palette and
235
+ run "Import: Cards from CSV". After you confirm the summary the course shows up
236
+ in Courses without a restart. See [debugging.md](debugging.md) for the rest.
@@ -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.