@tenphi/eslint-plugin-tasty 1.1.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -192,7 +192,7 @@ for typing shared signatures.
192
192
  | `tasty/valid-default-state-order` | warn | Misplaced default (`''`) or redundant `''` when only `_` is present |
193
193
  | `tasty/prefer-shorthand-property` | warn | Use Tasty shorthand instead of native CSS properties (`backgroundColor` → `fill`, `paddingBlock` → `blockPadding`, etc.). In an extension layer the rewrite is report-only and points at a token in the base component, and over a base you cannot edit it is skipped (see `ownedSources`) |
194
194
  | `tasty/no-raw-color-values` | warn | Raw hex/rgb/`okhsl`/`okhst`/`oklch`/named colors instead of `#color` tokens |
195
- | `tasty/no-raw-transition-duration` | warn | Hardcoded `transition` duration (`fill 0.2s`) instead of a duration token or tasty's default timing. Suggests each `$…-transition` / `$…duration` token in your config, or dropping the duration; a *delay* is left alone |
195
+ | `tasty/no-raw-transition-duration` | warn | Hardcoded `transition` duration (`fill 0.2s`) in Tasty styles or a local `tasty()` component's `transition` prop. Suggests a configured duration token or the implicit per-name timing; zero and delays are left alone |
196
196
  | `tasty/consistent-token-usage` | warn | Raw px values when custom units or tokens exist |
197
197
  | `tasty/prefer-auto-calc` | warn | `calc(...)` instead of Tasty auto-calc `(...)` |
198
198
  | `tasty/prefer-custom-property-syntax` | warn | `var(--prop)` / `$x-color` / `transparent` / `currentColor` instead of `$prop` / `#color` / `#clear` / `#current` |
@@ -210,6 +210,42 @@ for typing shared signatures.
210
210
  | `tasty/no-styles-prop` | warn | Direct `styles` prop usage |
211
211
  | `tasty/no-runtime-styles-mutation` | warn | Dynamic values in style objects |
212
212
 
213
+ ### Optional motion duration rule
214
+
215
+ Enable `tasty/no-raw-motion-duration` as a warning if your design system uses
216
+ tokens for animation timing and explicit CSS durations:
217
+
218
+ ```js
219
+ import tasty from '@tenphi/eslint-plugin-tasty';
220
+
221
+ export default [
222
+ tasty.configs.recommended,
223
+ { rules: { 'tasty/no-raw-motion-duration': 'warn' } },
224
+ ];
225
+ ```
226
+
227
+ It checks `animation`, `animationDuration`, and `transitionDuration` in Tasty
228
+ style objects. It suggests configured `$…duration` tokens when
229
+ available. It never suggests removing a duration, since these properties do not
230
+ inherit Tasty's implicit transition timing. Zero, delays, and values based on
231
+ tokens are left alone. Lengths and widths remain covered by the separate
232
+ `consistent-token-usage` rule; valid custom units such as `x` and `bw` are fine.
233
+
234
+ ### Animation and transition shorthands
235
+
236
+ Use Tasty's semantic `transition` value when defining a complete transition,
237
+ and the native CSS `animation` shorthand when defining a complete animation.
238
+ Tasty already supports both: `transition` uses semantic names and duration
239
+ tokens, while `animation` accepts tokens and tracks local keyframes. No
240
+ additional animation style handler is needed. Keep longhands such as
241
+ `transitionDuration`, `animationDuration`, and `animationTimeline` when changing
242
+ one part of an existing effect in a state or component extension.
243
+
244
+ `prefer-shorthand-property` deliberately leaves these motion longhands alone.
245
+ Replacing one with a shorthand can reset the other parts of the effect, including
246
+ an animation's timeline and range. The duration rules above still encourage
247
+ tokens wherever a duration is written.
248
+
213
249
  ## Logical styles
214
250
 
215
251
  Tasty 3.8 added one enhanced handler per logical axis/category pair, which the plugin
package/dist/index.js CHANGED
@@ -22,6 +22,7 @@ import require_default_state_default from "./rules/require-default-state.js";
22
22
  import no_unknown_state_alias_default from "./rules/no-unknown-state-alias.js";
23
23
  import no_raw_color_values_default from "./rules/no-raw-color-values.js";
24
24
  import no_raw_transition_duration_default from "./rules/no-raw-transition-duration.js";
25
+ import no_raw_motion_duration_default from "./rules/no-raw-motion-duration.js";
25
26
  import no_styles_prop_default from "./rules/no-styles-prop.js";
26
27
  import consistent_token_usage_default from "./rules/consistent-token-usage.js";
27
28
  import no_runtime_styles_mutation_default from "./rules/no-runtime-styles-mutation.js";
