diff --git a/src/index.test.ts b/src/index.test.ts index b4a93e2..e8192b3 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -601,6 +601,12 @@ test('handles empty input gracefully', () => { unique: {}, uniquenessRatio: 0, }, + names: { + total: 0, + totalUnique: 0, + unique: {}, + uniquenessRatio: 0, + }, }, prefixes: { total: 0, diff --git a/src/index.ts b/src/index.ts index 0cba31c..54dae08 100644 --- a/src/index.ts +++ b/src/index.ts @@ -199,6 +199,7 @@ function analyzeInternal(css: string, options: Options, useLo let lineHeights = new Collection(useLocations) let timingFunctions = new Collection(useLocations) let durations = new Collection(useLocations) + let animationNames = new Collection(useLocations) let colors = new ContextCollection(useLocations) let colorFormats = new Collection(useLocations) let units = new ContextCollection(useLocations) @@ -643,6 +644,7 @@ function analyzeInternal(css: string, options: Options, useLo lineHeights.p(normalized, valueLoc) } } else if (normalizedProperty === 'transition' || normalizedProperty === 'animation') { + let isAnimation = normalizedProperty === 'animation' analyzeAnimation(value, function (item) { if (item.type === 'fn') { timingFunctions.p(item.value.text.toLowerCase(), valueLoc) @@ -650,6 +652,8 @@ function analyzeInternal(css: string, options: Options, useLo durations.p(item.value.text.toLowerCase(), valueLoc) } else if (item.type === 'keyword') { valueKeywords.p(item.value.text.toLowerCase(), valueLoc) + } else if (item.type === 'name' && isAnimation) { + animationNames.p(item.value.text, valueLoc) } }) return SKIP @@ -676,6 +680,12 @@ function analyzeInternal(css: string, options: Options, useLo timingFunctions.p(child.text, valueLoc) } } + } else if (normalizedProperty === 'animation-name') { + for (let child of value.children) { + if (is_identifier(child) && !keywords.has(child.name)) { + animationNames.p(child.text, valueLoc) + } + } } else if (normalizedProperty === 'container-name') { containerNames.p(text, valueLoc) } else if (normalizedProperty === 'container') { @@ -1054,6 +1064,7 @@ function analyzeInternal(css: string, options: Options, useLo animations: { durations: durations.c(), timingFunctions: timingFunctions.c(), + names: animationNames.c(), }, prefixes: vendorPrefixedValues.c(), browserhacks: valueBrowserhacks.c(), diff --git a/src/values/animations.test.ts b/src/values/animations.test.ts index 8d39142..2a51e13 100644 --- a/src/values/animations.test.ts +++ b/src/values/animations.test.ts @@ -1,6 +1,8 @@ import { test } from 'vitest' import { expect } from 'vitest' import { analyze } from '../index.js' +import { parse_value } from '@projectwallace/css-parser/parse-value' +import { analyzeAnimation } from './animations.js' test('finds simple durations', () => { const fixture = ` @@ -223,6 +225,110 @@ test('analyzes animations/transitions with value lists', () => { }, uniquenessRatio: 5 / 8, }, + names: { + total: 4, + totalUnique: 1, + unique: { + ANIMATION_NAME: 4, + }, + uniquenessRatio: 1 / 4, + }, } expect(actual).toEqual(expected) }) + +test('emits name for custom animation name identifier', () => { + const names: string[] = [] + analyzeAnimation(parse_value('slide-in 300ms ease-out forwards'), (item) => { + if (item.type === 'name') names.push(item.value.text) + }) + expect(names).toEqual(['slide-in']) +}) + +test('emits name for each animation in a value list', () => { + const names: string[] = [] + analyzeAnimation(parse_value('slide-in 1s, fade-out 2s'), (item) => { + if (item.type === 'name') names.push(item.value.text) + }) + expect(names).toEqual(['slide-in', 'fade-out']) +}) + +test('does not emit name for animation direction keywords', () => { + const names: string[] = [] + for (const kw of ['normal', 'reverse', 'alternate', 'alternate-reverse']) { + analyzeAnimation(parse_value(`my-anim 1s ${kw}`), (item) => { + if (item.type === 'name') names.push(item.value.text) + }) + } + expect(names).toEqual(['my-anim', 'my-anim', 'my-anim', 'my-anim']) +}) + +test('does not emit name for animation fill-mode keywords', () => { + const names: string[] = [] + for (const kw of ['forwards', 'backwards', 'both']) { + analyzeAnimation(parse_value(`my-anim 1s ${kw}`), (item) => { + if (item.type === 'name') names.push(item.value.text) + }) + } + expect(names).toEqual(['my-anim', 'my-anim', 'my-anim']) +}) + +test('does not emit name for animation play-state keywords', () => { + const names: string[] = [] + for (const kw of ['running', 'paused']) { + analyzeAnimation(parse_value(`my-anim 1s ${kw}`), (item) => { + if (item.type === 'name') names.push(item.value.text) + }) + } + expect(names).toEqual(['my-anim', 'my-anim']) +}) + +test('does not emit name for infinite iteration-count', () => { + const names: string[] = [] + analyzeAnimation(parse_value('my-anim 1s infinite'), (item) => { + if (item.type === 'name') names.push(item.value.text) + }) + expect(names).toEqual(['my-anim']) +}) + +test('does not emit name for timing-function keywords', () => { + const names: string[] = [] + for (const kw of [ + 'ease', + 'linear', + 'ease-in', + 'ease-out', + 'ease-in-out', + 'step-start', + 'step-end', + ]) { + analyzeAnimation(parse_value(`my-anim 1s ${kw}`), (item) => { + if (item.type === 'name') names.push(item.value.text) + }) + } + expect(names).toEqual([ + 'my-anim', + 'my-anim', + 'my-anim', + 'my-anim', + 'my-anim', + 'my-anim', + 'my-anim', + ]) +}) + +test('does not emit name for none keyword', () => { + const names: string[] = [] + analyzeAnimation(parse_value('none'), (item) => { + if (item.type === 'name') names.push(item.value.text) + }) + expect(names).toEqual([]) +}) + +test('emits name for dashed-ident animation names', () => { + const names: string[] = [] + analyzeAnimation(parse_value('--my-name 1s ease-in'), (item) => { + if (item.type === 'name') names.push(item.value.text) + }) + expect(names).toEqual(['--my-name']) +}) diff --git a/src/values/animations.ts b/src/values/animations.ts index 34598a4..a710389 100644 --- a/src/values/animations.ts +++ b/src/values/animations.ts @@ -21,6 +21,24 @@ const TIMING_KEYWORDS = new KeywordSet([ const TIMING_FUNCTION_VALUES = new KeywordSet(['cubic-bezier', 'steps']) +// All identifier keywords valid in an animation shorthand that are NOT the animation name +const ANIMATION_NON_NAME_KEYWORDS = new KeywordSet([ + // animation-direction + 'normal', + 'reverse', + 'alternate', + 'alternate-reverse', + // animation-fill-mode (none is covered by the global keywords set) + 'forwards', + 'backwards', + 'both', + // animation-play-state + 'running', + 'paused', + // animation-iteration-count + 'infinite', +]) + export function analyzeAnimation( value: Value, cb: ({ type, value }: { type: string; value: CSSNode }) => void, @@ -49,6 +67,11 @@ export function analyzeAnimation( type: 'keyword', value: node, }) + } else if (!ANIMATION_NON_NAME_KEYWORDS.has(node.name)) { + cb({ + type: 'name', + value: node, + }) } } else if (is_function(node) && TIMING_FUNCTION_VALUES.has(node.name)) { cb({