epos-spec 1.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/dist/epos-spec.d.ts +60 -0
- package/dist/epos-spec.js +285 -0
- package/package.json +28 -0
- package/src/epos-spec.ts +442 -0
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { Obj } from 'dropcap/types';
|
|
2
|
+
|
|
3
|
+
type Action = true | string;
|
|
4
|
+
type Path = string;
|
|
5
|
+
type Match = LocusMatch | TopMatch | FrameMatch;
|
|
6
|
+
type MatchPattern = UrlMatchPattern | '<all_urls>';
|
|
7
|
+
type UrlMatchPattern = string;
|
|
8
|
+
type Access = 'installer' | 'engine';
|
|
9
|
+
type Manifest = Obj;
|
|
10
|
+
type Spec = {
|
|
11
|
+
name: string;
|
|
12
|
+
icon: string | null;
|
|
13
|
+
title: string | null;
|
|
14
|
+
version: string;
|
|
15
|
+
description: string | null;
|
|
16
|
+
popup: Popup;
|
|
17
|
+
action: Action | null;
|
|
18
|
+
config: Config;
|
|
19
|
+
assets: Path[];
|
|
20
|
+
targets: Target[];
|
|
21
|
+
permissions: Permissions;
|
|
22
|
+
manifest: Manifest | null;
|
|
23
|
+
};
|
|
24
|
+
type Popup = {
|
|
25
|
+
width: number;
|
|
26
|
+
height: number;
|
|
27
|
+
};
|
|
28
|
+
type Config = {
|
|
29
|
+
access: Access[];
|
|
30
|
+
preloadAssets: boolean;
|
|
31
|
+
allowMissingModels: boolean;
|
|
32
|
+
};
|
|
33
|
+
type Target = {
|
|
34
|
+
matches: Match[];
|
|
35
|
+
resources: Resource[];
|
|
36
|
+
};
|
|
37
|
+
type LocusMatch = {
|
|
38
|
+
context: 'locus';
|
|
39
|
+
value: 'popup' | 'sidePanel' | 'background';
|
|
40
|
+
};
|
|
41
|
+
type TopMatch = {
|
|
42
|
+
context: 'top';
|
|
43
|
+
value: MatchPattern;
|
|
44
|
+
};
|
|
45
|
+
type FrameMatch = {
|
|
46
|
+
context: 'frame';
|
|
47
|
+
value: MatchPattern;
|
|
48
|
+
};
|
|
49
|
+
type Resource = {
|
|
50
|
+
type: 'js' | 'css' | 'lite-js' | 'shadow-css';
|
|
51
|
+
path: Path;
|
|
52
|
+
};
|
|
53
|
+
type Permissions = {
|
|
54
|
+
mandatory: Permission[];
|
|
55
|
+
optional: Permission[];
|
|
56
|
+
};
|
|
57
|
+
type Permission = 'background' | 'browsingData' | 'contextMenus' | 'cookies' | 'downloads' | 'notifications' | 'storage';
|
|
58
|
+
declare function parseSpec(json: string): Spec;
|
|
59
|
+
|
|
60
|
+
export { type Access, type Action, type Config, type FrameMatch, type LocusMatch, type Manifest, type Match, type MatchPattern, type Path, type Permission, type Permissions, type Popup, type Resource, type Spec, type Target, type TopMatch, type UrlMatchPattern, parseSpec as default, parseSpec };
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
// src/epos-spec.ts
|
|
2
|
+
import { matchPattern } from "browser-extension-url-match";
|
|
3
|
+
import { ensureArray, is, safeSync, unique } from "dropcap/utils";
|
|
4
|
+
import stripJsonComments from "strip-json-comments";
|
|
5
|
+
var schema = {
|
|
6
|
+
keys: [
|
|
7
|
+
"$schema",
|
|
8
|
+
"name",
|
|
9
|
+
"version",
|
|
10
|
+
"icon",
|
|
11
|
+
"title",
|
|
12
|
+
"description",
|
|
13
|
+
"action",
|
|
14
|
+
"popup",
|
|
15
|
+
"config",
|
|
16
|
+
"assets",
|
|
17
|
+
"targets",
|
|
18
|
+
"permissions",
|
|
19
|
+
"manifest"
|
|
20
|
+
],
|
|
21
|
+
name: { min: 2, max: 50, regex: /^[a-z0-9][a-z0-9-]*[a-z0-9]$/ },
|
|
22
|
+
title: { min: 2, max: 45 },
|
|
23
|
+
description: { max: 132 },
|
|
24
|
+
version: { regex: /^(?:\d{1,5}\.){0,3}\d{1,5}$/ },
|
|
25
|
+
popup: {
|
|
26
|
+
keys: ["width", "height"],
|
|
27
|
+
width: { min: 150, max: 800, default: 380 },
|
|
28
|
+
height: { min: 150, max: 600 - 8 * 4, default: 600 - 8 * 4 }
|
|
29
|
+
},
|
|
30
|
+
config: {
|
|
31
|
+
keys: ["access", "preloadAssets", "allowMissingModels"],
|
|
32
|
+
access: { default: [], variants: ["installer", "engine"] },
|
|
33
|
+
preloadAssets: { default: true },
|
|
34
|
+
allowMissingModels: { default: false }
|
|
35
|
+
},
|
|
36
|
+
target: {
|
|
37
|
+
keys: ["matches", "load"]
|
|
38
|
+
},
|
|
39
|
+
permissions: [
|
|
40
|
+
"background",
|
|
41
|
+
"browsingData",
|
|
42
|
+
"contextMenus",
|
|
43
|
+
"cookies",
|
|
44
|
+
"downloads",
|
|
45
|
+
"notifications",
|
|
46
|
+
"storage",
|
|
47
|
+
"optional:background",
|
|
48
|
+
"optional:browsingData",
|
|
49
|
+
"optional:contextMenus",
|
|
50
|
+
"optional:cookies",
|
|
51
|
+
"optional:downloads",
|
|
52
|
+
"optional:notifications",
|
|
53
|
+
"optional:storage"
|
|
54
|
+
]
|
|
55
|
+
};
|
|
56
|
+
function parseSpec(json) {
|
|
57
|
+
json = stripJsonComments(json);
|
|
58
|
+
const [spec, error] = safeSync(() => JSON.parse(json));
|
|
59
|
+
if (error) throw new Error(`Failed to parse JSON: ${error.message}`);
|
|
60
|
+
if (!is.object(spec)) throw new Error(`Epos spec must be an object`);
|
|
61
|
+
const keys = [...schema.keys, ...schema.target.keys];
|
|
62
|
+
const badKey = Object.keys(spec).find((key) => !keys.includes(key));
|
|
63
|
+
if (badKey) throw new Error(`Unknown spec key: '${badKey}'`);
|
|
64
|
+
return {
|
|
65
|
+
name: parseName(spec),
|
|
66
|
+
icon: parseIcon(spec),
|
|
67
|
+
title: parseTitle(spec),
|
|
68
|
+
version: parseVersion(spec),
|
|
69
|
+
description: parseDescription(spec),
|
|
70
|
+
popup: parsePopup(spec),
|
|
71
|
+
action: parseAction(spec),
|
|
72
|
+
config: parseConfig(spec),
|
|
73
|
+
assets: parseAssets(spec),
|
|
74
|
+
targets: parseTargets(spec),
|
|
75
|
+
permissions: parsePermissions(spec),
|
|
76
|
+
manifest: parseManifest(spec)
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
function parseName(spec) {
|
|
80
|
+
if (!("name" in spec)) throw new Error(`'name' field is required`);
|
|
81
|
+
const name = spec.name;
|
|
82
|
+
const { min, max, regex } = schema.name;
|
|
83
|
+
if (!is.string(name)) throw new Error(`'name' must be a string`);
|
|
84
|
+
if (name.length < min) throw new Error(`'name' must be at least ${min} characters`);
|
|
85
|
+
if (name.length > max) throw new Error(`'name' must be at most ${max} characters`);
|
|
86
|
+
if (!regex.test(name)) throw new Error(`'name' must match ${regex}`);
|
|
87
|
+
return name;
|
|
88
|
+
}
|
|
89
|
+
function parseIcon(spec) {
|
|
90
|
+
if (!("icon" in spec)) return null;
|
|
91
|
+
const icon = spec.icon;
|
|
92
|
+
if (!is.string(icon)) throw new Error(`'icon' must be a string`);
|
|
93
|
+
return parsePath(icon);
|
|
94
|
+
}
|
|
95
|
+
function parseTitle(spec) {
|
|
96
|
+
if (!("title" in spec)) return null;
|
|
97
|
+
const title = spec.title;
|
|
98
|
+
if (!is.string(title)) throw new Error(`'title' must be a string`);
|
|
99
|
+
const { min, max } = schema.title;
|
|
100
|
+
if (title.length < min) throw new Error(`'title' must be at least ${min} characters`);
|
|
101
|
+
if (title.length > max) throw new Error(`'title' must be at most ${max} characters`);
|
|
102
|
+
return title;
|
|
103
|
+
}
|
|
104
|
+
function parseVersion(spec) {
|
|
105
|
+
if (!("version" in spec)) return "0.0.0";
|
|
106
|
+
const version = spec.version;
|
|
107
|
+
if (!is.string(version)) throw new Error(`'version' must be a string`);
|
|
108
|
+
if (!schema.version.regex.test(version)) throw new Error(`'version' must be in format X.Y.Z or X.Y or X`);
|
|
109
|
+
return version;
|
|
110
|
+
}
|
|
111
|
+
function parseDescription(spec) {
|
|
112
|
+
if (!("description" in spec)) return null;
|
|
113
|
+
const description = spec.description;
|
|
114
|
+
if (!is.string(description)) throw new Error(`'description' must be a string`);
|
|
115
|
+
const { max } = schema.description;
|
|
116
|
+
if (description.length > max) throw new Error(`'description' must be at most ${max} characters`);
|
|
117
|
+
return description;
|
|
118
|
+
}
|
|
119
|
+
function parsePopup(spec) {
|
|
120
|
+
const popup = structuredClone(spec.popup ?? {});
|
|
121
|
+
if (!is.object(popup)) throw new Error(`'popup' must be an object`);
|
|
122
|
+
const { keys, width, height } = schema.popup;
|
|
123
|
+
const badKey = Object.keys(popup).find((key) => !keys.includes(key));
|
|
124
|
+
if (badKey) throw new Error(`Unknown 'popup' key: '${badKey}'`);
|
|
125
|
+
popup.width ??= width.default;
|
|
126
|
+
if (!is.integer(popup.width)) throw new Error(`'popup.width' must be an integer`);
|
|
127
|
+
if (popup.width < width.min) throw new Error(`'popup.width' must be \u2265 ${width.min}`);
|
|
128
|
+
if (popup.width > width.max) throw new Error(`'popup.width' must be \u2264 ${width.max}`);
|
|
129
|
+
popup.height ??= height.default;
|
|
130
|
+
if (!is.integer(popup.height)) throw new Error(`'popup.height' must be an integer`);
|
|
131
|
+
if (popup.height < height.min) throw new Error(`'popup.height' must be \u2265 ${height.min}`);
|
|
132
|
+
if (popup.height > height.max) throw new Error(`'popup.height' must be \u2264 ${height.max}`);
|
|
133
|
+
return popup;
|
|
134
|
+
}
|
|
135
|
+
function parseAction(spec) {
|
|
136
|
+
const action = spec.action ?? null;
|
|
137
|
+
if (action === null) return null;
|
|
138
|
+
if (action === true) return true;
|
|
139
|
+
if (!is.string(action)) throw new Error(`'action' must be a URL or true`);
|
|
140
|
+
if (!isValidUrl(action)) throw new Error(`Invalid 'action' URL: '${JSON.stringify(action)}'`);
|
|
141
|
+
return action;
|
|
142
|
+
}
|
|
143
|
+
function parseConfig(spec) {
|
|
144
|
+
const config = spec.config ?? {};
|
|
145
|
+
if (!is.object(config)) throw new Error(`'config' must be an object`);
|
|
146
|
+
const badKey = Object.keys(config).find((key) => !schema.config.keys.includes(key));
|
|
147
|
+
if (badKey) throw new Error(`Unknown 'config' key: '${badKey}'`);
|
|
148
|
+
const access = config.access ?? schema.config.access.default;
|
|
149
|
+
if (!isArrayOfStrings(access)) throw new Error(`'config.access' must be an array of strings`);
|
|
150
|
+
const badAccess = access.find((value) => !schema.config.access.variants.includes(value));
|
|
151
|
+
if (badAccess) throw new Error(`Unknown 'config.access' value: '${badAccess}'`);
|
|
152
|
+
const preloadAssets = config.preloadAssets ?? schema.config.preloadAssets.default;
|
|
153
|
+
if (!is.boolean(preloadAssets)) throw new Error(`'config.preloadAssets' must be a boolean`);
|
|
154
|
+
const allowMissingModels = config.allowMissingModels ?? schema.config.allowMissingModels.default;
|
|
155
|
+
if (!is.boolean(allowMissingModels)) throw new Error(`'config.allowMissingModels' must be a boolean`);
|
|
156
|
+
return {
|
|
157
|
+
access,
|
|
158
|
+
preloadAssets,
|
|
159
|
+
allowMissingModels
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
function parseAssets(spec) {
|
|
163
|
+
const assets = structuredClone(spec.assets ?? []);
|
|
164
|
+
if (!isArrayOfStrings(assets)) throw new Error(`'assets' must be an array of strings`);
|
|
165
|
+
const icon = parseIcon(spec);
|
|
166
|
+
if (icon) assets.push(icon);
|
|
167
|
+
return unique(assets.map((path) => parsePath(path)));
|
|
168
|
+
}
|
|
169
|
+
function parseTargets(spec) {
|
|
170
|
+
const targets = structuredClone(spec.targets ?? []);
|
|
171
|
+
if (!is.array(targets)) throw new Error(`'targets' must be an array`);
|
|
172
|
+
if ("matches" in spec || "load" in spec || "mode" in spec) {
|
|
173
|
+
targets.unshift({
|
|
174
|
+
matches: structuredClone(spec.matches ?? []),
|
|
175
|
+
load: structuredClone(spec.load ?? [])
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
return targets.map((target) => parseTarget(target));
|
|
179
|
+
}
|
|
180
|
+
function parseTarget(target) {
|
|
181
|
+
if (!is.object(target)) throw new Error(`Each target must be an object`);
|
|
182
|
+
const { keys } = schema.target;
|
|
183
|
+
const badKey = Object.keys(target).find((key) => !keys.includes(key));
|
|
184
|
+
if (badKey) throw new Error(`Unknown target key: '${badKey}'`);
|
|
185
|
+
return {
|
|
186
|
+
matches: parseMatches(target),
|
|
187
|
+
resources: parseResources(target)
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
function parseMatches(target) {
|
|
191
|
+
const matches = ensureArray(target.matches ?? []);
|
|
192
|
+
return matches.map((match) => parseMatch(match)).flat();
|
|
193
|
+
}
|
|
194
|
+
function parseMatch(match) {
|
|
195
|
+
if (!is.string(match)) throw new Error(`Invalid match pattern: '${JSON.stringify(match)}'`);
|
|
196
|
+
if (match === "<popup>") return { context: "locus", value: "popup" };
|
|
197
|
+
if (match === "<sidePanel>") return { context: "locus", value: "sidePanel" };
|
|
198
|
+
if (match === "<background>") return { context: "locus", value: "background" };
|
|
199
|
+
const context = match.startsWith("frame:") ? "frame" : "top";
|
|
200
|
+
let pattern = context === "frame" ? match.replace("frame:", "") : match;
|
|
201
|
+
if (pattern === "<allUrls>") return { context, value: "<all_urls>" };
|
|
202
|
+
if (pattern === "<all_urls>") throw new Error(`Use '<allUrls>' instead of '<all_urls>'`);
|
|
203
|
+
if (pattern.startsWith("exact:")) {
|
|
204
|
+
return { context, value: parseMatchPattern(pattern.replace("exact:", "")) };
|
|
205
|
+
}
|
|
206
|
+
const href = pattern.replaceAll("*", "wildcard--");
|
|
207
|
+
if (!URL.canParse(href)) throw new Error(`Invalid match pattern: '${match}'`);
|
|
208
|
+
const url = new URL(href);
|
|
209
|
+
if (url.pathname === "") url.pathname = "/";
|
|
210
|
+
pattern = url.href.replaceAll("wildcard--", "*");
|
|
211
|
+
return [
|
|
212
|
+
{ context, value: parseMatchPattern(pattern) },
|
|
213
|
+
{ context, value: parseMatchPattern(`${pattern}?*`) }
|
|
214
|
+
];
|
|
215
|
+
}
|
|
216
|
+
function parseMatchPattern(pattern) {
|
|
217
|
+
const matcher = matchPattern(pattern);
|
|
218
|
+
if (!matcher.valid) throw new Error(`Invalid match pattern: '${pattern}'`);
|
|
219
|
+
return pattern;
|
|
220
|
+
}
|
|
221
|
+
function parseResources(target) {
|
|
222
|
+
const load = ensureArray(target.load ?? []);
|
|
223
|
+
if (!isArrayOfStrings(load)) throw new Error(`'load' must be an array of strings`);
|
|
224
|
+
return load.map((loadEntry) => parseResource(loadEntry));
|
|
225
|
+
}
|
|
226
|
+
function parseResource(loadEntry) {
|
|
227
|
+
const isJs = loadEntry.toLowerCase().endsWith(".js");
|
|
228
|
+
const isCss = loadEntry.toLowerCase().endsWith(".css");
|
|
229
|
+
if (!isJs && !isCss) throw new Error(`Invalid 'load' file, must be JS or CSS: '${loadEntry}'`);
|
|
230
|
+
if (loadEntry.startsWith("lite:")) {
|
|
231
|
+
if (!isJs) throw new Error(`'lite:' resources must be JS files: '${loadEntry}'`);
|
|
232
|
+
return { path: loadEntry.replace("lite:", ""), type: "lite-js" };
|
|
233
|
+
} else if (loadEntry.startsWith("shadow:")) {
|
|
234
|
+
if (!isCss) throw new Error(`'shadow:' resources must be CSS files: '${loadEntry}'`);
|
|
235
|
+
return { path: loadEntry.replace("shadow:", ""), type: "shadow-css" };
|
|
236
|
+
} else {
|
|
237
|
+
return { path: loadEntry, type: isJs ? "js" : "css" };
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
function parsePermissions(spec) {
|
|
241
|
+
const permissions = spec.permissions ?? [];
|
|
242
|
+
if (!isArrayOfStrings(permissions)) throw new Error(`'permissions' must be an array of strings`);
|
|
243
|
+
const badPermission = permissions.find((value) => !schema.permissions.includes(value));
|
|
244
|
+
if (badPermission) throw new Error(`Unknown permission: '${badPermission}'`);
|
|
245
|
+
const mandatoryPermissions = /* @__PURE__ */ new Set();
|
|
246
|
+
const optionalPermissions = /* @__PURE__ */ new Set();
|
|
247
|
+
for (const permission of permissions) {
|
|
248
|
+
if (permission.startsWith("optional:")) {
|
|
249
|
+
optionalPermissions.add(permission.replace("optional:", ""));
|
|
250
|
+
} else {
|
|
251
|
+
mandatoryPermissions.add(permission);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
for (const permission of mandatoryPermissions) {
|
|
255
|
+
if (optionalPermissions.has(permission)) {
|
|
256
|
+
throw new Error(`Permission cannot be both mandatory and optional: '${permission}'`);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
return {
|
|
260
|
+
mandatory: [...mandatoryPermissions],
|
|
261
|
+
optional: [...optionalPermissions]
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
function parseManifest(spec) {
|
|
265
|
+
if (!("manifest" in spec)) return null;
|
|
266
|
+
if (!is.object(spec.manifest)) throw new Error(`'manifest' must be an object`);
|
|
267
|
+
return spec.manifest;
|
|
268
|
+
}
|
|
269
|
+
function isArrayOfStrings(value) {
|
|
270
|
+
return is.array(value) && value.every(is.string);
|
|
271
|
+
}
|
|
272
|
+
function isValidUrl(value) {
|
|
273
|
+
if (!is.string(value)) return false;
|
|
274
|
+
return URL.canParse(value);
|
|
275
|
+
}
|
|
276
|
+
function parsePath(path) {
|
|
277
|
+
const normalizedPath = path.split("/").filter((path2) => path2 && path2 !== ".").join("/");
|
|
278
|
+
if (normalizedPath.startsWith("..")) throw new Error(`External paths are not allowed: '${path}'`);
|
|
279
|
+
return normalizedPath;
|
|
280
|
+
}
|
|
281
|
+
var epos_spec_default = parseSpec;
|
|
282
|
+
export {
|
|
283
|
+
epos_spec_default as default,
|
|
284
|
+
parseSpec
|
|
285
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "epos-spec",
|
|
3
|
+
"version": "1.3.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "imkost",
|
|
7
|
+
"description": "",
|
|
8
|
+
"keywords": [],
|
|
9
|
+
"scripts": {
|
|
10
|
+
"dev": "tsup --config ../../tsup.config.ts --watch",
|
|
11
|
+
"build": "tsup --config ../../tsup.config.ts",
|
|
12
|
+
"lint": "tsc --noEmit",
|
|
13
|
+
"release": "sh -c 'npm version ${1:-minor} && npm run build && npm publish' --"
|
|
14
|
+
},
|
|
15
|
+
"exports": {
|
|
16
|
+
".": "./dist/epos-spec.js",
|
|
17
|
+
"./ts": "./src/epos-spec.ts"
|
|
18
|
+
},
|
|
19
|
+
"files": [
|
|
20
|
+
"dist",
|
|
21
|
+
"src"
|
|
22
|
+
],
|
|
23
|
+
"dependencies": {
|
|
24
|
+
"browser-extension-url-match": "^1.2.0",
|
|
25
|
+
"dropcap": "^1.0.0",
|
|
26
|
+
"strip-json-comments": "^5.0.3"
|
|
27
|
+
}
|
|
28
|
+
}
|
package/src/epos-spec.ts
ADDED
|
@@ -0,0 +1,442 @@
|
|
|
1
|
+
import { matchPattern } from 'browser-extension-url-match'
|
|
2
|
+
import type { Obj } from 'dropcap/types'
|
|
3
|
+
import { ensureArray, is, safeSync, unique } from 'dropcap/utils'
|
|
4
|
+
import stripJsonComments from 'strip-json-comments'
|
|
5
|
+
|
|
6
|
+
export type Action = true | string
|
|
7
|
+
export type Path = string
|
|
8
|
+
export type Match = LocusMatch | TopMatch | FrameMatch
|
|
9
|
+
export type MatchPattern = UrlMatchPattern | '<all_urls>'
|
|
10
|
+
export type UrlMatchPattern = string // '*://*.example.com/*'
|
|
11
|
+
export type Access = 'installer' | 'engine'
|
|
12
|
+
export type Manifest = Obj
|
|
13
|
+
|
|
14
|
+
export type Spec = {
|
|
15
|
+
name: string
|
|
16
|
+
icon: string | null
|
|
17
|
+
title: string | null
|
|
18
|
+
version: string
|
|
19
|
+
description: string | null
|
|
20
|
+
popup: Popup
|
|
21
|
+
action: Action | null
|
|
22
|
+
config: Config
|
|
23
|
+
assets: Path[]
|
|
24
|
+
targets: Target[]
|
|
25
|
+
permissions: Permissions
|
|
26
|
+
manifest: Manifest | null
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export type Popup = {
|
|
30
|
+
width: number
|
|
31
|
+
height: number
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export type Config = {
|
|
35
|
+
access: Access[]
|
|
36
|
+
preloadAssets: boolean
|
|
37
|
+
allowMissingModels: boolean
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export type Target = {
|
|
41
|
+
matches: Match[]
|
|
42
|
+
resources: Resource[]
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export type LocusMatch = {
|
|
46
|
+
context: 'locus'
|
|
47
|
+
value: 'popup' | 'sidePanel' | 'background'
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export type TopMatch = {
|
|
51
|
+
context: 'top'
|
|
52
|
+
value: MatchPattern
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export type FrameMatch = {
|
|
56
|
+
context: 'frame'
|
|
57
|
+
value: MatchPattern
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export type Resource = {
|
|
61
|
+
type: 'js' | 'css' | 'lite-js' | 'shadow-css'
|
|
62
|
+
path: Path
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export type Permissions = {
|
|
66
|
+
mandatory: Permission[]
|
|
67
|
+
optional: Permission[]
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export type Permission =
|
|
71
|
+
| 'background'
|
|
72
|
+
| 'browsingData'
|
|
73
|
+
| 'contextMenus'
|
|
74
|
+
| 'cookies'
|
|
75
|
+
| 'downloads'
|
|
76
|
+
| 'notifications'
|
|
77
|
+
| 'storage'
|
|
78
|
+
|
|
79
|
+
const schema = {
|
|
80
|
+
keys: [
|
|
81
|
+
'$schema',
|
|
82
|
+
'name',
|
|
83
|
+
'version',
|
|
84
|
+
'icon',
|
|
85
|
+
'title',
|
|
86
|
+
'description',
|
|
87
|
+
'action',
|
|
88
|
+
'popup',
|
|
89
|
+
'config',
|
|
90
|
+
'assets',
|
|
91
|
+
'targets',
|
|
92
|
+
'permissions',
|
|
93
|
+
'manifest',
|
|
94
|
+
],
|
|
95
|
+
name: { min: 2, max: 50, regex: /^[a-z0-9][a-z0-9-]*[a-z0-9]$/ },
|
|
96
|
+
title: { min: 2, max: 45 },
|
|
97
|
+
description: { max: 132 },
|
|
98
|
+
version: { regex: /^(?:\d{1,5}\.){0,3}\d{1,5}$/ },
|
|
99
|
+
popup: {
|
|
100
|
+
keys: ['width', 'height'],
|
|
101
|
+
width: { min: 150, max: 800, default: 380 },
|
|
102
|
+
height: { min: 150, max: 600 - 8 * 4, default: 600 - 8 * 4 },
|
|
103
|
+
},
|
|
104
|
+
config: {
|
|
105
|
+
keys: ['access', 'preloadAssets', 'allowMissingModels'],
|
|
106
|
+
access: { default: [], variants: ['installer', 'engine'] },
|
|
107
|
+
preloadAssets: { default: true },
|
|
108
|
+
allowMissingModels: { default: false },
|
|
109
|
+
},
|
|
110
|
+
target: {
|
|
111
|
+
keys: ['matches', 'load'],
|
|
112
|
+
},
|
|
113
|
+
permissions: [
|
|
114
|
+
'background',
|
|
115
|
+
'browsingData',
|
|
116
|
+
'contextMenus',
|
|
117
|
+
'cookies',
|
|
118
|
+
'downloads',
|
|
119
|
+
'notifications',
|
|
120
|
+
'storage',
|
|
121
|
+
'optional:background',
|
|
122
|
+
'optional:browsingData',
|
|
123
|
+
'optional:contextMenus',
|
|
124
|
+
'optional:cookies',
|
|
125
|
+
'optional:downloads',
|
|
126
|
+
'optional:notifications',
|
|
127
|
+
'optional:storage',
|
|
128
|
+
],
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function parseSpec(json: string): Spec {
|
|
132
|
+
json = stripJsonComments(json)
|
|
133
|
+
const [spec, error] = safeSync(() => JSON.parse(json))
|
|
134
|
+
if (error) throw new Error(`Failed to parse JSON: ${error.message}`)
|
|
135
|
+
if (!is.object(spec)) throw new Error(`Epos spec must be an object`)
|
|
136
|
+
|
|
137
|
+
const keys = [...schema.keys, ...schema.target.keys]
|
|
138
|
+
const badKey = Object.keys(spec).find(key => !keys.includes(key))
|
|
139
|
+
if (badKey) throw new Error(`Unknown spec key: '${badKey}'`)
|
|
140
|
+
|
|
141
|
+
return {
|
|
142
|
+
name: parseName(spec),
|
|
143
|
+
icon: parseIcon(spec),
|
|
144
|
+
title: parseTitle(spec),
|
|
145
|
+
version: parseVersion(spec),
|
|
146
|
+
description: parseDescription(spec),
|
|
147
|
+
popup: parsePopup(spec),
|
|
148
|
+
action: parseAction(spec),
|
|
149
|
+
config: parseConfig(spec),
|
|
150
|
+
assets: parseAssets(spec),
|
|
151
|
+
targets: parseTargets(spec),
|
|
152
|
+
permissions: parsePermissions(spec),
|
|
153
|
+
manifest: parseManifest(spec),
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function parseName(spec: Obj) {
|
|
158
|
+
if (!('name' in spec)) throw new Error(`'name' field is required`)
|
|
159
|
+
|
|
160
|
+
const name = spec.name
|
|
161
|
+
const { min, max, regex } = schema.name
|
|
162
|
+
if (!is.string(name)) throw new Error(`'name' must be a string`)
|
|
163
|
+
if (name.length < min) throw new Error(`'name' must be at least ${min} characters`)
|
|
164
|
+
if (name.length > max) throw new Error(`'name' must be at most ${max} characters`)
|
|
165
|
+
if (!regex.test(name)) throw new Error(`'name' must match ${regex}`)
|
|
166
|
+
|
|
167
|
+
return name
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function parseIcon(spec: Obj) {
|
|
171
|
+
if (!('icon' in spec)) return null
|
|
172
|
+
|
|
173
|
+
const icon = spec.icon
|
|
174
|
+
if (!is.string(icon)) throw new Error(`'icon' must be a string`)
|
|
175
|
+
|
|
176
|
+
return parsePath(icon)
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function parseTitle(spec: Obj): string | null {
|
|
180
|
+
if (!('title' in spec)) return null
|
|
181
|
+
|
|
182
|
+
const title = spec.title
|
|
183
|
+
if (!is.string(title)) throw new Error(`'title' must be a string`)
|
|
184
|
+
|
|
185
|
+
const { min, max } = schema.title
|
|
186
|
+
if (title.length < min) throw new Error(`'title' must be at least ${min} characters`)
|
|
187
|
+
if (title.length > max) throw new Error(`'title' must be at most ${max} characters`)
|
|
188
|
+
|
|
189
|
+
return title
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function parseVersion(spec: Obj): string {
|
|
193
|
+
if (!('version' in spec)) return '0.0.0'
|
|
194
|
+
|
|
195
|
+
const version = spec.version
|
|
196
|
+
if (!is.string(version)) throw new Error(`'version' must be a string`)
|
|
197
|
+
if (!schema.version.regex.test(version)) throw new Error(`'version' must be in format X.Y.Z or X.Y or X`)
|
|
198
|
+
|
|
199
|
+
return version
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function parseDescription(spec: Obj): string | null {
|
|
203
|
+
if (!('description' in spec)) return null
|
|
204
|
+
|
|
205
|
+
const description = spec.description
|
|
206
|
+
if (!is.string(description)) throw new Error(`'description' must be a string`)
|
|
207
|
+
|
|
208
|
+
const { max } = schema.description
|
|
209
|
+
if (description.length > max) throw new Error(`'description' must be at most ${max} characters`)
|
|
210
|
+
|
|
211
|
+
return description
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function parsePopup(spec: Obj) {
|
|
215
|
+
const popup = structuredClone(spec.popup ?? {})
|
|
216
|
+
if (!is.object(popup)) throw new Error(`'popup' must be an object`)
|
|
217
|
+
|
|
218
|
+
const { keys, width, height } = schema.popup
|
|
219
|
+
const badKey = Object.keys(popup).find(key => !keys.includes(key))
|
|
220
|
+
if (badKey) throw new Error(`Unknown 'popup' key: '${badKey}'`)
|
|
221
|
+
|
|
222
|
+
popup.width ??= width.default
|
|
223
|
+
if (!is.integer(popup.width)) throw new Error(`'popup.width' must be an integer`)
|
|
224
|
+
if (popup.width < width.min) throw new Error(`'popup.width' must be ≥ ${width.min}`)
|
|
225
|
+
if (popup.width > width.max) throw new Error(`'popup.width' must be ≤ ${width.max}`)
|
|
226
|
+
|
|
227
|
+
popup.height ??= height.default
|
|
228
|
+
if (!is.integer(popup.height)) throw new Error(`'popup.height' must be an integer`)
|
|
229
|
+
if (popup.height < height.min) throw new Error(`'popup.height' must be ≥ ${height.min}`)
|
|
230
|
+
if (popup.height > height.max) throw new Error(`'popup.height' must be ≤ ${height.max}`)
|
|
231
|
+
|
|
232
|
+
return popup as Popup
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function parseAction(spec: Obj): Action | null {
|
|
236
|
+
const action = spec.action ?? null
|
|
237
|
+
if (action === null) return null
|
|
238
|
+
if (action === true) return true
|
|
239
|
+
|
|
240
|
+
if (!is.string(action)) throw new Error(`'action' must be a URL or true`)
|
|
241
|
+
if (!isValidUrl(action)) throw new Error(`Invalid 'action' URL: '${JSON.stringify(action)}'`)
|
|
242
|
+
|
|
243
|
+
return action
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function parseConfig(spec: Obj): Config {
|
|
247
|
+
const config = spec.config ?? {}
|
|
248
|
+
if (!is.object(config)) throw new Error(`'config' must be an object`)
|
|
249
|
+
|
|
250
|
+
const badKey = Object.keys(config).find(key => !schema.config.keys.includes(key))
|
|
251
|
+
if (badKey) throw new Error(`Unknown 'config' key: '${badKey}'`)
|
|
252
|
+
|
|
253
|
+
const access = config.access ?? schema.config.access.default
|
|
254
|
+
if (!isArrayOfStrings(access)) throw new Error(`'config.access' must be an array of strings`)
|
|
255
|
+
const badAccess = access.find(value => !schema.config.access.variants.includes(value))
|
|
256
|
+
if (badAccess) throw new Error(`Unknown 'config.access' value: '${badAccess}'`)
|
|
257
|
+
|
|
258
|
+
const preloadAssets = config.preloadAssets ?? schema.config.preloadAssets.default
|
|
259
|
+
if (!is.boolean(preloadAssets)) throw new Error(`'config.preloadAssets' must be a boolean`)
|
|
260
|
+
|
|
261
|
+
const allowMissingModels = config.allowMissingModels ?? schema.config.allowMissingModels.default
|
|
262
|
+
if (!is.boolean(allowMissingModels)) throw new Error(`'config.allowMissingModels' must be a boolean`)
|
|
263
|
+
|
|
264
|
+
return {
|
|
265
|
+
access: access as Access[],
|
|
266
|
+
preloadAssets,
|
|
267
|
+
allowMissingModels,
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function parseAssets(spec: Obj) {
|
|
272
|
+
const assets = structuredClone(spec.assets ?? [])
|
|
273
|
+
if (!isArrayOfStrings(assets)) throw new Error(`'assets' must be an array of strings`)
|
|
274
|
+
|
|
275
|
+
// Add icon to assets
|
|
276
|
+
const icon = parseIcon(spec)
|
|
277
|
+
if (icon) assets.push(icon)
|
|
278
|
+
|
|
279
|
+
return unique(assets.map(path => parsePath(path)))
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function parseTargets(spec: Obj) {
|
|
283
|
+
const targets = structuredClone(spec.targets ?? [])
|
|
284
|
+
if (!is.array(targets)) throw new Error(`'targets' must be an array`)
|
|
285
|
+
|
|
286
|
+
// Move top-level target to 'targets'
|
|
287
|
+
if ('matches' in spec || 'load' in spec || 'mode' in spec) {
|
|
288
|
+
targets.unshift({
|
|
289
|
+
matches: structuredClone(spec.matches ?? []),
|
|
290
|
+
load: structuredClone(spec.load ?? []),
|
|
291
|
+
})
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
return targets.map(target => parseTarget(target))
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function parseTarget(target: unknown): Target {
|
|
298
|
+
if (!is.object(target)) throw new Error(`Each target must be an object`)
|
|
299
|
+
|
|
300
|
+
const { keys } = schema.target
|
|
301
|
+
const badKey = Object.keys(target).find(key => !keys.includes(key))
|
|
302
|
+
if (badKey) throw new Error(`Unknown target key: '${badKey}'`)
|
|
303
|
+
|
|
304
|
+
return {
|
|
305
|
+
matches: parseMatches(target),
|
|
306
|
+
resources: parseResources(target),
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function parseMatches(target: Obj): Match[] {
|
|
311
|
+
const matches = ensureArray(target.matches ?? [])
|
|
312
|
+
return matches.map(match => parseMatch(match)).flat()
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function parseMatch(match: unknown): Match | Match[] {
|
|
316
|
+
if (!is.string(match)) throw new Error(`Invalid match pattern: '${JSON.stringify(match)}'`)
|
|
317
|
+
|
|
318
|
+
if (match === '<popup>') return { context: 'locus', value: 'popup' }
|
|
319
|
+
if (match === '<sidePanel>') return { context: 'locus', value: 'sidePanel' }
|
|
320
|
+
if (match === '<background>') return { context: 'locus', value: 'background' }
|
|
321
|
+
|
|
322
|
+
const context = match.startsWith('frame:') ? 'frame' : 'top'
|
|
323
|
+
let pattern = context === 'frame' ? match.replace('frame:', '') : match
|
|
324
|
+
|
|
325
|
+
if (pattern === '<allUrls>') return { context, value: '<all_urls>' }
|
|
326
|
+
if (pattern === '<all_urls>') throw new Error(`Use '<allUrls>' instead of '<all_urls>'`)
|
|
327
|
+
|
|
328
|
+
if (pattern.startsWith('exact:')) {
|
|
329
|
+
return { context, value: parseMatchPattern(pattern.replace('exact:', '')) }
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// Ensure pattern url has a path: `*://example.com` -> `*://example.com/`
|
|
333
|
+
const href = pattern.replaceAll('*', 'wildcard--')
|
|
334
|
+
if (!URL.canParse(href)) throw new Error(`Invalid match pattern: '${match}'`)
|
|
335
|
+
const url = new URL(href)
|
|
336
|
+
if (url.pathname === '') url.pathname = '/'
|
|
337
|
+
pattern = url.href.replaceAll('wildcard--', '*')
|
|
338
|
+
|
|
339
|
+
return [
|
|
340
|
+
{ context, value: parseMatchPattern(pattern) },
|
|
341
|
+
{ context, value: parseMatchPattern(`${pattern}?*`) },
|
|
342
|
+
]
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function parseMatchPattern(pattern: string): MatchPattern {
|
|
346
|
+
const matcher = matchPattern(pattern)
|
|
347
|
+
if (!matcher.valid) throw new Error(`Invalid match pattern: '${pattern}'`)
|
|
348
|
+
return pattern
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function parseResources(target: Obj) {
|
|
352
|
+
const load = ensureArray(target.load ?? [])
|
|
353
|
+
if (!isArrayOfStrings(load)) throw new Error(`'load' must be an array of strings`)
|
|
354
|
+
return load.map(loadEntry => parseResource(loadEntry))
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function parseResource(loadEntry: string): Resource {
|
|
358
|
+
const isJs = loadEntry.toLowerCase().endsWith('.js')
|
|
359
|
+
const isCss = loadEntry.toLowerCase().endsWith('.css')
|
|
360
|
+
if (!isJs && !isCss) throw new Error(`Invalid 'load' file, must be JS or CSS: '${loadEntry}'`)
|
|
361
|
+
|
|
362
|
+
if (loadEntry.startsWith('lite:')) {
|
|
363
|
+
if (!isJs) throw new Error(`'lite:' resources must be JS files: '${loadEntry}'`)
|
|
364
|
+
return { path: loadEntry.replace('lite:', ''), type: 'lite-js' }
|
|
365
|
+
} else if (loadEntry.startsWith('shadow:')) {
|
|
366
|
+
if (!isCss) throw new Error(`'shadow:' resources must be CSS files: '${loadEntry}'`)
|
|
367
|
+
return { path: loadEntry.replace('shadow:', ''), type: 'shadow-css' }
|
|
368
|
+
} else {
|
|
369
|
+
return { path: loadEntry, type: isJs ? 'js' : 'css' }
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function parsePermissions(spec: Obj): Permissions {
|
|
374
|
+
const permissions = spec.permissions ?? []
|
|
375
|
+
if (!isArrayOfStrings(permissions)) throw new Error(`'permissions' must be an array of strings`)
|
|
376
|
+
|
|
377
|
+
const badPermission = permissions.find(value => !schema.permissions.includes(value))
|
|
378
|
+
if (badPermission) throw new Error(`Unknown permission: '${badPermission}'`)
|
|
379
|
+
|
|
380
|
+
const mandatoryPermissions = new Set<string>()
|
|
381
|
+
const optionalPermissions = new Set<string>()
|
|
382
|
+
for (const permission of permissions) {
|
|
383
|
+
if (permission.startsWith('optional:')) {
|
|
384
|
+
optionalPermissions.add(permission.replace('optional:', ''))
|
|
385
|
+
} else {
|
|
386
|
+
mandatoryPermissions.add(permission)
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
for (const permission of mandatoryPermissions) {
|
|
391
|
+
if (optionalPermissions.has(permission)) {
|
|
392
|
+
throw new Error(`Permission cannot be both mandatory and optional: '${permission}'`)
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
return {
|
|
397
|
+
mandatory: [...mandatoryPermissions] as Permission[],
|
|
398
|
+
optional: [...optionalPermissions] as Permission[],
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function parseManifest(spec: Obj): Manifest | null {
|
|
403
|
+
if (!('manifest' in spec)) return null
|
|
404
|
+
if (!is.object(spec.manifest)) throw new Error(`'manifest' must be an object`)
|
|
405
|
+
return spec.manifest
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// ---------------------------------------------------------------------------
|
|
409
|
+
// HELPERS
|
|
410
|
+
// ---------------------------------------------------------------------------
|
|
411
|
+
|
|
412
|
+
function isArrayOfStrings(value: unknown) {
|
|
413
|
+
return is.array(value) && value.every(is.string)
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function isValidUrl(value: unknown) {
|
|
417
|
+
if (!is.string(value)) return false
|
|
418
|
+
return URL.canParse(value)
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* - 'path/to' -> 'path/to'
|
|
423
|
+
* - 'path/to/' -> 'path/to'
|
|
424
|
+
* - '/path/to' -> 'path/to'
|
|
425
|
+
* - 'path//to' -> 'path/to'
|
|
426
|
+
* - 'path/./to' -> 'path/to'
|
|
427
|
+
* - './path/to' -> 'path/to'
|
|
428
|
+
* - 'path/../to' -> 'path/../to'
|
|
429
|
+
* - '../path/to' -> throw
|
|
430
|
+
*/
|
|
431
|
+
function parsePath(path: string) {
|
|
432
|
+
const normalizedPath = path
|
|
433
|
+
.split('/')
|
|
434
|
+
.filter(path => path && path !== '.')
|
|
435
|
+
.join('/')
|
|
436
|
+
|
|
437
|
+
if (normalizedPath.startsWith('..')) throw new Error(`External paths are not allowed: '${path}'`)
|
|
438
|
+
|
|
439
|
+
return normalizedPath
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
export default parseSpec
|