@tenphi/eslint-plugin-tasty 1.5.0 → 1.5.2

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,11 +192,11 @@ for typing shared signatures.
192
192
  | `tasty/require-default-state` | error | Missing default (`''`) or fallback floor (`_`) key in state mappings (skipped for extending calls) |
193
193
  | `tasty/no-own-at-root` | warn | `@own()` used at root level where it is redundant |
194
194
  | `tasty/valid-default-state-order` | warn | Misplaced default (`''`) or redundant `''` when only `_` is present |
195
- | `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`) |
195
+ | `tasty/prefer-shorthand-property` | warn | Use Tasty shorthand instead of native CSS properties (`backgroundColor` → `fill`, `paddingBlock` → `blockPadding`, etc.). Report-only warnings explain how to override one part through a token, with concrete min/max dimension examples. Extension warnings point at the base component and are skipped over bases you cannot edit (see `ownedSources`; [details](docs/rules/prefer-shorthand-property.md)) |
196
196
  | `tasty/no-raw-color-values` | warn | Raw hex/rgb/`okhsl`/`okhst`/`oklch`/named colors instead of `#color` tokens |
197
197
  | `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 |
198
198
  | `tasty/no-runtime-styles-mutation` | warn | JavaScript variables, calls, conditionals, computed keys, or interpolated templates in Tasty style values; use states and tokens instead |
199
- | `tasty/no-style-spread` | warn | Object or array spreads inside runtime Tasty styles; suppress an intentional spread on its line with an ESLint comment and a reason |
199
+ | `tasty/no-style-spread` | warn | Object or array spreads inside runtime Tasty styles; recommends `mergeStyles` for multiple root spreads; intentional spreads need a per-line suppression and reason |
200
200
  | `tasty/consistent-token-usage` | warn | Raw px values when custom units or tokens exist |
201
201
  | `tasty/prefer-auto-calc` | warn | `calc(...)` instead of Tasty auto-calc `(...)` |
202
202
  | `tasty/prefer-state-negation` | warn | Top-level `:not(...)` in state keys instead of the `!` prefix (autofixable; nested CSS selectors stay intact) |
@@ -259,6 +259,27 @@ const Card = tasty({
259
259
  });
260
260
  ```
261
261
 
262
+ When a root style object contains two or more spreads, the rule reports one
263
+ composition warning at the first spread and recommends `mergeStyles`:
264
+
265
+ ```ts
266
+ import { mergeStyles, type Styles } from '@tenphi/tasty';
267
+
268
+ // Warning: shallow composition can discard sub-element properties and states.
269
+ const finalStyles: Styles = { ...outerStyles, ...styles };
270
+
271
+ // Use Tasty's merge semantics, in the same override order.
272
+ const mergedStyles: Styles = mergeStyles(outerStyles, styles);
273
+ ```
274
+
275
+ For example, spreading `Label: { fill: '#accent' }` over
276
+ `Label: { padding: '2x', fill: '#surface' }` loses `padding`; `mergeStyles`
277
+ preserves it. State maps without a default key extend inherited entries, while
278
+ maps with a `''` default deliberately replace them. This is a behavioral choice,
279
+ so the rule provides no automatic rewrite. If shallow replacement is intentional,
280
+ suppress the warning at the first spread with a reason. Single spreads and spreads
281
+ inside state maps, sub-elements or arrays retain the ordinary spread warning.
282
+
262
283
  `tastyStatic()` still rejects spreads and dynamic expressions as errors because
263
284
  its build-time extractor cannot evaluate them. An ESLint suppression does not
264
285
  make a spread valid there. To make warnings fail CI until addressed or explicitly
