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