@dolphy-app/create-extension 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 +3 -6
- package/dist/cli/main.js +977 -531
- package/package.json +1 -1
package/dist/cli/main.js
CHANGED
|
@@ -65,33 +65,25 @@ const WHEN_KEYS = Object.freeze({
|
|
|
65
65
|
});
|
|
66
66
|
|
|
67
67
|
//#endregion
|
|
68
|
-
//#region packages/extension-api/src/
|
|
69
|
-
/** Limits
|
|
70
|
-
const
|
|
71
|
-
/**
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
68
|
+
//#region packages/extension-api/src/hooks.ts
|
|
69
|
+
/** Limits on hooks; the host and the engine check the same numbers. */
|
|
70
|
+
const EXTENSION_HOOK_LIMITS = Object.freeze({
|
|
71
|
+
/** Budget of one handler call, ms. */
|
|
72
|
+
timeoutMs: 3e4,
|
|
73
|
+
/** Hooks registered by one extension. */
|
|
74
|
+
hooks: 8,
|
|
75
|
+
/** Exercises in a `practice.batch` response. */
|
|
76
|
+
maxExercises: 500
|
|
76
77
|
});
|
|
77
78
|
|
|
78
79
|
//#endregion
|
|
79
80
|
//#region packages/extension-api/src/index.ts
|
|
80
|
-
/**
|
|
81
|
-
* Public extension API. The package depends on neither the engine nor the DOM: it is imported
|
|
82
|
-
* by extension code (`main.mjs`), by the answer element (`view.mjs`), and by the engine itself.
|
|
83
|
-
*/
|
|
84
81
|
const EXTENSION_ID_PATTERN = /^[a-z][a-z0-9-]*(\.[a-z][a-z0-9-]*)*$/;
|
|
85
|
-
/** Limits on
|
|
86
|
-
const
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
minHeight: 80,
|
|
91
|
-
/** Largest allowed `maxHeight`, px. */
|
|
92
|
-
maxHeight: 320
|
|
93
|
-
});
|
|
94
|
-
/** Limits on schedules; the manifest, the scheduler, and the runtime enforce them. */
|
|
82
|
+
/** Limits on injections; the app enforces them. */
|
|
83
|
+
const INJECTION_LIMITS = Object.freeze({
|
|
84
|
+
/** Characters in `InjectionRegistration.target`. */
|
|
85
|
+
selectorLength: 200 });
|
|
86
|
+
/** Limits on schedules; the host, the scheduler, and the runtime enforce them. */
|
|
95
87
|
const EXTENSION_SCHEDULE_LIMITS = Object.freeze({
|
|
96
88
|
/** Schedules per extension. */
|
|
97
89
|
schedules: 4,
|
|
@@ -102,7 +94,7 @@ const EXTENSION_SCHEDULE_LIMITS = Object.freeze({
|
|
|
102
94
|
/** How often the app looks for due firings, ms. */
|
|
103
95
|
tickMs: 3e4
|
|
104
96
|
});
|
|
105
|
-
/** Limits of the settings types (`text`, `color`, `list`, `group`, `order`); they match those checked by the
|
|
97
|
+
/** Limits of the settings types (`text`, `color`, `list`, `enum`, `group`, `order`); they match those checked by the host and the engine. */
|
|
106
98
|
const SETTING_LIMITS = Object.freeze({
|
|
107
99
|
/** `maxLength` of `string` and `text`. */
|
|
108
100
|
stringLength: 1e4,
|
|
@@ -110,8 +102,16 @@ const SETTING_LIMITS = Object.freeze({
|
|
|
110
102
|
listItems: 50,
|
|
111
103
|
/** `itemMaxLength` of `list`. */
|
|
112
104
|
listItemLength: 200,
|
|
105
|
+
/** Length of a label, in UTF-16 code units; also of an option's label. */
|
|
106
|
+
labelLength: 60,
|
|
107
|
+
/** Length of a help text. */
|
|
108
|
+
descriptionLength: 500,
|
|
113
109
|
groupLength: 60,
|
|
114
|
-
orderMax: 1e3
|
|
110
|
+
orderMax: 1e3,
|
|
111
|
+
/** Options of one `enum`. */
|
|
112
|
+
options: 64,
|
|
113
|
+
/** Length of an option's value. */
|
|
114
|
+
optionValueLength: 100
|
|
115
115
|
});
|
|
116
116
|
const KEYBINDING_STROKE = `(?:(?:Mod|Ctrl|Control|Alt|Option|Shift|Cmd|Command|Meta|Win|Super)\\+){0,3}(?:[A-Z0-9]|F(?:[1-9]|1[0-9]|2[0-4])|Enter|Return|Space|Tab|Escape|Esc|Backspace|Delete|Insert|Arrow(?:Up|Down|Left|Right)|Home|End|Page(?:Up|Down)|Plus|\\[[A-Za-z][A-Za-z0-9]*\\]|[\`\\-=\\[\\]\\\\;',./+])`;
|
|
117
117
|
/**
|
|
@@ -124,11 +124,11 @@ const KEYBINDING_STROKE = `(?:(?:Mod|Ctrl|Control|Alt|Option|Shift|Cmd|Command|M
|
|
|
124
124
|
* host validates every string authoritatively with `@dolphy-app/keybindings`.
|
|
125
125
|
*/
|
|
126
126
|
const KEYBINDING_PATTERN = new RegExp(`^${KEYBINDING_STROKE}(?: ${KEYBINDING_STROKE})?$`);
|
|
127
|
-
/** Limits on commands and panels (R1, R3); they match those checked by the
|
|
127
|
+
/** Limits on commands and panels (R1, R3); they match those checked by the host and the engine. */
|
|
128
128
|
const EXTENSION_COMMAND_LIMITS = Object.freeze({
|
|
129
129
|
/** Keybinding entries (`keybindings`) per command. */
|
|
130
130
|
keybindingsPerCommand: 4,
|
|
131
|
-
/** Length of a `when` condition (of a command, panel
|
|
131
|
+
/** Length of a `when` condition (of a command, panel or `keybindings[]` entry). */
|
|
132
132
|
whenLength: 200,
|
|
133
133
|
/** Commands per extension. */
|
|
134
134
|
commands: 64,
|
|
@@ -146,7 +146,7 @@ const EXTENSION_COMMAND_LIMITS = Object.freeze({
|
|
|
146
146
|
/** Handler budget, ms. */
|
|
147
147
|
handlerMs: 1e4
|
|
148
148
|
});
|
|
149
|
-
/** Limits on importers and exporters; the
|
|
149
|
+
/** Limits on importers and exporters; the host and the engine check the same numbers. */
|
|
150
150
|
const EXTENSION_TRANSFER_LIMITS = Object.freeze({
|
|
151
151
|
/** Importers per extension. */
|
|
152
152
|
importers: 8,
|
|
@@ -171,6 +171,17 @@ const EXTENSION_TRANSFER_LIMITS = Object.freeze({
|
|
|
171
171
|
/** One path of the returned directory, UTF-8 bytes. */
|
|
172
172
|
pathBytes: 1024
|
|
173
173
|
});
|
|
174
|
+
/** Limits on RPC between the client and the server part of an extension; the host and the engine check the same numbers. */
|
|
175
|
+
const EXTENSION_RPC_LIMITS = Object.freeze({
|
|
176
|
+
/** Characters in the name of a contract. */
|
|
177
|
+
nameLength: 120,
|
|
178
|
+
/** Handlers (`server.handle`) per extension. */
|
|
179
|
+
rpcs: 64,
|
|
180
|
+
/** `JSON.stringify(input).length` at the engine boundary. */
|
|
181
|
+
inputChars: 2e5,
|
|
182
|
+
/** Handler budget, ms. */
|
|
183
|
+
handlerMs: 1e4
|
|
184
|
+
});
|
|
174
185
|
/** Extension storage limits (R2); they match the engine's limits, which enforces them. */
|
|
175
186
|
const EXTENSION_STORAGE_LIMITS = Object.freeze({
|
|
176
187
|
/** Key length in UTF-16 code units. */
|
|
@@ -191,11 +202,11 @@ const EXTENSION_SECRET_LIMITS = Object.freeze({
|
|
|
191
202
|
/** Number of keys. */
|
|
192
203
|
keys: 32
|
|
193
204
|
});
|
|
194
|
-
/** Limits of `
|
|
205
|
+
/** Limits of `server.stats`; the engine enforces them. */
|
|
195
206
|
const EXTENSION_STATS_LIMITS = Object.freeze({
|
|
196
207
|
/** Most dates in one `daily` range (both ends included). */
|
|
197
208
|
dailyDays: 366 });
|
|
198
|
-
/** Limits of `
|
|
209
|
+
/** Limits of `server.notifications`; the engine enforces them. */
|
|
199
210
|
const EXTENSION_NOTIFICATION_LIMITS = Object.freeze({
|
|
200
211
|
/** Title length in characters (code points). */
|
|
201
212
|
titleLength: 80,
|
|
@@ -206,6 +217,19 @@ const EXTENSION_NOTIFICATION_LIMITS = Object.freeze({
|
|
|
206
217
|
/** Notifications per rolling hour and extension. */
|
|
207
218
|
perHour: 30
|
|
208
219
|
});
|
|
220
|
+
/** Registration of an extension without a server part, or before it has registered anything. */
|
|
221
|
+
const EMPTY_SERVER_REGISTRATION = Object.freeze({
|
|
222
|
+
exerciseTypes: Object.freeze([]),
|
|
223
|
+
gradePolicies: Object.freeze([]),
|
|
224
|
+
settings: Object.freeze([]),
|
|
225
|
+
events: Object.freeze([]),
|
|
226
|
+
commands: Object.freeze([]),
|
|
227
|
+
schedules: Object.freeze([]),
|
|
228
|
+
importers: Object.freeze([]),
|
|
229
|
+
exporters: Object.freeze([]),
|
|
230
|
+
rpcs: Object.freeze([]),
|
|
231
|
+
hooks: Object.freeze([])
|
|
232
|
+
});
|
|
209
233
|
|
|
210
234
|
//#endregion
|
|
211
235
|
//#region packages/create-extension/src/templates/common.ts
|
|
@@ -215,38 +239,43 @@ const TEMPLATE_NAMES = [
|
|
|
215
239
|
"exercise",
|
|
216
240
|
"theme",
|
|
217
241
|
"command-panel",
|
|
242
|
+
"react-panel",
|
|
218
243
|
"events",
|
|
219
244
|
"blank"
|
|
220
245
|
];
|
|
221
246
|
const DEFAULT_TEMPLATE = "exercise";
|
|
222
247
|
const lines = (parts) => `${parts.join("\n")}\n`;
|
|
248
|
+
/** `typecheck` script command (default `tsc`). */
|
|
249
|
+
const typecheckCommand = (module) => module.typecheck ?? "tsc";
|
|
223
250
|
/** The `pnpm` scripts of every generated project. */
|
|
224
|
-
const scripts = (id) => ({
|
|
251
|
+
const scripts = (id, module) => ({
|
|
225
252
|
build: "dolphy-ext build",
|
|
226
253
|
dev: "dolphy-ext build --watch",
|
|
227
|
-
|
|
228
|
-
typecheck: "dolphy-ext types && tsc",
|
|
254
|
+
typecheck: typecheckCommand(module),
|
|
229
255
|
validate: `dolphy-ext validate dist-ext/${id}`,
|
|
230
256
|
lint: "dolphy-ext lint",
|
|
231
257
|
test: "vitest run"
|
|
232
258
|
});
|
|
233
|
-
const packageJson = ({ id, dependencies }) => `${JSON.stringify({
|
|
259
|
+
const packageJson = ({ id, dependencies }, module) => `${JSON.stringify({
|
|
234
260
|
name: id,
|
|
235
261
|
version: INITIAL_VERSION,
|
|
236
262
|
private: true,
|
|
237
263
|
type: "module",
|
|
238
|
-
scripts: scripts(id),
|
|
239
|
-
devDependencies: {
|
|
264
|
+
scripts: scripts(id, module),
|
|
265
|
+
devDependencies: Object.fromEntries(Object.entries({
|
|
240
266
|
"@dolphy-app/extension-api": dependencies.api,
|
|
241
267
|
"@dolphy-app/extension-sdk": dependencies.sdk,
|
|
242
268
|
"@dolphy-app/extension-tools": dependencies.tools,
|
|
243
269
|
"@types/node": "^22.20.4",
|
|
244
270
|
"happy-dom": "^20.14.5",
|
|
245
271
|
typescript: "^6.0.3",
|
|
246
|
-
vitest: "^5.0.2"
|
|
247
|
-
|
|
272
|
+
vitest: "^5.0.2",
|
|
273
|
+
vue: "^3.5.35",
|
|
274
|
+
vuetify: "^4.0.1",
|
|
275
|
+
...module.devDependencies
|
|
276
|
+
}).sort(([a], [b]) => a < b ? -1 : 1))
|
|
248
277
|
}, null, 2)}\n`;
|
|
249
|
-
const tsconfigJson = () => lines([
|
|
278
|
+
const tsconfigJson = (module) => lines([
|
|
250
279
|
"{",
|
|
251
280
|
" \"compilerOptions\": {",
|
|
252
281
|
" \"target\": \"ES2023\",",
|
|
@@ -260,18 +289,12 @@ const tsconfigJson = () => lines([
|
|
|
260
289
|
" \"verbatimModuleSyntax\": true,",
|
|
261
290
|
" \"isolatedModules\": true,",
|
|
262
291
|
" \"skipLibCheck\": true,",
|
|
292
|
+
...Object.entries(module.compilerOptions ?? {}).map(([name, value]) => ` "${name}": ${value},`),
|
|
263
293
|
" \"noEmit\": true",
|
|
264
294
|
" },",
|
|
265
|
-
" \"include\": [\"src\", \"test\"
|
|
295
|
+
" \"include\": [\"src\", \"test\"]",
|
|
266
296
|
"}"
|
|
267
297
|
]);
|
|
268
|
-
const idsBullet = [
|
|
269
|
-
"- `.dolphy/ids.d.ts` — generated from `extension.json` by",
|
|
270
|
-
" `dolphy-ext types` (and by every build): the ids the manifest declares,",
|
|
271
|
-
" as TypeScript types. Not committed. A misspelt id, a declared id without",
|
|
272
|
-
" a handler or a view, or `ctx.settings.get` of an undeclared setting fails",
|
|
273
|
-
" `pnpm typecheck`;"
|
|
274
|
-
];
|
|
275
298
|
const readme = (id, module) => lines([
|
|
276
299
|
`# ${id}`,
|
|
277
300
|
"",
|
|
@@ -297,7 +320,7 @@ const readme = (id, module) => lines([
|
|
|
297
320
|
"```",
|
|
298
321
|
"",
|
|
299
322
|
"A change to a file in `dist-ext` is applied live: the window does not",
|
|
300
|
-
"reload,
|
|
323
|
+
"reload, the components on screen are redrawn. Load errors are shown in",
|
|
301
324
|
"\"Settings → Extensions\".",
|
|
302
325
|
"",
|
|
303
326
|
"## Build, check, test",
|
|
@@ -306,7 +329,7 @@ const readme = (id, module) => lines([
|
|
|
306
329
|
`pnpm build # dist-ext/${id}`,
|
|
307
330
|
"pnpm validate # the same manifest parsing the app does",
|
|
308
331
|
"pnpm lint # metadata and bundle checks before a catalog pull request",
|
|
309
|
-
|
|
332
|
+
`pnpm typecheck # ${typecheckCommand(module)}`,
|
|
310
333
|
"pnpm test",
|
|
311
334
|
"```",
|
|
312
335
|
"",
|
|
@@ -321,14 +344,10 @@ const readme = (id, module) => lines([
|
|
|
321
344
|
"`<userData>/extensions/` and restart the app.",
|
|
322
345
|
"",
|
|
323
346
|
"To try the extension while developing, set `DOLPHY_DEV_EXTENSIONS` to the",
|
|
324
|
-
"project `dist-ext` directory when starting the app (
|
|
325
|
-
"
|
|
326
|
-
]);
|
|
327
|
-
const gitignore = () => lines([
|
|
328
|
-
"node_modules",
|
|
329
|
-
"dist-ext",
|
|
330
|
-
".dolphy"
|
|
347
|
+
"project `dist-ext` directory when starting the app (any build of the app",
|
|
348
|
+
"reads it, packaged or not; `dolphy-ext dev` relies on this)."
|
|
331
349
|
]);
|
|
350
|
+
const gitignore = () => lines(["node_modules", "dist-ext"]);
|
|
332
351
|
const claudeMd = () => "@AGENTS.md\n";
|
|
333
352
|
const agentsMd = (id, module) => lines([
|
|
334
353
|
`# ${id}`,
|
|
@@ -347,8 +366,7 @@ const agentsMd = (id, module) => lines([
|
|
|
347
366
|
"- `pnpm install` — install the toolchain;",
|
|
348
367
|
`- \`pnpm build\` — build into \`dist-ext/${id}\`;`,
|
|
349
368
|
"- `pnpm dev` — rebuild on every change (`dolphy-ext build --watch`);",
|
|
350
|
-
|
|
351
|
-
"- `pnpm typecheck` — `types`, then `tsc`;",
|
|
369
|
+
`- \`pnpm typecheck\` — \`${typecheckCommand(module)}\`;`,
|
|
352
370
|
"- `pnpm validate` — parse the built manifest the way the app does;",
|
|
353
371
|
"- `pnpm lint` — metadata and bundle checks the catalog review also runs;",
|
|
354
372
|
"- `pnpm test` — `vitest`.",
|
|
@@ -358,21 +376,42 @@ const agentsMd = (id, module) => lines([
|
|
|
358
376
|
"",
|
|
359
377
|
"## Rules",
|
|
360
378
|
"",
|
|
361
|
-
"- `extension.json`
|
|
362
|
-
"
|
|
363
|
-
|
|
364
|
-
"
|
|
365
|
-
"
|
|
366
|
-
|
|
379
|
+
"- `extension.json` holds the identity of the extension only (id, version,",
|
|
380
|
+
" name, author, tags). Contributions are registered by code: `src/index.ts`",
|
|
381
|
+
" exports `server` (`defineServer`, runs in the extension host) and/or",
|
|
382
|
+
" `client` (`defineClient`, runs in the app window). `pnpm build` writes",
|
|
383
|
+
" `main` and `client` into the built manifest; never edit `dist-ext/`.",
|
|
384
|
+
`- Write ids in the code, prefixed with the extension id (\`${id}\`): a`,
|
|
385
|
+
" command, an exercise type, a setting, a panel. The host and the window",
|
|
386
|
+
" refuse an id that is taken or does not carry the prefix.",
|
|
387
|
+
"- The server part (`server`) must not import `vue`, `vuetify` or any",
|
|
388
|
+
" component. The client part (`client`) is components registered with",
|
|
389
|
+
" `addPanel`, `addInjection`, `addAnswerView`, `addMarkdownRenderer`: a Vue",
|
|
390
|
+
" component (`.vue` single-file components and `defineComponent` both work;",
|
|
391
|
+
" `<v-btn>` and the other Vuetify components in a template need no import)",
|
|
392
|
+
" or a `Mountable` (`defineMountable` from `@dolphy-app/extension-sdk`):",
|
|
393
|
+
" an object with `mount(el, ctx)` that draws into `el` with any framework",
|
|
394
|
+
" and returns the cleanup. The client part must not import `node:*`",
|
|
395
|
+
" modules. The app gives the client code its own `vue` and `vuetify`:",
|
|
396
|
+
" import them as usual, they stay out of the bundle. Any other framework",
|
|
397
|
+
" is a bundled dependency of this extension and is switched on by",
|
|
398
|
+
" `\"frameworks\": [\"react\"]` in `dolphy-ext.config.json` (React is the only",
|
|
399
|
+
" one so far; `@dolphy-app/extension-sdk/react` has `reactComponent`).",
|
|
400
|
+
" Keep both parts in separate files that `src/index.ts` re-exports, so the",
|
|
401
|
+
" build of one never pulls in the other.",
|
|
402
|
+
"- A `<style>` of a single-file component goes into the whole window",
|
|
403
|
+
" document as a `<style data-dolphy-ext>` tag: write `<style scoped>`.",
|
|
404
|
+
" `<style module>` and `.vue` files in the server part are not supported.",
|
|
367
405
|
"- Before publishing replace `your-github-login` in the `author` field of",
|
|
368
406
|
" `extension.json` with the GitHub login of the publisher.",
|
|
369
|
-
"-
|
|
370
|
-
"
|
|
371
|
-
"-
|
|
372
|
-
" `
|
|
373
|
-
"
|
|
374
|
-
"
|
|
375
|
-
"
|
|
407
|
+
"- No `eval`, no `new Function`, no minified or obfuscated sources:",
|
|
408
|
+
" `pnpm lint` and the catalog review flag them.",
|
|
409
|
+
"- Keep tests next to the behaviour: `createTestServer` from",
|
|
410
|
+
" `@dolphy-app/extension-sdk/testing` starts `server` on in-memory fakes and",
|
|
411
|
+
" runs commands, events, schedules, exercise types, importers and",
|
|
412
|
+
" exporters; `createTestClient` records what `client` adds; mount a Vue",
|
|
413
|
+
" component with `createApp` from `vue` in `happy-dom`, a `Mountable` with",
|
|
414
|
+
" `mountForTest`.",
|
|
376
415
|
"",
|
|
377
416
|
"## Guide",
|
|
378
417
|
"",
|
|
@@ -409,7 +448,7 @@ const ciYml = () => lines([
|
|
|
409
448
|
|
|
410
449
|
//#endregion
|
|
411
450
|
//#region packages/create-extension/src/templates/blank.ts
|
|
412
|
-
const manifestJson$
|
|
451
|
+
const manifestJson$5 = (id) => `{
|
|
413
452
|
"$schema": "./node_modules/@dolphy-app/extension-api/dist/extension.schema.json",
|
|
414
453
|
"id": "${id}",
|
|
415
454
|
"version": "${INITIAL_VERSION}",
|
|
@@ -417,57 +456,51 @@ const manifestJson$4 = (id) => `{
|
|
|
417
456
|
"name": "Hello command",
|
|
418
457
|
"description": "A command-palette command that shows a notification.",
|
|
419
458
|
"author": "your-github-login",
|
|
420
|
-
"tags": ["productivity"]
|
|
421
|
-
"contributes": {
|
|
422
|
-
"commands": [{ "id": "${id}.hello", "title": "Say hello" }]
|
|
423
|
-
}
|
|
459
|
+
"tags": ["productivity"]
|
|
424
460
|
}
|
|
425
461
|
`;
|
|
426
|
-
const indexTs$
|
|
427
|
-
|
|
428
|
-
//
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
}
|
|
462
|
+
const indexTs$5 = (id) => `import { defineServer, notify } from '@dolphy-app/extension-sdk';
|
|
463
|
+
|
|
464
|
+
// runs in the extension host: every call registers a contribution
|
|
465
|
+
export const server = defineServer((s) => {
|
|
466
|
+
s.registerCommand({
|
|
467
|
+
id: '${id}.hello',
|
|
468
|
+
title: { en: 'Say hello', ru: 'Поздороваться' },
|
|
469
|
+
run: () => notify('Hello from ${id}!'),
|
|
470
|
+
});
|
|
435
471
|
});
|
|
436
472
|
`;
|
|
437
|
-
const indexTestTs$
|
|
473
|
+
const indexTestTs$4 = (id) => `import { createTestServer } from '@dolphy-app/extension-sdk/testing';
|
|
438
474
|
import { expect, it } from 'vitest';
|
|
439
|
-
import {
|
|
475
|
+
import { server } from '../src/index.ts';
|
|
440
476
|
|
|
441
477
|
it('the hello command notifies', async () => {
|
|
442
|
-
const
|
|
443
|
-
|
|
444
|
-
});
|
|
445
|
-
expect(await commands.run('${id}.hello')).toEqual({
|
|
478
|
+
const running = await createTestServer(server, { extensionId: '${id}' });
|
|
479
|
+
expect(await running.commands.run('${id}.hello')).toEqual({
|
|
446
480
|
kind: 'notify',
|
|
447
481
|
text: 'Hello from ${id}!',
|
|
448
482
|
});
|
|
449
|
-
await
|
|
483
|
+
await running.dispose();
|
|
450
484
|
});
|
|
451
485
|
`;
|
|
452
486
|
const blank = {
|
|
453
487
|
summary: ["A Dolphy extension: the smallest project that does something, one command", "in the command palette (Ctrl/⌘+K) that shows a notification."],
|
|
454
488
|
layout: [
|
|
455
|
-
"- `extension.json` — the manifest
|
|
456
|
-
"- `src/index.ts` — all the extension code: `
|
|
457
|
-
" build writes it to `main.mjs`;",
|
|
458
|
-
...idsBullet,
|
|
489
|
+
"- `extension.json` — the manifest: identity only, the build adds `main`;",
|
|
490
|
+
"- `src/index.ts` — all the extension code: `server` (`defineServer`),",
|
|
491
|
+
" which registers the command; the build writes it to `main.mjs`;",
|
|
459
492
|
"- `test/index.test.ts` — tests (`vitest`)."
|
|
460
493
|
],
|
|
461
494
|
files: (id) => ({
|
|
462
|
-
"extension.json": manifestJson$
|
|
463
|
-
"src/index.ts": indexTs$
|
|
464
|
-
"test/index.test.ts": indexTestTs$
|
|
495
|
+
"extension.json": manifestJson$5(id),
|
|
496
|
+
"src/index.ts": indexTs$5(id),
|
|
497
|
+
"test/index.test.ts": indexTestTs$4(id)
|
|
465
498
|
})
|
|
466
499
|
};
|
|
467
500
|
|
|
468
501
|
//#endregion
|
|
469
502
|
//#region packages/create-extension/src/templates/command-panel.ts
|
|
470
|
-
const manifestJson$
|
|
503
|
+
const manifestJson$4 = (id) => `{
|
|
471
504
|
"$schema": "./node_modules/@dolphy-app/extension-api/dist/extension.schema.json",
|
|
472
505
|
"id": "${id}",
|
|
473
506
|
"version": "${INITIAL_VERSION}",
|
|
@@ -475,173 +508,278 @@ const manifestJson$3 = (id) => `{
|
|
|
475
508
|
"name": "Hello panel",
|
|
476
509
|
"description": "Palette commands that greet the learner and open a small panel.",
|
|
477
510
|
"author": "your-github-login",
|
|
478
|
-
"tags": ["productivity"]
|
|
479
|
-
"contributes": {
|
|
480
|
-
"commands": [
|
|
481
|
-
{ "id": "${id}.hello", "title": "Say hello", "category": "Hello" },
|
|
482
|
-
{ "id": "${id}.open", "title": "Open the hello panel", "category": "Hello" },
|
|
483
|
-
{ "id": "${id}.data", "title": "Hello panel data", "palette": false }
|
|
484
|
-
],
|
|
485
|
-
"panels": [{ "id": "${id}.view", "title": "Hello" }]
|
|
486
|
-
}
|
|
511
|
+
"tags": ["productivity"]
|
|
487
512
|
}
|
|
488
513
|
`;
|
|
489
|
-
const indexTs$
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
// palette command: shows a notification
|
|
503
|
-
'${id}.hello': (args) => {
|
|
514
|
+
const indexTs$4 = () => `export { client } from './client.ts';
|
|
515
|
+
export { server } from './server.ts';
|
|
516
|
+
`;
|
|
517
|
+
const serverTs$2 = (id) => `import { defineServer, notify, openPanel } from '@dolphy-app/extension-sdk';
|
|
518
|
+
|
|
519
|
+
// runs in the extension host: every call registers a contribution
|
|
520
|
+
export const server = defineServer((s) => {
|
|
521
|
+
// palette command: shows a notification
|
|
522
|
+
s.registerCommand({
|
|
523
|
+
id: '${id}.hello',
|
|
524
|
+
title: { en: 'Say hello', ru: 'Поздороваться' },
|
|
525
|
+
category: 'Hello',
|
|
526
|
+
run: (args) => {
|
|
504
527
|
const name = typeof args === 'string' ? args : 'world';
|
|
505
528
|
return notify(\`Hello, \${name}!\`);
|
|
506
529
|
},
|
|
507
|
-
|
|
508
|
-
'${id}.open': () => openPanel('${id}.view', { name: 'Dolphy' }),
|
|
509
|
-
// hidden from the palette (palette: false): the panel asks for data
|
|
510
|
-
'${id}.data': () => ({ message: 'Hello from ${id}' }),
|
|
511
|
-
},
|
|
512
|
-
});
|
|
530
|
+
});
|
|
513
531
|
|
|
514
|
-
//
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
const line = doc.createElement('p');
|
|
522
|
-
container.append(title, line);
|
|
523
|
-
const name = (props: unknown): string =>
|
|
524
|
-
typeof props === 'object' && props !== null && 'name' in props
|
|
525
|
-
? String(props.name)
|
|
526
|
-
: 'world';
|
|
527
|
-
title.textContent = \`Hello, \${name(ctx.props)}!\`;
|
|
528
|
-
// the app opens the panel again with new properties: redraw the title
|
|
529
|
-
ctx.signal.addEventListener(
|
|
530
|
-
'abort',
|
|
531
|
-
ctx.onProps((props) => {
|
|
532
|
-
title.textContent = \`Hello, \${name(props)}!\`;
|
|
533
|
-
}),
|
|
534
|
-
);
|
|
535
|
-
const data = (await ctx.call('${id}.data')) as { message: string };
|
|
536
|
-
line.textContent = data.message;
|
|
537
|
-
},
|
|
538
|
-
}),
|
|
539
|
-
} satisfies ExtensionPanels;
|
|
540
|
-
`;
|
|
541
|
-
const indexTestTs$2 = (id) => `// @vitest-environment happy-dom
|
|
542
|
-
import {
|
|
543
|
-
loadCommands,
|
|
544
|
-
loadPanel,
|
|
545
|
-
} from '@dolphy-app/extension-sdk/testing';
|
|
546
|
-
import { afterEach, describe, expect, it } from 'vitest';
|
|
547
|
-
import { host, panels } from '../src/index.ts';
|
|
532
|
+
// palette command: opens the panel (registered by the client) with properties
|
|
533
|
+
s.registerCommand({
|
|
534
|
+
id: '${id}.open',
|
|
535
|
+
title: { en: 'Open the hello panel', ru: 'Открыть панель' },
|
|
536
|
+
category: 'Hello',
|
|
537
|
+
run: () => openPanel('${id}.view', { name: 'Dolphy' }),
|
|
538
|
+
});
|
|
548
539
|
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
540
|
+
// hidden from the palette (palette: false): the panel asks for data
|
|
541
|
+
s.registerCommand({
|
|
542
|
+
id: '${id}.data',
|
|
543
|
+
title: 'Hello panel data',
|
|
544
|
+
palette: false,
|
|
545
|
+
run: () => ({ message: 'Hello from ${id}' }),
|
|
546
|
+
});
|
|
547
|
+
});
|
|
548
|
+
`;
|
|
549
|
+
const clientTs$2 = (id) => `import { defineClient } from '@dolphy-app/extension-sdk';
|
|
550
|
+
import StatusPanel from './StatusPanel.vue';
|
|
551
|
+
|
|
552
|
+
// runs in the app window: the panel is a Vue component the app draws
|
|
553
|
+
export const client = defineClient((c) => {
|
|
554
|
+
c.addPanel({
|
|
555
|
+
id: '${id}.view',
|
|
556
|
+
title: { en: 'Hello', ru: 'Привет' },
|
|
557
|
+
component: StatusPanel,
|
|
558
|
+
});
|
|
559
|
+
});
|
|
560
|
+
`;
|
|
561
|
+
const statusPanelVue = (id) => `<script setup lang="ts">
|
|
562
|
+
import { usePanel } from '@dolphy-app/extension-sdk/client';
|
|
563
|
+
import { computed, ref } from 'vue';
|
|
564
|
+
|
|
565
|
+
// \`usePanel()\` gives the panel the properties it was opened with and \`call\`
|
|
566
|
+
// for the commands of the extension
|
|
567
|
+
const panel = usePanel();
|
|
568
|
+
const message = ref('');
|
|
569
|
+
// the app opens the panel again with new properties: \`panel.props\` is
|
|
570
|
+
// reactive, the title follows it
|
|
571
|
+
const name = computed(() => {
|
|
572
|
+
const { props } = panel;
|
|
573
|
+
return typeof props === 'object' && props !== null && 'name' in props
|
|
574
|
+
? String(props.name)
|
|
575
|
+
: 'world';
|
|
552
576
|
});
|
|
553
577
|
|
|
554
578
|
const load = async () => {
|
|
555
|
-
const
|
|
556
|
-
|
|
557
|
-
declaredPanels: ['${id}.view'],
|
|
558
|
-
});
|
|
559
|
-
disposables.push(commands);
|
|
560
|
-
return commands;
|
|
579
|
+
const data = await panel.call('${id}.data');
|
|
580
|
+
message.value = (data as { message: string }).message;
|
|
561
581
|
};
|
|
582
|
+
void load();
|
|
583
|
+
<\/script>
|
|
584
|
+
|
|
585
|
+
<template>
|
|
586
|
+
<section class="status-panel">
|
|
587
|
+
<h2>Hello, {{ name }}!</h2>
|
|
588
|
+
<p>{{ message }}</p>
|
|
589
|
+
<v-btn color="primary" @click="load">Reload</v-btn>
|
|
590
|
+
</section>
|
|
591
|
+
</template>
|
|
592
|
+
|
|
593
|
+
<style scoped>
|
|
594
|
+
.status-panel {
|
|
595
|
+
padding: 16px;
|
|
596
|
+
}
|
|
597
|
+
</style>
|
|
598
|
+
`;
|
|
599
|
+
const vitestConfigTs = () => `import vue from '@vitejs/plugin-vue';
|
|
600
|
+
import { defineConfig } from 'vitest/config';
|
|
562
601
|
|
|
563
|
-
|
|
602
|
+
export default defineConfig({ plugins: [vue()] });
|
|
603
|
+
`;
|
|
604
|
+
/** The tests of the server part: the same for every template with these commands. */
|
|
605
|
+
const serverTestsTs = (id) => `const start = async () => {
|
|
606
|
+
const running = await createTestServer(server, { extensionId: '${id}' });
|
|
607
|
+
disposables.push(running);
|
|
608
|
+
return running;
|
|
609
|
+
};
|
|
610
|
+
|
|
611
|
+
describe('${id}: server', () => {
|
|
564
612
|
it('hello greets the name from the arguments, "world" without them', async () => {
|
|
565
|
-
const
|
|
566
|
-
expect(await commands.run('${id}.hello', 'Ada')).toEqual({
|
|
613
|
+
const running = await start();
|
|
614
|
+
expect(await running.commands.run('${id}.hello', 'Ada')).toEqual({
|
|
567
615
|
kind: 'notify',
|
|
568
616
|
text: 'Hello, Ada!',
|
|
569
617
|
});
|
|
570
|
-
expect(await commands.run('${id}.hello')).toEqual({
|
|
618
|
+
expect(await running.commands.run('${id}.hello')).toEqual({
|
|
571
619
|
kind: 'notify',
|
|
572
620
|
text: 'Hello, world!',
|
|
573
621
|
});
|
|
574
622
|
});
|
|
575
623
|
|
|
576
624
|
it('open asks the app to open the panel with properties', async () => {
|
|
577
|
-
const
|
|
578
|
-
expect(await commands.run('${id}.open')).toEqual({
|
|
625
|
+
const running = await start();
|
|
626
|
+
expect(await running.commands.run('${id}.open')).toEqual({
|
|
579
627
|
kind: 'openPanel',
|
|
580
628
|
panelId: '${id}.view',
|
|
581
629
|
props: { name: 'Dolphy' },
|
|
582
630
|
});
|
|
583
631
|
});
|
|
584
632
|
|
|
585
|
-
it('data returns what the panel shows', async () => {
|
|
586
|
-
const
|
|
587
|
-
expect(await commands.run('${id}.data')).toEqual({
|
|
633
|
+
it('data returns what the panel shows and stays out of the palette', async () => {
|
|
634
|
+
const running = await start();
|
|
635
|
+
expect(await running.commands.run('${id}.data')).toEqual({
|
|
588
636
|
kind: 'data',
|
|
589
637
|
value: { message: 'Hello from ${id}' },
|
|
590
638
|
});
|
|
639
|
+
const hidden = running.registration.commands.find(
|
|
640
|
+
(command) => command.id === '${id}.data',
|
|
641
|
+
);
|
|
642
|
+
expect(hidden?.palette).toBe(false);
|
|
591
643
|
});
|
|
644
|
+
});`;
|
|
645
|
+
const indexTestTs$3 = (id) => `// @vitest-environment happy-dom
|
|
646
|
+
import { PANEL_HANDLE_KEY } from '@dolphy-app/extension-sdk';
|
|
647
|
+
import type { JsonValue, PanelHandle } from '@dolphy-app/extension-sdk';
|
|
648
|
+
import {
|
|
649
|
+
createTestClient,
|
|
650
|
+
createTestServer,
|
|
651
|
+
} from '@dolphy-app/extension-sdk/testing';
|
|
652
|
+
import { afterEach, describe, expect, it } from 'vitest';
|
|
653
|
+
import { createApp, defineComponent, h, nextTick, shallowReactive } from 'vue';
|
|
654
|
+
import { client, server } from '../src/index.ts';
|
|
655
|
+
import StatusPanel from '../src/StatusPanel.vue';
|
|
656
|
+
|
|
657
|
+
const disposables: { dispose(): unknown }[] = [];
|
|
658
|
+
afterEach(async () => {
|
|
659
|
+
await Promise.all(disposables.splice(0).map((item) => item.dispose()));
|
|
660
|
+
});
|
|
661
|
+
|
|
662
|
+
${serverTestsTs(id)}
|
|
663
|
+
|
|
664
|
+
describe('${id}: client', () => {
|
|
665
|
+
it('adds the panel that the open command points to', async () => {
|
|
666
|
+
const running = await createTestClient(client, { extensionId: '${id}' });
|
|
667
|
+
disposables.push(running);
|
|
668
|
+
expect(running.panels.map((panel) => panel.id)).toEqual(['${id}.view']);
|
|
669
|
+
expect(running.panels[0]?.component).toBe(StatusPanel);
|
|
670
|
+
});
|
|
671
|
+
});
|
|
672
|
+
|
|
673
|
+
// the app draws \`<v-btn>\` with its Vuetify; the test gives the panel a plain button
|
|
674
|
+
const VBtn = defineComponent({
|
|
675
|
+
setup: (_props, { slots }) => () => h('button', slots['default']?.()),
|
|
592
676
|
});
|
|
593
677
|
|
|
678
|
+
// draws the panel the way the app does: the handle is provided to the component
|
|
679
|
+
const mountPanel = async (
|
|
680
|
+
props: JsonValue | undefined,
|
|
681
|
+
call: PanelHandle['call'],
|
|
682
|
+
) => {
|
|
683
|
+
const handle = shallowReactive({
|
|
684
|
+
panelId: '${id}.view',
|
|
685
|
+
props,
|
|
686
|
+
context: { courseId: null },
|
|
687
|
+
call,
|
|
688
|
+
});
|
|
689
|
+
const host = document.createElement('div');
|
|
690
|
+
document.body.append(host);
|
|
691
|
+
const app = createApp({ render: () => h(StatusPanel) });
|
|
692
|
+
app.component('v-btn', VBtn);
|
|
693
|
+
app.provide(PANEL_HANDLE_KEY, handle);
|
|
694
|
+
app.mount(host);
|
|
695
|
+
disposables.push({
|
|
696
|
+
dispose: () => {
|
|
697
|
+
app.unmount();
|
|
698
|
+
host.remove();
|
|
699
|
+
},
|
|
700
|
+
});
|
|
701
|
+
// the panel asks a command: wait for the reply, then for the redraw
|
|
702
|
+
const settle = async () => {
|
|
703
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
704
|
+
await nextTick();
|
|
705
|
+
};
|
|
706
|
+
await settle();
|
|
707
|
+
return {
|
|
708
|
+
host,
|
|
709
|
+
reopen: async (next: JsonValue) => {
|
|
710
|
+
handle.props = next;
|
|
711
|
+
await settle();
|
|
712
|
+
},
|
|
713
|
+
};
|
|
714
|
+
};
|
|
715
|
+
|
|
594
716
|
describe('${id}: panel', () => {
|
|
595
717
|
it('shows the data command reply and follows new properties', async () => {
|
|
596
|
-
const
|
|
597
|
-
|
|
598
|
-
|
|
718
|
+
const calls: string[] = [];
|
|
719
|
+
const panel = await mountPanel({ name: 'Ada' }, async (commandId) => {
|
|
720
|
+
calls.push(commandId);
|
|
721
|
+
return { message: 'Hello from the test' };
|
|
599
722
|
});
|
|
600
|
-
|
|
601
|
-
expect(panel.
|
|
602
|
-
expect(panel.container.querySelector('p')?.textContent).toBe(
|
|
723
|
+
expect(panel.host.querySelector('h2')?.textContent).toBe('Hello, Ada!');
|
|
724
|
+
expect(panel.host.querySelector('p')?.textContent).toBe(
|
|
603
725
|
'Hello from the test',
|
|
604
726
|
);
|
|
605
|
-
expect(
|
|
727
|
+
expect(calls).toEqual(['${id}.data']);
|
|
606
728
|
|
|
607
|
-
panel.
|
|
608
|
-
expect(panel.
|
|
609
|
-
'Hello, Grace!',
|
|
610
|
-
);
|
|
729
|
+
await panel.reopen({ name: 'Grace' });
|
|
730
|
+
expect(panel.host.querySelector('h2')?.textContent).toBe('Hello, Grace!');
|
|
611
731
|
});
|
|
612
732
|
|
|
613
|
-
it('
|
|
614
|
-
const
|
|
615
|
-
|
|
733
|
+
it('asks the data command again when the button is pressed', async () => {
|
|
734
|
+
const calls: string[] = [];
|
|
735
|
+
const panel = await mountPanel(undefined, async (commandId) => {
|
|
736
|
+
calls.push(commandId);
|
|
737
|
+
return { message: 'x' };
|
|
616
738
|
});
|
|
617
|
-
|
|
618
|
-
panel.
|
|
619
|
-
expect(
|
|
620
|
-
|
|
621
|
-
|
|
739
|
+
panel.host.querySelector('button')?.click();
|
|
740
|
+
await panel.reopen({});
|
|
741
|
+
expect(calls).toEqual(['${id}.data', '${id}.data']);
|
|
742
|
+
});
|
|
743
|
+
|
|
744
|
+
it('greets the world when it is opened without properties', async () => {
|
|
745
|
+
const panel = await mountPanel(undefined, async () => ({ message: 'x' }));
|
|
746
|
+
expect(panel.host.querySelector('h2')?.textContent).toBe('Hello, world!');
|
|
622
747
|
});
|
|
623
748
|
});
|
|
624
749
|
`;
|
|
625
750
|
const commandPanel = {
|
|
626
751
|
summary: ["A Dolphy extension: two commands in the command palette (Ctrl/⌘+K), a hidden", "data command and a panel — a page of the extension inside the app."],
|
|
627
752
|
layout: [
|
|
628
|
-
"- `extension.json` — the manifest
|
|
629
|
-
"
|
|
630
|
-
"
|
|
631
|
-
"
|
|
632
|
-
|
|
753
|
+
"- `extension.json` — the manifest: identity only, the build adds `main` and",
|
|
754
|
+
" `client`;",
|
|
755
|
+
"- `src/server.ts` — `server` (`defineServer`): the command handlers; the",
|
|
756
|
+
" build writes it to `main.mjs`;",
|
|
757
|
+
"- `src/client.ts` — `client` (`defineClient`): registers the panel with",
|
|
758
|
+
" `addPanel`; `src/StatusPanel.vue` is the single-file Vue component the",
|
|
759
|
+
" app draws; the build writes them to `client.mjs`;",
|
|
760
|
+
"- `vitest.config.ts` — `@vitejs/plugin-vue`, so a test can import `.vue`;",
|
|
761
|
+
"- `src/index.ts` — re-exports `server` and `client`;",
|
|
633
762
|
"- `test/index.test.ts` — tests (`vitest`, `happy-dom`)."
|
|
634
763
|
],
|
|
635
764
|
files: (id) => ({
|
|
636
|
-
"extension.json": manifestJson$
|
|
637
|
-
"src/index.ts": indexTs$
|
|
638
|
-
"
|
|
639
|
-
|
|
765
|
+
"extension.json": manifestJson$4(id),
|
|
766
|
+
"src/index.ts": indexTs$4(),
|
|
767
|
+
"src/server.ts": serverTs$2(id),
|
|
768
|
+
"src/client.ts": clientTs$2(id),
|
|
769
|
+
"src/StatusPanel.vue": statusPanelVue(id),
|
|
770
|
+
"vitest.config.ts": vitestConfigTs(),
|
|
771
|
+
"test/index.test.ts": indexTestTs$3(id)
|
|
772
|
+
}),
|
|
773
|
+
devDependencies: {
|
|
774
|
+
"@vitejs/plugin-vue": "^6.0.7",
|
|
775
|
+
"vue-tsc": "^3.3.3"
|
|
776
|
+
},
|
|
777
|
+
typecheck: "vue-tsc --noEmit"
|
|
640
778
|
};
|
|
641
779
|
|
|
642
780
|
//#endregion
|
|
643
781
|
//#region packages/create-extension/src/templates/events.ts
|
|
644
|
-
const manifestJson$
|
|
782
|
+
const manifestJson$3 = (id) => `{
|
|
645
783
|
"$schema": "./node_modules/@dolphy-app/extension-api/dist/extension.schema.json",
|
|
646
784
|
"id": "${id}",
|
|
647
785
|
"version": "${INITIAL_VERSION}",
|
|
@@ -649,35 +787,19 @@ const manifestJson$2 = (id) => `{
|
|
|
649
787
|
"name": "Day streak",
|
|
650
788
|
"description": "Counts the days in a row with a closed attempt and shows the streak.",
|
|
651
789
|
"author": "your-github-login",
|
|
652
|
-
"
|
|
653
|
-
"tags": ["learning"],
|
|
654
|
-
"contributes": {
|
|
655
|
-
"events": [{ "event": "attempt.closed" }],
|
|
656
|
-
"commands": [
|
|
657
|
-
{ "id": "${id}.show", "title": "Show the streak", "category": "Streak" },
|
|
658
|
-
{ "id": "${id}.data", "title": "Streak data", "palette": false }
|
|
659
|
-
],
|
|
660
|
-
"panels": [{ "id": "${id}.view", "title": "Streak" }]
|
|
661
|
-
}
|
|
790
|
+
"tags": ["learning"]
|
|
662
791
|
}
|
|
663
792
|
`;
|
|
664
|
-
const indexTs$
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
openPanel,
|
|
670
|
-
} from '@dolphy-app/extension-sdk';
|
|
671
|
-
import type { ExtensionPanels } from '@dolphy-app/extension-sdk';
|
|
672
|
-
|
|
673
|
-
// a \`type\`, not an \`interface\`: an interface has no index signature and is
|
|
674
|
-
// not JSON for \`ctx.storage\`
|
|
793
|
+
const indexTs$3 = () => `export { client } from './client.ts';
|
|
794
|
+
export { server } from './server.ts';
|
|
795
|
+
`;
|
|
796
|
+
const streakTs = () => `// a \`type\`, not an \`interface\`: an interface has no index signature and is
|
|
797
|
+
// not JSON for \`server.storage\`
|
|
675
798
|
export type Streak = {
|
|
676
799
|
days: number;
|
|
677
800
|
last: string;
|
|
678
801
|
};
|
|
679
802
|
|
|
680
|
-
const KEY = 'streak';
|
|
681
803
|
const DAY_MS = 86_400_000;
|
|
682
804
|
|
|
683
805
|
const dayOf = (at: number): string => new Date(at).toISOString().slice(0, 10);
|
|
@@ -691,69 +813,94 @@ export const advance = (streak: Streak | undefined, at: number): Streak => {
|
|
|
691
813
|
streak !== undefined && dayOf(Date.parse(streak.last) + DAY_MS) === day;
|
|
692
814
|
return { days: continues ? streak.days + 1 : 1, last: day };
|
|
693
815
|
};
|
|
816
|
+
`;
|
|
817
|
+
const serverTs$1 = (id) => `import { defineServer, notify, openPanel } from '@dolphy-app/extension-sdk';
|
|
818
|
+
import { advance } from './streak.ts';
|
|
819
|
+
import type { Streak } from './streak.ts';
|
|
694
820
|
|
|
695
|
-
|
|
696
|
-
// \`inActivate\` means "registered in \`activate\`": the handlers need \`ctx\`
|
|
697
|
-
export const host = defineExtension({
|
|
698
|
-
events: { 'attempt.closed': inActivate },
|
|
699
|
-
commands: { '${id}.show': inActivate, '${id}.data': inActivate },
|
|
700
|
-
activate(ctx) {
|
|
701
|
-
// delivered asynchronously, once per recorded attempt
|
|
702
|
-
ctx.events.on('attempt.closed', async ({ at, outcome }) => {
|
|
703
|
-
if (outcome === 'gave-up') return;
|
|
704
|
-
const streak = await ctx.storage.get<Streak>(KEY);
|
|
705
|
-
await ctx.storage.set(KEY, advance(streak, at));
|
|
706
|
-
});
|
|
821
|
+
const KEY = 'streak';
|
|
707
822
|
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
);
|
|
823
|
+
// runs in the extension host: every call registers a contribution
|
|
824
|
+
export const server = defineServer((s) => {
|
|
825
|
+
// delivered asynchronously, once per recorded attempt
|
|
826
|
+
s.on('attempt.closed', async ({ at, outcome }) => {
|
|
827
|
+
if (outcome === 'gave-up') return;
|
|
828
|
+
const streak = await s.storage.get<Streak>(KEY);
|
|
829
|
+
await s.storage.set(KEY, advance(streak, at));
|
|
830
|
+
});
|
|
831
|
+
|
|
832
|
+
// data for the panel: hidden from the palette, the panel calls it
|
|
833
|
+
s.registerCommand({
|
|
834
|
+
id: '${id}.data',
|
|
835
|
+
title: 'Streak data',
|
|
836
|
+
palette: false,
|
|
837
|
+
run: async () =>
|
|
838
|
+
(await s.storage.get<Streak>(KEY)) ?? { days: 0, last: '' },
|
|
839
|
+
});
|
|
713
840
|
|
|
714
|
-
|
|
715
|
-
|
|
841
|
+
s.registerCommand({
|
|
842
|
+
id: '${id}.show',
|
|
843
|
+
title: { en: 'Show the streak', ru: 'Показать серию' },
|
|
844
|
+
category: 'Streak',
|
|
845
|
+
run: async () => {
|
|
846
|
+
const streak = await s.storage.get<Streak>(KEY);
|
|
716
847
|
if (streak === undefined) {
|
|
717
848
|
return notify('No streak yet: finish your first exercise.');
|
|
718
849
|
}
|
|
719
850
|
return openPanel('${id}.view', { days: streak.days });
|
|
851
|
+
},
|
|
852
|
+
});
|
|
853
|
+
});
|
|
854
|
+
`;
|
|
855
|
+
const clientTs$1 = (id) => `import { defineClient } from '@dolphy-app/extension-sdk';
|
|
856
|
+
import { StreakPanel } from './streak-panel.ts';
|
|
857
|
+
|
|
858
|
+
// runs in the app window: the panel is a Vue component the app draws
|
|
859
|
+
export const client = defineClient((c) => {
|
|
860
|
+
c.addPanel({
|
|
861
|
+
id: '${id}.view',
|
|
862
|
+
title: { en: 'Streak', ru: 'Серия' },
|
|
863
|
+
component: StreakPanel,
|
|
864
|
+
});
|
|
865
|
+
});
|
|
866
|
+
`;
|
|
867
|
+
const streakPanelTs = (id) => `import { usePanel } from '@dolphy-app/extension-sdk/client';
|
|
868
|
+
import { defineComponent, h, ref, watchEffect } from 'vue';
|
|
869
|
+
import type { Streak } from './streak.ts';
|
|
870
|
+
|
|
871
|
+
// the only way to the data is \`panel.call\` to the commands of the server
|
|
872
|
+
export const StreakPanel = defineComponent({
|
|
873
|
+
setup() {
|
|
874
|
+
const panel = usePanel();
|
|
875
|
+
const text = ref('');
|
|
876
|
+
// the command opens the panel again with new properties: ask again
|
|
877
|
+
watchEffect(async () => {
|
|
878
|
+
void panel.props;
|
|
879
|
+
const streak = (await panel.call('${id}.data')) as Streak;
|
|
880
|
+
text.value =
|
|
881
|
+
streak.days === 0
|
|
882
|
+
? 'No streak yet.'
|
|
883
|
+
: \`Streak: \${streak.days} days, last day \${streak.last}\`;
|
|
720
884
|
});
|
|
885
|
+
return () => h('p', text.value);
|
|
721
886
|
},
|
|
722
887
|
});
|
|
723
|
-
|
|
724
|
-
// the panel runs in an isolated frame: no network, the only way out is \`ctx.call\`
|
|
725
|
-
export const panels = {
|
|
726
|
-
'${id}.view': defineExtensionPanel({
|
|
727
|
-
async mount(container, ctx) {
|
|
728
|
-
const line = container.ownerDocument.createElement('p');
|
|
729
|
-
container.append(line);
|
|
730
|
-
const render = async () => {
|
|
731
|
-
const streak = (await ctx.call('${id}.data')) as Streak;
|
|
732
|
-
line.textContent =
|
|
733
|
-
streak.days === 0
|
|
734
|
-
? 'No streak yet.'
|
|
735
|
-
: \`Streak: \${streak.days} days, last day \${streak.last}\`;
|
|
736
|
-
};
|
|
737
|
-
// the command opens the panel again with new properties: redraw
|
|
738
|
-
ctx.signal.addEventListener(
|
|
739
|
-
'abort',
|
|
740
|
-
ctx.onProps(() => void render()),
|
|
741
|
-
);
|
|
742
|
-
await render();
|
|
743
|
-
},
|
|
744
|
-
}),
|
|
745
|
-
} satisfies ExtensionPanels;
|
|
746
888
|
`;
|
|
747
|
-
const indexTestTs$
|
|
748
|
-
import
|
|
889
|
+
const indexTestTs$2 = (id) => `// @vitest-environment happy-dom
|
|
890
|
+
import { PANEL_HANDLE_KEY } from '@dolphy-app/extension-sdk';
|
|
891
|
+
import type {
|
|
892
|
+
JsonValue,
|
|
893
|
+
LearningEventPayloads,
|
|
894
|
+
PanelHandle,
|
|
895
|
+
} from '@dolphy-app/extension-sdk';
|
|
749
896
|
import {
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
loadEvents,
|
|
753
|
-
loadPanel,
|
|
897
|
+
createTestClient,
|
|
898
|
+
createTestServer,
|
|
754
899
|
} from '@dolphy-app/extension-sdk/testing';
|
|
755
900
|
import { afterEach, describe, expect, it } from 'vitest';
|
|
756
|
-
import {
|
|
901
|
+
import { createApp, h, nextTick, shallowReactive } from 'vue';
|
|
902
|
+
import { client, server } from '../src/index.ts';
|
|
903
|
+
import { StreakPanel } from '../src/streak-panel.ts';
|
|
757
904
|
|
|
758
905
|
const disposables: { dispose(): unknown }[] = [];
|
|
759
906
|
afterEach(async () => {
|
|
@@ -772,44 +919,65 @@ const attempt = (day: string, outcome: Attempt['outcome'] = 'passed'): Attempt =
|
|
|
772
919
|
at: Date.parse(\`\${day}T12:00:00Z\`),
|
|
773
920
|
});
|
|
774
921
|
|
|
775
|
-
const
|
|
776
|
-
const
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
922
|
+
const start = async () => {
|
|
923
|
+
const running = await createTestServer(server, { extensionId: '${id}' });
|
|
924
|
+
disposables.push(running);
|
|
925
|
+
return running;
|
|
926
|
+
};
|
|
927
|
+
|
|
928
|
+
// draws the panel the way the app does: the handle is provided to the component
|
|
929
|
+
const mountPanel = async (call: PanelHandle['call']) => {
|
|
930
|
+
const handle = shallowReactive({
|
|
931
|
+
panelId: '${id}.view',
|
|
932
|
+
props: undefined as JsonValue | undefined,
|
|
933
|
+
context: { courseId: null },
|
|
934
|
+
call,
|
|
780
935
|
});
|
|
781
|
-
const
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
936
|
+
const host = document.createElement('div');
|
|
937
|
+
document.body.append(host);
|
|
938
|
+
const app = createApp({ render: () => h(StreakPanel) });
|
|
939
|
+
app.provide(PANEL_HANDLE_KEY, handle);
|
|
940
|
+
app.mount(host);
|
|
941
|
+
disposables.push({
|
|
942
|
+
dispose: () => {
|
|
943
|
+
app.unmount();
|
|
944
|
+
host.remove();
|
|
945
|
+
},
|
|
785
946
|
});
|
|
786
|
-
|
|
787
|
-
|
|
947
|
+
// the panel asks a command: wait for the reply, then for the redraw
|
|
948
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
949
|
+
await nextTick();
|
|
950
|
+
return host;
|
|
788
951
|
};
|
|
789
952
|
|
|
790
953
|
describe('${id}: events and storage', () => {
|
|
954
|
+
it('subscribes to attempt.closed', async () => {
|
|
955
|
+
const running = await start();
|
|
956
|
+
expect(running.registration.events).toEqual(['attempt.closed']);
|
|
957
|
+
});
|
|
958
|
+
|
|
791
959
|
it('counts consecutive days, ignores a repeat on the same day', async () => {
|
|
792
|
-
const
|
|
793
|
-
await events.emit('attempt.closed', attempt('2026-10-01'));
|
|
794
|
-
await events.emit('attempt.closed', attempt('2026-10-01'));
|
|
795
|
-
await events.emit('attempt.closed', attempt('2026-10-02'));
|
|
796
|
-
expect(await storage.get('streak')).toEqual({
|
|
960
|
+
const running = await start();
|
|
961
|
+
await running.events.emit('attempt.closed', attempt('2026-10-01'));
|
|
962
|
+
await running.events.emit('attempt.closed', attempt('2026-10-01'));
|
|
963
|
+
await running.events.emit('attempt.closed', attempt('2026-10-02'));
|
|
964
|
+
expect(await running.storage.get('streak')).toEqual({
|
|
797
965
|
days: 2,
|
|
798
966
|
last: '2026-10-02',
|
|
799
967
|
});
|
|
800
968
|
});
|
|
801
969
|
|
|
802
970
|
it('a skipped day starts over; giving up leaves the streak alone', async () => {
|
|
803
|
-
const
|
|
804
|
-
await events.emit('attempt.closed', attempt('2026-10-01'));
|
|
805
|
-
await events.emit('attempt.closed', attempt('2026-10-02'));
|
|
806
|
-
await events.emit('attempt.closed', attempt('2026-10-03', 'gave-up'));
|
|
807
|
-
expect(await storage.get('streak')).toEqual({
|
|
971
|
+
const running = await start();
|
|
972
|
+
await running.events.emit('attempt.closed', attempt('2026-10-01'));
|
|
973
|
+
await running.events.emit('attempt.closed', attempt('2026-10-02'));
|
|
974
|
+
await running.events.emit('attempt.closed', attempt('2026-10-03', 'gave-up'));
|
|
975
|
+
expect(await running.storage.get('streak')).toEqual({
|
|
808
976
|
days: 2,
|
|
809
977
|
last: '2026-10-02',
|
|
810
978
|
});
|
|
811
|
-
await events.emit('attempt.closed', attempt('2026-10-05'));
|
|
812
|
-
expect(await storage.get('streak')).toEqual({
|
|
979
|
+
await running.events.emit('attempt.closed', attempt('2026-10-05'));
|
|
980
|
+
expect(await running.storage.get('streak')).toEqual({
|
|
813
981
|
days: 1,
|
|
814
982
|
last: '2026-10-05',
|
|
815
983
|
});
|
|
@@ -818,66 +986,73 @@ describe('${id}: events and storage', () => {
|
|
|
818
986
|
|
|
819
987
|
describe('${id}: commands and panel', () => {
|
|
820
988
|
it('without a streak the show command notifies, the data command returns zeros', async () => {
|
|
821
|
-
const
|
|
822
|
-
expect(await commands.run('${id}.show')).toMatchObject({
|
|
823
|
-
|
|
989
|
+
const running = await start();
|
|
990
|
+
expect(await running.commands.run('${id}.show')).toMatchObject({
|
|
991
|
+
kind: 'notify',
|
|
992
|
+
});
|
|
993
|
+
expect(await running.commands.run('${id}.data')).toEqual({
|
|
824
994
|
kind: 'data',
|
|
825
995
|
value: { days: 0, last: '' },
|
|
826
996
|
});
|
|
827
997
|
});
|
|
828
998
|
|
|
829
999
|
it('with a streak the show command opens the panel with the days', async () => {
|
|
830
|
-
const
|
|
831
|
-
await events.emit('attempt.closed', attempt('2026-10-01'));
|
|
832
|
-
expect(await commands.run('${id}.show')).toEqual({
|
|
1000
|
+
const running = await start();
|
|
1001
|
+
await running.events.emit('attempt.closed', attempt('2026-10-01'));
|
|
1002
|
+
expect(await running.commands.run('${id}.show')).toEqual({
|
|
833
1003
|
kind: 'openPanel',
|
|
834
1004
|
panelId: '${id}.view',
|
|
835
1005
|
props: { days: 1 },
|
|
836
1006
|
});
|
|
837
1007
|
});
|
|
838
1008
|
|
|
1009
|
+
it('the client adds the panel the show command opens', async () => {
|
|
1010
|
+
const running = await createTestClient(client, { extensionId: '${id}' });
|
|
1011
|
+
disposables.push(running);
|
|
1012
|
+
expect(running.panels.map((panel) => panel.id)).toEqual(['${id}.view']);
|
|
1013
|
+
});
|
|
1014
|
+
|
|
839
1015
|
it('the panel shows what the data command returns', async () => {
|
|
840
|
-
const
|
|
841
|
-
await events.emit('attempt.closed', attempt('2026-10-01'));
|
|
842
|
-
const panel = await
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
return result.kind === 'data' ? result.value : undefined;
|
|
846
|
-
},
|
|
1016
|
+
const running = await start();
|
|
1017
|
+
await running.events.emit('attempt.closed', attempt('2026-10-01'));
|
|
1018
|
+
const panel = await mountPanel(async (commandId) => {
|
|
1019
|
+
const result = await running.commands.run(commandId);
|
|
1020
|
+
return result.kind === 'data' ? (result.value as JsonValue) : undefined;
|
|
847
1021
|
});
|
|
848
|
-
|
|
849
|
-
expect(panel.container.querySelector('p')?.textContent).toBe(
|
|
1022
|
+
expect(panel.querySelector('p')?.textContent).toBe(
|
|
850
1023
|
'Streak: 1 days, last day 2026-10-01',
|
|
851
1024
|
);
|
|
852
1025
|
});
|
|
853
1026
|
});
|
|
854
1027
|
`;
|
|
855
1028
|
const events = {
|
|
856
|
-
summary: [
|
|
857
|
-
"A Dolphy extension: a day streak. It listens to `attempt.closed`, keeps",
|
|
858
|
-
"the streak in `ctx.storage`, and shows it with a command and a panel.",
|
|
859
|
-
"It asks for the `learning.events` permission: without it no event arrives."
|
|
860
|
-
],
|
|
1029
|
+
summary: ["A Dolphy extension: a day streak. It listens to `attempt.closed`, keeps", "the streak in `server.storage`, and shows it with a command and a panel."],
|
|
861
1030
|
layout: [
|
|
862
|
-
"- `extension.json` — the manifest
|
|
863
|
-
"
|
|
864
|
-
"- `src/
|
|
865
|
-
"
|
|
866
|
-
"
|
|
867
|
-
"
|
|
868
|
-
|
|
1031
|
+
"- `extension.json` — the manifest: identity only, the build adds `main` and",
|
|
1032
|
+
" `client`;",
|
|
1033
|
+
"- `src/server.ts` — `server` (`defineServer`): the event handler, the",
|
|
1034
|
+
" commands, `server.storage`; `src/streak.ts` is the streak arithmetic; the",
|
|
1035
|
+
" build writes them to `main.mjs`;",
|
|
1036
|
+
"- `src/client.ts` — `client` (`defineClient`): registers the panel with",
|
|
1037
|
+
" `addPanel`; `src/streak-panel.ts` is the Vue component the app draws; the",
|
|
1038
|
+
" build writes them to `client.mjs`;",
|
|
1039
|
+
"- `src/index.ts` — re-exports `server` and `client`;",
|
|
869
1040
|
"- `test/index.test.ts` — tests (`vitest`, `happy-dom`)."
|
|
870
1041
|
],
|
|
871
1042
|
files: (id) => ({
|
|
872
|
-
"extension.json": manifestJson$
|
|
873
|
-
"src/index.ts": indexTs$
|
|
874
|
-
"
|
|
1043
|
+
"extension.json": manifestJson$3(id),
|
|
1044
|
+
"src/index.ts": indexTs$3(),
|
|
1045
|
+
"src/streak.ts": streakTs(),
|
|
1046
|
+
"src/server.ts": serverTs$1(id),
|
|
1047
|
+
"src/client.ts": clientTs$1(id),
|
|
1048
|
+
"src/streak-panel.ts": streakPanelTs(id),
|
|
1049
|
+
"test/index.test.ts": indexTestTs$2(id)
|
|
875
1050
|
})
|
|
876
1051
|
};
|
|
877
1052
|
|
|
878
1053
|
//#endregion
|
|
879
1054
|
//#region packages/create-extension/src/templates/exercise.ts
|
|
880
|
-
const manifestJson$
|
|
1055
|
+
const manifestJson$2 = (id) => `{
|
|
881
1056
|
"$schema": "./node_modules/@dolphy-app/extension-api/dist/extension.schema.json",
|
|
882
1057
|
"id": "${id}",
|
|
883
1058
|
"version": "${INITIAL_VERSION}",
|
|
@@ -885,71 +1060,61 @@ const manifestJson$1 = (id) => `{
|
|
|
885
1060
|
"name": "Text match",
|
|
886
1061
|
"description": "Exercise type: the learner types a string that is compared with the expected text.",
|
|
887
1062
|
"author": "your-github-login",
|
|
888
|
-
"tags": ["learning"]
|
|
889
|
-
"contributes": {
|
|
890
|
-
"exerciseTypes": [
|
|
891
|
-
{
|
|
892
|
-
"id": "${id}",
|
|
893
|
-
"specSchema": {
|
|
894
|
-
"type": "object",
|
|
895
|
-
"required": ["expected"],
|
|
896
|
-
"additionalProperties": false,
|
|
897
|
-
"properties": {
|
|
898
|
-
"expected": { "type": "string", "minLength": 1 },
|
|
899
|
-
"ignoreCase": { "type": "boolean" }
|
|
900
|
-
}
|
|
901
|
-
},
|
|
902
|
-
"answerSchema": { "type": "string" }
|
|
903
|
-
}
|
|
904
|
-
],
|
|
905
|
-
"settings": [
|
|
906
|
-
{
|
|
907
|
-
"id": "${id}.trim",
|
|
908
|
-
"type": "boolean",
|
|
909
|
-
"label": "Ignore spaces around the answer",
|
|
910
|
-
"default": true
|
|
911
|
-
}
|
|
912
|
-
],
|
|
913
|
-
"commands": [
|
|
914
|
-
{ "id": "${id}.status", "title": "Show how answers are compared" }
|
|
915
|
-
]
|
|
916
|
-
}
|
|
1063
|
+
"tags": ["learning"]
|
|
917
1064
|
}
|
|
918
1065
|
`;
|
|
919
|
-
const indexTs = (
|
|
920
|
-
|
|
1066
|
+
const indexTs$2 = () => `export { client } from './client.ts';
|
|
1067
|
+
export { server } from './server.ts';
|
|
1068
|
+
`;
|
|
1069
|
+
const serverTs = (id) => `import {
|
|
921
1070
|
defineExerciseType,
|
|
922
|
-
|
|
923
|
-
inActivate,
|
|
1071
|
+
defineServer,
|
|
924
1072
|
notify,
|
|
925
1073
|
} from '@dolphy-app/extension-sdk';
|
|
926
|
-
import type { ExtensionViews } from '@dolphy-app/extension-sdk';
|
|
927
1074
|
|
|
928
1075
|
interface Spec {
|
|
929
1076
|
expected: string;
|
|
930
1077
|
ignoreCase?: boolean;
|
|
931
1078
|
}
|
|
932
1079
|
|
|
933
|
-
//
|
|
934
|
-
const
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
//
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
1080
|
+
// runs in the extension host: every call registers a contribution
|
|
1081
|
+
export const server = defineServer((s) => {
|
|
1082
|
+
s.registerSettings([
|
|
1083
|
+
{
|
|
1084
|
+
id: '${id}.trim',
|
|
1085
|
+
type: 'boolean',
|
|
1086
|
+
label: { en: 'Ignore spaces around the answer', ru: 'Игнорировать пробелы вокруг ответа' },
|
|
1087
|
+
default: true,
|
|
1088
|
+
},
|
|
1089
|
+
]);
|
|
1090
|
+
|
|
1091
|
+
// read when the handler runs, so a change in the settings applies at once
|
|
1092
|
+
const trims = (): boolean => s.settings.get('${id}.trim') === true;
|
|
1093
|
+
|
|
1094
|
+
const matches = (answer: string, spec: Spec): boolean => {
|
|
1095
|
+
const given = trims() ? answer.trim() : answer;
|
|
1096
|
+
if (spec.ignoreCase === true) {
|
|
1097
|
+
return given.toLowerCase() === spec.expected.toLowerCase();
|
|
1098
|
+
}
|
|
1099
|
+
return given === spec.expected;
|
|
1100
|
+
};
|
|
1101
|
+
|
|
1102
|
+
// the app checks \`spec\` and the answer against the schemas before the
|
|
1103
|
+
// handlers run
|
|
1104
|
+
s.registerExerciseType(
|
|
1105
|
+
defineExerciseType<Spec, string, Record<string, never>>({
|
|
1106
|
+
id: '${id}',
|
|
1107
|
+
title: 'Text match',
|
|
1108
|
+
specSchema: {
|
|
1109
|
+
type: 'object',
|
|
1110
|
+
required: ['expected'],
|
|
1111
|
+
additionalProperties: false,
|
|
1112
|
+
properties: {
|
|
1113
|
+
expected: { type: 'string', minLength: 1 },
|
|
1114
|
+
ignoreCase: { type: 'boolean' },
|
|
1115
|
+
},
|
|
1116
|
+
},
|
|
1117
|
+
answerSchema: { type: 'string' },
|
|
953
1118
|
project: () => ({}),
|
|
954
1119
|
grade: ({ spec, answer }) =>
|
|
955
1120
|
matches(answer, spec)
|
|
@@ -957,77 +1122,88 @@ export const host = defineExtension({
|
|
|
957
1122
|
: { outcome: 'failed', reason: 'mismatch' },
|
|
958
1123
|
referenceAnswer: ({ spec }) => spec.expected,
|
|
959
1124
|
}),
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
if (change.id === '${id}.trim') options.trim = change.value;
|
|
967
|
-
});
|
|
968
|
-
ctx.commands.register('${id}.status', () =>
|
|
1125
|
+
);
|
|
1126
|
+
|
|
1127
|
+
s.registerCommand({
|
|
1128
|
+
id: '${id}.status',
|
|
1129
|
+
title: { en: 'Show how answers are compared', ru: 'Показать способ сравнения' },
|
|
1130
|
+
run: () =>
|
|
969
1131
|
notify(
|
|
970
|
-
|
|
1132
|
+
trims()
|
|
971
1133
|
? 'Answers are compared without the spaces around them.'
|
|
972
1134
|
: 'Answers are compared exactly as typed.',
|
|
973
1135
|
),
|
|
974
|
-
|
|
975
|
-
},
|
|
1136
|
+
});
|
|
976
1137
|
});
|
|
1138
|
+
`;
|
|
1139
|
+
const clientTs = (id) => `import { defineClient } from '@dolphy-app/extension-sdk';
|
|
1140
|
+
import { TextAnswer } from './text-answer.ts';
|
|
977
1141
|
|
|
978
|
-
//
|
|
979
|
-
//
|
|
980
|
-
export const
|
|
981
|
-
'${id}'
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
}
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
}
|
|
1142
|
+
// runs in the app window: the answer view is a Vue component for the exercise
|
|
1143
|
+
// type that \`server\` registers
|
|
1144
|
+
export const client = defineClient((c) => {
|
|
1145
|
+
c.addAnswerView('${id}', TextAnswer);
|
|
1146
|
+
});
|
|
1147
|
+
`;
|
|
1148
|
+
const textAnswerTs = () => `import type { AnswerChange } from '@dolphy-app/extension-sdk';
|
|
1149
|
+
import { defineComponent, h, ref, watch } from 'vue';
|
|
1150
|
+
import type { PropType } from 'vue';
|
|
1151
|
+
|
|
1152
|
+
// the answer input: a Vue component the app draws in its own window tree. It
|
|
1153
|
+
// takes the props of \`AnswerViewProps\` and reports the answer with \`change\`;
|
|
1154
|
+
// \`submit\` asks the app to check it
|
|
1155
|
+
export const TextAnswer = defineComponent({
|
|
1156
|
+
props: {
|
|
1157
|
+
view: { type: null },
|
|
1158
|
+
value: { type: null },
|
|
1159
|
+
disabled: Boolean,
|
|
1160
|
+
verdict: { type: null },
|
|
1161
|
+
label: { type: String as PropType<string | null>, default: null },
|
|
1162
|
+
},
|
|
1163
|
+
emits: ['change', 'submit'],
|
|
1164
|
+
setup(props, { emit }) {
|
|
1165
|
+
const asText = (value: unknown): string =>
|
|
1166
|
+
typeof value === 'string' ? value : '';
|
|
1167
|
+
// what is typed stays on screen even if the app never returns \`value\`
|
|
1168
|
+
const text = ref(asText(props.value));
|
|
1169
|
+
watch(
|
|
1170
|
+
() => props.value,
|
|
1171
|
+
(value) => {
|
|
1172
|
+
text.value = asText(value);
|
|
1010
1173
|
},
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1174
|
+
);
|
|
1175
|
+
return () =>
|
|
1176
|
+
h('input', {
|
|
1177
|
+
type: 'text',
|
|
1178
|
+
spellcheck: false,
|
|
1179
|
+
value: text.value,
|
|
1180
|
+
disabled: props.disabled,
|
|
1181
|
+
'aria-label': props.label ?? undefined,
|
|
1182
|
+
onInput: (event: Event) => {
|
|
1183
|
+
text.value = (event.target as HTMLInputElement).value;
|
|
1184
|
+
const change: AnswerChange<string> = {
|
|
1185
|
+
value: text.value,
|
|
1186
|
+
complete: text.value.trim().length > 0,
|
|
1187
|
+
};
|
|
1188
|
+
emit('change', change);
|
|
1189
|
+
},
|
|
1190
|
+
onKeydown: (event: KeyboardEvent) => {
|
|
1191
|
+
if (event.key === 'Enter') emit('submit');
|
|
1192
|
+
},
|
|
1193
|
+
});
|
|
1194
|
+
},
|
|
1195
|
+
});
|
|
1014
1196
|
`;
|
|
1015
|
-
const indexTestTs = (id) => `// @vitest-environment happy-dom
|
|
1016
|
-
import type { SettingContribution } from '@dolphy-app/extension-sdk';
|
|
1197
|
+
const indexTestTs$1 = (id) => `// @vitest-environment happy-dom
|
|
1017
1198
|
import {
|
|
1018
|
-
createMemorySettings,
|
|
1019
1199
|
createSchemaValidator,
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
loadView,
|
|
1200
|
+
createTestClient,
|
|
1201
|
+
createTestServer,
|
|
1023
1202
|
} from '@dolphy-app/extension-sdk/testing';
|
|
1024
1203
|
import { afterEach, describe, expect, it } from 'vitest';
|
|
1025
|
-
import
|
|
1026
|
-
import {
|
|
1027
|
-
|
|
1028
|
-
const [contribution] = manifest.contributes.exerciseTypes;
|
|
1029
|
-
const validateSpec = createSchemaValidator(contribution.specSchema);
|
|
1030
|
-
const validateAnswer = createSchemaValidator(contribution.answerSchema);
|
|
1204
|
+
import { createApp, h, nextTick, reactive } from 'vue';
|
|
1205
|
+
import { client, server } from '../src/index.ts';
|
|
1206
|
+
import { TextAnswer } from '../src/text-answer.ts';
|
|
1031
1207
|
|
|
1032
1208
|
const spec = { expected: 'Hello' };
|
|
1033
1209
|
|
|
@@ -1036,31 +1212,66 @@ afterEach(async () => {
|
|
|
1036
1212
|
await Promise.all(disposables.splice(0).map((item) => item.dispose()));
|
|
1037
1213
|
});
|
|
1038
1214
|
|
|
1039
|
-
const
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
disposables.push(
|
|
1045
|
-
return
|
|
1215
|
+
const start = async (settingValues = {}) => {
|
|
1216
|
+
const running = await createTestServer(server, {
|
|
1217
|
+
extensionId: '${id}',
|
|
1218
|
+
settingValues,
|
|
1219
|
+
});
|
|
1220
|
+
disposables.push(running);
|
|
1221
|
+
return running;
|
|
1046
1222
|
};
|
|
1047
1223
|
|
|
1224
|
+
// mounts the answer view the way the app does: the props of \`AnswerViewProps\`
|
|
1225
|
+
// in, the \`change\` and \`submit\` events out
|
|
1048
1226
|
const mount = async (label?: string) => {
|
|
1049
|
-
const
|
|
1050
|
-
|
|
1051
|
-
|
|
1227
|
+
const props = reactive<Record<string, unknown>>({
|
|
1228
|
+
view: {},
|
|
1229
|
+
value: undefined,
|
|
1230
|
+
disabled: false,
|
|
1231
|
+
verdict: null,
|
|
1232
|
+
label: label ?? null,
|
|
1233
|
+
});
|
|
1234
|
+
const changes: unknown[] = [];
|
|
1235
|
+
let submissions = 0;
|
|
1236
|
+
const host = document.createElement('div');
|
|
1237
|
+
document.body.append(host);
|
|
1238
|
+
const app = createApp({
|
|
1239
|
+
render: () =>
|
|
1240
|
+
h(TextAnswer, {
|
|
1241
|
+
...props,
|
|
1242
|
+
onChange: (change: unknown) => changes.push(change),
|
|
1243
|
+
onSubmit: () => (submissions += 1),
|
|
1244
|
+
}),
|
|
1245
|
+
});
|
|
1246
|
+
app.mount(host);
|
|
1247
|
+
disposables.push({
|
|
1248
|
+
dispose: () => {
|
|
1249
|
+
app.unmount();
|
|
1250
|
+
host.remove();
|
|
1251
|
+
},
|
|
1252
|
+
});
|
|
1253
|
+
await nextTick();
|
|
1254
|
+
const input = host.querySelector('input');
|
|
1052
1255
|
if (input === null) throw new Error('no input');
|
|
1053
|
-
return {
|
|
1256
|
+
return {
|
|
1257
|
+
input,
|
|
1258
|
+
changes,
|
|
1259
|
+
submissions: () => submissions,
|
|
1260
|
+
update: async (next: Record<string, unknown>) => {
|
|
1261
|
+
Object.assign(props, next);
|
|
1262
|
+
await nextTick();
|
|
1263
|
+
},
|
|
1264
|
+
};
|
|
1054
1265
|
};
|
|
1055
1266
|
|
|
1056
1267
|
describe('${id}: handler', () => {
|
|
1057
1268
|
it('project does not reveal the reference', async () => {
|
|
1058
|
-
const type = await
|
|
1269
|
+
const type = (await start()).exerciseType('${id}');
|
|
1059
1270
|
expect(await type.project(spec)).toEqual({});
|
|
1060
1271
|
});
|
|
1061
1272
|
|
|
1062
1273
|
it('grade: a match passes, a mismatch does not', async () => {
|
|
1063
|
-
const type = await
|
|
1274
|
+
const type = (await start()).exerciseType('${id}');
|
|
1064
1275
|
expect(await type.grade({ spec, answer: 'Hello' })).toEqual({
|
|
1065
1276
|
outcome: 'passed',
|
|
1066
1277
|
});
|
|
@@ -1071,7 +1282,7 @@ describe('${id}: handler', () => {
|
|
|
1071
1282
|
});
|
|
1072
1283
|
|
|
1073
1284
|
it('grade: ignoreCase turns case sensitivity off', async () => {
|
|
1074
|
-
const type = await
|
|
1285
|
+
const type = (await start()).exerciseType('${id}');
|
|
1075
1286
|
const relaxed = { ...spec, ignoreCase: true };
|
|
1076
1287
|
expect(await type.grade({ spec: relaxed, answer: 'hELLO' })).toEqual({
|
|
1077
1288
|
outcome: 'passed',
|
|
@@ -1079,7 +1290,7 @@ describe('${id}: handler', () => {
|
|
|
1079
1290
|
});
|
|
1080
1291
|
|
|
1081
1292
|
it('referenceAnswer passes the check itself', async () => {
|
|
1082
|
-
const type = await
|
|
1293
|
+
const type = (await start()).exerciseType('${id}');
|
|
1083
1294
|
const reference = await type.referenceAnswer(spec);
|
|
1084
1295
|
expect(reference).toEqual({ found: true, answer: 'Hello' });
|
|
1085
1296
|
if (!reference.found) throw new Error('reference expected');
|
|
@@ -1091,12 +1302,12 @@ describe('${id}: handler', () => {
|
|
|
1091
1302
|
|
|
1092
1303
|
describe('${id}: settings and commands', () => {
|
|
1093
1304
|
it('the trim setting decides whether the spaces around an answer count', async () => {
|
|
1094
|
-
const
|
|
1095
|
-
const type =
|
|
1305
|
+
const running = await start();
|
|
1306
|
+
const type = running.exerciseType('${id}');
|
|
1096
1307
|
expect(await type.grade({ spec, answer: ' Hello ' })).toEqual({
|
|
1097
1308
|
outcome: 'passed',
|
|
1098
1309
|
});
|
|
1099
|
-
await settings.set('${id}.trim', false);
|
|
1310
|
+
await running.settings.set('${id}.trim', false);
|
|
1100
1311
|
expect(await type.grade({ spec, answer: ' Hello ' })).toEqual({
|
|
1101
1312
|
outcome: 'failed',
|
|
1102
1313
|
reason: 'mismatch',
|
|
@@ -1104,29 +1315,40 @@ describe('${id}: settings and commands', () => {
|
|
|
1104
1315
|
});
|
|
1105
1316
|
|
|
1106
1317
|
it('the status command reports the current mode', async () => {
|
|
1107
|
-
const
|
|
1108
|
-
|
|
1109
|
-
declaredCommands: ['${id}.status'],
|
|
1110
|
-
settings,
|
|
1111
|
-
});
|
|
1112
|
-
disposables.push(commands);
|
|
1113
|
-
expect(await commands.run('${id}.status')).toEqual({
|
|
1318
|
+
const running = await start();
|
|
1319
|
+
expect(await running.commands.run('${id}.status')).toEqual({
|
|
1114
1320
|
kind: 'notify',
|
|
1115
1321
|
text: 'Answers are compared without the spaces around them.',
|
|
1116
1322
|
});
|
|
1117
|
-
await settings.set('${id}.trim', false);
|
|
1118
|
-
expect(await commands.run('${id}.status')).toEqual({
|
|
1323
|
+
await running.settings.set('${id}.trim', false);
|
|
1324
|
+
expect(await running.commands.run('${id}.status')).toEqual({
|
|
1119
1325
|
kind: 'notify',
|
|
1120
1326
|
text: 'Answers are compared exactly as typed.',
|
|
1121
1327
|
});
|
|
1122
1328
|
});
|
|
1329
|
+
|
|
1330
|
+
it('a user value of the setting replaces the default', async () => {
|
|
1331
|
+
const running = await start({ '${id}.trim': false });
|
|
1332
|
+
expect(await running.commands.run('${id}.status')).toMatchObject({
|
|
1333
|
+
text: 'Answers are compared exactly as typed.',
|
|
1334
|
+
});
|
|
1335
|
+
});
|
|
1123
1336
|
});
|
|
1124
1337
|
|
|
1125
1338
|
describe('${id}: schemas', () => {
|
|
1339
|
+
const registered = async () => {
|
|
1340
|
+
const [type] = (await start()).registration.exerciseTypes;
|
|
1341
|
+
if (type === undefined) throw new Error('exercise type expected');
|
|
1342
|
+
return {
|
|
1343
|
+
validateSpec: createSchemaValidator(type.specSchema),
|
|
1344
|
+
validateAnswer: createSchemaValidator(type.answerSchema),
|
|
1345
|
+
};
|
|
1346
|
+
};
|
|
1347
|
+
|
|
1126
1348
|
it.each([[{ expected: 'a' }], [{ expected: 'a', ignoreCase: true }]])(
|
|
1127
1349
|
'spec %j is valid',
|
|
1128
|
-
(value) => {
|
|
1129
|
-
expect(validateSpec(value)).toEqual([]);
|
|
1350
|
+
async (value) => {
|
|
1351
|
+
expect((await registered()).validateSpec(value)).toEqual([]);
|
|
1130
1352
|
},
|
|
1131
1353
|
);
|
|
1132
1354
|
|
|
@@ -1135,49 +1357,58 @@ describe('${id}: schemas', () => {
|
|
|
1135
1357
|
['empty expected', { expected: '' }],
|
|
1136
1358
|
['ignoreCase is not a boolean', { expected: 'a', ignoreCase: 'yes' }],
|
|
1137
1359
|
['an extra field', { expected: 'a', extra: 1 }],
|
|
1138
|
-
])('spec: %s is rejected', (_name, value) => {
|
|
1139
|
-
expect(validateSpec(value)).not.toEqual([]);
|
|
1360
|
+
])('spec: %s is rejected', async (_name, value) => {
|
|
1361
|
+
expect((await registered()).validateSpec(value)).not.toEqual([]);
|
|
1140
1362
|
});
|
|
1141
1363
|
|
|
1142
|
-
it('answer: a string is valid, a number is not', () => {
|
|
1364
|
+
it('answer: a string is valid, a number is not', async () => {
|
|
1365
|
+
const { validateAnswer } = await registered();
|
|
1143
1366
|
expect(validateAnswer('text')).toEqual([]);
|
|
1144
1367
|
expect(validateAnswer(42)).not.toEqual([]);
|
|
1145
1368
|
});
|
|
1146
1369
|
});
|
|
1147
1370
|
|
|
1371
|
+
describe('${id}: client', () => {
|
|
1372
|
+
it('adds the answer view for the exercise type', async () => {
|
|
1373
|
+
const running = await createTestClient(client, { extensionId: '${id}' });
|
|
1374
|
+
disposables.push(running);
|
|
1375
|
+
expect(running.answerViews.get('${id}')).toBe(TextAnswer);
|
|
1376
|
+
});
|
|
1377
|
+
});
|
|
1378
|
+
|
|
1148
1379
|
describe('${id}: view', () => {
|
|
1149
1380
|
it('typing reports the answer; an empty input is incomplete', async () => {
|
|
1150
|
-
const {
|
|
1381
|
+
const { changes, input } = await mount();
|
|
1151
1382
|
input.value = 'Hello';
|
|
1152
1383
|
input.dispatchEvent(new Event('input'));
|
|
1153
1384
|
input.value = ' ';
|
|
1154
1385
|
input.dispatchEvent(new Event('input'));
|
|
1155
|
-
expect(
|
|
1386
|
+
expect(changes).toEqual([
|
|
1156
1387
|
{ value: 'Hello', complete: true },
|
|
1157
1388
|
{ value: ' ', complete: false },
|
|
1158
1389
|
]);
|
|
1159
1390
|
});
|
|
1160
1391
|
|
|
1161
1392
|
it('Enter submits the answer', async () => {
|
|
1162
|
-
const {
|
|
1393
|
+
const { input, submissions } = await mount();
|
|
1163
1394
|
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' }));
|
|
1164
|
-
expect(
|
|
1395
|
+
expect(submissions()).toBe(1);
|
|
1165
1396
|
});
|
|
1166
1397
|
|
|
1167
1398
|
it('disabled blocks the input', async () => {
|
|
1168
|
-
const {
|
|
1169
|
-
await
|
|
1399
|
+
const { input, update } = await mount();
|
|
1400
|
+
await update({ disabled: true });
|
|
1170
1401
|
expect(input.disabled).toBe(true);
|
|
1171
1402
|
});
|
|
1172
1403
|
|
|
1173
1404
|
it('value restores the answer without events', async () => {
|
|
1174
|
-
const {
|
|
1175
|
-
await
|
|
1405
|
+
const { changes, input, update } = await mount();
|
|
1406
|
+
await update({ value: 'Hello' });
|
|
1176
1407
|
expect(input.value).toBe('Hello');
|
|
1177
|
-
expect(
|
|
1408
|
+
expect(changes).toEqual([]);
|
|
1178
1409
|
});
|
|
1179
1410
|
|
|
1180
|
-
it('the
|
|
1411
|
+
it('the label of the app becomes the aria-label of the input', async () => {
|
|
1181
1412
|
const { input } = await mount('Your answer');
|
|
1182
1413
|
expect(input.getAttribute('aria-label')).toBe('Your answer');
|
|
1183
1414
|
});
|
|
@@ -1186,19 +1417,212 @@ describe('${id}: view', () => {
|
|
|
1186
1417
|
const exercise = {
|
|
1187
1418
|
summary: ["A Dolphy extension: the \"text match\" exercise type (the learner types a", "string, it is compared with `spec.expected`), a setting and a command."],
|
|
1188
1419
|
layout: [
|
|
1189
|
-
"- `extension.json` — the manifest
|
|
1190
|
-
"
|
|
1191
|
-
"- `src/
|
|
1192
|
-
" `
|
|
1193
|
-
" it
|
|
1194
|
-
|
|
1420
|
+
"- `extension.json` — the manifest: identity only, the build adds `main` and",
|
|
1421
|
+
" `client`;",
|
|
1422
|
+
"- `src/server.ts` — `server` (`defineServer`): the exercise type with the",
|
|
1423
|
+
" `spec` and answer schemas, the setting and the command; the build writes",
|
|
1424
|
+
" it to `main.mjs`;",
|
|
1425
|
+
"- `src/client.ts` — `client` (`defineClient`): adds the answer view with",
|
|
1426
|
+
" `addAnswerView`; `src/text-answer.ts` is the Vue component that is the",
|
|
1427
|
+
" answer input; the build writes them to `client.mjs`;",
|
|
1428
|
+
"- `src/index.ts` — re-exports `server` and `client`;",
|
|
1195
1429
|
"- `test/index.test.ts` — tests (`vitest`, `happy-dom`)."
|
|
1196
1430
|
],
|
|
1431
|
+
files: (id) => ({
|
|
1432
|
+
"extension.json": manifestJson$2(id),
|
|
1433
|
+
"src/index.ts": indexTs$2(),
|
|
1434
|
+
"src/server.ts": serverTs(id),
|
|
1435
|
+
"src/client.ts": clientTs(id),
|
|
1436
|
+
"src/text-answer.ts": textAnswerTs(),
|
|
1437
|
+
"test/index.test.ts": indexTestTs$1(id)
|
|
1438
|
+
})
|
|
1439
|
+
};
|
|
1440
|
+
|
|
1441
|
+
//#endregion
|
|
1442
|
+
//#region packages/create-extension/src/templates/react-panel.ts
|
|
1443
|
+
const manifestJson$1 = (id) => `{
|
|
1444
|
+
"$schema": "./node_modules/@dolphy-app/extension-api/dist/extension.schema.json",
|
|
1445
|
+
"id": "${id}",
|
|
1446
|
+
"version": "${INITIAL_VERSION}",
|
|
1447
|
+
"apiVersion": 1,
|
|
1448
|
+
"name": "Hello React panel",
|
|
1449
|
+
"description": "Palette commands that greet the learner and open a panel drawn with React.",
|
|
1450
|
+
"author": "your-github-login",
|
|
1451
|
+
"tags": ["productivity"]
|
|
1452
|
+
}
|
|
1453
|
+
`;
|
|
1454
|
+
const configJson = () => `{
|
|
1455
|
+
"frameworks": ["react"]
|
|
1456
|
+
}
|
|
1457
|
+
`;
|
|
1458
|
+
const indexTs$1 = () => `export { client } from './client.tsx';
|
|
1459
|
+
export { server } from './server.ts';
|
|
1460
|
+
`;
|
|
1461
|
+
const clientTsx = (id) => `import { defineClient } from '@dolphy-app/extension-sdk';
|
|
1462
|
+
import type { PanelHandle, PanelProps } from '@dolphy-app/extension-sdk';
|
|
1463
|
+
import { reactComponent, usePanel } from '@dolphy-app/extension-sdk/react';
|
|
1464
|
+
import { useEffect, useState } from 'react';
|
|
1465
|
+
|
|
1466
|
+
// \`reactComponent\` draws this component with React and gives it the props of
|
|
1467
|
+
// the panel; \`usePanel()\` is the handle with \`call\` for the commands of the
|
|
1468
|
+
// extension
|
|
1469
|
+
const HelloPanel = ({ props }: PanelProps) => {
|
|
1470
|
+
const panel = usePanel();
|
|
1471
|
+
const [message, setMessage] = useState('');
|
|
1472
|
+
// the app opens the panel again with new properties: the component renders again
|
|
1473
|
+
const name =
|
|
1474
|
+
typeof props === 'object' && props !== null && 'name' in props
|
|
1475
|
+
? String(props.name)
|
|
1476
|
+
: 'world';
|
|
1477
|
+
|
|
1478
|
+
const load = async () => {
|
|
1479
|
+
const data = await panel.call('${id}.data');
|
|
1480
|
+
setMessage((data as { message: string }).message);
|
|
1481
|
+
};
|
|
1482
|
+
useEffect(() => {
|
|
1483
|
+
void load();
|
|
1484
|
+
}, []);
|
|
1485
|
+
|
|
1486
|
+
return (
|
|
1487
|
+
<section>
|
|
1488
|
+
<h2>Hello, {name}!</h2>
|
|
1489
|
+
<p>{message}</p>
|
|
1490
|
+
<button type="button" onClick={() => void load()}>
|
|
1491
|
+
Reload
|
|
1492
|
+
</button>
|
|
1493
|
+
</section>
|
|
1494
|
+
);
|
|
1495
|
+
};
|
|
1496
|
+
|
|
1497
|
+
// runs in the app window: the panel is a \`Mountable\` the app draws into its own element
|
|
1498
|
+
export const client = defineClient((c) => {
|
|
1499
|
+
c.addPanel({
|
|
1500
|
+
id: '${id}.view',
|
|
1501
|
+
title: { en: 'Hello', ru: 'Привет' },
|
|
1502
|
+
component: reactComponent<PanelProps, PanelHandle>(HelloPanel),
|
|
1503
|
+
});
|
|
1504
|
+
});
|
|
1505
|
+
`;
|
|
1506
|
+
const indexTestTs = (id) => `// @vitest-environment happy-dom
|
|
1507
|
+
import { isMountable } from '@dolphy-app/extension-sdk';
|
|
1508
|
+
import type { PanelHandle, PanelProps } from '@dolphy-app/extension-sdk';
|
|
1509
|
+
import {
|
|
1510
|
+
createTestClient,
|
|
1511
|
+
createTestServer,
|
|
1512
|
+
mountForTest,
|
|
1513
|
+
} from '@dolphy-app/extension-sdk/testing';
|
|
1514
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
1515
|
+
import { client, server } from '../src/index.ts';
|
|
1516
|
+
|
|
1517
|
+
const disposables: { dispose(): unknown }[] = [];
|
|
1518
|
+
afterEach(async () => {
|
|
1519
|
+
await Promise.all(disposables.splice(0).map((item) => item.dispose()));
|
|
1520
|
+
});
|
|
1521
|
+
|
|
1522
|
+
${serverTestsTs(id)}
|
|
1523
|
+
|
|
1524
|
+
describe('${id}: client', () => {
|
|
1525
|
+
it('adds the panel that the open command points to', async () => {
|
|
1526
|
+
const running = await createTestClient(client, { extensionId: '${id}' });
|
|
1527
|
+
disposables.push(running);
|
|
1528
|
+
expect(running.panels.map((panel) => panel.id)).toEqual(['${id}.view']);
|
|
1529
|
+
expect(isMountable(running.panels[0]?.component)).toBe(true);
|
|
1530
|
+
});
|
|
1531
|
+
});
|
|
1532
|
+
|
|
1533
|
+
// draws the panel the way the app does: into an element, on a context the test controls
|
|
1534
|
+
const mountPanel = async (
|
|
1535
|
+
props: PanelProps['props'],
|
|
1536
|
+
call: PanelHandle['call'],
|
|
1537
|
+
) => {
|
|
1538
|
+
const running = await createTestClient(client, { extensionId: '${id}' });
|
|
1539
|
+
disposables.push(running);
|
|
1540
|
+
const component = running.panels[0]?.component;
|
|
1541
|
+
if (!isMountable(component)) throw new Error('the panel is not a Mountable');
|
|
1542
|
+
const panelProps: PanelProps = {
|
|
1543
|
+
panelId: '${id}.view',
|
|
1544
|
+
props,
|
|
1545
|
+
context: { courseId: null },
|
|
1546
|
+
};
|
|
1547
|
+
const mounted = await mountForTest(component, {
|
|
1548
|
+
props: panelProps,
|
|
1549
|
+
handle: { ...panelProps, call },
|
|
1550
|
+
});
|
|
1551
|
+
disposables.push({ dispose: () => mounted.unmount() });
|
|
1552
|
+
return { mounted, panelProps };
|
|
1553
|
+
};
|
|
1554
|
+
|
|
1555
|
+
describe('${id}: panel', () => {
|
|
1556
|
+
it('shows the data command reply and follows new properties', async () => {
|
|
1557
|
+
const calls: string[] = [];
|
|
1558
|
+
const { mounted, panelProps } = await mountPanel(
|
|
1559
|
+
{ name: 'Ada' },
|
|
1560
|
+
async (commandId) => {
|
|
1561
|
+
calls.push(commandId);
|
|
1562
|
+
return { message: 'Hello from the test' };
|
|
1563
|
+
},
|
|
1564
|
+
);
|
|
1565
|
+
expect(mounted.el.querySelector('h2')?.textContent).toBe('Hello, Ada!');
|
|
1566
|
+
await vi.waitFor(() =>
|
|
1567
|
+
expect(mounted.el.querySelector('p')?.textContent).toBe(
|
|
1568
|
+
'Hello from the test',
|
|
1569
|
+
),
|
|
1570
|
+
);
|
|
1571
|
+
expect(calls).toEqual(['${id}.data']);
|
|
1572
|
+
|
|
1573
|
+
mounted.setProps({ ...panelProps, props: { name: 'Grace' } });
|
|
1574
|
+
expect(mounted.el.querySelector('h2')?.textContent).toBe('Hello, Grace!');
|
|
1575
|
+
});
|
|
1576
|
+
|
|
1577
|
+
it('greets the world when it is opened without properties', async () => {
|
|
1578
|
+
const { mounted } = await mountPanel(undefined, async () => ({
|
|
1579
|
+
message: 'x',
|
|
1580
|
+
}));
|
|
1581
|
+
expect(mounted.el.querySelector('h2')?.textContent).toBe('Hello, world!');
|
|
1582
|
+
});
|
|
1583
|
+
|
|
1584
|
+
it('asks the data command again when the button is pressed', async () => {
|
|
1585
|
+
const calls: string[] = [];
|
|
1586
|
+
const { mounted } = await mountPanel(undefined, async (commandId) => {
|
|
1587
|
+
calls.push(commandId);
|
|
1588
|
+
return { message: 'x' };
|
|
1589
|
+
});
|
|
1590
|
+
mounted.el.querySelector('button')?.click();
|
|
1591
|
+
await vi.waitFor(() => expect(calls).toEqual(['${id}.data', '${id}.data']));
|
|
1592
|
+
});
|
|
1593
|
+
});
|
|
1594
|
+
`;
|
|
1595
|
+
const reactPanel = {
|
|
1596
|
+
summary: ["A Dolphy extension: two commands in the command palette (Ctrl/⌘+K), a hidden", "data command and a panel drawn with React instead of Vue."],
|
|
1597
|
+
layout: [
|
|
1598
|
+
"- `extension.json` — the manifest: identity only, the build adds `main` and",
|
|
1599
|
+
" `client`;",
|
|
1600
|
+
"- `dolphy-ext.config.json` — `\"frameworks\": [\"react\"]` switches the React",
|
|
1601
|
+
" build on (`.tsx` files, `react` and `react-dom` go into `client.mjs`);",
|
|
1602
|
+
"- `src/server.ts` — `server` (`defineServer`): the command handlers; the",
|
|
1603
|
+
" build writes it to `main.mjs`;",
|
|
1604
|
+
"- `src/client.tsx` — `client` (`defineClient`): registers the panel with",
|
|
1605
|
+
" `addPanel`; `reactComponent` from `@dolphy-app/extension-sdk/react` turns",
|
|
1606
|
+
" the React component into what the app draws; the build writes it to",
|
|
1607
|
+
" `client.mjs`;",
|
|
1608
|
+
"- `src/index.ts` — re-exports `server` and `client`;",
|
|
1609
|
+
"- `test/index.test.ts` — tests (`vitest`, `happy-dom`, `mountForTest`)."
|
|
1610
|
+
],
|
|
1197
1611
|
files: (id) => ({
|
|
1198
1612
|
"extension.json": manifestJson$1(id),
|
|
1199
|
-
"
|
|
1613
|
+
"dolphy-ext.config.json": configJson(),
|
|
1614
|
+
"src/index.ts": indexTs$1(),
|
|
1615
|
+
"src/server.ts": serverTs$2(id),
|
|
1616
|
+
"src/client.tsx": clientTsx(id),
|
|
1200
1617
|
"test/index.test.ts": indexTestTs(id)
|
|
1201
|
-
})
|
|
1618
|
+
}),
|
|
1619
|
+
devDependencies: {
|
|
1620
|
+
"@types/react": "^19.3.0",
|
|
1621
|
+
"@types/react-dom": "^19.3.0",
|
|
1622
|
+
react: "^19.3.0",
|
|
1623
|
+
"react-dom": "^19.3.0"
|
|
1624
|
+
},
|
|
1625
|
+
compilerOptions: { jsx: "\"react-jsx\"" }
|
|
1202
1626
|
};
|
|
1203
1627
|
|
|
1204
1628
|
//#endregion
|
|
@@ -1211,32 +1635,42 @@ const manifestJson = (id) => `{
|
|
|
1211
1635
|
"name": "Midnight",
|
|
1212
1636
|
"description": "A dark color theme with an amber accent for the Dolphy app.",
|
|
1213
1637
|
"author": "your-github-login",
|
|
1214
|
-
"tags": ["theme"]
|
|
1215
|
-
"contributes": {
|
|
1216
|
-
"themes": [
|
|
1217
|
-
{
|
|
1218
|
-
"id": "${id}",
|
|
1219
|
-
"label": "Midnight",
|
|
1220
|
-
"dark": true,
|
|
1221
|
-
"colors": {
|
|
1222
|
-
"background": "#101820",
|
|
1223
|
-
"surface": "#1B2733",
|
|
1224
|
-
"on-background": "#E6EDF3",
|
|
1225
|
-
"on-surface": "#E6EDF3",
|
|
1226
|
-
"primary": "#FFB000",
|
|
1227
|
-
"on-primary": "#101820"
|
|
1228
|
-
},
|
|
1229
|
-
"variables": { "border-opacity": 0.2 }
|
|
1230
|
-
}
|
|
1231
|
-
]
|
|
1232
|
-
}
|
|
1638
|
+
"tags": ["theme"]
|
|
1233
1639
|
}
|
|
1234
1640
|
`;
|
|
1235
|
-
const
|
|
1236
|
-
|
|
1641
|
+
const themeTs = (id) => `import type { ThemeRegistration } from '@dolphy-app/extension-sdk';
|
|
1642
|
+
|
|
1643
|
+
// the allowed color and variable keys are \`THEME_COLOR_KEYS\` and
|
|
1644
|
+
// \`THEME_VARIABLE_KEYS\` of '@dolphy-app/extension-sdk'
|
|
1645
|
+
export const midnight: ThemeRegistration = {
|
|
1646
|
+
id: '${id}',
|
|
1647
|
+
label: 'Midnight',
|
|
1648
|
+
dark: true,
|
|
1649
|
+
colors: {
|
|
1650
|
+
background: '#101820',
|
|
1651
|
+
surface: '#1B2733',
|
|
1652
|
+
'on-background': '#E6EDF3',
|
|
1653
|
+
'on-surface': '#E6EDF3',
|
|
1654
|
+
primary: '#FFB000',
|
|
1655
|
+
'on-primary': '#101820',
|
|
1656
|
+
},
|
|
1657
|
+
variables: { 'border-opacity': 0.2 },
|
|
1658
|
+
};
|
|
1659
|
+
`;
|
|
1660
|
+
const indexTs = () => `import { defineClient } from '@dolphy-app/extension-sdk';
|
|
1661
|
+
import { midnight } from './theme.ts';
|
|
1237
1662
|
|
|
1238
|
-
|
|
1239
|
-
const
|
|
1663
|
+
// runs in the app window: a theme is data, there is no server part
|
|
1664
|
+
export const client = defineClient((c) => {
|
|
1665
|
+
c.addTheme(midnight);
|
|
1666
|
+
});
|
|
1667
|
+
`;
|
|
1668
|
+
const themeTestTs = (id) => `import { createTestClient } from '@dolphy-app/extension-sdk/testing';
|
|
1669
|
+
import { describe, expect, it } from 'vitest';
|
|
1670
|
+
import { client } from '../src/index.ts';
|
|
1671
|
+
import { midnight } from '../src/theme.ts';
|
|
1672
|
+
|
|
1673
|
+
const colors = midnight.colors;
|
|
1240
1674
|
|
|
1241
1675
|
// WCAG relative luminance of a #rrggbb color
|
|
1242
1676
|
const luminance = (hex: string): number => {
|
|
@@ -1255,6 +1689,12 @@ const contrast = (foreground: string, background: string): number => {
|
|
|
1255
1689
|
};
|
|
1256
1690
|
|
|
1257
1691
|
describe('${id}: theme', () => {
|
|
1692
|
+
it('the client adds the theme', async () => {
|
|
1693
|
+
const running = await createTestClient(client, { extensionId: '${id}' });
|
|
1694
|
+
expect(running.themes).toEqual([midnight]);
|
|
1695
|
+
await running.dispose();
|
|
1696
|
+
});
|
|
1697
|
+
|
|
1258
1698
|
it.each([
|
|
1259
1699
|
['on-surface', 'surface'],
|
|
1260
1700
|
['on-background', 'background'],
|
|
@@ -1267,20 +1707,25 @@ describe('${id}: theme', () => {
|
|
|
1267
1707
|
it('a dark theme has a dark background and a light text', () => {
|
|
1268
1708
|
const background = luminance(colors['background'] as string);
|
|
1269
1709
|
const text = luminance(colors['on-background'] as string);
|
|
1270
|
-
expect(
|
|
1710
|
+
expect(midnight.dark ? background < text : background > text).toBe(true);
|
|
1271
1711
|
});
|
|
1272
1712
|
});
|
|
1273
1713
|
`;
|
|
1274
1714
|
const theme = {
|
|
1275
|
-
summary: ["A Dolphy extension: a color theme (\"Midnight\"). A theme is data
|
|
1715
|
+
summary: ["A Dolphy extension: a color theme (\"Midnight\"). A theme is data the client", "part registers with `addTheme`; the test checks the text contrast."],
|
|
1276
1716
|
layout: [
|
|
1277
|
-
"- `extension.json` — the manifest:
|
|
1278
|
-
"
|
|
1279
|
-
"
|
|
1280
|
-
"
|
|
1717
|
+
"- `extension.json` — the manifest: identity only, the build adds `client`;",
|
|
1718
|
+
"- `src/theme.ts` — the theme: `colors` (the allowed keys are listed in the",
|
|
1719
|
+
" Dolphy extension guide) and `variables`;",
|
|
1720
|
+
"- `src/index.ts` — `client` (`defineClient`), which adds the theme; the",
|
|
1721
|
+
" build writes it to `client.mjs`; there is no server part;",
|
|
1722
|
+
"- `test/theme.test.ts` — checks the registration and the contrast of the",
|
|
1723
|
+
" text colors (`vitest`)."
|
|
1281
1724
|
],
|
|
1282
1725
|
files: (id) => ({
|
|
1283
1726
|
"extension.json": manifestJson(id),
|
|
1727
|
+
"src/theme.ts": themeTs(id),
|
|
1728
|
+
"src/index.ts": indexTs(),
|
|
1284
1729
|
"test/theme.test.ts": themeTestTs(id)
|
|
1285
1730
|
})
|
|
1286
1731
|
};
|
|
@@ -1291,6 +1736,7 @@ const TEMPLATES = {
|
|
|
1291
1736
|
exercise,
|
|
1292
1737
|
theme,
|
|
1293
1738
|
"command-panel": commandPanel,
|
|
1739
|
+
"react-panel": reactPanel,
|
|
1294
1740
|
events,
|
|
1295
1741
|
blank
|
|
1296
1742
|
};
|
|
@@ -1299,7 +1745,7 @@ const isTemplateName = (name) => TEMPLATE_NAMES.includes(name);
|
|
|
1299
1745
|
const MAX_ID_CHARS = 64;
|
|
1300
1746
|
/** Without a build (sources, `--local`) the package version is a placeholder. */
|
|
1301
1747
|
const UNPUBLISHED_VERSION = "^0.0.0";
|
|
1302
|
-
const builtPackageVersion = () => "0.
|
|
1748
|
+
const builtPackageVersion = () => "0.5.0";
|
|
1303
1749
|
var GenerateError = class extends Error {
|
|
1304
1750
|
code;
|
|
1305
1751
|
constructor(code, message) {
|
|
@@ -1357,8 +1803,8 @@ const renderProject = (input) => {
|
|
|
1357
1803
|
const { id, template = DEFAULT_TEMPLATE } = input;
|
|
1358
1804
|
const module = TEMPLATES[template];
|
|
1359
1805
|
return new Map([
|
|
1360
|
-
["package.json", packageJson(input)],
|
|
1361
|
-
["tsconfig.json", tsconfigJson()],
|
|
1806
|
+
["package.json", packageJson(input, module)],
|
|
1807
|
+
["tsconfig.json", tsconfigJson(module)],
|
|
1362
1808
|
...Object.entries(module.files(id)),
|
|
1363
1809
|
["README.md", readme(id, module)],
|
|
1364
1810
|
["AGENTS.md", agentsMd(id, module)],
|
|
@@ -1404,7 +1850,7 @@ const USAGE = `usage: create-dolphy-extension <dir> [--id <id>] [--template <nam
|
|
|
1404
1850
|
<dir> new project directory (must be empty or not exist)
|
|
1405
1851
|
--id <id> extension id (default: kebab-case of the directory name)
|
|
1406
1852
|
--template <name> project kind: exercise (default), theme, command-panel,
|
|
1407
|
-
events or blank
|
|
1853
|
+
react-panel, events or blank
|
|
1408
1854
|
--local <repoRoot> Dolphy repository root: @dolphy-app/extension-sdk and
|
|
1409
1855
|
@dolphy-app/extension-tools are linked as link:<repoRoot>/packages/...
|
|
1410
1856
|
--help show this help
|