@dolphy-app/create-extension 0.2.0 → 0.4.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 +10 -8
  2. package/dist/cli/main.js +1165 -201
  3. package/package.json +2 -2
package/dist/cli/main.js CHANGED
@@ -2,28 +2,242 @@
2
2
  import path from "node:path";
3
3
  import { mkdir, readdir, stat, writeFile } from "node:fs/promises";
4
4
 
5
+ //#region packages/extension-api/src/when.ts
6
+ /**
7
+ * Visibility conditions (`when`) of commands, panels and widgets. A condition
8
+ * is a small boolean expression over a closed set of typed keys of the app
9
+ * window (`WHEN_KEYS`). The functions are pure: the manifest check, the app
10
+ * window and `dolphy-ext validate` share them, and so can any dispatcher that
11
+ * can supply a `WhenContext`.
12
+ *
13
+ * Grammar (`!` binds tightest, then `&&`, then `||`):
14
+ *
15
+ * ```
16
+ * expr := term ('||' term)*
17
+ * term := factor ('&&' factor)*
18
+ * factor := '!' factor | '(' expr ')' | 'true' | 'false' | boolean-key | comparison
19
+ * comparison := key ('==' | '!=') literal | key 'in' '(' literal (',' literal)* ')'
20
+ * literal := 'text' | true | false
21
+ * ```
22
+ *
23
+ * `!` stands before a boolean key, a group or another `!`; negate a
24
+ * comparison with a group (`!(route == 'courses')`) or use `!=`.
25
+ */
26
+ /** Names of the screens `route` can have: the route names of the app window. */
27
+ const WHEN_ROUTES = [
28
+ "daily-plan",
29
+ "courses",
30
+ "extension-panel",
31
+ "placement",
32
+ "session",
33
+ "settings",
34
+ "settings-learning",
35
+ "settings-library",
36
+ "settings-appearance",
37
+ "settings-shortcuts",
38
+ "settings-extensions",
39
+ "settings-extension-details",
40
+ "settings-about"
41
+ ];
42
+ /** Interface languages a `locale` condition can name. */
43
+ const WHEN_LOCALES = ["ru", "en"];
44
+ /**
45
+ * Closed set of keys with their types.
46
+ *
47
+ * - `route`: the screen shown (a value of `WHEN_ROUTES`).
48
+ * - `course.active`: a course is in focus (the course switcher is not on "all courses").
49
+ * - `session.active`: the learning session screen is open.
50
+ * - `locale`: the interface language after resolving the "system" mode.
51
+ * - `theme.dark`: the current theme is dark.
52
+ */
53
+ const WHEN_KEYS = Object.freeze({
54
+ route: {
55
+ type: "string",
56
+ values: WHEN_ROUTES
57
+ },
58
+ "course.active": { type: "boolean" },
59
+ "session.active": { type: "boolean" },
60
+ locale: {
61
+ type: "string",
62
+ values: WHEN_LOCALES
63
+ },
64
+ "theme.dark": { type: "boolean" }
65
+ });
66
+
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
76
+ });
77
+
78
+ //#endregion
5
79
  //#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
+ */
6
84
  const EXTENSION_ID_PATTERN = /^[a-z][a-z0-9-]*(\.[a-z][a-z0-9-]*)*$/;
7
- /** Тег элемента по умолчанию: `dolphy.sql` → `dolphy-sql-answer`. */
8
- const defaultElementName = (id) => `${id.replaceAll(".", "-")}-answer`;
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. */
95
+ const EXTENSION_SCHEDULE_LIMITS = Object.freeze({
96
+ /** Schedules per extension. */
97
+ schedules: 4,
98
+ /** Handler budget, ms. */
99
+ handlerMs: 1e4,
100
+ /** A firing found later than this after its moment (the app was closed or asleep) is skipped, ms. */
101
+ lateMs: 12e4,
102
+ /** How often the app looks for due firings, ms. */
103
+ tickMs: 3e4
104
+ });
105
+ /** Limits of the settings types (`text`, `color`, `list`, `group`, `order`); they match those checked by the manifest and the engine. */
106
+ const SETTING_LIMITS = Object.freeze({
107
+ /** `maxLength` of `string` and `text`. */
108
+ stringLength: 1e4,
109
+ /** `maxItems` of `list`. */
110
+ listItems: 50,
111
+ /** `itemMaxLength` of `list`. */
112
+ listItemLength: 200,
113
+ groupLength: 60,
114
+ orderMax: 1e3
115
+ });
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
+ /**
118
+ * Key notation: one stroke or two strokes separated by a space
119
+ * (`Mod+K Mod+S`). A stroke is up to three modifiers (`Mod`, `Ctrl`, `Alt`,
120
+ * `Shift`, `Cmd`, `Meta`, `Win`, `Super`, `Option`, ...) and a key joined with
121
+ * `+`: a letter or digit, `F1`–`F24`, a punctuation mark, a physical key
122
+ * (`[KeyK]`) or a name (`Enter`, `Space`, `Tab`, `Escape`, `Insert`, arrows,
123
+ * `Home`, `End`, `PageUp`, `PageDown`). A superset for the JSON Schema; the
124
+ * host validates every string authoritatively with `@dolphy-app/keybindings`.
125
+ */
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. */
128
+ const EXTENSION_COMMAND_LIMITS = Object.freeze({
129
+ /** Keybinding entries (`keybindings`) per command. */
130
+ keybindingsPerCommand: 4,
131
+ /** Length of a `when` condition (of a command, panel, widget or `keybindings[]` entry). */
132
+ whenLength: 200,
133
+ /** Commands per extension. */
134
+ commands: 64,
135
+ /** Panels per extension. */
136
+ panels: 8,
137
+ titleLength: 60,
138
+ categoryLength: 40,
139
+ descriptionLength: 200,
140
+ /** `JSON.stringify(args).length` at the engine boundary. */
141
+ argsChars: 2e5,
142
+ /** JSON text of the result in UTF-8 bytes. */
143
+ resultBytes: 65536,
144
+ /** Length of `notify` in UTF-16 code units. */
145
+ notifyChars: 500,
146
+ /** Handler budget, ms. */
147
+ handlerMs: 1e4
148
+ });
149
+ /** Limits on importers and exporters; the manifest, host, and engine check the same numbers. */
150
+ const EXTENSION_TRANSFER_LIMITS = Object.freeze({
151
+ /** Importers per extension. */
152
+ importers: 8,
153
+ /** Exporters per extension. */
154
+ exporters: 8,
155
+ /** Entries in `accept` of one importer. */
156
+ acceptExtensions: 8,
157
+ /** Handler budget, ms (import and export). */
158
+ handlerMs: 3e4,
159
+ /** Size of the file the user picks for an importer, bytes. */
160
+ inputBytes: 20971520,
161
+ /** Files in the directory an importer returns. */
162
+ files: 5e3,
163
+ /** One file of the returned directory, UTF-8 bytes. */
164
+ fileBytes: 2097152,
165
+ /** All files of the returned directory (and of a course snapshot), UTF-8 bytes. */
166
+ totalBytes: 20971520,
167
+ /** Size of the file an exporter returns, bytes. */
168
+ outputBytes: 20971520,
169
+ /** Length of the file name an exporter returns. */
170
+ filenameChars: 120,
171
+ /** One path of the returned directory, UTF-8 bytes. */
172
+ pathBytes: 1024
173
+ });
174
+ /** Extension storage limits (R2); they match the engine's limits, which enforces them. */
175
+ const EXTENSION_STORAGE_LIMITS = Object.freeze({
176
+ /** Key length in UTF-16 code units. */
177
+ keyLength: 128,
178
+ /** JSON text of a single value in UTF-8 bytes. */
179
+ valueBytes: 65536,
180
+ /** Number of keys. */
181
+ keys: 256,
182
+ /** Sum of JSON texts of all values in UTF-8 bytes. */
183
+ totalBytes: 1048576
184
+ });
185
+ /** Secret limits; the engine enforces them (`StorageQuotaError`, kinds `key-length`, `value-size`, `key-count`). */
186
+ const EXTENSION_SECRET_LIMITS = Object.freeze({
187
+ /** Key length in UTF-16 code units. */
188
+ keyLength: 128,
189
+ /** Value size in UTF-8 bytes. */
190
+ valueBytes: 4096,
191
+ /** Number of keys. */
192
+ keys: 32
193
+ });
194
+ /** Limits of `ctx.stats`; the engine enforces them. */
195
+ const EXTENSION_STATS_LIMITS = Object.freeze({
196
+ /** Most dates in one `daily` range (both ends included). */
197
+ dailyDays: 366 });
198
+ /** Limits of `ctx.notifications`; the engine enforces them. */
199
+ const EXTENSION_NOTIFICATION_LIMITS = Object.freeze({
200
+ /** Title length in characters (code points). */
201
+ titleLength: 80,
202
+ /** Body length in characters (code points). */
203
+ bodyLength: 300,
204
+ /** Notifications per rolling minute and extension. */
205
+ perMinute: 3,
206
+ /** Notifications per rolling hour and extension. */
207
+ perHour: 30
208
+ });
9
209
 
