diff --git a/.changeset/animation-shorthand-references.md b/.changeset/animation-shorthand-references.md new file mode 100644 index 00000000..268b6b84 --- /dev/null +++ b/.changeset/animation-shorthand-references.md @@ -0,0 +1,5 @@ +--- +'@css-modules-kit/core': minor +--- + +feat(core): support `animation` shorthand diff --git a/docs/ts-plugin-internals.ja.md b/docs/ts-plugin-internals.ja.md index 8ed7078b..0f338620 100644 --- a/docs/ts-plugin-internals.ja.md +++ b/docs/ts-plugin-internals.ja.md @@ -302,9 +302,9 @@ CSS Modules Kit ではこの問題を、 参考: [mizdra/volar-single-quote-span-problem](https://github.com/mizdra/volar-single-quote-span-problem) -### Local Token References (`animation-name`, `composes`) のサポート +### Local Token References (`animation-name`, `animation`, `composes`) のサポート -CSS では `@keyframes foo {...}` で定義したアニメーション名を `animation-name: foo;` で参照できます。また CSS Modules では `composes: foo;` で同一ファイル内の別のクラス名を参照できます。CSS Modules Kit はこうした現在のファイルで利用可能なトークンへの参照 (local token reference) を Volar.js の mapping を介して定義側と結びつけ、Go to Definition / Find All References / Rename を一貫して動作させます。 +CSS では `@keyframes foo {...}` で定義したアニメーション名を `animation-name: foo;` や `animation` 一括指定 (例: `animation: foo 1s;`) で参照できます。また CSS Modules では `composes: foo;` で同一ファイル内の別のクラス名を参照できます。CSS Modules Kit はこうした現在のファイルで利用可能なトークンへの参照 (local token reference) を Volar.js の mapping を介して定義側と結びつけ、Go to Definition / Find All References / Rename を一貫して動作させます。 仕組みとしては、生成する `.d.ts` の末尾に「参照の式」を埋め込みます。default export の場合は `styles[''];` という bracket access の式文として、named export の場合は自モジュールへの self-import (`declare const __self: typeof import('./');`) を 1 度生成した上で `__self[''];` という bracket access として吐きます。各参照式のクオート内側部分には CSS 側の参照位置の mapping を張ります。 diff --git a/docs/ts-plugin-internals.md b/docs/ts-plugin-internals.md index 404e5ea0..e2b25a02 100644 --- a/docs/ts-plugin-internals.md +++ b/docs/ts-plugin-internals.md @@ -305,9 +305,9 @@ With this, `getDefinitionAtPosition`'s `{ start: 27, length: 5 }` also matches t Reference: [mizdra/volar-single-quote-span-problem](https://github.com/mizdra/volar-single-quote-span-problem) -### Local Token References (`animation-name`, `composes`) Support +### Local Token References (`animation-name`, `animation`, `composes`) Support -In CSS, an animation name defined with `@keyframes foo {...}` can be referenced by `animation-name: foo;`. In CSS Modules, `composes: foo;` can also reference another class name in the same file. CSS Modules Kit links such references to tokens available in the current file (local token references) to their definitions via Volar.js mappings, making Go to Definition / Find All References / Rename work consistently. +In CSS, an animation name defined with `@keyframes foo {...}` can be referenced by `animation-name: foo;` or by the `animation` shorthand (e.g. `animation: foo 1s;`). In CSS Modules, `composes: foo;` can also reference another class name in the same file. CSS Modules Kit links such references to tokens available in the current file (local token references) to their definitions via Volar.js mappings, making Go to Definition / Find All References / Rename work consistently. The mechanism is to embed "reference expressions" at the end of the generated `.d.ts`. For default export, it is emitted as a bracket access expression statement like `styles[''];`. For named export, after emitting a self-import (`declare const __self: typeof import('./');`) once, it is emitted as a bracket access like `__self[''];`. A mapping is attached to the inside-of-quotes part of each reference expression, pointing to the reference position in the CSS. diff --git a/examples/1-basic/src/a.module.css b/examples/1-basic/src/a.module.css index 5ec1a895..b772a4cb 100644 --- a/examples/1-basic/src/a.module.css +++ b/examples/1-basic/src/a.module.css @@ -9,6 +9,7 @@ .a_5 { composes: a_1, b_1 from './b.module.css'; animation-name: a_4; + animation: a_4 1s linear; } @import './b.module.css'; diff --git a/packages/core/src/parser/animation-parser.test.ts b/packages/core/src/parser/animation-parser.test.ts index 55c3d52d..67353555 100644 --- a/packages/core/src/parser/animation-parser.test.ts +++ b/packages/core/src/parser/animation-parser.test.ts @@ -1,7 +1,12 @@ import dedent from 'dedent'; import { describe, expect, test } from 'vite-plus/test'; import { fakeDeclaration } from '../test/ast.js'; -import { isAnimationNameProp, parseAnimationNameProp } from './animation-parser.js'; +import { + isAnimationNameProp, + isAnimationProp, + parseAnimationNameProp, + parseAnimationProp, +} from './animation-parser.js'; describe('isAnimationNameProp', () => { test.each([ @@ -174,40 +179,51 @@ describe('parseAnimationNameProp', () => { }); test('reports a diagnostic and skips references for invalid local() shapes (multiple idents, non-identifier node, empty)', () => { - const decl = fakeDeclaration('.a_1 { animation-name: local(a_2, a_3), local(a_4, local(a_5)), local() }'); + const decl = fakeDeclaration( + '.a_1 { animation-name: local(a_2, a_3), local(a_4, local(a_5)), local(), local("a_6") }', + ); expect(parseAnimationNameProp(decl)).toMatchInlineSnapshot(` - { - "diagnostics": [ - { - "category": "error", - "length": 15, - "start": { - "column": 24, - "line": 1, - }, - "text": "\`local(...)\` must contain exactly one identifier.", - }, - { - "category": "error", - "length": 22, - "start": { - "column": 41, - "line": 1, - }, - "text": "\`local(...)\` must contain exactly one identifier.", - }, - { - "category": "error", - "length": 7, - "start": { - "column": 65, - "line": 1, - }, - "text": "\`local(...)\` must contain exactly one identifier.", - }, - ], - "references": [], - } + { + "diagnostics": [ + { + "category": "error", + "length": 15, + "start": { + "column": 24, + "line": 1, + }, + "text": "\`local(...)\` must contain exactly one identifier.", + }, + { + "category": "error", + "length": 22, + "start": { + "column": 41, + "line": 1, + }, + "text": "\`local(...)\` must contain exactly one identifier.", + }, + { + "category": "error", + "length": 7, + "start": { + "column": 65, + "line": 1, + }, + "text": "\`local(...)\` must contain exactly one identifier.", + }, + { + "category": "error", + "length": 12, + "start": { + "column": 74, + "line": 1, + }, + "text": "\`local(...)\` must contain exactly one identifier.", + }, + ], + "references": [], + } `); }); @@ -271,3 +287,293 @@ describe('parseAnimationNameProp', () => { `); }); }); + +describe('isAnimationProp', () => { + test.each([ + ['animation', true], + ['-webkit-animation', true], + ['-moz-animation', true], + ['-o-animation', true], + ['-ms-animation', true], + ['Animation', true], + ['animation-name', false], + ['color', false], + ])('%s -> %s', (prop, expected) => { + expect(isAnimationProp(prop)).toBe(expected); + }); +}); + +describe('parseAnimationProp', () => { + test('extracts a keyframes name and skips times and longhand keywords', () => { + const decl = fakeDeclaration('.a_1 { animation: a_2 1s linear infinite }'); + expect(parseAnimationProp(decl)).toMatchInlineSnapshot(` + { + "diagnostics": [], + "references": [ + { + "loc": { + "end": { + "column": 22, + "line": 1, + "offset": 21, + }, + "start": { + "column": 19, + "line": 1, + "offset": 18, + }, + }, + "name": "a_2", + "type": "local", + }, + ], + } + `); + }); + + // `auto` is reserved (animation-duration/timeline) and is skipped. This differs from css-loader, + // which does not know `auto` and treats it as a ``. + test('extracts a dashed-ident name and skips auto and numbers', () => { + const decl = fakeDeclaration('.a_1 { animation: a_2 auto --foo 2 }'); + expect(parseAnimationProp(decl)).toMatchInlineSnapshot(` + { + "diagnostics": [], + "references": [ + { + "loc": { + "end": { + "column": 22, + "line": 1, + "offset": 21, + }, + "start": { + "column": 19, + "line": 1, + "offset": 18, + }, + }, + "name": "a_2", + "type": "local", + }, + { + "loc": { + "end": { + "column": 33, + "line": 1, + "offset": 32, + }, + "start": { + "column": 28, + "line": 1, + "offset": 27, + }, + }, + "name": "--foo", + "type": "local", + }, + ], + } + `); + }); + + test('extracts a name from each comma-separated segment', () => { + const decl = fakeDeclaration('.a_1 { animation: 1s a_2, 2s linear a_3 }'); + expect(parseAnimationProp(decl)).toMatchInlineSnapshot(` + { + "diagnostics": [], + "references": [ + { + "loc": { + "end": { + "column": 25, + "line": 1, + "offset": 24, + }, + "start": { + "column": 22, + "line": 1, + "offset": 21, + }, + }, + "name": "a_2", + "type": "local", + }, + { + "loc": { + "end": { + "column": 40, + "line": 1, + "offset": 39, + }, + "start": { + "column": 37, + "line": 1, + "offset": 36, + }, + }, + "name": "a_3", + "type": "local", + }, + ], + } + `); + }); + + // Per the CSS spec this declaration is invalid because a segment has a single `` slot, + // but for simplicity—and to match css-loader—every custom-ident is extracted. + test('extracts every custom-ident in a comma-less segment', () => { + const decl = fakeDeclaration('.a_1 { animation: a_2 a_3 }'); + expect(parseAnimationProp(decl)).toMatchInlineSnapshot(` + { + "diagnostics": [], + "references": [ + { + "loc": { + "end": { + "column": 22, + "line": 1, + "offset": 21, + }, + "start": { + "column": 19, + "line": 1, + "offset": 18, + }, + }, + "name": "a_2", + "type": "local", + }, + { + "loc": { + "end": { + "column": 26, + "line": 1, + "offset": 25, + }, + "start": { + "column": 23, + "line": 1, + "offset": 22, + }, + }, + "name": "a_3", + "type": "local", + }, + ], + } + `); + }); + + // css-loader counts keyword occurrences and treats a repeated keyword as a name (the 2nd `infinite`). + // We do not count occurrences, so every reserved keyword is always skipped. + test('skips repeated reserved keywords without occurrence counting', () => { + const decl = fakeDeclaration('.a_1 { animation: infinite infinite }'); + expect(parseAnimationProp(decl)).toMatchInlineSnapshot(` + { + "diagnostics": [], + "references": [], + } + `); + }); + + test('skips none', () => { + const decl = fakeDeclaration('.a_1 { animation: none }'); + expect(parseAnimationProp(decl)).toMatchInlineSnapshot(` + { + "diagnostics": [], + "references": [], + } + `); + }); + + test('extracts ident from local() and skips global() and var()', () => { + const decl = fakeDeclaration('.a_1 { animation: local(a_2), global(a_3), var(--a_4) a_5 }'); + expect(parseAnimationProp(decl)).toMatchInlineSnapshot(` + { + "diagnostics": [], + "references": [ + { + "loc": { + "end": { + "column": 28, + "line": 1, + "offset": 27, + }, + "start": { + "column": 25, + "line": 1, + "offset": 24, + }, + }, + "name": "a_2", + "type": "local", + }, + { + "loc": { + "end": { + "column": 58, + "line": 1, + "offset": 57, + }, + "start": { + "column": 55, + "line": 1, + "offset": 54, + }, + }, + "name": "a_5", + "type": "local", + }, + ], + } + `); + }); + + test('reports a diagnostic and skips references for invalid local() shapes', () => { + const decl = fakeDeclaration('.a_1 { animation: local(a_2, a_3) }'); + expect(parseAnimationProp(decl)).toMatchInlineSnapshot(` + { + "diagnostics": [ + { + "category": "error", + "length": 15, + "start": { + "column": 19, + "line": 1, + }, + "text": "\`local(...)\` must contain exactly one identifier.", + }, + ], + "references": [], + } + `); + }); + + // The CSS spec allows `` as a ``, but it is intentionally not detected, matching css-loader. + test('skips string-form keyframes names', () => { + const decl = fakeDeclaration('.a_1 { animation: "a_2" a_3 }'); + expect(parseAnimationProp(decl)).toMatchInlineSnapshot(` + { + "diagnostics": [], + "references": [ + { + "loc": { + "end": { + "column": 28, + "line": 1, + "offset": 27, + }, + "start": { + "column": 25, + "line": 1, + "offset": 24, + }, + }, + "name": "a_3", + "type": "local", + }, + ], + } + `); + }); +}); diff --git a/packages/core/src/parser/animation-parser.ts b/packages/core/src/parser/animation-parser.ts index 824944d6..297d07d8 100644 --- a/packages/core/src/parser/animation-parser.ts +++ b/packages/core/src/parser/animation-parser.ts @@ -4,12 +4,66 @@ import type { DiagnosticWithDetachedLocation, TokenReference } from '../type.js' import { calcDeclValueLoc } from './decl-value-location.js'; const ANIMATION_NAME_PROP_RE = /^(?:-(?:webkit|moz|o|ms)-)?animation-name$/iu; +const ANIMATION_PROP_RE = /^(?:-(?:webkit|moz|o|ms)-)?animation$/iu; export function isAnimationNameProp(prop: string): boolean { return ANIMATION_NAME_PROP_RE.test(prop); } -const COMMON_RESERVED_KEYWORDS = new Set(['none', 'inherit', 'initial', 'unset', 'revert', 'revert-layer']); +export function isAnimationProp(prop: string): boolean { + return ANIMATION_PROP_RE.test(prop); +} + +/** Keywords reserved by the `animation-name` longhand: `none` plus the CSS-wide keywords. */ +const ANIMATION_NAME_RESERVED_KEYWORDS = new Set([ + // animation-name + 'none', + // CSS-wide keywords + 'inherit', + 'initial', + 'unset', + 'revert', + 'revert-layer', +]); + +/** + * Keywords that cannot be a `` in the `animation` shorthand because + * they are reserved by an `animation-*` longhand (or are CSS-wide keywords). + */ +const ANIMATION_SHORTHAND_RESERVED_KEYWORDS = new Set([ + ...ANIMATION_NAME_RESERVED_KEYWORDS, + // animation-fill-mode, animation-timeline (`none` is also in ANIMATION_NAME_RESERVED_KEYWORDS) + 'none', + // animation-duration, animation-timeline + 'auto', + // animation-timing-function + 'linear', + 'ease', + 'ease-in', + 'ease-out', + 'ease-in-out', + 'step-start', + 'step-end', + // animation-iteration-count + 'infinite', + // animation-direction + 'normal', + 'reverse', + 'alternate', + 'alternate-reverse', + // animation-fill-mode + 'forwards', + 'backwards', + 'both', + // animation-play-state + 'running', + 'paused', +]); + +// A valid CSS identifier, including ``. Excludes `