@dolphy-app/create-extension 0.2.0 → 0.3.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 +258 -162
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -1,23 +1,25 @@
|
|
|
1
1
|
# @dolphy-app/create-extension
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Extension project generator: create-dolphy-extension <directory>
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
The package version equals the version of the Dolphy app release it was published from (0.3.0).
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
Generator of an extension project: `src/index.ts` with the host and a
|
|
8
|
+
view, a manifest, tests, and typed ids (`.dolphy/ids.d.ts`, generated from
|
|
9
|
+
`extension.json`).
|
|
8
10
|
|
|
9
11
|
```sh
|
|
10
|
-
npx @dolphy-app/create-extension
|
|
12
|
+
npx @dolphy-app/create-extension <directory>
|
|
11
13
|
```
|
|
12
14
|
|
|
13
|
-
|
|
15
|
+
The project gets tests, a build and a README.
|
|
14
16
|
|
|
15
|
-
##
|
|
17
|
+
## Installation
|
|
16
18
|
|
|
17
19
|
```sh
|
|
18
20
|
npm install @dolphy-app/create-extension
|
|
19
21
|
```
|
|
20
22
|
|
|
21
|
-
##
|
|
23
|
+
## Documentation
|
|
22
24
|
|
|
23
|
-
[
|
|
25
|
+
[Dolphy extensions](https://github.com/dolphy-app/dolphy/blob/main/docs/design/extensions.md).
|
package/dist/cli/main.js
CHANGED
|
@@ -4,12 +4,39 @@ import { mkdir, readdir, stat, writeFile } from "node:fs/promises";
|
|
|
4
4
|
|
|
5
5
|
//#region packages/extension-api/src/index.ts
|
|
6
6
|
const EXTENSION_ID_PATTERN = /^[a-z][a-z0-9-]*(\.[a-z][a-z0-9-]*)*$/;
|
|
7
|
-
/**
|
|
8
|
-
const
|
|
7
|
+
/** Limits on commands and panels (R1, R3); they match those checked by the manifest, host, and engine. */
|
|
8
|
+
const EXTENSION_COMMAND_LIMITS = Object.freeze({
|
|
9
|
+
/** Commands per extension. */
|
|
10
|
+
commands: 64,
|
|
11
|
+
/** Panels per extension. */
|
|
12
|
+
panels: 8,
|
|
13
|
+
titleLength: 60,
|
|
14
|
+
categoryLength: 40,
|
|
15
|
+
descriptionLength: 200,
|
|
16
|
+
/** `JSON.stringify(args).length` at the engine boundary. */
|
|
17
|
+
argsChars: 2e5,
|
|
18
|
+
/** JSON text of the result in UTF-8 bytes. */
|
|
19
|
+
resultBytes: 65536,
|
|
20
|
+
/** Length of `notify` in UTF-16 code units. */
|
|
21
|
+
notifyChars: 500,
|
|
22
|
+
/** Handler budget, ms. */
|
|
23
|
+
handlerMs: 1e4
|
|
24
|
+
});
|
|
25
|
+
/** Extension storage limits (R2); they match the engine's limits, which enforces them. */
|
|
26
|
+
const EXTENSION_STORAGE_LIMITS = Object.freeze({
|
|
27
|
+
/** Key length in UTF-16 code units. */
|
|
28
|
+
keyLength: 128,
|
|
29
|
+
/** JSON text of a single value in UTF-8 bytes. */
|
|
30
|
+
valueBytes: 65536,
|
|
31
|
+
/** Number of keys. */
|
|
32
|
+
keys: 256,
|
|
33
|
+
/** Sum of JSON texts of all values in UTF-8 bytes. */
|
|
34
|
+
totalBytes: 1048576
|
|
35
|
+
});
|
|
9
36
|
|
|
10
37
|
//#endregion
|
|
11
38
|
//#region packages/create-extension/src/template.ts
|
|
12
|
-
/**
|
|
39
|
+
/** Extension version in the template and its default for `apiVersion`. */
|
|
13
40
|
const INITIAL_VERSION = "0.1.0";
|
|
14
41
|
const lines = (parts) => `${parts.join("\n")}\n`;
|
|
15
42
|
const packageJson = ({ id, dependencies }) => `${JSON.stringify({
|
|
@@ -20,6 +47,8 @@ const packageJson = ({ id, dependencies }) => `${JSON.stringify({
|
|
|
20
47
|
scripts: {
|
|
21
48
|
build: "dolphy-ext build",
|
|
22
49
|
dev: "dolphy-ext build --watch",
|
|
50
|
+
types: "dolphy-ext types",
|
|
51
|
+
typecheck: "dolphy-ext types && tsc",
|
|
23
52
|
validate: `dolphy-ext validate dist-ext/${id}`,
|
|
24
53
|
test: "vitest run"
|
|
25
54
|
},
|
|
@@ -48,13 +77,14 @@ const tsconfigJson = () => lines([
|
|
|
48
77
|
" \"skipLibCheck\": true,",
|
|
49
78
|
" \"noEmit\": true",
|
|
50
79
|
" },",
|
|
51
|
-
" \"include\": [\"src\", \"test\"]",
|
|
80
|
+
" \"include\": [\"src\", \"test\", \".dolphy/ids.d.ts\"]",
|
|
52
81
|
"}"
|
|
53
82
|
]);
|
|
54
83
|
const manifestJson = (id) => `{
|
|
55
84
|
"id": "${id}",
|
|
56
85
|
"version": "${INITIAL_VERSION}",
|
|
57
86
|
"apiVersion": 1,
|
|
87
|
+
"tags": ["learning"],
|
|
58
88
|
"contributes": {
|
|
59
89
|
"exerciseTypes": [
|
|
60
90
|
{
|
|
@@ -70,26 +100,53 @@ const manifestJson = (id) => `{
|
|
|
70
100
|
},
|
|
71
101
|
"answerSchema": { "type": "string" }
|
|
72
102
|
}
|
|
103
|
+
],
|
|
104
|
+
"settings": [
|
|
105
|
+
{
|
|
106
|
+
"id": "${id}.trim",
|
|
107
|
+
"type": "boolean",
|
|
108
|
+
"label": "Ignore spaces around the answer",
|
|
109
|
+
"default": true
|
|
110
|
+
}
|
|
111
|
+
],
|
|
112
|
+
"commands": [
|
|
113
|
+
{ "id": "${id}.status", "title": "Show how answers are compared" }
|
|
73
114
|
]
|
|
74
115
|
}
|
|
75
116
|
}
|
|
76
117
|
`;
|
|
77
|
-
const
|
|
118
|
+
const indexTs = (id) => `import {
|
|
119
|
+
defineAnswerView,
|
|
120
|
+
defineExerciseType,
|
|
121
|
+
defineExtension,
|
|
122
|
+
inActivate,
|
|
123
|
+
notify,
|
|
124
|
+
} from '@dolphy-app/extension-sdk';
|
|
125
|
+
import type { ExtensionViews } from '@dolphy-app/extension-sdk';
|
|
78
126
|
|
|
79
127
|
interface Spec {
|
|
80
128
|
expected: string;
|
|
81
129
|
ignoreCase?: boolean;
|
|
82
130
|
}
|
|
83
131
|
|
|
132
|
+
// filled from the setting in \`activate\`, read by the handlers below
|
|
133
|
+
const options = { trim: true };
|
|
134
|
+
|
|
84
135
|
const matches = (answer: string, spec: Spec): boolean => {
|
|
136
|
+
const given = options.trim ? answer.trim() : answer;
|
|
85
137
|
if (spec.ignoreCase === true) {
|
|
86
|
-
return
|
|
138
|
+
return given.toLowerCase() === spec.expected.toLowerCase();
|
|
87
139
|
}
|
|
88
|
-
return
|
|
140
|
+
return given === spec.expected;
|
|
89
141
|
};
|
|
90
142
|
|
|
91
|
-
//
|
|
92
|
-
|
|
143
|
+
// extension code: runs in the extension process of the app
|
|
144
|
+
// the schemas from extension.json have already checked \`spec\` and the answer
|
|
145
|
+
// before the handlers run
|
|
146
|
+
// the ids come from extension.json: \`dolphy-ext types\` (and every build)
|
|
147
|
+
// writes them to .dolphy/ids.d.ts, so a misspelt id, a declared id without a
|
|
148
|
+
// handler or an undeclared setting fails \`pnpm typecheck\`
|
|
149
|
+
export const host = defineExtension({
|
|
93
150
|
exerciseTypes: {
|
|
94
151
|
'${id}': defineExerciseType<Spec, string, Record<string, never>>({
|
|
95
152
|
project: () => ({}),
|
|
@@ -100,50 +157,72 @@ export default defineExtension({
|
|
|
100
157
|
referenceAnswer: ({ spec }) => spec.expected,
|
|
101
158
|
}),
|
|
102
159
|
},
|
|
160
|
+
// this command is registered in \`activate\`: the marker names the id there
|
|
161
|
+
commands: { '${id}.status': inActivate },
|
|
162
|
+
activate(ctx) {
|
|
163
|
+
options.trim = ctx.settings.get('${id}.trim');
|
|
164
|
+
ctx.settings.onDidChange((change) => {
|
|
165
|
+
if (change.id === '${id}.trim') options.trim = change.value;
|
|
166
|
+
});
|
|
167
|
+
ctx.commands.register('${id}.status', () =>
|
|
168
|
+
notify(
|
|
169
|
+
options.trim
|
|
170
|
+
? 'Answers are compared without the spaces around them.'
|
|
171
|
+
: 'Answers are compared exactly as typed.',
|
|
172
|
+
),
|
|
173
|
+
);
|
|
174
|
+
},
|
|
103
175
|
});
|
|
176
|
+
|
|
177
|
+
// the answer input: runs in the app window; the build defines the custom
|
|
178
|
+
// element with the tag from extension.json
|
|
179
|
+
export const views = {
|
|
180
|
+
'${id}': defineAnswerView((api, initial) => {
|
|
181
|
+
const input = document.createElement('input');
|
|
182
|
+
input.type = 'text';
|
|
183
|
+
input.spellcheck = false;
|
|
184
|
+
if (api.label !== null) input.setAttribute('aria-label', api.label);
|
|
185
|
+
|
|
186
|
+
const applyValue = (value: unknown) => {
|
|
187
|
+
input.value = typeof value === 'string' ? value : '';
|
|
188
|
+
};
|
|
189
|
+
let appliedValue = initial.value;
|
|
190
|
+
applyValue(appliedValue);
|
|
191
|
+
input.disabled = initial.disabled;
|
|
192
|
+
|
|
193
|
+
input.addEventListener('input', () => {
|
|
194
|
+
api.setAnswer(input.value, input.value.trim().length > 0);
|
|
195
|
+
});
|
|
196
|
+
input.addEventListener('keydown', (event) => {
|
|
197
|
+
if (event.key === 'Enter') api.submit();
|
|
198
|
+
});
|
|
199
|
+
api.root.append(input);
|
|
200
|
+
|
|
201
|
+
return {
|
|
202
|
+
update: (props) => {
|
|
203
|
+
input.disabled = props.disabled;
|
|
204
|
+
// apply the value only when the app really changed it
|
|
205
|
+
if (props.value !== appliedValue) {
|
|
206
|
+
appliedValue = props.value;
|
|
207
|
+
applyValue(appliedValue);
|
|
208
|
+
}
|
|
209
|
+
},
|
|
210
|
+
};
|
|
211
|
+
}),
|
|
212
|
+
} satisfies ExtensionViews;
|
|
104
213
|
`;
|
|
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 {
|
|
214
|
+
const indexTestTs = (id) => `// @vitest-environment happy-dom
|
|
215
|
+
import type { SettingContribution } from '@dolphy-app/extension-sdk';
|
|
216
|
+
import {
|
|
217
|
+
createMemorySettings,
|
|
141
218
|
createSchemaValidator,
|
|
219
|
+
loadCommands,
|
|
142
220
|
loadExerciseType,
|
|
221
|
+
loadView,
|
|
143
222
|
} from '@dolphy-app/extension-sdk/testing';
|
|
144
223
|
import { afterEach, describe, expect, it } from 'vitest';
|
|
145
224
|
import manifest from '../extension.json';
|
|
146
|
-
import
|
|
225
|
+
import { host, views } from '../src/index.ts';
|
|
147
226
|
|
|
148
227
|
const [contribution] = manifest.contributes.exerciseTypes;
|
|
149
228
|
const validateSpec = createSchemaValidator(contribution.specSchema);
|
|
@@ -151,24 +230,35 @@ const validateAnswer = createSchemaValidator(contribution.answerSchema);
|
|
|
151
230
|
|
|
152
231
|
const spec = { expected: 'Hello' };
|
|
153
232
|
|
|
154
|
-
const disposables: { dispose():
|
|
233
|
+
const disposables: { dispose(): unknown }[] = [];
|
|
155
234
|
afterEach(async () => {
|
|
156
235
|
await Promise.all(disposables.splice(0).map((item) => item.dispose()));
|
|
157
236
|
});
|
|
158
237
|
|
|
159
|
-
const
|
|
160
|
-
|
|
238
|
+
const newSettings = () =>
|
|
239
|
+
createMemorySettings(manifest.contributes.settings as SettingContribution[]);
|
|
240
|
+
|
|
241
|
+
const load = async (settings = newSettings()) => {
|
|
242
|
+
const type = await loadExerciseType(host, '${id}', { settings });
|
|
161
243
|
disposables.push(type);
|
|
162
244
|
return type;
|
|
163
245
|
};
|
|
164
246
|
|
|
165
|
-
|
|
166
|
-
|
|
247
|
+
const mount = async (label?: string) => {
|
|
248
|
+
const view = await loadView(views, '${id}', label === undefined ? {} : { label });
|
|
249
|
+
disposables.push(view);
|
|
250
|
+
const input = view.query<HTMLInputElement>('input');
|
|
251
|
+
if (input === null) throw new Error('no input');
|
|
252
|
+
return { view, input };
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
describe('${id}: handler', () => {
|
|
256
|
+
it('project does not reveal the reference', async () => {
|
|
167
257
|
const type = await load();
|
|
168
258
|
expect(await type.project(spec)).toEqual({});
|
|
169
259
|
});
|
|
170
260
|
|
|
171
|
-
it('grade:
|
|
261
|
+
it('grade: a match passes, a mismatch does not', async () => {
|
|
172
262
|
const type = await load();
|
|
173
263
|
expect(await type.grade({ spec, answer: 'Hello' })).toEqual({
|
|
174
264
|
outcome: 'passed',
|
|
@@ -179,7 +269,7 @@ describe('${id}: обработчик', () => {
|
|
|
179
269
|
});
|
|
180
270
|
});
|
|
181
271
|
|
|
182
|
-
it('grade: ignoreCase
|
|
272
|
+
it('grade: ignoreCase turns case sensitivity off', async () => {
|
|
183
273
|
const type = await load();
|
|
184
274
|
const relaxed = { ...spec, ignoreCase: true };
|
|
185
275
|
expect(await type.grade({ spec: relaxed, answer: 'hELLO' })).toEqual({
|
|
@@ -187,7 +277,7 @@ describe('${id}: обработчик', () => {
|
|
|
187
277
|
});
|
|
188
278
|
});
|
|
189
279
|
|
|
190
|
-
it('referenceAnswer
|
|
280
|
+
it('referenceAnswer passes the check itself', async () => {
|
|
191
281
|
const type = await load();
|
|
192
282
|
const reference = await type.referenceAnswer(spec);
|
|
193
283
|
expect(reference).toEqual({ found: true, answer: 'Hello' });
|
|
@@ -198,158 +288,166 @@ describe('${id}: обработчик', () => {
|
|
|
198
288
|
});
|
|
199
289
|
});
|
|
200
290
|
|
|
201
|
-
describe('${id}:
|
|
291
|
+
describe('${id}: settings and commands', () => {
|
|
292
|
+
it('the trim setting decides whether the spaces around an answer count', async () => {
|
|
293
|
+
const settings = newSettings();
|
|
294
|
+
const type = await load(settings);
|
|
295
|
+
expect(await type.grade({ spec, answer: ' Hello ' })).toEqual({
|
|
296
|
+
outcome: 'passed',
|
|
297
|
+
});
|
|
298
|
+
await settings.set('${id}.trim', false);
|
|
299
|
+
expect(await type.grade({ spec, answer: ' Hello ' })).toEqual({
|
|
300
|
+
outcome: 'failed',
|
|
301
|
+
reason: 'mismatch',
|
|
302
|
+
});
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
it('the status command reports the current mode', async () => {
|
|
306
|
+
const settings = newSettings();
|
|
307
|
+
const commands = await loadCommands(host, {
|
|
308
|
+
declaredCommands: ['${id}.status'],
|
|
309
|
+
settings,
|
|
310
|
+
});
|
|
311
|
+
disposables.push(commands);
|
|
312
|
+
expect(await commands.run('${id}.status')).toEqual({
|
|
313
|
+
kind: 'notify',
|
|
314
|
+
text: 'Answers are compared without the spaces around them.',
|
|
315
|
+
});
|
|
316
|
+
await settings.set('${id}.trim', false);
|
|
317
|
+
expect(await commands.run('${id}.status')).toEqual({
|
|
318
|
+
kind: 'notify',
|
|
319
|
+
text: 'Answers are compared exactly as typed.',
|
|
320
|
+
});
|
|
321
|
+
});
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
describe('${id}: schemas', () => {
|
|
202
325
|
it.each([[{ expected: 'a' }], [{ expected: 'a', ignoreCase: true }]])(
|
|
203
|
-
'spec %j
|
|
326
|
+
'spec %j is valid',
|
|
204
327
|
(value) => {
|
|
205
328
|
expect(validateSpec(value)).toEqual([]);
|
|
206
329
|
},
|
|
207
330
|
);
|
|
208
331
|
|
|
209
332
|
it.each([
|
|
210
|
-
['
|
|
211
|
-
['
|
|
212
|
-
['ignoreCase
|
|
213
|
-
['
|
|
214
|
-
])('spec: %s
|
|
333
|
+
['no expected', {}],
|
|
334
|
+
['empty expected', { expected: '' }],
|
|
335
|
+
['ignoreCase is not a boolean', { expected: 'a', ignoreCase: 'yes' }],
|
|
336
|
+
['an extra field', { expected: 'a', extra: 1 }],
|
|
337
|
+
])('spec: %s is rejected', (_name, value) => {
|
|
215
338
|
expect(validateSpec(value)).not.toEqual([]);
|
|
216
339
|
});
|
|
217
340
|
|
|
218
|
-
it('answer:
|
|
341
|
+
it('answer: a string is valid, a number is not', () => {
|
|
219
342
|
expect(validateAnswer('text')).toEqual([]);
|
|
220
343
|
expect(validateAnswer(42)).not.toEqual([]);
|
|
221
344
|
});
|
|
222
345
|
});
|
|
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
|
-
|
|
254
|
-
afterEach(() => {
|
|
255
|
-
document.body.replaceChildren();
|
|
256
|
-
});
|
|
257
346
|
|
|
258
|
-
describe('${
|
|
259
|
-
it('
|
|
260
|
-
const {
|
|
347
|
+
describe('${id}: view', () => {
|
|
348
|
+
it('typing reports the answer; an empty input is incomplete', async () => {
|
|
349
|
+
const { view, input } = await mount();
|
|
261
350
|
input.value = 'Hello';
|
|
262
351
|
input.dispatchEvent(new Event('input'));
|
|
263
352
|
input.value = ' ';
|
|
264
353
|
input.dispatchEvent(new Event('input'));
|
|
265
|
-
expect(changes).toEqual([
|
|
354
|
+
expect(view.changes).toEqual([
|
|
266
355
|
{ value: 'Hello', complete: true },
|
|
267
356
|
{ value: ' ', complete: false },
|
|
268
357
|
]);
|
|
269
358
|
});
|
|
270
359
|
|
|
271
|
-
it('Enter
|
|
272
|
-
const {
|
|
273
|
-
let submits = 0;
|
|
274
|
-
element.addEventListener(ANSWER_EVENT.submit, () => void (submits += 1));
|
|
360
|
+
it('Enter submits the answer', async () => {
|
|
361
|
+
const { view, input } = await mount();
|
|
275
362
|
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' }));
|
|
276
|
-
expect(
|
|
363
|
+
expect(view.submissions).toBe(1);
|
|
277
364
|
});
|
|
278
365
|
|
|
279
|
-
it('disabled
|
|
280
|
-
const {
|
|
281
|
-
|
|
282
|
-
await flush();
|
|
366
|
+
it('disabled blocks the input', async () => {
|
|
367
|
+
const { view, input } = await mount();
|
|
368
|
+
await view.update({ disabled: true });
|
|
283
369
|
expect(input.disabled).toBe(true);
|
|
284
370
|
});
|
|
285
371
|
|
|
286
|
-
it('value
|
|
287
|
-
const {
|
|
288
|
-
|
|
289
|
-
await flush();
|
|
372
|
+
it('value restores the answer without events', async () => {
|
|
373
|
+
const { view, input } = await mount();
|
|
374
|
+
await view.update({ value: 'Hello' });
|
|
290
375
|
expect(input.value).toBe('Hello');
|
|
291
|
-
expect(changes).toEqual([]);
|
|
376
|
+
expect(view.changes).toEqual([]);
|
|
292
377
|
});
|
|
293
378
|
|
|
294
|
-
it('aria-label
|
|
295
|
-
const { input } = await
|
|
296
|
-
expect(input.getAttribute('aria-label')).toBe('
|
|
379
|
+
it('the aria-label of the host goes to the input', async () => {
|
|
380
|
+
const { input } = await mount('Your answer');
|
|
381
|
+
expect(input.getAttribute('aria-label')).toBe('Your answer');
|
|
297
382
|
});
|
|
298
383
|
});
|
|
299
384
|
`;
|
|
300
|
-
};
|
|
301
385
|
const readme = (id) => lines([
|
|
302
386
|
`# ${id}`,
|
|
303
387
|
"",
|
|
304
|
-
"
|
|
305
|
-
"
|
|
388
|
+
"A Dolphy extension: the \"text match\" exercise type (the learner types a",
|
|
389
|
+
"string, it is compared with `spec.expected`), a setting and a command.",
|
|
390
|
+
"Generated by `create-dolphy-extension`.",
|
|
306
391
|
"",
|
|
307
|
-
"##
|
|
392
|
+
"## Layout",
|
|
308
393
|
"",
|
|
309
|
-
"- `extension.json` —
|
|
310
|
-
"
|
|
311
|
-
"- `src/
|
|
312
|
-
"
|
|
394
|
+
"- `extension.json` — the manifest (the `spec` and answer schemas are written",
|
|
395
|
+
" in it);",
|
|
396
|
+
"- `src/index.ts` — all the extension code: `host` (`defineExtension` +",
|
|
397
|
+
" `defineExerciseType`) and `views` (`defineAnswerView`); the build splits",
|
|
398
|
+
" it into `main.mjs` and `view.mjs`;",
|
|
399
|
+
"- `.dolphy/ids.d.ts` — generated from `extension.json` by",
|
|
400
|
+
" `dolphy-ext types` (and by every build): the ids the manifest declares,",
|
|
401
|
+
" as TypeScript types. Not committed. A misspelt id, a declared id without",
|
|
402
|
+
" a handler or a view, or `ctx.settings.get` of an undeclared setting fails",
|
|
403
|
+
" `pnpm typecheck`;",
|
|
404
|
+
"- `test/index.test.ts` — tests (`vitest`, `happy-dom`).",
|
|
313
405
|
"",
|
|
314
|
-
"##
|
|
406
|
+
"## Development loop",
|
|
315
407
|
"",
|
|
316
408
|
"```sh",
|
|
317
409
|
"pnpm install",
|
|
318
|
-
`pnpm dev # dolphy-ext build --watch:
|
|
410
|
+
`pnpm dev # dolphy-ext build --watch: rebuilds into dist-ext/${id}`,
|
|
319
411
|
"```",
|
|
320
412
|
"",
|
|
321
|
-
"
|
|
322
|
-
"
|
|
413
|
+
"Start the app with the developer root pointing at the `dist-ext` directory",
|
|
414
|
+
"of this project (an absolute path):",
|
|
323
415
|
"",
|
|
324
416
|
"```sh",
|
|
325
|
-
"DOLPHY_DEV_EXTENSIONS
|
|
417
|
+
"DOLPHY_DEV_EXTENSIONS=<path to the project>/dist-ext pnpm dev # from the Dolphy repository",
|
|
326
418
|
"```",
|
|
327
419
|
"",
|
|
328
|
-
"
|
|
329
|
-
"
|
|
420
|
+
"A change to a file in `dist-ext` is applied live: the window does not",
|
|
421
|
+
"reload, mounted answer inputs are recreated. Load errors are shown in",
|
|
422
|
+
"\"Settings → Extensions\".",
|
|
330
423
|
"",
|
|
331
|
-
"##
|
|
424
|
+
"## Build, check, test",
|
|
332
425
|
"",
|
|
333
426
|
"```sh",
|
|
334
|
-
`pnpm build
|
|
335
|
-
"pnpm validate
|
|
427
|
+
`pnpm build # dist-ext/${id}`,
|
|
428
|
+
"pnpm validate # the same manifest parsing the app does",
|
|
429
|
+
"pnpm typecheck # writes .dolphy/ids.d.ts, then tsc",
|
|
336
430
|
"pnpm test",
|
|
337
431
|
"```",
|
|
338
432
|
"",
|
|
339
|
-
"##
|
|
433
|
+
"## Manual installation",
|
|
340
434
|
"",
|
|
341
|
-
|
|
342
|
-
"
|
|
435
|
+
`Copy the \`dist-ext/${id}\` directory to \`<userData>/extensions/\``,
|
|
436
|
+
"and restart the app. There is no installation from the app yet."
|
|
437
|
+
]);
|
|
438
|
+
const gitignore = () => lines([
|
|
439
|
+
"node_modules",
|
|
440
|
+
"dist-ext",
|
|
441
|
+
".dolphy"
|
|
343
442
|
]);
|
|
344
|
-
const gitignore = () => lines(["node_modules", "dist-ext"]);
|
|
345
443
|
|
|
346
444
|
//#endregion
|
|
347
445
|
//#region packages/create-extension/src/generate.ts
|
|
348
|
-
/**
|
|
446
|
+
/** Matches the manifest limit (`parseManifest`). */
|
|
349
447
|
const MAX_ID_CHARS = 64;
|
|
350
|
-
/**
|
|
448
|
+
/** Without a build (sources, `--local`) the package version is a placeholder. */
|
|
351
449
|
const UNPUBLISHED_VERSION = "^0.0.0";
|
|
352
|
-
const builtPackageVersion = () => "0.
|
|
450
|
+
const builtPackageVersion = () => "0.3.0";
|
|
353
451
|
var GenerateError = class extends Error {
|
|
354
452
|
code;
|
|
355
453
|
constructor(code, message) {
|
|
@@ -395,17 +493,15 @@ const assertEmpty = async (dir) => {
|
|
|
395
493
|
if (info === null) return;
|
|
396
494
|
if ((info.isDirectory() ? await readdir(dir) : ["(file)"]).length > 0) throw new GenerateError("target-not-empty", `${dir} is not empty`);
|
|
397
495
|
};
|
|
398
|
-
/**
|
|
496
|
+
/** Project files: relative path → content. */
|
|
399
497
|
const renderProject = (input) => {
|
|
400
498
|
const { id } = input;
|
|
401
499
|
return /* @__PURE__ */ new Map([
|
|
402
500
|
["package.json", packageJson(input)],
|
|
403
501
|
["tsconfig.json", tsconfigJson()],
|
|
404
502
|
["extension.json", manifestJson(id)],
|
|
405
|
-
["src/
|
|
406
|
-
["
|
|
407
|
-
["test/main.test.ts", mainTestTs(id)],
|
|
408
|
-
["test/view.test.ts", viewTestTs(id)],
|
|
503
|
+
["src/index.ts", indexTs(id)],
|
|
504
|
+
["test/index.test.ts", indexTestTs(id)],
|
|
409
505
|
["README.md", readme(id)],
|
|
410
506
|
[".gitignore", gitignore()]
|
|
411
507
|
]);
|
|
@@ -441,11 +537,11 @@ const EXIT_PROBLEMS = 1;
|
|
|
441
537
|
const EXIT_USAGE = 2;
|
|
442
538
|
const USAGE = `usage: create-dolphy-extension <dir> [--id <id>] [--local <repoRoot>]
|
|
443
539
|
|
|
444
|
-
<dir>
|
|
445
|
-
--id <id> id
|
|
446
|
-
--local <repoRoot>
|
|
447
|
-
@dolphy-app/extension-tools
|
|
448
|
-
--help
|
|
540
|
+
<dir> new project directory (must be empty or not exist)
|
|
541
|
+
--id <id> extension id (default: kebab-case of the directory name)
|
|
542
|
+
--local <repoRoot> Dolphy repository root: @dolphy-app/extension-sdk and
|
|
543
|
+
@dolphy-app/extension-tools are linked as link:<repoRoot>/packages/...
|
|
544
|
+
--help show this help
|
|
449
545
|
`;
|
|
450
546
|
const parseArgs = (argv) => {
|
|
451
547
|
if (argv.includes("--help") || argv.includes("-h")) return { help: true };
|
|
@@ -455,40 +551,40 @@ const parseArgs = (argv) => {
|
|
|
455
551
|
const arg = argv[i];
|
|
456
552
|
if (arg === "--id" || arg === "--local") {
|
|
457
553
|
const value = argv[++i];
|
|
458
|
-
if (value === void 0) return { usageError: `${arg}
|
|
554
|
+
if (value === void 0) return { usageError: `${arg} requires a value` };
|
|
459
555
|
values.set(arg, value);
|
|
460
|
-
} else if (arg.startsWith("-")) return { usageError:
|
|
556
|
+
} else if (arg.startsWith("-")) return { usageError: `unknown flag: ${arg}` };
|
|
461
557
|
else positional.push(arg);
|
|
462
558
|
}
|
|
463
559
|
const [dir, ...extra] = positional;
|
|
464
|
-
if (dir === void 0) return { usageError: "
|
|
465
|
-
if (extra.length > 0) return { usageError:
|
|
560
|
+
if (dir === void 0) return { usageError: "no directory given" };
|
|
561
|
+
if (extra.length > 0) return { usageError: `unexpected arguments: ${extra.join(" ")}` };
|
|
466
562
|
return {
|
|
467
563
|
dir,
|
|
468
564
|
id: values.get("--id"),
|
|
469
565
|
local: values.get("--local")
|
|
470
566
|
};
|
|
471
567
|
};
|
|
472
|
-
const PLACEHOLDER_NOTE = "\
|
|
568
|
+
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
569
|
const installNote = ({ isLocal, isPublished }) => {
|
|
474
570
|
if (isLocal) return "";
|
|
475
571
|
return isPublished ? "" : PLACEHOLDER_NOTE;
|
|
476
572
|
};
|
|
477
573
|
const nextSteps = (result) => {
|
|
478
574
|
const { dir, id } = result;
|
|
479
|
-
return `\n
|
|
575
|
+
return `\nNext steps:\n${[
|
|
480
576
|
` cd ${dir}`,
|
|
481
577
|
" pnpm install",
|
|
482
578
|
" pnpm test",
|
|
483
|
-
` pnpm dev #
|
|
579
|
+
` pnpm dev # rebuilds into dist-ext/${id}`,
|
|
484
580
|
"",
|
|
485
|
-
"
|
|
581
|
+
"Run the app with your extension (from the Dolphy repository):",
|
|
486
582
|
` DOLPHY_DEV_EXTENSIONS=${path.join(dir, "dist-ext")} pnpm dev`
|
|
487
583
|
].join("\n")}\n${installNote(result)}`;
|
|
488
584
|
};
|
|
489
585
|
/**
|
|
490
|
-
* `create-dolphy-extension`; `argv`
|
|
491
|
-
*
|
|
586
|
+
* `create-dolphy-extension`; `argv` without `node` and the script name; relative paths
|
|
587
|
+
* are resolved against `cwd`.
|
|
492
588
|
*/
|
|
493
589
|
const runCli = async (argv, io, cwd = process.cwd()) => {
|
|
494
590
|
const parsed = parseArgs(argv);
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dolphy-app/create-extension",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "Extension project generator: create-dolphy-extension <directory>",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"create-dolphy-extension": "./dist/cli/main.js"
|