browser-extension-utils 0.2.2 → 0.3.2

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/lib/index.ts ADDED
@@ -0,0 +1,361 @@
1
+ import {
2
+ doc,
3
+ win,
4
+ addEventListener,
5
+ removeEventListener,
6
+ getRootElement,
7
+ } from './dom-utils'
8
+ import { setAttributes } from './set-attributes'
9
+ import { createElement } from './create-element'
10
+ import { addElement } from './add-element'
11
+
12
+ export * from './dom-utils'
13
+ export * from './set-attributes'
14
+ export * from './create-element'
15
+ export * from './add-element'
16
+ export type RegisterMenuCommandOptions = Parameters<
17
+ typeof GM_registerMenuCommand
18
+ >[2]
19
+
20
+ export const uniq = <T>(array: T[]): T[] => [...new Set(array)]
21
+
22
+ export const $ = (
23
+ selector: string,
24
+ context: Element | Document = doc
25
+ ): HTMLElement | null => context.querySelector(selector)!
26
+
27
+ export const $$ = (
28
+ selector: string,
29
+ context: Element | Document = doc
30
+ ): HTMLElement[] =>
31
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
32
+ // @ts-ignore
33
+ [...context.querySelectorAll(selector)] as HTMLElement[]
34
+
35
+ export const addStyle = (
36
+ style: string,
37
+ attributes?: Record<string, unknown>
38
+ ): HTMLStyleElement => {
39
+ const element = createElement('style', {
40
+ textContent: style,
41
+ ...attributes,
42
+ }) as HTMLStyleElement
43
+ getRootElement(1).append(element)
44
+ return element
45
+ }
46
+
47
+ export type MenuCallback = (event?: MouseEvent | KeyboardEvent) => void
48
+
49
+ export const registerMenuCommand = (
50
+ _name?: string,
51
+ _callback?: MenuCallback,
52
+ _options?: Parameters<typeof GM_registerMenuCommand>[2]
53
+ ): Promise<string | number | undefined> | undefined => undefined
54
+
55
+ export const extendHistoryApi = (): void => {
56
+ // https://dirask.com/posts/JavaScript-on-location-changed-event-on-url-changed-event-DKeyZj
57
+ const pushState = history.pushState
58
+ const replaceState = history.replaceState
59
+
60
+ history.pushState = function (...args) {
61
+ pushState.apply(history, args)
62
+ globalThis.dispatchEvent(new Event('pushstate'))
63
+ globalThis.dispatchEvent(new Event('locationchange'))
64
+ }
65
+
66
+ history.replaceState = function (...args) {
67
+ replaceState.apply(history, args)
68
+ globalThis.dispatchEvent(new Event('replacestate'))
69
+ globalThis.dispatchEvent(new Event('locationchange'))
70
+ }
71
+
72
+ globalThis.addEventListener('popstate', () => {
73
+ globalThis.dispatchEvent(new Event('locationchange'))
74
+ })
75
+
76
+ // Usage example:
77
+ // window.addEventListener("locationchange", function () {
78
+ // console.log("onlocationchange event occurred!")
79
+ // })
80
+ }
81
+
82
+ // eslint-disable-next-line no-script-url
83
+ export const actionHref = 'javascript:;'
84
+
85
+ export const getOffsetPosition = (
86
+ element: HTMLElement | undefined,
87
+ referElement?: HTMLElement
88
+ ): {
89
+ top: number
90
+ left: number
91
+ } => {
92
+ const position = { top: 0, left: 0 }
93
+ referElement = referElement || doc.body
94
+
95
+ while (element && element !== referElement) {
96
+ position.top += element.offsetTop
97
+ position.left += element.offsetLeft
98
+ element = element.offsetParent as HTMLElement
99
+ }
100
+
101
+ return position
102
+ }
103
+
104
+ const runOnceCache: Record<string, any> = {}
105
+ export const runOnce = async (
106
+ key: string,
107
+ func: () => Promise<any> | any
108
+ ): Promise<any> => {
109
+ if (Object.hasOwn(runOnceCache, key)) {
110
+ return runOnceCache[key]
111
+ }
112
+
113
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
114
+ const result = await func()
115
+
116
+ if (key) {
117
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
118
+ runOnceCache[key] = result
119
+ }
120
+
121
+ return result
122
+ }
123
+
124
+ const cacheStore: Record<string, any> = {}
125
+ const makeKey = (key: string | any[]) =>
126
+ Array.isArray(key) ? key.join(':') : key
127
+
128
+ export type Cache = {
129
+ get: (key: string | any[]) => any
130
+ add: (key: string | any[], value: any) => void
131
+ }
132
+
133
+ export const cache: Cache = {
134
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-return
135
+ get: (key: string | any[]) => cacheStore[makeKey(key)],
136
+ add(key: string | any[], value: any) {
137
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
138
+ cacheStore[makeKey(key)] = value
139
+ },
140
+ }
141
+
142
+ export const sleep = async (time: number): Promise<any> =>
143
+ new Promise((resolve) => {
144
+ setTimeout(() => {
145
+ resolve(1)
146
+ }, time)
147
+ })
148
+
149
+ export const parseInt10 = (
150
+ number: string | number | undefined,
151
+ defaultValue?: number
152
+ ): number => {
153
+ if (typeof number === 'number' && !Number.isNaN(number)) {
154
+ return number
155
+ }
156
+
157
+ if (typeof defaultValue !== 'number') {
158
+ defaultValue = Number.NaN
159
+ }
160
+
161
+ if (!number) {
162
+ return defaultValue
163
+ }
164
+
165
+ const result = Number.parseInt(String(number), 10)
166
+ return Number.isNaN(result) ? defaultValue : result
167
+ }
168
+
169
+ const rootFuncArray: Array<() => void> = []
170
+ const headFuncArray: Array<() => void> = []
171
+ const bodyFuncArray: Array<() => void> = []
172
+ let headBodyObserver: MutationObserver
173
+
174
+ const startObserveHeadBodyExists = () => {
175
+ if (headBodyObserver) {
176
+ return
177
+ }
178
+
179
+ headBodyObserver = new MutationObserver(() => {
180
+ if (doc.head && doc.body) {
181
+ headBodyObserver.disconnect()
182
+ }
183
+
184
+ if (doc.documentElement && rootFuncArray.length > 0) {
185
+ for (const func of rootFuncArray) {
186
+ func()
187
+ }
188
+
189
+ rootFuncArray.length = 0
190
+ }
191
+
192
+ if (doc.head && headFuncArray.length > 0) {
193
+ for (const func of headFuncArray) {
194
+ func()
195
+ }
196
+
197
+ headFuncArray.length = 0
198
+ }
199
+
200
+ if (doc.body && bodyFuncArray.length > 0) {
201
+ for (const func of bodyFuncArray) {
202
+ func()
203
+ }
204
+
205
+ bodyFuncArray.length = 0
206
+ }
207
+ })
208
+
209
+ headBodyObserver.observe(doc, {
210
+ childList: true,
211
+ subtree: true,
212
+ })
213
+ }
214
+
215
+ /**
216
+ * Run function when document.documentElement exsits.
217
+ */
218
+ export const runWhenRootExists = (func: () => void): void => {
219
+ if (!doc.documentElement) {
220
+ rootFuncArray.push(func)
221
+ startObserveHeadBodyExists()
222
+ return
223
+ }
224
+
225
+ func()
226
+ }
227
+
228
+ /**
229
+ * Run function when document.head exsits.
230
+ */
231
+ export const runWhenHeadExists = (func: () => void): void => {
232
+ if (!doc.head) {
233
+ headFuncArray.push(func)
234
+ startObserveHeadBodyExists()
235
+ return
236
+ }
237
+
238
+ func()
239
+ }
240
+
241
+ /**
242
+ * Run function when document.body exsits. The function executed before DOMContentLoaded.
243
+ */
244
+ export const runWhenBodyExists = (func: () => void): void => {
245
+ if (!doc.body) {
246
+ bodyFuncArray.push(func)
247
+ startObserveHeadBodyExists()
248
+ return
249
+ }
250
+
251
+ func()
252
+ }
253
+
254
+ /**
255
+ * Equals to jQuery.domready
256
+ */
257
+ export const runWhenDomReady = (func: () => void): void => {
258
+ if (doc.readyState === 'interactive' || doc.readyState === 'complete') {
259
+ func()
260
+ return
261
+ }
262
+
263
+ const handler = () => {
264
+ if (doc.readyState === 'interactive' || doc.readyState === 'complete') {
265
+ func()
266
+ removeEventListener(doc, 'readystatechange', handler)
267
+ }
268
+ }
269
+
270
+ addEventListener(doc, 'readystatechange', handler)
271
+ }
272
+
273
+ export const isVisible = (element: HTMLElement): boolean => {
274
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
275
+ const el = element as any
276
+
277
+ if (typeof el.checkVisibility === 'function') {
278
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-call
279
+ return el.checkVisibility() as boolean
280
+ }
281
+
282
+ return element.offsetParent !== null
283
+ }
284
+
285
+ export const isTouchScreen = (): boolean => 'ontouchstart' in win
286
+
287
+ export const isUrl = (text: string): boolean => /^https?:\/\//.test(text)
288
+
289
+ /**
290
+ *
291
+ * @param { function } func
292
+ * @param { number } interval
293
+ * @returns
294
+ */
295
+ export const throttle = (
296
+ func: (...args: any[]) => any,
297
+ interval: number
298
+ ): ((...args: any[]) => void) => {
299
+ let timeoutId: any = null
300
+ let next = false
301
+ const handler = (...args: any[]) => {
302
+ if (timeoutId) {
303
+ next = true
304
+ } else {
305
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
306
+ // @ts-ignore
307
+
308
+ func.apply(this, args)
309
+ timeoutId = setTimeout(() => {
310
+ timeoutId = null
311
+ if (next) {
312
+ next = false
313
+ handler()
314
+ }
315
+ }, interval)
316
+ }
317
+ }
318
+
319
+ return handler
320
+ }
321
+
322
+ /**
323
+ * Compare two semantic version strings
324
+ * @param {string} v1 - First version string (e.g., "1.2.0")
325
+ * @param {string} v2 - Second version string (e.g., "1.1.5")
326
+ * @returns {number} - Returns 1 if v1 > v2, -1 if v1 < v2, 0 if equal
327
+ * @throws {Error} - Throws error for invalid version strings
328
+ */
329
+ export function compareVersions(v1: string, v2: string): number {
330
+ // Input validation
331
+ if (typeof v1 !== 'string' || typeof v2 !== 'string') {
332
+ throw new TypeError('Version strings must be of type string')
333
+ }
334
+
335
+ if (!v1.trim() || !v2.trim()) {
336
+ throw new Error('Version strings cannot be empty')
337
+ }
338
+
339
+ // Validate version format (basic semantic versioning)
340
+ const versionRegex = /^\d+(\.\d+)*$/
341
+ if (!versionRegex.test(v1) || !versionRegex.test(v2)) {
342
+ throw new Error(
343
+ "Invalid version format. Use semantic versioning (e.g., '1.2.3')"
344
+ )
345
+ }
346
+
347
+ const v1Parts = v1.split('.').map(Number)
348
+ const v2Parts = v2.split('.').map(Number)
349
+ const maxLength = Math.max(v1Parts.length, v2Parts.length)
350
+
351
+ for (let i = 0; i < maxLength; i++) {
352
+ const num1 = v1Parts[i] || 0 // Use logical OR for cleaner default assignment
353
+ const num2 = v2Parts[i] || 0
354
+
355
+ if (num1 !== num2) {
356
+ return num1 > num2 ? 1 : -1 // Simplified comparison
357
+ }
358
+ }
359
+
360
+ return 0 // Versions are equal
361
+ }
@@ -0,0 +1,42 @@
1
+ import {
2
+ setStyle,
3
+ createHTML,
4
+ addEventListener,
5
+ setAttribute,
6
+ } from './dom-utils'
7
+
8
+ export const setAttributes = (
9
+ element: HTMLElement | null | undefined,
10
+ attributes?: Record<string, unknown>
11
+ ): HTMLElement | null | undefined => {
12
+ if (element && attributes) {
13
+ for (const name in attributes) {
14
+ if (Object.hasOwn(attributes, name)) {
15
+ const value = attributes[name]
16
+ if (value === undefined) {
17
+ continue
18
+ }
19
+
20
+ if (/^(value|textContent|innerText)$/.test(name)) {
21
+ ;(element as any)[name] = value
22
+ } else if (/^(innerHTML)$/.test(name)) {
23
+ element.innerHTML = createHTML(value as string)
24
+ } else if (name === 'style') {
25
+ setStyle(element, value as string | Record<string, any>, true)
26
+ } else if (/on\w+/.test(name)) {
27
+ const type = name.slice(2)
28
+ addEventListener(
29
+ element,
30
+ type,
31
+ value as EventListenerOrEventListenerObject
32
+ )
33
+ } else {
34
+ // eslint-disable-next-line @typescript-eslint/no-base-to-string
35
+ setAttribute(element, name, String(value))
36
+ }
37
+ }
38
+ }
39
+ }
40
+
41
+ return element
42
+ }
@@ -0,0 +1,109 @@
1
+ import {
2
+ getRootElement,
3
+ setAttributes,
4
+ addElement as _addElement,
5
+ } from './index.js'
6
+
7
+ export * from './index.js'
8
+
9
+ export const addElement =
10
+ typeof GM_addElement === 'function'
11
+ ? (
12
+ parentNode: HTMLElement | string | null | undefined,
13
+ tagName: string | HTMLElement,
14
+ attributes?: Record<string, unknown>
15
+ ): HTMLElement | undefined => {
16
+ if (typeof parentNode === 'string') {
17
+ return addElement(
18
+ null,
19
+ parentNode,
20
+ tagName as unknown as Record<string, unknown>
21
+ )
22
+ }
23
+
24
+ if (!tagName) {
25
+ return undefined
26
+ }
27
+
28
+ if (!parentNode) {
29
+ parentNode = /^(script|link|style|meta)$/.test(tagName as string)
30
+ ? getRootElement(1)
31
+ : getRootElement(2)
32
+ }
33
+
34
+ if (typeof tagName === 'string') {
35
+ let attributes1: Record<string, string> | undefined
36
+ let attributes2: Record<string, unknown> | undefined
37
+ if (attributes) {
38
+ const entries1: Array<[string, unknown]> = []
39
+ const entries2: Array<[string, unknown]> = []
40
+ for (const entry of Object.entries(attributes)) {
41
+ // Some userscript managers do not support innerHTML
42
+ // Stay do not support multiple classes: GM_addElement('div', {"class": "a b"}). Remove `|class` when it is supported
43
+ if (/^(on\w+|innerHTML|class)$/.test(entry[0])) {
44
+ entries2.push(entry)
45
+ } else {
46
+ entries1.push(entry)
47
+ }
48
+ }
49
+
50
+ attributes1 = Object.fromEntries(entries1) as Record<string, string>
51
+ attributes2 = Object.fromEntries(entries2) as Record<
52
+ string,
53
+ unknown
54
+ >
55
+ }
56
+
57
+ try {
58
+ const element = GM_addElement(tagName, attributes1 || {})
59
+ setAttributes(element, attributes2)
60
+ parentNode.append(element)
61
+ return element
62
+ } catch (error) {
63
+ console.error('GM_addElement error:', error)
64
+ return _addElement(parentNode, tagName, attributes)
65
+ }
66
+ }
67
+
68
+ // tagName: HTMLElement
69
+ setAttributes(tagName, attributes)
70
+ parentNode.append(tagName)
71
+ return tagName
72
+ }
73
+ : _addElement
74
+
75
+ export const addStyle = (styleText: string): HTMLElement | undefined =>
76
+ addElement(null, 'style', { textContent: styleText })
77
+
78
+ // Only register menu on top frame
79
+ export const registerMenuCommand = async (
80
+ name: string,
81
+ callback: (event?: any) => void,
82
+ options?: Parameters<typeof GM_registerMenuCommand>[2]
83
+ ): Promise<number> => {
84
+ if (globalThis.self !== globalThis.top) {
85
+ return 0
86
+ }
87
+
88
+ if (typeof GM.registerMenuCommand !== 'function') {
89
+ console.warn('Do not support GM.registerMenuCommand!')
90
+ return 0
91
+ }
92
+
93
+ try {
94
+ return await GM.registerMenuCommand(name, callback, options)
95
+ } catch (error) {
96
+ if (typeof options === 'object') {
97
+ // Don't support object options
98
+ try {
99
+ return await GM.registerMenuCommand(name, callback, options.accessKey)
100
+ } catch (error_) {
101
+ console.error('GM.registerMenuCommand error:', error_)
102
+ }
103
+ } else {
104
+ console.error('GM.registerMenuCommand error:', error)
105
+ }
106
+
107
+ return 0
108
+ }
109
+ }
package/package.json CHANGED
@@ -1,17 +1,22 @@
1
1
  {
2
2
  "name": "browser-extension-utils",
3
- "version": "0.2.2",
3
+ "version": "0.3.2",
4
4
  "description": "Utilities for developing browser extensions and userscripts",
5
5
  "type": "module",
6
- "main": "./lib/index.js",
6
+ "main": "./lib/index.ts",
7
7
  "exports": {
8
- ".": "./lib/index.js",
9
- "./userscript": "./lib/userscript.js"
8
+ ".": "./lib/index.ts",
9
+ "./userscript": "./lib/userscript.ts"
10
10
  },
11
11
  "scripts": {
12
12
  "p": "prettier --write .",
13
- "lint": "prettier --write . && xo --fix",
14
- "test": "echo \"Error: no test specified\" && exit 0"
13
+ "xo": "xo",
14
+ "lint": "prettier --write . && xo --fix && tsc --noemit",
15
+ "lint:type": "tsc --noemit",
16
+ "test": "vitest run",
17
+ "test:watch": "vitest watch",
18
+ "test:ui": "vitest --ui --coverage.enabled=true",
19
+ "test:coverage": "vitest --coverage"
15
20
  },
16
21
  "repository": {
17
22
  "type": "git",
@@ -29,8 +34,14 @@
29
34
  },
30
35
  "homepage": "https://github.com/utags/browser-extension-utils#readme",
31
36
  "devDependencies": {
32
- "prettier": "^3.5.3",
33
- "xo": "^0.60.0"
37
+ "@types/chrome": "^0.1.32",
38
+ "@vitest/coverage-v8": "^4.0.16",
39
+ "@vitest/ui": "^4.0.16",
40
+ "happy-dom": "^20.0.11",
41
+ "prettier": "^3.7.4",
42
+ "typescript": "^5.9.3",
43
+ "vitest": "^4.0.16",
44
+ "xo": "^1.2.3"
34
45
  },
35
46
  "files": [
36
47
  "lib/",
@@ -38,28 +49,6 @@
38
49
  "README.md"
39
50
  ],
40
51
  "engines": {
41
- "node": ">=16.9.0"
42
- },
43
- "xo": {
44
- "space": 2,
45
- "prettier": true,
46
- "globals": [
47
- "GM",
48
- "GM_addElement",
49
- "GM_addStyle",
50
- "GM_registerMenuCommand",
51
- "trustedTypes",
52
- "MutationObserver",
53
- "history",
54
- "window",
55
- "top",
56
- "document"
57
- ],
58
- "rules": {
59
- "logical-assignment-operators": 0,
60
- "prefer-destructuring": 0,
61
- "unicorn/prevent-abbreviations": 0,
62
- "capitalized-comments": 0
63
- }
52
+ "node": "^18.0.0 || ^20.0.0 || >=22.0.0"
64
53
  }
65
54
  }