10
210
  //#endregion
11
- //#region packages/create-extension/src/template.ts
12
- /** Версия расширения в шаблоне и его умолчание для `apiVersion`. */
211
+ //#region packages/create-extension/src/templates/common.ts
212
+ /** Extension version in the template and its default for `apiVersion`. */
13
213
  const INITIAL_VERSION = "0.1.0";
214
+ const TEMPLATE_NAMES = [
215
+ "exercise",
216
+ "theme",
217
+ "command-panel",
218
+ "events",
219
+ "blank"
220
+ ];
221
+ const DEFAULT_TEMPLATE = "exercise";
14
222
  const lines = (parts) => `${parts.join("\n")}\n`;
223
+ /** The `pnpm` scripts of every generated project. */
224
+ const scripts = (id) => ({
225
+ build: "dolphy-ext build",
226
+ dev: "dolphy-ext build --watch",
227
+ types: "dolphy-ext types",
228
+ typecheck: "dolphy-ext types && tsc",
229
+ validate: `dolphy-ext validate dist-ext/${id}`,
230
+ lint: "dolphy-ext lint",
231
+ test: "vitest run"
232
+ });
15
233
  const packageJson = ({ id, dependencies }) => `${JSON.stringify({
16
234
  name: id,
17
235
  version: INITIAL_VERSION,
18
236
  private: true,
19
237
  type: "module",
20
- scripts: {
21
- build: "dolphy-ext build",
22
- dev: "dolphy-ext build --watch",
23
- validate: `dolphy-ext validate dist-ext/${id}`,
24
- test: "vitest run"
25
- },
238
+ scripts: scripts(id),
26
239
  devDependencies: {
240
+ "@dolphy-app/extension-api": dependencies.api,
27
241
  "@dolphy-app/extension-sdk": dependencies.sdk,
28
242
  "@dolphy-app/extension-tools": dependencies.tools,
29
243
  "@types/node": "^22.20.4",
@@ -48,13 +262,630 @@ const tsconfigJson = () => lines([
48
262
  " \"skipLibCheck\": true,",
49
263
  " \"noEmit\": true",
50
264
  " },",
51
- " \"include\": [\"src\", \"test\"]",
265
+ " \"include\": [\"src\", \"test\", \".dolphy/ids.d.ts\"]",
52
266
  "}"
53
267
  ]);
54
- const manifestJson = (id) => `{
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
+ const readme = (id, module) => lines([
276
+ `# ${id}`,
277
+ "",
278
+ ...module.summary,
279
+ "Generated by `create-dolphy-extension`.",
280
+ "",
281
+ "## Layout",
282
+ "",
283
+ ...module.layout,
284
+ "",
285
+ "## Development loop",
286
+ "",
287
+ "```sh",
288
+ "pnpm install",
289
+ `pnpm dev # dolphy-ext build --watch: rebuilds into dist-ext/${id}`,
290
+ "```",
291
+ "",
292
+ "Start the app with the developer root pointing at the `dist-ext` directory",
293
+ "of this project (an absolute path):",
294
+ "",
295
+ "```sh",
296
+ "DOLPHY_DEV_EXTENSIONS=<path to the project>/dist-ext pnpm dev # from the Dolphy repository",
297
+ "```",
298
+ "",
299
+ "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",
301
+ "\"Settings → Extensions\".",
302
+ "",
303
+ "## Build, check, test",
304
+ "",
305
+ "```sh",
306
+ `pnpm build # dist-ext/${id}`,
307
+ "pnpm validate # the same manifest parsing the app does",
308
+ "pnpm lint # metadata and bundle checks before a catalog pull request",
309
+ "pnpm typecheck # writes .dolphy/ids.d.ts, then tsc",
310
+ "pnpm test",
311
+ "```",
312
+ "",
313
+ "`.github/workflows/ci.yml` runs the same steps on every push and pull",
314
+ "request.",
315
+ "",
316
+ "## Installation",
317
+ "",
318
+ "From the catalog: Settings → Extensions → Catalog.",
319
+ "",
320
+ `By hand: copy the \`dist-ext/${id}\` directory to`,
321
+ "`<userData>/extensions/` and restart the app.",
322
+ "",
323
+ "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"
331
+ ]);
332
+ const claudeMd = () => "@AGENTS.md\n";
333
+ const agentsMd = (id, module) => lines([
334
+ `# ${id}`,
335
+ "",
336
+ "A Dolphy extension project made by `create-dolphy-extension`. Write the",
337
+ "code and the manifest; the build, the checks and the tests are ready.",
338
+ "",
339
+ "## Layout",
340
+ "",
341
+ ...module.layout,
342
+ "- `.github/workflows/ci.yml` — runs the commands below on every push and",
343
+ " pull request.",
344
+ "",
345
+ "## Commands",
346
+ "",
347
+ "- `pnpm install` — install the toolchain;",
348
+ `- \`pnpm build\` — build into \`dist-ext/${id}\`;`,
349
+ "- `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`;",
352
+ "- `pnpm validate` — parse the built manifest the way the app does;",
353
+ "- `pnpm lint` — metadata and bundle checks the catalog review also runs;",
354
+ "- `pnpm test` — `vitest`.",
355
+ "",
356
+ "Before a pull request to the catalog run `pnpm build`, `pnpm validate`,",
357
+ "`pnpm lint`, `pnpm typecheck` and `pnpm test`: all must pass.",
358
+ "",
359
+ "## Rules",
360
+ "",
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.",
367
+ "- Before publishing replace `your-github-login` in the `author` field of",
368
+ " `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.",
376
+ "",
377
+ "## Guide",
378
+ "",
379
+ "Start with `node_modules/@dolphy-app/extension-sdk/docs/quick-start.md`;",
380
+ "the recipes next to it show a task type, a theme, a command with a panel,",
381
+ "and events with storage."
382
+ ]);
383
+ const ciYml = () => lines([
384
+ "name: CI",
385
+ "",
386
+ "on:",
387
+ " push:",
388
+ " pull_request:",
389
+ "",
390
+ "jobs:",
391
+ " check:",
392
+ " runs-on: ubuntu-latest",
393
+ " steps:",
394
+ " - uses: actions/checkout@v4",
395
+ " - uses: pnpm/action-setup@v4",
396
+ " with:",
397
+ " version: 10",
398
+ " - uses: actions/setup-node@v4",
399
+ " with:",
400
+ " node-version: 22",
401
+ " # the project has no lockfile until you commit one",
402
+ " - run: pnpm install --no-frozen-lockfile",
403
+ " - run: pnpm build",
404
+ " - run: pnpm validate",
405
+ " - run: pnpm lint",
406
+ " - run: pnpm typecheck",
407
+ " - run: pnpm test"
408
+ ]);
409
+
410
+ //#endregion
411
+ //#region packages/create-extension/src/templates/blank.ts
412
+ const manifestJson$4 = (id) => `{
413
+ "$schema": "./node_modules/@dolphy-app/extension-api/dist/extension.schema.json",
414
+ "id": "${id}",
415
+ "version": "${INITIAL_VERSION}",
416
+ "apiVersion": 1,
417
+ "name": "Hello command",
418
+ "description": "A command-palette command that shows a notification.",
419
+ "author": "your-github-login",
420
+ "tags": ["productivity"],
421
+ "contributes": {
422
+ "commands": [{ "id": "${id}.hello", "title": "Say hello" }]
423
+ }
424
+ }
425
+ `;
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
+ },
435
+ });
436
+ `;
437
+ const indexTestTs$3 = (id) => `import { loadCommands } from '@dolphy-app/extension-sdk/testing';
438
+ import { expect, it } from 'vitest';
439
+ import { host } from '../src/index.ts';
440
+
441
+ 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({
446
+ kind: 'notify',
447
+ text: 'Hello from ${id}!',
448
+ });
449
+ await commands.dispose();
450
+ });
451
+ `;
452
+ const blank = {
453
+ summary: ["A Dolphy extension: the smallest project that does something, one command", "in the command palette (Ctrl/⌘+K) that shows a notification."],
454
+ 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,
459
+ "- `test/index.test.ts` — tests (`vitest`)."
460
+ ],
461
+ files: (id) => ({
462
+ "extension.json": manifestJson$4(id),
463
+ "src/index.ts": indexTs$3(id),
464
+ "test/index.test.ts": indexTestTs$3(id)
465
+ })
466
+ };
467
+
468
+ //#endregion
469
+ //#region packages/create-extension/src/templates/command-panel.ts
470
+ const manifestJson$3 = (id) => `{
471
+ "$schema": "./node_modules/@dolphy-app/extension-api/dist/extension.schema.json",
472
+ "id": "${id}",
473
+ "version": "${INITIAL_VERSION}",
474
+ "apiVersion": 1,
475
+ "name": "Hello panel",
476
+ "description": "Palette commands that greet the learner and open a small panel.",
477
+ "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
+ }
487
+ }
488
+ `;
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) => {
504
+ const name = typeof args === 'string' ? args : 'world';
505
+ return notify(\`Hello, \${name}!\`);
506
+ },
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
+ });
513
+
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';
548
+
549
+ const disposables: { dispose(): unknown }[] = [];
550
+ afterEach(async () => {
551
+ await Promise.all(disposables.splice(0).map((item) => item.dispose()));
552
+ });
553
+
554
+ 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;
561
+ };
562
+
563
+ describe('${id}: commands', () => {
564
+ 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({
567
+ kind: 'notify',
568
+ text: 'Hello, Ada!',
569
+ });
570
+ expect(await commands.run('${id}.hello')).toEqual({
571
+ kind: 'notify',
572
+ text: 'Hello, world!',
573
+ });
574
+ });
575
+
576
+ 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({
579
+ kind: 'openPanel',
580
+ panelId: '${id}.view',
581
+ props: { name: 'Dolphy' },
582
+ });
583
+ });
584
+
585
+ it('data returns what the panel shows', async () => {
586
+ const commands = await load();
587
+ expect(await commands.run('${id}.data')).toEqual({
588
+ kind: 'data',
589
+ value: { message: 'Hello from ${id}' },
590
+ });
591
+ });
592
+ });
593
+
594
+ describe('${id}: panel', () => {
595
+ 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' }),
599
+ });
600
+ disposables.push(panel);
601
+ expect(panel.container.querySelector('h2')?.textContent).toBe('Hello, Ada!');
602
+ expect(panel.container.querySelector('p')?.textContent).toBe(
603
+ 'Hello from the test',
604
+ );
605
+ expect(panel.calls).toEqual([{ commandId: '${id}.data', args: undefined }]);
606
+
607
+ panel.setProps({ name: 'Grace' });
608
+ expect(panel.container.querySelector('h2')?.textContent).toBe(
609
+ 'Hello, Grace!',
610
+ );
611
+ });
612
+
613
+ it('stops listening for properties when the panel closes', async () => {
614
+ const panel = await loadPanel(panels, '${id}.view', {
615
+ call: () => ({ message: 'x' }),
616
+ });
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!');
622
+ });
623
+ });
624
+ `;
625
+ const commandPanel = {
626
+ 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
+ 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,
633
+ "- `test/index.test.ts` — tests (`vitest`, `happy-dom`)."
634
+ ],
635
+ files: (id) => ({
636
+ "extension.json": manifestJson$3(id),
637
+ "src/index.ts": indexTs$2(id),
638
+ "test/index.test.ts": indexTestTs$2(id)
639
+ })
640
+ };
641
+
642
+ //#endregion
643
+ //#region packages/create-extension/src/templates/events.ts
644
+ const manifestJson$2 = (id) => `{
645
+ "$schema": "./node_modules/@dolphy-app/extension-api/dist/extension.schema.json",
646
+ "id": "${id}",
647
+ "version": "${INITIAL_VERSION}",
648
+ "apiVersion": 1,
649
+ "name": "Day streak",
650
+ "description": "Counts the days in a row with a closed attempt and shows the streak.",
651
+ "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
+ }
662
+ }
663
+ `;
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\`
675
+ export type Streak = {
676
+ days: number;
677
+ last: string;
678
+ };
679
+
680
+ const KEY = 'streak';
681
+ const DAY_MS = 86_400_000;
682
+
683
+ const dayOf = (at: number): string => new Date(at).toISOString().slice(0, 10);
684
+
685
+ // the streak grows when an attempt is closed the day after the last one;
686
+ // the same day changes nothing, a skipped day starts over
687
+ export const advance = (streak: Streak | undefined, at: number): Streak => {
688
+ const day = dayOf(at);
689
+ if (streak?.last === day) return streak;
690
+ const continues =
691
+ streak !== undefined && dayOf(Date.parse(streak.last) + DAY_MS) === day;
692
+ return { days: continues ? streak.days + 1 : 1, last: day };
693
+ };
694
+
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
+ });
707
+
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
+ );
713
+
714
+ ctx.commands.register('${id}.show', async () => {
715
+ const streak = await ctx.storage.get<Streak>(KEY);
716
+ if (streak === undefined) {
717
+ return notify('No streak yet: finish your first exercise.');
718
+ }
719
+ return openPanel('${id}.view', { days: streak.days });
720
+ });
721
+ },
722
+ });
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
+ `;
747
+ const indexTestTs$1 = (id) => `// @vitest-environment happy-dom
748
+ import type { LearningEventPayloads } from '@dolphy-app/extension-api';
749
+ import {
750
+ createMemoryStorage,
751
+ loadCommands,
752
+ loadEvents,
753
+ loadPanel,
754
+ } from '@dolphy-app/extension-sdk/testing';
755
+ import { afterEach, describe, expect, it } from 'vitest';
756
+ import { host, panels } from '../src/index.ts';
757
+
758
+ const disposables: { dispose(): unknown }[] = [];
759
+ afterEach(async () => {
760
+ await Promise.all(disposables.splice(0).map((item) => item.dispose()));
761
+ });
762
+
763
+ type Attempt = LearningEventPayloads['attempt.closed'];
764
+
765
+ const attempt = (day: string, outcome: Attempt['outcome'] = 'passed'): Attempt => ({
766
+ exerciseId: 'e',
767
+ courseId: 'c',
768
+ lessonId: 'l',
769
+ grade: 4,
770
+ outcome,
771
+ source: 'runner',
772
+ at: Date.parse(\`\${day}T12:00:00Z\`),
773
+ });
774
+
775
+ const load = async () => {
776
+ const storage = createMemoryStorage();
777
+ const events = await loadEvents(host, {
778
+ storage,
779
+ declared: ['attempt.closed'],
780
+ });
781
+ const commands = await loadCommands(host, {
782
+ storage,
783
+ declaredCommands: ['${id}.show', '${id}.data'],
784
+ declaredPanels: ['${id}.view'],
785
+ });
786
+ disposables.push(events, commands);
787
+ return { storage, events, commands };
788
+ };
789
+
790
+ describe('${id}: events and storage', () => {
791
+ 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({
797
+ days: 2,
798
+ last: '2026-10-02',
799
+ });
800
+ });
801
+
802
+ 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({
808
+ days: 2,
809
+ last: '2026-10-02',
810
+ });
811
+ await events.emit('attempt.closed', attempt('2026-10-05'));
812
+ expect(await storage.get('streak')).toEqual({
813
+ days: 1,
814
+ last: '2026-10-05',
815
+ });
816
+ });
817
+ });
818
+
819
+ describe('${id}: commands and panel', () => {
820
+ 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({
824
+ kind: 'data',
825
+ value: { days: 0, last: '' },
826
+ });
827
+ });
828
+
829
+ 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({
833
+ kind: 'openPanel',
834
+ panelId: '${id}.view',
835
+ props: { days: 1 },
836
+ });
837
+ });
838
+
839
+ 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
+ },
847
+ });
848
+ disposables.push(panel);
849
+ expect(panel.container.querySelector('p')?.textContent).toBe(
850
+ 'Streak: 1 days, last day 2026-10-01',
851
+ );
852
+ });
853
+ });
854
+ `;
855
+ 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
+ ],
861
+ 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,
869
+ "- `test/index.test.ts` — tests (`vitest`, `happy-dom`)."
870
+ ],
871
+ files: (id) => ({
872
+ "extension.json": manifestJson$2(id),
873
+ "src/index.ts": indexTs$1(id),
874
+ "test/index.test.ts": indexTestTs$1(id)
875
+ })
876
+ };
877
+
878
+ //#endregion
879
+ //#region packages/create-extension/src/templates/exercise.ts
880
+ const manifestJson$1 = (id) => `{
881
+ "$schema": "./node_modules/@dolphy-app/extension-api/dist/extension.schema.json",
55
882
  "id": "${id}",
56
883
  "version": "${INITIAL_VERSION}",
57
884
  "apiVersion": 1,
885
+ "name": "Text match",
886
+ "description": "Exercise type: the learner types a string that is compared with the expected text.",
887
+ "author": "your-github-login",
888
+ "tags": ["learning"],
58
889
  "contributes": {
59
890
  "exerciseTypes": [
60
891
  {
@@ -70,26 +901,53 @@ const manifestJson = (id) => `{
70
901
  },
