eslint-plugin-chrome-extensions 2.6.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 +403 -11
- package/package.json +4 -3
- package/src/data/regex.js +1 -0
- 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-remote-urls.js +102 -0
- package/src/rules/no-scrollbar-styling.js +73 -0
- package/src/rules/no-unused-i18n-keys.js +0 -1
- package/src/rules/require-license-file.js +0 -1
- package/src/rules/require-short-name-when-useful.js +0 -1
- package/src/rules/valid-license-file.js +2 -4
- package/src/utils/array.js +1 -0
- package/src/utils/init.js +10 -6
- package/src/data/deprecations.js +0 -8
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import array from '../utils/array.js'
|
|
2
|
+
import regex from '../data/regex.js'
|
|
3
|
+
|
|
4
|
+
const re = { keyframeSelector: /^(?:from|to|[\d.]+%)$/i }
|
|
5
|
+
|
|
6
|
+
export default {
|
|
7
|
+
meta: {
|
|
8
|
+
type: 'problem',
|
|
9
|
+
docs: {
|
|
10
|
+
description: 'Disallow host-page selectors (`html`, `body`, `:root`, `*`, bare element types) in content-script CSS',
|
|
11
|
+
category: 'Possible Errors',
|
|
12
|
+
css: true
|
|
13
|
+
},
|
|
14
|
+
defaultOptions: [{
|
|
15
|
+
allowedSelectors: [],
|
|
16
|
+
disallowUniversal: true,
|
|
17
|
+
disallowStructural: true,
|
|
18
|
+
disallowBareElements: true
|
|
19
|
+
}],
|
|
20
|
+
schema: [{
|
|
21
|
+
type: 'object',
|
|
22
|
+
properties: {
|
|
23
|
+
allowedSelectors: {
|
|
24
|
+
oneOf: [
|
|
25
|
+
{ type: 'string', description: 'A single selector' },
|
|
26
|
+
{ type: 'array', items: { type: 'string' }, description: 'List of selectors' }
|
|
27
|
+
],
|
|
28
|
+
description: 'Selectors to explicitly allow (e.g. `body.dark`)'
|
|
29
|
+
},
|
|
30
|
+
disallowUniversal: {
|
|
31
|
+
type: 'boolean',
|
|
32
|
+
description: 'Flag `*`'
|
|
33
|
+
},
|
|
34
|
+
disallowStructural: {
|
|
35
|
+
type: 'boolean',
|
|
36
|
+
description: 'Flag `html`, `body`, `:root`'
|
|
37
|
+
},
|
|
38
|
+
disallowBareElements: {
|
|
39
|
+
type: 'boolean',
|
|
40
|
+
description: 'Flag bare element types (`div`, `a`, etc.) lacking a class/id/attribute qualifier'
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
additionalProperties: false
|
|
44
|
+
}],
|
|
45
|
+
messages: {
|
|
46
|
+
universalSelector: 'Universal selector `*` targets every element on the host page. Scope with a namespaced class or use `:host`.',
|
|
47
|
+
structuralSelector: 'Selector `{{selector}}` targets a host-page structural element. Use `:host` or a namespaced root instead.',
|
|
48
|
+
elementSelector: 'Selector `{{selector}}` targets a bare element type on the host page. Scope with a namespaced class or wrap in `:host()`.'
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
|
|
52
|
+
create(context) {
|
|
53
|
+
const opts = context.options[0] || {},
|
|
54
|
+
allowedSelectors = new Set(array.to(opts.allowedSelectors)),
|
|
55
|
+
checkUniversal = opts.disallowUniversal !== false,
|
|
56
|
+
checkStructural = opts.disallowStructural !== false,
|
|
57
|
+
checkBareElements = opts.disallowBareElements !== false
|
|
58
|
+
function checkSelector(selector) {
|
|
59
|
+
if (!selector) return
|
|
60
|
+
const text = getSelectorText(selector).trim()
|
|
61
|
+
if (re.keyframeSelector.test(text) || allowedSelectors.has(text)) return
|
|
62
|
+
const simples = []
|
|
63
|
+
for (const child of selector.children ?? []) {
|
|
64
|
+
if (plainType(child) == 'Combinator') break
|
|
65
|
+
simples.push(child)
|
|
66
|
+
}
|
|
67
|
+
let hasQualifier = false,
|
|
68
|
+
isUniversal = false,
|
|
69
|
+
isRoot = false,
|
|
70
|
+
elementTypes = []
|
|
71
|
+
for (const simple of simples) {
|
|
72
|
+
const type = plainType(simple)
|
|
73
|
+
if (type == 'ClassSelector' || type == 'IdSelector' || type == 'AttributeSelector') hasQualifier = true
|
|
74
|
+
else if (type == 'PseudoClassSelector' && (simple.name ?? '').toLowerCase() == 'root') isRoot = true
|
|
75
|
+
else if (type == 'TypeSelector') {
|
|
76
|
+
const name = (simple.name ?? '').toLowerCase()
|
|
77
|
+
if (name == '*') isUniversal = true
|
|
78
|
+
else if (name) elementTypes.push(name)
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
if (isUniversal && checkUniversal)
|
|
82
|
+
return context.report({ node: selector, messageId: 'universalSelector' })
|
|
83
|
+
if (isRoot && checkStructural)
|
|
84
|
+
return context.report({ node: selector, messageId: 'structuralSelector', data: { selector: text }})
|
|
85
|
+
for (const type of elementTypes) {
|
|
86
|
+
if (type == 'html' || type == 'body') {
|
|
87
|
+
if (checkStructural)
|
|
88
|
+
return context.report({ node: selector, messageId: 'structuralSelector', data: { selector: text }})
|
|
89
|
+
} else if (checkBareElements && !hasQualifier) {
|
|
90
|
+
return context.report({ node: selector, messageId: 'elementSelector', data: { selector: text }})
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
function getSelectorText(selector) {
|
|
95
|
+
const range = selector.range
|
|
96
|
+
if (range && range.length == 2) return context.sourceCode.text.slice(range[0], range[1])
|
|
97
|
+
return context.sourceCode.getText(selector)
|
|
98
|
+
}
|
|
99
|
+
function handleRule(node) {
|
|
100
|
+
for (const selector of node.prelude?.children ?? [])
|
|
101
|
+
checkSelector(selector)
|
|
102
|
+
}
|
|
103
|
+
function plainType(node) { return node?.type?.replace(regex.prefix.css, '') ?? '' }
|
|
104
|
+
return { Rule: handleRule, CSSRule: handleRule }
|
|
105
|
+
}
|
|
106
|
+
}
|