eslint-plugin-chrome-extensions 2.6.0 → 2.8.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.
Files changed (50) hide show
  1. package/README.md +648 -33
  2. package/package.json +11 -9
  3. package/src/data/apis.js +74 -88
  4. package/src/data/manifest.js +3 -1
  5. package/src/data/regex.js +3 -1
  6. package/src/index.d.ts +42 -0
  7. package/src/index.js +0 -4
  8. package/src/rules/css-spacing.js +375 -0
  9. package/src/rules/manifest-key-order.js +1 -2
  10. package/src/rules/manifest-spacing.js +1 -2
  11. package/src/rules/no-content-style-leak.js +12 -11
  12. package/src/rules/no-deprecated-apis.js +2 -2
  13. package/src/rules/no-duplicate-manifest-keys.js +0 -1
  14. package/src/rules/no-global-keyframes.js +84 -0
  15. package/src/rules/no-host-page-selectors.js +105 -0
  16. package/src/rules/no-inline-event-handlers.js +116 -0
  17. package/src/rules/no-inline-scripts.js +65 -0
  18. package/src/rules/no-insecure-fetch.js +7 -6
  19. package/src/rules/no-insecure-messaging.js +4 -4
  20. package/src/rules/no-invalid-locales.js +3 -5
  21. package/src/rules/no-missing-default-locale.js +1 -1
  22. package/src/rules/no-missing-permissions.js +2 -3
  23. package/src/rules/no-missing-web-accessible-resources.js +2 -2
  24. package/src/rules/no-remote-code.js +77 -28
  25. package/src/rules/no-remote-objects.js +55 -0
  26. package/src/rules/no-remote-urls.js +102 -0
  27. package/src/rules/no-scrollbar-styling.js +71 -0
  28. package/src/rules/no-unrestricted-host-permissions.js +1 -1
  29. package/src/rules/no-unused-i18n-keys.js +0 -1
  30. package/src/rules/no-unused-permissions.js +5 -4
  31. package/src/rules/require-license-file.js +1 -2
  32. package/src/rules/require-mv3.js +3 -2
  33. package/src/rules/require-short-name-when-useful.js +0 -1
  34. package/src/rules/valid-filenames.js +3 -3
  35. package/src/rules/valid-license-file.js +2 -4
  36. package/src/rules/valid-manifest-icons.js +2 -2
  37. package/src/rules/valid-mv3-schema.js +5 -5
  38. package/src/rules/valid-url-fields.js +2 -2
  39. package/src/rules/valid-version-field.js +2 -2
  40. package/src/utils/api.js +20 -0
  41. package/src/utils/array.js +1 -0
  42. package/src/utils/extension.js +1 -3
  43. package/src/utils/html.js +29 -0
  44. package/src/utils/i18n.js +2 -2
  45. package/src/utils/init.js +21 -9
  46. package/src/utils/license.js +1 -1
  47. package/src/utils/node.js +3 -1
  48. package/src/utils/pkg.js +19 -2
  49. package/src/utils/rules.js +1 -1
  50. package/src/data/deprecations.js +0 -8