71
902
  "answerSchema": { "type": "string" }
72
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" }
73
915
  ]
74
916
  }
75
917
  }
76
918
  `;
77
- const mainTs = (id) => `import { defineExerciseType, defineExtension } from '@dolphy-app/extension-sdk';
919
+ const indexTs = (id) => `import {
920
+ defineAnswerView,
921
+ defineExerciseType,
922
+ defineExtension,
923
+ inActivate,
924
+ notify,
925
+ } from '@dolphy-app/extension-sdk';
926
+ import type { ExtensionViews } from '@dolphy-app/extension-sdk';
78
927
 
79
928
  interface Spec {
80
929
  expected: string;
81
930
  ignoreCase?: boolean;
82
931
  }
83
932
 
933
+ // filled from the setting in \`activate\`, read by the handlers below
934
+ const options = { trim: true };
935
+
84
936
  const matches = (answer: string, spec: Spec): boolean => {
937
+ const given = options.trim ? answer.trim() : answer;
85
938
  if (spec.ignoreCase === true) {
86
- return answer.toLowerCase() === spec.expected.toLowerCase();
939
+ return given.toLowerCase() === spec.expected.toLowerCase();
87
940
  }
88
- return answer === spec.expected;
941
+ return given === spec.expected;
89
942
  };
90
943
 
91
- // схемы из extension.json уже проверили spec и ответ до вызова обработчиков
92
- export default defineExtension({
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({
93
951
  exerciseTypes: {
94
952
  '${id}': defineExerciseType<Spec, string, Record<string, never>>({
95
953
  project: () => ({}),
@@ -100,50 +958,72 @@ export default defineExtension({
100
958
  referenceAnswer: ({ spec }) => spec.expected,
101
959
  }),
102
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', () =>
969
+ notify(
970
+ options.trim
971
+ ? 'Answers are compared without the spaces around them.'
972
+ : 'Answers are compared exactly as typed.',
973
+ ),
974
+ );
975
+ },
103
976
  });
977
+
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
+ }
1010
+ },
1011
+ };
1012
+ }),
1013
+ } satisfies ExtensionViews;
104
1014
  `;
