Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ export default defineConfig([
| [`no-invalid-at-rules`](./docs/rules/no-invalid-at-rules.md) | Disallow invalid at-rules | yes |
| [`no-invalid-named-grid-areas`](./docs/rules/no-invalid-named-grid-areas.md) | Disallow invalid named grid areas | yes |
| [`no-invalid-properties`](./docs/rules/no-invalid-properties.md) | Disallow invalid properties | yes |
| [`no-unknown-animations`](./docs/rules/no-unknown-animations.md) | Disallow unknown animation names | no |
| [`no-unmatchable-selectors`](./docs/rules/no-unmatchable-selectors.md) | Disallow unmatchable selectors | yes |
| [`prefer-logical-properties`](./docs/rules/prefer-logical-properties.md) | Enforce the use of logical properties | no |
| [`relative-font-units`](./docs/rules/relative-font-units.md) | Enforce the use of relative font units | no |
Expand Down
110 changes: 110 additions & 0 deletions docs/rules/no-unknown-animations.md
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/)
277 changes: 277 additions & 0 deletions src/rules/no-unknown-animations.js
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) {
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) {
/** @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"(
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 },
});
}
},
};
},
});
Loading