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