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