@dolphy-app/extension-tools 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +8 -5
  2. package/dist/cli/main.js +2153 -1278
  3. package/package.json +4 -2
package/dist/cli/main.js CHANGED
@@ -1,27 +1,112 @@
1
1
  #!/usr/bin/env node
2
2
  import { builtinModules } from "node:module";
3
- import path from "node:path";
4
- import { realpathSync, statSync, watch } from "node:fs";
3
+ import path, { extname } from "node:path";
4
+ import { existsSync, readFileSync, readdirSync, realpathSync, statSync, watch } from "node:fs";
5
5
  import { cp, lstat, mkdir, mkdtemp, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
6
- import { Ajv2020 } from "ajv/dist/2020.js";
6
+ import "ajv/dist/2020.js";
7
7
  import { z } from "zod";
8
8
  import { createHash } from "node:crypto";
9
- import { build, createLogger, parseAst } from "vite";
10
- import { tmpdir } from "node:os";
9
+ import { build, createLogger, mergeConfig, parseAst } from "vite";
10
+ import MagicString from "magic-string";
11
+ import vue from "@vitejs/plugin-vue";
12
+ import { homedir, tmpdir } from "node:os";
13
+ import { spawn } from "node:child_process";
11
14
 
15
+ //#region packages/extension-tools/src/errors.ts
16
+ /** Extension build/check error; `subject` is the extension id or directory. */
17
+ var BuildError = class extends Error {
18
+ subject;
19
+ constructor(message, subject) {
20
+ super(message);
21
+ this.name = "BuildError";
22
+ this.subject = subject;
23
+ }
24
+ };
25
+ /** Invalid command input (nonexistent directory): exit code 2. */
26
+ var CatalogUsageError = class extends BuildError {};
27
+
28
+ //#endregion
29
+ //#region packages/extension-api/src/when.ts
30
+ /**
31
+ * Visibility conditions (`when`) of commands, panels and widgets. A condition
32
+ * is a small boolean expression over a closed set of typed keys of the app
33
+ * window (`WHEN_KEYS`). The functions are pure: the manifest check, the app
34
+ * window and `dolphy-ext validate` share them, and so can any dispatcher that
35
+ * can supply a `WhenContext`.
36
+ *
37
+ * Grammar (`!` binds tightest, then `&&`, then `||`):
38
+ *
39
+ * ```
40
+ * expr := term ('||' term)*
41
+ * term := factor ('&&' factor)*
42
+ * factor := '!' factor | '(' expr ')' | 'true' | 'false' | boolean-key | comparison
43
+ * comparison := key ('==' | '!=') literal | key 'in' '(' literal (',' literal)* ')'
44
+ * literal := 'text' | true | false
45
+ * ```
46
+ *
47
+ * `!` stands before a boolean key, a group or another `!`; negate a
48
+ * comparison with a group (`!(route == 'courses')`) or use `!=`.
49
+ */
50
+ /** Names of the screens `route` can have: the route names of the app window. */
51
+ const WHEN_ROUTES = [
52
+ "daily-plan",
53
+ "courses",
54
+ "extension-panel",
55
+ "placement",
56
+ "session",
57
+ "settings",
58
+ "settings-learning",
59
+ "settings-library",
60
+ "settings-appearance",
61
+ "settings-shortcuts",
62
+ "settings-extensions",
63
+ "settings-extension-details",
64
+ "settings-about"
65
+ ];
66
+ /** Interface languages a `locale` condition can name. */
67
+ const WHEN_LOCALES = ["ru", "en"];
68
+ /**
69
+ * Closed set of keys with their types.
70
+ *
71
+ * - `route`: the screen shown (a value of `WHEN_ROUTES`).
72
+ * - `course.active`: a course is in focus (the course switcher is not on "all courses").
73
+ * - `session.active`: the learning session screen is open.
74
+ * - `locale`: the interface language after resolving the "system" mode.
75
+ * - `theme.dark`: the current theme is dark.
76
+ */
77
+ const WHEN_KEYS = Object.freeze({
78
+ route: {
79
+ type: "string",
80
+ values: WHEN_ROUTES
81
+ },
82
+ "course.active": { type: "boolean" },
83
+ "session.active": { type: "boolean" },
84
+ locale: {
85
+ type: "string",
86
+ values: WHEN_LOCALES
87
+ },
88
+ "theme.dark": { type: "boolean" }
89
+ });
90
+
91
+ //#endregion
92
+ //#region packages/extension-api/src/hooks.ts
93
+ /** Limits on hooks; the host and the engine check the same numbers. */
94
+ const EXTENSION_HOOK_LIMITS = Object.freeze({
95
+ /** Budget of one handler call, ms. */
96
+ timeoutMs: 3e4,
97
+ /** Hooks registered by one extension. */
98
+ hooks: 8,
99
+ /** Exercises in a `practice.batch` response. */
100
+ maxExercises: 500
101
+ });
102
+ /** Hooks an extension can register with `server.before`. */
103
+ const EXTENSION_HOOK_NAMES = ["session.start", "practice.batch"];
104
+
105
+ //#endregion
12
106
  //#region packages/extension-api/src/index.ts
13
107
  const EXTENSION_ID_PATTERN = /^[a-z][a-z0-9-]*(\.[a-z][a-z0-9-]*)*$/;
14
108
  /** GitHub login of the extension author (`author` in the manifest and catalog). */
15
109
  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
110
  /** Platforms the extension can run on (`process.platform`). */
26
111
  const EXTENSION_PLATFORMS = [
27
112
  "darwin",
@@ -38,64 +123,97 @@ const EXTENSION_TAGS = [
38
123
  "productivity",
39
124
  "developer"
40
125
  ];
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. */
126
+ /**
127
+ * Closed list of icon names an extension picks from (`icon` of a command or panel); the
128
+ * app draws its own glyph for each name, so nothing from the extension is rendered as an image.
129
+ */
130
+ const EXTENSION_ICONS = [
131
+ "puzzle",
132
+ "book",
133
+ "brain",
134
+ "calendar",
135
+ "chart",
136
+ "check",
137
+ "clock",
138
+ "cog",
139
+ "fire",
140
+ "flag",
141
+ "heart",
142
+ "help",
143
+ "home",
144
+ "idea",
145
+ "list",
146
+ "message",
147
+ "pencil",
148
+ "play",
149
+ "star",
150
+ "target",
151
+ "trophy",
152
+ "bell",
153
+ "bookmark",
154
+ "tag"
155
+ ];
156
+ /** Limits on injections; the app enforces them. */
157
+ const INJECTION_LIMITS = Object.freeze({
158
+ /** Characters in `InjectionRegistration.target`. */
159
+ selectorLength: 200 });
160
+ /** `at` of a `daily` schedule: `HH:MM`, 24-hour clock, local time. */
161
+ const SCHEDULE_AT_PATTERN = /^(?:[01]\d|2[0-3]):[0-5]\d$/;
162
+ /** Limits on schedules; the host, the scheduler, and the runtime enforce them. */
163
+ const EXTENSION_SCHEDULE_LIMITS = Object.freeze({
164
+ /** Schedules per extension. */
165
+ schedules: 4,
166
+ /** Handler budget, ms. */
167
+ handlerMs: 1e4,
168
+ /** A firing found later than this after its moment (the app was closed or asleep) is skipped, ms. */
169
+ lateMs: 12e4,
170
+ /** How often the app looks for due firings, ms. */
171
+ tickMs: 3e4
172
+ });
173
+ /** Limits of the settings types (`text`, `color`, `list`, `enum`, `group`, `order`); they match those checked by the host and the engine. */
174
+ const SETTING_LIMITS = Object.freeze({
175
+ /** `maxLength` of `string` and `text`. */
176
+ stringLength: 1e4,
177
+ /** `maxItems` of `list`. */
178
+ listItems: 50,
179
+ /** `itemMaxLength` of `list`. */
180
+ listItemLength: 200,
181
+ /** Length of a label, in UTF-16 code units; also of an option's label. */
182
+ labelLength: 60,
183
+ /** Length of a help text. */
184
+ descriptionLength: 500,
185
+ groupLength: 60,
186
+ orderMax: 1e3,
187
+ /** Options of one `enum`. */
188
+ options: 64,
189
+ /** Length of an option's value. */
190
+ optionValueLength: 100
191
+ });
192
+ /** `#rrggbb` (any case in a definition; the stored value is lower-case). */
193
+ const COLOR_SETTING_PATTERN = /^#[0-9a-fA-F]{6}$/;
194
+ /** Learning events an extension can subscribe to. */
43
195
  const LEARNING_EVENT_NAMES = [
44
196
  "session.started",
45
197
  "session.finished",
46
198
  "attempt.closed"
47
199
  ];
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";
200
+ const KEYBINDING_STROKE = `(?:(?:Mod|Ctrl|Control|Alt|Option|Shift|Cmd|Command|Meta|Win|Super)\\+){0,3}(?:[A-Z0-9]|F(?:[1-9]|1[0-9]|2[0-4])|Enter|Return|Space|Tab|Escape|Esc|Backspace|Delete|Insert|Arrow(?:Up|Down|Left|Right)|Home|End|Page(?:Up|Down)|Plus|\\[[A-Za-z][A-Za-z0-9]*\\]|[\`\\-=\\[\\]\\\\;',./+])`;
90
201
  /**
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`).
202
+ * Key notation: one stroke or two strokes separated by a space
203
+ * (`Mod+K Mod+S`). A stroke is up to three modifiers (`Mod`, `Ctrl`, `Alt`,
204
+ * `Shift`, `Cmd`, `Meta`, `Win`, `Super`, `Option`, ...) and a key joined with
205
+ * `+`: a letter or digit, `F1`–`F24`, a punctuation mark, a physical key
206
+ * (`[KeyK]`) or a name (`Enter`, `Space`, `Tab`, `Escape`, `Insert`, arrows,
207
+ * `Home`, `End`, `PageUp`, `PageDown`). A superset for the JSON Schema; the
208
+ * host validates every string authoritatively with `@dolphy-app/keybindings`.
95
209
  */
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. */
210
+ const KEYBINDING_PATTERN = new RegExp(`^${KEYBINDING_STROKE}(?: ${KEYBINDING_STROKE})?$`);
211
+ /** Limits on commands and panels (R1, R3); they match those checked by the host and the engine. */
98
212
  const EXTENSION_COMMAND_LIMITS = Object.freeze({
213
+ /** Keybinding entries (`keybindings`) per command. */
214
+ keybindingsPerCommand: 4,
215
+ /** Length of a `when` condition (of a command, panel or `keybindings[]` entry). */
216
+ whenLength: 200,
99
217
  /** Commands per extension. */
100
218
  commands: 64,
101
219
  /** Panels per extension. */
@@ -112,10 +230,52 @@ const EXTENSION_COMMAND_LIMITS = Object.freeze({
112
230
  /** Handler budget, ms. */
113
231
  handlerMs: 1e4
114
232
  });
233
+ /** A file extension an importer accepts: a dot and 1–16 lower-case letters or digits. */
234
+ const TRANSFER_ACCEPT_PATTERN = /^\.[a-z0-9]{1,16}$/;
235
+ /** Limits on importers and exporters; the host and the engine check the same numbers. */
236
+ const EXTENSION_TRANSFER_LIMITS = Object.freeze({
237
+ /** Importers per extension. */
238
+ importers: 8,
239
+ /** Exporters per extension. */
240
+ exporters: 8,
241
+ /** Entries in `accept` of one importer. */
242
+ acceptExtensions: 8,
243
+ /** Handler budget, ms (import and export). */
244
+ handlerMs: 3e4,
245
+ /** Size of the file the user picks for an importer, bytes. */
246
+ inputBytes: 20971520,
247
+ /** Files in the directory an importer returns. */
248
+ files: 5e3,
249
+ /** One file of the returned directory, UTF-8 bytes. */
250
+ fileBytes: 2097152,
251
+ /** All files of the returned directory (and of a course snapshot), UTF-8 bytes. */
252
+ totalBytes: 20971520,
253
+ /** Size of the file an exporter returns, bytes. */
254
+ outputBytes: 20971520,
255
+ /** Length of the file name an exporter returns. */
256
+ filenameChars: 120,
257
+ /** One path of the returned directory, UTF-8 bytes. */
258
+ pathBytes: 1024
259
+ });
260
+ /**
261
+ * Name of an RPC contract: lower-case dot-separated segments, such as
262
+ * `greeting.say-hello`, the first segment without a hyphen, at least two
263
+ * segments, at most `EXTENSION_RPC_LIMITS.nameLength` characters.
264
+ */
265
+ const RPC_NAME_PATTERN = /^[a-z][a-z0-9]*(\.[a-z][a-z0-9-]*)+$/;
266
+ /** Limits on RPC between the client and the server part of an extension; the host and the engine check the same numbers. */
267
+ const EXTENSION_RPC_LIMITS = Object.freeze({
268
+ /** Characters in the name of a contract. */
269
+ nameLength: 120,
270
+ /** Handlers (`server.handle`) per extension. */
271
+ rpcs: 64,
272
+ /** `JSON.stringify(input).length` at the engine boundary. */
273
+ inputChars: 2e5,
274
+ /** Handler budget, ms. */
275
+ handlerMs: 1e4
276
+ });
115
277
  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`;
278
+ const DEFAULT_CLIENT = "./client.mjs";
119
279
  /** Extension storage limits (R2); they match the engine's limits, which enforces them. */
120
280
  const EXTENSION_STORAGE_LIMITS = Object.freeze({
121
281
  /** Key length in UTF-16 code units. */
@@ -127,26 +287,81 @@ const EXTENSION_STORAGE_LIMITS = Object.freeze({
127
287
  /** Sum of JSON texts of all values in UTF-8 bytes. */
128
288
  totalBytes: 1048576
129
289
  });
290
+ /** Secret limits; the engine enforces them (`StorageQuotaError`, kinds `key-length`, `value-size`, `key-count`). */
291
+ const EXTENSION_SECRET_LIMITS = Object.freeze({
292
+ /** Key length in UTF-16 code units. */
293
+ keyLength: 128,
294
+ /** Value size in UTF-8 bytes. */
295
+ valueBytes: 4096,
296
+ /** Number of keys. */
297
+ keys: 32
298
+ });
299
+ /** Limits of `server.stats`; the engine enforces them. */
300
+ const EXTENSION_STATS_LIMITS = Object.freeze({
301
+ /** Most dates in one `daily` range (both ends included). */
302
+ dailyDays: 366 });
303
+ /** Limits of `server.notifications`; the engine enforces them. */
304
+ const EXTENSION_NOTIFICATION_LIMITS = Object.freeze({
305
+ /** Title length in characters (code points). */
306
+ titleLength: 80,
307
+ /** Body length in characters (code points). */
308
+ bodyLength: 300,
309
+ /** Notifications per rolling minute and extension. */
310
+ perMinute: 3,
311
+ /** Notifications per rolling hour and extension. */
312
+ perHour: 30
313
+ });
314
+ /** Registration of an extension without a server part, or before it has registered anything. */
315
+ const EMPTY_SERVER_REGISTRATION = Object.freeze({
316
+ exerciseTypes: Object.freeze([]),
317
+ gradePolicies: Object.freeze([]),
318
+ settings: Object.freeze([]),
319
+ events: Object.freeze([]),
320
+ commands: Object.freeze([]),
321
+ schedules: Object.freeze([]),
322
+ importers: Object.freeze([]),
323
+ exporters: Object.freeze([]),
324
+ rpcs: Object.freeze([]),
325
+ hooks: Object.freeze([])
326
+ });
130
327
 
131
328
  //#endregion
132
329
  //#region packages/extension-host/src/protocol.ts
133
330
  const eventParams = z.strictObject({
134
331
  extensionId: z.string(),
135
332
  name: z.enum(LEARNING_EVENT_NAMES),
136
- payload: z.record(z.string(), z.unknown()),
137
- isolated: z.boolean()
333
+ payload: z.record(z.string(), z.unknown())
334
+ });
335
+ const scheduleParams = z.strictObject({
336
+ extensionId: z.string(),
337
+ scheduleId: z.string()
138
338
  });
139
339
  const commandParams = z.strictObject({
140
340
  extensionId: z.string(),
141
341
  commandId: z.string(),
142
- args: z.unknown().optional(),
143
- isolated: z.boolean()
342
+ args: z.unknown().optional()
144
343
  });
344
+ const rpcParams = z.strictObject({
345
+ extensionId: z.string(),
346
+ name: z.string(),
347
+ input: z.unknown().optional()
348
+ });
349
+ const bytesField = z.custom((value) => value instanceof Uint8Array, "must be a Uint8Array");
350
+ const importParams = {
351
+ extensionId: z.string(),
352
+ importerId: z.string(),
353
+ name: z.string()
354
+ };
355
+ const exportInput = z.discriminatedUnion("scope", [z.strictObject({
356
+ scope: z.literal("course"),
357
+ courseId: z.string(),
358
+ title: z.string(),
359
+ files: z.record(z.string(), z.string())
360
+ }), z.strictObject({ scope: z.literal("progress") })]);
145
361
  const typed = {
146
362
  type: z.string(),
147
363
  exerciseId: z.string(),
148
- spec: z.unknown(),
149
- isolated: z.boolean()
364
+ spec: z.unknown()
150
365
  };
151
366
  const extRequestSchema = z.discriminatedUnion("method", [
152
367
  z.strictObject({
@@ -182,8 +397,7 @@ const extRequestSchema = z.discriminatedUnion("method", [
182
397
  ]),
183
398
  reason: z.string().optional()
184
399
  })),
185
- gaveUp: z.boolean(),
186
- isolated: z.boolean()
400
+ gaveUp: z.boolean()
187
401
  })
188
402
  }),
189
403
  z.strictObject({
@@ -191,16 +405,56 @@ const extRequestSchema = z.discriminatedUnion("method", [
191
405
  method: z.literal("deliverEvent"),
192
406
  params: eventParams
193
407
  }),
408
+ z.strictObject({
409
+ id: z.string(),
410
+ method: z.literal("fireSchedule"),
411
+ params: scheduleParams
412
+ }),
194
413
  z.strictObject({
195
414
  id: z.string(),
196
415
  method: z.literal("invokeCommand"),
197
416
  params: commandParams
417
+ }),
418
+ z.strictObject({
419
+ id: z.string(),
420
+ method: z.literal("invokeRpc"),
421
+ params: rpcParams
422
+ }),
423
+ z.strictObject({
424
+ id: z.string(),
425
+ method: z.literal("runImporter"),
426
+ params: z.union([z.strictObject({
427
+ ...importParams,
428
+ text: z.string()
429
+ }), z.strictObject({
430
+ ...importParams,
431
+ bytes: bytesField
432
+ })])
433
+ }),
434
+ z.strictObject({
435
+ id: z.string(),
436
+ method: z.literal("runExporter"),
437
+ params: z.strictObject({
438
+ extensionId: z.string(),
439
+ exporterId: z.string(),
440
+ input: exportInput
441
+ })
442
+ }),
443
+ z.strictObject({
444
+ id: z.string(),
445
+ method: z.literal("runHook"),
446
+ params: z.strictObject({
447
+ extensionId: z.string(),
448
+ name: z.enum(EXTENSION_HOOK_NAMES),
449
+ request: z.unknown()
450
+ })
198
451
  })
199
452
  ]);
200
453
  const settingValue = z.union([
201
454
  z.boolean(),
202
455
  z.string(),
203
- z.number()
456
+ z.number(),
457
+ z.array(z.string())
204
458
  ]);
205
459
  const settingChangedSchema = z.strictObject({
206
460
  method: z.literal("settingChanged"),
@@ -241,10 +495,82 @@ const hostRequestSchema = z.discriminatedUnion("method", [
241
495
  method: z.literal("storage.keys"),
242
496
  params: hostOwner
243
497
  }),
498
+ z.strictObject({
499
+ id: z.string(),
500
+ method: z.literal("secrets.get"),
501
+ params: hostKey
502
+ }),
503
+ z.strictObject({
504
+ id: z.string(),
505
+ method: z.literal("secrets.set"),
506
+ params: z.strictObject({
507
+ extensionId: z.string(),
508
+ key: z.string(),
509
+ value: z.unknown()
510
+ })
511
+ }),
512
+ z.strictObject({
513
+ id: z.string(),
514
+ method: z.literal("secrets.delete"),
515
+ params: hostKey
516
+ }),
244
517
  z.strictObject({
245
518
  id: z.string(),
246
519
  method: z.literal("settings.all"),
247
520
  params: hostOwner
521
+ }),
522
+ z.strictObject({
523
+ id: z.string(),
524
+ method: z.literal("stats.streak"),
525
+ params: z.strictObject({
526
+ extensionId: z.string(),
527
+ courseId: z.string().optional()
528
+ })
529
+ }),
530
+ z.strictObject({
531
+ id: z.string(),
532
+ method: z.literal("stats.daily"),
533
+ params: z.strictObject({
534
+ extensionId: z.string(),
535
+ from: z.string(),
536
+ to: z.string(),
537
+ courseId: z.string().optional()
538
+ })
539
+ }),
540
+ z.strictObject({
541
+ id: z.string(),
542
+ method: z.literal("notifications.show"),
543
+ params: z.strictObject({
544
+ extensionId: z.string(),
545
+ title: z.string(),
546
+ body: z.string()
547
+ })
548
+ }),
549
+ z.strictObject({
550
+ id: z.string(),
551
+ method: z.literal("health.report"),
552
+ params: z.discriminatedUnion("kind", [
553
+ z.strictObject({
554
+ extensionId: z.string(),
555
+ kind: z.literal("activated"),
556
+ durationMs: z.number().finite().nonnegative()
557
+ }),
558
+ z.strictObject({
559
+ extensionId: z.string(),
560
+ kind: z.literal("failed"),
561
+ reason: z.string(),
562
+ message: z.string()
563
+ }),
564
+ z.strictObject({
565
+ extensionId: z.string(),
566
+ kind: z.literal("suppressed"),
567
+ until: z.number().finite().nonnegative()
568
+ }),
569
+ z.strictObject({
570
+ extensionId: z.string(),
571
+ kind: z.literal("reset")
572
+ })
573
+ ])
248
574
  })
249
575
  ]);
250
576
  const hostResponseSchema = z.union([z.strictObject({
@@ -260,30 +586,44 @@ const hostResponseSchema = z.union([z.strictObject({
260
586
  details: z.record(z.string(), z.unknown()).optional()
261
587
  })
262
588
  })]);
263
- const isResolvedExtension = (value) => {
589
+ const isExtensionCandidate = (value) => {
264
590
  if (typeof value !== "object" || value === null) return false;
265
591
  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);
592
+ return typeof item.id === "string" && typeof item.version === "string" && typeof item.dir === "string" && typeof item.revision === "string" && (item.origin === "bundled" || item.origin === "user" || item.origin === "dev") && (item.mainPath === null || typeof item.mainPath === "string") && (item.clientPath === null || typeof item.clientPath === "string") && Array.isArray(item.dependencies) && Array.isArray(item.warnings);
267
593
  };
268
594
  /** Набор приходит от движка того же приложения, поэтому проверяется форма, а не каждое поле. */
269
595
  const replaceExtensionsSchema = z.strictObject({
270
596
  id: z.string(),
271
597
  method: z.literal("replaceExtensions"),
272
- params: z.strictObject({ extensions: z.array(z.custom(isResolvedExtension)) })
598
+ params: z.strictObject({ extensions: z.array(z.custom(isExtensionCandidate)) })
273
599
  });
600
+ /** Кадры туннеля `engine-rpc`: форму кадра проверяет получатель протокола (диспетчер или клиент движка). */
601
+ const engineTunnelSchema = z.discriminatedUnion("method", [z.strictObject({
602
+ method: z.literal("engineFrame"),
603
+ params: z.strictObject({
604
+ extensionId: z.string(),
605
+ frame: z.unknown()
606
+ })
607
+ }), z.strictObject({
608
+ method: z.literal("engineDetach"),
609
+ params: z.strictObject({ extensionId: z.string() })
610
+ })]);
274
611
  /** Всё, что хост расширений принимает по каналу. */
275
612
  const extMessageSchema = z.union([
276
613
  extRequestSchema,
277
614
  replaceExtensionsSchema,
278
615
  settingChangedSchema,
616
+ engineTunnelSchema,
279
617
  hostResponseSchema
280
618
  ]);
281
- /** Всё, что ограниченный процесс принимает от хоста (набор расширений ему не шлют). */
282
- const childInboundSchema = z.union([
283
- extRequestSchema,
284
- settingChangedSchema,
285
- hostResponseSchema
286
- ]);
619
+ /** Ответ на `replaceExtensions`: итог по каждому расширению; форму регистрации проверил регистратор хоста. */
620
+ const replaceExtensionsResultSchema = z.strictObject({ registrations: z.record(z.string(), z.union([z.strictObject({
621
+ ok: z.literal(true),
622
+ registration: z.custom((value) => typeof value === "object" && value !== null)
623
+ }), z.strictObject({
624
+ ok: z.literal(false),
625
+ error: z.string()
626
+ })])) });
287
627
  /** Результат правила оценки: целое 1–5 или `null`. */
288
628
  const gradeValueSchema = z.union([z.literal([
289
629
  1,
@@ -293,24 +633,24 @@ const gradeValueSchema = z.union([z.literal([
293
633
  5
294
634
  ]), z.null()]);
295
635
  const reason = z.string().min(1).max(100);
296
- const text$1 = z.string().max(4e3);
636
+ const text$2 = z.string().max(4e3);
297
637
  const gradeResultSchema = z.discriminatedUnion("outcome", [
298
638
  z.strictObject({
299
639
  outcome: z.literal("passed"),
300
- feedback: text$1.optional(),
640
+ feedback: text$2.optional(),
301
641
  data: z.unknown().optional()
302
642
  }),
303
643
  z.strictObject({
304
644
  outcome: z.literal("failed"),
305
645
  reason,
306
- feedback: text$1.optional(),
307
- detail: text$1.optional(),
646
+ feedback: text$2.optional(),
647
+ detail: text$2.optional(),
308
648
  data: z.unknown().optional()
309
649
  }),
310
650
  z.strictObject({
311
651
  outcome: z.literal("error"),
312
652
  reason,
313
- feedback: text$1.optional(),
653
+ feedback: text$2.optional(),
314
654
  data: z.unknown().optional()
315
655
  })
316
656
  ]);
@@ -347,6 +687,110 @@ const commandOutcomeSchema = z.discriminatedUnion("kind", [
347
687
  })
348
688
  ]);
349
689
 
690
+ //#endregion
691
+ //#region packages/extension-api/src/hook-schemas.ts
692
+ /**
693
+ * Request and response schemas of the hooks in `hooks.ts`. They live apart
694
+ * from the names and types so that code which only registers or types a hook
695
+ * (every browser bundle imports this package) does not pull `zod` in; the host,
696
+ * the engine and the test server import them from
697
+ * `@dolphy-app/extension-api/hook-schemas`.
698
+ */
699
+ const itemReason = z.enum([
700
+ "review",
701
+ "new",
702
+ "remediation"
703
+ ]);
704
+ /** `null` fields: the exercise has no attempts yet, so there is no memory state. */
705
+ const exerciseMemory = z.object({
706
+ /** Probability of recall right now, 0..1. */
707
+ retrievability: z.number().nullable(),
708
+ /** Unix epoch milliseconds of the newest attempt. */
709
+ lastAttemptAt: z.number().nullable(),
710
+ /** Attempts that count (cancelled ones excluded). */
711
+ attempts: z.number(),
712
+ /** FSRS stability in days. */
713
+ stability: z.number().nullable(),
714
+ /** FSRS difficulty. */
715
+ difficulty: z.number().nullable()
716
+ });
717
+ /**
718
+ * Request and response schema of every hook. The handlers of the extensions
719
+ * that registered a hook run one after another in ascending order of the
720
+ * extension id; each gets the response of the previous one (for a hook
721
+ * without a response, the same request). A thrown error, an invalid
722
+ * response or a handler exceeding `EXTENSION_HOOK_LIMITS.timeoutMs` cancels
723
+ * the operation and the error message reaches the user.
724
+ */
725
+ const EXTENSION_HOOKS = Object.freeze({
726
+ /**
727
+ * A learning session is about to start. The handler can only cancel the
728
+ * start by throwing.
729
+ */
730
+ "session.start": {
731
+ request: z.object({
732
+ /** Unix epoch milliseconds. */
733
+ now: z.number() }),
734
+ response: z.void()
735
+ },
736
+ /**
737
+ * A batch of exercises or a day plan is about to be handed to the learner.
738
+ * `exerciseIds` and `reasons` are parallel arrays; the response can
739
+ * reorder, remove and add exercises (every id must exist in the library;
740
+ * `exerciseIds` and `reasons` keep the same length, at most
741
+ * `EXTENSION_HOOK_LIMITS.maxExercises`).
742
+ */
743
+ "practice.batch": {
744
+ request: z.object({
745
+ /** The open session; `null` while none has started. */
746
+ sessionId: z.string().nullable(),
747
+ /** `batch` — `practice.getBatch`, `plan` — `plan.getDay`. */
748
+ source: z.enum(["batch", "plan"]),
749
+ exerciseIds: z.array(z.string()),
750
+ reasons: z.array(itemReason),
751
+ /**
752
+ * Parallel to `exerciseIds`: what the engine remembers of each
753
+ * exercise. Read-only; the FSRS state is not changed by the hook.
754
+ */
755
+ memory: z.array(exerciseMemory)
756
+ }),
757
+ response: z.object({
758
+ exerciseIds: z.array(z.string()).max(EXTENSION_HOOK_LIMITS.maxExercises),
759
+ reasons: z.array(itemReason).max(EXTENSION_HOOK_LIMITS.maxExercises)
760
+ }).refine(({ exerciseIds, reasons }) => exerciseIds.length === reasons.length, { message: "exerciseIds and reasons must have the same length" })
761
+ }
762
+ });
763
+
764
+ //#endregion
765
+ //#region packages/extension-host/src/diagnostics.ts
766
+ const text$1 = (data, key) => {
767
+ const value = data[key];
768
+ return Array.isArray(value) ? value.join(", ") : String(value ?? "");
769
+ };
770
+ const rangeSuffix = (data) => data.range === void 0 ? "" : ` ${text$1(data, "range")}`;
771
+ const FORMATTERS = {
772
+ "manifest-unreadable": (data) => `extension.json is unreadable: ${text$1(data, "reason")}`,
773
+ "manifest-invalid": (data) => Array.isArray(data.issues) ? data.issues.join("; ") : text$1(data, "issues"),
774
+ "id-mismatch": (data) => `directory name '${text$1(data, "expected")}' does not match manifest id '${text$1(data, "actual")}'`,
775
+ "requires-app": (data) => `requires app >= ${text$1(data, "minAppVersion")}`,
776
+ "unavailable-platform": (data) => `not available on ${text$1(data, "platform")}`,
777
+ "claim-clash": (data) => `${text$1(data, "kind")} '${text$1(data, "name")}' is already provided by '${text$1(data, "by")}'`,
778
+ "load-failed": (data) => text$1(data, "reason"),
779
+ "overridden-by": (data) => `overridden by ${text$1(data, "origin")} ${text$1(data, "version")}`,
780
+ "safe-mode": () => "disabled in safe mode",
781
+ "dependency-missing": (data) => `requires extension '${text$1(data, "id")}'${rangeSuffix(data)}, which is not installed`,
782
+ "dependency-disabled": (data) => `requires extension '${text$1(data, "id")}'${rangeSuffix(data)}, which is disabled`,
783
+ "dependency-version": (data) => `requires extension '${text$1(data, "id")}' ${text$1(data, "range")}, found ${text$1(data, "found")}`,
784
+ "dependency-unmet": (data) => `requires extension '${text$1(data, "id")}'${rangeSuffix(data)}, which is not loaded because its own dependencies are not met`,
785
+ "dependency-cycle": (data) => `extensions depend on each other: ${text$1(data, "cycle")}`
786
+ };
787
+ /**
788
+ * English text of a diagnostic: the single place that renders it for the CLI,
789
+ * logs and the installer. The app window builds its own localized text from
790
+ * the code and data.
791
+ */
792
+ const formatDiagnostic = ({ code, data }) => FORMATTERS[code](data);
793
+
350
794
  //#endregion
351
795
  //#region packages/extension-catalog/src/assets.ts
352
796
  /**
@@ -354,8 +798,8 @@ const commandOutcomeSchema = z.discriminatedUnion("kind", [
354
798
  * signature/geometry readers for the binary formats. Pure functions over bytes: the
355
799
  * installer, the protocol and the author tools read the same constants.
356
800
  */
357
- /** File extensions the first catalog format accepts (the released apps parse only these). */
358
- const LEGACY_FILE_EXTENSIONS = [
801
+ /** Extensions of a version's own files: manifest, code, schemas, documentation. */
802
+ const SOURCE_FILE_EXTENSIONS = [
359
803
  "json",
360
804
  "js",
361
805
  "mjs",
@@ -372,8 +816,8 @@ const ASSET_EXTENSIONS = [
372
816
  "jpeg",
373
817
  "woff2"
374
818
  ];
375
- /** Every extension a file of a version may have in the full index. */
376
- const CATALOG_FILE_EXTENSIONS = [...LEGACY_FILE_EXTENSIONS, ...ASSET_EXTENSIONS];
819
+ /** Every extension a file of a version may have. */
820
+ const CATALOG_FILE_EXTENSIONS = [...SOURCE_FILE_EXTENSIONS, ...ASSET_EXTENSIONS];
377
821
  const KIB = 1024;
378
822
  /** Per-file ceilings in bytes. `icon` bounds the manifest icon, which travels inside the index. */
379
823
  const ASSET_LIMITS = {
@@ -693,50 +1137,43 @@ const minAppVersionFailure = (minAppVersion, appVersion) => minAppVersion !== nu
693
1137
  /** `minAppVersion` и `platforms` манифеста или записи каталога. */
694
1138
  const checkCompatibility = (requirements, context) => minAppVersionFailure(requirements.minAppVersion, context.appVersion) ?? platformFailure(requirements.platforms, context.platform);
695
1139
 
1140
+ //#endregion
1141
+ //#region packages/extension-catalog/src/install-meta.ts
1142
+ const httpUrl = z.url().refine((value) => /^https?:\/\//i.test(value), "catalogUrl must be an http(s) URL");
1143
+ const installMetaSchema = z.strictObject({
1144
+ catalogUrl: httpUrl,
1145
+ version: z.string().refine(isSemver, "version must be semver"),
1146
+ installedAt: z.iso.datetime()
1147
+ });
1148
+
696
1149
  //#endregion
697
1150
  //#region packages/extension-catalog/src/schema.ts
698
- /** Format of `index.json`: parsed by every released app, strictly. */
699
- const LEGACY_SCHEMA_VERSION = 1;
700
- /** Format of `index.v2.json`: asset file types, icons, up to `MAX_FILES_V2` files. */
1151
+ /** Format of `index.v2.json`, the only index: asset file types, icons, up to `MAX_FILES_V2` files. */
701
1152
  const CATALOG_SCHEMA_VERSION = 2;
702
1153
  const MAX_VERSIONS = 5;
703
- const MAX_FILES = 50;
704
1154
  const MAX_TOTAL_BYTES = 1e7;
705
- /** Longest contribution title in `titles` (the manifest limit of `label`/`title`). */
706
- const MAX_TITLE_LENGTH = 60;
707
1155
  /** Most tags of a version. */
708
1156
  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"
716
- ];
717
1157
  const MAX_PATH_LENGTH = 200;
718
1158
  const SHA256 = /^[0-9a-f]{64}$/;
719
1159
  /** Допустимые символы сегмента пути: без `:` (потоки NTFS), пробелов и управляющих символов. */
720
1160
  const SEGMENT = /^[A-Za-z0-9_-][A-Za-z0-9._-]*$/;
721
1161
  /** Имена устройств Windows: недоступны как файлы, с расширением или без. */
722
1162
  const RESERVED_NAME = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(\..*)?$/i;
1163
+ const describeIssues = (error, prefix = "") => error.issues.map((issue) => `${prefix}${issue.path.join(".") || "/"}: ${issue.message}`);
723
1164
  const semver = z.string().refine(isSemver, "must be semver");
724
1165
  const timestamp = z.iso.datetime({ offset: true });
725
1166
  const httpsUrl = z.url().refine((value) => value.startsWith("https://"), "must be an https URL");
726
1167
  const isSafeSegment = (segment) => SEGMENT.test(segment) && !segment.endsWith(".") && !RESERVED_NAME.test(segment);
727
- /** Путь файла версии: безопасные сегменты и расширение из `extensions`. */
728
- const isSafePathWith = (value, extensions) => {
1168
+ /** Path of a version file: safe segments, an allowed extension. */
1169
+ const isSafeCatalogPath = (value) => {
729
1170
  if (value.length > MAX_PATH_LENGTH) return false;
730
1171
  if (!value.split("/").every(isSafeSegment)) return false;
731
1172
  const extension = extensionOf(value);
732
- return extension !== null && extensions.includes(extension);
1173
+ return extension !== null && CATALOG_FILE_EXTENSIONS.includes(extension);
733
1174
  };
734
- /** Path of a version file in the full index: safe segments, an allowed extension. */
735
- const isSafeCatalogPath = (value) => isSafePathWith(value, CATALOG_FILE_EXTENSIONS);
736
- /** Path of a version file in the first format (`index.json`). */
737
- const isLegacyCatalogPath = (value) => isSafePathWith(value, LEGACY_FILE_EXTENSIONS);
738
1175
  /**
739
- * Каталог файлов версии относительно адреса `index.json` (`extensions/<id>/<version>/`).
1176
+ * Каталог файлов версии относительно адреса индекса (`extensions/<id>/<version>/`).
740
1177
  * Абсолютный адрес не допускается: файлы версии по построению лежат на origin индекса,
741
1178
  * а локальный каталог разработчика и e2e не требуют https.
742
1179
  */
@@ -776,9 +1213,44 @@ const revokedSchema = z.strictObject({
776
1213
  versions: rangeText,
777
1214
  reason: z.string().min(1).max(300)
778
1215
  });
1216
+ /** Deprecation of an entry: a warning, not a revocation. `versions: null` — every version. */
1217
+ const MAX_DEPRECATION_REASON = 200;
1218
+ const MAX_ALTERNATIVES = 3;
1219
+ const deprecatedShape = {
1220
+ versions: rangeText.nullable(),
1221
+ reason: z.string().min(1).max(200),
1222
+ alternatives: z.array(extensionId$1).max(3)
1223
+ };
1224
+ const strictDeprecated = z.strictObject(deprecatedShape);
1225
+ /** The tolerant reader drops an unreadable `deprecated`; the entry stays. */
1226
+ const tolerantDeprecated = z.object(deprecatedShape).optional().catch(void 0);
1227
+ /** One item of `deprecated.json` in the catalog repository: no `versions` — every version. */
1228
+ const deprecatedItemSchema = z.strictObject({
1229
+ id: extensionId$1,
1230
+ versions: rangeText.optional(),
1231
+ reason: deprecatedShape.reason,
1232
+ alternatives: deprecatedShape.alternatives
1233
+ });
1234
+ const deprecatedListSchema = z.array(deprecatedItemSchema).superRefine((items, ctx) => {
1235
+ const seen = /* @__PURE__ */ new Set();
1236
+ items.forEach((item, position) => {
1237
+ if (seen.has(item.id)) ctx.addIssue({
1238
+ code: "custom",
1239
+ path: [position, "id"],
1240
+ message: `duplicate id '${item.id}'`
1241
+ });
1242
+ seen.add(item.id);
1243
+ });
1244
+ });
1245
+ /** Strict parse of `deprecated.json`; throws `CatalogFormatError` (also for a repeated `id`). */
1246
+ const parseDeprecatedList = (raw) => {
1247
+ const result = deprecatedListSchema.safeParse(raw);
1248
+ if (result.success) return result.data;
1249
+ throw new CatalogFormatError(describeIssues(result.error));
1250
+ };
779
1251
  const object = (profile, shape) => profile.strict ? z.strictObject(shape) : z.object(shape);
780
1252
  const fileSchemaOf = (profile) => object(profile, {
781
- path: z.string().refine((value) => isSafePathWith(value, profile.extensions), "must be a safe relative path"),
1253
+ path: z.string().refine(isSafeCatalogPath, "must be a safe relative path"),
782
1254
  size: z.number().int().min(0).max(MAX_TOTAL_BYTES),
783
1255
  sha256: z.string().regex(SHA256, "must be lowercase hex sha256")
784
1256
  }).superRefine((file, ctx) => {
@@ -788,7 +1260,7 @@ const fileSchemaOf = (profile) => object(profile, {
788
1260
  message: problem
789
1261
  });
790
1262
  });
791
- const filesSchemaOf = (profile) => z.array(fileSchemaOf(profile)).max(profile.maxFiles).superRefine((files, ctx) => {
1263
+ const filesSchemaOf = (profile) => z.array(fileSchemaOf(profile)).max(100).superRefine((files, ctx) => {
792
1264
  const seen = /* @__PURE__ */ new Set();
793
1265
  files.forEach((file, index) => {
794
1266
  const key = file.path.toLowerCase();
@@ -832,46 +1304,38 @@ const strictTags = z.array(z.enum(EXTENSION_TAGS)).max(5).superRefine((tags, ctx
832
1304
  const knownTags = EXTENSION_TAGS;
833
1305
  const tolerantTags = z.array(z.string()).transform((tags) => [...new Set(tags.filter((tag) => knownTags.includes(tag)))].slice(0, 5)).catch([]);
834
1306
  const tagsSchemaOf = (profile) => (profile.strict ? strictTags : tolerantTags).optional();
1307
+ const strictDependencies = z.array(z.strictObject({
1308
+ id: extensionId$1,
1309
+ range: rangeText.optional()
1310
+ })).max(16).superRefine((items, ctx) => {
1311
+ items.forEach(({ id }, index) => {
1312
+ if (items.findIndex((item) => item.id === id) !== index) ctx.addIssue({
1313
+ code: "custom",
1314
+ path: [index, "id"],
1315
+ message: `duplicate dependency '${id}'`
1316
+ });
1317
+ });
1318
+ });
1319
+ /** The tolerant reader drops an unreadable `dependencies`; the version stays. */
1320
+ const tolerantDependencies = z.array(z.object({
1321
+ id: extensionId$1,
1322
+ range: rangeText.optional()
1323
+ })).max(16).catch([]);
1324
+ const dependenciesSchemaOf = (profile) => (profile.strict ? strictDependencies : tolerantDependencies).optional();
835
1325
  const versionSchemaOf = (profile) => object(profile, {
836
1326
  version: semver,
837
1327
  apiVersion: z.number().int().min(1),
838
1328
  minAppVersion: semver.nullable(),
839
- permissions: z.array(z.enum(EXTENSION_PERMISSIONS)),
840
1329
  publishedAt: timestamp,
841
1330
  baseUrl,
842
1331
  files: filesSchemaOf(profile),
843
1332
  /** The icon of the version as a `data:` URI; the file itself is among `files`. */
844
1333
  icon: z.string().max(MAX_ICON_URI_LENGTH).regex(ICON_URI_PATTERN).optional(),
845
1334
  /** 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
- });
858
- });
859
- const contributesSchemaOf = (profile) => object(profile, {
860
- exerciseTypes: z.array(z.string()),
861
- themes: z.array(z.string()),
862
- markdownRenderers: 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()
1335
+ tags: tagsSchemaOf(profile),
1336
+ /** Extensions the version needs (`dependencies` of the manifest); no key — none. */
1337
+ dependencies: dependenciesSchemaOf(profile)
868
1338
  });
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();
874
- };
875
1339
  const entryHeadOf = (profile) => ({
876
1340
  id: extensionId$1,
877
1341
  name: z.string().min(1).max(80),
@@ -879,83 +1343,34 @@ const entryHeadOf = (profile) => ({
879
1343
  author: z.string().regex(GITHUB_LOGIN_PATTERN, "must be a GitHub login"),
880
1344
  source: httpsUrl,
881
1345
  platforms: z.array(z.enum(EXTENSION_PLATFORMS)),
882
- contributes: contributesSchemaOf(profile),
883
- titles: titlesSchemaOf(profile)
1346
+ deprecated: profile.strict ? strictDeprecated.optional() : tolerantDeprecated
884
1347
  });
885
1348
  const entrySchemaOf = (profile) => object(profile, {
886
1349
  ...entryHeadOf(profile),
887
1350
  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) {
891
- ctx.addIssue({
892
- code: "custom",
893
- path: ["titles"],
894
- message: "titles is not part of this index format"
895
- });
896
- return;
897
- }
898
- if (!profile.strict) return;
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}`
909
- });
910
- }
911
1351
  });
