-
-
Notifications
You must be signed in to change notification settings - Fork 43
feat: add no-unknown-animations rule #535
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,110 @@ | ||
| # no-unknown-animations | ||
|
|
||
| Disallow unknown animation names. | ||
|
|
||
| ## Background | ||
|
|
||
| CSS animations are created by assigning a [`@keyframes`](https://developer.mozilla.org/en-US/docs/Web/CSS/@keyframes) rule's name to the [`animation-name`](https://developer.mozilla.org/en-US-US/docs/Web/CSS/animation-name) property or the [`animation`](https://developer.mozilla.org/en-US/docs/Web/CSS/animation) shorthand property, as in this example: | ||
|
|
||
| ```css | ||
| .card { | ||
| animation: fade-in 300ms ease; | ||
| } | ||
|
|
||
| @keyframes fade-in { | ||
| from { | ||
| opacity: 0; | ||
| } | ||
|
|
||
| to { | ||
| opacity: 1; | ||
| } | ||
| } | ||
| ``` | ||
|
|
||
| If an animation name doesn't match any `@keyframes` rule, for example because of a typo or because the `@keyframes` rule was renamed or removed, the animation silently fails to run without any error. | ||
|
|
||
| ## Rule Details | ||
|
|
||
| This rule warns when an animation name used in `animation` or `animation-name` doesn't match any `@keyframes` rule defined in the same source. Vendor-prefixed properties such as `-webkit-animation` are checked too, and a vendor-prefixed `@keyframes` rule defines an animation name just like an unprefixed one does. | ||
|
|
||
| Animation names are case-sensitive, and quoted and unquoted names refer to the same animation, so `animation-name: "fade-in"` matches `@keyframes fade-in`. | ||
|
|
||
| The rule only checks statically determinable animation names. A `var()` contributes the animation name in its fallback, if it has one, and the rest of the value is checked either way: | ||
|
|
||
| ```css | ||
| /* `fade-in` is checked, the duration is not */ | ||
| animation: fade-in var(--duration); | ||
|
|
||
| /* the fallback names an animation, so `slide-in` is checked */ | ||
| animation-name: var(--animation-name, slide-in); | ||
|
|
||
| /* no name can be determined, so nothing is checked */ | ||
| animation-name: var(--animation-name); | ||
| ``` | ||
|
|
||
| Examples of **incorrect** code for this rule: | ||
|
|
||
| ```css | ||
| /* eslint css/no-unknown-animations: "error" */ | ||
|
|
||
| .card { | ||
| animation: fade-in 300ms ease; | ||
| } | ||
|
|
||
| .button { | ||
| animation-name: slide-up; | ||
| } | ||
|
|
||
| @keyframes fade-out { | ||
| from { | ||
| opacity: 1; | ||
| } | ||
|
|
||
| to { | ||
| opacity: 0; | ||
| } | ||
| } | ||
| ``` | ||
|
|
||
| Examples of **correct** code for this rule: | ||
|
|
||
| ```css | ||
| /* eslint css/no-unknown-animations: "error" */ | ||
|
|
||
| .card { | ||
| animation: fade-in 300ms ease; | ||
| } | ||
|
|
||
| .button { | ||
| animation-name: slide-up; | ||
| } | ||
|
|
||
| @keyframes fade-in { | ||
| from { | ||
| opacity: 0; | ||
| } | ||
|
|
||
| to { | ||
| opacity: 1; | ||
| } | ||
| } | ||
|
|
||
| @keyframes slide-up { | ||
| from { | ||
| transform: translateY(8px); | ||
| } | ||
|
|
||
| to { | ||
| transform: translateY(0); | ||
| } | ||
| } | ||
| ``` | ||
|
|
||
| ## When Not to Use It | ||
|
|
||
| Animations can reference `@keyframes` rules defined in another stylesheet, but this rule only checks `@keyframes` rules defined in the same source. If your `@keyframes` rules are defined separately from where the animations are used, you should not use this rule. | ||
|
|
||
| ## Prior Art | ||
|
|
||
| - [`no-unknown-animations`](https://stylelint.io/user-guide/rules/no-unknown-animations/) |
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -0,0 +1,277 @@ | ||||||
| /** | ||||||
| * @fileoverview Rule to disallow unknown animation names. | ||||||
| * @author Gaic4o | ||||||
| */ | ||||||
|
|
||||||
| //----------------------------------------------------------------------------- | ||||||
| // Imports | ||||||
| //----------------------------------------------------------------------------- | ||||||
|
|
||||||
| import { parse } from "@eslint/css-tree"; | ||||||
|
|
||||||
| //----------------------------------------------------------------------------- | ||||||
| // Type Definitions | ||||||
| //----------------------------------------------------------------------------- | ||||||
|
|
||||||
| /** | ||||||
| * @import { CSSRuleDefinition } from "../types.js" | ||||||
| * @import { CssLocationRange } from "@eslint/css-tree" | ||||||
| * @typedef {"unknownAnimation"} NoUnknownAnimationsMessageIds | ||||||
| * @typedef {CSSRuleDefinition<{ RuleOptions: [], MessageIds: NoUnknownAnimationsMessageIds }>} NoUnknownAnimationsRuleDefinition | ||||||
| */ | ||||||
|
|
||||||
| //----------------------------------------------------------------------------- | ||||||
| // Helpers | ||||||
| //----------------------------------------------------------------------------- | ||||||
|
|
||||||
| const animationPropertyPattern = | ||||||
| /^(?:-(?:o|moz|webkit)-)?animation(?:-name)?$/iu; | ||||||
|
|
||||||
| /** | ||||||
| * Extracts an animation name from a node. Quoted and unquoted animation | ||||||
| * names refer to the same animation, so `"fade-in"` and `fade-in` both | ||||||
| * yield `fade-in`. | ||||||
| * @param {Object} node The node to extract the animation name from. | ||||||
| * @returns {string|null} The animation name, or `null` if the node isn't a name. | ||||||
| */ | ||||||
| function getAnimationName(node) { | ||||||
| if (node.type === "Identifier") { | ||||||
| return node.name; | ||||||
| } | ||||||
|
|
||||||
| if (node.type === "String") { | ||||||
| return node.value; | ||||||
| } | ||||||
|
|
||||||
| return null; | ||||||
| } | ||||||
|
|
||||||
| /** | ||||||
| * Returns the children of a node as an array. Nodes coming from the rule's | ||||||
| * AST store children in an array, while nodes produced by `parse()` store | ||||||
| * them in a list. | ||||||
| * @param {Object} node The node to read the children of. | ||||||
| * @returns {Array<Object>} The children of the node. | ||||||
| */ | ||||||
| function getChildren(node) { | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The parser already converts every csstree list to an array. |
||||||
| const { children } = node; | ||||||
|
|
||||||
| if (!children) { | ||||||
| return []; | ||||||
| } | ||||||
|
|
||||||
| return Array.isArray(children) ? children : children.toArray(); | ||||||
| } | ||||||
|
|
||||||
| /** | ||||||
| * Finds every `var()` function inside a node. | ||||||
| * @param {Object} node The node to search. | ||||||
| * @param {Array<Object>} varFunctions The array to collect the functions into. | ||||||
| * @returns {Array<Object>} The `var()` functions found. | ||||||
| */ | ||||||
| function findVarFunctions(node, varFunctions) { | ||||||
| for (const child of getChildren(node)) { | ||||||
| if (child.type === "Function" && child.name.toLowerCase() === "var") { | ||||||
| varFunctions.push(child); | ||||||
| } | ||||||
|
|
||||||
| findVarFunctions(child, varFunctions); | ||||||
| } | ||||||
|
|
||||||
| return varFunctions; | ||||||
| } | ||||||
|
|
||||||
| /** | ||||||
| * Replaces every `var()` with whitespace, keeping any fallback value where it | ||||||
| * was. Because the replacement is the same length as the text it replaces, | ||||||
| * the remaining value keeps the offsets it has in the original source. | ||||||
| * @param {string} text The value text to mask. | ||||||
| * @param {number} baseOffset The offset at which `text` starts in the source. | ||||||
| * @param {Array<Object>} varFunctions The `var()` functions to mask. | ||||||
| * @returns {string} The masked value text. | ||||||
| */ | ||||||
| function maskVarFunctions(text, baseOffset, varFunctions) { | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Rather than using this hack, we should not use the |
||||||
| /** @type {Array<[number, number]>} */ | ||||||
| const ranges = []; | ||||||
|
|
||||||
| for (const varFunction of varFunctions) { | ||||||
| const start = varFunction.loc.start.offset - baseOffset; | ||||||
| const end = varFunction.loc.end.offset - baseOffset; | ||||||
| const fallback = getChildren(varFunction).find( | ||||||
| child => child.type === "Raw", | ||||||
| ); | ||||||
|
|
||||||
| if (fallback) { | ||||||
| ranges.push([start, fallback.loc.start.offset - baseOffset]); | ||||||
| ranges.push([fallback.loc.end.offset - baseOffset, end]); | ||||||
| } else { | ||||||
| ranges.push([start, end]); | ||||||
| } | ||||||
| } | ||||||
|
|
||||||
| let masked = text; | ||||||
|
|
||||||
| for (const [start, end] of ranges) { | ||||||
| masked = | ||||||
| masked.slice(0, start) + | ||||||
| " ".repeat(end - start) + | ||||||
| masked.slice(end); | ||||||
| } | ||||||
|
|
||||||
| return masked; | ||||||
| } | ||||||
|
|
||||||
| //----------------------------------------------------------------------------- | ||||||
| // Rule Definition | ||||||
| //----------------------------------------------------------------------------- | ||||||
|
|
||||||
| export default /** @satisfies {NoUnknownAnimationsRuleDefinition} */ ({ | ||||||
| meta: { | ||||||
| type: "problem", | ||||||
|
|
||||||
| docs: { | ||||||
| description: "Disallow unknown animation names", | ||||||
| recommended: false, | ||||||
| url: "https://github.com/eslint/css/blob/main/docs/rules/no-unknown-animations.md", | ||||||
| }, | ||||||
|
|
||||||
| messages: { | ||||||
| unknownAnimation: "Unknown animation name '{{name}}' found.", | ||||||
| }, | ||||||
| }, | ||||||
|
|
||||||
| create(context) { | ||||||
| const { sourceCode } = context; | ||||||
| const { lexer } = sourceCode; | ||||||
|
|
||||||
| /** @type {Set<string>} */ | ||||||
| const definedAnimations = new Set(); | ||||||
|
|
||||||
| /** @type {Array<{ name: string, loc: CssLocationRange }>} */ | ||||||
| const usedAnimations = []; | ||||||
|
|
||||||
| /** | ||||||
| * Finds the animation names that a declaration value refers to. Only | ||||||
| * names that can be determined statically are returned, so a value | ||||||
| * such as `var(--name)` contributes no name while the fallback in | ||||||
| * `var(--name, slide-in)` does. | ||||||
| * @param {string} property The property the value belongs to. | ||||||
| * @param {Object} value The value node to search. | ||||||
| * @returns {Array<{ name: string, loc: CssLocationRange }>} The names found. | ||||||
| */ | ||||||
| function findAnimationNames(property, value) { | ||||||
| let valueNode = value; | ||||||
| let matchResult = lexer.matchProperty(property, valueNode); | ||||||
|
|
||||||
| if (matchResult.error) { | ||||||
| let varFunctions = findVarFunctions(valueNode, []); | ||||||
|
|
||||||
| /* | ||||||
| * A value that doesn't match the property grammar for any | ||||||
| * other reason is an invalid value, which is outside the | ||||||
| * scope of this rule. | ||||||
| */ | ||||||
| if (varFunctions.length === 0) { | ||||||
| return []; | ||||||
| } | ||||||
|
|
||||||
| const baseOffset = valueNode.loc.start.offset; | ||||||
| const { line, column } = valueNode.loc.start; | ||||||
| let text = sourceCode.getText(value); | ||||||
|
|
||||||
| /* | ||||||
| * Masking replaces a `var()` with its fallback, which may | ||||||
| * contain another `var()`, so keep masking until none are | ||||||
| * left. Each pass removes at least one `var()`, so this | ||||||
| * always terminates. | ||||||
| */ | ||||||
| while (varFunctions.length > 0) { | ||||||
| text = maskVarFunctions(text, baseOffset, varFunctions); | ||||||
| valueNode = parse(text, { | ||||||
| context: "value", | ||||||
| positions: true, | ||||||
| offset: baseOffset, | ||||||
| line, | ||||||
| column, | ||||||
| }); | ||||||
| varFunctions = findVarFunctions(valueNode, []); | ||||||
| } | ||||||
|
|
||||||
| matchResult = lexer.matchProperty(property, valueNode); | ||||||
|
|
||||||
| if (matchResult.error) { | ||||||
| return []; | ||||||
| } | ||||||
| } | ||||||
|
|
||||||
| const names = []; | ||||||
|
|
||||||
| for (const child of getChildren(valueNode)) { | ||||||
| if (!matchResult.isType(child, "keyframes-name")) { | ||||||
| continue; | ||||||
| } | ||||||
|
|
||||||
| /* | ||||||
| * The lexer only matches an identifier or a string as a | ||||||
| * keyframes name, so a name is always found here. | ||||||
| */ | ||||||
| names.push({ | ||||||
| name: /** @type {string} */ (getAnimationName(child)), | ||||||
| loc: child.loc, | ||||||
| }); | ||||||
| } | ||||||
|
|
||||||
| return names; | ||||||
| } | ||||||
|
|
||||||
| return { | ||||||
| "Atrule[name=/^(-(o|moz|webkit)-)?keyframes$/i] > AtrulePrelude"( | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
The "ms" vendor-prefix is also missing here. Please also add a test case for this. |
||||||
| node, | ||||||
| ) { | ||||||
| const child = node.children[0]; | ||||||
|
|
||||||
| /* | ||||||
| * A prelude that isn't an identifier or a string, such as the | ||||||
| * one in `@keyframes 50%`, doesn't name an animation. | ||||||
| */ | ||||||
| const name = child ? getAnimationName(child) : null; | ||||||
|
|
||||||
| if (name !== null) { | ||||||
| definedAnimations.add(name); | ||||||
| } | ||||||
| }, | ||||||
|
|
||||||
| "Rule > Block Declaration"(node) { | ||||||
| if ( | ||||||
| !animationPropertyPattern.test(node.property) || | ||||||
| node.value.type !== "Value" | ||||||
| ) { | ||||||
| return; | ||||||
| } | ||||||
|
|
||||||
| usedAnimations.push( | ||||||
| ...findAnimationNames(node.property, node.value), | ||||||
| ); | ||||||
| }, | ||||||
|
|
||||||
| /* | ||||||
| * Usages are reported only after the entire stylesheet has been | ||||||
| * visited so that `@keyframes` rules defined after their usage | ||||||
| * are still found. | ||||||
| */ | ||||||
| "StyleSheet:exit"() { | ||||||
| for (const { name, loc } of usedAnimations) { | ||||||
| if (definedAnimations.has(name)) { | ||||||
| continue; | ||||||
| } | ||||||
|
|
||||||
| context.report({ | ||||||
| loc, | ||||||
| messageId: "unknownAnimation", | ||||||
| data: { name }, | ||||||
| }); | ||||||
| } | ||||||
| }, | ||||||
| }; | ||||||
| }, | ||||||
| }); | ||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The "ms" vendor-prefix is missing. Please also add a test case for this.