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.
@@ -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
+ }
@@ -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',
@@ -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,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
+ }