Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changepacks/changepack_log_lint_style_positions.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"changes": {
"packages/eslint-plugin/package.json": "Patch"
},
"note": "no-duplicate-value, no-useless-responsive, no-useless-tailing-nulls, no-typography-token-prefix and prefer-media-shorthand only report and fix values the build reads as styles: style props of Box, Flex and the other style components and the arguments of css, globalCss and keyframes, through style objects, responsive arrays, conditions and spreads. Arrays and keys in props the component passes through (data-*, aria-*, event handlers, HTML attributes, props, styleVars), in arguments of other functions and under imports/fontFaces/params are left alone, where autofix used to rewrite them; styles of a component nested in another's prop are checked too. css-utils-literal-only reads a css() or keyframes() result held in a const of any scope as static, as the build does",
"date": "2026-09-30T00:00:00.000Z"
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ The build knows:
- literals, and constants: imports and module-level `const`s
- what those compute through exact built-ins (`String`, `Number`, `JSON`, string and array methods, `Math.max`, `Math.round`, ... — not `Math.random`, `Math.sin` or `Math.pow`) and through functions this file declares that only compute
- what StyleX functions give: `defineVars()` variables, `keyframes()` names, `firstThatWorks()`
- the class a devup-ui `css()` and the name a `keyframes()` give, held in a module-level `const` (`const fade = keyframes({ ... }); css({ animationName: fade })`), also through the package imported whole (`Devup.keyframes`)
- the class a devup-ui `css()` and the name a `keyframes()` give, held in a `const` of any scope (`const fade = keyframes({ ... }); css({ animationName: fade })`, also inside a component), also through the package imported whole (`Devup.keyframes`)

