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