@dolphy-app/create-extension 0.2.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 +23 -0
- package/dist/cli/main.js +527 -0
- package/package.json +25 -0
package/README.md
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# @dolphy-app/create-extension
|
|
2
|
+
|
|
3
|
+
Генератор проекта расширения: create-dolphy-extension <каталог>
|
|
4
|
+
|
|
5
|
+
Версия пакетов равна версии приложения Dolphy, из релиза которого они опубликованы (0.2.0).
|
|
6
|
+
|
|
7
|
+
Генератор проекта расширения.
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
npx @dolphy-app/create-extension <каталог>
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Проект получает тесты, сборку и README.
|
|
14
|
+
|
|
15
|
+
## Установка
|
|
16
|
+
|
|
17
|
+
```sh
|
|
18
|
+
npm install @dolphy-app/create-extension
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Документация
|
|
22
|
+
|
|
23
|
+
[Расширения Dolphy](https://github.com/dolphy-app/dolphy/blob/main/docs/design/extensions.md).
|
package/dist/cli/main.js
ADDED
|
@@ -0,0 +1,527 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { mkdir, readdir, stat, writeFile } from "node:fs/promises";
|
|
4
|
+
|
|
5
|
+
//#region packages/extension-api/src/index.ts
|
|
6
|
+
const EXTENSION_ID_PATTERN = /^[a-z][a-z0-9-]*(\.[a-z][a-z0-9-]*)*$/;
|
|
7
|
+
/** Тег элемента по умолчанию: `dolphy.sql` → `dolphy-sql-answer`. */
|
|
8
|
+
const defaultElementName = (id) => `${id.replaceAll(".", "-")}-answer`;
|
|
9
|
+
|
|
10
|
+
//#endregion
|
|
11
|
+
//#region packages/create-extension/src/template.ts
|
|
12
|
+
/** Версия расширения в шаблоне и его умолчание для `apiVersion`. */
|
|
13
|
+
const INITIAL_VERSION = "0.1.0";
|
|
14
|
+
const lines = (parts) => `${parts.join("\n")}\n`;
|
|
15
|
+
const packageJson = ({ id, dependencies }) => `${JSON.stringify({
|
|
16
|
+
name: id,
|
|
17
|
+
version: INITIAL_VERSION,
|
|
18
|
+
private: true,
|
|
19
|
+
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
|
+
},
|
|
26
|
+
devDependencies: {
|
|
27
|
+
"@dolphy-app/extension-sdk": dependencies.sdk,
|
|
28
|
+
"@dolphy-app/extension-tools": dependencies.tools,
|
|
29
|
+
"@types/node": "^22.20.4",
|
|
30
|
+
"happy-dom": "^20.14.5",
|
|
31
|
+
typescript: "^6.0.3",
|
|
32
|
+
vitest: "^5.0.2"
|
|
33
|
+
}
|
|
34
|
+
}, null, 2)}\n`;
|
|
35
|
+
const tsconfigJson = () => lines([
|
|
36
|
+
"{",
|
|
37
|
+
" \"compilerOptions\": {",
|
|
38
|
+
" \"target\": \"ES2023\",",
|
|
39
|
+
" \"lib\": [\"ES2023\", \"DOM\"],",
|
|
40
|
+
" \"module\": \"ESNext\",",
|
|
41
|
+
" \"moduleResolution\": \"bundler\",",
|
|
42
|
+
" \"types\": [\"node\"],",
|
|
43
|
+
" \"strict\": true,",
|
|
44
|
+
" \"resolveJsonModule\": true,",
|
|
45
|
+
" \"allowImportingTsExtensions\": true,",
|
|
46
|
+
" \"verbatimModuleSyntax\": true,",
|
|
47
|
+
" \"isolatedModules\": true,",
|
|
48
|
+
" \"skipLibCheck\": true,",
|
|
49
|
+
" \"noEmit\": true",
|
|
50
|
+
" },",
|
|
51
|
+
" \"include\": [\"src\", \"test\"]",
|
|
52
|
+
"}"
|
|
53
|
+
]);
|
|
54
|
+
const manifestJson = (id) => `{
|
|
55
|
+
"id": "${id}",
|
|
56
|
+
"version": "${INITIAL_VERSION}",
|
|
57
|
+
"apiVersion": 1,
|
|
58
|
+
"contributes": {
|
|
59
|
+
"exerciseTypes": [
|
|
60
|
+
{
|
|
61
|
+
"id": "${id}",
|
|
62
|
+
"specSchema": {
|
|
63
|
+
"type": "object",
|
|
64
|
+
"required": ["expected"],
|
|
65
|
+
"additionalProperties": false,
|
|
66
|
+
"properties": {
|
|
67
|
+
"expected": { "type": "string", "minLength": 1 },
|
|
68
|
+
"ignoreCase": { "type": "boolean" }
|
|
69
|
+
}
|
|
70
|
+
},
|
|
71
|
+
"answerSchema": { "type": "string" }
|
|
72
|
+
}
|
|
73
|
+
]
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
`;
|
|
77
|
+
const mainTs = (id) => `import { defineExerciseType, defineExtension } from '@dolphy-app/extension-sdk';
|
|
78
|
+
|
|
79
|
+
interface Spec {
|
|
80
|
+
expected: string;
|
|
81
|
+
ignoreCase?: boolean;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const matches = (answer: string, spec: Spec): boolean => {
|
|
85
|
+
if (spec.ignoreCase === true) {
|
|
86
|
+
return answer.toLowerCase() === spec.expected.toLowerCase();
|
|
87
|
+
}
|
|
88
|
+
return answer === spec.expected;
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
// схемы из extension.json уже проверили spec и ответ до вызова обработчиков
|
|
92
|
+
export default defineExtension({
|
|
93
|
+
exerciseTypes: {
|
|
94
|
+
'${id}': defineExerciseType<Spec, string, Record<string, never>>({
|
|
95
|
+
project: () => ({}),
|
|
96
|
+
grade: ({ spec, answer }) =>
|
|
97
|
+
matches(answer, spec)
|
|
98
|
+
? { outcome: 'passed' }
|
|
99
|
+
: { outcome: 'failed', reason: 'mismatch' },
|
|
100
|
+
referenceAnswer: ({ spec }) => spec.expected,
|
|
101
|
+
}),
|
|
102
|
+
},
|
|
103
|
+
});
|
|
104
|
+
`;
|
|
105
|
+
const viewTs = (id) => `import { defineAnswerElement } from '@dolphy-app/extension-sdk';
|
|
106
|
+
|
|
107
|
+
defineAnswerElement('${defaultElementName(id)}', (api, initial) => {
|
|
108
|
+
const input = document.createElement('input');
|
|
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 {
|
|
141
|
+
createSchemaValidator,
|
|
142
|
+
loadExerciseType,
|
|
143
|
+
} from '@dolphy-app/extension-sdk/testing';
|
|
144
|
+
import { afterEach, describe, expect, it } from 'vitest';
|
|
145
|
+
import manifest from '../extension.json';
|
|
146
|
+
import module from '../src/main.ts';
|
|
147
|
+
|
|
148
|
+
const [contribution] = manifest.contributes.exerciseTypes;
|
|
149
|
+
const validateSpec = createSchemaValidator(contribution.specSchema);
|
|
150
|
+
const validateAnswer = createSchemaValidator(contribution.answerSchema);
|
|
151
|
+
|
|
152
|
+
const spec = { expected: 'Hello' };
|
|
153
|
+
|
|
154
|
+
const disposables: { dispose(): Promise<void> }[] = [];
|
|
155
|
+
afterEach(async () => {
|
|
156
|
+
await Promise.all(disposables.splice(0).map((item) => item.dispose()));
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
const load = async () => {
|
|
160
|
+
const type = await loadExerciseType(module, '${id}');
|
|
161
|
+
disposables.push(type);
|
|
162
|
+
return type;
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
describe('${id}: обработчик', () => {
|
|
166
|
+
it('project не раскрывает эталон', async () => {
|
|
167
|
+
const type = await load();
|
|
168
|
+
expect(await type.project(spec)).toEqual({});
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('grade: совпадение засчитывается, расхождение нет', async () => {
|
|
172
|
+
const type = await load();
|
|
173
|
+
expect(await type.grade({ spec, answer: 'Hello' })).toEqual({
|
|
174
|
+
outcome: 'passed',
|
|
175
|
+
});
|
|
176
|
+
expect(await type.grade({ spec, answer: 'hello' })).toEqual({
|
|
177
|
+
outcome: 'failed',
|
|
178
|
+
reason: 'mismatch',
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it('grade: ignoreCase отключает различие регистров', async () => {
|
|
183
|
+
const type = await load();
|
|
184
|
+
const relaxed = { ...spec, ignoreCase: true };
|
|
185
|
+
expect(await type.grade({ spec: relaxed, answer: 'hELLO' })).toEqual({
|
|
186
|
+
outcome: 'passed',
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it('referenceAnswer сам проходит проверку', async () => {
|
|
191
|
+
const type = await load();
|
|
192
|
+
const reference = await type.referenceAnswer(spec);
|
|
193
|
+
expect(reference).toEqual({ found: true, answer: 'Hello' });
|
|
194
|
+
if (!reference.found) throw new Error('reference expected');
|
|
195
|
+
expect(await type.grade({ spec, answer: reference.answer })).toEqual({
|
|
196
|
+
outcome: 'passed',
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
describe('${id}: схемы', () => {
|
|
202
|
+
it.each([[{ expected: 'a' }], [{ expected: 'a', ignoreCase: true }]])(
|
|
203
|
+
'spec %j допустим',
|
|
204
|
+
(value) => {
|
|
205
|
+
expect(validateSpec(value)).toEqual([]);
|
|
206
|
+
},
|
|
207
|
+
);
|
|
208
|
+
|
|
209
|
+
it.each([
|
|
210
|
+
['нет expected', {}],
|
|
211
|
+
['пустой expected', { expected: '' }],
|
|
212
|
+
['ignoreCase не boolean', { expected: 'a', ignoreCase: 'yes' }],
|
|
213
|
+
['лишнее поле', { expected: 'a', extra: 1 }],
|
|
214
|
+
])('spec: %s отклоняется', (_name, value) => {
|
|
215
|
+
expect(validateSpec(value)).not.toEqual([]);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it('answer: строка допустима, число нет', () => {
|
|
219
|
+
expect(validateAnswer('text')).toEqual([]);
|
|
220
|
+
expect(validateAnswer(42)).not.toEqual([]);
|
|
221
|
+
});
|
|
222
|
+
});
|
|
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
|
+
|
|
258
|
+
describe('${tag}', () => {
|
|
259
|
+
it('ввод текста сообщает ответ; пустой ввод неполный', async () => {
|
|
260
|
+
const { input, changes } = await mountElement();
|
|
261
|
+
input.value = 'Hello';
|
|
262
|
+
input.dispatchEvent(new Event('input'));
|
|
263
|
+
input.value = ' ';
|
|
264
|
+
input.dispatchEvent(new Event('input'));
|
|
265
|
+
expect(changes).toEqual([
|
|
266
|
+
{ value: 'Hello', complete: true },
|
|
267
|
+
{ value: ' ', complete: false },
|
|
268
|
+
]);
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
it('Enter отправляет ответ', async () => {
|
|
272
|
+
const { element, input } = await mountElement();
|
|
273
|
+
let submits = 0;
|
|
274
|
+
element.addEventListener(ANSWER_EVENT.submit, () => void (submits += 1));
|
|
275
|
+
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' }));
|
|
276
|
+
expect(submits).toBe(1);
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it('disabled блокирует поле', async () => {
|
|
280
|
+
const { element, input } = await mountElement();
|
|
281
|
+
element.disabled = true;
|
|
282
|
+
await flush();
|
|
283
|
+
expect(input.disabled).toBe(true);
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
it('value восстанавливает ответ без событий', async () => {
|
|
287
|
+
const { element, input, changes } = await mountElement();
|
|
288
|
+
element.value = 'Hello';
|
|
289
|
+
await flush();
|
|
290
|
+
expect(input.value).toBe('Hello');
|
|
291
|
+
expect(changes).toEqual([]);
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
it('aria-label хоста попадает на поле', async () => {
|
|
295
|
+
const { input } = await mountElement('Ваш ответ');
|
|
296
|
+
expect(input.getAttribute('aria-label')).toBe('Ваш ответ');
|
|
297
|
+
});
|
|
298
|
+
});
|
|
299
|
+
`;
|
|
300
|
+
};
|
|
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
|
+
|
|
346
|
+
//#endregion
|
|
347
|
+
//#region packages/create-extension/src/generate.ts
|
|
348
|
+
/** Совпадает с ограничением манифеста (`parseManifest`). */
|
|
349
|
+
const MAX_ID_CHARS = 64;
|
|
350
|
+
/** Без сборки (исходники, `--local`) версия пакетов условная. */
|
|
351
|
+
const UNPUBLISHED_VERSION = "^0.0.0";
|
|
352
|
+
const builtPackageVersion = () => "0.2.0";
|
|
353
|
+
var GenerateError = class extends Error {
|
|
354
|
+
code;
|
|
355
|
+
constructor(code, message) {
|
|
356
|
+
super(message);
|
|
357
|
+
this.name = "GenerateError";
|
|
358
|
+
this.code = code;
|
|
359
|
+
}
|
|
360
|
+
};
|
|
361
|
+
/** `AcmeHello`, `acme_hello`, `acme.hello` → `acme-hello`. */
|
|
362
|
+
const deriveExtensionId = (name) => name.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
363
|
+
const isValidExtensionId = (id) => id.length <= MAX_ID_CHARS && EXTENSION_ID_PATTERN.test(id);
|
|
364
|
+
const isDirectory = async (target) => (await stat(target).catch(() => null))?.isDirectory() === true;
|
|
365
|
+
const resolveId = (dir, id) => {
|
|
366
|
+
const resolved = id ?? deriveExtensionId(path.basename(dir));
|
|
367
|
+
if (!isValidExtensionId(resolved)) throw new GenerateError("invalid-id", `'${resolved}' is not a valid extension id; pass one with --id`);
|
|
368
|
+
return resolved;
|
|
369
|
+
};
|
|
370
|
+
const dependencySpecs = async (localRoot, packageVersion) => {
|
|
371
|
+
if (localRoot === void 0) {
|
|
372
|
+
const range = packageVersion === null ? UNPUBLISHED_VERSION : `^${packageVersion}`;
|
|
373
|
+
return {
|
|
374
|
+
dependencies: {
|
|
375
|
+
sdk: range,
|
|
376
|
+
tools: range
|
|
377
|
+
},
|
|
378
|
+
isPublished: packageVersion !== null
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
const root = path.resolve(localRoot);
|
|
382
|
+
const sdk = path.join(root, "packages", "extension-sdk");
|
|
383
|
+
const tools = path.join(root, "packages", "extension-tools");
|
|
384
|
+
for (const dir of [sdk, tools]) if (!await isDirectory(dir)) throw new GenerateError("invalid-local", `${dir} not found: --local must point to the Dolphy repository root`);
|
|
385
|
+
return {
|
|
386
|
+
dependencies: {
|
|
387
|
+
sdk: `link:${sdk}`,
|
|
388
|
+
tools: `link:${tools}`
|
|
389
|
+
},
|
|
390
|
+
isPublished: false
|
|
391
|
+
};
|
|
392
|
+
};
|
|
393
|
+
const assertEmpty = async (dir) => {
|
|
394
|
+
const info = await stat(dir).catch(() => null);
|
|
395
|
+
if (info === null) return;
|
|
396
|
+
if ((info.isDirectory() ? await readdir(dir) : ["(file)"]).length > 0) throw new GenerateError("target-not-empty", `${dir} is not empty`);
|
|
397
|
+
};
|
|
398
|
+
/** Файлы проекта: относительный путь → содержимое. */
|
|
399
|
+
const renderProject = (input) => {
|
|
400
|
+
const { id } = input;
|
|
401
|
+
return /* @__PURE__ */ new Map([
|
|
402
|
+
["package.json", packageJson(input)],
|
|
403
|
+
["tsconfig.json", tsconfigJson()],
|
|
404
|
+
["extension.json", manifestJson(id)],
|
|
405
|
+
["src/main.ts", mainTs(id)],
|
|
406
|
+
["src/view.ts", viewTs(id)],
|
|
407
|
+
["test/main.test.ts", mainTestTs(id)],
|
|
408
|
+
["test/view.test.ts", viewTestTs(id)],
|
|
409
|
+
["README.md", readme(id)],
|
|
410
|
+
[".gitignore", gitignore()]
|
|
411
|
+
]);
|
|
412
|
+
};
|
|
413
|
+
const generateExtension = async (options) => {
|
|
414
|
+
const dir = path.resolve(options.dir);
|
|
415
|
+
const id = resolveId(dir, options.id);
|
|
416
|
+
const { dependencies, isPublished } = await dependencySpecs(options.localRoot, options.packageVersion ?? builtPackageVersion());
|
|
417
|
+
await assertEmpty(dir);
|
|
418
|
+
const isLocal = options.localRoot !== void 0;
|
|
419
|
+
const project = renderProject({
|
|
420
|
+
id,
|
|
421
|
+
dependencies
|
|
422
|
+
});
|
|
423
|
+
for (const [file, content] of project) {
|
|
424
|
+
const target = path.join(dir, ...file.split("/"));
|
|
425
|
+
await mkdir(path.dirname(target), { recursive: true });
|
|
426
|
+
await writeFile(target, content);
|
|
427
|
+
}
|
|
428
|
+
return {
|
|
429
|
+
dir,
|
|
430
|
+
files: [...project.keys()].sort(),
|
|
431
|
+
id,
|
|
432
|
+
isLocal,
|
|
433
|
+
isPublished
|
|
434
|
+
};
|
|
435
|
+
};
|
|
436
|
+
|
|
437
|
+
//#endregion
|
|
438
|
+
//#region packages/create-extension/src/cli/run.ts
|
|
439
|
+
const EXIT_OK = 0;
|
|
440
|
+
const EXIT_PROBLEMS = 1;
|
|
441
|
+
const EXIT_USAGE = 2;
|
|
442
|
+
const USAGE = `usage: create-dolphy-extension <dir> [--id <id>] [--local <repoRoot>]
|
|
443
|
+
|
|
444
|
+
<dir> каталог нового проекта (должен быть пуст или отсутствовать)
|
|
445
|
+
--id <id> id расширения (по умолчанию — kebab-case имени каталога)
|
|
446
|
+
--local <repoRoot> корень репозитория Dolphy: @dolphy-app/extension-sdk и
|
|
447
|
+
@dolphy-app/extension-tools подключаются как link:<repoRoot>/packages/...
|
|
448
|
+
--help эта справка
|
|
449
|
+
`;
|
|
450
|
+
const parseArgs = (argv) => {
|
|
451
|
+
if (argv.includes("--help") || argv.includes("-h")) return { help: true };
|
|
452
|
+
const positional = [];
|
|
453
|
+
const values = /* @__PURE__ */ new Map();
|
|
454
|
+
for (let i = 0; i < argv.length; i++) {
|
|
455
|
+
const arg = argv[i];
|
|
456
|
+
if (arg === "--id" || arg === "--local") {
|
|
457
|
+
const value = argv[++i];
|
|
458
|
+
if (value === void 0) return { usageError: `${arg} требует значение` };
|
|
459
|
+
values.set(arg, value);
|
|
460
|
+
} else if (arg.startsWith("-")) return { usageError: `неизвестный флаг: ${arg}` };
|
|
461
|
+
else positional.push(arg);
|
|
462
|
+
}
|
|
463
|
+
const [dir, ...extra] = positional;
|
|
464
|
+
if (dir === void 0) return { usageError: "не указан каталог" };
|
|
465
|
+
if (extra.length > 0) return { usageError: `лишние аргументы: ${extra.join(" ")}` };
|
|
466
|
+
return {
|
|
467
|
+
dir,
|
|
468
|
+
id: values.get("--id"),
|
|
469
|
+
local: values.get("--local")
|
|
470
|
+
};
|
|
471
|
+
};
|
|
472
|
+
const PLACEHOLDER_NOTE = "\nЗамечание: @dolphy-app/extension-sdk и @dolphy-app/extension-tools не опубликованы, версия ^0.0.0 не установится.\nУкажите пути к репозиторию Dolphy: create-dolphy-extension <dir> --local <repoRoot>.\n";
|
|
473
|
+
const installNote = ({ isLocal, isPublished }) => {
|
|
474
|
+
if (isLocal) return "";
|
|
475
|
+
return isPublished ? "" : PLACEHOLDER_NOTE;
|
|
476
|
+
};
|
|
477
|
+
const nextSteps = (result) => {
|
|
478
|
+
const { dir, id } = result;
|
|
479
|
+
return `\nДальше:\n${[
|
|
480
|
+
` cd ${dir}`,
|
|
481
|
+
" pnpm install",
|
|
482
|
+
" pnpm test",
|
|
483
|
+
` pnpm dev # пересборка в dist-ext/${id}`,
|
|
484
|
+
"",
|
|
485
|
+
"Запуск приложения с вашим расширением (из репозитория Dolphy):",
|
|
486
|
+
` DOLPHY_DEV_EXTENSIONS=${path.join(dir, "dist-ext")} pnpm dev`
|
|
487
|
+
].join("\n")}\n${installNote(result)}`;
|
|
488
|
+
};
|
|
489
|
+
/**
|
|
490
|
+
* `create-dolphy-extension`; `argv` без `node` и имени скрипта, относительные пути
|
|
491
|
+
* считаются от `cwd`.
|
|
492
|
+
*/
|
|
493
|
+
const runCli = async (argv, io, cwd = process.cwd()) => {
|
|
494
|
+
const parsed = parseArgs(argv);
|
|
495
|
+
if ("help" in parsed) {
|
|
496
|
+
io.stdout(USAGE);
|
|
497
|
+
return 0;
|
|
498
|
+
}
|
|
499
|
+
if ("usageError" in parsed) {
|
|
500
|
+
io.stderr(`${parsed.usageError}\n${USAGE}`);
|
|
501
|
+
return 2;
|
|
502
|
+
}
|
|
503
|
+
try {
|
|
504
|
+
const result = await generateExtension({
|
|
505
|
+
dir: path.resolve(cwd, parsed.dir),
|
|
506
|
+
...parsed.id === void 0 ? {} : { id: parsed.id },
|
|
507
|
+
...parsed.local === void 0 ? {} : { localRoot: path.resolve(cwd, parsed.local) }
|
|
508
|
+
});
|
|
509
|
+
io.stdout(`created ${result.id} in ${result.dir} (${result.files.length} files)\n`);
|
|
510
|
+
io.stdout(nextSteps(result));
|
|
511
|
+
return 0;
|
|
512
|
+
} catch (error) {
|
|
513
|
+
if (!(error instanceof GenerateError)) throw error;
|
|
514
|
+
io.stderr(`error: ${error.message}\n`);
|
|
515
|
+
return error.code === "target-not-empty" ? 1 : 2;
|
|
516
|
+
}
|
|
517
|
+
};
|
|
518
|
+
|
|
519
|
+
//#endregion
|
|
520
|
+
//#region packages/create-extension/src/cli/main.ts
|
|
521
|
+
process.exitCode = await runCli(process.argv.slice(2), {
|
|
522
|
+
stdout: (text) => process.stdout.write(text),
|
|
523
|
+
stderr: (text) => process.stderr.write(text)
|
|
524
|
+
}, process.env["INIT_CWD"] ?? process.cwd());
|
|
525
|
+
|
|
526
|
+
//#endregion
|
|
527
|
+
export { };
|
package/package.json
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@dolphy-app/create-extension",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Генератор проекта расширения: create-dolphy-extension <каталог>",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"create-dolphy-extension": "./dist/cli/main.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"dist"
|
|
11
|
+
],
|
|
12
|
+
"dependencies": {},
|
|
13
|
+
"engines": {
|
|
14
|
+
"node": ">=22.12"
|
|
15
|
+
},
|
|
16
|
+
"repository": {
|
|
17
|
+
"type": "git",
|
|
18
|
+
"url": "git+https://github.com/dolphy-app/dolphy.git",
|
|
19
|
+
"directory": "packages/create-extension"
|
|
20
|
+
},
|
|
21
|
+
"publishConfig": {
|
|
22
|
+
"access": "public",
|
|
23
|
+
"registry": "https://registry.npmjs.org"
|
|
24
|
+
}
|
|
25
|
+
}
|