diff --git a/frontend/eslint-rules/stylex-only.mjs b/frontend/eslint-rules/stylex-only.mjs new file mode 100644 index 00000000..7eeb9988 --- /dev/null +++ b/frontend/eslint-rules/stylex-only.mjs @@ -0,0 +1,137 @@ +export default { + meta: { + type: 'problem', + schema: [], + messages: { + className: + 'Define styles with StyleX and pass a typed style prop or spread stylex.props on a host element.', + inlineStyle: + 'Define host styles with stylex.create and apply them with stylex.props.', + cssProperties: 'Component style props must accept StyleXStyles, not CSSProperties.', + hostProps: + 'Pass StyleX styles to application components instead of spreading generated DOM props.', + }, + }, + create(context) { + function applicationComponent(node) { + const name = node.type === 'JSXMemberExpression' ? node.object : node; + if (name.type !== 'JSXIdentifier' || !/^[A-Z]/.test(name.name)) return false; + let scope = context.sourceCode.getScope(node); + while (scope && !scope.set.has(name.name)) scope = scope.upper; + const variable = scope?.set.get(name.name); + const imported = variable?.defs.find(def => def.type === 'ImportBinding'); + if (!imported) { + return Boolean( + variable?.defs.some( + def => + def.type === 'FunctionName' || + (def.type === 'Variable' && + ['ArrowFunctionExpression', 'FunctionExpression'].includes( + def.node.init?.type + )) + ) + ); + } + const source = imported.parent.source.value; + return ( + source.startsWith('components/') || + source.startsWith('./') || + source.startsWith('../') + ); + } + + function initializer(node) { + if (node.type !== 'Identifier') return node; + let scope = context.sourceCode.getScope(node); + while (scope && !scope.set.has(node.name)) scope = scope.upper; + return scope?.set.get(node.name)?.defs.find(def => def.type === 'Variable')?.node + .init; + } + + function spreadValues(node, seen = new Set()) { + if (!node || seen.has(node)) return []; + seen.add(node); + if ( + node.type === 'TSAsExpression' || + node.type === 'TSSatisfiesExpression' || + node.type === 'TSNonNullExpression' + ) { + return spreadValues(node.expression, seen); + } + if (node.type === 'Identifier') return spreadValues(initializer(node), seen); + if (node.type === 'ObjectExpression') { + return node.properties.flatMap(property => + property.type === 'SpreadElement' + ? spreadValues(property.argument, seen) + : [property] + ); + } + if ( + node.type === 'CallExpression' && + node.callee.type === 'MemberExpression' && + node.callee.object.name === 'stylex' && + node.callee.property.name === 'props' + ) { + return [node]; + } + return []; + } + + return { + JSXAttribute(node) { + if (node.name.name === 'className') + context.report({node, messageId: 'className'}); + if ( + node.name.name === 'classNames' && + !context.filename.endsWith('/components/Calendar.tsx') + ) { + context.report({node, messageId: 'className'}); + } + if ( + node.name.name === 'style' && + (!applicationComponent(node.parent.name) || + node.value?.expression?.type === 'ObjectExpression') + ) { + context.report({node, messageId: 'inlineStyle'}); + } + }, + TSTypeReference(node) { + const name = + node.typeName.type === 'TSQualifiedName' + ? node.typeName.right.name + : node.typeName.name; + if (name === 'CSSProperties') context.report({node, messageId: 'cssProperties'}); + }, + AssignmentExpression(node) { + if ( + node.left.type === 'MemberExpression' && + node.left.object.type === 'MemberExpression' && + node.left.object.property.name === 'style' + ) { + context.report({node, messageId: 'inlineStyle'}); + } + }, + JSXSpreadAttribute(node) { + const host = node.parent.name; + const values = spreadValues(node.argument); + for (const value of values) { + if (value.type !== 'Property' || value.computed) continue; + const key = value.key.name ?? value.key.value; + if (key === 'className' || key === 'classNames') { + context.report({node: value, messageId: 'className'}); + } + if ( + key === 'style' && + (!applicationComponent(host) || value.value.type === 'ObjectExpression') + ) { + context.report({node: value, messageId: 'inlineStyle'}); + } + } + if (!values.some(value => value.type === 'CallExpression')) return; + if (applicationComponent(host)) { + context.report({node, messageId: 'hostProps'}); + } + }, + }; + }, +}; diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js index 86bce3bf..c78b9438 100644 --- a/frontend/eslint.config.js +++ b/frontend/eslint.config.js @@ -1,11 +1,13 @@ import js from '@eslint/js'; +import {rules as stylexRules} from '@stylexjs/eslint-plugin'; import reactHooks from 'eslint-plugin-react-hooks'; import reactRefresh from 'eslint-plugin-react-refresh'; -import eslintPluginTailwindCSS from 'eslint-plugin-tailwindcss'; import {globalIgnores} from 'eslint/config'; import globals from 'globals'; import tseslint from 'typescript-eslint'; +import stylexOnly from './eslint-rules/stylex-only.mjs'; + export default tseslint.config([ globalIgnores(['dist']), { @@ -15,26 +17,33 @@ export default tseslint.config([ tseslint.configs.recommended, reactHooks.configs.flat.recommended, reactRefresh.configs.vite, - eslintPluginTailwindCSS.configs['flat/recommended'], ], - settings: { - tailwindcss: { - // For Tailwind v4 - disable config path since it uses CSS-based config - config: false, - // Specify CSS files where Tailwind is defined - cssFiles: ['src/styles/index.css', 'src/styles/scraps-theme.css'], - }, - }, - rules: { - // Disable until plugin properly supports Tailwind v4 CSS-based config - 'tailwindcss/no-custom-classname': 'off', + plugins: { + '@stylexjs': {rules: stylexRules}, + local: {rules: {'stylex-only': stylexOnly}}, }, languageOptions: { ecmaVersion: 2020, globals: globals.browser, }, rules: { - 'tailwindcss/no-custom-classname': 'off', + '@stylexjs/valid-styles': 'error', + '@stylexjs/enforce-extension': 'error', + 'local/stylex-only': 'error', + 'no-restricted-imports': [ + 'error', + { + paths: [ + 'classnames', + 'class-variance-authority', + 'tailwind-merge', + 'tailwindcss', + '@tailwindcss/vite', + 'utils/cn', + ], + patterns: ['**/utils/cn', '**/cn', '@tailwindcss/*', '**/*.css', '**/*.scss'], + }, + ], }, }, ]); diff --git a/frontend/index.html b/frontend/index.html index 51e5e1bf..8eb25d6c 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -10,7 +10,7 @@