website refactor

This commit is contained in:
2026-01-14 23:46:04 +01:00
parent c1a86348d7
commit 4a2d7d15a5
294 changed files with 5637 additions and 3418 deletions

View File

@@ -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',
});
}
},
};

View File

@@ -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'],

View File

@@ -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',
});
}
},
};
},
};
};

View File

@@ -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',
});
}
},
};
},
};