912
1352
  const indexSchemaOf = (profile) => object(profile, {
913
- schemaVersion: z.literal(profile.schemaVersion),
1353
+ schemaVersion: z.literal(2),
914
1354
  generatedAt: timestamp,
915
1355
  extensions: z.array(entrySchemaOf(profile)),
916
1356
  revoked: z.array(revokedSchema)
917
1357
  });
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
- };
1358
+ const FULL_PROFILE = { strict: true };
936
1359
  /** 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. */
1360
+ const TOLERANT_PROFILE = { strict: false };
1361
+ /** `index.v2.json`: the only index format. */
944
1362
  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
1363
  /**
948
1364
  * Strict parse for the author tools: unknown keys, file types and limits fail.
949
- * Both formats are accepted; `schemaVersion` chooses the rules.
950
1365
  * Throws `CatalogFormatError`.
951
1366
  */
952
1367
  const parseIndex = (raw) => {
953
- const parsed = (isLegacyIndex(raw) ? legacyIndexSchema : indexSchema).safeParse(raw);
1368
+ const parsed = indexSchema.safeParse(raw);
954
1369
  if (parsed.success) return parsed.data;
955
1370
  throw new CatalogFormatError(describeIssues(parsed.error));
956
1371
  };
957
1372
  const tolerantHead = object(TOLERANT_PROFILE, {
958
- schemaVersion: z.union([z.literal(1), z.literal(2)]),
1373
+ schemaVersion: z.literal(2),
959
1374
  generatedAt: timestamp,
960
1375
  extensions: z.array(z.unknown()),
961
1376
  revoked: z.array(revokedSchema)
@@ -966,470 +1381,36 @@ const tolerantEntry = object(TOLERANT_PROFILE, {
966
1381
  });
967
1382
  const tolerantVersion = versionSchemaOf(TOLERANT_PROFILE);
968
1383
 
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
1384
  //#endregion
1022
1385
  //#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`. */
1386
+ /** File name of the catalog index, the only one. */
1026
1387
  const FULL_INDEX_FILE = "index.v2.json";
1027
1388
 
1028
- //#endregion
1029
- //#region packages/extension-host/src/points/support.ts
1030
- /** Относительный путь внутри каталога расширения: без `..`, `\` и ведущего `/`. */
1031
- const isSafeRelativePath = (value) => value.length > 0 && !value.includes("\\") && !value.startsWith("/") && !value.split("/").includes("..");
1032
- const safePath = (extensions) => z.string().refine(isSafeRelativePath, "must be a safe relative path").refine((value) => extensions.some((ext) => value.endsWith(ext)), `must end with ${extensions.join(" or ")}`);
1033
- const extensionId = z.string().max(64).regex(EXTENSION_ID_PATTERN, "invalid extension id");
1034
- /** Пути с нарушением правила «`id` равен id расширения или начинается с ним». */
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}.'`]);
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;
1039
- /** Путь внутри каталога расширения; выход за каталог — ошибка. */
1040
- const inside = (dir, relative) => {
1041
- const resolved = path.resolve(dir, relative);
1042
- if (!resolved.startsWith(dir + path.sep)) throw new Error(`path '${relative}' escapes the extension directory`);
1043
- return resolved;
1044
- };
1045
- const rendererUrlOf = (id, renderer) => `dolphy-ext://${id}/${renderer.replace(/^\.\//, "").split("/").map(encodeURIComponent).join("/")}`;
1046
- const defaultNote = (value, fallback) => value === fallback ? " (default)" : "";
1047
- /** Проверяет, что файл модуля существует (если проверка включена), и даёт адрес. */
1048
- const resolveModuleUrl = async (extensionId, dir, file, fallback, verifyFiles, label) => {
1049
- const absolute = inside(dir, file);
1050
- if (verifyFiles && !await isFile$1(absolute)) throw new Error(`${label} '${file}'${defaultNote(file, fallback)} is not a file`);
1051
- return rendererUrlOf(extensionId, file);
1052
- };
1053
- const deepFreeze = (value) => {
1054
- if (typeof value === "object" && value !== null) {
1055
- for (const child of Object.values(value)) deepFreeze(child);
1056
- Object.freeze(value);
1057
- }
1058
- return value;
1059
- };
1060
- const compileSchema = (ajv, schema, label) => {
1061
- try {
1062
- ajv.compile(schema);
1063
- } catch (error) {
1064
- throw new Error(`schema ${label} does not compile: ${error instanceof Error ? error.message : String(error)}`);
1065
- }
1066
- };
1067
- /** Схема объектом (копия, заморожена) или файлом внутри каталога расширения. */
1068
- const resolveSchema = async (ajv, dir, source, label) => {
1069
- if (typeof source !== "string") {
1070
- const schema = deepFreeze(structuredClone(source));
1071
- compileSchema(ajv, schema, `${label} (inline)`);
1072
- return schema;
1073
- }
1074
- const file = inside(dir, source);
1075
- let parsed;
1076
- try {
1077
- parsed = JSON.parse(await readFile(file, "utf8"));
1078
- } catch (error) {
1079
- throw new Error(`schema '${source}' is unreadable: ${error instanceof Error ? error.message : String(error)}`);
1080
- }
1081
- if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new Error(`schema '${source}' is not an object`);
1082
- compileSchema(ajv, parsed, `'${source}'`);
1083
- return parsed;
1084
- };
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
-
1123
- //#endregion
1124
- //#region packages/extension-host/src/points/exercise-types.ts
1125
- const schemaField = z.union([safePath([".json"]), z.record(z.string(), z.unknown()).refine((value) => Object.keys(value).length > 0, "must not be empty")]);
1126
- const exerciseTypes = {
1127
- key: "exerciseTypes",
1128
- needsMain: true,
1129
- schema: z.strictObject({
1130
- id: extensionId,
1131
- specSchema: schemaField,
1132
- answerSchema: schemaField,
1133
- element: z.string().optional(),
1134
- renderer: safePath([".js", ".mjs"]).optional()
1135
- }),
1136
- normalize: (entries) => entries.map((type) => ({
1137
- id: type.id,
1138
- specSchema: type.specSchema,
1139
- answerSchema: type.answerSchema,
1140
- element: type.element ?? defaultElementName(type.id),
1141
- renderer: type.renderer ?? "./view.mjs"
1142
- })),
1143
- check: (entries, owner) => [...idPrefixIssues("exerciseTypes", entries.map(({ id }) => id), owner), ...entries.flatMap((type, index) => ELEMENT_NAME_PATTERN.test(type.element) ? [] : [`contributes.exerciseTypes.${index}.element: invalid element name '${type.element}'`])],
1144
- resolve: async (entries, { dir, extensionId: owner, verifyFiles, ajv }) => {
1145
- const resolved = [];
1146
- for (const contribution of entries) {
1147
- const rendererUrl = await resolveModuleUrl(owner, dir, contribution.renderer, DEFAULT_RENDERER, verifyFiles, "renderer");
1148
- resolved.push({
1149
- id: contribution.id,
1150
- specSchema: await resolveSchema(ajv, dir, contribution.specSchema, `specSchema of '${contribution.id}'`),
1151
- answerSchema: await resolveSchema(ajv, dir, contribution.answerSchema, `answerSchema of '${contribution.id}'`),
1152
- element: contribution.element,
1153
- rendererUrl
1154
- });
1155
- }
1156
- return resolved;
1157
- },
1158
- claims: (resolved) => resolved.flatMap((type) => [`exerciseType:${type.id}`, `element:${type.element}`])
1159
- };
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
-
1173
- //#endregion
1174
- //#region packages/extension-host/src/points/grade-policies.ts
1175
- const BUILTIN_POLICY_ID = "passAtN";
1176
- const gradePolicies = {
1177
- key: "gradePolicies",
1178
- needsMain: true,
1179
- schema: z.strictObject({
1180
- id: extensionId.refine((id) => id !== BUILTIN_POLICY_ID, `id '${BUILTIN_POLICY_ID}' is reserved for the built-in policy`),
1181
- label: z.string().min(1).max(60)
1182
- }),
1183
- normalize: (entries) => entries,
1184
- check: (entries, owner) => idPrefixIssues("gradePolicies", entries.map(({ id }) => id), owner),
1185
- resolve: async (entries) => entries.map(({ id, label }) => ({
1186
- id,
1187
- label
1188
- })),
1189
- claims: (resolved) => resolved.map((policy) => `gradePolicy:${policy.id}`)
1190
- };
1191
-
1192
- //#endregion
1193
- //#region packages/extension-host/src/points/markdown-renderers.ts
1194
- const markdownRenderers = {
1195
- key: "markdownRenderers",
1196
- needsMain: false,
1197
- schema: z.strictObject({
1198
- language: z.string().regex(/^[a-z][a-z0-9-]{0,31}$/, "invalid language"),
1199
- renderer: safePath([".js", ".mjs"]).optional()
1200
- }),
1201
- normalize: (entries) => entries.map((entry) => ({
1202
- language: entry.language,
1203
- renderer: entry.renderer ?? "./markdown.mjs"
1204
- })),
1205
- check: () => [],
1206
- resolve: async (entries, { dir, extensionId, verifyFiles }) => {
1207
- const resolved = [];
1208
- for (const entry of entries) resolved.push({
1209
- language: entry.language,
1210
- rendererUrl: await resolveModuleUrl(extensionId, dir, entry.renderer, DEFAULT_MARKDOWN_RENDERER, verifyFiles, "markdown renderer")
1211
- });
1212
- return resolved;
1213
- },
1214
- claims: (resolved) => resolved.map((entry) => `markdown:${entry.language}`)
1215
- };
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
-
1369
- //#endregion
1370
- //#region packages/extension-host/src/points/themes.ts
1371
- const HEX_COLOR = /^#[0-9a-fA-F]{6}([0-9a-fA-F]{2})?$/;
1372
- const hexColor = z.string().regex(HEX_COLOR, "must be #rrggbb or #rrggbbaa");
1373
- const unknownKeys = (record, allowed) => Object.keys(record).filter((key) => !allowed.includes(key));
1374
- const rejectUnknownKeys = (allowed, noun) => (value, ctx) => {
1375
- const unknown = unknownKeys(value, allowed);
1376
- if (unknown.length === 0) return;
1377
- ctx.addIssue({
1378
- code: "custom",
1379
- message: `unknown ${noun} keys: ${unknown.join(", ")}`
1380
- });
1381
- };
1382
- const colors = z.record(z.string(), hexColor).refine((value) => Object.keys(value).length > 0, "must not be empty").superRefine(rejectUnknownKeys(THEME_COLOR_KEYS, "color"));
1383
- const isVariableValid = (key, value) => key === "border-color" ? typeof value === "string" && HEX_COLOR.test(value) : typeof value === "number" && value >= 0 && value <= 1;
1384
- const variables = z.record(z.string(), z.union([z.string(), z.number()])).superRefine(rejectUnknownKeys(THEME_VARIABLE_KEYS, "variable")).refine((value) => Object.entries(value).every(([key, item]) => isVariableValid(key, item)), "border-color must be a hex color, other variables numbers in [0, 1]");
1385
- const themes = {
1386
- key: "themes",
1387
- needsMain: false,
1388
- schema: z.strictObject({
1389
- id: extensionId.refine((id) => !BUILTIN_THEME_IDS.includes(id), "id is reserved for a built-in theme"),
1390
- label: z.string().min(1).max(60),
1391
- dark: z.boolean(),
1392
- colors,
1393
- variables: variables.optional()
1394
- }),
1395
- normalize: (entries) => entries,
1396
- check: (entries, owner) => idPrefixIssues("themes", entries.map(({ id }) => id), owner),
1397
- resolve: async (entries) => entries.map((theme) => ({
1398
- id: theme.id,
1399
- label: theme.label,
1400
- dark: theme.dark,
1401
- colors: { ...theme.colors },
1402
- variables: { ...theme.variables }
1403
- })),
1404
- claims: (resolved) => resolved.map((theme) => `theme:${theme.id}`)
1405
- };
1406
-
1407
- //#endregion
1408
- //#region packages/extension-host/src/points/index.ts
1409
- /** Реестр точек вклада: порядок значим для диагностики и разбора. */
1410
- const CONTRIBUTION_POINTS = [
1411
- exerciseTypes,
1412
- themes,
1413
- markdownRenderers,
1414
- gradePolicies,
1415
- settings,
1416
- events,
1417
- commands,
1418
- panels
1419
- ];
1420
- const POINT_KEYS = CONTRIBUTION_POINTS.map(({ key }) => key);
1421
-
1422
1389
  //#endregion
1423
1390
  //#region packages/extension-host/src/manifest.ts
1424
- const contributesSchema = z.strictObject(Object.fromEntries(CONTRIBUTION_POINTS.map((point) => [point.key, z.array(point.schema).optional()])));
1425
- const entriesOf = (contributes, key) => contributes[key] ?? [];
1426
- const isEmpty = (contributes) => CONTRIBUTION_POINTS.every((point) => entriesOf(contributes, point.key).length === 0);
1391
+ /** Относительный путь внутри каталога расширения: без `..`, `\` и ведущего `/`. */
1392
+ const isSafeRelativePath = (value) => value.length > 0 && !value.includes("\\") && !value.startsWith("/") && !value.split("/").includes("..");
1393
+ const safePath = (extensions) => z.string().refine(isSafeRelativePath, "must be a safe relative path").refine((value) => extensions.some((ext) => value.endsWith(ext)), `must end with ${extensions.join(" or ")}`);
1394
+ const extensionId = z.string().max(64).regex(EXTENSION_ID_PATTERN, "invalid extension id");
1395
+ /** Диапазон версий в том же виде, что `versions` отзыва и устаревания в каталоге. */
1396
+ const versionRange = z.string().superRefine((value, ctx) => {
1397
+ try {
1398
+ parseRange(value);
1399
+ } catch {
1400
+ ctx.addIssue({
1401
+ code: "custom",
1402
+ message: "range must be space-separated comparators such as '>=1.2.0 <2.0.0'"
1403
+ });
1404
+ }
1405
+ });
1427
1406
  const manifestSchema = z.strictObject({
1407
+ /** Ссылка на JSON Schema для редактора; приложением и инструментами игнорируется. */
1408
+ $schema: z.string().optional(),
1428
1409
  id: extensionId,
1429
1410
  version: z.string().refine(isSemver, "version must be semver"),
1430
1411
  apiVersion: z.literal(1),
1431
- main: safePath([".mjs"]).optional(),
1432
- permissions: z.array(z.enum(EXTENSION_PERMISSIONS)).optional(),
1412
+ main: safePath([".mjs"]).nullable().optional(),
1413
+ client: safePath([".mjs"]).nullable().optional(),
1433
1414
  name: z.string().min(1).max(80).optional(),
1434
1415
  description: z.string().min(1).max(500).optional(),
1435
1416
  author: z.string().regex(GITHUB_LOGIN_PATTERN, "must be a GitHub login").optional(),
@@ -1437,9 +1418,14 @@ const manifestSchema = z.strictObject({
1437
1418
  minAppVersion: z.string().refine(isSemver, "minAppVersion must be semver x.y.z").optional(),
1438
1419
  icon: safePath([".png", ".webp"]).optional(),
1439
1420
  tags: z.array(z.enum(EXTENSION_TAGS, { error: `tag must be one of: ${EXTENSION_TAGS.join(", ")}` })).max(5, "at most 5 tags").optional(),
1440
- contributes: contributesSchema
1421
+ dependencies: z.array(z.strictObject({
1422
+ id: extensionId,
1423
+ range: versionRange.optional()
1424
+ })).max(16, `at most ${16} dependencies`).optional(),
1425
+ /** Вклады регистрирует код (`server`, `client`): ключ отвергается с подсказкой. */
1426
+ contributes: z.never({ error: "contributions are registered in code (src/index.ts: server, client)" }).optional()
1441
1427
  }).superRefine((manifest, ctx) => {
1442
- const { permissions = [], platforms = [] } = manifest;
1428
+ const { platforms = [] } = manifest;
1443
1429
  platforms.forEach((platform, index) => {
1444
1430
  if (platforms.indexOf(platform) !== index) ctx.addIssue({
1445
1431
  code: "custom",
@@ -1447,13 +1433,6 @@ const manifestSchema = z.strictObject({
1447
1433
  message: `duplicate platform '${platform}'`
1448
1434
  });
1449
1435
  });
1450
- permissions.forEach((permission, index) => {
1451
- if (permissions.indexOf(permission) !== index) ctx.addIssue({
1452
- code: "custom",
1453
- path: ["permissions", index],
1454
- message: `duplicate permission '${permission}'`
1455
- });
1456
- });
1457
1436
  const { tags = [] } = manifest;
1458
1437
  tags.forEach((tag, index) => {
1459
1438
  if (tags.indexOf(tag) !== index) ctx.addIssue({
@@ -1462,70 +1441,93 @@ const manifestSchema = z.strictObject({
1462
1441
  message: `duplicate tag '${tag}'`
1463
1442
  });
1464
1443
  });
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
- });
1470
- if (isEmpty(manifest.contributes)) ctx.addIssue({
1471
- code: "custom",
1472
- path: ["contributes"],
1473
- message: "at least one contribution is required"
1444
+ const { dependencies = [] } = manifest;
1445
+ dependencies.forEach(({ id }, index) => {
1446
+ if (id === manifest.id) ctx.addIssue({
1447
+ code: "custom",
1448
+ path: [
1449
+ "dependencies",
1450
+ index,
1451
+ "id"
1452
+ ],
1453
+ message: "an extension cannot depend on itself"
1454
+ });
1455
+ else if (dependencies.findIndex((item) => item.id === id) !== index) ctx.addIssue({
1456
+ code: "custom",
1457
+ path: [
1458
+ "dependencies",
1459
+ index,
1460
+ "id"
1461
+ ],
1462
+ message: `duplicate dependency '${id}'`
1463
+ });
1474
1464
  });
1475
1465
  });
1476
- /** Код нужен, если есть записи в точке, которая его требует. */
1477
- const needsMain = (contributes) => CONTRIBUTION_POINTS.some((point) => point.needsMain && entriesOf(contributes, point.key).length > 0);
1478
- /** Применяет умолчания: `main`, `renderer`, `element`, пустые точки. */
1479
- const normalizeManifest = (input) => {
1480
- const contributes = Object.fromEntries(CONTRIBUTION_POINTS.map((point) => [point.key, point.normalize(entriesOf(input.contributes, point.key))]));
1481
- return {
1482
- id: input.id,
1483
- version: input.version,
1484
- apiVersion: input.apiVersion,
1485
- main: input.main ?? (needsMain(input.contributes) ? "./main.mjs" : null),
1486
- permissions: [...input.permissions ?? []],
1487
- name: input.name ?? null,
1488
- description: input.description ?? null,
1489
- author: input.author ?? null,
1490
- platforms: [...input.platforms ?? []],
1491
- minAppVersion: input.minAppVersion ?? null,
1492
- tags: [...input.tags ?? []],
1493
- icon: input.icon ?? null,
1494
- contributes
1495
- };
1496
- };
1497
- const formatIssues = (error) => error.issues.map((issue) => `${issue.path.join(".") || "/"}: ${issue.message}`).join("; ");
1498
- /** Сообщения о нарушениях в нормализованном манифесте. */
1499
- const normalizedIssues = (manifest) => CONTRIBUTION_POINTS.flatMap((point) => point.check(manifest.contributes[point.key], manifest.id));
1466
+ /** Применяет умолчания: части, которой нет, соответствует `null`. */
1467
+ const normalizeManifest = (input) => ({
1468
+ id: input.id,
1469
+ version: input.version,
1470
+ apiVersion: input.apiVersion,
1471
+ main: input.main ?? null,
1472
+ client: input.client ?? null,
1473
+ name: input.name ?? null,
1474
+ description: input.description ?? null,
1475
+ author: input.author ?? null,
1476
+ platforms: [...input.platforms ?? []],
1477
+ minAppVersion: input.minAppVersion ?? null,
1478
+ tags: [...input.tags ?? []],
1479
+ dependencies: (input.dependencies ?? []).map(({ id, range }) => ({
1480
+ id,
1481
+ range: range ?? null
1482
+ })),
1483
+ icon: input.icon ?? null
1484
+ });
1485
+ const zodIssues = (error) => error.issues.map((issue) => `${issue.path.join(".") || "/"}: ${issue.message}`);
1486
+ /** Разбор манифеста; ошибка — диагностика `manifest-invalid` (текст — `formatDiagnostic`). */
1500
1487
  const parseManifest = (raw) => {
1501
1488
  const parsed = manifestSchema.safeParse(raw);
1502
1489
  if (!parsed.success) return {
1503
1490
  ok: false,
1504
- message: formatIssues(parsed.error)
1505
- };
1506
- const manifest = normalizeManifest(parsed.data);
1507
- const issues = normalizedIssues(manifest);
1508
- if (issues.length > 0) return {
1509
- ok: false,
1510
- message: issues.join("; ")
1491
+ diagnostic: {
1492
+ code: "manifest-invalid",
1493
+ data: { issues: zodIssues(parsed.error) }
1494
+ }
1511
1495
  };
1512
1496
  return {
1513
1497
  ok: true,
1514
- manifest
1498
+ manifest: normalizeManifest(parsed.data)
1515
1499
  };
1516
1500
  };
1517
1501
 
1518
1502
  //#endregion
1519
1503
  //#region packages/extension-host/src/discover.ts
1520
- const compatibilityIssue = (manifest, appVersion, platform) => checkCompatibility(manifest, {
1521
- appVersion,
1522
- platform
1523
- })?.detail ?? null;
1524
- const resolveMain = async (dir, main, verifyFiles) => {
1525
- if (main === null) return null;
1526
- const mainPath = inside(dir, main);
1527
- if (verifyFiles && !await isFile$1(mainPath)) throw new Error(`main '${main}'${defaultNote(main, DEFAULT_MAIN)} is not a file`);
1528
- return mainPath;
1504
+ const compatibilityIssue = (manifest, appVersion, platform) => {
1505
+ const failure = checkCompatibility(manifest, {
1506
+ appVersion,
1507
+ platform
1508
+ });
1509
+ if (failure === null) return null;
1510
+ return failure.reason === "app" ? {
1511
+ code: "requires-app",
1512
+ data: { minAppVersion: manifest.minAppVersion ?? "" }
1513
+ } : {
1514
+ code: "unavailable-platform",
1515
+ data: { platform }
1516
+ };
1517
+ };
1518
+ const isFile$2 = async (file) => (await stat(file).catch(() => null))?.isFile() === true;
1519
+ /** Путь внутри каталога расширения; выход за каталог — ошибка. */
1520
+ const inside = (dir, relative) => {
1521
+ const resolved = path.resolve(dir, relative);
1522
+ if (!resolved.startsWith(dir + path.sep)) throw new Error(`path '${relative}' escapes the extension directory`);
1523
+ return resolved;
1524
+ };
1525
+ /** Путь собранной части расширения (`main`, `client`) внутри каталога; `null` — части нет. */
1526
+ const resolvePart = async (dir, part, file, verifyFiles) => {
1527
+ if (file === null) return null;
1528
+ const target = inside(dir, file);
1529
+ if (verifyFiles && !await isFile$2(target)) throw new Error(`${part} '${file}' is not a file`);
1530
+ return target;
1529
1531
  };
1530
1532
  /**
1531
1533
  * Значок манифеста как `data:`-URI. Читается при обнаружении (≤16 КиБ), снимок
@@ -1541,14 +1543,11 @@ const resolveIcon = async (dir, icon, verifyFiles) => {
1541
1543
  if (problem !== null) throw new Error(problem);
1542
1544
  return iconDataUri(icon, bytes);
1543
1545
  };
1546
+ /** Полностью разбирает каталог одного расширения; ошибка — диагностика (английский текст — `formatDiagnostic`). */
1544
1547
  const inspectExtensionDir = async (directory, options = {}) => {
1545
1548
  const { verifyFiles = true, expectedId = null, appVersion, platform = process.platform } = options;
1546
1549
  const dir = path.resolve(directory);
1547
1550
  const dirName = expectedId ?? path.basename(dir);
1548
- const ajv = new Ajv2020({
1549
- allErrors: true,
1550
- strict: false
1551
- });
1552
1551
  let raw;
1553
1552
  try {
1554
1553
  raw = JSON.parse(await readFile(path.join(dir, "extension.json"), "utf8"));
@@ -1556,68 +1555,237 @@ const inspectExtensionDir = async (directory, options = {}) => {
1556
1555
  return {
1557
1556
  ok: false,
1558
1557
  id: dirName,
1559
- message: `extension.json is unreadable: ${error instanceof Error ? error.message : String(error)}`
1558
+ diagnostic: {
1559
+ code: "manifest-unreadable",
1560
+ data: { reason: error instanceof Error ? error.message : String(error) }
1561
+ }
1560
1562
  };
1561
1563
  }
1562
1564
  const parsed = parseManifest(raw);
1563
1565
  if (!parsed.ok) return {
1564
1566
  ok: false,
1565
1567
  id: dirName,
1566
- message: parsed.message
1568
+ diagnostic: parsed.diagnostic
1567
1569
  };
1568
1570
  const { manifest } = parsed;
1569
1571
  if (expectedId !== null && manifest.id !== expectedId) return {
1570
1572
  ok: false,
1571
1573
  id: dirName,
1572
- message: `directory name '${expectedId}' does not match manifest id '${manifest.id}'`
1574
+ diagnostic: {
1575
+ code: "id-mismatch",
1576
+ data: {
1577
+ expected: expectedId,
1578
+ actual: manifest.id
1579
+ }
1580
+ }
1573
1581
  };
1574
1582
  const incompatible = compatibilityIssue(manifest, appVersion, platform);
1575
1583
  if (incompatible !== null) return {
1576
1584
  ok: false,
1577
1585
  id: manifest.id,
1578
- message: incompatible
1586
+ diagnostic: incompatible
1579
1587
  };
1580
1588
  try {
1581
- const mainPath = await resolveMain(dir, manifest.main, verifyFiles);
1582
- const context = {
1583
- dir,
1584
- extensionId: manifest.id,
1585
- verifyFiles,
1586
- ajv
1587
- };
1588
- const resolved = {};
1589
- for (const point of CONTRIBUTION_POINTS) resolved[point.key] = await point.resolve(manifest.contributes[point.key], context);
1590
1589
  return {
1591
1590
  ok: true,
1592
1591
  extension: {
1593
1592
  id: manifest.id,
1594
1593
  version: manifest.version,
1595
1594
  dir,
1596
- mainPath,
1597
- permissions: manifest.permissions,
1595
+ mainPath: await resolvePart(dir, "main", manifest.main, verifyFiles),
1596
+ clientPath: await resolvePart(dir, "client", manifest.client, verifyFiles),
1598
1597
  name: manifest.name,
1599
1598
  description: manifest.description,
1600
1599
  author: manifest.author,
1601
1600
  platforms: manifest.platforms,
1602
1601
  minAppVersion: manifest.minAppVersion,
1603
1602
  icon: await resolveIcon(dir, manifest.icon, verifyFiles),
1603
+ warnings: [],
1604
1604
  tags: manifest.tags,
1605
- ...resolved
1605
+ dependencies: manifest.dependencies
1606
1606
  }
1607
1607
  };
1608
1608
  } catch (error) {
1609
1609
  return {
1610
1610
  ok: false,
1611
1611
  id: manifest.id,
1612
- message: error instanceof Error ? error.message : String(error)
1612
+ diagnostic: {
1613
+ code: "load-failed",
1614
+ data: { reason: error instanceof Error ? error.message : String(error) }
1615
+ }
1613
1616
  };
1614
1617
  }
1615
1618
  };
1616
1619
 
1620
+ //#endregion
1621
+ //#region packages/extension-host/src/registrar-support.ts
1622
+ /** Идентификатор вклада: форма проверяется схемой, принадлежность расширению — `checkOwnId`. */
1623
+ const idField = z.string().max(64).regex(EXTENSION_ID_PATTERN, "invalid id");
1624
+ /** Подпись: строка или `{ en, ru? }`; каждый текст 1..`max` символов. */
1625
+ const localizedField = (max) => {
1626
+ const text = z.string().min(1).max(max);
1627
+ return z.union([text, z.strictObject({
1628
+ en: text,
1629
+ ru: text.optional()
1630
+ })]).transform((value) => {
1631
+ if (typeof value === "string") return value;
1632
+ return value.ru === void 0 ? { en: value.en } : {
1633
+ en: value.en,
1634
+ ru: value.ru
1635
+ };
1636
+ });
1637
+ };
1638
+ /** Условие видимости: длина здесь, разбор — `whenProblem`. */
1639
+ const whenField = z.string().min(1).max(EXTENSION_COMMAND_LIMITS.whenLength);
1640
+ /** Значение — функция (схема не копирует её, только проверяет тип). */
1641
+ const functionField = z.custom((value) => typeof value === "function", "must be a function");
1642
+
1643
+ //#endregion
1644
+ //#region packages/extension-host/src/registrar-commands.ts
1645
+ const keyText = z.string().min(1);
1646
+ const schema = z.strictObject({
1647
+ id: idField,
1648
+ title: localizedField(EXTENSION_COMMAND_LIMITS.titleLength),
1649
+ description: localizedField(EXTENSION_COMMAND_LIMITS.descriptionLength).optional(),
1650
+ category: localizedField(EXTENSION_COMMAND_LIMITS.categoryLength).optional(),
1651
+ keybindings: z.array(z.strictObject({
1652
+ key: keyText,
1653
+ mac: keyText.optional(),
1654
+ windows: keyText.optional(),
1655
+ linux: keyText.optional(),
1656
+ when: whenField.optional()
1657
+ })).max(EXTENSION_COMMAND_LIMITS.keybindingsPerCommand).optional(),
1658
+ palette: z.boolean().optional(),
1659
+ when: whenField.optional(),
1660
+ icon: z.enum(EXTENSION_ICONS).optional(),
1661
+ run: functionField
1662
+ });
1663
+
1664
+ //#endregion
1665
+ //#region packages/extension-host/src/registrar-settings.ts
1666
+ const base = {
1667
+ id: idField,
1668
+ label: localizedField(SETTING_LIMITS.labelLength),
1669
+ description: localizedField(SETTING_LIMITS.descriptionLength).optional(),
1670
+ group: localizedField(SETTING_LIMITS.groupLength).optional(),
1671
+ order: z.number().int().min(0).max(SETTING_LIMITS.orderMax).optional(),
1672
+ visibleWhen: z.strictObject({
1673
+ setting: idField,
1674
+ equals: z.union([
1675
+ z.boolean(),
1676
+ z.string(),
1677
+ z.number()
1678
+ ])
1679
+ }).optional()
1680
+ };
1681
+ const option = z.strictObject({
1682
+ value: z.string().min(1).max(SETTING_LIMITS.optionValueLength),
1683
+ label: localizedField(SETTING_LIMITS.labelLength)
1684
+ });
1685
+ const maxLength = z.number().int().min(1).max(SETTING_LIMITS.stringLength).optional();
1686
+ const definitionSchema = z.discriminatedUnion("type", [
1687
+ z.strictObject({
1688
+ ...base,
1689
+ type: z.literal("boolean"),
1690
+ default: z.boolean()
1691
+ }),
1692
+ z.strictObject({
1693
+ ...base,
1694
+ type: z.literal("string"),
1695
+ default: z.string(),
1696
+ maxLength
1697
+ }),
1698
+ z.strictObject({
1699
+ ...base,
1700
+ type: z.literal("text"),
1701
+ default: z.string(),
1702
+ maxLength
1703
+ }),
1704
+ z.strictObject({
1705
+ ...base,
1706
+ type: z.literal("color"),
1707
+ default: z.string().regex(COLOR_SETTING_PATTERN, "must be #rrggbb")
1708
+ }),
1709
+ z.strictObject({
1710
+ ...base,
1711
+ type: z.literal("list"),
1712
+ default: z.array(z.string()),
1713
+ maxItems: z.number().int().min(1).max(SETTING_LIMITS.listItems).optional(),
1714
+ itemMaxLength: z.number().int().min(1).max(SETTING_LIMITS.listItemLength).optional()
1715
+ }),
1716
+ z.strictObject({
1717
+ ...base,
1718
+ type: z.literal("number"),
1719
+ default: z.number().finite(),
1720
+ min: z.number().finite().optional(),
1721
+ max: z.number().finite().optional(),
1722
+ integer: z.boolean().optional()
1723
+ }),
1724
+ z.strictObject({
1725
+ ...base,
1726
+ type: z.literal("enum"),
1727
+ default: z.string(),
1728
+ options: z.array(option).min(1).max(SETTING_LIMITS.options)
1729
+ })
1730
+ ]);
1731
+
1732
+ //#endregion
1733
+ //#region packages/extension-host/src/registrar.ts
1734
+ const BUILTIN_POLICY_ID = "passAtN";
1735
+ const { titleLength } = EXTENSION_COMMAND_LIMITS;
1736
+ const schemaField = z.record(z.string(), z.unknown()).refine((value) => Object.keys(value).length > 0, "must not be empty");
1737
+ const exerciseTypeSchema = z.strictObject({
1738
+ id: idField,
1739
+ title: localizedField(titleLength).optional(),
1740
+ specSchema: schemaField,
1741
+ answerSchema: schemaField,
1742
+ project: functionField,
1743
+ grade: functionField,
1744
+ referenceAnswer: functionField.optional()
1745
+ });
1746
+ const gradePolicySchema = z.strictObject({
1747
+ id: idField.refine((id) => id !== BUILTIN_POLICY_ID, `id '${BUILTIN_POLICY_ID}' is reserved for the built-in policy`),
1748
+ label: localizedField(titleLength),
1749
+ evaluate: functionField
1750
+ });
1751
+ const scheduleSchema = z.discriminatedUnion("every", [z.strictObject({
1752
+ id: idField,
1753
+ every: z.literal("daily"),
1754
+ at: z.string().regex(SCHEDULE_AT_PATTERN, "must be a time such as '09:00'")
1755
+ }), z.strictObject({
1756
+ id: idField,
1757
+ every: z.literal("hourly")
1758
+ })]);
1759
+ const importerSchema = z.strictObject({
1760
+ id: idField,
1761
+ title: localizedField(titleLength),
1762
+ accept: z.array(z.string().regex(TRANSFER_ACCEPT_PATTERN, "must be a lower-case file extension such as '.csv'")).min(1).max(EXTENSION_TRANSFER_LIMITS.acceptExtensions),
1763
+ input: z.enum(["text", "bytes"]),
1764
+ run: functionField
1765
+ });
1766
+ const exporterSchema = z.strictObject({
1767
+ id: idField,
1768
+ title: localizedField(titleLength),
1769
+ scope: z.enum(["course", "progress"]),
1770
+ run: functionField
1771
+ });
1772
+ const schemaLike = z.custom((value) => typeof value === "object" && value !== null && typeof Reflect.get(value, "safeParseAsync") === "function", "must be a zod schema");
1773
+ const rpcSchema = z.object({
1774
+ name: z.string().max(EXTENSION_RPC_LIMITS.nameLength).regex(RPC_NAME_PATTERN, "must match RPC_NAME_PATTERN"),
1775
+ input: schemaLike,
1776
+ output: schemaLike
1777
+ });
1778
+
1617
1779
  //#endregion
1618
1780
  //#region packages/extension-host/src/runtime.ts
1619
- /** Срок обработчика команды расширения (R3); раннер ограниченного процесса и клиент движка ждут дольше. */
1781
+ /** Срок обработчика расписания (R12); клиент планировщика ждёт дольше. */
1782
+ const SCHEDULE_HANDLER_MS = EXTENSION_SCHEDULE_LIMITS.handlerMs;
1783
+ /** Срок обработчика команды расширения (R3); клиент движка ждёт дольше. */
1620
1784
  const COMMAND_HANDLER_MS = EXTENSION_COMMAND_LIMITS.handlerMs;
1785
+ /** Срок обработчика `server.handle`; клиент движка ждёт дольше. */
1786
+ const RPC_HANDLER_MS = EXTENSION_RPC_LIMITS.handlerMs;
1787
+ /** Срок обработчика импортёра и экспортёра; клиент движка (34 с) ждёт дольше. */
1788
+ const TRANSFER_HANDLER_MS = EXTENSION_TRANSFER_LIMITS.handlerMs;
1621
1789
 
1622
1790
  //#endregion
1623
1791
  //#region packages/extension-tools/src/catalog/assets.ts
@@ -2117,12 +2285,26 @@ const isBig = (file) => {
2117
2285
  return false;
2118
2286
  }
2119
2287
  };
2288
+ /** A style sheet or a `<style>` block of a component (`X.vue?vue&type=style…`): the module ends up as a string in the bundle. */
2289
+ const VUE_STYLE_MODULE = /\.vue\?(?:.*&)?type=style(?:&|$)/;
2290
+ const isStyleModule = (id) => STYLE_FILE.test(cleanId(id)) || VUE_STYLE_MODULE.test(id);
2291
+ /** `<style src="./x.css">` in the component `importer`: the component plugin resolves the file once as it is, to link it to the component, and then imports it as text. */
2292
+ const isStyleSourceOf = (importer, source) => {
2293
+ if (!importer.endsWith(".vue")) return false;
2294
+ try {
2295
+ const escaped = source.replaceAll(/[.*+?^${}()|[\]\\]/g, "\\$&");
2296
+ return new RegExp(`<style\\b[^>]*\\ssrc\\s*=\\s*(["'])${escaped}\\1`).test(readFileSync(importer, "utf8"));
2297
+ } catch {
2298
+ return false;
2299
+ }
2300
+ };
2120
2301
  /**
2121
2302
  * 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
2303
+ * plugin brings the documented paths. An image or a font is a data URI up to
2123
2304
  * `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.
2305
+ * `new URL('./x.png', import.meta.url)`); a style sheet of the author is imported as a
2306
+ * string with `?inline`, a plain `import './x.css'` is an error with the way out in the
2307
+ * message.
2126
2308
  */
2127
2309
  const assetsPlugin = (state) => ({
2128
2310
  name: "dolphy-ext:assets",
@@ -2146,13 +2328,13 @@ const assetsPlugin = (state) => ({
2146
2328
  const file = cleanId(resolved.id);
2147
2329
  const query = resolved.id.slice(file.length);
2148
2330
  if (STYLE_FILE.test(file)) {
2149
- if (TEXT_IMPORT.test(query)) return null;
2331
+ if (TEXT_IMPORT.test(query) || isStyleSourceOf(importer, source)) return null;
2150
2332
  const message = `'${source}' is imported as a side-effect style sheet, which a bundle cannot carry: import it as text with import css from '${source}?inline' and add it to the page, or ship it as a file in assets/`;
2151
2333
  state.problem = message;
2152
2334
  return this.error(message);
2153
2335
  }
2154
2336
  if (!ASSET_FILE.test(file) || MARKED.test(query)) return null;
2155
- if (STYLE_FILE.test(cleanId(importer)) || !isBig(file)) return null;
2337
+ if (isStyleModule(importer) || !isBig(file)) return null;
2156
2338
  return {
2157
2339
  ...resolved,
2158
2340
  id: `${resolved.id}${query === "" ? "?" : "&"}no-inline`
@@ -2161,33 +2343,393 @@ const assetsPlugin = (state) => ({
2161
2343
  });
2162
2344
 
2163
2345
  //#endregion
2164
- //#region packages/extension-tools/src/errors.ts
2165
- /** Extension build/check error; `subject` is the extension id or directory. */
2166
- var BuildError = class extends Error {
2167
- subject;
2168
- constructor(message, subject) {
2169
- super(message);
2170
- this.name = "BuildError";
2171
- this.subject = subject;
2346
+ //#region packages/extension-tools/src/host-modules.ts
2347
+ /**
2348
+ * Modules the app gives to the client code of an extension: the extension
2349
+ * imports them as usual, the bundle does not contain them, and the window
2350
+ * resolves them to its own instances (one Vue, one Vuetify, one theme).
2351
+ */
2352
+ const HOST_MODULES = [
2353
+ "vue",
2354
+ "vuetify",
2355
+ "vuetify/components",
2356
+ "vuetify/directives"
2357
+ ];
2358
+ /** Style sheets of the app: the window already has them, the import is dropped. */
2359
+ const HOST_STYLES = ["vuetify/styles"];
2360
+ /** Name of the host's loader on `globalThis`: `require(name)` returns a promise of the module. */
2361
+ const HOST_GLOBAL = "__dolphy";
2362
+ /** Modules of the window (`vue`, `vuetify`, `vuetify/…`): the host file is not a place for them, so they are not read. */
2363
+ const WINDOW_SPECIFIER = /^(?:vue|vuetify)(?:\/.*)?$/;
2364
+ const isHostModule = (specifier) => HOST_MODULES.includes(specifier);
2365
+ const isHostStyle = (specifier) => HOST_STYLES.includes(specifier);
2366
+ const child$1 = (node, key) => node[key];
2367
+ const children$1 = (node, key) => node[key];
2368
+ const nameOf$1 = (node) => node["name"] ?? node["value"];
2369
+ const newlines = (text) => "\n".repeat(text.split("\n").length - 1);
2370
+ /**
2371
+ * Replaces the imports of host modules in the generated code of an ES module
2372
+ * with reads from the host's loader. A replacement keeps the number of lines
2373
+ * of the import it replaces, so source maps stay valid line by line.
2374
+ */
2375
+ const rewriteHostImports = (code) => {
2376
+ const ast = parseAst(code, { lang: "js" }).body;
2377
+ const result = new MagicString(code);
2378
+ let index = 0;
2379
+ for (const statement of ast) {
2380
+ if (statement.type !== "ImportDeclaration") continue;
2381
+ const specifier = child$1(statement, "source")["value"];
2382
+ const source = code.slice(statement.start, statement.end);
2383
+ if (isHostStyle(specifier)) {
2384
+ result.overwrite(statement.start, statement.end, newlines(source));
2385
+ continue;
2386
+ }
2387
+ if (!isHostModule(specifier)) continue;
2388
+ const holder = `${HOST_GLOBAL}_${index}`;
2389
+ index += 1;
2390
+ const lines = [`const ${holder} = await globalThis.${HOST_GLOBAL}.require(${JSON.stringify(specifier)});`];
2391
+ const named = [];
2392
+ for (const item of children$1(statement, "specifiers")) {
2393
+ const local = nameOf$1(child$1(item, "local"));
2394
+ if (item.type === "ImportNamespaceSpecifier") lines.push(`const ${local} = ${holder};`);
2395
+ else if (item.type === "ImportDefaultSpecifier") lines.push(`const ${local} = ${holder}.default;`);
2396
+ else {
2397
+ const imported = nameOf$1(child$1(item, "imported"));
2398
+ named.push(imported === local ? local : `${imported}: ${local}`);
2399
+ }
2400
+ }
2401
+ if (named.length > 0) lines.push(`const { ${named.join(", ")} } = ${holder};`);
2402
+ result.overwrite(statement.start, statement.end, lines.join(" ") + newlines(source));
2172
2403
  }
2404
+ return result.toString();
2173
2405
  };
2174
- /** Invalid command input (nonexistent directory): exit code 2. */
2175
- var CatalogUsageError = class extends BuildError {};
2406
+ /**
2407
+ * Build plugin for browser files: marks the host modules external and, in
2408
+ * the generated bundle, turns their imports into reads from `globalThis.__dolphy`.
2409
+ * The result needs neither an import map nor a CSP change.
2410
+ */
2411
+ const hostModulesPlugin = () => ({
2412
+ name: "dolphy-ext:host-modules",
2413
+ enforce: "pre",
2414
+ resolveId(source) {
2415
+ if (isHostModule(source) || isHostStyle(source)) return {
2416
+ id: source,
2417
+ external: true
2418
+ };
2419
+ if (WINDOW_SPECIFIER.test(source)) return this.error(`'${source}' is not a module the app gives to extensions (${HOST_MODULES.join(", ")}): import the components from 'vuetify/components'`);
2420
+ return null;
2421
+ },
2422
+ generateBundle(_options, bundle) {
2423
+ for (const file of Object.values(bundle)) if (file.type === "chunk") file.code = rewriteHostImports(file.code);
2424
+ }
2425
+ });
2176
2426
 
2177
2427
  //#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"
2428
+ //#region packages/extension-tools/src/presets/react.ts
2429
+ /**
2430
+ * React: `.tsx` and `.jsx` with the automatic JSX runtime, compiled by the
2431
+ * bundler itself (no `@vitejs/plugin-react`: a bundle needs no Fast Refresh).
2432
+ * `react` and `react-dom` are the author's dependencies and go into the bundle:
2433
+ * every extension carries its own copy.
2434
+ */
2435
+ const reactPreset = {
2436
+ name: "react",
2437
+ extensions: [".tsx", ".jsx"],
2438
+ packages: [
2439
+ "react",
2440
+ "react-dom",
2441
+ "scheduler"
2442
+ ],
2443
+ plugins: () => [],
2444
+ options: { oxc: { jsx: {
2445
+ runtime: "automatic",
2446
+ development: false
2447
+ } } }
2448
+ };
2449
+
2450
+ //#endregion
2451
+ //#region packages/extension-tools/src/presets/vue-styles.ts
2452
+ const REGISTRY_SPECIFIER = "dolphy-ext:vue-styles";
2453
+ const REGISTRY_ID = `\0${REGISTRY_SPECIFIER}`;
2454
+ /** `<style>` blocks of components in the request form of `@vitejs/plugin-vue`: `X.vue?vue&type=style&index=0&inline&lang.css` (the file is the `src` of the block for `<style src>`). */
2455
+ const VUE_REQUEST = /[?&]vue(?:&|$)/;
2456
+ const STYLE_TYPE = /[?&]type=style(?:&|$)/;
2457
+ const INLINE = /[?&]inline(?:&|$)/;
2458
+ const DEFAULT_EXPORT = /^export default (".*");?\s*$/s;
2459
+ /**
2460
+ * Module the style blocks report to: puts all of them into one
2461
+ * `<style data-dolphy-ext="<id>">` of the page. The first block of a module
2462
+ * instance removes the tag an earlier instance of the same extension left
2463
+ * (the window imports `client.mjs` again after a reload), so there is one tag
2464
+ * per loaded module.
2465
+ */
2466
+ const registrySource = (extensionId) => [
2467
+ `const id = ${JSON.stringify(extensionId)};`,
2468
+ "let tag = null;",
2469
+ "export const addStyle = (css) => {",
2470
+ " if (tag === null) {",
2471
+ " for (const old of document.head.querySelectorAll('style[data-dolphy-ext]')) {",
2472
+ " if (old.getAttribute('data-dolphy-ext') === id) old.remove();",
2473
+ " }",
2474
+ " tag = document.createElement('style');",
2475
+ " tag.setAttribute('data-dolphy-ext', id);",
2476
+ " document.head.append(tag);",
2477
+ " }",
2478
+ " tag.append(css);",
2479
+ "};",
2480
+ ""
2481
+ ].join("\n");
2482
+ /**
2483
+ * Style blocks of single-file components go to the page as text: a bundle has
2484
+ * no style file, so every block, `scoped` or not, is a string the module
2485
+ * adds to the tag of the extension when it is evaluated.
2486
+ */
2487
+ const vueStylesPlugin = (extensionId) => ({
2488
+ name: "dolphy-ext:vue-styles",
2489
+ enforce: "post",
2490
+ resolveId(source) {
2491
+ return source === REGISTRY_SPECIFIER ? REGISTRY_ID : null;
2492
+ },
2493
+ load(id) {
2494
+ return id === REGISTRY_ID ? registrySource(extensionId) : null;
2495
+ },
2496
+ transform(code, id) {
2497
+ if (!VUE_REQUEST.test(id) || !STYLE_TYPE.test(id) || !INLINE.test(id)) return null;
2498
+ const exported = DEFAULT_EXPORT.exec(code)?.[1];
2499
+ if (exported === void 0) return this.error(`'${id.split("?")[0]}': a style block of a component did not become a string`);
2500
+ return {
2501
+ code: `import { addStyle } from ${JSON.stringify(REGISTRY_SPECIFIER)};\nconst css = ${exported};\naddStyle(css);\nexport default css;\n`,
2502
+ map: null
2503
+ };
2504
+ }
2505
+ });
2506
+
2507
+ //#endregion
2508
+ //#region packages/extension-tools/src/presets/vuetify-template.ts
2509
+ const COMPONENTS = "vuetify/components";
2510
+ const DIRECTIVES = "vuetify/directives";
2511
+ /** Exports of `vuetify/directives`: a template names them as `v-ripple`, `v-click-outside`. */
2512
+ const DIRECTIVE_EXPORTS = [
2513
+ "ClickOutside",
2514
+ "Intersect",
2515
+ "Mutate",
2516
+ "Resize",
2517
+ "Ripple",
2518
+ "Scroll",
2519
+ "Tooltip",
2520
+ "Touch"
2184
2521
  ];
2522
+ const VUE_FILE$1 = /\.vue(?:\?|$)/;
2523
+ /** `_resolveComponent("v-btn")`: the call of the template compiler for a tag that is not a binding of `<script setup>`. */
2524
+ const RESOLVE_CALL = /\b_resolve(Component|Directive)\(("(?:[^"\\]|\\.)*")\)/g;
2525
+ const pascalCase = (name) => name.split("-").map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join("");
2526
+ /** `v-btn` and `VBtn` → `VBtn`; the other tags are not Vuetify's. */
2527
+ const vuetifyComponentOf = (tag) => {
2528
+ if (/^v-[a-z][a-z0-9-]*$/.test(tag)) return pascalCase(tag);
2529
+ return /^V[A-Z][A-Za-z0-9]*$/.test(tag) ? tag : null;
2530
+ };
2531
+ /** `ripple` and `click-outside` → `Ripple` and `ClickOutside`. */
2532
+ const vuetifyDirectiveOf = (name) => {
2533
+ const exported = pascalCase(name);
2534
+ return DIRECTIVE_EXPORTS.includes(exported) ? exported : null;
2535
+ };
2536
+ /** `import { VBtn as _dolphy_VBtn } from "vuetify/components";` (empty for no names). */
2537
+ const importLine = (names, source) => names.size === 0 ? "" : `import { ${[...names].map((name) => `${name} as _dolphy_${name}`).join(", ")} } from ${JSON.stringify(source)};`;
2538
+ /**
2539
+ * Turns the tags of Vuetify in the templates (`<v-btn>`) into imports from
2540
+ * `vuetify/components`, and the directives (`v-ripple`) into imports from
2541
+ * `vuetify/directives`. Both are host modules: the window gives its own Vuetify
2542
+ * and there is no copy in the bundle. A name the module does not export falls
2543
+ * back to Vue's own lookup, which reports the unknown component.
2544
+ */
2545
+ const vuetifyTemplatePlugin = () => ({
2546
+ name: "dolphy-ext:vuetify-template",
2547
+ enforce: "post",
2548
+ transform(code, id) {
2549
+ if (!VUE_FILE$1.test(id) || !code.includes("_resolve")) return null;
2550
+ const result = new MagicString(code);
2551
+ const components = /* @__PURE__ */ new Set();
2552
+ const directives = /* @__PURE__ */ new Set();
2553
+ for (const match of code.matchAll(RESOLVE_CALL)) {
2554
+ const [call, kind, literal] = match;
2555
+ if (kind === void 0 || literal === void 0) continue;
2556
+ const name = JSON.parse(literal);
2557
+ const isComponent = kind === "Component";
2558
+ const exported = isComponent ? vuetifyComponentOf(name) : vuetifyDirectiveOf(name);
2559
+ if (exported === null) continue;
2560
+ (isComponent ? components : directives).add(exported);
2561
+ result.overwrite(match.index, match.index + call.length, `(_dolphy_${exported} ?? ${call})`);
2562
+ }
2563
+ const imports = [importLine(components, COMPONENTS), importLine(directives, DIRECTIVES)].filter((line) => line !== "");
2564
+ if (imports.length === 0) return null;
2565
+ result.prepend(`${imports.join(" ")} `);
2566
+ return {
2567
+ code: result.toString(),
2568
+ map: result.generateMap()
2569
+ };
2570
+ }
2571
+ });
2572
+
2573
+ //#endregion
2574
+ //#region packages/extension-tools/src/presets/vue.ts
2575
+ /**
2576
+ * `@vitejs/plugin-vue` takes "production" from `NODE_ENV` of the process, and a
2577
+ * build under `NODE_ENV=test` or `development` would write the development
2578
+ * form of the components (`__file` with a path on the build machine, a render
2579
+ * function that reads `$setup`). A bundle is always the production form.
2580
+ */
2581
+ const alwaysProduction = (plugin) => {
2582
+ const { configResolved } = plugin;
2583
+ if (typeof configResolved !== "function") throw new Error("@vitejs/plugin-vue has no configResolved hook");
2584
+ plugin.configResolved = function resolved(config) {
2585
+ return configResolved.call(this, {
2586
+ ...config,
2587
+ isProduction: true
2588
+ });
2589
+ };
2590
+ return plugin;
2591
+ };
2592
+ /**
2593
+ * Vue single-file components (`.vue`): `<script setup lang="ts">`,
2594
+ * `<template>` with Vuetify components as `<v-btn>`, `<style>` and
2595
+ * `<style scoped>`. Vue and Vuetify are the window's: the bundle imports them
2596
+ * from the host, like any other client code.
2597
+ */
2598
+ const vuePreset = {
2599
+ name: "vue",
2600
+ extensions: [".vue"],
2601
+ packages: [
2602
+ "vue",
2603
+ "@vue",
2604
+ "vuetify",
2605
+ "@vuetify"
2606
+ ],
2607
+ plugins: ({ extensionId }) => [
2608
+ alwaysProduction(vue({ customElement: true })),
2609
+ vuetifyTemplatePlugin(),
2610
+ vueStylesPlugin(extensionId)
2611
+ ]
2612
+ };
2613
+
2614
+ //#endregion
2615
+ //#region packages/extension-tools/src/presets/index.ts
2616
+ const PRESETS = [vuePreset, reactPreset];
2617
+ /** The window is a Vue application, so this preset is always on. */
2618
+ const ALWAYS_ON = vuePreset.name;
2619
+ const FRAMEWORK_NAMES = PRESETS.map((preset) => preset.name);
2620
+ const DEFAULT_FRAMEWORKS = [ALWAYS_ON];
2621
+ const isFramework = (name) => FRAMEWORK_NAMES.includes(name);
2622
+ /** `frameworks` of the config as it is used: no repeats, the always-on preset first. */
2623
+ const normalizeFrameworks = (names) => [.../* @__PURE__ */ new Set([ALWAYS_ON, ...names])];
2624
+ const presetsOf = (frameworks) => PRESETS.filter((preset) => frameworks.includes(preset.name));
2625
+ /**
2626
+ * Modules of the runtime packages of the frameworks in `frameworks`. The server
2627
+ * file shares `src/index.ts` with the client file and must not keep what it
2628
+ * imports of them (a component defined at the top level of `src/index.ts` is not
2629
+ * code of the server), so their top level does not count as code with effects
2630
+ * there: tree shaking drops the library together with the component.
2631
+ */
2632
+ const frameworkPackagePattern = (frameworks) => new RegExp(`[\\\\/]node_modules[\\\\/](?:${presetsOf(frameworks).flatMap((preset) => preset.packages).join("|")})[\\\\/]`);
2633
+ const hasJsx = (node) => {
2634
+ if (typeof node !== "object" || node === null) return false;
2635
+ if (Array.isArray(node)) return node.some(hasJsx);
2636
+ const { type } = node;
2637
+ if (type === "JSXElement" || type === "JSXFragment") return true;
2638
+ return Object.values(node).some(hasJsx);
2639
+ };
2640
+ const SOURCE_LANG = {
2641
+ ".tsx": "tsx",
2642
+ ".jsx": "jsx"
2643
+ };
2644
+ /**
2645
+ * A source file of a preset that is not on, with markup in it: without the
2646
+ * check the bundler would fail on an import of the runtime of a framework
2647
+ * that is not installed, with no word about the config.
2648
+ */
2649
+ const disabledPresetGuard = (disabled) => ({
2650
+ name: "dolphy-ext:disabled-presets",
2651
+ enforce: "pre",
2652
+ transform(code, id) {
2653
+ const file = id.split("?")[0] ?? id;
2654
+ const extension = extname(file);
2655
+ const lang = SOURCE_LANG[extension];
2656
+ if (lang === void 0) return null;
2657
+ const owner = disabled.find((preset) => preset.extensions.includes(extension));
2658
+ if (owner === void 0 || !hasJsx(parseAst(code, { lang }).body)) return null;
2659
+ return this.error(`'${file}' contains JSX, which needs the '${owner.name}' framework: add "frameworks": ["${owner.name}"] to dolphy-ext.config.json`);
2660
+ }
2661
+ });
2662
+ /** Plugins of the client build: those of the presets in `frameworks` and the check for the files of the others. */
2663
+ const presetPlugins = async (frameworks, context) => {
2664
+ const enabled = presetsOf(frameworks);
2665
+ const disabled = PRESETS.filter((preset) => !enabled.includes(preset));
2666
+ const groups = await Promise.all(enabled.map((preset) => preset.plugins(context)));
2667
+ return [disabledPresetGuard(disabled), ...groups.flat()];
2668
+ };
2669
+ /** Vite configuration of the client build with the additions of the presets in `frameworks`. */
2670
+ const presetConfig = (frameworks, config) => presetsOf(frameworks).reduce((merged, preset) => preset.options === void 0 ? merged : mergeConfig(merged, preset.options), config);
2671
+
2672
+ //#endregion
2673
+ //#region packages/extension-tools/src/server-vue.ts
2674
+ const VUE_FILE = /\.vue(?:\?|$)/;
2675
+ const STUB_PREFIX = "\0dolphy-ext:server-vue:";
2676
+ const MARK = "dolphy-ext:server-vue:";
2677
+ /** The stub as it stays in the code of a chunk (a comment with the module id may precede it). */
2678
+ const STUB_LITERAL = new RegExp(`"${MARK}(?:[^"\\\\]|\\\\.)*"`);
2679
+ /**
2680
+ * Boundary of a Node bundle: a `.vue` file is a component of the client part.
2681
+ * The server file is built without the compiler of components, and
2682
+ * `src/index.ts` may import a component for `client`, so the file is replaced
2683
+ * by a stub that tree shaking drops with the rest of the client code. A stub
2684
+ * that is still in the output means the server code uses the component: that
2685
+ * is the error, with the file in the message.
2686
+ */
2687
+ const serverVuePlugin = (root, output, state) => ({
2688
+ name: "dolphy-ext:server-vue",
2689
+ enforce: "pre",
2690
+ async resolveId(source, importer, options) {
2691
+ if (!VUE_FILE.test(source)) return null;
2692
+ const resolved = await this.resolve(source, importer, {
2693
+ ...options,
2694
+ skipSelf: true
2695
+ });
2696
+ if (resolved === null || resolved.external === true) return null;
2697
+ return {
2698
+ id: `${STUB_PREFIX}${resolved.id}`,
2699
+ moduleSideEffects: false
2700
+ };
2701
+ },
2702
+ load(id) {
2703
+ return id.startsWith(STUB_PREFIX) ? `export default ${JSON.stringify(`${MARK}${id.slice(23)}`)};\n` : null;
2704
+ },
2705
+ generateBundle(_options, bundle) {
2706
+ for (const chunk of Object.values(bundle)) {
2707
+ if (chunk.type !== "chunk") continue;
2708
+ const literal = STUB_LITERAL.exec(chunk.code)?.[0];
2709
+ if (literal === void 0) continue;
2710
+ const file = JSON.parse(literal).slice(22).split("?")[0] ?? "";
2711
+ const message = `${output} uses '${path.relative(root, file)}': a .vue component belongs to the client part, keep it out of the code of 'server'`;
2712
+ state.problem = message;
2713
+ this.error(message);
2714
+ }
2715
+ }
2716
+ });
2717
+
2718
+ //#endregion
2719
+ //#region packages/extension-tools/src/analyze.ts
2185
2720
  const SOURCE_EXTENSIONS = [
2186
2721
  ".ts",
2187
2722
  ".mts",
2723
+ ".tsx",
2188
2724
  ".js",
2189
- ".mjs"
2725
+ ".mjs",
2726
+ ".jsx"
2190
2727
  ];
2728
+ const langOf = (file) => {
2729
+ if (/\.tsx$/.test(file)) return "tsx";
2730
+ if (/\.jsx$/.test(file)) return "jsx";
2731
+ return /\.[mc]?ts$/.test(file) ? "ts" : "js";
2732
+ };
2191
2733
  const MAX_HOPS = 16;
2192
2734
  const child = (node, key) => node[key];
2193
2735
  const children = (node, key) => node[key];
@@ -2205,7 +2747,7 @@ const unwrap = (node) => {
2205
2747
  };
2206
2748
  /** Name from an identifier or string literal (`{ a: 1 }`, `{ 'a-b': 1 }`, `export { x as 'y' }`). */
2207
2749
  const nameOf = (node) => String(node.type === "Identifier" ? node.name : node.value);
2208
- const isFile = async (file) => (await stat(file).catch(() => null))?.isFile() === true;
2750
+ const isFile$1 = async (file) => (await stat(file).catch(() => null))?.isFile() === true;
2209
2751
  /** Relative import → source file; packages and `node:*` are not analyzed. */
2210
2752
  const resolveRelative = async (from, specifier) => {
2211
2753
  if (!specifier.startsWith(".")) return null;
@@ -2216,28 +2758,13 @@ const resolveRelative = async (from, specifier) => {
2216
2758
  ...SOURCE_EXTENSIONS.map((extension) => `${withoutJs}${extension}`),
2217
2759
  ...SOURCE_EXTENSIONS.map((extension) => path.join(base, `index${extension}`))
2218
2760
  ];
2219
- for (const candidate of candidates) if (await isFile(candidate)) return candidate;
2761
+ for (const candidate of candidates) if (await isFile$1(candidate)) return candidate;
2220
2762
  return null;
2221
2763
  };
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
2764
  /**
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.
2765
+ * Static analysis of `src/index.ts` without executing author code: whether it
2766
+ * exports `server` and `client`. Exports are found through local constants
2767
+ * and relative re-exports.
2241
2768
  */
2242
2769
  const analyzeIndex = async (indexFile) => {
2243
2770
  const cache = /* @__PURE__ */ new Map();
@@ -2245,7 +2772,7 @@ const analyzeIndex = async (indexFile) => {
2245
2772
  let parsed = cache.get(file);
2246
2773
  if (parsed === void 0) {
2247
2774
  parsed = readFile(file, "utf8").then((source) => {
2248
- const lang = /\.[mc]?ts$/.test(file) ? "ts" : "js";
2775
+ const lang = langOf(file);
2249
2776
  return {
2250
2777
  file,
2251
2778
  source,
@@ -2346,58 +2873,13 @@ const analyzeIndex = async (indexFile) => {
2346
2873
  return null;
2347
2874
  }
2348
2875
  };
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;
2876
+ const [hasServer, hasClient] = await Promise.all([walk.export(indexFile, "server", 0).then((found) => found !== null), walk.export(indexFile, "client", 0).then((found) => found !== null)]);
2378
2877
  return {
2379
2878
  files: [...cache.keys()],
2380
- hasHost,
2381
- records: {
2382
- views,
2383
- panels,
2384
- markdown
2385
- }
2879
+ hasServer,
2880
+ hasClient
2386
2881
  };
2387
2882
  };
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
2883
  const isRecord$1 = (value) => typeof value === "object" && value !== null;
2402
2884
  const collectImports = (node, found) => {
2403
2885
  if (Array.isArray(node)) {
@@ -2434,101 +2916,26 @@ const stripBareImports = (code, isDropped) => {
2434
2916
 
2435
2917
  //#endregion
2436
2918
  //#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
2919
  /** Path of the virtual shim: the build's `lib.entry` and the plugin's `resolveId`; there is no file on disk. */
2440
2920
  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);
2921
+ /** Export of `src/index.ts` the output file is built from. */
2922
+ const exportOf = (output) => output.kind;
2454
2923
  /**
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.
2924
+ * Source of the output file's virtual shim: the file's only export is the
2925
+ * entry the app loads (`server` or `client`).
2459
2926
  */
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
- };
2927
+ const shimSource = (output, indexFile) => `export { ${exportOf(output)} } from ${JSON.stringify(indexFile)};\n`;
2520
2928
  const isNodeSpecifier = (specifier, external) => specifier.startsWith("node:") || builtinModules.includes(specifier) || external.some((name) => specifier === name || specifier.startsWith(`${name}/`));
2521
2929
  /**
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:*`.
2930
+ * Build plugin for a single output file: the virtual shim, the check that
2931
+ * `src/index.ts` still exports what the file is built from, and the boundary
2932
+ * rules: the client file takes no Node.js modules, the server file no `vue`
2933
+ * or `vuetify`.
2525
2934
  */
2526
2935
  const shimPlugin = ({ project, output, state }) => {
2527
- const indexSource = project.indexSource;
2528
2936
  const root = realpathSync(project.root);
2529
- const indexFile = path.join(root, indexSource);
2937
+ const indexFile = path.join(root, project.indexSource);
2530
2938
  const shimId = shimEntry(project, output);
2531
- let sites = [];
2532
2939
  const fail = (context, message) => {
2533
2940
  state.problem = message;
2534
2941
  return context.error(message);
@@ -2538,44 +2945,35 @@ const shimPlugin = ({ project, output, state }) => {
2538
2945
  enforce: "pre",
2539
2946
  resolveId(specifier) {
2540
2947
  if (specifier === shimId) return shimId;
2541
- if (output.kind === "browser" && isNodeSpecifier(specifier, project.external)) return {
2948
+ if (output.kind === "client" && isNodeSpecifier(specifier, project.external)) return {
2542
2949
  id: specifier,
2543
2950
  external: true
2544
2951
  };
2545
2952
  return null;
2546
2953
  },
2547
2954
  generateBundle(_options, bundle) {
2548
- if (output.kind !== "browser") return;
2549
2955
  for (const chunk of Object.values(bundle)) {
2550
2956
  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`);
2957
+ if (output.kind === "server") {
2958
+ chunk.code = stripBareImports(chunk.code, (specifier) => WINDOW_SPECIFIER.test(specifier));
2959
+ const leaked = [...importsOf(chunk.code)].find((specifier) => WINDOW_SPECIFIER.test(specifier));
2960
+ if (leaked !== void 0) fail(this, `${output.output} imports '${leaked}': vue and vuetify belong to the client part, keep them out of the code of 'server'`);
2961
+ } else {
2962
+ chunk.code = stripBareImports(chunk.code, (specifier) => isNodeSpecifier(specifier, project.external));
2963
+ const leaked = [...importsOf(chunk.code)].find((specifier) => isNodeSpecifier(specifier, project.external));
2964
+ if (leaked !== void 0) fail(this, `${output.output} imports '${leaked}': Node.js modules and the 'external' packages of dolphy-ext.config.json belong to the server part, keep them out of the code of 'client'`);
2965
+ }
2554
2966
  }
2555
2967
  },
2556
2968
  async buildStart() {
2557
2969
  this.addWatchFile(indexFile);
2558
- const analysis = await analyzeIndex(indexFile).catch((error) => fail(this, `${indexSource} cannot be read: ${String(error)}`));
2970
+ const analysis = await analyzeIndex(indexFile).catch((error) => fail(this, `${project.indexSource} cannot be read: ${String(error)}`));
2559
2971
  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
- });
2972
+ const name = exportOf(output);
2973
+ if (!(output.kind === "server" ? analysis.hasServer : analysis.hasClient)) fail(this, `${project.indexSource} does not export '${name}', but ${output.output} is built from it`);
2570
2974
  },
2571
2975
  load(id) {
2572
2976
  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
2977
  }
2580
2978
  };
2581
2979
  };
@@ -2586,13 +2984,23 @@ const errorText$2 = (error) => error instanceof Error ? error.message : String(e
2586
2984
  const nodeExternal = (external) => [
2587
2985
  /^node:/,
2588
2986
  ...builtinModules,
2987
+ WINDOW_SPECIFIER,
2589
2988
  ...external
2590
2989
  ];
2990
+ /** Production flags for browser files: the page has no `process`, and Vue and Vuetify come from the app. */
2991
+ const BROWSER_DEFINE = {
2992
+ "process.env.NODE_ENV": "\"production\"",
2993
+ "process.env.VITE_LOGGER_ENABLED": "undefined",
2994
+ __VUE_OPTIONS_API__: "false",
2995
+ __VUE_PROD_DEVTOOLS__: "false",
2996
+ __VUE_PROD_HYDRATION_MISMATCH_DETAILS__: "false"
2997
+ };
2591
2998
  const bundleConfig = (project, entry, output, outDir, isNode) => ({
2592
2999
  root: project.root,
2593
3000
  configFile: false,
2594
3001
  publicDir: false,
2595
3002
  base: "./",
3003
+ define: isNode ? {} : BROWSER_DEFINE,
2596
3004
  logLevel: "warn",
2597
3005
  customLogger: {
2598
3006
  ...createLogger("warn"),
@@ -2603,6 +3011,7 @@ const bundleConfig = (project, entry, output, outDir, isNode) => ({
2603
3011
  outDir,
2604
3012
  emptyOutDir: false,
2605
3013
  minify: false,
3014
+ cssMinify: true,
2606
3015
  copyPublicDir: false,
2607
3016
  assetsInlineLimit: ASSETS_INLINE_LIMIT,
2608
3017
  lib: {
@@ -2616,19 +3025,54 @@ const bundleConfig = (project, entry, output, outDir, isNode) => ({
2616
3025
  }
2617
3026
  }
2618
3027
  });
2619
- const outputJob = (project, output, outDir) => {
3028
+ /** Constants of the extension API are `Object.freeze({…})` calls: without this a browser bundle keeps all of them. */
3029
+ const BROWSER_PURE_CALLS = ["Object.freeze"];
3030
+ /**
3031
+ * Calls of the client part that the server file shares `src/index.ts` with: a
3032
+ * call whose result the server does not use is dropped with everything it
3033
+ * refers to (a component and the library behind it). The `__NO_SIDE_EFFECTS__`
3034
+ * mark of the SDK does not do it for the SDK as published: the bundler keeps
3035
+ * the call, and with it React, in `main.mjs`.
3036
+ */
3037
+ const SERVER_PURE_CALLS = [
3038
+ "defineComponent",
3039
+ "defineAsyncComponent",
3040
+ "defineClient",
3041
+ "defineMountable",
3042
+ "reactComponent"
3043
+ ];
3044
+ /** `presets`: plugins of the frameworks of the client file (the server file and workers are built without them). */
3045
+ const outputJob = (project, output, outDir, presets) => {
2620
3046
  const state = { problem: null };
2621
- const config = bundleConfig(project, shimEntry(project, output), output.output, outDir, output.kind === "host");
3047
+ const isBrowser = output.kind === "client";
3048
+ const base = bundleConfig(project, shimEntry(project, output), output.output, outDir, !isBrowser);
3049
+ const config = isBrowser ? presetConfig(project.frameworks, base) : base;
3050
+ const frameworkPackages = frameworkPackagePattern(project.frameworks);
3051
+ config.build = {
3052
+ ...config.build,
3053
+ rolldownOptions: {
3054
+ ...config.build?.rolldownOptions,
3055
+ treeshake: isBrowser ? { manualPureFunctions: BROWSER_PURE_CALLS } : {
3056
+ moduleSideEffects: (id) => !frameworkPackages.test(id),
3057
+ manualPureFunctions: SERVER_PURE_CALLS
3058
+ }
3059
+ }
3060
+ };
2622
3061
  return {
2623
3062
  output: output.output,
2624
- label: `${output.output} (${exportsOf(output).join(", ")} from ${project.indexSource})`,
3063
+ label: `${output.output} (${exportOf(output)} from ${project.indexSource})`,
2625
3064
  config: {
2626
3065
  ...config,
2627
- plugins: [shimPlugin({
2628
- project,
2629
- output,
2630
- state
2631
- }), assetsPlugin(state)]
3066
+ plugins: [
3067
+ shimPlugin({
3068
+ project,
3069
+ output,
3070
+ state
3071
+ }),
3072
+ ...isBrowser ? [hostModulesPlugin()] : [serverVuePlugin(realpathSync(project.root), output.output, state)],
3073
+ ...presets,
3074
+ assetsPlugin(state)
3075
+ ]
2632
3076
  },
2633
3077
  state
2634
3078
  };
@@ -2640,22 +3084,22 @@ const workerJob = (project, entry, outDir) => {
2640
3084
  label: `${entry.output} (${entry.source})`,
2641
3085
  config: {
2642
3086
  ...bundleConfig(project, path.resolve(project.root, entry.source), entry.output, outDir, true),
2643
- plugins: [assetsPlugin(state)]
3087
+ plugins: [serverVuePlugin(realpathSync(project.root), entry.output, state), assetsPlugin(state)]
2644
3088
  },
2645
3089
  state
2646
3090
  };
2647
3091
  };
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)),
3092
+ /** Project bundles: the server part, the client part, workers. */
3093
+ const jobsOf = async (project, outDir) => [
3094
+ ...project.server === null ? [] : [outputJob(project, project.server, outDir, [])],
3095
+ ...project.client === null ? [] : [outputJob(project, project.client, outDir, await presetPlugins(project.frameworks, { extensionId: project.manifest.id }))],
2652
3096
  ...project.workerEntries.map((entry) => workerJob(project, entry, outDir))
2653
3097
  ];
2654
3098
  /** Failure cause: a reconciliation or guard error named by the plugin, or the bundler's text. */
2655
3099
  const failureDetail = (job, error) => job.state.problem ?? errorText$2(error);
2656
3100
  /** Builds all bundles (one at a time, no shared chunks) into `outDir`. */
2657
3101
  const bundleAll = async (project, outDir) => {
2658
- for (const job of jobsOf(project, outDir)) {
3102
+ for (const job of await jobsOf(project, outDir)) {
2659
3103
  job.state.problem = null;
2660
3104
  try {
2661
3105
  await build(job.config);
@@ -2665,66 +3109,6 @@ const bundleAll = async (project, outDir) => {
2665
3109
  }
2666
3110
  };
2667
3111
 
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");
2707
- };
2708
- /**
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.
2712
- */
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
2725
- };
2726
- };
2727
-
2728
3112
  //#endregion
2729
3113
  //#region packages/extension-tools/src/project.ts
2730
3114
  const MANIFEST_FILE = "extension.json";
@@ -2732,9 +3116,8 @@ const CONFIG_FILE = "dolphy-ext.config.json";
2732
3116
  const DEFAULT_OUT_DIR = "dist-ext";
2733
3117
  const INDEX_SOURCE = "src/index.ts";
2734
3118
  const stripDot = (file) => file.replace(/^\.\//, "");
2735
- const stripExtension = (file) => file.slice(0, file.length - path.posix.extname(file).length);
2736
- const entrySource = (file) => `src/${path.posix.basename(stripExtension(file))}.ts`;
2737
3119
  const errorText$1 = (error) => error instanceof Error ? error.message : String(error);
3120
+ const isFile = async (file) => (await stat(file).catch(() => null))?.isFile() === true;
2738
3121
  const isStringRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value) && Object.values(value).every((item) => typeof item === "string");
2739
3122
  const isStringArray = (value) => Array.isArray(value) && value.every((item) => typeof item === "string");
2740
3123
  const readJson = async (file, subject) => {
@@ -2744,11 +3127,19 @@ const readJson = async (file, subject) => {
2744
3127
  throw new BuildError(`${path.basename(file)} is unreadable: ${errorText$1(error)}`, subject);
2745
3128
  }
2746
3129
  };
3130
+ /** `frameworks` of the config: names of known presets (an error lists the known ones); `vue` is always on. */
3131
+ const frameworksOf = (value, root) => {
3132
+ if (!isStringArray(value)) throw new BuildError(`${CONFIG_FILE}: 'frameworks' must be an array of framework names (${FRAMEWORK_NAMES.join(", ")})`, root);
3133
+ const unknown = value.find((name) => !isFramework(name));
3134
+ if (unknown !== void 0) throw new BuildError(`${CONFIG_FILE}: unknown framework '${unknown}' in 'frameworks': the known frameworks are ${FRAMEWORK_NAMES.join(", ")}`, root);
3135
+ return normalizeFrameworks(value);
3136
+ };
2747
3137
  const readConfig = async (root) => {
2748
3138
  const file = path.join(root, CONFIG_FILE);
2749
3139
  if (!((await stat(file).catch(() => null))?.isFile() === true)) return {
2750
3140
  nodeEntries: {},
2751
- external: []
3141
+ external: [],
3142
+ frameworks: normalizeFrameworks(DEFAULT_FRAMEWORKS)
2752
3143
  };
2753
3144
  const config = await readJson(file, root);
2754
3145
  const nodeEntries = config.nodeEntries ?? {};
@@ -2756,52 +3147,16 @@ const readConfig = async (root) => {
2756
3147
  if (!isStringRecord(nodeEntries) || !isStringArray(external)) throw new BuildError(`${CONFIG_FILE}: 'nodeEntries' must map output files to sources and 'external' must be a string array`, root);
2757
3148
  return {
2758
3149
  nodeEntries,
2759
- external
3150
+ external,
3151
+ frameworks: frameworksOf(config.frameworks ?? DEFAULT_FRAMEWORKS, root)
2760
3152
  };
2761
3153
  };
2762
- const schemaPathsOf = (manifest) => {
2763
- const paths = manifest.contributes.exerciseTypes.flatMap((type) => [type.specSchema, type.answerSchema]).filter((schema) => typeof schema === "string").map(stripDot);
2764
- return [...new Set(paths)];
2765
- };
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]) => ({
3154
+ /** Workers are built only together with a server part. */
3155
+ const workerEntriesOf = (config, hasServer) => hasServer ? Object.entries(config.nodeEntries).map(([output, source]) => ({
2768
3156
  source,
2769
3157
  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. */
3158
+ })) : [];
3159
+ /** Reads the source manifest, project config and exports of `src/index.ts`, computes the output files. */
2805
3160
  const loadProject = async (rootDir) => {
2806
3161
  const root = path.resolve(rootDir);
2807
3162
  const manifestFile = path.join(root, MANIFEST_FILE);
@@ -2813,25 +3168,41 @@ const loadProject = async (rootDir) => {
2813
3168
  }
2814
3169
  const raw = await readJson(manifestFile, root);
2815
3170
  const parsed = parseManifest(raw);
2816
- if (!parsed.ok) throw new BuildError(parsed.message, root);
3171
+ if (!parsed.ok) throw new BuildError(formatDiagnostic(parsed.diagnostic), root);
2817
3172
  const { manifest } = parsed;
2818
3173
  const config = await readConfig(root);
2819
- const host = manifest.main === null ? null : {
2820
- kind: "host",
2821
- output: stripDot(manifest.main)
2822
- };
2823
- const browserOutputs = browserOutputsOf(manifest);
3174
+ const indexFile = path.join(root, INDEX_SOURCE);
3175
+ if (!await isFile(indexFile)) throw new BuildError(`'${INDEX_SOURCE}' is not found: an extension is built from one entry file that exports 'server' and/or 'client'`, manifest.id);
3176
+ const analysis = await analyzeIndex(indexFile).catch((error) => {
3177
+ throw new BuildError(`${INDEX_SOURCE} cannot be read: ${errorText$1(error)}`, manifest.id);
3178
+ });
3179
+ if (!analysis.hasServer && !analysis.hasClient) throw new BuildError(`${INDEX_SOURCE} exports neither 'server' nor 'client': export const server = defineServer(…) and/or export const client = defineClient(…)`, manifest.id);
2824
3180
  return {
2825
3181
  root,
2826
3182
  manifest,
2827
3183
  manifestBytes,
2828
- indexSource: host !== null || browserOutputs.length > 0 ? INDEX_SOURCE : null,
2829
- host,
2830
- browserOutputs,
2831
- workerEntries: workerEntriesOf(manifest, config),
3184
+ indexSource: INDEX_SOURCE,
3185
+ server: analysis.hasServer ? {
3186
+ kind: "server",
3187
+ output: stripDot(DEFAULT_MAIN)
3188
+ } : null,
3189
+ client: analysis.hasClient ? {
3190
+ kind: "client",
3191
+ output: stripDot(DEFAULT_CLIENT)
3192
+ } : null,
3193
+ workerEntries: workerEntriesOf(config, analysis.hasServer),
2832
3194
  external: config.external,
2833
- schemaPaths: schemaPathsOf(manifest)
3195
+ frameworks: config.frameworks
3196
+ };
3197
+ };
3198
+ /** Bytes of the built `extension.json`: the source manifest with `main` and `client` naming the built files (`null` — no such part). */
3199
+ const builtManifestText = (project) => {
3200
+ const built = {
3201
+ ...JSON.parse(project.manifestBytes.toString("utf8")),
3202
+ main: project.server === null ? null : `./${project.server.output}`,
3203
+ client: project.client === null ? null : `./${project.client.output}`
2834
3204
  };
3205
+ return `${JSON.stringify(built, null, 2)}\n`;
2835
3206
  };
2836
3207
 
2837
3208
  //#endregion
@@ -2890,12 +3261,19 @@ const createReporter = (onReport, settleMs = SETTLE_MS) => {
2890
3261
  }
2891
3262
  };
2892
3263
  };
3264
+ /**
3265
+ * Watch output is for development: inline source maps let DevTools show the
3266
+ * author's TypeScript for browser bundles. A normal build and a catalog build
3267
+ * never write them (`bundleConfig` leaves `sourcemap` unset; `catalog check`
3268
+ * rejects them in a submission, CHECK-025).
3269
+ */
2893
3270
  const watchJob = async (job, reporter, isFailFast, failedFirst) => {
2894
3271
  const started = await build({
2895
3272
  ...job.config,
2896
3273
  build: {
2897
3274
  ...job.config.build,
2898
- watch: {}
3275
+ watch: {},
3276
+ sourcemap: "inline"
2899
3277
  }
2900
3278
  });
2901
3279
  if (!isWatcher(started)) throw new Error(`watch mode is not available for '${job.output}'`);
@@ -2943,7 +3321,7 @@ const watchAll = async (project, outDir, reporter, isFailFast) => {
2943
3321
  const failedFirst = /* @__PURE__ */ new Set();
2944
3322
  try {
2945
3323
  const firsts = [];
2946
- for (const job of jobsOf(project, outDir)) {
3324
+ for (const job of await jobsOf(project, outDir)) {
2947
3325
  const { watcher, first } = await watchJob(job, reporter, isFailFast, failedFirst);
2948
3326
  watchers.push(watcher);
2949
3327
  firsts.push(first);
@@ -2961,7 +3339,7 @@ const watchAll = async (project, outDir, reporter, isFailFast) => {
2961
3339
 
2962
3340
  //#endregion
2963
3341
  //#region packages/extension-tools/src/index.ts
2964
- const STATIC_DIRS = ["schema", "assets"];
3342
+ const STATIC_DIRS = ["assets"];
2965
3343
  /** Editors write a file in several events: manifest reload waits for quiet. */
2966
3344
  const MANIFEST_SETTLE_MS = 100;
2967
3345
  const isPresent = async (file) => await stat(file).catch(() => null) !== null;
@@ -2969,29 +3347,11 @@ const targetDir = (project, options) => {
2969
3347
  const base = options.outDir ?? path.join(project.root, "dist-ext");
2970
3348
  return path.join(path.resolve(base), project.manifest.id);
2971
3349
  };
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
- };
2983
3350
  const requireSources = async (project) => {
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);
2990
- }
2991
3351
  for (const entry of project.workerEntries) if (!await isPresent(path.join(project.root, entry.source))) throw new BuildError(`entry source '${entry.source}' for '${entry.output}' is not found`, project.manifest.id);
2992
3352
  };
2993
3353
  const copyStatic = async (project, dir) => {
2994
- await writeFile(path.join(dir, MANIFEST_FILE), project.manifestBytes);
3354
+ await writeFile(path.join(dir, MANIFEST_FILE), builtManifestText(project));
2995
3355
  for (const name of STATIC_DIRS) {
2996
3356
  const from = path.join(project.root, name);
2997
3357
  if (await isPresent(from)) await cp(from, path.join(dir, name), { recursive: true });
@@ -3003,12 +3363,6 @@ const copyStatic = async (project, dir) => {
3003
3363
  await mkdir(path.dirname(path.join(dir, icon)), { recursive: true });
3004
3364
  await cp(from, path.join(dir, icon));
3005
3365
  }
3006
- for (const schema of project.schemaPaths) {
3007
- const from = path.join(project.root, schema);
3008
- if (!await isPresent(from)) throw new BuildError(`schema '${schema}' referenced by the manifest is not found`, project.manifest.id);
3009
- await mkdir(path.dirname(path.join(dir, schema)), { recursive: true });
3010
- await cp(from, path.join(dir, schema));
3011
- }
3012
3366
  };
3013
3367
  const listFiles = async (dir, prefix = "") => {
3014
3368
  const entries = await readdir(path.join(dir, prefix), { withFileTypes: true });
@@ -3025,9 +3379,10 @@ const validateExtension = async (dir) => {
3025
3379
  });
3026
3380
  if (!result.ok) return {
3027
3381
  ok: false,
3028
- problems: [result.message]
3382
+ problems: [formatDiagnostic(result.diagnostic)]
3029
3383
  };
3030
- const problems = (await assetFindings(root, await listFiles(root))).map(({ path: file, message }) => `${file}: ${message}`);
3384
+ const files = await listFiles(root);
3385
+ const problems = (await assetFindings(root, files)).map(({ path: file, message }) => `${file}: ${message}`);
3031
3386
  return {
3032
3387
  ok: problems.length === 0,
3033
3388
  problems
@@ -3037,11 +3392,8 @@ const assertValid = async (project, dir) => {
3037
3392
  const { ok, problems } = await validateExtension(dir);
3038
3393
  if (!ok) throw new BuildError(problems.join("; "), project.manifest.id);
3039
3394
  };
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));
3042
3395
  const prepare = async (options) => {
3043
3396
  const project = await loadProject(options.root);
3044
- await writeIds(project);
3045
3397
  await requireSources(project);
3046
3398
  const dir = targetDir(project, options);
3047
3399
  await rm(dir, {
@@ -3081,17 +3433,20 @@ const watchExtension = async (options) => {
3081
3433
  const { logger } = options;
3082
3434
  let project = first;
3083
3435
  let dir = firstDir;
3436
+ let current = null;
3084
3437
  const reporter = createReporter((report) => reportTo(project.manifest.id, logger)(report));
3085
- let current = await watchAll(project, dir, reporter, true);
3086
- try {
3087
- await copyStatic(project, dir);
3088
- await assertValid(project, dir);
3089
- } catch (error) {
3090
- await current.close();
3091
- reporter.close();
3092
- throw error;
3093
- }
3094
- const result = await resultOf$1(project, dir);
3438
+ const start = async () => {
3439
+ const started = await watchAll(project, dir, reporter, true);
3440
+ current = started;
3441
+ try {
3442
+ await copyStatic(project, dir);
3443
+ await assertValid(project, dir);
3444
+ } catch (error) {
3445
+ await started.close();
3446
+ throw error;
3447
+ }
3448
+ return resultOf$1(project, dir);
3449
+ };
3095
3450
  const reload = async () => {
3096
3451
  const previous = project.manifestBytes;
3097
3452
  let next;
@@ -3109,7 +3464,6 @@ const watchExtension = async (options) => {
3109
3464
  force: true
3110
3465
  });
3111
3466
  try {
3112
- await writeIds(next);
3113
3467
  await requireSources(next);
3114
3468
  project = next;
3115
3469
  dir = targetDir(project, options);
@@ -3125,19 +3479,32 @@ const watchExtension = async (options) => {
3125
3479
  }
3126
3480
  };
3127
3481
  let queue = Promise.resolve();
3128
- let timer = null;
3129
- const manifestWatcher = watch(project.root, (_event, name) => {
3482
+ let timer;
3483
+ const manifestWatcher = watch(first.root, (_event, name) => {
3130
3484
  if (name !== "extension.json") return;
3131
- if (timer !== null) clearTimeout(timer);
3485
+ clearTimeout(timer);
3132
3486
  timer = setTimeout(() => {
3133
3487
  queue = queue.then(reload);
3134
3488
  }, MANIFEST_SETTLE_MS);
3135
3489
  });
3490
+ const stopWatching = () => {
3491
+ manifestWatcher.close();
3492
+ clearTimeout(timer);
3493
+ };
3494
+ const started = start();
3495
+ queue = started.then(() => void 0, () => void 0);
3496
+ let result;
3497
+ try {
3498
+ result = await started;
3499
+ } catch (error) {
3500
+ stopWatching();
3501
+ reporter.close();
3502
+ throw error;
3503
+ }
3136
3504
  return {
3137
3505
  result,
3138
3506
  close: async () => {
3139
- manifestWatcher.close();
3140
- if (timer !== null) clearTimeout(timer);
3507
+ stopWatching();
3141
3508
  await queue;
3142
3509
  reporter.close();
3143
3510
  await current?.close();
@@ -3179,6 +3546,111 @@ const createGithubChecker = (options = {}) => {
3179
3546
  };
3180
3547
  };
3181
3548
 
3549
+ //#endregion
3550
+ //#region packages/extension-tools/src/lint/bundle.ts
3551
+ const CODE_FILE = /\.(?:mjs|js|cjs)$/;
3552
+ const OBFUSCATION_MIN_BYTES = 20480;
3553
+ const OBFUSCATION_LINE_LENGTH = 500;
3554
+ const OBFUSCATION_IDENTIFIERS = 20;
3555
+ /**
3556
+ * `eval(…)` and `new Function(…)`, except `new Function("")`: its body is
3557
+ * empty, nothing runs. `zod` makes that call to find out whether code
3558
+ * generation is allowed, so every bundle that validates with `zod` has it.
3559
+ */
3560
+ const DYNAMIC_EXECUTION = /(?<![\w$])(?:eval\s*\(|new\s+Function\s*\((?!\s*(?:""|''|``)\s*\)))/;
3561
+ const HEX_IDENTIFIER = /(?<![\w$])_0x[0-9a-f]{3,}(?![\w$])/gi;
3562
+ const SOURCE_MAP = /\/\/[#@]\s*sourceMappingURL=/;
3563
+ const listCodeFiles = async (dir, prefix = "") => {
3564
+ const entries = await readdir(path.join(dir, prefix), { withFileTypes: true });
3565
+ return (await Promise.all(entries.map(async (entry) => {
3566
+ const relative = prefix === "" ? entry.name : `${prefix}/${entry.name}`;
3567
+ if (entry.isDirectory()) return listCodeFiles(dir, relative);
3568
+ return entry.isFile() && CODE_FILE.test(entry.name) ? [relative] : [];
3569
+ }))).flat().sort();
3570
+ };
3571
+ /** Code files (`.mjs`, `.js`, `.cjs`) of a built extension directory. */
3572
+ const readBundleFiles = async (dir) => Promise.all((await listCodeFiles(dir)).map(async (file) => ({
3573
+ path: file,
3574
+ text: await readFile(path.join(dir, file), "utf8")
3575
+ })));
3576
+ const isObfuscated = (text) => {
3577
+ const size = Buffer.byteLength(text);
3578
+ if (size >= OBFUSCATION_MIN_BYTES) {
3579
+ const average = size / text.split("\n").length;
3580
+ if (average > OBFUSCATION_LINE_LENGTH) return `average line length is ${Math.round(average)} characters`;
3581
+ }
3582
+ const identifiers = new Set(text.match(HEX_IDENTIFIER) ?? []);
3583
+ return identifiers.size >= OBFUSCATION_IDENTIFIERS ? `${identifiers.size} identifiers of the form _0x1a2b` : null;
3584
+ };
3585
+ /**
3586
+ * Heuristics over the built code (`CHECK-022`, `CHECK-023`, `CHECK-025`). They
3587
+ * read the whole bundle, dependencies included, so only the source map is an
3588
+ * error.
3589
+ */
3590
+ const bundleFindings = (files) => {
3591
+ const findings = [];
3592
+ for (const { path: field, text } of files) {
3593
+ if (DYNAMIC_EXECUTION.test(text)) findings.push({
3594
+ ruleId: "CHECK-022",
3595
+ severity: "warning",
3596
+ field,
3597
+ message: "dynamic code execution (eval or new Function)"
3598
+ });
3599
+ const obfuscation = isObfuscated(text);
3600
+ if (obfuscation !== null) findings.push({
3601
+ ruleId: "CHECK-023",
3602
+ severity: "warning",
3603
+ field,
3604
+ message: `looks obfuscated: ${obfuscation}`
3605
+ });
3606
+ if (SOURCE_MAP.test(text)) findings.push({
3607
+ ruleId: "CHECK-025",
3608
+ severity: "error",
3609
+ field,
3610
+ message: "embedded source map: the catalog builds without maps"
3611
+ });
3612
+ }
3613
+ return findings;
3614
+ };
3615
+
3616
+ //#endregion
3617
+ //#region packages/extension-tools/src/lint/manifest.ts
3618
+ const MIN_DESCRIPTION_LENGTH = 20;
3619
+ /** `CHECK-019`: a description is present but too short to tell what the extension does. */
3620
+ const shortDescription = (description) => description !== null && description.trim() !== "" && description.trim().length < 20 ? [{
3621
+ severity: "warning",
3622
+ field: "description",
3623
+ message: `description is shorter than ${20} characters: say what the extension does`
3624
+ }] : [];
3625
+ const missing$1 = (value) => value === null || value.trim() === "";
3626
+ /** Publication metadata of a project: every problem is a warning. */
3627
+ const manifestFindings = (fields) => {
3628
+ const findings = [];
3629
+ for (const field of ["name", "author"]) if (missing$1(fields[field])) findings.push({
3630
+ ruleId: "CHECK-003",
3631
+ severity: "warning",
3632
+ field,
3633
+ message: `'${field}' is not set: the catalog requires it`
3634
+ });
3635
+ if (missing$1(fields.description)) findings.push({
3636
+ ruleId: "CHECK-003",
3637
+ severity: "warning",
3638
+ field: "description",
3639
+ message: "'description' is not set: the catalog requires it"
3640
+ });
3641
+ for (const finding of shortDescription(fields.description)) findings.push({
3642
+ ruleId: "CHECK-019",
3643
+ ...finding
3644
+ });
3645
+ if (fields.tags === null || fields.tags.length === 0) findings.push({
3646
+ ruleId: "LINT-001",
3647
+ severity: "warning",
3648
+ field: "tags",
3649
+ message: "'tags' is not set: the catalog lists the extension without tags"
3650
+ });
3651
+ return findings;
3652
+ };
3653
+
3182
3654
  //#endregion
3183
3655
  //#region packages/extension-tools/src/catalog/rules.ts
3184
3656
  const MAX_SOURCE_FILES = 200;
@@ -3388,6 +3860,91 @@ const iconFile = {
3388
3860
  return problem === null ? [] : [error("icon", problem)];
3389
3861
  }
3390
3862
  };
3863
+ const shortDescriptionRule = {
3864
+ id: "CHECK-019",
3865
+ title: "description is at least 20 characters",
3866
+ run: ({ declared }) => shortDescription(declared?.description ?? null).map((finding) => warning(finding.field, finding.message))
3867
+ };
3868
+ const MAX_CHANGELOG_BYTES = 65536;
3869
+ const escapeRegExp = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
3870
+ /** Why the bytes are no acceptable `CHANGELOG.md` (over 64 KiB, not UTF-8, NUL); `null` — fine. */
3871
+ const changelogProblem = (bytes) => {
3872
+ if (bytes.length > 65536) return `${bytes.length} bytes exceed the limit of ${MAX_CHANGELOG_BYTES}`;
3873
+ try {
3874
+ return new TextDecoder("utf-8", { fatal: true }).decode(bytes).includes("\0") ? "contains NUL characters" : null;
3875
+ } catch {
3876
+ return "is not valid UTF-8";
3877
+ }
3878
+ };
3879
+ /** CHANGELOG.md: up to 64 KiB, UTF-8 without NUL; a missing section of the manifest version is a warning. */
3880
+ const changelog = {
3881
+ id: "CHECK-030",
3882
+ title: "CHANGELOG.md is up to 64 KiB of UTF-8 text and has a section of the current version",
3883
+ run: async ({ readBytes, manifest }) => {
3884
+ const bytes = await readBytes("CHANGELOG.md");
3885
+ if (bytes === null) return [];
3886
+ const problem = changelogProblem(bytes);
3887
+ if (problem !== null) return [error("CHANGELOG.md", problem)];
3888
+ const text = new TextDecoder().decode(bytes);
3889
+ if (manifest === null) return [];
3890
+ return new RegExp(`^##[ \\t]+\\[?v?${escapeRegExp(manifest.version)}\\]?(?:[ \\t]|$)`, "m").test(text) ? [] : [warning("CHANGELOG.md", `has no '## ${manifest.version}' section for the current version`)];
3891
+ }
3892
+ };
3893
+ const firstPublisherOwnsId = {
3894
+ id: "CHECK-021",
3895
+ title: "the id is not published under another author",
3896
+ run: ({ declared, published }) => {
3897
+ const author = declared?.author;
3898
+ if (!author || published === void 0) return [];
3899
+ return published.author.toLowerCase() === author.toLowerCase() ? [] : [error("author", `id '${published.id}' is published by '${published.author}': the first publisher owns the id`)];
3900
+ }
3901
+ };
3902
+ const NO_BUNDLE = "built version is not found: the bundle heuristics are skipped";
3903
+ /** `CHECK-022`…`CHECK-025`: heuristics over the built version; silent without `--built`. */
3904
+ const bundleRule = (id, title, missingBundle = false) => ({
3905
+ id,
3906
+ title,
3907
+ run: async ({ bundleDir, manifest }) => {
3908
+ if (bundleDir === null || manifest === null) return [];
3909
+ const files = await readBundleFiles(bundleDir).catch(() => null);
3910
+ if (files === null) return missingBundle ? [warning("--built", `${NO_BUNDLE} (${bundleDir})`)] : [];
3911
+ return bundleFindings(files).filter((finding) => finding.ruleId === id).map(({ severity, field, message }) => ({
3912
+ severity,
3913
+ field,
3914
+ message
3915
+ }));
3916
+ }
3917
+ });
3918
+ const BUILT_PARTS = [{
3919
+ field: "main",
3920
+ file: DEFAULT_MAIN
3921
+ }, {
3922
+ field: "client",
3923
+ file: DEFAULT_CLIENT
3924
+ }];
3925
+ /** `CHECK-031`: the built manifest's `main` and `client` name the files that are in the built version, and each file has its field; silent without `--built`. */
3926
+ const builtParts = {
3927
+ id: "CHECK-031",
3928
+ title: "main.mjs and client.mjs match the main and client fields",
3929
+ run: async ({ bundleDir }) => {
3930
+ if (bundleDir === null) return [];
3931
+ let built;
3932
+ try {
3933
+ built = JSON.parse(await readFile(path.join(bundleDir, "extension.json"), "utf8"));
3934
+ } catch {
3935
+ return [];
3936
+ }
3937
+ if (!isRecord(built)) return [];
3938
+ const findings = [];
3939
+ for (const { field, file } of BUILT_PARTS) {
3940
+ const declared = built[field];
3941
+ const exists = (await stat(path.join(bundleDir, file)).catch(() => null))?.isFile() === true;
3942
+ if (typeof declared === "string" && !exists) findings.push(error(field, `'${declared}' is not in the built version`));
3943
+ else if (typeof declared !== "string" && exists) findings.push(error(field, `${file} is in the built version, but '${field}' is not set`));
3944
+ }
3945
+ return findings;
3946
+ }
3947
+ };
3391
3948
  const RULES = [
3392
3949
  manifestValid,
3393
3950
  directoryName,
@@ -3406,9 +3963,61 @@ const RULES = [
3406
3963
  noExecutables,
3407
3964
  appVersionBound,
3408
3965
  assetFiles,
3409
- iconFile
3966
+ iconFile,
3967
+ shortDescriptionRule,
3968
+ firstPublisherOwnsId,
3969
+ bundleRule("CHECK-022", "built code does not execute dynamic code", true),
3970
+ bundleRule("CHECK-023", "built code is not obfuscated"),
3971
+ bundleRule("CHECK-025", "built code has no embedded source map"),
3972
+ builtParts,
3973
+ changelog
3410
3974
  ];
3411
3975
 
3976
+ //#endregion
3977
+ //#region packages/extension-tools/src/catalog/deprecated.ts
3978
+ /** Reads `deprecated.json`; every problem is a `BuildError` (nothing is written by the caller). */
3979
+ const loadDeprecated = async (file) => {
3980
+ let raw;
3981
+ try {
3982
+ raw = JSON.parse(await readFile(file, "utf8"));
3983
+ } catch (error) {
3984
+ throw new BuildError(`deprecated list is unreadable: ${error instanceof Error ? error.message : String(error)}`, file);
3985
+ }
3986
+ try {
3987
+ return parseDeprecatedList(raw);
3988
+ } catch (error) {
3989
+ if (!(error instanceof CatalogFormatError)) throw error;
3990
+ throw new BuildError(`deprecated list is invalid: ${error.issues.join("; ")}`, file);
3991
+ }
3992
+ };
3993
+ /** Problems of the list against the ids known to the index: unknown `id`, alternative missing from the index. */
3994
+ const deprecatedProblems = (items, known, { checkIds = true } = {}) => items.flatMap((item) => [...checkIds && !known.has(item.id) ? [{
3995
+ id: item.id,
3996
+ field: "id",
3997
+ message: `'${item.id}' is not in the index`
3998
+ }] : [], ...item.alternatives.filter((alternative) => !known.has(alternative)).map((alternative) => ({
3999
+ id: item.id,
4000
+ field: "alternatives",
4001
+ message: `alternative '${alternative}' is not in the index`
4002
+ }))]);
4003
+ /** Sets `deprecated` of the entries from the list and removes it from entries no longer listed. */
4004
+ const applyDeprecated = (entries, items, file) => {
4005
+ const problems = deprecatedProblems(items, new Set(entries.map(({ id }) => id)));
4006
+ if (problems.length > 0) throw new BuildError(problems.map(({ id, message }) => `${id}: ${message}`).join("; "), file);
4007
+ const byId = new Map(items.map((item) => [item.id, item]));
4008
+ return entries.map((entry) => {
4009
+ const next = { ...entry };
4010
+ delete next.deprecated;
4011
+ const item = byId.get(entry.id);
4012
+ if (item !== void 0) next.deprecated = {
4013
+ versions: item.versions ?? null,
4014
+ reason: item.reason,
4015
+ alternatives: [...item.alternatives]
4016
+ };
4017
+ return next;
4018
+ });
4019
+ };
4020
+
3412
4021
  //#endregion
3413
4022
  //#region packages/extension-tools/src/catalog/tree.ts
3414
4023
  const compareText = (a, b) => {
@@ -3451,7 +4060,6 @@ const hashTree = async (root, files) => Promise.all(files.map(async (file) => ({
3451
4060
  const SKIPPED_SOURCE_DIRS = /* @__PURE__ */ new Set([
3452
4061
  "node_modules",
3453
4062
  "dist-ext",
3454
- ".dolphy",
3455
4063
  ".git"
3456
4064
  ]);
3457
4065
  const formatFinding = (finding) => `${finding.severity} ${finding.extensionId} ${finding.ruleId} ${finding.field}: ${finding.message}`;
@@ -3502,35 +4110,49 @@ const inspectManifest = async (dir) => {
3502
4110
  problem: null
3503
4111
  } : {
3504
4112
  manifest: null,
3505
- problem: result.message
4113
+ problem: formatDiagnostic(result.diagnostic)
3506
4114
  };
3507
4115
  };
3508
- const stringOrNull = (value) => typeof value === "string" ? value : null;
4116
+ const stringOrNull$1 = (value) => typeof value === "string" ? value : null;
4117
+ /** The extension manifest as written. */
3509
4118
  const readDeclared = async (dir) => {
4119
+ let raw;
3510
4120
  try {
3511
- const raw = JSON.parse(await readFile(path.join(dir, "extension.json"), "utf8"));
3512
- if (typeof raw !== "object" || raw === null) return null;
3513
- const fields = raw;
4121
+ raw = JSON.parse(await readFile(path.join(dir, "extension.json"), "utf8"));
4122
+ } catch {
3514
4123
  return {
3515
- name: stringOrNull(fields.name),
3516
- description: stringOrNull(fields.description),
3517
- author: stringOrNull(fields.author),
3518
- icon: stringOrNull(fields.icon)
4124
+ raw: null,
4125
+ declared: null
3519
4126
  };
3520
- } catch {
3521
- return null;
3522
4127
  }
4128
+ if (typeof raw !== "object" || raw === null) return {
4129
+ raw,
4130
+ declared: null
4131
+ };
4132
+ const fields = raw;
4133
+ return {
4134
+ raw,
4135
+ declared: {
4136
+ name: stringOrNull$1(fields.name),
4137
+ description: stringOrNull$1(fields.description),
4138
+ author: stringOrNull$1(fields.author),
4139
+ icon: stringOrNull$1(fields.icon)
4140
+ }
4141
+ };
3523
4142
  };
3524
4143
  const contextFor = async (options, dirName, published, checkGithubUser) => {
3525
4144
  const dir = path.resolve(options.extensionsDir, dirName);
3526
4145
  const { manifest, problem } = await inspectManifest(dir);
4146
+ const { raw, declared } = await readDeclared(dir);
3527
4147
  return {
3528
4148
  dirName,
3529
4149
  dir,
3530
4150
  manifest,
3531
- declared: await readDeclared(dir),
4151
+ declared,
4152
+ rawManifest: raw,
3532
4153
  manifestProblem: problem,
3533
4154
  tree: await readTree(dir, SKIPPED_SOURCE_DIRS),
4155
+ bundleDir: options.builtDir === void 0 || manifest === null ? null : path.join(path.resolve(options.builtDir), "extensions", manifest.id, manifest.version),
3534
4156
  readText: (file) => readTextOrNull(path.join(dir, file)),
3535
4157
  readBytes: (file) => readBytesInside(dir, file),
3536
4158
  published: published?.extensions.find((entry) => entry.id === (manifest?.id ?? dirName)),
@@ -3539,6 +4161,32 @@ const contextFor = async (options, dirName, published, checkGithubUser) => {
3539
4161
  checkGithubUser
3540
4162
  };
3541
4163
  };
4164
+ const DEPRECATED_RULE = "deprecated";
4165
+ /** The form of `deprecated.json` and (with an index) the existence of the alternatives; unknown ids are a build matter. */
4166
+ const deprecatedFindings = async (file, published) => {
4167
+ let items;
4168
+ try {
4169
+ items = await loadDeprecated(file);
4170
+ } catch (error) {
4171
+ if (!(error instanceof BuildError)) throw error;
4172
+ return [{
4173
+ severity: "error",
4174
+ extensionId: path.basename(file),
4175
+ ruleId: DEPRECATED_RULE,
4176
+ field: "/",
4177
+ message: error.message
4178
+ }];
4179
+ }
4180
+ if (published === null) return [];
4181
+ const known = new Set(published.extensions.map(({ id }) => id));
4182
+ return deprecatedProblems(items, known, { checkIds: false }).map(({ id, field, message }) => ({
4183
+ severity: "error",
4184
+ extensionId: id,
4185
+ ruleId: DEPRECATED_RULE,
4186
+ field,
4187
+ message
4188
+ }));
4189
+ };
3542
4190
  const checkCatalog = async (options) => {
3543
4191
  const dirs = await selectDirs(options);
3544
4192
  const published = options.publishedIndex === void 0 ? null : await loadIndexFile(options.publishedIndex);
@@ -3546,7 +4194,7 @@ const checkCatalog = async (options) => {
3546
4194
  ...options.fetch === void 0 ? {} : { fetch: options.fetch },
3547
4195
  token: options.githubToken
3548
4196
  });
3549
- const findings = [];
4197
+ const findings = [...options.deprecated === void 0 ? [] : await deprecatedFindings(options.deprecated, published)];
3550
4198
  for (const dirName of dirs) {
3551
4199
  const context = await contextFor(options, dirName, published, checkGithubUser);
3552
4200
  for (const rule of RULES) for (const finding of await rule.run(context)) findings.push({
@@ -3572,22 +4220,16 @@ const orderVersion = (version) => ({
3572
4220
  version: version.version,
3573
4221
  apiVersion: version.apiVersion,
3574
4222
  minAppVersion: version.minAppVersion,
3575
- permissions: [...version.permissions],
3576
4223
  publishedAt: version.publishedAt,
3577
4224
  baseUrl: version.baseUrl,
3578
4225
  files: [...version.files].sort(byPath).map(orderFile),
3579
4226
  ...version.icon === void 0 ? {} : { icon: version.icon },
3580
- ...version.tags === void 0 || version.tags.length === 0 ? {} : { tags: [...version.tags] }
4227
+ ...version.tags === void 0 || version.tags.length === 0 ? {} : { tags: [...version.tags] },
4228
+ ...version.dependencies === void 0 || version.dependencies.length === 0 ? {} : { dependencies: version.dependencies.map(({ id, range }) => ({
4229
+ id,
4230
+ ...range === void 0 ? {} : { range }
4231
+ })) }
3581
4232
  });
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
- };
3591
4233
  const orderEntry = (entry) => ({
3592
4234
  id: entry.id,
3593
4235
  name: entry.name,
@@ -3595,30 +4237,24 @@ const orderEntry = (entry) => ({
3595
4237
  author: entry.author,
3596
4238
  source: entry.source,
3597
4239
  platforms: [...entry.platforms],
3598
- contributes: {
3599
- exerciseTypes: [...entry.contributes.exerciseTypes],
3600
- themes: [...entry.contributes.themes],
3601
- markdownRenderers: [...entry.contributes.markdownRenderers],
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)
3607
- },
3608
- ...orderTitles(entry.titles),
4240
+ ...entry.deprecated === void 0 ? {} : { deprecated: {
4241
+ versions: entry.deprecated.versions,
4242
+ reason: entry.deprecated.reason,
4243
+ alternatives: [...entry.deprecated.alternatives]
4244
+ } },
3609
4245
  versions: entry.versions.map(orderVersion)
3610
4246
  });
3611
4247
  const byId = (a, b) => compareText(a.id, b.id);
3612
4248
  const newestFirst = (versions) => [...versions].sort((a, b) => compareSemver(b.version, a.version)).slice(0, 5);
3613
- const checked = (candidate, file) => {
4249
+ const checked = (candidate) => {
3614
4250
  try {
3615
4251
  parseIndex(candidate);
3616
4252
  return candidate;
3617
4253
  } catch (error) {
3618
- throw new BuildError(`resulting index is invalid: ${error instanceof Error ? error.message : String(error)}`, file);
4254
+ throw new BuildError(`resulting index is invalid: ${error instanceof Error ? error.message : String(error)}`, FULL_INDEX_FILE);
3619
4255
  }
3620
4256
  };
3621
- /** Builds the full index with a stable key order; throws `BuildError` if it fails `parseIndex`. */
4257
+ /** Builds the index with a stable key order; throws `BuildError` if it fails `parseIndex`. */
3622
4258
  const assembleIndex = (parts) => {
3623
4259
  const candidate = {
3624
4260
  schemaVersion: 2,
@@ -3630,15 +4266,7 @@ const assembleIndex = (parts) => {
3630
4266
  reason: item.reason
3631
4267
  }))
3632
4268
  };
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
- };
4269
+ return checked(candidate);
3642
4270
  };
3643
4271
  const writeIndexAtomically = async (file, index) => {
3644
4272
  const temporary = `${file}.${process.pid}.tmp`;
@@ -3664,11 +4292,20 @@ const hasSameContent = (current, next) => current !== null && contentOf(current)
3664
4292
  const DEFAULT_SOURCE_BASE = "https://github.com/dolphy-app/dolphy-extensions/tree/main/extensions";
3665
4293
  const errorText = (error) => error instanceof Error ? error.message : String(error);
3666
4294
  const README = "README.md";
4295
+ const CHANGELOG = "CHANGELOG.md";
3667
4296
  const copyReadme = async (srcDir, dir, id) => {
3668
4297
  const text = await readFile(path.join(srcDir, README), "utf8").catch(() => "");
3669
4298
  if (text.trim() === "") throw new BuildError(`${README} is missing or empty`, id);
3670
4299
  await writeFile(path.join(dir, README), text);
3671
4300
  };
4301
+ /** `CHANGELOG.md` is optional; its limits are checked by the file-size rules and `CHECK-030`. */
4302
+ const copyChangelog = async (srcDir, dir, id) => {
4303
+ const bytes = await readFile(path.join(srcDir, CHANGELOG)).catch(() => null);
4304
+ if (bytes === null) return;
4305
+ const problem = changelogProblem(bytes);
4306
+ if (problem !== null) throw new BuildError(`${CHANGELOG} ${problem}`, id);
4307
+ await writeFile(path.join(dir, CHANGELOG), bytes);
4308
+ };
3672
4309
  const fileProblems = (files) => {
3673
4310
  const problems = files.filter((file) => !isSafeCatalogPath(file.path)).map((file) => `'${file.path}' is not allowed (extensions ${CATALOG_FILE_EXTENSIONS.join("|")}, safe path segments)`);
3674
4311
  if (files.length > 100) problems.push(`${files.length} files exceed the limit of ${100}`);
@@ -3682,7 +4319,7 @@ const fileProblems = (files) => {
3682
4319
  };
3683
4320
  const readBuiltManifest = async (dir) => {
3684
4321
  const parsed = parseManifest(JSON.parse(await readFile(path.join(dir, "extension.json"), "utf8")));
3685
- if (!parsed.ok) throw new BuildError(parsed.message, dir);
4322
+ if (!parsed.ok) throw new BuildError(formatDiagnostic(parsed.diagnostic), dir);
3686
4323
  return parsed.manifest;
3687
4324
  };
3688
4325
  /** The icon declared in the manifest, read from the built directory; its checks ran in `buildExtension`. */
@@ -3701,6 +4338,7 @@ const stage = async (options, id, scratch) => {
3701
4338
  });
3702
4339
  if (built.id !== id) throw new BuildError(`directory name '${id}' does not match manifest id '${built.id}'`, id);
3703
4340
  await copyReadme(srcDir, built.dir, id);
4341
+ await copyChangelog(srcDir, built.dir, id);
3704
4342
  const tree = await readTree(built.dir);
3705
4343
  const files = await hashTree(built.dir, tree.files);
3706
4344
  const problems = [...tree.symlinks.map((link) => `'${link}' is a symbolic link`), ...fileProblems(files)];
@@ -3740,43 +4378,19 @@ const existingFiles = async (dir) => {
3740
4378
  }));
3741
4379
  };
3742
4380
  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
- };
3770
4381
  const newRecord = (staged, publishedAt) => ({
3771
4382
  version: staged.manifest.version,
3772
4383
  apiVersion: staged.manifest.apiVersion,
3773
4384
  minAppVersion: staged.manifest.minAppVersion,
3774
- permissions: [...staged.manifest.permissions],
3775
4385
  publishedAt,
3776
4386
  baseUrl: baseUrlOf(staged),
3777
4387
  files: staged.files,
3778
4388
  ...staged.icon === null ? {} : { icon: staged.icon },
3779
- ...staged.manifest.tags.length === 0 ? {} : { tags: [...staged.manifest.tags] }
4389
+ ...staged.manifest.tags.length === 0 ? {} : { tags: [...staged.manifest.tags] },
4390
+ ...staged.manifest.dependencies.length === 0 ? {} : { dependencies: staged.manifest.dependencies.map(({ id, range }) => ({
4391
+ id,
4392
+ ...range === null ? {} : { range }
4393
+ })) }
3780
4394
  });
3781
4395
  const plan = async (staged, out, previous, publishedAt) => {
3782
4396
  const onDisk = await existingFiles(versionDir(out, staged));
@@ -3795,11 +4409,6 @@ const entryOf = (item, previous, sourceBase) => {
3795
4409
  const { staged, record } = item;
3796
4410
  const { manifest } = staged;
3797
4411
  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);
3803
4412
  return {
3804
4413
  id: staged.id,
3805
4414
  name: manifest.name ?? "",
@@ -3807,17 +4416,7 @@ const entryOf = (item, previous, sourceBase) => {
3807
4416
  author: manifest.author ?? "",
3808
4417
  source: `${sourceBase.replace(/\/+$/, "")}/${staged.id}`,
3809
4418
  platforms: [...manifest.platforms],
3810
- contributes: {
3811
- exerciseTypes: manifest.contributes.exerciseTypes.map((type) => type.id),
3812
- themes: manifest.contributes.themes.map((theme) => theme.id),
3813
- markdownRenderers: manifest.contributes.markdownRenderers.map((renderer) => renderer.language),
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 } : {}
3819
- },
3820
- ...Object.keys(titles).length > 0 ? { titles } : {},
4419
+ ...previous?.deprecated === void 0 ? {} : { deprecated: previous.deprecated },
3821
4420
  versions: newestFirst([record, ...others])
3822
4421
  };
3823
4422
  };
@@ -3832,6 +4431,7 @@ const loadRevoked = async (file, previous) => {
3832
4431
  if (!Array.isArray(raw)) throw new BuildError("revoked list must be a JSON array", file);
3833
4432
  return raw;
3834
4433
  };
4434
+ const withDeprecations = async (entries, file) => file === void 0 ? entries : applyDeprecated(entries, await loadDeprecated(file), file);
3835
4435
  const writeVersion = async (item, out) => {
3836
4436
  const target = versionDir(out, item.staged);
3837
4437
  const parent = path.dirname(target);
@@ -3844,38 +4444,25 @@ const writeVersion = async (item, out) => {
3844
4444
  await cp(item.staged.dir, temporary, { recursive: true });
3845
4445
  await rename(temporary, target);
3846
4446
  };
3847
- const resultOf = (item, entry) => ({
4447
+ const resultOf = (item) => ({
3848
4448
  id: item.staged.id,
3849
4449
  version: item.staged.manifest.version,
3850
4450
  status: item.status,
3851
4451
  files: item.staged.files.length,
3852
- bytes: item.staged.files.reduce((sum, file) => sum + file.size, 0),
3853
- inLegacyIndex: isLegacyEntry(entry) && isLegacyVersion(item.record)
4452
+ bytes: item.staged.files.reduce((sum, file) => sum + file.size, 0)
3854
4453
  });
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;
4454
+ const formatPublishResult = (result) => result.status === "unchanged" ? `unchanged ${result.id}@${result.version}` : `published ${result.id}@${result.version} (${result.files} files, ${result.bytes} bytes)`;
4455
+ /** The index to start from: the explicit file, else the index of the site. */
4456
+ const previousIndexFile = async (out, explicit) => explicit === void 0 ? path.join(out, FULL_INDEX_FILE) : path.resolve(explicit);
4457
+ /** Writes `index.v2.json` when it differs from the disk (ignoring `generatedAt`). */
4458
+ const writeIndex = async (out, index) => {
4459
+ const file = path.join(out, FULL_INDEX_FILE);
4460
+ if (hasSameContent(await loadIndexFile(file), index)) return false;
3873
4461
  await mkdir(out, { recursive: true });
3874
- await writeIndexAtomically(fullFile, full);
3875
- await writeIndexAtomically(legacyFile, legacy);
4462
+ await writeIndexAtomically(file, index);
3876
4463
  return true;
3877
4464
  };
3878
- /** Builds extension versions and updates `index.v2.json` and `index.json`; on error nothing is written to disk. */
4465
+ /** Builds extension versions and updates `index.v2.json`; on error nothing is written to disk. */
3879
4466
  const buildCatalog = async (options) => {
3880
4467
  const out = path.resolve(options.out);
3881
4468
  const now = (options.now ?? (() => /* @__PURE__ */ new Date()))().toISOString();
@@ -3896,15 +4483,15 @@ const buildCatalog = async (options) => {
3896
4483
  plans.push(item);
3897
4484
  const entry = entryOf(item, before, sourceBase);
3898
4485
  entries.set(id, entry);
3899
- results.push(resultOf(item, entry));
4486
+ results.push(resultOf(item));
3900
4487
  }
3901
- const indexes = assembleIndexes({
4488
+ const index = assembleIndex({
3902
4489
  generatedAt: now,
3903
- extensions: [...entries.values()],
4490
+ extensions: await withDeprecations([...entries.values()], options.deprecated),
3904
4491
  revoked
3905
4492
  });
3906
4493
  for (const item of plans) if (item.shouldWrite) await writeVersion(item, out);
3907
- await writeIndexes(out, indexes);
4494
+ await writeIndex(out, index);
3908
4495
  return results;
3909
4496
  } finally {
3910
4497
  await rm(scratch, {
@@ -3914,21 +4501,21 @@ const buildCatalog = async (options) => {
3914
4501
  }
3915
4502
  };
3916
4503
  const formatReindexResult = (result) => `reindexed (${result.extensions} extensions, ${result.revoked} revoked)${result.changed ? "" : " — no changes"}`;
3917
- /** Rewrites `revoked` and `generatedAt` of both indexes; extension entries are unchanged. */
4504
+ /** Rewrites `revoked` and `generatedAt` of the index; extension entries are unchanged. */
3918
4505
  const reindexCatalog = async (options) => {
3919
4506
  const out = path.resolve(options.out);
3920
4507
  const source = await previousIndexFile(out, options.previousIndex);
3921
4508
  const previous = await loadIndexFile(source);
3922
4509
  if (previous === null) throw new CatalogUsageError(`nothing to reindex: ${source} does not exist`, source);
3923
- const indexes = assembleIndexes({
4510
+ const index = assembleIndex({
3924
4511
  generatedAt: options.publishedAt ?? (options.now ?? (() => /* @__PURE__ */ new Date()))().toISOString(),
3925
- extensions: previous.extensions,
4512
+ extensions: await withDeprecations(previous.extensions, options.deprecated),
3926
4513
  revoked: await loadRevoked(options.revoked, previous)
3927
4514
  });
3928
- const changed = await writeIndexes(out, indexes);
4515
+ const changed = await writeIndex(out, index);
3929
4516
  return {
3930
- extensions: indexes.full.extensions.length,
3931
- revoked: indexes.full.revoked.length,
4517
+ extensions: index.extensions.length,
4518
+ revoked: index.revoked.length,
3932
4519
  changed
3933
4520
  };
3934
4521
  };
@@ -3936,36 +4523,45 @@ const reindexCatalog = async (options) => {
3936
4523
  //#endregion
3937
4524
  //#region packages/extension-tools/src/catalog/cli.ts
3938
4525
  const CATALOG_SYNOPSIS = ` dolphy-ext catalog check <extensionsDir> [--ids a,b]
3939
- [--published-index <path>] [--max-app-version <x.y.z>]
3940
- [--skip-github-check] [--list-rules]
4526
+ [--published-index <path>] [--deprecated <path>]
4527
+ [--max-app-version <x.y.z>]
4528
+ [--built <siteDir>] [--skip-github-check] [--list-rules]
3941
4529
  dolphy-ext catalog build --src <extensionsDir> --ids a,b --out <siteDir>
3942
4530
  [--previous-index <path>] [--revoked <path>]
3943
- [--source-base <url>] [--published-at <iso>]
4531
+ [--deprecated <path>] [--source-base <url>]
4532
+ [--published-at <iso>]
3944
4533
  dolphy-ext catalog build --reindex --out <siteDir>
3945
4534
  [--previous-index <path>] [--revoked <path>]
3946
- [--published-at <iso>]
4535
+ [--deprecated <path>] [--published-at <iso>]
3947
4536
  `;
3948
4537
  const CATALOG_HELP = ` catalog check check the catalog's extension sources; lines
3949
4538
  «error|warning <id> <RULE-ID> <field>: <message>»,
3950
4539
  exit code 1 if any error
3951
4540
  catalog build build versions into <siteDir>/extensions/<id>/<version>/ and
3952
- update <siteDir>/index.json (published versions are
4541
+ update <siteDir>/index.v2.json (published versions are
3953
4542
  immutable)
3954
4543
  catalog build --reindex
3955
- only replace revoked and generatedAt in the existing
3956
- <siteDir>/index.json (--src and --ids are not needed)
4544
+ only replace revoked, deprecated and generatedAt in the
4545
+ existing <siteDir>/index.v2.json (--src and --ids are not needed)
3957
4546
 
3958
4547
  --ids a,b only these extensions (check: all by default)
3959
- --published-index <p> index.json of the published catalog (no file —
4548
+ --published-index <p> index.v2.json of the published catalog (no file —
3960
4549
  nothing is published)
3961
4550
  --max-app-version <v> minAppVersion must not be newer
4551
+ --built <siteDir> also check the built versions in
4552
+ <siteDir>/extensions/<id>/<version>/ (eval, obfuscation,
4553
+ URLs, source maps); without it these rules are silent
3962
4554
  --skip-github-check do not verify the author via api.github.com
3963
4555
  (token — the GITHUB_TOKEN variable)
3964
4556
  --list-rules print the check rules
3965
4557
  --src <dir> directory of projects <dir>/<id>
3966
4558
  --out <dir> site root
3967
- --previous-index <p> source index (default <out>/index.json)
4559
+ --previous-index <p> source index (default <out>/index.v2.json)
3968
4560
  --revoked <p> JSON array {id, versions, reason}
4561
+ --deprecated <p> JSON array {id, versions?, reason, alternatives}
4562
+ (check: validate the form and, with
4563
+ --published-index, the alternatives; build: write
4564
+ "deprecated" to entries, unset — keep the index's)
3969
4565
  --source-base <url> base for the index entry's source field
3970
4566
  --published-at <iso> publishedAt of new versions (default now);
3971
4567
  with --reindex — generatedAt
@@ -3974,7 +4570,9 @@ const VALUE_FLAGS = {
3974
4570
  check: [
3975
4571
  "--ids",
3976
4572
  "--published-index",
3977
- "--max-app-version"
4573
+ "--deprecated",
4574
+ "--max-app-version",
4575
+ "--built"
3978
4576
  ],
3979
4577
  build: [
3980
4578
  "--src",
@@ -3982,6 +4580,7 @@ const VALUE_FLAGS = {
3982
4580
  "--out",
3983
4581
  "--previous-index",
3984
4582
  "--revoked",
4583
+ "--deprecated",
3985
4584
  "--source-base",
3986
4585
  "--published-at"
3987
4586
  ]
@@ -4033,7 +4632,9 @@ const parseCheck = (args) => {
4033
4632
  dir: positional[0] ?? null,
4034
4633
  ids: idsOf(flags),
4035
4634
  publishedIndex: text(flags, "--published-index"),
4635
+ deprecated: text(flags, "--deprecated"),
4036
4636
  maxAppVersion,
4637
+ built: text(flags, "--built"),
4037
4638
  skipGithubCheck: flags["--skip-github-check"] === true,
4038
4639
  listRules: listRulesFlag
4039
4640
  };
@@ -4053,6 +4654,7 @@ const parseReindex = (flags) => {
4053
4654
  out,
4054
4655
  previousIndex: text(flags, "--previous-index"),
4055
4656
  revoked: text(flags, "--revoked"),
4657
+ deprecated: text(flags, "--deprecated"),
4056
4658
  publishedAt: text(flags, "--published-at")
4057
4659
  };
4058
4660
  };
@@ -4075,6 +4677,7 @@ const parseBuild$1 = (args) => {
4075
4677
  out,
4076
4678
  previousIndex: text(flags, "--previous-index"),
4077
4679
  revoked: text(flags, "--revoked"),
4680
+ deprecated: text(flags, "--deprecated"),
4078
4681
  sourceBase: text(flags, "--source-base"),
4079
4682
  publishedAt: text(flags, "--published-at")
4080
4683
  };
@@ -4094,7 +4697,9 @@ const runCheck = async (parsed, io, deps) => {
4094
4697
  extensionsDir: path.resolve(parsed.dir),
4095
4698
  ...parsed.ids === void 0 ? {} : { ids: parsed.ids },
4096
4699
  ...parsed.publishedIndex === void 0 ? {} : { publishedIndex: path.resolve(parsed.publishedIndex) },
4700
+ ...parsed.deprecated === void 0 ? {} : { deprecated: path.resolve(parsed.deprecated) },
4097
4701
  ...parsed.maxAppVersion === void 0 ? {} : { maxAppVersion: parsed.maxAppVersion },
4702
+ ...parsed.built === void 0 ? {} : { builtDir: path.resolve(parsed.built) },
4098
4703
  skipGithubCheck: parsed.skipGithubCheck,
4099
4704
  githubToken: deps.env?.GITHUB_TOKEN,
4100
4705
  ...deps.fetch === void 0 ? {} : { fetch: deps.fetch }
@@ -4107,6 +4712,7 @@ const runReindex = async (parsed, io, deps) => {
4107
4712
  out: parsed.out,
4108
4713
  ...parsed.previousIndex === void 0 ? {} : { previousIndex: parsed.previousIndex },
4109
4714
  ...parsed.revoked === void 0 ? {} : { revoked: parsed.revoked },
4715
+ ...parsed.deprecated === void 0 ? {} : { deprecated: parsed.deprecated },
4110
4716
  ...parsed.publishedAt === void 0 ? {} : { publishedAt: parsed.publishedAt },
4111
4717
  ...deps.now === void 0 ? {} : { now: deps.now }
4112
4718
  });
@@ -4120,6 +4726,7 @@ const runBuild$1 = async (parsed, io, deps) => {
4120
4726
  out: parsed.out,
4121
4727
  ...parsed.previousIndex === void 0 ? {} : { previousIndex: parsed.previousIndex },
4122
4728
  ...parsed.revoked === void 0 ? {} : { revoked: parsed.revoked },
4729
+ ...parsed.deprecated === void 0 ? {} : { deprecated: parsed.deprecated },
4123
4730
  ...parsed.sourceBase === void 0 ? {} : { sourceBase: parsed.sourceBase },
4124
4731
  ...parsed.publishedAt === void 0 ? {} : { publishedAt: parsed.publishedAt },
4125
4732
  ...deps.now === void 0 ? {} : { now: deps.now }
@@ -4140,23 +4747,229 @@ const runCatalog = async (parsed, io, deps = {}) => {
4140
4747
  }
4141
4748
  };
4142
4749
 
4750
+ //#endregion
4751
+ //#region packages/extension-tools/src/dev.ts
4752
+ /** An app that quits sooner than this did not start for us: see `QUICK_EXIT_HINT`. */
4753
+ const QUICK_EXIT_MS = 5e3;
4754
+ const QUICK_EXIT_HINT = "Dolphy is probably already running: quit it and run again (the app has a single instance, a second launch exits at once and the extension directory is lost)";
4755
+ const flavor = (platform) => platform === "win32" ? path.win32 : path.posix;
4756
+ /** `Dolphy-Linux-1.12.0.AppImage` → `[1, 12, 0]`; other names have no version. */
4757
+ const versionOf = (name) => {
4758
+ const match = /^Dolphy-Linux-(\d+(?:\.\d+)*)\.AppImage$/.exec(name);
4759
+ return match === null ? null : match[1].split(".").map(Number);
4760
+ };
4761
+ const compareVersions = (a, b) => {
4762
+ for (let i = 0; i < Math.max(a.length, b.length); i++) {
4763
+ const diff = (a[i] ?? 0) - (b[i] ?? 0);
4764
+ if (diff !== 0) return diff;
4765
+ }
4766
+ return 0;
4767
+ };
4768
+ /** Places the installer puts the app (`electron-builder.json`); Linux has no fixed place. */
4769
+ const standardPlaces = (where) => {
4770
+ const p = flavor(where.platform);
4771
+ if (where.platform === "darwin") return ["/Applications/Dolphy.app", p.join(where.home, "Applications", "Dolphy.app")];
4772
+ if (where.platform === "win32") {
4773
+ const base = where.env.LOCALAPPDATA ?? p.join(where.home, "AppData", "Local");
4774
+ return [p.join(base, "Programs", "Dolphy", "Dolphy.exe")];
4775
+ }
4776
+ const dir = p.join(where.home, "Applications");
4777
+ return where.list(dir).flatMap((name) => {
4778
+ const version = versionOf(name);
4779
+ return version === null ? [] : [{
4780
+ name,
4781
+ version
4782
+ }];
4783
+ }).sort((a, b) => compareVersions(b.version, a.version)).map(({ name }) => p.join(dir, name));
4784
+ };
4785
+ const lookedAt = (where) => where.platform === "linux" ? flavor(where.platform).join(where.home, "Applications", "Dolphy-Linux-*.AppImage") : standardPlaces(where).join(", ");
4786
+ const missing = (where, what) => new CatalogUsageError(`${what}; looked at: ${lookedAt(where)}; point to the app with --app <path> or the DOLPHY_APP environment variable`, "dolphy-ext dev");
4787
+ /**
4788
+ * The app to launch: `--app`, then `DOLPHY_APP`, then the standard places of the
4789
+ * platform. A macOS bundle (`Dolphy.app`) is started by its executable, so that
4790
+ * the environment reaches the app and the process lives as long as the app does.
4791
+ */
4792
+ const findApp = (explicit, where) => {
4793
+ const p = flavor(where.platform);
4794
+ const given = explicit ?? where.env.DOLPHY_APP;
4795
+ const chosen = given === void 0 || given === "" ? null : given;
4796
+ const found = (chosen === null ? standardPlaces(where) : [chosen]).find((place) => where.exists(place)) ?? null;
4797
+ if (found === null) throw missing(where, chosen === null ? "the Dolphy app is not found" : `${explicit === void 0 ? "DOLPHY_APP" : "--app"} points to '${chosen}', which does not exist`);
4798
+ return found.endsWith(".app") ? p.join(found, "Contents", "MacOS", p.basename(found, ".app")) : found;
4799
+ };
4800
+ const systemDeps = (env) => ({
4801
+ platform: process.platform,
4802
+ env,
4803
+ home: homedir(),
4804
+ exists: existsSync,
4805
+ list: (dir) => {
4806
+ try {
4807
+ return readdirSync(dir);
4808
+ } catch {
4809
+ return [];
4810
+ }
4811
+ },
4812
+ launch: (command, childEnv) => {
4813
+ const child = spawn(command, [], {
4814
+ env: childEnv,
4815
+ stdio: "inherit"
4816
+ });
4817
+ return {
4818
+ exited: new Promise((resolve, reject) => {
4819
+ child.once("error", reject);
4820
+ child.once("exit", (code, signal) => resolve({
4821
+ code,
4822
+ signal
4823
+ }));
4824
+ }),
4825
+ kill: () => {
4826
+ child.kill("SIGTERM");
4827
+ }
4828
+ };
4829
+ },
4830
+ now: Date.now
4831
+ });
4832
+ /**
4833
+ * `dolphy-ext dev`: a watch build of the project and the installed app that
4834
+ * reads the output through `DOLPHY_DEV_EXTENSIONS`. Resolves with the exit code
4835
+ * when the user stops it (0) or the app quits (0 after a normal session, 1 when
4836
+ * it quit at once).
4837
+ */
4838
+ const runDev = async (options, io, deps, startBuild, waitForExit) => {
4839
+ const command = findApp(options.app, deps);
4840
+ const build = await startBuild();
4841
+ io.stdout(`${build.summary}\n`);
4842
+ try {
4843
+ io.stdout(`launching ${command} with DOLPHY_DEV_EXTENSIONS=${build.outDir}\n`);
4844
+ const startedAt = deps.now();
4845
+ const app = deps.launch(command, {
4846
+ ...deps.env,
4847
+ DOLPHY_DEV_EXTENSIONS: build.outDir
4848
+ });
4849
+ io.stdout("watching for changes, Ctrl+C stops the build and the app\n");
4850
+ const ending = await Promise.race([waitForExit().then(() => ({ by: "user" })), app.exited.then((exit) => ({
4851
+ by: "app",
4852
+ exit
4853
+ }))]);
4854
+ if (ending.by === "user") {
4855
+ app.kill();
4856
+ await app.exited.catch(() => void 0);
4857
+ return 0;
4858
+ }
4859
+ const { code } = ending.exit;
4860
+ if (code !== null && deps.now() - startedAt < 5e3) {
4861
+ io.stderr(`${QUICK_EXIT_HINT}\n`);
4862
+ return 1;
4863
+ }
4864
+ return 0;
4865
+ } catch (error) {
4866
+ io.stderr(`error dolphy-ext dev: cannot run ${command}: ${error instanceof Error ? error.message : String(error)}\n`);
4867
+ return 1;
4868
+ } finally {
4869
+ await build.close();
4870
+ }
4871
+ };
4872
+
4873
+ //#endregion
4874
+ //#region packages/extension-tools/src/lint/index.ts
4875
+ const formatLintFinding = (finding) => `${finding.severity} ${finding.extensionId} ${finding.ruleId} ${finding.field}: ${finding.message}`;
4876
+ const lintHasErrors = (findings) => findings.some((finding) => finding.severity === "error");
4877
+ const stringOrNull = (value) => typeof value === "string" ? value : null;
4878
+ const readManifest = async (root) => {
4879
+ let raw;
4880
+ try {
4881
+ raw = JSON.parse(await readFile(path.join(root, MANIFEST_FILE), "utf8"));
4882
+ } catch (error) {
4883
+ throw new CatalogUsageError(`${MANIFEST_FILE} is unreadable: ${error instanceof Error ? error.message : String(error)}`, root);
4884
+ }
4885
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) throw new CatalogUsageError(`${MANIFEST_FILE} is not an object`, root);
4886
+ return raw;
4887
+ };
4888
+ const isDirectory = async (dir) => (await stat(dir).catch(() => null))?.isDirectory() === true;
4889
+ /** Code files of the build the author is going to publish. */
4890
+ const builtFiles = async (options) => {
4891
+ if (options.built !== void 0) return readBundleFiles(options.built);
4892
+ const temp = await mkdtemp(path.join(tmpdir(), "dolphy-lint-"));
4893
+ try {
4894
+ const { dir } = await buildExtension({
4895
+ root: options.root,
4896
+ outDir: temp
4897
+ });
4898
+ return await readBundleFiles(dir);
4899
+ } finally {
4900
+ await rm(temp, {
4901
+ recursive: true,
4902
+ force: true
4903
+ });
4904
+ }
4905
+ };
4906
+ /**
4907
+ * Checks a project the way `catalog check` checks it, before a pull request:
4908
+ * manifest metadata, README and the built code. Everything is a warning
4909
+ * except a missing README and an embedded source map.
4910
+ */
4911
+ const lintProject = async (options) => {
4912
+ const root = path.resolve(options.root);
4913
+ if (!await isDirectory(root)) throw new CatalogUsageError("not a directory", root);
4914
+ if (options.built !== void 0 && !await isDirectory(options.built)) throw new CatalogUsageError("--built: not a directory", options.built);
4915
+ const manifest = await readManifest(root);
4916
+ const extensionId = stringOrNull(manifest.id) ?? path.basename(root);
4917
+ const findings = manifestFindings({
4918
+ name: stringOrNull(manifest.name),
4919
+ description: stringOrNull(manifest.description),
4920
+ author: stringOrNull(manifest.author),
4921
+ tags: Array.isArray(manifest.tags) ? manifest.tags : null
4922
+ });
4923
+ const readme = await readFile(path.join(root, "README.md"), "utf8").catch(() => null);
4924
+ if (readme === null || readme.trim() === "") findings.push({
4925
+ ruleId: "CHECK-004",
4926
+ severity: "error",
4927
+ field: "README.md",
4928
+ message: "README.md is missing or empty"
4929
+ });
4930
+ const files = await builtFiles(options.built === void 0 ? { root } : {
4931
+ root,
4932
+ built: path.resolve(options.built)
4933
+ });
4934
+ findings.push(...bundleFindings(files));
4935
+ return findings.map((finding) => ({
4936
+ ...finding,
4937
+ severity: finding.ruleId === "CHECK-004" ? "error" : "warning",
4938
+ extensionId
4939
+ }));
4940
+ };
4941
+
4143
4942
  //#endregion
4144
4943
  //#region packages/extension-tools/src/cli/run.ts
4145
4944
  const EXIT_OK = 0;
4146
4945
  const EXIT_PROBLEMS = 1;
4147
4946
  const EXIT_USAGE = 2;
4148
4947
  const USAGE = `usage: dolphy-ext build [dir] [--out <dir>] [--watch]
4149
- dolphy-ext types [dir]
4150
4948
  dolphy-ext validate <dir>
4949
+ dolphy-ext lint [dir] [--built <dir>]
4950
+ dolphy-ext dev [dir] [--app <path>]
4151
4951
  ${CATALOG_SYNOPSIS}
4152
4952
  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)
4953
+ directory) into <dir>/dist-ext/<id>: main.mjs from the
4954
+ 'server' export of src/index.ts, client.mjs from 'client'
4157
4955
  validate <dir> check the directory of a built extension (extension.json,
4158
- schemas, main and renderer)
4956
+ main and client files, assets)
4957
+ lint [dir] check the project before a pull request to the catalog:
4958
+ manifest metadata, README.md and the built code (eval,
4959
+ obfuscation, source maps); lines «error|warning <id>
4960
+ <RULE-ID> <field>: <message>», exit code 1 only if
4961
+ README.md is missing
4962
+ dev [dir] watch-build the project and launch the installed Dolphy app
4963
+ with DOLPHY_DEV_EXTENSIONS=<dir>/dist-ext; Ctrl+C stops both.
4964
+ The app is --app, then the DOLPHY_APP variable, then the
4965
+ standard place of the platform (macOS /Applications/Dolphy.app
4966
+ and ~/Applications/Dolphy.app; Windows
4967
+ %LOCALAPPDATA%\\Programs\\Dolphy\\Dolphy.exe; Linux the newest
4968
+ ~/Applications/Dolphy-Linux-*.AppImage); exit code 2 if none
4159
4969
  ${CATALOG_HELP}
4970
+ --app <path> dev: the Dolphy app (macOS .app bundle or an executable)
4971
+ --built <dir> lint: check this built extension instead of building the
4972
+ project into a temporary directory
4160
4973
  --out <dir> output root (the extension goes to <dir>/<id>)
4161
4974
  --watch rebuild the bundles when the sources change
4162
4975
  --help this help
@@ -4182,15 +4995,6 @@ const parseBuild = (args) => {
4182
4995
  watch
4183
4996
  };
4184
4997
  };
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
- };
4194
4998
  const parseValidate = (args) => {
4195
4999
  const flag = args.find((arg) => arg.startsWith("-"));
4196
5000
  if (flag !== void 0) return { usageError: `unknown flag: ${flag}` };
@@ -4202,12 +5006,49 @@ const parseValidate = (args) => {
4202
5006
  dir
4203
5007
  };
4204
5008
  };
5009
+ const parseLint = (args) => {
5010
+ const positional = [];
5011
+ let built;
5012
+ for (let i = 0; i < args.length; i++) {
5013
+ const arg = args[i];
5014
+ if (arg === "--built") {
5015
+ built = args[++i];
5016
+ if (built === void 0) return { usageError: "--built needs a path" };
5017
+ } else if (arg.startsWith("-")) return { usageError: `unknown flag: ${arg}` };
5018
+ else positional.push(arg);
5019
+ }
5020
+ if (positional.length > 1) return { usageError: `extra arguments: ${positional.slice(1).join(" ")}` };
5021
+ return {
5022
+ command: "lint",
5023
+ dir: positional[0] ?? ".",
5024
+ built
5025
+ };
5026
+ };
5027
+ const parseDev = (args) => {
5028
+ const positional = [];
5029
+ let app;
5030
+ for (let i = 0; i < args.length; i++) {
5031
+ const arg = args[i];
5032
+ if (arg === "--app") {
5033
+ app = args[++i];
5034
+ if (app === void 0) return { usageError: "--app needs a path" };
5035
+ } else if (arg.startsWith("-")) return { usageError: `unknown flag: ${arg}` };
5036
+ else positional.push(arg);
5037
+ }
5038
+ if (positional.length > 1) return { usageError: `extra arguments: ${positional.slice(1).join(" ")}` };
5039
+ return {
5040
+ command: "dev",
5041
+ dir: positional[0] ?? ".",
5042
+ app
5043
+ };
5044
+ };
4205
5045
  const parseArgs = (argv) => {
4206
5046
  if (argv.includes("--help") || argv.includes("-h")) return { help: true };
4207
5047
  const [command, ...rest] = argv;
4208
5048
  if (command === "build") return parseBuild(rest);
4209
- if (command === "types") return parseTypes(rest);
4210
5049
  if (command === "validate") return parseValidate(rest);
5050
+ if (command === "lint") return parseLint(rest);
5051
+ if (command === "dev") return parseDev(rest);
4211
5052
  if (command === "catalog") return {
4212
5053
  command: "catalog",
4213
5054
  args: rest
@@ -4261,18 +5102,51 @@ const runValidate = async (dir, io) => {
4261
5102
  for (const problem of problems) io.stderr(`error ${root}: ${problem}\n`);
4262
5103
  return 1;
4263
5104
  };
4264
- const runTypes = async (dir, io) => {
5105
+ const runLint = async (parsed, io) => {
4265
5106
  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;
5107
+ const findings = await lintProject({
5108
+ root: parsed.dir,
5109
+ ...parsed.built === void 0 ? {} : { built: parsed.built }
5110
+ });
5111
+ for (const finding of findings) io.stdout(`${formatLintFinding(finding)}\n`);
5112
+ return lintHasErrors(findings) ? 1 : 0;
5113
+ } catch (error) {
5114
+ if (error instanceof CatalogUsageError) {
5115
+ io.stderr(`error ${error.subject}: ${error.message}\n`);
5116
+ return 2;
5117
+ }
5118
+ return reportBuildError(io, error);
5119
+ }
5120
+ };
5121
+ const runDevCommand = async (parsed, io, deps) => {
5122
+ const root = path.resolve(parsed.dir);
5123
+ try {
5124
+ return await runDev({
5125
+ root,
5126
+ ...parsed.app === void 0 ? {} : { app: parsed.app }
5127
+ }, io, deps.dev ?? systemDeps(deps.env ?? process.env), async () => {
5128
+ const handle = await watchExtension({
5129
+ root,
5130
+ logger: {
5131
+ info: (message) => io.stdout(`${message}\n`),
5132
+ error: (message) => io.stderr(`${message}\n`)
5133
+ }
5134
+ });
5135
+ return {
5136
+ outDir: path.join(root, DEFAULT_OUT_DIR),
5137
+ summary: summary(handle.result).trimEnd(),
5138
+ close: handle.close
5139
+ };
5140
+ }, deps.waitForExit ?? waitForSignal);
4271
5141
  } catch (error) {
5142
+ if (error instanceof CatalogUsageError) {
5143
+ io.stderr(`error ${error.subject}: ${error.message}\n`);
5144
+ return 2;
5145
+ }
4272
5146
  return reportBuildError(io, error);
4273
5147
  }
4274
5148
  };
4275
- /** `dolphy-ext build|types|validate|catalog`; `argv` is without `node` and the script name. */
5149
+ /** `dolphy-ext build|validate|lint|dev|catalog`; `argv` is without `node` and the script name. */
4276
5150
  const runCli = async (argv, io, deps = {}) => {
4277
5151
  const parsed = parseArgs(argv);
4278
5152
  if ("help" in parsed) {
@@ -4292,7 +5166,8 @@ const runCli = async (argv, io, deps = {}) => {
4292
5166
  return runCatalog(catalog, io, deps);
4293
5167
  }
4294
5168
  if (parsed.command === "validate") return runValidate(parsed.dir, io);
4295
- if (parsed.command === "types") return runTypes(parsed.dir, io);
5169
+ if (parsed.command === "lint") return runLint(parsed, io);
5170
+ if (parsed.command === "dev") return runDevCommand(parsed, io, deps);
4296
5171
  return runBuild(parsed, io, deps);
4297
5172
  };
4298
5173