eslint-plugin-chrome-extensions 2.5.0 → 2.7.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 +592 -124
- package/package.json +5 -7
- package/src/data/i18n.js +8 -0
- package/src/data/permissions.js +1 -8
- package/src/data/regex.js +2 -1
- package/src/index.js +0 -4
- package/src/rules/css-spacing.js +367 -0
- package/src/rules/manifest-key-order.js +0 -1
- package/src/rules/manifest-spacing.js +0 -1
- package/src/rules/no-duplicate-manifest-keys.js +0 -1
- package/src/rules/no-global-keyframes.js +84 -0
- package/src/rules/no-host-page-selectors.js +106 -0
- package/src/rules/no-invalid-locales.js +89 -0
- package/src/rules/no-invalid-permissions.js +3 -5
- package/src/rules/no-missing-default-locale.js +39 -14
- package/src/rules/no-missing-i18n-keys.js +3 -5
- package/src/rules/no-remote-urls.js +102 -0
- package/src/rules/no-scrollbar-styling.js +73 -0
- package/src/rules/no-sensitive-permissions.js +1 -1
- package/src/rules/no-unrestricted-host-permissions.js +31 -16
- package/src/rules/no-unused-i18n-keys.js +0 -1
- package/src/rules/no-unused-permissions.js +13 -4
- package/src/rules/require-license-file.js +0 -1
- package/src/rules/require-mv3.js +1 -1
- package/src/rules/require-short-name-when-useful.js +0 -1
- package/src/rules/valid-license-file.js +2 -4
- package/src/rules/valid-manifest-icons.js +31 -3
- package/src/rules/valid-mv3-schema.js +1 -1
- package/src/rules/valid-url-fields.js +42 -37
- package/src/rules/valid-version-field.js +1 -1
- package/src/utils/array.js +1 -0
- package/src/utils/extension.js +13 -0
- package/src/utils/i18n.js +3 -0
- package/src/utils/init.js +13 -7
- package/src/utils/license.js +3 -2
- package/src/data/deprecations.js +0 -10
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "eslint-plugin-chrome-extensions",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.7.0",
|
|
4
4
|
"description": "ESLint rules for Chrome extensions",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"eslint",
|
|
@@ -54,10 +54,8 @@
|
|
|
54
54
|
"bump:minor": "bash utils/bump.sh minor",
|
|
55
55
|
"bump:patch": "bash utils/bump.sh patch",
|
|
56
56
|
"demo": "node demo/run.js",
|
|
57
|
-
"lint": "
|
|
58
|
-
"lint:all": "
|
|
59
|
-
"lint:fix": "eslint . --fix --cache && yamllint .",
|
|
60
|
-
"lint:fix-all": "eslint . --fix && yamllint .",
|
|
57
|
+
"lint": "pre-commit run",
|
|
58
|
+
"lint:all": "pre-commit run --all-files",
|
|
61
59
|
"test": "node tests/run.js"
|
|
62
60
|
},
|
|
63
61
|
"dependencies": {
|
|
@@ -71,7 +69,7 @@
|
|
|
71
69
|
"@eslint/js": "^10.0.1",
|
|
72
70
|
"@eslint/json": "^2.1.1",
|
|
73
71
|
"@eslint/markdown": "^7.5.1",
|
|
74
|
-
"@html-eslint/eslint-plugin": "^0.
|
|
72
|
+
"@html-eslint/eslint-plugin": "^0.67.0",
|
|
75
73
|
"@stylistic/eslint-plugin": "^5.10.0",
|
|
76
74
|
"eslint": "^10.12.0",
|
|
77
75
|
"eslint-plugin-constructors": "^1.4.3",
|
|
@@ -84,7 +82,7 @@
|
|
|
84
82
|
"eslint-plugin-promise": "^7.3.0",
|
|
85
83
|
"eslint-plugin-regexp": "^3.3.1",
|
|
86
84
|
"eslint-plugin-security": "^4.2.0",
|
|
87
|
-
"
|
|
85
|
+
"eslint-plugin-woodpecker": "^1.0.0"
|
|
88
86
|
},
|
|
89
87
|
"peerDependencies": {
|
|
90
88
|
"@eslint/css": ">=0.5.0",
|
package/src/data/i18n.js
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
export default {
|
|
2
2
|
defaultScanExtensions: ['cjs', 'css', 'htm', 'html', 'js', 'json', 'jsx', 'md', 'mjs', 'svg', 'ts', 'tsx', 'txt'],
|
|
3
|
+
locales: {
|
|
4
|
+
supported: new Set([ // https://developer.chrome.com/docs/extensions/reference/api/i18n
|
|
5
|
+
'af', 'am', 'ar', 'bg', 'bn', 'ca', 'cs', 'da', 'de', 'el', 'en', 'en_AU', 'en_GB', 'en_IN', 'en_US', 'es',
|
|
6
|
+
'es_419', 'es_MX', 'es_US', 'et', 'fa', 'fi', 'fil', 'fr', 'fr_CA', 'fr_FR', 'gu', 'he', 'hi', 'hr', 'hu',
|
|
7
|
+
'id', 'it', 'ja', 'kn', 'ko', 'lt', 'lv', 'ml', 'mr', 'ms', 'nl', 'no', 'pl', 'pt_BR', 'pt_PT', 'ro', 'ru',
|
|
8
|
+
'sk', 'sl', 'sr', 'sv', 'sw', 'ta', 'te', 'th', 'tr', 'uk', 'vi', 'zh_CN', 'zh_TW'
|
|
9
|
+
])
|
|
10
|
+
},
|
|
3
11
|
re: {
|
|
4
12
|
getMsg: {
|
|
5
13
|
literal: /\b(?:browser|chrome)\.i18n\.getMessage\(\s*['"]([^'"]+)['"]/g,
|
package/src/data/permissions.js
CHANGED
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
import regex from './regex.js'
|
|
2
|
-
|
|
3
1
|
export default {
|
|
4
2
|
sensitive: new Set([
|
|
5
3
|
'accessibilityFeatures.modify', 'accessibilityFeatures.read', 'bookmarks', 'browsingData', 'clipboardRead',
|
|
@@ -22,10 +20,5 @@ export default {
|
|
|
22
20
|
'sessions', 'sidePanel', 'storage', 'system.cpu', 'system.display', 'system.memory', 'system.storage',
|
|
23
21
|
'tabCapture', 'tabGroups', 'tabs', 'topSites', 'tts', 'ttsEngine', 'unlimitedStorage', 'userScripts',
|
|
24
22
|
'vpnProvider', 'wallpaper', 'webAuthenticationProxy', 'webNavigation', 'webRequest', 'webRequestBlocking'
|
|
25
|
-
])
|
|
26
|
-
isValid(name, target = 'mv3') {
|
|
27
|
-
if (this.valid.has(name)) return true
|
|
28
|
-
if (target == 'mv3') return false
|
|
29
|
-
return regex.url.test(name) || name.startsWith('<') || name == '*://*/*'
|
|
30
|
-
}
|
|
23
|
+
])
|
|
31
24
|
}
|
package/src/data/regex.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
export default {
|
|
2
2
|
filename: {
|
|
3
|
-
|
|
3
|
+
css: /\.css$/i,
|
|
4
4
|
license: // eslint-disable-next-line security/detect-unsafe-regex
|
|
5
5
|
/^(?:[\w.-]*-)?(?:LICEN[SC]E|COPYING)(?:[-.][\w.-]+)?$/i,
|
|
6
6
|
manifest: /manifest\.json$/,
|
|
7
7
|
script: /\.[cm]?[jt]sx?$/i
|
|
8
8
|
},
|
|
9
|
+
prefix: { at: /^@/, css: /^CSS/ },
|
|
9
10
|
url: /^https?:\/\//
|
|
10
11
|
}
|
package/src/index.js
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import deprecations from './data/deprecations.js'
|
|
2
1
|
import init from './utils/init.js'
|
|
3
2
|
|
|
4
3
|
const plugin = init.plugin()
|
|
@@ -6,7 +5,4 @@ const plugin = init.plugin()
|
|
|
6
5
|
init.rules(plugin)
|
|
7
6
|
init.configs(plugin)
|
|
8
7
|
|
|
9
|
-
for (const entry of Object.entries(deprecations.bundles))
|
|
10
|
-
init.bundleDeprecation(plugin, entry, 'chrome-extensions')
|
|
11
|
-
|
|
12
8
|
export { plugin as default, plugin as chromeExtensions }
|
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
/* eslint-disable security/detect-object-injection -- array indices are controlled */
|
|
2
|
+
|
|
3
|
+
import extension from '../utils/extension.js'
|
|
4
|
+
import regex from '../data/regex.js'
|
|
5
|
+
|
|
6
|
+
const re = { leadingWhitespace: /^[ \t]+/, trailingWhitespace: /[ \t]+$/ },
|
|
7
|
+
combinators = new Set(['>', '+', '~'])
|
|
8
|
+
|
|
9
|
+
function endOf(node) { return node.loc.end.offset }
|
|
10
|
+
function startOf(node) { return node.loc.start.offset }
|
|
11
|
+
|
|
12
|
+
export default {
|
|
13
|
+
meta: {
|
|
14
|
+
type: 'layout',
|
|
15
|
+
docs: {
|
|
16
|
+
description: 'Enforces consistent indentation and spacing in CSS',
|
|
17
|
+
category: 'Stylistic Issues',
|
|
18
|
+
css: true
|
|
19
|
+
},
|
|
20
|
+
fixable: 'whitespace',
|
|
21
|
+
schema: [{
|
|
22
|
+
type: 'object',
|
|
23
|
+
properties: {
|
|
24
|
+
spaces: { type: 'number', description: 'Number of spaces per indent level (default 2)' },
|
|
25
|
+
checkBraces: { type: 'boolean', description: 'Enforce 1 space after `{` and before `}` in single-line rules (default true)' },
|
|
26
|
+
checkBrackets: { type: 'boolean', description: 'Enforce 0 spaces after `[` and before `]` in attribute selectors (default true)' },
|
|
27
|
+
checkColons: { type: 'boolean', description: 'Enforce 0 spaces before and 1 space after `:` in declarations, and 1 space between value tokens (default true)' },
|
|
28
|
+
checkCombinators: { type: 'boolean', description: 'Enforce 1 space on each side of `>`, `+`, `~` in selectors (default true)' },
|
|
29
|
+
checkCommas: { type: 'boolean', description: 'Enforce 0 spaces before and 1 space after `,` in selector lists (default true)' },
|
|
30
|
+
checkParentheses: { type: 'boolean', description: 'Enforce 0 spaces after `(` and before `)` in functions and pseudo-classes (default true)' },
|
|
31
|
+
checkSemicolons: { type: 'boolean', description: 'Enforce 1 space after `;` in single-line declaration blocks (default true)' }
|
|
32
|
+
},
|
|
33
|
+
additionalProperties: false
|
|
34
|
+
}],
|
|
35
|
+
defaultOptions: [{
|
|
36
|
+
spaces: 2, checkBraces: true, checkBrackets: true, checkColons: true,
|
|
37
|
+
checkCombinators: true, checkCommas: true, checkParentheses: true, checkSemicolons: true
|
|
38
|
+
}],
|
|
39
|
+
messages: {
|
|
40
|
+
wrongIndent: 'Expected {{expected}} spaces of indentation (found {{found}}).',
|
|
41
|
+
tabFound: 'Expected spaces for indentation, found tabs.',
|
|
42
|
+
spaceBeforeColon: 'Expected no spaces before `:`.',
|
|
43
|
+
spaceAfterColon: 'Expected 1 space after `:`.',
|
|
44
|
+
spaceBeforeComma: 'Expected no spaces before `,`.',
|
|
45
|
+
spaceAfterComma: 'Expected 1 space after `,`.',
|
|
46
|
+
spaceAfterBrace: 'Expected 1 space after `{`.',
|
|
47
|
+
spaceBeforeBrace: 'Expected 1 space before `}`.',
|
|
48
|
+
spaceInEmptyBraces: 'Expected no spaces in empty `{}`.',
|
|
49
|
+
spaceAfterBracket: 'Expected no spaces after `[`.',
|
|
50
|
+
spaceBeforeBracket: 'Expected no spaces before `]`.',
|
|
51
|
+
spaceAfterParen: 'Expected no spaces after `(`.',
|
|
52
|
+
spaceBeforeParen: 'Expected no spaces before `)`.',
|
|
53
|
+
spaceAroundCombinator: 'Expected 1 space on each side of `{{combinator}}`.',
|
|
54
|
+
spaceAfterSemicolon: 'Expected 1 space after `;`.',
|
|
55
|
+
spaceBetweenValues: 'Expected 1 space between value tokens.'
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
|
|
59
|
+
create(context) {
|
|
60
|
+
if (!regex.filename.css.test(extension.file.getName(context))) return {}
|
|
61
|
+
const opts = context.options[0] || {},
|
|
62
|
+
spaces = opts.spaces ?? 2,
|
|
63
|
+
checkBraces = opts.checkBraces ?? true,
|
|
64
|
+
checkBrackets = opts.checkBrackets ?? true,
|
|
65
|
+
checkColons = opts.checkColons ?? true,
|
|
66
|
+
checkCombinators = opts.checkCombinators ?? true,
|
|
67
|
+
checkCommas = opts.checkCommas ?? true,
|
|
68
|
+
checkParentheses = opts.checkParentheses ?? true,
|
|
69
|
+
checkSemicolons = opts.checkSemicolons ?? true,
|
|
70
|
+
sourceCode = context.sourceCode,
|
|
71
|
+
text = sourceCode.text,
|
|
72
|
+
lines = text.split('\n'),
|
|
73
|
+
lineOffsets = [0]
|
|
74
|
+
for (const line of lines) lineOffsets.push(lineOffsets.at(-1) + line.length +1)
|
|
75
|
+
function checkBlockBraces(block) {
|
|
76
|
+
if (!checkBraces) return
|
|
77
|
+
const innerStart = startOf(block) +1,
|
|
78
|
+
innerEnd = endOf(block) -1,
|
|
79
|
+
interior = text.slice(innerStart, innerEnd)
|
|
80
|
+
if (interior.includes('\n')) return
|
|
81
|
+
if (!interior.trim()) {
|
|
82
|
+
if (interior != '')
|
|
83
|
+
context.report({
|
|
84
|
+
loc: block.loc, messageId: 'spaceInEmptyBraces',
|
|
85
|
+
fix(fixer) { return fixer.replaceTextRange([innerStart, innerEnd], '') }
|
|
86
|
+
})
|
|
87
|
+
return
|
|
88
|
+
}
|
|
89
|
+
const leadingWs = interior.match(re.leadingWhitespace)?.[0] ?? ''
|
|
90
|
+
if (leadingWs != ' ')
|
|
91
|
+
context.report({
|
|
92
|
+
loc: block.loc.start, messageId: 'spaceAfterBrace',
|
|
93
|
+
fix(fixer) { return fixer.replaceTextRange([innerStart, innerStart + leadingWs.length], ' ') }
|
|
94
|
+
})
|
|
95
|
+
const trailingWs = interior.match(re.trailingWhitespace)?.[0] ?? ''
|
|
96
|
+
if (trailingWs != ' ')
|
|
97
|
+
context.report({
|
|
98
|
+
loc: block.loc.end, messageId: 'spaceBeforeBrace',
|
|
99
|
+
fix(fixer) { return fixer.replaceTextRange([innerEnd - trailingWs.length, innerEnd], ' ') }
|
|
100
|
+
})
|
|
101
|
+
}
|
|
102
|
+
function checkBlockChildren(children) {
|
|
103
|
+
for (let i = 1 ; i < children.length ; i++)
|
|
104
|
+
if (children[i -1].type?.replace(regex.prefix.css, '') == 'Declaration'
|
|
105
|
+
&& children[i].type?.replace(regex.prefix.css, '') == 'Declaration')
|
|
106
|
+
checkSemicolonBetween(children[i -1], children[i])
|
|
107
|
+
}
|
|
108
|
+
function checkBracketSpacing(node) {
|
|
109
|
+
if (!checkBrackets || !node?.loc) return
|
|
110
|
+
const innerStart = startOf(node) +1,
|
|
111
|
+
innerEnd = endOf(node) -1,
|
|
112
|
+
interior = text.slice(innerStart, innerEnd)
|
|
113
|
+
if (interior.includes('\n')) return
|
|
114
|
+
const leadingWs = interior.match(re.leadingWhitespace)?.[0] ?? ''
|
|
115
|
+
if (leadingWs)
|
|
116
|
+
context.report({
|
|
117
|
+
loc: node.loc.start, messageId: 'spaceAfterBracket',
|
|
118
|
+
fix(fixer) { return fixer.replaceTextRange([innerStart, innerStart + leadingWs.length], '') }
|
|
119
|
+
})
|
|
120
|
+
const trailingWs = interior.match(re.trailingWhitespace)?.[0] ?? ''
|
|
121
|
+
if (trailingWs)
|
|
122
|
+
context.report({
|
|
123
|
+
loc: node.loc.end, messageId: 'spaceBeforeBracket',
|
|
124
|
+
fix(fixer) { return fixer.replaceTextRange([innerEnd - trailingWs.length, innerEnd], '') }
|
|
125
|
+
})
|
|
126
|
+
}
|
|
127
|
+
function checkColon(decl) {
|
|
128
|
+
if (!checkColons || !decl.value?.loc) return
|
|
129
|
+
const declStart = startOf(decl),
|
|
130
|
+
valueStart = startOf(decl.value),
|
|
131
|
+
valueEnd = endOf(decl.value),
|
|
132
|
+
between = text.slice(declStart, valueStart),
|
|
133
|
+
colonPos = between.lastIndexOf(':')
|
|
134
|
+
if (colonPos < 0) return
|
|
135
|
+
const before = between.slice(0, colonPos),
|
|
136
|
+
after = between.slice(colonPos +1),
|
|
137
|
+
beforeTrailing = before.match(re.trailingWhitespace)?.[0] ?? ''
|
|
138
|
+
if (beforeTrailing) {
|
|
139
|
+
const fixEnd = declStart + colonPos
|
|
140
|
+
context.report({
|
|
141
|
+
loc: decl.loc.start, messageId: 'spaceBeforeColon',
|
|
142
|
+
fix(fixer) { return fixer.replaceTextRange([fixEnd - beforeTrailing.length, fixEnd], '') }
|
|
143
|
+
})
|
|
144
|
+
}
|
|
145
|
+
if (!after.includes('\n') && after != ' ') {
|
|
146
|
+
const fixStart = declStart + colonPos +1
|
|
147
|
+
context.report({
|
|
148
|
+
loc: decl.loc.start, messageId: 'spaceAfterColon',
|
|
149
|
+
fix(fixer) { return fixer.replaceTextRange([fixStart, valueStart], ' ') }
|
|
150
|
+
})
|
|
151
|
+
}
|
|
152
|
+
checkValueSpacing(valueStart, valueEnd)
|
|
153
|
+
}
|
|
154
|
+
function checkCombinatorSpacing(node) {
|
|
155
|
+
if (!checkCombinators || !node?.loc || !combinators.has(node.name)) return
|
|
156
|
+
const nodeStart = startOf(node),
|
|
157
|
+
nodeEnd = endOf(node)
|
|
158
|
+
let i = nodeStart -1
|
|
159
|
+
while (i >= 0 && (text[i] == ' ' || text[i] == '\t')) i--
|
|
160
|
+
if (i >= 0 && text[i] != '\n') {
|
|
161
|
+
const beforeWs = text.slice(i +1, nodeStart)
|
|
162
|
+
if (beforeWs != ' ')
|
|
163
|
+
context.report({
|
|
164
|
+
loc: node.loc.start, messageId: 'spaceAroundCombinator',
|
|
165
|
+
data: { combinator: node.name },
|
|
166
|
+
fix(fixer) { return fixer.replaceTextRange([i +1, nodeStart], ' ') }
|
|
167
|
+
})
|
|
168
|
+
}
|
|
169
|
+
let j = nodeEnd
|
|
170
|
+
while (j < text.length && (text[j] == ' ' || text[j] == '\t')) j++
|
|
171
|
+
if (j < text.length && text[j] != '\n') {
|
|
172
|
+
const afterWs = text.slice(nodeEnd, j)
|
|
173
|
+
if (afterWs != ' ')
|
|
174
|
+
context.report({
|
|
175
|
+
loc: node.loc.end, messageId: 'spaceAroundCombinator',
|
|
176
|
+
data: { combinator: node.name },
|
|
177
|
+
fix(fixer) { return fixer.replaceTextRange([nodeEnd, j], ' ') }
|
|
178
|
+
})
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
function checkCommaBetween(prev, curr) {
|
|
182
|
+
if (!checkCommas) return
|
|
183
|
+
const prevEnd = endOf(prev),
|
|
184
|
+
currStart = startOf(curr),
|
|
185
|
+
between = text.slice(prevEnd, currStart)
|
|
186
|
+
if (between.includes('\n') || between == ', ') return
|
|
187
|
+
const commaIdx = between.indexOf(',')
|
|
188
|
+
if (commaIdx < 0) return
|
|
189
|
+
if (between.slice(0, commaIdx)) {
|
|
190
|
+
const commaPos = prevEnd + commaIdx
|
|
191
|
+
context.report({
|
|
192
|
+
loc: offsetToLoc(commaPos), messageId: 'spaceBeforeComma',
|
|
193
|
+
fix(fixer) { return fixer.replaceTextRange([prevEnd, commaPos], '') }
|
|
194
|
+
})
|
|
195
|
+
}
|
|
196
|
+
if (between.slice(commaIdx +1) != ' ') {
|
|
197
|
+
const commaPos = prevEnd + commaIdx
|
|
198
|
+
context.report({
|
|
199
|
+
loc: offsetToLoc(commaPos +1), messageId: 'spaceAfterComma',
|
|
200
|
+
fix(fixer) { return fixer.replaceTextRange([commaPos +1, currStart], ' ') }
|
|
201
|
+
})
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
function checkFunctionCommas(node) {
|
|
205
|
+
if (!checkCommas) return
|
|
206
|
+
const nodeStart = startOf(node),
|
|
207
|
+
nodeText = text.slice(nodeStart, endOf(node)),
|
|
208
|
+
openIdx = nodeText.indexOf('('),
|
|
209
|
+
closeIdx = nodeText.lastIndexOf(')')
|
|
210
|
+
if (openIdx < 0 || closeIdx <= openIdx) return
|
|
211
|
+
const interior = nodeText.slice(openIdx +1, closeIdx)
|
|
212
|
+
if (interior.includes('\n')) return
|
|
213
|
+
let depth = 0, inString = null
|
|
214
|
+
for (let i = 0 ; i < interior.length ; i++) {
|
|
215
|
+
const ch = interior[i]
|
|
216
|
+
if (inString) {
|
|
217
|
+
if (ch == '\\') { i++; continue }
|
|
218
|
+
if (ch === inString) inString = null
|
|
219
|
+
continue
|
|
220
|
+
}
|
|
221
|
+
if (ch == '"' || ch === `'`) { inString = ch; continue }
|
|
222
|
+
if (ch == '(') { depth++; continue }
|
|
223
|
+
if (ch == ')') { depth--; continue }
|
|
224
|
+
if (ch == ',' && depth === 0) {
|
|
225
|
+
const commaPos = nodeStart + openIdx +1 + i,
|
|
226
|
+
before = interior.slice(0, i).match(re.trailingWhitespace)?.[0] ?? '',
|
|
227
|
+
after = interior.slice(i +1).match(re.leadingWhitespace)?.[0] ?? ''
|
|
228
|
+
if (before)
|
|
229
|
+
context.report({
|
|
230
|
+
loc: offsetToLoc(commaPos), messageId: 'spaceBeforeComma',
|
|
231
|
+
fix(fixer) { return fixer.replaceTextRange([commaPos - before.length, commaPos], '') }
|
|
232
|
+
})
|
|
233
|
+
if (after != ' ')
|
|
234
|
+
context.report({
|
|
235
|
+
loc: offsetToLoc(commaPos +1), messageId: 'spaceAfterComma',
|
|
236
|
+
fix(fixer) { return fixer.replaceTextRange([commaPos +1, commaPos +1 + after.length], ' ') }
|
|
237
|
+
})
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
function checkIndent(lineNum, expectedDepth) {
|
|
242
|
+
const lineText = lines[lineNum -1]
|
|
243
|
+
if (!lineText?.trim()) return
|
|
244
|
+
const indent = lineText.match(re.leadingWhitespace)?.[0] ?? '',
|
|
245
|
+
expected = ' '.repeat(expectedDepth * spaces)
|
|
246
|
+
if (indent !== expected) {
|
|
247
|
+
const lineStart = lineOffsets[lineNum -1]
|
|
248
|
+
context.report({
|
|
249
|
+
loc: { line: lineNum, column: 1 },
|
|
250
|
+
messageId: indent.includes('\t') ? 'tabFound' : 'wrongIndent',
|
|
251
|
+
data: { expected: expectedDepth * spaces, found: indent.length },
|
|
252
|
+
fix(fixer) { return fixer.replaceTextRange([lineStart, lineStart + indent.length], expected) }
|
|
253
|
+
})
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
function offsetToLoc(offset) {
|
|
257
|
+
for (let i = lineOffsets.length -1; i >= 0; i--)
|
|
258
|
+
if (lineOffsets[i] <= offset)
|
|
259
|
+
return { line: i +1, column: offset - lineOffsets[i] +1 }
|
|
260
|
+
return { line: 1, column: 1 }
|
|
261
|
+
}
|
|
262
|
+
function checkParenSpacing(node) {
|
|
263
|
+
if (!checkParentheses || !node?.loc) return
|
|
264
|
+
const nodeStart = startOf(node),
|
|
265
|
+
nodeText = text.slice(nodeStart, endOf(node)),
|
|
266
|
+
openIdx = nodeText.indexOf('('),
|
|
267
|
+
closeIdx = nodeText.lastIndexOf(')')
|
|
268
|
+
if (openIdx < 0 || closeIdx <= openIdx) return
|
|
269
|
+
const interior = nodeText.slice(openIdx +1, closeIdx)
|
|
270
|
+
if (interior.includes('\n')) return
|
|
271
|
+
const leadingWs = interior.match(re.leadingWhitespace)?.[0] ?? ''
|
|
272
|
+
if (leadingWs)
|
|
273
|
+
context.report({
|
|
274
|
+
loc: node.loc.start, messageId: 'spaceAfterParen',
|
|
275
|
+
fix(fixer) {
|
|
276
|
+
return fixer.replaceTextRange(
|
|
277
|
+
[nodeStart + openIdx +1, nodeStart + openIdx +1 + leadingWs.length], '')
|
|
278
|
+
}
|
|
279
|
+
})
|
|
280
|
+
const trailingWs = interior.match(re.trailingWhitespace)?.[0] ?? ''
|
|
281
|
+
if (trailingWs)
|
|
282
|
+
context.report({
|
|
283
|
+
loc: node.loc.end, messageId: 'spaceBeforeParen',
|
|
284
|
+
fix(fixer) {
|
|
285
|
+
return fixer.replaceTextRange(
|
|
286
|
+
[nodeStart + closeIdx - trailingWs.length, nodeStart + closeIdx], '')
|
|
287
|
+
}
|
|
288
|
+
})
|
|
289
|
+
}
|
|
290
|
+
function checkSemicolonBetween(prev, curr) {
|
|
291
|
+
if (!checkSemicolons) return
|
|
292
|
+
const prevEnd = endOf(prev),
|
|
293
|
+
currStart = startOf(curr),
|
|
294
|
+
between = text.slice(prevEnd, currStart)
|
|
295
|
+
if (between.includes('\n') || between == '; ' || between == ' ') return
|
|
296
|
+
if (between.startsWith(';'))
|
|
297
|
+
context.report({
|
|
298
|
+
loc: curr.loc.start, messageId: 'spaceAfterSemicolon',
|
|
299
|
+
fix(fixer) { return fixer.replaceTextRange([prevEnd, currStart], '; ') }
|
|
300
|
+
})
|
|
301
|
+
}
|
|
302
|
+
function checkValueSpacing(valueStart, valueEnd) {
|
|
303
|
+
const rawText = text.slice(valueStart, valueEnd),
|
|
304
|
+
scanStart = valueStart + (rawText.length - rawText.trimStart().length),
|
|
305
|
+
scanEnd = valueEnd - (rawText.length - rawText.trimEnd().length)
|
|
306
|
+
if (scanEnd <= scanStart) return
|
|
307
|
+
const valueText = text.slice(scanStart, scanEnd)
|
|
308
|
+
if (valueText.includes('\n')) return
|
|
309
|
+
let inString = null
|
|
310
|
+
for (let i = 0 ; i < valueText.length ; i++) {
|
|
311
|
+
const ch = valueText[i]
|
|
312
|
+
if (inString) {
|
|
313
|
+
if (ch == '\\') { i++; continue }
|
|
314
|
+
if (ch === inString) inString = null
|
|
315
|
+
continue
|
|
316
|
+
}
|
|
317
|
+
if (ch == '"' || ch === `'`) { inString = ch; continue }
|
|
318
|
+
if (ch == ' ' && valueText[i +1] == ' ') {
|
|
319
|
+
let end = i
|
|
320
|
+
while (end < valueText.length -1 && valueText[end +1] == ' ') end++
|
|
321
|
+
const runStart = scanStart + i
|
|
322
|
+
context.report({
|
|
323
|
+
loc: offsetToLoc(runStart), messageId: 'spaceBetweenValues',
|
|
324
|
+
fix(fixer) { return fixer.replaceTextRange([runStart, scanStart + end +1], ' ') }
|
|
325
|
+
})
|
|
326
|
+
i = end
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
function walkTree(node, depth) {
|
|
331
|
+
if (!node?.loc) return
|
|
332
|
+
const lineNum = node.loc.start.line,
|
|
333
|
+
lineStart = lineOffsets[lineNum -1],
|
|
334
|
+
indent = lines[lineNum -1]?.match(re.leadingWhitespace)?.[0] ?? ''
|
|
335
|
+
if (startOf(node) === lineStart + indent.length) checkIndent(lineNum, depth)
|
|
336
|
+
if (node.block?.children) {
|
|
337
|
+
checkBlockBraces(node.block)
|
|
338
|
+
checkBlockChildren(node.block.children)
|
|
339
|
+
for (const child of node.block.children) walkTree(child, depth +1)
|
|
340
|
+
if (node.block.loc.end.line !== node.block.loc.start.line)
|
|
341
|
+
checkIndent(node.block.loc.end.line, depth)
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
return {
|
|
345
|
+
AttributeSelector(node) { checkBracketSpacing(node) },
|
|
346
|
+
Combinator(node) { checkCombinatorSpacing(node) },
|
|
347
|
+
Declaration(node) { checkColon(node) },
|
|
348
|
+
Function(node) {
|
|
349
|
+
checkParenSpacing(node)
|
|
350
|
+
checkFunctionCommas(node)
|
|
351
|
+
},
|
|
352
|
+
PseudoClassSelector(node) {
|
|
353
|
+
if (!text.slice(startOf(node), endOf(node)).includes('(')) return
|
|
354
|
+
checkParenSpacing(node)
|
|
355
|
+
checkFunctionCommas(node)
|
|
356
|
+
},
|
|
357
|
+
SelectorList(node) {
|
|
358
|
+
const children = node.children ?? []
|
|
359
|
+
for (let i = 1 ; i < children.length ; i++)
|
|
360
|
+
checkCommaBetween(children[i -1], children[i])
|
|
361
|
+
},
|
|
362
|
+
'StyleSheet:exit'() {
|
|
363
|
+
for (const topNode of sourceCode.ast.children ?? []) walkTree(topNode, 0)
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import regex from '../data/regex.js'
|
|
2
|
+
|
|
3
|
+
const commonNames = new Set([
|
|
4
|
+
'blink', 'bounce', 'bounce-in', 'bounce-out', 'fade', 'fade-in', 'fade-in-down', 'fade-in-left', 'fade-in-right',
|
|
5
|
+
'fade-in-up', 'fade-out', 'fade-out-down', 'fade-out-left', 'fade-out-right', 'fade-out-up', 'flip', 'flip-in',
|
|
6
|
+
'flip-out', 'heartbeat', 'hinge', 'jello', 'loading', 'progress', 'pulse', 'roll-in', 'roll-out', 'rotate', 'shake',
|
|
7
|
+
'slide', 'slide-in', 'slide-in-down', 'slide-in-left', 'slide-in-right', 'slide-in-up', 'slide-out',
|
|
8
|
+
'slide-out-down', 'slide-out-left', 'slide-out-right', 'slide-out-up', 'spin', 'spinner', 'swing', 'wobble', 'zoom',
|
|
9
|
+
'zoom-in', 'zoom-out'
|
|
10
|
+
])
|
|
11
|
+
const re = {
|
|
12
|
+
keyframesHeader: /^@(?:-webkit-)?keyframes\s+([\w-]+)/i,
|
|
13
|
+
quotes: /^["']|["']$/g
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export default {
|
|
17
|
+
meta: {
|
|
18
|
+
type: 'problem',
|
|
19
|
+
docs: {
|
|
20
|
+
description: 'Disallow `@keyframes` in content-script CSS that could collide with the host page',
|
|
21
|
+
category: 'Possible Errors',
|
|
22
|
+
css: true
|
|
23
|
+
},
|
|
24
|
+
defaultOptions: [{}],
|
|
25
|
+
schema: [{
|
|
26
|
+
type: 'object',
|
|
27
|
+
properties: {
|
|
28
|
+
flagCommonNames: {
|
|
29
|
+
type: 'boolean',
|
|
30
|
+
description: 'Flag well-known animation names (e.g. `spin`, `fade-in`)'
|
|
31
|
+
},
|
|
32
|
+
requiredPrefix: {
|
|
33
|
+
type: 'string',
|
|
34
|
+
minLength: 1,
|
|
35
|
+
description: 'Flag any `@keyframes` name not starting w/ this prefix'
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
additionalProperties: false
|
|
39
|
+
}],
|
|
40
|
+
messages: {
|
|
41
|
+
missingRequiredPrefix: '`@keyframes {{name}}` must start w/ `{{requiredPrefix}}` '
|
|
42
|
+
+ 'to avoid colliding with host page animations.',
|
|
43
|
+
commonKeyframeName: '`@keyframes {{name}}` is a common name likely to collide w/ host page animations. '
|
|
44
|
+
+ 'Prefix it with your extension namespace (e.g. `myext-{{name}}`).'
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
|
|
48
|
+
create(context) {
|
|
49
|
+
const opts = context.options[0] || {},
|
|
50
|
+
requiredPrefix = opts.requiredPrefix,
|
|
51
|
+
flagCommonNames = opts.flagCommonNames !== false
|
|
52
|
+
function getKeyframeName(node) {
|
|
53
|
+
const pv = node.prelude?.value
|
|
54
|
+
if (typeof pv == 'string' && pv.trim())
|
|
55
|
+
return pv.trim().replace(re.quotes, '')
|
|
56
|
+
for (const child of node.prelude?.children ?? []) {
|
|
57
|
+
const type = child.type?.replace(regex.prefix.css, '')
|
|
58
|
+
if (type == 'Identifier' || type == 'TypeSelector') {
|
|
59
|
+
const name = (child.name ?? child.value ?? '').trim()
|
|
60
|
+
if (name) return name.replace(re.quotes, '')
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
if (node.range)
|
|
64
|
+
return (context.sourceCode.text.slice(node.range[0], node.range[1])
|
|
65
|
+
.match(re.keyframesHeader) ?? [])[1] ?? null
|
|
66
|
+
return null
|
|
67
|
+
}
|
|
68
|
+
function handleAtrule(node) {
|
|
69
|
+
const name = (node.name ?? '').replace(regex.prefix.at, '').toLowerCase()
|
|
70
|
+
if (name != 'keyframes' && name != '-webkit-keyframes') return
|
|
71
|
+
const keyframeName = getKeyframeName(node)
|
|
72
|
+
if (!keyframeName) return
|
|
73
|
+
if (requiredPrefix) {
|
|
74
|
+
if (!keyframeName.startsWith(requiredPrefix))
|
|
75
|
+
context.report({ node, messageId: 'missingRequiredPrefix',
|
|
76
|
+
data: { name: keyframeName, requiredPrefix }})
|
|
77
|
+
return
|
|
78
|
+
}
|
|
79
|
+
if (flagCommonNames && commonNames.has(keyframeName.toLowerCase()))
|
|
80
|
+
context.report({ node, messageId: 'commonKeyframeName', data: { name: keyframeName }})
|
|
81
|
+
}
|
|
82
|
+
return { Atrule: handleAtrule, CSSAtrule: handleAtrule, CSSAtRule: handleAtrule, AtRule: handleAtrule }
|
|
83
|
+
}
|
|
84
|
+
}
|