diff --git a/.changepacks/changepack_log_eslint_alias_rules.json b/.changepacks/changepack_log_eslint_alias_rules.json new file mode 100644 index 000000000..d5c9a818b --- /dev/null +++ b/.changepacks/changepack_log_eslint_alias_rules.json @@ -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" +} \ No newline at end of file diff --git a/.changepacks/changepack_log_eslint_autofix_scope.json b/.changepacks/changepack_log_eslint_autofix_scope.json new file mode 100644 index 000000000..df1a217d2 --- /dev/null +++ b/.changepacks/changepack_log_eslint_autofix_scope.json @@ -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" +} diff --git a/bindings/devup-ui-wasm/src/lib.rs b/bindings/devup-ui-wasm/src/lib.rs index 4560410f3..0a2347ac2 100644 --- a/bindings/devup-ui-wasm/src/lib.rs +++ b/bindings/devup-ui-wasm/src/lib.rs @@ -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()); @@ -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()); } @@ -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()); } @@ -2000,7 +2000,7 @@ mod tests { assert!(result.is_err()); if let Err(error) = result { - assert!(!error.is_empty()); + assert_ne!(error, ""); } } diff --git a/eslint.config.mjs b/eslint.config.mjs index 1e22b6085..e5314c0b3 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -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', + }, + }, ] diff --git a/libs/css/src/theme_tokens.rs b/libs/css/src/theme_tokens.rs index d8d8fb021..3b2346aed 100644 --- a/libs/css/src/theme_tokens.rs +++ b/libs/css/src/theme_tokens.rs @@ -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::::new()); } #[test] diff --git a/libs/extractor/src/lib.rs b/libs/extractor/src/lib.rs index 68981f36c..96c6ca430 100644 --- a/libs/extractor/src/lib.rs +++ b/libs/extractor/src/lib.rs @@ -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] @@ -13598,7 +13598,7 @@ globalCss({ ); assert!(result.is_ok()); let output = result.unwrap(); - assert!(!output.code.is_empty()); + assert_ne!(output.code, ""); } #[test] @@ -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 = ;", diff --git a/libs/extractor/src/tailwind.rs b/libs/extractor/src/tailwind.rs index 87e9f5178..70643aa7c 100644 --- a/libs/extractor/src/tailwind.rs +++ b/libs/extractor/src/tailwind.rs @@ -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; @@ -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] diff --git a/packages/eslint-plugin/README.md b/packages/eslint-plugin/README.md index e69de29bb..9782f521d 100644 --- a/packages/eslint-plugin/README.md +++ b/packages/eslint-plugin/README.md @@ -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 `` 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. diff --git a/packages/eslint-plugin/src/configs/__tests__/__snapshots__/recommended.test.ts.snap b/packages/eslint-plugin/src/configs/__tests__/__snapshots__/recommended.test.ts.snap index 84a9a7426..e4e932193 100644 --- a/packages/eslint-plugin/src/configs/__tests__/__snapshots__/recommended.test.ts.snap +++ b/packages/eslint-plugin/src/configs/__tests__/__snapshots__/recommended.test.ts.snap @@ -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", @@ -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": [], @@ -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", diff --git a/packages/eslint-plugin/src/configs/recommended.ts b/packages/eslint-plugin/src/configs/recommended.ts index b36e20df4..55530d045 100644 --- a/packages/eslint-plugin/src/configs/recommended.ts +++ b/packages/eslint-plugin/src/configs/recommended.ts @@ -1,6 +1,7 @@ import { cssUtilsLiteralOnly, noDuplicateValue, + noRuntimeRead, noTypographyTokenPrefix, noUselessResponsive, noUselessTailingNulls, @@ -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, @@ -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', diff --git a/packages/eslint-plugin/src/rules/__tests__/index.test.ts b/packages/eslint-plugin/src/rules/__tests__/index.test.ts index 624966813..ec99ed2e0 100644 --- a/packages/eslint-plugin/src/rules/__tests__/index.test.ts +++ b/packages/eslint-plugin/src/rules/__tests__/index.test.ts @@ -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), diff --git a/packages/eslint-plugin/src/rules/css-utils-literal-only/README.md b/packages/eslint-plugin/src/rules/css-utils-literal-only/README.md index c9416e32c..d60893f88 100644 --- a/packages/eslint-plugin/src/rules/css-utils-literal-only/README.md +++ b/packages/eslint-plugin/src/rules/css-utils-literal-only/README.md @@ -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 ``. `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] }) +``` diff --git a/packages/eslint-plugin/src/rules/css-utils-literal-only/__tests__/sources.test.ts b/packages/eslint-plugin/src/rules/css-utils-literal-only/__tests__/sources.test.ts new file mode 100644 index 000000000..8f80cf6d0 --- /dev/null +++ b/packages/eslint-plugin/src/rules/css-utils-literal-only/__tests__/sources.test.ts @@ -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', + ], + [ + emotion, + '', + ], + [emotion, ''], + [emotion, ''], + [emotion, ''], + [emotion, '
'], + [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, ''], + [emotion, 'let v = 1;\n'], + [ + emotion, + 'let v = 1;\n', + ], + [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')], + }, + ], + }) +}) diff --git a/packages/eslint-plugin/src/rules/css-utils-literal-only/index.ts b/packages/eslint-plugin/src/rules/css-utils-literal-only/index.ts index c7f703ca0..cf2a59551 100644 --- a/packages/eslint-plugin/src/rules/css-utils-literal-only/index.ts +++ b/packages/eslint-plugin/src/rules/css-utils-literal-only/index.ts @@ -6,6 +6,7 @@ import { } from '@typescript-eslint/utils' import { ImportStorage } from '../../utils/import-storage' +import { componentName } from '../../utils/style-position' const createRule = ESLintUtils.RuleCreator( (name) => @@ -946,6 +947,93 @@ class Values { } } +/** The globals that exist in a browser or Node but not where the build evaluates a vanilla-extract stylesheet (`.css.ts`, `.css.js`), which runs as it is and fails at the first read of one */ +const MISSING_IN_STYLESHEETS = new Set([ + 'AbortController', + 'Blob', + 'Buffer', + 'CSS', + 'Element', + 'Event', + 'EventTarget', + 'FormData', + 'HTMLElement', + 'Headers', + 'Intl', + 'Request', + 'Response', + 'TextDecoder', + 'TextEncoder', + 'URL', + 'WebAssembly', + 'Worker', + 'XMLHttpRequest', + '__dirname', + '__filename', + 'alert', + 'atob', + 'btoa', + 'clearInterval', + 'clearTimeout', + 'crypto', + 'document', + 'escape', + 'exports', + 'fetch', + 'getComputedStyle', + 'global', + 'history', + 'innerWidth', + 'localStorage', + 'location', + 'matchMedia', + 'module', + 'navigator', + 'performance', + 'process', + 'queueMicrotask', + 'requestAnimationFrame', + 'require', + 'screen', + 'self', + 'sessionStorage', + 'setImmediate', + 'setInterval', + 'setTimeout', + 'structuredClone', + 'unescape', + 'window', +]) + +/** The rule on a stylesheet the build runs whole: a read of a global that does not exist there fails the build, unless a `typeof` guards it */ +function vanillaGlobals( + context: Readonly< + TSESLint.RuleContext<'cssUtilsLiteralOnly' | 'missingGlobal', []> + >, +): TSESLint.RuleListener { + return { + 'Program:exit'() { + for (const reference of context.sourceCode.scopeManager?.globalScope + ?.through ?? []) { + const name = reference.identifier.name + const parent = reference.identifier.parent + if ( + MISSING_IN_STYLESHEETS.has(name) && + !( + parent.type === AST_NODE_TYPES.UnaryExpression && + parent.operator === 'typeof' + ) + ) + context.report({ + node: reference.identifier, + messageId: 'missingGlobal', + data: { name }, + }) + } + }, + } +} + export const cssUtilsLiteralOnly = createRule({ name: 'css-utils-literal-only', defaultOptions: [], @@ -954,6 +1042,8 @@ export const cssUtilsLiteralOnly = createRule({ 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`).', }, type: 'problem', docs: { @@ -962,7 +1052,7 @@ export const cssUtilsLiteralOnly = createRule({ }, }, create(context) { - const importStorage = new ImportStorage() + const importStorage = new ImportStorage(context) const stylexNamespaces = new Set() const stylexNames = new Map() const scopeOf = (node: TSESTree.Node) => context.sourceCode.getScope(node) @@ -981,9 +1071,18 @@ export const cssUtilsLiteralOnly = createRule({ return name === 'css' || name === 'keyframes' } const values = new Values(changes, scopeOf, isStylex, givesStyleName) + /** Whether `attribute` is the `styles` of Emotion's `Global`, which the build reads as rules */ + const isGlobalStyles = (attribute: TSESTree.JSXAttribute) => + attribute.name.type === AST_NODE_TYPES.JSXIdentifier && + attribute.name.name === 'styles' && + attribute.value?.type === AST_NODE_TYPES.JSXExpressionContainer && + componentName(attribute.parent.name, importStorage) === 'Global' /** The style API reading the code visited, and what it takes */ let api: { - node: TSESTree.CallExpression | TSESTree.TaggedTemplateExpression + node: + | TSESTree.CallExpression + | TSESTree.TaggedTemplateExpression + | TSESTree.JSXAttribute takes: Api } | null = null const apiOf = (callee: TSESTree.Node): Api | undefined => { @@ -1016,6 +1115,7 @@ export const cssUtilsLiteralOnly = createRule({ const exit = (node: TSESTree.Node) => { if (api?.node === node) api = null } + if (importStorage.vanilla) return vanillaGlobals(context) return { ImportDeclaration(node) { importStorage.addImportByDeclaration(node) @@ -1039,6 +1139,11 @@ export const cssUtilsLiteralOnly = createRule({ enter(node, node.tag) }, 'TaggedTemplateExpression:exit': exit, + JSXAttribute(node) { + if (api || !isGlobalStyles(node)) return + api = { node, takes: 'rules' } + }, + 'JSXAttribute:exit': exit, Identifier(node) { if (!api || node.name === 'undefined') return @@ -1049,7 +1154,9 @@ export const cssUtilsLiteralOnly = createRule({ if ( api.node.type === AST_NODE_TYPES.TaggedTemplateExpression ? path[0] !== api.node.quasi - : path[0] === api.node.callee || !inValue(api.takes, path) + : api.node.type === AST_NODE_TYPES.JSXAttribute + ? !inValue(api.takes, path) + : path[0] === api.node.callee || !inValue(api.takes, path) ) return const scope = scopeOf(node) diff --git a/packages/eslint-plugin/src/rules/index.ts b/packages/eslint-plugin/src/rules/index.ts index 4f12cc9f4..dea8ce5d4 100644 --- a/packages/eslint-plugin/src/rules/index.ts +++ b/packages/eslint-plugin/src/rules/index.ts @@ -1,5 +1,6 @@ export * from './css-utils-literal-only' export * from './no-duplicate-value' +export * from './no-runtime-read' export * from './no-typography-token-prefix' export * from './no-useless-responsive' export * from './no-useless-tailing-nulls' diff --git a/packages/eslint-plugin/src/rules/no-duplicate-value/README.md b/packages/eslint-plugin/src/rules/no-duplicate-value/README.md index bb2c4e04b..5412a76a7 100644 --- a/packages/eslint-plugin/src/rules/no-duplicate-value/README.md +++ b/packages/eslint-plugin/src/rules/no-duplicate-value/README.md @@ -70,3 +70,15 @@ The rule will not trigger for: ## Auto-fixable This rule is auto-fixable. ESLint will automatically replace consecutive duplicate values with `null` when possible. + + +## Where it applies + +The rule checks what the build reads as styles: + +- the style props of `Box`, `Flex` and the other Devup UI components, and the arguments of `css`, `globalCss`, `keyframes` and `createGlobalStyle` +- 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 `` from `@emotion/react` +- the rules of `styled` from `@emotion/styled` and `styled-components`: `styled.div({ ... })`, `styled('div')({ ... })`, `styled(Base)({ ... }, { ... })` and `styled('div', { ... })`. The arguments of `.attrs()` and `.withConfig()`, and CSS text, are not styles +- a module-level `const` object or array that is not exported, is never changed and is read only as a style (`const s = { w: [1, 1] }; css(s)`), where it is declared + +Nothing is checked in vanilla-extract stylesheets (`.css.ts`, `.css.js`): the build runs them as they are, and an array there is vanilla-extract's, not a responsive array. diff --git a/packages/eslint-plugin/src/rules/no-duplicate-value/index.ts b/packages/eslint-plugin/src/rules/no-duplicate-value/index.ts index 742218679..1e7e70c99 100644 --- a/packages/eslint-plugin/src/rules/no-duplicate-value/index.ts +++ b/packages/eslint-plugin/src/rules/no-duplicate-value/index.ts @@ -70,7 +70,7 @@ export const noDuplicateValue = createRule({ }, }, create(context) { - const importStorage = new ImportStorage() + const importStorage = new ImportStorage(context) return { ImportDeclaration(node) { importStorage.addImportByDeclaration(node) diff --git a/packages/eslint-plugin/src/rules/no-runtime-read/README.md b/packages/eslint-plugin/src/rules/no-runtime-read/README.md new file mode 100644 index 000000000..2549097b7 --- /dev/null +++ b/packages/eslint-plugin/src/rules/no-runtime-read/README.md @@ -0,0 +1,54 @@ +# no-runtime-read + +Disallow reading what the build compiles away anywhere but where it is called or rendered. + +## Rule Details + +The build replaces every call of `css`, `globalCss`, `keyframes` and `createGlobalStyle`, every `styled` component and every `Box`, `Flex`, `Text`... element with plain code and removes their imports. Code that reads one of them as a value is left reading something that no longer exists, and the build rejects it with ``X is read at runtime, where it does not exist``. The rule reports the same reads before the build does, with the same meaning. + +It follows the names the build compiles: the imports from `@devup-ui/react`, and from `@emotion/react`, `@emotion/styled`, `styled-components` and `@vanilla-extract/css` through the import aliases the build compiles by default (`style` and `globalStyle` there are `css` and `globalCss`), and the module-level bindings that only alias them (`const style = css`). + +Allowed reads: + +- a call or a tagged template of a style function: `css({ ... })`, `` css`...` ``, `keyframes({ ... })` +- a `styled` chain that is built into a component: `styled.div({ ... })`, `` styled.div`...` ``, `styled('div')({ ... })`, `styled('div', { ... })`, `styled(Box)({ ... })`, `styled.div.attrs({ ... })({ ... })` +- an element: ``, and `as={Text}` on a Devup UI component +- a type: `typeof css` +- a module-level alias: `const alias = css` + +### Examples + +#### ❌ Incorrect + +```tsx +import { Box, css, styled } from '@devup-ui/react' + +export const runtime = css +export default [Box] +export const bare = styled.div +export const unfinished = styled(Box) +const withMember = css.foo +React.createElement(Box) +function f() { + // Only a module-level alias is removed by the build + const local = css + return local({}) +} +``` + +#### ✅ Correct + +```tsx +import { Box, css, styled } from '@devup-ui/react' + +export const className = css({ p: 1 }) +export const Card = styled.div({ p: 1 }) +export const Wrapped = styled(Box)({ p: 1 }) +const alias = css +export const other = alias({ m: 1 }) +export const element = +``` + +## When Not To Use It + +The rule does nothing in vanilla-extract stylesheets (`.css.ts`, `.css.js`), which the build runs as they are, or for packages the build does not read as Devup UI (`@emotion/css`, for instance). Reads through the package imported whole (`Devup.css`) are left to the build. \ No newline at end of file diff --git a/packages/eslint-plugin/src/rules/no-runtime-read/__tests__/index.test.ts b/packages/eslint-plugin/src/rules/no-runtime-read/__tests__/index.test.ts new file mode 100644 index 000000000..0039da0aa --- /dev/null +++ b/packages/eslint-plugin/src/rules/no-runtime-read/__tests__/index.test.ts @@ -0,0 +1,155 @@ +import { RuleTester } from '@typescript-eslint/rule-tester' +import { describe } from 'bun:test' + +import { noRuntimeRead } from '../index' + +describe('no-runtime-read rule', () => { + const ruleTester = new RuleTester({ + languageOptions: { + ecmaVersion: 'latest', + parserOptions: { ecmaFeatures: { jsx: true } }, + }, + }) + const filename = 'src/app/page.tsx' + const devup = `import { css, globalCss, keyframes, styled, Box, Text } from "@devup-ui/react";\nimport * as Devup from "@devup-ui/react";\n` + const emotion = `import { css, keyframes, Global } from "@emotion/react";\nimport styled from "@emotion/styled";\n` + const styledComponents = `import styled, { css as sc, createGlobalStyle } from "styled-components";\n` + const read = (name: string) => ({ + messageId: 'noRuntimeRead' as const, + data: { name }, + }) + + ruleTester.run('no-runtime-read rule', noRuntimeRead, { + valid: [ + ...[ + 'css({ p: 1 })', + 'css`color: red;`', + 'css(base, { p: 1 })', + 'globalCss({ body: { p: 1 } })', + 'keyframes({ from: { p: 1 } })', + 'styled.div({ p: 1 })', + 'styled.div`color: red;`', + 'styled("div")({ p: 1 })', + 'styled("div")`color: red;`', + 'styled("div", { p: 1 })', + 'styled(Box)({ p: 1 })', + 'styled(Box)`color: red;`', + 'styled(Box, { p: 1 })', + 'styled.div.attrs({ a: 1 })({ p: 1 })', + 'styled.div.withConfig({}).attrs({})`color: red;`', + '', + 'text', + '', + '', + '', + '', + 'const alias = css;\nalias({ p: 1 })', + 'const Aliased = Box;\n', + 'const make = styled;\nmake.div({ p: 1 })', + 'const first = css, second = first;\nsecond({ p: 1 })', + 'const a = css;\nconst b = a;\nb({ p: 1 })', + 'Devup.css({ p: 1 })', + 'const namespace = Devup', + 'const member = Devup.css', + 'type Css = typeof css;\nlet value: typeof Box', + 'function f(css: string) { return css }', + 'function f() { const Box = 1; return Box }', + 'const other = { css: 1 };\nother.css', + 'foo({ css: 1 })', + ].map((code) => ({ code: devup + code, filename })), + { + code: `${emotion}css({ p: 1 });\nstyled.div({});\n;\nkeyframes({})`, + filename, + }, + { + code: `${styledComponents}sc({ p: 1 });\nstyled.div({});\ncreateGlobalStyle\`body {}\``, + filename, + }, + { + code: `import { css } from "other-package";\nexport const x = css;\nconst y = [css]`, + filename, + }, + { + code: `import { css, jsx } from "@emotion/react";\nexport const x = jsx;\ncss({})`, + filename, + }, + { + code: `import { style } from "@vanilla-extract/css";\nexport const x = style({ p: 1 })`, + filename, + }, + { + code: `import { css } from "@devup-ui/react";\nexport const x = css;`, + filename: 'src/styles.css.ts', + }, + { code: 'export const x = css', filename }, + ], + invalid: [ + { + code: `${devup}export const runtime = css`, + filename, + errors: [read('css')], + }, + ...[ + ['export const a = globalCss', 'globalCss'], + ['export const a = [keyframes]', 'keyframes'], + ['export default css', 'css'], + ['export { css }', 'css'], + ['export { css as other }', 'css'], + ['export const a = css.foo', 'css'], + ['export const a = css[0]', 'css'], + ['foo(css)', 'css'], + ['export const a = { css }', 'css'], + ['const alias = css;\nexport { alias }', 'alias'], + ['export const a = cond ? css : null', 'css'], + ['function f() {\n const q = css;\n return q({})\n}', 'css'], + ['let q = css;\nq = 1', 'q'], + ['export const alias = css', 'css'], + ['const a = css;\nfoo(a)', 'a'], + ['String(css)', 'css'], + ['export const a = styled', 'styled'], + ['export const a = styled.div', 'styled'], + ['export const a = styled("div")', 'styled'], + ['export const a = styled.div.attrs({})', 'styled'], + ['export const a = styled.div.withConfig({})', 'styled'], + ['export const a = styled["div"]({ p: 1 })', 'styled'], + ['export const a = styled.div.foo', 'styled'], + ['const base = styled.div;\nexport { base }', 'styled'], + ['export const a = styled`color: red;`', 'styled'], + ['export const a = styled(Box)', 'styled', 'Box'], + ['export const a =
', 'Box'], + ['export const a =
', 'Box'], + ['export const a = ', 'css'], + ['export const a = ', 'Box'], + ['export const a = [Box].length', 'Box'], + ['export const a = React.createElement(Box, {})', 'Box'], + ['export const a = foo(Box)', 'Box'], + ['export const a = styled(foo, Box)', 'Box'], + ['const Aliased = Box;\nfoo(Aliased)', 'Aliased'], + ].map(([code, ...names]) => ({ + code: devup + code, + filename, + errors: names.map(read), + })), + { + code: `${emotion}export const a = [css, styled];\nexport const b = Global`, + filename, + errors: [read('css'), read('styled')], + }, + { + code: `${styledComponents}export const a = sc;\nexport const b = styled.div;\nexport const c = createGlobalStyle`, + filename, + errors: [read('sc'), read('styled'), read('createGlobalStyle')], + }, + { + code: `import { style, globalStyle } from "@vanilla-extract/css";\nexport const a = [style, globalStyle]`, + filename, + errors: [read('style'), read('globalStyle')], + }, + { + code: `import styled, { css } from "styled-components";\nimport * as Emotion from "@emotion/styled";\nexport const a = [Emotion, css]`, + filename, + errors: [read('Emotion'), read('css')], + }, + ], + }) +}) diff --git a/packages/eslint-plugin/src/rules/no-runtime-read/index.ts b/packages/eslint-plugin/src/rules/no-runtime-read/index.ts new file mode 100644 index 000000000..6ded685e9 --- /dev/null +++ b/packages/eslint-plugin/src/rules/no-runtime-read/index.ts @@ -0,0 +1,195 @@ +import { + AST_NODE_TYPES, + ESLintUtils, + type TSESLint, + type TSESTree, +} from '@typescript-eslint/utils' + +import { + ImportStorage, + STYLE_COMPONENTS, + STYLE_FUNCTIONS, +} from '../../utils/import-storage' +import { + componentName, + isStyledFactory, + isStyledReference, +} from '../../utils/style-position' + +const createRule = ESLintUtils.RuleCreator( + (name) => + `https://github.com/dev-five-git/devup-ui/tree/main/packages/eslint-plugin/src/rules/${name}`, +) + +type Variable = TSESLint.Scope.Variable +type Reference = TSESLint.Scope.Reference + +/** What the build compiles away a name to, and so what it reads of that name */ +type Kind = 'component' | 'function' | 'styled' + +function kindOf(name: string): Kind | undefined { + if (STYLE_COMPONENTS.has(name)) return 'component' + if (STYLE_FUNCTIONS.has(name)) return 'function' + return name === 'styled' ? 'styled' : undefined +} + +type ChainLink = + TSESTree.Identifier | TSESTree.MemberExpression | TSESTree.CallExpression + +/** Whether the chain `styled.div`, `styled(tag)`, `.attrs()` ending at `node` is built into a component: called with rules or tagged with CSS text */ +function buildsComponent( + node: ChainLink, + importStorage: ImportStorage, +): boolean { + const parent = node.parent + switch (parent.type) { + case AST_NODE_TYPES.MemberExpression: + return ( + parent.object === node && + !parent.computed && + buildsComponent(parent, importStorage) + ) + case AST_NODE_TYPES.CallExpression: + return ( + parent.callee === node && + (!isStyledFactory(parent, importStorage) || + buildsComponent(parent, importStorage)) + ) + case AST_NODE_TYPES.TaggedTemplateExpression: + return parent.tag === node && node.type !== AST_NODE_TYPES.Identifier + default: + return false + } +} +/** Whether `identifier` is in a type, which the build erases */ +function inType(identifier: TSESTree.Node) { + for ( + let current: TSESTree.Node | undefined = identifier; + current; + current = current.parent + ) + if (current.type === AST_NODE_TYPES.TSTypeQuery) return true + return false +} + +export const noRuntimeRead = createRule({ + name: 'no-runtime-read', + defaultOptions: [], + meta: { + schema: [], + messages: { + noRuntimeRead: + '`{{name}}` is read at runtime, where it does not exist: the build compiles it only where it is called or rendered.', + }, + type: 'problem', + docs: { + description: + 'Disallow reading what the build compiles away, anywhere but where it is called or rendered.', + }, + }, + create(context) { + const importStorage = new ImportStorage(context) + return { + ImportDeclaration(node) { + importStorage.addImportByDeclaration(node) + }, + 'Program:exit'() { + const scopes = context.sourceCode.scopeManager?.scopes ?? [] + const scope = + scopes.find((candidate) => candidate.type === 'module') ?? scopes[0] + if (!scope) return + const pending: [Variable, Kind][] = [] + for (const [local, name] of importStorage.bindings()) { + const kind = kindOf(name) + const variable = scope.set.get(local) + if (kind && variable) pending.push([variable, kind]) + } + const seen = new Set() + for (let next = pending.pop(); next; next = pending.pop()) { + const [variable, kind] = next + if (seen.has(variable)) continue + seen.add(variable) + for (const reference of variable.references) { + if (reference.init) continue + const alias = aliasOf(reference, scope) + if (alias) pending.push([alias, kind]) + else if (!reads(reference, kind)) + context.report({ + node: reference.identifier, + messageId: 'noRuntimeRead', + data: { name: reference.identifier.name }, + }) + } + } + }, + } + + /** The module-level binding that only holds what the build compiles away, which the build removes (`const newCss = css`) */ + function aliasOf( + reference: Reference, + scope: TSESLint.Scope.Scope, + ): Variable | undefined { + const parent = reference.identifier.parent + return parent.type === AST_NODE_TYPES.VariableDeclarator && + parent.init === reference.identifier && + parent.id.type === AST_NODE_TYPES.Identifier && + parent.parent.parent.type === AST_NODE_TYPES.Program + ? scope.set.get(parent.id.name) + : undefined + } + + /** Whether the build compiles the read, or erases it */ + function reads(reference: Reference, kind: Kind): boolean { + const identifier = reference.identifier + if (inType(identifier)) return true + const parent = identifier.parent + switch (kind) { + case 'function': + return ( + (parent.type === AST_NODE_TYPES.CallExpression && + parent.callee === identifier) || + (parent.type === AST_NODE_TYPES.TaggedTemplateExpression && + parent.tag === identifier) + ) + case 'styled': + return ( + identifier.type === AST_NODE_TYPES.Identifier && + buildsComponent(identifier, importStorage) + ) + case 'component': + return rendersOrBases(identifier, parent) + } + } + + function rendersOrBases( + identifier: TSESTree.Node, + parent: TSESTree.Node, + ): boolean { + switch (parent.type) { + case AST_NODE_TYPES.JSXOpeningElement: + case AST_NODE_TYPES.JSXClosingElement: + return parent.name === identifier + case AST_NODE_TYPES.JSXExpressionContainer: { + const attribute = parent.parent + return ( + attribute.type === AST_NODE_TYPES.JSXAttribute && + attribute.name.type === AST_NODE_TYPES.JSXIdentifier && + attribute.name.name === 'as' && + STYLE_COMPONENTS.has( + componentName(attribute.parent.name, importStorage) ?? '', + ) + ) + } + case AST_NODE_TYPES.CallExpression: + return ( + parent.arguments[0] === identifier && + isStyledReference(parent.callee, importStorage) && + (!isStyledFactory(parent, importStorage) || + buildsComponent(parent, importStorage)) + ) + default: + return false + } + } + }, +}) diff --git a/packages/eslint-plugin/src/rules/no-typography-token-prefix/README.md b/packages/eslint-plugin/src/rules/no-typography-token-prefix/README.md index 66e714128..7fb7dde50 100644 --- a/packages/eslint-plugin/src/rules/no-typography-token-prefix/README.md +++ b/packages/eslint-plugin/src/rules/no-typography-token-prefix/README.md @@ -39,3 +39,15 @@ css({ _hover: { typography: 'title' } }) ## Auto-fixable The `$` prefix is removed. + + +## Where it applies + +The rule checks what the build reads as styles: + +- the style props of `Box`, `Flex` and the other Devup UI components, and the arguments of `css`, `globalCss`, `keyframes` and `createGlobalStyle` +- 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 `` from `@emotion/react` +- the rules of `styled` from `@emotion/styled` and `styled-components`: `styled.div({ ... })`, `styled('div')({ ... })`, `styled(Base)({ ... }, { ... })` and `styled('div', { ... })`. The arguments of `.attrs()` and `.withConfig()`, and CSS text, are not styles +- a module-level `const` object or array that is not exported, is never changed and is read only as a style (`const s = { w: [1, 1] }; css(s)`), where it is declared + +Nothing is checked in vanilla-extract stylesheets (`.css.ts`, `.css.js`): the build runs them as they are, and an array there is vanilla-extract's, not a responsive array. diff --git a/packages/eslint-plugin/src/rules/no-typography-token-prefix/index.ts b/packages/eslint-plugin/src/rules/no-typography-token-prefix/index.ts index 45ab1276c..05c92ae4c 100644 --- a/packages/eslint-plugin/src/rules/no-typography-token-prefix/index.ts +++ b/packages/eslint-plugin/src/rules/no-typography-token-prefix/index.ts @@ -53,7 +53,7 @@ export const noTypographyTokenPrefix = createRule({ }, }, create(context) { - const importStorage = new ImportStorage() + const importStorage = new ImportStorage(context) return { ImportDeclaration(node) { importStorage.addImportByDeclaration(node) diff --git a/packages/eslint-plugin/src/rules/no-useless-responsive/README.md b/packages/eslint-plugin/src/rules/no-useless-responsive/README.md index 9dc3ad940..914fd1548 100644 --- a/packages/eslint-plugin/src/rules/no-useless-responsive/README.md +++ b/packages/eslint-plugin/src/rules/no-useless-responsive/README.md @@ -78,3 +78,15 @@ The rule will not trigger for: ## Auto-fixable This rule is auto-fixable. ESLint will automatically convert single-element arrays to their direct values when possible. + + +## Where it applies + +The rule checks what the build reads as styles: + +- the style props of `Box`, `Flex` and the other Devup UI components, and the arguments of `css`, `globalCss`, `keyframes` and `createGlobalStyle` +- 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 `` from `@emotion/react` +- the rules of `styled` from `@emotion/styled` and `styled-components`: `styled.div({ ... })`, `styled('div')({ ... })`, `styled(Base)({ ... }, { ... })` and `styled('div', { ... })`. The arguments of `.attrs()` and `.withConfig()`, and CSS text, are not styles +- a module-level `const` object or array that is not exported, is never changed and is read only as a style (`const s = { w: [1, 1] }; css(s)`), where it is declared + +Nothing is checked in vanilla-extract stylesheets (`.css.ts`, `.css.js`): the build runs them as they are, and an array there is vanilla-extract's, not a responsive array. diff --git a/packages/eslint-plugin/src/rules/no-useless-responsive/index.ts b/packages/eslint-plugin/src/rules/no-useless-responsive/index.ts index 73fed13cf..343fd0c05 100644 --- a/packages/eslint-plugin/src/rules/no-useless-responsive/index.ts +++ b/packages/eslint-plugin/src/rules/no-useless-responsive/index.ts @@ -6,7 +6,7 @@ import { import type { RuleContext } from '@typescript-eslint/utils/ts-eslint' import { ImportStorage } from '../../utils/import-storage' -import { styleValueRoot } from '../../utils/style-position' +import { styleValueSite } from '../../utils/style-position' const createRule = ESLintUtils.RuleCreator( (name) => @@ -63,19 +63,19 @@ export const noUselessResponsive = createRule({ }, }, create(context) { - const importStorage = new ImportStorage() + const importStorage = new ImportStorage(context) return { ImportDeclaration(node) { importStorage.addImportByDeclaration(node) }, ArrayExpression(node) { - const root = styleValueRoot(node, importStorage) - if (root) + const site = styleValueSite(node, importStorage) + if (site) checkUselessResponsive( node, context.sourceCode .getAncestors(node) - .slice(context.sourceCode.getAncestors(root).length), + .slice(context.sourceCode.getAncestors(site.start).length), context, ) }, diff --git a/packages/eslint-plugin/src/rules/no-useless-tailing-nulls/README.md b/packages/eslint-plugin/src/rules/no-useless-tailing-nulls/README.md index ed87222f2..fb443ec0c 100644 --- a/packages/eslint-plugin/src/rules/no-useless-tailing-nulls/README.md +++ b/packages/eslint-plugin/src/rules/no-useless-tailing-nulls/README.md @@ -62,3 +62,15 @@ The rule will not trigger for: ## Auto-fixable This rule is auto-fixable. ESLint will automatically remove trailing null values when possible. + + +## Where it applies + +The rule checks what the build reads as styles: + +- the style props of `Box`, `Flex` and the other Devup UI components, and the arguments of `css`, `globalCss`, `keyframes` and `createGlobalStyle` +- 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 `` from `@emotion/react` +- the rules of `styled` from `@emotion/styled` and `styled-components`: `styled.div({ ... })`, `styled('div')({ ... })`, `styled(Base)({ ... }, { ... })` and `styled('div', { ... })`. The arguments of `.attrs()` and `.withConfig()`, and CSS text, are not styles +- a module-level `const` object or array that is not exported, is never changed and is read only as a style (`const s = { w: [1, 1] }; css(s)`), where it is declared + +Nothing is checked in vanilla-extract stylesheets (`.css.ts`, `.css.js`): the build runs them as they are, and an array there is vanilla-extract's, not a responsive array. diff --git a/packages/eslint-plugin/src/rules/no-useless-tailing-nulls/index.ts b/packages/eslint-plugin/src/rules/no-useless-tailing-nulls/index.ts index c87447748..94ecb4584 100644 --- a/packages/eslint-plugin/src/rules/no-useless-tailing-nulls/index.ts +++ b/packages/eslint-plugin/src/rules/no-useless-tailing-nulls/index.ts @@ -62,7 +62,7 @@ export const noUselessTailingNulls = createRule({ }, }, create(context) { - const importStorage = new ImportStorage() + const importStorage = new ImportStorage(context) return { ImportDeclaration(node) { importStorage.addImportByDeclaration(node) diff --git a/packages/eslint-plugin/src/rules/prefer-media-shorthand/README.md b/packages/eslint-plugin/src/rules/prefer-media-shorthand/README.md index f19a28ce6..3576cfab6 100644 --- a/packages/eslint-plugin/src/rules/prefer-media-shorthand/README.md +++ b/packages/eslint-plugin/src/rules/prefer-media-shorthand/README.md @@ -49,3 +49,15 @@ css({ _media: { '(min-width: 500px)': { p: 1 } } }) A `_media` object holding only the matched query, and a static `'@media …'` key, are rewritten to the shorthand. A `_media` object with other queries is reported without a fix. + + +## Where it applies + +The rule checks what the build reads as styles: + +- the style props of `Box`, `Flex` and the other Devup UI components, and the arguments of `css`, `globalCss`, `keyframes` and `createGlobalStyle` +- 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 `` from `@emotion/react` +- the rules of `styled` from `@emotion/styled` and `styled-components`: `styled.div({ ... })`, `styled('div')({ ... })`, `styled(Base)({ ... }, { ... })` and `styled('div', { ... })`. The arguments of `.attrs()` and `.withConfig()`, and CSS text, are not styles +- a module-level `const` object or array that is not exported, is never changed and is read only as a style (`const s = { w: [1, 1] }; css(s)`), where it is declared + +Nothing is checked in vanilla-extract stylesheets (`.css.ts`, `.css.js`): the build runs them as they are, and an array there is vanilla-extract's, not a responsive array. diff --git a/packages/eslint-plugin/src/rules/prefer-media-shorthand/index.ts b/packages/eslint-plugin/src/rules/prefer-media-shorthand/index.ts index 5a207664c..07e8b80eb 100644 --- a/packages/eslint-plugin/src/rules/prefer-media-shorthand/index.ts +++ b/packages/eslint-plugin/src/rules/prefer-media-shorthand/index.ts @@ -47,7 +47,7 @@ export const preferMediaShorthand = createRule({ }, }, create(context) { - const importStorage = new ImportStorage() + const importStorage = new ImportStorage(context) function checkMediaRecord( owner: TSESTree.Property | TSESTree.JSXAttribute, diff --git a/packages/eslint-plugin/src/rules/style-order-range/README.md b/packages/eslint-plugin/src/rules/style-order-range/README.md index 72c2da968..6058580dc 100644 --- a/packages/eslint-plugin/src/rules/style-order-range/README.md +++ b/packages/eslint-plugin/src/rules/style-order-range/README.md @@ -36,6 +36,15 @@ This rule enforces that the `styleOrder` prop must be a number greater than 0 an
``` +## Where it applies + +The rule 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` or `keyframes`. A `styleOrder` in nested data (`data-*` props, `props`, an element passed as a prop, nested style objects) is not checked. + ## When Not To Use It If you don't use `styleOrder` props or want to allow any value range, you can disable this rule. + + +## Where it applies + +`styleOrder` is read as a prop of a Devup UI component and as a top-level key of a style object passed to `css`, `globalCss`, `keyframes` or `createGlobalStyle`, through the same import aliases the build compiles by default (`@emotion/react`, `@emotion/styled`, `styled-components`, `@vanilla-extract/css`), and in the rules of `styled` (`styled.div({ styleOrder: 1 })`, `styled('div', { styleOrder: 1 })`). It is not read in `.attrs()` or in vanilla-extract stylesheets (`.css.ts`, `.css.js`). diff --git a/packages/eslint-plugin/src/rules/style-order-range/__tests__/index.test.ts b/packages/eslint-plugin/src/rules/style-order-range/__tests__/index.test.ts index dd3fb3ac6..92e0701a8 100644 --- a/packages/eslint-plugin/src/rules/style-order-range/__tests__/index.test.ts +++ b/packages/eslint-plugin/src/rules/style-order-range/__tests__/index.test.ts @@ -73,8 +73,33 @@ describe('style-order-range rule', () => { code: '', filename: 'src/app/page.tsx', }, + { + code: 'import { Box, css } from "@devup-ui/react";\n} props={{ styleOrder: 300 }} />;\ncss({ _hover: { styleOrder: 0 }, w: [{ styleOrder: 0 }] })', + filename: 'src/app/page.tsx', + }, + { + code: 'import { css } from "@devup-ui/react";\nconst other = { styleOrder: 0 };\nfoo({ styleOrder: 0 });\ncss({ [`styleOrder`]: 0, "styleOrder": 1 });\ncss({ w: css({ styleOrder: 1 }), styleOrder: 2 })', + filename: 'src/app/page.tsx', + }, + { + code: 'import { css } from "@devup-ui/react";\nconst { styleOrder = 0 } = css({});\ntoString({ styleOrder: 0 });\nnew Foo({ styleOrder: 0 });', + filename: 'src/app/page.tsx', + }, ], invalid: [ + { + code: 'import { css } from "@devup-ui/react";\ncss(base, { styleOrder: 300 })', + filename: 'src/app/page.tsx', + errors: [{ messageId: 'styleOrderRange' }], + }, + { + code: 'import { Box, css } from "@devup-ui/react";\ncss({ w: css({ styleOrder: 0 }), styleOrder: 300 })', + filename: 'src/app/page.tsx', + errors: [ + { messageId: 'styleOrderRange' }, + { messageId: 'styleOrderRange' }, + ], + }, { code: 'import { Box } from "@devup-ui/react";\n', filename: 'src/app/page.tsx', diff --git a/packages/eslint-plugin/src/rules/style-order-range/index.ts b/packages/eslint-plugin/src/rules/style-order-range/index.ts index 035ec7f26..b5154caa9 100644 --- a/packages/eslint-plugin/src/rules/style-order-range/index.ts +++ b/packages/eslint-plugin/src/rules/style-order-range/index.ts @@ -5,7 +5,8 @@ import { } from '@typescript-eslint/utils' import type { RuleContext } from '@typescript-eslint/utils/ts-eslint' -import { ImportStorage } from '../../utils/import-storage' +import { ImportStorage, STYLE_COMPONENTS } from '../../utils/import-storage' +import { componentName, styleArguments } from '../../utils/style-position' const createRule = ESLintUtils.RuleCreator( (name) => @@ -91,56 +92,38 @@ export const styleOrderRange = createRule({ }, }, create(context) { - const importStorage = new ImportStorage() - let devupContext: - TSESTree.CallExpression | TSESTree.JSXOpeningElement | null = null + const importStorage = new ImportStorage(context) 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 - } - }, Property(node) { + // The build reads `styleOrder` only as a key of a style object handed to the utility itself + const object = node.parent + const call = object.parent if ( - devupContext && node.key.type === AST_NODE_TYPES.Identifier && + !node.computed && node.key.name === 'styleOrder' && node.value.type !== AST_NODE_TYPES.AssignmentPattern && - node.value.type !== AST_NODE_TYPES.TSEmptyBodyFunctionExpression + node.value.type !== AST_NODE_TYPES.TSEmptyBodyFunctionExpression && + object.type === AST_NODE_TYPES.ObjectExpression && + call?.type === AST_NODE_TYPES.CallExpression && + call.arguments.includes(object) && + styleArguments(call, importStorage)?.includes(object) ) { checkStyleOrderRange(node.value, context) } }, - JSXOpeningElement(node) { - if (importStorage.checkContextType(node) === 'COMPONENT') { - devupContext = node - } - }, - 'JSXOpeningElement:exit'(node) { - if (devupContext === node) { - devupContext = null - } - }, JSXAttribute(node) { - if (!devupContext) return - // styleOrder prop만 체크 if ( node.name.type !== AST_NODE_TYPES.JSXIdentifier || node.name.name !== 'styleOrder' || - !node.value + !node.value || + !STYLE_COMPONENTS.has( + componentName(node.parent.name, importStorage) ?? '', + ) ) { return } diff --git a/packages/eslint-plugin/src/utils/__tests__/import-sources.test.ts b/packages/eslint-plugin/src/utils/__tests__/import-sources.test.ts new file mode 100644 index 000000000..ad5371e35 --- /dev/null +++ b/packages/eslint-plugin/src/utils/__tests__/import-sources.test.ts @@ -0,0 +1,119 @@ +import { RuleTester } from '@typescript-eslint/rule-tester' +import type { TSESLint } from '@typescript-eslint/utils' +import { describe, expect, it } from 'bun:test' + +import { ImportStorage, isVanillaExtractFile } from '../import-storage' +import { styleValueSite } from '../style-position' + +const ruleTester = new RuleTester({ + languageOptions: { + ecmaVersion: 'latest', + parserOptions: { ecmaFeatures: { jsx: true } }, + }, +}) + +/** Runs `check` on what the file imports, as a rule collecting its imports sees it, failing the run when `check` throws */ +function inspect( + code: string, + filename: string, + check: ( + storage: ImportStorage, + context: Readonly>, + ) => void, + withContext = true, +) { + const rule: TSESLint.RuleModule<'unused', []> = { + meta: { schema: [], messages: { unused: 'unused' }, type: 'problem' }, + defaultOptions: [], + create(context) { + const storage = new ImportStorage(withContext ? context : undefined) + return { + ImportDeclaration(node) { + storage.addImportByDeclaration(node) + }, + 'Program:exit'() { + check(storage, context) + }, + } + }, + } + ruleTester.run('inspect', rule, { valid: [{ code, filename }], invalid: [] }) +} + +describe('import sources', () => { + inspect( + [ + 'import { css as a, Box as B, unknown as u } from "@devup-ui/react";', + 'import { css as c, keyframes as k, Global, jsx, "css" as d } from "@emotion/react";', + 'import styled, { css as e, createGlobalStyle } from "styled-components";', + 'import * as E from "@emotion/styled";', + 'import { style, globalStyle, other } from "@vanilla-extract/css";', + 'import { css as skipped } from "@emotion/css";', + ].join('\n'), + 'page.tsx', + (storage) => { + expect(Object.fromEntries(storage.bindings())).toEqual({ + a: 'css', + B: 'Box', + u: 'unknown', + c: 'css', + k: 'keyframes', + Global: 'Global', + d: 'css', + styled: 'styled', + e: 'css', + createGlobalStyle: 'createGlobalStyle', + E: 'styled', + style: 'css', + globalStyle: 'globalCss', + }) + }, + ) + + inspect( + [ + 'import Devup from "@devup-ui/react";', + 'import * as Whole from "@devup-ui/react/compat";', + 'import * as Emotion from "@emotion/react";', + 'import styled from "@emotion/styled";', + ].join('\n'), + 'page.tsx', + (storage) => { + expect(storage.isImportObject('Devup')).toBe(true) + expect(storage.isImportObject('Whole')).toBe(true) + expect(storage.isImportObject('Emotion')).toBe(false) + expect(storage.importedName('styled')).toBe('styled') + expect(storage.importedName('Emotion')).toBeUndefined() + }, + ) + + for (const filename of ['a.css.ts', 'a.css.js']) + inspect( + 'import { style } from "@vanilla-extract/css";\nimport { css } from "@devup-ui/react";', + filename, + (storage) => { + expect(storage.vanilla).toBe(true) + expect(storage.bindings()).toEqual([]) + }, + ) + + inspect( + 'import { css } from "@devup-ui/react";\nconst s = { w: [1, 1] };\ncss(s)', + 'page.tsx', + (storage, context) => { + const program = context.sourceCode.ast + expect(storage.declaredVariables(program)).toEqual([]) + const declaration = program.body[1] + if (declaration.type !== 'VariableDeclaration') throw new Error('shape') + const object = declaration.declarations[0].init + if (!object) throw new Error('shape') + expect(styleValueSite(object, storage)).toBeNull() + }, + false, + ) + + it('tells vanilla-extract stylesheets from other files', () => { + expect(isVanillaExtractFile('a.css.tsx')).toBe(false) + expect(isVanillaExtractFile('css.ts')).toBe(false) + }) +}) diff --git a/packages/eslint-plugin/src/utils/__tests__/style-sources.test.ts b/packages/eslint-plugin/src/utils/__tests__/style-sources.test.ts new file mode 100644 index 000000000..5782e45a4 --- /dev/null +++ b/packages/eslint-plugin/src/utils/__tests__/style-sources.test.ts @@ -0,0 +1,389 @@ +import { RuleTester } from '@typescript-eslint/rule-tester' +import { describe } from 'bun:test' + +import { noDuplicateValue } from '../../rules/no-duplicate-value' +import { noTypographyTokenPrefix } from '../../rules/no-typography-token-prefix' +import { noUselessResponsive } from '../../rules/no-useless-responsive' +import { noUselessTailingNulls } from '../../rules/no-useless-tailing-nulls' +import { preferMediaShorthand } from '../../rules/prefer-media-shorthand' +import { styleOrderRange } from '../../rules/style-order-range' + +const ruleTester = new RuleTester({ + languageOptions: { + ecmaVersion: 'latest', + parserOptions: { ecmaFeatures: { jsx: true } }, + }, +}) + +/** What a file imports from each package the build reads as Devup UI's */ +const HEADERS = { + devup: `import { css, globalCss, keyframes, styled, Box } from "@devup-ui/react";\nimport * as Devup from "@devup-ui/react";\n`, + emotion: `import { css, keyframes, Global } from "@emotion/react";\nimport styled from "@emotion/styled";\nimport * as Namespaced from "@emotion/styled";\n`, + styledComponents: `import styled, { css, keyframes, createGlobalStyle } from "styled-components";\n`, + vanilla: `import { style, globalStyle, keyframes } from "@vanilla-extract/css";\n`, +} + +type Header = keyof typeof HEADERS + +const filename = 'src/app/page.tsx' +/** The libraries giving `css`, `keyframes` and a `styled` */ +const LIBRARIES: Header[] = ['devup', 'emotion', 'styledComponents'] + +/** A source, what a rule makes of it and how many reports it gives, in each library whose header is listed */ +type Case = [headers: Header[], source: string, fixed: string, count?: number] + +function invalid(cases: Case[], messageId: M) { + return cases.flatMap(([headers, source, fixed, count = 1]) => + headers.map((header) => ({ + code: HEADERS[header] + source, + output: HEADERS[header] + fixed, + filename, + errors: Array.from({ length: count }, () => ({ messageId })), + })), + ) +} + +function valid(sources: [Header[], string][]) { + return sources.flatMap(([headers, source]) => + headers.map((header) => ({ code: HEADERS[header] + source, filename })), + ) +} + +/** What the build does not read as styles: other packages, and vanilla-extract stylesheets, which it runs as a whole */ +function elsewhere(source: string) { + return [ + { code: `import { jsx } from "@emotion/react";\n${source}`, filename }, + { code: `import styled from "other-package";\n${source}`, filename }, + { code: `import { css } from "@emotion/css";\n${source}`, filename }, + { code: `${HEADERS.vanilla}${source}`, filename: 'src/styles.css.ts' }, + { code: `${HEADERS.devup}${source}`, filename: 'src/styles.css.js' }, + ] +} + +describe('style sources: no-duplicate-value', () => { + const fixes: Case[] = [ + [LIBRARIES, 'css({ w: [1, 1] })', 'css({ w: [1, null] })'], + [ + [...LIBRARIES, 'vanilla'], + 'keyframes({ from: { w: [1, 1] } })', + 'keyframes({ from: { w: [1, null] } })', + ], + [['vanilla'], 'style({ w: [1, 1] })', 'style({ w: [1, null] })'], + [ + ['vanilla'], + 'globalStyle("body", { w: [1, 1] })', + 'globalStyle("body", { w: [1, null] })', + ], + [ + ['devup'], + 'globalCss({ body: { w: [1, 1] } })', + 'globalCss({ body: { w: [1, null] } })', + ], + [ + ['styledComponents'], + 'createGlobalStyle({ body: { w: [1, 1] } })', + 'createGlobalStyle({ body: { w: [1, null] } })', + ], + [ + ['emotion'], + '', + '', + ], + [ + LIBRARIES, + 'styled("div", { w: [1, 1] })', + 'styled("div", { w: [1, null] })', + ], + [LIBRARIES, 'styled.div({ w: [1, 1] })', 'styled.div({ w: [1, null] })'], + [ + LIBRARIES, + 'styled("div")({ w: [1, 1] })', + 'styled("div")({ w: [1, null] })', + ], + [ + LIBRARIES, + 'styled(Base)({ w: [3, 3] }, { p: [4, 4] })', + 'styled(Base)({ w: [3, null] }, { p: [4, null] })', + 2, + ], + [ + LIBRARIES, + 'styled.div.attrs({ w: [1, 1] })({ p: [3, 3] })', + 'styled.div.attrs({ w: [1, 1] })({ p: [3, null] })', + ], + [ + LIBRARIES, + 'styled.div.withConfig({ w: [1, 1] })({ p: [3, 3] })', + 'styled.div.withConfig({ w: [1, 1] })({ p: [3, null] })', + ], + [ + ['devup'], + 'Devup.styled.div({ w: [1, 1] })', + 'Devup.styled.div({ w: [1, null] })', + ], + [ + ['emotion'], + 'Namespaced.div({ w: [1, 1] })', + 'Namespaced.div({ w: [1, null] })', + ], + [ + ['devup'], + 'const s = { w: [1, 1] };\ncss(s)', + 'const s = { w: [1, null] };\ncss(s)', + ], + [ + ['devup'], + 'const arr = [1, 1];\n', + 'const arr = [1, null];\n', + ], + [ + ['devup'], + 'const inner = { w: [1, 1] };\nconst outer = { _hover: inner };\ncss(outer);\nstyled.div(outer)', + 'const inner = { w: [1, null] };\nconst outer = { _hover: inner };\ncss(outer);\nstyled.div(outer)', + ], + [ + ['devup'], + 'const s = { w: [1, 1] };\ncss(s);\n', + 'const s = { w: [1, null] };\ncss(s);\n', + ], + ] + ruleTester.run('aliases and positions', noDuplicateValue, { + valid: [ + ...valid([ + [['devup'], 'const s = { w: [1, 1] };\nconsole.log(s);\ncss(s)'], + [['devup'], 'const s = { w: [1, 1] };\ncss(s);\nexport { s }'], + [['devup'], 'export const s = { w: [1, 1] };\ncss(s)'], + [['devup'], 'const s = { w: [1, 1] };\ncss(s);\ns.w = [2];'], + [['devup'], 'const s = { w: [1, 1] };\ncss(s);\nexport const t = s.w'], + [ + ['devup'], + 'function f() {\n const s = { w: [1, 1] };\n return css(s)\n}', + ], + [['devup'], 'let s = { w: [1, 1] };\ncss(s)'], + [['devup'], 'const s = { w: [1, 1] };\ncss(s);\ns = {}'], + [['devup'], 'const { w } = { w: [1, 1] };\ncss({ w })'], + [['devup'], 'const unused = { w: [1, 1] }'], + [['devup'], 'const a = { w: [1, 1], b };\nconst b = { c: a };\ncss(a)'], + [['devup'], 'const a = [{ w: [1, 1] }, a];\ncss(a)'], + [LIBRARIES, 'styled.div.attrs({ w: [1, 1] })'], + [LIBRARIES, 'styled("div", { w: [1, 1] }, extra)'], + [LIBRARIES, 'styled("div", base, { w: [1, 1] })'], + [LIBRARIES, 'styled("div", [{ w: [1, 1] }])'], + [LIBRARIES, 'styled["div"]({ w: [1, 1] })'], + [LIBRARIES, 'styled`w: ${[1, 1]};`'], + [LIBRARIES, 'styled.div`w: ${[1, 1]};`'], + [LIBRARIES, 'other({ w: [1, 1] })'], + [LIBRARIES, 'unknown.div({ w: [1, 1] })'], + [LIBRARIES, 'styled()'], + [ + ['emotion'], + '', + ], + [['emotion'], ''], + [['emotion'], ''], + [['emotion'], '
'], + [ + ['emotion'], + 'jsx({ w: [1, 1] });\n', + ], + ]), + ...elsewhere('css({ w: [1, 1] });\nstyled.div({ w: [1, 1] })'), + ], + invalid: invalid(fixes, 'duplicateValue'), + }) +}) + +describe('style sources: no-useless-tailing-nulls', () => { + ruleTester.run('aliases and positions', noUselessTailingNulls, { + valid: [ + ...valid([[LIBRARIES, 'styled.div.attrs({ w: [1, null] })']]), + ...elsewhere('css({ w: [1, null] });\nstyled.div({ w: [1, null] })'), + ], + invalid: invalid( + [ + [LIBRARIES, 'css({ w: [1, null] })', 'css({ w: [1] })'], + [['vanilla'], 'style({ w: [1, null] })', 'style({ w: [1] })'], + [['vanilla'], 'style({ w: [1, 2, null] })', 'style({ w: [1, 2] })'], + [ + LIBRARIES, + 'styled.div({ w: [1, 2, null] })', + 'styled.div({ w: [1, 2] })', + ], + [ + ['devup'], + 'const s = { w: [1, null] };\ncss(s)', + 'const s = { w: [1] };\ncss(s)', + ], + [ + ['emotion'], + '', + '', + ], + ], + 'uselessTailingNulls', + ), + }) +}) + +describe('style sources: no-useless-responsive', () => { + ruleTester.run('aliases and positions', noUselessResponsive, { + valid: [ + ...valid([ + [['devup'], 'const s = { w: [[1], 2] };\ncss(s)'], + [['devup'], 'const s = [[1], 2];\ncss(s)'], + [['devup'], 'const s = { w: [1] };\nconsole.log(s);\ncss(s)'], + [['devup'], 'const s = { w: [1] };\ncss(s)\nconst t = s.w'], + ]), + ...elsewhere('css({ w: [1] });\nstyled.div({ w: [1] })'), + ], + invalid: invalid( + [ + [LIBRARIES, 'css({ w: [1] })', 'css({ w: 1 })'], + [['vanilla'], 'style({ w: [1] })', 'style({ w: 1 })'], + [LIBRARIES, 'styled.div({ w: [1] })', 'styled.div({ w: 1 })'], + [ + ['devup'], + 'const s = { w: [1] };\ncss(s)', + 'const s = { w: 1 };\ncss(s)', + ], + [ + ['devup'], + 'const arr = [1];\n', + 'const arr = 1;\n', + ], + [ + ['emotion'], + '', + '', + ], + [ + ['devup'], + 'const s = { w: [1] };\ncss(cond ? s : {})', + 'const s = { w: 1 };\ncss(cond ? s : {})', + ], + ], + 'uselessResponsive', + ), + }) +}) + +describe('style sources: no-typography-token-prefix', () => { + ruleTester.run('aliases and positions', noTypographyTokenPrefix, { + valid: [ + ...valid([ + [['emotion'], ''], + [['devup'], 'const s = { typography: "$a" };\nconsole.log(s)'], + [LIBRARIES, 'styled.div.attrs({ typography: "$a" })'], + ]), + ...elsewhere( + 'css({ typography: "$a" });\nstyled.div({ typography: "$a" })', + ), + ], + invalid: invalid( + [ + [LIBRARIES, 'css({ typography: "$a" })', 'css({ typography: "a" })'], + [ + ['vanilla'], + 'style({ typography: "$a" })', + 'style({ typography: "a" })', + ], + [ + LIBRARIES, + 'styled.div({ typography: "$a" })', + 'styled.div({ typography: "a" })', + ], + [ + ['emotion'], + '', + '', + ], + [ + ['devup'], + 'const s = { typography: "$a" };\ncss(s)', + 'const s = { typography: "a" };\ncss(s)', + ], + ], + 'noTypographyTokenPrefix', + ), + }) +}) + +describe('style sources: prefer-media-shorthand', () => { + ruleTester.run('aliases and positions', preferMediaShorthand, { + valid: [ + ...valid([ + [['emotion'], ''], + [['devup'], 'const s = { "@media print": { w: 1 } };\nconsole.log(s)'], + ]), + ...elsewhere( + 'css({ "@media print": { w: 1 } });\nstyled.div({ "@media print": { w: 1 } })', + ), + ], + invalid: invalid( + [ + [ + LIBRARIES, + 'css({ "@media print": { w: 1 } })', + 'css({ _print: { w: 1 } })', + ], + [ + ['vanilla'], + 'style({ "@media print": { w: 1 } })', + 'style({ _print: { w: 1 } })', + ], + [ + LIBRARIES, + 'styled.div({ "@media print": { w: 1 } })', + 'styled.div({ _print: { w: 1 } })', + ], + [ + ['emotion'], + '', + '', + ], + [ + ['devup'], + 'const s = { "@media print": { w: 1 } };\ncss(s)', + 'const s = { _print: { w: 1 } };\ncss(s)', + ], + ], + 'preferMediaShorthand', + ), + }) +}) + +describe('style sources: style-order-range', () => { + ruleTester.run('aliases and positions', styleOrderRange, { + valid: [ + ...valid([ + [LIBRARIES, 'styled.div.attrs({ styleOrder: 0 })'], + [LIBRARIES, 'styled("div", { styleOrder: 1 })'], + [['emotion'], ''], + ]), + ...elsewhere('css({ styleOrder: 0 });\nstyled.div({ styleOrder: 0 })'), + ], + invalid: [ + ...LIBRARIES.flatMap((header) => + [ + 'css({ styleOrder: 0 })', + 'styled.div({ styleOrder: 300 })', + 'styled("div", { styleOrder: 0 })', + 'styled(Base)({ p: 1 }, { styleOrder: 0 })', + ].map((source) => ({ + code: HEADERS[header] + source, + filename, + errors: [{ messageId: 'styleOrderRange' as const }], + })), + ), + ...[ + [HEADERS.vanilla, 'style({ styleOrder: 0 })'], + [HEADERS.styledComponents, 'createGlobalStyle({ styleOrder: 0 })'], + [HEADERS.devup, 'globalCss({ styleOrder: 0 })'], + ].map(([header, source]) => ({ + code: header + source, + filename, + errors: [{ messageId: 'styleOrderRange' as const }], + })), + ], + }) +}) diff --git a/packages/eslint-plugin/src/utils/import-storage.ts b/packages/eslint-plugin/src/utils/import-storage.ts index 6e0c05d31..ea577b68d 100644 --- a/packages/eslint-plugin/src/utils/import-storage.ts +++ b/packages/eslint-plugin/src/utils/import-storage.ts @@ -1,104 +1,187 @@ -import { AST_NODE_TYPES, type TSESTree } from '@typescript-eslint/utils' -const defaultImports = { - css: 'css', - globalCss: 'globalCss', - keyframes: 'keyframes', - Box: 'Box', - Button: 'Button', - Text: 'Text', - Image: 'Image', - Flex: 'Flex', - Grid: 'Grid', - Center: 'Center', - VStack: 'VStack', - Input: 'Input', -} - -export class ImportStorage { - private imports: Record - private importObject: Set - - constructor() { - this.imports = {} - this.importObject = new Set() - } - - public addImportByDeclaration(node: TSESTree.ImportDeclaration) { - if (node.source.value !== '@devup-ui/react') return - - for (const specifier of node.specifiers) { - switch (specifier.type) { - case AST_NODE_TYPES.ImportSpecifier: - this.addImport( - specifier.local.name, - specifier.imported.type === AST_NODE_TYPES.Literal - ? specifier.imported.value - : specifier.imported.name, - ) - break - case AST_NODE_TYPES.ImportDefaultSpecifier: - this.importObject.add(specifier.local.name) - break - case AST_NODE_TYPES.ImportNamespaceSpecifier: - this.importObject.add(specifier.local.name) - break - } - } - } - - public addImport(key: string, value: string) { - this.imports[key] = value - } - - /** The name the package exports for what `local` imports by name */ - public importedName(local: string): string | undefined { - return Object.prototype.hasOwnProperty.call(this.imports, local) - ? this.imports[local] - : undefined - } - - /** Whether `local` is the package imported whole, as a namespace or default */ - public isImportObject(local: string): boolean { - return this.importObject.has(local) - } - - public checkContextType(node: TSESTree.Node) { - switch (node.type) { - case AST_NODE_TYPES.JSXOpeningElement: { - if (this.checkDevupUIComponent(node.name)) { - return 'COMPONENT' - } - break - } - case AST_NODE_TYPES.CallExpression: { - if (this.checkDevupUIUtil(node)) { - return 'UTIL' - } - break - } - } - } - - private checkDevupUIUtil(node: TSESTree.CallExpression): boolean { - return ( - (node.callee.type === AST_NODE_TYPES.Identifier && - node.callee.name in this.imports) || - (node.callee.type === AST_NODE_TYPES.MemberExpression && - node.callee.object.type === AST_NODE_TYPES.Identifier && - this.importObject.has(node.callee.object.name) && - node.callee.property.type === AST_NODE_TYPES.Identifier && - node.callee.property.name in defaultImports) - ) - } - - private checkDevupUIComponent(node: TSESTree.JSXTagNameExpression): boolean { - return ( - (node.type === AST_NODE_TYPES.JSXIdentifier && - node.name in this.imports) || - (node.type === AST_NODE_TYPES.JSXMemberExpression && - node.object.type === AST_NODE_TYPES.JSXIdentifier && - this.importObject.has(node.object.name) && - node.property.name in defaultImports) - ) - } -} +import { + AST_NODE_TYPES, + type TSESLint, + type TSESTree, +} from '@typescript-eslint/utils' + +const DEVUP_PACKAGE = '@devup-ui/react' + +/** The packages whose default export, and the namespace they are imported as, the build reads as the named export `styled` of Devup UI, as `DEFAULT_IMPORT_ALIASES` in `plugin-utils` lists them */ +const DEFAULT_IS_STYLED = new Set(['@emotion/styled', 'styled-components']) + +/** The packages whose named exports the build maps onto Devup UI's, as `DEFAULT_IMPORT_ALIASES` in `plugin-utils` lists them */ +const NAMED_ALIASES = new Set([ + '@emotion/react', + '@emotion/styled', + 'styled-components', + '@vanilla-extract/css', +]) + +/** The names of another package the build reads as Devup UI's, as `devup_equivalent` in `libs/extractor/src/import_alias_visit.rs` maps them */ +function devupEquivalent(source: string, imported: string): string | undefined { + if (source === '@vanilla-extract/css') { + if (imported === 'style') return 'css' + if (imported === 'globalStyle') return 'globalCss' + } + switch (imported) { + case 'css': + case 'keyframes': + case 'styled': + case 'createGlobalStyle': + case 'Global': + case 'ThemeProvider': + case 'ServerStyleSheet': + case 'StyleSheetManager': + case 'isStyledComponent': + case 'withTheme': + case 'useTheme': + return imported + default: + return undefined + } +} + +/** The components the build compiles into elements */ +export const STYLE_COMPONENTS: ReadonlySet = new Set([ + 'Box', + 'Button', + 'Center', + 'Flex', + 'Grid', + 'Image', + 'Input', + 'Text', + 'VStack', +]) + +/** The functions the build compiles into the classes and names they give */ +export const STYLE_FUNCTIONS: ReadonlySet = new Set([ + 'css', + 'globalCss', + 'keyframes', + 'createGlobalStyle', +]) + +/** Whether `filename` is a vanilla-extract stylesheet, which the build runs as a whole instead of reading its calls, as `is_vanilla_extract_file` does */ +export function isVanillaExtractFile(filename: string): boolean { + return filename.endsWith('.css.ts') || filename.endsWith('.css.js') +} + +export interface StorageContext { + readonly filename: string + readonly sourceCode: Readonly +} + +/** What a file imports from Devup UI and from the packages the build reads as it */ +export class ImportStorage { + private imports = new Map() + private importObject = new Set() + readonly vanilla: boolean + + constructor(private readonly context?: StorageContext) { + this.vanilla = isVanillaExtractFile(context?.filename ?? '') + } + + /** The variables `node` declares */ + public declaredVariables( + node: TSESTree.Node, + ): readonly TSESLint.Scope.Variable[] { + return this.context?.sourceCode.getDeclaredVariables(node) ?? [] + } + + public addImportByDeclaration(node: TSESTree.ImportDeclaration) { + const source = node.source.value + // A vanilla-extract stylesheet runs as it is: no import is compiled away + if (this.vanilla) return + const isDevup = + source === DEVUP_PACKAGE || source === `${DEVUP_PACKAGE}/compat` + if ( + !isDevup && + !NAMED_ALIASES.has(source) && + !DEFAULT_IS_STYLED.has(source) + ) + return + + for (const specifier of node.specifiers) { + switch (specifier.type) { + case AST_NODE_TYPES.ImportSpecifier: { + const imported = + specifier.imported.type === AST_NODE_TYPES.Literal + ? specifier.imported.value + : specifier.imported.name + const name = isDevup ? imported : devupEquivalent(source, imported) + if (name !== undefined) this.addImport(specifier.local.name, name) + break + } + case AST_NODE_TYPES.ImportDefaultSpecifier: + case AST_NODE_TYPES.ImportNamespaceSpecifier: + if (isDevup) this.importObject.add(specifier.local.name) + else if (DEFAULT_IS_STYLED.has(source)) + this.addImport(specifier.local.name, 'styled') + break + } + } + } + + public addImport(key: string, value: string) { + this.imports.set(key, value) + } + + /** The name Devup UI exports for what `local` imports by name, from it or from a package the build reads as it */ + public importedName(local: string): string | undefined { + return this.imports.get(local) + } + + /** Whether `local` is the package imported whole, as a namespace or default */ + public isImportObject(local: string): boolean { + return this.importObject.has(local) + } + + /** The names the file binds to what the build compiles away, with the Devup UI name each stands for */ + public bindings(): [string, string][] { + return [...this.imports] + } + + public checkContextType(node: TSESTree.Node) { + switch (node.type) { + case AST_NODE_TYPES.JSXOpeningElement: { + if (this.checkDevupUIComponent(node.name)) { + return 'COMPONENT' + } + break + } + case AST_NODE_TYPES.CallExpression: { + if (this.checkDevupUIUtil(node)) { + return 'UTIL' + } + break + } + } + } + + private checkDevupUIUtil(node: TSESTree.CallExpression): boolean { + const callee = node.callee + if (callee.type === AST_NODE_TYPES.Identifier) + return this.importedName(callee.name) !== undefined + return ( + callee.type === AST_NODE_TYPES.MemberExpression && + !callee.computed && + callee.object.type === AST_NODE_TYPES.Identifier && + this.importObject.has(callee.object.name) && + callee.property.type === AST_NODE_TYPES.Identifier && + (STYLE_FUNCTIONS.has(callee.property.name) || + callee.property.name === 'styled') + ) + } + + private checkDevupUIComponent(node: TSESTree.JSXTagNameExpression): boolean { + return ( + (node.type === AST_NODE_TYPES.JSXIdentifier && + this.importedName(node.name) !== undefined) || + (node.type === AST_NODE_TYPES.JSXMemberExpression && + node.object.type === AST_NODE_TYPES.JSXIdentifier && + this.importObject.has(node.object.name) && + STYLE_COMPONENTS.has(node.property.name)) + ) + } +} diff --git a/packages/eslint-plugin/src/utils/style-position.ts b/packages/eslint-plugin/src/utils/style-position.ts index 9a25c2666..5dc6d6c23 100644 --- a/packages/eslint-plugin/src/utils/style-position.ts +++ b/packages/eslint-plugin/src/utils/style-position.ts @@ -1,6 +1,14 @@ -import { AST_NODE_TYPES, type TSESTree } from '@typescript-eslint/utils' +import { + AST_NODE_TYPES, + type TSESLint, + type TSESTree, +} from '@typescript-eslint/utils' -import type { ImportStorage } from './import-storage' +import { + type ImportStorage, + STYLE_COMPONENTS, + STYLE_FUNCTIONS, +} from './import-storage' /** The HTML, SVG and React attributes a Devup UI component passes through instead of reading as styles, as `is_special_property` in `libs/css/src/is_special_property.rs` lists them */ export const SPECIAL_PROPERTIES = new Set([ @@ -375,19 +383,7 @@ const OWN_PROPS = new Set(['as', 'props', 'styleVars', 'styleOrder']) /** Keys of style objects holding data rather than style values */ const DATA_KEYS = new Set(['imports', 'fontFaces', 'params']) -const STYLE_FUNCTIONS = new Set(['css', 'globalCss', 'keyframes']) - -const STYLE_COMPONENTS = new Set([ - 'Box', - 'Button', - 'Center', - 'Flex', - 'Grid', - 'Image', - 'Input', - 'Text', - 'VStack', -]) +type Variable = TSESLint.Scope.Variable /** Whether a Devup UI component passes the prop `name` through instead of reading it as a style */ export function isPassThroughProp(name: string): boolean { @@ -400,34 +396,101 @@ export function isPassThroughProp(name: string): boolean { ) } -function isStyleComponent( +/** The Devup UI name `expression` reads: an import by name, or a member of the package imported whole */ +function devupName( + expression: TSESTree.Node, + importStorage: ImportStorage, +): string | undefined { + if (expression.type === AST_NODE_TYPES.Identifier) + return importStorage.importedName(expression.name) + if ( + expression.type === AST_NODE_TYPES.MemberExpression && + !expression.computed && + expression.object.type === AST_NODE_TYPES.Identifier && + expression.property.type === AST_NODE_TYPES.Identifier && + importStorage.isImportObject(expression.object.name) + ) + return expression.property.name + return undefined +} + +/** The Devup UI component a JSX name reads, if it reads one */ +export function componentName( name: TSESTree.JSXTagNameExpression, importStorage: ImportStorage, -): boolean { +): string | undefined { if (name.type === AST_NODE_TYPES.JSXIdentifier) - return STYLE_COMPONENTS.has(importStorage.importedName(name.name) ?? '') - return ( - name.type === AST_NODE_TYPES.JSXMemberExpression && + return importStorage.importedName(name.name) + return name.type === AST_NODE_TYPES.JSXMemberExpression && name.object.type === AST_NODE_TYPES.JSXIdentifier && - importStorage.isImportObject(name.object.name) && - STYLE_COMPONENTS.has(name.property.name) - ) + importStorage.isImportObject(name.object.name) + ? name.property.name + : undefined } -function isStyleFunction( - callee: TSESTree.Expression, +/** Whether `node` is a JSX element the build reads styles from: a style component, or Emotion's `Global` with its `styles` */ +function isStyleElement( + node: TSESTree.JSXOpeningElement, importStorage: ImportStorage, ): boolean { - if (callee.type === AST_NODE_TYPES.Identifier) - return STYLE_FUNCTIONS.has(importStorage.importedName(callee.name) ?? '') - return ( - callee.type === AST_NODE_TYPES.MemberExpression && - !callee.computed && - callee.object.type === AST_NODE_TYPES.Identifier && - callee.property.type === AST_NODE_TYPES.Identifier && - importStorage.isImportObject(callee.object.name) && - STYLE_FUNCTIONS.has(callee.property.name) - ) + const name = componentName(node.name, importStorage) ?? '' + return STYLE_COMPONENTS.has(name) || name === 'Global' +} + +export function isStyledReference( + node: TSESTree.Node, + importStorage: ImportStorage, +): boolean { + return devupName(node, importStorage) === 'styled' +} + +/** Whether `node` still waits for rules: `styled.div`, `styled(tag)`, and what `.attrs()` and `.withConfig()` give */ +export function isStyledFactory( + node: TSESTree.Node, + importStorage: ImportStorage, +): boolean { + if (node.type === AST_NODE_TYPES.MemberExpression) + return ( + !node.computed && + node.property.type === AST_NODE_TYPES.Identifier && + isStyledReference(node.object, importStorage) + ) + if (node.type !== AST_NODE_TYPES.CallExpression) return false + const callee = node.callee + if (callee.type === AST_NODE_TYPES.MemberExpression) + return ( + !callee.computed && + callee.property.type === AST_NODE_TYPES.Identifier && + (callee.property.name === 'attrs' || + callee.property.name === 'withConfig') && + isStyledFactory(callee.object, importStorage) + ) + return node.arguments.length === 1 && isStyledReference(callee, importStorage) +} + +/** The rules `styled(tag, rules)` takes in its second argument, which must be an object literal for the build to read it */ +function styledRulesOf( + call: TSESTree.CallExpression, + importStorage: ImportStorage, +): TSESTree.CallExpressionArgument[] { + const rules = call.arguments[1] + return call.arguments.length === 2 && + isStyledReference(call.callee, importStorage) && + rules.type === AST_NODE_TYPES.ObjectExpression + ? [rules] + : [] +} + +/** The arguments of `call` the build reads as styles: those of `css`, `globalCss`, `keyframes` and `createGlobalStyle`, of a styled component given its tag first (`styled.div({ ... })`), and the rules of `styled(tag, { ... })`. Null if it reads none */ +export function styleArguments( + call: TSESTree.CallExpression, + importStorage: ImportStorage, +): TSESTree.CallExpressionArgument[] | null { + if (STYLE_FUNCTIONS.has(devupName(call.callee, importStorage) ?? '')) + return call.arguments + if (isStyledFactory(call.callee, importStorage)) return call.arguments + const rules = styledRulesOf(call, importStorage) + return rules.length > 0 ? rules : null } /** The Devup UI style component or style function closest above `node` */ @@ -435,15 +498,19 @@ export function styleRoot( node: TSESTree.Node, importStorage: ImportStorage, ): TSESTree.JSXOpeningElement | TSESTree.CallExpression | null { - for (let current = node.parent; current; current = current.parent) { + for ( + let current: TSESTree.Node | undefined = node.parent; + current; + current = current.parent + ) { if ( current.type === AST_NODE_TYPES.JSXOpeningElement && - isStyleComponent(current.name, importStorage) + isStyleElement(current, importStorage) ) return current if ( current.type === AST_NODE_TYPES.CallExpression && - isStyleFunction(current.callee, importStorage) + styleArguments(current, importStorage) ) return current } @@ -451,7 +518,11 @@ export function styleRoot( } /** Whether the build reads what `parent` holds in `child` as a style value: a style prop, a style object value, a responsive array, a branch of a condition or a spread */ -function holdsStyle(parent: TSESTree.Node, child: TSESTree.Node): boolean { +function holdsStyle( + parent: TSESTree.Node, + child: TSESTree.Node, + importStorage: ImportStorage, +): boolean { switch (parent.type) { case AST_NODE_TYPES.ObjectExpression: case AST_NODE_TYPES.ArrayExpression: @@ -474,28 +545,91 @@ function holdsStyle(parent: TSESTree.Node, child: TSESTree.Node): boolean { ) case AST_NODE_TYPES.ConditionalExpression: return parent.test !== child - case AST_NODE_TYPES.JSXAttribute: - return ( - parent.name.type === AST_NODE_TYPES.JSXIdentifier && - !isPassThroughProp(parent.name.name) - ) + case AST_NODE_TYPES.JSXAttribute: { + if (parent.name.type !== AST_NODE_TYPES.JSXIdentifier) return false + return componentName(parent.parent.name, importStorage) === 'Global' + ? parent.name.name === 'styles' + : !isPassThroughProp(parent.name.name) + } default: return false } } -/** Whether the build reads `node` as a style value of `root`, every node between them holding it as a style */ -export function isStylePosition( - node: TSESTree.Node, - root: TSESTree.Node, +/** Where a style value the build reads sits: below `root`, from `start` down. `start` is `root`, or the value of a module-level `const` every use of which the build reads as a style */ +export interface StyleSite { + root: TSESTree.JSXOpeningElement | TSESTree.CallExpression + start: TSESTree.Node +} + +/** The root the build reads `child` from when it is the direct style value of `root`: an attribute of the element, or an argument it reads */ +function readsDirectly( + child: TSESTree.Node, + root: TSESTree.JSXOpeningElement | TSESTree.CallExpression, + importStorage: ImportStorage, ): boolean { + return ( + root.type !== AST_NODE_TYPES.CallExpression || + (styleArguments(root, importStorage) ?? []).includes( + child as TSESTree.CallExpressionArgument, + ) + ) +} + +/** Where the build reads `node` as a style value, every node between holding it as a style. Through a module-level `const` the build inlines, if it is not exported and nothing but styles reads it */ +export function styleValueSite( + node: TSESTree.Node, + importStorage: ImportStorage, + visiting: ReadonlySet = new Set(), +): StyleSite | null { + const root = styleRoot(node, importStorage) let child = node let parent = node.parent - while (parent && parent !== root && holdsStyle(parent, child)) { + while ( + parent && + parent !== root && + holdsStyle(parent, child, importStorage) + ) { child = parent parent = parent.parent } - return parent === root + if (root && parent === root) + return readsDirectly(child, root, importStorage) + ? { root, start: root } + : null + return parent?.type === AST_NODE_TYPES.VariableDeclarator && + parent.init === child + ? constSite(parent, child, importStorage, visiting) + : null +} + +function constSite( + declarator: TSESTree.VariableDeclarator, + init: TSESTree.Node, + importStorage: ImportStorage, + visiting: ReadonlySet, +): StyleSite | null { + const declaration = declarator.parent + if ( + declarator.id.type !== AST_NODE_TYPES.Identifier || + declaration.kind !== 'const' || + declaration.parent.type !== AST_NODE_TYPES.Program + ) + return null + const [variable] = importStorage.declaredVariables(declarator) + if (!variable || visiting.has(variable)) return null + const reads = variable.references.filter((reference) => !reference.init) + if (reads.length === 0) return null + const inner = new Set(visiting).add(variable) + let site: StyleSite | null = null + for (const reference of reads) { + const read = reference.isWrite() + ? null + : styleValueSite(reference.identifier, importStorage, inner) + if (!read) return null + site ??= { root: read.root, start: init } + } + return site } /** The Devup UI style component or function reading `node` as a style value, if one does */ @@ -503,6 +637,5 @@ export function styleValueRoot( node: TSESTree.Node, importStorage: ImportStorage, ): TSESTree.JSXOpeningElement | TSESTree.CallExpression | null { - const root = styleRoot(node, importStorage) - return root && isStylePosition(node, root) ? root : null + return styleValueSite(node, importStorage)?.root ?? null }