105
- const viewTs = (id) => `import { defineAnswerElement } from '@dolphy-app/extension-sdk';
106
-
107
- defineAnswerElement('${defaultElementName(id)}', (api, initial) => {
108
- const input = document.createElement('input');
109
- input.type = 'text';
110
- input.spellcheck = false;
111
- if (api.label !== null) input.setAttribute('aria-label', api.label);
112
-
113
- const applyValue = (value: unknown) => {
114
- input.value = typeof value === 'string' ? value : '';
115
- };
116
- let appliedValue = initial.value;
117
- applyValue(appliedValue);
118
- input.disabled = initial.disabled;
119
-
120
- input.addEventListener('input', () => {
121
- api.setAnswer(input.value, input.value.trim().length > 0);
122
- });
123
- input.addEventListener('keydown', (event) => {
124
- if (event.key === 'Enter') api.submit();
125
- });
126
- api.root.append(input);
127
-
128
- return {
129
- update: (props) => {
130
- input.disabled = props.disabled;
131
- // value применяется, только когда приложение его действительно сменило
132
- if (props.value !== appliedValue) {
133
- appliedValue = props.value;
134
- applyValue(appliedValue);
135
- }
136
- },
137
- };
138
- });
139
- `;
140
- const mainTestTs = (id) => `import {
1015
+ const indexTestTs = (id) => `// @vitest-environment happy-dom
1016
+ import type { SettingContribution } from '@dolphy-app/extension-sdk';
1017
+ import {
1018
+ createMemorySettings,
141
1019
  createSchemaValidator,
1020
+ loadCommands,
142
1021
  loadExerciseType,
1022
+ loadView,
143
1023
  } from '@dolphy-app/extension-sdk/testing';