@@ -62,6 +63,7 @@ const ruleMap = {
62
63
  "no-unknown-state-alias": no_unknown_state_alias_default,
63
64
  "no-raw-color-values": no_raw_color_values_default,
64
65
  "no-raw-transition-duration": no_raw_transition_duration_default,
66
+ "no-raw-motion-duration": no_raw_motion_duration_default,
65
67
  "no-styles-prop": no_styles_prop_default,
66
68
  "consistent-token-usage": consistent_token_usage_default,
67
69
  "no-runtime-styles-mutation": no_runtime_styles_mutation_default,
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","names":["rules.knownProperty","rules.validValue","rules.validColorToken","rules.validCustomUnit","rules.validStateKey","rules.validStylesStructure","rules.noNestedSelector","rules.validCustomProperty","rules.validPreset","rules.validRecipe","rules.validBooleanProperty","rules.validDirectionalModifier","rules.validRadiusShape","rules.noImportant","rules.validSubElement","rules.noNestedStateMap","rules.staticNoDynamicValues","rules.staticValidSelector","rules.preferShorthandProperty","rules.validTransition","rules.requireDefaultState","rules.noUnknownStateAlias","rules.noRawColorValues","rules.noRawTransitionDuration","rules.noStylesProp","rules.consistentTokenUsage","rules.noRuntimeStylesMutation","rules.validStateDefinition","rules.noOwnAtRoot","rules.validDefaultStateOrder","rules.preferAutoCalc","rules.preferCustomPropertySyntax","rules.preferHide","rules.preferDirectionalShorthand","rules.preferLonghandProperty"],"sources":["../src/index.ts"],"sourcesContent":["import { createRequire } from 'node:module';\nimport type { TSESLint } from '@typescript-eslint/utils';\nimport * as rules from './rules/index.js';\nimport { recommended, strict } from './configs.js';\n\nconst ruleMap: Record<string, TSESLint.RuleModule<string, unknown[]>> = {\n 'known-property': rules.knownProperty,\n 'valid-value': rules.validValue,\n 'valid-color-token': rules.validColorToken,\n 'valid-custom-unit': rules.validCustomUnit,\n 'valid-state-key': rules.validStateKey,\n 'valid-styles-structure': rules.validStylesStructure,\n 'no-nested-selector': rules.noNestedSelector,\n 'valid-custom-property': rules.validCustomProperty,\n 'valid-preset': rules.validPreset,\n 'valid-recipe': rules.validRecipe,\n 'valid-boolean-property': rules.validBooleanProperty,\n 'valid-directional-modifier': rules.validDirectionalModifier,\n 'valid-radius-shape': rules.validRadiusShape,\n 'no-important': rules.noImportant,\n 'valid-sub-element': rules.validSubElement,\n 'no-nested-state-map': rules.noNestedStateMap,\n 'static-no-dynamic-values': rules.staticNoDynamicValues,\n 'static-valid-selector': rules.staticValidSelector,\n 'prefer-shorthand-property': rules.preferShorthandProperty,\n 'valid-transition': rules.validTransition,\n 'require-default-state': rules.requireDefaultState,\n 'no-unknown-state-alias': rules.noUnknownStateAlias,\n 'no-raw-color-values': rules.noRawColorValues,\n 'no-raw-transition-duration': rules.noRawTransitionDuration,\n 'no-styles-prop': rules.noStylesProp,\n 'consistent-token-usage': rules.consistentTokenUsage,\n 'no-runtime-styles-mutation': rules.noRuntimeStylesMutation,\n 'valid-state-definition': rules.validStateDefinition,\n 'no-own-at-root': rules.noOwnAtRoot,\n 'valid-default-state-order': rules.validDefaultStateOrder,\n 'prefer-auto-calc': rules.preferAutoCalc,\n 'prefer-custom-property-syntax': rules.preferCustomPropertySyntax,\n 'prefer-hide': rules.preferHide,\n 'prefer-directional-shorthand': rules.preferDirectionalShorthand,\n 'prefer-longhand-property': rules.preferLonghandProperty,\n};\n\n// Read from package.json so `meta.version` cannot drift from the released\n// version, as the previous hardcoded literal had. `../package.json` resolves to\n// the package root from both `src/` and the unbundled `dist/`.\nconst { version } = createRequire(import.meta.url)('../package.json') as {\n version: string;\n};\n\nconst plugin = {\n meta: {\n name: '@tenphi/eslint-plugin-tasty',\n version,\n },\n rules: ruleMap,\n configs: {\n recommended: {\n plugins: {\n get tasty() {\n return plugin;\n },\n },\n rules: recommended,\n },\n strict: {\n plugins: {\n get tasty() {\n return plugin;\n },\n },\n rules: strict,\n },\n },\n} satisfies TSESLint.FlatConfig.Plugin & {\n configs: Record<string, TSESLint.FlatConfig.Config>;\n};\n\nexport default plugin;\n\nexport { recommended, strict } from './configs.js';\nexport type {\n TastyValidationConfig,\n ResolvedConfig,\n StyleFunctionConfig,\n} from './types.js';\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAKA,MAAM,UAAkE;CACtE,kBAAkBA;CAClB,eAAeC;CACf,qBAAqBC;CACrB,qBAAqBC;CACrB,mBAAmBC;CACnB,0BAA0BC;CAC1B,sBAAsBC;CACtB,yBAAyBC;CACzB,gBAAgBC;CAChB,gBAAgBC;CAChB,0BAA0BC;CAC1B,8BAA8BC;CAC9B,sBAAsBC;CACtB,gBAAgBC;CAChB,qBAAqBC;CACrB,uBAAuBC;CACvB,4BAA4BC;CAC5B,yBAAyBC;CACzB,6BAA6BC;CAC7B,oBAAoBC;CACpB,yBAAyBC;CACzB,0BAA0BC;CAC1B,uBAAuBC;CACvB,8BAA8BC;CAC9B,kBAAkBC;CAClB,0BAA0BC;CAC1B,8BAA8BC;CAC9B,0BAA0BC;CAC1B,kBAAkBC;CAClB,6BAA6BC;CAC7B,oBAAoBC;CACpB,iCAAiCC;CACjC,eAAeC;CACf,gCAAgCC;CAChC,4BAA4BC;CAC7B;AAKD,MAAM,EAAE,YAAY,cAAc,OAAO,KAAK,IAAI,CAAC,kBAAkB;AAIrE,MAAM,SAAS;CACb,MAAM;EACJ,MAAM;EACN;EACD;CACD,OAAO;CACP,SAAS;EACP,aAAa;GACX,SAAS,EACP,IAAI,QAAQ;AACV,WAAO;MAEV;GACD,OAAO;GACR;EACD,QAAQ;GACN,SAAS,EACP,IAAI,QAAQ;AACV,WAAO;MAEV;GACD,OAAO;GACR;EACF;CACF"}
1
+ {"version":3,"file":"index.js","names":["rules.knownProperty","rules.validValue","rules.validColorToken","rules.validCustomUnit","rules.validStateKey","rules.validStylesStructure","rules.noNestedSelector","rules.validCustomProperty","rules.validPreset","rules.validRecipe","rules.validBooleanProperty","rules.validDirectionalModifier","rules.validRadiusShape","rules.noImportant","rules.validSubElement","rules.noNestedStateMap","rules.staticNoDynamicValues","rules.staticValidSelector","rules.preferShorthandProperty","rules.validTransition","rules.requireDefaultState","rules.noUnknownStateAlias","rules.noRawColorValues","rules.noRawTransitionDuration","rules.noRawMotionDuration","rules.noStylesProp","rules.consistentTokenUsage","rules.noRuntimeStylesMutation","rules.validStateDefinition","rules.noOwnAtRoot","rules.validDefaultStateOrder","rules.preferAutoCalc","rules.preferCustomPropertySyntax","rules.preferHide","rules.preferDirectionalShorthand","rules.preferLonghandProperty"],"sources":["../src/index.ts"],"sourcesContent":["import { createRequire } from 'node:module';\nimport type { TSESLint } from '@typescript-eslint/utils';\nimport * as rules from './rules/index.js';\nimport { recommended, strict } from './configs.js';\n\nconst ruleMap: Record<string, TSESLint.RuleModule<string, unknown[]>> = {\n 'known-property': rules.knownProperty,\n 'valid-value': rules.validValue,\n 'valid-color-token': rules.validColorToken,\n 'valid-custom-unit': rules.validCustomUnit,\n 'valid-state-key': rules.validStateKey,\n 'valid-styles-structure': rules.validStylesStructure,\n 'no-nested-selector': rules.noNestedSelector,\n 'valid-custom-property': rules.validCustomProperty,\n 'valid-preset': rules.validPreset,\n 'valid-recipe': rules.validRecipe,\n 'valid-boolean-property': rules.validBooleanProperty,\n 'valid-directional-modifier': rules.validDirectionalModifier,\n 'valid-radius-shape': rules.validRadiusShape,\n 'no-important': rules.noImportant,\n 'valid-sub-element': rules.validSubElement,\n 'no-nested-state-map': rules.noNestedStateMap,\n 'static-no-dynamic-values': rules.staticNoDynamicValues,\n 'static-valid-selector': rules.staticValidSelector,\n 'prefer-shorthand-property': rules.preferShorthandProperty,\n 'valid-transition': rules.validTransition,\n 'require-default-state': rules.requireDefaultState,\n 'no-unknown-state-alias': rules.noUnknownStateAlias,\n 'no-raw-color-values': rules.noRawColorValues,\n 'no-raw-transition-duration': rules.noRawTransitionDuration,\n 'no-raw-motion-duration': rules.noRawMotionDuration,\n 'no-styles-prop': rules.noStylesProp,\n 'consistent-token-usage': rules.consistentTokenUsage,\n 'no-runtime-styles-mutation': rules.noRuntimeStylesMutation,\n 'valid-state-definition': rules.validStateDefinition,\n 'no-own-at-root': rules.noOwnAtRoot,\n 'valid-default-state-order': rules.validDefaultStateOrder,\n 'prefer-auto-calc': rules.preferAutoCalc,\n 'prefer-custom-property-syntax': rules.preferCustomPropertySyntax,\n 'prefer-hide': rules.preferHide,\n 'prefer-directional-shorthand': rules.preferDirectionalShorthand,\n 'prefer-longhand-property': rules.preferLonghandProperty,\n};\n\n// Read from package.json so `meta.version` cannot drift from the released\n// version, as the previous hardcoded literal had. `../package.json` resolves to\n// the package root from both `src/` and the unbundled `dist/`.\nconst { version } = createRequire(import.meta.url)('../package.json') as {\n version: string;\n};\n\nconst plugin = {\n meta: {\n name: '@tenphi/eslint-plugin-tasty',\n version,\n },\n rules: ruleMap,\n configs: {\n recommended: {\n plugins: {\n get tasty() {\n return plugin;\n },\n },\n rules: recommended,\n },\n strict: {\n plugins: {\n get tasty() {\n return plugin;\n },\n },\n rules: strict,\n },\n },\n} satisfies TSESLint.FlatConfig.Plugin & {\n configs: Record<string, TSESLint.FlatConfig.Config>;\n};\n\nexport default plugin;\n\nexport { recommended, strict } from './configs.js';\nexport type {\n TastyValidationConfig,\n ResolvedConfig,\n StyleFunctionConfig,\n} from './types.js';\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAKA,MAAM,UAAkE;CACtE,kBAAkBA;CAClB,eAAeC;CACf,qBAAqBC;CACrB,qBAAqBC;CACrB,mBAAmBC;CACnB,0BAA0BC;CAC1B,sBAAsBC;CACtB,yBAAyBC;CACzB,gBAAgBC;CAChB,gBAAgBC;CAChB,0BAA0BC;CAC1B,8BAA8BC;CAC9B,sBAAsBC;CACtB,gBAAgBC;CAChB,qBAAqBC;CACrB,uBAAuBC;CACvB,4BAA4BC;CAC5B,yBAAyBC;CACzB,6BAA6BC;CAC7B,oBAAoBC;CACpB,yBAAyBC;CACzB,0BAA0BC;CAC1B,uBAAuBC;CACvB,8BAA8BC;CAC9B,0BAA0BC;CAC1B,kBAAkBC;CAClB,0BAA0BC;CAC1B,8BAA8BC;CAC9B,0BAA0BC;CAC1B,kBAAkBC;CAClB,6BAA6BC;CAC7B,oBAAoBC;CACpB,iCAAiCC;CACjC,eAAeC;CACf,gCAAgCC;CAChC,4BAA4BC;CAC7B;AAKD,MAAM,EAAE,YAAY,cAAc,OAAO,KAAK,IAAI,CAAC,kBAAkB;AAIrE,MAAM,SAAS;CACb,MAAM;EACJ,MAAM;EACN;EACD;CACD,OAAO;CACP,SAAS;EACP,aAAa;GACX,SAAS,EACP,IAAI,QAAQ;AACV,WAAO;MAEV;GACD,OAAO;GACR;EACD,QAAQ;GACN,SAAS,EACP,IAAI,QAAQ;AACV,WAAO;MAEV;GACD,OAAO;GACR;EACF;CACF"}
@@ -0,0 +1,30 @@
1
+ //#region src/rules/motion-duration-utils.ts
2
+ /** Numeric CSS time values. A zero duration intentionally disables motion. */
3
+ const TIME_LITERAL = /^(?:\d+(?:\.\d+)?|\.\d+)(?:ms|s)$/i;
4
+ const TIME_IN_EXPRESSION = /(?:^|[^\w.])((?:\d+(?:\.\d+)?|\.\d+)(?:ms|s))(?![\w-])/gi;
5
+ const TIME_EXPRESSION = /^(?:\(|calc\(|min\(|max\(|clamp\()/i;
6
+ const DURATION_TOKEN = /(?:^\$transition$|-transition$|duration)/i;
7
+ function isTimeLiteral(value) {
8
+ return TIME_LITERAL.test(value);
9
+ }
10
+ function isZeroTime(value) {
11
+ return value === "0" || isTimeLiteral(value) && Number.parseFloat(value) === 0;
12
+ }
13
+ function isNonzeroTime(value) {
14
+ return isTimeLiteral(value) && Number.parseFloat(value) !== 0;
15
+ }
16
+ function hasRawTimeInExpression(value) {
17
+ if (!isTimeExpression(value) || /\$|\bvar\(/i.test(value)) return false;
18
+ for (const match of value.matchAll(TIME_IN_EXPRESSION)) if (Number.parseFloat(match[1]) !== 0) return true;
19
+ return false;
20
+ }
21
+ function isTimeExpression(value) {
22
+ return TIME_EXPRESSION.test(value);
23
+ }
24
+ function getDurationTokens(tokens) {
25
+ return Array.isArray(tokens) ? tokens.filter((token) => token.startsWith("$") && DURATION_TOKEN.test(token)) : [];
26
+ }
27
+
28
+ //#endregion
29
+ export { getDurationTokens, hasRawTimeInExpression, isNonzeroTime, isTimeExpression, isTimeLiteral, isZeroTime };
30
+ //# sourceMappingURL=motion-duration-utils.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"motion-duration-utils.js","names":[],"sources":["../../src/rules/motion-duration-utils.ts"],"sourcesContent":["/** Numeric CSS time values. A zero duration intentionally disables motion. */\nconst TIME_LITERAL = /^(?:\\d+(?:\\.\\d+)?|\\.\\d+)(?:ms|s)$/i;\nconst TIME_IN_EXPRESSION =\n /(?:^|[^\\w.])((?:\\d+(?:\\.\\d+)?|\\.\\d+)(?:ms|s))(?![\\w-])/gi;\nconst TIME_EXPRESSION = /^(?:\\(|calc\\(|min\\(|max\\(|clamp\\()/i;\nconst DURATION_TOKEN = /(?:^\\$transition$|-transition$|duration)/i;\n\nexport function isTimeLiteral(value: string): boolean {\n return TIME_LITERAL.test(value);\n}\n\nexport function isZeroTime(value: string): boolean {\n return (\n value === '0' || (isTimeLiteral(value) && Number.parseFloat(value) === 0)\n );\n}\n\nexport function isNonzeroTime(value: string): boolean {\n return isTimeLiteral(value) && Number.parseFloat(value) !== 0;\n}\n\nexport function hasRawTimeInExpression(value: string): boolean {\n // A variable-based expression already derives its timing from a token.\n if (!isTimeExpression(value) || /\\$|\\bvar\\(/i.test(value)) return false;\n\n for (const match of value.matchAll(TIME_IN_EXPRESSION)) {\n if (Number.parseFloat(match[1]) !== 0) return true;\n }\n return false;\n}\n\nexport function isTimeExpression(value: string): boolean {\n return TIME_EXPRESSION.test(value);\n}\n\nexport function getDurationTokens(\n tokens: false | string[] | undefined,\n): string[] {\n return Array.isArray(tokens)\n ? tokens.filter(\n (token) => token.startsWith('$') && DURATION_TOKEN.test(token),\n )\n : [];\n}\n"],"mappings":";;AACA,MAAM,eAAe;AACrB,MAAM,qBACJ;AACF,MAAM,kBAAkB;AACxB,MAAM,iBAAiB;AAEvB,SAAgB,cAAc,OAAwB;AACpD,QAAO,aAAa,KAAK,MAAM;;AAGjC,SAAgB,WAAW,OAAwB;AACjD,QACE,UAAU,OAAQ,cAAc,MAAM,IAAI,OAAO,WAAW,MAAM,KAAK;;AAI3E,SAAgB,cAAc,OAAwB;AACpD,QAAO,cAAc,MAAM,IAAI,OAAO,WAAW,MAAM,KAAK;;AAG9D,SAAgB,uBAAuB,OAAwB;AAE7D,KAAI,CAAC,iBAAiB,MAAM,IAAI,cAAc,KAAK,MAAM,CAAE,QAAO;AAElE,MAAK,MAAM,SAAS,MAAM,SAAS,mBAAmB,CACpD,KAAI,OAAO,WAAW,MAAM,GAAG,KAAK,EAAG,QAAO;AAEhD,QAAO;;AAGT,SAAgB,iBAAiB,OAAwB;AACvD,QAAO,gBAAgB,KAAK,MAAM;;AAGpC,SAAgB,kBACd,QACU;AACV,QAAO,MAAM,QAAQ,OAAO,GACxB,OAAO,QACJ,UAAU,MAAM,WAAW,IAAI,IAAI,eAAe,KAAK,MAAM,CAC/D,GACD,EAAE"}
@@ -0,0 +1,105 @@
1
+ import { createRule } from "../create-rule.js";
2
+ import { getKeyName, getStringValue } from "../utils.js";
3
+ import { TastyContext, styleObjectListeners } from "../context.js";
4
+ import { scanTokens } from "../parsers/utils.js";
5
+ import { replaceInStringValue } from "../fix-utils.js";
6
+ import { getDurationTokens, hasRawTimeInExpression, isNonzeroTime, isTimeExpression, isTimeLiteral, isZeroTime } from "./motion-duration-utils.js";
7
+
8
+ //#region src/rules/no-raw-motion-duration.ts
9
+ const PROPERTIES = new Set([
10
+ "animation",
11
+ "animationDuration",
12
+ "transitionDuration"
13
+ ]);
14
+ var no_raw_motion_duration_default = createRule({
15
+ name: "no-raw-motion-duration",
16
+ meta: {
17
+ type: "suggestion",
18
+ hasSuggestions: true,
19
+ docs: { description: "Suggest motion tokens for animation and explicit CSS duration properties" },
20
+ messages: {
21
+ rawMotionDuration: "{{property}} duration '{{duration}}' is hardcoded. Use a duration token.",
22
+ useDurationToken: "Replace '{{duration}}' with '{{token}}'"
23
+ },
24
+ schema: []
25
+ },
26
+ defaultOptions: [],
27
+ create(context) {
28
+ const ctx = new TastyContext(context);
29
+ const durationTokens = getDurationTokens(ctx.config.tokens);
30
+ function checkDuration(token, property, node) {
31
+ const value = token.value;
32
+ if (!isNonzeroTime(value) && !hasRawTimeInExpression(value)) return;
33
+ const candidates = property === "transitionDuration" ? durationTokens : durationTokens.filter((name) => /animation|duration/i.test(name));
34
+ const isLiteral = isTimeLiteral(value);
35
+ context.report({
36
+ node,
37
+ messageId: "rawMotionDuration",
38
+ data: {
39
+ property,
40
+ duration: value
41
+ },
42
+ suggest: isLiteral ? candidates.map((candidate) => ({
43
+ messageId: "useDurationToken",
44
+ data: {
45
+ duration: value,
46
+ token: candidate
47
+ },
48
+ fix: (fixer) => replaceInStringValue(fixer, node, [{
49
+ start: token.offset,
50
+ end: token.offset + value.length,
51
+ replacement: candidate
52
+ }], context.sourceCode)
53
+ })) : []
54
+ });
55
+ }
56
+ function checkGroup(group, property, node) {
57
+ for (const token of group) {
58
+ if (property === "animation" && (/^\$[\w-]+$/.test(token.value) || /^var\(/i.test(token.value) || isZeroTime(token.value) || isTimeExpression(token.value) && !hasRawTimeInExpression(token.value))) return;
59
+ if (isTimeLiteral(token.value)) {
60
+ checkDuration(token, property, node);
61
+ if (property === "animation") return;
62
+ } else if (hasRawTimeInExpression(token.value)) {
63
+ checkDuration(token, property, node);
64
+ if (property === "animation") return;
65
+ }
66
+ }
67
+ }
68
+ function checkValue(value, property, node) {
69
+ let group = [];
70
+ for (const token of scanTokens(value)) {
71
+ if (token.value) group.push(token);
72
+ if (token.isComma) {
73
+ checkGroup(group, property, node);
74
+ group = [];
75
+ }
76
+ }
77
+ checkGroup(group, property, node);
78
+ }
79
+ function handleStyleObject(node) {
80
+ if (!ctx.isStyleObject(node)) return;
81
+ for (const prop of node.properties) {
82
+ if (prop.type !== "Property" || prop.computed) continue;
83
+ const key = getKeyName(prop.key);
84
+ if (!key || !PROPERTIES.has(key)) continue;
85
+ const value = getStringValue(prop.value);
86
+ if (value) checkValue(value, key, prop.value);
87
+ else if (prop.value.type === "ObjectExpression" && ctx.isStateMap(prop.value, prop)) for (const stateProp of prop.value.properties) {
88
+ if (stateProp.type !== "Property") continue;
89
+ const stateValue = getStringValue(stateProp.value);
90
+ if (stateValue) checkValue(stateValue, key, stateProp.value);
91
+ }
92
+ }
93
+ }
94
+ return {
95
+ ImportDeclaration(node) {
96
+ ctx.trackImport(node);
97
+ },
98
+ ...styleObjectListeners(handleStyleObject)
99
+ };
100
+ }
101
+ });
102
+
103
+ //#endregion
104
+ export { no_raw_motion_duration_default as default };
105
+ //# sourceMappingURL=no-raw-motion-duration.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"no-raw-motion-duration.js","names":[],"sources":["../../src/rules/no-raw-motion-duration.ts"],"sourcesContent":["import type { TSESTree } from '@typescript-eslint/utils';\nimport { createRule } from '../create-rule.js';\nimport { TastyContext, styleObjectListeners } from '../context.js';\nimport { replaceInStringValue } from '../fix-utils.js';\nimport { scanTokens, type ScannedToken } from '../parsers/utils.js';\nimport { getKeyName, getStringValue } from '../utils.js';\nimport {\n getDurationTokens,\n hasRawTimeInExpression,\n isNonzeroTime,\n isTimeExpression,\n isTimeLiteral,\n isZeroTime,\n} from './motion-duration-utils.js';\n\ntype MessageIds = 'rawMotionDuration' | 'useDurationToken';\n\nconst PROPERTIES = new Set([\n 'animation',\n 'animationDuration',\n 'transitionDuration',\n]);\n\nexport default createRule<[], MessageIds>({\n name: 'no-raw-motion-duration',\n meta: {\n type: 'suggestion',\n hasSuggestions: true,\n docs: {\n description:\n 'Suggest motion tokens for animation and explicit CSS duration properties',\n },\n messages: {\n rawMotionDuration:\n \"{{property}} duration '{{duration}}' is hardcoded. Use a duration token.\",\n useDurationToken: \"Replace '{{duration}}' with '{{token}}'\",\n },\n schema: [],\n },\n defaultOptions: [],\n create(context) {\n const ctx = new TastyContext(context);\n const durationTokens = getDurationTokens(ctx.config.tokens);\n\n function checkDuration(\n token: ScannedToken,\n property: string,\n node: TSESTree.Node,\n ): void {\n const value = token.value;\n if (!isNonzeroTime(value) && !hasRawTimeInExpression(value)) return;\n\n // Animation has no implicit timing token. Only names that read as an\n // animation or generic duration are useful suggestions for that property.\n const candidates =\n property === 'transitionDuration'\n ? durationTokens\n : durationTokens.filter((name) => /animation|duration/i.test(name));\n const isLiteral = isTimeLiteral(value);\n context.report({\n node,\n messageId: 'rawMotionDuration',\n data: { property, duration: value },\n suggest: isLiteral\n ? candidates.map((candidate) => ({\n messageId: 'useDurationToken' as const,\n data: { duration: value, token: candidate },\n fix: (fixer: Parameters<typeof replaceInStringValue>[0]) =>\n replaceInStringValue(\n fixer,\n node,\n [\n {\n start: token.offset,\n end: token.offset + value.length,\n replacement: candidate,\n },\n ],\n context.sourceCode,\n ),\n }))\n : [],\n });\n }\n\n function checkGroup(\n group: ScannedToken[],\n property: string,\n node: TSESTree.Node,\n ): void {\n for (const token of group) {\n // The first time in an animation shorthand is its duration; a second\n // time is a delay. A variable can also be a duration, so stop there\n // rather than misreporting a later literal delay.\n if (\n property === 'animation' &&\n (/^\\$[\\w-]+$/.test(token.value) ||\n /^var\\(/i.test(token.value) ||\n isZeroTime(token.value) ||\n (isTimeExpression(token.value) &&\n !hasRawTimeInExpression(token.value)))\n ) {\n return;\n }\n if (isTimeLiteral(token.value)) {\n checkDuration(token, property, node);\n if (property === 'animation') return;\n } else if (hasRawTimeInExpression(token.value)) {\n checkDuration(token, property, node);\n if (property === 'animation') return;\n }\n }\n }\n\n function checkValue(\n value: string,\n property: string,\n node: TSESTree.Node,\n ): void {\n let group: ScannedToken[] = [];\n for (const token of scanTokens(value)) {\n if (token.value) group.push(token);\n if (token.isComma) {\n checkGroup(group, property, node);\n group = [];\n }\n }\n checkGroup(group, property, node);\n }\n\n function handleStyleObject(node: TSESTree.ObjectExpression): void {\n if (!ctx.isStyleObject(node)) return;\n\n for (const prop of node.properties) {\n if (prop.type !== 'Property' || prop.computed) continue;\n const key = getKeyName(prop.key);\n if (!key || !PROPERTIES.has(key)) continue;\n\n const value = getStringValue(prop.value);\n if (value) {\n checkValue(value, key, prop.value);\n } else if (\n prop.value.type === 'ObjectExpression' &&\n ctx.isStateMap(prop.value, prop)\n ) {\n for (const stateProp of prop.value.properties) {\n if (stateProp.type !== 'Property') continue;\n const stateValue = getStringValue(stateProp.value);\n if (stateValue) checkValue(stateValue, key, stateProp.value);\n }\n }\n }\n }\n\n return {\n ImportDeclaration(node) {\n ctx.trackImport(node);\n },\n ...styleObjectListeners(handleStyleObject),\n };\n },\n});\n"],"mappings":";;;;;;;;AAiBA,MAAM,aAAa,IAAI,IAAI;CACzB;CACA;CACA;CACD,CAAC;AAEF,qCAAe,WAA2B;CACxC,MAAM;CACN,MAAM;EACJ,MAAM;EACN,gBAAgB;EAChB,MAAM,EACJ,aACE,4EACH;EACD,UAAU;GACR,mBACE;GACF,kBAAkB;GACnB;EACD,QAAQ,EAAE;EACX;CACD,gBAAgB,EAAE;CAClB,OAAO,SAAS;EACd,MAAM,MAAM,IAAI,aAAa,QAAQ;EACrC,MAAM,iBAAiB,kBAAkB,IAAI,OAAO,OAAO;EAE3D,SAAS,cACP,OACA,UACA,MACM;GACN,MAAM,QAAQ,MAAM;AACpB,OAAI,CAAC,cAAc,MAAM,IAAI,CAAC,uBAAuB,MAAM,CAAE;GAI7D,MAAM,aACJ,aAAa,uBACT,iBACA,eAAe,QAAQ,SAAS,sBAAsB,KAAK,KAAK,CAAC;GACvE,MAAM,YAAY,cAAc,MAAM;AACtC,WAAQ,OAAO;IACb;IACA,WAAW;IACX,MAAM;KAAE;KAAU,UAAU;KAAO;IACnC,SAAS,YACL,WAAW,KAAK,eAAe;KAC7B,WAAW;KACX,MAAM;MAAE,UAAU;MAAO,OAAO;MAAW;KAC3C,MAAM,UACJ,qBACE,OACA,MACA,CACE;MACE,OAAO,MAAM;MACb,KAAK,MAAM,SAAS,MAAM;MAC1B,aAAa;MACd,CACF,EACD,QAAQ,WACT;KACJ,EAAE,GACH,EAAE;IACP,CAAC;;EAGJ,SAAS,WACP,OACA,UACA,MACM;AACN,QAAK,MAAM,SAAS,OAAO;AAIzB,QACE,aAAa,gBACZ,aAAa,KAAK,MAAM,MAAM,IAC7B,UAAU,KAAK,MAAM,MAAM,IAC3B,WAAW,MAAM,MAAM,IACtB,iBAAiB,MAAM,MAAM,IAC5B,CAAC,uBAAuB,MAAM,MAAM,EAExC;AAEF,QAAI,cAAc,MAAM,MAAM,EAAE;AAC9B,mBAAc,OAAO,UAAU,KAAK;AACpC,SAAI,aAAa,YAAa;eACrB,uBAAuB,MAAM,MAAM,EAAE;AAC9C,mBAAc,OAAO,UAAU,KAAK;AACpC,SAAI,aAAa,YAAa;;;;EAKpC,SAAS,WACP,OACA,UACA,MACM;GACN,IAAI,QAAwB,EAAE;AAC9B,QAAK,MAAM,SAAS,WAAW,MAAM,EAAE;AACrC,QAAI,MAAM,MAAO,OAAM,KAAK,MAAM;AAClC,QAAI,MAAM,SAAS;AACjB,gBAAW,OAAO,UAAU,KAAK;AACjC,aAAQ,EAAE;;;AAGd,cAAW,OAAO,UAAU,KAAK;;EAGnC,SAAS,kBAAkB,MAAuC;AAChE,OAAI,CAAC,IAAI,cAAc,KAAK,CAAE;AAE9B,QAAK,MAAM,QAAQ,KAAK,YAAY;AAClC,QAAI,KAAK,SAAS,cAAc,KAAK,SAAU;IAC/C,MAAM,MAAM,WAAW,KAAK,IAAI;AAChC,QAAI,CAAC,OAAO,CAAC,WAAW,IAAI,IAAI,CAAE;IAElC,MAAM,QAAQ,eAAe,KAAK,MAAM;AACxC,QAAI,MACF,YAAW,OAAO,KAAK,KAAK,MAAM;aAElC,KAAK,MAAM,SAAS,sBACpB,IAAI,WAAW,KAAK,OAAO,KAAK,CAEhC,MAAK,MAAM,aAAa,KAAK,MAAM,YAAY;AAC7C,SAAI,UAAU,SAAS,WAAY;KACnC,MAAM,aAAa,eAAe,UAAU,MAAM;AAClD,SAAI,WAAY,YAAW,YAAY,KAAK,UAAU,MAAM;;;;AAMpE,SAAO;GACL,kBAAkB,MAAM;AACtB,QAAI,YAAY,KAAK;;GAEvB,GAAG,qBAAqB,kBAAkB;GAC3C;;CAEJ,CAAC"}
@@ -1,47 +1,29 @@
1
1
  import { createRule } from "../create-rule.js";
2
2
  import { getKeyName, getStringValue } from "../utils.js";
3
3
  import { TastyContext, styleObjectListeners } from "../context.js";
4
+ import { scanTokens } from "../parsers/utils.js";
4
5
  import { replaceInStringValue } from "../fix-utils.js";
6
+ import { getDurationTokens, hasRawTimeInExpression, isNonzeroTime, isTimeLiteral } from "./motion-duration-utils.js";
5
7
 
6
8
  //#region src/rules/no-raw-transition-duration.ts
7
9
  /**
8
- * A CSS time literal: `0.2s`, `200ms`, `.15s`, `0`.
9
- *
10
- * A bare `0` counts because it is a duration in the timing slot — and the one
11
- * spelling of "no transition" a design system would rather express as a state
12
- * than as a hardcoded zero.
13
- *
14
- * Deliberately narrow. `$fast-transition`, `var(--transition)`, `(0.2s * 2)`
15
- * and `calc(…)` all reach the same slot and none is a raw literal: the first
16
- * two are already tokens, and the arithmetic forms are somebody deriving a
17
- * duration from one, which is not what this rule is about. A unitless non-zero
18
- * (`fill 200`) is invalid CSS rather than a hardcoded token, so it is left to
19
- * `valid-value`.
20
- */
21
- const TIME_LITERAL = /^(?:\d*\.?\d+m?s|0)$/i;
22
- /**
23
10
  * A plausible transition name: a semantic name, a CSS property, or a `$$name` /
24
11
  * `##name` / `--name` custom-property reference.
25
12
  *
26
- * This is the guard that makes the naive comma split safe. A value can carry a
27
- * multi-argument easing — `transition: 'fill cubic-bezier(0.4, 0, 0.2, 1) 1s'`
28
- * — whose commas are not group separators, so the last "group" comes out as
29
- * `1) 1s`. Requiring the name slot to look like a name drops that fragment
30
- * instead of reporting a duration for a property called `1)`.
13
+ * This excludes malformed groups from duration advice.
31
14
  */
32
15
  const TRANSITION_NAME = /^(?:\$\$|##|--)?[a-zA-Z][\w-]*$/;
33
- /**
34
- * Config `tokens` entries that name a duration.
35
- *
36
- * Tasty resolves an omitted duration to `var(--<name>-transition,
37
- * var(--transition))`, so `-transition` is the naming convention the runtime
38
- * itself imposes; `duration` is the other spelling design systems reach for.
39
- */
40
- const DURATION_TOKEN = /(?:^\$transition$|-transition$|duration)/i;
16
+ const NON_TRANSITION_NAMES = new Set([
17
+ "none",
18
+ "inherit",
19
+ "initial",
20
+ "unset",
21
+ "revert",
22
+ "revert-layer"
23
+ ]);
41
24
  /** The default timing tasty substitutes for a group whose duration is omitted. */
42
25
  function defaultTimingHint(name) {
43
- if (!/^[a-zA-Z][\w-]*$/.test(name)) return "$transition";
44
- return `$${name}-transition (falling back to $transition)`;
26
+ return `$${name.startsWith("$$") ? name.slice(2) : name.startsWith("##") ? `${name.slice(2)}-color` : name.startsWith("--") ? name.slice(2) : name}-transition (falling back to $transition)`;
45
27
  }
46
28
  var no_raw_transition_duration_default = createRule({
47
29
  name: "no-raw-transition-duration",
@@ -51,6 +33,7 @@ var no_raw_transition_duration_default = createRule({
51
33
  docs: { description: "Suggest a duration token, or tasty's default timing, instead of a hardcoded transition duration" },
52
34
  messages: {
53
35
  rawTransitionDuration: "Transition duration '{{duration}}' is hardcoded. Use a duration token{{available}}, or omit it to inherit {{fallback}}.",
36
+ rawTransitionDurationExpression: "Transition duration '{{duration}}' contains a hardcoded time. Derive it from a duration token instead.",
54
37
  useDurationToken: "Replace '{{duration}}' with '{{token}}'",
55
38
  useDefaultDuration: "Remove '{{duration}}' to inherit the default transition timing"
56
39
  },
@@ -60,55 +43,78 @@ var no_raw_transition_duration_default = createRule({
60
43
  create(context) {
61
44
  const ctx = new TastyContext(context);
62
45
  /** `$`-prefixed config tokens whose name reads as a duration. */
63
- const durationTokens = Array.isArray(ctx.config.tokens) ? ctx.config.tokens.filter((token) => token.startsWith("$") && DURATION_TOKEN.test(token)) : [];
64
- function checkTransitionValue(value, node) {
65
- let offset = 0;
66
- for (const group of value.split(",")) {
67
- const [name, timing] = group.trim().split(/\s+/).filter(Boolean);
68
- if (!name || !timing || !TRANSITION_NAME.test(name) || !TIME_LITERAL.test(timing)) {
69
- offset += group.length + 1;
70
- continue;
71
- }
72
- const nameEnd = group.indexOf(name) + name.length;
73
- const startInGroup = group.indexOf(timing, nameEnd);
74
- if (startInGroup === -1) {
75
- offset += group.length + 1;
76
- continue;
77
- }
78
- const start = offset + startInGroup;
79
- const end = start + timing.length;
80
- const removeStart = offset + nameEnd;
46
+ const durationTokens = getDurationTokens(ctx.config.tokens);
47
+ function checkGroup(tokens, node) {
48
+ const [name, timing] = tokens;
49
+ if (!name || !timing || !TRANSITION_NAME.test(name.value)) return;
50
+ if (NON_TRANSITION_NAMES.has(name.value)) return;
51
+ const isLiteral = isTimeLiteral(timing.value);
52
+ if (isLiteral && !isNonzeroTime(timing.value)) return;
53
+ if (!isLiteral && !hasRawTimeInExpression(timing.value)) return;
54
+ if (!isLiteral) {
81
55
  context.report({
82
56
  node,
83
- messageId: "rawTransitionDuration",
57
+ messageId: "rawTransitionDurationExpression",
58
+ data: { duration: timing.value }
59
+ });
60
+ return;
61
+ }
62
+ const start = timing.offset;
63
+ const end = start + timing.value.length;
64
+ const removeStart = name.offset + name.value.length;
65
+ context.report({
66
+ node,
67
+ messageId: "rawTransitionDuration",
68
+ data: {
69
+ duration: timing.value,
70
+ available: durationTokens.length ? ` (${durationTokens.join(", ")})` : "",
71
+ fallback: defaultTimingHint(name.value)
72
+ },
73
+ suggest: [...durationTokens.map((token) => ({
74
+ messageId: "useDurationToken",
84
75
  data: {
85
- duration: timing,
86
- available: durationTokens.length ? ` (${durationTokens.join(", ")})` : "",
87
- fallback: defaultTimingHint(name)
76
+ duration: timing.value,
77
+ token
88
78
  },
89
- suggest: [...durationTokens.map((token) => ({
90
- messageId: "useDurationToken",
91
- data: {
92
- duration: timing,
93
- token
94
- },
95
- fix: (fixer) => replaceInStringValue(fixer, node, [{
96
- start,
97
- end,
98
- replacement: token
99
- }], context.sourceCode)
100
- })), {
101
- messageId: "useDefaultDuration",
102
- data: { duration: timing },
103
- fix: (fixer) => replaceInStringValue(fixer, node, [{
104
- start: removeStart,
105
- end,
106
- replacement: ""
107
- }], context.sourceCode)
108
- }]
79
+ fix: (fixer) => replaceInStringValue(fixer, node, [{
80
+ start,
81
+ end,
82
+ replacement: token
83
+ }], context.sourceCode)
84
+ })), {
85
+ messageId: "useDefaultDuration",
86
+ data: { duration: timing.value },
87
+ fix: (fixer) => replaceInStringValue(fixer, node, [{
88
+ start: removeStart,
89
+ end,
90
+ replacement: ""
91
+ }], context.sourceCode)
92
+ }]
93
+ });
94
+ }
95
+ function checkTransitionValue(value, node) {
96
+ let group = [];
97
+ for (const token of scanTokens(value)) {
98
+ if (token.value) group.push(token);
99
+ if (token.isComma) {
100
+ checkGroup(group, node);
101
+ group = [];
102
+ }
103
+ }
104
+ checkGroup(group, node);
105
+ }
106
+ function isLocalTastyComponent(node, name) {
107
+ let scope = context.sourceCode.getScope(node);
108
+ while (scope) {
109
+ const variable = scope.set.get(name);
110
+ if (variable) return variable.defs.some((definition) => {
111
+ if (definition.type !== "Variable") return false;
112
+ const declaration = definition.node;
113
+ return declaration.id.type === "Identifier" && declaration.parent.type === "VariableDeclaration" && declaration.parent.kind === "const" && declaration.init?.type === "CallExpression" && ctx.isTastyCall(declaration.init)?.importedName === "tasty";
109
114
  });
110
- offset += group.length + 1;
115
+ scope = scope.upper;
111
116
  }
117
+ return false;
112
118
  }
113
119
  function handleStyleObject(node) {
114
120
  if (!ctx.isStyleObject(node)) return;
@@ -120,7 +126,7 @@ var no_raw_transition_duration_default = createRule({
120
126
  checkTransitionValue(str, prop.value);
121
127
  continue;
122
128
  }
123
- if (prop.value.type === "ObjectExpression") for (const stateProp of prop.value.properties) {
129
+ if (prop.value.type === "ObjectExpression" && ctx.isStateMap(prop.value, prop)) for (const stateProp of prop.value.properties) {
124
130
  if (stateProp.type !== "Property") continue;
125
131
  const stateStr = getStringValue(stateProp.value);
126
132
  if (stateStr) checkTransitionValue(stateStr, stateProp.value);
@@ -131,6 +137,15 @@ var no_raw_transition_duration_default = createRule({
131
137
  ImportDeclaration(node) {
132
138
  ctx.trackImport(node);
133
139
  },
140
+ JSXAttribute(node) {
141
+ if (node.name.type !== "JSXIdentifier" || node.name.name !== "transition") return;
142
+ const opening = node.parent;
143
+ if (opening?.type !== "JSXOpeningElement" || opening.name.type !== "JSXIdentifier" || !isLocalTastyComponent(node, opening.name.name)) return;
144
+ const valueNode = node.value?.type === "JSXExpressionContainer" ? node.value.expression : node.value;
145
+ if (!valueNode) return;
146
+ const value = getStringValue(valueNode);
147
+ if (value) checkTransitionValue(value, valueNode);
148
+ },
134
149
  ...styleObjectListeners(handleStyleObject)
135
150
  };
136
151
  }
@@ -1 +1 @@
1
- {"version":3,"file":"no-raw-transition-duration.js","names":[],"sources":["../../src/rules/no-raw-transition-duration.ts"],"sourcesContent":["import type { TSESTree } from '@typescript-eslint/utils';\nimport { createRule } from '../create-rule.js';\nimport { TastyContext, styleObjectListeners } from '../context.js';\nimport { getKeyName, getStringValue } from '../utils.js';\nimport { replaceInStringValue } from '../fix-utils.js';\n\ntype MessageIds =\n | 'rawTransitionDuration'\n | 'useDurationToken'\n | 'useDefaultDuration';\n\n/**\n * A CSS time literal: `0.2s`, `200ms`, `.15s`, `0`.\n *\n * A bare `0` counts because it is a duration in the timing slot — and the one\n * spelling of \"no transition\" a design system would rather express as a state\n * than as a hardcoded zero.\n *\n * Deliberately narrow. `$fast-transition`, `var(--transition)`, `(0.2s * 2)`\n * and `calc(…)` all reach the same slot and none is a raw literal: the first\n * two are already tokens, and the arithmetic forms are somebody deriving a\n * duration from one, which is not what this rule is about. A unitless non-zero\n * (`fill 200`) is invalid CSS rather than a hardcoded token, so it is left to\n * `valid-value`.\n */\nconst TIME_LITERAL = /^(?:\\d*\\.?\\d+m?s|0)$/i;\n\n/**\n * A plausible transition name: a semantic name, a CSS property, or a `$$name` /\n * `##name` / `--name` custom-property reference.\n *\n * This is the guard that makes the naive comma split safe. A value can carry a\n * multi-argument easing — `transition: 'fill cubic-bezier(0.4, 0, 0.2, 1) 1s'`\n * — whose commas are not group separators, so the last \"group\" comes out as\n * `1) 1s`. Requiring the name slot to look like a name drops that fragment\n * instead of reporting a duration for a property called `1)`.\n */\nconst TRANSITION_NAME = /^(?:\\$\\$|##|--)?[a-zA-Z][\\w-]*$/;\n\n/**\n * Config `tokens` entries that name a duration.\n *\n * Tasty resolves an omitted duration to `var(--<name>-transition,\n * var(--transition))`, so `-transition` is the naming convention the runtime\n * itself imposes; `duration` is the other spelling design systems reach for.\n */\nconst DURATION_TOKEN = /(?:^\\$transition$|-transition$|duration)/i;\n\n/** The default timing tasty substitutes for a group whose duration is omitted. */\nfunction defaultTimingHint(name: string): string {\n // A custom-property reference names no semantic group, so there is no\n // per-name timing token to point at — only the global one.\n if (!/^[a-zA-Z][\\w-]*$/.test(name)) return '$transition';\n\n return `$${name}-transition (falling back to $transition)`;\n}\n\nexport default createRule<[], MessageIds>({\n name: 'no-raw-transition-duration',\n meta: {\n type: 'suggestion',\n hasSuggestions: true,\n docs: {\n description:\n \"Suggest a duration token, or tasty's default timing, instead of a hardcoded transition duration\",\n },\n messages: {\n rawTransitionDuration:\n \"Transition duration '{{duration}}' is hardcoded. Use a duration token{{available}}, or omit it to inherit {{fallback}}.\",\n useDurationToken: \"Replace '{{duration}}' with '{{token}}'\",\n useDefaultDuration:\n \"Remove '{{duration}}' to inherit the default transition timing\",\n },\n schema: [],\n },\n defaultOptions: [],\n create(context) {\n const ctx = new TastyContext(context);\n\n /** `$`-prefixed config tokens whose name reads as a duration. */\n const durationTokens = Array.isArray(ctx.config.tokens)\n ? ctx.config.tokens.filter(\n (token) => token.startsWith('$') && DURATION_TOKEN.test(token),\n )\n : [];\n\n function checkTransitionValue(value: string, node: TSESTree.Node): void {\n let offset = 0;\n\n for (const group of value.split(',')) {\n const tokens = group.trim().split(/\\s+/).filter(Boolean);\n const [name, timing] = tokens;\n\n // Only the second token is inspected, which is exactly the slot tasty\n // reads as the duration. A later time value is a *delay*\n // (`fill ease-in 0.1s`), and the advice here would not hold for one:\n // omitting a delay means \"no delay\", a real rendering change, whereas\n // omitting a duration falls back to the design system's own timing.\n if (\n !name ||\n !timing ||\n !TRANSITION_NAME.test(name) ||\n !TIME_LITERAL.test(timing)\n ) {\n offset += group.length + 1;\n continue;\n }\n\n // Search from the end of the name so a duration that repeats the name's\n // own text cannot match the wrong occurrence.\n const nameEnd = group.indexOf(name) + name.length;\n const startInGroup = group.indexOf(timing, nameEnd);\n if (startInGroup === -1) {\n offset += group.length + 1;\n continue;\n }\n\n const start = offset + startInGroup;\n const end = start + timing.length;\n // Removal swallows the separating whitespace too, so dropping the\n // duration does not leave `fill ease-in` behind.\n const removeStart = offset + nameEnd;\n\n context.report({\n node,\n messageId: 'rawTransitionDuration',\n data: {\n duration: timing,\n available: durationTokens.length\n ? ` (${durationTokens.join(', ')})`\n : '',\n fallback: defaultTimingHint(name),\n },\n suggest: [\n // Config tokens first: naming the project's own duration is better\n // advice than deleting the author's intent, when one exists.\n ...durationTokens.map((token) => ({\n messageId: 'useDurationToken' as const,\n data: { duration: timing, token },\n fix: (fixer: Parameters<typeof replaceInStringValue>[0]) =>\n replaceInStringValue(\n fixer,\n node,\n [{ start, end, replacement: token }],\n context.sourceCode,\n ),\n })),\n {\n messageId: 'useDefaultDuration' as const,\n data: { duration: timing },\n fix: (fixer: Parameters<typeof replaceInStringValue>[0]) =>\n replaceInStringValue(\n fixer,\n node,\n [{ start: removeStart, end, replacement: '' }],\n context.sourceCode,\n ),\n },\n ],\n });\n\n offset += group.length + 1;\n }\n }\n\n function handleStyleObject(node: TSESTree.ObjectExpression) {\n if (!ctx.isStyleObject(node)) return;\n\n for (const prop of node.properties) {\n if (prop.type !== 'Property' || prop.computed) continue;\n\n const key = getKeyName(prop.key);\n if (key !== 'transition') continue;\n\n const str = getStringValue(prop.value);\n if (str) {\n checkTransitionValue(str, prop.value);\n continue;\n }\n\n if (prop.value.type === 'ObjectExpression') {\n for (const stateProp of prop.value.properties) {\n if (stateProp.type !== 'Property') continue;\n const stateStr = getStringValue(stateProp.value);\n if (stateStr) {\n checkTransitionValue(stateStr, stateProp.value);\n }\n }\n }\n }\n }\n\n return {\n ImportDeclaration(node) {\n ctx.trackImport(node);\n },\n ...styleObjectListeners(handleStyleObject),\n };\n },\n});\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AAyBA,MAAM,eAAe;;;;;;;;;;;AAYrB,MAAM,kBAAkB;;;;;;;;AASxB,MAAM,iBAAiB;;AAGvB,SAAS,kBAAkB,MAAsB;AAG/C,KAAI,CAAC,mBAAmB,KAAK,KAAK,CAAE,QAAO;AAE3C,QAAO,IAAI,KAAK;;AAGlB,yCAAe,WAA2B;CACxC,MAAM;CACN,MAAM;EACJ,MAAM;EACN,gBAAgB;EAChB,MAAM,EACJ,aACE,mGACH;EACD,UAAU;GACR,uBACE;GACF,kBAAkB;GAClB,oBACE;GACH;EACD,QAAQ,EAAE;EACX;CACD,gBAAgB,EAAE;CAClB,OAAO,SAAS;EACd,MAAM,MAAM,IAAI,aAAa,QAAQ;;EAGrC,MAAM,iBAAiB,MAAM,QAAQ,IAAI,OAAO,OAAO,GACnD,IAAI,OAAO,OAAO,QACf,UAAU,MAAM,WAAW,IAAI,IAAI,eAAe,KAAK,MAAM,CAC/D,GACD,EAAE;EAEN,SAAS,qBAAqB,OAAe,MAA2B;GACtE,IAAI,SAAS;AAEb,QAAK,MAAM,SAAS,MAAM,MAAM,IAAI,EAAE;IAEpC,MAAM,CAAC,MAAM,UADE,MAAM,MAAM,CAAC,MAAM,MAAM,CAAC,OAAO,QAAQ;AAQxD,QACE,CAAC,QACD,CAAC,UACD,CAAC,gBAAgB,KAAK,KAAK,IAC3B,CAAC,aAAa,KAAK,OAAO,EAC1B;AACA,eAAU,MAAM,SAAS;AACzB;;IAKF,MAAM,UAAU,MAAM,QAAQ,KAAK,GAAG,KAAK;IAC3C,MAAM,eAAe,MAAM,QAAQ,QAAQ,QAAQ;AACnD,QAAI,iBAAiB,IAAI;AACvB,eAAU,MAAM,SAAS;AACzB;;IAGF,MAAM,QAAQ,SAAS;IACvB,MAAM,MAAM,QAAQ,OAAO;IAG3B,MAAM,cAAc,SAAS;AAE7B,YAAQ,OAAO;KACb;KACA,WAAW;KACX,MAAM;MACJ,UAAU;MACV,WAAW,eAAe,SACtB,KAAK,eAAe,KAAK,KAAK,CAAC,KAC/B;MACJ,UAAU,kBAAkB,KAAK;MAClC;KACD,SAAS,CAGP,GAAG,eAAe,KAAK,WAAW;MAChC,WAAW;MACX,MAAM;OAAE,UAAU;OAAQ;OAAO;MACjC,MAAM,UACJ,qBACE,OACA,MACA,CAAC;OAAE;OAAO;OAAK,aAAa;OAAO,CAAC,EACpC,QAAQ,WACT;MACJ,EAAE,EACH;MACE,WAAW;MACX,MAAM,EAAE,UAAU,QAAQ;MAC1B,MAAM,UACJ,qBACE,OACA,MACA,CAAC;OAAE,OAAO;OAAa;OAAK,aAAa;OAAI,CAAC,EAC9C,QAAQ,WACT;MACJ,CACF;KACF,CAAC;AAEF,cAAU,MAAM,SAAS;;;EAI7B,SAAS,kBAAkB,MAAiC;AAC1D,OAAI,CAAC,IAAI,cAAc,KAAK,CAAE;AAE9B,QAAK,MAAM,QAAQ,KAAK,YAAY;AAClC,QAAI,KAAK,SAAS,cAAc,KAAK,SAAU;AAG/C,QADY,WAAW,KAAK,IAAI,KACpB,aAAc;IAE1B,MAAM,MAAM,eAAe,KAAK,MAAM;AACtC,QAAI,KAAK;AACP,0BAAqB,KAAK,KAAK,MAAM;AACrC;;AAGF,QAAI,KAAK,MAAM,SAAS,mBACtB,MAAK,MAAM,aAAa,KAAK,MAAM,YAAY;AAC7C,SAAI,UAAU,SAAS,WAAY;KACnC,MAAM,WAAW,eAAe,UAAU,MAAM;AAChD,SAAI,SACF,sBAAqB,UAAU,UAAU,MAAM;;;;AAOzD,SAAO;GACL,kBAAkB,MAAM;AACtB,QAAI,YAAY,KAAK;;GAEvB,GAAG,qBAAqB,kBAAkB;GAC3C;;CAEJ,CAAC"}
1
+ {"version":3,"file":"no-raw-transition-duration.js","names":[],"sources":["../../src/rules/no-raw-transition-duration.ts"],"sourcesContent":["import type { TSESTree } from '@typescript-eslint/utils';\nimport { createRule } from '../create-rule.js';\nimport { TastyContext, styleObjectListeners } from '../context.js';\nimport { getKeyName, getStringValue } from '../utils.js';\nimport { replaceInStringValue } from '../fix-utils.js';\nimport { scanTokens, type ScannedToken } from '../parsers/utils.js';\nimport {\n getDurationTokens,\n hasRawTimeInExpression,\n isNonzeroTime,\n isTimeLiteral,\n} from './motion-duration-utils.js';\n\ntype MessageIds =\n | 'rawTransitionDuration'\n | 'rawTransitionDurationExpression'\n | 'useDurationToken'\n | 'useDefaultDuration';\n\n/**\n * A plausible transition name: a semantic name, a CSS property, or a `$$name` /\n * `##name` / `--name` custom-property reference.\n *\n * This excludes malformed groups from duration advice.\n */\nconst TRANSITION_NAME = /^(?:\\$\\$|##|--)?[a-zA-Z][\\w-]*$/;\nconst NON_TRANSITION_NAMES = new Set([\n 'none',\n 'inherit',\n 'initial',\n 'unset',\n 'revert',\n 'revert-layer',\n]);\n\n/** The default timing tasty substitutes for a group whose duration is omitted. */\nfunction defaultTimingHint(name: string): string {\n // The parser turns $$name into --name and ##name into --name-color before\n // the transition handler selects its per-property timing variable.\n const property = name.startsWith('$$')\n ? name.slice(2)\n : name.startsWith('##')\n ? `${name.slice(2)}-color`\n : name.startsWith('--')\n ? name.slice(2)\n : name;\n return `$${property}-transition (falling back to $transition)`;\n}\n\nexport default createRule<[], MessageIds>({\n name: 'no-raw-transition-duration',\n meta: {\n type: 'suggestion',\n hasSuggestions: true,\n docs: {\n description:\n \"Suggest a duration token, or tasty's default timing, instead of a hardcoded transition duration\",\n },\n messages: {\n rawTransitionDuration:\n \"Transition duration '{{duration}}' is hardcoded. Use a duration token{{available}}, or omit it to inherit {{fallback}}.\",\n rawTransitionDurationExpression:\n \"Transition duration '{{duration}}' contains a hardcoded time. Derive it from a duration token instead.\",\n useDurationToken: \"Replace '{{duration}}' with '{{token}}'\",\n useDefaultDuration:\n \"Remove '{{duration}}' to inherit the default transition timing\",\n },\n schema: [],\n },\n defaultOptions: [],\n create(context) {\n const ctx = new TastyContext(context);\n\n /** `$`-prefixed config tokens whose name reads as a duration. */\n const durationTokens = getDurationTokens(ctx.config.tokens);\n\n function checkGroup(tokens: ScannedToken[], node: TSESTree.Node): void {\n const [name, timing] = tokens;\n\n // Only the second token is the duration slot. A later time value is a\n // delay, for which removing the value has different semantics.\n if (!name || !timing || !TRANSITION_NAME.test(name.value)) return;\n if (NON_TRANSITION_NAMES.has(name.value)) return;\n\n const isLiteral = isTimeLiteral(timing.value);\n if (isLiteral && !isNonzeroTime(timing.value)) return;\n if (!isLiteral && !hasRawTimeInExpression(timing.value)) return;\n\n if (!isLiteral) {\n // Replacing or dropping a whole expression can change its behavior in\n // ways the rule cannot infer, so offer a report without a quick fix.\n context.report({\n node,\n messageId: 'rawTransitionDurationExpression',\n data: { duration: timing.value },\n });\n return;\n }\n\n const start = timing.offset;\n const end = start + timing.value.length;\n // Removal swallows the separating whitespace too, so dropping the\n // duration does not leave `fill ease-in` behind.\n const removeStart = name.offset + name.value.length;\n\n context.report({\n node,\n messageId: 'rawTransitionDuration',\n data: {\n duration: timing.value,\n available: durationTokens.length\n ? ` (${durationTokens.join(', ')})`\n : '',\n fallback: defaultTimingHint(name.value),\n },\n suggest: [\n // Config tokens first: naming the project's own duration is better\n // advice than deleting the author's intent, when one exists.\n ...durationTokens.map((token) => ({\n messageId: 'useDurationToken' as const,\n data: { duration: timing.value, token },\n fix: (fixer: Parameters<typeof replaceInStringValue>[0]) =>\n replaceInStringValue(\n fixer,\n node,\n [{ start, end, replacement: token }],\n context.sourceCode,\n ),\n })),\n {\n messageId: 'useDefaultDuration' as const,\n data: { duration: timing.value },\n fix: (fixer: Parameters<typeof replaceInStringValue>[0]) =>\n replaceInStringValue(\n fixer,\n node,\n [{ start: removeStart, end, replacement: '' }],\n context.sourceCode,\n ),\n },\n ],\n });\n }\n\n function checkTransitionValue(value: string, node: TSESTree.Node): void {\n let group: ScannedToken[] = [];\n for (const token of scanTokens(value)) {\n if (token.value) group.push(token);\n if (token.isComma) {\n checkGroup(group, node);\n group = [];\n }\n }\n checkGroup(group, node);\n }\n\n function isLocalTastyComponent(node: TSESTree.Node, name: string): boolean {\n let scope: ReturnType<typeof context.sourceCode.getScope> | null =\n context.sourceCode.getScope(node);\n while (scope) {\n const variable = scope.set.get(name);\n if (variable) {\n return variable.defs.some((definition) => {\n if (definition.type !== 'Variable') return false;\n const declaration = definition.node;\n return (\n declaration.id.type === 'Identifier' &&\n declaration.parent.type === 'VariableDeclaration' &&\n declaration.parent.kind === 'const' &&\n declaration.init?.type === 'CallExpression' &&\n ctx.isTastyCall(declaration.init)?.importedName === 'tasty'\n );\n });\n }\n scope = scope.upper;\n }\n return false;\n }\n\n function handleStyleObject(node: TSESTree.ObjectExpression) {\n if (!ctx.isStyleObject(node)) return;\n\n for (const prop of node.properties) {\n if (prop.type !== 'Property' || prop.computed) continue;\n\n const key = getKeyName(prop.key);\n if (key !== 'transition') continue;\n\n const str = getStringValue(prop.value);\n if (str) {\n checkTransitionValue(str, prop.value);\n continue;\n }\n\n if (\n prop.value.type === 'ObjectExpression' &&\n ctx.isStateMap(prop.value, prop)\n ) {\n for (const stateProp of prop.value.properties) {\n if (stateProp.type !== 'Property') continue;\n const stateStr = getStringValue(stateProp.value);\n if (stateStr) {\n checkTransitionValue(stateStr, stateProp.value);\n }\n }\n }\n }\n }\n\n return {\n ImportDeclaration(node) {\n ctx.trackImport(node);\n },\n JSXAttribute(node) {\n if (\n node.name.type !== 'JSXIdentifier' ||\n node.name.name !== 'transition'\n ) {\n return;\n }\n const opening = node.parent;\n if (\n opening?.type !== 'JSXOpeningElement' ||\n opening.name.type !== 'JSXIdentifier' ||\n !isLocalTastyComponent(node, opening.name.name)\n ) {\n return;\n }\n\n const valueNode =\n node.value?.type === 'JSXExpressionContainer'\n ? node.value.expression\n : node.value;\n if (!valueNode) return;\n const value = getStringValue(valueNode);\n if (value) checkTransitionValue(value, valueNode);\n },\n ...styleObjectListeners(handleStyleObject),\n };\n },\n});\n"],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tenphi/eslint-plugin-tasty",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "description": "ESLint plugin for validating tasty() and tastyStatic() style objects",
5
5
  "type": "module",
6
6
  "exports": {