@dolphy-app/extension-tools 0.2.0 → 0.3.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 +9 -7
  2. package/dist/cli/main.js +2640 -383
  3. package/package.json +2 -2
package/dist/cli/main.js CHANGED
@@ -1,14 +1,147 @@
1
1
  #!/usr/bin/env node
2
2
  import { builtinModules } from "node:module";
3
3
  import path from "node:path";
4
+ import { realpathSync, statSync, watch } from "node:fs";
4
5
  import { cp, lstat, mkdir, mkdtemp, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
5
6
  import { Ajv2020 } from "ajv/dist/2020.js";
6
7
  import { z } from "zod";
7
- import { build } from "vite";
8
- import { tmpdir } from "node:os";
9
8
  import { createHash } from "node:crypto";
9
+ import { build, createLogger, parseAst } from "vite";
10
+ import { tmpdir } from "node:os";
10
11
 
12
+ //#region packages/extension-api/src/index.ts
13
+ const EXTENSION_ID_PATTERN = /^[a-z][a-z0-9-]*(\.[a-z][a-z0-9-]*)*$/;
14
+ /** GitHub login of the extension author (`author` in the manifest and catalog). */
15
+ const GITHUB_LOGIN_PATTERN = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})$/;
16
+ /** Capabilities an extension declares in its manifest; none without a declaration. */
17
+ const EXTENSION_PERMISSIONS = [
18
+ "library.read",
19
+ "process.spawn",
20
+ "worker.threads",
21
+ "native.addons",
22
+ "network",
23
+ "learning.events"
24
+ ];
25
+ /** Platforms the extension can run on (`process.platform`). */
26
+ const EXTENSION_PLATFORMS = [
27
+ "darwin",
28
+ "linux",
29
+ "win32"
30
+ ];
31
+ /** Closed vocabulary of extension tags (`extension.json` → `tags`, up to 5 unique values). */
32
+ const EXTENSION_TAGS = [
33
+ "learning",
34
+ "language",
35
+ "content",
36
+ "theme",
37
+ "interface",
38
+ "productivity",
39
+ "developer"
40
+ ];
41
+ const ELEMENT_NAME_PATTERN = /^[a-z][a-z0-9]*(-[a-z0-9]+)+$/;
42
+ /** Learning events an extension with the `learning.events` permission can subscribe to. */
43
+ const LEARNING_EVENT_NAMES = [
44
+ "session.started",
45
+ "session.finished",
46
+ "attempt.closed"
47
+ ];
48
+ /** Identifiers of built-in themes: extensions cannot take them. */
49
+ const BUILTIN_THEME_IDS = [
50
+ "system",
51
+ "light",
52
+ "dark"
53
+ ];
54
+ /** Allowed keys of `ThemeContribution.colors`. */
55
+ const THEME_COLOR_KEYS = [
56
+ "background",
57
+ "surface",
58
+ "surface-bright",
59
+ "surface-light",
60
+ "surface-variant",
61
+ "on-background",
62
+ "on-surface",
63
+ "on-surface-variant",
64
+ "primary",
65
+ "on-primary",
66
+ "secondary",
67
+ "on-secondary",
68
+ "error",
69
+ "on-error",
70
+ "warning",
71
+ "on-warning",
72
+ "success",
73
+ "on-success",
74
+ "info",
75
+ "on-info",
76
+ "hero-start",
77
+ "hero-end",
78
+ "hero-contrast"
79
+ ];
80
+ /** Allowed keys of `ThemeContribution.variables`. */
81
+ const THEME_VARIABLE_KEYS = [
82
+ "border-color",
83
+ "border-opacity",
84
+ "medium-emphasis-opacity",
85
+ "high-emphasis-opacity",
86
+ "disabled-opacity"
87
+ ];
88
+ const DEFAULT_MARKDOWN_RENDERER = "./markdown.mjs";
89
+ const DEFAULT_PANEL = "./panel.mjs";
90
+ /**
91
+ * Command key hint: up to three modifiers (`Mod`, `Ctrl`, `Alt`,
92
+ * `Shift`) and a key joined with `+`: a letter or digit, `F1`–`F12`, or a name
93
+ * (`Enter`, `Space`, `Tab`, `Escape`, `Backspace`, `Delete`, arrows,
94
+ * `Home`, `End`, `PageUp`, `PageDown`).
95
+ */
96
+ const KEYBINDING_PATTERN = /^(?:(?:Mod|Ctrl|Alt|Shift)\+){0,3}(?:[A-Z0-9]|F(?:[1-9]|1[0-2])|Enter|Space|Tab|Escape|Backspace|Delete|Arrow(?:Up|Down|Left|Right)|Home|End|Page(?:Up|Down))$/;
97
+ /** Limits on commands and panels (R1, R3); they match those checked by the manifest, host, and engine. */
98
+ const EXTENSION_COMMAND_LIMITS = Object.freeze({
99
+ /** Commands per extension. */
100
+ commands: 64,
101
+ /** Panels per extension. */
102
+ panels: 8,
103
+ titleLength: 60,
104
+ categoryLength: 40,
105
+ descriptionLength: 200,
106
+ /** `JSON.stringify(args).length` at the engine boundary. */
107
+ argsChars: 2e5,
108
+ /** JSON text of the result in UTF-8 bytes. */
109
+ resultBytes: 65536,
110
+ /** Length of `notify` in UTF-16 code units. */
111
+ notifyChars: 500,
112
+ /** Handler budget, ms. */
113
+ handlerMs: 1e4
114
+ });
115
+ const DEFAULT_MAIN = "./main.mjs";
116
+ const DEFAULT_RENDERER = "./view.mjs";
117
+ /** Default element tag: `dolphy.sql` → `dolphy-sql-answer`. */
118
+ const defaultElementName = (id) => `${id.replaceAll(".", "-")}-answer`;
119
+ /** Extension storage limits (R2); they match the engine's limits, which enforces them. */
120
+ const EXTENSION_STORAGE_LIMITS = Object.freeze({
121
+ /** Key length in UTF-16 code units. */
122
+ keyLength: 128,
123
+ /** JSON text of a single value in UTF-8 bytes. */
124
+ valueBytes: 65536,
125
+ /** Number of keys. */
126
+ keys: 256,
127
+ /** Sum of JSON texts of all values in UTF-8 bytes. */
128
+ totalBytes: 1048576
129
+ });
130
+
131
+ //#endregion
11
132
  //#region packages/extension-host/src/protocol.ts
133
+ const eventParams = z.strictObject({
134
+ extensionId: z.string(),
135
+ name: z.enum(LEARNING_EVENT_NAMES),
136
+ payload: z.record(z.string(), z.unknown()),
137
+ isolated: z.boolean()
138
+ });
139
+ const commandParams = z.strictObject({
140
+ extensionId: z.string(),
141
+ commandId: z.string(),
142
+ args: z.unknown().optional(),
143
+ isolated: z.boolean()
144
+ });
12
145
  const typed = {
13
146
  type: z.string(),
14
147
  exerciseId: z.string(),
@@ -52,8 +185,105 @@ const extRequestSchema = z.discriminatedUnion("method", [
52
185
  gaveUp: z.boolean(),
53
186
  isolated: z.boolean()
54
187
  })
188
+ }),
189
+ z.strictObject({
190
+ id: z.string(),
191
+ method: z.literal("deliverEvent"),
192
+ params: eventParams
193
+ }),
194
+ z.strictObject({
195
+ id: z.string(),
196
+ method: z.literal("invokeCommand"),
197
+ params: commandParams
198
+ })
199
+ ]);
200
+ const settingValue = z.union([
201
+ z.boolean(),
202
+ z.string(),
203
+ z.number()
204
+ ]);
205
+ const settingChangedSchema = z.strictObject({
206
+ method: z.literal("settingChanged"),
207
+ params: z.strictObject({
208
+ extensionId: z.string(),
209
+ id: z.string(),
210
+ value: settingValue
211
+ })
212
+ });
213
+ const hostKey = z.strictObject({
214
+ extensionId: z.string(),
215
+ key: z.string()
216
+ });
217
+ const hostOwner = z.strictObject({ extensionId: z.string() });
218
+ /** Запрос хоста к движку: форму проверяет получатель, значение хранилища — сервис движка. */
219
+ const hostRequestSchema = z.discriminatedUnion("method", [
220
+ z.strictObject({
221
+ id: z.string(),
222
+ method: z.literal("storage.get"),
223
+ params: hostKey
224
+ }),
225
+ z.strictObject({
226
+ id: z.string(),
227
+ method: z.literal("storage.set"),
228
+ params: z.strictObject({
229
+ extensionId: z.string(),
230
+ key: z.string(),
231
+ value: z.unknown()
232
+ })
233
+ }),
234
+ z.strictObject({
235
+ id: z.string(),
236
+ method: z.literal("storage.delete"),
237
+ params: hostKey
238
+ }),
239
+ z.strictObject({
240
+ id: z.string(),
241
+ method: z.literal("storage.keys"),
242
+ params: hostOwner
243
+ }),
244
+ z.strictObject({
245
+ id: z.string(),
246
+ method: z.literal("settings.all"),
247
+ params: hostOwner
55
248
  })
56
249
  ]);
250
+ const hostResponseSchema = z.union([z.strictObject({
251
+ id: z.string(),
252
+ ok: z.literal(true),
253
+ result: z.unknown().optional()
254
+ }), z.strictObject({
255
+ id: z.string(),
256
+ ok: z.literal(false),
257
+ error: z.strictObject({
258
+ code: z.string(),
259
+ message: z.string(),
260
+ details: z.record(z.string(), z.unknown()).optional()
261
+ })
262
+ })]);
263
+ const isResolvedExtension = (value) => {
264
+ if (typeof value !== "object" || value === null) return false;
265
+ const item = value;
266
+ return typeof item.id === "string" && typeof item.version === "string" && typeof item.dir === "string" && typeof item.revision === "string" && (item.origin === "bundled" || item.origin === "user" || item.origin === "dev") && Array.isArray(item.permissions) && Array.isArray(item.exerciseTypes) && Array.isArray(item.themes) && Array.isArray(item.markdownRenderers) && Array.isArray(item.gradePolicies) && Array.isArray(item.settings) && Array.isArray(item.events) && Array.isArray(item.commands) && Array.isArray(item.panels);
267
+ };
268
+ /** Набор приходит от движка того же приложения, поэтому проверяется форма, а не каждое поле. */
269
+ const replaceExtensionsSchema = z.strictObject({
270
+ id: z.string(),
271
+ method: z.literal("replaceExtensions"),
272
+ params: z.strictObject({ extensions: z.array(z.custom(isResolvedExtension)) })
273
+ });
274
+ /** Всё, что хост расширений принимает по каналу. */
275
+ const extMessageSchema = z.union([
276
+ extRequestSchema,
277
+ replaceExtensionsSchema,
278
+ settingChangedSchema,
279
+ hostResponseSchema
280
+ ]);
281
+ /** Всё, что ограниченный процесс принимает от хоста (набор расширений ему не шлют). */
282
+ const childInboundSchema = z.union([
283
+ extRequestSchema,
284
+ settingChangedSchema,
285
+ hostResponseSchema
286
+ ]);
57
287
  /** Результат правила оценки: целое 1–5 или `null`. */
