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 ADDED
@@ -0,0 +1,37 @@
1
+ # eslint-plugin-greasemonkey
2
+
3
+ [![](https://img.shields.io/badge/Requires-ESLint_%3E%3D9.0.0-4B32C3?style=for-the-badge&logo=eslint)](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
+ }