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