@dolphy-app/create-extension 0.3.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.
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Extension project generator: create-dolphy-extension <directory>
4
4
 
5
- The package version equals the version of the Dolphy app release it was published from (0.3.0).
5
+ The package version equals the version of the Dolphy app release it was published from (0.4.0).
6
6
 
7
7
  Generator of an extension project: `src/index.ts` with the host and a
8
8
  view, a manifest, tests, and typed ids (`.dolphy/ids.d.ts`, generated from
package/dist/cli/main.js CHANGED
@@ -2,10 +2,134 @@
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-]*)*$/;
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})?$`);
7
127
  /** Limits on commands and panels (R1, R3); they match those checked by the manifest, host, and engine. */
8
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,
9
133
  /** Commands per extension. */
10
134
  commands: 64,
11
135
  /** Panels per extension. */
@@ -22,6 +146,31 @@ const EXTENSION_COMMAND_LIMITS = Object.freeze({
22
146
  /** Handler budget, ms. */
23
147
  handlerMs: 1e4
24
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
+ });
25
174
  /** Extension storage limits (R2); they match the engine's limits, which enforces them. */
26
175
  const EXTENSION_STORAGE_LIMITS = Object.freeze({
27
176
  /** Key length in UTF-16 code units. */
@@ -33,26 +182,62 @@ const EXTENSION_STORAGE_LIMITS = Object.freeze({
33
182
  /** Sum of JSON texts of all values in UTF-8 bytes. */
34
183
  totalBytes: 1048576
35
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
+ });
36
209
 
37
210
  //#endregion
38
- //#region packages/create-extension/src/template.ts
211
+ //#region packages/create-extension/src/templates/common.ts
39
212
  /** Extension version in the template and its default for `apiVersion`. */
40
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";
41
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
+ });
42
233
  const packageJson = ({ id, dependencies }) => `${JSON.stringify({
43
234
  name: id,
44
235
  version: INITIAL_VERSION,
45
236
  private: true,
46
237
  type: "module",
47
- scripts: {
48
- build: "dolphy-ext build",
49
- dev: "dolphy-ext build --watch",
50
- types: "dolphy-ext types",
51
- typecheck: "dolphy-ext types && tsc",
52
- validate: `dolphy-ext validate dist-ext/${id}`,
53
- test: "vitest run"
54
- },
238
+ scripts: scripts(id),
55
239
  devDependencies: {
240
+ "@dolphy-app/extension-api": dependencies.api,
56
241
  "@dolphy-app/extension-sdk": dependencies.sdk,
57
242
  "@dolphy-app/extension-tools": dependencies.tools,
58
243
  "@types/node": "^22.20.4",
@@ -80,10 +265,626 @@ const tsconfigJson = () => lines([
80
265
  " \"include\": [\"src\", \"test\", \".dolphy/ids.d.ts\"]",
81
266
  "}"
82
267
  ]);
83
- 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",
84
414
  "id": "${id}",
85
415
  "version": "${INITIAL_VERSION}",
86
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",
882
+ "id": "${id}",
883
+ "version": "${INITIAL_VERSION}",
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",
87
888
  "tags": ["learning"],
88
889
  "contributes": {
89
890
  "exerciseTypes": [
@@ -382,72 +1183,123 @@ describe('${id}: view', () => {
382
1183
  });
383
1184
  });
384
1185
  `;
385
- const readme = (id) => lines([
386
- `# ${id}`,
387
- "",
388
- "A Dolphy extension: the \"text match\" exercise type (the learner types a",
389
- "string, it is compared with `spec.expected`), a setting and a command.",
390
- "Generated by `create-dolphy-extension`.",
391
- "",
392
- "## Layout",
393
- "",
394
- "- `extension.json` — the manifest (the `spec` and answer schemas are written",
395
- " in it);",
396
- "- `src/index.ts` — all the extension code: `host` (`defineExtension` +",
397
- " `defineExerciseType`) and `views` (`defineAnswerView`); the build splits",
398
- " it into `main.mjs` and `view.mjs`;",
399
- "- `.dolphy/ids.d.ts` — generated from `extension.json` by",
400
- " `dolphy-ext types` (and by every build): the ids the manifest declares,",
401
- " as TypeScript types. Not committed. A misspelt id, a declared id without",
402
- " a handler or a view, or `ctx.settings.get` of an undeclared setting fails",
403
- " `pnpm typecheck`;",
404
- "- `test/index.test.ts` — tests (`vitest`, `happy-dom`).",
405
- "",
406
- "## Development loop",
407
- "",
408
- "```sh",
409
- "pnpm install",
410
- `pnpm dev # dolphy-ext build --watch: rebuilds into dist-ext/${id}`,
411
- "```",
412
- "",
413
- "Start the app with the developer root pointing at the `dist-ext` directory",
414
- "of this project (an absolute path):",
415
- "",
416
- "```sh",
417
- "DOLPHY_DEV_EXTENSIONS=<path to the project>/dist-ext pnpm dev # from the Dolphy repository",
418
- "```",
419
- "",
420
- "A change to a file in `dist-ext` is applied live: the window does not",
421
- "reload, mounted answer inputs are recreated. Load errors are shown in",
422
- "\"Settings → Extensions\".",
423
- "",
424
- "## Build, check, test",
425
- "",
426
- "```sh",
427
- `pnpm build # dist-ext/${id}`,
428
- "pnpm validate # the same manifest parsing the app does",
429
- "pnpm typecheck # writes .dolphy/ids.d.ts, then tsc",
430
- "pnpm test",
431
- "```",
432
- "",
433
- "## Manual installation",
434
- "",
435
- `Copy the \`dist-ext/${id}\` directory to \`<userData>/extensions/\``,
436
- "and restart the app. There is no installation from the app yet."
437
- ]);
438
- const gitignore = () => lines([
439
- "node_modules",
440
- "dist-ext",
441
- ".dolphy"
442
- ]);
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
+ })
1286
+ };
443
1287
 