The build inlines constants, folds `Math` and runs the file's own functions at build time. It never runs another module's code, so calling an imported function is reported.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -135,12 +135,36 @@ describe.each(['css' /* 'globalCss', 'keyframes'*/])(
code: `import { css, keyframes as kf, globalCss } from "@devup-ui/react";\nimport * as Devup from "@devup-ui/react";\nconst fade = kf({ from: { opacity: 0 } });\nconst spin = Devup.keyframes\`from { rotate: 0deg; }\`;\nconst base = css({ color: 'red' });\nconst tagged = css\`color: blue;\`;\ncss({ animationName: fade, animation: \`\${spin} 1s\`, selectors: { [\`.\${base} &\`]: { m: 1 } } });\nglobalCss({ body: { animationName: fade } });\nkf({ from: { opacity: 0 }, to: { content: \`"\${tagged}"\` } });`,
filename: 'src/app/page.tsx',
},
{
code: `import { css, keyframes } from "@devup-ui/react";\nexport function C() { const fade = keyframes({ from: { opacity: 0 } }); const spin = keyframes\`from { rotate: 0deg; }\`; return css({ animationName: fade, animation: \`\${spin} 1s\` }); }`,
filename: 'src/app/page.tsx',
},
{
code: `import * as stylex from "@stylexjs/stylex";\nimport sx, { create, defineVars as vars, props } from "@stylexjs/stylex";\nconst colors = stylex.defineVars({ c: 'red' });\nconst named = vars({ c: 'blue' });\nconst fade = stylex.keyframes({ from: { opacity: 0 } });\nconst styles = stylex.create({ a: { color: colors.c, animationName: fade, width: stylex.firstThatWorks('1px', 'auto') }, b: (w) => ({ width: w }) });\ncreate({ a: { color: named.c } });\nsx.create({ a: { color: 'red' } });\nstylex.createTheme(colors, { c: 'green' });\nstylex.props(styles.a, on);\nprops(on);`,
filename: 'src/app/page.tsx',
},
],
invalid: [
...[
[
`export function C() { let fade = keyframes({ from: { opacity: 0 } }); return css({ animationName: fade }); }`,
1,
],
[
`export function C(v) { const fade = keyframes({ from: { opacity: v } }); return css({ animationName: fade }); }`,
2,
],
[
`export function C() { const fade = other({ from: { opacity: 0 } }); return css({ animationName: fade }); }`,
1,
],
].map(([code, count]) => ({
code: `import { css, keyframes } from "@devup-ui/react";\n${code}`,
filename: 'src/app/page.tsx',
errors: Array.from({ length: Number(count) }, () => ({
messageId: 'cssUtilsLiteralOnly' as const,
})),
})),
...[
[`let fade = keyframes({ from: { opacity: 0 } });`, 1],
[`const fade = keyframes({ from: { opacity: v } });`, 2],
Expand Down
15 changes: 13 additions & 2 deletions packages/eslint-plugin/src/rules/css-utils-literal-only/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -628,6 +628,14 @@ class Values {
private readonly givesStyleName: (callee: TSESTree.Node) => boolean,
) {}

/** Whether `init` is a call of `css()` or `keyframes()`, whose result the build writes in place of a `const` holding it in any scope */
private holdsStyleName(init: TSESTree.Expression): boolean {
return init.type === AST_NODE_TYPES.TaggedTemplateExpression
? this.givesStyleName(init.tag)
: init.type === AST_NODE_TYPES.CallExpression &&
this.givesStyleName(init.callee)
}

/** Whether reading member `name` of `object` gives the same on every engine and page */
private exactMember(
object: TSESTree.Node,
Expand Down Expand Up @@ -766,9 +774,12 @@ class Values {
if (
definition.type !== 'Variable' ||
definition.parent.kind !== 'const' ||
!['module', 'global'].includes(variable.scope.type) ||
definition.node.id.type !== AST_NODE_TYPES.Identifier ||
!definition.node.init
!definition.node.init ||
!(
['module', 'global'].includes(variable.scope.type) ||
this.holdsStyleName(definition.node.init)
)
)
return false
seen.add(name)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ The rule will not trigger for:
- Arrays used with other libraries
- Non-literal values
- Arrays that are part of member expressions
- Arrays the build does not read as styles: in props the component passes through (`data-*`, `aria-*`, event handlers, HTML attributes, `props`, `styleVars`), in arguments of other functions, and under `imports`/`fontFaces`/`params`

## Auto-fixable

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,24 @@ describe('no-duplicate-value rule', () => {
code: 'import { Box } from "@devup-ui/react";\n<Box w={[call(), null, null, 3]} />',
filename: 'src/app/page.tsx',
},
...[
'<Box data-values={[5, 5]} />',
'<Box aria-x={[5, 5]} />',
'<Box onPick={() => pick([5, 5])} />',
'<Box props={{ items: [5, 5] }} />',
'<Box styleVars={{ a: [5, 5] }} />',
'<Box w={pick([5, 5])} />',
'<Box xlink:href={[5, 5]} />',
'<ThemeScript x={[5, 5]} />',
'<Devup.Other w={[5, 5]} />',
'getTheme([5, 5])',
'Devup.getTheme([5, 5])',
'globalCss({ imports: ["a.css", "a.css"] })',
'css({ [[5, 5]]: 1 })',
].map((use) => ({
code: `import { Box, ThemeScript, getTheme, globalCss, css } from "@devup-ui/react";\nimport * as Devup from "@devup-ui/react";\n${use}`,
filename: 'src/app/page.tsx',
})),
],
invalid: [
{
Expand Down Expand Up @@ -99,6 +117,37 @@ describe('no-duplicate-value rule', () => {
},
],
},
...[
['<Box w={on ? [1, 1] : 2} />', '<Box w={on ? [1, null] : 2} />'],
['<Box w={on && [1, 1]} />', '<Box w={on && [1, null]} />'],
['<Box {...{ w: [1, 1] }} />', '<Box {...{ w: [1, null] }} />'],
['<Box w={[1, 1] as any} />', '<Box w={[1, null] as any} />'],
[
'<Box w={[1, 1] satisfies number[]} />',
'<Box w={[1, null] satisfies number[]} />',
],
['<Box w={[1, 1]!} />', '<Box w={[1, null]!} />'],
['<Box _hover={{ w: [1, 1] }} />', '<Box _hover={{ w: [1, null] }} />'],
['<Devup.Box w={[1, 1]} />', '<Devup.Box w={[1, null]} />'],
['Devup.css({ w: [1, 1] })', 'Devup.css({ w: [1, null] })'],
['css(base, { w: [1, 1] })', 'css(base, { w: [1, null] })'],
['css({ ...{ w: [1, 1] } })', 'css({ ...{ w: [1, null] } })'],
].map(([use, fixed]) => ({
code: `import { Box, css } from "@devup-ui/react";\nimport * as Devup from "@devup-ui/react";\n${use}`,
output: `import { Box, css } from "@devup-ui/react";\nimport * as Devup from "@devup-ui/react";\n${fixed}`,
filename: 'src/app/page.tsx',
errors: [{ messageId: 'duplicateValue' as const }],
})),
{
code: 'import { Box } from "@devup-ui/react";\n<Box icon={<Box w={[1, 1]} />} m={[2, 2]} />',
output:
'import { Box } from "@devup-ui/react";\n<Box icon={<Box w={[1, null]} />} m={[2, null]} />',
filename: 'src/app/page.tsx',
errors: [
{ messageId: 'duplicateValue' },
{ messageId: 'duplicateValue' },
],
},
],
})
})
30 changes: 3 additions & 27 deletions packages/eslint-plugin/src/rules/no-duplicate-value/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
import type { RuleContext } from '@typescript-eslint/utils/ts-eslint'

