epos-spec 1.3.0 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/epos-spec.js CHANGED
@@ -283,3 +283,4 @@ export {
283
283
  epos_spec_default as default,
284
284
  parseSpec
285
285
  };
286
+ //# sourceMappingURL=epos-spec.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/epos-spec.ts"],"sourcesContent":["import { matchPattern } from 'browser-extension-url-match'\nimport type { Obj } from 'dropcap/types'\nimport { ensureArray, is, safeSync, unique } from 'dropcap/utils'\nimport stripJsonComments from 'strip-json-comments'\n\nexport type Action = true | string\nexport type Path = string\nexport type Match = LocusMatch | TopMatch | FrameMatch\nexport type MatchPattern = UrlMatchPattern | '<all_urls>'\nexport type UrlMatchPattern = string // '*://*.example.com/*'\nexport type Access = 'installer' | 'engine'\nexport type Manifest = Obj\n\nexport type Spec = {\n name: string\n icon: string | null\n title: string | null\n version: string\n description: string | null\n popup: Popup\n action: Action | null\n config: Config\n assets: Path[]\n targets: Target[]\n permissions: Permissions\n manifest: Manifest | null\n}\n\nexport type Popup = {\n width: number\n height: number\n}\n\nexport type Config = {\n access: Access[]\n preloadAssets: boolean\n allowMissingModels: boolean\n}\n\nexport type Target = {\n matches: Match[]\n resources: Resource[]\n}\n\nexport type LocusMatch = {\n context: 'locus'\n value: 'popup' | 'sidePanel' | 'background'\n}\n\nexport type TopMatch = {\n context: 'top'\n value: MatchPattern\n}\n\nexport type FrameMatch = {\n context: 'frame'\n value: MatchPattern\n}\n\nexport type Resource = {\n type: 'js' | 'css' | 'lite-js' | 'shadow-css'\n path: Path\n}\n\nexport type Permissions = {\n mandatory: Permission[]\n optional: Permission[]\n}\n\nexport type Permission =\n | 'background'\n | 'browsingData'\n | 'contextMenus'\n | 'cookies'\n | 'downloads'\n | 'notifications'\n | 'storage'\n\nconst schema = {\n keys: [\n '$schema',\n 'name',\n 'version',\n 'icon',\n 'title',\n 'description',\n 'action',\n 'popup',\n 'config',\n 'assets',\n 'targets',\n 'permissions',\n 'manifest',\n ],\n name: { min: 2, max: 50, regex: /^[a-z0-9][a-z0-9-]*[a-z0-9]$/ },\n title: { min: 2, max: 45 },\n description: { max: 132 },\n version: { regex: /^(?:\\d{1,5}\\.){0,3}\\d{1,5}$/ },\n popup: {\n keys: ['width', 'height'],\n width: { min: 150, max: 800, default: 380 },\n height: { min: 150, max: 600 - 8 * 4, default: 600 - 8 * 4 },\n },\n config: {\n keys: ['access', 'preloadAssets', 'allowMissingModels'],\n access: { default: [], variants: ['installer', 'engine'] },\n preloadAssets: { default: true },\n allowMissingModels: { default: false },\n },\n target: {\n keys: ['matches', 'load'],\n },\n permissions: [\n 'background',\n 'browsingData',\n 'contextMenus',\n 'cookies',\n 'downloads',\n 'notifications',\n 'storage',\n 'optional:background',\n 'optional:browsingData',\n 'optional:contextMenus',\n 'optional:cookies',\n 'optional:downloads',\n 'optional:notifications',\n 'optional:storage',\n ],\n}\n\nexport function parseSpec(json: string): Spec {\n json = stripJsonComments(json)\n const [spec, error] = safeSync(() => JSON.parse(json))\n if (error) throw new Error(`Failed to parse JSON: ${error.message}`)\n if (!is.object(spec)) throw new Error(`Epos spec must be an object`)\n\n const keys = [...schema.keys, ...schema.target.keys]\n const badKey = Object.keys(spec).find(key => !keys.includes(key))\n if (badKey) throw new Error(`Unknown spec key: '${badKey}'`)\n\n return {\n name: parseName(spec),\n icon: parseIcon(spec),\n title: parseTitle(spec),\n version: parseVersion(spec),\n description: parseDescription(spec),\n popup: parsePopup(spec),\n action: parseAction(spec),\n config: parseConfig(spec),\n assets: parseAssets(spec),\n targets: parseTargets(spec),\n permissions: parsePermissions(spec),\n manifest: parseManifest(spec),\n }\n}\n\nfunction parseName(spec: Obj) {\n if (!('name' in spec)) throw new Error(`'name' field is required`)\n\n const name = spec.name\n const { min, max, regex } = schema.name\n if (!is.string(name)) throw new Error(`'name' must be a string`)\n if (name.length < min) throw new Error(`'name' must be at least ${min} characters`)\n if (name.length > max) throw new Error(`'name' must be at most ${max} characters`)\n if (!regex.test(name)) throw new Error(`'name' must match ${regex}`)\n\n return name\n}\n\nfunction parseIcon(spec: Obj) {\n if (!('icon' in spec)) return null\n\n const icon = spec.icon\n if (!is.string(icon)) throw new Error(`'icon' must be a string`)\n\n return parsePath(icon)\n}\n\nfunction parseTitle(spec: Obj): string | null {\n if (!('title' in spec)) return null\n\n const title = spec.title\n if (!is.string(title)) throw new Error(`'title' must be a string`)\n\n const { min, max } = schema.title\n if (title.length < min) throw new Error(`'title' must be at least ${min} characters`)\n if (title.length > max) throw new Error(`'title' must be at most ${max} characters`)\n\n return title\n}\n\nfunction parseVersion(spec: Obj): string {\n if (!('version' in spec)) return '0.0.0'\n\n const version = spec.version\n if (!is.string(version)) throw new Error(`'version' must be a string`)\n if (!schema.version.regex.test(version)) throw new Error(`'version' must be in format X.Y.Z or X.Y or X`)\n\n return version\n}\n\nfunction parseDescription(spec: Obj): string | null {\n if (!('description' in spec)) return null\n\n const description = spec.description\n if (!is.string(description)) throw new Error(`'description' must be a string`)\n\n const { max } = schema.description\n if (description.length > max) throw new Error(`'description' must be at most ${max} characters`)\n\n return description\n}\n\nfunction parsePopup(spec: Obj) {\n const popup = structuredClone(spec.popup ?? {})\n if (!is.object(popup)) throw new Error(`'popup' must be an object`)\n\n const { keys, width, height } = schema.popup\n const badKey = Object.keys(popup).find(key => !keys.includes(key))\n if (badKey) throw new Error(`Unknown 'popup' key: '${badKey}'`)\n\n popup.width ??= width.default\n if (!is.integer(popup.width)) throw new Error(`'popup.width' must be an integer`)\n if (popup.width < width.min) throw new Error(`'popup.width' must be ≥ ${width.min}`)\n if (popup.width > width.max) throw new Error(`'popup.width' must be ≤ ${width.max}`)\n\n popup.height ??= height.default\n if (!is.integer(popup.height)) throw new Error(`'popup.height' must be an integer`)\n if (popup.height < height.min) throw new Error(`'popup.height' must be ≥ ${height.min}`)\n if (popup.height > height.max) throw new Error(`'popup.height' must be ≤ ${height.max}`)\n\n return popup as Popup\n}\n\nfunction parseAction(spec: Obj): Action | null {\n const action = spec.action ?? null\n if (action === null) return null\n if (action === true) return true\n\n if (!is.string(action)) throw new Error(`'action' must be a URL or true`)\n if (!isValidUrl(action)) throw new Error(`Invalid 'action' URL: '${JSON.stringify(action)}'`)\n\n return action\n}\n\nfunction parseConfig(spec: Obj): Config {\n const config = spec.config ?? {}\n if (!is.object(config)) throw new Error(`'config' must be an object`)\n\n const badKey = Object.keys(config).find(key => !schema.config.keys.includes(key))\n if (badKey) throw new Error(`Unknown 'config' key: '${badKey}'`)\n\n const access = config.access ?? schema.config.access.default\n if (!isArrayOfStrings(access)) throw new Error(`'config.access' must be an array of strings`)\n const badAccess = access.find(value => !schema.config.access.variants.includes(value))\n if (badAccess) throw new Error(`Unknown 'config.access' value: '${badAccess}'`)\n\n const preloadAssets = config.preloadAssets ?? schema.config.preloadAssets.default\n if (!is.boolean(preloadAssets)) throw new Error(`'config.preloadAssets' must be a boolean`)\n\n const allowMissingModels = config.allowMissingModels ?? schema.config.allowMissingModels.default\n if (!is.boolean(allowMissingModels)) throw new Error(`'config.allowMissingModels' must be a boolean`)\n\n return {\n access: access as Access[],\n preloadAssets,\n allowMissingModels,\n }\n}\n\nfunction parseAssets(spec: Obj) {\n const assets = structuredClone(spec.assets ?? [])\n if (!isArrayOfStrings(assets)) throw new Error(`'assets' must be an array of strings`)\n\n // Add icon to assets\n const icon = parseIcon(spec)\n if (icon) assets.push(icon)\n\n return unique(assets.map(path => parsePath(path)))\n}\n\nfunction parseTargets(spec: Obj) {\n const targets = structuredClone(spec.targets ?? [])\n if (!is.array(targets)) throw new Error(`'targets' must be an array`)\n\n // Move top-level target to 'targets'\n if ('matches' in spec || 'load' in spec || 'mode' in spec) {\n targets.unshift({\n matches: structuredClone(spec.matches ?? []),\n load: structuredClone(spec.load ?? []),\n })\n }\n\n return targets.map(target => parseTarget(target))\n}\n\nfunction parseTarget(target: unknown): Target {\n if (!is.object(target)) throw new Error(`Each target must be an object`)\n\n const { keys } = schema.target\n const badKey = Object.keys(target).find(key => !keys.includes(key))\n if (badKey) throw new Error(`Unknown target key: '${badKey}'`)\n\n return {\n matches: parseMatches(target),\n resources: parseResources(target),\n }\n}\n\nfunction parseMatches(target: Obj): Match[] {\n const matches = ensureArray(target.matches ?? [])\n return matches.map(match => parseMatch(match)).flat()\n}\n\nfunction parseMatch(match: unknown): Match | Match[] {\n if (!is.string(match)) throw new Error(`Invalid match pattern: '${JSON.stringify(match)}'`)\n\n if (match === '<popup>') return { context: 'locus', value: 'popup' }\n if (match === '<sidePanel>') return { context: 'locus', value: 'sidePanel' }\n if (match === '<background>') return { context: 'locus', value: 'background' }\n\n const context = match.startsWith('frame:') ? 'frame' : 'top'\n let pattern = context === 'frame' ? match.replace('frame:', '') : match\n\n if (pattern === '<allUrls>') return { context, value: '<all_urls>' }\n if (pattern === '<all_urls>') throw new Error(`Use '<allUrls>' instead of '<all_urls>'`)\n\n if (pattern.startsWith('exact:')) {\n return { context, value: parseMatchPattern(pattern.replace('exact:', '')) }\n }\n\n // Ensure pattern url has a path: `*://example.com` -> `*://example.com/`\n const href = pattern.replaceAll('*', 'wildcard--')\n if (!URL.canParse(href)) throw new Error(`Invalid match pattern: '${match}'`)\n const url = new URL(href)\n if (url.pathname === '') url.pathname = '/'\n pattern = url.href.replaceAll('wildcard--', '*')\n\n return [\n { context, value: parseMatchPattern(pattern) },\n { context, value: parseMatchPattern(`${pattern}?*`) },\n ]\n}\n\nfunction parseMatchPattern(pattern: string): MatchPattern {\n const matcher = matchPattern(pattern)\n if (!matcher.valid) throw new Error(`Invalid match pattern: '${pattern}'`)\n return pattern\n}\n\nfunction parseResources(target: Obj) {\n const load = ensureArray(target.load ?? [])\n if (!isArrayOfStrings(load)) throw new Error(`'load' must be an array of strings`)\n return load.map(loadEntry => parseResource(loadEntry))\n}\n\nfunction parseResource(loadEntry: string): Resource {\n const isJs = loadEntry.toLowerCase().endsWith('.js')\n const isCss = loadEntry.toLowerCase().endsWith('.css')\n if (!isJs && !isCss) throw new Error(`Invalid 'load' file, must be JS or CSS: '${loadEntry}'`)\n\n if (loadEntry.startsWith('lite:')) {\n if (!isJs) throw new Error(`'lite:' resources must be JS files: '${loadEntry}'`)\n return { path: loadEntry.replace('lite:', ''), type: 'lite-js' }\n } else if (loadEntry.startsWith('shadow:')) {\n if (!isCss) throw new Error(`'shadow:' resources must be CSS files: '${loadEntry}'`)\n return { path: loadEntry.replace('shadow:', ''), type: 'shadow-css' }\n } else {\n return { path: loadEntry, type: isJs ? 'js' : 'css' }\n }\n}\n\nfunction parsePermissions(spec: Obj): Permissions {\n const permissions = spec.permissions ?? []\n if (!isArrayOfStrings(permissions)) throw new Error(`'permissions' must be an array of strings`)\n\n const badPermission = permissions.find(value => !schema.permissions.includes(value))\n if (badPermission) throw new Error(`Unknown permission: '${badPermission}'`)\n\n const mandatoryPermissions = new Set<string>()\n const optionalPermissions = new Set<string>()\n for (const permission of permissions) {\n if (permission.startsWith('optional:')) {\n optionalPermissions.add(permission.replace('optional:', ''))\n } else {\n mandatoryPermissions.add(permission)\n }\n }\n\n for (const permission of mandatoryPermissions) {\n if (optionalPermissions.has(permission)) {\n throw new Error(`Permission cannot be both mandatory and optional: '${permission}'`)\n }\n }\n\n return {\n mandatory: [...mandatoryPermissions] as Permission[],\n optional: [...optionalPermissions] as Permission[],\n }\n}\n\nfunction parseManifest(spec: Obj): Manifest | null {\n if (!('manifest' in spec)) return null\n if (!is.object(spec.manifest)) throw new Error(`'manifest' must be an object`)\n return spec.manifest\n}\n\n// ---------------------------------------------------------------------------\n// HELPERS\n// ---------------------------------------------------------------------------\n\nfunction isArrayOfStrings(value: unknown) {\n return is.array(value) && value.every(is.string)\n}\n\nfunction isValidUrl(value: unknown) {\n if (!is.string(value)) return false\n return URL.canParse(value)\n}\n\n/**\n * - 'path/to' -> 'path/to'\n * - 'path/to/' -> 'path/to'\n * - '/path/to' -> 'path/to'\n * - 'path//to' -> 'path/to'\n * - 'path/./to' -> 'path/to'\n * - './path/to' -> 'path/to'\n * - 'path/../to' -> 'path/../to'\n * - '../path/to' -> throw\n */\nfunction parsePath(path: string) {\n const normalizedPath = path\n .split('/')\n .filter(path => path && path !== '.')\n .join('/')\n\n if (normalizedPath.startsWith('..')) throw new Error(`External paths are not allowed: '${path}'`)\n\n return normalizedPath\n}\n\nexport default parseSpec\n"],"mappings":"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","names":["path"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "epos-spec",
3
- "version": "1.3.0",
3
+ "version": "1.5.0",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "author": "imkost",
@@ -13,16 +13,14 @@
13
13
  "release": "sh -c 'npm version ${1:-minor} && npm run build && npm publish' --"
14
14
  },
15
15
  "exports": {
16
- ".": "./dist/epos-spec.js",
17
- "./ts": "./src/epos-spec.ts"
16
+ ".": "./dist/epos-spec.js"
18
17
  },
19
18
  "files": [
20
- "dist",
21
- "src"
19
+ "dist"
22
20
  ],
23
21
  "dependencies": {
24
22
  "browser-extension-url-match": "^1.2.0",
25
- "dropcap": "^1.0.0",
23
+ "dropcap": "^1.4.0",
26
24
  "strip-json-comments": "^5.0.3"
27
25
  }
28
26
  }
package/src/epos-spec.ts DELETED
@@ -1,442 +0,0 @@
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