@@ -7,13 +7,17 @@ var no_style_spread_default = createRule({
7
7
  name: "no-style-spread",
8
8
  meta: {
9
9
  type: "suggestion",
10
- docs: { description: "Warn about spreads inside Tasty style objects" },
11
- messages: { styleSpread: "List Tasty style properties explicitly instead of spreading. If this spread is intentional, suppress this warning on this line with an ESLint comment and a reason." },
10
+ docs: { description: "Warn about style spreads and recommend mergeStyles for composition" },
11
+ messages: {
12
+ styleSpread: "List Tasty style properties explicitly instead of spreading. If this spread is intentional, suppress this warning on this line with an ESLint comment and a reason.",
13
+ preferMergeStyles: "Use mergeStyles(...) to compose Tasty style objects. Object spread is shallow and can discard sub-element properties and state-map entries. If shallow replacement is intentional, suppress this warning on this line with a reason."
14
+ },
12
15
  schema: []
13
16
  },
14
17
  defaultOptions: [],
15
18
  create(context) {
16
19
  const ctx = new TastyContext(context);
20
+ const visited = /* @__PURE__ */ new WeakSet();
17
21
  function checkValue(node) {
18
22
  const value = unwrapExpression(node);
19
23
  if (value.type === "ObjectExpression") for (const prop of value.properties) if (prop.type === "SpreadElement") context.report({
@@ -31,15 +35,31 @@ var no_style_spread_default = createRule({
31
35
  }
32
36
  }
33
37
  function handleStyleObject(node) {
38
+ if (visited.has(node)) return;
34
39
  const styleCtx = ctx.getStyleContext(node);
35
40
  if (!styleCtx || styleCtx.isStaticCall) return;
36
41
  if (ctx.getRootStyleObject(node) !== node) return;
37
- checkValue(node);
42
+ visited.add(node);
43
+ const spreads = node.properties.filter((prop) => prop.type === "SpreadElement");
44
+ if (spreads.length < 2) {
45
+ checkValue(node);
46
+ return;
47
+ }
48
+ context.report({
49
+ node: spreads[0],
50
+ messageId: "preferMergeStyles"
51
+ });
52
+ for (const prop of node.properties) if (prop.type === "Property") checkValue(prop.value);
38
53
  }
39
54
  return {
40
55
  ImportDeclaration(node) {
41
56
  ctx.trackImport(node);
42
57
  },
58
+ VariableDeclarator(node) {
59
+ if (!node.init) return;
60
+ const value = unwrapExpression(node.init);
61
+ if (value.type === "ObjectExpression") handleStyleObject(value);
62
+ },
43
63
  ...styleObjectListeners(handleStyleObject)
44
64
  };
45
65
  }
@@ -1 +1 @@
1
- {"version":3,"file":"no-style-spread.js","names":[],"sources":["../../src/rules/no-style-spread.ts"],"sourcesContent":["import type { TSESTree } from '@typescript-eslint/utils';\nimport { createRule } from '../create-rule.js';\nimport { TastyContext, styleObjectListeners } from '../context.js';\nimport { unwrapExpression } from '../utils.js';\n\ntype MessageIds = 'styleSpread';\n\nexport default createRule<[], MessageIds>({\n name: 'no-style-spread',\n meta: {\n type: 'suggestion',\n docs: {\n description: 'Warn about spreads inside Tasty style objects',\n },\n messages: {\n styleSpread:\n 'List Tasty style properties explicitly instead of spreading. If this spread is intentional, suppress this warning on this line with an ESLint comment and a reason.',\n },\n schema: [],\n },\n defaultOptions: [],\n create(context) {\n const ctx = new TastyContext(context);\n\n function checkValue(node: TSESTree.Node): void {\n const value = unwrapExpression(node);\n\n if (value.type === 'ObjectExpression') {\n for (const prop of value.properties) {\n if (prop.type === 'SpreadElement') {\n context.report({ node: prop, messageId: 'styleSpread' });\n } else {\n checkValue(prop.value);\n }\n }\n } else if (value.type === 'ArrayExpression') {\n for (const element of value.elements) {\n if (!element) continue;\n if (element.type === 'SpreadElement') {\n context.report({ node: element, messageId: 'styleSpread' });\n } else {\n checkValue(element);\n }\n }\n }\n }\n\n function handleStyleObject(node: TSESTree.ObjectExpression): void {\n const styleCtx = ctx.getStyleContext(node);\n // tastyStatic rejects spreads during extraction, so its existing error\n // gives the useful diagnostic without a second warning.\n if (!styleCtx || styleCtx.isStaticCall) return;\n if (ctx.getRootStyleObject(node) !== node) return;\n\n checkValue(node);\n }\n\n return {\n ImportDeclaration(node) {\n ctx.trackImport(node);\n },\n ...styleObjectListeners(handleStyleObject),\n };\n },\n});\n"],"mappings":";;;;;AAOA,8BAAe,WAA2B;CACxC,MAAM;CACN,MAAM;EACJ,MAAM;EACN,MAAM,EACJ,aAAa,iDACd;EACD,UAAU,EACR,aACE,uKACH;EACD,QAAQ,EAAE;EACX;CACD,gBAAgB,EAAE;CAClB,OAAO,SAAS;EACd,MAAM,MAAM,IAAI,aAAa,QAAQ;EAErC,SAAS,WAAW,MAA2B;GAC7C,MAAM,QAAQ,iBAAiB,KAAK;AAEpC,OAAI,MAAM,SAAS,mBACjB,MAAK,MAAM,QAAQ,MAAM,WACvB,KAAI,KAAK,SAAS,gBAChB,SAAQ,OAAO;IAAE,MAAM;IAAM,WAAW;IAAe,CAAC;OAExD,YAAW,KAAK,MAAM;YAGjB,MAAM,SAAS,kBACxB,MAAK,MAAM,WAAW,MAAM,UAAU;AACpC,QAAI,CAAC,QAAS;AACd,QAAI,QAAQ,SAAS,gBACnB,SAAQ,OAAO;KAAE,MAAM;KAAS,WAAW;KAAe,CAAC;QAE3D,YAAW,QAAQ;;;EAM3B,SAAS,kBAAkB,MAAuC;GAChE,MAAM,WAAW,IAAI,gBAAgB,KAAK;AAG1C,OAAI,CAAC,YAAY,SAAS,aAAc;AACxC,OAAI,IAAI,mBAAmB,KAAK,KAAK,KAAM;AAE3C,cAAW,KAAK;;AAGlB,SAAO;GACL,kBAAkB,MAAM;AACtB,QAAI,YAAY,KAAK;;GAEvB,GAAG,qBAAqB,kBAAkB;GAC3C;;CAEJ,CAAC"}
1
+ {"version":3,"file":"no-style-spread.js","names":[],"sources":["../../src/rules/no-style-spread.ts"],"sourcesContent":["import type { TSESTree } from '@typescript-eslint/utils';\nimport { createRule } from '../create-rule.js';\nimport { TastyContext, styleObjectListeners } from '../context.js';\nimport { unwrapExpression } from '../utils.js';\n\ntype MessageIds = 'styleSpread' | 'preferMergeStyles';\n\nexport default createRule<[], MessageIds>({\n name: 'no-style-spread',\n meta: {\n type: 'suggestion',\n docs: {\n description:\n 'Warn about style spreads and recommend mergeStyles for composition',\n },\n messages: {\n styleSpread:\n 'List Tasty style properties explicitly instead of spreading. If this spread is intentional, suppress this warning on this line with an ESLint comment and a reason.',\n preferMergeStyles:\n 'Use mergeStyles(...) to compose Tasty style objects. Object spread is shallow and can discard sub-element properties and state-map entries. If shallow replacement is intentional, suppress this warning on this line with a reason.',\n },\n schema: [],\n },\n defaultOptions: [],\n create(context) {\n const ctx = new TastyContext(context);\n const visited = new WeakSet<TSESTree.ObjectExpression>();\n\n function checkValue(node: TSESTree.Node): void {\n const value = unwrapExpression(node);\n\n if (value.type === 'ObjectExpression') {\n for (const prop of value.properties) {\n if (prop.type === 'SpreadElement') {\n context.report({ node: prop, messageId: 'styleSpread' });\n } else {\n checkValue(prop.value);\n }\n }\n } else if (value.type === 'ArrayExpression') {\n for (const element of value.elements) {\n if (!element) continue;\n if (element.type === 'SpreadElement') {\n context.report({ node: element, messageId: 'styleSpread' });\n } else {\n checkValue(element);\n }\n }\n }\n }\n\n function handleStyleObject(node: TSESTree.ObjectExpression): void {\n if (visited.has(node)) return;\n const styleCtx = ctx.getStyleContext(node);\n // tastyStatic rejects spreads during extraction, so its existing error\n // gives the useful diagnostic without a second warning.\n if (!styleCtx || styleCtx.isStaticCall) return;\n if (ctx.getRootStyleObject(node) !== node) return;\n visited.add(node);\n\n const spreads = node.properties.filter(\n (prop): prop is TSESTree.SpreadElement => prop.type === 'SpreadElement',\n );\n if (spreads.length < 2) {\n checkValue(node);\n return;\n }\n\n // Report composition once, at its first spread so the existing per-line\n // suppression convention still works. Nested state maps/arrays are not\n // Styles objects and must keep their ordinary spread diagnostics.\n context.report({ node: spreads[0], messageId: 'preferMergeStyles' });\n for (const prop of node.properties) {\n if (prop.type === 'Property') checkValue(prop.value);\n }\n }\n\n return {\n ImportDeclaration(node) {\n ctx.trackImport(node);\n },\n VariableDeclarator(node) {\n if (!node.init) return;\n // The shared selectors cover only one as/satisfies wrapper. Typed\n // composition can also have nested wrappers or a non-null assertion.\n const value = unwrapExpression(node.init);\n if (value.type === 'ObjectExpression') handleStyleObject(value);\n },\n ...styleObjectListeners(handleStyleObject),\n };\n },\n});\n"],"mappings":";;;;;AAOA,8BAAe,WAA2B;CACxC,MAAM;CACN,MAAM;EACJ,MAAM;EACN,MAAM,EACJ,aACE,sEACH;EACD,UAAU;GACR,aACE;GACF,mBACE;GACH;EACD,QAAQ,EAAE;EACX;CACD,gBAAgB,EAAE;CAClB,OAAO,SAAS;EACd,MAAM,MAAM,IAAI,aAAa,QAAQ;EACrC,MAAM,0BAAU,IAAI,SAAoC;EAExD,SAAS,WAAW,MAA2B;GAC7C,MAAM,QAAQ,iBAAiB,KAAK;AAEpC,OAAI,MAAM,SAAS,mBACjB,MAAK,MAAM,QAAQ,MAAM,WACvB,KAAI,KAAK,SAAS,gBAChB,SAAQ,OAAO;IAAE,MAAM;IAAM,WAAW;IAAe,CAAC;OAExD,YAAW,KAAK,MAAM;YAGjB,MAAM,SAAS,kBACxB,MAAK,MAAM,WAAW,MAAM,UAAU;AACpC,QAAI,CAAC,QAAS;AACd,QAAI,QAAQ,SAAS,gBACnB,SAAQ,OAAO;KAAE,MAAM;KAAS,WAAW;KAAe,CAAC;QAE3D,YAAW,QAAQ;;;EAM3B,SAAS,kBAAkB,MAAuC;AAChE,OAAI,QAAQ,IAAI,KAAK,CAAE;GACvB,MAAM,WAAW,IAAI,gBAAgB,KAAK;AAG1C,OAAI,CAAC,YAAY,SAAS,aAAc;AACxC,OAAI,IAAI,mBAAmB,KAAK,KAAK,KAAM;AAC3C,WAAQ,IAAI,KAAK;GAEjB,MAAM,UAAU,KAAK,WAAW,QAC7B,SAAyC,KAAK,SAAS,gBACzD;AACD,OAAI,QAAQ,SAAS,GAAG;AACtB,eAAW,KAAK;AAChB;;AAMF,WAAQ,OAAO;IAAE,MAAM,QAAQ;IAAI,WAAW;IAAqB,CAAC;AACpE,QAAK,MAAM,QAAQ,KAAK,WACtB,KAAI,KAAK,SAAS,WAAY,YAAW,KAAK,MAAM;;AAIxD,SAAO;GACL,kBAAkB,MAAM;AACtB,QAAI,YAAY,KAAK;;GAEvB,mBAAmB,MAAM;AACvB,QAAI,CAAC,KAAK,KAAM;IAGhB,MAAM,QAAQ,iBAAiB,KAAK,KAAK;AACzC,QAAI,MAAM,SAAS,mBAAoB,mBAAkB,MAAM;;GAEjE,GAAG,qBAAqB,kBAAkB;GAC3C;;CAEJ,CAAC"}
@@ -21,12 +21,31 @@ const CSS_WIDE_KEYWORDS = new Set([
21
21
  * whole typography group, so `preset: 'inherit'` yields a real
22
22
  * `font-family: inherit`. Point at that instead of `font`.
23
23
  */
24
- function shorthandHint(key, prop) {
24
+ function shorthandMapping(key, prop) {
25
25
  const mapping = SHORTHAND_MAPPING[key];
26
26
  if (!mapping) return null;
27
- if (key !== "fontFamily") return mapping.hint;
27
+ if (key !== "fontFamily") return mapping;
28
28
  const value = getStringValue(prop.value)?.trim().toLowerCase();
29
- return value && CSS_WIDE_KEYWORDS.has(value) ? `preset: '${value}'` : mapping.hint;
29
+ return value && CSS_WIDE_KEYWORDS.has(value) ? {
30
+ ...mapping,
31
+ property: "preset",
32
+ hint: `preset: '${value}'`
33
+ } : mapping;
34
+ }
35
+ function tokenOverrideHint(key, property, styleCtx, isSubElement) {
36
+ const usesTokensProp = styleCtx.type === "tasty";
37
+ const original = styleCtx.isExtending ? usesTokensProp ? `base component's` : `base style definition's` : "original";
38
+ const warning = styleCtx.isExtending ? "" : ` Replacing '${property}' can reset its other values.`;
39
+ const defaultHint = usesTokensProp && isSubElement ? " Declare the default on the component root so this sub-element inherits the token." : "";
40
+ const dimension = /^(min|max)(Width|Height|BlockSize|InlineSize)$/.exec(key);
41
+ if (dimension) {
42
+ const token = `$${key.replace(/[A-Z]/g, (char) => `-${char.toLowerCase()}`)}`;
43
+ return `${warning} To override only '${key}', expose a token with a default in the ${original} '${property}' (e.g. ${property}: '${dimension[1] === "min" ? `${token} auto initial` : `initial auto ${token}`}'), keep its other values, and ${usesTokensProp ? `set tokens={{ '${token}': '20x' }}` : `set the '${token.replace("$", "--")}' CSS custom property on the target element to a CSS value (e.g. '160px')`}.${defaultHint}`;
44
+ }
45
+ const override = usesTokensProp ? `set it via the 'tokens' prop` : "set its CSS custom property on the target element";
46
+ if (property === "preset") return `${warning} For independent typography overrides, use the CSS value tokens referenced by a named preset in the ${original} styles, keep its other values, and ${override}. Keep preset names and modifiers static.${defaultHint}`;
47
+ if (key === "scrollbarWidth" || key === "scrollbarGutter") return `${warning} For independent overrides, expose the value as a token with a default in the ${original} '${key}' longhand, keep its other values, and ${override}. '${property}' width and gutter modifiers are static.${defaultHint}`;
48
+ return `${warning} For independent overrides, expose the part you need to change as a token with a default in the ${original} '${property}', keep its other values, and ${override}.${defaultHint}`;
30
49
  }
31
50
  var prefer_shorthand_property_default = createRule({
32
51
  name: "prefer-shorthand-property",
@@ -35,8 +54,8 @@ var prefer_shorthand_property_default = createRule({
35
54
  fixable: "code",
36
55
  docs: { description: "Suggest tasty shorthand when a native CSS property with a tasty alternative is used" },
37
56
  messages: {
38
- preferShorthand: "Prefer tasty shorthand '{{alternative}}' instead of '{{native}}'.",
39
- preferShorthandExtending: "'{{native}}' patches the base component's '{{property}}' from an extension layer. Expose a token in the base's '{{property}}' — e.g. padding: '$v-padding $h-padding' — and set it from here; '{{alternative}}' would replace the whole '{{property}}'."
57
+ preferShorthand: "When defining the complete style, prefer tasty shorthand '{{alternative}}' instead of '{{native}}'.{{overrideHint}}",
58
+ preferShorthandExtending: "'{{native}}' changes one part of the base styles. '{{alternative}}' would replace the whole '{{property}}'.{{overrideHint}}"
40
59
  },
41
60
  schema: []
42
61
  },
@@ -46,13 +65,14 @@ var prefer_shorthand_property_default = createRule({
46
65
  function handleStyleObject(node) {
47
66
  const styleCtx = ctx.getStyleContext(node);
48
67
  if (!styleCtx) return;
68
+ const isSubElement = ctx.isInsideSubElement(node);
49
69
  for (const prop of node.properties) {
50
70
  if (prop.type !== "Property" || prop.computed) continue;
51
71
  const key = getKeyName(prop.key);
52
72
  if (key === null) continue;
53
- const mapping = SHORTHAND_MAPPING[key];
54
- const hint = shorthandHint(key, prop);
55
- if (mapping && hint) {
73
+ const mapping = shorthandMapping(key, prop);
74
+ if (mapping) {
75
+ const hint = mapping.hint;
56
76
  if (styleCtx.isExtending) {
57
77
  if (!ctx.isOwnedComponent(styleCtx.baseComponent)) continue;
58
78
  context.report({
@@ -61,7 +81,8 @@ var prefer_shorthand_property_default = createRule({
61
81
  data: {
62
82
  native: key,
63
83
  alternative: hint,
64
- property: mapping.property
84
+ property: mapping.property,
85
+ overrideHint: tokenOverrideHint(key, mapping.property, styleCtx, isSubElement)
65
86
  }
66
87
  });
67
88
  continue;
@@ -71,7 +92,8 @@ var prefer_shorthand_property_default = createRule({
71
92
  messageId: "preferShorthand",
72
93
  data: {
73
94
  native: key,
74
- alternative: hint
95
+ alternative: hint,
96
+ overrideHint: mapping.safeFix ? "" : tokenOverrideHint(key, mapping.property, styleCtx, isSubElement)
75
97
  },
76
98
  fix(fixer) {
77
99
  if (!mapping.safeFix) return null;
@@ -1 +1 @@
1
- {"version":3,"file":"prefer-shorthand-property.js","names":[],"sources":["../../src/rules/prefer-shorthand-property.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 { SHORTHAND_MAPPING } from '../constants.js';\n\ntype MessageIds = 'preferShorthand' | 'preferShorthandExtending';\n\nconst CSS_WIDE_KEYWORDS = new Set([\n 'inherit',\n 'initial',\n 'unset',\n 'revert',\n 'revert-layer',\n]);\n\n/**\n * `font` resolves to `<value>, var(--font-sans, var(--font-sans-fallback))`,\n * so it cannot express a CSS-wide keyword — `font: 'inherit'` emits\n * `font-family: inherit, …`, which is invalid and drops the inherit.\n *\n * `preset` is the property that handles these: a CSS-wide keyword used as the\n * preset name short-circuits token lookup and is emitted verbatim across the\n * whole typography group, so `preset: 'inherit'` yields a real\n * `font-family: inherit`. Point at that instead of `font`.\n */\nfunction shorthandHint(key: string, prop: TSESTree.Property): string | null {\n const mapping = SHORTHAND_MAPPING[key];\n if (!mapping) return null;\n if (key !== 'fontFamily') return mapping.hint;\n\n const value = getStringValue(prop.value)?.trim().toLowerCase();\n return value && CSS_WIDE_KEYWORDS.has(value)\n ? `preset: '${value}'`\n : mapping.hint;\n}\n\nexport default createRule<[], MessageIds>({\n name: 'prefer-shorthand-property',\n meta: {\n type: 'suggestion',\n fixable: 'code',\n docs: {\n description:\n 'Suggest tasty shorthand when a native CSS property with a tasty alternative is used',\n },\n messages: {\n preferShorthand:\n \"Prefer tasty shorthand '{{alternative}}' instead of '{{native}}'.\",\n preferShorthandExtending:\n \"'{{native}}' patches the base component's '{{property}}' from an extension layer. Expose a token in the base's '{{property}}' — e.g. padding: '$v-padding $h-padding' — and set it from here; '{{alternative}}' would replace the whole '{{property}}'.\",\n },\n schema: [],\n },\n defaultOptions: [],\n create(context) {\n const ctx = new TastyContext(context);\n\n function handleStyleObject(node: TSESTree.ObjectExpression) {\n const styleCtx = ctx.getStyleContext(node);\n if (!styleCtx) 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 === null) continue;\n\n const mapping = SHORTHAND_MAPPING[key];\n const hint = shorthandHint(key, prop);\n if (mapping && hint) {\n // An extension layer merges per key, so renaming the key replaces the\n // base component's whole `mapping.property` instead of patching the\n // one part written here — `paddingTop: '2x'` -> `padding: '2x top'`\n // drops the base's other three edges to 0. The token seam belongs in\n // the base component, which is another file and may not even be the\n // author's to edit, so this stays a report with no fix.\n if (styleCtx.isExtending) {\n // The token seam has to be added to the base component's own\n // definition, so the suggestion is only actionable when that file\n // belongs to this project. Over an imported base — a UI kit, say —\n // the author's remaining options are the longhand they already\n // wrote or a rewrite that would clobber the base, and a warning\n // recommending neither is just noise. `ownedSources` opts a\n // published-from-this-monorepo design system back in.\n if (!ctx.isOwnedComponent(styleCtx.baseComponent)) continue;\n\n context.report({\n node: prop.key,\n messageId: 'preferShorthandExtending',\n data: {\n native: key,\n alternative: hint,\n property: mapping.property,\n },\n });\n\n continue;\n }\n\n context.report({\n node: prop.key,\n messageId: 'preferShorthand',\n data: { native: key, alternative: hint },\n fix(fixer) {\n // Only auto-fix the carry-over subset where the value passes\n // through unchanged (e.g. backgroundColor → fill, borderRadius →\n // radius). Directional / min/max / border-* renames change\n // semantics and stay report-only.\n if (!mapping.safeFix) return null;\n return fixer.replaceText(prop.key, mapping.property);\n },\n });\n }\n }\n }\n\n return {\n ImportDeclaration(node) {\n ctx.trackImport(node);\n },\n ...styleObjectListeners(handleStyleObject),\n };\n },\n});\n"],"mappings":";;;;;;AAQA,MAAM,oBAAoB,IAAI,IAAI;CAChC;CACA;CACA;CACA;CACA;CACD,CAAC;;;;;;;;;;;AAYF,SAAS,cAAc,KAAa,MAAwC;CAC1E,MAAM,UAAU,kBAAkB;AAClC,KAAI,CAAC,QAAS,QAAO;AACrB,KAAI,QAAQ,aAAc,QAAO,QAAQ;CAEzC,MAAM,QAAQ,eAAe,KAAK,MAAM,EAAE,MAAM,CAAC,aAAa;AAC9D,QAAO,SAAS,kBAAkB,IAAI,MAAM,GACxC,YAAY,MAAM,KAClB,QAAQ;;AAGd,wCAAe,WAA2B;CACxC,MAAM;CACN,MAAM;EACJ,MAAM;EACN,SAAS;EACT,MAAM,EACJ,aACE,uFACH;EACD,UAAU;GACR,iBACE;GACF,0BACE;GACH;EACD,QAAQ,EAAE;EACX;CACD,gBAAgB,EAAE;CAClB,OAAO,SAAS;EACd,MAAM,MAAM,IAAI,aAAa,QAAQ;EAErC,SAAS,kBAAkB,MAAiC;GAC1D,MAAM,WAAW,IAAI,gBAAgB,KAAK;AAC1C,OAAI,CAAC,SAAU;AAEf,QAAK,MAAM,QAAQ,KAAK,YAAY;AAClC,QAAI,KAAK,SAAS,cAAc,KAAK,SAAU;IAE/C,MAAM,MAAM,WAAW,KAAK,IAAI;AAChC,QAAI,QAAQ,KAAM;IAElB,MAAM,UAAU,kBAAkB;IAClC,MAAM,OAAO,cAAc,KAAK,KAAK;AACrC,QAAI,WAAW,MAAM;AAOnB,SAAI,SAAS,aAAa;AAQxB,UAAI,CAAC,IAAI,iBAAiB,SAAS,cAAc,CAAE;AAEnD,cAAQ,OAAO;OACb,MAAM,KAAK;OACX,WAAW;OACX,MAAM;QACJ,QAAQ;QACR,aAAa;QACb,UAAU,QAAQ;QACnB;OACF,CAAC;AAEF;;AAGF,aAAQ,OAAO;MACb,MAAM,KAAK;MACX,WAAW;MACX,MAAM;OAAE,QAAQ;OAAK,aAAa;OAAM;MACxC,IAAI,OAAO;AAKT,WAAI,CAAC,QAAQ,QAAS,QAAO;AAC7B,cAAO,MAAM,YAAY,KAAK,KAAK,QAAQ,SAAS;;MAEvD,CAAC;;;;AAKR,SAAO;GACL,kBAAkB,MAAM;AACtB,QAAI,YAAY,KAAK;;GAEvB,GAAG,qBAAqB,kBAAkB;GAC3C;;CAEJ,CAAC"}
1
+ {"version":3,"file":"prefer-shorthand-property.js","names":[],"sources":["../../src/rules/prefer-shorthand-property.ts"],"sourcesContent":["import type { TSESTree } from '@typescript-eslint/utils';\nimport { createRule } from '../create-rule.js';\nimport { TastyContext, styleObjectListeners } from '../context.js';\nimport type { StyleContext } from '../context.js';\nimport { getKeyName, getStringValue } from '../utils.js';\nimport { SHORTHAND_MAPPING } from '../constants.js';\n\ntype MessageIds = 'preferShorthand' | 'preferShorthandExtending';\n\nconst CSS_WIDE_KEYWORDS = new Set([\n 'inherit',\n 'initial',\n 'unset',\n 'revert',\n 'revert-layer',\n]);\n\n/**\n * `font` resolves to `<value>, var(--font-sans, var(--font-sans-fallback))`,\n * so it cannot express a CSS-wide keyword — `font: 'inherit'` emits\n * `font-family: inherit, …`, which is invalid and drops the inherit.\n *\n * `preset` is the property that handles these: a CSS-wide keyword used as the\n * preset name short-circuits token lookup and is emitted verbatim across the\n * whole typography group, so `preset: 'inherit'` yields a real\n * `font-family: inherit`. Point at that instead of `font`.\n */\nfunction shorthandMapping(key: string, prop: TSESTree.Property) {\n const mapping = SHORTHAND_MAPPING[key];\n if (!mapping) return null;\n if (key !== 'fontFamily') return mapping;\n\n const value = getStringValue(prop.value)?.trim().toLowerCase();\n return value && CSS_WIDE_KEYWORDS.has(value)\n ? { ...mapping, property: 'preset', hint: `preset: '${value}'` }\n : mapping;\n}\n\nfunction tokenOverrideHint(\n key: string,\n property: string,\n styleCtx: StyleContext,\n isSubElement: boolean,\n): string {\n const usesTokensProp = styleCtx.type === 'tasty';\n const original = styleCtx.isExtending\n ? usesTokensProp\n ? `base component's`\n : `base style definition's`\n : 'original';\n const warning = styleCtx.isExtending\n ? ''\n : ` Replacing '${property}' can reset its other values.`;\n const defaultHint =\n usesTokensProp && isSubElement\n ? ' Declare the default on the component root so this sub-element inherits the token.'\n : '';\n const dimension = /^(min|max)(Width|Height|BlockSize|InlineSize)$/.exec(key);\n\n if (dimension) {\n const token = `$${key.replace(/[A-Z]/g, (char) => `-${char.toLowerCase()}`)}`;\n const values =\n dimension[1] === 'min'\n ? `${token} auto initial`\n : `initial auto ${token}`;\n const override = usesTokensProp\n ? `set tokens={{ '${token}': '20x' }}`\n : `set the '${token.replace('$', '--')}' CSS custom property on the target element to a CSS value (e.g. '160px')`;\n\n return `${warning} To override only '${key}', expose a token with a default in the ${original} '${property}' (e.g. ${property}: '${values}'), keep its other values, and ${override}.${defaultHint}`;\n }\n\n const override = usesTokensProp\n ? `set it via the 'tokens' prop`\n : 'set its CSS custom property on the target element';\n\n if (property === 'preset') {\n return `${warning} For independent typography overrides, use the CSS value tokens referenced by a named preset in the ${original} styles, keep its other values, and ${override}. Keep preset names and modifiers static.${defaultHint}`;\n }\n\n if (key === 'scrollbarWidth' || key === 'scrollbarGutter') {\n return `${warning} For independent overrides, expose the value as a token with a default in the ${original} '${key}' longhand, keep its other values, and ${override}. '${property}' width and gutter modifiers are static.${defaultHint}`;\n }\n\n return `${warning} For independent overrides, expose the part you need to change as a token with a default in the ${original} '${property}', keep its other values, and ${override}.${defaultHint}`;\n}\n\nexport default createRule<[], MessageIds>({\n name: 'prefer-shorthand-property',\n meta: {\n type: 'suggestion',\n fixable: 'code',\n docs: {\n description:\n 'Suggest tasty shorthand when a native CSS property with a tasty alternative is used',\n },\n messages: {\n preferShorthand:\n \"When defining the complete style, prefer tasty shorthand '{{alternative}}' instead of '{{native}}'.{{overrideHint}}\",\n preferShorthandExtending:\n \"'{{native}}' changes one part of the base styles. '{{alternative}}' would replace the whole '{{property}}'.{{overrideHint}}\",\n },\n schema: [],\n },\n defaultOptions: [],\n create(context) {\n const ctx = new TastyContext(context);\n\n function handleStyleObject(node: TSESTree.ObjectExpression) {\n const styleCtx = ctx.getStyleContext(node);\n if (!styleCtx) return;\n const isSubElement = ctx.isInsideSubElement(node);\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 === null) continue;\n\n const mapping = shorthandMapping(key, prop);\n if (mapping) {\n const hint = mapping.hint;\n // An extension layer merges per key, so renaming the key replaces the\n // base component's whole `mapping.property` instead of patching the\n // one part written here — `paddingTop: '2x'` -> `padding: '2x top'`\n // drops the base's other three edges to 0. The token seam belongs in\n // the base component, which is another file and may not even be the\n // author's to edit, so this stays a report with no fix.\n if (styleCtx.isExtending) {\n // The token seam has to be added to the base component's own\n // definition, so the suggestion is only actionable when that file\n // belongs to this project. Over an imported base — a UI kit, say —\n // the author's remaining options are the longhand they already\n // wrote or a rewrite that would clobber the base, and a warning\n // recommending neither is just noise. `ownedSources` opts a\n // published-from-this-monorepo design system back in.\n if (!ctx.isOwnedComponent(styleCtx.baseComponent)) continue;\n\n context.report({\n node: prop.key,\n messageId: 'preferShorthandExtending',\n data: {\n native: key,\n alternative: hint,\n property: mapping.property,\n overrideHint: tokenOverrideHint(\n key,\n mapping.property,\n styleCtx,\n isSubElement,\n ),\n },\n });\n\n continue;\n }\n\n context.report({\n node: prop.key,\n messageId: 'preferShorthand',\n data: {\n native: key,\n alternative: hint,\n overrideHint: mapping.safeFix\n ? ''\n : tokenOverrideHint(\n key,\n mapping.property,\n styleCtx,\n isSubElement,\n ),\n },\n fix(fixer) {\n // Only auto-fix the carry-over subset where the value passes\n // through unchanged (e.g. backgroundColor → fill, borderRadius →\n // radius). Directional / min/max / border-* renames change\n // semantics and stay report-only.\n if (!mapping.safeFix) return null;\n return fixer.replaceText(prop.key, mapping.property);\n },\n });\n }\n }\n }\n\n return {\n ImportDeclaration(node) {\n ctx.trackImport(node);\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.5.0",
3
+ "version": "1.5.2",
4
4
  "description": "ESLint plugin for validating tasty() and tastyStatic() style objects",
5
5
  "type": "module",
6
6
  "exports": {