import { ImportStorage } from '../../utils/import-storage'
import { styleValueRoot } from '../../utils/style-position'

const createRule = ESLintUtils.RuleCreator(
(name) =>
Expand Down Expand Up @@ -70,38 +71,13 @@ export const noDuplicateValue = createRule({
},
create(context) {
const importStorage = new ImportStorage()
let devupContext:
TSESTree.CallExpression | TSESTree.JSXOpeningElement | null = null
return {
ImportDeclaration(node) {
importStorage.addImportByDeclaration(node)
},
CallExpression(node) {
if (
importStorage.checkContextType(node) === 'UTIL' &&
node.arguments.length === 1 &&
node.arguments[0].type === AST_NODE_TYPES.ObjectExpression
) {
devupContext = node
}
},
'CallExpression:exit'(node) {
if (devupContext === node) {
devupContext = null
}
},
JSXOpeningElement(node) {
if (importStorage.checkContextType(node) === 'COMPONENT') {
devupContext = node
}
},
'JSXOpeningElement:exit'(node) {
if (devupContext === node) {
devupContext = null
}
},
ArrayExpression(node) {
if (devupContext) checkDuplicateValue(node, context)
if (styleValueRoot(node, importStorage))
checkDuplicateValue(node, context)
},
}
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@ preset that does not exist, so no style is applied.

The rule checks string values of `typography` on Devup UI components and
utilities, including values inside responsive arrays, conditionals, and
selector objects.
selector objects. A `typography` key the build does not read as a style — in a
prop the component passes through (`data-*`, `props`, ...) or in an argument of
another function — is not checked.

### Examples

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,12 @@ describe('no-typography-token-prefix rule', () => {
{ code: `${imports}css({ typography: 1 })` },
{ code: `import { Box } from "other";\n<Box typography="$heading" />` },
{ code: `const a = { typography: '$heading' }` },
{ code: `${imports}<Box data-config={{ typography: '$heading' }} />` },
{ code: `${imports}<Box props={{ typography: '$heading' }} />` },
{ code: `${imports}css({ w: pick({ typography: '$heading' }) })` },
{
code: `import { setTheme } from "@devup-ui/react";\nsetTheme({ typography: '$heading' })`,
},
],
invalid: [
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {

import { ImportStorage } from '../../utils/import-storage'
import { propertyKeyName } from '../../utils/property-key-name'
import { styleValueRoot } from '../../utils/style-position'

const createRule = ESLintUtils.RuleCreator(
(name) =>
Expand Down Expand Up @@ -53,34 +54,16 @@ export const noTypographyTokenPrefix = createRule({
},
create(context) {
const importStorage = new ImportStorage()
let devupContext:
TSESTree.CallExpression | TSESTree.JSXOpeningElement | null = null
return {
ImportDeclaration(node) {
importStorage.addImportByDeclaration(node)
},
CallExpression(node) {
if (!devupContext && importStorage.checkContextType(node) === 'UTIL') {
devupContext = node
}
},
'CallExpression:exit'(node) {
if (devupContext === node) devupContext = null
},
JSXOpeningElement(node) {
if (importStorage.checkContextType(node) === 'COMPONENT') {
devupContext = node
}
},
'JSXOpeningElement:exit'(node) {
if (devupContext === node) devupContext = null
},
Literal(node) {
if (
!devupContext ||
typeof node.value !== 'string' ||
!node.value.startsWith('$') ||
!isTypographyValue(node)
!isTypographyValue(node) ||
!styleValueRoot(node, importStorage)
)
return
const name = node.value.slice(1)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ The rule will not trigger for:
- Empty arrays (e.g., `[]`)
- Arrays used with other libraries
- Non-array values
- Arrays the build does not read as styles: in props the component passes through (`data-*`, `aria-*`, event handlers, HTML attributes, `props`, `styleVars`), in arguments of other functions, and under `imports`/`fontFaces`/`params`

## Auto-fixable

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -89,8 +89,29 @@ describe('no-useless-responsive rule', () => {
code: 'import { globalCss } from "@devup-ui/react";\nglobalCss({ imports: [{"url": "@devup-ui/react/css/global.css"}] })',
filename: 'src/app/page.tsx',
},
...[
'<Box data-x={[1]} />',
'<Box props={{ items: [1] }} />',
'<Box w={pick([1])} />',
'<ThemeScript x={[1]} />',
'css({ w: pick([1]) })',
'globalCss({ fontFaces: [{ fontFamily: "a" }] })',
].map((use) => ({
code: `import { Box, ThemeScript, css, globalCss } from "@devup-ui/react";\n${use}`,
filename: 'src/app/page.tsx',
})),
],
invalid: [
{
code: 'import { Box } from "@devup-ui/react";\n<Box icon={<Box w={[1]} />} m={[2]} />',
output:
'import { Box } from "@devup-ui/react";\n<Box icon={<Box w={1} />} m={2} />',
filename: 'src/app/page.tsx',
errors: [
{ messageId: 'uselessResponsive' },
{ messageId: 'uselessResponsive' },
],
},
{
code: 'import { Box } from "@devup-ui/react";\n<Box w={[1]} />',
output: 'import { Box } from "@devup-ui/react";\n<Box w={1} />',
Expand Down
41 changes: 4 additions & 37 deletions packages/eslint-plugin/src/rules/no-useless-responsive/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
import type { RuleContext } from '@typescript-eslint/utils/ts-eslint'

import { ImportStorage } from '../../utils/import-storage'
import { styleValueRoot } from '../../utils/style-position'

const createRule = ESLintUtils.RuleCreator(
(name) =>
Expand Down Expand Up @@ -63,52 +64,18 @@ export const noUselessResponsive = createRule({
},
create(context) {
const importStorage = new ImportStorage()
let devupContext:
TSESTree.CallExpression | TSESTree.JSXOpeningElement | null = null
return {
ImportDeclaration(node) {
importStorage.addImportByDeclaration(node)
},
CallExpression(node) {
if (
importStorage.checkContextType(node) === 'UTIL' &&
node.arguments.length === 1 &&
node.arguments[0].type === AST_NODE_TYPES.ObjectExpression
) {
devupContext = node
}
},
'CallExpression:exit'(node) {
if (devupContext === node) {
devupContext = null
}
},
JSXOpeningElement(node) {
if (importStorage.checkContextType(node) === 'COMPONENT') {
devupContext = node
}
},
'JSXOpeningElement:exit'(node) {
if (devupContext === node) {
devupContext = null
}
},
Property(node) {
if (
devupContext &&
node.key.type === AST_NODE_TYPES.Identifier &&
['imports', 'params', 'fontFaces'].includes(node.key.name)
) {
devupContext = null
}
},
ArrayExpression(node) {
if (devupContext)
const root = styleValueRoot(node, importStorage)
if (root)
checkUselessResponsive(
node,
context.sourceCode
.getAncestors(node)
.slice(context.sourceCode.getAncestors(devupContext).length),
.slice(context.sourceCode.getAncestors(root).length),
context,
)
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ The rule will not trigger for:
- Arrays with null values in the middle
- Arrays used with other libraries
- Arrays that are part of member expressions
- Arrays the build does not read as styles: in props the component passes through (`data-*`, `aria-*`, event handlers, HTML attributes, `props`, `styleVars`), in arguments of other functions, and under `imports`/`fontFaces`/`params`

## Auto-fixable

Expand Down
Loading
Loading