@@ -0,0 +1,375 @@
1
+ // oxlint-disable security/detect-object-injection
2
+
3
+ import extension from '../utils/extension.js'
4
+ import regex from '../data/regex.js'
5
+
6
+ const re = { leadingWhitespace: /^[ \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
+ function getTrailingWs(str) {
12
+ let end = str.length
13
+ while (end > 0 && (str[end -1] == ' ' || str[end -1] == '\t')) end--
14
+ return str.slice(end)
15
+ }
16
+
17
+ export default {
18
+ meta: {
19
+ type: 'layout',
20
+ docs: {
21
+ description: 'Enforces consistent indentation and spacing in CSS',
22
+ category: 'Stylistic Issues',
23
+ css: true
24
+ },
25
+ fixable: 'whitespace',
26
+ schema: [{
27
+ type: 'object',
28
+ properties: {
29
+ spaces: { type: 'number', description: 'Number of spaces per indent level (default 2)' },
30
+ checkBraces: { type: 'boolean', description: 'Enforce 1 space after `{` and before `}` in single-line rules (default true)' },
31
+ checkBrackets: { type: 'boolean', description: 'Enforce 0 spaces after `[` and before `]` in attribute selectors (default true)' },
32
+ checkColons: { type: 'boolean', description: 'Enforce 0 spaces before and 1 space after `:` in declarations, and 1 space between value tokens (default true)' },
33
+ checkCombinators: { type: 'boolean', description: 'Enforce 1 space on each side of `>`, `+`, `~` in selectors (default true)' },
34
+ checkCommas: { type: 'boolean', description: 'Enforce 0 spaces before and 1 space after `,` in selector lists (default true)' },
35
+ checkParentheses: { type: 'boolean', description: 'Enforce 0 spaces after `(` and before `)` in functions and pseudo-classes (default true)' },
36
+ checkSemicolons: { type: 'boolean', description: 'Enforce 1 space after `;` in single-line declaration blocks (default true)' }
37
+ },
38
+ additionalProperties: false
39
+ }],
40
+ defaultOptions: [{
41
+ spaces: 2, checkBraces: true, checkBrackets: true, checkColons: true,
42
+ checkCombinators: true, checkCommas: true, checkParentheses: true, checkSemicolons: true
43
+ }],
44
+ messages: {
45
+ wrongIndent: 'Expected {{expected}} spaces of indentation (found {{found}}).',
46
+ tabFound: 'Expected spaces for indentation, found tabs.',
47
+ spaceBeforeColon: 'Expected no spaces before `:`.',
48
+ spaceAfterColon: 'Expected 1 space after `:`.',
49
+ spaceBeforeComma: 'Expected no spaces before `,`.',
50
+ spaceAfterComma: 'Expected 1 space after `,`.',
51
+ spaceAfterBrace: 'Expected 1 space after `{`.',
52
+ spaceBeforeBrace: 'Expected 1 space before `}`.',
53
+ spaceInEmptyBraces: 'Expected no spaces in empty `{}`.',
54
+ spaceAfterBracket: 'Expected no spaces after `[`.',
55
+ spaceBeforeBracket: 'Expected no spaces before `]`.',
56
+ spaceAfterParen: 'Expected no spaces after `(`.',
57
+ spaceBeforeParen: 'Expected no spaces before `)`.',
58
+ spaceAroundCombinator: 'Expected 1 space on each side of `{{combinator}}`.',
59
+ spaceAfterSemicolon: 'Expected 1 space after `;`.',
60
+ spaceBetweenValues: 'Expected 1 space between value tokens.'
61
+ }
62
+ },
63
+
64
+ create(context) {
65
+ if (!regex.filename.css.test(extension.file.getName(context))) return {}
66
+ const opts = context.options[0] || {},
67
+ spaces = opts.spaces ?? 2,
68
+ checkBraces = opts.checkBraces ?? true,
69
+ checkBrackets = opts.checkBrackets ?? true,
70
+ checkColons = opts.checkColons ?? true,
71
+ checkCombinators = opts.checkCombinators ?? true,
72
+ checkCommas = opts.checkCommas ?? true,
73
+ checkParentheses = opts.checkParentheses ?? true,
74
+ checkSemicolons = opts.checkSemicolons ?? true,
75
+ sourceCode = context.sourceCode,
76
+ text = sourceCode.text,
77
+ lines = text.split('\n'),
78
+ lineOffsets = [0]
79
+ for (const line of lines) lineOffsets.push(lineOffsets.at(-1) + line.length +1)
80
+ function checkBlockBraces(block) {
81
+ if (!checkBraces) return
82
+ const innerStart = startOf(block) +1,
83
+ innerEnd = endOf(block) -1,
84
+ interior = text.slice(innerStart, innerEnd)
85
+ if (interior.includes('\n')) return
86
+ if (!interior.trim()) {
87
+ if (interior != '')
88
+ context.report({
89
+ loc: block.loc, messageId: 'spaceInEmptyBraces',
90
+ fix(fixer) { return fixer.replaceTextRange([innerStart, innerEnd], '') }
91
+ })
92
+ return
93
+ }
94
+ const leadingWs = interior.match(re.leadingWhitespace)?.[0] ?? ''
95
+ if (leadingWs != ' ')
96
+ context.report({
97
+ loc: block.loc.start, messageId: 'spaceAfterBrace',
98
+ fix(fixer) { return fixer.replaceTextRange([innerStart, innerStart + leadingWs.length], ' ') }
99
+ })
100
+ const trailingWs = getTrailingWs(interior)
101
+ if (trailingWs != ' ')
102
+ context.report({
103
+ loc: block.loc.end, messageId: 'spaceBeforeBrace',
104
+ fix(fixer) { return fixer.replaceTextRange([innerEnd - trailingWs.length, innerEnd], ' ') }
105
+ })
106
+ }
107
+ function checkBlockChildren(children) {
108
+ for (let i = 1 ; i < children.length ; i++)
109
+ if (children[i -1].type?.replace(regex.prefix.css, '') == 'Declaration'
110
+ && children[i].type?.replace(regex.prefix.css, '') == 'Declaration')
111
+ checkSemicolonBetween(children[i -1], children[i])
112
+ }
113
+ function checkBracketSpacing(node) {
114
+ if (!checkBrackets || !node?.loc) return
115
+ const innerStart = startOf(node) +1,
116
+ innerEnd = endOf(node) -1,
117
+ interior = text.slice(innerStart, innerEnd)
118
+ if (interior.includes('\n')) return
119
+ const leadingWs = interior.match(re.leadingWhitespace)?.[0] ?? ''
120
+ if (leadingWs)
121
+ context.report({
122
+ loc: node.loc.start, messageId: 'spaceAfterBracket',
123
+ fix(fixer) { return fixer.replaceTextRange([innerStart, innerStart + leadingWs.length], '') }
124
+ })
125
+ const trailingWs = getTrailingWs(interior)
126
+ if (trailingWs)
127
+ context.report({
128
+ loc: node.loc.end, messageId: 'spaceBeforeBracket',
129
+ fix(fixer) { return fixer.replaceTextRange([innerEnd - trailingWs.length, innerEnd], '') }
130
+ })
131
+ }
132
+ function checkColon(decl) {
133
+ if (!checkColons || !decl.value?.loc) return
134
+ const declStart = startOf(decl),
135
+ valueStart = startOf(decl.value),
136
+ valueEnd = endOf(decl.value),
137
+ between = text.slice(declStart, valueStart),
138
+ colonPos = between.lastIndexOf(':')
139
+ if (colonPos < 0) return
140
+ const before = between.slice(0, colonPos),
141
+ after = between.slice(colonPos +1),
142
+ beforeTrailing = getTrailingWs(before)
143
+ if (beforeTrailing) {
144
+ const fixEnd = declStart + colonPos
145
+ context.report({
146
+ loc: decl.loc.start, messageId: 'spaceBeforeColon',
147
+ fix(fixer) { return fixer.replaceTextRange([fixEnd - beforeTrailing.length, fixEnd], '') }
148
+ })
149
+ }
150
+ if (!after.includes('\n') && after != ' ') {
151
+ const fixStart = declStart + colonPos +1
152
+ context.report({
153
+ loc: decl.loc.start, messageId: 'spaceAfterColon',
154
+ fix(fixer) { return fixer.replaceTextRange([fixStart, valueStart], ' ') }
155
+ })
156
+ }
157
+ checkValueSpacing(valueStart, valueEnd)
158
+ }
159
+ function checkCombinatorSpacing(node) {
160
+ if (!checkCombinators || !node?.loc || !combinators.has(node.name)) return
161
+ const nodeStart = startOf(node),
162
+ nodeEnd = endOf(node)
163
+ let i = nodeStart -1
164
+ while (i >= 0 && (text[i] == ' ' || text[i] == '\t')) i--
165
+ if (i >= 0 && text[i] != '\n') {
166
+ const beforeWs = text.slice(i +1, nodeStart)
167
+ if (beforeWs != ' ')
168
+ context.report({
169
+ loc: node.loc.start, messageId: 'spaceAroundCombinator',
170
+ data: { combinator: node.name },
171
+ fix(fixer) { return fixer.replaceTextRange([i +1, nodeStart], ' ') }
172
+ })
173
+ }
174
+ let j = nodeEnd
175
+ while (j < text.length && (text[j] == ' ' || text[j] == '\t')) j++
176
+ if (j < text.length && text[j] != '\n') {
177
+ const afterWs = text.slice(nodeEnd, j)
178
+ if (afterWs != ' ')
179
+ context.report({
180
+ loc: node.loc.end, messageId: 'spaceAroundCombinator',
181
+ data: { combinator: node.name },
182
+ fix(fixer) { return fixer.replaceTextRange([nodeEnd, j], ' ') }
183
+ })
184
+ }
185
+ }
186
+ function checkCommaBetween(prev, curr) {
187
+ if (!checkCommas) return
188
+ const prevEnd = endOf(prev),
189
+ currStart = startOf(curr),
190
+ between = text.slice(prevEnd, currStart)
191
+ if (between.includes('\n') || between == ', ') return
192
+ const commaIdx = between.indexOf(',')
193
+ if (commaIdx < 0) return
194
+ if (between.slice(0, commaIdx)) {
195
+ const commaPos = prevEnd + commaIdx
196
+ context.report({
197
+ loc: offsetToLoc(commaPos), messageId: 'spaceBeforeComma',
198
+ fix(fixer) { return fixer.replaceTextRange([prevEnd, commaPos], '') }
199
+ })
200
+ }
201
+ if (between.slice(commaIdx +1) != ' ') {
202
+ const commaPos = prevEnd + commaIdx
203
+ context.report({
204
+ loc: offsetToLoc(commaPos +1), messageId: 'spaceAfterComma',
205
+ fix(fixer) { return fixer.replaceTextRange([commaPos +1, currStart], ' ') }
206
+ })
207
+ }
208
+ }
209
+ function checkFunctionCommas(node) {
210
+ if (!checkCommas) return
211
+ const nodeStart = startOf(node),
212
+ nodeText = text.slice(nodeStart, endOf(node)),
213
+ openIdx = nodeText.indexOf('('),
214
+ closeIdx = nodeText.lastIndexOf(')')
215
+ if (openIdx < 0 || closeIdx <= openIdx) return
216
+ const interior = nodeText.slice(openIdx +1, closeIdx)
217
+ if (interior.includes('\n')) return
218
+ let depth = 0, inString = null, i = 0
219
+ while (i < interior.length) {
220
+ const ch = interior[i]
221
+ if (inString) {
222
+ if (ch == '\\') { i += 2 ; continue }
223
+ if (ch === inString) inString = null
224
+ i++ ; continue
225
+ }
226
+ if (ch == '"' || ch === `'`) { inString = ch ; i++ ; continue }
227
+ if (ch == '(') { depth++ ; i++ ; continue }
228
+ if (ch == ')') { depth-- ; i++ ; continue }
229
+ if (ch == ',' && depth === 0) {
230
+ const commaPos = nodeStart + openIdx +1 + i,
231
+ before = getTrailingWs(interior.slice(0, i)),
232
+ after = interior.slice(i +1).match(re.leadingWhitespace)?.[0] ?? ''
233
+ if (before)
234
+ context.report({
235
+ loc: offsetToLoc(commaPos), messageId: 'spaceBeforeComma',
236
+ fix(fixer) { return fixer.replaceTextRange([commaPos - before.length, commaPos], '') }
237
+ })
238
+ if (after != ' ')
239
+ context.report({
240
+ loc: offsetToLoc(commaPos +1), messageId: 'spaceAfterComma',
241
+ fix(fixer) { return fixer.replaceTextRange([commaPos +1, commaPos +1 + after.length], ' ') }
242
+ })
243
+ }
244
+ i++
245
+ }
246
+ }
247
+ function checkIndent(lineNum, expectedDepth) {
248
+ const lineText = lines[lineNum -1]
249
+ if (!lineText?.trim()) return
250
+ const indent = lineText.match(re.leadingWhitespace)?.[0] ?? '',
251
+ expected = ' '.repeat(expectedDepth * spaces)
252
+ if (indent !== expected) {
253
+ const lineStart = lineOffsets[lineNum -1]
254
+ context.report({
255
+ loc: { line: lineNum, column: 1 },
256
+ messageId: indent.includes('\t') ? 'tabFound' : 'wrongIndent',
257
+ data: { expected: expectedDepth * spaces, found: indent.length },
258
+ fix(fixer) { return fixer.replaceTextRange([lineStart, lineStart + indent.length], expected) }
259
+ })
260
+ }
261
+ }
262
+ function offsetToLoc(offset) {
263
+ for (let i = lineOffsets.length -1; i >= 0; i--)
264
+ if (lineOffsets[i] <= offset)
265
+ return { line: i +1, column: offset - lineOffsets[i] +1 }
266
+ return { line: 1, column: 1 }
267
+ }
268
+ function checkParenSpacing(node) {
269
+ if (!checkParentheses || !node?.loc) return
270
+ const nodeStart = startOf(node),
271
+ nodeText = text.slice(nodeStart, endOf(node)),
272
+ openIdx = nodeText.indexOf('('),
273
+ closeIdx = nodeText.lastIndexOf(')')
274
+ if (openIdx < 0 || closeIdx <= openIdx) return
275
+ const interior = nodeText.slice(openIdx +1, closeIdx)
276
+ if (interior.includes('\n')) return
277
+ const leadingWs = interior.match(re.leadingWhitespace)?.[0] ?? ''
278
+ if (leadingWs)
279
+ context.report({
280
+ loc: node.loc.start, messageId: 'spaceAfterParen',
281
+ fix(fixer) {
282
+ return fixer.replaceTextRange(
283
+ [nodeStart + openIdx +1, nodeStart + openIdx +1 + leadingWs.length], '')
284
+ }
285
+ })
286
+ const trailingWs = getTrailingWs(interior)
287
+ if (trailingWs)
288
+ context.report({
289
+ loc: node.loc.end, messageId: 'spaceBeforeParen',
290
+ fix(fixer) {
291
+ return fixer.replaceTextRange(
292
+ [nodeStart + closeIdx - trailingWs.length, nodeStart + closeIdx], '')
293
+ }
294
+ })
295
+ }
296
+ function checkSemicolonBetween(prev, curr) {
297
+ if (!checkSemicolons) return
298
+ const prevEnd = endOf(prev),
299
+ currStart = startOf(curr),
300
+ between = text.slice(prevEnd, currStart)
301
+ if (between.includes('\n') || between == '; ' || between == ' ') return
302
+ if (between.startsWith(';'))
303
+ context.report({
304
+ loc: curr.loc.start, messageId: 'spaceAfterSemicolon',
305
+ fix(fixer) { return fixer.replaceTextRange([prevEnd, currStart], '; ') }
306
+ })
307
+ }
308
+ function checkValueSpacing(valueStart, valueEnd) {
309
+ const rawText = text.slice(valueStart, valueEnd),
310
+ scanStart = valueStart + (rawText.length - rawText.trimStart().length),
311
+ scanEnd = valueEnd - getTrailingWs(rawText).length
312
+ if (scanEnd <= scanStart) return
313
+ const valueText = text.slice(scanStart, scanEnd)
314
+ if (valueText.includes('\n')) return
315
+ let inString = null, i = 0
316
+ while (i < valueText.length) {
317
+ const ch = valueText[i]
318
+ if (inString) {
319
+ if (ch == '\\') { i += 2 ; continue }
320
+ if (ch === inString) inString = null
321
+ i++ ; continue
322
+ }
323
+ if (ch == '"' || ch === `'`) { inString = ch ; i++ ; continue }
324
+ if (ch == ' ' && valueText[i +1] == ' ') {
325
+ let end = i
326
+ while (end < valueText.length -1 && valueText[end +1] == ' ') end++
327
+ const runStart = scanStart + i
328
+ context.report({
329
+ loc: offsetToLoc(runStart), messageId: 'spaceBetweenValues',
330
+ fix(fixer) { return fixer.replaceTextRange([runStart, scanStart + end +1], ' ') }
331
+ })
332
+ i = end +1
333
+ continue
334
+ }
335
+ i++
336
+ }
337
+ }
338
+ function walkTree(node, depth) {
339
+ if (!node?.loc) return
340
+ const lineNum = node.loc.start.line,
341
+ lineStart = lineOffsets[lineNum -1],
342
+ indent = lines[lineNum -1]?.match(re.leadingWhitespace)?.[0] ?? ''
343
+ if (startOf(node) === lineStart + indent.length) checkIndent(lineNum, depth)
344
+ if (node.block?.children) {
345
+ checkBlockBraces(node.block)
346
+ checkBlockChildren(node.block.children)
347
+ for (const child of node.block.children) walkTree(child, depth +1)
348
+ if (node.block.loc.end.line !== node.block.loc.start.line)
349
+ checkIndent(node.block.loc.end.line, depth)
350
+ }
351
+ }
352
+ return {
353
+ AttributeSelector(node) { checkBracketSpacing(node) },
354
+ Combinator(node) { checkCombinatorSpacing(node) },
355
+ Declaration(node) { checkColon(node) },
356
+ Function(node) {
357
+ checkParenSpacing(node)
358
+ checkFunctionCommas(node)
359
+ },
360
+ PseudoClassSelector(node) {
361
+ if (!text.slice(startOf(node), endOf(node)).includes('(')) return
362
+ checkParenSpacing(node)
363
+ checkFunctionCommas(node)
364
+ },
365
+ SelectorList(node) {
366
+ const children = node.children ?? []
367
+ for (let i = 1 ; i < children.length ; i++)
368
+ checkCommaBetween(children[i -1], children[i])
369
+ },
370
+ 'StyleSheet:exit'() {
371
+ for (const topNode of sourceCode.ast.children ?? []) walkTree(topNode, 0)
372
+ }
373
+ }
374
+ }
375
+ }
@@ -14,7 +14,7 @@ function findViolation(members, keys) {
14
14
  const key = keyOf(member)
15
15
  return { member, key, rank: ranks.get(key) }
16
16
  }).filter(item => item.rank !== undefined)
17
- for (let i = 1 ; i < known.length ; i++) // eslint-disable-next-line security/detect-object-injection
17
+ for (let i = 1 ; i < known.length ; i++) // oxlint-disable-next-line security/detect-object-injection
18
18
  if (known[i -1].rank > known[i].rank) return [known[i -1], known[i]]
19
19
  }
