133 lines
4.6 KiB
JavaScript
133 lines
4.6 KiB
JavaScript
/**
|
|
* 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',
|
|
});
|
|
}
|
|
},
|
|
};
|
|
},
|
|
};
|