Skip to content

Commit ade4a68

Browse files
authored
Add no-unused-keyframes rule (#11)
1 parent c10ff11 commit ade4a68

File tree

18 files changed

+521
-17
lines changed

18 files changed

+521
-17
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,7 @@ Enforce all the rules in this category with:
9797
| | Rule ID | Description |
9898
|:---|:--------|:------------|
9999
| | [vue-scoped-css/no-parsing-error](./docs/rules/no-parsing-error.md) | Disallow parsing errors in `<style>` |
100+
| | [vue-scoped-css/no-unused-keyframes](./docs/rules/no-unused-keyframes.md) | Reports the `@keyframes` is not used in Scoped CSS. |
100101
| | [vue-scoped-css/no-unused-selector](./docs/rules/no-unused-selector.md) | Reports selectors defined in Scoped CSS not used in `<template>`. |
101102
| | [vue-scoped-css/require-scoped](./docs/rules/require-scoped.md) | Enforce the `<style>` tags to has the `scoped` attribute. |
102103

docs/rules/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ Enforce all the rules in this category with:
1919
| Rule ID | Description | |
2020
|:--------|:------------|:---|
2121
| [vue-scoped-css/no-parsing-error](./no-parsing-error.md) | Disallow parsing errors in `<style>` | |
22+
| [vue-scoped-css/no-unused-keyframes](./no-unused-keyframes.md) | Reports the `@keyframes` is not used in Scoped CSS. | |
2223
| [vue-scoped-css/no-unused-selector](./no-unused-selector.md) | Reports selectors defined in Scoped CSS not used in `<template>`. | |
2324
| [vue-scoped-css/require-scoped](./require-scoped.md) | Enforce the `<style>` tags to has the `scoped` attribute. | |
2425

docs/rules/no-unused-keyframes.md

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
---
2+
pageClass: "rule-details"
3+
sidebarDepth: 0
4+
title: "vue-scoped-css/no-unused-keyframes"
5+
description: "Reports the `@keyframes` is not used in Scoped CSS."
6+
---
7+
# vue-scoped-css/no-unused-keyframes
8+
9+
> Reports the `@keyframes` is not used in Scoped CSS.
10+
11+
- :gear: This rule is included in `"plugin:vue-scoped-css/recommended"` and `"plugin:vue-scoped-css/all"`.
12+
13+
This rule reports `@keyframes` is not used in Scoped CSS.
14+
15+
<eslint-code-block :rules="{'vue-scoped-css/no-unused-keyframes': ['error']}">
16+
17+
```vue
18+
<style scoped>
19+
.item {
20+
animation-name: slidein;
21+
}
22+
23+
/* ✗ BAD */
24+
@keyframes unused-animation {
25+
}
26+
27+
/* ✓ GOOD */
28+
@keyframes slidein {
29+
}
30+
</style>
31+
```
32+
33+
</eslint-code-block>
34+
35+
## :books: Further reading
36+
37+
- None
38+
39+
## Implementation
40+
41+
- [Rule source](https://github.com/future-architect/eslint-plugin-vue-scoped-css/blob/master/lib/rules/no-unused-keyframes.ts)
42+
- [Test source](https://github.com/future-architect/eslint-plugin-vue-scoped-css/blob/master/tests/lib/rules/no-unused-keyframes.js)

lib/rules/no-unused-keyframes.ts

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
import {
2+
getStyleContexts,
3+
getCommentDirectivesReporter,
4+
StyleContext,
5+
} from "../styles"
6+
import { VCSSAtRule, VCSSDeclarationProperty } from "../styles/ast"
7+
import { RuleContext } from "../types"
8+
import { Template } from "../styles/template"
9+
10+
module.exports = {
11+
meta: {
12+
docs: {
13+
description: "Reports the `@keyframes` is not used in Scoped CSS.",
14+
category: "recommended",
15+
default: "warn",
16+
url:
17+
"https://future-architect.github.io/eslint-plugin-vue-scoped-css/rules/no-unused-keyframes.ts.html",
18+
},
19+
fixable: null,
20+
messages: {
21+
unused: "The @keyframes `{{params}}` is unused.",
22+
},
23+
schema: [],
24+
type: "suggestion", // "problem",
25+
},
26+
create(context: RuleContext) {
27+
const styles = getStyleContexts(context).filter(
28+
style => !style.invalid && style.scoped,
29+
)
30+
if (!styles.length) {
31+
return {}
32+
}
33+
const reporter = getCommentDirectivesReporter(context)
34+
const sourceCode = context.getSourceCode()
35+
36+
/**
37+
* Reports the given node
38+
* @param {ASTNode} node node to report
39+
*/
40+
function report(node: VCSSAtRule) {
41+
const paramsStartIndex =
42+
node.range[0] + // start index of at-rule
43+
1 + // `@`
44+
node.name.length + // `nest`
45+
(node.node.raws.afterName || "").length // comments and spaces
46+
const paramsEndIndex = paramsStartIndex + node.rawParamsText.length
47+
reporter.report({
48+
node,
49+
loc: {
50+
start: sourceCode.getLocFromIndex(paramsStartIndex),
51+
end: sourceCode.getLocFromIndex(paramsEndIndex),
52+
},
53+
messageId: "unused",
54+
data: { params: node.paramsText },
55+
})
56+
}
57+
58+
/**
59+
* Extract nodes
60+
*/
61+
function extract(
62+
style: StyleContext,
63+
): {
64+
keyframes: { node: VCSSAtRule; params: Template }[]
65+
animationNames: VCSSDeclarationProperty[]
66+
animations: VCSSDeclarationProperty[]
67+
} {
68+
const keyframes: { node: VCSSAtRule; params: Template }[] = []
69+
const animationNames: VCSSDeclarationProperty[] = []
70+
const animations: VCSSDeclarationProperty[] = []
71+
style.traverseNodes({
72+
enterNode(node) {
73+
if (node.type === "VCSSAtRule") {
74+
if (/-?keyframes$/u.test(node.name)) {
75+
// register keyframes
76+
keyframes.push({
77+
params: Template.ofParams(node),
78+
node,
79+
})
80+
}
81+
} else if (node.type === "VCSSDeclarationProperty") {
82+
// individual animation-name declaration
83+
if (/^(-\w+-)?animation-name$/u.test(node.property)) {
84+
animationNames.push(node)
85+
}
86+
// shorthand
87+
if (/^(-\w+-)?animation$/u.test(node.property)) {
88+
animations.push(node)
89+
}
90+
}
91+
},
92+
leaveNode() {
93+
// noop
94+
},
95+
})
96+
return {
97+
keyframes,
98+
animationNames,
99+
animations,
100+
}
101+
}
102+
103+
/**
104+
* Verify the style
105+
*/
106+
function verify(style: StyleContext) {
107+
const { keyframes, animationNames, animations } = extract(style)
108+
109+
for (const decl of animationNames) {
110+
for (const v of decl.value.split(",").map(s => s.trim())) {
111+
const value = Template.ofDeclValue(v, decl.lang)
112+
for (
113+
let index = keyframes.length - 1;
114+
index >= 0;
115+
index--
116+
) {
117+
const { params } = keyframes[index]
118+
if (value.match(params)) {
119+
keyframes.splice(index, 1)
120+
}
121+
}
122+
}
123+
}
124+
125+
for (const decl of animations) {
126+
for (const v of decl.value.split(",").map(s => s.trim())) {
127+
const vals = v.trim().split(/\s+/u)
128+
for (const val of vals) {
129+
const value = Template.ofDeclValue(val, decl.lang)
130+
for (
131+
let index = keyframes.length - 1;
132+
index >= 0;
133+
index--
134+
) {
135+
const { params } = keyframes[index]
136+
if (value.match(params)) {
137+
keyframes.splice(index, 1)
138+
}
139+
}
140+
}
141+
}
142+
}
143+
144+
for (const { node } of keyframes) {
145+
report(node)
146+
}
147+
}
148+
149+
return {
150+
"Program:exit"() {
151+
for (const style of styles) {
152+
verify(style)
153+
}
154+
},
155+
}
156+
},
157+
}

lib/styles/ast.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -272,6 +272,7 @@ export class VCSSAtRule extends HasParentNode<"VCSSAtRule", VCSSContainerNode> {
272272
public readonly rawParamsText: string
273273
public rawSelectorText?: string
274274
public selectors?: VCSSSelectorNode[]
275+
public readonly node: PostCSSAtRule
275276
/**
276277
* constructor.
277278
* @param {PostCSSAtRule} node The node.
@@ -295,6 +296,7 @@ export class VCSSAtRule extends HasParentNode<"VCSSAtRule", VCSSContainerNode> {
295296
},
296297
) {
297298
super(node, "VCSSAtRule", loc, start, end, props)
299+
this.node = node
298300

299301
this.name = getProp(props, node, "name")
300302
this.paramsText = props.paramsText ?? node.params

lib/styles/context/style/index.ts

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { parse } from "../../parser"
22
import { AST, SourceCode, RuleContext } from "../../../types"
3-
import { VCSSStyleSheet } from "../../ast"
3+
import { VCSSStyleSheet, VCSSNode } from "../../ast"
4+
import { isVCSSContainerNode } from "../../utils/css-nodes"
45

56
/**
67
* Check whether the templateBody of the program has invalid EOF or not.
@@ -70,6 +71,13 @@ function getLang(style: AST.VElement) {
7071
)
7172
}
7273

74+
interface Visitor {
75+
exit?: boolean
76+
break?: boolean
77+
enterNode(node: VCSSNode): void
78+
leaveNode(node: VCSSNode): void
79+
}
80+
7381
/**
7482
* Style context
7583
*/
@@ -108,7 +116,41 @@ export class StyleContext {
108116
this.cssNode = null
109117
}
110118
}
119+
120+
public traverseNodes(visitor: Visitor): void {
121+
if (this.cssNode) {
122+
traverseNodes(this.cssNode, visitor)
123+
}
124+
}
111125
}
126+
127+
/**
128+
* Traverse the given node.
129+
* @param node The node to traverse.
130+
* @param visitor The node visitor.
131+
*/
132+
function traverseNodes(node: VCSSNode, visitor: Visitor): void {
133+
visitor.break = false
134+
visitor.enterNode(node)
135+
if (visitor.exit || visitor.break) {
136+
return
137+
}
138+
139+
if (isVCSSContainerNode(node)) {
140+
for (const child of node.nodes) {
141+
traverseNodes(child, visitor)
142+
if (visitor.break) {
143+
break
144+
}
145+
if (visitor.exit) {
146+
return
147+
}
148+
}
149+
}
150+
151+
visitor.leaveNode(node)
152+
}
153+
112154
/**
113155
* Create the style contexts
114156
* @param {RuleContext} context ESLint rule context
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
/**
2+
* Returns the template elements that the given atrule params.
3+
*/
4+
export default function(text: string): string[] {
5+
return [text]
6+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import getCSSTemplateElements from "./css"
2+
import getSCSSTemplateElements from "./scss"
3+
import { Interpolation } from "../interpolation"
4+
import { isSupportedStyleLang } from "../../utils"
5+
6+
const BUILDERS = {
7+
css: getCSSTemplateElements,
8+
scss: getSCSSTemplateElements,
9+
}
10+
11+
/**
12+
* Returns the template elements that the given atrule params.
13+
*/
14+
export default function getAtRuleParamsTemplateElements(
15+
text: string,
16+
lang: string,
17+
): (Interpolation | string)[] {
18+
const templateBuilder = isSupportedStyleLang(lang)
19+
? BUILDERS[lang]
20+
: getCSSTemplateElements
21+
return templateBuilder(text.trim())
22+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { Interpolation } from "../interpolation"
2+
import { processText } from "../scss/util"
3+
4+
/**
5+
* Returns the template elements that the given atrule params.
6+
*/
7+
export default function(text: string): (Interpolation | string)[] {
8+
return processText(text)
9+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
/**
2+
* Returns the template elements that the given decl value.
3+
*/
4+
export default function(text: string): string[] {
5+
return [text]
6+
}

0 commit comments

Comments
 (0)