eslint-plugin-greasemonkey 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +37 -0
- package/package.json +50 -0
- package/src/index.js +33 -0
- package/src/rules/meta-spacing.js +47 -0
- package/src/rules/no-unused-grants.js +36 -0
- package/src/rules/no-unused-resources.js +40 -0
- package/src/rules/require-matching-localized-fields.js +53 -0
- package/src/rules/require-required-fields.js +41 -0
- package/src/rules/valid-field-values.js +60 -0
- package/src/utils.js +19 -0
package/README.md
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# eslint-plugin-greasemonkey
|
|
2
|
+
|
|
3
|
+
[](https://eslint.org/)
|
|
4
|
+
|
|
5
|
+
ESLint rules for Greasemonkey userscripts.
|
|
6
|
+
|
|
7
|
+
<img src="https://cdn.staticdelivr.com/gl/adamlui/eslint-plugin-greasemonkey/b7c510f949/assets/images/screenshots/meta-spacing/auto-fix.png">
|
|
8
|
+
|
|
9
|
+
## Installation
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
npm i -D eslint-plugin-greasemonkey
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Usage (flat config)
|
|
16
|
+
|
|
17
|
+
```js
|
|
18
|
+
import greasemonkey from 'eslint-plugin-greasemonkey'
|
|
19
|
+
export default [
|
|
20
|
+
{
|
|
21
|
+
files: ['**/*.user.js'],
|
|
22
|
+
plugins: { greasemonkey },
|
|
23
|
+
rules: {
|
|
24
|
+
'greasemonkey/meta-spacing': 'error',
|
|
25
|
+
'greasemonkey/no-unused-grants': 'error',
|
|
26
|
+
'greasemonkey/no-unused-resources': 'error',
|
|
27
|
+
'greasemonkey/require-matching-localized-fields': 'error',
|
|
28
|
+
'greasemonkey/require-required-fields': 'error',
|
|
29
|
+
'greasemonkey/valid-field-values': 'error'
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
]
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## License
|
|
36
|
+
|
|
37
|
+
Copyright © 2026 [Adam Lui](https://codeberg.org/adamlui).
|
package/package.json
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "eslint-plugin-greasemonkey",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "ESLint rules for Greasemonkey/Tampermonkey userscripts",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"engines": {
|
|
7
|
+
"node": ">=18.18.0"
|
|
8
|
+
},
|
|
9
|
+
"author": {
|
|
10
|
+
"name": "Adam Lui",
|
|
11
|
+
"email": "adam@kudoai.org",
|
|
12
|
+
"url": "https://codeberg.org/adamlui"
|
|
13
|
+
},
|
|
14
|
+
"homepage": "https://codeberg.org/adamlui/eslint-plugin-greasemonkey/#readme",
|
|
15
|
+
"license": "MIT",
|
|
16
|
+
"main": "src/index.js",
|
|
17
|
+
"files": ["src"],
|
|
18
|
+
"scripts": {
|
|
19
|
+
"prepare": "husky",
|
|
20
|
+
"lint": "eslint . --cache",
|
|
21
|
+
"lint:all": "eslint .",
|
|
22
|
+
"lint:fix": "eslint . --fix --cache",
|
|
23
|
+
"lint:fix-all": "eslint . --fix",
|
|
24
|
+
"test": "node tests/run.js",
|
|
25
|
+
"demo": "eslint --config demo/eslint.config.js demo/example.user.js"
|
|
26
|
+
},
|
|
27
|
+
"keywords": [
|
|
28
|
+
"greasemonkey",
|
|
29
|
+
"tampermonkey",
|
|
30
|
+
"userscript",
|
|
31
|
+
"eslint",
|
|
32
|
+
"eslint-plugin",
|
|
33
|
+
"metadata"
|
|
34
|
+
],
|
|
35
|
+
"devDependencies": {
|
|
36
|
+
"@eslint/js": "^10.0.1",
|
|
37
|
+
"@eslint/json": "^2.1.0",
|
|
38
|
+
"@eslint/markdown": "^8.0.3",
|
|
39
|
+
"@stylistic/eslint-plugin": "^5.10.0",
|
|
40
|
+
"eslint": "^10.10.0",
|
|
41
|
+
"eslint-plugin-eslint-plugin": "^6.5.0",
|
|
42
|
+
"eslint-plugin-import-x": "^4.17.1",
|
|
43
|
+
"eslint-plugin-promise": "^7.3.0",
|
|
44
|
+
"eslint-plugin-regexp": "^3.3.0",
|
|
45
|
+
"globals": "^17.12.0"
|
|
46
|
+
},
|
|
47
|
+
"peerDependencies": {
|
|
48
|
+
"eslint": ">=9.0.0"
|
|
49
|
+
}
|
|
50
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import metaSpacing from './rules/meta-spacing.js'
|
|
2
|
+
import noUnusedGrants from './rules/no-unused-grants.js'
|
|
3
|
+
import noUnusedResources from './rules/no-unused-resources.js'
|
|
4
|
+
import requireMatchingLocalizedFields from './rules/require-matching-localized-fields.js'
|
|
5
|
+
import requireRequiredFields from './rules/require-required-fields.js'
|
|
6
|
+
import validFieldValues from './rules/valid-field-values.js'
|
|
7
|
+
|
|
8
|
+
const plugin = {
|
|
9
|
+
rules: {
|
|
10
|
+
'meta-spacing': metaSpacing,
|
|
11
|
+
'require-matching-localized-fields': requireMatchingLocalizedFields,
|
|
12
|
+
'require-required-fields': requireRequiredFields,
|
|
13
|
+
'valid-field-values': validFieldValues,
|
|
14
|
+
'no-unused-resources': noUnusedResources,
|
|
15
|
+
'no-unused-grants': noUnusedGrants
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
plugin.configs = {
|
|
20
|
+
recommended: {
|
|
21
|
+
plugins: { greasemonkey: plugin },
|
|
22
|
+
rules: {
|
|
23
|
+
'greasemonkey/meta-spacing': 'error',
|
|
24
|
+
'greasemonkey/require-matching-localized-fields': 'error',
|
|
25
|
+
'greasemonkey/require-required-fields': 'error',
|
|
26
|
+
'greasemonkey/valid-field-values': 'error',
|
|
27
|
+
'greasemonkey/no-unused-resources': 'error',
|
|
28
|
+
'greasemonkey/no-unused-grants': 'error'
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export default plugin
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { extractMetadata } from '../utils.js'
|
|
2
|
+
|
|
3
|
+
const reLeadingWhitespace = /^\s*/
|
|
4
|
+
|
|
5
|
+
export default {
|
|
6
|
+
meta: {
|
|
7
|
+
type: 'layout',
|
|
8
|
+
docs: {
|
|
9
|
+
description: 'Align metadata values to the column after the longest key',
|
|
10
|
+
category: 'Stylistic Issues',
|
|
11
|
+
recommended: true
|
|
12
|
+
},
|
|
13
|
+
fixable: 'whitespace',
|
|
14
|
+
schema: [{ type: 'number', description: 'Minimum spaces after the longest key (default 3)' }],
|
|
15
|
+
defaultOptions: [3],
|
|
16
|
+
messages: { misaligned: 'Metadata values should be aligned at column {{column}}' }
|
|
17
|
+
},
|
|
18
|
+
|
|
19
|
+
create(context) {
|
|
20
|
+
const minSpaces = context.options[0] ?? 3
|
|
21
|
+
return {
|
|
22
|
+
Program() {
|
|
23
|
+
const sourceCode = context.sourceCode,
|
|
24
|
+
metaEntries = extractMetadata(sourceCode)
|
|
25
|
+
if (!metaEntries.length) return
|
|
26
|
+
const longestKey = metaEntries.reduce((max, entry) => Math.max(max, entry.key.length), 0),
|
|
27
|
+
targetColumn = longestKey + minSpaces +4
|
|
28
|
+
for (const entry of metaEntries) {
|
|
29
|
+
const keyLen = entry.key.length,
|
|
30
|
+
commentText = sourceCode.getText(entry.comment),
|
|
31
|
+
actualSpaces = commentText.split(entry.key)[1].match(reLeadingWhitespace)[0].length,
|
|
32
|
+
desiredSpaces = targetColumn - keyLen -4
|
|
33
|
+
if (actualSpaces != desiredSpaces)
|
|
34
|
+
context.report({
|
|
35
|
+
node: entry.comment, messageId: 'misaligned', data: { column: targetColumn },
|
|
36
|
+
fix(fixer) {
|
|
37
|
+
const keyIdx = commentText.indexOf(entry.key) + entry.key.length,
|
|
38
|
+
rest = commentText.slice(keyIdx).replace(reLeadingWhitespace, ''),
|
|
39
|
+
fixedText = commentText.slice(0, keyIdx) + ' '.repeat(desiredSpaces) + rest
|
|
40
|
+
return fixer.replaceText(entry.comment, fixedText)
|
|
41
|
+
}
|
|
42
|
+
})
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { extractMetadata } from '../utils.js'
|
|
2
|
+
|
|
3
|
+
export default {
|
|
4
|
+
meta: {
|
|
5
|
+
type: 'problem',
|
|
6
|
+
docs: {
|
|
7
|
+
description: 'Flag @grant entries whose APIs are never used',
|
|
8
|
+
category: 'Possible Errors',
|
|
9
|
+
recommended: true
|
|
10
|
+
},
|
|
11
|
+
schema: [],
|
|
12
|
+
messages: {
|
|
13
|
+
unusedGrant: 'Unused @grant: "{{grant}}". If used indirectly,'
|
|
14
|
+
+ ' add // eslint-disable-line greasemonkey/no-unused-grants'
|
|
15
|
+
}
|
|
16
|
+
},
|
|
17
|
+
|
|
18
|
+
create(context) {
|
|
19
|
+
const usedIdentifiers = new Set()
|
|
20
|
+
return {
|
|
21
|
+
Identifier(node) { usedIdentifiers.add(node.name) },
|
|
22
|
+
'Program:exit'() {
|
|
23
|
+
const sourceCode = context.sourceCode,
|
|
24
|
+
metaEntries = extractMetadata(sourceCode),
|
|
25
|
+
grants = metaEntries.filter(e => e.key == 'grant')
|
|
26
|
+
if (!grants.length) return
|
|
27
|
+
for (const grant of grants) {
|
|
28
|
+
const grantName = grant.value.trim()
|
|
29
|
+
if (['none', 'unsafeWindow'].includes(grantName)) continue
|
|
30
|
+
if (!usedIdentifiers.has(grantName))
|
|
31
|
+
context.report({ node: grant.comment, messageId: 'unusedGrant', data: { grant: grantName }})
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { extractMetadata } from '../utils.js'
|
|
2
|
+
|
|
3
|
+
const reWhitespace = /\s+/
|
|
4
|
+
|
|
5
|
+
export default {
|
|
6
|
+
meta: {
|
|
7
|
+
type: 'problem',
|
|
8
|
+
docs: {
|
|
9
|
+
description: 'Flag @resource entries that are never referenced directly',
|
|
10
|
+
category: 'Possible Errors',
|
|
11
|
+
recommended: true
|
|
12
|
+
},
|
|
13
|
+
schema: [],
|
|
14
|
+
messages: {
|
|
15
|
+
unusedResource: 'Unused @resource: "{{name}}". If used indirectly,'
|
|
16
|
+
+ ' add // eslint-disable-line greasemonkey/no-unused-resources'
|
|
17
|
+
}
|
|
18
|
+
},
|
|
19
|
+
|
|
20
|
+
create(context) {
|
|
21
|
+
return {
|
|
22
|
+
Program() {
|
|
23
|
+
const sourceCode = context.sourceCode,
|
|
24
|
+
metaEntries = extractMetadata(sourceCode),
|
|
25
|
+
resources = metaEntries.filter(e => e.key == 'resource')
|
|
26
|
+
if (!resources.length) return
|
|
27
|
+
const text = sourceCode.getText(),
|
|
28
|
+
usedResources = new Set(),
|
|
29
|
+
regex = /GM_getResource(?:Text|URL)\s*\(\s*['"]([^'"]+)['"]\s*\)/g
|
|
30
|
+
let match
|
|
31
|
+
while ((match = regex.exec(text)) !== null) usedResources.add(match[1])
|
|
32
|
+
for (const res of resources) {
|
|
33
|
+
const name = res.value.split(reWhitespace)[0]
|
|
34
|
+
if (!usedResources.has(name))
|
|
35
|
+
context.report({ node: res.comment, messageId: 'unusedResource', data: { name }})
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { extractMetadata } from '../utils.js'
|
|
2
|
+
|
|
3
|
+
export default {
|
|
4
|
+
meta: {
|
|
5
|
+
type: 'problem',
|
|
6
|
+
docs: {
|
|
7
|
+
description: 'Require matching localized name and description fields',
|
|
8
|
+
category: 'Possible Errors',
|
|
9
|
+
recommended: true
|
|
10
|
+
},
|
|
11
|
+
schema: [{
|
|
12
|
+
type: 'object',
|
|
13
|
+
properties: {
|
|
14
|
+
ignoreLanguages: {
|
|
15
|
+
type: 'array',
|
|
16
|
+
items: { type: 'string' },
|
|
17
|
+
description: 'Language codes to ignore (e.g., ["fr", "de"])'
|
|
18
|
+
}
|
|
19
|
+
},
|
|
20
|
+
additionalProperties: false
|
|
21
|
+
}],
|
|
22
|
+
defaultOptions: [{}],
|
|
23
|
+
messages: { missingPair: 'Missing matching @{{missing}} for @{{present}}' }
|
|
24
|
+
},
|
|
25
|
+
|
|
26
|
+
create(context) {
|
|
27
|
+
const options = context.options[0] || {},
|
|
28
|
+
ignoreLanguages = options.ignoreLanguages || []
|
|
29
|
+
return {
|
|
30
|
+
Program() {
|
|
31
|
+
const metaEntries = extractMetadata(context.sourceCode),
|
|
32
|
+
nameEntries = metaEntries.filter(e => e.key.startsWith('name:')),
|
|
33
|
+
descEntries = metaEntries.filter(e => e.key.startsWith('description:')),
|
|
34
|
+
nameLangs = new Set(nameEntries.map(e => e.key.split(':')[1])),
|
|
35
|
+
descLangs = new Set(descEntries.map(e => e.key.split(':')[1]))
|
|
36
|
+
for (const lang of nameLangs)
|
|
37
|
+
if (!ignoreLanguages.includes(lang) && !descLangs.has(lang))
|
|
38
|
+
context.report({
|
|
39
|
+
node: nameEntries.find(entry => entry.key == `name:${lang}`).comment,
|
|
40
|
+
messageId: 'missingPair',
|
|
41
|
+
data: { missing: `description:${lang}`, present: `name:${lang}` }
|
|
42
|
+
})
|
|
43
|
+
for (const lang of descLangs)
|
|
44
|
+
if (!ignoreLanguages.includes(lang) && !nameLangs.has(lang))
|
|
45
|
+
context.report({
|
|
46
|
+
node: descEntries.find(e => e.key == `description:${lang}`).comment,
|
|
47
|
+
messageId: 'missingPair',
|
|
48
|
+
data: { missing: `name:${lang}`, present: `description:${lang}` }
|
|
49
|
+
})
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { extractMetadata } from '../utils.js'
|
|
2
|
+
|
|
3
|
+
export default {
|
|
4
|
+
meta: {
|
|
5
|
+
type: 'problem',
|
|
6
|
+
docs: {
|
|
7
|
+
description: 'Require essential metadata fields',
|
|
8
|
+
category: 'Possible Errors',
|
|
9
|
+
recommended: true
|
|
10
|
+
},
|
|
11
|
+
schema: [{
|
|
12
|
+
type: 'object',
|
|
13
|
+
properties: {
|
|
14
|
+
fields: {
|
|
15
|
+
type: 'array',
|
|
16
|
+
items: { type: 'string' },
|
|
17
|
+
description: 'Required metadata fields (default: name, namespace, version)'
|
|
18
|
+
}
|
|
19
|
+
},
|
|
20
|
+
additionalProperties: false
|
|
21
|
+
}],
|
|
22
|
+
defaultOptions: [{}],
|
|
23
|
+
messages: { missingField: 'Missing required metadata field: @{{field}}' }
|
|
24
|
+
},
|
|
25
|
+
|
|
26
|
+
create(context) {
|
|
27
|
+
const options = context.options[0] || {},
|
|
28
|
+
requiredFields = options.fields || ['name', 'namespace', 'version']
|
|
29
|
+
return {
|
|
30
|
+
Program(node) {
|
|
31
|
+
const metaEntries = extractMetadata(context.sourceCode),
|
|
32
|
+
keys = new Set(metaEntries.map(e => e.key))
|
|
33
|
+
for (const field of requiredFields)
|
|
34
|
+
if (!keys.has(field))
|
|
35
|
+
context.report({ node, messageId: 'missingField', data: { field }})
|
|
36
|
+
if (!metaEntries.some(entry => ['include', 'match'].includes(entry.key)))
|
|
37
|
+
context.report({ node, messageId: 'missingField', data: { field: 'match or include' }})
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { extractMetadata } from '../utils.js'
|
|
2
|
+
|
|
3
|
+
const RE_SEMVER = /^\d+\.\d+\.\d+(?:-[0-9a-z.-]+)?(?:\+[0-9a-z.-]+)?$/i
|
|
4
|
+
|
|
5
|
+
export default {
|
|
6
|
+
meta: {
|
|
7
|
+
type: 'problem',
|
|
8
|
+
docs: {
|
|
9
|
+
description: 'Validate values of metadata fields',
|
|
10
|
+
category: 'Possible Errors',
|
|
11
|
+
recommended: true
|
|
12
|
+
},
|
|
13
|
+
schema: [{
|
|
14
|
+
type: 'object',
|
|
15
|
+
properties: {
|
|
16
|
+
reVer: {
|
|
17
|
+
type: 'string',
|
|
18
|
+
description: 'Regular expression pattern for @version validation (default: ^\\d+\\.\\d+\\.\\d+$)'
|
|
19
|
+
},
|
|
20
|
+
allowedGrants: {
|
|
21
|
+
type: 'array',
|
|
22
|
+
items: { type: 'string' },
|
|
23
|
+
description: 'Allowed @grant values to check against; if empty, no grant validation'
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
additionalProperties: false
|
|
27
|
+
}],
|
|
28
|
+
defaultOptions: [{}],
|
|
29
|
+
messages: {
|
|
30
|
+
invalidVersion: 'Invalid @version: must match pattern "{{pattern}}"',
|
|
31
|
+
invalidURL: 'Invalid URL for @{{field}}',
|
|
32
|
+
invalidGrant: 'Unknown @grant: "{{grant}}"'
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
|
|
36
|
+
create(context) {
|
|
37
|
+
const options = context.options[0] || {},
|
|
38
|
+
reVer = options.reVer || '^\\d+\\.\\d+\\.\\d+$',
|
|
39
|
+
allowedGrants = options.allowedGrants || []
|
|
40
|
+
return {
|
|
41
|
+
Program() {
|
|
42
|
+
const metaEntries = extractMetadata(context.sourceCode)
|
|
43
|
+
for (const entry of metaEntries) {
|
|
44
|
+
if (entry.key == 'version') {
|
|
45
|
+
if (!RE_SEMVER.test(entry.value) && !(new RegExp(reVer).test(entry.value)))
|
|
46
|
+
context.report({
|
|
47
|
+
node: entry.comment, messageId: 'invalidVersion', data: { pattern: reVer }})
|
|
48
|
+
}
|
|
49
|
+
if (['homepage', 'icon', 'updateURL', 'downloadURL', 'supportURL'].includes(entry.key))
|
|
50
|
+
try { new URL(entry.value) }
|
|
51
|
+
catch {
|
|
52
|
+
context.report({ node: entry.comment, messageId: 'invalidURL', data: { field: entry.key }})
|
|
53
|
+
}
|
|
54
|
+
if (entry.key == 'grant' && allowedGrants.length && !allowedGrants.includes(entry.value))
|
|
55
|
+
context.report({ node: entry.comment, messageId: 'invalidGrant', data: { grant: entry.value }})
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
package/src/utils.js
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export function extractMetadata(sourceCode) {
|
|
2
|
+
const comments = sourceCode.getAllComments(),
|
|
3
|
+
metaEntries = []
|
|
4
|
+
let inMetaBlock = false
|
|
5
|
+
for (const comment of comments) {
|
|
6
|
+
const text = comment.value.trim()
|
|
7
|
+
if (!inMetaBlock && text == '==UserScript==') { inMetaBlock = true ; continue }
|
|
8
|
+
if (inMetaBlock && text == '==/UserScript==') break
|
|
9
|
+
if (inMetaBlock && text.startsWith('@')) {
|
|
10
|
+
const spaceIdx = text.indexOf(' ')
|
|
11
|
+
if (spaceIdx > 1) { // '@' at 0, key starts at 1, need space after key
|
|
12
|
+
const key = text.slice(1, spaceIdx).trim(),
|
|
13
|
+
value = text.slice(spaceIdx +1).trim()
|
|
14
|
+
metaEntries.push({ key, value, comment, line: comment.loc.start.line })
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
return metaEntries
|
|
19
|
+
}
|