20
20
 
@@ -24,7 +24,6 @@ export default {
24
24
  docs: {
25
25
  description: 'Enforces a preferred order for manifest keys',
26
26
  category: 'Stylistic Issues',
27
- recommended: false,
28
27
  manifest: true
29
28
  },
30
29
  fixable: 'code',
@@ -10,7 +10,6 @@ export default {
10
10
  docs: {
11
11
  description: 'Enforces consistent indentation and spacing in `manifest.json`',
12
12
  category: 'Stylistic Issues',
13
- recommended: false,
14
13
  manifest: true
15
14
  },
16
15
  fixable: 'whitespace',
@@ -103,7 +102,7 @@ export default {
103
102
  prevToken = token
104
103
  }
105
104
  for (let t = 0 ; t < tokens.length ; t++) { // check inline spacing
106
- const token = tokens[t], // eslint-disable-line security/detect-object-injection
105
+ const token = tokens[t], // oxlint-disable-line security/detect-object-injection
107
106
  type = token.type,
108
107
  prev = tokens[t -1], next = tokens[t +1]
109
108
  if (next) {
@@ -2,6 +2,18 @@ import extension from '../utils/extension.js'
2
2
 
3
3
  const re = { appendMethod: /^append(?:Child)?$/ }
4
4
 
5
+ function isCreateStyle(node) {
6
+ return node?.type == 'CallExpression'
7
+ && node.callee?.property?.name == 'createElement'
8
+ && node.arguments[0]?.value == 'style'
9
+ }
10
+ function isObjectAssign(node) {
11
+ return node?.type == 'CallExpression'
12
+ && node.callee?.type == 'MemberExpression'
13
+ && node.callee.object?.name == 'Object'
14
+ && node.callee.property?.name == 'assign'
15
+ }
16
+
5
17
  export default {
6
18
  meta: {
7
19
  type: 'suggestion',
@@ -20,17 +32,6 @@ export default {
20
32
  const manifest = extension.manifest.read(context)
21
33
  if (!manifest || !extension.file.isContentScript(context, manifest)) return {}
22
34
  const styleElems = new Set(), docAliases = new Set()
23
- function isCreateStyle(node) {
24
- return node?.type == 'CallExpression'
25
- && node.callee?.property?.name == 'createElement'
26
- && node.arguments[0]?.value == 'style'
27
- }
28
- function isObjectAssign(node) {
29
- return node?.type == 'CallExpression'
30
- && node.callee?.type == 'MemberExpression'
31
- && node.callee.object?.name == 'Object'
32
- && node.callee.property?.name == 'assign'
33
- }
34
35
  function isDocumentRef(node) {
35
36
  return node?.type == 'Identifier'
36
37
  && (node.name == 'document' || docAliases.has(node.name))
@@ -1,5 +1,5 @@
1
+ import apiUtils from '../utils/api.js'
1
2
  import nodeUtils from '../utils/node.js'
2
- import apis from '../data/apis.js'
3
3
 
4
4
  export default {
5
5
  meta: {
@@ -51,7 +51,7 @@ export default {
51
51
  MemberExpression(node) {
52
52
  if (node.parent.type == 'MemberExpression' && node.parent.object === node) return
53
53
  const api = nodeUtils.getMemberPath(node) ; if (!api?.startsWith('chrome.') || isAllowed(api)) return
54
- const found = apis.findDeprecated(api) ; if (!found) return
54
+ const found = apiUtils.findDeprecated(api) ; if (!found) return
55
55
  context.report({ node, messageId: 'deprecatedAPI',
56
56
  data: { api: found.deprecated, replacement: found.replacement, target }})
57
57
  }
@@ -7,7 +7,6 @@ export default {
7
7
  docs: {
8
8
  description: 'Disallow duplicate keys in manifest.json',
9
9
  category: 'Possible Errors',
10
- recommended: false,
11
10
  manifest: true
12
11
  },
13
12
  schema: [],
@@ -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,105 @@
1
+ import array from '../utils/array.js'
2
+ import node from '../utils/node.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 (node.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 = node.plainType(simple)
73
+ if (['AttributeSelector', 'ClassSelector', 'IdSelector'].includes(type)) 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?.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
+ return { Rule: handleRule, CSSRule: handleRule }
104
+ }
105
+ }