144
1024
  import { afterEach, describe, expect, it } from 'vitest';
145
1025
  import manifest from '../extension.json';
146
- import module from '../src/main.ts';
1026
+ import { host, views } from '../src/index.ts';
147
1027
 
148
1028
  const [contribution] = manifest.contributes.exerciseTypes;
149
1029
  const validateSpec = createSchemaValidator(contribution.specSchema);
@@ -151,24 +1031,35 @@ const validateAnswer = createSchemaValidator(contribution.answerSchema);
151
1031
 
152
1032
  const spec = { expected: 'Hello' };
153
1033
 
154
- const disposables: { dispose(): Promise<void> }[] = [];
1034
+ const disposables: { dispose(): unknown }[] = [];
155
1035
  afterEach(async () => {
156
1036
  await Promise.all(disposables.splice(0).map((item) => item.dispose()));
157
1037
  });
158
1038
 
159
- const load = async () => {
160
- const type = await loadExerciseType(module, '${id}');
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 });
161
1044
  disposables.push(type);
162
1045
  return type;
163
1046
  };
164
1047
 
165
- describe('${id}: обработчик', () => {
166
- it('project не раскрывает эталон', async () => {
1048
+ 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');
1052
+ if (input === null) throw new Error('no input');
1053
+ return { view, input };
1054
+ };
1055
+
1056
+ describe('${id}: handler', () => {
1057
+ it('project does not reveal the reference', async () => {
167
1058
  const type = await load();
168
1059
  expect(await type.project(spec)).toEqual({});
169
1060
  });
170
1061
 
171
- it('grade: совпадение засчитывается, расхождение нет', async () => {
1062
+ it('grade: a match passes, a mismatch does not', async () => {
172
1063
  const type = await load();
173
1064
  expect(await type.grade({ spec, answer: 'Hello' })).toEqual({
174
1065
  outcome: 'passed',
@@ -179,7 +1070,7 @@ describe('${id}: обработчик', () => {
179
1070
  });
180
1071
  });
181
1072
 
182
- it('grade: ignoreCase отключает различие регистров', async () => {
1073
+ it('grade: ignoreCase turns case sensitivity off', async () => {
183
1074
  const type = await load();
184
1075
  const relaxed = { ...spec, ignoreCase: true };
185
1076
  expect(await type.grade({ spec: relaxed, answer: 'hELLO' })).toEqual({
@@ -187,7 +1078,7 @@ describe('${id}: обработчик', () => {
187
1078
  });
188
1079
  });
189
1080
 
190
- it('referenceAnswer сам проходит проверку', async () => {
1081
+ it('referenceAnswer passes the check itself', async () => {
191
1082
  const type = await load();
192
1083
  const reference = await type.referenceAnswer(spec);
193
1084
  expect(reference).toEqual({ found: true, answer: 'Hello' });
@@ -198,158 +1089,217 @@ describe('${id}: обработчик', () => {
198
1089
  });
199
1090
  });
200
1091
 
201
- describe('${id}: схемы', () => {
1092
+ describe('${id}: settings and commands', () => {
1093
+ it('the trim setting decides whether the spaces around an answer count', async () => {
1094
+ const settings = newSettings();
1095
+ const type = await load(settings);
1096
+ expect(await type.grade({ spec, answer: ' Hello ' })).toEqual({
1097
+ outcome: 'passed',
1098
+ });
1099
+ await settings.set('${id}.trim', false);
1100
+ expect(await type.grade({ spec, answer: ' Hello ' })).toEqual({
1101
+ outcome: 'failed',
1102
+ reason: 'mismatch',
1103
+ });
1104
+ });
1105
+
1106
+ 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({
1114
+ kind: 'notify',
1115
+ text: 'Answers are compared without the spaces around them.',
1116
+ });
1117
+ await settings.set('${id}.trim', false);
1118
+ expect(await commands.run('${id}.status')).toEqual({
1119
+ kind: 'notify',
1120
+ text: 'Answers are compared exactly as typed.',
1121
+ });
1122
+ });
1123
+ });
1124
+
1125
+ describe('${id}: schemas', () => {
202
1126
  it.each([[{ expected: 'a' }], [{ expected: 'a', ignoreCase: true }]])(
203
- 'spec %j допустим',
1127
+ 'spec %j is valid',
204
1128
  (value) => {
205
1129
  expect(validateSpec(value)).toEqual([]);
206
1130
  },
207
1131
  );
208
1132
 
209
1133
  it.each([
210
- ['нет expected', {}],
211
- ['пустой expected', { expected: '' }],
212
- ['ignoreCase не boolean', { expected: 'a', ignoreCase: 'yes' }],
213
- ['лишнее поле', { expected: 'a', extra: 1 }],
214
- ])('spec: %s отклоняется', (_name, value) => {
1134
+ ['no expected', {}],
1135
+ ['empty expected', { expected: '' }],
1136
+ ['ignoreCase is not a boolean', { expected: 'a', ignoreCase: 'yes' }],
1137
+ ['an extra field', { expected: 'a', extra: 1 }],
1138
+ ])('spec: %s is rejected', (_name, value) => {
215
1139
  expect(validateSpec(value)).not.toEqual([]);
216
1140
  });
217
1141
 
218
- it('answer: строка допустима, число нет', () => {
1142
+ it('answer: a string is valid, a number is not', () => {
219
1143
  expect(validateAnswer('text')).toEqual([]);
220
1144
  expect(validateAnswer(42)).not.toEqual([]);
221
1145
  });
222
1146
  });
223
- `;
224
- const viewTestTs = (id) => {
225
- const tag = defaultElementName(id);
226
- return `// @vitest-environment happy-dom
227
- import { ANSWER_EVENT } from '@dolphy-app/extension-sdk';
228
- import type { AnswerChangeDetail } from '@dolphy-app/extension-sdk';
229
- import { afterEach, describe, expect, it } from 'vitest';
230
- import '../src/view.ts';
231
-
232
- interface AnswerElement extends HTMLElement {
233
- view: unknown;
234
- value: unknown;
235
- disabled: boolean;
236
- }
237
-
238
- const flush = () => Promise.resolve();
239
-
240
- const mountElement = async (label: string | null = null) => {
241
- const element = document.createElement('${tag}') as AnswerElement;
242
- if (label !== null) element.setAttribute('aria-label', label);
243
- document.body.append(element);
244
- await flush();
245
- const changes: AnswerChangeDetail[] = [];
246
- element.addEventListener(ANSWER_EVENT.change, (event) => {
247
- changes.push((event as CustomEvent<AnswerChangeDetail>).detail);
248
- });
249
- const input = element.shadowRoot?.querySelector('input');
250
- if (input === null || input === undefined) throw new Error('no input');
251
- return { element, input, changes };
252
- };
253
1147
 
254
- afterEach(() => {
255
- document.body.replaceChildren();
256
- });
257
-
258
- describe('${tag}', () => {
259
- it('ввод текста сообщает ответ; пустой ввод неполный', async () => {
260
- const { input, changes } = await mountElement();
1148
+ describe('${id}: view', () => {
1149
+ it('typing reports the answer; an empty input is incomplete', async () => {
1150
+ const { view, input } = await mount();
261
1151
  input.value = 'Hello';
262
1152
  input.dispatchEvent(new Event('input'));
263
1153
  input.value = ' ';
264
1154
  input.dispatchEvent(new Event('input'));
265
- expect(changes).toEqual([
1155
+ expect(view.changes).toEqual([
266
1156
  { value: 'Hello', complete: true },
267
1157
  { value: ' ', complete: false },
268
1158
  ]);
269
1159
  });
270
1160
 
271
- it('Enter отправляет ответ', async () => {
272
- const { element, input } = await mountElement();
273
- let submits = 0;
274
- element.addEventListener(ANSWER_EVENT.submit, () => void (submits += 1));
1161
+ it('Enter submits the answer', async () => {
1162
+ const { view, input } = await mount();
275
1163
  input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' }));
276
- expect(submits).toBe(1);
1164
+ expect(view.submissions).toBe(1);
277
1165
  });
278
1166
 
279
- it('disabled блокирует поле', async () => {
280
- const { element, input } = await mountElement();
281
- element.disabled = true;
282
- await flush();
1167
+ it('disabled blocks the input', async () => {
1168
+ const { view, input } = await mount();
1169
+ await view.update({ disabled: true });
283
1170
  expect(input.disabled).toBe(true);
284
1171
  });
285
1172
 
286
- it('value восстанавливает ответ без событий', async () => {
287
- const { element, input, changes } = await mountElement();
288
- element.value = 'Hello';
289
- await flush();
1173
+ it('value restores the answer without events', async () => {
1174
+ const { view, input } = await mount();
1175
+ await view.update({ value: 'Hello' });
290
1176
  expect(input.value).toBe('Hello');
291
- expect(changes).toEqual([]);
1177
+ expect(view.changes).toEqual([]);
292
1178
  });
293
1179
 
294
- it('aria-label хоста попадает на поле', async () => {
295
- const { input } = await mountElement('Ваш ответ');
296
- expect(input.getAttribute('aria-label')).toBe('Ваш ответ');
1180
+ it('the aria-label of the host goes to the input', async () => {
1181
+ const { input } = await mount('Your answer');
1182
+ expect(input.getAttribute('aria-label')).toBe('Your answer');
297
1183
  });
298
1184
  });
299
1185
  `;
1186
+ const exercise = {
1187
+ 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
+ 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,
1195
+ "- `test/index.test.ts` — tests (`vitest`, `happy-dom`)."
1196
+ ],
1197
+ files: (id) => ({
1198
+ "extension.json": manifestJson$1(id),
1199
+ "src/index.ts": indexTs(id),
1200
+ "test/index.test.ts": indexTestTs(id)
1201
+ })
1202
+ };
1203
+
1204
+ //#endregion
1205
+ //#region packages/create-extension/src/templates/theme.ts
1206
+ const manifestJson = (id) => `{
1207
+ "$schema": "./node_modules/@dolphy-app/extension-api/dist/extension.schema.json",
1208
+ "id": "${id}",
1209
+ "version": "${INITIAL_VERSION}",
1210
+ "apiVersion": 1,
1211
+ "name": "Midnight",
1212
+ "description": "A dark color theme with an amber accent for the Dolphy app.",
1213
+ "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
+ }
1233
+ }
1234
+ `;
1235
+ const themeTestTs = (id) => `import { describe, expect, it } from 'vitest';
1236
+ import manifest from '../extension.json';
1237
+
1238
+ const [theme] = manifest.contributes.themes;
1239
+ const colors: Record<string, string> = theme.colors;
1240
+
1241
+ // WCAG relative luminance of a #rrggbb color
1242
+ const luminance = (hex: string): number => {
1243
+ const [r, g, b] = [1, 3, 5].map((start) => {
1244
+ const channel = Number.parseInt(hex.slice(start, start + 2), 16) / 255;
1245
+ return channel <= 0.03928 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4;
1246
+ }) as [number, number, number];
1247
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b;
1248
+ };
1249
+
1250
+ const contrast = (foreground: string, background: string): number => {
1251
+ const [light, dark] = [luminance(foreground), luminance(background)].sort(
1252
+ (a, b) => b - a,
1253
+ ) as [number, number];
1254
+ return (light + 0.05) / (dark + 0.05);
1255
+ };
1256
+
1257
+ describe('${id}: theme', () => {
1258
+ it.each([
1259
+ ['on-surface', 'surface'],
1260
+ ['on-background', 'background'],
1261
+ ['on-primary', 'primary'],
1262
+ ])('%s on %s has a contrast of at least 4.5:1', (foreground, background) => {
1263
+ expect(contrast(colors[foreground] as string, colors[background] as string))
1264
+ .toBeGreaterThanOrEqual(4.5);
1265
+ });
1266
+
1267
+ it('a dark theme has a dark background and a light text', () => {
1268
+ const background = luminance(colors['background'] as string);
1269
+ const text = luminance(colors['on-background'] as string);
1270
+ expect(theme.dark ? background < text : background > text).toBe(true);
1271
+ });
1272
+ });
1273
+ `;
1274
+ 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."],
1276
+ 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."
1281
+ ],
1282
+ files: (id) => ({
1283
+ "extension.json": manifestJson(id),
1284
+ "test/theme.test.ts": themeTestTs(id)
1285
+ })
300
1286
  };
301
- const readme = (id) => lines([
302
- `# ${id}`,
303
- "",
304
- "Расширение Dolphy: вид задания «text match» (ученик вводит строку, она",
305
- "сравнивается с `spec.expected`). Сгенерировано `create-dolphy-extension`.",
306
- "",
307
- "## Раскладка",
308
- "",
309
- "- `extension.json` — манифест (схемы `spec` и ответа записаны прямо в нём);",
310
- "- `src/main.ts` — код расширения: `defineExtension` + `defineExerciseType`;",
311
- "- `src/view.ts` — элемент ввода ответа: `defineAnswerElement`;",
312
- "- `test/` — тесты обработчика и элемента (`vitest`, `happy-dom`).",
313
- "",
314
- "## Цикл разработки",
315
- "",
316
- "```sh",
317
- "pnpm install",
318
- `pnpm dev # dolphy-ext build --watch: пересборка в dist-ext/${id}`,
319
- "```",
320
- "",
321
- "Запустите приложение с корнем режима разработчика — каталогом `dist-ext`",
322
- "этого проекта (абсолютный путь):",
323
- "",
324
- "```sh",
325
- "DOLPHY_DEV_EXTENSIONS=<путь к проекту>/dist-ext pnpm dev # из репозитория Dolphy",
326
- "```",
327
- "",
328
- "Правка файла в `dist-ext` перезапускает хосты и перезагружает окно.",
329
- "Ошибки загрузки видны в «Настройки → Расширения».",
330
- "",
331
- "## Сборка, проверка, тесты",
332
- "",
333
- "```sh",
334
- `pnpm build # dist-ext/${id}`,
335
- "pnpm validate # тот же разбор манифеста, что делает приложение",
336
- "pnpm test",
337
- "```",
338
- "",
339
- "## Установка вручную",
340
- "",
341
- `Скопируйте каталог \`dist-ext/${id}\` в \`<userData>/extensions/\``,
342
- "и перезапустите приложение. Установки из приложения пока нет."
343
- ]);
344
- const gitignore = () => lines(["node_modules", "dist-ext"]);
345
1287
 
346
1288
  //#endregion
347
1289
  //#region packages/create-extension/src/generate.ts
348
- /** Совпадает с ограничением манифеста (`parseManifest`). */
1290
+ const TEMPLATES = {
1291
+ exercise,
1292
+ theme,
1293
+ "command-panel": commandPanel,
1294
+ events,
1295
+ blank
1296
+ };
1297
+ const isTemplateName = (name) => TEMPLATE_NAMES.includes(name);
1298
+ /** Matches the manifest limit (`parseManifest`). */
349
1299
  const MAX_ID_CHARS = 64;
350
- /** Без сборки (исходники, `--local`) версия пакетов условная. */
1300
+ /** Without a build (sources, `--local`) the package version is a placeholder. */
351
1301
  const UNPUBLISHED_VERSION = "^0.0.0";
352
- const builtPackageVersion = () => "0.2.0";
1302
+ const builtPackageVersion = () => "0.4.0";
353
1303
  var GenerateError = class extends Error {
354
1304
  code;
355
1305
  constructor(code, message) {
@@ -372,6 +1322,7 @@ const dependencySpecs = async (localRoot, packageVersion) => {
372
1322
  const range = packageVersion === null ? UNPUBLISHED_VERSION : `^${packageVersion}`;
373
1323
  return {
374
1324
  dependencies: {
1325
+ api: range,
375
1326
  sdk: range,
376
1327
  tools: range
377
1328
  },
@@ -379,11 +1330,17 @@ const dependencySpecs = async (localRoot, packageVersion) => {
379
1330
  };
380
1331
  }
381
1332
  const root = path.resolve(localRoot);
1333
+ const api = path.join(root, "packages", "extension-api");
382
1334
  const sdk = path.join(root, "packages", "extension-sdk");
383
1335
  const tools = path.join(root, "packages", "extension-tools");
384
- for (const dir of [sdk, tools]) if (!await isDirectory(dir)) throw new GenerateError("invalid-local", `${dir} not found: --local must point to the Dolphy repository root`);
1336
+ for (const dir of [
1337
+ api,
1338
+ sdk,
1339
+ tools
1340
+ ]) if (!await isDirectory(dir)) throw new GenerateError("invalid-local", `${dir} not found: --local must point to the Dolphy repository root`);
385
1341
  return {
386
1342
  dependencies: {
1343
+ api: `link:${api}`,
387
1344
  sdk: `link:${sdk}`,
388
1345
  tools: `link:${tools}`
389
1346
  },
@@ -395,29 +1352,32 @@ const assertEmpty = async (dir) => {
395
1352
  if (info === null) return;
396
1353
  if ((info.isDirectory() ? await readdir(dir) : ["(file)"]).length > 0) throw new GenerateError("target-not-empty", `${dir} is not empty`);
397
1354
  };
398
- /** Файлы проекта: относительный путь → содержимое. */
1355
+ /** Project files: relative path → content. */
399
1356
  const renderProject = (input) => {
400
- const { id } = input;
401
- return /* @__PURE__ */ new Map([
1357
+ const { id, template = DEFAULT_TEMPLATE } = input;
1358
+ const module = TEMPLATES[template];
1359
+ return new Map([
402
1360
  ["package.json", packageJson(input)],
403
1361
  ["tsconfig.json", tsconfigJson()],
404
- ["extension.json", manifestJson(id)],
405
- ["src/main.ts", mainTs(id)],
406
- ["src/view.ts", viewTs(id)],
407
- ["test/main.test.ts", mainTestTs(id)],
408
- ["test/view.test.ts", viewTestTs(id)],
409
- ["README.md", readme(id)],
410
- [".gitignore", gitignore()]
1362
+ ...Object.entries(module.files(id)),
1363
+ ["README.md", readme(id, module)],
1364
+ ["AGENTS.md", agentsMd(id, module)],
1365
+ ["CLAUDE.md", claudeMd()],
1366
+ [".gitignore", gitignore()],
1367
+ [".github/workflows/ci.yml", ciYml()]
411
1368
  ]);
412
1369
  };
413
1370
  const generateExtension = async (options) => {
414
1371
  const dir = path.resolve(options.dir);
415
1372
  const id = resolveId(dir, options.id);
1373
+ const template = options.template ?? "exercise";
1374
+ if (!isTemplateName(template)) throw new GenerateError("invalid-template", `unknown template '${template}'; available: ${TEMPLATE_NAMES.join(", ")}`);
416
1375
  const { dependencies, isPublished } = await dependencySpecs(options.localRoot, options.packageVersion ?? builtPackageVersion());
417
1376
  await assertEmpty(dir);
418
1377
  const isLocal = options.localRoot !== void 0;
419
1378
  const project = renderProject({
420
1379
  id,
1380
+ template,
421
1381
  dependencies
422
1382
  });
423
1383
  for (const [file, content] of project) {
@@ -439,13 +1399,15 @@ const generateExtension = async (options) => {
439
1399
  const EXIT_OK = 0;
440
1400
  const EXIT_PROBLEMS = 1;
441
1401
  const EXIT_USAGE = 2;
442
- const USAGE = `usage: create-dolphy-extension <dir> [--id <id>] [--local <repoRoot>]
1402
+ const USAGE = `usage: create-dolphy-extension <dir> [--id <id>] [--template <name>] [--local <repoRoot>]
443
1403
 
444
- <dir> каталог нового проекта (должен быть пуст или отсутствовать)
445
- --id <id> id расширения (по умолчанию — kebab-case имени каталога)
446
- --local <repoRoot> корень репозитория Dolphy: @dolphy-app/extension-sdk и
447
- @dolphy-app/extension-tools подключаются как link:<repoRoot>/packages/...
448
- --help эта справка
1404
+ <dir> new project directory (must be empty or not exist)
1405
+ --id <id> extension id (default: kebab-case of the directory name)
1406
+ --template <name> project kind: exercise (default), theme, command-panel,
1407
+ events or blank
1408
+ --local <repoRoot> Dolphy repository root: @dolphy-app/extension-sdk and
1409
+ @dolphy-app/extension-tools are linked as link:<repoRoot>/packages/...
1410
+ --help show this help
449
1411
  `;
450
1412
  const parseArgs = (argv) => {
451
1413
  if (argv.includes("--help") || argv.includes("-h")) return { help: true };
@@ -453,42 +1415,43 @@ const parseArgs = (argv) => {
453
1415
  const values = /* @__PURE__ */ new Map();
454
1416
  for (let i = 0; i < argv.length; i++) {
455
1417
  const arg = argv[i];
456
- if (arg === "--id" || arg === "--local") {
1418
+ if (arg === "--id" || arg === "--local" || arg === "--template") {
457
1419
  const value = argv[++i];
458
- if (value === void 0) return { usageError: `${arg} требует значение` };
1420
+ if (value === void 0) return { usageError: `${arg} requires a value` };
459
1421
  values.set(arg, value);
460
- } else if (arg.startsWith("-")) return { usageError: `неизвестный флаг: ${arg}` };
1422
+ } else if (arg.startsWith("-")) return { usageError: `unknown flag: ${arg}` };
461
1423
  else positional.push(arg);
462
1424
  }
463
1425
  const [dir, ...extra] = positional;
464
- if (dir === void 0) return { usageError: "не указан каталог" };
465
- if (extra.length > 0) return { usageError: `лишние аргументы: ${extra.join(" ")}` };
1426
+ if (dir === void 0) return { usageError: "no directory given" };
1427
+ if (extra.length > 0) return { usageError: `unexpected arguments: ${extra.join(" ")}` };
466
1428
  return {
467
1429
  dir,
468
1430
  id: values.get("--id"),
469
- local: values.get("--local")
1431
+ local: values.get("--local"),
1432
+ template: values.get("--template")
470
1433
  };
471
1434
  };
472
- const PLACEHOLDER_NOTE = "\nЗамечание: @dolphy-app/extension-sdk и @dolphy-app/extension-tools не опубликованы, версия ^0.0.0 не установится.\nУкажите пути к репозиторию Dolphy: create-dolphy-extension <dir> --local <repoRoot>.\n";
1435
+ const PLACEHOLDER_NOTE = "\nNote: @dolphy-app/extension-sdk and @dolphy-app/extension-tools are not published, version ^0.0.0 cannot be installed.\nPoint to the Dolphy repository: create-dolphy-extension <dir> --local <repoRoot>.\n";
473
1436
  const installNote = ({ isLocal, isPublished }) => {
474
1437
  if (isLocal) return "";
475
1438
  return isPublished ? "" : PLACEHOLDER_NOTE;
476
1439
  };
477
1440
  const nextSteps = (result) => {
478
1441
  const { dir, id } = result;
479
- return `\nДальше:\n${[
1442
+ return `\nNext steps:\n${[
480
1443
  ` cd ${dir}`,
481
1444
  " pnpm install",
482
1445
  " pnpm test",
483
- ` pnpm dev # пересборка в dist-ext/${id}`,
1446
+ ` pnpm dev # rebuilds into dist-ext/${id}`,
484
1447
  "",
485
- "Запуск приложения с вашим расширением (из репозитория Dolphy):",
1448
+ "Run the app with your extension (from the Dolphy repository):",
486
1449
  ` DOLPHY_DEV_EXTENSIONS=${path.join(dir, "dist-ext")} pnpm dev`
487
1450
  ].join("\n")}\n${installNote(result)}`;
488
1451
  };
489
1452
  /**
490
- * `create-dolphy-extension`; `argv` без `node` и имени скрипта, относительные пути
491
- * считаются от `cwd`.
1453
+ * `create-dolphy-extension`; `argv` without `node` and the script name; relative paths
1454
+ * are resolved against `cwd`.
492
1455
  */
493
1456
  const runCli = async (argv, io, cwd = process.cwd()) => {
494
1457
  const parsed = parseArgs(argv);
@@ -504,6 +1467,7 @@ const runCli = async (argv, io, cwd = process.cwd()) => {
504
1467
  const result = await generateExtension({
505
1468
  dir: path.resolve(cwd, parsed.dir),
506
1469
  ...parsed.id === void 0 ? {} : { id: parsed.id },
1470
+ ...parsed.template === void 0 ? {} : { template: parsed.template },
507
1471
  ...parsed.local === void 0 ? {} : { localRoot: path.resolve(cwd, parsed.local) }
508
1472
  });
509
1473
  io.stdout(`created ${result.id} in ${result.dir} (${result.files.length} files)\n`);