Skip to content
Merged
7 changes: 7 additions & 0 deletions .changepacks/changepack_log_eslint_alias_rules.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"changes": {
"packages/eslint-plugin/package.json": "Patch"
},
"note": "ESLint rules now check exactly what the build reads. The array, typography, media and styleOrder rules and css-utils-literal-only follow the import aliases the build compiles by default (@emotion/react, @emotion/styled, styled-components, @vanilla-extract/css) and the rules of styled (styled.div({...}), styled(\"div\", {...}), styled(Base)({...}, {...}); not .attrs()), createGlobalStyle({...}) and the styles of Emotion Global, and check and fix a module-level const object that only styles read. Vanilla-extract stylesheets (.css.ts/.css.js) are no longer read as Devup UI. New rule no-runtime-read reports css/keyframes/styled/Box read as a value (\"`X` is read at runtime, where it does not exist\"), and css-utils-literal-only reports globals that do not exist where a .css.ts is evaluated (window, document, process, ...). No new build errors.",
"date": "2026-10-01T00:00:00.000Z"
}
7 changes: 7 additions & 0 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -62,4 +62,11 @@ export default [
ignores: ['**/*.md'],
},
...devupUi.configs.recommended,
// the snippets of this page are .css.ts stylesheets, which the build runs as they are
{
files: ['apps/landing/src/app/(detail)/docs/migration/vanilla-extract/**'],
rules: {
'@devup-ui/css-utils-literal-only': 'off',
},
},
]
29 changes: 29 additions & 0 deletions packages/eslint-plugin/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# @devup-ui/eslint-plugin

ESLint rules that check code the way the Devup UI build reads it. A rule reports and fixes only what the build reads as styles, and reports what the build rejects before the build does.

## Usage

```js
// eslint.config.mjs
import devupUi from '@devup-ui/eslint-plugin'

export default [...devupUi.configs.recommended]
```

## Rules

| Rule | Checks | Fix |
| ------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --- |
| [`css-utils-literal-only`](src/rules/css-utils-literal-only/README.md) | values of `css`, `globalCss`, `keyframes`, `createGlobalStyle` and `<Global styles>` are known at build time, and `.css.ts` stylesheets read no global that does not exist there | no |
| [`no-runtime-read`](src/rules/no-runtime-read/README.md) | what the build compiles away (`css`, `keyframes`, `styled`, `Box`...) is read only where it is called or rendered | no |
| [`no-duplicate-value`](src/rules/no-duplicate-value/README.md) | consecutive duplicate values of a responsive array | yes |
| [`no-useless-responsive`](src/rules/no-useless-responsive/README.md) | responsive arrays with a single value | yes |
| [`no-useless-tailing-nulls`](src/rules/no-useless-tailing-nulls/README.md) | trailing `null`s of a responsive array | yes |
| [`no-typography-token-prefix`](src/rules/no-typography-token-prefix/README.md) | `$` before a `typography` key | yes |
| [`prefer-media-shorthand`](src/rules/prefer-media-shorthand/README.md) | `_media` entries and `@media` keys that have a shorthand prop | yes |
| [`style-order-range`](src/rules/style-order-range/README.md) | `styleOrder` is a number from 1 to 254 | no |

## What counts as a style

