@dolphy-app/extension-tools 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -2
- package/dist/cli/main.js +2796 -318
- package/package.json +2 -1
package/dist/cli/main.js
CHANGED
|
@@ -1,15 +1,499 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { builtinModules } from "node:module";
|
|
3
3
|
import path from "node:path";
|
|
4
|
-
import { realpathSync, statSync, watch } from "node:fs";
|
|
4
|
+
import { existsSync, 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
6
|
import { Ajv2020 } from "ajv/dist/2020.js";
|
|
7
7
|
import { z } from "zod";
|
|
8
8
|
import { createHash } from "node:crypto";
|
|
9
|
+
import { spawn } from "node:child_process";
|
|
9
10
|
import { build, createLogger, parseAst } from "vite";
|
|
10
|
-
import
|
|
11
|
+
import MagicString from "magic-string";
|
|
12
|
+
import { homedir, tmpdir } from "node:os";
|
|
11
13
|
|
|
14
|
+
//#region packages/extension-tools/src/errors.ts
|
|
15
|
+
/** Extension build/check error; `subject` is the extension id or directory. */
|
|
16
|
+
var BuildError = class extends Error {
|
|
17
|
+
subject;
|
|
18
|
+
constructor(message, subject) {
|
|
19
|
+
super(message);
|
|
20
|
+
this.name = "BuildError";
|
|
21
|
+
this.subject = subject;
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
/** Invalid command input (nonexistent directory): exit code 2. */
|
|
25
|
+
var CatalogUsageError = class extends BuildError {};
|
|
26
|
+
|
|
27
|
+
//#endregion
|
|
28
|
+
//#region packages/extension-api/src/when.ts
|
|
29
|
+
/**
|
|
30
|
+
* Visibility conditions (`when`) of commands, panels and widgets. A condition
|
|
31
|
+
* is a small boolean expression over a closed set of typed keys of the app
|
|
32
|
+
* window (`WHEN_KEYS`). The functions are pure: the manifest check, the app
|
|
33
|
+
* window and `dolphy-ext validate` share them, and so can any dispatcher that
|
|
34
|
+
* can supply a `WhenContext`.
|
|
35
|
+
*
|
|
36
|
+
* Grammar (`!` binds tightest, then `&&`, then `||`):
|
|
37
|
+
*
|
|
38
|
+
* ```
|
|
39
|
+
* expr := term ('||' term)*
|
|
40
|
+
* term := factor ('&&' factor)*
|
|
41
|
+
* factor := '!' factor | '(' expr ')' | 'true' | 'false' | boolean-key | comparison
|
|
42
|
+
* comparison := key ('==' | '!=') literal | key 'in' '(' literal (',' literal)* ')'
|
|
43
|
+
* literal := 'text' | true | false
|
|
44
|
+
* ```
|
|
45
|
+
*
|
|
46
|
+
* `!` stands before a boolean key, a group or another `!`; negate a
|
|
47
|
+
* comparison with a group (`!(route == 'courses')`) or use `!=`.
|
|
48
|
+
*/
|
|
49
|
+
/** Names of the screens `route` can have: the route names of the app window. */
|
|
50
|
+
const WHEN_ROUTES = [
|
|
51
|
+
"daily-plan",
|
|
52
|
+
"courses",
|
|
53
|
+
"extension-panel",
|
|
54
|
+
"placement",
|
|
55
|
+
"session",
|
|
56
|
+
"settings",
|
|
57
|
+
"settings-learning",
|
|
58
|
+
"settings-library",
|
|
59
|
+
"settings-appearance",
|
|
60
|
+
"settings-shortcuts",
|
|
61
|
+
"settings-extensions",
|
|
62
|
+
"settings-extension-details",
|
|
63
|
+
"settings-about"
|
|
64
|
+
];
|
|
65
|
+
/** Interface languages a `locale` condition can name. */
|
|
66
|
+
const WHEN_LOCALES = ["ru", "en"];
|
|
67
|
+
/**
|
|
68
|
+
* Closed set of keys with their types.
|
|
69
|
+
*
|
|
70
|
+
* - `route`: the screen shown (a value of `WHEN_ROUTES`).
|
|
71
|
+
* - `course.active`: a course is in focus (the course switcher is not on "all courses").
|
|
72
|
+
* - `session.active`: the learning session screen is open.
|
|
73
|
+
* - `locale`: the interface language after resolving the "system" mode.
|
|
74
|
+
* - `theme.dark`: the current theme is dark.
|
|
75
|
+
*/
|
|
76
|
+
const WHEN_KEYS = Object.freeze({
|
|
77
|
+
route: {
|
|
78
|
+
type: "string",
|
|
79
|
+
values: WHEN_ROUTES
|
|
80
|
+
},
|
|
81
|
+
"course.active": { type: "boolean" },
|
|
82
|
+
"session.active": { type: "boolean" },
|
|
83
|
+
locale: {
|
|
84
|
+
type: "string",
|
|
85
|
+
values: WHEN_LOCALES
|
|
86
|
+
},
|
|
87
|
+
"theme.dark": { type: "boolean" }
|
|
88
|
+
});
|
|
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
|
+
|
|
330
|
+
//#endregion
|
|
331
|
+
//#region packages/extension-api/src/locale.ts
|
|
332
|
+
/**
|
|
333
|
+
* Manifest localization. A string that is entirely `%key%` in a localizable
|
|
334
|
+
* manifest field is replaced by the value of `key` from `locales/<language>.json`.
|
|
335
|
+
* The functions are pure: the app window, the engine and the tools share them.
|
|
336
|
+
*/
|
|
337
|
+
/** Languages of `locales/<language>.json` (the app interface languages). */
|
|
338
|
+
const EXTENSION_LOCALES = ["ru", "en"];
|
|
339
|
+
/** The root of the fallback chain and the only language of the catalog. */
|
|
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
|
|
355
|
+
});
|
|
356
|
+
const isExtensionLocale = (value) => EXTENSION_LOCALES.includes(value);
|
|
357
|
+
/** The key of `%key%`; `null` if the string is not a placeholder as a whole. */
|
|
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
|
+
};
|
|
490
|
+
|
|
491
|
+
//#endregion
|
|
12
492
|
//#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
|
+
*/
|
|
13
497
|
const EXTENSION_ID_PATTERN = /^[a-z][a-z0-9-]*(\.[a-z][a-z0-9-]*)*$/;
|
|
14
498
|
/** GitHub login of the extension author (`author` in the manifest and catalog). */
|
|
15
499
|
const GITHUB_LOGIN_PATTERN = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})$/;
|
|
@@ -20,7 +504,9 @@ const EXTENSION_PERMISSIONS = [
|
|
|
20
504
|
"worker.threads",
|
|
21
505
|
"native.addons",
|
|
22
506
|
"network",
|
|
23
|
-
"learning.events"
|
|
507
|
+
"learning.events",
|
|
508
|
+
"learning.stats",
|
|
509
|
+
"notifications"
|
|
24
510
|
];
|
|
25
511
|
/** Platforms the extension can run on (`process.platform`). */
|
|
26
512
|
const EXTENSION_PLATFORMS = [
|
|
@@ -39,6 +525,77 @@ const EXTENSION_TAGS = [
|
|
|
39
525
|
"developer"
|
|
40
526
|
];
|
|
41
527
|
const ELEMENT_NAME_PATTERN = /^[a-z][a-z0-9]*(-[a-z0-9]+)+$/;
|
|
528
|
+
/**
|
|
529
|
+
* Closed list of icon names an extension picks from (`icon` of a command or panel); the
|
|
530
|
+
* app draws its own glyph for each name, so nothing from the extension is rendered as an image.
|
|
531
|
+
*/
|
|
532
|
+
const EXTENSION_ICONS = [
|
|
533
|
+
"puzzle",
|
|
534
|
+
"book",
|
|
535
|
+
"brain",
|
|
536
|
+
"calendar",
|
|
537
|
+
"chart",
|
|
538
|
+
"check",
|
|
539
|
+
"clock",
|
|
540
|
+
"cog",
|
|
541
|
+
"fire",
|
|
542
|
+
"flag",
|
|
543
|
+
"heart",
|
|
544
|
+
"help",
|
|
545
|
+
"home",
|
|
546
|
+
"idea",
|
|
547
|
+
"list",
|
|
548
|
+
"message",
|
|
549
|
+
"pencil",
|
|
550
|
+
"play",
|
|
551
|
+
"star",
|
|
552
|
+
"target",
|
|
553
|
+
"trophy",
|
|
554
|
+
"bell",
|
|
555
|
+
"bookmark",
|
|
556
|
+
"tag"
|
|
557
|
+
];
|
|
558
|
+
/** Icon of a command or panel without `icon`. */
|
|
559
|
+
const DEFAULT_EXTENSION_ICON = "puzzle";
|
|
560
|
+
/** Where on the screen a widget is shown; `dailyPlan` is the "Daily plan" page. */
|
|
561
|
+
const EXTENSION_WIDGET_SLOTS = ["dailyPlan"];
|
|
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
|
+
});
|
|
571
|
+
/** `at` of a `daily` schedule: `HH:MM`, 24-hour clock, local time. */
|
|
572
|
+
const SCHEDULE_AT_PATTERN = /^(?:[01]\d|2[0-3]):[0-5]\d$/;
|
|
573
|
+
/** `at` of a `daily` schedule without one. */
|
|
574
|
+
const DEFAULT_SCHEDULE_AT = "09:00";
|
|
575
|
+
/** Limits on schedules; the manifest, the scheduler, and the runtime enforce them. */
|
|
576
|
+
const EXTENSION_SCHEDULE_LIMITS = Object.freeze({
|
|
577
|
+
/** Schedules per extension. */
|
|
578
|
+
schedules: 4,
|
|
579
|
+
/** Handler budget, ms. */
|
|
580
|
+
handlerMs: 1e4,
|
|
581
|
+
/** A firing found later than this after its moment (the app was closed or asleep) is skipped, ms. */
|
|
582
|
+
lateMs: 12e4,
|
|
583
|
+
/** How often the app looks for due firings, ms. */
|
|
584
|
+
tickMs: 3e4
|
|
585
|
+
});
|
|
586
|
+
/** Limits of the settings types (`text`, `color`, `list`, `group`, `order`); they match those checked by the manifest and the engine. */
|
|
587
|
+
const SETTING_LIMITS = Object.freeze({
|
|
588
|
+
/** `maxLength` of `string` and `text`. */
|
|
589
|
+
stringLength: 1e4,
|
|
590
|
+
/** `maxItems` of `list`. */
|
|
591
|
+
listItems: 50,
|
|
592
|
+
/** `itemMaxLength` of `list`. */
|
|
593
|
+
listItemLength: 200,
|
|
594
|
+
groupLength: 60,
|
|
595
|
+
orderMax: 1e3
|
|
596
|
+
});
|
|
597
|
+
/** `#rrggbb` (any case in the manifest; the stored value is lower-case). */
|
|
598
|
+
const COLOR_SETTING_PATTERN = /^#[0-9a-fA-F]{6}$/;
|
|
42
599
|
/** Learning events an extension with the `learning.events` permission can subscribe to. */
|
|
43
600
|
const LEARNING_EVENT_NAMES = [
|
|
44
601
|
"session.started",
|
|
@@ -87,15 +644,24 @@ const THEME_VARIABLE_KEYS = [
|
|
|
87
644
|
];
|
|
88
645
|
const DEFAULT_MARKDOWN_RENDERER = "./markdown.mjs";
|
|
89
646
|
const DEFAULT_PANEL = "./panel.mjs";
|
|
647
|
+
const DEFAULT_WIDGET = "./widget.mjs";
|
|
648
|
+
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]*\\]|[\`\\-=\\[\\]\\\\;',./+])`;
|
|
90
649
|
/**
|
|
91
|
-
*
|
|
92
|
-
* `
|
|
93
|
-
*
|
|
94
|
-
*
|
|
650
|
+
* Key notation: one stroke or two strokes separated by a space
|
|
651
|
+
* (`Mod+K Mod+S`). A stroke is up to three modifiers (`Mod`, `Ctrl`, `Alt`,
|
|
652
|
+
* `Shift`, `Cmd`, `Meta`, `Win`, `Super`, `Option`, ...) and a key joined with
|
|
653
|
+
* `+`: a letter or digit, `F1`–`F24`, a punctuation mark, a physical key
|
|
654
|
+
* (`[KeyK]`) or a name (`Enter`, `Space`, `Tab`, `Escape`, `Insert`, arrows,
|
|
655
|
+
* `Home`, `End`, `PageUp`, `PageDown`). A superset for the JSON Schema; the
|
|
656
|
+
* host validates every string authoritatively with `@dolphy-app/keybindings`.
|
|
95
657
|
*/
|
|
96
|
-
const KEYBINDING_PATTERN =
|
|
658
|
+
const KEYBINDING_PATTERN = new RegExp(`^${KEYBINDING_STROKE}(?: ${KEYBINDING_STROKE})?$`);
|
|
97
659
|
/** Limits on commands and panels (R1, R3); they match those checked by the manifest, host, and engine. */
|
|
98
660
|
const EXTENSION_COMMAND_LIMITS = Object.freeze({
|
|
661
|
+
/** Keybinding entries (`keybindings`) per command. */
|
|
662
|
+
keybindingsPerCommand: 4,
|
|
663
|
+
/** Length of a `when` condition (of a command, panel, widget or `keybindings[]` entry). */
|
|
664
|
+
whenLength: 200,
|
|
99
665
|
/** Commands per extension. */
|
|
100
666
|
commands: 64,
|
|
101
667
|
/** Panels per extension. */
|
|
@@ -112,6 +678,33 @@ const EXTENSION_COMMAND_LIMITS = Object.freeze({
|
|
|
112
678
|
/** Handler budget, ms. */
|
|
113
679
|
handlerMs: 1e4
|
|
114
680
|
});
|
|
681
|
+
/** A file extension an importer accepts: a dot and 1–16 lower-case letters or digits. */
|
|
682
|
+
const TRANSFER_ACCEPT_PATTERN = /^\.[a-z0-9]{1,16}$/;
|
|
683
|
+
/** Limits on importers and exporters; the manifest, host, and engine check the same numbers. */
|
|
684
|
+
const EXTENSION_TRANSFER_LIMITS = Object.freeze({
|
|
685
|
+
/** Importers per extension. */
|
|
686
|
+
importers: 8,
|
|
687
|
+
/** Exporters per extension. */
|
|
688
|
+
exporters: 8,
|
|
689
|
+
/** Entries in `accept` of one importer. */
|
|
690
|
+
acceptExtensions: 8,
|
|
691
|
+
/** Handler budget, ms (import and export). */
|
|
692
|
+
handlerMs: 3e4,
|
|
693
|
+
/** Size of the file the user picks for an importer, bytes. */
|
|
694
|
+
inputBytes: 20971520,
|
|
695
|
+
/** Files in the directory an importer returns. */
|
|
696
|
+
files: 5e3,
|
|
697
|
+
/** One file of the returned directory, UTF-8 bytes. */
|
|
698
|
+
fileBytes: 2097152,
|
|
699
|
+
/** All files of the returned directory (and of a course snapshot), UTF-8 bytes. */
|
|
700
|
+
totalBytes: 20971520,
|
|
701
|
+
/** Size of the file an exporter returns, bytes. */
|
|
702
|
+
outputBytes: 20971520,
|
|
703
|
+
/** Length of the file name an exporter returns. */
|
|
704
|
+
filenameChars: 120,
|
|
705
|
+
/** One path of the returned directory, UTF-8 bytes. */
|
|
706
|
+
pathBytes: 1024
|
|
707
|
+
});
|
|
115
708
|
const DEFAULT_MAIN = "./main.mjs";
|
|
116
709
|
const DEFAULT_RENDERER = "./view.mjs";
|
|
117
710
|
/** Default element tag: `dolphy.sql` → `dolphy-sql-answer`. */
|
|
@@ -127,6 +720,30 @@ const EXTENSION_STORAGE_LIMITS = Object.freeze({
|
|
|
127
720
|
/** Sum of JSON texts of all values in UTF-8 bytes. */
|
|
128
721
|
totalBytes: 1048576
|
|
129
722
|
});
|
|
723
|
+
/** Secret limits; the engine enforces them (`StorageQuotaError`, kinds `key-length`, `value-size`, `key-count`). */
|
|
724
|
+
const EXTENSION_SECRET_LIMITS = Object.freeze({
|
|
725
|
+
/** Key length in UTF-16 code units. */
|
|
726
|
+
keyLength: 128,
|
|
727
|
+
/** Value size in UTF-8 bytes. */
|
|
728
|
+
valueBytes: 4096,
|
|
729
|
+
/** Number of keys. */
|
|
730
|
+
keys: 32
|
|
731
|
+
});
|
|
732
|
+
/** Limits of `ctx.stats`; the engine enforces them. */
|
|
733
|
+
const EXTENSION_STATS_LIMITS = Object.freeze({
|
|
734
|
+
/** Most dates in one `daily` range (both ends included). */
|
|
735
|
+
dailyDays: 366 });
|
|
736
|
+
/** Limits of `ctx.notifications`; the engine enforces them. */
|
|
737
|
+
const EXTENSION_NOTIFICATION_LIMITS = Object.freeze({
|
|
738
|
+
/** Title length in characters (code points). */
|
|
739
|
+
titleLength: 80,
|
|
740
|
+
/** Body length in characters (code points). */
|
|
741
|
+
bodyLength: 300,
|
|
742
|
+
/** Notifications per rolling minute and extension. */
|
|
743
|
+
perMinute: 3,
|
|
744
|
+
/** Notifications per rolling hour and extension. */
|
|
745
|
+
perHour: 30
|
|
746
|
+
});
|
|
130
747
|
|
|
131
748
|
//#endregion
|
|
132
749
|
//#region packages/extension-host/src/protocol.ts
|
|
@@ -136,12 +753,30 @@ const eventParams = z.strictObject({
|
|
|
136
753
|
payload: z.record(z.string(), z.unknown()),
|
|
137
754
|
isolated: z.boolean()
|
|
138
755
|
});
|
|
756
|
+
const scheduleParams = z.strictObject({
|
|
757
|
+
extensionId: z.string(),
|
|
758
|
+
scheduleId: z.string(),
|
|
759
|
+
isolated: z.boolean()
|
|
760
|
+
});
|
|
139
761
|
const commandParams = z.strictObject({
|
|
140
762
|
extensionId: z.string(),
|
|
141
763
|
commandId: z.string(),
|
|
142
764
|
args: z.unknown().optional(),
|
|
143
765
|
isolated: z.boolean()
|
|
144
766
|
});
|
|
767
|
+
const bytesField = z.custom((value) => value instanceof Uint8Array, "must be a Uint8Array");
|
|
768
|
+
const importParams = {
|
|
769
|
+
extensionId: z.string(),
|
|
770
|
+
importerId: z.string(),
|
|
771
|
+
name: z.string(),
|
|
772
|
+
isolated: z.boolean()
|
|
773
|
+
};
|
|
774
|
+
const exportInput = z.discriminatedUnion("scope", [z.strictObject({
|
|
775
|
+
scope: z.literal("course"),
|
|
776
|
+
courseId: z.string(),
|
|
777
|
+
title: z.string(),
|
|
778
|
+
files: z.record(z.string(), z.string())
|
|
779
|
+
}), z.strictObject({ scope: z.literal("progress") })]);
|
|
145
780
|
const typed = {
|
|
146
781
|
type: z.string(),
|
|
147
782
|
exerciseId: z.string(),
|
|
@@ -191,16 +826,43 @@ const extRequestSchema = z.discriminatedUnion("method", [
|
|
|
191
826
|
method: z.literal("deliverEvent"),
|
|
192
827
|
params: eventParams
|
|
193
828
|
}),
|
|
829
|
+
z.strictObject({
|
|
830
|
+
id: z.string(),
|
|
831
|
+
method: z.literal("fireSchedule"),
|
|
832
|
+
params: scheduleParams
|
|
833
|
+
}),
|
|
194
834
|
z.strictObject({
|
|
195
835
|
id: z.string(),
|
|
196
836
|
method: z.literal("invokeCommand"),
|
|
197
837
|
params: commandParams
|
|
838
|
+
}),
|
|
839
|
+
z.strictObject({
|
|
840
|
+
id: z.string(),
|
|
841
|
+
method: z.literal("runImporter"),
|
|
842
|
+
params: z.union([z.strictObject({
|
|
843
|
+
...importParams,
|
|
844
|
+
text: z.string()
|
|
845
|
+
}), z.strictObject({
|
|
846
|
+
...importParams,
|
|
847
|
+
bytes: bytesField
|
|
848
|
+
})])
|
|
849
|
+
}),
|
|
850
|
+
z.strictObject({
|
|
851
|
+
id: z.string(),
|
|
852
|
+
method: z.literal("runExporter"),
|
|
853
|
+
params: z.strictObject({
|
|
854
|
+
extensionId: z.string(),
|
|
855
|
+
exporterId: z.string(),
|
|
856
|
+
input: exportInput,
|
|
857
|
+
isolated: z.boolean()
|
|
858
|
+
})
|
|
198
859
|
})
|
|
199
860
|
]);
|
|
200
861
|
const settingValue = z.union([
|
|
201
862
|
z.boolean(),
|
|
202
863
|
z.string(),
|
|
203
|
-
z.number()
|
|
864
|
+
z.number(),
|
|
865
|
+
z.array(z.string())
|
|
204
866
|
]);
|
|
205
867
|
const settingChangedSchema = z.strictObject({
|
|
206
868
|
method: z.literal("settingChanged"),
|
|
@@ -241,10 +903,82 @@ const hostRequestSchema = z.discriminatedUnion("method", [
|
|
|
241
903
|
method: z.literal("storage.keys"),
|
|
242
904
|
params: hostOwner
|
|
243
905
|
}),
|
|
906
|
+
z.strictObject({
|
|
907
|
+
id: z.string(),
|
|
908
|
+
method: z.literal("secrets.get"),
|
|
909
|
+
params: hostKey
|
|
910
|
+
}),
|
|
911
|
+
z.strictObject({
|
|
912
|
+
id: z.string(),
|
|
913
|
+
method: z.literal("secrets.set"),
|
|
914
|
+
params: z.strictObject({
|
|
915
|
+
extensionId: z.string(),
|
|
916
|
+
key: z.string(),
|
|
917
|
+
value: z.unknown()
|
|
918
|
+
})
|
|
919
|
+
}),
|
|
920
|
+
z.strictObject({
|
|
921
|
+
id: z.string(),
|
|
922
|
+
method: z.literal("secrets.delete"),
|
|
923
|
+
params: hostKey
|
|
924
|
+
}),
|
|
244
925
|
z.strictObject({
|
|
245
926
|
id: z.string(),
|
|
246
927
|
method: z.literal("settings.all"),
|
|
247
928
|
params: hostOwner
|
|
929
|
+
}),
|
|
930
|
+
z.strictObject({
|
|
931
|
+
id: z.string(),
|
|
932
|
+
method: z.literal("stats.streak"),
|
|
933
|
+
params: z.strictObject({
|
|
934
|
+
extensionId: z.string(),
|
|
935
|
+
courseId: z.string().optional()
|
|
936
|
+
})
|
|
937
|
+
}),
|
|
938
|
+
z.strictObject({
|
|
939
|
+
id: z.string(),
|
|
940
|
+
method: z.literal("stats.daily"),
|
|
941
|
+
params: z.strictObject({
|
|
942
|
+
extensionId: z.string(),
|
|
943
|
+
from: z.string(),
|
|
944
|
+
to: z.string(),
|
|
945
|
+
courseId: z.string().optional()
|
|
946
|
+
})
|
|
947
|
+
}),
|
|
948
|
+
z.strictObject({
|
|
949
|
+
id: z.string(),
|
|
950
|
+
method: z.literal("notifications.show"),
|
|
951
|
+
params: z.strictObject({
|
|
952
|
+
extensionId: z.string(),
|
|
953
|
+
title: z.string(),
|
|
954
|
+
body: z.string()
|
|
955
|
+
})
|
|
956
|
+
}),
|
|
957
|
+
z.strictObject({
|
|
958
|
+
id: z.string(),
|
|
959
|
+
method: z.literal("health.report"),
|
|
960
|
+
params: z.discriminatedUnion("kind", [
|
|
961
|
+
z.strictObject({
|
|
962
|
+
extensionId: z.string(),
|
|
963
|
+
kind: z.literal("activated"),
|
|
964
|
+
durationMs: z.number().finite().nonnegative()
|
|
965
|
+
}),
|
|
966
|
+
z.strictObject({
|
|
967
|
+
extensionId: z.string(),
|
|
968
|
+
kind: z.literal("failed"),
|
|
969
|
+
reason: z.string(),
|
|
970
|
+
message: z.string()
|
|
971
|
+
}),
|
|
972
|
+
z.strictObject({
|
|
973
|
+
extensionId: z.string(),
|
|
974
|
+
kind: z.literal("suppressed"),
|
|
975
|
+
until: z.number().finite().nonnegative()
|
|
976
|
+
}),
|
|
977
|
+
z.strictObject({
|
|
978
|
+
extensionId: z.string(),
|
|
979
|
+
kind: z.literal("reset")
|
|
980
|
+
})
|
|
981
|
+
])
|
|
248
982
|
})
|
|
249
983
|
]);
|
|
250
984
|
const hostResponseSchema = z.union([z.strictObject({
|
|
@@ -263,7 +997,7 @@ const hostResponseSchema = z.union([z.strictObject({
|
|
|
263
997
|
const isResolvedExtension = (value) => {
|
|
264
998
|
if (typeof value !== "object" || value === null) return false;
|
|
265
999
|
const item = value;
|
|
266
|
-
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") && Array.isArray(item.permissions) && Array.isArray(item.exerciseTypes) && Array.isArray(item.themes) && Array.isArray(item.markdownRenderers) && Array.isArray(item.gradePolicies) && Array.isArray(item.settings) && Array.isArray(item.events) && Array.isArray(item.commands) && Array.isArray(item.panels);
|
|
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") && Array.isArray(item.permissions) && Array.isArray(item.dependencies) && typeof item.messages === "object" && item.messages !== null && Array.isArray(item.warnings) && Array.isArray(item.exerciseTypes) && Array.isArray(item.themes) && Array.isArray(item.markdownRenderers) && Array.isArray(item.gradePolicies) && Array.isArray(item.settings) && Array.isArray(item.events) && Array.isArray(item.commands) && Array.isArray(item.panels) && Array.isArray(item.widgets) && Array.isArray(item.schedules) && Array.isArray(item.importers) && Array.isArray(item.exporters);
|
|
267
1001
|
};
|
|
268
1002
|
/** Набор приходит от движка того же приложения, поэтому проверяется форма, а не каждое поле. */
|
|
269
1003
|
const replaceExtensionsSchema = z.strictObject({
|
|
@@ -293,24 +1027,24 @@ const gradeValueSchema = z.union([z.literal([
|
|
|
293
1027
|
5
|
|
294
1028
|
]), z.null()]);
|
|
295
1029
|
const reason = z.string().min(1).max(100);
|
|
296
|
-
const text$
|
|
1030
|
+
const text$2 = z.string().max(4e3);
|
|
297
1031
|
const gradeResultSchema = z.discriminatedUnion("outcome", [
|
|
298
1032
|
z.strictObject({
|
|
299
1033
|
outcome: z.literal("passed"),
|
|
300
|
-
feedback: text$
|
|
1034
|
+
feedback: text$2.optional(),
|
|
301
1035
|
data: z.unknown().optional()
|
|
302
1036
|
}),
|
|
303
1037
|
z.strictObject({
|
|
304
1038
|
outcome: z.literal("failed"),
|
|
305
1039
|
reason,
|
|
306
|
-
feedback: text$
|
|
307
|
-
detail: text$
|
|
1040
|
+
feedback: text$2.optional(),
|
|
1041
|
+
detail: text$2.optional(),
|
|
308
1042
|
data: z.unknown().optional()
|
|
309
1043
|
}),
|
|
310
1044
|
z.strictObject({
|
|
311
1045
|
outcome: z.literal("error"),
|
|
312
1046
|
reason,
|
|
313
|
-
feedback: text$
|
|
1047
|
+
feedback: text$2.optional(),
|
|
314
1048
|
data: z.unknown().optional()
|
|
315
1049
|
})
|
|
316
1050
|
]);
|
|
@@ -347,6 +1081,38 @@ const commandOutcomeSchema = z.discriminatedUnion("kind", [
|
|
|
347
1081
|
})
|
|
348
1082
|
]);
|
|
349
1083
|
|
|
1084
|
+
//#endregion
|
|
1085
|
+
//#region packages/extension-host/src/diagnostics.ts
|
|
1086
|
+
const text$1 = (data, key) => {
|
|
1087
|
+
const value = data[key];
|
|
1088
|
+
return Array.isArray(value) ? value.join(", ") : String(value ?? "");
|
|
1089
|
+
};
|
|
1090
|
+
const rangeSuffix = (data) => data.range === void 0 ? "" : ` ${text$1(data, "range")}`;
|
|
1091
|
+
const FORMATTERS = {
|
|
1092
|
+
"manifest-unreadable": (data) => `extension.json is unreadable: ${text$1(data, "reason")}`,
|
|
1093
|
+
"manifest-invalid": (data) => Array.isArray(data.issues) ? data.issues.join("; ") : text$1(data, "issues"),
|
|
1094
|
+
"id-mismatch": (data) => `directory name '${text$1(data, "expected")}' does not match manifest id '${text$1(data, "actual")}'`,
|
|
1095
|
+
"requires-app": (data) => `requires app >= ${text$1(data, "minAppVersion")}`,
|
|
1096
|
+
"unavailable-platform": (data) => `not available on ${text$1(data, "platform")}`,
|
|
1097
|
+
"claim-clash": (data) => `${text$1(data, "kind")} '${text$1(data, "name")}' is already provided by '${text$1(data, "by")}'`,
|
|
1098
|
+
"load-failed": (data) => text$1(data, "reason"),
|
|
1099
|
+
"overridden-by": (data) => `overridden by ${text$1(data, "origin")} ${text$1(data, "version")}`,
|
|
1100
|
+
"safe-mode": () => "disabled in safe mode",
|
|
1101
|
+
"dependency-missing": (data) => `requires extension '${text$1(data, "id")}'${rangeSuffix(data)}, which is not installed`,
|
|
1102
|
+
"dependency-disabled": (data) => `requires extension '${text$1(data, "id")}'${rangeSuffix(data)}, which is disabled`,
|
|
1103
|
+
"dependency-version": (data) => `requires extension '${text$1(data, "id")}' ${text$1(data, "range")}, found ${text$1(data, "found")}`,
|
|
1104
|
+
"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")}`
|
|
1108
|
+
};
|
|
1109
|
+
/**
|
|
1110
|
+
* English text of a diagnostic: the single place that renders it for the CLI,
|
|
1111
|
+
* logs and the installer. The app window builds its own localized text from
|
|
1112
|
+
* the code and data.
|
|
1113
|
+
*/
|
|
1114
|
+
const formatDiagnostic = ({ code, data }) => FORMATTERS[code](data);
|
|
1115
|
+
|
|
350
1116
|
//#endregion
|
|
351
1117
|
//#region packages/extension-catalog/src/assets.ts
|
|
352
1118
|
/**
|
|
@@ -354,8 +1120,8 @@ const commandOutcomeSchema = z.discriminatedUnion("kind", [
|
|
|
354
1120
|
* signature/geometry readers for the binary formats. Pure functions over bytes: the
|
|
355
1121
|
* installer, the protocol and the author tools read the same constants.
|
|
356
1122
|
*/
|
|
357
|
-
/**
|
|
358
|
-
const
|
|
1123
|
+
/** Extensions of a version's own files: manifest, code, schemas, documentation. */
|
|
1124
|
+
const SOURCE_FILE_EXTENSIONS = [
|
|
359
1125
|
"json",
|
|
360
1126
|
"js",
|
|
361
1127
|
"mjs",
|
|
@@ -372,8 +1138,8 @@ const ASSET_EXTENSIONS = [
|
|
|
372
1138
|
"jpeg",
|
|
373
1139
|
"woff2"
|
|
374
1140
|
];
|
|
375
|
-
/** Every extension a file of a version may have
|
|
376
|
-
const CATALOG_FILE_EXTENSIONS = [...
|
|
1141
|
+
/** Every extension a file of a version may have. */
|
|
1142
|
+
const CATALOG_FILE_EXTENSIONS = [...SOURCE_FILE_EXTENSIONS, ...ASSET_EXTENSIONS];
|
|
377
1143
|
const KIB = 1024;
|
|
378
1144
|
/** Per-file ceilings in bytes. `icon` bounds the manifest icon, which travels inside the index. */
|
|
379
1145
|
const ASSET_LIMITS = {
|
|
@@ -693,26 +1459,37 @@ const minAppVersionFailure = (minAppVersion, appVersion) => minAppVersion !== nu
|
|
|
693
1459
|
/** `minAppVersion` и `platforms` манифеста или записи каталога. */
|
|
694
1460
|
const checkCompatibility = (requirements, context) => minAppVersionFailure(requirements.minAppVersion, context.appVersion) ?? platformFailure(requirements.platforms, context.platform);
|
|
695
1461
|
|
|
1462
|
+
//#endregion
|
|
1463
|
+
//#region packages/extension-catalog/src/install-meta.ts
|
|
1464
|
+
const httpUrl = z.url().refine((value) => /^https?:\/\//i.test(value), "catalogUrl must be an http(s) URL");
|
|
1465
|
+
const installMetaSchema = z.strictObject({
|
|
1466
|
+
catalogUrl: httpUrl,
|
|
1467
|
+
version: z.string().refine(isSemver, "version must be semver"),
|
|
1468
|
+
installedAt: z.iso.datetime()
|
|
1469
|
+
});
|
|
1470
|
+
|
|
696
1471
|
//#endregion
|
|
697
1472
|
//#region packages/extension-catalog/src/schema.ts
|
|
698
|
-
/** Format of `index.json
|
|
699
|
-
const LEGACY_SCHEMA_VERSION = 1;
|
|
700
|
-
/** Format of `index.v2.json`: asset file types, icons, up to `MAX_FILES_V2` files. */
|
|
1473
|
+
/** Format of `index.v2.json`, the only index: asset file types, icons, up to `MAX_FILES_V2` files. */
|
|
701
1474
|
const CATALOG_SCHEMA_VERSION = 2;
|
|
702
1475
|
const MAX_VERSIONS = 5;
|
|
703
|
-
const MAX_FILES = 50;
|
|
704
1476
|
const MAX_TOTAL_BYTES = 1e7;
|
|
705
1477
|
/** Longest contribution title in `titles` (the manifest limit of `label`/`title`). */
|
|
706
1478
|
const MAX_TITLE_LENGTH = 60;
|
|
707
1479
|
/** Most tags of a version. */
|
|
708
1480
|
const MAX_TAGS = 5;
|
|
709
|
-
/** Contribution points whose entries carry a human title in the manifest (`label` or `title`). */
|
|
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. */
|
|
710
1482
|
const TITLED_POINTS = [
|
|
1483
|
+
"exerciseTypes",
|
|
1484
|
+
"markdownRenderers",
|
|
711
1485
|
"themes",
|
|
712
1486
|
"gradePolicies",
|
|
713
1487
|
"settings",
|
|
714
1488
|
"commands",
|
|
715
|
-
"panels"
|
|
1489
|
+
"panels",
|
|
1490
|
+
"widgets",
|
|
1491
|
+
"importers",
|
|
1492
|
+
"exporters"
|
|
716
1493
|
];
|
|
717
1494
|
const MAX_PATH_LENGTH = 200;
|
|
718
1495
|
const SHA256 = /^[0-9a-f]{64}$/;
|
|
@@ -720,23 +1497,20 @@ const SHA256 = /^[0-9a-f]{64}$/;
|
|
|
720
1497
|
const SEGMENT = /^[A-Za-z0-9_-][A-Za-z0-9._-]*$/;
|
|
721
1498
|
/** Имена устройств Windows: недоступны как файлы, с расширением или без. */
|
|
722
1499
|
const RESERVED_NAME = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(\..*)?$/i;
|
|
1500
|
+
const describeIssues = (error, prefix = "") => error.issues.map((issue) => `${prefix}${issue.path.join(".") || "/"}: ${issue.message}`);
|
|
723
1501
|
const semver = z.string().refine(isSemver, "must be semver");
|
|
724
1502
|
const timestamp = z.iso.datetime({ offset: true });
|
|
725
1503
|
const httpsUrl = z.url().refine((value) => value.startsWith("https://"), "must be an https URL");
|
|
726
1504
|
const isSafeSegment = (segment) => SEGMENT.test(segment) && !segment.endsWith(".") && !RESERVED_NAME.test(segment);
|
|
727
|
-
/**
|
|
728
|
-
const
|
|
1505
|
+
/** Path of a version file: safe segments, an allowed extension. */
|
|
1506
|
+
const isSafeCatalogPath = (value) => {
|
|
729
1507
|
if (value.length > MAX_PATH_LENGTH) return false;
|
|
730
1508
|
if (!value.split("/").every(isSafeSegment)) return false;
|
|
731
1509
|
const extension = extensionOf(value);
|
|
732
|
-
return extension !== null &&
|
|
1510
|
+
return extension !== null && CATALOG_FILE_EXTENSIONS.includes(extension);
|
|
733
1511
|
};
|
|
734
|
-
/** Path of a version file in the full index: safe segments, an allowed extension. */
|
|
735
|
-
const isSafeCatalogPath = (value) => isSafePathWith(value, CATALOG_FILE_EXTENSIONS);
|
|
736
|
-
/** Path of a version file in the first format (`index.json`). */
|
|
737
|
-
const isLegacyCatalogPath = (value) => isSafePathWith(value, LEGACY_FILE_EXTENSIONS);
|
|
738
1512
|
/**
|
|
739
|
-
* Каталог файлов версии относительно адреса
|
|
1513
|
+
* Каталог файлов версии относительно адреса индекса (`extensions/<id>/<version>/`).
|
|
740
1514
|
* Абсолютный адрес не допускается: файлы версии по построению лежат на origin индекса,
|
|
741
1515
|
* а локальный каталог разработчика и e2e не требуют https.
|
|
742
1516
|
*/
|
|
@@ -776,9 +1550,44 @@ const revokedSchema = z.strictObject({
|
|
|
776
1550
|
versions: rangeText,
|
|
777
1551
|
reason: z.string().min(1).max(300)
|
|
778
1552
|
});
|
|
1553
|
+
/** Deprecation of an entry: a warning, not a revocation. `versions: null` — every version. */
|
|
1554
|
+
const MAX_DEPRECATION_REASON = 200;
|
|
1555
|
+
const MAX_ALTERNATIVES = 3;
|
|
1556
|
+
const deprecatedShape = {
|
|
1557
|
+
versions: rangeText.nullable(),
|
|
1558
|
+
reason: z.string().min(1).max(200),
|
|
1559
|
+
alternatives: z.array(extensionId$1).max(3)
|
|
1560
|
+
};
|
|
1561
|
+
const strictDeprecated = z.strictObject(deprecatedShape);
|
|
1562
|
+
/** The tolerant reader drops an unreadable `deprecated`; the entry stays. */
|
|
1563
|
+
const tolerantDeprecated = z.object(deprecatedShape).optional().catch(void 0);
|
|
1564
|
+
/** One item of `deprecated.json` in the catalog repository: no `versions` — every version. */
|
|
1565
|
+
const deprecatedItemSchema = z.strictObject({
|
|
1566
|
+
id: extensionId$1,
|
|
1567
|
+
versions: rangeText.optional(),
|
|
1568
|
+
reason: deprecatedShape.reason,
|
|
1569
|
+
alternatives: deprecatedShape.alternatives
|
|
1570
|
+
});
|
|
1571
|
+
const deprecatedListSchema = z.array(deprecatedItemSchema).superRefine((items, ctx) => {
|
|
1572
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1573
|
+
items.forEach((item, position) => {
|
|
1574
|
+
if (seen.has(item.id)) ctx.addIssue({
|
|
1575
|
+
code: "custom",
|
|
1576
|
+
path: [position, "id"],
|
|
1577
|
+
message: `duplicate id '${item.id}'`
|
|
1578
|
+
});
|
|
1579
|
+
seen.add(item.id);
|
|
1580
|
+
});
|
|
1581
|
+
});
|
|
1582
|
+
/** Strict parse of `deprecated.json`; throws `CatalogFormatError` (also for a repeated `id`). */
|
|
1583
|
+
const parseDeprecatedList = (raw) => {
|
|
1584
|
+
const result = deprecatedListSchema.safeParse(raw);
|
|
1585
|
+
if (result.success) return result.data;
|
|
1586
|
+
throw new CatalogFormatError(describeIssues(result.error));
|
|
1587
|
+
};
|
|
779
1588
|
const object = (profile, shape) => profile.strict ? z.strictObject(shape) : z.object(shape);
|
|
780
1589
|
const fileSchemaOf = (profile) => object(profile, {
|
|
781
|
-
path: z.string().refine(
|
|
1590
|
+
path: z.string().refine(isSafeCatalogPath, "must be a safe relative path"),
|
|
782
1591
|
size: z.number().int().min(0).max(MAX_TOTAL_BYTES),
|
|
783
1592
|
sha256: z.string().regex(SHA256, "must be lowercase hex sha256")
|
|
784
1593
|
}).superRefine((file, ctx) => {
|
|
@@ -788,7 +1597,7 @@ const fileSchemaOf = (profile) => object(profile, {
|
|
|
788
1597
|
message: problem
|
|
789
1598
|
});
|
|
790
1599
|
});
|
|
791
|
-
const filesSchemaOf = (profile) => z.array(fileSchemaOf(profile)).max(
|
|
1600
|
+
const filesSchemaOf = (profile) => z.array(fileSchemaOf(profile)).max(100).superRefine((files, ctx) => {
|
|
792
1601
|
const seen = /* @__PURE__ */ new Set();
|
|
793
1602
|
files.forEach((file, index) => {
|
|
794
1603
|
const key = file.path.toLowerCase();
|
|
@@ -832,6 +1641,24 @@ const strictTags = z.array(z.enum(EXTENSION_TAGS)).max(5).superRefine((tags, ctx
|
|
|
832
1641
|
const knownTags = EXTENSION_TAGS;
|
|
833
1642
|
const tolerantTags = z.array(z.string()).transform((tags) => [...new Set(tags.filter((tag) => knownTags.includes(tag)))].slice(0, 5)).catch([]);
|
|
834
1643
|
const tagsSchemaOf = (profile) => (profile.strict ? strictTags : tolerantTags).optional();
|
|
1644
|
+
const strictDependencies = z.array(z.strictObject({
|
|
1645
|
+
id: extensionId$1,
|
|
1646
|
+
range: rangeText.optional()
|
|
1647
|
+
})).max(16).superRefine((items, ctx) => {
|
|
1648
|
+
items.forEach(({ id }, index) => {
|
|
1649
|
+
if (items.findIndex((item) => item.id === id) !== index) ctx.addIssue({
|
|
1650
|
+
code: "custom",
|
|
1651
|
+
path: [index, "id"],
|
|
1652
|
+
message: `duplicate dependency '${id}'`
|
|
1653
|
+
});
|
|
1654
|
+
});
|
|
1655
|
+
});
|
|
1656
|
+
/** The tolerant reader drops an unreadable `dependencies`; the version stays. */
|
|
1657
|
+
const tolerantDependencies = z.array(z.object({
|
|
1658
|
+
id: extensionId$1,
|
|
1659
|
+
range: rangeText.optional()
|
|
1660
|
+
})).max(16).catch([]);
|
|
1661
|
+
const dependenciesSchemaOf = (profile) => (profile.strict ? strictDependencies : tolerantDependencies).optional();
|
|
835
1662
|
const versionSchemaOf = (profile) => object(profile, {
|
|
836
1663
|
version: semver,
|
|
837
1664
|
apiVersion: z.number().int().min(1),
|
|
@@ -843,18 +1670,9 @@ const versionSchemaOf = (profile) => object(profile, {
|
|
|
843
1670
|
/** The icon of the version as a `data:` URI; the file itself is among `files`. */
|
|
844
1671
|
icon: z.string().max(MAX_ICON_URI_LENGTH).regex(ICON_URI_PATTERN).optional(),
|
|
845
1672
|
/** Explicit tags of the version (a closed vocabulary); the tolerant reader drops the ones it does not know. */
|
|
846
|
-
tags: tagsSchemaOf(profile)
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
code: "custom",
|
|
850
|
-
path: ["tags"],
|
|
851
|
-
message: "tags is not part of this index format"
|
|
852
|
-
});
|
|
853
|
-
if (!profile.icon && version.icon !== void 0) ctx.addIssue({
|
|
854
|
-
code: "custom",
|
|
855
|
-
path: ["icon"],
|
|
856
|
-
message: "icon is not part of this index format"
|
|
857
|
-
});
|
|
1673
|
+
tags: tagsSchemaOf(profile),
|
|
1674
|
+
/** Extensions the version needs (`dependencies` of the manifest); no key — none. */
|
|
1675
|
+
dependencies: dependenciesSchemaOf(profile)
|
|
858
1676
|
});
|
|
859
1677
|
const contributesSchemaOf = (profile) => object(profile, {
|
|
860
1678
|
exerciseTypes: z.array(z.string()),
|
|
@@ -864,7 +1682,11 @@ const contributesSchemaOf = (profile) => object(profile, {
|
|
|
864
1682
|
settings: z.array(z.string()).optional(),
|
|
865
1683
|
events: z.array(z.string()).optional(),
|
|
866
1684
|
commands: z.array(z.string()).optional(),
|
|
867
|
-
panels: 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()
|
|
868
1690
|
});
|
|
869
1691
|
const titleMap = z.record(extensionId$1, z.string().min(1).max(MAX_TITLE_LENGTH));
|
|
870
1692
|
/** Titles of the contributions by point; an unreadable map is dropped by the tolerant reader, the entry stays. */
|
|
@@ -880,22 +1702,14 @@ const entryHeadOf = (profile) => ({
|
|
|
880
1702
|
source: httpsUrl,
|
|
881
1703
|
platforms: z.array(z.enum(EXTENSION_PLATFORMS)),
|
|
882
1704
|
contributes: contributesSchemaOf(profile),
|
|
883
|
-
titles: titlesSchemaOf(profile)
|
|
1705
|
+
titles: titlesSchemaOf(profile),
|
|
1706
|
+
deprecated: profile.strict ? strictDeprecated.optional() : tolerantDeprecated
|
|
884
1707
|
});
|
|
885
1708
|
const entrySchemaOf = (profile) => object(profile, {
|
|
886
1709
|
...entryHeadOf(profile),
|
|
887
1710
|
versions: z.array(versionSchemaOf(profile)).min(1).max(5).superRefine(descendingUnique)
|
|
888
1711
|
}).superRefine((entry, ctx) => {
|
|
889
|
-
if (entry.titles === void 0) return;
|
|
890
|
-
if (!profile.titles) {
|
|
891
|
-
ctx.addIssue({
|
|
892
|
-
code: "custom",
|
|
893
|
-
path: ["titles"],
|
|
894
|
-
message: "titles is not part of this index format"
|
|
895
|
-
});
|
|
896
|
-
return;
|
|
897
|
-
}
|
|
898
|
-
if (!profile.strict) return;
|
|
1712
|
+
if (entry.titles === void 0 || !profile.strict) return;
|
|
899
1713
|
for (const point of TITLED_POINTS) {
|
|
900
1714
|
const known = new Set(entry.contributes[point] ?? []);
|
|
901
1715
|
for (const id of Object.keys(entry.titles[point] ?? {})) if (!known.has(id)) ctx.addIssue({
|
|
@@ -908,122 +1722,559 @@ const entrySchemaOf = (profile) => object(profile, {
|
|
|
908
1722
|
message: `title for '${id}', which is not in contributes.${point}`
|
|
909
1723
|
});
|
|
910
1724
|
}
|
|
911
|
-
});
|
|
912
|
-
const indexSchemaOf = (profile) => object(profile, {
|
|
913
|
-
schemaVersion: z.literal(
|
|
914
|
-
generatedAt: timestamp,
|
|
915
|
-
extensions: z.array(entrySchemaOf(profile)),
|
|
916
|
-
revoked: z.array(revokedSchema)
|
|
917
|
-
});
|
|
918
|
-
const
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
const
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
1725
|
+
});
|
|
1726
|
+
const indexSchemaOf = (profile) => object(profile, {
|
|
1727
|
+
schemaVersion: z.literal(2),
|
|
1728
|
+
generatedAt: timestamp,
|
|
1729
|
+
extensions: z.array(entrySchemaOf(profile)),
|
|
1730
|
+
revoked: z.array(revokedSchema)
|
|
1731
|
+
});
|
|
1732
|
+
const FULL_PROFILE = { strict: true };
|
|
1733
|
+
/** Reader of the app: the limits of the full format, unknown keys dropped. */
|
|
1734
|
+
const TOLERANT_PROFILE = { strict: false };
|
|
1735
|
+
/** `index.v2.json`: the only index format. */
|
|
1736
|
+
const indexSchema = indexSchemaOf(FULL_PROFILE);
|
|
1737
|
+
/**
|
|
1738
|
+
* Strict parse for the author tools: unknown keys, file types and limits fail.
|
|
1739
|
+
* Throws `CatalogFormatError`.
|
|
1740
|
+
*/
|
|
1741
|
+
const parseIndex = (raw) => {
|
|
1742
|
+
const parsed = indexSchema.safeParse(raw);
|
|
1743
|
+
if (parsed.success) return parsed.data;
|
|
1744
|
+
throw new CatalogFormatError(describeIssues(parsed.error));
|
|
1745
|
+
};
|
|
1746
|
+
const tolerantHead = object(TOLERANT_PROFILE, {
|
|
1747
|
+
schemaVersion: z.literal(2),
|
|
1748
|
+
generatedAt: timestamp,
|
|
1749
|
+
extensions: z.array(z.unknown()),
|
|
1750
|
+
revoked: z.array(revokedSchema)
|
|
1751
|
+
});
|
|
1752
|
+
const tolerantEntry = object(TOLERANT_PROFILE, {
|
|
1753
|
+
...entryHeadOf(TOLERANT_PROFILE),
|
|
1754
|
+
versions: z.array(z.unknown())
|
|
1755
|
+
});
|
|
1756
|
+
const tolerantVersion = versionSchemaOf(TOLERANT_PROFILE);
|
|
1757
|
+
|
|
1758
|
+
//#endregion
|
|
1759
|
+
//#region packages/extension-catalog/src/urls.ts
|
|
1760
|
+
/** File name of the catalog index, the only one. */
|
|
1761
|
+
const FULL_INDEX_FILE = "index.v2.json";
|
|
1762
|
+
|
|
1763
|
+
//#endregion
|
|
1764
|
+
//#region packages/extension-host/src/locales.ts
|
|
1765
|
+
const errorText$3 = (error) => error instanceof Error ? error.message : String(error);
|
|
1766
|
+
const readTable = async (dir, locale) => {
|
|
1767
|
+
const file = path.join(dir, localeFile(locale));
|
|
1768
|
+
const info = await lstat(file).catch((error) => error.code === "ENOENT" ? null : error);
|
|
1769
|
+
if (info === null) return { kind: "absent" };
|
|
1770
|
+
if (info instanceof Error) return {
|
|
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;
|
|
1783
|
+
try {
|
|
1784
|
+
text = await readFile(file, "utf8");
|
|
1785
|
+
} catch (error) {
|
|
1786
|
+
return {
|
|
1787
|
+
kind: "invalid",
|
|
1788
|
+
reason: errorText$3(error)
|
|
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
|
+
}
|
|
1817
|
+
});
|
|
1818
|
+
}
|
|
1819
|
+
const english = messages["en"];
|
|
1820
|
+
for (const key of placeholderKeys(manifest)) if (english === void 0 || !Object.hasOwn(english, key)) warnings.push({
|
|
1821
|
+
code: "locale.missing-key",
|
|
1822
|
+
data: { key }
|
|
1823
|
+
});
|
|
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
|
+
});
|
|
1906
|
+
/** `Mod++` — клавиша `+`; `Mod+K` — модификаторы и клавиша. */
|
|
1907
|
+
const splitStroke = (text) => {
|
|
1908
|
+
if (text === "+") return {
|
|
1909
|
+
modifiers: [],
|
|
1910
|
+
key: "+"
|
|
1911
|
+
};
|
|
1912
|
+
const plus = text.endsWith("++");
|
|
1913
|
+
const index = plus ? text.length - 2 : text.lastIndexOf("+");
|
|
1914
|
+
if (index < 0) return {
|
|
1915
|
+
modifiers: [],
|
|
1916
|
+
key: text
|
|
1917
|
+
};
|
|
1918
|
+
return {
|
|
1919
|
+
modifiers: text.slice(0, index).split("+"),
|
|
1920
|
+
key: plus ? "+" : text.slice(index + 1)
|
|
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
|
+
}
|
|
935
2145
|
};
|
|
936
|
-
/**
|
|
937
|
-
const
|
|
938
|
-
|
|
939
|
-
|
|
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;
|
|
940
2214
|
};
|
|
941
|
-
/** `index.json`: the first format. */
|
|
942
|
-
const legacyIndexSchema = indexSchemaOf(LEGACY_PROFILE);
|
|
943
|
-
/** `index.v2.json`: the full format. */
|
|
944
|
-
const indexSchema = indexSchemaOf(FULL_PROFILE);
|
|
945
|
-
const describeIssues = (error, prefix = "") => error.issues.map((issue) => `${prefix}${issue.path.join(".") || "/"}: ${issue.message}`);
|
|
946
|
-
const isLegacyIndex = (raw) => typeof raw === "object" && raw !== null && "schemaVersion" in raw && raw.schemaVersion === 1;
|
|
947
2215
|
/**
|
|
948
|
-
*
|
|
949
|
-
*
|
|
950
|
-
*
|
|
2216
|
+
* Могут ли оба условия быть истинными одновременно. `null` — нет условия.
|
|
2217
|
+
* Сверх 64 термов в нормальной форме ответ «да» (лучше лишнее предупреждение,
|
|
2218
|
+
* чем молчание).
|
|
951
2219
|
*/
|
|
952
|
-
const
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
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])));
|
|
956
2226
|
};
|
|
957
|
-
const tolerantHead = object(TOLERANT_PROFILE, {
|
|
958
|
-
schemaVersion: z.union([z.literal(1), z.literal(2)]),
|
|
959
|
-
generatedAt: timestamp,
|
|
960
|
-
extensions: z.array(z.unknown()),
|
|
961
|
-
revoked: z.array(revokedSchema)
|
|
962
|
-
});
|
|
963
|
-
const tolerantEntry = object(TOLERANT_PROFILE, {
|
|
964
|
-
...entryHeadOf(TOLERANT_PROFILE),
|
|
965
|
-
versions: z.array(z.unknown())
|
|
966
|
-
});
|
|
967
|
-
const tolerantVersion = versionSchemaOf(TOLERANT_PROFILE);
|
|
968
2227
|
|
|
969
2228
|
//#endregion
|
|
970
|
-
//#region packages/
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
"
|
|
975
|
-
"
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
"events",
|
|
983
|
-
"commands",
|
|
984
|
-
"panels"
|
|
985
|
-
];
|
|
986
|
-
/** Whether a released app can parse the entry's summary of contributions. */
|
|
987
|
-
const isLegacyEntry = (entry) => NEWER_CONTRIBUTION_KEYS.every((key) => entry.contributes[key] === void 0);
|
|
988
|
-
/**
|
|
989
|
-
* Whether a released app can parse the version: only first-format file types,
|
|
990
|
-
* at most `MAX_FILES` files, no `icon` and no `tags`, only permissions it knows.
|
|
991
|
-
*/
|
|
992
|
-
const isLegacyVersion = (version) => version.icon === void 0 && version.tags === void 0 && version.files.length <= 50 && version.files.every((file) => isLegacyCatalogPath(file.path)) && version.permissions.every((permission) => LEGACY_PERMISSIONS.includes(permission));
|
|
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
|
+
};
|
|
993
2241
|
/**
|
|
994
|
-
*
|
|
995
|
-
*
|
|
996
|
-
*
|
|
997
|
-
*
|
|
2242
|
+
* Проверка привязки: клавиши (на каждой из `platforms`: `Mod` раскрывается по
|
|
2243
|
+
* платформе, и запись вроде `Mod+Ctrl+K` верна не везде), условие и правило
|
|
2244
|
+
* «сочетание без Ctrl и Meta, которое печатает или правит текст, должно быть
|
|
2245
|
+
* неактивно, пока фокус в поле ввода». `null` — привязка верна.
|
|
998
2246
|
*/
|
|
999
|
-
const
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
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
|
|
1006
2261
|
};
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
}
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
/** File name of the first-format index: the catalog identity (`catalogUrl`) points at it. */
|
|
1024
|
-
const INDEX_FILE = "index.json";
|
|
1025
|
-
/** File name of the full index, published next to `index.json`. */
|
|
1026
|
-
const FULL_INDEX_FILE = "index.v2.json";
|
|
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
|
+
};
|
|
1027
2278
|
|
|
1028
2279
|
//#endregion
|
|
1029
2280
|
//#region packages/extension-host/src/points/support.ts
|
|
@@ -1031,6 +2282,19 @@ const FULL_INDEX_FILE = "index.v2.json";
|
|
|
1031
2282
|
const isSafeRelativePath = (value) => value.length > 0 && !value.includes("\\") && !value.startsWith("/") && !value.split("/").includes("..");
|
|
1032
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 ")}`);
|
|
1033
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
|
+
};
|
|
1034
2298
|
/** Пути с нарушением правила «`id` равен id расширения или начинается с ним». */
|
|
1035
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}.'`]);
|
|
1036
2300
|
/** Повторы значений в списке `path` (путь до массива, `field` — имя поля записи). */
|
|
@@ -1085,7 +2349,62 @@ const resolveSchema = async (ajv, dir, source, label) => {
|
|
|
1085
2349
|
|
|
1086
2350
|
//#endregion
|
|
1087
2351
|
//#region packages/extension-host/src/points/commands.ts
|
|
1088
|
-
const { commands: MAX_COMMANDS } = EXTENSION_COMMAND_LIMITS;
|
|
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
|
+
};
|
|
1089
2408
|
const commands = {
|
|
1090
2409
|
key: "commands",
|
|
1091
2410
|
needsMain: true,
|
|
@@ -1095,18 +2414,30 @@ const commands = {
|
|
|
1095
2414
|
description: z.string().min(1).max(EXTENSION_COMMAND_LIMITS.descriptionLength).optional(),
|
|
1096
2415
|
category: z.string().min(1).max(EXTENSION_COMMAND_LIMITS.categoryLength).optional(),
|
|
1097
2416
|
keybinding: z.string().regex(KEYBINDING_PATTERN, "must look like Mod+Shift+L").optional(),
|
|
1098
|
-
|
|
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()
|
|
1099
2427
|
}),
|
|
1100
2428
|
normalize: (entries) => entries.map((entry) => ({
|
|
1101
2429
|
...entry,
|
|
1102
|
-
palette: entry.palette ?? true
|
|
2430
|
+
palette: entry.palette ?? true,
|
|
2431
|
+
icon: entry.icon ?? "puzzle"
|
|
1103
2432
|
})),
|
|
1104
2433
|
check: (entries, owner) => {
|
|
1105
2434
|
const ids = entries.map(({ id }) => id);
|
|
1106
2435
|
return [
|
|
1107
2436
|
...entries.length > MAX_COMMANDS ? [`contributes.commands: at most ${MAX_COMMANDS} commands allowed`] : [],
|
|
1108
2437
|
...idPrefixIssues("commands", ids, owner),
|
|
1109
|
-
...duplicateIssues("contributes.commands", "id", ids)
|
|
2438
|
+
...duplicateIssues("contributes.commands", "id", ids),
|
|
2439
|
+
...entries.flatMap(bindingIssues),
|
|
2440
|
+
...entries.flatMap((entry, index) => whenIssues("commands", index, entry.when))
|
|
1110
2441
|
];
|
|
1111
2442
|
},
|
|
1112
2443
|
resolve: async (entries) => entries.map((entry) => ({
|
|
@@ -1115,7 +2446,16 @@ const commands = {
|
|
|
1115
2446
|
description: entry.description ?? null,
|
|
1116
2447
|
category: entry.category ?? null,
|
|
1117
2448
|
keybinding: entry.keybinding ?? null,
|
|
1118
|
-
|
|
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
|
|
1119
2459
|
})),
|
|
1120
2460
|
claims: (resolved) => resolved.map((command) => `command:${command.id}`)
|
|
1121
2461
|
};
|
|
@@ -1128,6 +2468,7 @@ const exerciseTypes = {
|
|
|
1128
2468
|
needsMain: true,
|
|
1129
2469
|
schema: z.strictObject({
|
|
1130
2470
|
id: extensionId,
|
|
2471
|
+
title: z.string().min(1).max(EXTENSION_COMMAND_LIMITS.titleLength).optional(),
|
|
1131
2472
|
specSchema: schemaField,
|
|
1132
2473
|
answerSchema: schemaField,
|
|
1133
2474
|
element: z.string().optional(),
|
|
@@ -1135,6 +2476,7 @@ const exerciseTypes = {
|
|
|
1135
2476
|
}),
|
|
1136
2477
|
normalize: (entries) => entries.map((type) => ({
|
|
1137
2478
|
id: type.id,
|
|
2479
|
+
...type.title === void 0 ? {} : { title: type.title },
|
|
1138
2480
|
specSchema: type.specSchema,
|
|
1139
2481
|
answerSchema: type.answerSchema,
|
|
1140
2482
|
element: type.element ?? defaultElementName(type.id),
|
|
@@ -1147,6 +2489,7 @@ const exerciseTypes = {
|
|
|
1147
2489
|
const rendererUrl = await resolveModuleUrl(owner, dir, contribution.renderer, DEFAULT_RENDERER, verifyFiles, "renderer");
|
|
1148
2490
|
resolved.push({
|
|
1149
2491
|
id: contribution.id,
|
|
2492
|
+
title: contribution.title ?? null,
|
|
1150
2493
|
specSchema: await resolveSchema(ajv, dir, contribution.specSchema, `specSchema of '${contribution.id}'`),
|
|
1151
2494
|
answerSchema: await resolveSchema(ajv, dir, contribution.answerSchema, `answerSchema of '${contribution.id}'`),
|
|
1152
2495
|
element: contribution.element,
|
|
@@ -1170,6 +2513,34 @@ const events = {
|
|
|
1170
2513
|
claims: () => []
|
|
1171
2514
|
};
|
|
1172
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
|
+
|
|
1173
2544
|
//#endregion
|
|
1174
2545
|
//#region packages/extension-host/src/points/grade-policies.ts
|
|
1175
2546
|
const BUILTIN_POLICY_ID = "passAtN";
|
|
@@ -1189,6 +2560,40 @@ const gradePolicies = {
|
|
|
1189
2560
|
claims: (resolved) => resolved.map((policy) => `gradePolicy:${policy.id}`)
|
|
1190
2561
|
};
|
|
1191
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
|
+
|
|
1192
2597
|
//#endregion
|
|
1193
2598
|
//#region packages/extension-host/src/points/markdown-renderers.ts
|
|
1194
2599
|
const markdownRenderers = {
|
|
@@ -1196,10 +2601,12 @@ const markdownRenderers = {
|
|
|
1196
2601
|
needsMain: false,
|
|
1197
2602
|
schema: z.strictObject({
|
|
1198
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(),
|
|
1199
2605
|
renderer: safePath([".js", ".mjs"]).optional()
|
|
1200
2606
|
}),
|
|
1201
2607
|
normalize: (entries) => entries.map((entry) => ({
|
|
1202
2608
|
language: entry.language,
|
|
2609
|
+
...entry.title === void 0 ? {} : { title: entry.title },
|
|
1203
2610
|
renderer: entry.renderer ?? "./markdown.mjs"
|
|
1204
2611
|
})),
|
|
1205
2612
|
check: () => [],
|
|
@@ -1207,6 +2614,7 @@ const markdownRenderers = {
|
|
|
1207
2614
|
const resolved = [];
|
|
1208
2615
|
for (const entry of entries) resolved.push({
|
|
1209
2616
|
language: entry.language,
|
|
2617
|
+
title: entry.title ?? null,
|
|
1210
2618
|
rendererUrl: await resolveModuleUrl(extensionId, dir, entry.renderer, DEFAULT_MARKDOWN_RENDERER, verifyFiles, "markdown renderer")
|
|
1211
2619
|
});
|
|
1212
2620
|
return resolved;
|
|
@@ -1223,19 +2631,24 @@ const panels = {
|
|
|
1223
2631
|
schema: z.strictObject({
|
|
1224
2632
|
id: extensionId,
|
|
1225
2633
|
title: z.string().min(1).max(EXTENSION_COMMAND_LIMITS.titleLength),
|
|
1226
|
-
module: safePath([".js", ".mjs"]).optional()
|
|
2634
|
+
module: safePath([".js", ".mjs"]).optional(),
|
|
2635
|
+
icon: z.enum(EXTENSION_ICONS).optional(),
|
|
2636
|
+
when: whenField.optional()
|
|
1227
2637
|
}),
|
|
1228
2638
|
normalize: (entries) => entries.map((entry) => ({
|
|
1229
2639
|
id: entry.id,
|
|
1230
2640
|
title: entry.title,
|
|
1231
|
-
module: entry.module ?? "./panel.mjs"
|
|
2641
|
+
module: entry.module ?? "./panel.mjs",
|
|
2642
|
+
icon: entry.icon ?? "puzzle",
|
|
2643
|
+
...entry.when === void 0 ? {} : { when: entry.when }
|
|
1232
2644
|
})),
|
|
1233
2645
|
check: (entries, owner) => {
|
|
1234
2646
|
const ids = entries.map(({ id }) => id);
|
|
1235
2647
|
return [
|
|
1236
2648
|
...entries.length > MAX_PANELS ? [`contributes.panels: at most ${MAX_PANELS} panels allowed`] : [],
|
|
1237
2649
|
...idPrefixIssues("panels", ids, owner),
|
|
1238
|
-
...duplicateIssues("contributes.panels", "id", ids)
|
|
2650
|
+
...duplicateIssues("contributes.panels", "id", ids),
|
|
2651
|
+
...entries.flatMap((entry, index) => whenIssues("panels", index, entry.when))
|
|
1239
2652
|
];
|
|
1240
2653
|
},
|
|
1241
2654
|
resolve: async (entries, { dir, extensionId: owner, verifyFiles }) => {
|
|
@@ -1243,6 +2656,8 @@ const panels = {
|
|
|
1243
2656
|
for (const entry of entries) resolved.push({
|
|
1244
2657
|
id: entry.id,
|
|
1245
2658
|
title: entry.title,
|
|
2659
|
+
icon: entry.icon,
|
|
2660
|
+
when: entry.when ?? null,
|
|
1246
2661
|
rendererUrl: await resolveModuleUrl(owner, dir, entry.module, DEFAULT_PANEL, verifyFiles, "panel module")
|
|
1247
2662
|
});
|
|
1248
2663
|
return resolved;
|
|
@@ -1250,15 +2665,63 @@ const panels = {
|
|
|
1250
2665
|
claims: (resolved) => resolved.map((panel) => `panel:${panel.id}`)
|
|
1251
2666
|
};
|
|
1252
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
|
+
|
|
1253
2706
|
//#endregion
|
|
1254
2707
|
//#region packages/extension-host/src/points/settings.ts
|
|
1255
2708
|
/** Потолок длины строкового значения: даже в UTF-8 (3 байта на единицу) остаётся в пределах 64 КиБ хранилища. */
|
|
1256
|
-
const MAX_STRING_LENGTH =
|
|
2709
|
+
const MAX_STRING_LENGTH = SETTING_LIMITS.stringLength;
|
|
1257
2710
|
const MAX_OPTIONS = 64;
|
|
1258
2711
|
const base = {
|
|
1259
2712
|
id: extensionId,
|
|
1260
2713
|
label: z.string().min(1).max(60),
|
|
1261
|
-
description: z.string().min(1).max(500).optional()
|
|
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()
|
|
1262
2725
|
};
|
|
1263
2726
|
const option = z.strictObject({
|
|
1264
2727
|
value: z.string().min(1).max(100),
|
|
@@ -1276,6 +2739,24 @@ const schema = z.discriminatedUnion("type", [
|
|
|
1276
2739
|
default: z.string(),
|
|
1277
2740
|
maxLength: z.number().int().min(1).max(MAX_STRING_LENGTH).optional()
|
|
1278
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
|
+
}),
|
|
1279
2760
|
z.strictObject({
|
|
1280
2761
|
...base,
|
|
1281
2762
|
type: z.literal("number"),
|
|
@@ -1295,7 +2776,14 @@ const schema = z.discriminatedUnion("type", [
|
|
|
1295
2776
|
const constraintIssues = (setting, at) => {
|
|
1296
2777
|
const issue = (field, message) => `${at}.${field}: ${message}`;
|
|
1297
2778
|
switch (setting.type) {
|
|
1298
|
-
case "boolean":
|
|
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":
|
|
1299
2787
|
case "string": {
|
|
1300
2788
|
const limit = setting.maxLength ?? MAX_STRING_LENGTH;
|
|
1301
2789
|
return setting.default.length > limit ? [issue("default", `default is longer than ${limit} characters`)] : [];
|
|
@@ -1315,11 +2803,30 @@ const constraintIssues = (setting, at) => {
|
|
|
1315
2803
|
}
|
|
1316
2804
|
}
|
|
1317
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
|
+
};
|
|
1318
2822
|
const resolveSetting = (setting) => {
|
|
1319
2823
|
const common = {
|
|
1320
2824
|
id: setting.id,
|
|
1321
2825
|
label: setting.label,
|
|
1322
|
-
description: setting.description ?? null
|
|
2826
|
+
description: setting.description ?? null,
|
|
2827
|
+
group: setting.group ?? null,
|
|
2828
|
+
order: setting.order ?? 0,
|
|
2829
|
+
visibleWhen: setting.visibleWhen === void 0 ? null : { ...setting.visibleWhen }
|
|
1323
2830
|
};
|
|
1324
2831
|
switch (setting.type) {
|
|
1325
2832
|
case "boolean": return {
|
|
@@ -1327,6 +2834,24 @@ const resolveSetting = (setting) => {
|
|
|
1327
2834
|
type: "boolean",
|
|
1328
2835
|
default: setting.default
|
|
1329
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
|
+
};
|
|
1330
2855
|
case "string": return {
|
|
1331
2856
|
...common,
|
|
1332
2857
|
type: "string",
|
|
@@ -1360,7 +2885,8 @@ const settings = {
|
|
|
1360
2885
|
check: (entries, owner) => [
|
|
1361
2886
|
...idPrefixIssues("settings", entries.map(({ id }) => id), owner),
|
|
1362
2887
|
...duplicateIssues("contributes.settings", "id", entries.map(({ id }) => id)),
|
|
1363
|
-
...entries.flatMap((entry, index) => constraintIssues(entry, `contributes.settings.${index}`))
|
|
2888
|
+
...entries.flatMap((entry, index) => constraintIssues(entry, `contributes.settings.${index}`)),
|
|
2889
|
+
...visibleWhenIssues(entries)
|
|
1364
2890
|
],
|
|
1365
2891
|
resolve: async (entries) => entries.map(resolveSetting),
|
|
1366
2892
|
claims: (resolved) => resolved.map((setting) => `setting:${setting.id}`)
|
|
@@ -1404,6 +2930,57 @@ const themes = {
|
|
|
1404
2930
|
claims: (resolved) => resolved.map((theme) => `theme:${theme.id}`)
|
|
1405
2931
|
};
|
|
1406
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
|
+
|
|
1407
2984
|
//#endregion
|
|
1408
2985
|
//#region packages/extension-host/src/points/index.ts
|
|
1409
2986
|
/** Реестр точек вклада: порядок значим для диагностики и разбора. */
|
|
@@ -1415,7 +2992,11 @@ const CONTRIBUTION_POINTS = [
|
|
|
1415
2992
|
settings,
|
|
1416
2993
|
events,
|
|
1417
2994
|
commands,
|
|
1418
|
-
panels
|
|
2995
|
+
panels,
|
|
2996
|
+
widgets,
|
|
2997
|
+
schedules,
|
|
2998
|
+
importers,
|
|
2999
|
+
exporters
|
|
1419
3000
|
];
|
|
1420
3001
|
const POINT_KEYS = CONTRIBUTION_POINTS.map(({ key }) => key);
|
|
1421
3002
|
|
|
@@ -1424,7 +3005,20 @@ const POINT_KEYS = CONTRIBUTION_POINTS.map(({ key }) => key);
|
|
|
1424
3005
|
const contributesSchema = z.strictObject(Object.fromEntries(CONTRIBUTION_POINTS.map((point) => [point.key, z.array(point.schema).optional()])));
|
|
1425
3006
|
const entriesOf = (contributes, key) => contributes[key] ?? [];
|
|
1426
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
|
+
});
|
|
1427
3019
|
const manifestSchema = z.strictObject({
|
|
3020
|
+
/** Ссылка на JSON Schema для редактора; приложением и инструментами игнорируется. */
|
|
3021
|
+
$schema: z.string().optional(),
|
|
1428
3022
|
id: extensionId,
|
|
1429
3023
|
version: z.string().refine(isSemver, "version must be semver"),
|
|
1430
3024
|
apiVersion: z.literal(1),
|
|
@@ -1437,6 +3031,10 @@ const manifestSchema = z.strictObject({
|
|
|
1437
3031
|
minAppVersion: z.string().refine(isSemver, "minAppVersion must be semver x.y.z").optional(),
|
|
1438
3032
|
icon: safePath([".png", ".webp"]).optional(),
|
|
1439
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(),
|
|
1440
3038
|
contributes: contributesSchema
|
|
1441
3039
|
}).superRefine((manifest, ctx) => {
|
|
1442
3040
|
const { permissions = [], platforms = [] } = manifest;
|
|
@@ -1462,11 +3060,44 @@ const manifestSchema = z.strictObject({
|
|
|
1462
3060
|
message: `duplicate tag '${tag}'`
|
|
1463
3061
|
});
|
|
1464
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
|
+
});
|
|
1465
3084
|
if (entriesOf(manifest.contributes, "events").length > 0 && !permissions.includes("learning.events")) ctx.addIssue({
|
|
1466
3085
|
code: "custom",
|
|
1467
3086
|
path: ["permissions"],
|
|
1468
3087
|
message: "contributes.events requires the 'learning.events' permission"
|
|
1469
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
|
+
});
|
|
1470
3101
|
if (isEmpty(manifest.contributes)) ctx.addIssue({
|
|
1471
3102
|
code: "custom",
|
|
1472
3103
|
path: ["contributes"],
|
|
@@ -1490,25 +3121,31 @@ const normalizeManifest = (input) => {
|
|
|
1490
3121
|
platforms: [...input.platforms ?? []],
|
|
1491
3122
|
minAppVersion: input.minAppVersion ?? null,
|
|
1492
3123
|
tags: [...input.tags ?? []],
|
|
3124
|
+
dependencies: (input.dependencies ?? []).map(({ id, range }) => ({
|
|
3125
|
+
id,
|
|
3126
|
+
range: range ?? null
|
|
3127
|
+
})),
|
|
1493
3128
|
icon: input.icon ?? null,
|
|
1494
3129
|
contributes
|
|
1495
3130
|
};
|
|
1496
3131
|
};
|
|
1497
|
-
const
|
|
3132
|
+
const zodIssues = (error) => error.issues.map((issue) => `${issue.path.join(".") || "/"}: ${issue.message}`);
|
|
1498
3133
|
/** Сообщения о нарушениях в нормализованном манифесте. */
|
|
1499
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`). */
|
|
1500
3143
|
const parseManifest = (raw) => {
|
|
1501
3144
|
const parsed = manifestSchema.safeParse(raw);
|
|
1502
|
-
if (!parsed.success) return
|
|
1503
|
-
ok: false,
|
|
1504
|
-
message: formatIssues(parsed.error)
|
|
1505
|
-
};
|
|
3145
|
+
if (!parsed.success) return invalid(zodIssues(parsed.error));
|
|
1506
3146
|
const manifest = normalizeManifest(parsed.data);
|
|
1507
3147
|
const issues = normalizedIssues(manifest);
|
|
1508
|
-
if (issues.length > 0) return
|
|
1509
|
-
ok: false,
|
|
1510
|
-
message: issues.join("; ")
|
|
1511
|
-
};
|
|
3148
|
+
if (issues.length > 0) return invalid(issues);
|
|
1512
3149
|
return {
|
|
1513
3150
|
ok: true,
|
|
1514
3151
|
manifest
|
|
@@ -1517,10 +3154,20 @@ const parseManifest = (raw) => {
|
|
|
1517
3154
|
|
|
1518
3155
|
//#endregion
|
|
1519
3156
|
//#region packages/extension-host/src/discover.ts
|
|
1520
|
-
const compatibilityIssue = (manifest, appVersion, platform) =>
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
3157
|
+
const compatibilityIssue = (manifest, appVersion, platform) => {
|
|
3158
|
+
const failure = checkCompatibility(manifest, {
|
|
3159
|
+
appVersion,
|
|
3160
|
+
platform
|
|
3161
|
+
});
|
|
3162
|
+
if (failure === null) return null;
|
|
3163
|
+
return failure.reason === "app" ? {
|
|
3164
|
+
code: "requires-app",
|
|
3165
|
+
data: { minAppVersion: manifest.minAppVersion ?? "" }
|
|
3166
|
+
} : {
|
|
3167
|
+
code: "unavailable-platform",
|
|
3168
|
+
data: { platform }
|
|
3169
|
+
};
|
|
3170
|
+
};
|
|
1524
3171
|
const resolveMain = async (dir, main, verifyFiles) => {
|
|
1525
3172
|
if (main === null) return null;
|
|
1526
3173
|
const mainPath = inside(dir, main);
|
|
@@ -1556,26 +3203,35 @@ const inspectExtensionDir = async (directory, options = {}) => {
|
|
|
1556
3203
|
return {
|
|
1557
3204
|
ok: false,
|
|
1558
3205
|
id: dirName,
|
|
1559
|
-
|
|
3206
|
+
diagnostic: {
|
|
3207
|
+
code: "manifest-unreadable",
|
|
3208
|
+
data: { reason: error instanceof Error ? error.message : String(error) }
|
|
3209
|
+
}
|
|
1560
3210
|
};
|
|
1561
3211
|
}
|
|
1562
3212
|
const parsed = parseManifest(raw);
|
|
1563
3213
|
if (!parsed.ok) return {
|
|
1564
3214
|
ok: false,
|
|
1565
3215
|
id: dirName,
|
|
1566
|
-
|
|
3216
|
+
diagnostic: parsed.diagnostic
|
|
1567
3217
|
};
|
|
1568
3218
|
const { manifest } = parsed;
|
|
1569
3219
|
if (expectedId !== null && manifest.id !== expectedId) return {
|
|
1570
3220
|
ok: false,
|
|
1571
3221
|
id: dirName,
|
|
1572
|
-
|
|
3222
|
+
diagnostic: {
|
|
3223
|
+
code: "id-mismatch",
|
|
3224
|
+
data: {
|
|
3225
|
+
expected: expectedId,
|
|
3226
|
+
actual: manifest.id
|
|
3227
|
+
}
|
|
3228
|
+
}
|
|
1573
3229
|
};
|
|
1574
3230
|
const incompatible = compatibilityIssue(manifest, appVersion, platform);
|
|
1575
3231
|
if (incompatible !== null) return {
|
|
1576
3232
|
ok: false,
|
|
1577
3233
|
id: manifest.id,
|
|
1578
|
-
|
|
3234
|
+
diagnostic: incompatible
|
|
1579
3235
|
};
|
|
1580
3236
|
try {
|
|
1581
3237
|
const mainPath = await resolveMain(dir, manifest.main, verifyFiles);
|
|
@@ -1587,6 +3243,10 @@ const inspectExtensionDir = async (directory, options = {}) => {
|
|
|
1587
3243
|
};
|
|
1588
3244
|
const resolved = {};
|
|
1589
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
|
+
};
|
|
1590
3250
|
return {
|
|
1591
3251
|
ok: true,
|
|
1592
3252
|
extension: {
|
|
@@ -1601,7 +3261,10 @@ const inspectExtensionDir = async (directory, options = {}) => {
|
|
|
1601
3261
|
platforms: manifest.platforms,
|
|
1602
3262
|
minAppVersion: manifest.minAppVersion,
|
|
1603
3263
|
icon: await resolveIcon(dir, manifest.icon, verifyFiles),
|
|
3264
|
+
messages: locales.messages,
|
|
3265
|
+
warnings: locales.warnings,
|
|
1604
3266
|
tags: manifest.tags,
|
|
3267
|
+
dependencies: manifest.dependencies,
|
|
1605
3268
|
...resolved
|
|
1606
3269
|
}
|
|
1607
3270
|
};
|
|
@@ -1609,15 +3272,37 @@ const inspectExtensionDir = async (directory, options = {}) => {
|
|
|
1609
3272
|
return {
|
|
1610
3273
|
ok: false,
|
|
1611
3274
|
id: manifest.id,
|
|
1612
|
-
|
|
3275
|
+
diagnostic: {
|
|
3276
|
+
code: "load-failed",
|
|
3277
|
+
data: { reason: error instanceof Error ? error.message : String(error) }
|
|
3278
|
+
}
|
|
1613
3279
|
};
|
|
1614
3280
|
}
|
|
1615
3281
|
};
|
|
1616
3282
|
|
|
1617
3283
|
//#endregion
|
|
1618
3284
|
//#region packages/extension-host/src/runtime.ts
|
|
3285
|
+
/** Срок обработчика расписания (R12); раннер ограниченного процесса и клиент планировщика ждут дольше. */
|
|
3286
|
+
const SCHEDULE_HANDLER_MS = EXTENSION_SCHEDULE_LIMITS.handlerMs;
|
|
1619
3287
|
/** Срок обработчика команды расширения (R3); раннер ограниченного процесса и клиент движка ждут дольше. */
|
|
1620
3288
|
const COMMAND_HANDLER_MS = EXTENSION_COMMAND_LIMITS.handlerMs;
|
|
3289
|
+
/** Срок обработчика импортёра и экспортёра; раннер ограниченного процесса (32 с) и клиент движка (34 с) ждут дольше. */
|
|
3290
|
+
const TRANSFER_HANDLER_MS = EXTENSION_TRANSFER_LIMITS.handlerMs;
|
|
3291
|
+
|
|
3292
|
+
//#endregion
|
|
3293
|
+
//#region packages/extension-host/src/transfer-wire.ts
|
|
3294
|
+
/** Самое большое тело каталога файлов: содержимое, пути и длины в кадрах. */
|
|
3295
|
+
const MAX_FILES_FRAME_BYTES = EXTENSION_TRANSFER_LIMITS.totalBytes + EXTENSION_TRANSFER_LIMITS.files * (8 + EXTENSION_TRANSFER_LIMITS.pathBytes);
|
|
3296
|
+
const encoder = new TextEncoder();
|
|
3297
|
+
const decoder = new TextDecoder("utf-8", {
|
|
3298
|
+
fatal: true,
|
|
3299
|
+
ignoreBOM: true
|
|
3300
|
+
});
|
|
3301
|
+
|
|
3302
|
+
//#endregion
|
|
3303
|
+
//#region packages/extension-host/src/restricted-runner.ts
|
|
3304
|
+
/** Раннер ждёт обработчик импорта или экспорта дольше его срока (30 с), но короче клиента движка (34 с). */
|
|
3305
|
+
const TRANSFER_DEADLINE_MS = EXTENSION_TRANSFER_LIMITS.handlerMs + 2e3;
|
|
1621
3306
|
|
|
1622
3307
|
//#endregion
|
|
1623
3308
|
//#region packages/extension-tools/src/catalog/assets.ts
|
|
@@ -2084,15 +3769,122 @@ const assetFindings = async (root, files) => {
|
|
|
2084
3769
|
});
|
|
2085
3770
|
continue;
|
|
2086
3771
|
}
|
|
2087
|
-
if (assetExtensionOf(file) === null) continue;
|
|
2088
|
-
const message = assetProblem(file, await readFile(path.join(root, file)));
|
|
2089
|
-
if (message !== null) findings.push({
|
|
2090
|
-
path: file,
|
|
2091
|
-
message
|
|
2092
|
-
});
|
|
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));
|
|
2093
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`));
|
|
3869
|
+
}
|
|
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`));
|
|
2094
3872
|
return findings;
|
|
2095
3873
|
};
|
|
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");
|
|
2096
3888
|
|
|
2097
3889
|
//#endregion
|
|
2098
3890
|
//#region packages/extension-tools/src/assets-plugin.ts
|
|
@@ -2160,26 +3952,13 @@ const assetsPlugin = (state) => ({
|
|
|
2160
3952
|
}
|
|
2161
3953
|
});
|
|
2162
3954
|
|
|
2163
|
-
//#endregion
|
|
2164
|
-
//#region packages/extension-tools/src/errors.ts
|
|
2165
|
-
/** Extension build/check error; `subject` is the extension id or directory. */
|
|
2166
|
-
var BuildError = class extends Error {
|
|
2167
|
-
subject;
|
|
2168
|
-
constructor(message, subject) {
|
|
2169
|
-
super(message);
|
|
2170
|
-
this.name = "BuildError";
|
|
2171
|
-
this.subject = subject;
|
|
2172
|
-
}
|
|
2173
|
-
};
|
|
2174
|
-
/** Invalid command input (nonexistent directory): exit code 2. */
|
|
2175
|
-
var CatalogUsageError = class extends BuildError {};
|
|
2176
|
-
|
|
2177
3955
|
//#endregion
|
|
2178
3956
|
//#region packages/extension-tools/src/analyze.ts
|
|
2179
3957
|
/** Named entries of `src/index.ts` from which the browser files are built. */
|
|
2180
3958
|
const RECORDS = [
|
|
2181
3959
|
"views",
|
|
2182
3960
|
"panels",
|
|
3961
|
+
"widgets",
|
|
2183
3962
|
"markdown"
|
|
2184
3963
|
];
|
|
2185
3964
|
const SOURCE_EXTENSIONS = [
|
|
@@ -2235,7 +4014,7 @@ const propertyKeys = (literal) => {
|
|
|
2235
4014
|
};
|
|
2236
4015
|
/**
|
|
2237
4016
|
* Static analysis of `src/index.ts` without executing author code: whether the
|
|
2238
|
-
* `host` export exists and which keys the `views`, `panels` and `markdown`
|
|
4017
|
+
* `host` export exists and which keys the `views`, `panels`, `widgets` and `markdown`
|
|
2239
4018
|
* entries have. Entries are found through local constants and relative
|
|
2240
4019
|
* re-exports; keys must be given as an object literal.
|
|
2241
4020
|
*/
|
|
@@ -2373,7 +4152,7 @@ const analyzeIndex = async (indexFile) => {
|
|
|
2373
4152
|
}
|
|
2374
4153
|
};
|
|
2375
4154
|
};
|
|
2376
|
-
const [views, panels, markdown] = await Promise.all(RECORDS.map(recordOf));
|
|
4155
|
+
const [views, panels, widgets, markdown] = await Promise.all(RECORDS.map(recordOf));
|
|
2377
4156
|
const hasHost = await walk.export(indexFile, "host", 0) !== null;
|
|
2378
4157
|
return {
|
|
2379
4158
|
files: [...cache.keys()],
|
|
@@ -2381,6 +4160,7 @@ const analyzeIndex = async (indexFile) => {
|
|
|
2381
4160
|
records: {
|
|
2382
4161
|
views,
|
|
2383
4162
|
panels,
|
|
4163
|
+
widgets,
|
|
2384
4164
|
markdown
|
|
2385
4165
|
}
|
|
2386
4166
|
};
|
|
@@ -2388,15 +4168,26 @@ const analyzeIndex = async (indexFile) => {
|
|
|
2388
4168
|
/**
|
|
2389
4169
|
* Source of `site.file` with the entry literal trimmed to `keep`: the code of the
|
|
2390
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.
|
|
2391
4173
|
*/
|
|
2392
4174
|
const pruneRecords = (source, sites) => {
|
|
2393
|
-
|
|
2394
|
-
const
|
|
2395
|
-
|
|
2396
|
-
|
|
2397
|
-
|
|
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, " ");
|
|
2398
4186
|
}
|
|
2399
|
-
return
|
|
4187
|
+
return {
|
|
4188
|
+
code: edit.toString(),
|
|
4189
|
+
map: edit.generateMap({ hires: "boundary" })
|
|
4190
|
+
};
|
|
2400
4191
|
};
|
|
2401
4192
|
const isRecord$1 = (value) => typeof value === "object" && value !== null;
|
|
2402
4193
|
const collectImports = (node, found) => {
|
|
@@ -2443,10 +4234,12 @@ const quote = (text) => JSON.stringify(text);
|
|
|
2443
4234
|
const wantedRecords = (output) => output.kind === "host" ? {
|
|
2444
4235
|
views: [],
|
|
2445
4236
|
panels: [],
|
|
4237
|
+
widgets: [],
|
|
2446
4238
|
markdown: []
|
|
2447
4239
|
} : {
|
|
2448
4240
|
views: output.views.map((view) => view.id),
|
|
2449
4241
|
panels: output.panels,
|
|
4242
|
+
widgets: output.widgets,
|
|
2450
4243
|
markdown: output.languages
|
|
2451
4244
|
};
|
|
2452
4245
|
/** Exports of `src/index.ts` the file is built from (for messages). */
|
|
@@ -2471,6 +4264,10 @@ const shimSource = (output, indexFile) => {
|
|
|
2471
4264
|
runtime.push("dispatchPanels");
|
|
2472
4265
|
modules.push(`...dispatchPanels(${table("panels", output.panels)})`);
|
|
2473
4266
|
}
|
|
4267
|
+
if (output.widgets.length > 0) {
|
|
4268
|
+
runtime.push("dispatchWidgets");
|
|
4269
|
+
modules.push(`...dispatchWidgets(${table("widgets", output.widgets)})`);
|
|
4270
|
+
}
|
|
2474
4271
|
if (output.languages.length > 0) {
|
|
2475
4272
|
runtime.push("dispatchMarkdown");
|
|
2476
4273
|
modules.push(`...dispatchMarkdown(${table("markdown", output.languages)})`);
|
|
@@ -2490,16 +4287,19 @@ const findMismatches = (project, analysis, indexSource, root) => {
|
|
|
2490
4287
|
const declared = {
|
|
2491
4288
|
views: project.manifest.contributes.exerciseTypes.map((type) => type.id),
|
|
2492
4289
|
panels: project.manifest.contributes.panels.map((panel) => panel.id),
|
|
4290
|
+
widgets: project.manifest.contributes.widgets.map((widget) => widget.id),
|
|
2493
4291
|
markdown: project.manifest.contributes.markdownRenderers.map((entry) => entry.language)
|
|
2494
4292
|
};
|
|
2495
4293
|
const what = {
|
|
2496
4294
|
views: "exercise type",
|
|
2497
4295
|
panels: "panel",
|
|
4296
|
+
widgets: "widget",
|
|
2498
4297
|
markdown: "markdown renderer"
|
|
2499
4298
|
};
|
|
2500
4299
|
const fix = {
|
|
2501
4300
|
views: "defineAnswerView(…)",
|
|
2502
4301
|
panels: "defineExtensionPanel(…)",
|
|
4302
|
+
widgets: "defineExtensionWidget(…)",
|
|
2503
4303
|
markdown: "defineMarkdownRenderer(…)"
|
|
2504
4304
|
};
|
|
2505
4305
|
for (const name of RECORDS) {
|
|
@@ -2550,7 +4350,7 @@ const shimPlugin = ({ project, output, state }) => {
|
|
|
2550
4350
|
if (chunk.type !== "chunk") continue;
|
|
2551
4351
|
chunk.code = stripBareImports(chunk.code, (specifier) => isNodeSpecifier(specifier, project.external));
|
|
2552
4352
|
const leaked = [...importsOf(chunk.code)].find((specifier) => isNodeSpecifier(specifier, project.external));
|
|
2553
|
-
if (leaked !== void 0) fail(this, `${output.output} imports '${leaked}': Node.js modules and the 'external' packages of dolphy-ext.config.json are for the extension host, keep them out of code that views, panels and markdown renderers use`);
|
|
4353
|
+
if (leaked !== void 0) fail(this, `${output.output} imports '${leaked}': Node.js modules and the 'external' packages of dolphy-ext.config.json are for the extension host, keep them out of code that views, panels, widgets and markdown renderers use`);
|
|
2554
4354
|
}
|
|
2555
4355
|
},
|
|
2556
4356
|
async buildStart() {
|
|
@@ -2676,7 +4476,15 @@ const HEADER = "// Generated by `dolphy-ext types` from extension.json. Do not e
|
|
|
2676
4476
|
const literal = (text) => `'${JSON.stringify(text).slice(1, -1).replaceAll("\\\"", "\"").replaceAll("'", "\\'")}'`;
|
|
2677
4477
|
/** A union of literals; `never` when the manifest declares none. */
|
|
2678
4478
|
const union = (values) => values.length === 0 ? "never" : values.map(literal).join(" | ");
|
|
2679
|
-
const settingType = (setting) =>
|
|
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
|
+
};
|
|
2680
4488
|
const settingsType = (settings) => settings.length === 0 ? "{}" : `{\n${settings.map((setting) => ` ${literal(setting.id)}: ${settingType(setting)};`).join("\n")}\n }`;
|
|
2681
4489
|
/**
|
|
2682
4490
|
* The declarations for a manifest: an augmentation of `ExtensionIds` in
|
|
@@ -2690,6 +4498,10 @@ const renderIds = ({ contributes }) => {
|
|
|
2690
4498
|
["commands", union(contributes.commands.map(({ id }) => id))],
|
|
2691
4499
|
["events", union(contributes.events.map(({ event }) => event))],
|
|
2692
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))],
|
|
2693
4505
|
["markdownLanguages", union(contributes.markdownRenderers.map(({ language }) => language))],
|
|
2694
4506
|
["settings", settingsType(contributes.settings)]
|
|
2695
4507
|
];
|
|
@@ -2779,18 +4591,20 @@ const browserOutputsOf = (manifest) => {
|
|
|
2779
4591
|
output,
|
|
2780
4592
|
views: [],
|
|
2781
4593
|
panels: [],
|
|
4594
|
+
widgets: [],
|
|
2782
4595
|
languages: []
|
|
2783
4596
|
};
|
|
2784
4597
|
outputs.set(output, created);
|
|
2785
4598
|
return created;
|
|
2786
4599
|
};
|
|
2787
|
-
const { exerciseTypes, markdownRenderers, panels } = manifest.contributes;
|
|
4600
|
+
const { exerciseTypes, markdownRenderers, panels, widgets } = manifest.contributes;
|
|
2788
4601
|
for (const type of exerciseTypes) outputOf(type.renderer).views.push({
|
|
2789
4602
|
id: type.id,
|
|
2790
4603
|
element: type.element
|
|
2791
4604
|
});
|
|
2792
4605
|
for (const entry of markdownRenderers) outputOf(entry.renderer).languages.push(entry.language);
|
|
2793
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);
|
|
2794
4608
|
return [...outputs.values()];
|
|
2795
4609
|
};
|
|
2796
4610
|
/**
|
|
@@ -2813,7 +4627,7 @@ const loadProject = async (rootDir) => {
|
|
|
2813
4627
|
}
|
|
2814
4628
|
const raw = await readJson(manifestFile, root);
|
|
2815
4629
|
const parsed = parseManifest(raw);
|
|
2816
|
-
if (!parsed.ok) throw new BuildError(parsed.
|
|
4630
|
+
if (!parsed.ok) throw new BuildError(formatDiagnostic(parsed.diagnostic), root);
|
|
2817
4631
|
const { manifest } = parsed;
|
|
2818
4632
|
const config = await readConfig(root);
|
|
2819
4633
|
const host = manifest.main === null ? null : {
|
|
@@ -2890,12 +4704,19 @@ const createReporter = (onReport, settleMs = SETTLE_MS) => {
|
|
|
2890
4704
|
}
|
|
2891
4705
|
};
|
|
2892
4706
|
};
|
|
4707
|
+
/**
|
|
4708
|
+
* Watch output is for development: inline source maps let DevTools show the
|
|
4709
|
+
* author's TypeScript for browser bundles. A normal build and a catalog build
|
|
4710
|
+
* never write them (`bundleConfig` leaves `sourcemap` unset; `catalog check`
|
|
4711
|
+
* rejects them in a submission, CHECK-025).
|
|
4712
|
+
*/
|
|
2893
4713
|
const watchJob = async (job, reporter, isFailFast, failedFirst) => {
|
|
2894
4714
|
const started = await build({
|
|
2895
4715
|
...job.config,
|
|
2896
4716
|
build: {
|
|
2897
4717
|
...job.config.build,
|
|
2898
|
-
watch: {}
|
|
4718
|
+
watch: {},
|
|
4719
|
+
sourcemap: "inline"
|
|
2899
4720
|
}
|
|
2900
4721
|
});
|
|
2901
4722
|
if (!isWatcher(started)) throw new Error(`watch mode is not available for '${job.output}'`);
|
|
@@ -2961,7 +4782,11 @@ const watchAll = async (project, outDir, reporter, isFailFast) => {
|
|
|
2961
4782
|
|
|
2962
4783
|
//#endregion
|
|
2963
4784
|
//#region packages/extension-tools/src/index.ts
|
|
2964
|
-
const STATIC_DIRS = [
|
|
4785
|
+
const STATIC_DIRS = [
|
|
4786
|
+
"schema",
|
|
4787
|
+
"assets",
|
|
4788
|
+
"locales"
|
|
4789
|
+
];
|
|
2965
4790
|
/** Editors write a file in several events: manifest reload waits for quiet. */
|
|
2966
4791
|
const MANIFEST_SETTLE_MS = 100;
|
|
2967
4792
|
const isPresent = async (file) => await stat(file).catch(() => null) !== null;
|
|
@@ -3025,16 +4850,27 @@ const validateExtension = async (dir) => {
|
|
|
3025
4850
|
});
|
|
3026
4851
|
if (!result.ok) return {
|
|
3027
4852
|
ok: false,
|
|
3028
|
-
problems: [result.
|
|
4853
|
+
problems: [formatDiagnostic(result.diagnostic)],
|
|
4854
|
+
warnings: []
|
|
3029
4855
|
};
|
|
3030
|
-
const
|
|
4856
|
+
const files = await listFiles(root);
|
|
4857
|
+
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));
|
|
3031
4865
|
return {
|
|
3032
4866
|
ok: problems.length === 0,
|
|
3033
|
-
problems
|
|
4867
|
+
problems,
|
|
4868
|
+
warnings: locales.filter(({ severity }) => severity === "warning").map(describe)
|
|
3034
4869
|
};
|
|
3035
4870
|
};
|
|
3036
|
-
const assertValid = async (project, dir) => {
|
|
3037
|
-
const { ok, problems } = await validateExtension(dir);
|
|
4871
|
+
const assertValid = async (project, dir, logger) => {
|
|
4872
|
+
const { ok, problems, warnings } = await validateExtension(dir);
|
|
4873
|
+
for (const warning of warnings) logger?.info(`warning ${project.manifest.id}: ${warning}`);
|
|
3038
4874
|
if (!ok) throw new BuildError(problems.join("; "), project.manifest.id);
|
|
3039
4875
|
};
|
|
3040
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. */
|
|
@@ -3063,7 +4899,7 @@ const buildExtension = async (options) => {
|
|
|
3063
4899
|
const { project, dir } = await prepare(options);
|
|
3064
4900
|
await bundleAll(project, dir);
|
|
3065
4901
|
await copyStatic(project, dir);
|
|
3066
|
-
await assertValid(project, dir);
|
|
4902
|
+
await assertValid(project, dir, options.logger);
|
|
3067
4903
|
return resultOf$1(project, dir);
|
|
3068
4904
|
};
|
|
3069
4905
|
const reportTo = (id, logger) => (report) => {
|
|
@@ -3085,7 +4921,7 @@ const watchExtension = async (options) => {
|
|
|
3085
4921
|
let current = await watchAll(project, dir, reporter, true);
|
|
3086
4922
|
try {
|
|
3087
4923
|
await copyStatic(project, dir);
|
|
3088
|
-
await assertValid(project, dir);
|
|
4924
|
+
await assertValid(project, dir, options.logger);
|
|
3089
4925
|
} catch (error) {
|
|
3090
4926
|
await current.close();
|
|
3091
4927
|
reporter.close();
|
|
@@ -3117,7 +4953,7 @@ const watchExtension = async (options) => {
|
|
|
3117
4953
|
current = await watchAll(project, dir, reporter, false);
|
|
3118
4954
|
await copyStatic(project, dir);
|
|
3119
4955
|
if (current.isHealthy) {
|
|
3120
|
-
await assertValid(project, dir);
|
|
4956
|
+
await assertValid(project, dir, options.logger);
|
|
3121
4957
|
logger?.info(`rebuilt ${(await resultOf$1(project, dir)).files.join(", ")}`);
|
|
3122
4958
|
}
|
|
3123
4959
|
} catch (error) {
|
|
@@ -3179,6 +5015,118 @@ const createGithubChecker = (options = {}) => {
|
|
|
3179
5015
|
};
|
|
3180
5016
|
};
|
|
3181
5017
|
|
|
5018
|
+
//#endregion
|
|
5019
|
+
//#region packages/extension-tools/src/lint/bundle.ts
|
|
5020
|
+
const CODE_FILE = /\.(?:mjs|js|cjs)$/;
|
|
5021
|
+
const OBFUSCATION_MIN_BYTES = 20480;
|
|
5022
|
+
const OBFUSCATION_LINE_LENGTH = 500;
|
|
5023
|
+
const OBFUSCATION_IDENTIFIERS = 20;
|
|
5024
|
+
const DYNAMIC_EXECUTION = /(?<![\w$])(?:eval\s*\(|new\s+Function\s*\()/;
|
|
5025
|
+
const HEX_IDENTIFIER = /(?<![\w$])_0x[0-9a-f]{3,}(?![\w$])/gi;
|
|
5026
|
+
const URL_PATTERN = /https?:\/\/[^\s"'`<>)\\]+/g;
|
|
5027
|
+
const SOURCE_MAP = /\/\/[#@]\s*sourceMappingURL=/;
|
|
5028
|
+
/** XML namespaces (`createElementNS`) are identifiers, not network addresses. */
|
|
5029
|
+
const NAMESPACE_URL = /^https?:\/\/www\.w3\.org\//;
|
|
5030
|
+
const listCodeFiles = async (dir, prefix = "") => {
|
|
5031
|
+
const entries = await readdir(path.join(dir, prefix), { withFileTypes: true });
|
|
5032
|
+
return (await Promise.all(entries.map(async (entry) => {
|
|
5033
|
+
const relative = prefix === "" ? entry.name : `${prefix}/${entry.name}`;
|
|
5034
|
+
if (entry.isDirectory()) return listCodeFiles(dir, relative);
|
|
5035
|
+
return entry.isFile() && CODE_FILE.test(entry.name) ? [relative] : [];
|
|
5036
|
+
}))).flat().sort();
|
|
5037
|
+
};
|
|
5038
|
+
/** Code files (`.mjs`, `.js`, `.cjs`) of a built extension directory. */
|
|
5039
|
+
const readBundleFiles = async (dir) => Promise.all((await listCodeFiles(dir)).map(async (file) => ({
|
|
5040
|
+
path: file,
|
|
5041
|
+
text: await readFile(path.join(dir, file), "utf8")
|
|
5042
|
+
})));
|
|
5043
|
+
const isObfuscated = (text) => {
|
|
5044
|
+
const size = Buffer.byteLength(text);
|
|
5045
|
+
if (size >= OBFUSCATION_MIN_BYTES) {
|
|
5046
|
+
const average = size / text.split("\n").length;
|
|
5047
|
+
if (average > OBFUSCATION_LINE_LENGTH) return `average line length is ${Math.round(average)} characters`;
|
|
5048
|
+
}
|
|
5049
|
+
const identifiers = new Set(text.match(HEX_IDENTIFIER) ?? []);
|
|
5050
|
+
return identifiers.size >= OBFUSCATION_IDENTIFIERS ? `${identifiers.size} identifiers of the form _0x1a2b` : null;
|
|
5051
|
+
};
|
|
5052
|
+
/**
|
|
5053
|
+
* Heuristics over the built code (`CHECK-022`…`CHECK-025`). They read the
|
|
5054
|
+
* whole bundle, dependencies included, so only the source map is an error.
|
|
5055
|
+
*/
|
|
5056
|
+
const bundleFindings = (files, permissions) => {
|
|
5057
|
+
const findings = [];
|
|
5058
|
+
const mayUseNetwork = permissions.includes("network");
|
|
5059
|
+
for (const { path: field, text } of files) {
|
|
5060
|
+
if (DYNAMIC_EXECUTION.test(text)) findings.push({
|
|
5061
|
+
ruleId: "CHECK-022",
|
|
5062
|
+
severity: "warning",
|
|
5063
|
+
field,
|
|
5064
|
+
message: "dynamic code execution (eval or new Function)"
|
|
5065
|
+
});
|
|
5066
|
+
const obfuscation = isObfuscated(text);
|
|
5067
|
+
if (obfuscation !== null) findings.push({
|
|
5068
|
+
ruleId: "CHECK-023",
|
|
5069
|
+
severity: "warning",
|
|
5070
|
+
field,
|
|
5071
|
+
message: `looks obfuscated: ${obfuscation}`
|
|
5072
|
+
});
|
|
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
|
+
if (SOURCE_MAP.test(text)) findings.push({
|
|
5083
|
+
ruleId: "CHECK-025",
|
|
5084
|
+
severity: "error",
|
|
5085
|
+
field,
|
|
5086
|
+
message: "embedded source map: the catalog builds without maps"
|
|
5087
|
+
});
|
|
5088
|
+
}
|
|
5089
|
+
return findings;
|
|
5090
|
+
};
|
|
5091
|
+
|
|
5092
|
+
//#endregion
|
|
5093
|
+
//#region packages/extension-tools/src/lint/manifest.ts
|
|
5094
|
+
const MIN_DESCRIPTION_LENGTH = 20;
|
|
5095
|
+
/** `CHECK-019`: a description is present but too short to tell what the extension does. */
|
|
5096
|
+
const shortDescription = (description) => description !== null && description.trim() !== "" && description.trim().length < 20 ? [{
|
|
5097
|
+
severity: "warning",
|
|
5098
|
+
field: "description",
|
|
5099
|
+
message: `description is shorter than ${20} characters: say what the extension does`
|
|
5100
|
+
}] : [];
|
|
5101
|
+
const missing$1 = (value) => value === null || value.trim() === "";
|
|
5102
|
+
/** Publication metadata of a project: every problem is a warning. */
|
|
5103
|
+
const manifestFindings = (fields) => {
|
|
5104
|
+
const findings = [];
|
|
5105
|
+
for (const field of ["name", "author"]) if (missing$1(fields[field])) findings.push({
|
|
5106
|
+
ruleId: "CHECK-003",
|
|
5107
|
+
severity: "warning",
|
|
5108
|
+
field,
|
|
5109
|
+
message: `'${field}' is not set: the catalog requires it`
|
|
5110
|
+
});
|
|
5111
|
+
if (missing$1(fields.description)) findings.push({
|
|
5112
|
+
ruleId: "CHECK-003",
|
|
5113
|
+
severity: "warning",
|
|
5114
|
+
field: "description",
|
|
5115
|
+
message: "'description' is not set: the catalog requires it"
|
|
5116
|
+
});
|
|
5117
|
+
for (const finding of shortDescription(fields.description)) findings.push({
|
|
5118
|
+
ruleId: "CHECK-019",
|
|
5119
|
+
...finding
|
|
5120
|
+
});
|
|
5121
|
+
if (fields.tags === null || fields.tags.length === 0) findings.push({
|
|
5122
|
+
ruleId: "LINT-001",
|
|
5123
|
+
severity: "warning",
|
|
5124
|
+
field: "tags",
|
|
5125
|
+
message: "'tags' is not set: without it the catalog derives tags from contributions"
|
|
5126
|
+
});
|
|
5127
|
+
return findings;
|
|
5128
|
+
};
|
|
5129
|
+
|
|
3182
5130
|
//#endregion
|
|
3183
5131
|
//#region packages/extension-tools/src/catalog/rules.ts
|
|
3184
5132
|
const MAX_SOURCE_FILES = 200;
|
|
@@ -3388,6 +5336,80 @@ const iconFile = {
|
|
|
3388
5336
|
return problem === null ? [] : [error("icon", problem)];
|
|
3389
5337
|
}
|
|
3390
5338
|
};
|
|
5339
|
+
const shortDescriptionRule = {
|
|
5340
|
+
id: "CHECK-019",
|
|
5341
|
+
title: "description is at least 20 characters",
|
|
5342
|
+
run: ({ declared }) => shortDescription(declared?.description ?? null).map((finding) => warning(finding.field, finding.message))
|
|
5343
|
+
};
|
|
5344
|
+
const MAX_CHANGELOG_BYTES = 65536;
|
|
5345
|
+
const escapeRegExp = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
5346
|
+
/** Why the bytes are no acceptable `CHANGELOG.md` (over 64 KiB, not UTF-8, NUL); `null` — fine. */
|
|
5347
|
+
const changelogProblem = (bytes) => {
|
|
5348
|
+
if (bytes.length > 65536) return `${bytes.length} bytes exceed the limit of ${MAX_CHANGELOG_BYTES}`;
|
|
5349
|
+
try {
|
|
5350
|
+
return new TextDecoder("utf-8", { fatal: true }).decode(bytes).includes("\0") ? "contains NUL characters" : null;
|
|
5351
|
+
} catch {
|
|
5352
|
+
return "is not valid UTF-8";
|
|
5353
|
+
}
|
|
5354
|
+
};
|
|
5355
|
+
/** CHANGELOG.md: up to 64 KiB, UTF-8 without NUL; a missing section of the manifest version is a warning. */
|
|
5356
|
+
const changelog = {
|
|
5357
|
+
id: "CHECK-030",
|
|
5358
|
+
title: "CHANGELOG.md is up to 64 KiB of UTF-8 text and has a section of the current version",
|
|
5359
|
+
run: async ({ readBytes, manifest }) => {
|
|
5360
|
+
const bytes = await readBytes("CHANGELOG.md");
|
|
5361
|
+
if (bytes === null) return [];
|
|
5362
|
+
const problem = changelogProblem(bytes);
|
|
5363
|
+
if (problem !== null) return [error("CHANGELOG.md", problem)];
|
|
5364
|
+
const text = new TextDecoder().decode(bytes);
|
|
5365
|
+
if (manifest === null) return [];
|
|
5366
|
+
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
|
+
}
|
|
5368
|
+
};
|
|
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
|
+
const firstPublisherOwnsId = {
|
|
5389
|
+
id: "CHECK-021",
|
|
5390
|
+
title: "the id is not published under another author",
|
|
5391
|
+
run: ({ declared, published }) => {
|
|
5392
|
+
const author = declared?.author;
|
|
5393
|
+
if (!author || published === void 0) return [];
|
|
5394
|
+
return published.author.toLowerCase() === author.toLowerCase() ? [] : [error("author", `id '${published.id}' is published by '${published.author}': the first publisher owns the id`)];
|
|
5395
|
+
}
|
|
5396
|
+
};
|
|
5397
|
+
const NO_BUNDLE = "built version is not found: the bundle heuristics are skipped";
|
|
5398
|
+
/** `CHECK-022`…`CHECK-025`: heuristics over the built version; silent without `--built`. */
|
|
5399
|
+
const bundleRule = (id, title, missingBundle = false) => ({
|
|
5400
|
+
id,
|
|
5401
|
+
title,
|
|
5402
|
+
run: async ({ bundleDir, manifest }) => {
|
|
5403
|
+
if (bundleDir === null || manifest === null) return [];
|
|
5404
|
+
const files = await readBundleFiles(bundleDir).catch(() => null);
|
|
5405
|
+
if (files === null) return missingBundle ? [warning("--built", `${NO_BUNDLE} (${bundleDir})`)] : [];
|
|
5406
|
+
return bundleFindings(files, manifest.permissions).filter((finding) => finding.ruleId === id).map(({ severity, field, message }) => ({
|
|
5407
|
+
severity,
|
|
5408
|
+
field,
|
|
5409
|
+
message
|
|
5410
|
+
}));
|
|
5411
|
+
}
|
|
5412
|
+
});
|
|
3391
5413
|
const RULES = [
|
|
3392
5414
|
manifestValid,
|
|
3393
5415
|
directoryName,
|
|
@@ -3406,9 +5428,63 @@ const RULES = [
|
|
|
3406
5428
|
noExecutables,
|
|
3407
5429
|
appVersionBound,
|
|
3408
5430
|
assetFiles,
|
|
3409
|
-
iconFile
|
|
5431
|
+
iconFile,
|
|
5432
|
+
shortDescriptionRule,
|
|
5433
|
+
permissionsExplained,
|
|
5434
|
+
firstPublisherOwnsId,
|
|
5435
|
+
bundleRule("CHECK-022", "built code does not execute dynamic code", true),
|
|
5436
|
+
bundleRule("CHECK-023", "built code is not obfuscated"),
|
|
5437
|
+
bundleRule("CHECK-024", "URLs in built code need the network permission"),
|
|
5438
|
+
bundleRule("CHECK-025", "built code has no embedded source map"),
|
|
5439
|
+
translations,
|
|
5440
|
+
changelog
|
|
3410
5441
|
];
|
|
3411
5442
|
|
|
5443
|
+
//#endregion
|
|
5444
|
+
//#region packages/extension-tools/src/catalog/deprecated.ts
|
|
5445
|
+
/** Reads `deprecated.json`; every problem is a `BuildError` (nothing is written by the caller). */
|
|
5446
|
+
const loadDeprecated = async (file) => {
|
|
5447
|
+
let raw;
|
|
5448
|
+
try {
|
|
5449
|
+
raw = JSON.parse(await readFile(file, "utf8"));
|
|
5450
|
+
} catch (error) {
|
|
5451
|
+
throw new BuildError(`deprecated list is unreadable: ${error instanceof Error ? error.message : String(error)}`, file);
|
|
5452
|
+
}
|
|
5453
|
+
try {
|
|
5454
|
+
return parseDeprecatedList(raw);
|
|
5455
|
+
} catch (error) {
|
|
5456
|
+
if (!(error instanceof CatalogFormatError)) throw error;
|
|
5457
|
+
throw new BuildError(`deprecated list is invalid: ${error.issues.join("; ")}`, file);
|
|
5458
|
+
}
|
|
5459
|
+
};
|
|
5460
|
+
/** Problems of the list against the ids known to the index: unknown `id`, alternative missing from the index. */
|
|
5461
|
+
const deprecatedProblems = (items, known, { checkIds = true } = {}) => items.flatMap((item) => [...checkIds && !known.has(item.id) ? [{
|
|
5462
|
+
id: item.id,
|
|
5463
|
+
field: "id",
|
|
5464
|
+
message: `'${item.id}' is not in the index`
|
|
5465
|
+
}] : [], ...item.alternatives.filter((alternative) => !known.has(alternative)).map((alternative) => ({
|
|
5466
|
+
id: item.id,
|
|
5467
|
+
field: "alternatives",
|
|
5468
|
+
message: `alternative '${alternative}' is not in the index`
|
|
5469
|
+
}))]);
|
|
5470
|
+
/** Sets `deprecated` of the entries from the list and removes it from entries no longer listed. */
|
|
5471
|
+
const applyDeprecated = (entries, items, file) => {
|
|
5472
|
+
const problems = deprecatedProblems(items, new Set(entries.map(({ id }) => id)));
|
|
5473
|
+
if (problems.length > 0) throw new BuildError(problems.map(({ id, message }) => `${id}: ${message}`).join("; "), file);
|
|
5474
|
+
const byId = new Map(items.map((item) => [item.id, item]));
|
|
5475
|
+
return entries.map((entry) => {
|
|
5476
|
+
const next = { ...entry };
|
|
5477
|
+
delete next.deprecated;
|
|
5478
|
+
const item = byId.get(entry.id);
|
|
5479
|
+
if (item !== void 0) next.deprecated = {
|
|
5480
|
+
versions: item.versions ?? null,
|
|
5481
|
+
reason: item.reason,
|
|
5482
|
+
alternatives: [...item.alternatives]
|
|
5483
|
+
};
|
|
5484
|
+
return next;
|
|
5485
|
+
});
|
|
5486
|
+
};
|
|
5487
|
+
|
|
3412
5488
|
//#endregion
|
|
3413
5489
|
//#region packages/extension-tools/src/catalog/tree.ts
|
|
3414
5490
|
const compareText = (a, b) => {
|
|
@@ -3502,35 +5578,54 @@ const inspectManifest = async (dir) => {
|
|
|
3502
5578
|
problem: null
|
|
3503
5579
|
} : {
|
|
3504
5580
|
manifest: null,
|
|
3505
|
-
problem: result.
|
|
5581
|
+
problem: formatDiagnostic(result.diagnostic)
|
|
3506
5582
|
};
|
|
3507
5583
|
};
|
|
3508
|
-
const stringOrNull = (value) => typeof value === "string" ? value : null;
|
|
5584
|
+
const stringOrNull$1 = (value) => typeof value === "string" ? value : null;
|
|
5585
|
+
/** The extension manifest as written, with name and description in English (`%key%` → `locales/en.json`). */
|
|
3509
5586
|
const readDeclared = async (dir) => {
|
|
5587
|
+
let raw;
|
|
3510
5588
|
try {
|
|
3511
|
-
|
|
3512
|
-
|
|
3513
|
-
const fields = raw;
|
|
5589
|
+
raw = JSON.parse(await readFile(path.join(dir, "extension.json"), "utf8"));
|
|
5590
|
+
} catch {
|
|
3514
5591
|
return {
|
|
3515
|
-
|
|
3516
|
-
|
|
3517
|
-
author: stringOrNull(fields.author),
|
|
3518
|
-
icon: stringOrNull(fields.icon)
|
|
5592
|
+
raw: null,
|
|
5593
|
+
declared: null
|
|
3519
5594
|
};
|
|
3520
|
-
} catch {
|
|
3521
|
-
return null;
|
|
3522
5595
|
}
|
|
5596
|
+
if (typeof raw !== "object" || raw === null) return {
|
|
5597
|
+
raw,
|
|
5598
|
+
declared: null
|
|
5599
|
+
};
|
|
5600
|
+
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
|
+
return {
|
|
5607
|
+
raw,
|
|
5608
|
+
declared: {
|
|
5609
|
+
name: text(fields.name),
|
|
5610
|
+
description: text(fields.description),
|
|
5611
|
+
author: stringOrNull$1(fields.author),
|
|
5612
|
+
icon: stringOrNull$1(fields.icon)
|
|
5613
|
+
}
|
|
5614
|
+
};
|
|
3523
5615
|
};
|
|
3524
5616
|
const contextFor = async (options, dirName, published, checkGithubUser) => {
|
|
3525
5617
|
const dir = path.resolve(options.extensionsDir, dirName);
|
|
3526
5618
|
const { manifest, problem } = await inspectManifest(dir);
|
|
5619
|
+
const { raw, declared } = await readDeclared(dir);
|
|
3527
5620
|
return {
|
|
3528
5621
|
dirName,
|
|
3529
5622
|
dir,
|
|
3530
5623
|
manifest,
|
|
3531
|
-
declared
|
|
5624
|
+
declared,
|
|
5625
|
+
rawManifest: raw,
|
|
3532
5626
|
manifestProblem: problem,
|
|
3533
5627
|
tree: await readTree(dir, SKIPPED_SOURCE_DIRS),
|
|
5628
|
+
bundleDir: options.builtDir === void 0 || manifest === null ? null : path.join(path.resolve(options.builtDir), "extensions", manifest.id, manifest.version),
|
|
3534
5629
|
readText: (file) => readTextOrNull(path.join(dir, file)),
|
|
3535
5630
|
readBytes: (file) => readBytesInside(dir, file),
|
|
3536
5631
|
published: published?.extensions.find((entry) => entry.id === (manifest?.id ?? dirName)),
|
|
@@ -3539,6 +5634,32 @@ const contextFor = async (options, dirName, published, checkGithubUser) => {
|
|
|
3539
5634
|
checkGithubUser
|
|
3540
5635
|
};
|
|
3541
5636
|
};
|
|
5637
|
+
const DEPRECATED_RULE = "deprecated";
|
|
5638
|
+
/** The form of `deprecated.json` and (with an index) the existence of the alternatives; unknown ids are a build matter. */
|
|
5639
|
+
const deprecatedFindings = async (file, published) => {
|
|
5640
|
+
let items;
|
|
5641
|
+
try {
|
|
5642
|
+
items = await loadDeprecated(file);
|
|
5643
|
+
} catch (error) {
|
|
5644
|
+
if (!(error instanceof BuildError)) throw error;
|
|
5645
|
+
return [{
|
|
5646
|
+
severity: "error",
|
|
5647
|
+
extensionId: path.basename(file),
|
|
5648
|
+
ruleId: DEPRECATED_RULE,
|
|
5649
|
+
field: "/",
|
|
5650
|
+
message: error.message
|
|
5651
|
+
}];
|
|
5652
|
+
}
|
|
5653
|
+
if (published === null) return [];
|
|
5654
|
+
const known = new Set(published.extensions.map(({ id }) => id));
|
|
5655
|
+
return deprecatedProblems(items, known, { checkIds: false }).map(({ id, field, message }) => ({
|
|
5656
|
+
severity: "error",
|
|
5657
|
+
extensionId: id,
|
|
5658
|
+
ruleId: DEPRECATED_RULE,
|
|
5659
|
+
field,
|
|
5660
|
+
message
|
|
5661
|
+
}));
|
|
5662
|
+
};
|
|
3542
5663
|
const checkCatalog = async (options) => {
|
|
3543
5664
|
const dirs = await selectDirs(options);
|
|
3544
5665
|
const published = options.publishedIndex === void 0 ? null : await loadIndexFile(options.publishedIndex);
|
|
@@ -3546,7 +5667,7 @@ const checkCatalog = async (options) => {
|
|
|
3546
5667
|
...options.fetch === void 0 ? {} : { fetch: options.fetch },
|
|
3547
5668
|
token: options.githubToken
|
|
3548
5669
|
});
|
|
3549
|
-
const findings = [];
|
|
5670
|
+
const findings = [...options.deprecated === void 0 ? [] : await deprecatedFindings(options.deprecated, published)];
|
|
3550
5671
|
for (const dirName of dirs) {
|
|
3551
5672
|
const context = await contextFor(options, dirName, published, checkGithubUser);
|
|
3552
5673
|
for (const rule of RULES) for (const finding of await rule.run(context)) findings.push({
|
|
@@ -3577,7 +5698,11 @@ const orderVersion = (version) => ({
|
|
|
3577
5698
|
baseUrl: version.baseUrl,
|
|
3578
5699
|
files: [...version.files].sort(byPath).map(orderFile),
|
|
3579
5700
|
...version.icon === void 0 ? {} : { icon: version.icon },
|
|
3580
|
-
...version.tags === void 0 || version.tags.length === 0 ? {} : { tags: [...version.tags] }
|
|
5701
|
+
...version.tags === void 0 || version.tags.length === 0 ? {} : { tags: [...version.tags] },
|
|
5702
|
+
...version.dependencies === void 0 || version.dependencies.length === 0 ? {} : { dependencies: version.dependencies.map(({ id, range }) => ({
|
|
5703
|
+
id,
|
|
5704
|
+
...range === void 0 ? {} : { range }
|
|
5705
|
+
})) }
|
|
3581
5706
|
});
|
|
3582
5707
|
const optionalIds = (key, ids) => ids === void 0 || ids.length === 0 ? {} : { [key]: [...ids] };
|
|
3583
5708
|
const orderTitles = (titles) => {
|
|
@@ -3603,22 +5728,31 @@ const orderEntry = (entry) => ({
|
|
|
3603
5728
|
...optionalIds("settings", entry.contributes.settings),
|
|
3604
5729
|
...optionalIds("events", entry.contributes.events),
|
|
3605
5730
|
...optionalIds("commands", entry.contributes.commands),
|
|
3606
|
-
...optionalIds("panels", entry.contributes.panels)
|
|
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)
|
|
3607
5736
|
},
|
|
3608
5737
|
...orderTitles(entry.titles),
|
|
5738
|
+
...entry.deprecated === void 0 ? {} : { deprecated: {
|
|
5739
|
+
versions: entry.deprecated.versions,
|
|
5740
|
+
reason: entry.deprecated.reason,
|
|
5741
|
+
alternatives: [...entry.deprecated.alternatives]
|
|
5742
|
+
} },
|
|
3609
5743
|
versions: entry.versions.map(orderVersion)
|
|
3610
5744
|
});
|
|
3611
5745
|
const byId = (a, b) => compareText(a.id, b.id);
|
|
3612
5746
|
const newestFirst = (versions) => [...versions].sort((a, b) => compareSemver(b.version, a.version)).slice(0, 5);
|
|
3613
|
-
const checked = (candidate
|
|
5747
|
+
const checked = (candidate) => {
|
|
3614
5748
|
try {
|
|
3615
5749
|
parseIndex(candidate);
|
|
3616
5750
|
return candidate;
|
|
3617
5751
|
} catch (error) {
|
|
3618
|
-
throw new BuildError(`resulting index is invalid: ${error instanceof Error ? error.message : String(error)}`,
|
|
5752
|
+
throw new BuildError(`resulting index is invalid: ${error instanceof Error ? error.message : String(error)}`, FULL_INDEX_FILE);
|
|
3619
5753
|
}
|
|
3620
5754
|
};
|
|
3621
|
-
/** Builds the
|
|
5755
|
+
/** Builds the index with a stable key order; throws `BuildError` if it fails `parseIndex`. */
|
|
3622
5756
|
const assembleIndex = (parts) => {
|
|
3623
5757
|
const candidate = {
|
|
3624
5758
|
schemaVersion: 2,
|
|
@@ -3630,15 +5764,7 @@ const assembleIndex = (parts) => {
|
|
|
3630
5764
|
reason: item.reason
|
|
3631
5765
|
}))
|
|
3632
5766
|
};
|
|
3633
|
-
return checked(candidate
|
|
3634
|
-
};
|
|
3635
|
-
/** `index.v2.json` and `index.json` of the same content and `generatedAt`. */
|
|
3636
|
-
const assembleIndexes = (parts) => {
|
|
3637
|
-
const full = assembleIndex(parts);
|
|
3638
|
-
return {
|
|
3639
|
-
full,
|
|
3640
|
-
legacy: checked(legacySubset(full), INDEX_FILE)
|
|
3641
|
-
};
|
|
5767
|
+
return checked(candidate);
|
|
3642
5768
|
};
|
|
3643
5769
|
const writeIndexAtomically = async (file, index) => {
|
|
3644
5770
|
const temporary = `${file}.${process.pid}.tmp`;
|
|
@@ -3664,11 +5790,20 @@ const hasSameContent = (current, next) => current !== null && contentOf(current)
|
|
|
3664
5790
|
const DEFAULT_SOURCE_BASE = "https://github.com/dolphy-app/dolphy-extensions/tree/main/extensions";
|
|
3665
5791
|
const errorText = (error) => error instanceof Error ? error.message : String(error);
|
|
3666
5792
|
const README = "README.md";
|
|
5793
|
+
const CHANGELOG = "CHANGELOG.md";
|
|
3667
5794
|
const copyReadme = async (srcDir, dir, id) => {
|
|
3668
5795
|
const text = await readFile(path.join(srcDir, README), "utf8").catch(() => "");
|
|
3669
5796
|
if (text.trim() === "") throw new BuildError(`${README} is missing or empty`, id);
|
|
3670
5797
|
await writeFile(path.join(dir, README), text);
|
|
3671
5798
|
};
|
|
5799
|
+
/** `CHANGELOG.md` is optional; its limits are checked by the file-size rules and `CHECK-030`. */
|
|
5800
|
+
const copyChangelog = async (srcDir, dir, id) => {
|
|
5801
|
+
const bytes = await readFile(path.join(srcDir, CHANGELOG)).catch(() => null);
|
|
5802
|
+
if (bytes === null) return;
|
|
5803
|
+
const problem = changelogProblem(bytes);
|
|
5804
|
+
if (problem !== null) throw new BuildError(`${CHANGELOG} ${problem}`, id);
|
|
5805
|
+
await writeFile(path.join(dir, CHANGELOG), bytes);
|
|
5806
|
+
};
|
|
3672
5807
|
const fileProblems = (files) => {
|
|
3673
5808
|
const problems = files.filter((file) => !isSafeCatalogPath(file.path)).map((file) => `'${file.path}' is not allowed (extensions ${CATALOG_FILE_EXTENSIONS.join("|")}, safe path segments)`);
|
|
3674
5809
|
if (files.length > 100) problems.push(`${files.length} files exceed the limit of ${100}`);
|
|
@@ -3682,7 +5817,7 @@ const fileProblems = (files) => {
|
|
|
3682
5817
|
};
|
|
3683
5818
|
const readBuiltManifest = async (dir) => {
|
|
3684
5819
|
const parsed = parseManifest(JSON.parse(await readFile(path.join(dir, "extension.json"), "utf8")));
|
|
3685
|
-
if (!parsed.ok) throw new BuildError(parsed.
|
|
5820
|
+
if (!parsed.ok) throw new BuildError(formatDiagnostic(parsed.diagnostic), dir);
|
|
3686
5821
|
return parsed.manifest;
|
|
3687
5822
|
};
|
|
3688
5823
|
/** The icon declared in the manifest, read from the built directory; its checks ran in `buildExtension`. */
|
|
@@ -3701,11 +5836,12 @@ const stage = async (options, id, scratch) => {
|
|
|
3701
5836
|
});
|
|
3702
5837
|
if (built.id !== id) throw new BuildError(`directory name '${id}' does not match manifest id '${built.id}'`, id);
|
|
3703
5838
|
await copyReadme(srcDir, built.dir, id);
|
|
5839
|
+
await copyChangelog(srcDir, built.dir, id);
|
|
3704
5840
|
const tree = await readTree(built.dir);
|
|
3705
5841
|
const files = await hashTree(built.dir, tree.files);
|
|
3706
5842
|
const problems = [...tree.symlinks.map((link) => `'${link}' is a symbolic link`), ...fileProblems(files)];
|
|
3707
5843
|
if (problems.length > 0) throw new BuildError(problems.join("; "), id);
|
|
3708
|
-
const manifest = await readBuiltManifest(built.dir);
|
|
5844
|
+
const manifest = localizeManifest(await readBuiltManifest(built.dir), { en: await readEnglishTable(built.dir) ?? {} }, "en");
|
|
3709
5845
|
return {
|
|
3710
5846
|
id,
|
|
3711
5847
|
dir: built.dir,
|
|
@@ -3744,6 +5880,14 @@ const immutabilityError = (staged) => new BuildError(`version ${staged.manifest.
|
|
|
3744
5880
|
const titlesOf = (manifest) => {
|
|
3745
5881
|
const { contributes } = manifest;
|
|
3746
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
|
+
}]),
|
|
3747
5891
|
themes: contributes.themes.map(({ id, label }) => ({
|
|
3748
5892
|
id,
|
|
3749
5893
|
title: label
|
|
@@ -3763,6 +5907,18 @@ const titlesOf = (manifest) => {
|
|
|
3763
5907
|
panels: contributes.panels.map(({ id, title }) => ({
|
|
3764
5908
|
id,
|
|
3765
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
|
|
3766
5922
|
}))
|
|
3767
5923
|
};
|
|
3768
5924
|
return Object.fromEntries(TITLED_POINTS.filter((point) => byPoint[point].length > 0).map((point) => [point, Object.fromEntries(byPoint[point].map(({ id, title }) => [id, title]))]));
|
|
@@ -3776,7 +5932,11 @@ const newRecord = (staged, publishedAt) => ({
|
|
|
3776
5932
|
baseUrl: baseUrlOf(staged),
|
|
3777
5933
|
files: staged.files,
|
|
3778
5934
|
...staged.icon === null ? {} : { icon: staged.icon },
|
|
3779
|
-
...staged.manifest.tags.length === 0 ? {} : { tags: [...staged.manifest.tags] }
|
|
5935
|
+
...staged.manifest.tags.length === 0 ? {} : { tags: [...staged.manifest.tags] },
|
|
5936
|
+
...staged.manifest.dependencies.length === 0 ? {} : { dependencies: staged.manifest.dependencies.map(({ id, range }) => ({
|
|
5937
|
+
id,
|
|
5938
|
+
...range === null ? {} : { range }
|
|
5939
|
+
})) }
|
|
3780
5940
|
});
|
|
3781
5941
|
const plan = async (staged, out, previous, publishedAt) => {
|
|
3782
5942
|
const onDisk = await existingFiles(versionDir(out, staged));
|
|
@@ -3799,6 +5959,10 @@ const entryOf = (item, previous, sourceBase) => {
|
|
|
3799
5959
|
const events = manifest.contributes.events.map((item) => item.event);
|
|
3800
5960
|
const commands = manifest.contributes.commands.map(({ id }) => id);
|
|
3801
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);
|
|
3802
5966
|
const titles = titlesOf(manifest);
|
|
3803
5967
|
return {
|
|
3804
5968
|
id: staged.id,
|
|
@@ -3815,9 +5979,14 @@ const entryOf = (item, previous, sourceBase) => {
|
|
|
3815
5979
|
...settings.length > 0 ? { settings } : {},
|
|
3816
5980
|
...events.length > 0 ? { events } : {},
|
|
3817
5981
|
...commands.length > 0 ? { commands } : {},
|
|
3818
|
-
...panels.length > 0 ? { panels } : {}
|
|
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 } : {}
|
|
3819
5987
|
},
|
|
3820
5988
|
...Object.keys(titles).length > 0 ? { titles } : {},
|
|
5989
|
+
...previous?.deprecated === void 0 ? {} : { deprecated: previous.deprecated },
|
|
3821
5990
|
versions: newestFirst([record, ...others])
|
|
3822
5991
|
};
|
|
3823
5992
|
};
|
|
@@ -3832,6 +6001,7 @@ const loadRevoked = async (file, previous) => {
|
|
|
3832
6001
|
if (!Array.isArray(raw)) throw new BuildError("revoked list must be a JSON array", file);
|
|
3833
6002
|
return raw;
|
|
3834
6003
|
};
|
|
6004
|
+
const withDeprecations = async (entries, file) => file === void 0 ? entries : applyDeprecated(entries, await loadDeprecated(file), file);
|
|
3835
6005
|
const writeVersion = async (item, out) => {
|
|
3836
6006
|
const target = versionDir(out, item.staged);
|
|
3837
6007
|
const parent = path.dirname(target);
|
|
@@ -3844,38 +6014,25 @@ const writeVersion = async (item, out) => {
|
|
|
3844
6014
|
await cp(item.staged.dir, temporary, { recursive: true });
|
|
3845
6015
|
await rename(temporary, target);
|
|
3846
6016
|
};
|
|
3847
|
-
const resultOf = (item
|
|
6017
|
+
const resultOf = (item) => ({
|
|
3848
6018
|
id: item.staged.id,
|
|
3849
6019
|
version: item.staged.manifest.version,
|
|
3850
6020
|
status: item.status,
|
|
3851
6021
|
files: item.staged.files.length,
|
|
3852
|
-
bytes: item.staged.files.reduce((sum, file) => sum + file.size, 0)
|
|
3853
|
-
inLegacyIndex: isLegacyEntry(entry) && isLegacyVersion(item.record)
|
|
6022
|
+
bytes: item.staged.files.reduce((sum, file) => sum + file.size, 0)
|
|
3854
6023
|
});
|
|
3855
|
-
const formatPublishResult = (result) => {
|
|
3856
|
-
|
|
3857
|
-
|
|
3858
|
-
|
|
3859
|
-
|
|
3860
|
-
const
|
|
3861
|
-
if (
|
|
3862
|
-
const full = path.join(out, FULL_INDEX_FILE);
|
|
3863
|
-
return await stat(full).catch(() => null) === null ? path.join(out, INDEX_FILE) : full;
|
|
3864
|
-
};
|
|
3865
|
-
/**
|
|
3866
|
-
* Writes `index.v2.json` and `index.json` when either differs from the disk (ignoring
|
|
3867
|
-
* `generatedAt`); both get the same `generatedAt`, so the app can compare them with each other.
|
|
3868
|
-
*/
|
|
3869
|
-
const writeIndexes = async (out, { full, legacy }) => {
|
|
3870
|
-
const fullFile = path.join(out, FULL_INDEX_FILE);
|
|
3871
|
-
const legacyFile = path.join(out, INDEX_FILE);
|
|
3872
|
-
if (hasSameContent(await loadIndexFile(fullFile), full) && hasSameContent(await loadIndexFile(legacyFile), legacy)) return false;
|
|
6024
|
+
const formatPublishResult = (result) => result.status === "unchanged" ? `unchanged ${result.id}@${result.version}` : `published ${result.id}@${result.version} (${result.files} files, ${result.bytes} bytes)`;
|
|
6025
|
+
/** The index to start from: the explicit file, else the index of the site. */
|
|
6026
|
+
const previousIndexFile = async (out, explicit) => explicit === void 0 ? path.join(out, FULL_INDEX_FILE) : path.resolve(explicit);
|
|
6027
|
+
/** Writes `index.v2.json` when it differs from the disk (ignoring `generatedAt`). */
|
|
6028
|
+
const writeIndex = async (out, index) => {
|
|
6029
|
+
const file = path.join(out, FULL_INDEX_FILE);
|
|
6030
|
+
if (hasSameContent(await loadIndexFile(file), index)) return false;
|
|
3873
6031
|
await mkdir(out, { recursive: true });
|
|
3874
|
-
await writeIndexAtomically(
|
|
3875
|
-
await writeIndexAtomically(legacyFile, legacy);
|
|
6032
|
+
await writeIndexAtomically(file, index);
|
|
3876
6033
|
return true;
|
|
3877
6034
|
};
|
|
3878
|
-
/** Builds extension versions and updates `index.v2.json
|
|
6035
|
+
/** Builds extension versions and updates `index.v2.json`; on error nothing is written to disk. */
|
|
3879
6036
|
const buildCatalog = async (options) => {
|
|
3880
6037
|
const out = path.resolve(options.out);
|
|
3881
6038
|
const now = (options.now ?? (() => /* @__PURE__ */ new Date()))().toISOString();
|
|
@@ -3896,15 +6053,15 @@ const buildCatalog = async (options) => {
|
|
|
3896
6053
|
plans.push(item);
|
|
3897
6054
|
const entry = entryOf(item, before, sourceBase);
|
|
3898
6055
|
entries.set(id, entry);
|
|
3899
|
-
results.push(resultOf(item
|
|
6056
|
+
results.push(resultOf(item));
|
|
3900
6057
|
}
|
|
3901
|
-
const
|
|
6058
|
+
const index = assembleIndex({
|
|
3902
6059
|
generatedAt: now,
|
|
3903
|
-
extensions: [...entries.values()],
|
|
6060
|
+
extensions: await withDeprecations([...entries.values()], options.deprecated),
|
|
3904
6061
|
revoked
|
|
3905
6062
|
});
|
|
3906
6063
|
for (const item of plans) if (item.shouldWrite) await writeVersion(item, out);
|
|
3907
|
-
await
|
|
6064
|
+
await writeIndex(out, index);
|
|
3908
6065
|
return results;
|
|
3909
6066
|
} finally {
|
|
3910
6067
|
await rm(scratch, {
|
|
@@ -3914,21 +6071,21 @@ const buildCatalog = async (options) => {
|
|
|
3914
6071
|
}
|
|
3915
6072
|
};
|
|
3916
6073
|
const formatReindexResult = (result) => `reindexed (${result.extensions} extensions, ${result.revoked} revoked)${result.changed ? "" : " — no changes"}`;
|
|
3917
|
-
/** Rewrites `revoked` and `generatedAt` of
|
|
6074
|
+
/** Rewrites `revoked` and `generatedAt` of the index; extension entries are unchanged. */
|
|
3918
6075
|
const reindexCatalog = async (options) => {
|
|
3919
6076
|
const out = path.resolve(options.out);
|
|
3920
6077
|
const source = await previousIndexFile(out, options.previousIndex);
|
|
3921
6078
|
const previous = await loadIndexFile(source);
|
|
3922
6079
|
if (previous === null) throw new CatalogUsageError(`nothing to reindex: ${source} does not exist`, source);
|
|
3923
|
-
const
|
|
6080
|
+
const index = assembleIndex({
|
|
3924
6081
|
generatedAt: options.publishedAt ?? (options.now ?? (() => /* @__PURE__ */ new Date()))().toISOString(),
|
|
3925
|
-
extensions: previous.extensions,
|
|
6082
|
+
extensions: await withDeprecations(previous.extensions, options.deprecated),
|
|
3926
6083
|
revoked: await loadRevoked(options.revoked, previous)
|
|
3927
6084
|
});
|
|
3928
|
-
const changed = await
|
|
6085
|
+
const changed = await writeIndex(out, index);
|
|
3929
6086
|
return {
|
|
3930
|
-
extensions:
|
|
3931
|
-
revoked:
|
|
6087
|
+
extensions: index.extensions.length,
|
|
6088
|
+
revoked: index.revoked.length,
|
|
3932
6089
|
changed
|
|
3933
6090
|
};
|
|
3934
6091
|
};
|
|
@@ -3936,36 +6093,45 @@ const reindexCatalog = async (options) => {
|
|
|
3936
6093
|
//#endregion
|
|
3937
6094
|
//#region packages/extension-tools/src/catalog/cli.ts
|
|
3938
6095
|
const CATALOG_SYNOPSIS = ` dolphy-ext catalog check <extensionsDir> [--ids a,b]
|
|
3939
|
-
[--published-index <path>] [--
|
|
3940
|
-
[--
|
|
6096
|
+
[--published-index <path>] [--deprecated <path>]
|
|
6097
|
+
[--max-app-version <x.y.z>]
|
|
6098
|
+
[--built <siteDir>] [--skip-github-check] [--list-rules]
|
|
3941
6099
|
dolphy-ext catalog build --src <extensionsDir> --ids a,b --out <siteDir>
|
|
3942
6100
|
[--previous-index <path>] [--revoked <path>]
|
|
3943
|
-
[--
|
|
6101
|
+
[--deprecated <path>] [--source-base <url>]
|
|
6102
|
+
[--published-at <iso>]
|
|
3944
6103
|
dolphy-ext catalog build --reindex --out <siteDir>
|
|
3945
6104
|
[--previous-index <path>] [--revoked <path>]
|
|
3946
|
-
[--published-at <iso>]
|
|
6105
|
+
[--deprecated <path>] [--published-at <iso>]
|
|
3947
6106
|
`;
|
|
3948
6107
|
const CATALOG_HELP = ` catalog check check the catalog's extension sources; lines
|
|
3949
6108
|
«error|warning <id> <RULE-ID> <field>: <message>»,
|
|
3950
6109
|
exit code 1 if any error
|
|
3951
6110
|
catalog build build versions into <siteDir>/extensions/<id>/<version>/ and
|
|
3952
|
-
update <siteDir>/index.json (published versions are
|
|
6111
|
+
update <siteDir>/index.v2.json (published versions are
|
|
3953
6112
|
immutable)
|
|
3954
6113
|
catalog build --reindex
|
|
3955
|
-
only replace revoked and generatedAt in the
|
|
3956
|
-
<siteDir>/index.json (--src and --ids are not needed)
|
|
6114
|
+
only replace revoked, deprecated and generatedAt in the
|
|
6115
|
+
existing <siteDir>/index.v2.json (--src and --ids are not needed)
|
|
3957
6116
|
|
|
3958
6117
|
--ids a,b only these extensions (check: all by default)
|
|
3959
|
-
--published-index <p> index.json of the published catalog (no file —
|
|
6118
|
+
--published-index <p> index.v2.json of the published catalog (no file —
|
|
3960
6119
|
nothing is published)
|
|
3961
6120
|
--max-app-version <v> minAppVersion must not be newer
|
|
6121
|
+
--built <siteDir> also check the built versions in
|
|
6122
|
+
<siteDir>/extensions/<id>/<version>/ (eval, obfuscation,
|
|
6123
|
+
URLs, source maps); without it these rules are silent
|
|
3962
6124
|
--skip-github-check do not verify the author via api.github.com
|
|
3963
6125
|
(token — the GITHUB_TOKEN variable)
|
|
3964
6126
|
--list-rules print the check rules
|
|
3965
6127
|
--src <dir> directory of projects <dir>/<id>
|
|
3966
6128
|
--out <dir> site root
|
|
3967
|
-
--previous-index <p> source index (default <out>/index.json)
|
|
6129
|
+
--previous-index <p> source index (default <out>/index.v2.json)
|
|
3968
6130
|
--revoked <p> JSON array {id, versions, reason}
|
|
6131
|
+
--deprecated <p> JSON array {id, versions?, reason, alternatives}
|
|
6132
|
+
(check: validate the form and, with
|
|
6133
|
+
--published-index, the alternatives; build: write
|
|
6134
|
+
"deprecated" to entries, unset — keep the index's)
|
|
3969
6135
|
--source-base <url> base for the index entry's source field
|
|
3970
6136
|
--published-at <iso> publishedAt of new versions (default now);
|
|
3971
6137
|
with --reindex — generatedAt
|
|
@@ -3974,7 +6140,9 @@ const VALUE_FLAGS = {
|
|
|
3974
6140
|
check: [
|
|
3975
6141
|
"--ids",
|
|
3976
6142
|
"--published-index",
|
|
3977
|
-
"--
|
|
6143
|
+
"--deprecated",
|
|
6144
|
+
"--max-app-version",
|
|
6145
|
+
"--built"
|
|
3978
6146
|
],
|
|
3979
6147
|
build: [
|
|
3980
6148
|
"--src",
|
|
@@ -3982,6 +6150,7 @@ const VALUE_FLAGS = {
|
|
|
3982
6150
|
"--out",
|
|
3983
6151
|
"--previous-index",
|
|
3984
6152
|
"--revoked",
|
|
6153
|
+
"--deprecated",
|
|
3985
6154
|
"--source-base",
|
|
3986
6155
|
"--published-at"
|
|
3987
6156
|
]
|
|
@@ -4033,7 +6202,9 @@ const parseCheck = (args) => {
|
|
|
4033
6202
|
dir: positional[0] ?? null,
|
|
4034
6203
|
ids: idsOf(flags),
|
|
4035
6204
|
publishedIndex: text(flags, "--published-index"),
|
|
6205
|
+
deprecated: text(flags, "--deprecated"),
|
|
4036
6206
|
maxAppVersion,
|
|
6207
|
+
built: text(flags, "--built"),
|
|
4037
6208
|
skipGithubCheck: flags["--skip-github-check"] === true,
|
|
4038
6209
|
listRules: listRulesFlag
|
|
4039
6210
|
};
|
|
@@ -4053,6 +6224,7 @@ const parseReindex = (flags) => {
|
|
|
4053
6224
|
out,
|
|
4054
6225
|
previousIndex: text(flags, "--previous-index"),
|
|
4055
6226
|
revoked: text(flags, "--revoked"),
|
|
6227
|
+
deprecated: text(flags, "--deprecated"),
|
|
4056
6228
|
publishedAt: text(flags, "--published-at")
|
|
4057
6229
|
};
|
|
4058
6230
|
};
|
|
@@ -4075,6 +6247,7 @@ const parseBuild$1 = (args) => {
|
|
|
4075
6247
|
out,
|
|
4076
6248
|
previousIndex: text(flags, "--previous-index"),
|
|
4077
6249
|
revoked: text(flags, "--revoked"),
|
|
6250
|
+
deprecated: text(flags, "--deprecated"),
|
|
4078
6251
|
sourceBase: text(flags, "--source-base"),
|
|
4079
6252
|
publishedAt: text(flags, "--published-at")
|
|
4080
6253
|
};
|
|
@@ -4094,7 +6267,9 @@ const runCheck = async (parsed, io, deps) => {
|
|
|
4094
6267
|
extensionsDir: path.resolve(parsed.dir),
|
|
4095
6268
|
...parsed.ids === void 0 ? {} : { ids: parsed.ids },
|
|
4096
6269
|
...parsed.publishedIndex === void 0 ? {} : { publishedIndex: path.resolve(parsed.publishedIndex) },
|
|
6270
|
+
...parsed.deprecated === void 0 ? {} : { deprecated: path.resolve(parsed.deprecated) },
|
|
4097
6271
|
...parsed.maxAppVersion === void 0 ? {} : { maxAppVersion: parsed.maxAppVersion },
|
|
6272
|
+
...parsed.built === void 0 ? {} : { builtDir: path.resolve(parsed.built) },
|
|
4098
6273
|
skipGithubCheck: parsed.skipGithubCheck,
|
|
4099
6274
|
githubToken: deps.env?.GITHUB_TOKEN,
|
|
4100
6275
|
...deps.fetch === void 0 ? {} : { fetch: deps.fetch }
|
|
@@ -4107,6 +6282,7 @@ const runReindex = async (parsed, io, deps) => {
|
|
|
4107
6282
|
out: parsed.out,
|
|
4108
6283
|
...parsed.previousIndex === void 0 ? {} : { previousIndex: parsed.previousIndex },
|
|
4109
6284
|
...parsed.revoked === void 0 ? {} : { revoked: parsed.revoked },
|
|
6285
|
+
...parsed.deprecated === void 0 ? {} : { deprecated: parsed.deprecated },
|
|
4110
6286
|
...parsed.publishedAt === void 0 ? {} : { publishedAt: parsed.publishedAt },
|
|
4111
6287
|
...deps.now === void 0 ? {} : { now: deps.now }
|
|
4112
6288
|
});
|
|
@@ -4120,6 +6296,7 @@ const runBuild$1 = async (parsed, io, deps) => {
|
|
|
4120
6296
|
out: parsed.out,
|
|
4121
6297
|
...parsed.previousIndex === void 0 ? {} : { previousIndex: parsed.previousIndex },
|
|
4122
6298
|
...parsed.revoked === void 0 ? {} : { revoked: parsed.revoked },
|
|
6299
|
+
...parsed.deprecated === void 0 ? {} : { deprecated: parsed.deprecated },
|
|
4123
6300
|
...parsed.sourceBase === void 0 ? {} : { sourceBase: parsed.sourceBase },
|
|
4124
6301
|
...parsed.publishedAt === void 0 ? {} : { publishedAt: parsed.publishedAt },
|
|
4125
6302
|
...deps.now === void 0 ? {} : { now: deps.now }
|
|
@@ -4140,6 +6317,205 @@ const runCatalog = async (parsed, io, deps = {}) => {
|
|
|
4140
6317
|
}
|
|
4141
6318
|
};
|
|
4142
6319
|
|
|
6320
|
+
//#endregion
|
|
6321
|
+
//#region packages/extension-tools/src/dev.ts
|
|
6322
|
+
/** An app that quits sooner than this did not start for us: see `QUICK_EXIT_HINT`. */
|
|
6323
|
+
const QUICK_EXIT_MS = 5e3;
|
|
6324
|
+
const QUICK_EXIT_HINT = "Dolphy is probably already running: quit it and run again (the app has a single instance, a second launch exits at once and the extension directory is lost)";
|
|
6325
|
+
const flavor = (platform) => platform === "win32" ? path.win32 : path.posix;
|
|
6326
|
+
/** `Dolphy-Linux-1.12.0.AppImage` → `[1, 12, 0]`; other names have no version. */
|
|
6327
|
+
const versionOf = (name) => {
|
|
6328
|
+
const match = /^Dolphy-Linux-(\d+(?:\.\d+)*)\.AppImage$/.exec(name);
|
|
6329
|
+
return match === null ? null : match[1].split(".").map(Number);
|
|
6330
|
+
};
|
|
6331
|
+
const compareVersions = (a, b) => {
|
|
6332
|
+
for (let i = 0; i < Math.max(a.length, b.length); i++) {
|
|
6333
|
+
const diff = (a[i] ?? 0) - (b[i] ?? 0);
|
|
6334
|
+
if (diff !== 0) return diff;
|
|
6335
|
+
}
|
|
6336
|
+
return 0;
|
|
6337
|
+
};
|
|
6338
|
+
/** Places the installer puts the app (`electron-builder.json`); Linux has no fixed place. */
|
|
6339
|
+
const standardPlaces = (where) => {
|
|
6340
|
+
const p = flavor(where.platform);
|
|
6341
|
+
if (where.platform === "darwin") return ["/Applications/Dolphy.app", p.join(where.home, "Applications", "Dolphy.app")];
|
|
6342
|
+
if (where.platform === "win32") {
|
|
6343
|
+
const base = where.env.LOCALAPPDATA ?? p.join(where.home, "AppData", "Local");
|
|
6344
|
+
return [p.join(base, "Programs", "Dolphy", "Dolphy.exe")];
|
|
6345
|
+
}
|
|
6346
|
+
const dir = p.join(where.home, "Applications");
|
|
6347
|
+
return where.list(dir).flatMap((name) => {
|
|
6348
|
+
const version = versionOf(name);
|
|
6349
|
+
return version === null ? [] : [{
|
|
6350
|
+
name,
|
|
6351
|
+
version
|
|
6352
|
+
}];
|
|
6353
|
+
}).sort((a, b) => compareVersions(b.version, a.version)).map(({ name }) => p.join(dir, name));
|
|
6354
|
+
};
|
|
6355
|
+
const lookedAt = (where) => where.platform === "linux" ? flavor(where.platform).join(where.home, "Applications", "Dolphy-Linux-*.AppImage") : standardPlaces(where).join(", ");
|
|
6356
|
+
const missing = (where, what) => new CatalogUsageError(`${what}; looked at: ${lookedAt(where)}; point to the app with --app <path> or the DOLPHY_APP environment variable`, "dolphy-ext dev");
|
|
6357
|
+
/**
|
|
6358
|
+
* The app to launch: `--app`, then `DOLPHY_APP`, then the standard places of the
|
|
6359
|
+
* platform. A macOS bundle (`Dolphy.app`) is started by its executable, so that
|
|
6360
|
+
* the environment reaches the app and the process lives as long as the app does.
|
|
6361
|
+
*/
|
|
6362
|
+
const findApp = (explicit, where) => {
|
|
6363
|
+
const p = flavor(where.platform);
|
|
6364
|
+
const given = explicit ?? where.env.DOLPHY_APP;
|
|
6365
|
+
const chosen = given === void 0 || given === "" ? null : given;
|
|
6366
|
+
const found = (chosen === null ? standardPlaces(where) : [chosen]).find((place) => where.exists(place)) ?? null;
|
|
6367
|
+
if (found === null) throw missing(where, chosen === null ? "the Dolphy app is not found" : `${explicit === void 0 ? "DOLPHY_APP" : "--app"} points to '${chosen}', which does not exist`);
|
|
6368
|
+
return found.endsWith(".app") ? p.join(found, "Contents", "MacOS", p.basename(found, ".app")) : found;
|
|
6369
|
+
};
|
|
6370
|
+
const systemDeps = (env) => ({
|
|
6371
|
+
platform: process.platform,
|
|
6372
|
+
env,
|
|
6373
|
+
home: homedir(),
|
|
6374
|
+
exists: existsSync,
|
|
6375
|
+
list: (dir) => {
|
|
6376
|
+
try {
|
|
6377
|
+
return readdirSync(dir);
|
|
6378
|
+
} catch {
|
|
6379
|
+
return [];
|
|
6380
|
+
}
|
|
6381
|
+
},
|
|
6382
|
+
launch: (command, childEnv) => {
|
|
6383
|
+
const child = spawn(command, [], {
|
|
6384
|
+
env: childEnv,
|
|
6385
|
+
stdio: "inherit"
|
|
6386
|
+
});
|
|
6387
|
+
return {
|
|
6388
|
+
exited: new Promise((resolve, reject) => {
|
|
6389
|
+
child.once("error", reject);
|
|
6390
|
+
child.once("exit", (code, signal) => resolve({
|
|
6391
|
+
code,
|
|
6392
|
+
signal
|
|
6393
|
+
}));
|
|
6394
|
+
}),
|
|
6395
|
+
kill: () => {
|
|
6396
|
+
child.kill("SIGTERM");
|
|
6397
|
+
}
|
|
6398
|
+
};
|
|
6399
|
+
},
|
|
6400
|
+
now: Date.now
|
|
6401
|
+
});
|
|
6402
|
+
/**
|
|
6403
|
+
* `dolphy-ext dev`: a watch build of the project and the installed app that
|
|
6404
|
+
* reads the output through `DOLPHY_DEV_EXTENSIONS`. Resolves with the exit code
|
|
6405
|
+
* when the user stops it (0) or the app quits (0 after a normal session, 1 when
|
|
6406
|
+
* it quit at once).
|
|
6407
|
+
*/
|
|
6408
|
+
const runDev = async (options, io, deps, startBuild, waitForExit) => {
|
|
6409
|
+
const command = findApp(options.app, deps);
|
|
6410
|
+
const build = await startBuild();
|
|
6411
|
+
io.stdout(`${build.summary}\n`);
|
|
6412
|
+
try {
|
|
6413
|
+
io.stdout(`launching ${command} with DOLPHY_DEV_EXTENSIONS=${build.outDir}\n`);
|
|
6414
|
+
const startedAt = deps.now();
|
|
6415
|
+
const app = deps.launch(command, {
|
|
6416
|
+
...deps.env,
|
|
6417
|
+
DOLPHY_DEV_EXTENSIONS: build.outDir
|
|
6418
|
+
});
|
|
6419
|
+
io.stdout("watching for changes, Ctrl+C stops the build and the app\n");
|
|
6420
|
+
const ending = await Promise.race([waitForExit().then(() => ({ by: "user" })), app.exited.then((exit) => ({
|
|
6421
|
+
by: "app",
|
|
6422
|
+
exit
|
|
6423
|
+
}))]);
|
|
6424
|
+
if (ending.by === "user") {
|
|
6425
|
+
app.kill();
|
|
6426
|
+
await app.exited.catch(() => void 0);
|
|
6427
|
+
return 0;
|
|
6428
|
+
}
|
|
6429
|
+
const { code } = ending.exit;
|
|
6430
|
+
if (code !== null && deps.now() - startedAt < 5e3) {
|
|
6431
|
+
io.stderr(`${QUICK_EXIT_HINT}\n`);
|
|
6432
|
+
return 1;
|
|
6433
|
+
}
|
|
6434
|
+
return 0;
|
|
6435
|
+
} catch (error) {
|
|
6436
|
+
io.stderr(`error dolphy-ext dev: cannot run ${command}: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
6437
|
+
return 1;
|
|
6438
|
+
} finally {
|
|
6439
|
+
await build.close();
|
|
6440
|
+
}
|
|
6441
|
+
};
|
|
6442
|
+
|
|
6443
|
+
//#endregion
|
|
6444
|
+
//#region packages/extension-tools/src/lint/index.ts
|
|
6445
|
+
const formatLintFinding = (finding) => `${finding.severity} ${finding.extensionId} ${finding.ruleId} ${finding.field}: ${finding.message}`;
|
|
6446
|
+
const lintHasErrors = (findings) => findings.some((finding) => finding.severity === "error");
|
|
6447
|
+
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
|
+
const readManifest = async (root) => {
|
|
6455
|
+
let raw;
|
|
6456
|
+
try {
|
|
6457
|
+
raw = JSON.parse(await readFile(path.join(root, MANIFEST_FILE), "utf8"));
|
|
6458
|
+
} catch (error) {
|
|
6459
|
+
throw new CatalogUsageError(`${MANIFEST_FILE} is unreadable: ${error instanceof Error ? error.message : String(error)}`, root);
|
|
6460
|
+
}
|
|
6461
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) throw new CatalogUsageError(`${MANIFEST_FILE} is not an object`, root);
|
|
6462
|
+
return raw;
|
|
6463
|
+
};
|
|
6464
|
+
const isDirectory = async (dir) => (await stat(dir).catch(() => null))?.isDirectory() === true;
|
|
6465
|
+
/** Code files of the build the author is going to publish. */
|
|
6466
|
+
const builtFiles = async (options) => {
|
|
6467
|
+
if (options.built !== void 0) return readBundleFiles(options.built);
|
|
6468
|
+
const temp = await mkdtemp(path.join(tmpdir(), "dolphy-lint-"));
|
|
6469
|
+
try {
|
|
6470
|
+
const { dir } = await buildExtension({
|
|
6471
|
+
root: options.root,
|
|
6472
|
+
outDir: temp
|
|
6473
|
+
});
|
|
6474
|
+
return await readBundleFiles(dir);
|
|
6475
|
+
} finally {
|
|
6476
|
+
await rm(temp, {
|
|
6477
|
+
recursive: true,
|
|
6478
|
+
force: true
|
|
6479
|
+
});
|
|
6480
|
+
}
|
|
6481
|
+
};
|
|
6482
|
+
/**
|
|
6483
|
+
* Checks a project the way `catalog check` checks it, before a pull request:
|
|
6484
|
+
* manifest metadata, README and the built code. Everything is a warning
|
|
6485
|
+
* except a missing README and an embedded source map.
|
|
6486
|
+
*/
|
|
6487
|
+
const lintProject = async (options) => {
|
|
6488
|
+
const root = path.resolve(options.root);
|
|
6489
|
+
if (!await isDirectory(root)) throw new CatalogUsageError("not a directory", root);
|
|
6490
|
+
if (options.built !== void 0 && !await isDirectory(options.built)) throw new CatalogUsageError("--built: not a directory", options.built);
|
|
6491
|
+
const manifest = await readManifest(root);
|
|
6492
|
+
const extensionId = stringOrNull(manifest.id) ?? path.basename(root);
|
|
6493
|
+
const english = await readEnglishTable(root);
|
|
6494
|
+
const findings = manifestFindings({
|
|
6495
|
+
name: inEnglish(manifest.name, english),
|
|
6496
|
+
description: inEnglish(manifest.description, english),
|
|
6497
|
+
author: stringOrNull(manifest.author),
|
|
6498
|
+
tags: Array.isArray(manifest.tags) ? manifest.tags : null
|
|
6499
|
+
});
|
|
6500
|
+
const readme = await readFile(path.join(root, "README.md"), "utf8").catch(() => null);
|
|
6501
|
+
if (readme === null || readme.trim() === "") findings.push({
|
|
6502
|
+
ruleId: "CHECK-004",
|
|
6503
|
+
severity: "error",
|
|
6504
|
+
field: "README.md",
|
|
6505
|
+
message: "README.md is missing or empty"
|
|
6506
|
+
});
|
|
6507
|
+
const files = await builtFiles(options.built === void 0 ? { root } : {
|
|
6508
|
+
root,
|
|
6509
|
+
built: path.resolve(options.built)
|
|
6510
|
+
});
|
|
6511
|
+
findings.push(...bundleFindings(files, stringsOf(manifest.permissions)));
|
|
6512
|
+
return findings.map((finding) => ({
|
|
6513
|
+
...finding,
|
|
6514
|
+
severity: finding.ruleId === "CHECK-004" ? "error" : "warning",
|
|
6515
|
+
extensionId
|
|
6516
|
+
}));
|
|
6517
|
+
};
|
|
6518
|
+
|
|
4143
6519
|
//#endregion
|
|
4144
6520
|
//#region packages/extension-tools/src/cli/run.ts
|
|
4145
6521
|
const EXIT_OK = 0;
|
|
@@ -4148,6 +6524,8 @@ const EXIT_USAGE = 2;
|
|
|
4148
6524
|
const USAGE = `usage: dolphy-ext build [dir] [--out <dir>] [--watch]
|
|
4149
6525
|
dolphy-ext types [dir]
|
|
4150
6526
|
dolphy-ext validate <dir>
|
|
6527
|
+
dolphy-ext lint [dir] [--built <dir>]
|
|
6528
|
+
dolphy-ext dev [dir] [--app <path>]
|
|
4151
6529
|
${CATALOG_SYNOPSIS}
|
|
4152
6530
|
build [dir] build the extension from a project (default: the current
|
|
4153
6531
|
directory) into <dir>/dist-ext/<id>; also writes
|
|
@@ -4155,8 +6533,23 @@ ${CATALOG_SYNOPSIS}
|
|
|
4155
6533
|
types [dir] write <dir>/.dolphy/ids.d.ts: the ids declared in
|
|
4156
6534
|
extension.json as types for the SDK (no code is run)
|
|
4157
6535
|
validate <dir> check the directory of a built extension (extension.json,
|
|
4158
|
-
schemas, main and renderer)
|
|
6536
|
+
schemas, main and renderer, locales/*.json)
|
|
6537
|
+
lint [dir] check the project before a pull request to the catalog:
|
|
6538
|
+
manifest metadata, README.md and the built code (eval,
|
|
6539
|
+
obfuscation, URLs without the network permission, source
|
|
6540
|
+
maps); lines «error|warning <id> <RULE-ID> <field>:
|
|
6541
|
+
<message>», exit code 1 only if README.md is missing
|
|
6542
|
+
dev [dir] watch-build the project and launch the installed Dolphy app
|
|
6543
|
+
with DOLPHY_DEV_EXTENSIONS=<dir>/dist-ext; Ctrl+C stops both.
|
|
6544
|
+
The app is --app, then the DOLPHY_APP variable, then the
|
|
6545
|
+
standard place of the platform (macOS /Applications/Dolphy.app
|
|
6546
|
+
and ~/Applications/Dolphy.app; Windows
|
|
6547
|
+
%LOCALAPPDATA%\\Programs\\Dolphy\\Dolphy.exe; Linux the newest
|
|
6548
|
+
~/Applications/Dolphy-Linux-*.AppImage); exit code 2 if none
|
|
4159
6549
|
${CATALOG_HELP}
|
|
6550
|
+
--app <path> dev: the Dolphy app (macOS .app bundle or an executable)
|
|
6551
|
+
--built <dir> lint: check this built extension instead of building the
|
|
6552
|
+
project into a temporary directory
|
|
4160
6553
|
--out <dir> output root (the extension goes to <dir>/<id>)
|
|
4161
6554
|
--watch rebuild the bundles when the sources change
|
|
4162
6555
|
--help this help
|
|
@@ -4202,12 +6595,50 @@ const parseValidate = (args) => {
|
|
|
4202
6595
|
dir
|
|
4203
6596
|
};
|
|
4204
6597
|
};
|
|
6598
|
+
const parseLint = (args) => {
|
|
6599
|
+
const positional = [];
|
|
6600
|
+
let built;
|
|
6601
|
+
for (let i = 0; i < args.length; i++) {
|
|
6602
|
+
const arg = args[i];
|
|
6603
|
+
if (arg === "--built") {
|
|
6604
|
+
built = args[++i];
|
|
6605
|
+
if (built === void 0) return { usageError: "--built needs a path" };
|
|
6606
|
+
} else if (arg.startsWith("-")) return { usageError: `unknown flag: ${arg}` };
|
|
6607
|
+
else positional.push(arg);
|
|
6608
|
+
}
|
|
6609
|
+
if (positional.length > 1) return { usageError: `extra arguments: ${positional.slice(1).join(" ")}` };
|
|
6610
|
+
return {
|
|
6611
|
+
command: "lint",
|
|
6612
|
+
dir: positional[0] ?? ".",
|
|
6613
|
+
built
|
|
6614
|
+
};
|
|
6615
|
+
};
|
|
6616
|
+
const parseDev = (args) => {
|
|
6617
|
+
const positional = [];
|
|
6618
|
+
let app;
|
|
6619
|
+
for (let i = 0; i < args.length; i++) {
|
|
6620
|
+
const arg = args[i];
|
|
6621
|
+
if (arg === "--app") {
|
|
6622
|
+
app = args[++i];
|
|
6623
|
+
if (app === void 0) return { usageError: "--app needs a path" };
|
|
6624
|
+
} else if (arg.startsWith("-")) return { usageError: `unknown flag: ${arg}` };
|
|
6625
|
+
else positional.push(arg);
|
|
6626
|
+
}
|
|
6627
|
+
if (positional.length > 1) return { usageError: `extra arguments: ${positional.slice(1).join(" ")}` };
|
|
6628
|
+
return {
|
|
6629
|
+
command: "dev",
|
|
6630
|
+
dir: positional[0] ?? ".",
|
|
6631
|
+
app
|
|
6632
|
+
};
|
|
6633
|
+
};
|
|
4205
6634
|
const parseArgs = (argv) => {
|
|
4206
6635
|
if (argv.includes("--help") || argv.includes("-h")) return { help: true };
|
|
4207
6636
|
const [command, ...rest] = argv;
|
|
4208
6637
|
if (command === "build") return parseBuild(rest);
|
|
4209
6638
|
if (command === "types") return parseTypes(rest);
|
|
4210
6639
|
if (command === "validate") return parseValidate(rest);
|
|
6640
|
+
if (command === "lint") return parseLint(rest);
|
|
6641
|
+
if (command === "dev") return parseDev(rest);
|
|
4211
6642
|
if (command === "catalog") return {
|
|
4212
6643
|
command: "catalog",
|
|
4213
6644
|
args: rest
|
|
@@ -4253,7 +6684,8 @@ const runBuild = async (parsed, io, deps) => {
|
|
|
4253
6684
|
};
|
|
4254
6685
|
const runValidate = async (dir, io) => {
|
|
4255
6686
|
const root = path.resolve(dir);
|
|
4256
|
-
const { ok, problems } = await validateExtension(root);
|
|
6687
|
+
const { ok, problems, warnings } = await validateExtension(root);
|
|
6688
|
+
for (const warning of warnings) io.stdout(`warning ${root}: ${warning}\n`);
|
|
4257
6689
|
if (ok) {
|
|
4258
6690
|
io.stdout(`${root}: ok\n`);
|
|
4259
6691
|
return 0;
|
|
@@ -4261,6 +6693,50 @@ const runValidate = async (dir, io) => {
|
|
|
4261
6693
|
for (const problem of problems) io.stderr(`error ${root}: ${problem}\n`);
|
|
4262
6694
|
return 1;
|
|
4263
6695
|
};
|
|
6696
|
+
const runLint = async (parsed, io) => {
|
|
6697
|
+
try {
|
|
6698
|
+
const findings = await lintProject({
|
|
6699
|
+
root: parsed.dir,
|
|
6700
|
+
...parsed.built === void 0 ? {} : { built: parsed.built }
|
|
6701
|
+
});
|
|
6702
|
+
for (const finding of findings) io.stdout(`${formatLintFinding(finding)}\n`);
|
|
6703
|
+
return lintHasErrors(findings) ? 1 : 0;
|
|
6704
|
+
} catch (error) {
|
|
6705
|
+
if (error instanceof CatalogUsageError) {
|
|
6706
|
+
io.stderr(`error ${error.subject}: ${error.message}\n`);
|
|
6707
|
+
return 2;
|
|
6708
|
+
}
|
|
6709
|
+
return reportBuildError(io, error);
|
|
6710
|
+
}
|
|
6711
|
+
};
|
|
6712
|
+
const runDevCommand = async (parsed, io, deps) => {
|
|
6713
|
+
const root = path.resolve(parsed.dir);
|
|
6714
|
+
try {
|
|
6715
|
+
return await runDev({
|
|
6716
|
+
root,
|
|
6717
|
+
...parsed.app === void 0 ? {} : { app: parsed.app }
|
|
6718
|
+
}, io, deps.dev ?? systemDeps(deps.env ?? process.env), async () => {
|
|
6719
|
+
const handle = await watchExtension({
|
|
6720
|
+
root,
|
|
6721
|
+
logger: {
|
|
6722
|
+
info: (message) => io.stdout(`${message}\n`),
|
|
6723
|
+
error: (message) => io.stderr(`${message}\n`)
|
|
6724
|
+
}
|
|
6725
|
+
});
|
|
6726
|
+
return {
|
|
6727
|
+
outDir: path.join(root, DEFAULT_OUT_DIR),
|
|
6728
|
+
summary: summary(handle.result).trimEnd(),
|
|
6729
|
+
close: handle.close
|
|
6730
|
+
};
|
|
6731
|
+
}, deps.waitForExit ?? waitForSignal);
|
|
6732
|
+
} catch (error) {
|
|
6733
|
+
if (error instanceof CatalogUsageError) {
|
|
6734
|
+
io.stderr(`error ${error.subject}: ${error.message}\n`);
|
|
6735
|
+
return 2;
|
|
6736
|
+
}
|
|
6737
|
+
return reportBuildError(io, error);
|
|
6738
|
+
}
|
|
6739
|
+
};
|
|
4264
6740
|
const runTypes = async (dir, io) => {
|
|
4265
6741
|
try {
|
|
4266
6742
|
const root = path.resolve(dir);
|
|
@@ -4272,7 +6748,7 @@ const runTypes = async (dir, io) => {
|
|
|
4272
6748
|
return reportBuildError(io, error);
|
|
4273
6749
|
}
|
|
4274
6750
|
};
|
|
4275
|
-
/** `dolphy-ext build|types|validate|catalog`; `argv` is without `node` and the script name. */
|
|
6751
|
+
/** `dolphy-ext build|types|validate|lint|dev|catalog`; `argv` is without `node` and the script name. */
|
|
4276
6752
|
const runCli = async (argv, io, deps = {}) => {
|
|
4277
6753
|
const parsed = parseArgs(argv);
|
|
4278
6754
|
if ("help" in parsed) {
|
|
@@ -4292,6 +6768,8 @@ const runCli = async (argv, io, deps = {}) => {
|
|
|
4292
6768
|
return runCatalog(catalog, io, deps);
|
|
4293
6769
|
}
|
|
4294
6770
|
if (parsed.command === "validate") return runValidate(parsed.dir, io);
|
|
6771
|
+
if (parsed.command === "lint") return runLint(parsed, io);
|
|
6772
|
+
if (parsed.command === "dev") return runDevCommand(parsed, io, deps);
|
|
4295
6773
|
if (parsed.command === "types") return runTypes(parsed.dir, io);
|
|
4296
6774
|
return runBuild(parsed, io, deps);
|
|
4297
6775
|
};
|