444
1288
  //#endregion
445
1289
  //#region packages/create-extension/src/generate.ts
1290
+ const TEMPLATES = {
1291
+ exercise,
1292
+ theme,
1293
+ "command-panel": commandPanel,
1294
+ events,
1295
+ blank
1296
+ };
1297
+ const isTemplateName = (name) => TEMPLATE_NAMES.includes(name);
446
1298
  /** Matches the manifest limit (`parseManifest`). */
447
1299
  const MAX_ID_CHARS = 64;
448
1300
  /** Without a build (sources, `--local`) the package version is a placeholder. */
449
1301
  const UNPUBLISHED_VERSION = "^0.0.0";
450
- const builtPackageVersion = () => "0.3.0";
1302
+ const builtPackageVersion = () => "0.4.0";
451
1303
  var GenerateError = class extends Error {
452
1304
  code;
453
1305
  constructor(code, message) {
@@ -470,6 +1322,7 @@ const dependencySpecs = async (localRoot, packageVersion) => {
470
1322
  const range = packageVersion === null ? UNPUBLISHED_VERSION : `^${packageVersion}`;
471
1323
  return {
472
1324
  dependencies: {
1325
+ api: range,
473
1326
  sdk: range,
474
1327
  tools: range
475
1328
  },
@@ -477,11 +1330,17 @@ const dependencySpecs = async (localRoot, packageVersion) => {
477
1330
  };
478
1331
  }
479
1332
  const root = path.resolve(localRoot);
1333
+ const api = path.join(root, "packages", "extension-api");
480
1334
  const sdk = path.join(root, "packages", "extension-sdk");
481
1335
  const tools = path.join(root, "packages", "extension-tools");
482
- 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`);
483
1341
  return {
484
1342
  dependencies: {
1343
+ api: `link:${api}`,
485
1344
  sdk: `link:${sdk}`,
486
1345
  tools: `link:${tools}`
487
1346
  },
@@ -495,25 +1354,30 @@ const assertEmpty = async (dir) => {
495
1354
  };
496
1355
  /** Project files: relative path → content. */
497
1356
  const renderProject = (input) => {
498
- const { id } = input;
499
- return /* @__PURE__ */ new Map([
1357
+ const { id, template = DEFAULT_TEMPLATE } = input;
1358
+ const module = TEMPLATES[template];
1359
+ return new Map([
500
1360
  ["package.json", packageJson(input)],
501
1361
  ["tsconfig.json", tsconfigJson()],
502
- ["extension.json", manifestJson(id)],
503
- ["src/index.ts", indexTs(id)],
504
- ["test/index.test.ts", indexTestTs(id)],
505
- ["README.md", readme(id)],
506
- [".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()]
507
1368
  ]);
508
1369
  };
509
1370
  const generateExtension = async (options) => {
510
1371
  const dir = path.resolve(options.dir);
511
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(", ")}`);
512
1375
  const { dependencies, isPublished } = await dependencySpecs(options.localRoot, options.packageVersion ?? builtPackageVersion());
513
1376
  await assertEmpty(dir);
514
1377
  const isLocal = options.localRoot !== void 0;
515
1378
  const project = renderProject({
516
1379
  id,
1380
+ template,
517
1381
  dependencies
518
1382
  });
519
1383
  for (const [file, content] of project) {
@@ -535,10 +1399,12 @@ const generateExtension = async (options) => {
535
1399
  const EXIT_OK = 0;
536
1400
  const EXIT_PROBLEMS = 1;
537
1401
  const EXIT_USAGE = 2;
538
- 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>]
539
1403
 
540
1404
  <dir> new project directory (must be empty or not exist)
541
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
542
1408
  --local <repoRoot> Dolphy repository root: @dolphy-app/extension-sdk and
543
1409
  @dolphy-app/extension-tools are linked as link:<repoRoot>/packages/...
544
1410
  --help show this help
@@ -549,7 +1415,7 @@ const parseArgs = (argv) => {
549
1415
  const values = /* @__PURE__ */ new Map();
550
1416
  for (let i = 0; i < argv.length; i++) {
551
1417
  const arg = argv[i];
552
- if (arg === "--id" || arg === "--local") {
1418
+ if (arg === "--id" || arg === "--local" || arg === "--template") {
553
1419
  const value = argv[++i];
554
1420
  if (value === void 0) return { usageError: `${arg} requires a value` };
555
1421
  values.set(arg, value);
@@ -562,7 +1428,8 @@ const parseArgs = (argv) => {
562
1428
  return {
563
1429
  dir,
564
1430
  id: values.get("--id"),
565
- local: values.get("--local")
1431
+ local: values.get("--local"),
1432
+ template: values.get("--template")
566
1433
  };
567
1434
  };
568
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";
@@ -600,6 +1467,7 @@ const runCli = async (argv, io, cwd = process.cwd()) => {
600
1467
  const result = await generateExtension({
601
1468
  dir: path.resolve(cwd, parsed.dir),
602
1469
  ...parsed.id === void 0 ? {} : { id: parsed.id },
1470
+ ...parsed.template === void 0 ? {} : { template: parsed.template },
603
1471
  ...parsed.local === void 0 ? {} : { localRoot: path.resolve(cwd, parsed.local) }
604
1472
  });
605
1473
  io.stdout(`created ${result.id} in ${result.dir} (${result.files.length} files)\n`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dolphy-app/create-extension",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Extension project generator: create-dolphy-extension <directory>",
5
5
  "type": "module",
6
6
  "bin": {