The rules follow the build: the style props of Devup UI components, the arguments of `css`, `globalCss`, `keyframes` and `createGlobalStyle`, and the same through the import aliases the build compiles by default: `@emotion/react`, `@emotion/styled`, `styled-components` and `@vanilla-extract/css` (`styled`, `Global`, `style`, `globalStyle`). Props the component passes through (`data-*`, `aria-*`, handlers, `props`, `styleVars`), `.attrs()` arguments and plain data elsewhere are left alone. Vanilla-extract stylesheets (`.css.ts`, `.css.js`) are run as they are by the build, so the style rules skip them.
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ exports[`recommended export recommended config 1`] = `
},
"messages": {
"cssUtilsLiteralOnly": "CSS utils should only be used with values known at build time: literals, constants, or what exact built-ins and the functions of this file compute from them.",
"missingGlobal": "\`{{name}}\` does not exist where the build evaluates a stylesheet (\`.css.ts\`, \`.css.js\`).",
},
"schema": [],
"type": "problem",
Expand All @@ -39,6 +40,22 @@ exports[`recommended export recommended config 1`] = `
},
"name": "no-duplicate-value",
},
"no-runtime-read": {
"create": [Function: create],
"defaultOptions": [],
"meta": {
"docs": {
"description": "Disallow reading what the build compiles away, anywhere but where it is called or rendered.",
"url": "https://github.com/dev-five-git/devup-ui/tree/main/packages/eslint-plugin/src/rules/no-runtime-read",
},
"messages": {
"noRuntimeRead": "\`{{name}}\` is read at runtime, where it does not exist: the build compiles it only where it is called or rendered.",
},
"schema": [],
"type": "problem",
},
"name": "no-runtime-read",
},
"no-typography-token-prefix": {
"create": [Function: create],
"defaultOptions": [],
Expand Down Expand Up @@ -130,6 +147,7 @@ exports[`recommended export recommended config 1`] = `
"rules": {
"@devup-ui/css-utils-literal-only": "error",
"@devup-ui/no-duplicate-value": "error",
"@devup-ui/no-runtime-read": "error",
"@devup-ui/no-typography-token-prefix": "error",
"@devup-ui/no-useless-responsive": "error",
"@devup-ui/no-useless-tailing-nulls": "error",
Expand Down
3 changes: 3 additions & 0 deletions packages/eslint-plugin/src/configs/recommended.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {
cssUtilsLiteralOnly,
noDuplicateValue,
noRuntimeRead,
noTypographyTokenPrefix,
noUselessResponsive,
noUselessTailingNulls,
Expand All @@ -16,6 +17,7 @@ export default [
'no-useless-tailing-nulls': noUselessTailingNulls,
'css-utils-literal-only': cssUtilsLiteralOnly,
'no-duplicate-value': noDuplicateValue,
'no-runtime-read': noRuntimeRead,
'no-useless-responsive': noUselessResponsive,
'style-order-range': styleOrderRange,
'no-typography-token-prefix': noTypographyTokenPrefix,
Expand All @@ -27,6 +29,7 @@ export default [
'@devup-ui/no-useless-tailing-nulls': 'error',
'@devup-ui/css-utils-literal-only': 'error',
'@devup-ui/no-duplicate-value': 'error',
'@devup-ui/no-runtime-read': 'error',
'@devup-ui/no-useless-responsive': 'error',
'@devup-ui/style-order-range': 'error',
'@devup-ui/no-typography-token-prefix': 'error',
Expand Down
1 change: 1 addition & 0 deletions packages/eslint-plugin/src/rules/__tests__/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ describe('export index', () => {
noUselessTailingNulls: expect.any(Object),
cssUtilsLiteralOnly: expect.any(Object),
noDuplicateValue: expect.any(Object),
noRuntimeRead: expect.any(Object),
noUselessResponsive: expect.any(Object),
styleOrderRange: expect.any(Object),
noTypographyTokenPrefix: expect.any(Object),
Expand Down
19 changes: 19 additions & 0 deletions packages/eslint-plugin/src/rules/css-utils-literal-only/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -166,3 +166,22 @@ The rule will not trigger for:
## Why This Rule Exists

CSS utilities in devup-ui compile to static classes at build time, so every value they read must be known then. The rule catches a value the build cannot know before the build does, at the line that reads it.


## Libraries the build reads as Devup UI

The rule checks the same calls through the import aliases the build compiles by default: `css`, `keyframes` and `createGlobalStyle` from `@emotion/react` and `styled-components`, `style` and `globalStyle` from `@vanilla-extract/css` (outside stylesheets), and the `styles` of Emotion's `<Global>`. `styled()` is not checked, as it sets a CSS variable on the element.

A vanilla-extract stylesheet (`.css.ts`, `.css.js`) is run as it is: any computation is fine, but a global that does not exist where the build evaluates it (`window`, `document`, `process`, `setTimeout`, `fetch`, `URL`, ...) fails the build, and the rule reports each read of one, unless a `typeof` guards it.

```ts
// styles.css.ts
import { style } from '@vanilla-extract/css'

// ? `window` does not exist where the build evaluates the stylesheet
export const root = style({ color: window.name })

// ? a `typeof` guard, and any computation over what exists
export const ready = typeof window === 'undefined'
export const wide = style({ width: [1, 2].map((n) => n * 4)[1] })
```
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
import { RuleTester } from '@typescript-eslint/rule-tester'
import { describe } from 'bun:test'

import { cssUtilsLiteralOnly } from '../index'

describe('css-utils-literal-only rule: libraries the build reads as Devup UI', () => {
const ruleTester = new RuleTester({
languageOptions: {
ecmaVersion: 'latest',
parserOptions: { ecmaFeatures: { jsx: true } },
},
})
const filename = 'src/app/page.tsx'
const emotion = `import { css, keyframes, Global } from "@emotion/react";\nimport styled from "@emotion/styled";\n`
const styledComponents = `import styled, { css, keyframes, createGlobalStyle } from "styled-components";\n`
const vanilla = `import { style, globalStyle, keyframes } from "@vanilla-extract/css";\n`
const devup = `import { css, globalCss } from "@devup-ui/react";\n`
const error = { messageId: 'cssUtilsLiteralOnly' as const }
const missing = (name: string) => ({
messageId: 'missingGlobal' as const,
data: { name },
})

ruleTester.run('literal-only', cssUtilsLiteralOnly, {
valid: [
...[
[emotion, 'css({ color: "red", p: [1, 2] })'],
[emotion, 'keyframes({ from: { opacity: 0 } })'],
[emotion, 'css`color: ${"red"};`'],
[
emotion,
'function Component() {\n const fade = keyframes({ from: { opacity: 0 } });\n return css({ animationName: fade })\n}',
],
[
emotion,
'const LIMIT = 4;\n<Global styles={{ body: { p: LIMIT } }} />',
],
[
emotion,
'<Global styles={{ body: { color: "red" } }} data-x={window.name} other={v} />',
],
[emotion, '<Global styles />'],
[emotion, '<Global styles="body {}" />'],
[emotion, '<Global.Sub styles={{ color: v }} />'],
[emotion, '<div styles={{ color: v }} />'],
[emotion, 'styled("div", { color: window.name })'],
[emotion, 'styled.div({ color: v })'],
[styledComponents, 'css({ color: "red" })'],
[styledComponents, 'keyframes({ from: { opacity: 0 } })'],
[styledComponents, 'createGlobalStyle({ body: { color: "red" } })'],
[styledComponents, 'createGlobalStyle`body { color: red; }`'],
[styledComponents, 'styled.div({ color: v })'],
[styledComponents, 'styled.div`color: ${(props) => props.color};`'],
[vanilla, 'style({ color: "red" })'],
[vanilla, 'globalStyle("body", { color: "red" })'],
[devup, 'globalCss({ body: { color: "red" } })'],
[
`import { css } from "@emotion/css";\n`,
'css({ color: window.name })',
],
[`import { css } from "other";\n`, 'css({ color: window.name })'],
[
`import { jsx } from "@emotion/react";\n`,
'jsx({ color: window.name })',
],
].map(([header, source]) => ({ code: header + source, filename })),
...[
'export const root = style({ color: "red" })',
'const size = (n) => `${n * 4}px`;\nexport const root = style({ width: size(4) })',
'const widths = [1, 2].map((n) => n * 4);\nexport const root = style({ width: widths[0] })',
'export const guarded = typeof window === "undefined" ? 1 : 2',
'export const defined = typeof document !== "undefined" && typeof process !== "undefined"',
'const window = { name: "a" };\nexport const root = style({ content: window.name })',
'export const root = style({ content: String(Date.now()) })',
'export const local = (setTimeout) => setTimeout',
'export const x = { window: 1 }.window',
'export const y = globalThis',
'export const z = Math.PI + Symbol.length',
].map((source) => ({
code: vanilla + source,
filename: 'src/styles.css.ts',
})),
{
code: 'export const x = window.name',
filename: 'src/page.tsx',
},
{
code: `${devup}css({ color: "red" })`,
filename: 'src/styles.css.js',
},
],
invalid: [
...[
[emotion, 'css({ color: window.name })'],
[emotion, 'keyframes({ from: { opacity: window.ratio } })'],
[emotion, 'let v = 1;\ncss({ color: v })'],
[emotion, 'function Component(v) {\n return css({ color: v })\n}'],
[emotion, '<Global styles={{ body: { color: window.name } }} />'],
[emotion, 'let v = 1;\n<Global styles={{ body: { p: v } }} />'],
[
emotion,
'let v = 1;\n<Global styles={{ body: { p: v } }} data-x={window.name} />',
],
[styledComponents, 'css({ color: window.name })'],
[styledComponents, 'keyframes({ from: { opacity: window.ratio } })'],
[
styledComponents,
'createGlobalStyle({ body: { color: window.name } })',
],
[
styledComponents,
'let v = 1;\ncreateGlobalStyle`body { color: ${v}; }`',
],
[vanilla, 'style({ color: window.name })'],
[vanilla, 'let v = 1;\nglobalStyle("body", { color: v })'],
[vanilla, 'let v = 1;\nkeyframes({ from: { opacity: v } })'],
[devup, 'let v = 1;\nglobalCss({ body: { color: v } })'],
].map(([header, source]) => ({
code: header + source,
filename,
errors: [error],
})),
{
code:
emotion +
'function Component(v) {\n const fade = keyframes({ from: { opacity: v } });\n return css({ animationName: fade })\n}',
filename,
errors: [error, error],
},
...[
['export const a = window.name', ['window']],
[
'export const a = [document.title, process.env.NODE_ENV, window.name]',
['document', 'process', 'window'],
],
[
'export const root = style({ color: window.name });\nexport const other = style({ color: window.theme })',
['window', 'window'],
],
['setTimeout(() => {}, 1)', ['setTimeout']],
['export const a = typeof window.name', ['window']],
['export const a = require("x")', ['require']],
['export const a = Buffer.from("a")', ['Buffer']],
['export const a = new URL("https://a.b")', ['URL']],
].map(([source, names]) => ({
code: vanilla + (source as string),
filename: 'src/styles.css.ts',
errors: (names as string[]).map(missing),
})),
{
code: 'export const a = [localStorage, navigator]',
filename: 'src/styles.css.js',
errors: [missing('localStorage'), missing('navigator')],
},
],
})
})
Loading
Loading