@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.
- package/README.md +8 -5
- package/dist/cli/main.js +2153 -1278
- 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
|
|
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
|
|
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
|
-
|
|
42
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
92
|
-
* `
|
|
93
|
-
*
|
|
94
|
-
*
|
|
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 =
|
|
97
|
-
/** Limits on commands and panels (R1, R3); they match those checked by the
|
|
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
|
|
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
|
-
|
|
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
|
|
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") &&
|
|
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(
|
|
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
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
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$
|
|
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$
|
|
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$
|
|
307
|
-
detail: text$
|
|
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$
|
|
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
|
-
/**
|
|
358
|
-
const
|
|
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
|
|
376
|
-
const CATALOG_FILE_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
|
|
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
|
-
/**
|
|
728
|
-
const
|
|
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 &&
|
|
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
|
-
* Каталог файлов версии относительно адреса
|
|
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(
|
|
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(
|
|
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
|
-
|
|
848
|
-
|
|
849
|
-
code: "custom",
|
|
850
|
-
path: ["tags"],
|
|
851
|
-
message: "tags is not part of this index format"
|
|
852
|
-
});
|
|
853
|
-
if (!profile.icon && version.icon !== void 0) ctx.addIssue({
|
|
854
|
-
code: "custom",
|
|
855
|
-
path: ["icon"],
|
|
856
|
-
message: "icon is not part of this index format"
|
|
857
|
-
});
|
|
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
|
-
|
|
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(
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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.
|
|
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
|
|
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
|
-
|
|
1425
|
-
const
|
|
1426
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
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
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
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
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
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) =>
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
if (
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/** Срок обработчика
|
|
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
|
|
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
|
|
2125
|
-
* `?inline`, a plain `import './x.css'` is an error with the way out in the
|
|
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 (
|
|
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/
|
|
2165
|
-
/**
|
|
2166
|
-
|
|
2167
|
-
|
|
2168
|
-
|
|
2169
|
-
|
|
2170
|
-
|
|
2171
|
-
|
|
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
|
-
/**
|
|
2175
|
-
|
|
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/
|
|
2179
|
-
/**
|
|
2180
|
-
|
|
2181
|
-
|
|
2182
|
-
|
|
2183
|
-
|
|
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
|
|
2238
|
-
* `
|
|
2239
|
-
*
|
|
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 =
|
|
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
|
|
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
|
-
|
|
2381
|
-
|
|
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
|
-
|
|
2442
|
-
|
|
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:
|
|
2456
|
-
*
|
|
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,
|
|
2523
|
-
* `src/index.ts`
|
|
2524
|
-
*
|
|
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 === "
|
|
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
|
-
|
|
2552
|
-
|
|
2553
|
-
|
|
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
|
|
2561
|
-
if (
|
|
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
|
-
|
|
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
|
|
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} (${
|
|
3063
|
+
label: `${output.output} (${exportOf(output)} from ${project.indexSource})`,
|
|
2625
3064
|
config: {
|
|
2626
3065
|
...config,
|
|
2627
|
-
plugins: [
|
|
2628
|
-
|
|
2629
|
-
|
|
2630
|
-
|
|
2631
|
-
|
|
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
|
|
2649
|
-
const jobsOf = (project, outDir) => [
|
|
2650
|
-
...project.
|
|
2651
|
-
...project.
|
|
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
|
-
|
|
2763
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
2820
|
-
|
|
2821
|
-
|
|
2822
|
-
|
|
2823
|
-
|
|
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:
|
|
2829
|
-
|
|
2830
|
-
|
|
2831
|
-
|
|
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
|
-
|
|
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 = ["
|
|
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
|
|
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.
|
|
3382
|
+
problems: [formatDiagnostic(result.diagnostic)]
|
|
3029
3383
|
};
|
|
3030
|
-
const
|
|
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
|
-
|
|
3086
|
-
|
|
3087
|
-
|
|
3088
|
-
|
|
3089
|
-
|
|
3090
|
-
|
|
3091
|
-
|
|
3092
|
-
|
|
3093
|
-
|
|
3094
|
-
|
|
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
|
|
3129
|
-
const manifestWatcher = watch(
|
|
3482
|
+
let timer;
|
|
3483
|
+
const manifestWatcher = watch(first.root, (_event, name) => {
|
|
3130
3484
|
if (name !== "extension.json") return;
|
|
3131
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
3512
|
-
|
|
3513
|
-
const fields = raw;
|
|
4121
|
+
raw = JSON.parse(await readFile(path.join(dir, "extension.json"), "utf8"));
|
|
4122
|
+
} catch {
|
|
3514
4123
|
return {
|
|
3515
|
-
|
|
3516
|
-
|
|
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
|
|
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
|
-
|
|
3599
|
-
|
|
3600
|
-
|
|
3601
|
-
|
|
3602
|
-
|
|
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
|
|
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)}`,
|
|
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
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
3857
|
-
|
|
3858
|
-
|
|
3859
|
-
|
|
3860
|
-
const
|
|
3861
|
-
if (
|
|
3862
|
-
const full = path.join(out, FULL_INDEX_FILE);
|
|
3863
|
-
return await stat(full).catch(() => null) === null ? path.join(out, INDEX_FILE) : full;
|
|
3864
|
-
};
|
|
3865
|
-
/**
|
|
3866
|
-
* Writes `index.v2.json` and `index.json` when either differs from the disk (ignoring
|
|
3867
|
-
* `generatedAt`); both get the same `generatedAt`, so the app can compare them with each other.
|
|
3868
|
-
*/
|
|
3869
|
-
const writeIndexes = async (out, { full, legacy }) => {
|
|
3870
|
-
const fullFile = path.join(out, FULL_INDEX_FILE);
|
|
3871
|
-
const legacyFile = path.join(out, INDEX_FILE);
|
|
3872
|
-
if (hasSameContent(await loadIndexFile(fullFile), full) && hasSameContent(await loadIndexFile(legacyFile), legacy)) return false;
|
|
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(
|
|
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
|
|
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
|
|
4486
|
+
results.push(resultOf(item));
|
|
3900
4487
|
}
|
|
3901
|
-
const
|
|
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
|
|
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
|
|
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
|
|
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
|
|
4515
|
+
const changed = await writeIndex(out, index);
|
|
3929
4516
|
return {
|
|
3930
|
-
extensions:
|
|
3931
|
-
revoked:
|
|
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>] [--
|
|
3940
|
-
[--
|
|
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
|
-
[--
|
|
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
|
|
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
|
-
"--
|
|
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
|
|
4154
|
-
|
|
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
|
-
|
|
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
|
|
5105
|
+
const runLint = async (parsed, io) => {
|
|
4265
5106
|
try {
|
|
4266
|
-
const
|
|
4267
|
-
|
|
4268
|
-
|
|
4269
|
-
|
|
4270
|
-
|
|
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|
|
|
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 === "
|
|
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
|
|