@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.
Files changed (3) hide show
  1. package/README.md +4 -2
  2. package/dist/cli/main.js +2796 -318
  3. 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 { tmpdir } from "node:os";
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
- * Command key hint: up to three modifiers (`Mod`, `Ctrl`, `Alt`,
92
- * `Shift`) and a key joined with `+`: a letter or digit, `F1`–`F12`, or a name
93
- * (`Enter`, `Space`, `Tab`, `Escape`, `Backspace`, `Delete`, arrows,
94
- * `Home`, `End`, `PageUp`, `PageDown`).
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 = /^(?:(?:Mod|Ctrl|Alt|Shift)\+){0,3}(?:[A-Z0-9]|F(?:[1-9]|1[0-2])|Enter|Space|Tab|Escape|Backspace|Delete|Arrow(?:Up|Down|Left|Right)|Home|End|Page(?:Up|Down))$/;
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$1 = z.string().max(4e3);
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$1.optional(),
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$1.optional(),
307
- detail: text$1.optional(),
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$1.optional(),
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
- /** File extensions the first catalog format accepts (the released apps parse only these). */
358
- const LEGACY_FILE_EXTENSIONS = [
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 in the full index. */
376
- const CATALOG_FILE_EXTENSIONS = [...LEGACY_FILE_EXTENSIONS, ...ASSET_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`: parsed by every released app, strictly. */
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
- /** Путь файла версии: безопасные сегменты и расширение из `extensions`. */
728
- const isSafePathWith = (value, extensions) => {
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 && extensions.includes(extension);
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
- * Каталог файлов версии относительно адреса `index.json` (`extensions/<id>/<version>/`).
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((value) => isSafePathWith(value, profile.extensions), "must be a safe relative path"),
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(profile.maxFiles).superRefine((files, ctx) => {
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
- }).superRefine((version, ctx) => {
848
- if (!profile.tags && version.tags !== void 0) ctx.addIssue({
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(profile.schemaVersion),
914
- generatedAt: timestamp,
915
- extensions: z.array(entrySchemaOf(profile)),
916
- revoked: z.array(revokedSchema)
917
- });
918
- const LEGACY_PROFILE = {
919
- schemaVersion: 1,
920
- maxFiles: 50,
921
- extensions: LEGACY_FILE_EXTENSIONS,
922
- icon: false,
923
- titles: false,
924
- tags: false,
925
- strict: true
926
- };
927
- const FULL_PROFILE = {
928
- schemaVersion: 2,
929
- maxFiles: 100,
930
- extensions: CATALOG_FILE_EXTENSIONS,
931
- icon: true,
932
- titles: true,
933
- tags: true,
934
- strict: true
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
- /** Reader of the app: the limits of the full format, unknown keys dropped. */
937
- const TOLERANT_PROFILE = {
938
- ...FULL_PROFILE,
939
- strict: false
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
- * Strict parse for the author tools: unknown keys, file types and limits fail.
949
- * Both formats are accepted; `schemaVersion` chooses the rules.
950
- * Throws `CatalogFormatError`.
2216
+ * Могут ли оба условия быть истинными одновременно. `null` — нет условия.
2217
+ * Сверх 64 термов в нормальной форме ответ «да» (лучше лишнее предупреждение,
2218
+ * чем молчание).
951
2219
  */
952
- const parseIndex = (raw) => {
953
- const parsed = (isLegacyIndex(raw) ? legacyIndexSchema : indexSchema).safeParse(raw);
954
- if (parsed.success) return parsed.data;
955
- throw new CatalogFormatError(describeIssues(parsed.error));
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/extension-catalog/src/legacy.ts
971
- /** Permissions the released apps know: a catalog with any other value is rejected whole by them. */
972
- const LEGACY_PERMISSIONS = [
973
- "library.read",
974
- "process.spawn",
975
- "worker.threads",
976
- "native.addons",
977
- "network"
978
- ];
979
- /** Contribution lists the released apps do not know (`contributes.settings` and later). */
980
- const NEWER_CONTRIBUTION_KEYS = [
981
- "settings",
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
- * The part of the full index that every released app parses: versions and entries
995
- * they cannot read are left out, an extension left without versions is skipped,
996
- * `titles` (unknown to them) is dropped from the entries that stay.
997
- * The revocation list is kept whole.
2242
+ * Проверка привязки: клавиши (на каждой из `platforms`: `Mod` раскрывается по
2243
+ * платформе, и запись вроде `Mod+Ctrl+K` верна не везде), условие и правило
2244
+ * «сочетание без Ctrl и Meta, которое печатает или правит текст, должно быть
2245
+ * неактивно, пока фокус в поле ввода». `null` — привязка верна.
998
2246
  */
999
- const legacySubset = (index) => ({
1000
- ...index,
1001
- schemaVersion: 1,
1002
- extensions: index.extensions.filter(isLegacyEntry).map((entry) => {
1003
- const legacy = {
1004
- ...entry,
1005
- versions: entry.versions.filter(isLegacyVersion)
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
- delete legacy.titles;
1008
- return legacy;
1009
- }).filter((entry) => entry.versions.length > 0)
1010
- });
1011
-
1012
- //#endregion
1013
- //#region packages/extension-catalog/src/install-meta.ts
1014
- const httpUrl = z.url().refine((value) => /^https?:\/\//i.test(value), "catalogUrl must be an http(s) URL");
1015
- const installMetaSchema = z.strictObject({
1016
- catalogUrl: httpUrl,
1017
- version: z.string().refine(isSemver, "version must be semver"),
1018
- installedAt: z.iso.datetime()
1019
- });
1020
-
1021
- //#endregion
1022
- //#region packages/extension-catalog/src/urls.ts
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
- palette: z.boolean().optional()
2417
+ keybindings: z.array(z.strictObject({
2418
+ key: keyText,
2419
+ mac: keyText.optional(),
2420
+ windows: keyText.optional(),
2421
+ linux: keyText.optional(),
2422
+ when: z.string().min(1).max(EXTENSION_COMMAND_LIMITS.whenLength).optional()
2423
+ })).max(MAX_KEYBINDINGS).optional(),
2424
+ palette: z.boolean().optional(),
2425
+ when: whenField.optional(),
2426
+ icon: z.enum(EXTENSION_ICONS).optional()
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
- palette: entry.palette
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 = 1e4;
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": return [];
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 formatIssues = (error) => error.issues.map((issue) => `${issue.path.join(".") || "/"}: ${issue.message}`).join("; ");
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) => checkCompatibility(manifest, {
1521
- appVersion,
1522
- platform
1523
- })?.detail ?? null;
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
- message: `extension.json is unreadable: ${error instanceof Error ? error.message : String(error)}`
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
- message: parsed.message
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
- message: `directory name '${expectedId}' does not match manifest id '${manifest.id}'`
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
- message: incompatible
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
- message: error instanceof Error ? error.message : String(error)
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
- let result = source;
2394
- const ordered = [...sites].sort((a, b) => b.site.start - a.site.start);
2395
- for (const { site, keep } of ordered) {
2396
- const kept = site.keys.filter((key) => keep.has(key.name)).map((key) => source.slice(key.start, key.end));
2397
- result = `${result.slice(0, site.start)}{ ${kept.join(", ")} }${result.slice(site.end)}`;
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 result;
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) => setting.type === "enum" ? union(setting.options.map((option) => option.value)) : setting.type;
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.message, root);
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 = ["schema", "assets"];
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.message]
4853
+ problems: [formatDiagnostic(result.diagnostic)],
4854
+ warnings: []
3029
4855
  };
3030
- const problems = (await assetFindings(root, await listFiles(root))).map(({ path: file, message }) => `${file}: ${message}`);
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.message
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
- const raw = JSON.parse(await readFile(path.join(dir, "extension.json"), "utf8"));
3512
- if (typeof raw !== "object" || raw === null) return null;
3513
- const fields = raw;
5589
+ raw = JSON.parse(await readFile(path.join(dir, "extension.json"), "utf8"));
5590
+ } catch {
3514
5591
  return {
3515
- name: stringOrNull(fields.name),
3516
- description: stringOrNull(fields.description),
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: await readDeclared(dir),
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, file) => {
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)}`, file);
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 full index with a stable key order; throws `BuildError` if it fails `parseIndex`. */
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, FULL_INDEX_FILE);
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.message, dir);
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, entry) => ({
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
- const legacy = result.inLegacyIndex ? "" : ` — only in ${FULL_INDEX_FILE}: released apps cannot read it`;
3857
- return result.status === "unchanged" ? `unchanged ${result.id}@${result.version}${legacy}` : `published ${result.id}@${result.version} (${result.files} files, ${result.bytes} bytes)${legacy}`;
3858
- };
3859
- /** The index to start from: the explicit file, else the full index of the site, else its `index.json`. */
3860
- const previousIndexFile = async (out, explicit) => {
3861
- if (explicit !== void 0) return path.resolve(explicit);
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(fullFile, full);
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` and `index.json`; on error nothing is written to disk. */
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, entry));
6056
+ results.push(resultOf(item));
3900
6057
  }
3901
- const indexes = assembleIndexes({
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 writeIndexes(out, indexes);
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 both indexes; extension entries are unchanged. */
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 indexes = assembleIndexes({
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 writeIndexes(out, indexes);
6085
+ const changed = await writeIndex(out, index);
3929
6086
  return {
3930
- extensions: indexes.full.extensions.length,
3931
- revoked: indexes.full.revoked.length,
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>] [--max-app-version <x.y.z>]
3940
- [--skip-github-check] [--list-rules]
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
- [--source-base <url>] [--published-at <iso>]
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 existing
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
- "--max-app-version"
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
  };