@dolphy-app/extension-tools 0.4.0 → 0.6.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 +7 -6
- package/dist/cli/main.js +1121 -2724
- package/package.json +3 -2
package/dist/cli/main.js
CHANGED
|
@@ -1,15 +1,16 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { builtinModules } from "node:module";
|
|
3
|
-
import path from "node:path";
|
|
4
|
-
import { existsSync, readdirSync, realpathSync, statSync, watch } from "node:fs";
|
|
3
|
+
import path, { extname } from "node:path";
|
|
4
|
+
import { existsSync, readFileSync, readdirSync, realpathSync, statSync, watch } from "node:fs";
|
|
5
5
|
import { cp, lstat, mkdir, mkdtemp, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
6
|
-
import
|
|
6
|
+
import "ajv/dist/2020.js";
|
|
7
7
|
import { z } from "zod";
|
|
8
8
|
import { createHash } from "node:crypto";
|
|
9
|
-
import {
|
|
10
|
-
import { build, createLogger, parseAst } from "vite";
|
|
9
|
+
import { build, createLogger, mergeConfig, parseAst } from "vite";
|
|
11
10
|
import MagicString from "magic-string";
|
|
11
|
+
import vue from "@vitejs/plugin-vue";
|
|
12
12
|
import { homedir, tmpdir } from "node:os";
|
|
13
|
+
import { spawn } from "node:child_process";
|
|
13
14
|
|
|
14
15
|
//#region packages/extension-tools/src/errors.ts
|
|
15
16
|
/** Extension build/check error; `subject` is the extension id or directory. */
|
|
@@ -86,428 +87,26 @@ const WHEN_KEYS = Object.freeze({
|
|
|
86
87
|
},
|
|
87
88
|
"theme.dark": { type: "boolean" }
|
|
88
89
|
});
|
|
89
|
-
/** Longest condition, in characters. */
|
|
90
|
-
const WHEN_MAX_LENGTH$1 = 200;
|
|
91
|
-
/** A condition that does not parse or does not type-check. */
|
|
92
|
-
var WhenError = class extends Error {
|
|
93
|
-
reason;
|
|
94
|
-
/** Character index in the text where the problem starts. */
|
|
95
|
-
position;
|
|
96
|
-
/** What is wrong, without the position. */
|
|
97
|
-
detail;
|
|
98
|
-
constructor(reason, position, detail) {
|
|
99
|
-
super(`${detail} at ${position}`);
|
|
100
|
-
this.name = "WhenError";
|
|
101
|
-
this.reason = reason;
|
|
102
|
-
this.position = position;
|
|
103
|
-
this.detail = detail;
|
|
104
|
-
}
|
|
105
|
-
};
|
|
106
|
-
const WORD = /[A-Za-z][A-Za-z0-9._-]*/y;
|
|
107
|
-
const tokenize$1 = (text) => {
|
|
108
|
-
const tokens = [];
|
|
109
|
-
let index = 0;
|
|
110
|
-
while (index < text.length) {
|
|
111
|
-
const char = text[index];
|
|
112
|
-
if (/\s/.test(char)) {
|
|
113
|
-
index += 1;
|
|
114
|
-
continue;
|
|
115
|
-
}
|
|
116
|
-
const two = text.slice(index, index + 2);
|
|
117
|
-
if (two === "&&" || two === "||" || two === "==" || two === "!=") {
|
|
118
|
-
tokens.push({
|
|
119
|
-
kind: "punct",
|
|
120
|
-
text: two,
|
|
121
|
-
at: index
|
|
122
|
-
});
|
|
123
|
-
index += 2;
|
|
124
|
-
continue;
|
|
125
|
-
}
|
|
126
|
-
if (char === "(" || char === ")" || char === "," || char === "!") {
|
|
127
|
-
tokens.push({
|
|
128
|
-
kind: "punct",
|
|
129
|
-
text: char,
|
|
130
|
-
at: index
|
|
131
|
-
});
|
|
132
|
-
index += 1;
|
|
133
|
-
continue;
|
|
134
|
-
}
|
|
135
|
-
if (char === "'") {
|
|
136
|
-
const close = text.indexOf("'", index + 1);
|
|
137
|
-
if (close === -1) throw new WhenError("unterminated-string", index, "unterminated string");
|
|
138
|
-
tokens.push({
|
|
139
|
-
kind: "string",
|
|
140
|
-
text: text.slice(index + 1, close),
|
|
141
|
-
at: index
|
|
142
|
-
});
|
|
143
|
-
index = close + 1;
|
|
144
|
-
continue;
|
|
145
|
-
}
|
|
146
|
-
WORD.lastIndex = index;
|
|
147
|
-
const word = WORD.exec(text);
|
|
148
|
-
if (word !== null) {
|
|
149
|
-
tokens.push({
|
|
150
|
-
kind: "word",
|
|
151
|
-
text: word[0],
|
|
152
|
-
at: index
|
|
153
|
-
});
|
|
154
|
-
index += word[0].length;
|
|
155
|
-
continue;
|
|
156
|
-
}
|
|
157
|
-
throw new WhenError("unexpected-character", index, `unexpected character '${char}'`);
|
|
158
|
-
}
|
|
159
|
-
tokens.push({
|
|
160
|
-
kind: "end",
|
|
161
|
-
at: text.length
|
|
162
|
-
});
|
|
163
|
-
return tokens;
|
|
164
|
-
};
|
|
165
|
-
const isKey = (name) => Object.hasOwn(WHEN_KEYS, name);
|
|
166
|
-
const describe = (token) => {
|
|
167
|
-
if (token.kind === "end") return "end of the condition";
|
|
168
|
-
if (token.kind === "string") return `string '${token.text}'`;
|
|
169
|
-
return `'${token.text}'`;
|
|
170
|
-
};
|
|
171
|
-
function fail$1(token, expected) {
|
|
172
|
-
throw new WhenError(token.kind === "end" ? "unexpected-end" : "unexpected-token", token.at, `expected ${expected}, found ${describe(token)}`);
|
|
173
|
-
}
|
|
174
|
-
var Parser$1 = class {
|
|
175
|
-
index = 0;
|
|
176
|
-
tokens;
|
|
177
|
-
constructor(tokens) {
|
|
178
|
-
this.tokens = tokens;
|
|
179
|
-
}
|
|
180
|
-
peek() {
|
|
181
|
-
return this.tokens[this.index];
|
|
182
|
-
}
|
|
183
|
-
next() {
|
|
184
|
-
const token = this.tokens[this.index];
|
|
185
|
-
if (token.kind !== "end") this.index += 1;
|
|
186
|
-
return token;
|
|
187
|
-
}
|
|
188
|
-
isPunct(text) {
|
|
189
|
-
const token = this.peek();
|
|
190
|
-
return token.kind === "punct" && token.text === text;
|
|
191
|
-
}
|
|
192
|
-
isWord(text) {
|
|
193
|
-
const token = this.peek();
|
|
194
|
-
return token.kind === "word" && token.text === text;
|
|
195
|
-
}
|
|
196
|
-
parse() {
|
|
197
|
-
const expr = this.or();
|
|
198
|
-
const token = this.peek();
|
|
199
|
-
if (token.kind !== "end") fail$1(token, "'&&', '||' or the end");
|
|
200
|
-
return expr;
|
|
201
|
-
}
|
|
202
|
-
or() {
|
|
203
|
-
const terms = [this.and()];
|
|
204
|
-
while (this.isPunct("||")) {
|
|
205
|
-
this.next();
|
|
206
|
-
terms.push(this.and());
|
|
207
|
-
}
|
|
208
|
-
return terms.length === 1 ? terms[0] : {
|
|
209
|
-
type: "or",
|
|
210
|
-
terms
|
|
211
|
-
};
|
|
212
|
-
}
|
|
213
|
-
and() {
|
|
214
|
-
const terms = [this.factor()];
|
|
215
|
-
while (this.isPunct("&&")) {
|
|
216
|
-
this.next();
|
|
217
|
-
terms.push(this.factor());
|
|
218
|
-
}
|
|
219
|
-
return terms.length === 1 ? terms[0] : {
|
|
220
|
-
type: "and",
|
|
221
|
-
terms
|
|
222
|
-
};
|
|
223
|
-
}
|
|
224
|
-
factor() {
|
|
225
|
-
const token = this.peek();
|
|
226
|
-
if (token.kind === "punct" && token.text === "!") {
|
|
227
|
-
this.next();
|
|
228
|
-
return {
|
|
229
|
-
type: "not",
|
|
230
|
-
expr: this.negated()
|
|
231
|
-
};
|
|
232
|
-
}
|
|
233
|
-
if (token.kind === "punct" && token.text === "(") {
|
|
234
|
-
this.next();
|
|
235
|
-
const expr = this.or();
|
|
236
|
-
const close = this.peek();
|
|
237
|
-
if (!(close.kind === "punct" && close.text === ")")) fail$1(close, "')'");
|
|
238
|
-
this.next();
|
|
239
|
-
return expr;
|
|
240
|
-
}
|
|
241
|
-
if (token.kind !== "word") fail$1(token, "a key, true, false, ! or (");
|
|
242
|
-
if (token.text === "true" || token.text === "false") {
|
|
243
|
-
this.next();
|
|
244
|
-
return {
|
|
245
|
-
type: "literal",
|
|
246
|
-
value: token.text === "true"
|
|
247
|
-
};
|
|
248
|
-
}
|
|
249
|
-
return this.keyed();
|
|
250
|
-
}
|
|
251
|
-
/** The operand of `!`: a boolean key, a group or another `!`. */
|
|
252
|
-
negated() {
|
|
253
|
-
const token = this.peek();
|
|
254
|
-
if (token.kind === "word" && isKey(token.text)) {
|
|
255
|
-
const expr = this.keyed();
|
|
256
|
-
if (expr.type !== "key") throw new WhenError("unexpected-token", token.at, "'!' applies to a key or a group; write !(…) around a comparison");
|
|
257
|
-
return expr;
|
|
258
|
-
}
|
|
259
|
-
return this.factor();
|
|
260
|
-
}
|
|
261
|
-
/** A key alone (boolean keys only), or a comparison with it. */
|
|
262
|
-
keyed() {
|
|
263
|
-
const token = this.next();
|
|
264
|
-
if (token.kind !== "word") fail$1(token, "a key");
|
|
265
|
-
if (!isKey(token.text)) throw new WhenError("unknown-key", token.at, `unknown key '${token.text}' (known: ${Object.keys(WHEN_KEYS).join(", ")})`);
|
|
266
|
-
const key = token.text;
|
|
267
|
-
const spec = WHEN_KEYS[key];
|
|
268
|
-
const op = this.peek();
|
|
269
|
-
if (op.kind === "punct" && (op.text === "==" || op.text === "!=")) {
|
|
270
|
-
this.next();
|
|
271
|
-
const literal = this.literal(key, spec);
|
|
272
|
-
return {
|
|
273
|
-
type: "compare",
|
|
274
|
-
key,
|
|
275
|
-
op: op.text,
|
|
276
|
-
value: literal
|
|
277
|
-
};
|
|
278
|
-
}
|
|
279
|
-
if (this.isWord("in")) {
|
|
280
|
-
this.next();
|
|
281
|
-
const open = this.peek();
|
|
282
|
-
if (!(open.kind === "punct" && open.text === "(")) fail$1(open, "'(' after in");
|
|
283
|
-
this.next();
|
|
284
|
-
const values = [this.literal(key, spec)];
|
|
285
|
-
while (this.isPunct(",")) {
|
|
286
|
-
this.next();
|
|
287
|
-
values.push(this.literal(key, spec));
|
|
288
|
-
}
|
|
289
|
-
const close = this.peek();
|
|
290
|
-
if (!(close.kind === "punct" && close.text === ")")) fail$1(close, "',' or ')'");
|
|
291
|
-
this.next();
|
|
292
|
-
return {
|
|
293
|
-
type: "in",
|
|
294
|
-
key,
|
|
295
|
-
values
|
|
296
|
-
};
|
|
297
|
-
}
|
|
298
|
-
if (spec.type !== "boolean") throw new WhenError("type-mismatch", token.at, `'${key}' is a text key: compare it ('${key} == …') or use 'in'`);
|
|
299
|
-
return {
|
|
300
|
-
type: "key",
|
|
301
|
-
key
|
|
302
|
-
};
|
|
303
|
-
}
|
|
304
|
-
/** A string or `true`/`false` that fits the key's type. */
|
|
305
|
-
literal(key, spec) {
|
|
306
|
-
const token = this.next();
|
|
307
|
-
if (token.kind === "string") {
|
|
308
|
-
if (spec.type === "boolean") throw new WhenError("type-mismatch", token.at, `'${key}' is a boolean: compare it with true or false, not '${token.text}'`);
|
|
309
|
-
if (!spec.values.includes(token.text)) throw new WhenError("unknown-value", token.at, `unknown value '${token.text}' for '${key}' (known: ${spec.values.join(", ")})`);
|
|
310
|
-
return token.text;
|
|
311
|
-
}
|
|
312
|
-
if (token.kind === "word" && (token.text === "true" || token.text === "false")) {
|
|
313
|
-
if (spec.type !== "boolean") throw new WhenError("type-mismatch", token.at, `'${key}' is a text key: compare it with a 'string', not ${token.text}`);
|
|
314
|
-
return token.text === "true";
|
|
315
|
-
}
|
|
316
|
-
return fail$1(token, spec.type === "boolean" ? "true or false" : "a 'string'");
|
|
317
|
-
}
|
|
318
|
-
};
|
|
319
|
-
/**
|
|
320
|
-
* Parses and type-checks a condition. Throws `WhenError` (reason and
|
|
321
|
-
* character position) for an empty or longer than `WHEN_MAX_LENGTH`
|
|
322
|
-
* condition, a syntax error, an unknown key or value, and a type mismatch.
|
|
323
|
-
*/
|
|
324
|
-
const parseWhen$1 = (text) => {
|
|
325
|
-
if (text.trim() === "") throw new WhenError("empty", 0, "empty condition");
|
|
326
|
-
if (text.length > 200) throw new WhenError("too-long", 200, `condition longer than ${200} characters`);
|
|
327
|
-
return new Parser$1(tokenize$1(text)).parse();
|
|
328
|
-
};
|
|
329
90
|
|
|
330
91
|
//#endregion
|
|
331
|
-
//#region packages/extension-api/src/
|
|
332
|
-
/**
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
*/
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
const FALLBACK_LOCALE = "en";
|
|
341
|
-
/** Directory of the translation tables inside an extension. */
|
|
342
|
-
const LOCALES_DIR = "locales";
|
|
343
|
-
/** `locales/<language>.json` for a language. */
|
|
344
|
-
const localeFile = (locale) => `${LOCALES_DIR}/${locale}.json`;
|
|
345
|
-
/** Key of a translation (without the `%` signs). */
|
|
346
|
-
const LOCALE_KEY_PATTERN = /^[A-Za-z0-9_.-]{1,64}$/;
|
|
347
|
-
const PLACEHOLDER_PATTERN = /^%([A-Za-z0-9_.-]{1,64})%$/;
|
|
348
|
-
/** Limits of a translation file; the values are checked at discovery and by the tools. */
|
|
349
|
-
const LOCALE_LIMITS = Object.freeze({
|
|
350
|
-
/** Size of the file in bytes. */
|
|
351
|
-
fileBytes: 65536,
|
|
352
|
-
keys: 500,
|
|
353
|
-
/** Longest value in UTF-16 code units. */
|
|
354
|
-
valueLength: 500
|
|
92
|
+
//#region packages/extension-api/src/hooks.ts
|
|
93
|
+
/** Limits on hooks; the host and the engine check the same numbers. */
|
|
94
|
+
const EXTENSION_HOOK_LIMITS = Object.freeze({
|
|
95
|
+
/** Budget of one handler call, ms. */
|
|
96
|
+
timeoutMs: 3e4,
|
|
97
|
+
/** Hooks registered by one extension. */
|
|
98
|
+
hooks: 8,
|
|
99
|
+
/** Exercises in a `practice.batch` response. */
|
|
100
|
+
maxExercises: 500
|
|
355
101
|
});
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
const placeholderKey = (value) => PLACEHOLDER_PATTERN.exec(value)?.[1] ?? null;
|
|
359
|
-
const lookup = (table, key) => table !== void 0 && Object.hasOwn(table, key) ? table[key] : void 0;
|
|
360
|
-
/**
|
|
361
|
-
* Text for `locale`: a plain string is returned as is; `%key%` is taken from
|
|
362
|
-
* the table of `locale`, then from `en`, then stays `%key%`.
|
|
363
|
-
*/
|
|
364
|
-
const resolveText = (value, tables, locale) => {
|
|
365
|
-
const key = placeholderKey(value);
|
|
366
|
-
if (key === null || tables === void 0) return value;
|
|
367
|
-
return (isExtensionLocale(locale) ? lookup(tables[locale], key) : void 0) ?? lookup(tables["en"], key) ?? value;
|
|
368
|
-
};
|
|
369
|
-
const isRecord$2 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
370
|
-
/** Checks the parsed content of a translation file; every problem is an English message. */
|
|
371
|
-
const parseLocaleTable = (json) => {
|
|
372
|
-
if (!isRecord$2(json)) return {
|
|
373
|
-
ok: false,
|
|
374
|
-
issues: ["must be a flat object of strings"]
|
|
375
|
-
};
|
|
376
|
-
const entries = Object.entries(json);
|
|
377
|
-
const issues = [];
|
|
378
|
-
if (entries.length > LOCALE_LIMITS.keys) issues.push(`${entries.length} keys exceed the limit of ${LOCALE_LIMITS.keys}`);
|
|
379
|
-
const table = Object.create(null);
|
|
380
|
-
for (const [key, value] of entries) if (!LOCALE_KEY_PATTERN.test(key)) issues.push(`key '${key}' must match ${LOCALE_KEY_PATTERN}`);
|
|
381
|
-
else if (typeof value !== "string") issues.push(`'${key}' must be a string`);
|
|
382
|
-
else if (value.length > LOCALE_LIMITS.valueLength) issues.push(`'${key}' is longer than ${LOCALE_LIMITS.valueLength} characters`);
|
|
383
|
-
else table[key] = value;
|
|
384
|
-
return issues.length > 0 ? {
|
|
385
|
-
ok: false,
|
|
386
|
-
issues
|
|
387
|
-
} : {
|
|
388
|
-
ok: true,
|
|
389
|
-
table: { ...table }
|
|
390
|
-
};
|
|
391
|
-
};
|
|
392
|
-
/** Size of the text in UTF-8, without `TextEncoder`: the package has no DOM or Node types. */
|
|
393
|
-
const utf8Length = (text) => {
|
|
394
|
-
let bytes = 0;
|
|
395
|
-
for (const char of text) {
|
|
396
|
-
const code = char.codePointAt(0) ?? 0;
|
|
397
|
-
if (code < 128) bytes += 1;
|
|
398
|
-
else if (code < 2048) bytes += 2;
|
|
399
|
-
else if (code < 65536) bytes += 3;
|
|
400
|
-
else bytes += 4;
|
|
401
|
-
}
|
|
402
|
-
return bytes;
|
|
403
|
-
};
|
|
404
|
-
/** Checks the text of a translation file: size, JSON syntax and content. */
|
|
405
|
-
const parseLocaleText = (text) => {
|
|
406
|
-
const bytes = utf8Length(text);
|
|
407
|
-
if (bytes > LOCALE_LIMITS.fileBytes) return {
|
|
408
|
-
ok: false,
|
|
409
|
-
issues: [`${bytes} bytes exceed the limit of ${LOCALE_LIMITS.fileBytes}`]
|
|
410
|
-
};
|
|
411
|
-
let json;
|
|
412
|
-
try {
|
|
413
|
-
json = JSON.parse(text);
|
|
414
|
-
} catch (error) {
|
|
415
|
-
return {
|
|
416
|
-
ok: false,
|
|
417
|
-
issues: [`is not valid JSON: ${error instanceof Error ? error.message : String(error)}`]
|
|
418
|
-
};
|
|
419
|
-
}
|
|
420
|
-
return parseLocaleTable(json);
|
|
421
|
-
};
|
|
422
|
-
/** Fields of the manifest that accept `%key%`, by contribution point. */
|
|
423
|
-
const LOCALIZED_FIELDS = {
|
|
424
|
-
exerciseTypes: ["title"],
|
|
425
|
-
markdownRenderers: ["title"],
|
|
426
|
-
themes: ["label"],
|
|
427
|
-
gradePolicies: ["label"],
|
|
428
|
-
settings: [
|
|
429
|
-
"label",
|
|
430
|
-
"description",
|
|
431
|
-
"group"
|
|
432
|
-
],
|
|
433
|
-
commands: [
|
|
434
|
-
"title",
|
|
435
|
-
"description",
|
|
436
|
-
"category"
|
|
437
|
-
],
|
|
438
|
-
panels: ["title"],
|
|
439
|
-
widgets: ["title"]
|
|
440
|
-
};
|
|
441
|
-
/** Fields of the manifest root that accept `%key%`. */
|
|
442
|
-
const LOCALIZED_ROOT_FIELDS = ["name", "description"];
|
|
443
|
-
const visitFields = (holder, fields, prefix, visit) => {
|
|
444
|
-
if (!isRecord$2(holder)) return;
|
|
445
|
-
for (const field of fields) if (typeof holder[field] === "string") visit(holder, field, `${prefix}.${field}`);
|
|
446
|
-
};
|
|
447
|
-
const visitLocalizable = (manifest, visit) => {
|
|
448
|
-
if (!isRecord$2(manifest)) return;
|
|
449
|
-
for (const field of LOCALIZED_ROOT_FIELDS) if (typeof manifest[field] === "string") visit(manifest, field, field);
|
|
450
|
-
const { contributes } = manifest;
|
|
451
|
-
if (!isRecord$2(contributes)) return;
|
|
452
|
-
for (const [point, fields] of Object.entries(LOCALIZED_FIELDS)) {
|
|
453
|
-
const entries = contributes[point];
|
|
454
|
-
if (!Array.isArray(entries)) continue;
|
|
455
|
-
entries.forEach((entry, index) => {
|
|
456
|
-
const prefix = `contributes.${point}.${index}`;
|
|
457
|
-
visitFields(entry, fields, prefix, visit);
|
|
458
|
-
if (point !== "settings" || !isRecord$2(entry)) return;
|
|
459
|
-
const { options } = entry;
|
|
460
|
-
if (!Array.isArray(options)) return;
|
|
461
|
-
options.forEach((option, optionIndex) => visitFields(option, ["label"], `${prefix}.options.${optionIndex}`, visit));
|
|
462
|
-
});
|
|
463
|
-
}
|
|
464
|
-
};
|
|
465
|
-
/** Every localizable string of a raw or normalized manifest, in manifest order. */
|
|
466
|
-
const localizableStrings = (manifest) => {
|
|
467
|
-
const found = [];
|
|
468
|
-
visitLocalizable(manifest, (holder, field, path) => found.push({
|
|
469
|
-
path,
|
|
470
|
-
value: holder[field]
|
|
471
|
-
}));
|
|
472
|
-
return found;
|
|
473
|
-
};
|
|
474
|
-
/** Distinct keys of the `%key%` strings of a manifest, in manifest order. */
|
|
475
|
-
const placeholderKeys = (manifest) => [...new Set(localizableStrings(manifest).flatMap(({ value }) => {
|
|
476
|
-
const key = placeholderKey(value);
|
|
477
|
-
return key === null ? [] : [key];
|
|
478
|
-
}))];
|
|
479
|
-
/**
|
|
480
|
-
* A copy of the manifest with every placeholder replaced by its text in
|
|
481
|
-
* `locale` (chain: `locale`, `en`); a key without a text stays `%key%`.
|
|
482
|
-
*/
|
|
483
|
-
const localizeManifest = (manifest, tables, locale) => {
|
|
484
|
-
const copy = JSON.parse(JSON.stringify(manifest));
|
|
485
|
-
visitLocalizable(copy, (holder, field) => {
|
|
486
|
-
holder[field] = resolveText(holder[field], tables, locale);
|
|
487
|
-
});
|
|
488
|
-
return copy;
|
|
489
|
-
};
|
|
102
|
+
/** Hooks an extension can register with `server.before`. */
|
|
103
|
+
const EXTENSION_HOOK_NAMES = ["session.start", "practice.batch"];
|
|
490
104
|
|
|
491
105
|
//#endregion
|
|
492
106
|
//#region packages/extension-api/src/index.ts
|
|
493
|
-
/**
|
|
494
|
-
* Public extension API. The package depends on neither the engine nor the DOM: it is imported
|
|
495
|
-
* by extension code (`main.mjs`), by the answer element (`view.mjs`), and by the engine itself.
|
|
496
|
-
*/
|
|
497
107
|
const EXTENSION_ID_PATTERN = /^[a-z][a-z0-9-]*(\.[a-z][a-z0-9-]*)*$/;
|
|
498
108
|
/** GitHub login of the extension author (`author` in the manifest and catalog). */
|
|
499
109
|
const GITHUB_LOGIN_PATTERN = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})$/;
|
|
500
|
-
/** Capabilities an extension declares in its manifest; none without a declaration. */
|
|
501
|
-
const EXTENSION_PERMISSIONS = [
|
|
502
|
-
"library.read",
|
|
503
|
-
"process.spawn",
|
|
504
|
-
"worker.threads",
|
|
505
|
-
"native.addons",
|
|
506
|
-
"network",
|
|
507
|
-
"learning.events",
|
|
508
|
-
"learning.stats",
|
|
509
|
-
"notifications"
|
|
510
|
-
];
|
|
511
110
|
/** Platforms the extension can run on (`process.platform`). */
|
|
512
111
|
const EXTENSION_PLATFORMS = [
|
|
513
112
|
"darwin",
|
|
@@ -524,7 +123,6 @@ const EXTENSION_TAGS = [
|
|
|
524
123
|
"productivity",
|
|
525
124
|
"developer"
|
|
526
125
|
];
|
|
527
|
-
const ELEMENT_NAME_PATTERN = /^[a-z][a-z0-9]*(-[a-z0-9]+)+$/;
|
|
528
126
|
/**
|
|
529
127
|
* Closed list of icon names an extension picks from (`icon` of a command or panel); the
|
|
530
128
|
* app draws its own glyph for each name, so nothing from the extension is rendered as an image.
|
|
@@ -555,24 +153,13 @@ const EXTENSION_ICONS = [
|
|
|
555
153
|
"bookmark",
|
|
556
154
|
"tag"
|
|
557
155
|
];
|
|
558
|
-
/**
|
|
559
|
-
const
|
|
560
|
-
/**
|
|
561
|
-
|
|
562
|
-
/** Limits on widgets; the manifest and the app enforce them. */
|
|
563
|
-
const EXTENSION_WIDGET_LIMITS = Object.freeze({
|
|
564
|
-
/** Widgets per extension. */
|
|
565
|
-
widgets: 3,
|
|
566
|
-
/** Smallest allowed `minHeight`, px. */
|
|
567
|
-
minHeight: 80,
|
|
568
|
-
/** Largest allowed `maxHeight`, px. */
|
|
569
|
-
maxHeight: 320
|
|
570
|
-
});
|
|
156
|
+
/** Limits on injections; the app enforces them. */
|
|
157
|
+
const INJECTION_LIMITS = Object.freeze({
|
|
158
|
+
/** Characters in `InjectionRegistration.target`. */
|
|
159
|
+
selectorLength: 200 });
|
|
571
160
|
/** `at` of a `daily` schedule: `HH:MM`, 24-hour clock, local time. */
|
|
572
161
|
const SCHEDULE_AT_PATTERN = /^(?:[01]\d|2[0-3]):[0-5]\d$/;
|
|
573
|
-
/**
|
|
574
|
-
const DEFAULT_SCHEDULE_AT = "09:00";
|
|
575
|
-
/** Limits on schedules; the manifest, the scheduler, and the runtime enforce them. */
|
|
162
|
+
/** Limits on schedules; the host, the scheduler, and the runtime enforce them. */
|
|
576
163
|
const EXTENSION_SCHEDULE_LIMITS = Object.freeze({
|
|
577
164
|
/** Schedules per extension. */
|
|
578
165
|
schedules: 4,
|
|
@@ -583,7 +170,7 @@ const EXTENSION_SCHEDULE_LIMITS = Object.freeze({
|
|
|
583
170
|
/** How often the app looks for due firings, ms. */
|
|
584
171
|
tickMs: 3e4
|
|
585
172
|
});
|
|
586
|
-
/** Limits of the settings types (`text`, `color`, `list`, `group`, `order`); they match those checked by the
|
|
173
|
+
/** Limits of the settings types (`text`, `color`, `list`, `enum`, `group`, `order`); they match those checked by the host and the engine. */
|
|
587
174
|
const SETTING_LIMITS = Object.freeze({
|
|
588
175
|
/** `maxLength` of `string` and `text`. */
|
|
589
176
|
stringLength: 1e4,
|
|
@@ -591,60 +178,25 @@ const SETTING_LIMITS = Object.freeze({
|
|
|
591
178
|
listItems: 50,
|
|
592
179
|
/** `itemMaxLength` of `list`. */
|
|
593
180
|
listItemLength: 200,
|
|
181
|
+
/** Length of a label, in UTF-16 code units; also of an option's label. */
|
|
182
|
+
labelLength: 60,
|
|
183
|
+
/** Length of a help text. */
|
|
184
|
+
descriptionLength: 500,
|
|
594
185
|
groupLength: 60,
|
|
595
|
-
orderMax: 1e3
|
|
186
|
+
orderMax: 1e3,
|
|
187
|
+
/** Options of one `enum`. */
|
|
188
|
+
options: 64,
|
|
189
|
+
/** Length of an option's value. */
|
|
190
|
+
optionValueLength: 100
|
|
596
191
|
});
|
|
597
|
-
/** `#rrggbb` (any case in
|
|
192
|
+
/** `#rrggbb` (any case in a definition; the stored value is lower-case). */
|
|
598
193
|
const COLOR_SETTING_PATTERN = /^#[0-9a-fA-F]{6}$/;
|
|
599
|
-
/** Learning events an extension
|
|
194
|
+
/** Learning events an extension can subscribe to. */
|
|
600
195
|
const LEARNING_EVENT_NAMES = [
|
|
601
196
|
"session.started",
|
|
602
197
|
"session.finished",
|
|
603
198
|
"attempt.closed"
|
|
604
199
|
];
|
|
605
|
-
/** Identifiers of built-in themes: extensions cannot take them. */
|
|
606
|
-
const BUILTIN_THEME_IDS = [
|
|
607
|
-
"system",
|
|
608
|
-
"light",
|
|
609
|
-
"dark"
|
|
610
|
-
];
|
|
611
|
-
/** Allowed keys of `ThemeContribution.colors`. */
|
|
612
|
-
const THEME_COLOR_KEYS = [
|
|
613
|
-
"background",
|
|
614
|
-
"surface",
|
|
615
|
-
"surface-bright",
|
|
616
|
-
"surface-light",
|
|
617
|
-
"surface-variant",
|
|
618
|
-
"on-background",
|
|
619
|
-
"on-surface",
|
|
620
|
-
"on-surface-variant",
|
|
621
|
-
"primary",
|
|
622
|
-
"on-primary",
|
|
623
|
-
"secondary",
|
|
624
|
-
"on-secondary",
|
|
625
|
-
"error",
|
|
626
|
-
"on-error",
|
|
627
|
-
"warning",
|
|
628
|
-
"on-warning",
|
|
629
|
-
"success",
|
|
630
|
-
"on-success",
|
|
631
|
-
"info",
|
|
632
|
-
"on-info",
|
|
633
|
-
"hero-start",
|
|
634
|
-
"hero-end",
|
|
635
|
-
"hero-contrast"
|
|
636
|
-
];
|
|
637
|
-
/** Allowed keys of `ThemeContribution.variables`. */
|
|
638
|
-
const THEME_VARIABLE_KEYS = [
|
|
639
|
-
"border-color",
|
|
640
|
-
"border-opacity",
|
|
641
|
-
"medium-emphasis-opacity",
|
|
642
|
-
"high-emphasis-opacity",
|
|
643
|
-
"disabled-opacity"
|
|
644
|
-
];
|
|
645
|
-
const DEFAULT_MARKDOWN_RENDERER = "./markdown.mjs";
|
|
646
|
-
const DEFAULT_PANEL = "./panel.mjs";
|
|
647
|
-
const DEFAULT_WIDGET = "./widget.mjs";
|
|
648
200
|
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]*\\]|[\`\\-=\\[\\]\\\\;',./+])`;
|
|
649
201
|
/**
|
|
650
202
|
* Key notation: one stroke or two strokes separated by a space
|
|
@@ -656,11 +208,11 @@ const KEYBINDING_STROKE = `(?:(?:Mod|Ctrl|Control|Alt|Option|Shift|Cmd|Command|M
|
|
|
656
208
|
* host validates every string authoritatively with `@dolphy-app/keybindings`.
|
|
657
209
|
*/
|
|
658
210
|
const KEYBINDING_PATTERN = new RegExp(`^${KEYBINDING_STROKE}(?: ${KEYBINDING_STROKE})?$`);
|
|
659
|
-
/** Limits on commands and panels (R1, R3); they match those checked by the
|
|
211
|
+
/** Limits on commands and panels (R1, R3); they match those checked by the host and the engine. */
|
|
660
212
|
const EXTENSION_COMMAND_LIMITS = Object.freeze({
|
|
661
213
|
/** Keybinding entries (`keybindings`) per command. */
|
|
662
214
|
keybindingsPerCommand: 4,
|
|
663
|
-
/** Length of a `when` condition (of a command, panel
|
|
215
|
+
/** Length of a `when` condition (of a command, panel or `keybindings[]` entry). */
|
|
664
216
|
whenLength: 200,
|
|
665
217
|
/** Commands per extension. */
|
|
666
218
|
commands: 64,
|
|
@@ -680,7 +232,7 @@ const EXTENSION_COMMAND_LIMITS = Object.freeze({
|
|
|
680
232
|
});
|
|
681
233
|
/** A file extension an importer accepts: a dot and 1–16 lower-case letters or digits. */
|
|
682
234
|
const TRANSFER_ACCEPT_PATTERN = /^\.[a-z0-9]{1,16}$/;
|
|
683
|
-
/** Limits on importers and exporters; the
|
|
235
|
+
/** Limits on importers and exporters; the host and the engine check the same numbers. */
|
|
684
236
|
const EXTENSION_TRANSFER_LIMITS = Object.freeze({
|
|
685
237
|
/** Importers per extension. */
|
|
686
238
|
importers: 8,
|
|
@@ -705,10 +257,25 @@ const EXTENSION_TRANSFER_LIMITS = Object.freeze({
|
|
|
705
257
|
/** One path of the returned directory, UTF-8 bytes. */
|
|
706
258
|
pathBytes: 1024
|
|
707
259
|
});
|
|
260
|
+
/**
|
|
261
|
+
* Name of an RPC contract: lower-case dot-separated segments, such as
|
|
262
|
+
* `greeting.say-hello`, the first segment without a hyphen, at least two
|
|
263
|
+
* segments, at most `EXTENSION_RPC_LIMITS.nameLength` characters.
|
|
264
|
+
*/
|
|
265
|
+
const RPC_NAME_PATTERN = /^[a-z][a-z0-9]*(\.[a-z][a-z0-9-]*)+$/;
|
|
266
|
+
/** Limits on RPC between the client and the server part of an extension; the host and the engine check the same numbers. */
|
|
267
|
+
const EXTENSION_RPC_LIMITS = Object.freeze({
|
|
268
|
+
/** Characters in the name of a contract. */
|
|
269
|
+
nameLength: 120,
|
|
270
|
+
/** Handlers (`server.handle`) per extension. */
|
|
271
|
+
rpcs: 64,
|
|
272
|
+
/** `JSON.stringify(input).length` at the engine boundary. */
|
|
273
|
+
inputChars: 2e5,
|
|
274
|
+
/** Handler budget, ms. */
|
|
275
|
+
handlerMs: 1e4
|
|
276
|
+
});
|
|
708
277
|
const DEFAULT_MAIN = "./main.mjs";
|
|
709
|
-
const
|
|
710
|
-
/** Default element tag: `dolphy.sql` → `dolphy-sql-answer`. */
|
|
711
|
-
const defaultElementName = (id) => `${id.replaceAll(".", "-")}-answer`;
|
|
278
|
+
const DEFAULT_CLIENT = "./client.mjs";
|
|
712
279
|
/** Extension storage limits (R2); they match the engine's limits, which enforces them. */
|
|
713
280
|
const EXTENSION_STORAGE_LIMITS = Object.freeze({
|
|
714
281
|
/** Key length in UTF-16 code units. */
|
|
@@ -729,11 +296,11 @@ const EXTENSION_SECRET_LIMITS = Object.freeze({
|
|
|
729
296
|
/** Number of keys. */
|
|
730
297
|
keys: 32
|
|
731
298
|
});
|
|
732
|
-
/** Limits of `
|
|
299
|
+
/** Limits of `server.stats`; the engine enforces them. */
|
|
733
300
|
const EXTENSION_STATS_LIMITS = Object.freeze({
|
|
734
301
|
/** Most dates in one `daily` range (both ends included). */
|
|
735
302
|
dailyDays: 366 });
|
|
736
|
-
/** Limits of `
|
|
303
|
+
/** Limits of `server.notifications`; the engine enforces them. */
|
|
737
304
|
const EXTENSION_NOTIFICATION_LIMITS = Object.freeze({
|
|
738
305
|
/** Title length in characters (code points). */
|
|
739
306
|
titleLength: 80,
|
|
@@ -744,32 +311,46 @@ const EXTENSION_NOTIFICATION_LIMITS = Object.freeze({
|
|
|
744
311
|
/** Notifications per rolling hour and extension. */
|
|
745
312
|
perHour: 30
|
|
746
313
|
});
|
|
314
|
+
/** Registration of an extension without a server part, or before it has registered anything. */
|
|
315
|
+
const EMPTY_SERVER_REGISTRATION = Object.freeze({
|
|
316
|
+
exerciseTypes: Object.freeze([]),
|
|
317
|
+
gradePolicies: Object.freeze([]),
|
|
318
|
+
settings: Object.freeze([]),
|
|
319
|
+
events: Object.freeze([]),
|
|
320
|
+
commands: Object.freeze([]),
|
|
321
|
+
schedules: Object.freeze([]),
|
|
322
|
+
importers: Object.freeze([]),
|
|
323
|
+
exporters: Object.freeze([]),
|
|
324
|
+
rpcs: Object.freeze([]),
|
|
325
|
+
hooks: Object.freeze([])
|
|
326
|
+
});
|
|
747
327
|
|
|
748
328
|
//#endregion
|
|
749
329
|
//#region packages/extension-host/src/protocol.ts
|
|
750
330
|
const eventParams = z.strictObject({
|
|
751
331
|
extensionId: z.string(),
|
|
752
332
|
name: z.enum(LEARNING_EVENT_NAMES),
|
|
753
|
-
payload: z.record(z.string(), z.unknown())
|
|
754
|
-
isolated: z.boolean()
|
|
333
|
+
payload: z.record(z.string(), z.unknown())
|
|
755
334
|
});
|
|
756
335
|
const scheduleParams = z.strictObject({
|
|
757
336
|
extensionId: z.string(),
|
|
758
|
-
scheduleId: z.string()
|
|
759
|
-
isolated: z.boolean()
|
|
337
|
+
scheduleId: z.string()
|
|
760
338
|
});
|
|
761
339
|
const commandParams = z.strictObject({
|
|
762
340
|
extensionId: z.string(),
|
|
763
341
|
commandId: z.string(),
|
|
764
|
-
args: z.unknown().optional()
|
|
765
|
-
|
|
342
|
+
args: z.unknown().optional()
|
|
343
|
+
});
|
|
344
|
+
const rpcParams = z.strictObject({
|
|
345
|
+
extensionId: z.string(),
|
|
346
|
+
name: z.string(),
|
|
347
|
+
input: z.unknown().optional()
|
|
766
348
|
});
|
|
767
349
|
const bytesField = z.custom((value) => value instanceof Uint8Array, "must be a Uint8Array");
|
|
768
350
|
const importParams = {
|
|
769
351
|
extensionId: z.string(),
|
|
770
352
|
importerId: z.string(),
|
|
771
|
-
name: z.string()
|
|
772
|
-
isolated: z.boolean()
|
|
353
|
+
name: z.string()
|
|
773
354
|
};
|
|
774
355
|
const exportInput = z.discriminatedUnion("scope", [z.strictObject({
|
|
775
356
|
scope: z.literal("course"),
|
|
@@ -780,8 +361,7 @@ const exportInput = z.discriminatedUnion("scope", [z.strictObject({
|
|
|
780
361
|
const typed = {
|
|
781
362
|
type: z.string(),
|
|
782
363
|
exerciseId: z.string(),
|
|
783
|
-
spec: z.unknown()
|
|
784
|
-
isolated: z.boolean()
|
|
364
|
+
spec: z.unknown()
|
|
785
365
|
};
|
|
786
366
|
const extRequestSchema = z.discriminatedUnion("method", [
|
|
787
367
|
z.strictObject({
|
|
@@ -817,8 +397,7 @@ const extRequestSchema = z.discriminatedUnion("method", [
|
|
|
817
397
|
]),
|
|
818
398
|
reason: z.string().optional()
|
|
819
399
|
})),
|
|
820
|
-
gaveUp: z.boolean()
|
|
821
|
-
isolated: z.boolean()
|
|
400
|
+
gaveUp: z.boolean()
|
|
822
401
|
})
|
|
823
402
|
}),
|
|
824
403
|
z.strictObject({
|
|
@@ -836,6 +415,11 @@ const extRequestSchema = z.discriminatedUnion("method", [
|
|
|
836
415
|
method: z.literal("invokeCommand"),
|
|
837
416
|
params: commandParams
|
|
838
417
|
}),
|
|
418
|
+
z.strictObject({
|
|
419
|
+
id: z.string(),
|
|
420
|
+
method: z.literal("invokeRpc"),
|
|
421
|
+
params: rpcParams
|
|
422
|
+
}),
|
|
839
423
|
z.strictObject({
|
|
840
424
|
id: z.string(),
|
|
841
425
|
method: z.literal("runImporter"),
|
|
@@ -853,8 +437,16 @@ const extRequestSchema = z.discriminatedUnion("method", [
|
|
|
853
437
|
params: z.strictObject({
|
|
854
438
|
extensionId: z.string(),
|
|
855
439
|
exporterId: z.string(),
|
|
856
|
-
input: exportInput
|
|
857
|
-
|
|
440
|
+
input: exportInput
|
|
441
|
+
})
|
|
442
|
+
}),
|
|
443
|
+
z.strictObject({
|
|
444
|
+
id: z.string(),
|
|
445
|
+
method: z.literal("runHook"),
|
|
446
|
+
params: z.strictObject({
|
|
447
|
+
extensionId: z.string(),
|
|
448
|
+
name: z.enum(EXTENSION_HOOK_NAMES),
|
|
449
|
+
request: z.unknown()
|
|
858
450
|
})
|
|
859
451
|
})
|
|
860
452
|
]);
|
|
@@ -994,30 +586,44 @@ const hostResponseSchema = z.union([z.strictObject({
|
|
|
994
586
|
details: z.record(z.string(), z.unknown()).optional()
|
|
995
587
|
})
|
|
996
588
|
})]);
|
|
997
|
-
const
|
|
589
|
+
const isExtensionCandidate = (value) => {
|
|
998
590
|
if (typeof value !== "object" || value === null) return false;
|
|
999
591
|
const item = value;
|
|
1000
|
-
return typeof item.id === "string" && typeof item.version === "string" && typeof item.dir === "string" && typeof item.revision === "string" && (item.origin === "bundled" || item.origin === "user" || item.origin === "dev") &&
|
|
592
|
+
return typeof item.id === "string" && typeof item.version === "string" && typeof item.dir === "string" && typeof item.revision === "string" && (item.origin === "bundled" || item.origin === "user" || item.origin === "dev") && (item.mainPath === null || typeof item.mainPath === "string") && (item.clientPath === null || typeof item.clientPath === "string") && Array.isArray(item.dependencies) && Array.isArray(item.warnings);
|
|
1001
593
|
};
|
|
1002
594
|
/** Набор приходит от движка того же приложения, поэтому проверяется форма, а не каждое поле. */
|
|
1003
595
|
const replaceExtensionsSchema = z.strictObject({
|
|
1004
596
|
id: z.string(),
|
|
1005
597
|
method: z.literal("replaceExtensions"),
|
|
1006
|
-
params: z.strictObject({ extensions: z.array(z.custom(
|
|
598
|
+
params: z.strictObject({ extensions: z.array(z.custom(isExtensionCandidate)) })
|
|
1007
599
|
});
|
|
600
|
+
/** Кадры туннеля `engine-rpc`: форму кадра проверяет получатель протокола (диспетчер или клиент движка). */
|
|
601
|
+
const engineTunnelSchema = z.discriminatedUnion("method", [z.strictObject({
|
|
602
|
+
method: z.literal("engineFrame"),
|
|
603
|
+
params: z.strictObject({
|
|
604
|
+
extensionId: z.string(),
|
|
605
|
+
frame: z.unknown()
|
|
606
|
+
})
|
|
607
|
+
}), z.strictObject({
|
|
608
|
+
method: z.literal("engineDetach"),
|
|
609
|
+
params: z.strictObject({ extensionId: z.string() })
|
|
610
|
+
})]);
|
|
1008
611
|
/** Всё, что хост расширений принимает по каналу. */
|
|
1009
612
|
const extMessageSchema = z.union([
|
|
1010
613
|
extRequestSchema,
|
|
1011
614
|
replaceExtensionsSchema,
|
|
1012
615
|
settingChangedSchema,
|
|
616
|
+
engineTunnelSchema,
|
|
1013
617
|
hostResponseSchema
|
|
1014
618
|
]);
|
|
1015
|
-
/**
|
|
1016
|
-
const
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
619
|
+
/** Ответ на `replaceExtensions`: итог по каждому расширению; форму регистрации проверил регистратор хоста. */
|
|
620
|
+
const replaceExtensionsResultSchema = z.strictObject({ registrations: z.record(z.string(), z.union([z.strictObject({
|
|
621
|
+
ok: z.literal(true),
|
|
622
|
+
registration: z.custom((value) => typeof value === "object" && value !== null)
|
|
623
|
+
}), z.strictObject({
|
|
624
|
+
ok: z.literal(false),
|
|
625
|
+
error: z.string()
|
|
626
|
+
})])) });
|
|
1021
627
|
/** Результат правила оценки: целое 1–5 или `null`. */
|
|
1022
628
|
const gradeValueSchema = z.union([z.literal([
|
|
1023
629
|
1,
|
|
@@ -1081,6 +687,80 @@ const commandOutcomeSchema = z.discriminatedUnion("kind", [
|
|
|
1081
687
|
})
|
|
1082
688
|
]);
|
|
1083
689
|
|
|
690
|
+
//#endregion
|
|
691
|
+
//#region packages/extension-api/src/hook-schemas.ts
|
|
692
|
+
/**
|
|
693
|
+
* Request and response schemas of the hooks in `hooks.ts`. They live apart
|
|
694
|
+
* from the names and types so that code which only registers or types a hook
|
|
695
|
+
* (every browser bundle imports this package) does not pull `zod` in; the host,
|
|
696
|
+
* the engine and the test server import them from
|
|
697
|
+
* `@dolphy-app/extension-api/hook-schemas`.
|
|
698
|
+
*/
|
|
699
|
+
const itemReason = z.enum([
|
|
700
|
+
"review",
|
|
701
|
+
"new",
|
|
702
|
+
"remediation"
|
|
703
|
+
]);
|
|
704
|
+
/** `null` fields: the exercise has no attempts yet, so there is no memory state. */
|
|
705
|
+
const exerciseMemory = z.object({
|
|
706
|
+
/** Probability of recall right now, 0..1. */
|
|
707
|
+
retrievability: z.number().nullable(),
|
|
708
|
+
/** Unix epoch milliseconds of the newest attempt. */
|
|
709
|
+
lastAttemptAt: z.number().nullable(),
|
|
710
|
+
/** Attempts that count (cancelled ones excluded). */
|
|
711
|
+
attempts: z.number(),
|
|
712
|
+
/** FSRS stability in days. */
|
|
713
|
+
stability: z.number().nullable(),
|
|
714
|
+
/** FSRS difficulty. */
|
|
715
|
+
difficulty: z.number().nullable()
|
|
716
|
+
});
|
|
717
|
+
/**
|
|
718
|
+
* Request and response schema of every hook. The handlers of the extensions
|
|
719
|
+
* that registered a hook run one after another in ascending order of the
|
|
720
|
+
* extension id; each gets the response of the previous one (for a hook
|
|
721
|
+
* without a response, the same request). A thrown error, an invalid
|
|
722
|
+
* response or a handler exceeding `EXTENSION_HOOK_LIMITS.timeoutMs` cancels
|
|
723
|
+
* the operation and the error message reaches the user.
|
|
724
|
+
*/
|
|
725
|
+
const EXTENSION_HOOKS = Object.freeze({
|
|
726
|
+
/**
|
|
727
|
+
* A learning session is about to start. The handler can only cancel the
|
|
728
|
+
* start by throwing.
|
|
729
|
+
*/
|
|
730
|
+
"session.start": {
|
|
731
|
+
request: z.object({
|
|
732
|
+
/** Unix epoch milliseconds. */
|
|
733
|
+
now: z.number() }),
|
|
734
|
+
response: z.void()
|
|
735
|
+
},
|
|
736
|
+
/**
|
|
737
|
+
* A batch of exercises or a day plan is about to be handed to the learner.
|
|
738
|
+
* `exerciseIds` and `reasons` are parallel arrays; the response can
|
|
739
|
+
* reorder, remove and add exercises (every id must exist in the library;
|
|
740
|
+
* `exerciseIds` and `reasons` keep the same length, at most
|
|
741
|
+
* `EXTENSION_HOOK_LIMITS.maxExercises`).
|
|
742
|
+
*/
|
|
743
|
+
"practice.batch": {
|
|
744
|
+
request: z.object({
|
|
745
|
+
/** The open session; `null` while none has started. */
|
|
746
|
+
sessionId: z.string().nullable(),
|
|
747
|
+
/** `batch` — `practice.getBatch`, `plan` — `plan.getDay`. */
|
|
748
|
+
source: z.enum(["batch", "plan"]),
|
|
749
|
+
exerciseIds: z.array(z.string()),
|
|
750
|
+
reasons: z.array(itemReason),
|
|
751
|
+
/**
|
|
752
|
+
* Parallel to `exerciseIds`: what the engine remembers of each
|
|
753
|
+
* exercise. Read-only; the FSRS state is not changed by the hook.
|
|
754
|
+
*/
|
|
755
|
+
memory: z.array(exerciseMemory)
|
|
756
|
+
}),
|
|
757
|
+
response: z.object({
|
|
758
|
+
exerciseIds: z.array(z.string()).max(EXTENSION_HOOK_LIMITS.maxExercises),
|
|
759
|
+
reasons: z.array(itemReason).max(EXTENSION_HOOK_LIMITS.maxExercises)
|
|
760
|
+
}).refine(({ exerciseIds, reasons }) => exerciseIds.length === reasons.length, { message: "exerciseIds and reasons must have the same length" })
|
|
761
|
+
}
|
|
762
|
+
});
|
|
763
|
+
|
|
1084
764
|
//#endregion
|
|
1085
765
|
//#region packages/extension-host/src/diagnostics.ts
|
|
1086
766
|
const text$1 = (data, key) => {
|
|
@@ -1102,9 +782,7 @@ const FORMATTERS = {
|
|
|
1102
782
|
"dependency-disabled": (data) => `requires extension '${text$1(data, "id")}'${rangeSuffix(data)}, which is disabled`,
|
|
1103
783
|
"dependency-version": (data) => `requires extension '${text$1(data, "id")}' ${text$1(data, "range")}, found ${text$1(data, "found")}`,
|
|
1104
784
|
"dependency-unmet": (data) => `requires extension '${text$1(data, "id")}'${rangeSuffix(data)}, which is not loaded because its own dependencies are not met`,
|
|
1105
|
-
"dependency-cycle": (data) => `extensions depend on each other: ${text$1(data, "cycle")}
|
|
1106
|
-
"locale.missing-key": (data) => `key '${text$1(data, "key")}' is missing in locales/en.json`,
|
|
1107
|
-
"locale.invalid-file": (data) => `${text$1(data, "file")} is ignored: ${text$1(data, "reason")}`
|
|
785
|
+
"dependency-cycle": (data) => `extensions depend on each other: ${text$1(data, "cycle")}`
|
|
1108
786
|
};
|
|
1109
787
|
/**
|
|
1110
788
|
* English text of a diagnostic: the single place that renders it for the CLI,
|
|
@@ -1474,23 +1152,8 @@ const installMetaSchema = z.strictObject({
|
|
|
1474
1152
|
const CATALOG_SCHEMA_VERSION = 2;
|
|
1475
1153
|
const MAX_VERSIONS = 5;
|
|
1476
1154
|
const MAX_TOTAL_BYTES = 1e7;
|
|
1477
|
-
/** Longest contribution title in `titles` (the manifest limit of `label`/`title`). */
|
|
1478
|
-
const MAX_TITLE_LENGTH = 60;
|
|
1479
1155
|
/** Most tags of a version. */
|
|
1480
1156
|
const MAX_TAGS = 5;
|
|
1481
|
-
/** Contribution points whose entries carry a human title in the manifest (`label` or `title`); for exercise types and renderers the title is optional and the renderer is keyed by its language. */
|
|
1482
|
-
const TITLED_POINTS = [
|
|
1483
|
-
"exerciseTypes",
|
|
1484
|
-
"markdownRenderers",
|
|
1485
|
-
"themes",
|
|
1486
|
-
"gradePolicies",
|
|
1487
|
-
"settings",
|
|
1488
|
-
"commands",
|
|
1489
|
-
"panels",
|
|
1490
|
-
"widgets",
|
|
1491
|
-
"importers",
|
|
1492
|
-
"exporters"
|
|
1493
|
-
];
|
|
1494
1157
|
const MAX_PATH_LENGTH = 200;
|
|
1495
1158
|
const SHA256 = /^[0-9a-f]{64}$/;
|
|
1496
1159
|
/** Допустимые символы сегмента пути: без `:` (потоки NTFS), пробелов и управляющих символов. */
|
|
@@ -1663,7 +1326,6 @@ const versionSchemaOf = (profile) => object(profile, {
|
|
|
1663
1326
|
version: semver,
|
|
1664
1327
|
apiVersion: z.number().int().min(1),
|
|
1665
1328
|
minAppVersion: semver.nullable(),
|
|
1666
|
-
permissions: z.array(z.enum(EXTENSION_PERMISSIONS)),
|
|
1667
1329
|
publishedAt: timestamp,
|
|
1668
1330
|
baseUrl,
|
|
1669
1331
|
files: filesSchemaOf(profile),
|
|
@@ -1674,26 +1336,6 @@ const versionSchemaOf = (profile) => object(profile, {
|
|
|
1674
1336
|
/** Extensions the version needs (`dependencies` of the manifest); no key — none. */
|
|
1675
1337
|
dependencies: dependenciesSchemaOf(profile)
|
|
1676
1338
|
});
|
|
1677
|
-
const contributesSchemaOf = (profile) => object(profile, {
|
|
1678
|
-
exerciseTypes: z.array(z.string()),
|
|
1679
|
-
themes: z.array(z.string()),
|
|
1680
|
-
markdownRenderers: z.array(z.string()),
|
|
1681
|
-
gradePolicies: z.array(z.string()),
|
|
1682
|
-
settings: z.array(z.string()).optional(),
|
|
1683
|
-
events: z.array(z.string()).optional(),
|
|
1684
|
-
commands: z.array(z.string()).optional(),
|
|
1685
|
-
panels: z.array(z.string()).optional(),
|
|
1686
|
-
widgets: z.array(z.string()).optional(),
|
|
1687
|
-
schedules: z.array(z.string()).optional(),
|
|
1688
|
-
importers: z.array(z.string()).optional(),
|
|
1689
|
-
exporters: z.array(z.string()).optional()
|
|
1690
|
-
});
|
|
1691
|
-
const titleMap = z.record(extensionId$1, z.string().min(1).max(MAX_TITLE_LENGTH));
|
|
1692
|
-
/** Titles of the contributions by point; an unreadable map is dropped by the tolerant reader, the entry stays. */
|
|
1693
|
-
const titlesSchemaOf = (profile) => {
|
|
1694
|
-
const schema = object(profile, Object.fromEntries(TITLED_POINTS.map((point) => [point, titleMap.optional()]))).optional();
|
|
1695
|
-
return profile.strict ? schema : schema.catch(void 0).optional();
|
|
1696
|
-
};
|
|
1697
1339
|
const entryHeadOf = (profile) => ({
|
|
1698
1340
|
id: extensionId$1,
|
|
1699
1341
|
name: z.string().min(1).max(80),
|
|
@@ -1701,27 +1343,11 @@ const entryHeadOf = (profile) => ({
|
|
|
1701
1343
|
author: z.string().regex(GITHUB_LOGIN_PATTERN, "must be a GitHub login"),
|
|
1702
1344
|
source: httpsUrl,
|
|
1703
1345
|
platforms: z.array(z.enum(EXTENSION_PLATFORMS)),
|
|
1704
|
-
contributes: contributesSchemaOf(profile),
|
|
1705
|
-
titles: titlesSchemaOf(profile),
|
|
1706
1346
|
deprecated: profile.strict ? strictDeprecated.optional() : tolerantDeprecated
|
|
1707
1347
|
});
|
|
1708
1348
|
const entrySchemaOf = (profile) => object(profile, {
|
|
1709
1349
|
...entryHeadOf(profile),
|
|
1710
1350
|
versions: z.array(versionSchemaOf(profile)).min(1).max(5).superRefine(descendingUnique)
|
|
1711
|
-
}).superRefine((entry, ctx) => {
|
|
1712
|
-
if (entry.titles === void 0 || !profile.strict) return;
|
|
1713
|
-
for (const point of TITLED_POINTS) {
|
|
1714
|
-
const known = new Set(entry.contributes[point] ?? []);
|
|
1715
|
-
for (const id of Object.keys(entry.titles[point] ?? {})) if (!known.has(id)) ctx.addIssue({
|
|
1716
|
-
code: "custom",
|
|
1717
|
-
path: [
|
|
1718
|
-
"titles",
|
|
1719
|
-
point,
|
|
1720
|
-
id
|
|
1721
|
-
],
|
|
1722
|
-
message: `title for '${id}', which is not in contributes.${point}`
|
|
1723
|
-
});
|
|
1724
|
-
}
|
|
1725
1351
|
});
|
|
1726
1352
|
const indexSchemaOf = (profile) => object(profile, {
|
|
1727
1353
|
schemaVersion: z.literal(2),
|
|
@@ -1761,1394 +1387,115 @@ const tolerantVersion = versionSchemaOf(TOLERANT_PROFILE);
|
|
|
1761
1387
|
const FULL_INDEX_FILE = "index.v2.json";
|
|
1762
1388
|
|
|
1763
1389
|
//#endregion
|
|
1764
|
-
//#region packages/extension-host/src/
|
|
1765
|
-
|
|
1766
|
-
const
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
kind: "invalid",
|
|
1772
|
-
reason: errorText$3(info)
|
|
1773
|
-
};
|
|
1774
|
-
if (!info.isFile()) return {
|
|
1775
|
-
kind: "invalid",
|
|
1776
|
-
reason: "is not a regular file"
|
|
1777
|
-
};
|
|
1778
|
-
if (info.size > LOCALE_LIMITS.fileBytes) return {
|
|
1779
|
-
kind: "invalid",
|
|
1780
|
-
reason: `${info.size} bytes exceed the limit of ${LOCALE_LIMITS.fileBytes}`
|
|
1781
|
-
};
|
|
1782
|
-
let text;
|
|
1390
|
+
//#region packages/extension-host/src/manifest.ts
|
|
1391
|
+
/** Относительный путь внутри каталога расширения: без `..`, `\` и ведущего `/`. */
|
|
1392
|
+
const isSafeRelativePath = (value) => value.length > 0 && !value.includes("\\") && !value.startsWith("/") && !value.split("/").includes("..");
|
|
1393
|
+
const safePath = (extensions) => z.string().refine(isSafeRelativePath, "must be a safe relative path").refine((value) => extensions.some((ext) => value.endsWith(ext)), `must end with ${extensions.join(" or ")}`);
|
|
1394
|
+
const extensionId = z.string().max(64).regex(EXTENSION_ID_PATTERN, "invalid extension id");
|
|
1395
|
+
/** Диапазон версий в том же виде, что `versions` отзыва и устаревания в каталоге. */
|
|
1396
|
+
const versionRange = z.string().superRefine((value, ctx) => {
|
|
1783
1397
|
try {
|
|
1784
|
-
|
|
1785
|
-
} catch
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
};
|
|
1790
|
-
}
|
|
1791
|
-
const parsed = parseLocaleText(text);
|
|
1792
|
-
return parsed.ok ? {
|
|
1793
|
-
kind: "table",
|
|
1794
|
-
table: { ...parsed.table }
|
|
1795
|
-
} : {
|
|
1796
|
-
kind: "invalid",
|
|
1797
|
-
reason: parsed.issues.join("; ")
|
|
1798
|
-
};
|
|
1799
|
-
};
|
|
1800
|
-
/**
|
|
1801
|
-
* Reads `locales/<language>.json` of an extension. A broken file is ignored with
|
|
1802
|
-
* a warning, the extension keeps working; a `%key%` of the manifest that `en`
|
|
1803
|
-
* does not translate is a warning too (the window shows the raw `%key%`).
|
|
1804
|
-
*/
|
|
1805
|
-
const loadLocales = async (dir, manifest) => {
|
|
1806
|
-
const messages = {};
|
|
1807
|
-
const warnings = [];
|
|
1808
|
-
for (const locale of EXTENSION_LOCALES) {
|
|
1809
|
-
const read = await readTable(dir, locale);
|
|
1810
|
-
if (read.kind === "table") messages[locale] = read.table;
|
|
1811
|
-
else if (read.kind === "invalid") warnings.push({
|
|
1812
|
-
code: "locale.invalid-file",
|
|
1813
|
-
data: {
|
|
1814
|
-
file: localeFile(locale),
|
|
1815
|
-
reason: read.reason
|
|
1816
|
-
}
|
|
1398
|
+
parseRange(value);
|
|
1399
|
+
} catch {
|
|
1400
|
+
ctx.addIssue({
|
|
1401
|
+
code: "custom",
|
|
1402
|
+
message: "range must be space-separated comparators such as '>=1.2.0 <2.0.0'"
|
|
1817
1403
|
});
|
|
1818
1404
|
}
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1405
|
+
});
|
|
1406
|
+
const manifestSchema = z.strictObject({
|
|
1407
|
+
/** Ссылка на JSON Schema для редактора; приложением и инструментами игнорируется. */
|
|
1408
|
+
$schema: z.string().optional(),
|
|
1409
|
+
id: extensionId,
|
|
1410
|
+
version: z.string().refine(isSemver, "version must be semver"),
|
|
1411
|
+
apiVersion: z.literal(1),
|
|
1412
|
+
main: safePath([".mjs"]).nullable().optional(),
|
|
1413
|
+
client: safePath([".mjs"]).nullable().optional(),
|
|
1414
|
+
name: z.string().min(1).max(80).optional(),
|
|
1415
|
+
description: z.string().min(1).max(500).optional(),
|
|
1416
|
+
author: z.string().regex(GITHUB_LOGIN_PATTERN, "must be a GitHub login").optional(),
|
|
1417
|
+
platforms: z.array(z.enum(EXTENSION_PLATFORMS)).optional(),
|
|
1418
|
+
minAppVersion: z.string().refine(isSemver, "minAppVersion must be semver x.y.z").optional(),
|
|
1419
|
+
icon: safePath([".png", ".webp"]).optional(),
|
|
1420
|
+
tags: z.array(z.enum(EXTENSION_TAGS, { error: `tag must be one of: ${EXTENSION_TAGS.join(", ")}` })).max(5, "at most 5 tags").optional(),
|
|
1421
|
+
dependencies: z.array(z.strictObject({
|
|
1422
|
+
id: extensionId,
|
|
1423
|
+
range: versionRange.optional()
|
|
1424
|
+
})).max(16, `at most ${16} dependencies`).optional(),
|
|
1425
|
+
/** Вклады регистрирует код (`server`, `client`): ключ отвергается с подсказкой. */
|
|
1426
|
+
contributes: z.never({ error: "contributions are registered in code (src/index.ts: server, client)" }).optional()
|
|
1427
|
+
}).superRefine((manifest, ctx) => {
|
|
1428
|
+
const { platforms = [] } = manifest;
|
|
1429
|
+
platforms.forEach((platform, index) => {
|
|
1430
|
+
if (platforms.indexOf(platform) !== index) ctx.addIssue({
|
|
1431
|
+
code: "custom",
|
|
1432
|
+
path: ["platforms", index],
|
|
1433
|
+
message: `duplicate platform '${platform}'`
|
|
1434
|
+
});
|
|
1435
|
+
});
|
|
1436
|
+
const { tags = [] } = manifest;
|
|
1437
|
+
tags.forEach((tag, index) => {
|
|
1438
|
+
if (tags.indexOf(tag) !== index) ctx.addIssue({
|
|
1439
|
+
code: "custom",
|
|
1440
|
+
path: ["tags", index],
|
|
1441
|
+
message: `duplicate tag '${tag}'`
|
|
1442
|
+
});
|
|
1443
|
+
});
|
|
1444
|
+
const { dependencies = [] } = manifest;
|
|
1445
|
+
dependencies.forEach(({ id }, index) => {
|
|
1446
|
+
if (id === manifest.id) ctx.addIssue({
|
|
1447
|
+
code: "custom",
|
|
1448
|
+
path: [
|
|
1449
|
+
"dependencies",
|
|
1450
|
+
index,
|
|
1451
|
+
"id"
|
|
1452
|
+
],
|
|
1453
|
+
message: "an extension cannot depend on itself"
|
|
1454
|
+
});
|
|
1455
|
+
else if (dependencies.findIndex((item) => item.id === id) !== index) ctx.addIssue({
|
|
1456
|
+
code: "custom",
|
|
1457
|
+
path: [
|
|
1458
|
+
"dependencies",
|
|
1459
|
+
index,
|
|
1460
|
+
"id"
|
|
1461
|
+
],
|
|
1462
|
+
message: `duplicate dependency '${id}'`
|
|
1463
|
+
});
|
|
1823
1464
|
});
|
|
1824
|
-
return {
|
|
1825
|
-
messages,
|
|
1826
|
-
warnings
|
|
1827
|
-
};
|
|
1828
|
-
};
|
|
1829
|
-
|
|
1830
|
-
//#endregion
|
|
1831
|
-
//#region packages/keybindings/src/keystroke.ts
|
|
1832
|
-
const MAX_CHORD_LENGTH = 2;
|
|
1833
|
-
var KeybindingSyntaxError = class extends Error {
|
|
1834
|
-
reason;
|
|
1835
|
-
text;
|
|
1836
|
-
constructor(reason, text) {
|
|
1837
|
-
super(`invalid keybinding "${text}": ${reason}`);
|
|
1838
|
-
this.name = "KeybindingSyntaxError";
|
|
1839
|
-
this.reason = reason;
|
|
1840
|
-
this.text = text;
|
|
1841
|
-
}
|
|
1842
|
-
};
|
|
1843
|
-
const MODIFIER_ALIASES = {
|
|
1844
|
-
mod: "mod",
|
|
1845
|
-
ctrl: "ctrl",
|
|
1846
|
-
control: "ctrl",
|
|
1847
|
-
alt: "alt",
|
|
1848
|
-
option: "alt",
|
|
1849
|
-
opt: "alt",
|
|
1850
|
-
shift: "shift",
|
|
1851
|
-
meta: "meta",
|
|
1852
|
-
cmd: "meta",
|
|
1853
|
-
command: "meta",
|
|
1854
|
-
win: "meta",
|
|
1855
|
-
super: "meta"
|
|
1856
|
-
};
|
|
1857
|
-
const NAMED_KEYS = {
|
|
1858
|
-
enter: "enter",
|
|
1859
|
-
return: "enter",
|
|
1860
|
-
escape: "escape",
|
|
1861
|
-
esc: "escape",
|
|
1862
|
-
tab: "tab",
|
|
1863
|
-
space: "space",
|
|
1864
|
-
spacebar: "space",
|
|
1865
|
-
backspace: "backspace",
|
|
1866
|
-
delete: "delete",
|
|
1867
|
-
del: "delete",
|
|
1868
|
-
insert: "insert",
|
|
1869
|
-
ins: "insert",
|
|
1870
|
-
home: "home",
|
|
1871
|
-
end: "end",
|
|
1872
|
-
pageup: "pageup",
|
|
1873
|
-
pgup: "pageup",
|
|
1874
|
-
pagedown: "pagedown",
|
|
1875
|
-
pgdn: "pagedown",
|
|
1876
|
-
arrowup: "arrowup",
|
|
1877
|
-
up: "arrowup",
|
|
1878
|
-
arrowdown: "arrowdown",
|
|
1879
|
-
down: "arrowdown",
|
|
1880
|
-
arrowleft: "arrowleft",
|
|
1881
|
-
left: "arrowleft",
|
|
1882
|
-
arrowright: "arrowright",
|
|
1883
|
-
right: "arrowright",
|
|
1884
|
-
plus: "+"
|
|
1885
|
-
};
|
|
1886
|
-
const FUNCTION_KEY = /^f([1-9]|1\d|2[0-4])$/;
|
|
1887
|
-
const PHYSICAL = /^\[([A-Za-z][A-Za-z0-9]*)\]$/;
|
|
1888
|
-
const own = (table, key) => Object.hasOwn(table, key) ? table[key] : void 0;
|
|
1889
|
-
const isSingleCharacter = (text) => Array.from(text).length === 1 && !/\s/u.test(text);
|
|
1890
|
-
/** Имя клавиши записи в каноническую логическую форму; `null` — неизвестное имя. */
|
|
1891
|
-
const normalizeKeyName = (part) => {
|
|
1892
|
-
const lower = part.toLowerCase();
|
|
1893
|
-
const named = own(NAMED_KEYS, lower);
|
|
1894
|
-
if (named !== void 0) return named;
|
|
1895
|
-
if (FUNCTION_KEY.test(lower)) return lower;
|
|
1896
|
-
return isSingleCharacter(part) ? lower : null;
|
|
1897
|
-
};
|
|
1898
|
-
const stroke = (flags, key, code) => ({
|
|
1899
|
-
ctrl: flags.ctrl,
|
|
1900
|
-
alt: flags.alt,
|
|
1901
|
-
shift: flags.shift,
|
|
1902
|
-
meta: flags.meta,
|
|
1903
|
-
key,
|
|
1904
|
-
code
|
|
1905
1465
|
});
|
|
1906
|
-
/**
|
|
1907
|
-
const
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
|
|
1466
|
+
/** Применяет умолчания: части, которой нет, соответствует `null`. */
|
|
1467
|
+
const normalizeManifest = (input) => ({
|
|
1468
|
+
id: input.id,
|
|
1469
|
+
version: input.version,
|
|
1470
|
+
apiVersion: input.apiVersion,
|
|
1471
|
+
main: input.main ?? null,
|
|
1472
|
+
client: input.client ?? null,
|
|
1473
|
+
name: input.name ?? null,
|
|
1474
|
+
description: input.description ?? null,
|
|
1475
|
+
author: input.author ?? null,
|
|
1476
|
+
platforms: [...input.platforms ?? []],
|
|
1477
|
+
minAppVersion: input.minAppVersion ?? null,
|
|
1478
|
+
tags: [...input.tags ?? []],
|
|
1479
|
+
dependencies: (input.dependencies ?? []).map(({ id, range }) => ({
|
|
1480
|
+
id,
|
|
1481
|
+
range: range ?? null
|
|
1482
|
+
})),
|
|
1483
|
+
icon: input.icon ?? null
|
|
1484
|
+
});
|
|
1485
|
+
const zodIssues = (error) => error.issues.map((issue) => `${issue.path.join(".") || "/"}: ${issue.message}`);
|
|
1486
|
+
/** Разбор манифеста; ошибка — диагностика `manifest-invalid` (текст — `formatDiagnostic`). */
|
|
1487
|
+
const parseManifest = (raw) => {
|
|
1488
|
+
const parsed = manifestSchema.safeParse(raw);
|
|
1489
|
+
if (!parsed.success) return {
|
|
1490
|
+
ok: false,
|
|
1491
|
+
diagnostic: {
|
|
1492
|
+
code: "manifest-invalid",
|
|
1493
|
+
data: { issues: zodIssues(parsed.error) }
|
|
1494
|
+
}
|
|
1917
1495
|
};
|
|
1918
1496
|
return {
|
|
1919
|
-
|
|
1920
|
-
|
|
1921
|
-
};
|
|
1922
|
-
};
|
|
1923
|
-
const parseStroke = (text, platform, whole) => {
|
|
1924
|
-
const { modifiers, key: keyPart } = splitStroke(text);
|
|
1925
|
-
const flags = {
|
|
1926
|
-
ctrl: false,
|
|
1927
|
-
alt: false,
|
|
1928
|
-
shift: false,
|
|
1929
|
-
meta: false
|
|
1930
|
-
};
|
|
1931
|
-
for (const token of modifiers) {
|
|
1932
|
-
if (token === "") throw new KeybindingSyntaxError("empty-part", whole);
|
|
1933
|
-
const name = own(MODIFIER_ALIASES, token.toLowerCase());
|
|
1934
|
-
if (name === void 0) throw new KeybindingSyntaxError("unknown-modifier", whole);
|
|
1935
|
-
const resolved = name === "mod" ? platform === "mac" ? "meta" : "ctrl" : name;
|
|
1936
|
-
if (flags[resolved]) throw new KeybindingSyntaxError("repeated-modifier", whole);
|
|
1937
|
-
flags[resolved] = true;
|
|
1938
|
-
}
|
|
1939
|
-
if (keyPart === "" || Object.hasOwn(MODIFIER_ALIASES, keyPart.toLowerCase())) throw new KeybindingSyntaxError("modifier-only", whole);
|
|
1940
|
-
const physical = PHYSICAL.exec(keyPart);
|
|
1941
|
-
if (physical) return stroke(flags, "", physical[1]);
|
|
1942
|
-
if (keyPart.length > 1 && (keyPart.startsWith("[") || keyPart.endsWith("]"))) throw new KeybindingSyntaxError("invalid-code", whole);
|
|
1943
|
-
const key = normalizeKeyName(keyPart);
|
|
1944
|
-
if (key === null) throw new KeybindingSyntaxError("unknown-key", whole);
|
|
1945
|
-
return stroke(flags, key, "");
|
|
1946
|
-
};
|
|
1947
|
-
/**
|
|
1948
|
-
* `Mod+Shift+L` или `Mod+K Mod+S` → сочетание для платформы. `Mod` — Meta на
|
|
1949
|
-
* macOS и Ctrl на остальных; остальные модификаторы — по имени. Ошибка
|
|
1950
|
-
* записи — `KeybindingSyntaxError` с причиной.
|
|
1951
|
-
*/
|
|
1952
|
-
const parseChord = (text, platform) => {
|
|
1953
|
-
const trimmed = text.trim();
|
|
1954
|
-
if (trimmed === "") throw new KeybindingSyntaxError("empty", text);
|
|
1955
|
-
const parts = trimmed.split(/\s+/);
|
|
1956
|
-
if (parts.length > 2) throw new KeybindingSyntaxError("too-long", text);
|
|
1957
|
-
return parts.map((part) => parseStroke(part, platform, text));
|
|
1958
|
-
};
|
|
1959
|
-
/**
|
|
1960
|
-
* Первое нажатие печатает или правит текст (`K`, `Shift+K`, `,`, `Enter`,
|
|
1961
|
-
* стрелки): без Ctrl и Meta такое сочетание срабатывало бы при наборе. Не
|
|
1962
|
-
* считаются `Escape` и F1–F24.
|
|
1963
|
-
*/
|
|
1964
|
-
const isTypingChord = (chord) => {
|
|
1965
|
-
const first = chord[0];
|
|
1966
|
-
if (first === void 0 || first.ctrl || first.meta) return false;
|
|
1967
|
-
if (first.code !== "") return true;
|
|
1968
|
-
return first.key !== "escape" && !FUNCTION_KEY.test(first.key);
|
|
1969
|
-
};
|
|
1970
|
-
|
|
1971
|
-
//#endregion
|
|
1972
|
-
//#region packages/keybindings/src/when.ts
|
|
1973
|
-
const WHEN_MAX_LENGTH = 256;
|
|
1974
|
-
const MAX_DEPTH = 16;
|
|
1975
|
-
const MAX_TERMS = 64;
|
|
1976
|
-
var WhenSyntaxError = class extends Error {
|
|
1977
|
-
reason;
|
|
1978
|
-
/** Позиция ошибки в тексте (индекс символа). */
|
|
1979
|
-
position;
|
|
1980
|
-
constructor(reason, position) {
|
|
1981
|
-
super(`invalid when clause: ${reason} at ${position}`);
|
|
1982
|
-
this.name = "WhenSyntaxError";
|
|
1983
|
-
this.reason = reason;
|
|
1984
|
-
this.position = position;
|
|
1985
|
-
}
|
|
1986
|
-
};
|
|
1987
|
-
const IDENT = /[A-Za-z_][A-Za-z0-9_.-]*/y;
|
|
1988
|
-
const BARE = /[^\s&|()!='"]+/y;
|
|
1989
|
-
const matchAt = (pattern, text, index) => {
|
|
1990
|
-
pattern.lastIndex = index;
|
|
1991
|
-
return pattern.exec(text)?.[0] ?? null;
|
|
1992
|
-
};
|
|
1993
|
-
/** После `==`/`!=` значение — слово или строка в одинарных кавычках. */
|
|
1994
|
-
const tokenize = (text) => {
|
|
1995
|
-
const tokens = [];
|
|
1996
|
-
let index = 0;
|
|
1997
|
-
let expectValue = false;
|
|
1998
|
-
while (index < text.length) {
|
|
1999
|
-
const char = text[index];
|
|
2000
|
-
if (/\s/.test(char)) {
|
|
2001
|
-
index++;
|
|
2002
|
-
continue;
|
|
2003
|
-
}
|
|
2004
|
-
const two = text.slice(index, index + 2);
|
|
2005
|
-
if (two === "&&" || two === "||" || two === "==" || two === "!=") {
|
|
2006
|
-
tokens.push({
|
|
2007
|
-
kind: "op",
|
|
2008
|
-
text: two,
|
|
2009
|
-
position: index
|
|
2010
|
-
});
|
|
2011
|
-
expectValue = two === "==" || two === "!=";
|
|
2012
|
-
index += 2;
|
|
2013
|
-
continue;
|
|
2014
|
-
}
|
|
2015
|
-
if (char === "(" || char === ")" || char === "!") {
|
|
2016
|
-
tokens.push({
|
|
2017
|
-
kind: "op",
|
|
2018
|
-
text: char,
|
|
2019
|
-
position: index
|
|
2020
|
-
});
|
|
2021
|
-
expectValue = false;
|
|
2022
|
-
index++;
|
|
2023
|
-
continue;
|
|
2024
|
-
}
|
|
2025
|
-
if (char === "'") {
|
|
2026
|
-
const close = text.indexOf("'", index + 1);
|
|
2027
|
-
if (close < 0) throw new WhenSyntaxError("unterminated-string", index);
|
|
2028
|
-
tokens.push({
|
|
2029
|
-
kind: "value",
|
|
2030
|
-
text: text.slice(index + 1, close),
|
|
2031
|
-
position: index
|
|
2032
|
-
});
|
|
2033
|
-
expectValue = false;
|
|
2034
|
-
index = close + 1;
|
|
2035
|
-
continue;
|
|
2036
|
-
}
|
|
2037
|
-
const word = matchAt(expectValue ? BARE : IDENT, text, index);
|
|
2038
|
-
if (word === null) throw new WhenSyntaxError("unexpected-token", index);
|
|
2039
|
-
tokens.push({
|
|
2040
|
-
kind: expectValue ? "value" : "ident",
|
|
2041
|
-
text: word,
|
|
2042
|
-
position: index
|
|
2043
|
-
});
|
|
2044
|
-
expectValue = false;
|
|
2045
|
-
index += word.length;
|
|
2046
|
-
}
|
|
2047
|
-
tokens.push({
|
|
2048
|
-
kind: "end",
|
|
2049
|
-
text: "",
|
|
2050
|
-
position: text.length
|
|
2051
|
-
});
|
|
2052
|
-
return tokens;
|
|
2053
|
-
};
|
|
2054
|
-
var Parser = class {
|
|
2055
|
-
index = 0;
|
|
2056
|
-
tokens;
|
|
2057
|
-
constructor(tokens) {
|
|
2058
|
-
this.tokens = tokens;
|
|
2059
|
-
}
|
|
2060
|
-
parse() {
|
|
2061
|
-
const result = this.parseOr(0);
|
|
2062
|
-
if (this.peek().kind !== "end") this.fail();
|
|
2063
|
-
return result;
|
|
2064
|
-
}
|
|
2065
|
-
peek() {
|
|
2066
|
-
return this.tokens[this.index];
|
|
2067
|
-
}
|
|
2068
|
-
isOp(text) {
|
|
2069
|
-
const token = this.peek();
|
|
2070
|
-
return token.kind === "op" && token.text === text;
|
|
2071
|
-
}
|
|
2072
|
-
fail() {
|
|
2073
|
-
const token = this.peek();
|
|
2074
|
-
throw new WhenSyntaxError(token.kind === "end" ? "unexpected-end" : "unexpected-token", token.position);
|
|
2075
|
-
}
|
|
2076
|
-
parseOr(depth) {
|
|
2077
|
-
if (depth > MAX_DEPTH) throw new WhenSyntaxError("too-deep", this.peek().position);
|
|
2078
|
-
const terms = [this.parseAnd(depth)];
|
|
2079
|
-
while (this.isOp("||")) {
|
|
2080
|
-
this.index++;
|
|
2081
|
-
terms.push(this.parseAnd(depth));
|
|
2082
|
-
}
|
|
2083
|
-
return terms.length === 1 ? terms[0] : {
|
|
2084
|
-
type: "or",
|
|
2085
|
-
terms
|
|
2086
|
-
};
|
|
2087
|
-
}
|
|
2088
|
-
parseAnd(depth) {
|
|
2089
|
-
const terms = [this.parseUnary(depth)];
|
|
2090
|
-
while (this.isOp("&&")) {
|
|
2091
|
-
this.index++;
|
|
2092
|
-
terms.push(this.parseUnary(depth));
|
|
2093
|
-
}
|
|
2094
|
-
return terms.length === 1 ? terms[0] : {
|
|
2095
|
-
type: "and",
|
|
2096
|
-
terms
|
|
2097
|
-
};
|
|
2098
|
-
}
|
|
2099
|
-
parseUnary(depth) {
|
|
2100
|
-
if (this.isOp("!")) {
|
|
2101
|
-
this.index++;
|
|
2102
|
-
if (this.isOp("(")) return {
|
|
2103
|
-
type: "not",
|
|
2104
|
-
expr: this.parseGroup(depth)
|
|
2105
|
-
};
|
|
2106
|
-
const token = this.peek();
|
|
2107
|
-
if (token.kind !== "ident") return this.fail();
|
|
2108
|
-
this.index++;
|
|
2109
|
-
return {
|
|
2110
|
-
type: "not",
|
|
2111
|
-
expr: {
|
|
2112
|
-
type: "key",
|
|
2113
|
-
key: token.text
|
|
2114
|
-
}
|
|
2115
|
-
};
|
|
2116
|
-
}
|
|
2117
|
-
if (this.isOp("(")) return this.parseGroup(depth);
|
|
2118
|
-
const token = this.peek();
|
|
2119
|
-
if (token.kind !== "ident") return this.fail();
|
|
2120
|
-
this.index++;
|
|
2121
|
-
if (this.isOp("==") || this.isOp("!=")) {
|
|
2122
|
-
const operator = this.peek().text;
|
|
2123
|
-
this.index++;
|
|
2124
|
-
const value = this.peek();
|
|
2125
|
-
if (value.kind !== "value") return this.fail();
|
|
2126
|
-
this.index++;
|
|
2127
|
-
return {
|
|
2128
|
-
type: operator === "==" ? "eq" : "neq",
|
|
2129
|
-
key: token.text,
|
|
2130
|
-
value: value.text
|
|
2131
|
-
};
|
|
2132
|
-
}
|
|
2133
|
-
return {
|
|
2134
|
-
type: "key",
|
|
2135
|
-
key: token.text
|
|
2136
|
-
};
|
|
2137
|
-
}
|
|
2138
|
-
parseGroup(depth) {
|
|
2139
|
-
this.index++;
|
|
2140
|
-
const inner = this.parseOr(depth + 1);
|
|
2141
|
-
if (!this.isOp(")")) return this.fail();
|
|
2142
|
-
this.index++;
|
|
2143
|
-
return inner;
|
|
2144
|
-
}
|
|
2145
|
-
};
|
|
2146
|
-
/** Текст условия → выражение; ошибка — `WhenSyntaxError` с причиной и позицией. */
|
|
2147
|
-
const parseWhen = (text) => {
|
|
2148
|
-
if (text.length > 256) throw new WhenSyntaxError("too-long", 0);
|
|
2149
|
-
if (text.trim() === "") throw new WhenSyntaxError("empty", 0);
|
|
2150
|
-
return new Parser(tokenize(text)).parse();
|
|
2151
|
-
};
|
|
2152
|
-
/** Дизъюнкция конъюнкций литералов; `null` — слишком много термов. */
|
|
2153
|
-
const toDnf = (expr, negated) => {
|
|
2154
|
-
switch (expr.type) {
|
|
2155
|
-
case "key": return [[{
|
|
2156
|
-
kind: negated ? "falsy" : "truthy",
|
|
2157
|
-
key: expr.key
|
|
2158
|
-
}]];
|
|
2159
|
-
case "eq":
|
|
2160
|
-
case "neq": return [[{
|
|
2161
|
-
kind: expr.type === "eq" !== negated ? "eq" : "neq",
|
|
2162
|
-
key: expr.key,
|
|
2163
|
-
value: expr.value
|
|
2164
|
-
}]];
|
|
2165
|
-
case "not": return toDnf(expr.expr, !negated);
|
|
2166
|
-
default: {
|
|
2167
|
-
const isConjunction = expr.type === "and" !== negated;
|
|
2168
|
-
let result = isConjunction ? [[]] : [];
|
|
2169
|
-
for (const part of expr.terms) {
|
|
2170
|
-
const dnf = toDnf(part, negated);
|
|
2171
|
-
if (dnf === null || result === null) return null;
|
|
2172
|
-
const current = result;
|
|
2173
|
-
result = isConjunction ? dnf.flatMap((right) => current.map((left) => [...left, ...right])) : [...current, ...dnf];
|
|
2174
|
-
if (result.length > MAX_TERMS) return null;
|
|
2175
|
-
}
|
|
2176
|
-
return result;
|
|
2177
|
-
}
|
|
2178
|
-
}
|
|
2179
|
-
};
|
|
2180
|
-
/** Значение `value`, при котором `ключ == value` истинно, обязательно истинно или ложно как условие. */
|
|
2181
|
-
const forcedTruth = (value) => {
|
|
2182
|
-
if (value === "") return false;
|
|
2183
|
-
if (value === "false" || value === "0") return null;
|
|
2184
|
-
return true;
|
|
2185
|
-
};
|
|
2186
|
-
const isConsistent = (term) => {
|
|
2187
|
-
const states = /* @__PURE__ */ new Map();
|
|
2188
|
-
for (const literal of term) {
|
|
2189
|
-
let state = states.get(literal.key);
|
|
2190
|
-
if (state === void 0) {
|
|
2191
|
-
state = {
|
|
2192
|
-
truthy: null,
|
|
2193
|
-
eq: null,
|
|
2194
|
-
neq: /* @__PURE__ */ new Set()
|
|
2195
|
-
};
|
|
2196
|
-
states.set(literal.key, state);
|
|
2197
|
-
}
|
|
2198
|
-
if (literal.kind === "truthy" || literal.kind === "falsy") {
|
|
2199
|
-
const wanted = literal.kind === "truthy";
|
|
2200
|
-
if (state.truthy !== null && state.truthy !== wanted) return false;
|
|
2201
|
-
state.truthy = wanted;
|
|
2202
|
-
} else if (literal.kind === "eq") {
|
|
2203
|
-
if (state.eq !== null && state.eq !== literal.value) return false;
|
|
2204
|
-
state.eq = literal.value;
|
|
2205
|
-
} else state.neq.add(literal.value);
|
|
2206
|
-
}
|
|
2207
|
-
for (const state of states.values()) {
|
|
2208
|
-
if (state.eq === null) continue;
|
|
2209
|
-
if (state.neq.has(state.eq)) return false;
|
|
2210
|
-
const forced = forcedTruth(state.eq);
|
|
2211
|
-
if (forced !== null && state.truthy !== null && state.truthy !== forced) return false;
|
|
2212
|
-
}
|
|
2213
|
-
return true;
|
|
2214
|
-
};
|
|
2215
|
-
/**
|
|
2216
|
-
* Могут ли оба условия быть истинными одновременно. `null` — нет условия.
|
|
2217
|
-
* Сверх 64 термов в нормальной форме ответ «да» (лучше лишнее предупреждение,
|
|
2218
|
-
* чем молчание).
|
|
2219
|
-
*/
|
|
2220
|
-
const whenOverlaps = (left, right) => {
|
|
2221
|
-
if (left === null || right === null) return true;
|
|
2222
|
-
const leftDnf = toDnf(left, false);
|
|
2223
|
-
const rightDnf = toDnf(right, false);
|
|
2224
|
-
if (leftDnf === null || rightDnf === null) return true;
|
|
2225
|
-
return leftDnf.some((a) => rightDnf.some((b) => isConsistent([...a, ...b])));
|
|
2226
|
-
};
|
|
2227
|
-
|
|
2228
|
-
//#endregion
|
|
2229
|
-
//#region packages/keybindings/src/validate.ts
|
|
2230
|
-
const KEY_MAX_LENGTH = 64;
|
|
2231
|
-
/** Условие «фокус в поле ввода»: привязка с набором, который с ним пересекается, срабатывала бы при наборе. */
|
|
2232
|
-
const WHILE_TYPING = {
|
|
2233
|
-
type: "key",
|
|
2234
|
-
key: "inputFocus"
|
|
2235
|
-
};
|
|
2236
|
-
const TYPING = {
|
|
2237
|
-
field: "when",
|
|
2238
|
-
reason: "typing",
|
|
2239
|
-
detail: "typing"
|
|
2240
|
-
};
|
|
2241
|
-
/**
|
|
2242
|
-
* Проверка привязки: клавиши (на каждой из `platforms`: `Mod` раскрывается по
|
|
2243
|
-
* платформе, и запись вроде `Mod+Ctrl+K` верна не везде), условие и правило
|
|
2244
|
-
* «сочетание без Ctrl и Meta, которое печатает или правит текст, должно быть
|
|
2245
|
-
* неактивно, пока фокус в поле ввода». `null` — привязка верна.
|
|
2246
|
-
*/
|
|
2247
|
-
const validateBinding = (binding, platforms) => {
|
|
2248
|
-
if (binding.key.length > 64) return {
|
|
2249
|
-
field: "key",
|
|
2250
|
-
reason: "syntax",
|
|
2251
|
-
detail: "too-long-text"
|
|
2252
|
-
};
|
|
2253
|
-
let typing = false;
|
|
2254
|
-
for (const platform of platforms) try {
|
|
2255
|
-
typing ||= isTypingChord(parseChord(binding.key, platform));
|
|
2256
|
-
} catch (error) {
|
|
2257
|
-
if (error instanceof KeybindingSyntaxError) return {
|
|
2258
|
-
field: "key",
|
|
2259
|
-
reason: "syntax",
|
|
2260
|
-
detail: error.reason
|
|
2261
|
-
};
|
|
2262
|
-
throw error;
|
|
2263
|
-
}
|
|
2264
|
-
if (binding.when === null || binding.when.trim() === "") return typing ? TYPING : null;
|
|
2265
|
-
try {
|
|
2266
|
-
const when = parseWhen(binding.when);
|
|
2267
|
-
return typing && whenOverlaps(when, WHILE_TYPING) ? TYPING : null;
|
|
2268
|
-
} catch (error) {
|
|
2269
|
-
if (error instanceof WhenSyntaxError) return {
|
|
2270
|
-
field: "when",
|
|
2271
|
-
reason: "syntax",
|
|
2272
|
-
detail: error.reason,
|
|
2273
|
-
position: error.position
|
|
2274
|
-
};
|
|
2275
|
-
throw error;
|
|
2276
|
-
}
|
|
2277
|
-
};
|
|
2278
|
-
|
|
2279
|
-
//#endregion
|
|
2280
|
-
//#region packages/extension-host/src/points/support.ts
|
|
2281
|
-
/** Относительный путь внутри каталога расширения: без `..`, `\` и ведущего `/`. */
|
|
2282
|
-
const isSafeRelativePath = (value) => value.length > 0 && !value.includes("\\") && !value.startsWith("/") && !value.split("/").includes("..");
|
|
2283
|
-
const safePath = (extensions) => z.string().refine(isSafeRelativePath, "must be a safe relative path").refine((value) => extensions.some((ext) => value.endsWith(ext)), `must end with ${extensions.join(" or ")}`);
|
|
2284
|
-
const extensionId = z.string().max(64).regex(EXTENSION_ID_PATTERN, "invalid extension id");
|
|
2285
|
-
/** Условие видимости `when` у команды, панели и виджета: длина здесь, разбор и типы — в `whenIssues`. */
|
|
2286
|
-
const whenField = z.string().min(1).max(EXTENSION_COMMAND_LIMITS.whenLength);
|
|
2287
|
-
/** Ошибки условия `when` записи `contributes.<key>.<index>` (с позицией в тексте условия). */
|
|
2288
|
-
const whenIssues = (key, index, when) => {
|
|
2289
|
-
if (when === void 0) return [];
|
|
2290
|
-
try {
|
|
2291
|
-
parseWhen$1(when);
|
|
2292
|
-
return [];
|
|
2293
|
-
} catch (error) {
|
|
2294
|
-
if (!(error instanceof WhenError)) throw error;
|
|
2295
|
-
return [`contributes.${key}.${index}.when: invalid "when" (${error.detail} at ${error.position})`];
|
|
2296
|
-
}
|
|
2297
|
-
};
|
|
2298
|
-
/** Пути с нарушением правила «`id` равен id расширения или начинается с ним». */
|
|
2299
|
-
const idPrefixIssues = (key, ids, owner) => ids.flatMap((id, index) => id === owner || id.startsWith(`${owner}.`) ? [] : [`contributes.${key}.${index}.id: id must be '${owner}' or start with '${owner}.'`]);
|
|
2300
|
-
/** Повторы значений в списке `path` (путь до массива, `field` — имя поля записи). */
|
|
2301
|
-
const duplicateIssues = (path, field, values) => values.flatMap((value, index) => values.indexOf(value) === index ? [] : [`${path}.${index}.${field}: duplicate ${field} '${value}'`]);
|
|
2302
|
-
const isFile$1 = async (file) => (await stat(file).catch(() => null))?.isFile() === true;
|
|
2303
|
-
/** Путь внутри каталога расширения; выход за каталог — ошибка. */
|
|
2304
|
-
const inside = (dir, relative) => {
|
|
2305
|
-
const resolved = path.resolve(dir, relative);
|
|
2306
|
-
if (!resolved.startsWith(dir + path.sep)) throw new Error(`path '${relative}' escapes the extension directory`);
|
|
2307
|
-
return resolved;
|
|
2308
|
-
};
|
|
2309
|
-
const rendererUrlOf = (id, renderer) => `dolphy-ext://${id}/${renderer.replace(/^\.\//, "").split("/").map(encodeURIComponent).join("/")}`;
|
|
2310
|
-
const defaultNote = (value, fallback) => value === fallback ? " (default)" : "";
|
|
2311
|
-
/** Проверяет, что файл модуля существует (если проверка включена), и даёт адрес. */
|
|
2312
|
-
const resolveModuleUrl = async (extensionId, dir, file, fallback, verifyFiles, label) => {
|
|
2313
|
-
const absolute = inside(dir, file);
|
|
2314
|
-
if (verifyFiles && !await isFile$1(absolute)) throw new Error(`${label} '${file}'${defaultNote(file, fallback)} is not a file`);
|
|
2315
|
-
return rendererUrlOf(extensionId, file);
|
|
2316
|
-
};
|
|
2317
|
-
const deepFreeze = (value) => {
|
|
2318
|
-
if (typeof value === "object" && value !== null) {
|
|
2319
|
-
for (const child of Object.values(value)) deepFreeze(child);
|
|
2320
|
-
Object.freeze(value);
|
|
2321
|
-
}
|
|
2322
|
-
return value;
|
|
2323
|
-
};
|
|
2324
|
-
const compileSchema = (ajv, schema, label) => {
|
|
2325
|
-
try {
|
|
2326
|
-
ajv.compile(schema);
|
|
2327
|
-
} catch (error) {
|
|
2328
|
-
throw new Error(`schema ${label} does not compile: ${error instanceof Error ? error.message : String(error)}`);
|
|
2329
|
-
}
|
|
2330
|
-
};
|
|
2331
|
-
/** Схема объектом (копия, заморожена) или файлом внутри каталога расширения. */
|
|
2332
|
-
const resolveSchema = async (ajv, dir, source, label) => {
|
|
2333
|
-
if (typeof source !== "string") {
|
|
2334
|
-
const schema = deepFreeze(structuredClone(source));
|
|
2335
|
-
compileSchema(ajv, schema, `${label} (inline)`);
|
|
2336
|
-
return schema;
|
|
2337
|
-
}
|
|
2338
|
-
const file = inside(dir, source);
|
|
2339
|
-
let parsed;
|
|
2340
|
-
try {
|
|
2341
|
-
parsed = JSON.parse(await readFile(file, "utf8"));
|
|
2342
|
-
} catch (error) {
|
|
2343
|
-
throw new Error(`schema '${source}' is unreadable: ${error instanceof Error ? error.message : String(error)}`);
|
|
2344
|
-
}
|
|
2345
|
-
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new Error(`schema '${source}' is not an object`);
|
|
2346
|
-
compileSchema(ajv, parsed, `'${source}'`);
|
|
2347
|
-
return parsed;
|
|
2348
|
-
};
|
|
2349
|
-
|
|
2350
|
-
//#endregion
|
|
2351
|
-
//#region packages/extension-host/src/points/commands.ts
|
|
2352
|
-
const { commands: MAX_COMMANDS, keybindingsPerCommand: MAX_KEYBINDINGS } = EXTENSION_COMMAND_LIMITS;
|
|
2353
|
-
const PLATFORMS = [
|
|
2354
|
-
"mac",
|
|
2355
|
-
"windows",
|
|
2356
|
-
"linux"
|
|
2357
|
-
];
|
|
2358
|
-
const keyText = z.string().min(1);
|
|
2359
|
-
/** Описание проблемы привязки для сообщения манифеста. */
|
|
2360
|
-
const problemText = (problem) => {
|
|
2361
|
-
if (problem.reason === "typing") return "a key that types text needs a \"when\" inactive while inputFocus (for example \"!inputFocus\")";
|
|
2362
|
-
if (problem.field === "when") return `invalid "when" (${problem.detail} at ${problem.position})`;
|
|
2363
|
-
return `invalid key (${problem.detail}); it must be valid on mac, windows and linux`;
|
|
2364
|
-
};
|
|
2365
|
-
/** Проблемы одной строки клавиш: поле `field` записи, `when` — условие записи. */
|
|
2366
|
-
const keyIssues = (path, field, key, when, reportWhen) => {
|
|
2367
|
-
const problem = validateBinding({
|
|
2368
|
-
key,
|
|
2369
|
-
when
|
|
2370
|
-
}, PLATFORMS);
|
|
2371
|
-
if (problem === null) return [];
|
|
2372
|
-
if (problem.field === "when" && problem.reason === "syntax" && !reportWhen) return [];
|
|
2373
|
-
return [`${path}.${problem.field === "when" && reportWhen ? "when" : field}: ${problemText(problem)}`];
|
|
2374
|
-
};
|
|
2375
|
-
const bindingIssues = (entry, index) => {
|
|
2376
|
-
const base = `contributes.commands.${index}`;
|
|
2377
|
-
const { keybinding, keybindings = [] } = entry;
|
|
2378
|
-
const issues = [];
|
|
2379
|
-
const seen = /* @__PURE__ */ new Set();
|
|
2380
|
-
const remember = (path, key, when) => {
|
|
2381
|
-
const id = `${key.trim()}\u0000${when?.trim() ?? ""}`;
|
|
2382
|
-
if (seen.has(id)) issues.push(`${path}: duplicate binding '${key}' in one command`);
|
|
2383
|
-
seen.add(id);
|
|
2384
|
-
};
|
|
2385
|
-
if (keybinding !== void 0) {
|
|
2386
|
-
const path = `${base}.keybinding`;
|
|
2387
|
-
const problem = validateBinding({
|
|
2388
|
-
key: keybinding,
|
|
2389
|
-
when: null
|
|
2390
|
-
}, PLATFORMS);
|
|
2391
|
-
if (problem !== null) issues.push(`${path}: ${problemText(problem)}`);
|
|
2392
|
-
remember(path, keybinding, null);
|
|
2393
|
-
}
|
|
2394
|
-
keybindings.forEach((binding, position) => {
|
|
2395
|
-
const path = `${base}.keybindings.${position}`;
|
|
2396
|
-
const when = binding.when ?? null;
|
|
2397
|
-
issues.push(...keyIssues(path, "key", binding.key, when, true));
|
|
2398
|
-
for (const platform of PLATFORMS) {
|
|
2399
|
-
const override = binding[platform];
|
|
2400
|
-
if (override === void 0) continue;
|
|
2401
|
-
issues.push(...keyIssues(path, platform, override, when, false));
|
|
2402
|
-
}
|
|
2403
|
-
remember(`${path}.key`, binding.key, when);
|
|
2404
|
-
});
|
|
2405
|
-
if ((keybinding !== void 0 || keybindings.length > 0) && entry.palette === false) issues.push(`${base}.palette: keybindings need palette: true`);
|
|
2406
|
-
return issues;
|
|
2407
|
-
};
|
|
2408
|
-
const commands = {
|
|
2409
|
-
key: "commands",
|
|
2410
|
-
needsMain: true,
|
|
2411
|
-
schema: z.strictObject({
|
|
2412
|
-
id: extensionId,
|
|
2413
|
-
title: z.string().min(1).max(EXTENSION_COMMAND_LIMITS.titleLength),
|
|
2414
|
-
description: z.string().min(1).max(EXTENSION_COMMAND_LIMITS.descriptionLength).optional(),
|
|
2415
|
-
category: z.string().min(1).max(EXTENSION_COMMAND_LIMITS.categoryLength).optional(),
|
|
2416
|
-
keybinding: z.string().regex(KEYBINDING_PATTERN, "must look like Mod+Shift+L").optional(),
|
|
2417
|
-
keybindings: z.array(z.strictObject({
|
|
2418
|
-
key: keyText,
|
|
2419
|
-
mac: keyText.optional(),
|
|
2420
|
-
windows: keyText.optional(),
|
|
2421
|
-
linux: keyText.optional(),
|
|
2422
|
-
when: z.string().min(1).max(EXTENSION_COMMAND_LIMITS.whenLength).optional()
|
|
2423
|
-
})).max(MAX_KEYBINDINGS).optional(),
|
|
2424
|
-
palette: z.boolean().optional(),
|
|
2425
|
-
when: whenField.optional(),
|
|
2426
|
-
icon: z.enum(EXTENSION_ICONS).optional()
|
|
2427
|
-
}),
|
|
2428
|
-
normalize: (entries) => entries.map((entry) => ({
|
|
2429
|
-
...entry,
|
|
2430
|
-
palette: entry.palette ?? true,
|
|
2431
|
-
icon: entry.icon ?? "puzzle"
|
|
2432
|
-
})),
|
|
2433
|
-
check: (entries, owner) => {
|
|
2434
|
-
const ids = entries.map(({ id }) => id);
|
|
2435
|
-
return [
|
|
2436
|
-
...entries.length > MAX_COMMANDS ? [`contributes.commands: at most ${MAX_COMMANDS} commands allowed`] : [],
|
|
2437
|
-
...idPrefixIssues("commands", ids, owner),
|
|
2438
|
-
...duplicateIssues("contributes.commands", "id", ids),
|
|
2439
|
-
...entries.flatMap(bindingIssues),
|
|
2440
|
-
...entries.flatMap((entry, index) => whenIssues("commands", index, entry.when))
|
|
2441
|
-
];
|
|
2442
|
-
},
|
|
2443
|
-
resolve: async (entries) => entries.map((entry) => ({
|
|
2444
|
-
id: entry.id,
|
|
2445
|
-
title: entry.title,
|
|
2446
|
-
description: entry.description ?? null,
|
|
2447
|
-
category: entry.category ?? null,
|
|
2448
|
-
keybinding: entry.keybinding ?? null,
|
|
2449
|
-
keybindings: (entry.keybindings ?? []).map((binding) => ({
|
|
2450
|
-
key: binding.key,
|
|
2451
|
-
mac: binding.mac ?? null,
|
|
2452
|
-
windows: binding.windows ?? null,
|
|
2453
|
-
linux: binding.linux ?? null,
|
|
2454
|
-
when: binding.when ?? null
|
|
2455
|
-
})),
|
|
2456
|
-
palette: entry.palette,
|
|
2457
|
-
when: entry.when ?? null,
|
|
2458
|
-
icon: entry.icon
|
|
2459
|
-
})),
|
|
2460
|
-
claims: (resolved) => resolved.map((command) => `command:${command.id}`)
|
|
2461
|
-
};
|
|
2462
|
-
|
|
2463
|
-
//#endregion
|
|
2464
|
-
//#region packages/extension-host/src/points/exercise-types.ts
|
|
2465
|
-
const schemaField = z.union([safePath([".json"]), z.record(z.string(), z.unknown()).refine((value) => Object.keys(value).length > 0, "must not be empty")]);
|
|
2466
|
-
const exerciseTypes = {
|
|
2467
|
-
key: "exerciseTypes",
|
|
2468
|
-
needsMain: true,
|
|
2469
|
-
schema: z.strictObject({
|
|
2470
|
-
id: extensionId,
|
|
2471
|
-
title: z.string().min(1).max(EXTENSION_COMMAND_LIMITS.titleLength).optional(),
|
|
2472
|
-
specSchema: schemaField,
|
|
2473
|
-
answerSchema: schemaField,
|
|
2474
|
-
element: z.string().optional(),
|
|
2475
|
-
renderer: safePath([".js", ".mjs"]).optional()
|
|
2476
|
-
}),
|
|
2477
|
-
normalize: (entries) => entries.map((type) => ({
|
|
2478
|
-
id: type.id,
|
|
2479
|
-
...type.title === void 0 ? {} : { title: type.title },
|
|
2480
|
-
specSchema: type.specSchema,
|
|
2481
|
-
answerSchema: type.answerSchema,
|
|
2482
|
-
element: type.element ?? defaultElementName(type.id),
|
|
2483
|
-
renderer: type.renderer ?? "./view.mjs"
|
|
2484
|
-
})),
|
|
2485
|
-
check: (entries, owner) => [...idPrefixIssues("exerciseTypes", entries.map(({ id }) => id), owner), ...entries.flatMap((type, index) => ELEMENT_NAME_PATTERN.test(type.element) ? [] : [`contributes.exerciseTypes.${index}.element: invalid element name '${type.element}'`])],
|
|
2486
|
-
resolve: async (entries, { dir, extensionId: owner, verifyFiles, ajv }) => {
|
|
2487
|
-
const resolved = [];
|
|
2488
|
-
for (const contribution of entries) {
|
|
2489
|
-
const rendererUrl = await resolveModuleUrl(owner, dir, contribution.renderer, DEFAULT_RENDERER, verifyFiles, "renderer");
|
|
2490
|
-
resolved.push({
|
|
2491
|
-
id: contribution.id,
|
|
2492
|
-
title: contribution.title ?? null,
|
|
2493
|
-
specSchema: await resolveSchema(ajv, dir, contribution.specSchema, `specSchema of '${contribution.id}'`),
|
|
2494
|
-
answerSchema: await resolveSchema(ajv, dir, contribution.answerSchema, `answerSchema of '${contribution.id}'`),
|
|
2495
|
-
element: contribution.element,
|
|
2496
|
-
rendererUrl
|
|
2497
|
-
});
|
|
2498
|
-
}
|
|
2499
|
-
return resolved;
|
|
2500
|
-
},
|
|
2501
|
-
claims: (resolved) => resolved.flatMap((type) => [`exerciseType:${type.id}`, `element:${type.element}`])
|
|
2502
|
-
};
|
|
2503
|
-
|
|
2504
|
-
//#endregion
|
|
2505
|
-
//#region packages/extension-host/src/points/events.ts
|
|
2506
|
-
const events = {
|
|
2507
|
-
key: "events",
|
|
2508
|
-
needsMain: true,
|
|
2509
|
-
schema: z.strictObject({ event: z.enum(LEARNING_EVENT_NAMES) }),
|
|
2510
|
-
normalize: (entries) => entries,
|
|
2511
|
-
check: (entries) => duplicateIssues("contributes.events", "event", entries.map(({ event }) => event)),
|
|
2512
|
-
resolve: async (entries) => entries.map(({ event }) => ({ event })),
|
|
2513
|
-
claims: () => []
|
|
2514
|
-
};
|
|
2515
|
-
|
|
2516
|
-
//#endregion
|
|
2517
|
-
//#region packages/extension-host/src/points/exporters.ts
|
|
2518
|
-
const { exporters: MAX_EXPORTERS } = EXTENSION_TRANSFER_LIMITS;
|
|
2519
|
-
const exporters = {
|
|
2520
|
-
key: "exporters",
|
|
2521
|
-
needsMain: true,
|
|
2522
|
-
schema: z.strictObject({
|
|
2523
|
-
id: extensionId,
|
|
2524
|
-
title: z.string().min(1).max(EXTENSION_COMMAND_LIMITS.titleLength),
|
|
2525
|
-
scope: z.enum(["course", "progress"])
|
|
2526
|
-
}),
|
|
2527
|
-
normalize: (entries) => entries.map((entry) => ({ ...entry })),
|
|
2528
|
-
check: (entries, owner) => {
|
|
2529
|
-
const ids = entries.map(({ id }) => id);
|
|
2530
|
-
return [
|
|
2531
|
-
...entries.length > MAX_EXPORTERS ? [`contributes.exporters: at most ${MAX_EXPORTERS} exporters allowed`] : [],
|
|
2532
|
-
...idPrefixIssues("exporters", ids, owner),
|
|
2533
|
-
...duplicateIssues("contributes.exporters", "id", ids)
|
|
2534
|
-
];
|
|
2535
|
-
},
|
|
2536
|
-
resolve: async (entries) => entries.map((entry) => ({
|
|
2537
|
-
id: entry.id,
|
|
2538
|
-
title: entry.title,
|
|
2539
|
-
scope: entry.scope
|
|
2540
|
-
})),
|
|
2541
|
-
claims: (resolved) => resolved.map((exporter) => `exporter:${exporter.id}`)
|
|
2542
|
-
};
|
|
2543
|
-
|
|
2544
|
-
//#endregion
|
|
2545
|
-
//#region packages/extension-host/src/points/grade-policies.ts
|
|
2546
|
-
const BUILTIN_POLICY_ID = "passAtN";
|
|
2547
|
-
const gradePolicies = {
|
|
2548
|
-
key: "gradePolicies",
|
|
2549
|
-
needsMain: true,
|
|
2550
|
-
schema: z.strictObject({
|
|
2551
|
-
id: extensionId.refine((id) => id !== BUILTIN_POLICY_ID, `id '${BUILTIN_POLICY_ID}' is reserved for the built-in policy`),
|
|
2552
|
-
label: z.string().min(1).max(60)
|
|
2553
|
-
}),
|
|
2554
|
-
normalize: (entries) => entries,
|
|
2555
|
-
check: (entries, owner) => idPrefixIssues("gradePolicies", entries.map(({ id }) => id), owner),
|
|
2556
|
-
resolve: async (entries) => entries.map(({ id, label }) => ({
|
|
2557
|
-
id,
|
|
2558
|
-
label
|
|
2559
|
-
})),
|
|
2560
|
-
claims: (resolved) => resolved.map((policy) => `gradePolicy:${policy.id}`)
|
|
2561
|
-
};
|
|
2562
|
-
|
|
2563
|
-
//#endregion
|
|
2564
|
-
//#region packages/extension-host/src/points/importers.ts
|
|
2565
|
-
const { importers: MAX_IMPORTERS, acceptExtensions: MAX_ACCEPT } = EXTENSION_TRANSFER_LIMITS;
|
|
2566
|
-
const importers = {
|
|
2567
|
-
key: "importers",
|
|
2568
|
-
needsMain: true,
|
|
2569
|
-
schema: z.strictObject({
|
|
2570
|
-
id: extensionId,
|
|
2571
|
-
title: z.string().min(1).max(EXTENSION_COMMAND_LIMITS.titleLength),
|
|
2572
|
-
accept: z.array(z.string().regex(TRANSFER_ACCEPT_PATTERN, "must be a lower-case file extension such as '.csv'")).min(1).max(MAX_ACCEPT),
|
|
2573
|
-
input: z.enum(["text", "bytes"]).optional()
|
|
2574
|
-
}),
|
|
2575
|
-
normalize: (entries) => entries.map((entry) => ({
|
|
2576
|
-
...entry,
|
|
2577
|
-
input: entry.input ?? "text"
|
|
2578
|
-
})),
|
|
2579
|
-
check: (entries, owner) => {
|
|
2580
|
-
const ids = entries.map(({ id }) => id);
|
|
2581
|
-
return [
|
|
2582
|
-
...entries.length > MAX_IMPORTERS ? [`contributes.importers: at most ${MAX_IMPORTERS} importers allowed`] : [],
|
|
2583
|
-
...idPrefixIssues("importers", ids, owner),
|
|
2584
|
-
...duplicateIssues("contributes.importers", "id", ids),
|
|
2585
|
-
...entries.flatMap((entry, index) => entry.accept.flatMap((extension, position) => entry.accept.indexOf(extension) === position ? [] : [`contributes.importers.${index}.accept.${position}: duplicate extension '${extension}'`]))
|
|
2586
|
-
];
|
|
2587
|
-
},
|
|
2588
|
-
resolve: async (entries) => entries.map((entry) => ({
|
|
2589
|
-
id: entry.id,
|
|
2590
|
-
title: entry.title,
|
|
2591
|
-
accept: [...entry.accept],
|
|
2592
|
-
input: entry.input
|
|
2593
|
-
})),
|
|
2594
|
-
claims: (resolved) => resolved.map((importer) => `importer:${importer.id}`)
|
|
2595
|
-
};
|
|
2596
|
-
|
|
2597
|
-
//#endregion
|
|
2598
|
-
//#region packages/extension-host/src/points/markdown-renderers.ts
|
|
2599
|
-
const markdownRenderers = {
|
|
2600
|
-
key: "markdownRenderers",
|
|
2601
|
-
needsMain: false,
|
|
2602
|
-
schema: z.strictObject({
|
|
2603
|
-
language: z.string().regex(/^[a-z][a-z0-9-]{0,31}$/, "invalid language"),
|
|
2604
|
-
title: z.string().min(1).max(EXTENSION_COMMAND_LIMITS.titleLength).optional(),
|
|
2605
|
-
renderer: safePath([".js", ".mjs"]).optional()
|
|
2606
|
-
}),
|
|
2607
|
-
normalize: (entries) => entries.map((entry) => ({
|
|
2608
|
-
language: entry.language,
|
|
2609
|
-
...entry.title === void 0 ? {} : { title: entry.title },
|
|
2610
|
-
renderer: entry.renderer ?? "./markdown.mjs"
|
|
2611
|
-
})),
|
|
2612
|
-
check: () => [],
|
|
2613
|
-
resolve: async (entries, { dir, extensionId, verifyFiles }) => {
|
|
2614
|
-
const resolved = [];
|
|
2615
|
-
for (const entry of entries) resolved.push({
|
|
2616
|
-
language: entry.language,
|
|
2617
|
-
title: entry.title ?? null,
|
|
2618
|
-
rendererUrl: await resolveModuleUrl(extensionId, dir, entry.renderer, DEFAULT_MARKDOWN_RENDERER, verifyFiles, "markdown renderer")
|
|
2619
|
-
});
|
|
2620
|
-
return resolved;
|
|
2621
|
-
},
|
|
2622
|
-
claims: (resolved) => resolved.map((entry) => `markdown:${entry.language}`)
|
|
2623
|
-
};
|
|
2624
|
-
|
|
2625
|
-
//#endregion
|
|
2626
|
-
//#region packages/extension-host/src/points/panels.ts
|
|
2627
|
-
const { panels: MAX_PANELS } = EXTENSION_COMMAND_LIMITS;
|
|
2628
|
-
const panels = {
|
|
2629
|
-
key: "panels",
|
|
2630
|
-
needsMain: false,
|
|
2631
|
-
schema: z.strictObject({
|
|
2632
|
-
id: extensionId,
|
|
2633
|
-
title: z.string().min(1).max(EXTENSION_COMMAND_LIMITS.titleLength),
|
|
2634
|
-
module: safePath([".js", ".mjs"]).optional(),
|
|
2635
|
-
icon: z.enum(EXTENSION_ICONS).optional(),
|
|
2636
|
-
when: whenField.optional()
|
|
2637
|
-
}),
|
|
2638
|
-
normalize: (entries) => entries.map((entry) => ({
|
|
2639
|
-
id: entry.id,
|
|
2640
|
-
title: entry.title,
|
|
2641
|
-
module: entry.module ?? "./panel.mjs",
|
|
2642
|
-
icon: entry.icon ?? "puzzle",
|
|
2643
|
-
...entry.when === void 0 ? {} : { when: entry.when }
|
|
2644
|
-
})),
|
|
2645
|
-
check: (entries, owner) => {
|
|
2646
|
-
const ids = entries.map(({ id }) => id);
|
|
2647
|
-
return [
|
|
2648
|
-
...entries.length > MAX_PANELS ? [`contributes.panels: at most ${MAX_PANELS} panels allowed`] : [],
|
|
2649
|
-
...idPrefixIssues("panels", ids, owner),
|
|
2650
|
-
...duplicateIssues("contributes.panels", "id", ids),
|
|
2651
|
-
...entries.flatMap((entry, index) => whenIssues("panels", index, entry.when))
|
|
2652
|
-
];
|
|
2653
|
-
},
|
|
2654
|
-
resolve: async (entries, { dir, extensionId: owner, verifyFiles }) => {
|
|
2655
|
-
const resolved = [];
|
|
2656
|
-
for (const entry of entries) resolved.push({
|
|
2657
|
-
id: entry.id,
|
|
2658
|
-
title: entry.title,
|
|
2659
|
-
icon: entry.icon,
|
|
2660
|
-
when: entry.when ?? null,
|
|
2661
|
-
rendererUrl: await resolveModuleUrl(owner, dir, entry.module, DEFAULT_PANEL, verifyFiles, "panel module")
|
|
2662
|
-
});
|
|
2663
|
-
return resolved;
|
|
2664
|
-
},
|
|
2665
|
-
claims: (resolved) => resolved.map((panel) => `panel:${panel.id}`)
|
|
2666
|
-
};
|
|
2667
|
-
|
|
2668
|
-
//#endregion
|
|
2669
|
-
//#region packages/extension-host/src/points/schedules.ts
|
|
2670
|
-
const { schedules: MAX_SCHEDULES } = EXTENSION_SCHEDULE_LIMITS;
|
|
2671
|
-
const schedules = {
|
|
2672
|
-
key: "schedules",
|
|
2673
|
-
needsMain: true,
|
|
2674
|
-
schema: z.discriminatedUnion("every", [z.strictObject({
|
|
2675
|
-
id: extensionId,
|
|
2676
|
-
every: z.literal("daily"),
|
|
2677
|
-
at: z.string().regex(SCHEDULE_AT_PATTERN, "must be a time such as '09:00'").optional()
|
|
2678
|
-
}), z.strictObject({
|
|
2679
|
-
id: extensionId,
|
|
2680
|
-
every: z.literal("hourly")
|
|
2681
|
-
})]),
|
|
2682
|
-
normalize: (entries) => entries.map((entry) => entry.every === "daily" ? {
|
|
2683
|
-
id: entry.id,
|
|
2684
|
-
every: "daily",
|
|
2685
|
-
at: entry.at ?? "09:00"
|
|
2686
|
-
} : {
|
|
2687
|
-
id: entry.id,
|
|
2688
|
-
every: "hourly"
|
|
2689
|
-
}),
|
|
2690
|
-
check: (entries, owner) => {
|
|
2691
|
-
const ids = entries.map(({ id }) => id);
|
|
2692
|
-
return [
|
|
2693
|
-
...entries.length > MAX_SCHEDULES ? [`contributes.schedules: at most ${MAX_SCHEDULES} schedules allowed`] : [],
|
|
2694
|
-
...idPrefixIssues("schedules", ids, owner),
|
|
2695
|
-
...duplicateIssues("contributes.schedules", "id", ids)
|
|
2696
|
-
];
|
|
2697
|
-
},
|
|
2698
|
-
resolve: async (entries) => entries.map((entry) => ({
|
|
2699
|
-
id: entry.id,
|
|
2700
|
-
every: entry.every,
|
|
2701
|
-
at: entry.every === "daily" ? entry.at : null
|
|
2702
|
-
})),
|
|
2703
|
-
claims: (resolved) => resolved.map((schedule) => `schedule:${schedule.id}`)
|
|
2704
|
-
};
|
|
2705
|
-
|
|
2706
|
-
//#endregion
|
|
2707
|
-
//#region packages/extension-host/src/points/settings.ts
|
|
2708
|
-
/** Потолок длины строкового значения: даже в UTF-8 (3 байта на единицу) остаётся в пределах 64 КиБ хранилища. */
|
|
2709
|
-
const MAX_STRING_LENGTH = SETTING_LIMITS.stringLength;
|
|
2710
|
-
const MAX_OPTIONS = 64;
|
|
2711
|
-
const base = {
|
|
2712
|
-
id: extensionId,
|
|
2713
|
-
label: z.string().min(1).max(60),
|
|
2714
|
-
description: z.string().min(1).max(500).optional(),
|
|
2715
|
-
group: z.string().min(1).max(SETTING_LIMITS.groupLength).optional(),
|
|
2716
|
-
order: z.number().int().min(0).max(SETTING_LIMITS.orderMax).optional(),
|
|
2717
|
-
visibleWhen: z.strictObject({
|
|
2718
|
-
setting: extensionId,
|
|
2719
|
-
equals: z.union([
|
|
2720
|
-
z.boolean(),
|
|
2721
|
-
z.string(),
|
|
2722
|
-
z.number()
|
|
2723
|
-
])
|
|
2724
|
-
}).optional()
|
|
2725
|
-
};
|
|
2726
|
-
const option = z.strictObject({
|
|
2727
|
-
value: z.string().min(1).max(100),
|
|
2728
|
-
label: z.string().min(1).max(60)
|
|
2729
|
-
});
|
|
2730
|
-
const schema = z.discriminatedUnion("type", [
|
|
2731
|
-
z.strictObject({
|
|
2732
|
-
...base,
|
|
2733
|
-
type: z.literal("boolean"),
|
|
2734
|
-
default: z.boolean()
|
|
2735
|
-
}),
|
|
2736
|
-
z.strictObject({
|
|
2737
|
-
...base,
|
|
2738
|
-
type: z.literal("string"),
|
|
2739
|
-
default: z.string(),
|
|
2740
|
-
maxLength: z.number().int().min(1).max(MAX_STRING_LENGTH).optional()
|
|
2741
|
-
}),
|
|
2742
|
-
z.strictObject({
|
|
2743
|
-
...base,
|
|
2744
|
-
type: z.literal("text"),
|
|
2745
|
-
default: z.string(),
|
|
2746
|
-
maxLength: z.number().int().min(1).max(MAX_STRING_LENGTH).optional()
|
|
2747
|
-
}),
|
|
2748
|
-
z.strictObject({
|
|
2749
|
-
...base,
|
|
2750
|
-
type: z.literal("color"),
|
|
2751
|
-
default: z.string().regex(COLOR_SETTING_PATTERN, "must be #rrggbb")
|
|
2752
|
-
}),
|
|
2753
|
-
z.strictObject({
|
|
2754
|
-
...base,
|
|
2755
|
-
type: z.literal("list"),
|
|
2756
|
-
default: z.array(z.string()),
|
|
2757
|
-
maxItems: z.number().int().min(1).max(SETTING_LIMITS.listItems).optional(),
|
|
2758
|
-
itemMaxLength: z.number().int().min(1).max(SETTING_LIMITS.listItemLength).optional()
|
|
2759
|
-
}),
|
|
2760
|
-
z.strictObject({
|
|
2761
|
-
...base,
|
|
2762
|
-
type: z.literal("number"),
|
|
2763
|
-
default: z.number(),
|
|
2764
|
-
min: z.number().optional(),
|
|
2765
|
-
max: z.number().optional(),
|
|
2766
|
-
integer: z.boolean().optional()
|
|
2767
|
-
}),
|
|
2768
|
-
z.strictObject({
|
|
2769
|
-
...base,
|
|
2770
|
-
type: z.literal("enum"),
|
|
2771
|
-
default: z.string(),
|
|
2772
|
-
options: z.array(option).min(1).max(MAX_OPTIONS)
|
|
2773
|
-
})
|
|
2774
|
-
]);
|
|
2775
|
-
/** Нарушения `default` и границ одного определения (путь — до поля). */
|
|
2776
|
-
const constraintIssues = (setting, at) => {
|
|
2777
|
-
const issue = (field, message) => `${at}.${field}: ${message}`;
|
|
2778
|
-
switch (setting.type) {
|
|
2779
|
-
case "boolean":
|
|
2780
|
-
case "color": return [];
|
|
2781
|
-
case "list": {
|
|
2782
|
-
const maxItems = setting.maxItems ?? SETTING_LIMITS.listItems;
|
|
2783
|
-
const itemMax = setting.itemMaxLength ?? SETTING_LIMITS.listItemLength;
|
|
2784
|
-
return [...setting.default.length > maxItems ? [issue("default", `default has more than ${maxItems} items`)] : [], ...setting.default.some((item) => item.length > itemMax) ? [issue("default", `default has an item longer than ${itemMax} characters`)] : []];
|
|
2785
|
-
}
|
|
2786
|
-
case "text":
|
|
2787
|
-
case "string": {
|
|
2788
|
-
const limit = setting.maxLength ?? MAX_STRING_LENGTH;
|
|
2789
|
-
return setting.default.length > limit ? [issue("default", `default is longer than ${limit} characters`)] : [];
|
|
2790
|
-
}
|
|
2791
|
-
case "number": {
|
|
2792
|
-
const { min, max, integer = false } = setting;
|
|
2793
|
-
const issues = [];
|
|
2794
|
-
if (min !== void 0 && max !== void 0 && min > max) issues.push(issue("min", "min is greater than max"));
|
|
2795
|
-
if (integer && !Number.isInteger(setting.default)) issues.push(issue("default", "default must be an integer"));
|
|
2796
|
-
if (min !== void 0 && setting.default < min) issues.push(issue("default", `default is less than min ${min}`));
|
|
2797
|
-
if (max !== void 0 && setting.default > max) issues.push(issue("default", `default is greater than max ${max}`));
|
|
2798
|
-
return issues;
|
|
2799
|
-
}
|
|
2800
|
-
default: {
|
|
2801
|
-
const values = setting.options.map(({ value }) => value);
|
|
2802
|
-
return [...duplicateIssues(`${at}.options`, "value", values), ...values.includes(setting.default) ? [] : [issue("default", `default '${setting.default}' is not an option`)]];
|
|
2803
|
-
}
|
|
2804
|
-
}
|
|
2805
|
-
};
|
|
2806
|
-
/** Нарушения `visibleWhen` всех определений: цель есть, это не сама настройка, не `list`, без цепочек, тип `equals` совпадает. */
|
|
2807
|
-
const visibleWhenIssues = (entries) => {
|
|
2808
|
-
const byId = new Map(entries.map((entry) => [entry.id, entry]));
|
|
2809
|
-
return entries.flatMap((entry, index) => {
|
|
2810
|
-
const { visibleWhen } = entry;
|
|
2811
|
-
if (visibleWhen === void 0) return [];
|
|
2812
|
-
const at = `contributes.settings.${index}.visibleWhen`;
|
|
2813
|
-
const target = byId.get(visibleWhen.setting);
|
|
2814
|
-
if (target === void 0) return [`${at}.setting: unknown setting '${visibleWhen.setting}'`];
|
|
2815
|
-
if (target.id === entry.id) return [`${at}.setting: a setting cannot depend on itself`];
|
|
2816
|
-
if (target.type === "list") return [`${at}.setting: '${target.id}' is a list and cannot be a condition`];
|
|
2817
|
-
if (target.visibleWhen !== void 0) return [`${at}.setting: '${target.id}' has its own visibleWhen; chains and cycles are not allowed`];
|
|
2818
|
-
const expected = target.type === "boolean" || target.type === "number" ? target.type : "string";
|
|
2819
|
-
return typeof visibleWhen.equals === expected ? [] : [`${at}.equals: must be a ${expected} to match '${target.id}'`];
|
|
2820
|
-
});
|
|
2821
|
-
};
|
|
2822
|
-
const resolveSetting = (setting) => {
|
|
2823
|
-
const common = {
|
|
2824
|
-
id: setting.id,
|
|
2825
|
-
label: setting.label,
|
|
2826
|
-
description: setting.description ?? null,
|
|
2827
|
-
group: setting.group ?? null,
|
|
2828
|
-
order: setting.order ?? 0,
|
|
2829
|
-
visibleWhen: setting.visibleWhen === void 0 ? null : { ...setting.visibleWhen }
|
|
2830
|
-
};
|
|
2831
|
-
switch (setting.type) {
|
|
2832
|
-
case "boolean": return {
|
|
2833
|
-
...common,
|
|
2834
|
-
type: "boolean",
|
|
2835
|
-
default: setting.default
|
|
2836
|
-
};
|
|
2837
|
-
case "color": return {
|
|
2838
|
-
...common,
|
|
2839
|
-
type: "color",
|
|
2840
|
-
default: setting.default.toLowerCase()
|
|
2841
|
-
};
|
|
2842
|
-
case "text": return {
|
|
2843
|
-
...common,
|
|
2844
|
-
type: "text",
|
|
2845
|
-
default: setting.default,
|
|
2846
|
-
maxLength: setting.maxLength ?? null
|
|
2847
|
-
};
|
|
2848
|
-
case "list": return {
|
|
2849
|
-
...common,
|
|
2850
|
-
type: "list",
|
|
2851
|
-
default: [...setting.default],
|
|
2852
|
-
maxItems: setting.maxItems ?? SETTING_LIMITS.listItems,
|
|
2853
|
-
itemMaxLength: setting.itemMaxLength ?? SETTING_LIMITS.listItemLength
|
|
2854
|
-
};
|
|
2855
|
-
case "string": return {
|
|
2856
|
-
...common,
|
|
2857
|
-
type: "string",
|
|
2858
|
-
default: setting.default,
|
|
2859
|
-
maxLength: setting.maxLength ?? null
|
|
2860
|
-
};
|
|
2861
|
-
case "number": return {
|
|
2862
|
-
...common,
|
|
2863
|
-
type: "number",
|
|
2864
|
-
default: setting.default,
|
|
2865
|
-
min: setting.min ?? null,
|
|
2866
|
-
max: setting.max ?? null,
|
|
2867
|
-
integer: setting.integer ?? false
|
|
2868
|
-
};
|
|
2869
|
-
default: return {
|
|
2870
|
-
...common,
|
|
2871
|
-
type: "enum",
|
|
2872
|
-
default: setting.default,
|
|
2873
|
-
options: setting.options.map(({ value, label }) => ({
|
|
2874
|
-
value,
|
|
2875
|
-
label
|
|
2876
|
-
}))
|
|
2877
|
-
};
|
|
2878
|
-
}
|
|
2879
|
-
};
|
|
2880
|
-
const settings = {
|
|
2881
|
-
key: "settings",
|
|
2882
|
-
needsMain: false,
|
|
2883
|
-
schema,
|
|
2884
|
-
normalize: (entries) => entries,
|
|
2885
|
-
check: (entries, owner) => [
|
|
2886
|
-
...idPrefixIssues("settings", entries.map(({ id }) => id), owner),
|
|
2887
|
-
...duplicateIssues("contributes.settings", "id", entries.map(({ id }) => id)),
|
|
2888
|
-
...entries.flatMap((entry, index) => constraintIssues(entry, `contributes.settings.${index}`)),
|
|
2889
|
-
...visibleWhenIssues(entries)
|
|
2890
|
-
],
|
|
2891
|
-
resolve: async (entries) => entries.map(resolveSetting),
|
|
2892
|
-
claims: (resolved) => resolved.map((setting) => `setting:${setting.id}`)
|
|
2893
|
-
};
|
|
2894
|
-
|
|
2895
|
-
//#endregion
|
|
2896
|
-
//#region packages/extension-host/src/points/themes.ts
|
|
2897
|
-
const HEX_COLOR = /^#[0-9a-fA-F]{6}([0-9a-fA-F]{2})?$/;
|
|
2898
|
-
const hexColor = z.string().regex(HEX_COLOR, "must be #rrggbb or #rrggbbaa");
|
|
2899
|
-
const unknownKeys = (record, allowed) => Object.keys(record).filter((key) => !allowed.includes(key));
|
|
2900
|
-
const rejectUnknownKeys = (allowed, noun) => (value, ctx) => {
|
|
2901
|
-
const unknown = unknownKeys(value, allowed);
|
|
2902
|
-
if (unknown.length === 0) return;
|
|
2903
|
-
ctx.addIssue({
|
|
2904
|
-
code: "custom",
|
|
2905
|
-
message: `unknown ${noun} keys: ${unknown.join(", ")}`
|
|
2906
|
-
});
|
|
2907
|
-
};
|
|
2908
|
-
const colors = z.record(z.string(), hexColor).refine((value) => Object.keys(value).length > 0, "must not be empty").superRefine(rejectUnknownKeys(THEME_COLOR_KEYS, "color"));
|
|
2909
|
-
const isVariableValid = (key, value) => key === "border-color" ? typeof value === "string" && HEX_COLOR.test(value) : typeof value === "number" && value >= 0 && value <= 1;
|
|
2910
|
-
const variables = z.record(z.string(), z.union([z.string(), z.number()])).superRefine(rejectUnknownKeys(THEME_VARIABLE_KEYS, "variable")).refine((value) => Object.entries(value).every(([key, item]) => isVariableValid(key, item)), "border-color must be a hex color, other variables numbers in [0, 1]");
|
|
2911
|
-
const themes = {
|
|
2912
|
-
key: "themes",
|
|
2913
|
-
needsMain: false,
|
|
2914
|
-
schema: z.strictObject({
|
|
2915
|
-
id: extensionId.refine((id) => !BUILTIN_THEME_IDS.includes(id), "id is reserved for a built-in theme"),
|
|
2916
|
-
label: z.string().min(1).max(60),
|
|
2917
|
-
dark: z.boolean(),
|
|
2918
|
-
colors,
|
|
2919
|
-
variables: variables.optional()
|
|
2920
|
-
}),
|
|
2921
|
-
normalize: (entries) => entries,
|
|
2922
|
-
check: (entries, owner) => idPrefixIssues("themes", entries.map(({ id }) => id), owner),
|
|
2923
|
-
resolve: async (entries) => entries.map((theme) => ({
|
|
2924
|
-
id: theme.id,
|
|
2925
|
-
label: theme.label,
|
|
2926
|
-
dark: theme.dark,
|
|
2927
|
-
colors: { ...theme.colors },
|
|
2928
|
-
variables: { ...theme.variables }
|
|
2929
|
-
})),
|
|
2930
|
-
claims: (resolved) => resolved.map((theme) => `theme:${theme.id}`)
|
|
2931
|
-
};
|
|
2932
|
-
|
|
2933
|
-
//#endregion
|
|
2934
|
-
//#region packages/extension-host/src/points/widgets.ts
|
|
2935
|
-
const { widgets: MAX_WIDGETS, minHeight: MIN_HEIGHT, maxHeight: MAX_HEIGHT } = EXTENSION_WIDGET_LIMITS;
|
|
2936
|
-
const height = z.number().int().min(MIN_HEIGHT).max(MAX_HEIGHT);
|
|
2937
|
-
const widgets = {
|
|
2938
|
-
key: "widgets",
|
|
2939
|
-
needsMain: false,
|
|
2940
|
-
schema: z.strictObject({
|
|
2941
|
-
id: extensionId,
|
|
2942
|
-
title: z.string().min(1).max(EXTENSION_COMMAND_LIMITS.titleLength),
|
|
2943
|
-
slot: z.enum(EXTENSION_WIDGET_SLOTS),
|
|
2944
|
-
minHeight: height.optional(),
|
|
2945
|
-
maxHeight: height.optional(),
|
|
2946
|
-
module: safePath([".js", ".mjs"]).optional(),
|
|
2947
|
-
when: whenField.optional()
|
|
2948
|
-
}),
|
|
2949
|
-
normalize: (entries) => entries.map((entry) => ({
|
|
2950
|
-
id: entry.id,
|
|
2951
|
-
title: entry.title,
|
|
2952
|
-
slot: entry.slot,
|
|
2953
|
-
minHeight: entry.minHeight ?? MIN_HEIGHT,
|
|
2954
|
-
maxHeight: entry.maxHeight ?? MAX_HEIGHT,
|
|
2955
|
-
module: entry.module ?? "./widget.mjs",
|
|
2956
|
-
...entry.when === void 0 ? {} : { when: entry.when }
|
|
2957
|
-
})),
|
|
2958
|
-
check: (entries, owner) => {
|
|
2959
|
-
const ids = entries.map(({ id }) => id);
|
|
2960
|
-
return [
|
|
2961
|
-
...entries.length > MAX_WIDGETS ? [`contributes.widgets: at most ${MAX_WIDGETS} widgets allowed`] : [],
|
|
2962
|
-
...idPrefixIssues("widgets", ids, owner),
|
|
2963
|
-
...duplicateIssues("contributes.widgets", "id", ids),
|
|
2964
|
-
...entries.flatMap((entry, index) => whenIssues("widgets", index, entry.when)),
|
|
2965
|
-
...entries.flatMap((entry, index) => entry.minHeight > entry.maxHeight ? [`contributes.widgets.${index}.minHeight: minHeight (${entry.minHeight}) must not exceed maxHeight (${entry.maxHeight})`] : [])
|
|
2966
|
-
];
|
|
2967
|
-
},
|
|
2968
|
-
resolve: async (entries, { dir, extensionId: owner, verifyFiles }) => {
|
|
2969
|
-
const resolved = [];
|
|
2970
|
-
for (const entry of entries) resolved.push({
|
|
2971
|
-
id: entry.id,
|
|
2972
|
-
title: entry.title,
|
|
2973
|
-
slot: entry.slot,
|
|
2974
|
-
minHeight: entry.minHeight,
|
|
2975
|
-
maxHeight: entry.maxHeight,
|
|
2976
|
-
when: entry.when ?? null,
|
|
2977
|
-
rendererUrl: await resolveModuleUrl(owner, dir, entry.module, DEFAULT_WIDGET, verifyFiles, "widget module")
|
|
2978
|
-
});
|
|
2979
|
-
return resolved;
|
|
2980
|
-
},
|
|
2981
|
-
claims: (resolved) => resolved.map((widget) => `widget:${widget.id}`)
|
|
2982
|
-
};
|
|
2983
|
-
|
|
2984
|
-
//#endregion
|
|
2985
|
-
//#region packages/extension-host/src/points/index.ts
|
|
2986
|
-
/** Реестр точек вклада: порядок значим для диагностики и разбора. */
|
|
2987
|
-
const CONTRIBUTION_POINTS = [
|
|
2988
|
-
exerciseTypes,
|
|
2989
|
-
themes,
|
|
2990
|
-
markdownRenderers,
|
|
2991
|
-
gradePolicies,
|
|
2992
|
-
settings,
|
|
2993
|
-
events,
|
|
2994
|
-
commands,
|
|
2995
|
-
panels,
|
|
2996
|
-
widgets,
|
|
2997
|
-
schedules,
|
|
2998
|
-
importers,
|
|
2999
|
-
exporters
|
|
3000
|
-
];
|
|
3001
|
-
const POINT_KEYS = CONTRIBUTION_POINTS.map(({ key }) => key);
|
|
3002
|
-
|
|
3003
|
-
//#endregion
|
|
3004
|
-
//#region packages/extension-host/src/manifest.ts
|
|
3005
|
-
const contributesSchema = z.strictObject(Object.fromEntries(CONTRIBUTION_POINTS.map((point) => [point.key, z.array(point.schema).optional()])));
|
|
3006
|
-
const entriesOf = (contributes, key) => contributes[key] ?? [];
|
|
3007
|
-
const isEmpty = (contributes) => CONTRIBUTION_POINTS.every((point) => entriesOf(contributes, point.key).length === 0);
|
|
3008
|
-
/** Диапазон версий в том же виде, что `versions` отзыва и устаревания в каталоге. */
|
|
3009
|
-
const versionRange = z.string().superRefine((value, ctx) => {
|
|
3010
|
-
try {
|
|
3011
|
-
parseRange(value);
|
|
3012
|
-
} catch {
|
|
3013
|
-
ctx.addIssue({
|
|
3014
|
-
code: "custom",
|
|
3015
|
-
message: "range must be space-separated comparators such as '>=1.2.0 <2.0.0'"
|
|
3016
|
-
});
|
|
3017
|
-
}
|
|
3018
|
-
});
|
|
3019
|
-
const manifestSchema = z.strictObject({
|
|
3020
|
-
/** Ссылка на JSON Schema для редактора; приложением и инструментами игнорируется. */
|
|
3021
|
-
$schema: z.string().optional(),
|
|
3022
|
-
id: extensionId,
|
|
3023
|
-
version: z.string().refine(isSemver, "version must be semver"),
|
|
3024
|
-
apiVersion: z.literal(1),
|
|
3025
|
-
main: safePath([".mjs"]).optional(),
|
|
3026
|
-
permissions: z.array(z.enum(EXTENSION_PERMISSIONS)).optional(),
|
|
3027
|
-
name: z.string().min(1).max(80).optional(),
|
|
3028
|
-
description: z.string().min(1).max(500).optional(),
|
|
3029
|
-
author: z.string().regex(GITHUB_LOGIN_PATTERN, "must be a GitHub login").optional(),
|
|
3030
|
-
platforms: z.array(z.enum(EXTENSION_PLATFORMS)).optional(),
|
|
3031
|
-
minAppVersion: z.string().refine(isSemver, "minAppVersion must be semver x.y.z").optional(),
|
|
3032
|
-
icon: safePath([".png", ".webp"]).optional(),
|
|
3033
|
-
tags: z.array(z.enum(EXTENSION_TAGS, { error: `tag must be one of: ${EXTENSION_TAGS.join(", ")}` })).max(5, "at most 5 tags").optional(),
|
|
3034
|
-
dependencies: z.array(z.strictObject({
|
|
3035
|
-
id: extensionId,
|
|
3036
|
-
range: versionRange.optional()
|
|
3037
|
-
})).max(16, `at most ${16} dependencies`).optional(),
|
|
3038
|
-
contributes: contributesSchema
|
|
3039
|
-
}).superRefine((manifest, ctx) => {
|
|
3040
|
-
const { permissions = [], platforms = [] } = manifest;
|
|
3041
|
-
platforms.forEach((platform, index) => {
|
|
3042
|
-
if (platforms.indexOf(platform) !== index) ctx.addIssue({
|
|
3043
|
-
code: "custom",
|
|
3044
|
-
path: ["platforms", index],
|
|
3045
|
-
message: `duplicate platform '${platform}'`
|
|
3046
|
-
});
|
|
3047
|
-
});
|
|
3048
|
-
permissions.forEach((permission, index) => {
|
|
3049
|
-
if (permissions.indexOf(permission) !== index) ctx.addIssue({
|
|
3050
|
-
code: "custom",
|
|
3051
|
-
path: ["permissions", index],
|
|
3052
|
-
message: `duplicate permission '${permission}'`
|
|
3053
|
-
});
|
|
3054
|
-
});
|
|
3055
|
-
const { tags = [] } = manifest;
|
|
3056
|
-
tags.forEach((tag, index) => {
|
|
3057
|
-
if (tags.indexOf(tag) !== index) ctx.addIssue({
|
|
3058
|
-
code: "custom",
|
|
3059
|
-
path: ["tags", index],
|
|
3060
|
-
message: `duplicate tag '${tag}'`
|
|
3061
|
-
});
|
|
3062
|
-
});
|
|
3063
|
-
const { dependencies = [] } = manifest;
|
|
3064
|
-
dependencies.forEach(({ id }, index) => {
|
|
3065
|
-
if (id === manifest.id) ctx.addIssue({
|
|
3066
|
-
code: "custom",
|
|
3067
|
-
path: [
|
|
3068
|
-
"dependencies",
|
|
3069
|
-
index,
|
|
3070
|
-
"id"
|
|
3071
|
-
],
|
|
3072
|
-
message: "an extension cannot depend on itself"
|
|
3073
|
-
});
|
|
3074
|
-
else if (dependencies.findIndex((item) => item.id === id) !== index) ctx.addIssue({
|
|
3075
|
-
code: "custom",
|
|
3076
|
-
path: [
|
|
3077
|
-
"dependencies",
|
|
3078
|
-
index,
|
|
3079
|
-
"id"
|
|
3080
|
-
],
|
|
3081
|
-
message: `duplicate dependency '${id}'`
|
|
3082
|
-
});
|
|
3083
|
-
});
|
|
3084
|
-
if (entriesOf(manifest.contributes, "events").length > 0 && !permissions.includes("learning.events")) ctx.addIssue({
|
|
3085
|
-
code: "custom",
|
|
3086
|
-
path: ["permissions"],
|
|
3087
|
-
message: "contributes.events requires the 'learning.events' permission"
|
|
3088
|
-
});
|
|
3089
|
-
entriesOf(manifest.contributes, "exporters").forEach((entry, index) => {
|
|
3090
|
-
if (entry.scope === "progress" && !permissions.includes("learning.stats")) ctx.addIssue({
|
|
3091
|
-
code: "custom",
|
|
3092
|
-
path: [
|
|
3093
|
-
"contributes",
|
|
3094
|
-
"exporters",
|
|
3095
|
-
index,
|
|
3096
|
-
"scope"
|
|
3097
|
-
],
|
|
3098
|
-
message: "scope 'progress' requires the 'learning.stats' permission"
|
|
3099
|
-
});
|
|
3100
|
-
});
|
|
3101
|
-
if (isEmpty(manifest.contributes)) ctx.addIssue({
|
|
3102
|
-
code: "custom",
|
|
3103
|
-
path: ["contributes"],
|
|
3104
|
-
message: "at least one contribution is required"
|
|
3105
|
-
});
|
|
3106
|
-
});
|
|
3107
|
-
/** Код нужен, если есть записи в точке, которая его требует. */
|
|
3108
|
-
const needsMain = (contributes) => CONTRIBUTION_POINTS.some((point) => point.needsMain && entriesOf(contributes, point.key).length > 0);
|
|
3109
|
-
/** Применяет умолчания: `main`, `renderer`, `element`, пустые точки. */
|
|
3110
|
-
const normalizeManifest = (input) => {
|
|
3111
|
-
const contributes = Object.fromEntries(CONTRIBUTION_POINTS.map((point) => [point.key, point.normalize(entriesOf(input.contributes, point.key))]));
|
|
3112
|
-
return {
|
|
3113
|
-
id: input.id,
|
|
3114
|
-
version: input.version,
|
|
3115
|
-
apiVersion: input.apiVersion,
|
|
3116
|
-
main: input.main ?? (needsMain(input.contributes) ? "./main.mjs" : null),
|
|
3117
|
-
permissions: [...input.permissions ?? []],
|
|
3118
|
-
name: input.name ?? null,
|
|
3119
|
-
description: input.description ?? null,
|
|
3120
|
-
author: input.author ?? null,
|
|
3121
|
-
platforms: [...input.platforms ?? []],
|
|
3122
|
-
minAppVersion: input.minAppVersion ?? null,
|
|
3123
|
-
tags: [...input.tags ?? []],
|
|
3124
|
-
dependencies: (input.dependencies ?? []).map(({ id, range }) => ({
|
|
3125
|
-
id,
|
|
3126
|
-
range: range ?? null
|
|
3127
|
-
})),
|
|
3128
|
-
icon: input.icon ?? null,
|
|
3129
|
-
contributes
|
|
3130
|
-
};
|
|
3131
|
-
};
|
|
3132
|
-
const zodIssues = (error) => error.issues.map((issue) => `${issue.path.join(".") || "/"}: ${issue.message}`);
|
|
3133
|
-
/** Сообщения о нарушениях в нормализованном манифесте. */
|
|
3134
|
-
const normalizedIssues = (manifest) => CONTRIBUTION_POINTS.flatMap((point) => point.check(manifest.contributes[point.key], manifest.id));
|
|
3135
|
-
const invalid = (issues) => ({
|
|
3136
|
-
ok: false,
|
|
3137
|
-
diagnostic: {
|
|
3138
|
-
code: "manifest-invalid",
|
|
3139
|
-
data: { issues }
|
|
3140
|
-
}
|
|
3141
|
-
});
|
|
3142
|
-
/** Разбор манифеста; ошибка — диагностика `manifest-invalid` (текст — `formatDiagnostic`). */
|
|
3143
|
-
const parseManifest = (raw) => {
|
|
3144
|
-
const parsed = manifestSchema.safeParse(raw);
|
|
3145
|
-
if (!parsed.success) return invalid(zodIssues(parsed.error));
|
|
3146
|
-
const manifest = normalizeManifest(parsed.data);
|
|
3147
|
-
const issues = normalizedIssues(manifest);
|
|
3148
|
-
if (issues.length > 0) return invalid(issues);
|
|
3149
|
-
return {
|
|
3150
|
-
ok: true,
|
|
3151
|
-
manifest
|
|
1497
|
+
ok: true,
|
|
1498
|
+
manifest: normalizeManifest(parsed.data)
|
|
3152
1499
|
};
|
|
3153
1500
|
};
|
|
3154
1501
|
|
|
@@ -3168,11 +1515,19 @@ const compatibilityIssue = (manifest, appVersion, platform) => {
|
|
|
3168
1515
|
data: { platform }
|
|
3169
1516
|
};
|
|
3170
1517
|
};
|
|
3171
|
-
const
|
|
3172
|
-
|
|
3173
|
-
|
|
3174
|
-
|
|
3175
|
-
|
|
1518
|
+
const isFile$2 = async (file) => (await stat(file).catch(() => null))?.isFile() === true;
|
|
1519
|
+
/** Путь внутри каталога расширения; выход за каталог — ошибка. */
|
|
1520
|
+
const inside = (dir, relative) => {
|
|
1521
|
+
const resolved = path.resolve(dir, relative);
|
|
1522
|
+
if (!resolved.startsWith(dir + path.sep)) throw new Error(`path '${relative}' escapes the extension directory`);
|
|
1523
|
+
return resolved;
|
|
1524
|
+
};
|
|
1525
|
+
/** Путь собранной части расширения (`main`, `client`) внутри каталога; `null` — части нет. */
|
|
1526
|
+
const resolvePart = async (dir, part, file, verifyFiles) => {
|
|
1527
|
+
if (file === null) return null;
|
|
1528
|
+
const target = inside(dir, file);
|
|
1529
|
+
if (verifyFiles && !await isFile$2(target)) throw new Error(`${part} '${file}' is not a file`);
|
|
1530
|
+
return target;
|
|
3176
1531
|
};
|
|
3177
1532
|
/**
|
|
3178
1533
|
* Значок манифеста как `data:`-URI. Читается при обнаружении (≤16 КиБ), снимок
|
|
@@ -3188,14 +1543,11 @@ const resolveIcon = async (dir, icon, verifyFiles) => {
|
|
|
3188
1543
|
if (problem !== null) throw new Error(problem);
|
|
3189
1544
|
return iconDataUri(icon, bytes);
|
|
3190
1545
|
};
|
|
1546
|
+
/** Полностью разбирает каталог одного расширения; ошибка — диагностика (английский текст — `formatDiagnostic`). */
|
|
3191
1547
|
const inspectExtensionDir = async (directory, options = {}) => {
|
|
3192
1548
|
const { verifyFiles = true, expectedId = null, appVersion, platform = process.platform } = options;
|
|
3193
1549
|
const dir = path.resolve(directory);
|
|
3194
1550
|
const dirName = expectedId ?? path.basename(dir);
|
|
3195
|
-
const ajv = new Ajv2020({
|
|
3196
|
-
allErrors: true,
|
|
3197
|
-
strict: false
|
|
3198
|
-
});
|
|
3199
1551
|
let raw;
|
|
3200
1552
|
try {
|
|
3201
1553
|
raw = JSON.parse(await readFile(path.join(dir, "extension.json"), "utf8"));
|
|
@@ -3234,38 +1586,23 @@ const inspectExtensionDir = async (directory, options = {}) => {
|
|
|
3234
1586
|
diagnostic: incompatible
|
|
3235
1587
|
};
|
|
3236
1588
|
try {
|
|
3237
|
-
const mainPath = await resolveMain(dir, manifest.main, verifyFiles);
|
|
3238
|
-
const context = {
|
|
3239
|
-
dir,
|
|
3240
|
-
extensionId: manifest.id,
|
|
3241
|
-
verifyFiles,
|
|
3242
|
-
ajv
|
|
3243
|
-
};
|
|
3244
|
-
const resolved = {};
|
|
3245
|
-
for (const point of CONTRIBUTION_POINTS) resolved[point.key] = await point.resolve(manifest.contributes[point.key], context);
|
|
3246
|
-
const locales = verifyFiles ? await loadLocales(dir, manifest) : {
|
|
3247
|
-
messages: {},
|
|
3248
|
-
warnings: []
|
|
3249
|
-
};
|
|
3250
1589
|
return {
|
|
3251
1590
|
ok: true,
|
|
3252
1591
|
extension: {
|
|
3253
1592
|
id: manifest.id,
|
|
3254
1593
|
version: manifest.version,
|
|
3255
1594
|
dir,
|
|
3256
|
-
mainPath,
|
|
3257
|
-
|
|
1595
|
+
mainPath: await resolvePart(dir, "main", manifest.main, verifyFiles),
|
|
1596
|
+
clientPath: await resolvePart(dir, "client", manifest.client, verifyFiles),
|
|
3258
1597
|
name: manifest.name,
|
|
3259
1598
|
description: manifest.description,
|
|
3260
1599
|
author: manifest.author,
|
|
3261
1600
|
platforms: manifest.platforms,
|
|
3262
1601
|
minAppVersion: manifest.minAppVersion,
|
|
3263
1602
|
icon: await resolveIcon(dir, manifest.icon, verifyFiles),
|
|
3264
|
-
|
|
3265
|
-
warnings: locales.warnings,
|
|
1603
|
+
warnings: [],
|
|
3266
1604
|
tags: manifest.tags,
|
|
3267
|
-
dependencies: manifest.dependencies
|
|
3268
|
-
...resolved
|
|
1605
|
+
dependencies: manifest.dependencies
|
|
3269
1606
|
}
|
|
3270
1607
|
};
|
|
3271
1608
|
} catch (error) {
|
|
@@ -3281,28 +1618,174 @@ const inspectExtensionDir = async (directory, options = {}) => {
|
|
|
3281
1618
|
};
|
|
3282
1619
|
|
|
3283
1620
|
//#endregion
|
|
3284
|
-
//#region packages/extension-host/src/
|
|
3285
|
-
/**
|
|
3286
|
-
const
|
|
3287
|
-
/**
|
|
3288
|
-
const
|
|
3289
|
-
|
|
3290
|
-
|
|
1621
|
+
//#region packages/extension-host/src/registrar-support.ts
|
|
1622
|
+
/** Идентификатор вклада: форма проверяется схемой, принадлежность расширению — `checkOwnId`. */
|
|
1623
|
+
const idField = z.string().max(64).regex(EXTENSION_ID_PATTERN, "invalid id");
|
|
1624
|
+
/** Подпись: строка или `{ en, ru? }`; каждый текст 1..`max` символов. */
|
|
1625
|
+
const localizedField = (max) => {
|
|
1626
|
+
const text = z.string().min(1).max(max);
|
|
1627
|
+
return z.union([text, z.strictObject({
|
|
1628
|
+
en: text,
|
|
1629
|
+
ru: text.optional()
|
|
1630
|
+
})]).transform((value) => {
|
|
1631
|
+
if (typeof value === "string") return value;
|
|
1632
|
+
return value.ru === void 0 ? { en: value.en } : {
|
|
1633
|
+
en: value.en,
|
|
1634
|
+
ru: value.ru
|
|
1635
|
+
};
|
|
1636
|
+
});
|
|
1637
|
+
};
|
|
1638
|
+
/** Условие видимости: длина здесь, разбор — `whenProblem`. */
|
|
1639
|
+
const whenField = z.string().min(1).max(EXTENSION_COMMAND_LIMITS.whenLength);
|
|
1640
|
+
/** Значение — функция (схема не копирует её, только проверяет тип). */
|
|
1641
|
+
const functionField = z.custom((value) => typeof value === "function", "must be a function");
|
|
3291
1642
|
|
|
3292
1643
|
//#endregion
|
|
3293
|
-
//#region packages/extension-host/src/
|
|
3294
|
-
|
|
3295
|
-
const
|
|
3296
|
-
|
|
3297
|
-
|
|
3298
|
-
|
|
3299
|
-
|
|
1644
|
+
//#region packages/extension-host/src/registrar-commands.ts
|
|
1645
|
+
const keyText = z.string().min(1);
|
|
1646
|
+
const schema = z.strictObject({
|
|
1647
|
+
id: idField,
|
|
1648
|
+
title: localizedField(EXTENSION_COMMAND_LIMITS.titleLength),
|
|
1649
|
+
description: localizedField(EXTENSION_COMMAND_LIMITS.descriptionLength).optional(),
|
|
1650
|
+
category: localizedField(EXTENSION_COMMAND_LIMITS.categoryLength).optional(),
|
|
1651
|
+
keybindings: z.array(z.strictObject({
|
|
1652
|
+
key: keyText,
|
|
1653
|
+
mac: keyText.optional(),
|
|
1654
|
+
windows: keyText.optional(),
|
|
1655
|
+
linux: keyText.optional(),
|
|
1656
|
+
when: whenField.optional()
|
|
1657
|
+
})).max(EXTENSION_COMMAND_LIMITS.keybindingsPerCommand).optional(),
|
|
1658
|
+
palette: z.boolean().optional(),
|
|
1659
|
+
when: whenField.optional(),
|
|
1660
|
+
icon: z.enum(EXTENSION_ICONS).optional(),
|
|
1661
|
+
run: functionField
|
|
3300
1662
|
});
|
|
3301
1663
|
|
|
3302
1664
|
//#endregion
|
|
3303
|
-
//#region packages/extension-host/src/
|
|
3304
|
-
|
|
3305
|
-
|
|
1665
|
+
//#region packages/extension-host/src/registrar-settings.ts
|
|
1666
|
+
const base = {
|
|
1667
|
+
id: idField,
|
|
1668
|
+
label: localizedField(SETTING_LIMITS.labelLength),
|
|
1669
|
+
description: localizedField(SETTING_LIMITS.descriptionLength).optional(),
|
|
1670
|
+
group: localizedField(SETTING_LIMITS.groupLength).optional(),
|
|
1671
|
+
order: z.number().int().min(0).max(SETTING_LIMITS.orderMax).optional(),
|
|
1672
|
+
visibleWhen: z.strictObject({
|
|
1673
|
+
setting: idField,
|
|
1674
|
+
equals: z.union([
|
|
1675
|
+
z.boolean(),
|
|
1676
|
+
z.string(),
|
|
1677
|
+
z.number()
|
|
1678
|
+
])
|
|
1679
|
+
}).optional()
|
|
1680
|
+
};
|
|
1681
|
+
const option = z.strictObject({
|
|
1682
|
+
value: z.string().min(1).max(SETTING_LIMITS.optionValueLength),
|
|
1683
|
+
label: localizedField(SETTING_LIMITS.labelLength)
|
|
1684
|
+
});
|
|
1685
|
+
const maxLength = z.number().int().min(1).max(SETTING_LIMITS.stringLength).optional();
|
|
1686
|
+
const definitionSchema = z.discriminatedUnion("type", [
|
|
1687
|
+
z.strictObject({
|
|
1688
|
+
...base,
|
|
1689
|
+
type: z.literal("boolean"),
|
|
1690
|
+
default: z.boolean()
|
|
1691
|
+
}),
|
|
1692
|
+
z.strictObject({
|
|
1693
|
+
...base,
|
|
1694
|
+
type: z.literal("string"),
|
|
1695
|
+
default: z.string(),
|
|
1696
|
+
maxLength
|
|
1697
|
+
}),
|
|
1698
|
+
z.strictObject({
|
|
1699
|
+
...base,
|
|
1700
|
+
type: z.literal("text"),
|
|
1701
|
+
default: z.string(),
|
|
1702
|
+
maxLength
|
|
1703
|
+
}),
|
|
1704
|
+
z.strictObject({
|
|
1705
|
+
...base,
|
|
1706
|
+
type: z.literal("color"),
|
|
1707
|
+
default: z.string().regex(COLOR_SETTING_PATTERN, "must be #rrggbb")
|
|
1708
|
+
}),
|
|
1709
|
+
z.strictObject({
|
|
1710
|
+
...base,
|
|
1711
|
+
type: z.literal("list"),
|
|
1712
|
+
default: z.array(z.string()),
|
|
1713
|
+
maxItems: z.number().int().min(1).max(SETTING_LIMITS.listItems).optional(),
|
|
1714
|
+
itemMaxLength: z.number().int().min(1).max(SETTING_LIMITS.listItemLength).optional()
|
|
1715
|
+
}),
|
|
1716
|
+
z.strictObject({
|
|
1717
|
+
...base,
|
|
1718
|
+
type: z.literal("number"),
|
|
1719
|
+
default: z.number().finite(),
|
|
1720
|
+
min: z.number().finite().optional(),
|
|
1721
|
+
max: z.number().finite().optional(),
|
|
1722
|
+
integer: z.boolean().optional()
|
|
1723
|
+
}),
|
|
1724
|
+
z.strictObject({
|
|
1725
|
+
...base,
|
|
1726
|
+
type: z.literal("enum"),
|
|
1727
|
+
default: z.string(),
|
|
1728
|
+
options: z.array(option).min(1).max(SETTING_LIMITS.options)
|
|
1729
|
+
})
|
|
1730
|
+
]);
|
|
1731
|
+
|
|
1732
|
+
//#endregion
|
|
1733
|
+
//#region packages/extension-host/src/registrar.ts
|
|
1734
|
+
const BUILTIN_POLICY_ID = "passAtN";
|
|
1735
|
+
const { titleLength } = EXTENSION_COMMAND_LIMITS;
|
|
1736
|
+
const schemaField = z.record(z.string(), z.unknown()).refine((value) => Object.keys(value).length > 0, "must not be empty");
|
|
1737
|
+
const exerciseTypeSchema = z.strictObject({
|
|
1738
|
+
id: idField,
|
|
1739
|
+
title: localizedField(titleLength).optional(),
|
|
1740
|
+
specSchema: schemaField,
|
|
1741
|
+
answerSchema: schemaField,
|
|
1742
|
+
project: functionField,
|
|
1743
|
+
grade: functionField,
|
|
1744
|
+
referenceAnswer: functionField.optional()
|
|
1745
|
+
});
|
|
1746
|
+
const gradePolicySchema = z.strictObject({
|
|
1747
|
+
id: idField.refine((id) => id !== BUILTIN_POLICY_ID, `id '${BUILTIN_POLICY_ID}' is reserved for the built-in policy`),
|
|
1748
|
+
label: localizedField(titleLength),
|
|
1749
|
+
evaluate: functionField
|
|
1750
|
+
});
|
|
1751
|
+
const scheduleSchema = z.discriminatedUnion("every", [z.strictObject({
|
|
1752
|
+
id: idField,
|
|
1753
|
+
every: z.literal("daily"),
|
|
1754
|
+
at: z.string().regex(SCHEDULE_AT_PATTERN, "must be a time such as '09:00'")
|
|
1755
|
+
}), z.strictObject({
|
|
1756
|
+
id: idField,
|
|
1757
|
+
every: z.literal("hourly")
|
|
1758
|
+
})]);
|
|
1759
|
+
const importerSchema = z.strictObject({
|
|
1760
|
+
id: idField,
|
|
1761
|
+
title: localizedField(titleLength),
|
|
1762
|
+
accept: z.array(z.string().regex(TRANSFER_ACCEPT_PATTERN, "must be a lower-case file extension such as '.csv'")).min(1).max(EXTENSION_TRANSFER_LIMITS.acceptExtensions),
|
|
1763
|
+
input: z.enum(["text", "bytes"]),
|
|
1764
|
+
run: functionField
|
|
1765
|
+
});
|
|
1766
|
+
const exporterSchema = z.strictObject({
|
|
1767
|
+
id: idField,
|
|
1768
|
+
title: localizedField(titleLength),
|
|
1769
|
+
scope: z.enum(["course", "progress"]),
|
|
1770
|
+
run: functionField
|
|
1771
|
+
});
|
|
1772
|
+
const schemaLike = z.custom((value) => typeof value === "object" && value !== null && typeof Reflect.get(value, "safeParseAsync") === "function", "must be a zod schema");
|
|
1773
|
+
const rpcSchema = z.object({
|
|
1774
|
+
name: z.string().max(EXTENSION_RPC_LIMITS.nameLength).regex(RPC_NAME_PATTERN, "must match RPC_NAME_PATTERN"),
|
|
1775
|
+
input: schemaLike,
|
|
1776
|
+
output: schemaLike
|
|
1777
|
+
});
|
|
1778
|
+
|
|
1779
|
+
//#endregion
|
|
1780
|
+
//#region packages/extension-host/src/runtime.ts
|
|
1781
|
+
/** Срок обработчика расписания (R12); клиент планировщика ждёт дольше. */
|
|
1782
|
+
const SCHEDULE_HANDLER_MS = EXTENSION_SCHEDULE_LIMITS.handlerMs;
|
|
1783
|
+
/** Срок обработчика команды расширения (R3); клиент движка ждёт дольше. */
|
|
1784
|
+
const COMMAND_HANDLER_MS = EXTENSION_COMMAND_LIMITS.handlerMs;
|
|
1785
|
+
/** Срок обработчика `server.handle`; клиент движка ждёт дольше. */
|
|
1786
|
+
const RPC_HANDLER_MS = EXTENSION_RPC_LIMITS.handlerMs;
|
|
1787
|
+
/** Срок обработчика импортёра и экспортёра; клиент движка (34 с) ждёт дольше. */
|
|
1788
|
+
const TRANSFER_HANDLER_MS = EXTENSION_TRANSFER_LIMITS.handlerMs;
|
|
3306
1789
|
|
|
3307
1790
|
//#endregion
|
|
3308
1791
|
//#region packages/extension-tools/src/catalog/assets.ts
|
|
@@ -3763,128 +2246,21 @@ const assetFindings = async (root, files) => {
|
|
|
3763
2246
|
const findings = [];
|
|
3764
2247
|
for (const file of files) {
|
|
3765
2248
|
if (hasUppercaseAssetExtension(file)) {
|
|
3766
|
-
findings.push({
|
|
3767
|
-
path: file,
|
|
3768
|
-
message: "the file extension must be lowercase"
|
|
3769
|
-
});
|
|
3770
|
-
continue;
|
|
3771
|
-
}
|
|
3772
|
-
if (assetExtensionOf(file) === null) continue;
|
|
3773
|
-
const message = assetProblem(file, await readFile(path.join(root, file)));
|
|
3774
|
-
if (message !== null) findings.push({
|
|
3775
|
-
path: file,
|
|
3776
|
-
message
|
|
3777
|
-
});
|
|
3778
|
-
}
|
|
3779
|
-
return findings;
|
|
3780
|
-
};
|
|
3781
|
-
|
|
3782
|
-
//#endregion
|
|
3783
|
-
//#region packages/extension-tools/src/locales.ts
|
|
3784
|
-
const error$1 = (field, message) => ({
|
|
3785
|
-
severity: "error",
|
|
3786
|
-
field,
|
|
3787
|
-
message
|
|
3788
|
-
});
|
|
3789
|
-
const warning$1 = (field, message) => ({
|
|
3790
|
-
severity: "warning",
|
|
3791
|
-
field,
|
|
3792
|
-
message
|
|
3793
|
-
});
|
|
3794
|
-
/** Paths of the manifest strings that are `%key%` placeholders, by key (first use first). */
|
|
3795
|
-
const usesOf = (manifest) => {
|
|
3796
|
-
const uses = /* @__PURE__ */ new Map();
|
|
3797
|
-
for (const { path: where, value } of localizableStrings(manifest)) {
|
|
3798
|
-
const key = placeholderKey(value);
|
|
3799
|
-
if (key === null) continue;
|
|
3800
|
-
uses.set(key, [...uses.get(key) ?? [], where]);
|
|
3801
|
-
}
|
|
3802
|
-
return uses;
|
|
3803
|
-
};
|
|
3804
|
-
const loadTables = async (input, findings) => {
|
|
3805
|
-
const loaded = {};
|
|
3806
|
-
for (const locale of EXTENSION_LOCALES) {
|
|
3807
|
-
const file = localeFile(locale);
|
|
3808
|
-
const text = await input.read(file);
|
|
3809
|
-
if (text === null) {
|
|
3810
|
-
loaded[locale] = { kind: "absent" };
|
|
3811
|
-
continue;
|
|
3812
|
-
}
|
|
3813
|
-
const parsed = parseLocaleText(text);
|
|
3814
|
-
if (parsed.ok) {
|
|
3815
|
-
loaded[locale] = {
|
|
3816
|
-
kind: "table",
|
|
3817
|
-
table: parsed.table
|
|
3818
|
-
};
|
|
3819
|
-
continue;
|
|
3820
|
-
}
|
|
3821
|
-
loaded[locale] = { kind: "invalid" };
|
|
3822
|
-
for (const issue of parsed.issues) findings.push(error$1(file, issue));
|
|
3823
|
-
}
|
|
3824
|
-
return loaded;
|
|
3825
|
-
};
|
|
3826
|
-
const tablesOf = (loaded) => Object.fromEntries(EXTENSION_LOCALES.flatMap((locale) => {
|
|
3827
|
-
const entry = loaded[locale];
|
|
3828
|
-
return entry.kind === "table" ? [[locale, entry.table]] : [];
|
|
3829
|
-
}));
|
|
3830
|
-
/** Errors about the text a language produces: the manifest with that text must still be valid. */
|
|
3831
|
-
const resultFindings = (manifest, tables, locale, uses) => {
|
|
3832
|
-
const parsed = parseManifest(localizeManifest(manifest, tables, locale));
|
|
3833
|
-
if (parsed.ok) return [];
|
|
3834
|
-
const issues = parsed.diagnostic.data["issues"];
|
|
3835
|
-
if (!Array.isArray(issues)) return [];
|
|
3836
|
-
const keyAt = new Map([...uses].flatMap(([key, paths]) => paths.map((where) => [where, key])));
|
|
3837
|
-
return issues.flatMap((issue) => {
|
|
3838
|
-
const where = [...keyAt.keys()].find((candidate) => issue.startsWith(`${candidate}:`));
|
|
3839
|
-
if (where === void 0) return [];
|
|
3840
|
-
const reason = issue.slice(where.length + 1).trim();
|
|
3841
|
-
return [error$1(where, `the ${locale} text of '%${keyAt.get(where)}%' is not valid for this field: ${reason}`)];
|
|
3842
|
-
});
|
|
3843
|
-
};
|
|
3844
|
-
/**
|
|
3845
|
-
* Checks the translations of an extension: `locales/en.json` is required when
|
|
3846
|
-
* the manifest has a `%key%`; every key needs a text in `en`; the text of a
|
|
3847
|
-
* language must fit the limit of its field; files must have a valid shape.
|
|
3848
|
-
* A key the manifest does not use and an unsupported file are warnings.
|
|
3849
|
-
*/
|
|
3850
|
-
const localeFindings = async (input) => {
|
|
3851
|
-
const findings = [];
|
|
3852
|
-
const uses = usesOf(input.manifest);
|
|
3853
|
-
const loaded = await loadTables(input, findings);
|
|
3854
|
-
const english = loaded["en"];
|
|
3855
|
-
const englishFile = localeFile("en");
|
|
3856
|
-
if (uses.size > 0 && english.kind === "absent") findings.push(error$1(englishFile, `${englishFile} is required: the manifest uses %key% placeholders`));
|
|
3857
|
-
if (english.kind === "table") {
|
|
3858
|
-
for (const [key, paths] of uses) if (!Object.hasOwn(english.table, key)) findings.push(error$1(paths[0], `'%${key}%' has no text in ${englishFile}`));
|
|
3859
|
-
}
|
|
3860
|
-
const tables = tablesOf(loaded);
|
|
3861
|
-
for (const locale of EXTENSION_LOCALES) {
|
|
3862
|
-
if (loaded[locale].kind !== "table") continue;
|
|
3863
|
-
findings.push(...resultFindings(input.manifest, tables, locale, uses));
|
|
3864
|
-
}
|
|
3865
|
-
for (const locale of EXTENSION_LOCALES) {
|
|
3866
|
-
const entry = loaded[locale];
|
|
3867
|
-
if (entry.kind !== "table") continue;
|
|
3868
|
-
for (const key of Object.keys(entry.table)) if (!uses.has(key)) findings.push(warning$1(localeFile(locale), `key '${key}' is not used by extension.json`));
|
|
2249
|
+
findings.push({
|
|
2250
|
+
path: file,
|
|
2251
|
+
message: "the file extension must be lowercase"
|
|
2252
|
+
});
|
|
2253
|
+
continue;
|
|
2254
|
+
}
|
|
2255
|
+
if (assetExtensionOf(file) === null) continue;
|
|
2256
|
+
const message = assetProblem(file, await readFile(path.join(root, file)));
|
|
2257
|
+
if (message !== null) findings.push({
|
|
2258
|
+
path: file,
|
|
2259
|
+
message
|
|
2260
|
+
});
|
|
3869
2261
|
}
|
|
3870
|
-
const supported = new Set(EXTENSION_LOCALES.map(localeFile));
|
|
3871
|
-
for (const file of input.files) if (file.startsWith(`${"locales"}/`) && !supported.has(file)) findings.push(warning$1(file, `${file} is not a translation file (${[...supported].join(", ")}): the app ignores it`));
|
|
3872
2262
|
return findings;
|
|
3873
2263
|
};
|
|
3874
|
-
/** Text of a file in `dir`, or `null` when it is not a regular file. */
|
|
3875
|
-
const readFileOrNull = async (dir, file) => {
|
|
3876
|
-
const target = path.join(dir, file);
|
|
3877
|
-
return (await stat(target).catch(() => null))?.isFile() === true ? readFile(target, "utf8") : null;
|
|
3878
|
-
};
|
|
3879
|
-
/** The `en` table of an extension directory; `null` when it is absent or broken. */
|
|
3880
|
-
const readEnglishTable = async (dir) => {
|
|
3881
|
-
const text = await readFileOrNull(dir, localeFile("en"));
|
|
3882
|
-
if (text === null) return null;
|
|
3883
|
-
const parsed = parseLocaleText(text);
|
|
3884
|
-
return parsed.ok ? parsed.table : null;
|
|
3885
|
-
};
|
|
3886
|
-
/** A manifest string in English: the catalog and the checks of publication metadata show this text. */
|
|
3887
|
-
const englishText = (value, table) => table === null ? value : resolveText(value, { en: table }, "en");
|
|
3888
2264
|
|
|
3889
2265
|
//#endregion
|
|
3890
2266
|
//#region packages/extension-tools/src/assets-plugin.ts
|
|
@@ -3909,12 +2285,26 @@ const isBig = (file) => {
|
|
|
3909
2285
|
return false;
|
|
3910
2286
|
}
|
|
3911
2287
|
};
|
|
2288
|
+
/** A style sheet or a `<style>` block of a component (`X.vue?vue&type=style…`): the module ends up as a string in the bundle. */
|
|
2289
|
+
const VUE_STYLE_MODULE = /\.vue\?(?:.*&)?type=style(?:&|$)/;
|
|
2290
|
+
const isStyleModule = (id) => STYLE_FILE.test(cleanId(id)) || VUE_STYLE_MODULE.test(id);
|
|
2291
|
+
/** `<style src="./x.css">` in the component `importer`: the component plugin resolves the file once as it is, to link it to the component, and then imports it as text. */
|
|
2292
|
+
const isStyleSourceOf = (importer, source) => {
|
|
2293
|
+
if (!importer.endsWith(".vue")) return false;
|
|
2294
|
+
try {
|
|
2295
|
+
const escaped = source.replaceAll(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
2296
|
+
return new RegExp(`<style\\b[^>]*\\ssrc\\s*=\\s*(["'])${escaped}\\1`).test(readFileSync(importer, "utf8"));
|
|
2297
|
+
} catch {
|
|
2298
|
+
return false;
|
|
2299
|
+
}
|
|
2300
|
+
};
|
|
3912
2301
|
/**
|
|
3913
2302
|
* Vite in library mode inlines every asset and has no place for a style sheet: the
|
|
3914
|
-
* plugin brings the
|
|
2303
|
+
* plugin brings the documented paths. An image or a font is a data URI up to
|
|
3915
2304
|
* `ASSETS_INLINE_LIMIT` and a file in `assets/` above it (an import with `?url` or
|
|
3916
|
-
* `new URL('./x.png', import.meta.url)`); a style sheet is imported as a
|
|
3917
|
-
* `?inline`, a plain `import './x.css'` is an error with the way out in the
|
|
2305
|
+
* `new URL('./x.png', import.meta.url)`); a style sheet of the author is imported as a
|
|
2306
|
+
* string with `?inline`, a plain `import './x.css'` is an error with the way out in the
|
|
2307
|
+
* message.
|
|
3918
2308
|
*/
|
|
3919
2309
|
const assetsPlugin = (state) => ({
|
|
3920
2310
|
name: "dolphy-ext:assets",
|
|
@@ -3938,13 +2328,13 @@ const assetsPlugin = (state) => ({
|
|
|
3938
2328
|
const file = cleanId(resolved.id);
|
|
3939
2329
|
const query = resolved.id.slice(file.length);
|
|
3940
2330
|
if (STYLE_FILE.test(file)) {
|
|
3941
|
-
if (TEXT_IMPORT.test(query)) return null;
|
|
2331
|
+
if (TEXT_IMPORT.test(query) || isStyleSourceOf(importer, source)) return null;
|
|
3942
2332
|
const message = `'${source}' is imported as a side-effect style sheet, which a bundle cannot carry: import it as text with import css from '${source}?inline' and add it to the page, or ship it as a file in assets/`;
|
|
3943
2333
|
state.problem = message;
|
|
3944
2334
|
return this.error(message);
|
|
3945
2335
|
}
|
|
3946
2336
|
if (!ASSET_FILE.test(file) || MARKED.test(query)) return null;
|
|
3947
|
-
if (
|
|
2337
|
+
if (isStyleModule(importer) || !isBig(file)) return null;
|
|
3948
2338
|
return {
|
|
3949
2339
|
...resolved,
|
|
3950
2340
|
id: `${resolved.id}${query === "" ? "?" : "&"}no-inline`
|
|
@@ -3953,20 +2343,393 @@ const assetsPlugin = (state) => ({
|
|
|
3953
2343
|
});
|
|
3954
2344
|
|
|
3955
2345
|
//#endregion
|
|
3956
|
-
//#region packages/extension-tools/src/
|
|
3957
|
-
/**
|
|
3958
|
-
|
|
3959
|
-
|
|
3960
|
-
|
|
3961
|
-
|
|
3962
|
-
|
|
2346
|
+
//#region packages/extension-tools/src/host-modules.ts
|
|
2347
|
+
/**
|
|
2348
|
+
* Modules the app gives to the client code of an extension: the extension
|
|
2349
|
+
* imports them as usual, the bundle does not contain them, and the window
|
|
2350
|
+
* resolves them to its own instances (one Vue, one Vuetify, one theme).
|
|
2351
|
+
*/
|
|
2352
|
+
const HOST_MODULES = [
|
|
2353
|
+
"vue",
|
|
2354
|
+
"vuetify",
|
|
2355
|
+
"vuetify/components",
|
|
2356
|
+
"vuetify/directives"
|
|
2357
|
+
];
|
|
2358
|
+
/** Style sheets of the app: the window already has them, the import is dropped. */
|
|
2359
|
+
const HOST_STYLES = ["vuetify/styles"];
|
|
2360
|
+
/** Name of the host's loader on `globalThis`: `require(name)` returns a promise of the module. */
|
|
2361
|
+
const HOST_GLOBAL = "__dolphy";
|
|
2362
|
+
/** Modules of the window (`vue`, `vuetify`, `vuetify/…`): the host file is not a place for them, so they are not read. */
|
|
2363
|
+
const WINDOW_SPECIFIER = /^(?:vue|vuetify)(?:\/.*)?$/;
|
|
2364
|
+
const isHostModule = (specifier) => HOST_MODULES.includes(specifier);
|
|
2365
|
+
const isHostStyle = (specifier) => HOST_STYLES.includes(specifier);
|
|
2366
|
+
const child$1 = (node, key) => node[key];
|
|
2367
|
+
const children$1 = (node, key) => node[key];
|
|
2368
|
+
const nameOf$1 = (node) => node["name"] ?? node["value"];
|
|
2369
|
+
const newlines = (text) => "\n".repeat(text.split("\n").length - 1);
|
|
2370
|
+
/**
|
|
2371
|
+
* Replaces the imports of host modules in the generated code of an ES module
|
|
2372
|
+
* with reads from the host's loader. A replacement keeps the number of lines
|
|
2373
|
+
* of the import it replaces, so source maps stay valid line by line.
|
|
2374
|
+
*/
|
|
2375
|
+
const rewriteHostImports = (code) => {
|
|
2376
|
+
const ast = parseAst(code, { lang: "js" }).body;
|
|
2377
|
+
const result = new MagicString(code);
|
|
2378
|
+
let index = 0;
|
|
2379
|
+
for (const statement of ast) {
|
|
2380
|
+
if (statement.type !== "ImportDeclaration") continue;
|
|
2381
|
+
const specifier = child$1(statement, "source")["value"];
|
|
2382
|
+
const source = code.slice(statement.start, statement.end);
|
|
2383
|
+
if (isHostStyle(specifier)) {
|
|
2384
|
+
result.overwrite(statement.start, statement.end, newlines(source));
|
|
2385
|
+
continue;
|
|
2386
|
+
}
|
|
2387
|
+
if (!isHostModule(specifier)) continue;
|
|
2388
|
+
const holder = `${HOST_GLOBAL}_${index}`;
|
|
2389
|
+
index += 1;
|
|
2390
|
+
const lines = [`const ${holder} = await globalThis.${HOST_GLOBAL}.require(${JSON.stringify(specifier)});`];
|
|
2391
|
+
const named = [];
|
|
2392
|
+
for (const item of children$1(statement, "specifiers")) {
|
|
2393
|
+
const local = nameOf$1(child$1(item, "local"));
|
|
2394
|
+
if (item.type === "ImportNamespaceSpecifier") lines.push(`const ${local} = ${holder};`);
|
|
2395
|
+
else if (item.type === "ImportDefaultSpecifier") lines.push(`const ${local} = ${holder}.default;`);
|
|
2396
|
+
else {
|
|
2397
|
+
const imported = nameOf$1(child$1(item, "imported"));
|
|
2398
|
+
named.push(imported === local ? local : `${imported}: ${local}`);
|
|
2399
|
+
}
|
|
2400
|
+
}
|
|
2401
|
+
if (named.length > 0) lines.push(`const { ${named.join(", ")} } = ${holder};`);
|
|
2402
|
+
result.overwrite(statement.start, statement.end, lines.join(" ") + newlines(source));
|
|
2403
|
+
}
|
|
2404
|
+
return result.toString();
|
|
2405
|
+
};
|
|
2406
|
+
/**
|
|
2407
|
+
* Build plugin for browser files: marks the host modules external and, in
|
|
2408
|
+
* the generated bundle, turns their imports into reads from `globalThis.__dolphy`.
|
|
2409
|
+
* The result needs neither an import map nor a CSP change.
|
|
2410
|
+
*/
|
|
2411
|
+
const hostModulesPlugin = () => ({
|
|
2412
|
+
name: "dolphy-ext:host-modules",
|
|
2413
|
+
enforce: "pre",
|
|
2414
|
+
resolveId(source) {
|
|
2415
|
+
if (isHostModule(source) || isHostStyle(source)) return {
|
|
2416
|
+
id: source,
|
|
2417
|
+
external: true
|
|
2418
|
+
};
|
|
2419
|
+
if (WINDOW_SPECIFIER.test(source)) return this.error(`'${source}' is not a module the app gives to extensions (${HOST_MODULES.join(", ")}): import the components from 'vuetify/components'`);
|
|
2420
|
+
return null;
|
|
2421
|
+
},
|
|
2422
|
+
generateBundle(_options, bundle) {
|
|
2423
|
+
for (const file of Object.values(bundle)) if (file.type === "chunk") file.code = rewriteHostImports(file.code);
|
|
2424
|
+
}
|
|
2425
|
+
});
|
|
2426
|
+
|
|
2427
|
+
//#endregion
|
|
2428
|
+
//#region packages/extension-tools/src/presets/react.ts
|
|
2429
|
+
/**
|
|
2430
|
+
* React: `.tsx` and `.jsx` with the automatic JSX runtime, compiled by the
|
|
2431
|
+
* bundler itself (no `@vitejs/plugin-react`: a bundle needs no Fast Refresh).
|
|
2432
|
+
* `react` and `react-dom` are the author's dependencies and go into the bundle:
|
|
2433
|
+
* every extension carries its own copy.
|
|
2434
|
+
*/
|
|
2435
|
+
const reactPreset = {
|
|
2436
|
+
name: "react",
|
|
2437
|
+
extensions: [".tsx", ".jsx"],
|
|
2438
|
+
packages: [
|
|
2439
|
+
"react",
|
|
2440
|
+
"react-dom",
|
|
2441
|
+
"scheduler"
|
|
2442
|
+
],
|
|
2443
|
+
plugins: () => [],
|
|
2444
|
+
options: { oxc: { jsx: {
|
|
2445
|
+
runtime: "automatic",
|
|
2446
|
+
development: false
|
|
2447
|
+
} } }
|
|
2448
|
+
};
|
|
2449
|
+
|
|
2450
|
+
//#endregion
|
|
2451
|
+
//#region packages/extension-tools/src/presets/vue-styles.ts
|
|
2452
|
+
const REGISTRY_SPECIFIER = "dolphy-ext:vue-styles";
|
|
2453
|
+
const REGISTRY_ID = `\0${REGISTRY_SPECIFIER}`;
|
|
2454
|
+
/** `<style>` blocks of components in the request form of `@vitejs/plugin-vue`: `X.vue?vue&type=style&index=0&inline&lang.css` (the file is the `src` of the block for `<style src>`). */
|
|
2455
|
+
const VUE_REQUEST = /[?&]vue(?:&|$)/;
|
|
2456
|
+
const STYLE_TYPE = /[?&]type=style(?:&|$)/;
|
|
2457
|
+
const INLINE = /[?&]inline(?:&|$)/;
|
|
2458
|
+
const DEFAULT_EXPORT = /^export default (".*");?\s*$/s;
|
|
2459
|
+
/**
|
|
2460
|
+
* Module the style blocks report to: puts all of them into one
|
|
2461
|
+
* `<style data-dolphy-ext="<id>">` of the page. The first block of a module
|
|
2462
|
+
* instance removes the tag an earlier instance of the same extension left
|
|
2463
|
+
* (the window imports `client.mjs` again after a reload), so there is one tag
|
|
2464
|
+
* per loaded module.
|
|
2465
|
+
*/
|
|
2466
|
+
const registrySource = (extensionId) => [
|
|
2467
|
+
`const id = ${JSON.stringify(extensionId)};`,
|
|
2468
|
+
"let tag = null;",
|
|
2469
|
+
"export const addStyle = (css) => {",
|
|
2470
|
+
" if (tag === null) {",
|
|
2471
|
+
" for (const old of document.head.querySelectorAll('style[data-dolphy-ext]')) {",
|
|
2472
|
+
" if (old.getAttribute('data-dolphy-ext') === id) old.remove();",
|
|
2473
|
+
" }",
|
|
2474
|
+
" tag = document.createElement('style');",
|
|
2475
|
+
" tag.setAttribute('data-dolphy-ext', id);",
|
|
2476
|
+
" document.head.append(tag);",
|
|
2477
|
+
" }",
|
|
2478
|
+
" tag.append(css);",
|
|
2479
|
+
"};",
|
|
2480
|
+
""
|
|
2481
|
+
].join("\n");
|
|
2482
|
+
/**
|
|
2483
|
+
* Style blocks of single-file components go to the page as text: a bundle has
|
|
2484
|
+
* no style file, so every block, `scoped` or not, is a string the module
|
|
2485
|
+
* adds to the tag of the extension when it is evaluated.
|
|
2486
|
+
*/
|
|
2487
|
+
const vueStylesPlugin = (extensionId) => ({
|
|
2488
|
+
name: "dolphy-ext:vue-styles",
|
|
2489
|
+
enforce: "post",
|
|
2490
|
+
resolveId(source) {
|
|
2491
|
+
return source === REGISTRY_SPECIFIER ? REGISTRY_ID : null;
|
|
2492
|
+
},
|
|
2493
|
+
load(id) {
|
|
2494
|
+
return id === REGISTRY_ID ? registrySource(extensionId) : null;
|
|
2495
|
+
},
|
|
2496
|
+
transform(code, id) {
|
|
2497
|
+
if (!VUE_REQUEST.test(id) || !STYLE_TYPE.test(id) || !INLINE.test(id)) return null;
|
|
2498
|
+
const exported = DEFAULT_EXPORT.exec(code)?.[1];
|
|
2499
|
+
if (exported === void 0) return this.error(`'${id.split("?")[0]}': a style block of a component did not become a string`);
|
|
2500
|
+
return {
|
|
2501
|
+
code: `import { addStyle } from ${JSON.stringify(REGISTRY_SPECIFIER)};\nconst css = ${exported};\naddStyle(css);\nexport default css;\n`,
|
|
2502
|
+
map: null
|
|
2503
|
+
};
|
|
2504
|
+
}
|
|
2505
|
+
});
|
|
2506
|
+
|
|
2507
|
+
//#endregion
|
|
2508
|
+
//#region packages/extension-tools/src/presets/vuetify-template.ts
|
|
2509
|
+
const COMPONENTS = "vuetify/components";
|
|
2510
|
+
const DIRECTIVES = "vuetify/directives";
|
|
2511
|
+
/** Exports of `vuetify/directives`: a template names them as `v-ripple`, `v-click-outside`. */
|
|
2512
|
+
const DIRECTIVE_EXPORTS = [
|
|
2513
|
+
"ClickOutside",
|
|
2514
|
+
"Intersect",
|
|
2515
|
+
"Mutate",
|
|
2516
|
+
"Resize",
|
|
2517
|
+
"Ripple",
|
|
2518
|
+
"Scroll",
|
|
2519
|
+
"Tooltip",
|
|
2520
|
+
"Touch"
|
|
3963
2521
|
];
|
|
2522
|
+
const VUE_FILE$1 = /\.vue(?:\?|$)/;
|
|
2523
|
+
/** `_resolveComponent("v-btn")`: the call of the template compiler for a tag that is not a binding of `<script setup>`. */
|
|
2524
|
+
const RESOLVE_CALL = /\b_resolve(Component|Directive)\(("(?:[^"\\]|\\.)*")\)/g;
|
|
2525
|
+
const pascalCase = (name) => name.split("-").map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join("");
|
|
2526
|
+
/** `v-btn` and `VBtn` → `VBtn`; the other tags are not Vuetify's. */
|
|
2527
|
+
const vuetifyComponentOf = (tag) => {
|
|
2528
|
+
if (/^v-[a-z][a-z0-9-]*$/.test(tag)) return pascalCase(tag);
|
|
2529
|
+
return /^V[A-Z][A-Za-z0-9]*$/.test(tag) ? tag : null;
|
|
2530
|
+
};
|
|
2531
|
+
/** `ripple` and `click-outside` → `Ripple` and `ClickOutside`. */
|
|
2532
|
+
const vuetifyDirectiveOf = (name) => {
|
|
2533
|
+
const exported = pascalCase(name);
|
|
2534
|
+
return DIRECTIVE_EXPORTS.includes(exported) ? exported : null;
|
|
2535
|
+
};
|
|
2536
|
+
/** `import { VBtn as _dolphy_VBtn } from "vuetify/components";` (empty for no names). */
|
|
2537
|
+
const importLine = (names, source) => names.size === 0 ? "" : `import { ${[...names].map((name) => `${name} as _dolphy_${name}`).join(", ")} } from ${JSON.stringify(source)};`;
|
|
2538
|
+
/**
|
|
2539
|
+
* Turns the tags of Vuetify in the templates (`<v-btn>`) into imports from
|
|
2540
|
+
* `vuetify/components`, and the directives (`v-ripple`) into imports from
|
|
2541
|
+
* `vuetify/directives`. Both are host modules: the window gives its own Vuetify
|
|
2542
|
+
* and there is no copy in the bundle. A name the module does not export falls
|
|
2543
|
+
* back to Vue's own lookup, which reports the unknown component.
|
|
2544
|
+
*/
|
|
2545
|
+
const vuetifyTemplatePlugin = () => ({
|
|
2546
|
+
name: "dolphy-ext:vuetify-template",
|
|
2547
|
+
enforce: "post",
|
|
2548
|
+
transform(code, id) {
|
|
2549
|
+
if (!VUE_FILE$1.test(id) || !code.includes("_resolve")) return null;
|
|
2550
|
+
const result = new MagicString(code);
|
|
2551
|
+
const components = /* @__PURE__ */ new Set();
|
|
2552
|
+
const directives = /* @__PURE__ */ new Set();
|
|
2553
|
+
for (const match of code.matchAll(RESOLVE_CALL)) {
|
|
2554
|
+
const [call, kind, literal] = match;
|
|
2555
|
+
if (kind === void 0 || literal === void 0) continue;
|
|
2556
|
+
const name = JSON.parse(literal);
|
|
2557
|
+
const isComponent = kind === "Component";
|
|
2558
|
+
const exported = isComponent ? vuetifyComponentOf(name) : vuetifyDirectiveOf(name);
|
|
2559
|
+
if (exported === null) continue;
|
|
2560
|
+
(isComponent ? components : directives).add(exported);
|
|
2561
|
+
result.overwrite(match.index, match.index + call.length, `(_dolphy_${exported} ?? ${call})`);
|
|
2562
|
+
}
|
|
2563
|
+
const imports = [importLine(components, COMPONENTS), importLine(directives, DIRECTIVES)].filter((line) => line !== "");
|
|
2564
|
+
if (imports.length === 0) return null;
|
|
2565
|
+
result.prepend(`${imports.join(" ")} `);
|
|
2566
|
+
return {
|
|
2567
|
+
code: result.toString(),
|
|
2568
|
+
map: result.generateMap()
|
|
2569
|
+
};
|
|
2570
|
+
}
|
|
2571
|
+
});
|
|
2572
|
+
|
|
2573
|
+
//#endregion
|
|
2574
|
+
//#region packages/extension-tools/src/presets/vue.ts
|
|
2575
|
+
/**
|
|
2576
|
+
* `@vitejs/plugin-vue` takes "production" from `NODE_ENV` of the process, and a
|
|
2577
|
+
* build under `NODE_ENV=test` or `development` would write the development
|
|
2578
|
+
* form of the components (`__file` with a path on the build machine, a render
|
|
2579
|
+
* function that reads `$setup`). A bundle is always the production form.
|
|
2580
|
+
*/
|
|
2581
|
+
const alwaysProduction = (plugin) => {
|
|
2582
|
+
const { configResolved } = plugin;
|
|
2583
|
+
if (typeof configResolved !== "function") throw new Error("@vitejs/plugin-vue has no configResolved hook");
|
|
2584
|
+
plugin.configResolved = function resolved(config) {
|
|
2585
|
+
return configResolved.call(this, {
|
|
2586
|
+
...config,
|
|
2587
|
+
isProduction: true
|
|
2588
|
+
});
|
|
2589
|
+
};
|
|
2590
|
+
return plugin;
|
|
2591
|
+
};
|
|
2592
|
+
/**
|
|
2593
|
+
* Vue single-file components (`.vue`): `<script setup lang="ts">`,
|
|
2594
|
+
* `<template>` with Vuetify components as `<v-btn>`, `<style>` and
|
|
2595
|
+
* `<style scoped>`. Vue and Vuetify are the window's: the bundle imports them
|
|
2596
|
+
* from the host, like any other client code.
|
|
2597
|
+
*/
|
|
2598
|
+
const vuePreset = {
|
|
2599
|
+
name: "vue",
|
|
2600
|
+
extensions: [".vue"],
|
|
2601
|
+
packages: [
|
|
2602
|
+
"vue",
|
|
2603
|
+
"@vue",
|
|
2604
|
+
"vuetify",
|
|
2605
|
+
"@vuetify"
|
|
2606
|
+
],
|
|
2607
|
+
plugins: ({ extensionId }) => [
|
|
2608
|
+
alwaysProduction(vue({ customElement: true })),
|
|
2609
|
+
vuetifyTemplatePlugin(),
|
|
2610
|
+
vueStylesPlugin(extensionId)
|
|
2611
|
+
]
|
|
2612
|
+
};
|
|
2613
|
+
|
|
2614
|
+
//#endregion
|
|
2615
|
+
//#region packages/extension-tools/src/presets/index.ts
|
|
2616
|
+
const PRESETS = [vuePreset, reactPreset];
|
|
2617
|
+
/** The window is a Vue application, so this preset is always on. */
|
|
2618
|
+
const ALWAYS_ON = vuePreset.name;
|
|
2619
|
+
const FRAMEWORK_NAMES = PRESETS.map((preset) => preset.name);
|
|
2620
|
+
const DEFAULT_FRAMEWORKS = [ALWAYS_ON];
|
|
2621
|
+
const isFramework = (name) => FRAMEWORK_NAMES.includes(name);
|
|
2622
|
+
/** `frameworks` of the config as it is used: no repeats, the always-on preset first. */
|
|
2623
|
+
const normalizeFrameworks = (names) => [.../* @__PURE__ */ new Set([ALWAYS_ON, ...names])];
|
|
2624
|
+
const presetsOf = (frameworks) => PRESETS.filter((preset) => frameworks.includes(preset.name));
|
|
2625
|
+
/**
|
|
2626
|
+
* Modules of the runtime packages of the frameworks in `frameworks`. The server
|
|
2627
|
+
* file shares `src/index.ts` with the client file and must not keep what it
|
|
2628
|
+
* imports of them (a component defined at the top level of `src/index.ts` is not
|
|
2629
|
+
* code of the server), so their top level does not count as code with effects
|
|
2630
|
+
* there: tree shaking drops the library together with the component.
|
|
2631
|
+
*/
|
|
2632
|
+
const frameworkPackagePattern = (frameworks) => new RegExp(`[\\\\/]node_modules[\\\\/](?:${presetsOf(frameworks).flatMap((preset) => preset.packages).join("|")})[\\\\/]`);
|
|
2633
|
+
const hasJsx = (node) => {
|
|
2634
|
+
if (typeof node !== "object" || node === null) return false;
|
|
2635
|
+
if (Array.isArray(node)) return node.some(hasJsx);
|
|
2636
|
+
const { type } = node;
|
|
2637
|
+
if (type === "JSXElement" || type === "JSXFragment") return true;
|
|
2638
|
+
return Object.values(node).some(hasJsx);
|
|
2639
|
+
};
|
|
2640
|
+
const SOURCE_LANG = {
|
|
2641
|
+
".tsx": "tsx",
|
|
2642
|
+
".jsx": "jsx"
|
|
2643
|
+
};
|
|
2644
|
+
/**
|
|
2645
|
+
* A source file of a preset that is not on, with markup in it: without the
|
|
2646
|
+
* check the bundler would fail on an import of the runtime of a framework
|
|
2647
|
+
* that is not installed, with no word about the config.
|
|
2648
|
+
*/
|
|
2649
|
+
const disabledPresetGuard = (disabled) => ({
|
|
2650
|
+
name: "dolphy-ext:disabled-presets",
|
|
2651
|
+
enforce: "pre",
|
|
2652
|
+
transform(code, id) {
|
|
2653
|
+
const file = id.split("?")[0] ?? id;
|
|
2654
|
+
const extension = extname(file);
|
|
2655
|
+
const lang = SOURCE_LANG[extension];
|
|
2656
|
+
if (lang === void 0) return null;
|
|
2657
|
+
const owner = disabled.find((preset) => preset.extensions.includes(extension));
|
|
2658
|
+
if (owner === void 0 || !hasJsx(parseAst(code, { lang }).body)) return null;
|
|
2659
|
+
return this.error(`'${file}' contains JSX, which needs the '${owner.name}' framework: add "frameworks": ["${owner.name}"] to dolphy-ext.config.json`);
|
|
2660
|
+
}
|
|
2661
|
+
});
|
|
2662
|
+
/** Plugins of the client build: those of the presets in `frameworks` and the check for the files of the others. */
|
|
2663
|
+
const presetPlugins = async (frameworks, context) => {
|
|
2664
|
+
const enabled = presetsOf(frameworks);
|
|
2665
|
+
const disabled = PRESETS.filter((preset) => !enabled.includes(preset));
|
|
2666
|
+
const groups = await Promise.all(enabled.map((preset) => preset.plugins(context)));
|
|
2667
|
+
return [disabledPresetGuard(disabled), ...groups.flat()];
|
|
2668
|
+
};
|
|
2669
|
+
/** Vite configuration of the client build with the additions of the presets in `frameworks`. */
|
|
2670
|
+
const presetConfig = (frameworks, config) => presetsOf(frameworks).reduce((merged, preset) => preset.options === void 0 ? merged : mergeConfig(merged, preset.options), config);
|
|
2671
|
+
|
|
2672
|
+
//#endregion
|
|
2673
|
+
//#region packages/extension-tools/src/server-vue.ts
|
|
2674
|
+
const VUE_FILE = /\.vue(?:\?|$)/;
|
|
2675
|
+
const STUB_PREFIX = "\0dolphy-ext:server-vue:";
|
|
2676
|
+
const MARK = "dolphy-ext:server-vue:";
|
|
2677
|
+
/** The stub as it stays in the code of a chunk (a comment with the module id may precede it). */
|
|
2678
|
+
const STUB_LITERAL = new RegExp(`"${MARK}(?:[^"\\\\]|\\\\.)*"`);
|
|
2679
|
+
/**
|
|
2680
|
+
* Boundary of a Node bundle: a `.vue` file is a component of the client part.
|
|
2681
|
+
* The server file is built without the compiler of components, and
|
|
2682
|
+
* `src/index.ts` may import a component for `client`, so the file is replaced
|
|
2683
|
+
* by a stub that tree shaking drops with the rest of the client code. A stub
|
|
2684
|
+
* that is still in the output means the server code uses the component: that
|
|
2685
|
+
* is the error, with the file in the message.
|
|
2686
|
+
*/
|
|
2687
|
+
const serverVuePlugin = (root, output, state) => ({
|
|
2688
|
+
name: "dolphy-ext:server-vue",
|
|
2689
|
+
enforce: "pre",
|
|
2690
|
+
async resolveId(source, importer, options) {
|
|
2691
|
+
if (!VUE_FILE.test(source)) return null;
|
|
2692
|
+
const resolved = await this.resolve(source, importer, {
|
|
2693
|
+
...options,
|
|
2694
|
+
skipSelf: true
|
|
2695
|
+
});
|
|
2696
|
+
if (resolved === null || resolved.external === true) return null;
|
|
2697
|
+
return {
|
|
2698
|
+
id: `${STUB_PREFIX}${resolved.id}`,
|
|
2699
|
+
moduleSideEffects: false
|
|
2700
|
+
};
|
|
2701
|
+
},
|
|
2702
|
+
load(id) {
|
|
2703
|
+
return id.startsWith(STUB_PREFIX) ? `export default ${JSON.stringify(`${MARK}${id.slice(23)}`)};\n` : null;
|
|
2704
|
+
},
|
|
2705
|
+
generateBundle(_options, bundle) {
|
|
2706
|
+
for (const chunk of Object.values(bundle)) {
|
|
2707
|
+
if (chunk.type !== "chunk") continue;
|
|
2708
|
+
const literal = STUB_LITERAL.exec(chunk.code)?.[0];
|
|
2709
|
+
if (literal === void 0) continue;
|
|
2710
|
+
const file = JSON.parse(literal).slice(22).split("?")[0] ?? "";
|
|
2711
|
+
const message = `${output} uses '${path.relative(root, file)}': a .vue component belongs to the client part, keep it out of the code of 'server'`;
|
|
2712
|
+
state.problem = message;
|
|
2713
|
+
this.error(message);
|
|
2714
|
+
}
|
|
2715
|
+
}
|
|
2716
|
+
});
|
|
2717
|
+
|
|
2718
|
+
//#endregion
|
|
2719
|
+
//#region packages/extension-tools/src/analyze.ts
|
|
3964
2720
|
const SOURCE_EXTENSIONS = [
|
|
3965
2721
|
".ts",
|
|
3966
2722
|
".mts",
|
|
2723
|
+
".tsx",
|
|
3967
2724
|
".js",
|
|
3968
|
-
".mjs"
|
|
2725
|
+
".mjs",
|
|
2726
|
+
".jsx"
|
|
3969
2727
|
];
|
|
2728
|
+
const langOf = (file) => {
|
|
2729
|
+
if (/\.tsx$/.test(file)) return "tsx";
|
|
2730
|
+
if (/\.jsx$/.test(file)) return "jsx";
|
|
2731
|
+
return /\.[mc]?ts$/.test(file) ? "ts" : "js";
|
|
2732
|
+
};
|
|
3970
2733
|
const MAX_HOPS = 16;
|
|
3971
2734
|
const child = (node, key) => node[key];
|
|
3972
2735
|
const children = (node, key) => node[key];
|
|
@@ -3984,7 +2747,7 @@ const unwrap = (node) => {
|
|
|
3984
2747
|
};
|
|
3985
2748
|
/** Name from an identifier or string literal (`{ a: 1 }`, `{ 'a-b': 1 }`, `export { x as 'y' }`). */
|
|
3986
2749
|
const nameOf = (node) => String(node.type === "Identifier" ? node.name : node.value);
|
|
3987
|
-
const isFile = async (file) => (await stat(file).catch(() => null))?.isFile() === true;
|
|
2750
|
+
const isFile$1 = async (file) => (await stat(file).catch(() => null))?.isFile() === true;
|
|
3988
2751
|
/** Relative import → source file; packages and `node:*` are not analyzed. */
|
|
3989
2752
|
const resolveRelative = async (from, specifier) => {
|
|
3990
2753
|
if (!specifier.startsWith(".")) return null;
|
|
@@ -3995,28 +2758,13 @@ const resolveRelative = async (from, specifier) => {
|
|
|
3995
2758
|
...SOURCE_EXTENSIONS.map((extension) => `${withoutJs}${extension}`),
|
|
3996
2759
|
...SOURCE_EXTENSIONS.map((extension) => path.join(base, `index${extension}`))
|
|
3997
2760
|
];
|
|
3998
|
-
for (const candidate of candidates) if (await isFile(candidate)) return candidate;
|
|
2761
|
+
for (const candidate of candidates) if (await isFile$1(candidate)) return candidate;
|
|
3999
2762
|
return null;
|
|
4000
2763
|
};
|
|
4001
|
-
const propertyKeys = (literal) => {
|
|
4002
|
-
const keys = [];
|
|
4003
|
-
for (const property of children(literal, "properties")) {
|
|
4004
|
-
if (property.type !== "Property") return { reason: "it uses a spread or a computed entry" };
|
|
4005
|
-
const key = child(property, "key");
|
|
4006
|
-
if (!(property.computed !== true || key.type === "Literal")) return { reason: "it uses a computed key" };
|
|
4007
|
-
keys.push({
|
|
4008
|
-
name: nameOf(key),
|
|
4009
|
-
start: property.start,
|
|
4010
|
-
end: property.end
|
|
4011
|
-
});
|
|
4012
|
-
}
|
|
4013
|
-
return { keys };
|
|
4014
|
-
};
|
|
4015
2764
|
/**
|
|
4016
|
-
* Static analysis of `src/index.ts` without executing author code: whether
|
|
4017
|
-
* `
|
|
4018
|
-
*
|
|
4019
|
-
* re-exports; keys must be given as an object literal.
|
|
2765
|
+
* Static analysis of `src/index.ts` without executing author code: whether it
|
|
2766
|
+
* exports `server` and `client`. Exports are found through local constants
|
|
2767
|
+
* and relative re-exports.
|
|
4020
2768
|
*/
|
|
4021
2769
|
const analyzeIndex = async (indexFile) => {
|
|
4022
2770
|
const cache = /* @__PURE__ */ new Map();
|
|
@@ -4024,7 +2772,7 @@ const analyzeIndex = async (indexFile) => {
|
|
|
4024
2772
|
let parsed = cache.get(file);
|
|
4025
2773
|
if (parsed === void 0) {
|
|
4026
2774
|
parsed = readFile(file, "utf8").then((source) => {
|
|
4027
|
-
const lang =
|
|
2775
|
+
const lang = langOf(file);
|
|
4028
2776
|
return {
|
|
4029
2777
|
file,
|
|
4030
2778
|
source,
|
|
@@ -4125,68 +2873,11 @@ const analyzeIndex = async (indexFile) => {
|
|
|
4125
2873
|
return null;
|
|
4126
2874
|
}
|
|
4127
2875
|
};
|
|
4128
|
-
const
|
|
4129
|
-
const resolved = await walk.export(indexFile, name, 0);
|
|
4130
|
-
if (resolved === null) return { status: "missing" };
|
|
4131
|
-
if (resolved.kind === "opaque") return {
|
|
4132
|
-
status: "opaque",
|
|
4133
|
-
reason: resolved.reason
|
|
4134
|
-
};
|
|
4135
|
-
if (resolved.node.type !== "ObjectExpression") return {
|
|
4136
|
-
status: "opaque",
|
|
4137
|
-
reason: "it is not an object literal"
|
|
4138
|
-
};
|
|
4139
|
-
const found = propertyKeys(resolved.node);
|
|
4140
|
-
if ("reason" in found) return {
|
|
4141
|
-
status: "opaque",
|
|
4142
|
-
reason: found.reason
|
|
4143
|
-
};
|
|
4144
|
-
return {
|
|
4145
|
-
status: "found",
|
|
4146
|
-
site: {
|
|
4147
|
-
file: resolved.parsed.file,
|
|
4148
|
-
source: resolved.parsed.source,
|
|
4149
|
-
start: resolved.node.start,
|
|
4150
|
-
end: resolved.node.end,
|
|
4151
|
-
keys: found.keys
|
|
4152
|
-
}
|
|
4153
|
-
};
|
|
4154
|
-
};
|
|
4155
|
-
const [views, panels, widgets, markdown] = await Promise.all(RECORDS.map(recordOf));
|
|
4156
|
-
const hasHost = await walk.export(indexFile, "host", 0) !== null;
|
|
2876
|
+
const [hasServer, hasClient] = await Promise.all([walk.export(indexFile, "server", 0).then((found) => found !== null), walk.export(indexFile, "client", 0).then((found) => found !== null)]);
|
|
4157
2877
|
return {
|
|
4158
2878
|
files: [...cache.keys()],
|
|
4159
|
-
|
|
4160
|
-
|
|
4161
|
-
views,
|
|
4162
|
-
panels,
|
|
4163
|
-
widgets,
|
|
4164
|
-
markdown
|
|
4165
|
-
}
|
|
4166
|
-
};
|
|
4167
|
-
};
|
|
4168
|
-
/**
|
|
4169
|
-
* Source of `site.file` with the entry literal trimmed to `keep`: the code of the
|
|
4170
|
-
* discarded keys does not reach the bundle, and neither do their dependencies.
|
|
4171
|
-
* The source map describes the edit, so inline maps of watch builds point at the
|
|
4172
|
-
* author's lines.
|
|
4173
|
-
*/
|
|
4174
|
-
const pruneRecords = (source, sites) => {
|
|
4175
|
-
const edit = new MagicString(source);
|
|
4176
|
-
for (const { site, keep } of sites) {
|
|
4177
|
-
let cursor = site.start + 1;
|
|
4178
|
-
let isFirst = true;
|
|
4179
|
-
for (const key of site.keys) {
|
|
4180
|
-
if (!keep.has(key.name)) continue;
|
|
4181
|
-
if (cursor < key.start) edit.overwrite(cursor, key.start, isFirst ? " " : ", ");
|
|
4182
|
-
cursor = key.end;
|
|
4183
|
-
isFirst = false;
|
|
4184
|
-
}
|
|
4185
|
-
if (cursor < site.end - 1) edit.overwrite(cursor, site.end - 1, " ");
|
|
4186
|
-
}
|
|
4187
|
-
return {
|
|
4188
|
-
code: edit.toString(),
|
|
4189
|
-
map: edit.generateMap({ hires: "boundary" })
|
|
2879
|
+
hasServer,
|
|
2880
|
+
hasClient
|
|
4190
2881
|
};
|
|
4191
2882
|
};
|
|
4192
2883
|
const isRecord$1 = (value) => typeof value === "object" && value !== null;
|
|
@@ -4225,110 +2916,26 @@ const stripBareImports = (code, isDropped) => {
|
|
|
4225
2916
|
|
|
4226
2917
|
//#endregion
|
|
4227
2918
|
//#region packages/extension-tools/src/shim.ts
|
|
4228
|
-
/** SDK subpath with element registration and dispatch; resolved from the author's project. */
|
|
4229
|
-
const SDK_RUNTIME = "@dolphy-app/extension-sdk/runtime";
|
|
4230
2919
|
/** Path of the virtual shim: the build's `lib.entry` and the plugin's `resolveId`; there is no file on disk. */
|
|
4231
2920
|
const shimEntry = (project, output) => path.join(project.root, "src", `.dolphy-ext-${output.output.replaceAll(/[^\w.-]/g, "-")}.js`);
|
|
4232
|
-
|
|
4233
|
-
|
|
4234
|
-
const wantedRecords = (output) => output.kind === "host" ? {
|
|
4235
|
-
views: [],
|
|
4236
|
-
panels: [],
|
|
4237
|
-
widgets: [],
|
|
4238
|
-
markdown: []
|
|
4239
|
-
} : {
|
|
4240
|
-
views: output.views.map((view) => view.id),
|
|
4241
|
-
panels: output.panels,
|
|
4242
|
-
widgets: output.widgets,
|
|
4243
|
-
markdown: output.languages
|
|
4244
|
-
};
|
|
4245
|
-
/** Exports of `src/index.ts` the file is built from (for messages). */
|
|
4246
|
-
const exportsOf = (output) => output.kind === "host" ? ["host"] : RECORDS.filter((name) => wantedRecords(output)[name].length > 0);
|
|
2921
|
+
/** Export of `src/index.ts` the output file is built from. */
|
|
2922
|
+
const exportOf = (output) => output.kind;
|
|
4247
2923
|
/**
|
|
4248
|
-
* Source of the output file's virtual shim:
|
|
4249
|
-
*
|
|
4250
|
-
* `export default` result is the module the app loads (`ExtensionModule`,
|
|
4251
|
-
* panel module, renderer module); a file of this kind registers elements.
|
|
2924
|
+
* Source of the output file's virtual shim: the file's only export is the
|
|
2925
|
+
* entry the app loads (`server` or `client`).
|
|
4252
2926
|
*/
|
|
4253
|
-
const shimSource = (output, indexFile) => {
|
|
4254
|
-
if (output.kind === "host") return `export { host as default } from ${quote(indexFile)};\n`;
|
|
4255
|
-
const runtime = [];
|
|
4256
|
-
const body = [];
|
|
4257
|
-
const modules = [];
|
|
4258
|
-
if (output.views.length > 0) {
|
|
4259
|
-
runtime.push("registerAnswerView");
|
|
4260
|
-
for (const view of output.views) body.push(`registerAnswerView(${quote(view.element)}, views[${quote(view.id)}]);`);
|
|
4261
|
-
}
|
|
4262
|
-
const table = (name, keys) => `{ ${keys.map((key) => `${quote(key)}: ${name}[${quote(key)}]`).join(", ")} }`;
|
|
4263
|
-
if (output.panels.length > 0) {
|
|
4264
|
-
runtime.push("dispatchPanels");
|
|
4265
|
-
modules.push(`...dispatchPanels(${table("panels", output.panels)})`);
|
|
4266
|
-
}
|
|
4267
|
-
if (output.widgets.length > 0) {
|
|
4268
|
-
runtime.push("dispatchWidgets");
|
|
4269
|
-
modules.push(`...dispatchWidgets(${table("widgets", output.widgets)})`);
|
|
4270
|
-
}
|
|
4271
|
-
if (output.languages.length > 0) {
|
|
4272
|
-
runtime.push("dispatchMarkdown");
|
|
4273
|
-
modules.push(`...dispatchMarkdown(${table("markdown", output.languages)})`);
|
|
4274
|
-
}
|
|
4275
|
-
const lines = [
|
|
4276
|
-
`import { ${RECORDS.filter((name) => wantedRecords(output)[name].length > 0).join(", ")} } from ${quote(indexFile)};`,
|
|
4277
|
-
`import { ${runtime.join(", ")} } from ${quote(SDK_RUNTIME)};`,
|
|
4278
|
-
...body
|
|
4279
|
-
];
|
|
4280
|
-
if (modules.length > 0) lines.push(`export default { ${modules.join(", ")} };`);
|
|
4281
|
-
return `${lines.join("\n")}\n`;
|
|
4282
|
-
};
|
|
4283
|
-
/** Mismatches between `src/index.ts` and the manifest; empty — everything agrees. */
|
|
4284
|
-
const findMismatches = (project, analysis, indexSource, root) => {
|
|
4285
|
-
const problems = [];
|
|
4286
|
-
if (project.host !== null && !analysis.hasHost) problems.push(`${indexSource} does not export 'host' for ${project.host.output}: add export const host = defineExtension({ … })`);
|
|
4287
|
-
const declared = {
|
|
4288
|
-
views: project.manifest.contributes.exerciseTypes.map((type) => type.id),
|
|
4289
|
-
panels: project.manifest.contributes.panels.map((panel) => panel.id),
|
|
4290
|
-
widgets: project.manifest.contributes.widgets.map((widget) => widget.id),
|
|
4291
|
-
markdown: project.manifest.contributes.markdownRenderers.map((entry) => entry.language)
|
|
4292
|
-
};
|
|
4293
|
-
const what = {
|
|
4294
|
-
views: "exercise type",
|
|
4295
|
-
panels: "panel",
|
|
4296
|
-
widgets: "widget",
|
|
4297
|
-
markdown: "markdown renderer"
|
|
4298
|
-
};
|
|
4299
|
-
const fix = {
|
|
4300
|
-
views: "defineAnswerView(…)",
|
|
4301
|
-
panels: "defineExtensionPanel(…)",
|
|
4302
|
-
widgets: "defineExtensionWidget(…)",
|
|
4303
|
-
markdown: "defineMarkdownRenderer(…)"
|
|
4304
|
-
};
|
|
4305
|
-
for (const name of RECORDS) {
|
|
4306
|
-
const result = analysis.records[name];
|
|
4307
|
-
const ids = declared[name];
|
|
4308
|
-
if (result.status === "opaque") problems.push(`${indexSource}: cannot read the keys of '${name}' (${result.reason}); declare it as an object literal with static keys`);
|
|
4309
|
-
else if (result.status === "missing") {
|
|
4310
|
-
if (ids.length > 0) problems.push(`${indexSource} does not export '${name}', but extension.json declares ${what[name]} ${ids.map((id) => `'${id}'`).join(", ")}: add export const ${name} = { ${quote(ids[0])}: ${fix[name]} }`);
|
|
4311
|
-
} else {
|
|
4312
|
-
const file = path.relative(root, result.site.file).split(path.sep).join("/");
|
|
4313
|
-
const keys = result.site.keys.map((key) => key.name);
|
|
4314
|
-
for (const id of ids) if (!keys.includes(id)) problems.push(`${file}: '${name}' has no entry '${id}', but extension.json declares ${what[name]} '${id}'`);
|
|
4315
|
-
for (const key of keys) if (!ids.includes(key)) problems.push(`${file}: '${name}' has entry '${key}', but extension.json declares no ${what[name]} '${key}'`);
|
|
4316
|
-
}
|
|
4317
|
-
}
|
|
4318
|
-
return problems;
|
|
4319
|
-
};
|
|
2927
|
+
const shimSource = (output, indexFile) => `export { ${exportOf(output)} } from ${JSON.stringify(indexFile)};\n`;
|
|
4320
2928
|
const isNodeSpecifier = (specifier, external) => specifier.startsWith("node:") || builtinModules.includes(specifier) || external.some((name) => specifier === name || specifier.startsWith(`${name}/`));
|
|
4321
2929
|
/**
|
|
4322
|
-
* Build plugin for a single output file: the virtual shim,
|
|
4323
|
-
* `src/index.ts`
|
|
4324
|
-
*
|
|
2930
|
+
* Build plugin for a single output file: the virtual shim, the check that
|
|
2931
|
+
* `src/index.ts` still exports what the file is built from, and the boundary
|
|
2932
|
+
* rules: the client file takes no Node.js modules, the server file no `vue`
|
|
2933
|
+
* or `vuetify`.
|
|
4325
2934
|
*/
|
|
4326
2935
|
const shimPlugin = ({ project, output, state }) => {
|
|
4327
|
-
const indexSource = project.indexSource;
|
|
4328
2936
|
const root = realpathSync(project.root);
|
|
4329
|
-
const indexFile = path.join(root, indexSource);
|
|
2937
|
+
const indexFile = path.join(root, project.indexSource);
|
|
4330
2938
|
const shimId = shimEntry(project, output);
|
|
4331
|
-
let sites = [];
|
|
4332
2939
|
const fail = (context, message) => {
|
|
4333
2940
|
state.problem = message;
|
|
4334
2941
|
return context.error(message);
|
|
@@ -4338,44 +2945,35 @@ const shimPlugin = ({ project, output, state }) => {
|
|
|
4338
2945
|
enforce: "pre",
|
|
4339
2946
|
resolveId(specifier) {
|
|
4340
2947
|
if (specifier === shimId) return shimId;
|
|
4341
|
-
if (output.kind === "
|
|
2948
|
+
if (output.kind === "client" && isNodeSpecifier(specifier, project.external)) return {
|
|
4342
2949
|
id: specifier,
|
|
4343
2950
|
external: true
|
|
4344
2951
|
};
|
|
4345
2952
|
return null;
|
|
4346
2953
|
},
|
|
4347
2954
|
generateBundle(_options, bundle) {
|
|
4348
|
-
if (output.kind !== "browser") return;
|
|
4349
2955
|
for (const chunk of Object.values(bundle)) {
|
|
4350
2956
|
if (chunk.type !== "chunk") continue;
|
|
4351
|
-
|
|
4352
|
-
|
|
4353
|
-
|
|
2957
|
+
if (output.kind === "server") {
|
|
2958
|
+
chunk.code = stripBareImports(chunk.code, (specifier) => WINDOW_SPECIFIER.test(specifier));
|
|
2959
|
+
const leaked = [...importsOf(chunk.code)].find((specifier) => WINDOW_SPECIFIER.test(specifier));
|
|
2960
|
+
if (leaked !== void 0) fail(this, `${output.output} imports '${leaked}': vue and vuetify belong to the client part, keep them out of the code of 'server'`);
|
|
2961
|
+
} else {
|
|
2962
|
+
chunk.code = stripBareImports(chunk.code, (specifier) => isNodeSpecifier(specifier, project.external));
|
|
2963
|
+
const leaked = [...importsOf(chunk.code)].find((specifier) => isNodeSpecifier(specifier, project.external));
|
|
2964
|
+
if (leaked !== void 0) fail(this, `${output.output} imports '${leaked}': Node.js modules and the 'external' packages of dolphy-ext.config.json belong to the server part, keep them out of the code of 'client'`);
|
|
2965
|
+
}
|
|
4354
2966
|
}
|
|
4355
2967
|
},
|
|
4356
2968
|
async buildStart() {
|
|
4357
2969
|
this.addWatchFile(indexFile);
|
|
4358
|
-
const analysis = await analyzeIndex(indexFile).catch((error) => fail(this, `${indexSource} cannot be read: ${String(error)}`));
|
|
2970
|
+
const analysis = await analyzeIndex(indexFile).catch((error) => fail(this, `${project.indexSource} cannot be read: ${String(error)}`));
|
|
4359
2971
|
for (const file of analysis.files) this.addWatchFile(file);
|
|
4360
|
-
const
|
|
4361
|
-
if (
|
|
4362
|
-
const wanted = wantedRecords(output);
|
|
4363
|
-
sites = RECORDS.flatMap((name) => {
|
|
4364
|
-
const result = analysis.records[name];
|
|
4365
|
-
return result.status === "found" ? [{
|
|
4366
|
-
site: result.site,
|
|
4367
|
-
keep: new Set(wanted[name])
|
|
4368
|
-
}] : [];
|
|
4369
|
-
});
|
|
2972
|
+
const name = exportOf(output);
|
|
2973
|
+
if (!(output.kind === "server" ? analysis.hasServer : analysis.hasClient)) fail(this, `${project.indexSource} does not export '${name}', but ${output.output} is built from it`);
|
|
4370
2974
|
},
|
|
4371
2975
|
load(id) {
|
|
4372
2976
|
return id === shimId ? shimSource(output, indexFile) : null;
|
|
4373
|
-
},
|
|
4374
|
-
transform(code, id) {
|
|
4375
|
-
const here = sites.filter(({ site }) => site.file === id);
|
|
4376
|
-
const first = here[0];
|
|
4377
|
-
if (first === void 0 || first.site.source !== code) return null;
|
|
4378
|
-
return pruneRecords(code, here);
|
|
4379
2977
|
}
|
|
4380
2978
|
};
|
|
4381
2979
|
};
|
|
@@ -4386,13 +2984,23 @@ const errorText$2 = (error) => error instanceof Error ? error.message : String(e
|
|
|
4386
2984
|
const nodeExternal = (external) => [
|
|
4387
2985
|
/^node:/,
|
|
4388
2986
|
...builtinModules,
|
|
2987
|
+
WINDOW_SPECIFIER,
|
|
4389
2988
|
...external
|
|
4390
2989
|
];
|
|
2990
|
+
/** Production flags for browser files: the page has no `process`, and Vue and Vuetify come from the app. */
|
|
2991
|
+
const BROWSER_DEFINE = {
|
|
2992
|
+
"process.env.NODE_ENV": "\"production\"",
|
|
2993
|
+
"process.env.VITE_LOGGER_ENABLED": "undefined",
|
|
2994
|
+
__VUE_OPTIONS_API__: "false",
|
|
2995
|
+
__VUE_PROD_DEVTOOLS__: "false",
|
|
2996
|
+
__VUE_PROD_HYDRATION_MISMATCH_DETAILS__: "false"
|
|
2997
|
+
};
|
|
4391
2998
|
const bundleConfig = (project, entry, output, outDir, isNode) => ({
|
|
4392
2999
|
root: project.root,
|
|
4393
3000
|
configFile: false,
|
|
4394
3001
|
publicDir: false,
|
|
4395
3002
|
base: "./",
|
|
3003
|
+
define: isNode ? {} : BROWSER_DEFINE,
|
|
4396
3004
|
logLevel: "warn",
|
|
4397
3005
|
customLogger: {
|
|
4398
3006
|
...createLogger("warn"),
|
|
@@ -4403,6 +3011,7 @@ const bundleConfig = (project, entry, output, outDir, isNode) => ({
|
|
|
4403
3011
|
outDir,
|
|
4404
3012
|
emptyOutDir: false,
|
|
4405
3013
|
minify: false,
|
|
3014
|
+
cssMinify: true,
|
|
4406
3015
|
copyPublicDir: false,
|
|
4407
3016
|
assetsInlineLimit: ASSETS_INLINE_LIMIT,
|
|
4408
3017
|
lib: {
|
|
@@ -4416,19 +3025,54 @@ const bundleConfig = (project, entry, output, outDir, isNode) => ({
|
|
|
4416
3025
|
}
|
|
4417
3026
|
}
|
|
4418
3027
|
});
|
|
4419
|
-
|
|
3028
|
+
/** Constants of the extension API are `Object.freeze({…})` calls: without this a browser bundle keeps all of them. */
|
|
3029
|
+
const BROWSER_PURE_CALLS = ["Object.freeze"];
|
|
3030
|
+
/**
|
|
3031
|
+
* Calls of the client part that the server file shares `src/index.ts` with: a
|
|
3032
|
+
* call whose result the server does not use is dropped with everything it
|
|
3033
|
+
* refers to (a component and the library behind it). The `__NO_SIDE_EFFECTS__`
|
|
3034
|
+
* mark of the SDK does not do it for the SDK as published: the bundler keeps
|
|
3035
|
+
* the call, and with it React, in `main.mjs`.
|
|
3036
|
+
*/
|
|
3037
|
+
const SERVER_PURE_CALLS = [
|
|
3038
|
+
"defineComponent",
|
|
3039
|
+
"defineAsyncComponent",
|
|
3040
|
+
"defineClient",
|
|
3041
|
+
"defineMountable",
|
|
3042
|
+
"reactComponent"
|
|
3043
|
+
];
|
|
3044
|
+
/** `presets`: plugins of the frameworks of the client file (the server file and workers are built without them). */
|
|
3045
|
+
const outputJob = (project, output, outDir, presets) => {
|
|
4420
3046
|
const state = { problem: null };
|
|
4421
|
-
const
|
|
3047
|
+
const isBrowser = output.kind === "client";
|
|
3048
|
+
const base = bundleConfig(project, shimEntry(project, output), output.output, outDir, !isBrowser);
|
|
3049
|
+
const config = isBrowser ? presetConfig(project.frameworks, base) : base;
|
|
3050
|
+
const frameworkPackages = frameworkPackagePattern(project.frameworks);
|
|
3051
|
+
config.build = {
|
|
3052
|
+
...config.build,
|
|
3053
|
+
rolldownOptions: {
|
|
3054
|
+
...config.build?.rolldownOptions,
|
|
3055
|
+
treeshake: isBrowser ? { manualPureFunctions: BROWSER_PURE_CALLS } : {
|
|
3056
|
+
moduleSideEffects: (id) => !frameworkPackages.test(id),
|
|
3057
|
+
manualPureFunctions: SERVER_PURE_CALLS
|
|
3058
|
+
}
|
|
3059
|
+
}
|
|
3060
|
+
};
|
|
4422
3061
|
return {
|
|
4423
3062
|
output: output.output,
|
|
4424
|
-
label: `${output.output} (${
|
|
3063
|
+
label: `${output.output} (${exportOf(output)} from ${project.indexSource})`,
|
|
4425
3064
|
config: {
|
|
4426
3065
|
...config,
|
|
4427
|
-
plugins: [
|
|
4428
|
-
|
|
4429
|
-
|
|
4430
|
-
|
|
4431
|
-
|
|
3066
|
+
plugins: [
|
|
3067
|
+
shimPlugin({
|
|
3068
|
+
project,
|
|
3069
|
+
output,
|
|
3070
|
+
state
|
|
3071
|
+
}),
|
|
3072
|
+
...isBrowser ? [hostModulesPlugin()] : [serverVuePlugin(realpathSync(project.root), output.output, state)],
|
|
3073
|
+
...presets,
|
|
3074
|
+
assetsPlugin(state)
|
|
3075
|
+
]
|
|
4432
3076
|
},
|
|
4433
3077
|
state
|
|
4434
3078
|
};
|
|
@@ -4440,22 +3084,22 @@ const workerJob = (project, entry, outDir) => {
|
|
|
4440
3084
|
label: `${entry.output} (${entry.source})`,
|
|
4441
3085
|
config: {
|
|
4442
3086
|
...bundleConfig(project, path.resolve(project.root, entry.source), entry.output, outDir, true),
|
|
4443
|
-
plugins: [assetsPlugin(state)]
|
|
3087
|
+
plugins: [serverVuePlugin(realpathSync(project.root), entry.output, state), assetsPlugin(state)]
|
|
4444
3088
|
},
|
|
4445
3089
|
state
|
|
4446
3090
|
};
|
|
4447
3091
|
};
|
|
4448
|
-
/** Project bundles: the
|
|
4449
|
-
const jobsOf = (project, outDir) => [
|
|
4450
|
-
...project.
|
|
4451
|
-
...project.
|
|
3092
|
+
/** Project bundles: the server part, the client part, workers. */
|
|
3093
|
+
const jobsOf = async (project, outDir) => [
|
|
3094
|
+
...project.server === null ? [] : [outputJob(project, project.server, outDir, [])],
|
|
3095
|
+
...project.client === null ? [] : [outputJob(project, project.client, outDir, await presetPlugins(project.frameworks, { extensionId: project.manifest.id }))],
|
|
4452
3096
|
...project.workerEntries.map((entry) => workerJob(project, entry, outDir))
|
|
4453
3097
|
];
|
|
4454
3098
|
/** Failure cause: a reconciliation or guard error named by the plugin, or the bundler's text. */
|
|
4455
3099
|
const failureDetail = (job, error) => job.state.problem ?? errorText$2(error);
|
|
4456
3100
|
/** Builds all bundles (one at a time, no shared chunks) into `outDir`. */
|
|
4457
3101
|
const bundleAll = async (project, outDir) => {
|
|
4458
|
-
for (const job of jobsOf(project, outDir)) {
|
|
3102
|
+
for (const job of await jobsOf(project, outDir)) {
|
|
4459
3103
|
job.state.problem = null;
|
|
4460
3104
|
try {
|
|
4461
3105
|
await build(job.config);
|
|
@@ -4465,78 +3109,6 @@ const bundleAll = async (project, outDir) => {
|
|
|
4465
3109
|
}
|
|
4466
3110
|
};
|
|
4467
3111
|
|
|
4468
|
-
//#endregion
|
|
4469
|
-
//#region packages/extension-tools/src/ids.ts
|
|
4470
|
-
/** Directory of the generated files in a project; never part of `dist-ext` or a catalog source tree. */
|
|
4471
|
-
const GENERATED_DIR = ".dolphy";
|
|
4472
|
-
/** The declarations file, relative to the project root (posix separators, as in `tsconfig` `include`). */
|
|
4473
|
-
const IDS_FILE = `${GENERATED_DIR}/ids.d.ts`;
|
|
4474
|
-
const HEADER = "// Generated by `dolphy-ext types` from extension.json. Do not edit: the file is rewritten on every build.";
|
|
4475
|
-
/** A single-quoted TypeScript string literal. */
|
|
4476
|
-
const literal = (text) => `'${JSON.stringify(text).slice(1, -1).replaceAll("\\\"", "\"").replaceAll("'", "\\'")}'`;
|
|
4477
|
-
/** A union of literals; `never` when the manifest declares none. */
|
|
4478
|
-
const union = (values) => values.length === 0 ? "never" : values.map(literal).join(" | ");
|
|
4479
|
-
const settingType = (setting) => {
|
|
4480
|
-
switch (setting.type) {
|
|
4481
|
-
case "enum": return union(setting.options.map((option) => option.value));
|
|
4482
|
-
case "text":
|
|
4483
|
-
case "color": return "string";
|
|
4484
|
-
case "list": return "string[]";
|
|
4485
|
-
default: return setting.type;
|
|
4486
|
-
}
|
|
4487
|
-
};
|
|
4488
|
-
const settingsType = (settings) => settings.length === 0 ? "{}" : `{\n${settings.map((setting) => ` ${literal(setting.id)}: ${settingType(setting)};`).join("\n")}\n }`;
|
|
4489
|
-
/**
|
|
4490
|
-
* The declarations for a manifest: an augmentation of `ExtensionIds` in
|
|
4491
|
-
* `@dolphy-app/extension-sdk`. Pure function of the normalized manifest, so
|
|
4492
|
-
* the output is deterministic; a kind with no ids is `never`.
|
|
4493
|
-
*/
|
|
4494
|
-
const renderIds = ({ contributes }) => {
|
|
4495
|
-
const entries = [
|
|
4496
|
-
["exerciseTypes", union(contributes.exerciseTypes.map(({ id }) => id))],
|
|
4497
|
-
["gradePolicies", union(contributes.gradePolicies.map(({ id }) => id))],
|
|
4498
|
-
["commands", union(contributes.commands.map(({ id }) => id))],
|
|
4499
|
-
["events", union(contributes.events.map(({ event }) => event))],
|
|
4500
|
-
["panels", union(contributes.panels.map(({ id }) => id))],
|
|
4501
|
-
["widgets", union(contributes.widgets.map(({ id }) => id))],
|
|
4502
|
-
["schedules", union(contributes.schedules.map(({ id }) => id))],
|
|
4503
|
-
["importers", union(contributes.importers.map(({ id }) => id))],
|
|
4504
|
-
["exporters", union(contributes.exporters.map(({ id }) => id))],
|
|
4505
|
-
["markdownLanguages", union(contributes.markdownRenderers.map(({ language }) => language))],
|
|
4506
|
-
["settings", settingsType(contributes.settings)]
|
|
4507
|
-
];
|
|
4508
|
-
return [
|
|
4509
|
-
HEADER,
|
|
4510
|
-
"declare module '@dolphy-app/extension-sdk' {",
|
|
4511
|
-
" interface ExtensionIds {",
|
|
4512
|
-
...entries.map(([name, type]) => ` ${name}: ${type};`),
|
|
4513
|
-
" }",
|
|
4514
|
-
"}",
|
|
4515
|
-
"",
|
|
4516
|
-
"export {};",
|
|
4517
|
-
""
|
|
4518
|
-
].join("\n");
|
|
4519
|
-
};
|
|
4520
|
-
/**
|
|
4521
|
-
* Writes `.dolphy/ids.d.ts` for the project, from `extension.json` only (no
|
|
4522
|
-
* author code is run). A file with identical content is not rewritten, so a
|
|
4523
|
-
* watcher on the project does not see a change.
|
|
4524
|
-
*/
|
|
4525
|
-
const writeIds = async (project) => {
|
|
4526
|
-
const file = path.join(project.root, IDS_FILE);
|
|
4527
|
-
const content = renderIds(project.manifest);
|
|
4528
|
-
if (await readFile(file, "utf8").catch(() => null) === content) return {
|
|
4529
|
-
file,
|
|
4530
|
-
changed: false
|
|
4531
|
-
};
|
|
4532
|
-
await mkdir(path.dirname(file), { recursive: true });
|
|
4533
|
-
await writeFile(file, content);
|
|
4534
|
-
return {
|
|
4535
|
-
file,
|
|
4536
|
-
changed: true
|
|
4537
|
-
};
|
|
4538
|
-
};
|
|
4539
|
-
|
|
4540
3112
|
//#endregion
|
|
4541
3113
|
//#region packages/extension-tools/src/project.ts
|
|
4542
3114
|
const MANIFEST_FILE = "extension.json";
|
|
@@ -4544,9 +3116,8 @@ const CONFIG_FILE = "dolphy-ext.config.json";
|
|
|
4544
3116
|
const DEFAULT_OUT_DIR = "dist-ext";
|
|
4545
3117
|
const INDEX_SOURCE = "src/index.ts";
|
|
4546
3118
|
const stripDot = (file) => file.replace(/^\.\//, "");
|
|
4547
|
-
const stripExtension = (file) => file.slice(0, file.length - path.posix.extname(file).length);
|
|
4548
|
-
const entrySource = (file) => `src/${path.posix.basename(stripExtension(file))}.ts`;
|
|
4549
3119
|
const errorText$1 = (error) => error instanceof Error ? error.message : String(error);
|
|
3120
|
+
const isFile = async (file) => (await stat(file).catch(() => null))?.isFile() === true;
|
|
4550
3121
|
const isStringRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value) && Object.values(value).every((item) => typeof item === "string");
|
|
4551
3122
|
const isStringArray = (value) => Array.isArray(value) && value.every((item) => typeof item === "string");
|
|
4552
3123
|
const readJson = async (file, subject) => {
|
|
@@ -4556,11 +3127,19 @@ const readJson = async (file, subject) => {
|
|
|
4556
3127
|
throw new BuildError(`${path.basename(file)} is unreadable: ${errorText$1(error)}`, subject);
|
|
4557
3128
|
}
|
|
4558
3129
|
};
|
|
3130
|
+
/** `frameworks` of the config: names of known presets (an error lists the known ones); `vue` is always on. */
|
|
3131
|
+
const frameworksOf = (value, root) => {
|
|
3132
|
+
if (!isStringArray(value)) throw new BuildError(`${CONFIG_FILE}: 'frameworks' must be an array of framework names (${FRAMEWORK_NAMES.join(", ")})`, root);
|
|
3133
|
+
const unknown = value.find((name) => !isFramework(name));
|
|
3134
|
+
if (unknown !== void 0) throw new BuildError(`${CONFIG_FILE}: unknown framework '${unknown}' in 'frameworks': the known frameworks are ${FRAMEWORK_NAMES.join(", ")}`, root);
|
|
3135
|
+
return normalizeFrameworks(value);
|
|
3136
|
+
};
|
|
4559
3137
|
const readConfig = async (root) => {
|
|
4560
3138
|
const file = path.join(root, CONFIG_FILE);
|
|
4561
3139
|
if (!((await stat(file).catch(() => null))?.isFile() === true)) return {
|
|
4562
3140
|
nodeEntries: {},
|
|
4563
|
-
external: []
|
|
3141
|
+
external: [],
|
|
3142
|
+
frameworks: normalizeFrameworks(DEFAULT_FRAMEWORKS)
|
|
4564
3143
|
};
|
|
4565
3144
|
const config = await readJson(file, root);
|
|
4566
3145
|
const nodeEntries = config.nodeEntries ?? {};
|
|
@@ -4568,54 +3147,16 @@ const readConfig = async (root) => {
|
|
|
4568
3147
|
if (!isStringRecord(nodeEntries) || !isStringArray(external)) throw new BuildError(`${CONFIG_FILE}: 'nodeEntries' must map output files to sources and 'external' must be a string array`, root);
|
|
4569
3148
|
return {
|
|
4570
3149
|
nodeEntries,
|
|
4571
|
-
external
|
|
3150
|
+
external,
|
|
3151
|
+
frameworks: frameworksOf(config.frameworks ?? DEFAULT_FRAMEWORKS, root)
|
|
4572
3152
|
};
|
|
4573
3153
|
};
|
|
4574
|
-
|
|
4575
|
-
|
|
4576
|
-
return [...new Set(paths)];
|
|
4577
|
-
};
|
|
4578
|
-
/** Workers are built only together with code: without `main` there are none. */
|
|
4579
|
-
const workerEntriesOf = (manifest, config) => manifest.main === null ? [] : Object.entries(config.nodeEntries).map(([output, source]) => ({
|
|
3154
|
+
/** Workers are built only together with a server part. */
|
|
3155
|
+
const workerEntriesOf = (config, hasServer) => hasServer ? Object.entries(config.nodeEntries).map(([output, source]) => ({
|
|
4580
3156
|
source,
|
|
4581
3157
|
output: stripDot(output)
|
|
4582
|
-
}));
|
|
4583
|
-
|
|
4584
|
-
const outputs = /* @__PURE__ */ new Map();
|
|
4585
|
-
const outputOf = (file) => {
|
|
4586
|
-
const output = stripDot(file);
|
|
4587
|
-
const existing = outputs.get(output);
|
|
4588
|
-
if (existing !== void 0) return existing;
|
|
4589
|
-
const created = {
|
|
4590
|
-
kind: "browser",
|
|
4591
|
-
output,
|
|
4592
|
-
views: [],
|
|
4593
|
-
panels: [],
|
|
4594
|
-
widgets: [],
|
|
4595
|
-
languages: []
|
|
4596
|
-
};
|
|
4597
|
-
outputs.set(output, created);
|
|
4598
|
-
return created;
|
|
4599
|
-
};
|
|
4600
|
-
const { exerciseTypes, markdownRenderers, panels, widgets } = manifest.contributes;
|
|
4601
|
-
for (const type of exerciseTypes) outputOf(type.renderer).views.push({
|
|
4602
|
-
id: type.id,
|
|
4603
|
-
element: type.element
|
|
4604
|
-
});
|
|
4605
|
-
for (const entry of markdownRenderers) outputOf(entry.renderer).languages.push(entry.language);
|
|
4606
|
-
for (const panel of panels) outputOf(panel.module).panels.push(panel.id);
|
|
4607
|
-
for (const widget of widgets) outputOf(widget.module).widgets.push(widget.id);
|
|
4608
|
-
return [...outputs.values()];
|
|
4609
|
-
};
|
|
4610
|
-
/**
|
|
4611
|
-
* Paths where code lived before the single entry (`src/<file name>.ts`):
|
|
4612
|
-
* the build uses them to recognize the old layout and suggest a migration.
|
|
4613
|
-
*/
|
|
4614
|
-
const legacySources = (project) => {
|
|
4615
|
-
const outputs = [...project.host === null ? [] : [project.host.output], ...project.browserOutputs.map((entry) => entry.output)];
|
|
4616
|
-
return [...new Set(outputs.map(entrySource))];
|
|
4617
|
-
};
|
|
4618
|
-
/** Reads the source manifest and project config, computes the output files. */
|
|
3158
|
+
})) : [];
|
|
3159
|
+
/** Reads the source manifest, project config and exports of `src/index.ts`, computes the output files. */
|
|
4619
3160
|
const loadProject = async (rootDir) => {
|
|
4620
3161
|
const root = path.resolve(rootDir);
|
|
4621
3162
|
const manifestFile = path.join(root, MANIFEST_FILE);
|
|
@@ -4630,22 +3171,38 @@ const loadProject = async (rootDir) => {
|
|
|
4630
3171
|
if (!parsed.ok) throw new BuildError(formatDiagnostic(parsed.diagnostic), root);
|
|
4631
3172
|
const { manifest } = parsed;
|
|
4632
3173
|
const config = await readConfig(root);
|
|
4633
|
-
const
|
|
4634
|
-
|
|
4635
|
-
|
|
4636
|
-
|
|
4637
|
-
|
|
3174
|
+
const indexFile = path.join(root, INDEX_SOURCE);
|
|
3175
|
+
if (!await isFile(indexFile)) throw new BuildError(`'${INDEX_SOURCE}' is not found: an extension is built from one entry file that exports 'server' and/or 'client'`, manifest.id);
|
|
3176
|
+
const analysis = await analyzeIndex(indexFile).catch((error) => {
|
|
3177
|
+
throw new BuildError(`${INDEX_SOURCE} cannot be read: ${errorText$1(error)}`, manifest.id);
|
|
3178
|
+
});
|
|
3179
|
+
if (!analysis.hasServer && !analysis.hasClient) throw new BuildError(`${INDEX_SOURCE} exports neither 'server' nor 'client': export const server = defineServer(…) and/or export const client = defineClient(…)`, manifest.id);
|
|
4638
3180
|
return {
|
|
4639
3181
|
root,
|
|
4640
3182
|
manifest,
|
|
4641
3183
|
manifestBytes,
|
|
4642
|
-
indexSource:
|
|
4643
|
-
|
|
4644
|
-
|
|
4645
|
-
|
|
3184
|
+
indexSource: INDEX_SOURCE,
|
|
3185
|
+
server: analysis.hasServer ? {
|
|
3186
|
+
kind: "server",
|
|
3187
|
+
output: stripDot(DEFAULT_MAIN)
|
|
3188
|
+
} : null,
|
|
3189
|
+
client: analysis.hasClient ? {
|
|
3190
|
+
kind: "client",
|
|
3191
|
+
output: stripDot(DEFAULT_CLIENT)
|
|
3192
|
+
} : null,
|
|
3193
|
+
workerEntries: workerEntriesOf(config, analysis.hasServer),
|
|
4646
3194
|
external: config.external,
|
|
4647
|
-
|
|
3195
|
+
frameworks: config.frameworks
|
|
3196
|
+
};
|
|
3197
|
+
};
|
|
3198
|
+
/** Bytes of the built `extension.json`: the source manifest with `main` and `client` naming the built files (`null` — no such part). */
|
|
3199
|
+
const builtManifestText = (project) => {
|
|
3200
|
+
const built = {
|
|
3201
|
+
...JSON.parse(project.manifestBytes.toString("utf8")),
|
|
3202
|
+
main: project.server === null ? null : `./${project.server.output}`,
|
|
3203
|
+
client: project.client === null ? null : `./${project.client.output}`
|
|
4648
3204
|
};
|
|
3205
|
+
return `${JSON.stringify(built, null, 2)}\n`;
|
|
4649
3206
|
};
|
|
4650
3207
|
|
|
4651
3208
|
//#endregion
|
|
@@ -4764,7 +3321,7 @@ const watchAll = async (project, outDir, reporter, isFailFast) => {
|
|
|
4764
3321
|
const failedFirst = /* @__PURE__ */ new Set();
|
|
4765
3322
|
try {
|
|
4766
3323
|
const firsts = [];
|
|
4767
|
-
for (const job of jobsOf(project, outDir)) {
|
|
3324
|
+
for (const job of await jobsOf(project, outDir)) {
|
|
4768
3325
|
const { watcher, first } = await watchJob(job, reporter, isFailFast, failedFirst);
|
|
4769
3326
|
watchers.push(watcher);
|
|
4770
3327
|
firsts.push(first);
|
|
@@ -4782,11 +3339,7 @@ const watchAll = async (project, outDir, reporter, isFailFast) => {
|
|
|
4782
3339
|
|
|
4783
3340
|
//#endregion
|
|
4784
3341
|
//#region packages/extension-tools/src/index.ts
|
|
4785
|
-
const STATIC_DIRS = [
|
|
4786
|
-
"schema",
|
|
4787
|
-
"assets",
|
|
4788
|
-
"locales"
|
|
4789
|
-
];
|
|
3342
|
+
const STATIC_DIRS = ["assets"];
|
|
4790
3343
|
/** Editors write a file in several events: manifest reload waits for quiet. */
|
|
4791
3344
|
const MANIFEST_SETTLE_MS = 100;
|
|
4792
3345
|
const isPresent = async (file) => await stat(file).catch(() => null) !== null;
|
|
@@ -4794,29 +3347,11 @@ const targetDir = (project, options) => {
|
|
|
4794
3347
|
const base = options.outDir ?? path.join(project.root, "dist-ext");
|
|
4795
3348
|
return path.join(path.resolve(base), project.manifest.id);
|
|
4796
3349
|
};
|
|
4797
|
-
const legacyHint = (project) => {
|
|
4798
|
-
const { contributes } = project.manifest;
|
|
4799
|
-
return [
|
|
4800
|
-
"create src/index.ts",
|
|
4801
|
-
...project.host === null ? [] : [`move the default export of the main file into it as export const host = defineExtension({ … })`],
|
|
4802
|
-
...contributes.exerciseTypes.length === 0 ? [] : [`replace defineAnswerElement(tag, mount) with an entry of export const views = { '<exercise type id>': defineAnswerView(mount) } (the tag now comes from extension.json)`],
|
|
4803
|
-
...contributes.panels.length === 0 ? [] : [`move the default export of the panel module into export const panels = { '<panel id>': defineExtensionPanel({ … }) }`],
|
|
4804
|
-
...contributes.markdownRenderers.length === 0 ? [] : [`move the default export of the renderer module into export const markdown = { '<language>': defineMarkdownRenderer(…) }`],
|
|
4805
|
-
"delete the old src files and import from src/index.ts in tests"
|
|
4806
|
-
].map((step, index) => `${index + 1}. ${step}`).join("; ");
|
|
4807
|
-
};
|
|
4808
3350
|
const requireSources = async (project) => {
|
|
4809
|
-
const { indexSource } = project;
|
|
4810
|
-
if (indexSource !== null && !await isPresent(path.join(project.root, indexSource))) {
|
|
4811
|
-
const legacy = legacySources(project);
|
|
4812
|
-
const found = (await Promise.all(legacy.map(async (source) => await isPresent(path.join(project.root, source)) ? source : null))).filter((source) => source !== null);
|
|
4813
|
-
const reason = found.length > 0 ? `found the old layout (${found.join(", ")}), which is no longer supported; migrate: ${legacyHint(project)}` : `add it: ${legacyHint(project)}`;
|
|
4814
|
-
throw new BuildError(`'${indexSource}' is not found: an extension with code is built from one entry file; ${reason}`, project.manifest.id);
|
|
4815
|
-
}
|
|
4816
3351
|
for (const entry of project.workerEntries) if (!await isPresent(path.join(project.root, entry.source))) throw new BuildError(`entry source '${entry.source}' for '${entry.output}' is not found`, project.manifest.id);
|
|
4817
3352
|
};
|
|
4818
3353
|
const copyStatic = async (project, dir) => {
|
|
4819
|
-
await writeFile(path.join(dir, MANIFEST_FILE), project
|
|
3354
|
+
await writeFile(path.join(dir, MANIFEST_FILE), builtManifestText(project));
|
|
4820
3355
|
for (const name of STATIC_DIRS) {
|
|
4821
3356
|
const from = path.join(project.root, name);
|
|
4822
3357
|
if (await isPresent(from)) await cp(from, path.join(dir, name), { recursive: true });
|
|
@@ -4828,12 +3363,6 @@ const copyStatic = async (project, dir) => {
|
|
|
4828
3363
|
await mkdir(path.dirname(path.join(dir, icon)), { recursive: true });
|
|
4829
3364
|
await cp(from, path.join(dir, icon));
|
|
4830
3365
|
}
|
|
4831
|
-
for (const schema of project.schemaPaths) {
|
|
4832
|
-
const from = path.join(project.root, schema);
|
|
4833
|
-
if (!await isPresent(from)) throw new BuildError(`schema '${schema}' referenced by the manifest is not found`, project.manifest.id);
|
|
4834
|
-
await mkdir(path.dirname(path.join(dir, schema)), { recursive: true });
|
|
4835
|
-
await cp(from, path.join(dir, schema));
|
|
4836
|
-
}
|
|
4837
3366
|
};
|
|
4838
3367
|
const listFiles = async (dir, prefix = "") => {
|
|
4839
3368
|
const entries = await readdir(path.join(dir, prefix), { withFileTypes: true });
|
|
@@ -4850,34 +3379,21 @@ const validateExtension = async (dir) => {
|
|
|
4850
3379
|
});
|
|
4851
3380
|
if (!result.ok) return {
|
|
4852
3381
|
ok: false,
|
|
4853
|
-
problems: [formatDiagnostic(result.diagnostic)]
|
|
4854
|
-
warnings: []
|
|
3382
|
+
problems: [formatDiagnostic(result.diagnostic)]
|
|
4855
3383
|
};
|
|
4856
3384
|
const files = await listFiles(root);
|
|
4857
3385
|
const problems = (await assetFindings(root, files)).map(({ path: file, message }) => `${file}: ${message}`);
|
|
4858
|
-
const locales = await localeFindings({
|
|
4859
|
-
manifest: JSON.parse(await readFileOrNull(root, "extension.json") ?? "null"),
|
|
4860
|
-
files,
|
|
4861
|
-
read: (file) => readFileOrNull(root, file)
|
|
4862
|
-
});
|
|
4863
|
-
const describe = ({ field, message }) => `${field}: ${message}`;
|
|
4864
|
-
problems.push(...locales.filter(({ severity }) => severity === "error").map(describe));
|
|
4865
3386
|
return {
|
|
4866
3387
|
ok: problems.length === 0,
|
|
4867
|
-
problems
|
|
4868
|
-
warnings: locales.filter(({ severity }) => severity === "warning").map(describe)
|
|
3388
|
+
problems
|
|
4869
3389
|
};
|
|
4870
3390
|
};
|
|
4871
|
-
const assertValid = async (project, dir
|
|
4872
|
-
const { ok, problems
|
|
4873
|
-
for (const warning of warnings) logger?.info(`warning ${project.manifest.id}: ${warning}`);
|
|
3391
|
+
const assertValid = async (project, dir) => {
|
|
3392
|
+
const { ok, problems } = await validateExtension(dir);
|
|
4874
3393
|
if (!ok) throw new BuildError(problems.join("; "), project.manifest.id);
|
|
4875
3394
|
};
|
|
4876
|
-
/** Writes `.dolphy/ids.d.ts` for the project in `options.root` from its `extension.json`; `changed` is false when the file was already current. */
|
|
4877
|
-
const generateTypes = async (options) => writeIds(await loadProject(options.root));
|
|
4878
3395
|
const prepare = async (options) => {
|
|
4879
3396
|
const project = await loadProject(options.root);
|
|
4880
|
-
await writeIds(project);
|
|
4881
3397
|
await requireSources(project);
|
|
4882
3398
|
const dir = targetDir(project, options);
|
|
4883
3399
|
await rm(dir, {
|
|
@@ -4899,7 +3415,7 @@ const buildExtension = async (options) => {
|
|
|
4899
3415
|
const { project, dir } = await prepare(options);
|
|
4900
3416
|
await bundleAll(project, dir);
|
|
4901
3417
|
await copyStatic(project, dir);
|
|
4902
|
-
await assertValid(project, dir
|
|
3418
|
+
await assertValid(project, dir);
|
|
4903
3419
|
return resultOf$1(project, dir);
|
|
4904
3420
|
};
|
|
4905
3421
|
const reportTo = (id, logger) => (report) => {
|
|
@@ -4917,17 +3433,20 @@ const watchExtension = async (options) => {
|
|
|
4917
3433
|
const { logger } = options;
|
|
4918
3434
|
let project = first;
|
|
4919
3435
|
let dir = firstDir;
|
|
3436
|
+
let current = null;
|
|
4920
3437
|
const reporter = createReporter((report) => reportTo(project.manifest.id, logger)(report));
|
|
4921
|
-
|
|
4922
|
-
|
|
4923
|
-
|
|
4924
|
-
|
|
4925
|
-
|
|
4926
|
-
|
|
4927
|
-
|
|
4928
|
-
|
|
4929
|
-
|
|
4930
|
-
|
|
3438
|
+
const start = async () => {
|
|
3439
|
+
const started = await watchAll(project, dir, reporter, true);
|
|
3440
|
+
current = started;
|
|
3441
|
+
try {
|
|
3442
|
+
await copyStatic(project, dir);
|
|
3443
|
+
await assertValid(project, dir);
|
|
3444
|
+
} catch (error) {
|
|
3445
|
+
await started.close();
|
|
3446
|
+
throw error;
|
|
3447
|
+
}
|
|
3448
|
+
return resultOf$1(project, dir);
|
|
3449
|
+
};
|
|
4931
3450
|
const reload = async () => {
|
|
4932
3451
|
const previous = project.manifestBytes;
|
|
4933
3452
|
let next;
|
|
@@ -4945,7 +3464,6 @@ const watchExtension = async (options) => {
|
|
|
4945
3464
|
force: true
|
|
4946
3465
|
});
|
|
4947
3466
|
try {
|
|
4948
|
-
await writeIds(next);
|
|
4949
3467
|
await requireSources(next);
|
|
4950
3468
|
project = next;
|
|
4951
3469
|
dir = targetDir(project, options);
|
|
@@ -4953,7 +3471,7 @@ const watchExtension = async (options) => {
|
|
|
4953
3471
|
current = await watchAll(project, dir, reporter, false);
|
|
4954
3472
|
await copyStatic(project, dir);
|
|
4955
3473
|
if (current.isHealthy) {
|
|
4956
|
-
await assertValid(project, dir
|
|
3474
|
+
await assertValid(project, dir);
|
|
4957
3475
|
logger?.info(`rebuilt ${(await resultOf$1(project, dir)).files.join(", ")}`);
|
|
4958
3476
|
}
|
|
4959
3477
|
} catch (error) {
|
|
@@ -4961,19 +3479,32 @@ const watchExtension = async (options) => {
|
|
|
4961
3479
|
}
|
|
4962
3480
|
};
|
|
4963
3481
|
let queue = Promise.resolve();
|
|
4964
|
-
let timer
|
|
4965
|
-
const manifestWatcher = watch(
|
|
3482
|
+
let timer;
|
|
3483
|
+
const manifestWatcher = watch(first.root, (_event, name) => {
|
|
4966
3484
|
if (name !== "extension.json") return;
|
|
4967
|
-
|
|
3485
|
+
clearTimeout(timer);
|
|
4968
3486
|
timer = setTimeout(() => {
|
|
4969
3487
|
queue = queue.then(reload);
|
|
4970
3488
|
}, MANIFEST_SETTLE_MS);
|
|
4971
3489
|
});
|
|
3490
|
+
const stopWatching = () => {
|
|
3491
|
+
manifestWatcher.close();
|
|
3492
|
+
clearTimeout(timer);
|
|
3493
|
+
};
|
|
3494
|
+
const started = start();
|
|
3495
|
+
queue = started.then(() => void 0, () => void 0);
|
|
3496
|
+
let result;
|
|
3497
|
+
try {
|
|
3498
|
+
result = await started;
|
|
3499
|
+
} catch (error) {
|
|
3500
|
+
stopWatching();
|
|
3501
|
+
reporter.close();
|
|
3502
|
+
throw error;
|
|
3503
|
+
}
|
|
4972
3504
|
return {
|
|
4973
3505
|
result,
|
|
4974
3506
|
close: async () => {
|
|
4975
|
-
|
|
4976
|
-
if (timer !== null) clearTimeout(timer);
|
|
3507
|
+
stopWatching();
|
|
4977
3508
|
await queue;
|
|
4978
3509
|
reporter.close();
|
|
4979
3510
|
await current?.close();
|
|
@@ -5021,12 +3552,14 @@ const CODE_FILE = /\.(?:mjs|js|cjs)$/;
|
|
|
5021
3552
|
const OBFUSCATION_MIN_BYTES = 20480;
|
|
5022
3553
|
const OBFUSCATION_LINE_LENGTH = 500;
|
|
5023
3554
|
const OBFUSCATION_IDENTIFIERS = 20;
|
|
5024
|
-
|
|
3555
|
+
/**
|
|
3556
|
+
* `eval(…)` and `new Function(…)`, except `new Function("")`: its body is
|
|
3557
|
+
* empty, nothing runs. `zod` makes that call to find out whether code
|
|
3558
|
+
* generation is allowed, so every bundle that validates with `zod` has it.
|
|
3559
|
+
*/
|
|
3560
|
+
const DYNAMIC_EXECUTION = /(?<![\w$])(?:eval\s*\(|new\s+Function\s*\((?!\s*(?:""|''|``)\s*\)))/;
|
|
5025
3561
|
const HEX_IDENTIFIER = /(?<![\w$])_0x[0-9a-f]{3,}(?![\w$])/gi;
|
|
5026
|
-
const URL_PATTERN = /https?:\/\/[^\s"'`<>)\\]+/g;
|
|
5027
3562
|
const SOURCE_MAP = /\/\/[#@]\s*sourceMappingURL=/;
|
|
5028
|
-
/** XML namespaces (`createElementNS`) are identifiers, not network addresses. */
|
|
5029
|
-
const NAMESPACE_URL = /^https?:\/\/www\.w3\.org\//;
|
|
5030
3563
|
const listCodeFiles = async (dir, prefix = "") => {
|
|
5031
3564
|
const entries = await readdir(path.join(dir, prefix), { withFileTypes: true });
|
|
5032
3565
|
return (await Promise.all(entries.map(async (entry) => {
|
|
@@ -5050,12 +3583,12 @@ const isObfuscated = (text) => {
|
|
|
5050
3583
|
return identifiers.size >= OBFUSCATION_IDENTIFIERS ? `${identifiers.size} identifiers of the form _0x1a2b` : null;
|
|
5051
3584
|
};
|
|
5052
3585
|
/**
|
|
5053
|
-
* Heuristics over the built code (`CHECK-022
|
|
5054
|
-
* whole bundle, dependencies included, so only the source map is an
|
|
3586
|
+
* Heuristics over the built code (`CHECK-022`, `CHECK-023`, `CHECK-025`). They
|
|
3587
|
+
* read the whole bundle, dependencies included, so only the source map is an
|
|
3588
|
+
* error.
|
|
5055
3589
|
*/
|
|
5056
|
-
const bundleFindings = (files
|
|
3590
|
+
const bundleFindings = (files) => {
|
|
5057
3591
|
const findings = [];
|
|
5058
|
-
const mayUseNetwork = permissions.includes("network");
|
|
5059
3592
|
for (const { path: field, text } of files) {
|
|
5060
3593
|
if (DYNAMIC_EXECUTION.test(text)) findings.push({
|
|
5061
3594
|
ruleId: "CHECK-022",
|
|
@@ -5070,15 +3603,6 @@ const bundleFindings = (files, permissions) => {
|
|
|
5070
3603
|
field,
|
|
5071
3604
|
message: `looks obfuscated: ${obfuscation}`
|
|
5072
3605
|
});
|
|
5073
|
-
if (!mayUseNetwork) {
|
|
5074
|
-
const url = (text.match(URL_PATTERN) ?? []).find((item) => !NAMESPACE_URL.test(item));
|
|
5075
|
-
if (url !== void 0) findings.push({
|
|
5076
|
-
ruleId: "CHECK-024",
|
|
5077
|
-
severity: "warning",
|
|
5078
|
-
field,
|
|
5079
|
-
message: `URL ${url} in code without the 'network' permission`
|
|
5080
|
-
});
|
|
5081
|
-
}
|
|
5082
3606
|
if (SOURCE_MAP.test(text)) findings.push({
|
|
5083
3607
|
ruleId: "CHECK-025",
|
|
5084
3608
|
severity: "error",
|
|
@@ -5122,7 +3646,7 @@ const manifestFindings = (fields) => {
|
|
|
5122
3646
|
ruleId: "LINT-001",
|
|
5123
3647
|
severity: "warning",
|
|
5124
3648
|
field: "tags",
|
|
5125
|
-
message: "'tags' is not set:
|
|
3649
|
+
message: "'tags' is not set: the catalog lists the extension without tags"
|
|
5126
3650
|
});
|
|
5127
3651
|
return findings;
|
|
5128
3652
|
};
|
|
@@ -5366,25 +3890,6 @@ const changelog = {
|
|
|
5366
3890
|
return new RegExp(`^##[ \\t]+\\[?v?${escapeRegExp(manifest.version)}\\]?(?:[ \\t]|$)`, "m").test(text) ? [] : [warning("CHANGELOG.md", `has no '## ${manifest.version}' section for the current version`)];
|
|
5367
3891
|
}
|
|
5368
3892
|
};
|
|
5369
|
-
const translations = {
|
|
5370
|
-
id: "CHECK-026",
|
|
5371
|
-
title: "locales/*.json: en is complete, texts fit their fields, files are valid",
|
|
5372
|
-
run: ({ rawManifest, tree, readText }) => rawManifest === null ? [] : localeFindings({
|
|
5373
|
-
manifest: rawManifest,
|
|
5374
|
-
files: tree.files.map((file) => file.path),
|
|
5375
|
-
read: readText
|
|
5376
|
-
})
|
|
5377
|
-
};
|
|
5378
|
-
const permissionsExplained = {
|
|
5379
|
-
id: "CHECK-020",
|
|
5380
|
-
title: "every permission is mentioned in README.md",
|
|
5381
|
-
run: async (context) => {
|
|
5382
|
-
const { manifest } = context;
|
|
5383
|
-
if (manifest === null || manifest.permissions.length === 0) return [];
|
|
5384
|
-
const readmeText = await context.readText("README.md") ?? "";
|
|
5385
|
-
return manifest.permissions.filter((permission) => !readmeText.includes(permission)).map((permission) => warning("permissions", `permission '${permission}' is not mentioned in README.md: explain why the extension needs it`));
|
|
5386
|
-
}
|
|
5387
|
-
};
|
|
5388
3893
|
const firstPublisherOwnsId = {
|
|
5389
3894
|
id: "CHECK-021",
|
|
5390
3895
|
title: "the id is not published under another author",
|
|
@@ -5403,13 +3908,43 @@ const bundleRule = (id, title, missingBundle = false) => ({
|
|
|
5403
3908
|
if (bundleDir === null || manifest === null) return [];
|
|
5404
3909
|
const files = await readBundleFiles(bundleDir).catch(() => null);
|
|
5405
3910
|
if (files === null) return missingBundle ? [warning("--built", `${NO_BUNDLE} (${bundleDir})`)] : [];
|
|
5406
|
-
return bundleFindings(files
|
|
3911
|
+
return bundleFindings(files).filter((finding) => finding.ruleId === id).map(({ severity, field, message }) => ({
|
|
5407
3912
|
severity,
|
|
5408
3913
|
field,
|
|
5409
3914
|
message
|
|
5410
3915
|
}));
|
|
5411
3916
|
}
|
|
5412
3917
|
});
|
|
3918
|
+
const BUILT_PARTS = [{
|
|
3919
|
+
field: "main",
|
|
3920
|
+
file: DEFAULT_MAIN
|
|
3921
|
+
}, {
|
|
3922
|
+
field: "client",
|
|
3923
|
+
file: DEFAULT_CLIENT
|
|
3924
|
+
}];
|
|
3925
|
+
/** `CHECK-031`: the built manifest's `main` and `client` name the files that are in the built version, and each file has its field; silent without `--built`. */
|
|
3926
|
+
const builtParts = {
|
|
3927
|
+
id: "CHECK-031",
|
|
3928
|
+
title: "main.mjs and client.mjs match the main and client fields",
|
|
3929
|
+
run: async ({ bundleDir }) => {
|
|
3930
|
+
if (bundleDir === null) return [];
|
|
3931
|
+
let built;
|
|
3932
|
+
try {
|
|
3933
|
+
built = JSON.parse(await readFile(path.join(bundleDir, "extension.json"), "utf8"));
|
|
3934
|
+
} catch {
|
|
3935
|
+
return [];
|
|
3936
|
+
}
|
|
3937
|
+
if (!isRecord(built)) return [];
|
|
3938
|
+
const findings = [];
|
|
3939
|
+
for (const { field, file } of BUILT_PARTS) {
|
|
3940
|
+
const declared = built[field];
|
|
3941
|
+
const exists = (await stat(path.join(bundleDir, file)).catch(() => null))?.isFile() === true;
|
|
3942
|
+
if (typeof declared === "string" && !exists) findings.push(error(field, `'${declared}' is not in the built version`));
|
|
3943
|
+
else if (typeof declared !== "string" && exists) findings.push(error(field, `${file} is in the built version, but '${field}' is not set`));
|
|
3944
|
+
}
|
|
3945
|
+
return findings;
|
|
3946
|
+
}
|
|
3947
|
+
};
|
|
5413
3948
|
const RULES = [
|
|
5414
3949
|
manifestValid,
|
|
5415
3950
|
directoryName,
|
|
@@ -5430,13 +3965,11 @@ const RULES = [
|
|
|
5430
3965
|
assetFiles,
|
|
5431
3966
|
iconFile,
|
|
5432
3967
|
shortDescriptionRule,
|
|
5433
|
-
permissionsExplained,
|
|
5434
3968
|
firstPublisherOwnsId,
|
|
5435
3969
|
bundleRule("CHECK-022", "built code does not execute dynamic code", true),
|
|
5436
3970
|
bundleRule("CHECK-023", "built code is not obfuscated"),
|
|
5437
|
-
bundleRule("CHECK-024", "URLs in built code need the network permission"),
|
|
5438
3971
|
bundleRule("CHECK-025", "built code has no embedded source map"),
|
|
5439
|
-
|
|
3972
|
+
builtParts,
|
|
5440
3973
|
changelog
|
|
5441
3974
|
];
|
|
5442
3975
|
|
|
@@ -5527,7 +4060,6 @@ const hashTree = async (root, files) => Promise.all(files.map(async (file) => ({
|
|
|
5527
4060
|
const SKIPPED_SOURCE_DIRS = /* @__PURE__ */ new Set([
|
|
5528
4061
|
"node_modules",
|
|
5529
4062
|
"dist-ext",
|
|
5530
|
-
".dolphy",
|
|
5531
4063
|
".git"
|
|
5532
4064
|
]);
|
|
5533
4065
|
const formatFinding = (finding) => `${finding.severity} ${finding.extensionId} ${finding.ruleId} ${finding.field}: ${finding.message}`;
|
|
@@ -5582,7 +4114,7 @@ const inspectManifest = async (dir) => {
|
|
|
5582
4114
|
};
|
|
5583
4115
|
};
|
|
5584
4116
|
const stringOrNull$1 = (value) => typeof value === "string" ? value : null;
|
|
5585
|
-
/** The extension manifest as written
|
|
4117
|
+
/** The extension manifest as written. */
|
|
5586
4118
|
const readDeclared = async (dir) => {
|
|
5587
4119
|
let raw;
|
|
5588
4120
|
try {
|
|
@@ -5598,16 +4130,11 @@ const readDeclared = async (dir) => {
|
|
|
5598
4130
|
declared: null
|
|
5599
4131
|
};
|
|
5600
4132
|
const fields = raw;
|
|
5601
|
-
const english = await readEnglishTable(dir);
|
|
5602
|
-
const text = (value) => {
|
|
5603
|
-
const plain = stringOrNull$1(value);
|
|
5604
|
-
return plain === null ? null : englishText(plain, english);
|
|
5605
|
-
};
|
|
5606
4133
|
return {
|
|
5607
4134
|
raw,
|
|
5608
4135
|
declared: {
|
|
5609
|
-
name:
|
|
5610
|
-
description:
|
|
4136
|
+
name: stringOrNull$1(fields.name),
|
|
4137
|
+
description: stringOrNull$1(fields.description),
|
|
5611
4138
|
author: stringOrNull$1(fields.author),
|
|
5612
4139
|
icon: stringOrNull$1(fields.icon)
|
|
5613
4140
|
}
|
|
@@ -5693,7 +4220,6 @@ const orderVersion = (version) => ({
|
|
|
5693
4220
|
version: version.version,
|
|
5694
4221
|
apiVersion: version.apiVersion,
|
|
5695
4222
|
minAppVersion: version.minAppVersion,
|
|
5696
|
-
permissions: [...version.permissions],
|
|
5697
4223
|
publishedAt: version.publishedAt,
|
|
5698
4224
|
baseUrl: version.baseUrl,
|
|
5699
4225
|
files: [...version.files].sort(byPath).map(orderFile),
|
|
@@ -5704,15 +4230,6 @@ const orderVersion = (version) => ({
|
|
|
5704
4230
|
...range === void 0 ? {} : { range }
|
|
5705
4231
|
})) }
|
|
5706
4232
|
});
|
|
5707
|
-
const optionalIds = (key, ids) => ids === void 0 || ids.length === 0 ? {} : { [key]: [...ids] };
|
|
5708
|
-
const orderTitles = (titles) => {
|
|
5709
|
-
const ordered = {};
|
|
5710
|
-
for (const point of TITLED_POINTS) {
|
|
5711
|
-
const map = titles?.[point];
|
|
5712
|
-
if (map !== void 0 && Object.keys(map).length > 0) ordered[point] = { ...map };
|
|
5713
|
-
}
|
|
5714
|
-
return Object.keys(ordered).length > 0 ? { titles: ordered } : {};
|
|
5715
|
-
};
|
|
5716
4233
|
const orderEntry = (entry) => ({
|
|
5717
4234
|
id: entry.id,
|
|
5718
4235
|
name: entry.name,
|
|
@@ -5720,21 +4237,6 @@ const orderEntry = (entry) => ({
|
|
|
5720
4237
|
author: entry.author,
|
|
5721
4238
|
source: entry.source,
|
|
5722
4239
|
platforms: [...entry.platforms],
|
|
5723
|
-
contributes: {
|
|
5724
|
-
exerciseTypes: [...entry.contributes.exerciseTypes],
|
|
5725
|
-
themes: [...entry.contributes.themes],
|
|
5726
|
-
markdownRenderers: [...entry.contributes.markdownRenderers],
|
|
5727
|
-
gradePolicies: [...entry.contributes.gradePolicies],
|
|
5728
|
-
...optionalIds("settings", entry.contributes.settings),
|
|
5729
|
-
...optionalIds("events", entry.contributes.events),
|
|
5730
|
-
...optionalIds("commands", entry.contributes.commands),
|
|
5731
|
-
...optionalIds("panels", entry.contributes.panels),
|
|
5732
|
-
...optionalIds("widgets", entry.contributes.widgets),
|
|
5733
|
-
...optionalIds("schedules", entry.contributes.schedules),
|
|
5734
|
-
...optionalIds("importers", entry.contributes.importers),
|
|
5735
|
-
...optionalIds("exporters", entry.contributes.exporters)
|
|
5736
|
-
},
|
|
5737
|
-
...orderTitles(entry.titles),
|
|
5738
4240
|
...entry.deprecated === void 0 ? {} : { deprecated: {
|
|
5739
4241
|
versions: entry.deprecated.versions,
|
|
5740
4242
|
reason: entry.deprecated.reason,
|
|
@@ -5841,7 +4343,7 @@ const stage = async (options, id, scratch) => {
|
|
|
5841
4343
|
const files = await hashTree(built.dir, tree.files);
|
|
5842
4344
|
const problems = [...tree.symlinks.map((link) => `'${link}' is a symbolic link`), ...fileProblems(files)];
|
|
5843
4345
|
if (problems.length > 0) throw new BuildError(problems.join("; "), id);
|
|
5844
|
-
const manifest =
|
|
4346
|
+
const manifest = await readBuiltManifest(built.dir);
|
|
5845
4347
|
return {
|
|
5846
4348
|
id,
|
|
5847
4349
|
dir: built.dir,
|
|
@@ -5876,58 +4378,10 @@ const existingFiles = async (dir) => {
|
|
|
5876
4378
|
}));
|
|
5877
4379
|
};
|
|
5878
4380
|
const immutabilityError = (staged) => new BuildError(`version ${staged.manifest.version} is already published with different content; published versions never change`, staged.id);
|
|
5879
|
-
/** `label`/`title` of the manifest contributions by point; points without contributions are left out. */
|
|
5880
|
-
const titlesOf = (manifest) => {
|
|
5881
|
-
const { contributes } = manifest;
|
|
5882
|
-
const byPoint = {
|
|
5883
|
-
exerciseTypes: contributes.exerciseTypes.flatMap(({ id, title }) => title === void 0 ? [] : [{
|
|
5884
|
-
id,
|
|
5885
|
-
title
|
|
5886
|
-
}]),
|
|
5887
|
-
markdownRenderers: contributes.markdownRenderers.flatMap(({ language, title }) => title === void 0 ? [] : [{
|
|
5888
|
-
id: language,
|
|
5889
|
-
title
|
|
5890
|
-
}]),
|
|
5891
|
-
themes: contributes.themes.map(({ id, label }) => ({
|
|
5892
|
-
id,
|
|
5893
|
-
title: label
|
|
5894
|
-
})),
|
|
5895
|
-
gradePolicies: contributes.gradePolicies.map(({ id, label }) => ({
|
|
5896
|
-
id,
|
|
5897
|
-
title: label
|
|
5898
|
-
})),
|
|
5899
|
-
settings: contributes.settings.map(({ id, label }) => ({
|
|
5900
|
-
id,
|
|
5901
|
-
title: label
|
|
5902
|
-
})),
|
|
5903
|
-
commands: contributes.commands.map(({ id, title }) => ({
|
|
5904
|
-
id,
|
|
5905
|
-
title
|
|
5906
|
-
})),
|
|
5907
|
-
panels: contributes.panels.map(({ id, title }) => ({
|
|
5908
|
-
id,
|
|
5909
|
-
title
|
|
5910
|
-
})),
|
|
5911
|
-
widgets: contributes.widgets.map(({ id, title }) => ({
|
|
5912
|
-
id,
|
|
5913
|
-
title
|
|
5914
|
-
})),
|
|
5915
|
-
importers: contributes.importers.map(({ id, title }) => ({
|
|
5916
|
-
id,
|
|
5917
|
-
title
|
|
5918
|
-
})),
|
|
5919
|
-
exporters: contributes.exporters.map(({ id, title }) => ({
|
|
5920
|
-
id,
|
|
5921
|
-
title
|
|
5922
|
-
}))
|
|
5923
|
-
};
|
|
5924
|
-
return Object.fromEntries(TITLED_POINTS.filter((point) => byPoint[point].length > 0).map((point) => [point, Object.fromEntries(byPoint[point].map(({ id, title }) => [id, title]))]));
|
|
5925
|
-
};
|
|
5926
4381
|
const newRecord = (staged, publishedAt) => ({
|
|
5927
4382
|
version: staged.manifest.version,
|
|
5928
4383
|
apiVersion: staged.manifest.apiVersion,
|
|
5929
4384
|
minAppVersion: staged.manifest.minAppVersion,
|
|
5930
|
-
permissions: [...staged.manifest.permissions],
|
|
5931
4385
|
publishedAt,
|
|
5932
4386
|
baseUrl: baseUrlOf(staged),
|
|
5933
4387
|
files: staged.files,
|
|
@@ -5955,15 +4409,6 @@ const entryOf = (item, previous, sourceBase) => {
|
|
|
5955
4409
|
const { staged, record } = item;
|
|
5956
4410
|
const { manifest } = staged;
|
|
5957
4411
|
const others = (previous?.versions ?? []).filter((version) => version.version !== record.version);
|
|
5958
|
-
const settings = manifest.contributes.settings.map((setting) => setting.id);
|
|
5959
|
-
const events = manifest.contributes.events.map((item) => item.event);
|
|
5960
|
-
const commands = manifest.contributes.commands.map(({ id }) => id);
|
|
5961
|
-
const panels = manifest.contributes.panels.map(({ id }) => id);
|
|
5962
|
-
const widgets = manifest.contributes.widgets.map(({ id }) => id);
|
|
5963
|
-
const schedules = manifest.contributes.schedules.map(({ id }) => id);
|
|
5964
|
-
const importers = manifest.contributes.importers.map(({ id }) => id);
|
|
5965
|
-
const exporters = manifest.contributes.exporters.map(({ id }) => id);
|
|
5966
|
-
const titles = titlesOf(manifest);
|
|
5967
4412
|
return {
|
|
5968
4413
|
id: staged.id,
|
|
5969
4414
|
name: manifest.name ?? "",
|
|
@@ -5971,21 +4416,6 @@ const entryOf = (item, previous, sourceBase) => {
|
|
|
5971
4416
|
author: manifest.author ?? "",
|
|
5972
4417
|
source: `${sourceBase.replace(/\/+$/, "")}/${staged.id}`,
|
|
5973
4418
|
platforms: [...manifest.platforms],
|
|
5974
|
-
contributes: {
|
|
5975
|
-
exerciseTypes: manifest.contributes.exerciseTypes.map((type) => type.id),
|
|
5976
|
-
themes: manifest.contributes.themes.map((theme) => theme.id),
|
|
5977
|
-
markdownRenderers: manifest.contributes.markdownRenderers.map((renderer) => renderer.language),
|
|
5978
|
-
gradePolicies: manifest.contributes.gradePolicies.map((policy) => policy.id),
|
|
5979
|
-
...settings.length > 0 ? { settings } : {},
|
|
5980
|
-
...events.length > 0 ? { events } : {},
|
|
5981
|
-
...commands.length > 0 ? { commands } : {},
|
|
5982
|
-
...panels.length > 0 ? { panels } : {},
|
|
5983
|
-
...widgets.length > 0 ? { widgets } : {},
|
|
5984
|
-
...schedules.length > 0 ? { schedules } : {},
|
|
5985
|
-
...importers.length > 0 ? { importers } : {},
|
|
5986
|
-
...exporters.length > 0 ? { exporters } : {}
|
|
5987
|
-
},
|
|
5988
|
-
...Object.keys(titles).length > 0 ? { titles } : {},
|
|
5989
4419
|
...previous?.deprecated === void 0 ? {} : { deprecated: previous.deprecated },
|
|
5990
4420
|
versions: newestFirst([record, ...others])
|
|
5991
4421
|
};
|
|
@@ -6445,12 +4875,6 @@ const runDev = async (options, io, deps, startBuild, waitForExit) => {
|
|
|
6445
4875
|
const formatLintFinding = (finding) => `${finding.severity} ${finding.extensionId} ${finding.ruleId} ${finding.field}: ${finding.message}`;
|
|
6446
4876
|
const lintHasErrors = (findings) => findings.some((finding) => finding.severity === "error");
|
|
6447
4877
|
const stringOrNull = (value) => typeof value === "string" ? value : null;
|
|
6448
|
-
/** A manifest string as the catalog shows it: a `%key%` becomes the text of `locales/en.json`. */
|
|
6449
|
-
const inEnglish = (value, table) => {
|
|
6450
|
-
const text = stringOrNull(value);
|
|
6451
|
-
return text === null ? null : englishText(text, table);
|
|
6452
|
-
};
|
|
6453
|
-
const stringsOf = (value) => Array.isArray(value) ? value.filter((item) => typeof item === "string") : [];
|
|
6454
4878
|
const readManifest = async (root) => {
|
|
6455
4879
|
let raw;
|
|
6456
4880
|
try {
|
|
@@ -6490,10 +4914,9 @@ const lintProject = async (options) => {
|
|
|
6490
4914
|
if (options.built !== void 0 && !await isDirectory(options.built)) throw new CatalogUsageError("--built: not a directory", options.built);
|
|
6491
4915
|
const manifest = await readManifest(root);
|
|
6492
4916
|
const extensionId = stringOrNull(manifest.id) ?? path.basename(root);
|
|
6493
|
-
const english = await readEnglishTable(root);
|
|
6494
4917
|
const findings = manifestFindings({
|
|
6495
|
-
name:
|
|
6496
|
-
description:
|
|
4918
|
+
name: stringOrNull(manifest.name),
|
|
4919
|
+
description: stringOrNull(manifest.description),
|
|
6497
4920
|
author: stringOrNull(manifest.author),
|
|
6498
4921
|
tags: Array.isArray(manifest.tags) ? manifest.tags : null
|
|
6499
4922
|
});
|
|
@@ -6508,7 +4931,7 @@ const lintProject = async (options) => {
|
|
|
6508
4931
|
root,
|
|
6509
4932
|
built: path.resolve(options.built)
|
|
6510
4933
|
});
|
|
6511
|
-
findings.push(...bundleFindings(files
|
|
4934
|
+
findings.push(...bundleFindings(files));
|
|
6512
4935
|
return findings.map((finding) => ({
|
|
6513
4936
|
...finding,
|
|
6514
4937
|
severity: finding.ruleId === "CHECK-004" ? "error" : "warning",
|
|
@@ -6522,23 +4945,20 @@ const EXIT_OK = 0;
|
|
|
6522
4945
|
const EXIT_PROBLEMS = 1;
|
|
6523
4946
|
const EXIT_USAGE = 2;
|
|
6524
4947
|
const USAGE = `usage: dolphy-ext build [dir] [--out <dir>] [--watch]
|
|
6525
|
-
dolphy-ext types [dir]
|
|
6526
4948
|
dolphy-ext validate <dir>
|
|
6527
4949
|
dolphy-ext lint [dir] [--built <dir>]
|
|
6528
4950
|
dolphy-ext dev [dir] [--app <path>]
|
|
6529
4951
|
${CATALOG_SYNOPSIS}
|
|
6530
4952
|
build [dir] build the extension from a project (default: the current
|
|
6531
|
-
directory) into <dir>/dist-ext/<id
|
|
6532
|
-
|
|
6533
|
-
types [dir] write <dir>/.dolphy/ids.d.ts: the ids declared in
|
|
6534
|
-
extension.json as types for the SDK (no code is run)
|
|
4953
|
+
directory) into <dir>/dist-ext/<id>: main.mjs from the
|
|
4954
|
+
'server' export of src/index.ts, client.mjs from 'client'
|
|
6535
4955
|
validate <dir> check the directory of a built extension (extension.json,
|
|
6536
|
-
|
|
4956
|
+
main and client files, assets)
|
|
6537
4957
|
lint [dir] check the project before a pull request to the catalog:
|
|
6538
4958
|
manifest metadata, README.md and the built code (eval,
|
|
6539
|
-
obfuscation,
|
|
6540
|
-
|
|
6541
|
-
|
|
4959
|
+
obfuscation, source maps); lines «error|warning <id>
|
|
4960
|
+
<RULE-ID> <field>: <message>», exit code 1 only if
|
|
4961
|
+
README.md is missing
|
|
6542
4962
|
dev [dir] watch-build the project and launch the installed Dolphy app
|
|
6543
4963
|
with DOLPHY_DEV_EXTENSIONS=<dir>/dist-ext; Ctrl+C stops both.
|
|
6544
4964
|
The app is --app, then the DOLPHY_APP variable, then the
|
|
@@ -6575,15 +4995,6 @@ const parseBuild = (args) => {
|
|
|
6575
4995
|
watch
|
|
6576
4996
|
};
|
|
6577
4997
|
};
|
|
6578
|
-
const parseTypes = (args) => {
|
|
6579
|
-
const flag = args.find((arg) => arg.startsWith("-"));
|
|
6580
|
-
if (flag !== void 0) return { usageError: `unknown flag: ${flag}` };
|
|
6581
|
-
if (args.length > 1) return { usageError: `extra arguments: ${args.slice(1).join(" ")}` };
|
|
6582
|
-
return {
|
|
6583
|
-
command: "types",
|
|
6584
|
-
dir: args[0] ?? "."
|
|
6585
|
-
};
|
|
6586
|
-
};
|
|
6587
4998
|
const parseValidate = (args) => {
|
|
6588
4999
|
const flag = args.find((arg) => arg.startsWith("-"));
|
|
6589
5000
|
if (flag !== void 0) return { usageError: `unknown flag: ${flag}` };
|
|
@@ -6635,7 +5046,6 @@ const parseArgs = (argv) => {
|
|
|
6635
5046
|
if (argv.includes("--help") || argv.includes("-h")) return { help: true };
|
|
6636
5047
|
const [command, ...rest] = argv;
|
|
6637
5048
|
if (command === "build") return parseBuild(rest);
|
|
6638
|
-
if (command === "types") return parseTypes(rest);
|
|
6639
5049
|
if (command === "validate") return parseValidate(rest);
|
|
6640
5050
|
if (command === "lint") return parseLint(rest);
|
|
6641
5051
|
if (command === "dev") return parseDev(rest);
|
|
@@ -6684,8 +5094,7 @@ const runBuild = async (parsed, io, deps) => {
|
|
|
6684
5094
|
};
|
|
6685
5095
|
const runValidate = async (dir, io) => {
|
|
6686
5096
|
const root = path.resolve(dir);
|
|
6687
|
-
const { ok, problems
|
|
6688
|
-
for (const warning of warnings) io.stdout(`warning ${root}: ${warning}\n`);
|
|
5097
|
+
const { ok, problems } = await validateExtension(root);
|
|
6689
5098
|
if (ok) {
|
|
6690
5099
|
io.stdout(`${root}: ok\n`);
|
|
6691
5100
|
return 0;
|
|
@@ -6737,18 +5146,7 @@ const runDevCommand = async (parsed, io, deps) => {
|
|
|
6737
5146
|
return reportBuildError(io, error);
|
|
6738
5147
|
}
|
|
6739
5148
|
};
|
|
6740
|
-
|
|
6741
|
-
try {
|
|
6742
|
-
const root = path.resolve(dir);
|
|
6743
|
-
const { file, changed } = await generateTypes({ root });
|
|
6744
|
-
const shown = path.relative(root, file);
|
|
6745
|
-
io.stdout(changed ? `wrote ${shown}\n` : `${shown} is up to date\n`);
|
|
6746
|
-
return 0;
|
|
6747
|
-
} catch (error) {
|
|
6748
|
-
return reportBuildError(io, error);
|
|
6749
|
-
}
|
|
6750
|
-
};
|
|
6751
|
-
/** `dolphy-ext build|types|validate|lint|dev|catalog`; `argv` is without `node` and the script name. */
|
|
5149
|
+
/** `dolphy-ext build|validate|lint|dev|catalog`; `argv` is without `node` and the script name. */
|
|
6752
5150
|
const runCli = async (argv, io, deps = {}) => {
|
|
6753
5151
|
const parsed = parseArgs(argv);
|
|
6754
5152
|
if ("help" in parsed) {
|
|
@@ -6770,7 +5168,6 @@ const runCli = async (argv, io, deps = {}) => {
|
|
|
6770
5168
|
if (parsed.command === "validate") return runValidate(parsed.dir, io);
|
|
6771
5169
|
if (parsed.command === "lint") return runLint(parsed, io);
|
|
6772
5170
|
if (parsed.command === "dev") return runDevCommand(parsed, io, deps);
|
|
6773
|
-
if (parsed.command === "types") return runTypes(parsed.dir, io);
|
|
6774
5171
|
return runBuild(parsed, io, deps);
|
|
6775
5172
|
};
|
|
6776
5173
|
|