browser-extension-utils 0.2.2 → 0.3.1

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