diff --git a/knip.config.ts b/knip.config.ts index 67833e1..4ea28d4 100644 --- a/knip.config.ts +++ b/knip.config.ts @@ -2,7 +2,6 @@ import type { KnipConfig } from 'knip' const config: KnipConfig = { project: ['src/**/*.ts'], - ignoreDependencies: ['@projectwallace/preset-oxlint'], } export default config diff --git a/src/defined-used.test.ts b/src/defined-used.test.ts index 98ff8dd..51fa88d 100644 --- a/src/defined-used.test.ts +++ b/src/defined-used.test.ts @@ -187,6 +187,55 @@ describe('Animation names / keyframes', () => { expect(result.used).toEqual([]) }) + test('tracks quoted animation-name references as used', () => { + let css = `.foo { animation-name: "slide in"; }` + let result = analyze(css).atrules.keyframes + expect(result.used).toEqual(['slide in']) + }) + + test('tracks quoted animation shorthand names as used', () => { + let css = `.foo { animation: "slide in" 1s ease; }` + let result = analyze(css).atrules.keyframes + expect(result.used).toEqual(['slide in']) + }) + + test('matches a quoted @keyframes definition with a quoted usage', () => { + let css = ` + @keyframes "slide in" { from { opacity: 0; } to { opacity: 1; } } + .foo { animation-name: "slide in"; } + ` + let result = analyze(css).atrules.keyframes + expect(result.defined).toEqual(['slide in']) + expect(result.used).toEqual(['slide in']) + expect(result.unused).toEqual([]) + expect(result.unknown).toEqual([]) + }) + + test('matches a quoted @keyframes definition with an unquoted usage and vice versa', () => { + let css = ` + @keyframes spin { from { opacity: 0; } to { opacity: 1; } } + @keyframes "fade" { from { opacity: 0; } to { opacity: 1; } } + .foo { + animation-name: "spin"; + animation: fade 1s; + } + ` + let result = analyze(css).atrules.keyframes + expect(result.defined).toEqual(['spin', 'fade']) + expect(result.used).toEqual(['spin', 'fade']) + expect(result.unused).toEqual([]) + expect(result.unknown).toEqual([]) + }) + + test('quoted reserved keywords are tracked as used animation names, unlike their bare forms', () => { + // `none` on its own means "no animation", but a quoted `"none"` is a + // valid (if unusual) custom animation name per the CSS Animations spec. + let css = `.foo { animation-name: "none", "infinite", none; }` + let result = analyze(css).atrules.keyframes + expect(result.used).toEqual(['none', 'infinite']) + expect(result.unknown).toEqual(['none', 'infinite']) + }) + test('handles vendor-prefixed @keyframes as defined', () => { let css = `@-webkit-keyframes spin {}` let result = analyze(css).atrules.keyframes diff --git a/src/index.ts b/src/index.ts index 5a199c4..800c5e9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -28,6 +28,7 @@ import { is_hash, is_function, is_operator, + is_string, } from '@projectwallace/css-parser' import { isSupportsBrowserhack, isMediaBrowserhack } from './atrules/atrules.js' import { getCombinators, getComplexity, isPrefixed, isAccessibility } from './selectors/utils.js' @@ -287,7 +288,7 @@ function analyzeInternal(css: string, options: Options, useLo complexity++ }) } else if (normalized_name.endsWith('keyframes')) { - let prelude = node.prelude.text + let prelude = unquote(node.prelude.text) keyframes.p(prelude, toLoc(node)) animationNamesTracking.define(prelude) @@ -671,8 +672,9 @@ function analyzeInternal(css: string, options: Options, useLo } else if (item.type === 'keyword') { valueKeywords.p(item.value.text.toLowerCase(), valueLoc) } else if (item.type === 'name' && isAnimation) { - animationNames.p(item.value.text, valueLoc) - animationNamesTracking.use(item.value.text) + let name = unquote(item.value.text) + animationNames.p(name, valueLoc) + animationNamesTracking.use(name) } }) return SKIP @@ -704,6 +706,10 @@ function analyzeInternal(css: string, options: Options, useLo if (is_identifier(child) && !keywords.has(child.name)) { animationNames.p(child.text, valueLoc) animationNamesTracking.use(child.text) + } else if (is_string(child)) { + let name = unquote(child.text) + animationNames.p(name, valueLoc) + animationNamesTracking.use(name) } } } else if (normalizedProperty === 'container-name') { diff --git a/src/values/animations.test.ts b/src/values/animations.test.ts index a394733..5cc4f91 100644 --- a/src/values/animations.test.ts +++ b/src/values/animations.test.ts @@ -333,3 +333,30 @@ test('emits name for dashed-ident animation names', () => { }) expect(names).toEqual(['--my-name']) }) + +test('emits name for quoted (string) animation names', () => { + const names: string[] = [] + analyzeAnimation(parse_value('"slide in" 300ms ease-out'), (item) => { + if (item.type === 'name') names.push(item.value.text) + }) + expect(names).toEqual(['"slide in"']) +}) + +test('emits name for each quoted 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('emits name for a quoted keyword used as animation name', () => { + // Unlike the bare `none` keyword, a quoted "none" is a valid animation name + const names: string[] = [] + for (const kw of ['none', 'infinite', 'paused', 'alternate', 'linear']) { + analyzeAnimation(parse_value(`"${kw}" 1s ease-in`), (item) => { + if (item.type === 'name') names.push(item.value.text) + }) + } + expect(names).toEqual(['"none"', '"infinite"', '"paused"', '"alternate"', '"linear"']) +}) diff --git a/src/values/animations.ts b/src/values/animations.ts index a710389..6fc770a 100644 --- a/src/values/animations.ts +++ b/src/values/animations.ts @@ -5,6 +5,7 @@ import { is_operator, is_dimension, is_function, + is_string, } from '@projectwallace/css-parser' import { KeywordSet } from '../keyword-set.js' import { keywords } from './values.js' @@ -78,6 +79,12 @@ export function analyzeAnimation( type: 'fn', value: node, }) + } else if (is_string(node)) { + // A quoted string is always a valid animation name, even when it's a reserved keyword (e.g. "none" or "infinite") + cb({ + type: 'name', + value: node, + }) } } }