58
288
  const gradeValueSchema = z.union([z.literal([
59
289
  1,
@@ -84,72 +314,291 @@ const gradeResultSchema = z.discriminatedUnion("outcome", [
84
314
  data: z.unknown().optional()
85
315
  })
86
316
  ]);
317
+ const isJsonValue = (value, depth = 0) => {
318
+ if (depth > 64) return false;
319
+ if (value === null || typeof value === "string") return true;
320
+ if (typeof value === "boolean") return true;
321
+ if (typeof value === "number") return Number.isFinite(value);
322
+ if (Array.isArray(value)) return value.every((item) => isJsonValue(item, depth + 1));
323
+ if (typeof value !== "object") return false;
324
+ return Object.values(value).every((item) => isJsonValue(item, depth + 1));
325
+ };
326
+ const fitsResult = (value) => isJsonValue(value) && new TextEncoder().encode(JSON.stringify(value)).length <= EXTENSION_COMMAND_LIMITS.resultBytes;
327
+ const resultJson = z.custom(fitsResult, "must be JSON up to 64 KiB");
328
+ /**
329
+ * Результат команды на границе хоста и движка. Ограниченный процесс не
330
+ * доверен, поэтому форму и потолки проверяет и получатель, а не только
331
+ * `normalizeCommandResult` в рантайме.
332
+ */
333
+ const commandOutcomeSchema = z.discriminatedUnion("kind", [
334
+ z.strictObject({ kind: z.literal("none") }),
335
+ z.strictObject({
336
+ kind: z.literal("notify"),
337
+ text: z.string().min(1).max(EXTENSION_COMMAND_LIMITS.notifyChars)
338
+ }),
339
+ z.strictObject({
340
+ kind: z.literal("openPanel"),
341
+ panelId: z.string().min(1).max(128),
342
+ props: resultJson.optional()
343
+ }),
344
+ z.strictObject({
345
+ kind: z.literal("data"),
346
+ value: resultJson
347
+ })
348
+ ]);
87
349
 
88
350
  //#endregion
89
- //#region packages/extension-api/src/index.ts
90
- const EXTENSION_ID_PATTERN = /^[a-z][a-z0-9-]*(\.[a-z][a-z0-9-]*)*$/;
91
- /** GitHub-логин автора расширения (`author` в манифесте и каталоге). */
92
- const GITHUB_LOGIN_PATTERN = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})$/;
93
- /** Возможности, которые расширение объявляет в манифесте; без объявления — ни одной. */
94
- const EXTENSION_PERMISSIONS = [
95
- "library.read",
96
- "process.spawn",
97
- "worker.threads",
98
- "native.addons",
99
- "network"
100
- ];
101
- /** Платформы, на которых расширение может работать (`process.platform`). */
102
- const EXTENSION_PLATFORMS = [
103
- "darwin",
104
- "linux",
105
- "win32"
106
- ];
107
- const ELEMENT_NAME_PATTERN = /^[a-z][a-z0-9]*(-[a-z0-9]+)+$/;
108
- /** Идентификаторы встроенных тем: расширения не могут их занять. */
109
- const BUILTIN_THEME_IDS = [
110
- "system",
111
- "light",
112
- "dark"
351
+ //#region packages/extension-catalog/src/assets.ts
352
+ /**
353
+ * Asset types of a catalog version: file extensions, size ceilings, MIME types, and
354
+ * signature/geometry readers for the binary formats. Pure functions over bytes: the
355
+ * installer, the protocol and the author tools read the same constants.
356
+ */
357
+ /** File extensions the first catalog format accepts (the released apps parse only these). */
358
+ const LEGACY_FILE_EXTENSIONS = [
359
+ "json",
360
+ "js",
361
+ "mjs",
362
+ "md",
363
+ "txt"
113
364
  ];
114
- /** Допустимые ключи `ThemeContribution.colors`. */
115
- const THEME_COLOR_KEYS = [
116
- "background",
117
- "surface",
118
- "surface-bright",
119
- "surface-light",
120
- "surface-variant",
121
- "on-background",
122
- "on-surface",
123
- "on-surface-variant",
124
- "primary",
125
- "on-primary",
126
- "secondary",
127
- "on-secondary",
128
- "error",
129
- "on-error",
130
- "warning",
131
- "on-warning",
132
- "success",
133
- "on-success",
134
- "info",
135
- "on-info",
136
- "hero-start",
137
- "hero-end",
138
- "hero-contrast"
365
+ /** Style sheets, images and fonts an extension may ship. */
366
+ const ASSET_EXTENSIONS = [
367
+ "css",
368
+ "svg",
369
+ "png",
370
+ "webp",
371
+ "jpg",
372
+ "jpeg",
373
+ "woff2"
139
374
  ];
140
- /** Допустимые ключи `ThemeContribution.variables`. */
141
- const THEME_VARIABLE_KEYS = [
142
- "border-color",
143
- "border-opacity",
144
- "medium-emphasis-opacity",
145
- "high-emphasis-opacity",
146
- "disabled-opacity"
375
+ /** Every extension a file of a version may have in the full index. */
376
+ const CATALOG_FILE_EXTENSIONS = [...LEGACY_FILE_EXTENSIONS, ...ASSET_EXTENSIONS];
377
+ const KIB = 1024;
378
+ /** Per-file ceilings in bytes. `icon` bounds the manifest icon, which travels inside the index. */
379
+ const ASSET_LIMITS = {
380
+ css: 256 * KIB,
381
+ svg: 64 * KIB,
382
+ image: 512 * KIB,
383
+ woff2: 1024 * KIB,
384
+ icon: 16 * KIB
385
+ };
386
+ /** Largest side of a raster image in pixels. */
387
+ const MAX_IMAGE_SIDE = 4096;
388
+ /** Icon side range in pixels; the icon is square. */
389
+ const ICON_MIN_SIDE = 64;
390
+ const ICON_MAX_SIDE = 512;
391
+ /** Extension of the last path segment, as written (case-sensitive); `null` — none. */
392
+ const extensionOf = (path) => {
393
+ const name = path.slice(path.lastIndexOf("/") + 1);
394
+ const dot = name.lastIndexOf(".");
395
+ return dot > 0 ? name.slice(dot + 1) : null;
396
+ };
397
+ const isAssetExtension = (value) => ASSET_EXTENSIONS.includes(value);
398
+ /** The asset type of a path (lowercase extension only), or `null` for a non-asset file. */
399
+ const assetExtensionOf = (path) => {
400
+ const extension = extensionOf(path);
401
+ return extension !== null && isAssetExtension(extension) ? extension : null;
402
+ };
403
+ const isBinaryAsset = (extension) => extension !== "css" && extension !== "svg";
404
+ /** Ceiling in bytes for one file of the asset type. */
405
+ const assetSizeLimit = (extension) => {
406
+ if (extension === "css") return ASSET_LIMITS.css;
407
+ if (extension === "svg") return ASSET_LIMITS.svg;
408
+ return extension === "woff2" ? ASSET_LIMITS.woff2 : ASSET_LIMITS.image;
409
+ };
410
+ /** Problem text when a file of the given path exceeds its per-type ceiling; `null` — within limits. */
411
+ const sizeProblem = (path, size) => {
412
+ const extension = assetExtensionOf(path);
413
+ if (extension === null) return null;
414
+ const limit = assetSizeLimit(extension);
415
+ return size > limit ? `${path}: ${size} bytes exceed the limit of ${limit} for .${extension}` : null;
416
+ };
417
+ const fail = (reason) => ({
418
+ ok: false,
419
+ reason
420
+ });
421
+ const okWith = (size) => ({
422
+ ok: true,
423
+ size
424
+ });
425
+ const ascii = (bytes, offset, length) => {
426
+ let text = "";
427
+ for (let i = offset; i < offset + length && i < bytes.length; i++) text += String.fromCharCode(bytes[i] ?? 0);
428
+ return text;
429
+ };
430
+ const view = (bytes) => new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
431
+ const PNG_SIGNATURE = [
432
+ 137,
433
+ 80,
434
+ 78,
435
+ 71,
436
+ 13,
437
+ 10,
438
+ 26,
439
+ 10
147
440
  ];
148
- const DEFAULT_MARKDOWN_RENDERER = "./markdown.mjs";
149
- const DEFAULT_MAIN = "./main.mjs";
150
- const DEFAULT_RENDERER = "./view.mjs";
151
- /** Тег элемента по умолчанию: `dolphy.sql` → `dolphy-sql-answer`. */
152
- const defaultElementName = (id) => `${id.replaceAll(".", "-")}-answer`;
441
+ let crcTable = null;
442
+ const crc32 = (bytes, start, end) => {
443
+ if (crcTable === null) {
444
+ crcTable = /* @__PURE__ */ new Uint32Array(256);
445
+ for (let n = 0; n < 256; n++) {
446
+ let c = n;
447
+ for (let k = 0; k < 8; k++) c = c & 1 ? 3988292384 ^ c >>> 1 : c >>> 1;
448
+ crcTable[n] = c >>> 0;
449
+ }
450
+ }
451
+ let crc = 4294967295;
452
+ for (let i = start; i < end; i++) crc = (crcTable[(crc ^ (bytes[i] ?? 0)) & 255] ?? 0) ^ crc >>> 8;
453
+ return (crc ^ 4294967295) >>> 0;
454
+ };
455
+ const inspectPng = (bytes) => {
456
+ if (bytes.length < 8 || PNG_SIGNATURE.some((value, index) => bytes[index] !== value)) return fail("not a PNG file (signature mismatch)");
457
+ const data = view(bytes);
458
+ let offset = 8;
459
+ let dimensions = null;
460
+ let first = true;
461
+ let ended = false;
462
+ while (offset + 12 <= bytes.length) {
463
+ const length = data.getUint32(offset);
464
+ const type = ascii(bytes, offset + 4, 4);
465
+ const end = offset + 12 + length;
466
+ if (end > bytes.length) return fail("PNG chunk runs past the end of file");
467
+ if (first && type !== "IHDR") return fail("PNG does not start with IHDR");
468
+ if (type === "IHDR") {
469
+ if (!first || length !== 13) return fail("PNG has a malformed IHDR");
470
+ if (data.getUint32(end - 4) !== crc32(bytes, offset + 4, end - 4)) return fail("PNG IHDR checksum mismatch");
471
+ dimensions = {
472
+ width: data.getUint32(offset + 8),
473
+ height: data.getUint32(offset + 12)
474
+ };
475
+ }
476
+ if (type === "acTL") return fail("animated PNG is not accepted");
477
+ first = false;
478
+ offset = end;
479
+ if (type === "IEND") {
480
+ ended = true;
481
+ break;
482
+ }
483
+ }
484
+ if (!ended || dimensions === null) return fail("PNG is truncated (no IEND)");
485
+ if (offset !== bytes.length) return fail("PNG has data after IEND");
486
+ return okWith(dimensions);
487
+ };
488
+ const isStandaloneMarker = (marker) => marker === 1 || marker >= 208 && marker <= 216;
489
+ const isStartOfFrame = (marker) => marker >= 192 && marker <= 207 && marker !== 196 && marker !== 200 && marker !== 204;
490
+ const inspectJpeg = (bytes) => {
491
+ if (bytes[0] !== 255 || bytes[1] !== 216 || bytes[2] !== 255) return fail("not a JPEG file (signature mismatch)");
492
+ const data = view(bytes);
493
+ let offset = 2;
494
+ while (offset + 4 <= bytes.length) {
495
+ if (bytes[offset] !== 255) return fail("JPEG segment is malformed");
496
+ const marker = bytes[offset + 1] ?? 0;
497
+ if (marker === 255) {
498
+ offset += 1;
499
+ continue;
500
+ }
501
+ if (isStandaloneMarker(marker)) {
502
+ offset += 2;
503
+ continue;
504
+ }
505
+ const length = data.getUint16(offset + 2);
506
+ if (length < 2 || offset + 2 + length > bytes.length) return fail("JPEG segment runs past the end of file");
507
+ if (isStartOfFrame(marker)) {
508
+ if (length < 8) return fail("JPEG frame header is malformed");
509
+ return okWith({
510
+ height: data.getUint16(offset + 5),
511
+ width: data.getUint16(offset + 7)
512
+ });
513
+ }
514
+ if (marker === 218) break;
515
+ offset += 2 + length;
516
+ }
517
+ return fail("JPEG has no frame header");
518
+ };
519
+ const littleEndian24 = (bytes, offset) => (bytes[offset] ?? 0) | (bytes[offset + 1] ?? 0) << 8 | (bytes[offset + 2] ?? 0) << 16;
520
+ const inspectWebp = (bytes) => {
521
+ if (bytes.length < 21 || ascii(bytes, 0, 4) !== "RIFF" || ascii(bytes, 8, 4) !== "WEBP") return fail("not a WebP file (signature mismatch)");
522
+ const data = view(bytes);
523
+ if (data.getUint32(4, true) + 8 !== bytes.length) return fail("WebP RIFF size does not match the file size");
524
+ const chunk = ascii(bytes, 12, 4);
525
+ const needed = chunk === "VP8L" ? 25 : 30;
526
+ if (bytes.length < needed) return fail("WebP header is truncated");
527
+ if (chunk === "VP8X") {
528
+ if ((bytes[20] ?? 0) & 2) return fail("animated WebP is not accepted");
529
+ return okWith({
530
+ width: littleEndian24(bytes, 24) + 1,
531
+ height: littleEndian24(bytes, 27) + 1
532
+ });
533
+ }
534
+ if (chunk === "VP8L") {
535
+ if (bytes[20] !== 47) return fail("WebP lossless header is malformed");
536
+ const bits = data.getUint32(21, true);
537
+ return okWith({
538
+ width: (bits & 16383) + 1,
539
+ height: (bits >>> 14 & 16383) + 1
540
+ });
541
+ }
542
+ if (chunk === "VP8 ") {
543
+ if (bytes[23] !== 157 || bytes[24] !== 1 || bytes[25] !== 42) return fail("WebP lossy header is malformed");
544
+ return okWith({
545
+ width: data.getUint16(26, true) & 16383,
546
+ height: data.getUint16(28, true) & 16383
547
+ });
548
+ }
549
+ return fail("WebP has an unknown first chunk");
550
+ };
551
+ const WOFF2_HEADER_BYTES = 48;
552
+ const inspectWoff2 = (bytes) => {
553
+ if (bytes.length < WOFF2_HEADER_BYTES || ascii(bytes, 0, 4) !== "wOF2") return fail("not a WOFF2 file (signature mismatch)");
554
+ return view(bytes).getUint32(8) === bytes.length ? okWith(null) : fail("WOFF2 length field does not match the file size");
555
+ };
556
+ /**
557
+ * Checks the signature and structure of a binary asset and reads the pixel size of
558
+ * images. A declared size above `MAX_IMAGE_SIDE` is a failure: the header is not trusted.
559
+ */
560
+ const inspectBinaryAsset = (extension, bytes) => {
561
+ let result;
562
+ if (extension === "png") result = inspectPng(bytes);
563
+ else if (extension === "webp") result = inspectWebp(bytes);
564
+ else if (extension === "woff2") result = inspectWoff2(bytes);
565
+ else result = inspectJpeg(bytes);
566
+ if (!result.ok || result.size === null) return result;
567
+ const { width, height } = result.size;
568
+ if (width < 1 || height < 1) return fail("image has an empty size");
569
+ if (width > 4096 || height > 4096) return fail(`image is ${width}x${height}, the limit is ${MAX_IMAGE_SIDE}x${MAX_IMAGE_SIDE}`);
570
+ return result;
571
+ };
572
+ const isIconExtension = (value) => value === "png" || value === "webp";
573
+ /**
574
+ * Problem with a manifest icon: format, size in bytes, geometry. `null` — fits.
575
+ * `path` is the icon path from the manifest, `bytes` the file contents.
576
+ */
577
+ const iconProblem = (path, bytes) => {
578
+ const extension = extensionOf(path);
579
+ if (!isIconExtension(extension)) return `icon '${path}' must be a .png or .webp file`;
580
+ if (bytes.length > ASSET_LIMITS.icon) return `icon '${path}' is ${bytes.length} bytes, the limit is ${ASSET_LIMITS.icon}`;
581
+ const inspected = inspectBinaryAsset(extension, bytes);
582
+ if (!inspected.ok) return `icon '${path}': ${inspected.reason}`;
583
+ const { width, height } = inspected.size ?? {
584
+ width: 0,
585
+ height: 0
586
+ };
587
+ if (width !== height) return `icon '${path}' must be square, it is ${width}x${height}`;
588
+ if (width < 64 || width > 512) return `icon '${path}' must be ${64} to ${512} pixels, it is ${width}`;
589
+ return null;
590
+ };
591
+ /** `data:image/png|webp;base64,…` — the icon as the index and the window carry it. */
592
+ const iconDataUri = (path, bytes) => {
593
+ const extension = extensionOf(path) === "webp" ? "webp" : "png";
594
+ let binary = "";
595
+ const chunk = 32768;
596
+ for (let i = 0; i < bytes.length; i += chunk) binary += String.fromCharCode(...bytes.subarray(i, i + chunk));
597
+ return `data:image/${extension};base64,${btoa(binary)}`;
598
+ };
599
+ /** Longest `iconDataUri` the schema accepts: the base64 of `ASSET_LIMITS.icon` bytes plus the prefix. */
600
+ const MAX_ICON_URI_LENGTH = 23 + Math.ceil(ASSET_LIMITS.icon / 3) * 4;
601
+ const ICON_URI_PATTERN = /^data:image\/(?:png|webp);base64,[A-Za-z0-9+/]+={0,2}$/;
153
602
 
154
603
  //#endregion
155
604
  //#region packages/extension-catalog/src/errors.ts
@@ -244,30 +693,28 @@ const minAppVersionFailure = (minAppVersion, appVersion) => minAppVersion !== nu
244
693
  /** `minAppVersion` и `platforms` манифеста или записи каталога. */
245
694
  const checkCompatibility = (requirements, context) => minAppVersionFailure(requirements.minAppVersion, context.appVersion) ?? platformFailure(requirements.platforms, context.platform);
246
695
 
247
- //#endregion
248
- //#region packages/extension-catalog/src/install-meta.ts
249
- const httpUrl = z.url().refine((value) => /^https?:\/\//i.test(value), "catalogUrl must be an http(s) URL");
250
- const installMetaSchema = z.strictObject({
251
- catalogUrl: httpUrl,
252
- version: z.string().refine(isSemver, "version must be semver"),
253
- installedAt: z.iso.datetime()
254
- });
255
-
256
696
  //#endregion
257
697
  //#region packages/extension-catalog/src/schema.ts
258
- const CATALOG_SCHEMA_VERSION = 1;
698
+ /** Format of `index.json`: parsed by every released app, strictly. */
699
+ const LEGACY_SCHEMA_VERSION = 1;
700
+ /** Format of `index.v2.json`: asset file types, icons, up to `MAX_FILES_V2` files. */
701
+ const CATALOG_SCHEMA_VERSION = 2;
259
702
  const MAX_VERSIONS = 5;
260
703
  const MAX_FILES = 50;
261
704
  const MAX_TOTAL_BYTES = 1e7;
262
- const MAX_PATH_LENGTH = 200;
263
- const CATALOG_FILE_EXTENSIONS = [
264
- "json",
265
- "js",
266
- "mjs",
267
- "md",
268
- "txt"
705
+ /** Longest contribution title in `titles` (the manifest limit of `label`/`title`). */
706
+ const MAX_TITLE_LENGTH = 60;
707
+ /** Most tags of a version. */
708
+ const MAX_TAGS = 5;
709
+ /** Contribution points whose entries carry a human title in the manifest (`label` or `title`). */
710
+ const TITLED_POINTS = [
711
+ "themes",
712
+ "gradePolicies",
713
+ "settings",
714
+ "commands",
715
+ "panels"
269
716
  ];
270
- const FILE_EXTENSIONS = new Set(CATALOG_FILE_EXTENSIONS);
717
+ const MAX_PATH_LENGTH = 200;
271
718
  const SHA256 = /^[0-9a-f]{64}$/;
272
719
  /** Допустимые символы сегмента пути: без `:` (потоки NTFS), пробелов и управляющих символов. */
273
720
  const SEGMENT = /^[A-Za-z0-9_-][A-Za-z0-9._-]*$/;
@@ -277,129 +724,306 @@ const semver = z.string().refine(isSemver, "must be semver");
277
724
  const timestamp = z.iso.datetime({ offset: true });
278
725
  const httpsUrl = z.url().refine((value) => value.startsWith("https://"), "must be an https URL");
279
726
  const isSafeSegment = (segment) => SEGMENT.test(segment) && !segment.endsWith(".") && !RESERVED_NAME.test(segment);
280
- /** Путь файла версии: безопасные сегменты и расширение из `FILE_EXTENSIONS`. */
281
- const isSafeCatalogPath = (value) => {
727
+ /** Путь файла версии: безопасные сегменты и расширение из `extensions`. */
728
+ const isSafePathWith = (value, extensions) => {
282
729
  if (value.length > MAX_PATH_LENGTH) return false;
283
- const segments = value.split("/");
284
- if (!segments.every(isSafeSegment)) return false;
285
- const name = segments[segments.length - 1] ?? "";
286
- const dot = name.lastIndexOf(".");
287
- return dot > 0 && FILE_EXTENSIONS.has(name.slice(dot + 1));
730
+ if (!value.split("/").every(isSafeSegment)) return false;
731
+ const extension = extensionOf(value);
732
+ return extension !== null && extensions.includes(extension);
288
733
  };
289
- const filePath = z.string().refine(isSafeCatalogPath, "must be a safe relative path");
734
+ /** Path of a version file in the full index: safe segments, an allowed extension. */
735
+ const isSafeCatalogPath = (value) => isSafePathWith(value, CATALOG_FILE_EXTENSIONS);
736
+ /** Path of a version file in the first format (`index.json`). */
737
+ const isLegacyCatalogPath = (value) => isSafePathWith(value, LEGACY_FILE_EXTENSIONS);
290
738
  /**
291
739
  * Каталог файлов версии относительно адреса `index.json` (`extensions/<id>/<version>/`).
292
740
  * Абсолютный адрес не допускается: файлы версии по построению лежат на origin индекса,
293
741
  * а локальный каталог разработчика и e2e не требуют https.
294
742
  */
295
743
  const baseUrl = z.string().max(MAX_PATH_LENGTH).refine((value) => value.endsWith("/") && value.slice(0, -1).split("/").every(isSafeSegment), "must be a relative directory path ending with /");
296
- const fileSchema = z.strictObject({
297
- path: filePath,
744
+ const extensionId$1 = z.string().max(64).regex(EXTENSION_ID_PATTERN, "invalid extension id");
745
+ const descendingUnique = (versions, ctx) => {
746
+ for (let i = 1; i < versions.length; i++) {
747
+ const previous = versions[i - 1]?.version ?? "";
748
+ const current = versions[i]?.version ?? "";
749
+ if (previous === current) ctx.addIssue({
750
+ code: "custom",
751
+ path: [i, "version"],
752
+ message: `duplicate version '${current}'`
753
+ });
754
+ else if (isSemver(previous) && isSemver(current)) {
755
+ if (compareSemver(previous, current) < 0) ctx.addIssue({
756
+ code: "custom",
757
+ path: [i, "version"],
758
+ message: "versions must be sorted newest first"
759
+ });
760
+ }
761
+ }
762
+ };
763
+ const rangeText = z.string().superRefine((value, ctx) => {
764
+ try {
765
+ parseRange(value);
766
+ } catch (error) {
767
+ if (!(error instanceof CatalogFormatError)) throw error;
768
+ ctx.addIssue({
769
+ code: "custom",
770
+ message: "invalid version range"
771
+ });
772
+ }
773
+ });
774
+ const revokedSchema = z.strictObject({
775
+ id: extensionId$1,
776
+ versions: rangeText,
777
+ reason: z.string().min(1).max(300)
778
+ });
779
+ const object = (profile, shape) => profile.strict ? z.strictObject(shape) : z.object(shape);
780
+ const fileSchemaOf = (profile) => object(profile, {
781
+ path: z.string().refine((value) => isSafePathWith(value, profile.extensions), "must be a safe relative path"),
298
782
  size: z.number().int().min(0).max(MAX_TOTAL_BYTES),
299
783
  sha256: z.string().regex(SHA256, "must be lowercase hex sha256")
784
+ }).superRefine((file, ctx) => {
785
+ const problem = sizeProblem(file.path, file.size);
786
+ if (problem !== null) ctx.addIssue({
787
+ code: "custom",
788
+ message: problem
789
+ });
790
+ });
791
+ const filesSchemaOf = (profile) => z.array(fileSchemaOf(profile)).max(profile.maxFiles).superRefine((files, ctx) => {
792
+ const seen = /* @__PURE__ */ new Set();
793
+ files.forEach((file, index) => {
794
+ const key = file.path.toLowerCase();
795
+ if (seen.has(key)) ctx.addIssue({
796
+ code: "custom",
797
+ path: [index, "path"],
798
+ message: `duplicate path '${file.path}'`
799
+ });
800
+ seen.add(key);
801
+ });
802
+ files.forEach((file, index) => {
803
+ const segments = file.path.toLowerCase().split("/");
804
+ for (let depth = 1; depth < segments.length; depth++) if (seen.has(segments.slice(0, depth).join("/"))) {
805
+ ctx.addIssue({
806
+ code: "custom",
807
+ path: [index, "path"],
808
+ message: `'${file.path}' lies inside a file`
809
+ });
810
+ break;
811
+ }
812
+ });
813
+ if (!files.some((file) => file.path === "extension.json")) ctx.addIssue({
814
+ code: "custom",
815
+ message: "extension.json is required"
816
+ });
817
+ const total = files.reduce((sum, file) => sum + file.size, 0);
818
+ if (total > 1e7) ctx.addIssue({
819
+ code: "custom",
820
+ message: `total size ${total} exceeds ${MAX_TOTAL_BYTES}`
821
+ });
300
822
  });
301
- const extensionId$1 = z.string().max(64).regex(EXTENSION_ID_PATTERN, "invalid extension id");
302
- const versionSchema = z.strictObject({
823
+ const strictTags = z.array(z.enum(EXTENSION_TAGS)).max(5).superRefine((tags, ctx) => {
824
+ tags.forEach((tag, index) => {
825
+ if (tags.indexOf(tag) !== index) ctx.addIssue({
826
+ code: "custom",
827
+ path: [index],
828
+ message: `duplicate tag '${tag}'`
829
+ });
830
+ });
831
+ });
832
+ const knownTags = EXTENSION_TAGS;
833
+ const tolerantTags = z.array(z.string()).transform((tags) => [...new Set(tags.filter((tag) => knownTags.includes(tag)))].slice(0, 5)).catch([]);
834
+ const tagsSchemaOf = (profile) => (profile.strict ? strictTags : tolerantTags).optional();
835
+ const versionSchemaOf = (profile) => object(profile, {
303
836
  version: semver,
304
837
  apiVersion: z.number().int().min(1),
305
838
  minAppVersion: semver.nullable(),
306
839
  permissions: z.array(z.enum(EXTENSION_PERMISSIONS)),
307
840
  publishedAt: timestamp,
308
841
  baseUrl,
309
- files: z.array(fileSchema).max(50).superRefine((files, ctx) => {
310
- const seen = /* @__PURE__ */ new Set();
311
- files.forEach((file, index) => {
312
- const key = file.path.toLowerCase();
313
- if (seen.has(key)) ctx.addIssue({
314
- code: "custom",
315
- path: [index, "path"],
316
- message: `duplicate path '${file.path}'`
317
- });
318
- seen.add(key);
319
- });
320
- files.forEach((file, index) => {
321
- const segments = file.path.toLowerCase().split("/");
322
- for (let depth = 1; depth < segments.length; depth++) if (seen.has(segments.slice(0, depth).join("/"))) {
323
- ctx.addIssue({
324
- code: "custom",
325
- path: [index, "path"],
326
- message: `'${file.path}' lies inside a file`
327
- });
328
- break;
329
- }
330
- });
331
- if (!files.some((file) => file.path === "extension.json")) ctx.addIssue({
332
- code: "custom",
333
- message: "extension.json is required"
334
- });
335
- const total = files.reduce((sum, file) => sum + file.size, 0);
336
- if (total > 1e7) ctx.addIssue({
337
- code: "custom",
338
- message: `total size ${total} exceeds ${MAX_TOTAL_BYTES}`
339
- });
340
- })
842
+ files: filesSchemaOf(profile),
843
+ /** The icon of the version as a `data:` URI; the file itself is among `files`. */
844
+ icon: z.string().max(MAX_ICON_URI_LENGTH).regex(ICON_URI_PATTERN).optional(),
845
+ /** Explicit tags of the version (a closed vocabulary); the tolerant reader drops the ones it does not know. */
846
+ tags: tagsSchemaOf(profile)
847
+ }).superRefine((version, ctx) => {
848
+ if (!profile.tags && version.tags !== void 0) ctx.addIssue({
849
+ code: "custom",
850
+ path: ["tags"],
851
+ message: "tags is not part of this index format"
852
+ });
853
+ if (!profile.icon && version.icon !== void 0) ctx.addIssue({
854
+ code: "custom",
855
+ path: ["icon"],
856
+ message: "icon is not part of this index format"
857
+ });
341
858
  });
342
- const contributesSchema$1 = z.strictObject({
859
+ const contributesSchemaOf = (profile) => object(profile, {
343
860
  exerciseTypes: z.array(z.string()),
344
861
  themes: z.array(z.string()),
345
862
  markdownRenderers: z.array(z.string()),
346
- gradePolicies: z.array(z.string())
863
+ gradePolicies: z.array(z.string()),
864
+ settings: z.array(z.string()).optional(),
865
+ events: z.array(z.string()).optional(),
866
+ commands: z.array(z.string()).optional(),
867
+ panels: z.array(z.string()).optional()
347
868
  });
348
- const descendingUnique = (versions, ctx) => {
349
- for (let i = 1; i < versions.length; i++) {
350
- const previous = versions[i - 1]?.version ?? "";
351
- const current = versions[i]?.version ?? "";
352
- if (previous === current) ctx.addIssue({
353
- code: "custom",
354
- path: [i, "version"],
355
- message: `duplicate version '${current}'`
356
- });
357
- else if (isSemver(previous) && isSemver(current)) {
358
- if (compareSemver(previous, current) < 0) ctx.addIssue({
359
- code: "custom",
360
- path: [i, "version"],
361
- message: "versions must be sorted newest first"
362
- });
363
- }
364
- }
869
+ const titleMap = z.record(extensionId$1, z.string().min(1).max(MAX_TITLE_LENGTH));
870
+ /** Titles of the contributions by point; an unreadable map is dropped by the tolerant reader, the entry stays. */
871
+ const titlesSchemaOf = (profile) => {
872
+ const schema = object(profile, Object.fromEntries(TITLED_POINTS.map((point) => [point, titleMap.optional()]))).optional();
873
+ return profile.strict ? schema : schema.catch(void 0).optional();
365
874
  };
366
- const entrySchema = z.strictObject({
875
+ const entryHeadOf = (profile) => ({
367
876
  id: extensionId$1,
368
877
  name: z.string().min(1).max(80),
369
878
  description: z.string().min(1).max(500),
370
879
  author: z.string().regex(GITHUB_LOGIN_PATTERN, "must be a GitHub login"),
371
880
  source: httpsUrl,
372
881
  platforms: z.array(z.enum(EXTENSION_PLATFORMS)),
373
- contributes: contributesSchema$1,
374
- versions: z.array(versionSchema).min(1).max(5).superRefine(descendingUnique)
882
+ contributes: contributesSchemaOf(profile),
883
+ titles: titlesSchemaOf(profile)
375
884
  });
376
- const rangeText = z.string().superRefine((value, ctx) => {
377
- try {
378
- parseRange(value);
379
- } catch (error) {
380
- if (!(error instanceof CatalogFormatError)) throw error;
885
+ const entrySchemaOf = (profile) => object(profile, {
886
+ ...entryHeadOf(profile),
887
+ versions: z.array(versionSchemaOf(profile)).min(1).max(5).superRefine(descendingUnique)
888
+ }).superRefine((entry, ctx) => {
889
+ if (entry.titles === void 0) return;
890
+ if (!profile.titles) {
381
891
  ctx.addIssue({
382
892
  code: "custom",
383
- message: "invalid version range"
893
+ path: ["titles"],
894
+ message: "titles is not part of this index format"
895
+ });
896
+ return;
897
+ }
898
+ if (!profile.strict) return;
899
+ for (const point of TITLED_POINTS) {
900
+ const known = new Set(entry.contributes[point] ?? []);
901
+ for (const id of Object.keys(entry.titles[point] ?? {})) if (!known.has(id)) ctx.addIssue({
902
+ code: "custom",
903
+ path: [
904
+ "titles",
905
+ point,
906
+ id
907
+ ],
908
+ message: `title for '${id}', which is not in contributes.${point}`
384
909
  });
385
910
  }
386
911
  });
387
- const revokedSchema = z.strictObject({
388
- id: extensionId$1,
389
- versions: rangeText,
390
- reason: z.string().min(1).max(300)
391
- });
392
- const indexSchema = z.strictObject({
393
- schemaVersion: z.literal(1),
912
+ const indexSchemaOf = (profile) => object(profile, {
913
+ schemaVersion: z.literal(profile.schemaVersion),
394
914
  generatedAt: timestamp,
395
- extensions: z.array(entrySchema),
915
+ extensions: z.array(entrySchemaOf(profile)),
396
916
  revoked: z.array(revokedSchema)
397
917
  });
918
+ const LEGACY_PROFILE = {
919
+ schemaVersion: 1,
920
+ maxFiles: 50,
921
+ extensions: LEGACY_FILE_EXTENSIONS,
922
+ icon: false,
923
+ titles: false,
924
+ tags: false,
925
+ strict: true
926
+ };
927
+ const FULL_PROFILE = {
928
+ schemaVersion: 2,
929
+ maxFiles: 100,
930
+ extensions: CATALOG_FILE_EXTENSIONS,
931
+ icon: true,
932
+ titles: true,
933
+ tags: true,
934
+ strict: true
935
+ };
936
+ /** Reader of the app: the limits of the full format, unknown keys dropped. */
937
+ const TOLERANT_PROFILE = {
938
+ ...FULL_PROFILE,
939
+ strict: false
940
+ };
941
+ /** `index.json`: the first format. */
942
+ const legacyIndexSchema = indexSchemaOf(LEGACY_PROFILE);
943
+ /** `index.v2.json`: the full format. */
944
+ const indexSchema = indexSchemaOf(FULL_PROFILE);
945
+ const describeIssues = (error, prefix = "") => error.issues.map((issue) => `${prefix}${issue.path.join(".") || "/"}: ${issue.message}`);
946
+ const isLegacyIndex = (raw) => typeof raw === "object" && raw !== null && "schemaVersion" in raw && raw.schemaVersion === 1;
947
+ /**
948
+ * Strict parse for the author tools: unknown keys, file types and limits fail.
949
+ * Both formats are accepted; `schemaVersion` chooses the rules.
950
+ * Throws `CatalogFormatError`.
951
+ */
398
952
  const parseIndex = (raw) => {
399
- const parsed = indexSchema.safeParse(raw);
953
+ const parsed = (isLegacyIndex(raw) ? legacyIndexSchema : indexSchema).safeParse(raw);
400
954
  if (parsed.success) return parsed.data;
401
- throw new CatalogFormatError(parsed.error.issues.map((issue) => `${issue.path.join(".") || "/"}: ${issue.message}`));
955
+ throw new CatalogFormatError(describeIssues(parsed.error));
402
956
  };
957
+ const tolerantHead = object(TOLERANT_PROFILE, {
958
+ schemaVersion: z.union([z.literal(1), z.literal(2)]),
959
+ generatedAt: timestamp,
960
+ extensions: z.array(z.unknown()),
961
+ revoked: z.array(revokedSchema)
962
+ });
963
+ const tolerantEntry = object(TOLERANT_PROFILE, {
964
+ ...entryHeadOf(TOLERANT_PROFILE),
965
+ versions: z.array(z.unknown())
966
+ });
967
+ const tolerantVersion = versionSchemaOf(TOLERANT_PROFILE);
968
+
969
+ //#endregion
970
+ //#region packages/extension-catalog/src/legacy.ts
971
+ /** Permissions the released apps know: a catalog with any other value is rejected whole by them. */
972
+ const LEGACY_PERMISSIONS = [
973
+ "library.read",
974
+ "process.spawn",
975
+ "worker.threads",
976
+ "native.addons",
977
+ "network"
978
+ ];
979
+ /** Contribution lists the released apps do not know (`contributes.settings` and later). */
980
+ const NEWER_CONTRIBUTION_KEYS = [
981
+ "settings",
982
+ "events",
983
+ "commands",
984
+ "panels"
985
+ ];
986
+ /** Whether a released app can parse the entry's summary of contributions. */
987
+ const isLegacyEntry = (entry) => NEWER_CONTRIBUTION_KEYS.every((key) => entry.contributes[key] === void 0);
988
+ /**
989
+ * Whether a released app can parse the version: only first-format file types,
990
+ * at most `MAX_FILES` files, no `icon` and no `tags`, only permissions it knows.
991
+ */
992
+ const isLegacyVersion = (version) => version.icon === void 0 && version.tags === void 0 && version.files.length <= 50 && version.files.every((file) => isLegacyCatalogPath(file.path)) && version.permissions.every((permission) => LEGACY_PERMISSIONS.includes(permission));
993
+ /**
994
+ * The part of the full index that every released app parses: versions and entries
995
+ * they cannot read are left out, an extension left without versions is skipped,
996
+ * `titles` (unknown to them) is dropped from the entries that stay.
997
+ * The revocation list is kept whole.
998
+ */
999
+ const legacySubset = (index) => ({
1000
+ ...index,
1001
+ schemaVersion: 1,
1002
+ extensions: index.extensions.filter(isLegacyEntry).map((entry) => {
1003
+ const legacy = {
1004
+ ...entry,
1005
+ versions: entry.versions.filter(isLegacyVersion)
1006
+ };
1007
+ delete legacy.titles;
1008
+ return legacy;
1009
+ }).filter((entry) => entry.versions.length > 0)
1010
+ });
1011
+
1012
+ //#endregion
1013
+ //#region packages/extension-catalog/src/install-meta.ts
1014
+ const httpUrl = z.url().refine((value) => /^https?:\/\//i.test(value), "catalogUrl must be an http(s) URL");
1015
+ const installMetaSchema = z.strictObject({
1016
+ catalogUrl: httpUrl,
1017
+ version: z.string().refine(isSemver, "version must be semver"),
1018
+ installedAt: z.iso.datetime()
1019
+ });
1020
+
1021
+ //#endregion
1022
+ //#region packages/extension-catalog/src/urls.ts
1023
+ /** File name of the first-format index: the catalog identity (`catalogUrl`) points at it. */
1024
+ const INDEX_FILE = "index.json";
1025
+ /** File name of the full index, published next to `index.json`. */
1026
+ const FULL_INDEX_FILE = "index.v2.json";
403
1027
 
404
1028
  //#endregion
405
1029
  //#region packages/extension-host/src/points/support.ts
@@ -409,7 +1033,9 @@ const safePath = (extensions) => z.string().refine(isSafeRelativePath, "must be
409
1033
  const extensionId = z.string().max(64).regex(EXTENSION_ID_PATTERN, "invalid extension id");
410
1034
  /** Пути с нарушением правила «`id` равен id расширения или начинается с ним». */
411
1035
  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}.'`]);
412
- const isFile = async (file) => (await stat(file).catch(() => null))?.isFile() === true;
1036
+ /** Повторы значений в списке `path` (путь до массива, `field` — имя поля записи). */
1037
+ const duplicateIssues = (path, field, values) => values.flatMap((value, index) => values.indexOf(value) === index ? [] : [`${path}.${index}.${field}: duplicate ${field} '${value}'`]);
1038
+ const isFile$1 = async (file) => (await stat(file).catch(() => null))?.isFile() === true;
413
1039
  /** Путь внутри каталога расширения; выход за каталог — ошибка. */
414
1040
  const inside = (dir, relative) => {
415
1041
  const resolved = path.resolve(dir, relative);
@@ -421,7 +1047,7 @@ const defaultNote = (value, fallback) => value === fallback ? " (default)" : "";
421
1047
  /** Проверяет, что файл модуля существует (если проверка включена), и даёт адрес. */
422
1048
  const resolveModuleUrl = async (extensionId, dir, file, fallback, verifyFiles, label) => {
423
1049
  const absolute = inside(dir, file);
424
- if (verifyFiles && !await isFile(absolute)) throw new Error(`${label} '${file}'${defaultNote(file, fallback)} is not a file`);
1050
+ if (verifyFiles && !await isFile$1(absolute)) throw new Error(`${label} '${file}'${defaultNote(file, fallback)} is not a file`);
425
1051
  return rendererUrlOf(extensionId, file);
426
1052
  };
427
1053
  const deepFreeze = (value) => {
@@ -457,6 +1083,43 @@ const resolveSchema = async (ajv, dir, source, label) => {
457
1083
  return parsed;
458
1084
  };
459
1085
 
1086
+ //#endregion
1087
+ //#region packages/extension-host/src/points/commands.ts
1088
+ const { commands: MAX_COMMANDS } = EXTENSION_COMMAND_LIMITS;
1089
+ const commands = {
1090
+ key: "commands",
1091
+ needsMain: true,
1092
+ schema: z.strictObject({
1093
+ id: extensionId,
1094
+ title: z.string().min(1).max(EXTENSION_COMMAND_LIMITS.titleLength),
1095
+ description: z.string().min(1).max(EXTENSION_COMMAND_LIMITS.descriptionLength).optional(),
1096
+ category: z.string().min(1).max(EXTENSION_COMMAND_LIMITS.categoryLength).optional(),
1097
+ keybinding: z.string().regex(KEYBINDING_PATTERN, "must look like Mod+Shift+L").optional(),
1098
+ palette: z.boolean().optional()
1099
+ }),
1100
+ normalize: (entries) => entries.map((entry) => ({
1101
+ ...entry,
1102
+ palette: entry.palette ?? true
1103
+ })),
1104
+ check: (entries, owner) => {
1105
+ const ids = entries.map(({ id }) => id);
1106
+ return [
1107
+ ...entries.length > MAX_COMMANDS ? [`contributes.commands: at most ${MAX_COMMANDS} commands allowed`] : [],
1108
+ ...idPrefixIssues("commands", ids, owner),
1109
+ ...duplicateIssues("contributes.commands", "id", ids)
1110
+ ];
1111
+ },
1112
+ resolve: async (entries) => entries.map((entry) => ({
1113
+ id: entry.id,
1114
+ title: entry.title,
1115
+ description: entry.description ?? null,
1116
+ category: entry.category ?? null,
1117
+ keybinding: entry.keybinding ?? null,
1118
+ palette: entry.palette
1119
+ })),
1120
+ claims: (resolved) => resolved.map((command) => `command:${command.id}`)
1121
+ };
1122
+
460
1123
  //#endregion
461
1124
  //#region packages/extension-host/src/points/exercise-types.ts
462
1125
  const schemaField = z.union([safePath([".json"]), z.record(z.string(), z.unknown()).refine((value) => Object.keys(value).length > 0, "must not be empty")]);
@@ -495,6 +1158,18 @@ const exerciseTypes = {
495
1158
  claims: (resolved) => resolved.flatMap((type) => [`exerciseType:${type.id}`, `element:${type.element}`])
496
1159
  };
497
1160
 
1161
+ //#endregion
1162
+ //#region packages/extension-host/src/points/events.ts
1163
+ const events = {
1164
+ key: "events",
1165
+ needsMain: true,
1166
+ schema: z.strictObject({ event: z.enum(LEARNING_EVENT_NAMES) }),
1167
+ normalize: (entries) => entries,
1168
+ check: (entries) => duplicateIssues("contributes.events", "event", entries.map(({ event }) => event)),
1169
+ resolve: async (entries) => entries.map(({ event }) => ({ event })),
1170
+ claims: () => []
1171
+ };
1172
+
498
1173
  //#endregion
499
1174
  //#region packages/extension-host/src/points/grade-policies.ts
500
1175
  const BUILTIN_POLICY_ID = "passAtN";
@@ -539,6 +1214,158 @@ const markdownRenderers = {
539
1214
  claims: (resolved) => resolved.map((entry) => `markdown:${entry.language}`)
540
1215
  };
541
1216
 
1217
+ //#endregion
1218
+ //#region packages/extension-host/src/points/panels.ts
1219
+ const { panels: MAX_PANELS } = EXTENSION_COMMAND_LIMITS;
1220
+ const panels = {
1221
+ key: "panels",
1222
+ needsMain: false,
1223
+ schema: z.strictObject({
1224
+ id: extensionId,
1225
+ title: z.string().min(1).max(EXTENSION_COMMAND_LIMITS.titleLength),
1226
+ module: safePath([".js", ".mjs"]).optional()
1227
+ }),
1228
+ normalize: (entries) => entries.map((entry) => ({
1229
+ id: entry.id,
1230
+ title: entry.title,
1231
+ module: entry.module ?? "./panel.mjs"
1232
+ })),
1233
+ check: (entries, owner) => {
1234
+ const ids = entries.map(({ id }) => id);
1235
+ return [
1236
+ ...entries.length > MAX_PANELS ? [`contributes.panels: at most ${MAX_PANELS} panels allowed`] : [],
1237
+ ...idPrefixIssues("panels", ids, owner),
1238
+ ...duplicateIssues("contributes.panels", "id", ids)
1239
+ ];
1240
+ },
1241
+ resolve: async (entries, { dir, extensionId: owner, verifyFiles }) => {
1242
+ const resolved = [];
1243
+ for (const entry of entries) resolved.push({
1244
+ id: entry.id,
1245
+ title: entry.title,
1246
+ rendererUrl: await resolveModuleUrl(owner, dir, entry.module, DEFAULT_PANEL, verifyFiles, "panel module")
1247
+ });
1248
+ return resolved;
1249
+ },
1250
+ claims: (resolved) => resolved.map((panel) => `panel:${panel.id}`)
1251
+ };
1252
+
1253
+ //#endregion
1254
+ //#region packages/extension-host/src/points/settings.ts
1255
+ /** Потолок длины строкового значения: даже в UTF-8 (3 байта на единицу) остаётся в пределах 64 КиБ хранилища. */
1256
+ const MAX_STRING_LENGTH = 1e4;
1257
+ const MAX_OPTIONS = 64;
1258
+ const base = {
1259
+ id: extensionId,
1260
+ label: z.string().min(1).max(60),
1261
+ description: z.string().min(1).max(500).optional()
1262
+ };
1263
+ const option = z.strictObject({
1264
+ value: z.string().min(1).max(100),
1265
+ label: z.string().min(1).max(60)
1266
+ });
1267
+ const schema = z.discriminatedUnion("type", [
1268
+ z.strictObject({
1269
+ ...base,
1270
+ type: z.literal("boolean"),
1271
+ default: z.boolean()
1272
+ }),
1273
+ z.strictObject({
1274
+ ...base,
1275
+ type: z.literal("string"),
1276
+ default: z.string(),
1277
+ maxLength: z.number().int().min(1).max(MAX_STRING_LENGTH).optional()
1278
+ }),
1279
+ z.strictObject({
1280
+ ...base,
1281
+ type: z.literal("number"),
1282
+ default: z.number(),
1283
+ min: z.number().optional(),
1284
+ max: z.number().optional(),
1285
+ integer: z.boolean().optional()
1286
+ }),
1287
+ z.strictObject({
1288
+ ...base,
1289
+ type: z.literal("enum"),
1290
+ default: z.string(),
1291
+ options: z.array(option).min(1).max(MAX_OPTIONS)
1292
+ })
1293
+ ]);
1294
+ /** Нарушения `default` и границ одного определения (путь — до поля). */
1295
+ const constraintIssues = (setting, at) => {
1296
+ const issue = (field, message) => `${at}.${field}: ${message}`;
1297
+ switch (setting.type) {
1298
+ case "boolean": return [];
1299
+ case "string": {
1300
+ const limit = setting.maxLength ?? MAX_STRING_LENGTH;
1301
+ return setting.default.length > limit ? [issue("default", `default is longer than ${limit} characters`)] : [];
1302
+ }
1303
+ case "number": {
1304
+ const { min, max, integer = false } = setting;
1305
+ const issues = [];
1306
+ if (min !== void 0 && max !== void 0 && min > max) issues.push(issue("min", "min is greater than max"));
1307
+ if (integer && !Number.isInteger(setting.default)) issues.push(issue("default", "default must be an integer"));
1308
+ if (min !== void 0 && setting.default < min) issues.push(issue("default", `default is less than min ${min}`));
1309
+ if (max !== void 0 && setting.default > max) issues.push(issue("default", `default is greater than max ${max}`));
1310
+ return issues;
1311
+ }
1312
+ default: {
1313
+ const values = setting.options.map(({ value }) => value);
1314
+ return [...duplicateIssues(`${at}.options`, "value", values), ...values.includes(setting.default) ? [] : [issue("default", `default '${setting.default}' is not an option`)]];
1315
+ }
1316
+ }
1317
+ };
1318
+ const resolveSetting = (setting) => {
1319
+ const common = {
1320
+ id: setting.id,
1321
+ label: setting.label,
1322
+ description: setting.description ?? null
1323
+ };
1324
+ switch (setting.type) {
1325
+ case "boolean": return {
1326
+ ...common,
1327
+ type: "boolean",
1328
+ default: setting.default
1329
+ };
1330
+ case "string": return {
1331
+ ...common,
1332
+ type: "string",
1333
+ default: setting.default,
1334
+ maxLength: setting.maxLength ?? null
1335
+ };
1336
+ case "number": return {
1337
+ ...common,
1338
+ type: "number",
1339
+ default: setting.default,
1340
+ min: setting.min ?? null,
1341
+ max: setting.max ?? null,
1342
+ integer: setting.integer ?? false
1343
+ };
1344
+ default: return {
1345
+ ...common,
1346
+ type: "enum",
1347
+ default: setting.default,
1348
+ options: setting.options.map(({ value, label }) => ({
1349
+ value,
1350
+ label
1351
+ }))
1352
+ };
1353
+ }
1354
+ };
1355
+ const settings = {
1356
+ key: "settings",
1357
+ needsMain: false,
1358
+ schema,
1359
+ normalize: (entries) => entries,
1360
+ check: (entries, owner) => [
1361
+ ...idPrefixIssues("settings", entries.map(({ id }) => id), owner),
1362
+ ...duplicateIssues("contributes.settings", "id", entries.map(({ id }) => id)),
1363
+ ...entries.flatMap((entry, index) => constraintIssues(entry, `contributes.settings.${index}`))
1364
+ ],
1365
+ resolve: async (entries) => entries.map(resolveSetting),
1366
+ claims: (resolved) => resolved.map((setting) => `setting:${setting.id}`)
1367
+ };
1368
+
542
1369
  //#endregion
543
1370
  //#region packages/extension-host/src/points/themes.ts
544
1371
  const HEX_COLOR = /^#[0-9a-fA-F]{6}([0-9a-fA-F]{2})?$/;
@@ -584,7 +1411,11 @@ const CONTRIBUTION_POINTS = [
584
1411
  exerciseTypes,
585
1412
  themes,
586
1413
  markdownRenderers,
587
- gradePolicies
1414
+ gradePolicies,
1415
+ settings,
1416
+ events,
1417
+ commands,
1418
+ panels
588
1419
  ];
589
1420
  const POINT_KEYS = CONTRIBUTION_POINTS.map(({ key }) => key);
590
1421
 
@@ -604,6 +1435,8 @@ const manifestSchema = z.strictObject({
604
1435
  author: z.string().regex(GITHUB_LOGIN_PATTERN, "must be a GitHub login").optional(),
605
1436
  platforms: z.array(z.enum(EXTENSION_PLATFORMS)).optional(),
606
1437
  minAppVersion: z.string().refine(isSemver, "minAppVersion must be semver x.y.z").optional(),
1438
+ icon: safePath([".png", ".webp"]).optional(),
1439
+ tags: z.array(z.enum(EXTENSION_TAGS, { error: `tag must be one of: ${EXTENSION_TAGS.join(", ")}` })).max(5, "at most 5 tags").optional(),
607
1440
  contributes: contributesSchema
608
1441
  }).superRefine((manifest, ctx) => {
609
1442
  const { permissions = [], platforms = [] } = manifest;
@@ -621,6 +1454,19 @@ const manifestSchema = z.strictObject({
621
1454
  message: `duplicate permission '${permission}'`
622
1455
  });
623
1456
  });
1457
+ const { tags = [] } = manifest;
1458
+ tags.forEach((tag, index) => {
1459
+ if (tags.indexOf(tag) !== index) ctx.addIssue({
1460
+ code: "custom",
1461
+ path: ["tags", index],
1462
+ message: `duplicate tag '${tag}'`
1463
+ });
1464
+ });
1465
+ if (entriesOf(manifest.contributes, "events").length > 0 && !permissions.includes("learning.events")) ctx.addIssue({
1466
+ code: "custom",
1467
+ path: ["permissions"],
1468
+ message: "contributes.events requires the 'learning.events' permission"
1469
+ });
624
1470
  if (isEmpty(manifest.contributes)) ctx.addIssue({
625
1471
  code: "custom",
626
1472
  path: ["contributes"],
@@ -643,6 +1489,8 @@ const normalizeManifest = (input) => {
643
1489
  author: input.author ?? null,
644
1490
  platforms: [...input.platforms ?? []],
645
1491
  minAppVersion: input.minAppVersion ?? null,
1492
+ tags: [...input.tags ?? []],
1493
+ icon: input.icon ?? null,
646
1494
  contributes
647
1495
  };
648
1496
  };
@@ -676,9 +1524,23 @@ const compatibilityIssue = (manifest, appVersion, platform) => checkCompatibilit
676
1524
  const resolveMain = async (dir, main, verifyFiles) => {
677
1525
  if (main === null) return null;
678
1526
  const mainPath = inside(dir, main);
679
- if (verifyFiles && !await isFile(mainPath)) throw new Error(`main '${main}'${defaultNote(main, DEFAULT_MAIN)} is not a file`);
1527
+ if (verifyFiles && !await isFile$1(mainPath)) throw new Error(`main '${main}'${defaultNote(main, DEFAULT_MAIN)} is not a file`);
680
1528
  return mainPath;
681
1529
  };
1530
+ /**
1531
+ * Значок манифеста как `data:`-URI. Читается при обнаружении (≤16 КиБ), снимок
1532
+ * обнаружения хранит результат до следующего обнаружения; `verifyFiles: false` файл не читает.
1533
+ * Ссылка вместо файла не допускается.
1534
+ */
1535
+ const resolveIcon = async (dir, icon, verifyFiles) => {
1536
+ if (icon === null || !verifyFiles) return null;
1537
+ const target = inside(dir, icon);
1538
+ if (!(await lstat(target).catch(() => null))?.isFile()) throw new Error(`icon '${icon}' is not a file`);
1539
+ const bytes = await readFile(target);
1540
+ const problem = iconProblem(icon, bytes);
1541
+ if (problem !== null) throw new Error(problem);
1542
+ return iconDataUri(icon, bytes);
1543
+ };
682
1544
  const inspectExtensionDir = async (directory, options = {}) => {
683
1545
  const { verifyFiles = true, expectedId = null, appVersion, platform = process.platform } = options;
684
1546
  const dir = path.resolve(directory);
@@ -738,21 +1600,569 @@ const inspectExtensionDir = async (directory, options = {}) => {
738
1600
  author: manifest.author,
739
1601
  platforms: manifest.platforms,
740
1602
  minAppVersion: manifest.minAppVersion,
1603
+ icon: await resolveIcon(dir, manifest.icon, verifyFiles),
1604
+ tags: manifest.tags,
741
1605
  ...resolved
742
1606
  }
743
1607
  };
744
- } catch (error) {
1608
+ } catch (error) {
1609
+ return {
1610
+ ok: false,
1611
+ id: manifest.id,
1612
+ message: error instanceof Error ? error.message : String(error)
1613
+ };
1614
+ }
1615
+ };
1616
+
1617
+ //#endregion
1618
+ //#region packages/extension-host/src/runtime.ts
1619
+ /** Срок обработчика команды расширения (R3); раннер ограниченного процесса и клиент движка ждут дольше. */
1620
+ const COMMAND_HANDLER_MS = EXTENSION_COMMAND_LIMITS.handlerMs;
1621
+
1622
+ //#endregion
1623
+ //#region packages/extension-tools/src/catalog/assets.ts
1624
+ /**
1625
+ * Content checks of the files an extension ships next to its code (style sheets, images,
1626
+ * fonts) and of its icon. One module for `catalog check`, `catalog build` and `dolphy-ext
1627
+ * build`: a file that passes here is one the protocol may serve and the installer may accept.
1628
+ * Every function returns the first problem as a message, `null` when the file is fine.
1629
+ */
1630
+ const SVG_NAMESPACE = "http://www.w3.org/2000/svg";
1631
+ const XLINK_NAMESPACE = "http://www.w3.org/1999/xlink";
1632
+ const MAX_SVG_DEPTH = 32;
1633
+ var AssetProblem = class extends Error {};
1634
+ const refuse = (message) => {
1635
+ throw new AssetProblem(message);
1636
+ };
1637
+ const first = (check) => {
1638
+ try {
1639
+ check();
1640
+ return null;
1641
+ } catch (error) {
1642
+ if (error instanceof AssetProblem) return error.message;
1643
+ throw error;
1644
+ }
1645
+ };
1646
+ const IDENT_START = /[A-Za-z_\u0080-\uffff]/;
1647
+ const IDENT_PART = /[A-Za-z0-9_\u0080-\uffff-]/;
1648
+ const HEX = /[0-9A-Fa-f]/;
1649
+ const WHITESPACE = /[ \t\r\n\f]/;
1650
+ /** Decodes the CSS escape starting after a backslash at `at`; returns the text and the next index. */
1651
+ const readEscape = (text, at) => {
1652
+ let end = at;
1653
+ while (end < text.length && end - at < 6 && HEX.test(text[end] ?? "")) end++;
1654
+ if (end === at) return [text[at] ?? "", at + 1];
1655
+ const code = Number.parseInt(text.slice(at, end), 16);
1656
+ return [code === 0 || code > 1114111 || code >= 55296 && code <= 57343 ? "�" : String.fromCodePoint(code), WHITESPACE.test(text[end] ?? "") ? end + 1 : end];
1657
+ };
1658
+ const readIdent = (text, from) => {
1659
+ let value = "";
1660
+ let i = from;
1661
+ while (i < text.length) {
1662
+ const char = text[i] ?? "";
1663
+ if (char === "\\") {
1664
+ const [decoded, next] = readEscape(text, i + 1);
1665
+ value += decoded;
1666
+ i = next;
1667
+ } else if (IDENT_PART.test(char)) {
1668
+ value += char;
1669
+ i++;
1670
+ } else break;
1671
+ }
1672
+ return [value, i];
1673
+ };
1674
+ const readString = (text, from, quote) => {
1675
+ let value = "";
1676
+ let i = from + 1;
1677
+ while (i < text.length) {
1678
+ const char = text[i] ?? "";
1679
+ if (char === quote) return [value, i + 1];
1680
+ if (char === "\n") return [value, i];
1681
+ if (char === "\\") {
1682
+ if (text[i + 1] === "\n") {
1683
+ i += 2;
1684
+ continue;
1685
+ }
1686
+ const [decoded, next] = readEscape(text, i + 1);
1687
+ value += decoded;
1688
+ i = next;
1689
+ } else {
1690
+ value += char;
1691
+ i++;
1692
+ }
1693
+ }
1694
+ return [value, i];
1695
+ };
1696
+ const readUnquotedUrl = (text, from) => {
1697
+ let value = "";
1698
+ let i = from;
1699
+ while (i < text.length) {
1700
+ const char = text[i] ?? "";
1701
+ if (char === ")") return [value, i + 1];
1702
+ if (char === "\\") {
1703
+ const [decoded, next] = readEscape(text, i + 1);
1704
+ value += decoded;
1705
+ i = next;
1706
+ } else {
1707
+ value += char;
1708
+ i++;
1709
+ }
1710
+ }
1711
+ return [value, i];
1712
+ };
1713
+ const SCHEME = /^[a-z][a-z0-9+.-]*:/i;
1714
+ const DATA_IMAGE_SVG_SCOPE = /^data:image\/(?:png|jpeg|webp)[;,]/i;
1715
+ const DATA_CSS_SCOPE = /^data:(?:image|font)\/[a-z0-9.+-]+[;,]/i;
1716
+ /** ASCII whitespace and controls removed: browsers drop them inside a URL, `java\tscript:` is `javascript:`. */
1717
+ const withoutBlanks = (value) => Array.from(value).filter((char) => char > " ").join("");
1718
+ /** `value` is the text of a `url()`; throws when it leaves what the scope allows. */
1719
+ const checkUrl = (value, scope) => {
1720
+ const url = withoutBlanks(value);
1721
+ if (url === "" || url.startsWith("#")) return;
1722
+ if (scope.kind === "svg") {
1723
+ if (!DATA_IMAGE_SVG_SCOPE.test(url)) refuse(`url(${value.slice(0, 40)}) is not allowed in SVG: only #id and data:image/png|jpeg|webp`);
1724
+ return;
1725
+ }
1726
+ if (url.toLowerCase().startsWith("data:")) {
1727
+ if (DATA_CSS_SCOPE.test(url)) return;
1728
+ refuse("url(data:…) is allowed only for images and fonts");
1729
+ }
1730
+ if (SCHEME.test(url) || url.startsWith("//")) refuse(`url(${value.slice(0, 60)}) points outside the extension`);
1731
+ if (url.startsWith("/") || url.includes("\\")) refuse(`url(${value.slice(0, 60)}) must be a relative path`);
1732
+ const target = path.posix.normalize(path.posix.join(path.posix.dirname(scope.path), url.split(/[?#]/)[0] ?? ""));
1733
+ if (target === ".." || target.startsWith("../")) refuse(`url(${value.slice(0, 60)}) leaves the extension directory`);
1734
+ };
1735
+ const FORBIDDEN_FUNCTIONS = /* @__PURE__ */ new Set(["expression", "src"]);
1736
+ const IMAGE_SET = /* @__PURE__ */ new Set(["image-set", "-webkit-image-set"]);
1737
+ const FORBIDDEN_IDENTS = /* @__PURE__ */ new Set(["-moz-binding", "behavior"]);
1738
+ /**
1739
+ * Walks CSS tokens, so that nothing hides in strings, comments or escapes (`\40import`,
1740
+ * `u\72l(`): `@import`, `expression(`, `src()`, `url()` outside the scope, a string
1741
+ * as a URL inside `image-set()`.
1742
+ */
1743
+ const scanCss = (text, scope) => {
1744
+ const functions = [];
1745
+ let i = 0;
1746
+ while (i < text.length) {
1747
+ const char = text[i] ?? "";
1748
+ if (char === "/" && text[i + 1] === "*") {
1749
+ const end = text.indexOf("*/", i + 2);
1750
+ i = end === -1 ? text.length : end + 2;
1751
+ } else if (char === "\"" || char === "'") {
1752
+ const [value, next] = readString(text, i, char);
1753
+ if (IMAGE_SET.has(functions.at(-1) ?? "")) refuse(`a string URL in image-set() is not allowed (${value.slice(0, 40)}); use url()`);
1754
+ i = next;
1755
+ } else if (char === "@" && (IDENT_START.test(text[i + 1] ?? "") || text[i + 1] === "\\")) {
1756
+ const [name, next] = readIdent(text, i + 1);
1757
+ if (name.toLowerCase() === "import") refuse("@import is not allowed");
1758
+ i = next;
1759
+ } else if (IDENT_START.test(char) || char === "\\" || char === "-") {
1760
+ const [name, next] = readIdent(text, i);
1761
+ const lower = name.toLowerCase();
1762
+ if (FORBIDDEN_IDENTS.has(lower)) refuse(`'${name}' is not allowed`);
1763
+ if (text[next] === "(") {
1764
+ if (FORBIDDEN_FUNCTIONS.has(lower)) refuse(`${lower}( is not allowed`);
1765
+ if (lower === "url") {
1766
+ let at = next + 1;
1767
+ while (WHITESPACE.test(text[at] ?? "")) at++;
1768
+ const quote = text[at] ?? "";
1769
+ if (quote === "\"" || quote === "'") {
1770
+ const [value, end] = readString(text, at, quote);
1771
+ checkUrl(value, scope);
1772
+ at = end;
1773
+ while (WHITESPACE.test(text[at] ?? "")) at++;
1774
+ i = text[at] === ")" ? at + 1 : at;
1775
+ } else {
1776
+ const [value, end] = readUnquotedUrl(text, at);
1777
+ checkUrl(value.trim(), scope);
1778
+ i = end;
1779
+ }
1780
+ } else {
1781
+ functions.push(lower);
1782
+ i = next + 1;
1783
+ }
1784
+ } else i = next === i ? i + 1 : next;
1785
+ } else {
1786
+ if (char === "(") functions.push("");
1787
+ if (char === ")") functions.pop();
1788
+ i++;
1789
+ }
1790
+ }
1791
+ };
1792
+ /**
1793
+ * R2: a style sheet without `@import`, `expression(`, `src()`, with `url()` only to
1794
+ * `data:image`, `data:font`, `#id` and relative paths inside the extension.
1795
+ */
1796
+ const validateCss = (text, scope) => first(() => {
1797
+ if (text.includes("\0")) refuse("contains a NUL character");
1798
+ scanCss(text, scope);
1799
+ });
1800
+ const ALLOWED_ELEMENTS = /* @__PURE__ */ new Set([
1801
+ "svg",
1802
+ "g",
1803
+ "defs",
1804
+ "symbol",
1805
+ "use",
1806
+ "path",
1807
+ "rect",
1808
+ "circle",
1809
+ "ellipse",
1810
+ "line",
1811
+ "polyline",
1812
+ "polygon",
1813
+ "text",
1814
+ "tspan",
1815
+ "title",
1816
+ "desc",
1817
+ "linearGradient",
1818
+ "radialGradient",
1819
+ "stop",
1820
+ "clipPath",
1821
+ "mask",
1822
+ "pattern",
1823
+ "marker",
1824
+ "image",
1825
+ "style",
1826
+ "filter",
1827
+ "feBlend",
1828
+ "feColorMatrix",
1829
+ "feComponentTransfer",
1830
+ "feFuncR",
1831
+ "feFuncG",
1832
+ "feFuncB",
1833
+ "feFuncA",
1834
+ "feComposite",
1835
+ "feFlood",
1836
+ "feGaussianBlur",
1837
+ "feMerge",
1838
+ "feMergeNode",
1839
+ "feOffset",
1840
+ "feDropShadow"
1841
+ ]);
1842
+ const ALLOWED_ATTRIBUTES = new Set([
1843
+ "id class style lang role xml:space xmlns xmlns:xlink version",
1844
+ "viewBox preserveAspectRatio width height x y x1 y1 x2 y2 cx cy r rx ry fx fy fr",
1845
+ "d points transform transform-origin transform-box pathLength",
1846
+ "gradientUnits gradientTransform spreadMethod offset",
1847
+ "patternUnits patternContentUnits patternTransform",
1848
+ "clipPathUnits maskUnits maskContentUnits filterUnits primitiveUnits",
1849
+ "stop-color stop-opacity fill fill-opacity fill-rule clip-rule",
1850
+ "stroke stroke-width stroke-linecap stroke-linejoin stroke-miterlimit",
1851
+ "stroke-dasharray stroke-dashoffset stroke-opacity vector-effect",
1852
+ "opacity color display visibility overflow clip-path mask filter",
1853
+ "mix-blend-mode isolation paint-order shape-rendering text-rendering",
1854
+ "image-rendering color-interpolation color-interpolation-filters",
1855
+ "font-family font-size font-style font-weight font-variant font-stretch",
1856
+ "text-anchor text-decoration dominant-baseline alignment-baseline baseline-shift",
1857
+ "letter-spacing word-spacing textLength lengthAdjust dx dy rotate",
1858
+ "writing-mode direction unicode-bidi",
1859
+ "marker-start marker-mid marker-end markerWidth markerHeight markerUnits refX refY orient",
1860
+ "href xlink:href",
1861
+ "in in2 result stdDeviation mode type values operator k1 k2 k3 k4",
1862
+ "flood-color flood-opacity tableValues slope intercept amplitude exponent"
1863
+ ].join(" ").split(" "));
1864
+ const PLAIN_ATTRIBUTE_FAMILIES = /^(?:data|aria)-[a-z0-9-]+$/;
1865
+ const FRAGMENT = /^#[A-Za-z_][A-Za-z0-9_.:-]*$/;
1866
+ const NAME = /^[A-Za-z_][A-Za-z0-9_.-]*$/;
1867
+ const ATTRIBUTE_NAME = /^[A-Za-z_][A-Za-z0-9_.:-]*$/;
1868
+ const XML_DECLARATION = /^<\?xml\s+version\s*=\s*["']1\.\d["'](?:\s+encoding\s*=\s*["'][A-Za-z0-9._-]+["'])?(?:\s+standalone\s*=\s*["'](?:yes|no)["'])?\s*\?>$/;
1869
+ const ENTITY_REFERENCE = /&(?:(amp|lt|gt|quot|apos)|#(\d+)|#x([0-9A-Fa-f]+));/g;
1870
+ /** Resolves the five predefined and the numeric references; any other `&` is an error. */
1871
+ const decodeEntities = (value) => {
1872
+ const decoded = value.replace(ENTITY_REFERENCE, (_match, name, dec, hex) => {
1873
+ if (name !== void 0) return {
1874
+ amp: "&",
1875
+ lt: "<",
1876
+ gt: ">",
1877
+ quot: "\"",
1878
+ apos: "'"
1879
+ }[name] ?? "";
1880
+ const code = Number.parseInt(dec ?? hex ?? "", dec === void 0 ? 16 : 10);
1881
+ if (code === 0 || code > 1114111) refuse("invalid character reference");
1882
+ return String.fromCodePoint(code);
1883
+ });
1884
+ if (value.replace(ENTITY_REFERENCE, "").includes("&")) refuse("entity references other than the predefined ones are not allowed");
1885
+ return decoded;
1886
+ };
1887
+ const checkHref = (element, value) => {
1888
+ if (FRAGMENT.test(value)) return;
1889
+ if (DATA_IMAGE_SVG_SCOPE.test(withoutBlanks(value))) {
1890
+ if (element === "use") refuse("<use> may reference only '#id'");
1891
+ return;
1892
+ }
1893
+ refuse(`href '${value.slice(0, 40)}' is not allowed: only #id and data:image/png|jpeg|webp`);
1894
+ };
1895
+ const checkAttribute = (element, name, rawValue) => {
1896
+ if (/^on/i.test(name)) refuse(`event handler attribute '${name}'`);
1897
+ const value = decodeEntities(rawValue);
1898
+ if (name === "xmlns") {
1899
+ if (value !== SVG_NAMESPACE) refuse(`xmlns must be ${SVG_NAMESPACE}`);
1900
+ return;
1901
+ }
1902
+ if (name === "xmlns:xlink") {
1903
+ if (value !== XLINK_NAMESPACE) refuse(`xmlns:xlink must be ${XLINK_NAMESPACE}`);
1904
+ return;
1905
+ }
1906
+ if (name.startsWith("xmlns")) refuse(`namespace declaration '${name}'`);
1907
+ if (!ALLOWED_ATTRIBUTES.has(name) && !PLAIN_ATTRIBUTE_FAMILIES.test(name)) refuse(`attribute '${name}' of <${element}> is not allowed`);
1908
+ if (name === "href" || name === "xlink:href") {
1909
+ checkHref(element, value);
1910
+ return;
1911
+ }
1912
+ const problem = validateCss(value, { kind: "svg" });
1913
+ if (problem !== null) refuse(`attribute '${name}': ${problem}`);
1914
+ };
1915
+ const readStartTag = (text, from) => {
1916
+ let i = from + 1;
1917
+ const nameStart = i;
1918
+ while (i < text.length && /[^\s/>]/.test(text[i] ?? "")) i++;
1919
+ const name = text.slice(nameStart, i);
1920
+ if (name.includes(":")) refuse(`namespaced element <${name}> is not allowed`);
1921
+ if (!NAME.test(name)) refuse(`malformed element name '${name.slice(0, 20)}'`);
1922
+ const attributes = [];
1923
+ const seen = /* @__PURE__ */ new Set();
1924
+ for (;;) {
1925
+ while (WHITESPACE.test(text[i] ?? "")) i++;
1926
+ const char = text[i];
1927
+ if (char === void 0) return refuse(`<${name}> is not closed`);
1928
+ if (char === ">") return {
1929
+ name,
1930
+ attributes,
1931
+ selfClosing: false,
1932
+ next: i + 1
1933
+ };
1934
+ if (char === "/") {
1935
+ if (text[i + 1] !== ">") refuse(`malformed <${name}> tag`);
1936
+ return {
1937
+ name,
1938
+ attributes,
1939
+ selfClosing: true,
1940
+ next: i + 2
1941
+ };
1942
+ }
1943
+ const attributeStart = i;
1944
+ while (i < text.length && /[^\s=/>]/.test(text[i] ?? "")) i++;
1945
+ const attribute = text.slice(attributeStart, i);
1946
+ if (!ATTRIBUTE_NAME.test(attribute)) refuse(`malformed attribute '${attribute.slice(0, 20)}' in <${name}>`);
1947
+ while (WHITESPACE.test(text[i] ?? "")) i++;
1948
+ if (text[i] !== "=") refuse(`attribute '${attribute}' has no value`);
1949
+ i++;
1950
+ while (WHITESPACE.test(text[i] ?? "")) i++;
1951
+ const quote = text[i];
1952
+ if (quote !== "\"" && quote !== "'") refuse(`attribute '${attribute}' is not quoted`);
1953
+ const end = text.indexOf(quote ?? "\"", i + 1);
1954
+ if (end === -1) refuse(`attribute '${attribute}' is not closed`);
1955
+ const value = text.slice(i + 1, end);
1956
+ if (value.includes("<")) refuse(`attribute '${attribute}' contains '<'`);
1957
+ if (seen.has(attribute)) refuse(`duplicate attribute '${attribute}'`);
1958
+ seen.add(attribute);
1959
+ attributes.push([attribute, value]);
1960
+ i = end + 1;
1961
+ }
1962
+ };
1963
+ const closeFrame = (stack) => {
1964
+ const frame = stack.pop();
1965
+ if (frame?.name !== "style") return;
1966
+ const problem = validateCss(frame.text, { kind: "svg" });
1967
+ if (problem !== null) refuse(`<style>: ${problem}`);
1968
+ };
1969
+ const walkSvg = (text) => {
1970
+ const stack = [];
1971
+ let roots = 0;
1972
+ let i = 0;
1973
+ while (i < text.length) if (text.startsWith("<!--", i)) {
1974
+ const end = text.indexOf("-->", i + 4);
1975
+ if (end === -1) refuse("unterminated comment");
1976
+ i = end + 3;
1977
+ } else if (text.startsWith("<![CDATA[", i)) {
1978
+ const end = text.indexOf("]]>", i + 9);
1979
+ if (end === -1) refuse("unterminated CDATA section");
1980
+ const top = stack.at(-1);
1981
+ if (top === void 0) refuse("CDATA outside the root element");
1982
+ else top.text += text.slice(i + 9, end);
1983
+ i = end + 3;
1984
+ } else if (text.startsWith("<?", i)) {
1985
+ const end = text.indexOf("?>", i + 2);
1986
+ const declaration = end === -1 ? "" : text.slice(i, end + 2);
1987
+ if (i !== 0 || !XML_DECLARATION.test(declaration)) refuse("processing instructions other than the XML declaration are not allowed");
1988
+ i = end + 2;
1989
+ } else if (text.startsWith("<!", i)) refuse("DOCTYPE, ENTITY and other declarations are not allowed");
1990
+ else if (text.startsWith("</", i)) {
1991
+ const end = text.indexOf(">", i);
1992
+ if (end === -1) refuse("unterminated closing tag");
1993
+ const name = text.slice(i + 2, end).trim();
1994
+ if (stack.at(-1)?.name !== name) refuse(`unexpected </${name}>`);
1995
+ closeFrame(stack);
1996
+ i = end + 1;
1997
+ } else if (text[i] === "<") {
1998
+ const tag = readStartTag(text, i);
1999
+ if (stack.length === 0) {
2000
+ roots++;
2001
+ if (roots > 1) refuse("more than one root element");
2002
+ if (tag.name !== "svg") refuse(`root element must be <svg>, it is <${tag.name}>`);
2003
+ if (!tag.attributes.some(([name]) => name === "xmlns")) refuse(`<svg> must declare xmlns="${SVG_NAMESPACE}"`);
2004
+ }
2005
+ if (!ALLOWED_ELEMENTS.has(tag.name)) refuse(`element <${tag.name}> is not allowed`);
2006
+ if (stack.length >= MAX_SVG_DEPTH) refuse("elements are nested too deep");
2007
+ if (tag.name === "style") {
2008
+ const type = tag.attributes.find(([name]) => name === "type")?.[1];
2009
+ if (type !== void 0 && type !== "text/css") refuse("<style> must be text/css");
2010
+ }
2011
+ for (const [name, value] of tag.attributes) checkAttribute(tag.name, name, value);
2012
+ stack.push({
2013
+ name: tag.name,
2014
+ text: ""
2015
+ });
2016
+ if (tag.selfClosing) closeFrame(stack);
2017
+ i = tag.next;
2018
+ } else {
2019
+ const end = text.indexOf("<", i);
2020
+ const chunk = text.slice(i, end === -1 ? text.length : end);
2021
+ const top = stack.at(-1);
2022
+ if (top === void 0) {
2023
+ if (chunk.trim() !== "") refuse("text outside the root element");
2024
+ } else top.text += decodeEntities(chunk);
2025
+ i = end === -1 ? text.length : end;
2026
+ }
2027
+ if (stack.length > 0) refuse(`<${stack.at(-1)?.name}> is not closed`);
2028
+ if (roots === 0) refuse("no root element");
2029
+ };
2030
+ const decodeUtf8 = (bytes) => {
2031
+ try {
2032
+ return new TextDecoder("utf-8", {
2033
+ fatal: true,
2034
+ ignoreBOM: false
2035
+ }).decode(bytes).replace(/^\ufeff/, "");
2036
+ } catch {
2037
+ return refuse("is not valid UTF-8");
2038
+ }
2039
+ };
2040
+ /**
2041
+ * R1/R2: an SVG is accepted as an image and only from an allow-list: a well-formed XML
2042
+ * document with the `svg` root, known elements and attributes, no scripts, `foreignObject`,
2043
+ * event handlers, DOCTYPE or entities, links other than `#id` and `data:image/png|jpeg|webp`.
2044
+ */
2045
+ const validateSvg = (text) => first(() => {
2046
+ if (text.includes("\0")) refuse("contains a NUL character");
2047
+ walkSvg(text.replace(/^\ufeff/, ""));
2048
+ });
2049
+ /**
2050
+ * The type, signature, size and content of one asset file by its extension.
2051
+ * `null` for a file that is not an asset or is fine.
2052
+ */
2053
+ const assetProblem = (file, bytes) => {
2054
+ const extension = assetExtensionOf(file);
2055
+ if (extension === null) return null;
2056
+ const limit = assetSizeLimit(extension);
2057
+ if (bytes.length > limit) return `${bytes.length} bytes exceed the limit of ${limit} for .${extension}`;
2058
+ if (isBinaryAsset(extension)) {
2059
+ const inspected = inspectBinaryAsset(extension, bytes);
2060
+ return inspected.ok ? null : inspected.reason;
2061
+ }
2062
+ return first(() => {
2063
+ const text = decodeUtf8(bytes);
2064
+ const problem = extension === "svg" ? validateSvg(text) : validateCss(text, {
2065
+ kind: "css",
2066
+ path: file
2067
+ });
2068
+ if (problem !== null) refuse(problem);
2069
+ });
2070
+ };
2071
+ /** An asset written with capital letters in the extension (`Logo.PNG`): the catalog takes lowercase only. */
2072
+ const hasUppercaseAssetExtension = (file) => {
2073
+ const extension = extensionOf(file);
2074
+ return extension !== null && extension !== extension.toLowerCase() && ASSET_EXTENSIONS.includes(extension.toLowerCase());
2075
+ };
2076
+ /** Checks every asset among `files` (paths relative to `root`); other files are not read. */
2077
+ const assetFindings = async (root, files) => {
2078
+ const findings = [];
2079
+ for (const file of files) {
2080
+ if (hasUppercaseAssetExtension(file)) {
2081
+ findings.push({
2082
+ path: file,
2083
+ message: "the file extension must be lowercase"
2084
+ });
2085
+ continue;
2086
+ }
2087
+ if (assetExtensionOf(file) === null) continue;
2088
+ const message = assetProblem(file, await readFile(path.join(root, file)));
2089
+ if (message !== null) findings.push({
2090
+ path: file,
2091
+ message
2092
+ });
2093
+ }
2094
+ return findings;
2095
+ };
2096
+
2097
+ //#endregion
2098
+ //#region packages/extension-tools/src/assets-plugin.ts
2099
+ /**
2100
+ * Assets up to this size are inlined into the bundle as data URIs; bigger ones become
2101
+ * files in `assets/` (`assets/<name>-<hash>.<ext>`: the name follows the content, so a
2102
+ * rebuild of the same sources writes the same file).
2103
+ */
2104
+ const ASSETS_INLINE_LIMIT = 4096;
2105
+ const ASSET_FILE_NAME = "assets/[name]-[hash][extname]";
2106
+ const ASSET_FILE = /\.(?:png|webp|jpe?g|svg|woff2)$/;
2107
+ const STYLE_FILE = /\.(?:css|scss|sass|less|styl|stylus|pcss|postcss)$/;
2108
+ const TEXT_IMPORT = /[?&](?:inline|url|raw)(?:&|$)/;
2109
+ const MARKED = /[?&](?:inline|no-inline|raw)(?:&|$)/;
2110
+ const cleanId = (id) => id.split("?")[0] ?? id;
2111
+ /** `new URL('./logo.png', import.meta.url)` with a relative literal to an image or a font. */
2112
+ const URL_EXPRESSION = /new\s+URL\(\s*(['"])(\.{1,2}\/[^'"?#\n]+\.(?:png|webp|jpe?g|svg|woff2))\1\s*,\s*import\.meta\.url\s*\)/g;
2113
+ const isBig = (file) => {
2114
+ try {
2115
+ return statSync(file).size > ASSETS_INLINE_LIMIT;
2116
+ } catch {
2117
+ return false;
2118
+ }
2119
+ };
2120
+ /**
2121
+ * Vite in library mode inlines every asset and has no place for a style sheet: the
2122
+ * plugin brings the two documented paths. An image or a font is a data URI up to
2123
+ * `ASSETS_INLINE_LIMIT` and a file in `assets/` above it (an import with `?url` or
2124
+ * `new URL('./x.png', import.meta.url)`); a style sheet is imported as a string with
2125
+ * `?inline`, a plain `import './x.css'` is an error with the way out in the message.
2126
+ */
2127
+ const assetsPlugin = (state) => ({
2128
+ name: "dolphy-ext:assets",
2129
+ enforce: "pre",
2130
+ transform(code, id) {
2131
+ const file = cleanId(id);
2132
+ if (file.includes("\0") || !code.includes("import.meta.url")) return null;
2133
+ const marked = code.replace(URL_EXPRESSION, (expression, quote, relative) => isBig(path.resolve(path.dirname(file), relative)) ? expression.replace(`${quote}${relative}${quote}`, `${quote}${relative}?no-inline${quote}`) : expression);
2134
+ return marked === code ? null : {
2135
+ code: marked,
2136
+ map: null
2137
+ };
2138
+ },
2139
+ async resolveId(source, importer, options) {
2140
+ if (importer === void 0 || importer.startsWith("\0")) return null;
2141
+ const resolved = await this.resolve(source, importer, {
2142
+ ...options,
2143
+ skipSelf: true
2144
+ });
2145
+ if (resolved === null || resolved.external === true) return null;
2146
+ const file = cleanId(resolved.id);
2147
+ const query = resolved.id.slice(file.length);
2148
+ if (STYLE_FILE.test(file)) {
2149
+ if (TEXT_IMPORT.test(query)) return null;
2150
+ 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/`;
2151
+ state.problem = message;
2152
+ return this.error(message);
2153
+ }
2154
+ if (!ASSET_FILE.test(file) || MARKED.test(query)) return null;
2155
+ if (STYLE_FILE.test(cleanId(importer)) || !isBig(file)) return null;
745
2156
  return {
746
- ok: false,
747
- id: manifest.id,
748
- message: error instanceof Error ? error.message : String(error)
2157
+ ...resolved,
2158
+ id: `${resolved.id}${query === "" ? "?" : "&"}no-inline`
749
2159
  };
750
2160
  }
751
- };
2161
+ });
752
2162
 
753
2163
  //#endregion
754
2164
  //#region packages/extension-tools/src/errors.ts
755
- /** Ошибка сборки/проверки расширения; `subject` — id расширения или каталог. */
2165
+ /** Extension build/check error; `subject` is the extension id or directory. */
756
2166
  var BuildError = class extends Error {
757
2167
  subject;
758
2168
  constructor(message, subject) {
@@ -761,99 +2171,558 @@ var BuildError = class extends Error {
761
2171
  this.subject = subject;
762
2172
  }
763
2173
  };
764
- /** Неверные входные данные команды (несуществующий каталог): код выхода 2. */
2174
+ /** Invalid command input (nonexistent directory): exit code 2. */
765
2175
  var CatalogUsageError = class extends BuildError {};
766
2176
 
2177
+ //#endregion
2178
+ //#region packages/extension-tools/src/analyze.ts
2179
+ /** Named entries of `src/index.ts` from which the browser files are built. */
2180
+ const RECORDS = [
2181
+ "views",
2182
+ "panels",
2183
+ "markdown"
2184
+ ];
2185
+ const SOURCE_EXTENSIONS = [
2186
+ ".ts",
2187
+ ".mts",
2188
+ ".js",
2189
+ ".mjs"
2190
+ ];
2191
+ const MAX_HOPS = 16;
2192
+ const child = (node, key) => node[key];
2193
+ const children = (node, key) => node[key];
2194
+ const WRAPPERS = /* @__PURE__ */ new Set([
2195
+ "TSAsExpression",
2196
+ "TSSatisfiesExpression",
2197
+ "TSNonNullExpression",
2198
+ "TSTypeAssertion",
2199
+ "ParenthesizedExpression"
2200
+ ]);
2201
+ const unwrap = (node) => {
2202
+ let current = node;
2203
+ while (WRAPPERS.has(current.type)) current = child(current, "expression");
2204
+ return current;
2205
+ };
2206
+ /** Name from an identifier or string literal (`{ a: 1 }`, `{ 'a-b': 1 }`, `export { x as 'y' }`). */
2207
+ const nameOf = (node) => String(node.type === "Identifier" ? node.name : node.value);
2208
+ const isFile = async (file) => (await stat(file).catch(() => null))?.isFile() === true;
2209
+ /** Relative import → source file; packages and `node:*` are not analyzed. */
2210
+ const resolveRelative = async (from, specifier) => {
2211
+ if (!specifier.startsWith(".")) return null;
2212
+ const base = path.resolve(path.dirname(from), specifier);
2213
+ const withoutJs = base.replace(/\.(m?js)$/, "");
2214
+ const candidates = [
2215
+ base,
2216
+ ...SOURCE_EXTENSIONS.map((extension) => `${withoutJs}${extension}`),
2217
+ ...SOURCE_EXTENSIONS.map((extension) => path.join(base, `index${extension}`))
2218
+ ];
2219
+ for (const candidate of candidates) if (await isFile(candidate)) return candidate;
2220
+ return null;
2221
+ };
2222
+ const propertyKeys = (literal) => {
2223
+ const keys = [];
2224
+ for (const property of children(literal, "properties")) {
2225
+ if (property.type !== "Property") return { reason: "it uses a spread or a computed entry" };
2226
+ const key = child(property, "key");
2227
+ if (!(property.computed !== true || key.type === "Literal")) return { reason: "it uses a computed key" };
2228
+ keys.push({
2229
+ name: nameOf(key),
2230
+ start: property.start,
2231
+ end: property.end
2232
+ });
2233
+ }
2234
+ return { keys };
2235
+ };
2236
+ /**
2237
+ * Static analysis of `src/index.ts` without executing author code: whether the
2238
+ * `host` export exists and which keys the `views`, `panels` and `markdown`
2239
+ * entries have. Entries are found through local constants and relative
2240
+ * re-exports; keys must be given as an object literal.
2241
+ */
2242
+ const analyzeIndex = async (indexFile) => {
2243
+ const cache = /* @__PURE__ */ new Map();
2244
+ const parse = (file) => {
2245
+ let parsed = cache.get(file);
2246
+ if (parsed === void 0) {
2247
+ parsed = readFile(file, "utf8").then((source) => {
2248
+ const lang = /\.[mc]?ts$/.test(file) ? "ts" : "js";
2249
+ return {
2250
+ file,
2251
+ source,
2252
+ body: parseAst(source, { lang }).body
2253
+ };
2254
+ });
2255
+ cache.set(file, parsed);
2256
+ }
2257
+ return parsed;
2258
+ };
2259
+ const walk = {
2260
+ local: async (file, name, hops) => {
2261
+ if (hops > MAX_HOPS) return {
2262
+ kind: "opaque",
2263
+ reason: "it is re-exported too deeply"
2264
+ };
2265
+ const parsed = await parse(file);
2266
+ for (const statement of parsed.body) {
2267
+ const declaration = statement.type === "ExportNamedDeclaration" ? statement.declaration : statement;
2268
+ if (declaration?.type === "VariableDeclaration") for (const declarator of children(declaration, "declarations")) {
2269
+ const id = child(declarator, "id");
2270
+ if (id.type !== "Identifier" || id.name !== name) continue;
2271
+ const init = declarator.init;
2272
+ if (init === null) return {
2273
+ kind: "opaque",
2274
+ reason: "it has no initializer"
2275
+ };
2276
+ const value = unwrap(init);
2277
+ if (value.type === "Identifier") return walk.local(file, value.name, hops + 1);
2278
+ return {
2279
+ kind: "value",
2280
+ parsed,
2281
+ node: value
2282
+ };
2283
+ }
2284
+ else if ((declaration?.type === "FunctionDeclaration" || declaration?.type === "ClassDeclaration") && declaration.id?.name === name) return {
2285
+ kind: "opaque",
2286
+ reason: "it is not an object literal"
2287
+ };
2288
+ else if (statement.type === "ImportDeclaration") for (const specifier of children(statement, "specifiers")) {
2289
+ if (child(specifier, "local").name !== name) continue;
2290
+ if (specifier.type !== "ImportSpecifier") return {
2291
+ kind: "opaque",
2292
+ reason: "it is imported as a default or namespace"
2293
+ };
2294
+ const target = await resolveRelative(file, child(statement, "source").value);
2295
+ if (target === null) return {
2296
+ kind: "opaque",
2297
+ reason: "it is imported from a package"
2298
+ };
2299
+ return walk.export(target, nameOf(child(specifier, "imported")), hops + 1);
2300
+ }
2301
+ }
2302
+ return null;
2303
+ },
2304
+ export: async (file, name, hops) => {
2305
+ if (hops > MAX_HOPS) return {
2306
+ kind: "opaque",
2307
+ reason: "it is re-exported too deeply"
2308
+ };
2309
+ const parsed = await parse(file);
2310
+ const stars = [];
2311
+ for (const statement of parsed.body) {
2312
+ if (statement.type === "ExportAllDeclaration") {
2313
+ const exported = statement.exported;
2314
+ if (exported === null) stars.push(statement);
2315
+ else if (nameOf(exported) === name) return {
2316
+ kind: "opaque",
2317
+ reason: "it is a namespace re-export"
2318
+ };
2319
+ continue;
2320
+ }
2321
+ if (statement.type !== "ExportNamedDeclaration") continue;
2322
+ const declaration = statement.declaration;
2323
+ if (declaration !== null) {
2324
+ if ((declaration.type === "VariableDeclaration" ? children(declaration, "declarations").map((item) => child(item, "id")) : [declaration.id]).some((id) => id?.type === "Identifier" && id.name === name)) return walk.local(file, name, hops + 1);
2325
+ continue;
2326
+ }
2327
+ const source = statement.source;
2328
+ for (const specifier of children(statement, "specifiers")) {
2329
+ if (nameOf(child(specifier, "exported")) !== name) continue;
2330
+ const local = nameOf(child(specifier, "local"));
2331
+ if (source === null) return walk.local(file, local, hops + 1);
2332
+ const target = await resolveRelative(file, source.value);
2333
+ if (target === null) return {
2334
+ kind: "opaque",
2335
+ reason: "it is re-exported from a package"
2336
+ };
2337
+ return walk.export(target, local, hops + 1);
2338
+ }
2339
+ }
2340
+ for (const star of stars) {
2341
+ const target = await resolveRelative(file, child(star, "source").value);
2342
+ if (target === null) continue;
2343
+ const found = await walk.export(target, name, hops + 1);
2344
+ if (found !== null) return found;
2345
+ }
2346
+ return null;
2347
+ }
2348
+ };
2349
+ const recordOf = async (name) => {
2350
+ const resolved = await walk.export(indexFile, name, 0);
2351
+ if (resolved === null) return { status: "missing" };
2352
+ if (resolved.kind === "opaque") return {
2353
+ status: "opaque",
2354
+ reason: resolved.reason
2355
+ };
2356
+ if (resolved.node.type !== "ObjectExpression") return {
2357
+ status: "opaque",
2358
+ reason: "it is not an object literal"
2359
+ };
2360
+ const found = propertyKeys(resolved.node);
2361
+ if ("reason" in found) return {
2362
+ status: "opaque",
2363
+ reason: found.reason
2364
+ };
2365
+ return {
2366
+ status: "found",
2367
+ site: {
2368
+ file: resolved.parsed.file,
2369
+ source: resolved.parsed.source,
2370
+ start: resolved.node.start,
2371
+ end: resolved.node.end,
2372
+ keys: found.keys
2373
+ }
2374
+ };
2375
+ };
2376
+ const [views, panels, markdown] = await Promise.all(RECORDS.map(recordOf));
2377
+ const hasHost = await walk.export(indexFile, "host", 0) !== null;
2378
+ return {
2379
+ files: [...cache.keys()],
2380
+ hasHost,
2381
+ records: {
2382
+ views,
2383
+ panels,
2384
+ markdown
2385
+ }
2386
+ };
2387
+ };
2388
+ /**
2389
+ * Source of `site.file` with the entry literal trimmed to `keep`: the code of the
2390
+ * discarded keys does not reach the bundle, and neither do their dependencies.
2391
+ */
2392
+ const pruneRecords = (source, sites) => {
2393
+ let result = source;
2394
+ const ordered = [...sites].sort((a, b) => b.site.start - a.site.start);
2395
+ for (const { site, keep } of ordered) {
2396
+ const kept = site.keys.filter((key) => keep.has(key.name)).map((key) => source.slice(key.start, key.end));
2397
+ result = `${result.slice(0, site.start)}{ ${kept.join(", ")} }${result.slice(site.end)}`;
2398
+ }
2399
+ return result;
2400
+ };
2401
+ const isRecord$1 = (value) => typeof value === "object" && value !== null;
2402
+ const collectImports = (node, found) => {
2403
+ if (Array.isArray(node)) {
2404
+ for (const item of node) collectImports(item, found);
2405
+ return;
2406
+ }
2407
+ if (!isRecord$1(node)) return;
2408
+ const { source } = node;
2409
+ if ((node.type === "ImportDeclaration" || node.type === "ExportNamedDeclaration" || node.type === "ExportAllDeclaration" || node.type === "ImportExpression") && isRecord$1(source) && typeof source.value === "string") found.add(source.value);
2410
+ for (const value of Object.values(node)) collectImports(value, found);
2411
+ };
2412
+ /** Specifiers the built file still imports (statically and via `import()`). */
2413
+ const importsOf = (code) => {
2414
+ const found = /* @__PURE__ */ new Set();
2415
+ collectImports(parseAst(code, { lang: "js" }).body, found);
2416
+ return found;
2417
+ };
2418
+ /**
2419
+ * Removes binding-less imports (`import "node:fs"`) from the built code that
2420
+ * `isDropped` selects. The bundler leaves such imports from the pruned host
2421
+ * code; they have no bindings, so removing them is safe.
2422
+ */
2423
+ const stripBareImports = (code, isDropped) => {
2424
+ const ranges = [];
2425
+ for (const statement of parseAst(code, { lang: "js" }).body) {
2426
+ if (statement.type !== "ImportDeclaration") continue;
2427
+ if (children(statement, "specifiers").length > 0) continue;
2428
+ if (isDropped(child(statement, "source").value)) ranges.push([statement.start, statement.end]);
2429
+ }
2430
+ let result = code;
2431
+ for (const [start, end] of ranges.reverse()) result = result.slice(0, start) + result.slice(end);
2432
+ return result;
2433
+ };
2434
+
2435
+ //#endregion
2436
+ //#region packages/extension-tools/src/shim.ts
2437
+ /** SDK subpath with element registration and dispatch; resolved from the author's project. */
2438
+ const SDK_RUNTIME = "@dolphy-app/extension-sdk/runtime";
2439
+ /** Path of the virtual shim: the build's `lib.entry` and the plugin's `resolveId`; there is no file on disk. */
2440
+ const shimEntry = (project, output) => path.join(project.root, "src", `.dolphy-ext-${output.output.replaceAll(/[^\w.-]/g, "-")}.js`);
2441
+ const quote = (text) => JSON.stringify(text);
2442
+ /** Which `src/index.ts` entries the output file needs: entry name → keys. */
2443
+ const wantedRecords = (output) => output.kind === "host" ? {
2444
+ views: [],
2445
+ panels: [],
2446
+ markdown: []
2447
+ } : {
2448
+ views: output.views.map((view) => view.id),
2449
+ panels: output.panels,
2450
+ markdown: output.languages
2451
+ };
2452
+ /** Exports of `src/index.ts` the file is built from (for messages). */
2453
+ const exportsOf = (output) => output.kind === "host" ? ["host"] : RECORDS.filter((name) => wantedRecords(output)[name].length > 0);
2454
+ /**
2455
+ * Source of the output file's virtual shim: imports from `src/index.ts` only
2456
+ * what is needed and does what the app expects from this file. The
2457
+ * `export default` result is the module the app loads (`ExtensionModule`,
2458
+ * panel module, renderer module); a file of this kind registers elements.
2459
+ */
2460
+ const shimSource = (output, indexFile) => {
2461
+ if (output.kind === "host") return `export { host as default } from ${quote(indexFile)};\n`;
2462
+ const runtime = [];
2463
+ const body = [];
2464
+ const modules = [];
2465
+ if (output.views.length > 0) {
2466
+ runtime.push("registerAnswerView");
2467
+ for (const view of output.views) body.push(`registerAnswerView(${quote(view.element)}, views[${quote(view.id)}]);`);
2468
+ }
2469
+ const table = (name, keys) => `{ ${keys.map((key) => `${quote(key)}: ${name}[${quote(key)}]`).join(", ")} }`;
2470
+ if (output.panels.length > 0) {
2471
+ runtime.push("dispatchPanels");
2472
+ modules.push(`...dispatchPanels(${table("panels", output.panels)})`);
2473
+ }
2474
+ if (output.languages.length > 0) {
2475
+ runtime.push("dispatchMarkdown");
2476
+ modules.push(`...dispatchMarkdown(${table("markdown", output.languages)})`);
2477
+ }
2478
+ const lines = [
2479
+ `import { ${RECORDS.filter((name) => wantedRecords(output)[name].length > 0).join(", ")} } from ${quote(indexFile)};`,
2480
+ `import { ${runtime.join(", ")} } from ${quote(SDK_RUNTIME)};`,
2481
+ ...body
2482
+ ];
2483
+ if (modules.length > 0) lines.push(`export default { ${modules.join(", ")} };`);
2484
+ return `${lines.join("\n")}\n`;
2485
+ };
2486
+ /** Mismatches between `src/index.ts` and the manifest; empty — everything agrees. */
2487
+ const findMismatches = (project, analysis, indexSource, root) => {
2488
+ const problems = [];
2489
+ if (project.host !== null && !analysis.hasHost) problems.push(`${indexSource} does not export 'host' for ${project.host.output}: add export const host = defineExtension({ … })`);
2490
+ const declared = {
2491
+ views: project.manifest.contributes.exerciseTypes.map((type) => type.id),
2492
+ panels: project.manifest.contributes.panels.map((panel) => panel.id),
2493
+ markdown: project.manifest.contributes.markdownRenderers.map((entry) => entry.language)
2494
+ };
2495
+ const what = {
2496
+ views: "exercise type",
2497
+ panels: "panel",
2498
+ markdown: "markdown renderer"
2499
+ };
2500
+ const fix = {
2501
+ views: "defineAnswerView(…)",
2502
+ panels: "defineExtensionPanel(…)",
2503
+ markdown: "defineMarkdownRenderer(…)"
2504
+ };
2505
+ for (const name of RECORDS) {
2506
+ const result = analysis.records[name];
2507
+ const ids = declared[name];
2508
+ if (result.status === "opaque") problems.push(`${indexSource}: cannot read the keys of '${name}' (${result.reason}); declare it as an object literal with static keys`);
2509
+ else if (result.status === "missing") {
2510
+ 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]} }`);
2511
+ } else {
2512
+ const file = path.relative(root, result.site.file).split(path.sep).join("/");
2513
+ const keys = result.site.keys.map((key) => key.name);
2514
+ for (const id of ids) if (!keys.includes(id)) problems.push(`${file}: '${name}' has no entry '${id}', but extension.json declares ${what[name]} '${id}'`);
2515
+ for (const key of keys) if (!ids.includes(key)) problems.push(`${file}: '${name}' has entry '${key}', but extension.json declares no ${what[name]} '${key}'`);
2516
+ }
2517
+ }
2518
+ return problems;
2519
+ };
2520
+ const isNodeSpecifier = (specifier, external) => specifier.startsWith("node:") || builtinModules.includes(specifier) || external.some((name) => specifier === name || specifier.startsWith(`${name}/`));
2521
+ /**
2522
+ * Build plugin for a single output file: the virtual shim, reconciliation of
2523
+ * `src/index.ts` with the manifest on every (re)build, pruning of entries not
2524
+ * belonging to the file, and protection of browser files from `node:*`.
2525
+ */
2526
+ const shimPlugin = ({ project, output, state }) => {
2527
+ const indexSource = project.indexSource;
2528
+ const root = realpathSync(project.root);
2529
+ const indexFile = path.join(root, indexSource);
2530
+ const shimId = shimEntry(project, output);
2531
+ let sites = [];
2532
+ const fail = (context, message) => {
2533
+ state.problem = message;
2534
+ return context.error(message);
2535
+ };
2536
+ return {
2537
+ name: "dolphy-ext:entry",
2538
+ enforce: "pre",
2539
+ resolveId(specifier) {
2540
+ if (specifier === shimId) return shimId;
2541
+ if (output.kind === "browser" && isNodeSpecifier(specifier, project.external)) return {
2542
+ id: specifier,
2543
+ external: true
2544
+ };
2545
+ return null;
2546
+ },
2547
+ generateBundle(_options, bundle) {
2548
+ if (output.kind !== "browser") return;
2549
+ for (const chunk of Object.values(bundle)) {
2550
+ if (chunk.type !== "chunk") continue;
2551
+ chunk.code = stripBareImports(chunk.code, (specifier) => isNodeSpecifier(specifier, project.external));
2552
+ const leaked = [...importsOf(chunk.code)].find((specifier) => isNodeSpecifier(specifier, project.external));
2553
+ if (leaked !== void 0) fail(this, `${output.output} imports '${leaked}': Node.js modules and the 'external' packages of dolphy-ext.config.json are for the extension host, keep them out of code that views, panels and markdown renderers use`);
2554
+ }
2555
+ },
2556
+ async buildStart() {
2557
+ this.addWatchFile(indexFile);
2558
+ const analysis = await analyzeIndex(indexFile).catch((error) => fail(this, `${indexSource} cannot be read: ${String(error)}`));
2559
+ for (const file of analysis.files) this.addWatchFile(file);
2560
+ const problems = findMismatches(project, analysis, indexSource, root);
2561
+ if (problems.length > 0) fail(this, problems.join("; "));
2562
+ const wanted = wantedRecords(output);
2563
+ sites = RECORDS.flatMap((name) => {
2564
+ const result = analysis.records[name];
2565
+ return result.status === "found" ? [{
2566
+ site: result.site,
2567
+ keep: new Set(wanted[name])
2568
+ }] : [];
2569
+ });
2570
+ },
2571
+ load(id) {
2572
+ return id === shimId ? shimSource(output, indexFile) : null;
2573
+ },
2574
+ transform(code, id) {
2575
+ const here = sites.filter(({ site }) => site.file === id);
2576
+ const first = here[0];
2577
+ if (first === void 0 || first.site.source !== code) return null;
2578
+ return pruneRecords(code, here);
2579
+ }
2580
+ };
2581
+ };
2582
+
767
2583
  //#endregion
768
2584
  //#region packages/extension-tools/src/bundle.ts
769
- const isWatcher = (value) => typeof value === "object" && value !== null && "on" in value && "close" in value;
770
2585
  const errorText$2 = (error) => error instanceof Error ? error.message : String(error);
771
2586
  const nodeExternal = (external) => [
772
2587
  /^node:/,
773
2588
  ...builtinModules,
774
2589
  ...external
775
2590
  ];
776
- const bundleConfig = (project, entry, outDir, isNode) => ({
2591
+ const bundleConfig = (project, entry, output, outDir, isNode) => ({
777
2592
  root: project.root,
778
2593
  configFile: false,
779
2594
  publicDir: false,
2595
+ base: "./",
780
2596
  logLevel: "warn",
2597
+ customLogger: {
2598
+ ...createLogger("warn"),
2599
+ error: () => void 0
2600
+ },
781
2601
  build: {
782
2602
  target: isNode ? "node22" : "es2022",
783
2603
  outDir,
784
2604
  emptyOutDir: false,
785
2605
  minify: false,
786
2606
  copyPublicDir: false,
2607
+ assetsInlineLimit: ASSETS_INLINE_LIMIT,
787
2608
  lib: {
788
- entry: path.resolve(project.root, entry.source),
2609
+ entry,
789
2610
  formats: ["es"],
790
- fileName: () => entry.output
2611
+ fileName: () => output
791
2612
  },
792
- rolldownOptions: { external: isNode ? nodeExternal(project.external) : [] }
2613
+ rolldownOptions: {
2614
+ external: isNode ? nodeExternal(project.external) : [],
2615
+ output: { assetFileNames: ASSET_FILE_NAME }
2616
+ }
793
2617
  }
794
2618
  });
795
- const configsOf = (project, outDir) => [...project.nodeEntries.map((entry) => bundleConfig(project, entry, outDir, true)), ...project.browserEntries.map((entry) => bundleConfig(project, entry, outDir, false))];
796
- const sourcesOf = (project) => [...project.nodeEntries, ...project.browserEntries];
797
- const buildOne = async (config, entry, subject) => {
798
- try {
799
- await build(config);
800
- } catch (error) {
801
- throw new BuildError(`failed to bundle '${entry.source}': ${errorText$2(error)}`, subject);
802
- }
2619
+ const outputJob = (project, output, outDir) => {
2620
+ const state = { problem: null };
2621
+ const config = bundleConfig(project, shimEntry(project, output), output.output, outDir, output.kind === "host");
2622
+ return {
2623
+ output: output.output,
2624
+ label: `${output.output} (${exportsOf(output).join(", ")} from ${project.indexSource})`,
2625
+ config: {
2626
+ ...config,
2627
+ plugins: [shimPlugin({
2628
+ project,
2629
+ output,
2630
+ state
2631
+ }), assetsPlugin(state)]
2632
+ },
2633
+ state
2634
+ };
2635
+ };
2636
+ const workerJob = (project, entry, outDir) => {
2637
+ const state = { problem: null };
2638
+ return {
2639
+ output: entry.output,
2640
+ label: `${entry.output} (${entry.source})`,
2641
+ config: {
2642
+ ...bundleConfig(project, path.resolve(project.root, entry.source), entry.output, outDir, true),
2643
+ plugins: [assetsPlugin(state)]
2644
+ },
2645
+ state
2646
+ };
803
2647
  };
804
- /** Собирает все бандлы (по одному, без общих чанков) в `outDir`. */
2648
+ /** Project bundles: the extensions process, browser files, workers. */
2649
+ const jobsOf = (project, outDir) => [
2650
+ ...project.host === null ? [] : [outputJob(project, project.host, outDir)],
2651
+ ...project.browserOutputs.map((output) => outputJob(project, output, outDir)),
2652
+ ...project.workerEntries.map((entry) => workerJob(project, entry, outDir))
2653
+ ];
2654
+ /** Failure cause: a reconciliation or guard error named by the plugin, or the bundler's text. */
2655
+ const failureDetail = (job, error) => job.state.problem ?? errorText$2(error);
2656
+ /** Builds all bundles (one at a time, no shared chunks) into `outDir`. */
805
2657
  const bundleAll = async (project, outDir) => {
806
- const configs = configsOf(project, outDir);
807
- const entries = sourcesOf(project);
808
- for (const [index, config] of configs.entries()) await buildOne(config, entries[index], project.manifest.id);
2658
+ for (const job of jobsOf(project, outDir)) {
2659
+ job.state.problem = null;
2660
+ try {
2661
+ await build(job.config);
2662
+ } catch (error) {
2663
+ throw new BuildError(`failed to bundle ${job.label}: ${failureDetail(job, error)}`, project.manifest.id);
2664
+ }
2665
+ }
809
2666
  };
810
- const whenFirstEnd = (watcher, entry, onRebuild) => {
811
- let isFirst = true;
812
- return new Promise((resolve, reject) => {
813
- watcher.on("event", (event) => {
814
- if (event.code === "ERROR") {
815
- if (isFirst) reject(event.error);
816
- else onRebuild(entry, event.error);
817
- }
818
- if (event.code === "END") {
819
- if (isFirst) resolve();
820
- else onRebuild(entry, null);
821
- isFirst = false;
822
- }
823
- });
824
- });
2667
+
2668
+ //#endregion
2669
+ //#region packages/extension-tools/src/ids.ts
2670
+ /** Directory of the generated files in a project; never part of `dist-ext` or a catalog source tree. */
2671
+ const GENERATED_DIR = ".dolphy";
2672
+ /** The declarations file, relative to the project root (posix separators, as in `tsconfig` `include`). */
2673
+ const IDS_FILE = `${GENERATED_DIR}/ids.d.ts`;
2674
+ const HEADER = "// Generated by `dolphy-ext types` from extension.json. Do not edit: the file is rewritten on every build.";
2675
+ /** A single-quoted TypeScript string literal. */
2676
+ const literal = (text) => `'${JSON.stringify(text).slice(1, -1).replaceAll("\\\"", "\"").replaceAll("'", "\\'")}'`;
2677
+ /** A union of literals; `never` when the manifest declares none. */
2678
+ const union = (values) => values.length === 0 ? "never" : values.map(literal).join(" | ");
2679
+ const settingType = (setting) => setting.type === "enum" ? union(setting.options.map((option) => option.value)) : setting.type;
2680
+ const settingsType = (settings) => settings.length === 0 ? "{}" : `{\n${settings.map((setting) => ` ${literal(setting.id)}: ${settingType(setting)};`).join("\n")}\n }`;
2681
+ /**
2682
+ * The declarations for a manifest: an augmentation of `ExtensionIds` in
2683
+ * `@dolphy-app/extension-sdk`. Pure function of the normalized manifest, so
2684
+ * the output is deterministic; a kind with no ids is `never`.
2685
+ */
2686
+ const renderIds = ({ contributes }) => {
2687
+ const entries = [
2688
+ ["exerciseTypes", union(contributes.exerciseTypes.map(({ id }) => id))],
2689
+ ["gradePolicies", union(contributes.gradePolicies.map(({ id }) => id))],
2690
+ ["commands", union(contributes.commands.map(({ id }) => id))],
2691
+ ["events", union(contributes.events.map(({ event }) => event))],
2692
+ ["panels", union(contributes.panels.map(({ id }) => id))],
2693
+ ["markdownLanguages", union(contributes.markdownRenderers.map(({ language }) => language))],
2694
+ ["settings", settingsType(contributes.settings)]
2695
+ ];
2696
+ return [
2697
+ HEADER,
2698
+ "declare module '@dolphy-app/extension-sdk' {",
2699
+ " interface ExtensionIds {",
2700
+ ...entries.map(([name, type]) => ` ${name}: ${type};`),
2701
+ " }",
2702
+ "}",
2703
+ "",
2704
+ "export {};",
2705
+ ""
2706
+ ].join("\n");
825
2707
  };
826
2708
  /**
827
- * Запускает вотчеры всех бандлов; резолвится после первой сборки каждого.
828
- * `onRebuild` вызывается после последующих пересборок (`error` — причина сбоя).
2709
+ * Writes `.dolphy/ids.d.ts` for the project, from `extension.json` only (no
2710
+ * author code is run). A file with identical content is not rewritten, so a
2711
+ * watcher on the project does not see a change.
829
2712
  */
830
- const watchAll = async (project, outDir, onRebuild) => {
831
- const watchers = [];
832
- const close = async () => {
833
- await Promise.all(watchers.map((watcher) => watcher.close()));
2713
+ const writeIds = async (project) => {
2714
+ const file = path.join(project.root, IDS_FILE);
2715
+ const content = renderIds(project.manifest);
2716
+ if (await readFile(file, "utf8").catch(() => null) === content) return {
2717
+ file,
2718
+ changed: false
2719
+ };
2720
+ await mkdir(path.dirname(file), { recursive: true });
2721
+ await writeFile(file, content);
2722
+ return {
2723
+ file,
2724
+ changed: true
834
2725
  };
835
- const entries = sourcesOf(project);
836
- try {
837
- const firsts = [];
838
- for (const [index, config] of configsOf(project, outDir).entries()) {
839
- const entry = entries[index];
840
- const started = await build({
841
- ...config,
842
- build: {
843
- ...config.build,
844
- watch: {}
845
- }
846
- });
847
- if (!isWatcher(started)) throw new Error(`watch mode is not available for '${entry.source}'`);
848
- watchers.push(started);
849
- firsts.push(whenFirstEnd(started, entry, onRebuild));
850
- }
851
- await Promise.all(firsts);
852
- } catch (error) {
853
- await close();
854
- throw new BuildError(`failed to bundle: ${errorText$2(error)}`, project.manifest.id);
855
- }
856
- return { close };
857
2726
  };
858
2727
 
859
2728
  //#endregion
@@ -861,6 +2730,7 @@ const watchAll = async (project, outDir, onRebuild) => {
861
2730
  const MANIFEST_FILE = "extension.json";
862
2731
  const CONFIG_FILE = "dolphy-ext.config.json";
863
2732
  const DEFAULT_OUT_DIR = "dist-ext";
2733
+ const INDEX_SOURCE = "src/index.ts";
864
2734
  const stripDot = (file) => file.replace(/^\.\//, "");
865
2735
  const stripExtension = (file) => file.slice(0, file.length - path.posix.extname(file).length);
866
2736
  const entrySource = (file) => `src/${path.posix.basename(stripExtension(file))}.ts`;
@@ -893,19 +2763,45 @@ const schemaPathsOf = (manifest) => {
893
2763
  const paths = manifest.contributes.exerciseTypes.flatMap((type) => [type.specSchema, type.answerSchema]).filter((schema) => typeof schema === "string").map(stripDot);
894
2764
  return [...new Set(paths)];
895
2765
  };
896
- /** Без кода (`main: null`) node-входов нет. */
897
- const nodeEntriesOf = (manifest, config) => {
898
- if (manifest.main === null) return [];
899
- const main = stripDot(manifest.main);
900
- return [{
901
- source: entrySource(main),
902
- output: main
903
- }, ...Object.entries(config.nodeEntries).map(([output, source]) => ({
904
- source,
905
- output: stripDot(output)
906
- }))];
907
- };
908
- /** Читает исходный манифест и конфиг проекта, вычисляет точки входа. */
2766
+ /** Workers are built only together with code: without `main` there are none. */
2767
+ const workerEntriesOf = (manifest, config) => manifest.main === null ? [] : Object.entries(config.nodeEntries).map(([output, source]) => ({
2768
+ source,
2769
+ output: stripDot(output)
2770
+ }));
2771
+ const browserOutputsOf = (manifest) => {
2772
+ const outputs = /* @__PURE__ */ new Map();
2773
+ const outputOf = (file) => {
2774
+ const output = stripDot(file);
2775
+ const existing = outputs.get(output);
2776
+ if (existing !== void 0) return existing;
2777
+ const created = {
2778
+ kind: "browser",
2779
+ output,
2780
+ views: [],
2781
+ panels: [],
2782
+ languages: []
2783
+ };
2784
+ outputs.set(output, created);
2785
+ return created;
2786
+ };
2787
+ const { exerciseTypes, markdownRenderers, panels } = manifest.contributes;
2788
+ for (const type of exerciseTypes) outputOf(type.renderer).views.push({
2789
+ id: type.id,
2790
+ element: type.element
2791
+ });
2792
+ for (const entry of markdownRenderers) outputOf(entry.renderer).languages.push(entry.language);
2793
+ for (const panel of panels) outputOf(panel.module).panels.push(panel.id);
2794
+ return [...outputs.values()];
2795
+ };
2796
+ /**
2797
+ * Paths where code lived before the single entry (`src/<file name>.ts`):
2798
+ * the build uses them to recognize the old layout and suggest a migration.
2799
+ */
2800
+ const legacySources = (project) => {
2801
+ const outputs = [...project.host === null ? [] : [project.host.output], ...project.browserOutputs.map((entry) => entry.output)];
2802
+ return [...new Set(outputs.map(entrySource))];
2803
+ };
2804
+ /** Reads the source manifest and project config, computes the output files. */
909
2805
  const loadProject = async (rootDir) => {
910
2806
  const root = path.resolve(rootDir);
911
2807
  const manifestFile = path.join(root, MANIFEST_FILE);
@@ -920,36 +2816,179 @@ const loadProject = async (rootDir) => {
920
2816
  if (!parsed.ok) throw new BuildError(parsed.message, root);
921
2817
  const { manifest } = parsed;
922
2818
  const config = await readConfig(root);
923
- const nodeEntries = nodeEntriesOf(manifest, config);
924
- const browserEntries = [.../* @__PURE__ */ new Set([...manifest.contributes.exerciseTypes.map((type) => type.renderer), ...manifest.contributes.markdownRenderers.map((entry) => entry.renderer)])].map(stripDot).map((output) => ({
925
- source: entrySource(output),
926
- output
927
- }));
2819
+ const host = manifest.main === null ? null : {
2820
+ kind: "host",
2821
+ output: stripDot(manifest.main)
2822
+ };
2823
+ const browserOutputs = browserOutputsOf(manifest);
928
2824
  return {
929
2825
  root,
930
2826
  manifest,
931
2827
  manifestBytes,
932
- nodeEntries,
933
- browserEntries,
2828
+ indexSource: host !== null || browserOutputs.length > 0 ? INDEX_SOURCE : null,
2829
+ host,
2830
+ browserOutputs,
2831
+ workerEntries: workerEntriesOf(manifest, config),
934
2832
  external: config.external,
935
2833
  schemaPaths: schemaPathsOf(manifest)
936
2834
  };
937
2835
  };
938
2836
 
2837
+ //#endregion
2838
+ //#region packages/extension-tools/src/watch.ts
2839
+ const isWatcher = (value) => typeof value === "object" && value !== null && "on" in value && "close" in value;
2840
+ /**
2841
+ * Editing a source wakes the watchers of all files almost simultaneously: the
2842
+ * events of one cycle are gathered into one report, sent after quiet and once no
2843
+ * watcher is busy rebuilding.
2844
+ */
2845
+ const SETTLE_MS = 150;
2846
+ const createReporter = (onReport, settleMs = SETTLE_MS) => {
2847
+ const rebuilt = /* @__PURE__ */ new Set();
2848
+ let failures = [];
2849
+ let busy = 0;
2850
+ let timer = null;
2851
+ const flush = () => {
2852
+ timer = null;
2853
+ if (busy > 0) return;
2854
+ const report = {
2855
+ rebuilt: [...rebuilt],
2856
+ failures
2857
+ };
2858
+ rebuilt.clear();
2859
+ failures = [];
2860
+ if (report.rebuilt.length > 0 || report.failures.length > 0) onReport(report);
2861
+ };
2862
+ const touch = () => {
2863
+ if (timer !== null) clearTimeout(timer);
2864
+ timer = setTimeout(flush, settleMs);
2865
+ };
2866
+ return {
2867
+ begin() {
2868
+ busy += 1;
2869
+ },
2870
+ end() {
2871
+ busy = Math.max(0, busy - 1);
2872
+ touch();
2873
+ },
2874
+ rebuilt(job) {
2875
+ rebuilt.add(job.output);
2876
+ touch();
2877
+ },
2878
+ failed(job, detail) {
2879
+ const same = failures.find((failure) => failure.detail === detail);
2880
+ if (same === void 0) failures.push({
2881
+ labels: [job.label],
2882
+ detail
2883
+ });
2884
+ else if (!same.labels.includes(job.label)) same.labels.push(job.label);
2885
+ touch();
2886
+ },
2887
+ close() {
2888
+ if (timer !== null) clearTimeout(timer);
2889
+ timer = null;
2890
+ }
2891
+ };
2892
+ };
2893
+ const watchJob = async (job, reporter, isFailFast, failedFirst) => {
2894
+ const started = await build({
2895
+ ...job.config,
2896
+ build: {
2897
+ ...job.config.build,
2898
+ watch: {}
2899
+ }
2900
+ });
2901
+ if (!isWatcher(started)) throw new Error(`watch mode is not available for '${job.output}'`);
2902
+ let isFirst = true;
2903
+ let hasFailed = false;
2904
+ return {
2905
+ watcher: started,
2906
+ first: new Promise((resolve, reject) => {
2907
+ started.on("event", (event) => {
2908
+ if (event.code === "START") {
2909
+ reporter.begin();
2910
+ hasFailed = false;
2911
+ job.state.problem = null;
2912
+ }
2913
+ if (event.code === "ERROR") {
2914
+ hasFailed = true;
2915
+ const detail = failureDetail(job, event.error);
2916
+ if (isFirst && isFailFast) reject(/* @__PURE__ */ new Error(`${job.label}: ${detail}`));
2917
+ else reporter.failed(job, detail);
2918
+ if (isFirst) {
2919
+ failedFirst.add(job.output);
2920
+ resolve();
2921
+ isFirst = false;
2922
+ }
2923
+ }
2924
+ if (event.code === "END") {
2925
+ reporter.end();
2926
+ if (isFirst) resolve();
2927
+ else if (!hasFailed) reporter.rebuilt(job);
2928
+ isFirst = false;
2929
+ }
2930
+ });
2931
+ })
2932
+ };
2933
+ };
2934
+ /**
2935
+ * Starts the watchers of all project bundles; resolves after each one's first build. A first build with an error is a failure
2936
+ * (`isFailFast`); otherwise the error goes into the report and the watcher waits for an edit.
2937
+ */
2938
+ const watchAll = async (project, outDir, reporter, isFailFast) => {
2939
+ const watchers = [];
2940
+ const close = async () => {
2941
+ await Promise.all(watchers.map((watcher) => watcher.close()));
2942
+ };
2943
+ const failedFirst = /* @__PURE__ */ new Set();
2944
+ try {
2945
+ const firsts = [];
2946
+ for (const job of jobsOf(project, outDir)) {
2947
+ const { watcher, first } = await watchJob(job, reporter, isFailFast, failedFirst);
2948
+ watchers.push(watcher);
2949
+ firsts.push(first);
2950
+ }
2951
+ await Promise.all(firsts);
2952
+ } catch (error) {
2953
+ await close();
2954
+ throw new BuildError(`failed to bundle: ${errorText$2(error)}`, project.manifest.id);
2955
+ }
2956
+ return {
2957
+ isHealthy: failedFirst.size === 0,
2958
+ close
2959
+ };
2960
+ };
2961
+
939
2962
  //#endregion
940
2963
  //#region packages/extension-tools/src/index.ts
941
2964
  const STATIC_DIRS = ["schema", "assets"];
2965
+ /** Editors write a file in several events: manifest reload waits for quiet. */
2966
+ const MANIFEST_SETTLE_MS = 100;
942
2967
  const isPresent = async (file) => await stat(file).catch(() => null) !== null;
943
2968
  const targetDir = (project, options) => {
944
2969
  const base = options.outDir ?? path.join(project.root, "dist-ext");
945
2970
  return path.join(path.resolve(base), project.manifest.id);
946
2971
  };
2972
+ const legacyHint = (project) => {
2973
+ const { contributes } = project.manifest;
2974
+ return [
2975
+ "create src/index.ts",
2976
+ ...project.host === null ? [] : [`move the default export of the main file into it as export const host = defineExtension({ … })`],
2977
+ ...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)`],
2978
+ ...contributes.panels.length === 0 ? [] : [`move the default export of the panel module into export const panels = { '<panel id>': defineExtensionPanel({ … }) }`],
2979
+ ...contributes.markdownRenderers.length === 0 ? [] : [`move the default export of the renderer module into export const markdown = { '<language>': defineMarkdownRenderer(…) }`],
2980
+ "delete the old src files and import from src/index.ts in tests"
2981
+ ].map((step, index) => `${index + 1}. ${step}`).join("; ");
2982
+ };
947
2983
  const requireSources = async (project) => {
948
- const entries = [...project.nodeEntries, ...project.browserEntries];
949
- for (const entry of entries) {
950
- const file = path.join(project.root, entry.source);
951
- if (!await isPresent(file)) throw new BuildError(`entry source '${entry.source}' for '${entry.output}' is not found`, project.manifest.id);
2984
+ const { indexSource } = project;
2985
+ if (indexSource !== null && !await isPresent(path.join(project.root, indexSource))) {
2986
+ const legacy = legacySources(project);
2987
+ const found = (await Promise.all(legacy.map(async (source) => await isPresent(path.join(project.root, source)) ? source : null))).filter((source) => source !== null);
2988
+ const reason = found.length > 0 ? `found the old layout (${found.join(", ")}), which is no longer supported; migrate: ${legacyHint(project)}` : `add it: ${legacyHint(project)}`;
2989
+ throw new BuildError(`'${indexSource}' is not found: an extension with code is built from one entry file; ${reason}`, project.manifest.id);
952
2990
  }
2991
+ 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);
953
2992
  };
954
2993
  const copyStatic = async (project, dir) => {
955
2994
  await writeFile(path.join(dir, MANIFEST_FILE), project.manifestBytes);
@@ -957,6 +2996,13 @@ const copyStatic = async (project, dir) => {
957
2996
  const from = path.join(project.root, name);
958
2997
  if (await isPresent(from)) await cp(from, path.join(dir, name), { recursive: true });
959
2998
  }
2999
+ const { icon } = project.manifest;
3000
+ if (icon !== null) {
3001
+ const from = path.join(project.root, icon);
3002
+ if (!await isPresent(from)) throw new BuildError(`icon '${icon}' referenced by the manifest is not found`, project.manifest.id);
3003
+ await mkdir(path.dirname(path.join(dir, icon)), { recursive: true });
3004
+ await cp(from, path.join(dir, icon));
3005
+ }
960
3006
  for (const schema of project.schemaPaths) {
961
3007
  const from = path.join(project.root, schema);
962
3008
  if (!await isPresent(from)) throw new BuildError(`schema '${schema}' referenced by the manifest is not found`, project.manifest.id);
@@ -972,24 +3018,30 @@ const listFiles = async (dir, prefix = "") => {
972
3018
  }))).flat().sort();
973
3019
  };
974
3020
  const validateExtension = async (dir) => {
975
- const result = await inspectExtensionDir(path.resolve(dir), {
3021
+ const root = path.resolve(dir);
3022
+ const result = await inspectExtensionDir(root, {
976
3023
  verifyFiles: true,
977
3024
  expectedId: null
978
3025
  });
979
- return result.ok ? {
980
- ok: true,
981
- problems: []
982
- } : {
3026
+ if (!result.ok) return {
983
3027
  ok: false,
984
3028
  problems: [result.message]
985
3029
  };
3030
+ const problems = (await assetFindings(root, await listFiles(root))).map(({ path: file, message }) => `${file}: ${message}`);
3031
+ return {
3032
+ ok: problems.length === 0,
3033
+ problems
3034
+ };
986
3035
  };
987
3036
  const assertValid = async (project, dir) => {
988
3037
  const { ok, problems } = await validateExtension(dir);
989
3038
  if (!ok) throw new BuildError(problems.join("; "), project.manifest.id);
990
3039
  };
3040
+ /** Writes `.dolphy/ids.d.ts` for the project in `options.root` from its `extension.json`; `changed` is false when the file was already current. */
3041
+ const generateTypes = async (options) => writeIds(await loadProject(options.root));
991
3042
  const prepare = async (options) => {
992
3043
  const project = await loadProject(options.root);
3044
+ await writeIds(project);
993
3045
  await requireSources(project);
994
3046
  const dir = targetDir(project, options);
995
3047
  await rm(dir, {
@@ -1014,30 +3066,82 @@ const buildExtension = async (options) => {
1014
3066
  await assertValid(project, dir);
1015
3067
  return resultOf$1(project, dir);
1016
3068
  };
3069
+ const reportTo = (id, logger) => (report) => {
3070
+ if (report.rebuilt.length > 0) logger?.info(`rebuilt ${report.rebuilt.join(", ")}`);
3071
+ for (const failure of report.failures) logger?.error(`error ${id}: failed to bundle ${failure.labels.join(", ")}: ${failure.detail}`);
3072
+ };
1017
3073
  /**
1018
- * Первая сборка + пересборка бандлов при правке исходников до `close()`.
1019
- * Манифест, схемы и assets копируются один раз: их правка требует перезапуска.
3074
+ * First build + rebuild of affected files when `src/index.ts` or its
3075
+ * dependencies are edited, until `close()`. Editing `extension.json` rebuilds
3076
+ * everything: the shims depend on the manifest. Schemas and assets are copied on
3077
+ * the first build and after a manifest edit.
1020
3078
  */
1021
3079
  const watchExtension = async (options) => {
1022
- const { project, dir } = await prepare(options);
3080
+ const { project: first, dir: firstDir } = await prepare(options);
1023
3081
  const { logger } = options;
1024
- const watch = await watchAll(project, dir, (entry, error) => {
1025
- if (error === null) logger?.info(`rebuilt ${entry.output}`);
1026
- else {
1027
- const message = error instanceof Error ? error.message : String(error);
1028
- logger?.error(`error ${project.manifest.id}: ${message}`);
1029
- }
1030
- });
3082
+ let project = first;
3083
+ let dir = firstDir;
3084
+ const reporter = createReporter((report) => reportTo(project.manifest.id, logger)(report));
3085
+ let current = await watchAll(project, dir, reporter, true);
1031
3086
  try {
1032
3087
  await copyStatic(project, dir);
1033
3088
  await assertValid(project, dir);
1034
3089
  } catch (error) {
1035
- await watch.close();
3090
+ await current.close();
3091
+ reporter.close();
1036
3092
  throw error;
1037
3093
  }
3094
+ const result = await resultOf$1(project, dir);
3095
+ const reload = async () => {
3096
+ const previous = project.manifestBytes;
3097
+ let next;
3098
+ try {
3099
+ next = await loadProject(options.root);
3100
+ } catch (error) {
3101
+ logger?.error(`error ${project.manifest.id}: ${error instanceof Error ? error.message : String(error)}`);
3102
+ return;
3103
+ }
3104
+ if (next.manifestBytes.equals(previous)) return;
3105
+ await current?.close();
3106
+ current = null;
3107
+ await rm(dir, {
3108
+ recursive: true,
3109
+ force: true
3110
+ });
3111
+ try {
3112
+ await writeIds(next);
3113
+ await requireSources(next);
3114
+ project = next;
3115
+ dir = targetDir(project, options);
3116
+ await mkdir(dir, { recursive: true });
3117
+ current = await watchAll(project, dir, reporter, false);
3118
+ await copyStatic(project, dir);
3119
+ if (current.isHealthy) {
3120
+ await assertValid(project, dir);
3121
+ logger?.info(`rebuilt ${(await resultOf$1(project, dir)).files.join(", ")}`);
3122
+ }
3123
+ } catch (error) {
3124
+ logger?.error(`error ${project.manifest.id}: ${error instanceof Error ? error.message : String(error)}`);
3125
+ }
3126
+ };
3127
+ let queue = Promise.resolve();
3128
+ let timer = null;
3129
+ const manifestWatcher = watch(project.root, (_event, name) => {
3130
+ if (name !== "extension.json") return;
3131
+ if (timer !== null) clearTimeout(timer);
3132
+ timer = setTimeout(() => {
3133
+ queue = queue.then(reload);
3134
+ }, MANIFEST_SETTLE_MS);
3135
+ });
1038
3136
  return {
1039
- result: await resultOf$1(project, dir),
1040
- close: watch.close
3137
+ result,
3138
+ close: async () => {
3139
+ manifestWatcher.close();
3140
+ if (timer !== null) clearTimeout(timer);
3141
+ await queue;
3142
+ reporter.close();
3143
+ await current?.close();
3144
+ }
1041
3145
  };
1042
3146
  };
1043
3147
 
@@ -1049,7 +3153,7 @@ const statusOf = (response) => {
1049
3153
  if (response.status === 404) return "missing";
1050
3154
  return "unknown";
1051
3155
  };
1052
- /** Результат кэшируется на время жизни проверяющего: один автор — один запрос. */
3156
+ /** The result is cached for the checker's lifetime: one author — one request. */
1053
3157
  const createGithubChecker = (options = {}) => {
1054
3158
  const fetchUser = options.fetch ?? fetch;
1055
3159
  const headers = {
@@ -1138,12 +3242,12 @@ const readPackageJson = async (context) => {
1138
3242
  };
1139
3243
  const manifestValid = {
1140
3244
  id: "CHECK-001",
1141
- title: "extension.json читается и проходит разбор манифеста",
3245
+ title: "extension.json is readable and passes manifest parsing",
1142
3246
  run: ({ manifestProblem }) => manifestProblem === null ? [] : [error("extension.json", manifestProblem)]
1143
3247
  };
1144
3248
  const directoryName = {
1145
3249
  id: "CHECK-002",
1146
- title: "имя каталога равно id из манифеста",
3250
+ title: "directory name equals the manifest id",
1147
3251
  run: ({ manifest, dirName }) => manifest === null || manifest.id === dirName ? [] : [error("id", `directory name '${dirName}' does not match manifest id '${manifest.id}'`)]
1148
3252
  };
1149
3253
  const textFinding = (field, value, maxLength) => {
@@ -1152,7 +3256,7 @@ const textFinding = (field, value, maxLength) => {
1152
3256
  };
1153
3257
  const publicationMetadata = {
1154
3258
  id: "CHECK-003",
1155
- title: "заданы name, description и author",
3259
+ title: "name, description and author are set",
1156
3260
  run: ({ declared }) => declared === null ? [] : [
1157
3261
  ...textFinding("name", declared.name, MAX_NAME_LENGTH),
1158
3262
  ...textFinding("description", declared.description, MAX_DESCRIPTION_LENGTH),
@@ -1161,7 +3265,7 @@ const publicationMetadata = {
1161
3265
  };
1162
3266
  const readme = {
1163
3267
  id: "CHECK-004",
1164
- title: "README.md существует и не пуст",
3268
+ title: "README.md exists and is not empty",
1165
3269
  run: async (context) => {
1166
3270
  const text = await context.readText("README.md");
1167
3271
  return text === null || text.trim() === "" ? [error("README.md", "README.md is missing or empty")] : [];
@@ -1170,12 +3274,12 @@ const readme = {
1170
3274
  const isLogin = (author) => GITHUB_LOGIN_PATTERN.test(author);
1171
3275
  const authorLogin = {
1172
3276
  id: "CHECK-005",
1173
- title: "author имеет форму GitHub-логина",
3277
+ title: "author has the shape of a GitHub login",
1174
3278
  run: ({ declared }) => declared?.author && !isLogin(declared.author) ? [error("author", `'${declared.author}' is not a GitHub login`)] : []
1175
3279
  };
1176
3280
  const authorExists = {
1177
3281
  id: "CHECK-006",
1178
- title: "author — существующий пользователь GitHub",
3282
+ title: "author is an existing GitHub user",
1179
3283
  run: async ({ declared, skipGithubCheck, checkGithubUser }) => {
1180
3284
  const author = declared?.author;
1181
3285
  if (skipGithubCheck || !author || !isLogin(author)) return [];
@@ -1186,7 +3290,7 @@ const authorExists = {
1186
3290
  };
1187
3291
  const packageJson = {
1188
3292
  id: "CHECK-007",
1189
- title: "package.json существует и разбирается",
3293
+ title: "package.json exists and parses",
1190
3294
  run: async (context) => {
1191
3295
  if (await context.readText("package.json") === null) return [error("package.json", "package.json is missing")];
1192
3296
  return await readPackageJson(context) === null ? [error("package.json", "package.json is not a valid JSON object")] : [];
@@ -1194,12 +3298,12 @@ const packageJson = {
1194
3298
  };
1195
3299
  const lockfile = {
1196
3300
  id: "CHECK-008",
1197
- title: "есть lock-файл зависимостей",
3301
+ title: "a dependency lock file exists",
1198
3302
  run: ({ tree }) => LOCKFILES.some((name) => tree.files.some((file) => file.path === name)) ? [] : [error("package.json", `no lockfile (${LOCKFILES.join(", ")})`)]
1199
3303
  };
1200
3304
  const lifecycleScripts = {
1201
3305
  id: "CHECK-009",
1202
- title: "нет lifecycle-скриптов установки и публикации",
3306
+ title: "no install or publish lifecycle scripts",
1203
3307
  run: async (context) => {
1204
3308
  const scripts = (await readPackageJson(context))?.scripts;
1205
3309
  if (!isRecord(scripts)) return [];
@@ -1208,7 +3312,7 @@ const lifecycleScripts = {
1208
3312
  };
1209
3313
  const registryDependencies = {
1210
3314
  id: "CHECK-010",
1211
- title: "зависимости только из реестра (без git, http, file, link, workspace)",
3315
+ title: "dependencies come only from the registry (no git, http, file, link, workspace)",
1212
3316
  run: async (context) => {
1213
3317
  const manifest = await readPackageJson(context);
1214
3318
  if (manifest === null) return [];
@@ -1221,7 +3325,7 @@ const registryDependencies = {
1221
3325
  };
1222
3326
  const packageScope = {
1223
3327
  id: "CHECK-011",
1224
- title: "name в package.json не занимает чужой scope",
3328
+ title: "package.json name does not take another's scope",
1225
3329
  run: async (context) => {
1226
3330
  const name = (await readPackageJson(context))?.name;
1227
3331
  if (typeof name !== "string" || !name.startsWith("@")) return [];
@@ -1230,7 +3334,7 @@ const packageScope = {
1230
3334
  };
1231
3335
  const newerThanPublished = {
1232
3336
  id: "CHECK-012",
1233
- title: "версия строго больше опубликованной",
3337
+ title: "version is strictly greater than the published one",
1234
3338
  run: ({ manifest, published }) => {
1235
3339
  if (manifest === null || published === void 0) return [];
1236
3340
  const { version } = manifest;
@@ -1241,7 +3345,7 @@ const newerThanPublished = {
1241
3345
  };
1242
3346
  const sourceLimits = {
1243
3347
  id: "CHECK-013",
1244
- title: `не более ${200} файлов и 5 МБ исходников, файл не больше 1 МБ`,
3348
+ title: `at most ${200} files and 5 MB of sources, no file over 1 MB`,
1245
3349
  run: ({ tree }) => {
1246
3350
  const findings = [];
1247
3351
  const total = tree.files.reduce((sum, file) => sum + file.size, 0);
@@ -1253,19 +3357,37 @@ const sourceLimits = {
1253
3357
  };
1254
3358
  const noSymlinks = {
1255
3359
  id: "CHECK-014",
1256
- title: "нет символических ссылок",
3360
+ title: "no symbolic links",
1257
3361
  run: ({ tree }) => tree.symlinks.map((link) => error(link, "symbolic links are not allowed"))
1258
3362
  };
1259
3363
  const noExecutables = {
1260
3364
  id: "CHECK-015",
1261
- title: `нет исполняемых файлов (${EXECUTABLE_EXTENSIONS.join(", ")})`,
3365
+ title: `no executable files (${EXECUTABLE_EXTENSIONS.join(", ")})`,
1262
3366
  run: ({ tree }) => tree.files.filter((file) => EXECUTABLE_EXTENSIONS.includes(path.posix.extname(file.path).toLowerCase())).map((file) => error(file.path, "executable files are not allowed"))
1263
3367
  };
1264
3368
  const appVersionBound = {
1265
3369
  id: "CHECK-016",
1266
- title: "minAppVersion не новее --max-app-version",
3370
+ title: "minAppVersion is not newer than --max-app-version",
1267
3371
  run: ({ manifest, maxAppVersion }) => manifest?.minAppVersion && maxAppVersion !== null && compareSemver(manifest.minAppVersion, maxAppVersion) > 0 ? [error("minAppVersion", `minAppVersion ${manifest.minAppVersion} is newer than the released app ${maxAppVersion}`)] : []
1268
3372
  };
3373
+ const ASSETS_DIR = "assets/";
3374
+ const assetFiles = {
3375
+ id: "CHECK-017",
3376
+ title: "files in assets/ match their type: signature, size limits, pixels, safe SVG and CSS",
3377
+ run: async ({ dir, tree }) => (await assetFindings(dir, tree.files.map((file) => file.path).filter((file) => file.startsWith(ASSETS_DIR)))).map(({ path: file, message }) => error(file, message))
3378
+ };
3379
+ const iconFile = {
3380
+ id: "CHECK-018",
3381
+ title: "icon is a square 64–512 px PNG or WebP file up to 16 KiB",
3382
+ run: async ({ declared, readBytes }) => {
3383
+ const icon = declared?.icon;
3384
+ if (icon === null || icon === void 0) return [];
3385
+ const bytes = await readBytes(icon);
3386
+ if (bytes === null) return [error("icon", `icon '${icon}' is not a file`)];
3387
+ const problem = iconProblem(icon, bytes);
3388
+ return problem === null ? [] : [error("icon", problem)];
3389
+ }
3390
+ };
1269
3391
  const RULES = [
1270
3392
  manifestValid,
1271
3393
  directoryName,
@@ -1282,7 +3404,9 @@ const RULES = [
1282
3404
  sourceLimits,
1283
3405
  noSymlinks,
1284
3406
  noExecutables,
1285
- appVersionBound
3407
+ appVersionBound,
3408
+ assetFiles,
3409
+ iconFile
1286
3410
  ];
1287
3411
 
1288
3412
  //#endregion
@@ -1327,6 +3451,7 @@ const hashTree = async (root, files) => Promise.all(files.map(async (file) => ({
1327
3451
  const SKIPPED_SOURCE_DIRS = /* @__PURE__ */ new Set([
1328
3452
  "node_modules",
1329
3453
  "dist-ext",
3454
+ ".dolphy",
1330
3455
  ".git"
1331
3456
  ]);
1332
3457
  const formatFinding = (finding) => `${finding.severity} ${finding.extensionId} ${finding.ruleId} ${finding.field}: ${finding.message}`;
@@ -1361,6 +3486,12 @@ const selectDirs = async (options) => {
1361
3486
  const readTextOrNull = async (file) => {
1362
3487
  return (await stat(file).catch(() => null))?.isFile() === true ? readFile(file, "utf8") : null;
1363
3488
  };
3489
+ /** A file inside `dir`; a path that leaves it (`../x`) is no file. */
3490
+ const readBytesInside = async (dir, file) => {
3491
+ const target = path.resolve(dir, file);
3492
+ if (!target.startsWith(dir + path.sep)) return null;
3493
+ return (await stat(target).catch(() => null))?.isFile() === true ? readFile(target) : null;
3494
+ };
1364
3495
  const inspectManifest = async (dir) => {
1365
3496
  const result = await inspectExtensionDir(dir, {
1366
3497
  verifyFiles: false,
@@ -1383,7 +3514,8 @@ const readDeclared = async (dir) => {
1383
3514
  return {
1384
3515
  name: stringOrNull(fields.name),
1385
3516
  description: stringOrNull(fields.description),
1386
- author: stringOrNull(fields.author)
3517
+ author: stringOrNull(fields.author),
3518
+ icon: stringOrNull(fields.icon)
1387
3519
  };
1388
3520
  } catch {
1389
3521
  return null;
@@ -1400,6 +3532,7 @@ const contextFor = async (options, dirName, published, checkGithubUser) => {
1400
3532
  manifestProblem: problem,
1401
3533
  tree: await readTree(dir, SKIPPED_SOURCE_DIRS),
1402
3534
  readText: (file) => readTextOrNull(path.join(dir, file)),
3535
+ readBytes: (file) => readBytesInside(dir, file),
1403
3536
  published: published?.extensions.find((entry) => entry.id === (manifest?.id ?? dirName)),
1404
3537
  maxAppVersion: options.maxAppVersion ?? null,
1405
3538
  skipGithubCheck: options.skipGithubCheck === true,
@@ -1428,7 +3561,6 @@ const listRules = () => RULES.map((rule) => `${rule.id} ${rule.title}`);
1428
3561
 
1429
3562
  //#endregion
1430
3563
  //#region packages/extension-tools/src/catalog/index-file.ts
1431
- const INDEX_FILE = "index.json";
1432
3564
  const orderFile = (file) => ({
1433
3565
  path: file.path,
1434
3566
  size: file.size,
@@ -1443,8 +3575,19 @@ const orderVersion = (version) => ({
1443
3575
  permissions: [...version.permissions],
1444
3576
  publishedAt: version.publishedAt,
1445
3577
  baseUrl: version.baseUrl,
1446
- files: [...version.files].sort(byPath).map(orderFile)
3578
+ files: [...version.files].sort(byPath).map(orderFile),
3579
+ ...version.icon === void 0 ? {} : { icon: version.icon },
3580
+ ...version.tags === void 0 || version.tags.length === 0 ? {} : { tags: [...version.tags] }
1447
3581
  });
3582
+ const optionalIds = (key, ids) => ids === void 0 || ids.length === 0 ? {} : { [key]: [...ids] };
3583
+ const orderTitles = (titles) => {
3584
+ const ordered = {};
3585
+ for (const point of TITLED_POINTS) {
3586
+ const map = titles?.[point];
3587
+ if (map !== void 0 && Object.keys(map).length > 0) ordered[point] = { ...map };
3588
+ }
3589
+ return Object.keys(ordered).length > 0 ? { titles: ordered } : {};
3590
+ };
1448
3591
  const orderEntry = (entry) => ({
1449
3592
  id: entry.id,
1450
3593
  name: entry.name,
@@ -1456,16 +3599,29 @@ const orderEntry = (entry) => ({
1456
3599
  exerciseTypes: [...entry.contributes.exerciseTypes],
1457
3600
  themes: [...entry.contributes.themes],
1458
3601
  markdownRenderers: [...entry.contributes.markdownRenderers],
1459
- gradePolicies: [...entry.contributes.gradePolicies]
3602
+ gradePolicies: [...entry.contributes.gradePolicies],
3603
+ ...optionalIds("settings", entry.contributes.settings),
3604
+ ...optionalIds("events", entry.contributes.events),
3605
+ ...optionalIds("commands", entry.contributes.commands),
3606
+ ...optionalIds("panels", entry.contributes.panels)
1460
3607
  },
3608
+ ...orderTitles(entry.titles),
1461
3609
  versions: entry.versions.map(orderVersion)
1462
3610
  });
1463
3611
  const byId = (a, b) => compareText(a.id, b.id);
1464
3612
  const newestFirst = (versions) => [...versions].sort((a, b) => compareSemver(b.version, a.version)).slice(0, 5);
1465
- /** Собирает индекс в стабильном порядке ключей; бросает `BuildError`, если он не проходит `parseIndex`. */
3613
+ const checked = (candidate, file) => {
3614
+ try {
3615
+ parseIndex(candidate);
3616
+ return candidate;
3617
+ } catch (error) {
3618
+ throw new BuildError(`resulting index is invalid: ${error instanceof Error ? error.message : String(error)}`, file);
3619
+ }
3620
+ };
3621
+ /** Builds the full index with a stable key order; throws `BuildError` if it fails `parseIndex`. */
1466
3622
  const assembleIndex = (parts) => {
1467
3623
  const candidate = {
1468
- schemaVersion: 1,
3624
+ schemaVersion: 2,
1469
3625
  generatedAt: parts.generatedAt,
1470
3626
  extensions: [...parts.extensions].sort(byId).map(orderEntry),
1471
3627
  revoked: parts.revoked.map((item) => ({
@@ -1474,12 +3630,15 @@ const assembleIndex = (parts) => {
1474
3630
  reason: item.reason
1475
3631
  }))
1476
3632
  };
1477
- try {
1478
- parseIndex(candidate);
1479
- return candidate;
1480
- } catch (error) {
1481
- throw new BuildError(`resulting index is invalid: ${error instanceof Error ? error.message : String(error)}`, INDEX_FILE);
1482
- }
3633
+ return checked(candidate, FULL_INDEX_FILE);
3634
+ };
3635
+ /** `index.v2.json` and `index.json` of the same content and `generatedAt`. */
3636
+ const assembleIndexes = (parts) => {
3637
+ const full = assembleIndex(parts);
3638
+ return {
3639
+ full,
3640
+ legacy: checked(legacySubset(full), INDEX_FILE)
3641
+ };
1483
3642
  };
1484
3643
  const writeIndexAtomically = async (file, index) => {
1485
3644
  const temporary = `${file}.${process.pid}.tmp`;
@@ -1497,7 +3656,7 @@ const contentOf = (index) => {
1497
3656
  revoked
1498
3657
  });
1499
3658
  };
1500
- /** Совпадают ли записи и список отзыва (без `generatedAt`). */
3659
+ /** Whether entries and the revocation list match (ignoring `generatedAt`). */
1501
3660
  const hasSameContent = (current, next) => current !== null && contentOf(current) === contentOf(next);
1502
3661
 
1503
3662
  //#endregion
@@ -1512,7 +3671,11 @@ const copyReadme = async (srcDir, dir, id) => {
1512
3671
  };
1513
3672
  const fileProblems = (files) => {
1514
3673
  const problems = files.filter((file) => !isSafeCatalogPath(file.path)).map((file) => `'${file.path}' is not allowed (extensions ${CATALOG_FILE_EXTENSIONS.join("|")}, safe path segments)`);
1515
- if (files.length > 50) problems.push(`${files.length} files exceed the limit of ${50}`);
3674
+ if (files.length > 100) problems.push(`${files.length} files exceed the limit of ${100}`);
3675
+ for (const file of files) {
3676
+ const problem = sizeProblem(file.path, file.size);
3677
+ if (problem !== null) problems.push(problem);
3678
+ }
1516
3679
  const total = files.reduce((sum, file) => sum + file.size, 0);
1517
3680
  if (total > 1e7) problems.push(`${total} bytes exceed the limit of ${MAX_TOTAL_BYTES}`);
1518
3681
  return problems;
@@ -1522,6 +3685,14 @@ const readBuiltManifest = async (dir) => {
1522
3685
  if (!parsed.ok) throw new BuildError(parsed.message, dir);
1523
3686
  return parsed.manifest;
1524
3687
  };
3688
+ /** The icon declared in the manifest, read from the built directory; its checks ran in `buildExtension`. */
3689
+ const readIcon = async (dir, manifest, id) => {
3690
+ if (manifest.icon === null) return null;
3691
+ const bytes = await readFile(path.join(dir, manifest.icon));
3692
+ const problem = iconProblem(manifest.icon, bytes);
3693
+ if (problem !== null) throw new BuildError(problem, id);
3694
+ return iconDataUri(manifest.icon, bytes);
3695
+ };
1525
3696
  const stage = async (options, id, scratch) => {
1526
3697
  const srcDir = path.resolve(options.src, id);
1527
3698
  const built = await buildExtension({
@@ -1534,10 +3705,12 @@ const stage = async (options, id, scratch) => {
1534
3705
  const files = await hashTree(built.dir, tree.files);
1535
3706
  const problems = [...tree.symlinks.map((link) => `'${link}' is a symbolic link`), ...fileProblems(files)];
1536
3707
  if (problems.length > 0) throw new BuildError(problems.join("; "), id);
3708
+ const manifest = await readBuiltManifest(built.dir);
1537
3709
  return {
1538
3710
  id,
1539
3711
  dir: built.dir,
1540
- manifest: await readBuiltManifest(built.dir),
3712
+ manifest,
3713
+ icon: await readIcon(built.dir, manifest, id),
1541
3714
  files: files.map(({ path: file, size, sha256 }) => ({
1542
3715
  path: file,
1543
3716
  size,
@@ -1567,6 +3740,33 @@ const existingFiles = async (dir) => {
1567
3740
  }));
1568
3741
  };
1569
3742
  const immutabilityError = (staged) => new BuildError(`version ${staged.manifest.version} is already published with different content; published versions never change`, staged.id);
3743
+ /** `label`/`title` of the manifest contributions by point; points without contributions are left out. */
3744
+ const titlesOf = (manifest) => {
3745
+ const { contributes } = manifest;
3746
+ const byPoint = {
3747
+ themes: contributes.themes.map(({ id, label }) => ({
3748
+ id,
3749
+ title: label
3750
+ })),
3751
+ gradePolicies: contributes.gradePolicies.map(({ id, label }) => ({
3752
+ id,
3753
+ title: label
3754
+ })),
3755
+ settings: contributes.settings.map(({ id, label }) => ({
3756
+ id,
3757
+ title: label
3758
+ })),
3759
+ commands: contributes.commands.map(({ id, title }) => ({
3760
+ id,
3761
+ title
3762
+ })),
3763
+ panels: contributes.panels.map(({ id, title }) => ({
3764
+ id,
3765
+ title
3766
+ }))
3767
+ };
3768
+ return Object.fromEntries(TITLED_POINTS.filter((point) => byPoint[point].length > 0).map((point) => [point, Object.fromEntries(byPoint[point].map(({ id, title }) => [id, title]))]));
3769
+ };
1570
3770
  const newRecord = (staged, publishedAt) => ({
1571
3771
  version: staged.manifest.version,
1572
3772
  apiVersion: staged.manifest.apiVersion,
@@ -1574,7 +3774,9 @@ const newRecord = (staged, publishedAt) => ({
1574
3774
  permissions: [...staged.manifest.permissions],
1575
3775
  publishedAt,
1576
3776
  baseUrl: baseUrlOf(staged),
1577
- files: staged.files
3777
+ files: staged.files,
3778
+ ...staged.icon === null ? {} : { icon: staged.icon },
3779
+ ...staged.manifest.tags.length === 0 ? {} : { tags: [...staged.manifest.tags] }
1578
3780
  });
1579
3781
  const plan = async (staged, out, previous, publishedAt) => {
1580
3782
  const onDisk = await existingFiles(versionDir(out, staged));
@@ -1593,6 +3795,11 @@ const entryOf = (item, previous, sourceBase) => {
1593
3795
  const { staged, record } = item;
1594
3796
  const { manifest } = staged;
1595
3797
  const others = (previous?.versions ?? []).filter((version) => version.version !== record.version);
3798
+ const settings = manifest.contributes.settings.map((setting) => setting.id);
3799
+ const events = manifest.contributes.events.map((item) => item.event);
3800
+ const commands = manifest.contributes.commands.map(({ id }) => id);
3801
+ const panels = manifest.contributes.panels.map(({ id }) => id);
3802
+ const titles = titlesOf(manifest);
1596
3803
  return {
1597
3804
  id: staged.id,
1598
3805
  name: manifest.name ?? "",
@@ -1604,8 +3811,13 @@ const entryOf = (item, previous, sourceBase) => {
1604
3811
  exerciseTypes: manifest.contributes.exerciseTypes.map((type) => type.id),
1605
3812
  themes: manifest.contributes.themes.map((theme) => theme.id),
1606
3813
  markdownRenderers: manifest.contributes.markdownRenderers.map((renderer) => renderer.language),
1607
- gradePolicies: manifest.contributes.gradePolicies.map((policy) => policy.id)
3814
+ gradePolicies: manifest.contributes.gradePolicies.map((policy) => policy.id),
3815
+ ...settings.length > 0 ? { settings } : {},
3816
+ ...events.length > 0 ? { events } : {},
3817
+ ...commands.length > 0 ? { commands } : {},
3818
+ ...panels.length > 0 ? { panels } : {}
1608
3819
  },
3820
+ ...Object.keys(titles).length > 0 ? { titles } : {},
1609
3821
  versions: newestFirst([record, ...others])
1610
3822
  };
1611
3823
  };
@@ -1632,25 +3844,49 @@ const writeVersion = async (item, out) => {
1632
3844
  await cp(item.staged.dir, temporary, { recursive: true });
1633
3845
  await rename(temporary, target);
1634
3846
  };
1635
- const resultOf = (item) => ({
3847
+ const resultOf = (item, entry) => ({
1636
3848
  id: item.staged.id,
1637
3849
  version: item.staged.manifest.version,
1638
3850
  status: item.status,
1639
3851
  files: item.staged.files.length,
1640
- bytes: item.staged.files.reduce((sum, file) => sum + file.size, 0)
3852
+ bytes: item.staged.files.reduce((sum, file) => sum + file.size, 0),
3853
+ inLegacyIndex: isLegacyEntry(entry) && isLegacyVersion(item.record)
1641
3854
  });
1642
- const formatPublishResult = (result) => result.status === "unchanged" ? `unchanged ${result.id}@${result.version}` : `published ${result.id}@${result.version} (${result.files} files, ${result.bytes} bytes)`;
1643
- /** Собирает версии расширений и обновляет `index.json`; при ошибке на диск ничего не пишется. */
3855
+ const formatPublishResult = (result) => {
3856
+ const legacy = result.inLegacyIndex ? "" : ` — only in ${FULL_INDEX_FILE}: released apps cannot read it`;
3857
+ return result.status === "unchanged" ? `unchanged ${result.id}@${result.version}${legacy}` : `published ${result.id}@${result.version} (${result.files} files, ${result.bytes} bytes)${legacy}`;
3858
+ };
3859
+ /** The index to start from: the explicit file, else the full index of the site, else its `index.json`. */
3860
+ const previousIndexFile = async (out, explicit) => {
3861
+ if (explicit !== void 0) return path.resolve(explicit);
3862
+ const full = path.join(out, FULL_INDEX_FILE);
3863
+ return await stat(full).catch(() => null) === null ? path.join(out, INDEX_FILE) : full;
3864
+ };
3865
+ /**
3866
+ * Writes `index.v2.json` and `index.json` when either differs from the disk (ignoring
3867
+ * `generatedAt`); both get the same `generatedAt`, so the app can compare them with each other.
3868
+ */
3869
+ const writeIndexes = async (out, { full, legacy }) => {
3870
+ const fullFile = path.join(out, FULL_INDEX_FILE);
3871
+ const legacyFile = path.join(out, INDEX_FILE);
3872
+ if (hasSameContent(await loadIndexFile(fullFile), full) && hasSameContent(await loadIndexFile(legacyFile), legacy)) return false;
3873
+ await mkdir(out, { recursive: true });
3874
+ await writeIndexAtomically(fullFile, full);
3875
+ await writeIndexAtomically(legacyFile, legacy);
3876
+ return true;
3877
+ };
3878
+ /** Builds extension versions and updates `index.v2.json` and `index.json`; on error nothing is written to disk. */
1644
3879
  const buildCatalog = async (options) => {
1645
3880
  const out = path.resolve(options.out);
1646
3881
  const now = (options.now ?? (() => /* @__PURE__ */ new Date()))().toISOString();
1647
3882
  const publishedAt = options.publishedAt ?? now;
1648
3883
  const sourceBase = options.sourceBase ?? "https://github.com/dolphy-app/dolphy-extensions/tree/main/extensions";
1649
- const previous = await loadIndexFile(path.resolve(options.previousIndex ?? path.join(out, "index.json")));
3884
+ const previous = await loadIndexFile(await previousIndexFile(out, options.previousIndex));
1650
3885
  const revoked = await loadRevoked(options.revoked, previous);
1651
3886
  const scratch = await mkdtemp(path.join(tmpdir(), "dolphy-catalog-"));
1652
3887
  try {
1653
3888
  const plans = [];
3889
+ const results = [];
1654
3890
  const entries = new Map((previous?.extensions ?? []).map((entry) => [entry.id, entry]));
1655
3891
  for (const id of options.ids) {
1656
3892
  const staged = await stage(options, id, scratch);
@@ -1658,18 +3894,18 @@ const buildCatalog = async (options) => {
1658
3894
  const before = entries.get(id);
1659
3895
  const item = await plan(staged, out, before, publishedAt);
1660
3896
  plans.push(item);
1661
- entries.set(id, entryOf(item, before, sourceBase));
3897
+ const entry = entryOf(item, before, sourceBase);
3898
+ entries.set(id, entry);
3899
+ results.push(resultOf(item, entry));
1662
3900
  }
1663
- const index = assembleIndex({
3901
+ const indexes = assembleIndexes({
1664
3902
  generatedAt: now,
1665
3903
  extensions: [...entries.values()],
1666
3904
  revoked
1667
3905
  });
1668
3906
  for (const item of plans) if (item.shouldWrite) await writeVersion(item, out);
1669
- await mkdir(out, { recursive: true });
1670
- const indexFile = path.join(out, INDEX_FILE);
1671
- if (!hasSameContent(await loadIndexFile(indexFile), index)) await writeIndexAtomically(indexFile, index);
1672
- return plans.map(resultOf);
3907
+ await writeIndexes(out, indexes);
3908
+ return results;
1673
3909
  } finally {
1674
3910
  await rm(scratch, {
1675
3911
  recursive: true,
@@ -1678,26 +3914,21 @@ const buildCatalog = async (options) => {
1678
3914
  }
1679
3915
  };
1680
3916
  const formatReindexResult = (result) => `reindexed (${result.extensions} extensions, ${result.revoked} revoked)${result.changed ? "" : " — no changes"}`;
1681
- /** Перезаписывает `revoked` и `generatedAt` существующего индекса; записи расширений не меняются. */
3917
+ /** Rewrites `revoked` and `generatedAt` of both indexes; extension entries are unchanged. */
1682
3918
  const reindexCatalog = async (options) => {
1683
3919
  const out = path.resolve(options.out);
1684
- const source = path.resolve(options.previousIndex ?? path.join(out, "index.json"));
3920
+ const source = await previousIndexFile(out, options.previousIndex);
1685
3921
  const previous = await loadIndexFile(source);
1686
3922
  if (previous === null) throw new CatalogUsageError(`nothing to reindex: ${source} does not exist`, source);
1687
- const index = assembleIndex({
3923
+ const indexes = assembleIndexes({
1688
3924
  generatedAt: options.publishedAt ?? (options.now ?? (() => /* @__PURE__ */ new Date()))().toISOString(),
1689
3925
  extensions: previous.extensions,
1690
3926
  revoked: await loadRevoked(options.revoked, previous)
1691
3927
  });
1692
- const indexFile = path.join(out, INDEX_FILE);
1693
- const changed = !hasSameContent(await loadIndexFile(indexFile), index);
1694
- if (changed) {
1695
- await mkdir(out, { recursive: true });
1696
- await writeIndexAtomically(indexFile, index);
1697
- }
3928
+ const changed = await writeIndexes(out, indexes);
1698
3929
  return {
1699
- extensions: index.extensions.length,
1700
- revoked: index.revoked.length,
3930
+ extensions: indexes.full.extensions.length,
3931
+ revoked: indexes.full.revoked.length,
1701
3932
  changed
1702
3933
  };
1703
3934
  };
@@ -1714,30 +3945,30 @@ const CATALOG_SYNOPSIS = ` dolphy-ext catalog check <extensionsDir> [--ids
1714
3945
  [--previous-index <path>] [--revoked <path>]
1715
3946
  [--published-at <iso>]
1716
3947
  `;
1717
- const CATALOG_HELP = ` catalog check проверить исходники расширений каталога; строки
1718
- «error|warning <id> <RULE-ID> <поле>: <сообщение>»,
1719
- код 1 при наличии error
1720
- catalog build собрать версии в <siteDir>/extensions/<id>/<version>/ и
1721
- обновить <siteDir>/index.json (опубликованные версии
1722
- неизменны)
3948
+ const CATALOG_HELP = ` catalog check check the catalog's extension sources; lines
3949
+ «error|warning <id> <RULE-ID> <field>: <message>»,
3950
+ exit code 1 if any error
3951
+ catalog build build versions into <siteDir>/extensions/<id>/<version>/ and
3952
+ update <siteDir>/index.json (published versions are
3953
+ immutable)
1723
3954
  catalog build --reindex
1724
- только заменить revoked и generatedAt в существующем
1725
- <siteDir>/index.json (--src и --ids не нужны)
3955
+ only replace revoked and generatedAt in the existing
3956
+ <siteDir>/index.json (--src and --ids are not needed)
1726
3957
 
1727
- --ids a,b только эти расширения (check: по умолчанию все)
1728
- --published-index <p> index.json опубликованного каталога (нет файла —
1729
- ничего не опубликовано)
1730
- --max-app-version <v> minAppVersion не должен быть новее
1731
- --skip-github-check не проверять автора через api.github.com
1732
- (токен — переменная GITHUB_TOKEN)
1733
- --list-rules вывести правила check
1734
- --src <dir> каталог с проектами <dir>/<id>
1735
- --out <dir> корень сайта
1736
- --previous-index <p> исходный индекс (по умолчанию <out>/index.json)
1737
- --revoked <p> JSON-массив {id, versions, reason}
1738
- --source-base <url> основа поля source записи индекса
1739
- --published-at <iso> publishedAt новых версий (по умолчанию сейчас);
1740
- с --reindex — generatedAt
3958
+ --ids a,b only these extensions (check: all by default)
3959
+ --published-index <p> index.json of the published catalog (no file —
3960
+ nothing is published)
3961
+ --max-app-version <v> minAppVersion must not be newer
3962
+ --skip-github-check do not verify the author via api.github.com
3963
+ (token — the GITHUB_TOKEN variable)
3964
+ --list-rules print the check rules
3965
+ --src <dir> directory of projects <dir>/<id>
3966
+ --out <dir> site root
3967
+ --previous-index <p> source index (default <out>/index.json)
3968
+ --revoked <p> JSON array {id, versions, reason}
3969
+ --source-base <url> base for the index entry's source field
3970
+ --published-at <iso> publishedAt of new versions (default now);
3971
+ with --reindex — generatedAt
1741
3972
  `;
1742
3973
  const VALUE_FLAGS = {
1743
3974
  check: [
@@ -1769,9 +4000,9 @@ const readFlags = (mode, args) => {
1769
4000
  if (switches.includes(arg)) flags[arg] = true;
1770
4001
  else if (valued.includes(arg)) {
1771
4002
  const value = args[++i];
1772
- if (value === void 0) return `${arg} требует значение`;
4003
+ if (value === void 0) return `${arg} requires a value`;
1773
4004
  flags[arg] = value;
1774
- } else if (arg.startsWith("-")) return `неизвестный флаг: ${arg}`;
4005
+ } else if (arg.startsWith("-")) return `unknown flag: ${arg}`;
1775
4006
  else positional.push(arg);
1776
4007
  }
1777
4008
  return {
@@ -1793,10 +4024,10 @@ const parseCheck = (args) => {
1793
4024
  if (typeof read === "string") return { usageError: read };
1794
4025
  const { flags, positional } = read;
1795
4026
  const maxAppVersion = text(flags, "--max-app-version");
1796
- if (maxAppVersion !== void 0 && !isSemver(maxAppVersion)) return { usageError: "--max-app-version: нужен semver x.y.z" };
4027
+ if (maxAppVersion !== void 0 && !isSemver(maxAppVersion)) return { usageError: "--max-app-version: expected semver x.y.z" };
1797
4028
  const listRulesFlag = flags["--list-rules"] === true;
1798
- if (positional.length > 1) return { usageError: `лишние аргументы: ${positional.slice(1).join(" ")}` };
1799
- if (positional[0] === void 0 && !listRulesFlag) return { usageError: "catalog check: не указан каталог расширений" };
4029
+ if (positional.length > 1) return { usageError: `extra arguments: ${positional.slice(1).join(" ")}` };
4030
+ if (positional[0] === void 0 && !listRulesFlag) return { usageError: "catalog check: no extensions directory given" };
1800
4031
  return {
1801
4032
  command: "check",
1802
4033
  dir: positional[0] ?? null,
@@ -1814,9 +4045,9 @@ const REINDEX_FORBIDDEN = [
1814
4045
  ];
1815
4046
  const parseReindex = (flags) => {
1816
4047
  const forbidden = REINDEX_FORBIDDEN.find((name) => name in flags);
1817
- if (forbidden !== void 0) return { usageError: `catalog build --reindex: ${forbidden} не нужен` };
4048
+ if (forbidden !== void 0) return { usageError: `catalog build --reindex: ${forbidden} is not needed` };
1818
4049
  const out = text(flags, "--out");
1819
- if (out === void 0) return { usageError: "catalog build: нужен --out" };
4050
+ if (out === void 0) return { usageError: "catalog build: --out is required" };
1820
4051
  return {
1821
4052
  command: "reindex",
1822
4053
  out,
@@ -1829,14 +4060,14 @@ const parseBuild$1 = (args) => {
1829
4060
  const read = readFlags("build", args);
1830
4061
  if (typeof read === "string") return { usageError: read };
1831
4062
  const { flags, positional } = read;
1832
- if (positional.length > 0) return { usageError: `лишние аргументы: ${positional.join(" ")}` };
4063
+ if (positional.length > 0) return { usageError: `extra arguments: ${positional.join(" ")}` };
1833
4064
  if (flags["--reindex"] === true) return parseReindex(flags);
1834
4065
  const src = text(flags, "--src");
1835
4066
  const out = text(flags, "--out");
1836
4067
  const ids = idsOf(flags);
1837
- if (src === void 0) return { usageError: "catalog build: нужен --src" };
1838
- if (out === void 0) return { usageError: "catalog build: нужен --out" };
1839
- if (ids === void 0 || ids.length === 0) return { usageError: "catalog build: нужен --ids" };
4068
+ if (src === void 0) return { usageError: "catalog build: --src is required" };
4069
+ if (out === void 0) return { usageError: "catalog build: --out is required" };
4070
+ if (ids === void 0 || ids.length === 0) return { usageError: "catalog build: --ids is required" };
1840
4071
  return {
1841
4072
  command: "build",
1842
4073
  src,
@@ -1852,7 +4083,7 @@ const parseCatalogArgs = (args) => {
1852
4083
  const [sub, ...rest] = args;
1853
4084
  if (sub === "check") return parseCheck(rest);
1854
4085
  if (sub === "build") return parseBuild$1(rest);
1855
- return { usageError: sub === void 0 ? "catalog: не указана подкоманда (check|build)" : `catalog: неизвестная подкоманда: ${sub}` };
4086
+ return { usageError: sub === void 0 ? "catalog: no subcommand given (check|build)" : `catalog: unknown subcommand: ${sub}` };
1856
4087
  };
1857
4088
  const runCheck = async (parsed, io, deps) => {
1858
4089
  if (parsed.listRules) {
@@ -1896,7 +4127,7 @@ const runBuild$1 = async (parsed, io, deps) => {
1896
4127
  for (const result of results) io.stdout(`${formatPublishResult(result)}\n`);
1897
4128
  return 0;
1898
4129
  };
1899
- /** Код выхода: 0, 1 (проблемы), 2 (аргументы/окружение). */
4130
+ /** Exit code: 0, 1 (problems), 2 (arguments/environment). */
1900
4131
  const runCatalog = async (parsed, io, deps = {}) => {
1901
4132
  try {
1902
4133
  if (parsed.command === "check") return await runCheck(parsed, io, deps);
@@ -1915,16 +4146,20 @@ const EXIT_OK = 0;
1915
4146
  const EXIT_PROBLEMS = 1;
1916
4147
  const EXIT_USAGE = 2;
1917
4148
  const USAGE = `usage: dolphy-ext build [dir] [--out <dir>] [--watch]
4149
+ dolphy-ext types [dir]
1918
4150
  dolphy-ext validate <dir>
1919
4151
  ${CATALOG_SYNOPSIS}
1920
- build [dir] собрать расширение из проекта (по умолчанию — текущий каталог)
1921
- в <dir>/dist-ext/<id>
1922
- validate <dir> проверить каталог собранного расширения (extension.json,
1923
- схемы, main и renderer)
4152
+ build [dir] build the extension from a project (default: the current
4153
+ directory) into <dir>/dist-ext/<id>; also writes
4154
+ <dir>/.dolphy/ids.d.ts
4155
+ types [dir] write <dir>/.dolphy/ids.d.ts: the ids declared in
4156
+ extension.json as types for the SDK (no code is run)
4157
+ validate <dir> check the directory of a built extension (extension.json,
4158
+ schemas, main and renderer)
1924
4159
  ${CATALOG_HELP}
1925
- --out <dir> корень вывода (расширение кладётся в <dir>/<id>)
1926
- --watch пересобирать бандлы при изменении исходников
1927
- --help эта справка
4160
+ --out <dir> output root (the extension goes to <dir>/<id>)
4161
+ --watch rebuild the bundles when the sources change
4162
+ --help this help
1928
4163
  `;
1929
4164
  const parseBuild = (args) => {
1930
4165
  const positional = [];
@@ -1935,11 +4170,11 @@ const parseBuild = (args) => {
1935
4170
  if (arg === "--watch") watch = true;
1936
4171
  else if (arg === "--out") {
1937
4172
  out = args[++i];
1938
- if (out === void 0) return { usageError: "--out требует путь" };
1939
- } else if (arg.startsWith("-")) return { usageError: `неизвестный флаг: ${arg}` };
4173
+ if (out === void 0) return { usageError: "--out needs a path" };
4174
+ } else if (arg.startsWith("-")) return { usageError: `unknown flag: ${arg}` };
1940
4175
  else positional.push(arg);
1941
4176
  }
1942
- if (positional.length > 1) return { usageError: `лишние аргументы: ${positional.slice(1).join(" ")}` };
4177
+ if (positional.length > 1) return { usageError: `extra arguments: ${positional.slice(1).join(" ")}` };
1943
4178
  return {
1944
4179
  command: "build",
1945
4180
  dir: positional[0] ?? ".",
@@ -1947,12 +4182,21 @@ const parseBuild = (args) => {
1947
4182
  watch
1948
4183
  };
1949
4184
  };
4185
+ const parseTypes = (args) => {
4186
+ const flag = args.find((arg) => arg.startsWith("-"));
4187
+ if (flag !== void 0) return { usageError: `unknown flag: ${flag}` };
4188
+ if (args.length > 1) return { usageError: `extra arguments: ${args.slice(1).join(" ")}` };
4189
+ return {
4190
+ command: "types",
4191
+ dir: args[0] ?? "."
4192
+ };
4193
+ };
1950
4194
  const parseValidate = (args) => {
1951
4195
  const flag = args.find((arg) => arg.startsWith("-"));
1952
- if (flag !== void 0) return { usageError: `неизвестный флаг: ${flag}` };
4196
+ if (flag !== void 0) return { usageError: `unknown flag: ${flag}` };
1953
4197
  const [dir, ...extra] = args;
1954
- if (dir === void 0) return { usageError: "validate: не указан каталог" };
1955
- if (extra.length > 0) return { usageError: `лишние аргументы: ${extra.join(" ")}` };
4198
+ if (dir === void 0) return { usageError: "validate: no directory given" };
4199
+ if (extra.length > 0) return { usageError: `extra arguments: ${extra.join(" ")}` };
1956
4200
  return {
1957
4201
  command: "validate",
1958
4202
  dir
@@ -1962,12 +4206,13 @@ const parseArgs = (argv) => {
1962
4206
  if (argv.includes("--help") || argv.includes("-h")) return { help: true };
1963
4207
  const [command, ...rest] = argv;
1964
4208
  if (command === "build") return parseBuild(rest);
4209
+ if (command === "types") return parseTypes(rest);
1965
4210
  if (command === "validate") return parseValidate(rest);
1966
4211
  if (command === "catalog") return {
1967
4212
  command: "catalog",
1968
4213
  args: rest
1969
4214
  };
1970
- return { usageError: command === void 0 ? "не указана команда" : `неизвестная команда: ${command}` };
4215
+ return { usageError: command === void 0 ? "no command given" : `unknown command: ${command}` };
1971
4216
  };
1972
4217
  const waitForSignal = () => new Promise((resolve) => {
1973
4218
  process.once("SIGINT", resolve);
@@ -2016,7 +4261,18 @@ const runValidate = async (dir, io) => {
2016
4261
  for (const problem of problems) io.stderr(`error ${root}: ${problem}\n`);
2017
4262
  return 1;
2018
4263
  };
2019
- /** `dolphy-ext build|validate|catalog`; `argv` без `node` и имени скрипта. */
4264
+ const runTypes = async (dir, io) => {
4265
+ try {
4266
+ const root = path.resolve(dir);
4267
+ const { file, changed } = await generateTypes({ root });
4268
+ const shown = path.relative(root, file);
4269
+ io.stdout(changed ? `wrote ${shown}\n` : `${shown} is up to date\n`);
4270
+ return 0;
4271
+ } catch (error) {
4272
+ return reportBuildError(io, error);
4273
+ }
4274
+ };
4275
+ /** `dolphy-ext build|types|validate|catalog`; `argv` is without `node` and the script name. */
2020
4276
  const runCli = async (argv, io, deps = {}) => {
2021
4277
  const parsed = parseArgs(argv);
2022
4278
  if ("help" in parsed) {
@@ -2036,6 +4292,7 @@ const runCli = async (argv, io, deps = {}) => {
2036
4292
  return runCatalog(catalog, io, deps);
2037
4293
  }
2038
4294
  if (parsed.command === "validate") return runValidate(parsed.dir, io);
4295
+ if (parsed.command === "types") return runTypes(parsed.dir, io);
2039
4296
  return runBuild(parsed, io, deps);
2040
4297
  };
2041
4298