@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.
Files changed (3) hide show
  1. package/README.md +3 -6
  2. package/dist/cli/main.js +977 -531
  3. 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/locale.ts
69
- /** Limits of a translation file; the values are checked at discovery and by the tools. */
70
- const LOCALE_LIMITS = Object.freeze({
71
- /** Size of the file in bytes. */
72
- fileBytes: 65536,
73
- keys: 500,
74
- /** Longest value in UTF-16 code units. */
75
- valueLength: 500
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 widgets; the manifest and the app enforce them. */
86
- const EXTENSION_WIDGET_LIMITS = Object.freeze({
87
- /** Widgets per extension. */
88
- widgets: 3,
89
- /** Smallest allowed `minHeight`, px. */
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 manifest and the engine. */
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 manifest, host, and engine. */
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, widget or `keybindings[]` entry). */
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 manifest, host, and engine check the same numbers. */
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 `ctx.stats`; the engine enforces them. */
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 `ctx.notifications`; the engine enforces them. */
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
- types: "dolphy-ext types",
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\", \".dolphy/ids.d.ts\"]",
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, mounted answer inputs are recreated. Load errors are shown in",
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
- "pnpm typecheck # writes .dolphy/ids.d.ts, then tsc",
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 (read only by an",
325
- "unpackaged app)."
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
- "- `pnpm types` — write `.dolphy/ids.d.ts` from `extension.json`;",
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` is the only declaration: every id the code registers",
362
- " is declared there first. Never edit `.dolphy/ids.d.ts` or `dist-ext/`.",
363
- `- Keep ids prefixed with the extension id (\`${id}\`).`,
364
- "- Extension code runs in the extension process (`host`), the app window",
365
- " (`views`) or an isolated frame (`panels`, no network). Do not import",
366
- " `node:*` modules into code that runs in the window or a frame.",
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
- "- Ask only for the permissions the extension uses, and say in `README.md`",
370
- " what each one is for.",
371
- "- No `eval`, no `new Function`, no minified or obfuscated sources, no",
372
- " `http(s)://` URLs unless the manifest declares the `network`",
373
- " permission: `pnpm lint` and the catalog review flag them.",
374
- "- Keep tests next to the behaviour: `@dolphy-app/extension-sdk/testing`",
375
- " runs handlers, commands, events, views and panels without the app.",
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$4 = (id) => `{
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$3 = (id) => `import { defineExtension, notify } from '@dolphy-app/extension-sdk';
427
-
428
- // extension code: runs in the extension process of the app
429
- // the command id comes from extension.json: a misspelt id or a declared id
430
- // without a handler fails \`pnpm typecheck\`
431
- export const host = defineExtension({
432
- commands: {
433
- '${id}.hello': () => notify('Hello from ${id}!'),
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$3 = (id) => `import { loadCommands } from '@dolphy-app/extension-sdk/testing';
473
+ const indexTestTs$4 = (id) => `import { createTestServer } from '@dolphy-app/extension-sdk/testing';
438
474
  import { expect, it } from 'vitest';
439
- import { host } from '../src/index.ts';
475
+ import { server } from '../src/index.ts';
440
476
 
441
477
  it('the hello command notifies', async () => {
442
- const commands = await loadCommands(host, {
443
- declaredCommands: ['${id}.hello'],
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 commands.dispose();
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 (the command is declared in it);",
456
- "- `src/index.ts` — all the extension code: `host` (`defineExtension`); the",
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$4(id),
463
- "src/index.ts": indexTs$3(id),
464
- "test/index.test.ts": indexTestTs$3(id)
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$3 = (id) => `{
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$2 = (id) => `import {
490
- defineExtension,
491
- defineExtensionPanel,
492
- notify,
493
- openPanel,
494
- } from '@dolphy-app/extension-sdk';
495
- import type { ExtensionPanels } from '@dolphy-app/extension-sdk';
496
-
497
- // extension code: \`host\` runs in the extension process of the app
498
- // the ids come from extension.json: a misspelt id or a declared id without a
499
- // handler fails \`pnpm typecheck\`
500
- export const host = defineExtension({
501
- commands: {
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
- // palette command: opens the panel with properties
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
- // the panel runs in an isolated frame of the app window: no network, no
515
- // window.dolphy; the only way out is \`ctx.call\` to the commands above
516
- export const panels = {
517
- '${id}.view': defineExtensionPanel({
518
- async mount(container, ctx) {
519
- const doc = container.ownerDocument;
520
- const title = doc.createElement('h2');
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
- const disposables: { dispose(): unknown }[] = [];
550
- afterEach(async () => {
551
- await Promise.all(disposables.splice(0).map((item) => item.dispose()));
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 commands = await loadCommands(host, {
556
- declaredCommands: ['${id}.hello', '${id}.open', '${id}.data'],
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
- describe('${id}: commands', () => {
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 commands = await load();
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 commands = await load();
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 commands = await load();
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 panel = await loadPanel(panels, '${id}.view', {
597
- props: { name: 'Ada' },
598
- call: () => ({ message: 'Hello from the test' }),
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
- disposables.push(panel);
601
- expect(panel.container.querySelector('h2')?.textContent).toBe('Hello, Ada!');
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(panel.calls).toEqual([{ commandId: '${id}.data', args: undefined }]);
727
+ expect(calls).toEqual(['${id}.data']);
606
728
 
607
- panel.setProps({ name: 'Grace' });
608
- expect(panel.container.querySelector('h2')?.textContent).toBe(
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('stops listening for properties when the panel closes', async () => {
614
- const panel = await loadPanel(panels, '${id}.view', {
615
- call: () => ({ message: 'x' }),
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
- const heading = panel.container.querySelector('h2');
618
- panel.dispose();
619
- expect(panel.aborted).toBe(true);
620
- panel.setProps({ name: 'Late' });
621
- expect(heading?.textContent).toBe('Hello, world!');
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 (commands and the panel are declared in it);",
629
- "- `src/index.ts` — all the extension code: `host` (`defineExtension`, the",
630
- " command handlers) and `panels` (`defineExtensionPanel`); the build",
631
- " splits it into `main.mjs` and `panel.mjs`;",
632
- ...idsBullet,
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$3(id),
637
- "src/index.ts": indexTs$2(id),
638
- "test/index.test.ts": indexTestTs$2(id)
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$2 = (id) => `{
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
- "permissions": ["learning.events"],
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$1 = (id) => `import {
665
- defineExtension,
666
- defineExtensionPanel,
667
- inActivate,
668
- notify,
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
- // every event and command declared in extension.json is listed here;
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
- // data for the panel: hidden from the palette, the panel calls it
709
- ctx.commands.register(
710
- '${id}.data',
711
- async () => (await ctx.storage.get<Streak>(KEY)) ?? { days: 0, last: '' },
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
- ctx.commands.register('${id}.show', async () => {
715
- const streak = await ctx.storage.get<Streak>(KEY);
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$1 = (id) => `// @vitest-environment happy-dom
748
- import type { LearningEventPayloads } from '@dolphy-app/extension-api';
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
- createMemoryStorage,
751
- loadCommands,
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 { host, panels } from '../src/index.ts';
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 load = async () => {
776
- const storage = createMemoryStorage();
777
- const events = await loadEvents(host, {
778
- storage,
779
- declared: ['attempt.closed'],
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 commands = await loadCommands(host, {
782
- storage,
783
- declaredCommands: ['${id}.show', '${id}.data'],
784
- declaredPanels: ['${id}.view'],
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
- disposables.push(events, commands);
787
- return { storage, events, commands };
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 { storage, events } = await load();
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 { storage, events } = await load();
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 { commands } = await load();
822
- expect(await commands.run('${id}.show')).toMatchObject({ kind: 'notify' });
823
- expect(await commands.run('${id}.data')).toEqual({
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 { events, commands } = await load();
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 { events, commands } = await load();
841
- await events.emit('attempt.closed', attempt('2026-10-01'));
842
- const panel = await loadPanel(panels, '${id}.view', {
843
- call: async (commandId) => {
844
- const result = await commands.run(commandId);
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
- disposables.push(panel);
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 (the event, the commands, the panel and",
863
- " the `learning.events` permission are declared in it);",
864
- "- `src/index.ts` — all the extension code: `host` (`defineExtension`: the",
865
- " event handler, the commands, `ctx.storage`) and `panels`",
866
- " (`defineExtensionPanel`); the build splits it into `main.mjs` and",
867
- " `panel.mjs`;",
868
- ...idsBullet,
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$2(id),
873
- "src/index.ts": indexTs$1(id),
874
- "test/index.test.ts": indexTestTs$1(id)
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$1 = (id) => `{
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 = (id) => `import {
920
- defineAnswerView,
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
- defineExtension,
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
- // filled from the setting in \`activate\`, read by the handlers below
934
- const options = { trim: true };
935
-
936
- const matches = (answer: string, spec: Spec): boolean => {
937
- const given = options.trim ? answer.trim() : answer;
938
- if (spec.ignoreCase === true) {
939
- return given.toLowerCase() === spec.expected.toLowerCase();
940
- }
941
- return given === spec.expected;
942
- };
943
-
944
- // extension code: runs in the extension process of the app
945
- // the schemas from extension.json have already checked \`spec\` and the answer
946
- // before the handlers run
947
- // the ids come from extension.json: \`dolphy-ext types\` (and every build)
948
- // writes them to .dolphy/ids.d.ts, so a misspelt id, a declared id without a
949
- // handler or an undeclared setting fails \`pnpm typecheck\`
950
- export const host = defineExtension({
951
- exerciseTypes: {
952
- '${id}': defineExerciseType<Spec, string, Record<string, never>>({
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
- // this command is registered in \`activate\`: the marker names the id there
962
- commands: { '${id}.status': inActivate },
963
- activate(ctx) {
964
- options.trim = ctx.settings.get('${id}.trim');
965
- ctx.settings.onDidChange((change) => {
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
- options.trim
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
- // the answer input: runs in the app window; the build defines the custom
979
- // element with the tag from extension.json
980
- export const views = {
981
- '${id}': defineAnswerView((api, initial) => {
982
- const input = document.createElement('input');
983
- input.type = 'text';
984
- input.spellcheck = false;
985
- if (api.label !== null) input.setAttribute('aria-label', api.label);
986
-
987
- const applyValue = (value: unknown) => {
988
- input.value = typeof value === 'string' ? value : '';
989
- };
990
- let appliedValue = initial.value;
991
- applyValue(appliedValue);
992
- input.disabled = initial.disabled;
993
-
994
- input.addEventListener('input', () => {
995
- api.setAnswer(input.value, input.value.trim().length > 0);
996
- });
997
- input.addEventListener('keydown', (event) => {
998
- if (event.key === 'Enter') api.submit();
999
- });
1000
- api.root.append(input);
1001
-
1002
- return {
1003
- update: (props) => {
1004
- input.disabled = props.disabled;
1005
- // apply the value only when the app really changed it
1006
- if (props.value !== appliedValue) {
1007
- appliedValue = props.value;
1008
- applyValue(appliedValue);
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
- } satisfies ExtensionViews;
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
- loadCommands,
1021
- loadExerciseType,
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 manifest from '../extension.json';
1026
- import { host, views } from '../src/index.ts';
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 newSettings = () =>
1040
- createMemorySettings(manifest.contributes.settings as SettingContribution[]);
1041
-
1042
- const load = async (settings = newSettings()) => {
1043
- const type = await loadExerciseType(host, '${id}', { settings });
1044
- disposables.push(type);
1045
- return type;
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 view = await loadView(views, '${id}', label === undefined ? {} : { label });
1050
- disposables.push(view);
1051
- const input = view.query<HTMLInputElement>('input');
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 { view, input };
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 load();
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 load();
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 load();
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 load();
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 settings = newSettings();
1095
- const type = await load(settings);
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 settings = newSettings();
1108
- const commands = await loadCommands(host, {
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 { view, input } = await mount();
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(view.changes).toEqual([
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 { view, input } = await mount();
1393
+ const { input, submissions } = await mount();
1163
1394
  input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' }));
1164
- expect(view.submissions).toBe(1);
1395
+ expect(submissions()).toBe(1);
1165
1396
  });
1166
1397
 
1167
1398
  it('disabled blocks the input', async () => {
1168
- const { view, input } = await mount();
1169
- await view.update({ disabled: true });
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 { view, input } = await mount();
1175
- await view.update({ value: 'Hello' });
1405
+ const { changes, input, update } = await mount();
1406
+ await update({ value: 'Hello' });
1176
1407
  expect(input.value).toBe('Hello');
1177
- expect(view.changes).toEqual([]);
1408
+ expect(changes).toEqual([]);
1178
1409
  });
1179
1410
 
1180
- it('the aria-label of the host goes to the input', async () => {
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 (the `spec` and answer schemas are written",
1190
- " in it);",
1191
- "- `src/index.ts` — all the extension code: `host` (`defineExtension` +",
1192
- " `defineExerciseType`) and `views` (`defineAnswerView`); the build splits",
1193
- " it into `main.mjs` and `view.mjs`;",
1194
- ...idsBullet,
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
- "src/index.ts": indexTs(id),
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 themeTestTs = (id) => `import { describe, expect, it } from 'vitest';
1236
- import manifest from '../extension.json';
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
- const [theme] = manifest.contributes.themes;
1239
- const colors: Record<string, string> = theme.colors;
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(theme.dark ? background < text : background > text).toBe(true);
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 only, so", "there is no code to build; the test checks the text contrast."],
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: the theme `colors` (allowed keys are",
1278
- " listed in the Dolphy extension guide) and `variables`;",
1279
- "- `test/theme.test.ts` — checks the contrast of the text colors",
1280
- " (`vitest`); there is no `src/`, a theme has no code."
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.4.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