Skip to content
Open
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_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"
}
8 changes: 8 additions & 0 deletions .changepacks/changepack_log_eslint_autofix_scope.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"changes": {
"packages/eslint-plugin/package.json": "Patch",
"bindings/devup-ui-wasm/package.json": "Patch"
},
"note": "style-order-range only checks styleOrder where the build reads it: a prop of a Devup UI component and a top-level key of a style object passed to css/globalCss/keyframes. A styleOrder key in nested data, in a prop value such as an element passed as a prop, or in a call of a function sharing a name with an Object.prototype member is no longer reported. The array, typography and media rules already skip non-style data, and css-utils-literal-only accepts a css()/keyframes() result held in a const inside a function. No new build errors.",
"date": "2026-10-01T00:00:00.000Z"
}
8 changes: 4 additions & 4 deletions bindings/devup-ui-wasm/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1551,7 +1551,7 @@ mod tests {
);

// Test getters
assert!(!output.code().is_empty());
assert_ne!(output.code(), "");
assert_eq!(output.css_file(), Some("devup-ui-0.css".to_string()));
assert_eq!(output.map(), Some("//# sourceMappingURL=test".to_string()));
assert!(output.css().is_some());
Expand Down Expand Up @@ -1951,7 +1951,7 @@ mod tests {

assert!(result.is_ok());
let output = result.unwrap();
assert!(!output.code().is_empty());
assert_ne!(output.code(), "");
assert!(output.map().is_some());
}

Expand All @@ -1975,7 +1975,7 @@ mod tests {

assert!(result.is_ok());
let output = result.unwrap();
assert!(!output.code().is_empty());
assert_ne!(output.code(), "");
assert!(output.map().is_none());
}

Expand All @@ -2000,7 +2000,7 @@ mod tests {

assert!(result.is_err());
if let Err(error) = result {
assert!(!error.is_empty());
assert_ne!(error, "");
}
}

Expand Down
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',
},
},
]
2 changes: 1 addition & 1 deletion libs/css/src/theme_tokens.rs
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ mod tests {
set_typography_keys(vec!["body".to_string(), "title".to_string()]);
assert_eq!(get_typography_keys(), vec!["body", "title"]);
set_typography_keys(vec![]);
assert!(get_typography_keys().is_empty());
assert_eq!(get_typography_keys(), Vec::<String>::new());
}

#[test]
Expand Down
8 changes: 4 additions & 4 deletions libs/extractor/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -836,8 +836,8 @@ mod tests {
alternate: None,
};

assert!(empty.extract().is_empty());
assert!(empty.into_extract().is_empty());
assert_eq!(empty.extract(), vec![]);
assert_eq!(empty.into_extract(), vec![]);
}

#[test]
Expand Down Expand Up @@ -13598,7 +13598,7 @@ globalCss({
);
assert!(result.is_ok());
let output = result.unwrap();
assert!(!output.code.is_empty());
assert_ne!(output.code, "");
}

#[test]
Expand Down Expand Up @@ -18778,7 +18778,7 @@ export const k = styled('div')({ color: SIZE });",
&memory_resolver(CONSTANT_MODULES),
)
.unwrap();
assert!(without_imports.dependencies.is_empty());
assert_eq!(without_imports.dependencies.len(), 0);
let without_constants = extract_with_modules(
"/src/Handler.tsx",
"import { Box } from '@devup-ui/react';\nimport { handler } from './handler';\nexport const a = <Box onClick={handler} color='red' />;",
Expand Down
4 changes: 2 additions & 2 deletions libs/extractor/src/tailwind.rs
Original file line number Diff line number Diff line change
Expand Up @@ -284,7 +284,7 @@ pub struct TailwindClass {
/// non-overlapping) but mutates the existing buffer instead of allocating a new
/// `String`. `needle` must be non-empty.
fn remove_all_substr(haystack: &mut String, needle: &str) {
debug_assert!(!needle.is_empty());
debug_assert_ne!(needle, "");
let mut search_from = 0;
while let Some(rel) = haystack[search_from..].find(needle) {
let at = search_from + rel;
Expand Down Expand Up @@ -3934,7 +3934,7 @@ mod tests {
#[test]
fn test_empty_string() {
let styles = parse_tailwind_to_styles("");
assert!(styles.is_empty());
assert_eq!(styles, vec![]);
}

#[test]
Expand Down
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] })
```
Loading
Loading