/** * ESLint rule to suggest proper component classification and enforce UI component usage * * Architecture: * - app/ - Pages and layouts only (no business logic) * - components/ - App-level components (can be stateful, can use hooks) * - ui/ - Pure, reusable UI elements (stateless, no hooks) * * This rule enforces that components use proper UI elements instead of raw HTML and className. */ module.exports = { meta: { type: 'problem', docs: { description: 'Enforce proper component classification and UI component usage', category: 'Architecture', recommended: true, }, fixable: null, schema: [], messages: { uiShouldBePure: 'This appears to be a pure UI element. Consider moving to ui/ for maximum reusability.', componentShouldBeInComponents: 'This component uses state/hooks. Consider moving to components/.', pureComponentInComponents: 'Pure component in components/. Consider moving to ui/ for better reusability.', noRawHtml: 'Raw HTML tags are forbidden in components and pages. Use UI components from ui/ instead.', noClassName: 'The className property is forbidden in components and pages. Use proper component props for styling.', noStyle: 'The style property is forbidden in components and pages. Use proper component props for styling.', }, }, create(context) { const filename = context.getFilename(); const isInUi = filename.includes('/ui/'); const isInComponents = filename.includes('/components/'); const isInApp = filename.includes('/app/'); if (!isInUi && !isInComponents && !isInApp) return {}; return { Program(node) { if (isInApp) return; // Don't check classification for app/ files const sourceCode = context.getSourceCode(); const text = sourceCode.getText(); // Detect stateful patterns const hasState = /useState|useReducer|this\.state/.test(text); const hasEffects = /useEffect|useLayoutEffect/.test(text); const hasContext = /useContext/.test(text); const hasComplexLogic = /if\s*\(|switch\s*\(|for\s*\(|while\s*\(/.test(text); // Detect pure UI patterns (just JSX, props, simple functions) const hasOnlyJsx = /^\s*import.*from.*;\s*export\s+function\s+\w+\s*\([^)]*\)\s*{?\s*return\s*\(?.*\)?;?\s*}?\s*$/m.test(text); const hasNoLogic = !hasState && !hasEffects && !hasContext && !hasComplexLogic; if (isInUi && hasState) { context.report({ loc: { line: 1, column: 0 }, messageId: 'componentShouldBeInComponents', }); } if (isInComponents && hasNoLogic && hasOnlyJsx) { context.report({ loc: { line: 1, column: 0 }, messageId: 'pureComponentInComponents', }); } }, JSXOpeningElement(node) { if (isInUi) return; // Allow raw HTML and className in ui/ let tagName = ''; if (node.name.type === 'JSXIdentifier') { tagName = node.name.name; } else if (node.name.type === 'JSXMemberExpression') { tagName = node.name.property.name; } if (!tagName) return; // 1. Forbid raw HTML tags (lowercase) if (tagName[0] === tagName[0].toLowerCase()) { // Special case for html and body in RootLayout if (isInApp && (tagName === 'html' || tagName === 'body' || tagName === 'head' || tagName === 'meta' || tagName === 'link' || tagName === 'script')) { return; } context.report({ node, messageId: 'noRawHtml', }); } // 2. Forbid className property const classNameAttr = node.attributes.find( attr => attr.type === 'JSXAttribute' && attr.name.type === 'JSXIdentifier' && attr.name.name === 'className' ); if (classNameAttr) { // Special case for html and body in RootLayout if (isInApp && (tagName === 'html' || tagName === 'body')) { return; } context.report({ node: classNameAttr, messageId: 'noClassName', }); } // 3. Forbid style property const styleAttr = node.attributes.find( attr => attr.type === 'JSXAttribute' && attr.name.type === 'JSXIdentifier' && attr.name.name === 'style' ); if (styleAttr) { context.report({ node: styleAttr, messageId: 'noStyle', }); } }, }; }, };