website refactor
This commit is contained in:
@@ -1,29 +1,31 @@
|
||||
/**
|
||||
* ESLint rule to suggest proper component classification
|
||||
* 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)
|
||||
* - hooks/ - Shared stateful logic
|
||||
*
|
||||
* This rule provides SUGGESTIONS, not errors, for component placement.
|
||||
* This rule enforces that components use proper UI elements instead of raw HTML and className.
|
||||
*/
|
||||
|
||||
module.exports = {
|
||||
meta: {
|
||||
type: 'suggestion',
|
||||
type: 'problem',
|
||||
docs: {
|
||||
description: 'Suggest proper component classification',
|
||||
description: 'Enforce proper component classification and UI component usage',
|
||||
category: 'Architecture',
|
||||
recommended: false,
|
||||
recommended: true,
|
||||
},
|
||||
fixable: 'code',
|
||||
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.',
|
||||
},
|
||||
},
|
||||
|
||||
@@ -33,10 +35,12 @@ module.exports = {
|
||||
const isInComponents = filename.includes('/components/');
|
||||
const isInApp = filename.includes('/app/');
|
||||
|
||||
if (!isInUi && !isInComponents) return {};
|
||||
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();
|
||||
|
||||
@@ -63,18 +67,64 @@ module.exports = {
|
||||
messageId: 'pureComponentInComponents',
|
||||
});
|
||||
}
|
||||
|
||||
if (isInComponents && !hasState && !hasEffects && !hasContext) {
|
||||
// Check if it's mostly just rendering props
|
||||
const hasManyProps = /\{\s*\.\.\.props\s*\}/.test(text) ||
|
||||
/\{\s*props\./.test(text);
|
||||
|
||||
if (hasManyProps && hasNoLogic) {
|
||||
context.report({
|
||||
loc: { line: 1, column: 0 },
|
||||
messageId: 'uiShouldBePure',
|
||||
});
|
||||
},
|
||||
|
||||
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',
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@@ -73,7 +73,7 @@ module.exports = {
|
||||
'template-no-external-state': templatePurityRules['no-restricted-imports-in-templates'],
|
||||
'template-no-global-objects': templatePurityRules['no-invalid-template-signature'],
|
||||
'template-no-mutation-props': templatePurityRules['no-template-helper-exports'],
|
||||
'template-no-unsafe-html': templatePurityRules['invalid-template-filename'],
|
||||
'template-no-unsafe-html': require('./template-no-unsafe-html'),
|
||||
|
||||
// Display Object Rules
|
||||
'display-no-domain-models': displayObjectRules['no-io-in-display-objects'],
|
||||
|
||||
@@ -1,125 +1,80 @@
|
||||
/**
|
||||
* ESLint rule to enforce Presenter contract
|
||||
* ESLint rule to forbid raw HTML and className in templates
|
||||
*
|
||||
* Enforces that classes ending with "Presenter" must:
|
||||
* 1. Implement Presenter<TInput, TOutput> interface
|
||||
* 2. Have a present(input) method
|
||||
* 3. Have 'use client' directive
|
||||
* Templates must use proper reusable UI components instead of low level html and css.
|
||||
* To avoid workarounds using `Box` with tailwind classes, the `className` property is forbidden.
|
||||
*/
|
||||
|
||||
module.exports = {
|
||||
meta: {
|
||||
type: 'problem',
|
||||
docs: {
|
||||
description: 'Enforce Presenter contract implementation',
|
||||
category: 'Best Practices',
|
||||
description: 'Forbid raw HTML and className in templates',
|
||||
category: 'Architecture',
|
||||
recommended: true,
|
||||
},
|
||||
fixable: null,
|
||||
schema: [],
|
||||
messages: {
|
||||
missingImplements: 'Presenter class must implement Presenter<TInput, TOutput> interface',
|
||||
missingPresentMethod: 'Presenter class must have present(input) method',
|
||||
missingUseClient: 'Presenter must have \'use client\' directive at top-level',
|
||||
noRawHtml: 'Raw HTML tags are forbidden in templates. Use UI components from ui/ instead.',
|
||||
noClassName: 'The className property is forbidden in templates. Use proper component props for styling.',
|
||||
noStyle: 'The style property is forbidden in templates. Use proper component props for styling.',
|
||||
},
|
||||
},
|
||||
|
||||
create(context) {
|
||||
const sourceCode = context.getSourceCode();
|
||||
let hasUseClient = false;
|
||||
let presenterClassNode = null;
|
||||
let hasPresentMethod = false;
|
||||
let hasImplements = false;
|
||||
const filename = context.getFilename();
|
||||
const isInTemplates = filename.includes('/templates/');
|
||||
|
||||
if (!isInTemplates) return {};
|
||||
|
||||
return {
|
||||
// Check for 'use client' directive
|
||||
Program(node) {
|
||||
// Check comments at the top
|
||||
const comments = sourceCode.getAllComments();
|
||||
if (comments.length > 0) {
|
||||
const firstComment = comments[0];
|
||||
if (firstComment.type === 'Line' && firstComment.value.trim() === 'use client') {
|
||||
hasUseClient = true;
|
||||
} else if (firstComment.type === 'Block' && firstComment.value.includes('use client')) {
|
||||
hasUseClient = true;
|
||||
}
|
||||
JSXOpeningElement(node) {
|
||||
let tagName = '';
|
||||
if (node.name.type === 'JSXIdentifier') {
|
||||
tagName = node.name.name;
|
||||
} else if (node.name.type === 'JSXMemberExpression') {
|
||||
tagName = node.name.property.name;
|
||||
}
|
||||
|
||||
// Also check for 'use client' string literal as first statement
|
||||
if (node.body.length > 0) {
|
||||
const firstStmt = node.body[0];
|
||||
if (firstStmt &&
|
||||
firstStmt.type === 'ExpressionStatement' &&
|
||||
firstStmt.expression.type === 'Literal' &&
|
||||
firstStmt.expression.value === 'use client') {
|
||||
hasUseClient = true;
|
||||
}
|
||||
}
|
||||
},
|
||||
if (!tagName) return;
|
||||
|
||||
// Find Presenter classes
|
||||
ClassDeclaration(node) {
|
||||
const className = node.id?.name;
|
||||
|
||||
// Check if this is a Presenter class
|
||||
if (className && className.endsWith('Presenter')) {
|
||||
presenterClassNode = node;
|
||||
|
||||
// Check if it implements any interface
|
||||
if (node.implements && node.implements.length > 0) {
|
||||
for (const impl of node.implements) {
|
||||
// Handle GenericTypeAnnotation for Presenter<TInput, TOutput>
|
||||
if (impl.expression.type === 'TSInstantiationExpression') {
|
||||
const expr = impl.expression.expression;
|
||||
if (expr.type === 'Identifier' && expr.name === 'Presenter') {
|
||||
hasImplements = true;
|
||||
}
|
||||
} else if (impl.expression.type === 'Identifier') {
|
||||
// Handle simple Presenter (without generics)
|
||||
if (impl.expression.name === 'Presenter') {
|
||||
hasImplements = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// Check for present method in classes
|
||||
MethodDefinition(node) {
|
||||
if (presenterClassNode &&
|
||||
node.key.type === 'Identifier' &&
|
||||
node.key.name === 'present' &&
|
||||
node.parent === presenterClassNode) {
|
||||
hasPresentMethod = true;
|
||||
}
|
||||
},
|
||||
|
||||
// Report violations at the end
|
||||
'Program:exit'() {
|
||||
if (!presenterClassNode) return;
|
||||
|
||||
if (!hasImplements) {
|
||||
// 1. Forbid raw HTML tags (lowercase)
|
||||
if (tagName[0] === tagName[0].toLowerCase()) {
|
||||
context.report({
|
||||
node: presenterClassNode,
|
||||
messageId: 'missingImplements',
|
||||
node,
|
||||
messageId: 'noRawHtml',
|
||||
});
|
||||
}
|
||||
|
||||
if (!hasPresentMethod) {
|
||||
// 2. Forbid className property
|
||||
const classNameAttr = node.attributes.find(
|
||||
attr => attr.type === 'JSXAttribute' &&
|
||||
attr.name.type === 'JSXIdentifier' &&
|
||||
attr.name.name === 'className'
|
||||
);
|
||||
|
||||
if (classNameAttr) {
|
||||
context.report({
|
||||
node: presenterClassNode,
|
||||
messageId: 'missingPresentMethod',
|
||||
node: classNameAttr,
|
||||
messageId: 'noClassName',
|
||||
});
|
||||
}
|
||||
|
||||
if (!hasUseClient) {
|
||||
// 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: presenterClassNode,
|
||||
messageId: 'missingUseClient',
|
||||
node: styleAttr,
|
||||
messageId: 'noStyle',
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,22 +1,23 @@
|
||||
/**
|
||||
* ESLint rule to enforce UI element purity
|
||||
* ESLint rule to enforce UI element purity and architectural boundaries
|
||||
*
|
||||
* UI elements in ui/ must be:
|
||||
* - Stateless (no useState, useReducer)
|
||||
* - No side effects (no useEffect)
|
||||
* - Pure functions that only render based on props
|
||||
* - Isolated (cannot import from outside the ui/ directory)
|
||||
*
|
||||
* Rationale:
|
||||
* - ui/ is for reusable, pure presentation elements
|
||||
* - Stateful logic belongs in components/ or hooks/
|
||||
* - This ensures maximum reusability and testability
|
||||
* - Isolation ensures UI elements don't depend on app-specific logic
|
||||
* - Raw HTML and className are allowed in ui/ for implementation
|
||||
*/
|
||||
|
||||
module.exports = {
|
||||
meta: {
|
||||
type: 'problem',
|
||||
docs: {
|
||||
description: 'Enforce UI elements are pure and stateless',
|
||||
description: 'Enforce UI elements are pure and isolated',
|
||||
category: 'Architecture',
|
||||
recommended: true,
|
||||
},
|
||||
@@ -26,6 +27,7 @@ module.exports = {
|
||||
noStateInUi: 'UI elements in ui/ must be stateless. Use components/ for stateful wrappers.',
|
||||
noHooksInUi: 'UI elements must not use hooks. Use components/ or hooks/ for stateful logic.',
|
||||
noSideEffects: 'UI elements must not have side effects. Use components/ for side effect logic.',
|
||||
noExternalImports: 'UI elements in ui/ cannot import from outside the ui/ directory. Only npm packages and other UI elements are allowed.',
|
||||
},
|
||||
},
|
||||
|
||||
@@ -36,6 +38,38 @@ module.exports = {
|
||||
if (!isInUi) return {};
|
||||
|
||||
return {
|
||||
// Check for imports from outside ui/
|
||||
ImportDeclaration(node) {
|
||||
const importPath = node.source.value;
|
||||
|
||||
// Allow npm packages (don't start with . or @/)
|
||||
if (!importPath.startsWith('.') && !importPath.startsWith('@/')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check internal imports
|
||||
const isInternalUiImport = importPath.startsWith('@/ui/') ||
|
||||
(importPath.startsWith('.') && !importPath.includes('..'));
|
||||
|
||||
// Special case for relative imports that stay within ui/
|
||||
let staysInUi = false;
|
||||
if (importPath.startsWith('.')) {
|
||||
const path = require('path');
|
||||
const absoluteImportPath = path.resolve(path.dirname(filename), importPath);
|
||||
const uiDir = filename.split('/ui/')[0] + '/ui';
|
||||
if (absoluteImportPath.startsWith(uiDir)) {
|
||||
staysInUi = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!isInternalUiImport && !staysInUi) {
|
||||
context.report({
|
||||
node,
|
||||
messageId: 'noExternalImports',
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
// Check for 'use client' directive
|
||||
ExpressionStatement(node) {
|
||||
if (node.expression.type === 'Literal' &&
|
||||
@@ -77,31 +111,6 @@ module.exports = {
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
// Check for class components with state
|
||||
ClassDeclaration(node) {
|
||||
if (node.superClass &&
|
||||
node.superClass.type === 'Identifier' &&
|
||||
node.superClass.name === 'Component') {
|
||||
context.report({
|
||||
node,
|
||||
messageId: 'noStateInUi',
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
// Check for direct state assignment (rare but possible)
|
||||
AssignmentExpression(node) {
|
||||
if (node.left.type === 'MemberExpression' &&
|
||||
node.left.property.type === 'Identifier' &&
|
||||
(node.left.property.name === 'state' ||
|
||||
node.left.property.name === 'setState')) {
|
||||
context.report({
|
||||
node,
|
||||
messageId: 'noStateInUi',
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user