website refactor
This commit is contained in:
82
apps/website/eslint-rules/component-classification.js
Normal file
82
apps/website/eslint-rules/component-classification.js
Normal file
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* ESLint rule to suggest proper component classification
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
module.exports = {
|
||||
meta: {
|
||||
type: 'suggestion',
|
||||
docs: {
|
||||
description: 'Suggest proper component classification',
|
||||
category: 'Architecture',
|
||||
recommended: false,
|
||||
},
|
||||
fixable: 'code',
|
||||
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.',
|
||||
},
|
||||
},
|
||||
|
||||
create(context) {
|
||||
const filename = context.getFilename();
|
||||
const isInUi = filename.includes('/ui/');
|
||||
const isInComponents = filename.includes('/components/');
|
||||
const isInApp = filename.includes('/app/');
|
||||
|
||||
if (!isInUi && !isInComponents) return {};
|
||||
|
||||
return {
|
||||
Program(node) {
|
||||
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',
|
||||
});
|
||||
}
|
||||
|
||||
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',
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -138,6 +138,16 @@ module.exports = {
|
||||
// Clean Error Handling Rules
|
||||
'clean-error-handling': cleanErrorHandling,
|
||||
'services-implement-contract': servicesImplementContract,
|
||||
|
||||
// Component Architecture Rules
|
||||
'no-raw-html-in-app': require('./no-raw-html-in-app'),
|
||||
'ui-element-purity': require('./ui-element-purity'),
|
||||
'no-nextjs-imports-in-ui': require('./no-nextjs-imports-in-ui'),
|
||||
'component-classification': require('./component-classification'),
|
||||
|
||||
// Route Configuration Rules
|
||||
'no-hardcoded-routes': require('./no-hardcoded-routes'),
|
||||
'no-hardcoded-search-params': require('./no-hardcoded-search-params'),
|
||||
},
|
||||
|
||||
// Configurations for different use cases
|
||||
@@ -228,6 +238,15 @@ module.exports = {
|
||||
// Service Rules
|
||||
'gridpilot-rules/service-function-format': 'error',
|
||||
'gridpilot-rules/lib-no-next-imports': 'error',
|
||||
|
||||
// Component Architecture Rules
|
||||
'gridpilot-rules/no-raw-html-in-app': 'error',
|
||||
'gridpilot-rules/ui-element-purity': 'error',
|
||||
'gridpilot-rules/no-nextjs-imports-in-ui': 'error',
|
||||
'gridpilot-rules/component-classification': 'warn',
|
||||
|
||||
// Route Configuration Rules
|
||||
'gridpilot-rules/no-hardcoded-routes': 'error',
|
||||
},
|
||||
},
|
||||
|
||||
|
||||
288
apps/website/eslint-rules/no-hardcoded-routes.js
Normal file
288
apps/website/eslint-rules/no-hardcoded-routes.js
Normal file
@@ -0,0 +1,288 @@
|
||||
/**
|
||||
* @file no-hardcoded-routes.js
|
||||
* Enforces use of RouteConfig.ts instead of hardcoded route strings
|
||||
*
|
||||
* This rule prevents hardcoded route strings in:
|
||||
* - router.push() / router.replace()
|
||||
* - redirect()
|
||||
* - Link href
|
||||
* - a href
|
||||
* - revalidatePath()
|
||||
* - Any string literal matching route patterns
|
||||
*/
|
||||
|
||||
module.exports = {
|
||||
meta: {
|
||||
type: 'problem',
|
||||
docs: {
|
||||
description: 'Enforce use of RouteConfig.ts instead of hardcoded route strings',
|
||||
category: 'Best Practices',
|
||||
recommended: true,
|
||||
},
|
||||
fixable: 'code',
|
||||
schema: [],
|
||||
messages: {
|
||||
hardcodedRoute: 'Hardcoded route "{{route}}". Use routes from RouteConfig.ts instead: import { routes } from "@/lib/routing/RouteConfig"',
|
||||
hardcodedRedirect: 'Hardcoded redirect route "{{route}}". Use routes from RouteConfig.ts',
|
||||
hardcodedLink: 'Hardcoded link href "{{route}}". Use routes from RouteConfig.ts',
|
||||
hardcodedAnchor: 'Hardcoded anchor href "{{route}}". Use routes from RouteConfig.ts',
|
||||
hardcodedRevalidate: 'Hardcoded revalidatePath "{{route}}". Use routes from RouteConfig.ts',
|
||||
},
|
||||
},
|
||||
|
||||
create(context) {
|
||||
// Route patterns that should be in RouteConfig
|
||||
const routePatterns = [
|
||||
'^/auth/',
|
||||
'^/public/',
|
||||
'^/protected/',
|
||||
'^/sponsor/',
|
||||
'^/admin/',
|
||||
'^/league(s)?/',
|
||||
'^/race(s)?/',
|
||||
'^/team(s)?/',
|
||||
'^/driver(s)?/',
|
||||
'^/leaderboard(s)?/',
|
||||
'^/error/',
|
||||
'^/404$',
|
||||
'^/500$',
|
||||
'^/dashboard$',
|
||||
'^/onboarding$',
|
||||
'^/profile',
|
||||
'^/sponsor/signup$',
|
||||
];
|
||||
|
||||
// Allowed exceptions (non-route paths)
|
||||
const allowedPaths = [
|
||||
'^/api/',
|
||||
'^/legal/',
|
||||
'^/terms$',
|
||||
'^/privacy$',
|
||||
'^/support$',
|
||||
'^/$', // root is allowed
|
||||
];
|
||||
|
||||
function isHardcodedRoute(value) {
|
||||
if (typeof value !== 'string') return false;
|
||||
|
||||
// Check if it's an allowed path
|
||||
if (allowedPaths.some(pattern => new RegExp(pattern).test(value))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if it matches a route pattern
|
||||
return routePatterns.some(pattern => new RegExp(pattern).test(value));
|
||||
}
|
||||
|
||||
function getRouteConfigSuggestion(route) {
|
||||
// Map common routes to RouteConfig suggestions
|
||||
const routeMap = {
|
||||
'/auth/login': 'routes.auth.login',
|
||||
'/auth/signup': 'routes.auth.signup',
|
||||
'/auth/forgot-password': 'routes.auth.forgotPassword',
|
||||
'/auth/reset-password': 'routes.auth.resetPassword',
|
||||
'/': 'routes.public.home',
|
||||
'/leagues': 'routes.public.leagues',
|
||||
'/drivers': 'routes.public.drivers',
|
||||
'/teams': 'routes.public.teams',
|
||||
'/leaderboards': 'routes.public.leaderboards',
|
||||
'/races': 'routes.public.races',
|
||||
'/sponsor/signup': 'routes.public.sponsorSignup',
|
||||
'/dashboard': 'routes.protected.dashboard',
|
||||
'/onboarding': 'routes.protected.onboarding',
|
||||
'/profile': 'routes.protected.profile',
|
||||
'/profile/settings': 'routes.protected.profileSettings',
|
||||
'/profile/leagues': 'routes.protected.profileLeagues',
|
||||
'/profile/liveries': 'routes.protected.profileLiveries',
|
||||
'/profile/liveries/upload': 'routes.protected.profileLiveryUpload',
|
||||
'/profile/sponsorship-requests': 'routes.protected.profileSponsorshipRequests',
|
||||
'/sponsor': 'routes.sponsor.root',
|
||||
'/sponsor/dashboard': 'routes.sponsor.dashboard',
|
||||
'/sponsor/billing': 'routes.sponsor.billing',
|
||||
'/sponsor/campaigns': 'routes.sponsor.campaigns',
|
||||
'/sponsor/leagues': 'routes.sponsor.leagues',
|
||||
'/sponsor/settings': 'routes.sponsor.settings',
|
||||
'/admin': 'routes.admin.root',
|
||||
'/admin/users': 'routes.admin.users',
|
||||
'/404': 'routes.error.notFound',
|
||||
'/500': 'routes.error.serverError',
|
||||
};
|
||||
|
||||
// Check for parameterized routes
|
||||
const leagueMatch = route.match(/^\/leagues\/([^\/]+)$/);
|
||||
if (leagueMatch) {
|
||||
return `routes.league.detail('${leagueMatch[1]}')`;
|
||||
}
|
||||
|
||||
const raceMatch = route.match(/^\/races\/([^\/]+)$/);
|
||||
if (raceMatch) {
|
||||
return `routes.race.detail('${raceMatch[1]}')`;
|
||||
}
|
||||
|
||||
const teamMatch = route.match(/^\/teams\/([^\/]+)$/);
|
||||
if (teamMatch) {
|
||||
return `routes.team.detail('${teamMatch[1]}')`;
|
||||
}
|
||||
|
||||
const driverMatch = route.match(/^\/drivers\/([^\/]+)$/);
|
||||
if (driverMatch) {
|
||||
return `routes.driver.detail('${driverMatch[1]}')`;
|
||||
}
|
||||
|
||||
const sponsorLeagueMatch = route.match(/^\/sponsor\/leagues\/([^\/]+)$/);
|
||||
if (sponsorLeagueMatch) {
|
||||
return `routes.sponsor.leagueDetail('${sponsorLeagueMatch[1]}')`;
|
||||
}
|
||||
|
||||
const leagueAdminMatch = route.match(/^\/leagues\/([^\/]+)\/schedule\/admin$/);
|
||||
if (leagueAdminMatch) {
|
||||
return `routes.league.scheduleAdmin('${leagueAdminMatch[1]}')`;
|
||||
}
|
||||
|
||||
return routeMap[route] || null;
|
||||
}
|
||||
|
||||
function reportHardcodedRoute(node, value, messageId, data = {}) {
|
||||
const suggestion = getRouteConfigSuggestion(value);
|
||||
const messageData = {
|
||||
route: value,
|
||||
...data,
|
||||
};
|
||||
|
||||
context.report({
|
||||
node,
|
||||
messageId,
|
||||
data: messageData,
|
||||
fix(fixer) {
|
||||
if (suggestion) {
|
||||
return fixer.replaceText(node, suggestion);
|
||||
}
|
||||
return null;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
// Check router.push() and router.replace()
|
||||
CallExpression(node) {
|
||||
if (node.callee.type === 'MemberExpression' &&
|
||||
node.callee.property.name === 'push' || node.callee.property.name === 'replace') {
|
||||
|
||||
// Check if it's router.push/replace
|
||||
const calleeObj = node.callee.object;
|
||||
if (calleeObj.type === 'Identifier' && calleeObj.name === 'router') {
|
||||
const arg = node.arguments[0];
|
||||
if (arg && arg.type === 'Literal' && isHardcodedRoute(arg.value)) {
|
||||
reportHardcodedRoute(arg, arg.value, 'hardcodedRoute');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check redirect()
|
||||
if (node.callee.type === 'Identifier' && node.callee.name === 'redirect') {
|
||||
const arg = node.arguments[0];
|
||||
if (arg && arg.type === 'Literal' && isHardcodedRoute(arg.value)) {
|
||||
reportHardcodedRoute(arg, arg.value, 'hardcodedRedirect');
|
||||
}
|
||||
}
|
||||
|
||||
// Check revalidatePath()
|
||||
if (node.callee.type === 'Identifier' && node.callee.name === 'revalidatePath') {
|
||||
const arg = node.arguments[0];
|
||||
if (arg && arg.type === 'Literal' && isHardcodedRoute(arg.value)) {
|
||||
reportHardcodedRoute(arg, arg.value, 'hardcodedRevalidate');
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// Check Link href
|
||||
JSXOpeningElement(node) {
|
||||
if (node.name.type === 'JSXIdentifier' && node.name.name === 'Link') {
|
||||
const hrefAttr = node.attributes.find(attr =>
|
||||
attr.type === 'JSXAttribute' && attr.name.name === 'href'
|
||||
);
|
||||
|
||||
if (hrefAttr && hrefAttr.value && hrefAttr.value.type === 'Literal') {
|
||||
const value = hrefAttr.value.value;
|
||||
if (isHardcodedRoute(value)) {
|
||||
reportHardcodedRoute(hrefAttr.value, value, 'hardcodedLink');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check a href
|
||||
if (node.name.type === 'JSXIdentifier' && node.name.name === 'a') {
|
||||
const hrefAttr = node.attributes.find(attr =>
|
||||
attr.type === 'JSXAttribute' && attr.name.name === 'href'
|
||||
);
|
||||
|
||||
if (hrefAttr && hrefAttr.value && hrefAttr.value.type === 'Literal') {
|
||||
const value = hrefAttr.value.value;
|
||||
if (isHardcodedRoute(value)) {
|
||||
reportHardcodedRoute(hrefAttr.value, value, 'hardcodedAnchor');
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// Check template literals in href
|
||||
JSXAttribute(node) {
|
||||
if (node.name.name === 'href' && node.value && node.value.type === 'JSXExpressionContainer') {
|
||||
const expr = node.value.expression;
|
||||
if (expr.type === 'TemplateLiteral') {
|
||||
// Check if template literal contains hardcoded route
|
||||
for (const quasi of expr.quasis) {
|
||||
if (quasi.value && isHardcodedRoute(quasi.value.raw)) {
|
||||
reportHardcodedRoute(quasi, quasi.value.raw, 'hardcodedLink');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// Check string literals in general (catches other cases)
|
||||
Literal(node) {
|
||||
if (typeof node.value === 'string' && isHardcodedRoute(node.value)) {
|
||||
// Skip if it's already being handled by more specific checks
|
||||
const parent = node.parent;
|
||||
|
||||
// Skip if it's in a comment or already reported
|
||||
if (parent.type === 'Property' && parent.key === node) {
|
||||
return; // Object property key
|
||||
}
|
||||
|
||||
// Check if this is in a context we care about
|
||||
let isInRelevantContext = false;
|
||||
let messageId = 'hardcodedRoute';
|
||||
|
||||
// Walk up to find context
|
||||
let current = parent;
|
||||
while (current) {
|
||||
if (current.type === 'CallExpression') {
|
||||
if (current.callee.type === 'MemberExpression') {
|
||||
const prop = current.callee.property.name;
|
||||
if (prop === 'push' || prop === 'replace') {
|
||||
isInRelevantContext = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (current.callee.type === 'Identifier') {
|
||||
const name = current.callee.name;
|
||||
if (name === 'redirect' || name === 'revalidatePath') {
|
||||
isInRelevantContext = true;
|
||||
messageId = name === 'redirect' ? 'hardcodedRedirect' : 'hardcodedRevalidate';
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
current = current.parent;
|
||||
}
|
||||
|
||||
if (isInRelevantContext) {
|
||||
reportHardcodedRoute(node, node.value, messageId);
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
124
apps/website/eslint-rules/no-hardcoded-search-params.js
Normal file
124
apps/website/eslint-rules/no-hardcoded-search-params.js
Normal file
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* @file no-hardcoded-search-params.js
|
||||
* Enforces use of SearchParam system instead of manual URLSearchParams manipulation
|
||||
*/
|
||||
|
||||
module.exports = {
|
||||
meta: {
|
||||
type: 'problem',
|
||||
docs: {
|
||||
description: 'Enforce use of SearchParamBuilder/SearchParamParser instead of manual URLSearchParams manipulation',
|
||||
category: 'Best Practices',
|
||||
recommended: true,
|
||||
},
|
||||
fixable: 'code',
|
||||
schema: [],
|
||||
messages: {
|
||||
manualSearchParams: 'Manual URLSearchParams construction. Use SearchParamBuilder instead: import { SearchParamBuilder } from "@/lib/routing/search-params"',
|
||||
manualGetParam: 'Manual search param access with get(). Use SearchParamParser instead: import { SearchParamParser } from "@/lib/routing/search-params"',
|
||||
manualSetParam: 'Manual search param setting with set(). Use SearchParamBuilder instead',
|
||||
},
|
||||
},
|
||||
|
||||
create(context) {
|
||||
return {
|
||||
// Detect: new URLSearchParams()
|
||||
NewExpression(node) {
|
||||
if (node.callee.type === 'Identifier' && node.callee.name === 'URLSearchParams') {
|
||||
// Check if it's being used for construction (not just parsing)
|
||||
const parent = node.parent;
|
||||
|
||||
// If it's in a call expression like new URLSearchParams().toString()
|
||||
// or new URLSearchParams().get()
|
||||
if (parent.type === 'MemberExpression') {
|
||||
const property = parent.property.name;
|
||||
if (property === 'toString' || property === 'set' || property === 'append') {
|
||||
context.report({
|
||||
node,
|
||||
messageId: 'manualSearchParams',
|
||||
});
|
||||
}
|
||||
}
|
||||
// If it's just new URLSearchParams() without further use
|
||||
else if (parent.type === 'VariableDeclarator' || parent.type === 'AssignmentExpression') {
|
||||
context.report({
|
||||
node,
|
||||
messageId: 'manualSearchParams',
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// Detect: params.get() or params.set()
|
||||
CallExpression(node) {
|
||||
if (node.callee.type === 'MemberExpression') {
|
||||
const object = node.callee.object;
|
||||
const property = node.callee.property.name;
|
||||
|
||||
// Check if it's a URLSearchParams instance
|
||||
if (object.type === 'Identifier' &&
|
||||
(property === 'get' || property === 'set' || property === 'append' || property === 'delete')) {
|
||||
|
||||
// Try to trace back to see if it's URLSearchParams
|
||||
let current = object;
|
||||
let isUrlSearchParams = false;
|
||||
|
||||
// Check if it's from URLSearchParams constructor
|
||||
const scope = context.getScope();
|
||||
const variable = scope.variables.find(v => v.name === current.name);
|
||||
|
||||
if (variable && variable.defs.length > 0) {
|
||||
const def = variable.defs[0];
|
||||
if (def.node.init &&
|
||||
def.node.init.type === 'NewExpression' &&
|
||||
def.node.init.callee.name === 'URLSearchParams') {
|
||||
isUrlSearchParams = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (isUrlSearchParams) {
|
||||
if (property === 'get') {
|
||||
context.report({
|
||||
node,
|
||||
messageId: 'manualGetParam',
|
||||
});
|
||||
} else if (property === 'set' || property === 'append') {
|
||||
context.report({
|
||||
node,
|
||||
messageId: 'manualSetParam',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// Detect: searchParams.get() directly (from function parameter)
|
||||
MemberExpression(node) {
|
||||
if (node.property.type === 'Identifier' &&
|
||||
(node.property.name === 'get' || node.property.name === 'set')) {
|
||||
|
||||
// Check if object is named "searchParams" or "params"
|
||||
if (node.object.type === 'Identifier' &&
|
||||
(node.object.name === 'searchParams' || node.object.name === 'params')) {
|
||||
|
||||
// Check parent is a call expression
|
||||
if (node.parent.type === 'CallExpression' && node.parent.callee === node) {
|
||||
if (node.property.name === 'get') {
|
||||
context.report({
|
||||
node,
|
||||
messageId: 'manualGetParam',
|
||||
});
|
||||
} else if (node.property.name === 'set') {
|
||||
context.report({
|
||||
node,
|
||||
messageId: 'manualSetParam',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
94
apps/website/eslint-rules/no-nextjs-imports-in-ui.js
Normal file
94
apps/website/eslint-rules/no-nextjs-imports-in-ui.js
Normal file
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* ESLint rule to forbid Next.js imports in components/ and ui/
|
||||
*
|
||||
* Next.js imports (from 'next/*') should only be used in:
|
||||
* - app/ directory (pages, layouts, routes)
|
||||
* - Server Actions
|
||||
*
|
||||
* Components and UI elements should be framework-agnostic.
|
||||
* Navigation and routing logic should be passed down from pages.
|
||||
*
|
||||
* Rationale:
|
||||
* - Keeps components/ui portable and testable
|
||||
* - Prevents framework coupling in reusable code
|
||||
* - Forces clear separation of concerns
|
||||
*/
|
||||
|
||||
module.exports = {
|
||||
meta: {
|
||||
type: 'problem',
|
||||
docs: {
|
||||
description: 'Forbid Next.js imports in components/ and ui/ directories',
|
||||
category: 'Architecture',
|
||||
recommended: true,
|
||||
},
|
||||
fixable: null,
|
||||
schema: [],
|
||||
messages: {
|
||||
noNextImports: 'Next.js imports are forbidden in {{dir}}/. Pass navigation/routing from app/ or use callbacks.',
|
||||
},
|
||||
},
|
||||
|
||||
create(context) {
|
||||
const filename = context.getFilename();
|
||||
const isInComponents = filename.includes('/components/');
|
||||
const isInUi = filename.includes('/ui/');
|
||||
|
||||
if (!isInComponents && !isInUi) return {};
|
||||
|
||||
const dir = isInComponents ? 'components' : 'ui';
|
||||
|
||||
// Next.js modules that should not be imported
|
||||
const forbiddenModules = [
|
||||
'next/navigation', // useRouter, usePathname, useSearchParams
|
||||
'next/router', // useRouter (pages router)
|
||||
'next/link', // Link component
|
||||
'next/image', // Image component
|
||||
'next/script', // Script component
|
||||
'next/head', // Head component
|
||||
'next/dynamic', // dynamic import
|
||||
'next/headers', // cookies, headers (server-only)
|
||||
'next/cache', // revalidatePath, revalidateTag
|
||||
];
|
||||
|
||||
return {
|
||||
ImportDeclaration(node) {
|
||||
const importSource = node.source.value;
|
||||
|
||||
// Check if importing from forbidden modules
|
||||
if (forbiddenModules.includes(importSource)) {
|
||||
context.report({
|
||||
node,
|
||||
messageId: 'noNextImports',
|
||||
data: { dir },
|
||||
});
|
||||
}
|
||||
|
||||
// Also check for direct next/* imports
|
||||
if (importSource.startsWith('next/')) {
|
||||
context.report({
|
||||
node,
|
||||
messageId: 'noNextImports',
|
||||
data: { dir },
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
// Also check for dynamic imports
|
||||
CallExpression(node) {
|
||||
if (node.callee.type === 'Identifier' && node.callee.name === 'import') {
|
||||
if (node.arguments.length > 0 && node.arguments[0].type === 'Literal') {
|
||||
const importPath = node.arguments[0].value;
|
||||
if (typeof importPath === 'string' && importPath.startsWith('next/')) {
|
||||
context.report({
|
||||
node,
|
||||
messageId: 'noNextImports',
|
||||
data: { dir },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
79
apps/website/eslint-rules/no-raw-html-in-app.js
Normal file
79
apps/website/eslint-rules/no-raw-html-in-app.js
Normal file
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* ESLint rule to forbid raw HTML in app/ directory
|
||||
*
|
||||
* All HTML must be encapsulated in React components from components/ or ui/
|
||||
*
|
||||
* Rationale:
|
||||
* - app/ should only contain page/layout components
|
||||
* - Raw HTML with styling violates separation of concerns
|
||||
* - UI logic belongs in components/ui layers
|
||||
*/
|
||||
|
||||
module.exports = {
|
||||
meta: {
|
||||
type: 'problem',
|
||||
docs: {
|
||||
description: 'Forbid raw HTML with styling in app/ directory',
|
||||
category: 'Architecture',
|
||||
recommended: true,
|
||||
},
|
||||
fixable: null,
|
||||
schema: [],
|
||||
messages: {
|
||||
noRawHtml: 'Raw HTML with styling is forbidden in app/. Use a component from components/ or ui/.',
|
||||
},
|
||||
},
|
||||
|
||||
create(context) {
|
||||
const filename = context.getFilename();
|
||||
const isInApp = filename.includes('/app/');
|
||||
|
||||
if (!isInApp) return {};
|
||||
|
||||
// HTML tags that should be wrapped in components
|
||||
const htmlTags = [
|
||||
'div', 'span', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
|
||||
'p', 'button', 'input', 'form', 'label', 'select', 'textarea',
|
||||
'ul', 'ol', 'li', 'table', 'tr', 'td', 'th', 'thead', 'tbody',
|
||||
'section', 'article', 'header', 'footer', 'nav', 'aside',
|
||||
'main', 'aside', 'figure', 'figcaption', 'blockquote', 'code',
|
||||
'pre', 'a', 'img', 'svg', 'path', 'g', 'rect', 'circle'
|
||||
];
|
||||
|
||||
return {
|
||||
JSXElement(node) {
|
||||
const openingElement = node.openingElement;
|
||||
|
||||
if (openingElement.name.type !== 'JSXIdentifier') return;
|
||||
|
||||
const tagName = openingElement.name.name;
|
||||
|
||||
// Check if it's a raw HTML element (lowercase)
|
||||
if (htmlTags.includes(tagName) && tagName[0] === tagName[0].toLowerCase()) {
|
||||
|
||||
// Check for styling attributes
|
||||
const hasClassName = openingElement.attributes.some(
|
||||
attr => attr.type === 'JSXAttribute' && attr.name.name === 'className'
|
||||
);
|
||||
const hasStyle = openingElement.attributes.some(
|
||||
attr => attr.type === 'JSXAttribute' && attr.name.name === 'style'
|
||||
);
|
||||
|
||||
// Check for inline event handlers (also a concern)
|
||||
const hasInlineHandlers = openingElement.attributes.some(
|
||||
attr => attr.type === 'JSXAttribute' &&
|
||||
attr.name.name &&
|
||||
attr.name.name.startsWith('on')
|
||||
);
|
||||
|
||||
if (hasClassName || hasStyle || hasInlineHandlers) {
|
||||
context.report({
|
||||
node,
|
||||
messageId: 'noRawHtml',
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
103
apps/website/eslint-rules/ui-element-purity.js
Normal file
103
apps/website/eslint-rules/ui-element-purity.js
Normal file
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* ESLint rule to enforce UI element purity
|
||||
*
|
||||
* UI elements in ui/ must be:
|
||||
* - Stateless (no useState, useReducer)
|
||||
* - No side effects (no useEffect)
|
||||
* - Pure functions that only render based on props
|
||||
*
|
||||
* Rationale:
|
||||
* - ui/ is for reusable, pure presentation elements
|
||||
* - Stateful logic belongs in components/ or hooks/
|
||||
* - This ensures maximum reusability and testability
|
||||
*/
|
||||
|
||||
module.exports = {
|
||||
meta: {
|
||||
type: 'problem',
|
||||
docs: {
|
||||
description: 'Enforce UI elements are pure and stateless',
|
||||
category: 'Architecture',
|
||||
recommended: true,
|
||||
},
|
||||
fixable: null,
|
||||
schema: [],
|
||||
messages: {
|
||||
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.',
|
||||
},
|
||||
},
|
||||
|
||||
create(context) {
|
||||
const filename = context.getFilename();
|
||||
const isInUi = filename.includes('/ui/');
|
||||
|
||||
if (!isInUi) return {};
|
||||
|
||||
let hasStateHooks = false;
|
||||
let hasEffectHooks = false;
|
||||
let hasContext = false;
|
||||
|
||||
return {
|
||||
// Check for state hooks
|
||||
CallExpression(node) {
|
||||
if (node.callee.type !== 'Identifier') return;
|
||||
|
||||
const hookName = node.callee.name;
|
||||
|
||||
// State management hooks
|
||||
if (['useState', 'useReducer', 'useRef'].includes(hookName)) {
|
||||
hasStateHooks = true;
|
||||
context.report({
|
||||
node,
|
||||
messageId: 'noStateInUi',
|
||||
});
|
||||
}
|
||||
|
||||
// Effect hooks
|
||||
if (['useEffect', 'useLayoutEffect', 'useInsertionEffect'].includes(hookName)) {
|
||||
hasEffectHooks = true;
|
||||
context.report({
|
||||
node,
|
||||
messageId: 'noSideEffects',
|
||||
});
|
||||
}
|
||||
|
||||
// Context (can introduce state)
|
||||
if (hookName === 'useContext') {
|
||||
hasContext = true;
|
||||
context.report({
|
||||
node,
|
||||
messageId: 'noStateInUi',
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
// 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',
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
400
docs/architecture/website/COMPONENT_ARCHITECTURE.md
Normal file
400
docs/architecture/website/COMPONENT_ARCHITECTURE.md
Normal file
@@ -0,0 +1,400 @@
|
||||
# Component Architecture
|
||||
|
||||
This document defines the strict separation of concerns for all UI code in the website layer.
|
||||
|
||||
## The Three Layers
|
||||
|
||||
```
|
||||
apps/website/
|
||||
├── app/ ← App Layer (Pages & Layouts)
|
||||
├── components/ ← Component Layer (App Components)
|
||||
├── ui/ ← UI Layer (Pure Elements)
|
||||
└── hooks/ ← Shared Logic
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 1. App Layer (`app/`)
|
||||
|
||||
**Purpose**: Pages, layouts, and routing configuration.
|
||||
|
||||
**Characteristics**:
|
||||
- ✅ Only page.tsx, layout.tsx, route.tsx files
|
||||
- ✅ Can import from `components/`, `ui/`, `hooks/`
|
||||
- ✅ Can use Next.js features (redirect, cookies, headers)
|
||||
- ✅ Can use Server Components
|
||||
- ❌ **NO raw HTML with styling**
|
||||
- ❌ **NO business logic**
|
||||
- ❌ **NO state management**
|
||||
|
||||
**Allowed**:
|
||||
```typescript
|
||||
// app/dashboard/page.tsx
|
||||
export default function DashboardPage() {
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<DashboardHeader />
|
||||
<DashboardStats />
|
||||
<DashboardActions />
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Forbidden**:
|
||||
```typescript
|
||||
// ❌ WRONG - Raw HTML in app/
|
||||
export default function DashboardPage() {
|
||||
return (
|
||||
<div className="bg-white p-6"> {/* ❌ No raw HTML with styling */}
|
||||
<h1 className="text-2xl">Dashboard</h1>
|
||||
<button onClick={handleSubmit}>Click</button> {/* ❌ No inline handlers */}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Component Layer (`components/`)
|
||||
|
||||
**Purpose**: App-level components that may contain state and business logic.
|
||||
|
||||
**Characteristics**:
|
||||
- ✅ Can be stateful (useState, useReducer)
|
||||
- ✅ Can have side effects (useEffect)
|
||||
- ✅ Can use hooks
|
||||
- ✅ Can contain business logic
|
||||
- ✅ Can import from `ui/`, `hooks/`
|
||||
- ❌ **NO Next.js imports** (navigation, routing)
|
||||
- ❌ **NO raw HTML with styling** (use ui/ elements)
|
||||
|
||||
**Allowed**:
|
||||
```typescript
|
||||
// components/DashboardHeader.tsx
|
||||
export function DashboardHeader() {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<header>
|
||||
<Button onClick={() => setIsOpen(!isOpen)}>Toggle</Button>
|
||||
{isOpen && <Menu />}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Forbidden**:
|
||||
```typescript
|
||||
// ❌ WRONG - Next.js imports in components/
|
||||
import { useRouter } from 'next/navigation'; // ❌
|
||||
|
||||
export function DashboardHeader() {
|
||||
const router = useRouter(); // ❌
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. UI Layer (`ui/`)
|
||||
|
||||
**Purpose**: Pure, reusable, stateless UI elements.
|
||||
|
||||
**Characteristics**:
|
||||
- ✅ Stateless (no useState, useReducer)
|
||||
- ✅ No side effects (no useEffect)
|
||||
- ✅ Pure functions based on props
|
||||
- ✅ Maximum reusability
|
||||
- ✅ Framework-agnostic
|
||||
- ❌ **NO state management**
|
||||
- ❌ **NO Next.js imports**
|
||||
- ❌ **NO business logic**
|
||||
|
||||
**Allowed**:
|
||||
```typescript
|
||||
// ui/Button.tsx
|
||||
export function Button({ children, onClick, variant = 'primary' }) {
|
||||
const className = variant === 'primary'
|
||||
? 'bg-blue-500 text-white'
|
||||
: 'bg-gray-200 text-black';
|
||||
|
||||
return (
|
||||
<button className={className} onClick={onClick}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// ui/Card.tsx
|
||||
export function Card({ children, className = '' }) {
|
||||
return (
|
||||
<div className={`bg-white rounded-lg shadow ${className}`}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Forbidden**:
|
||||
```typescript
|
||||
// ❌ WRONG - State in UI element
|
||||
export function Button({ children }) {
|
||||
const [isLoading, setIsLoading] = useState(false); // ❌
|
||||
|
||||
return <button>{isLoading ? 'Loading...' : children}</button>;
|
||||
}
|
||||
|
||||
// ❌ WRONG - Next.js imports
|
||||
import { useRouter } from 'next/navigation'; // ❌
|
||||
|
||||
export function Link({ href, children }) {
|
||||
const router = useRouter(); // ❌
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Hooks Layer (`hooks/`)
|
||||
|
||||
**Purpose**: Shared stateful logic.
|
||||
|
||||
**Characteristics**:
|
||||
- ✅ Can use all hooks
|
||||
- ✅ Can contain business logic
|
||||
- ✅ Can be used by components and pages
|
||||
- ❌ **NO JSX**
|
||||
- ❌ **NO UI rendering**
|
||||
|
||||
**Allowed**:
|
||||
```typescript
|
||||
// hooks/useDropdown.ts
|
||||
export function useDropdown() {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
return {
|
||||
isOpen,
|
||||
open: () => setIsOpen(true),
|
||||
close: () => setIsOpen(false),
|
||||
toggle: () => setIsOpen(!isOpen),
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ESLint Enforcement
|
||||
|
||||
### 1. **No Raw HTML in `app/`**
|
||||
```json
|
||||
{
|
||||
"files": ["app/**/*.tsx", "app/**/*.ts"],
|
||||
"rules": {
|
||||
"gridpilot-rules/no-raw-html-in-app": "error"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Catches**:
|
||||
- `<div className="...">` in app/
|
||||
- `<button style={{...}}>` in app/
|
||||
- `<span onClick={...}>` in app/
|
||||
|
||||
### 2. **UI Element Purity**
|
||||
```json
|
||||
{
|
||||
"files": ["ui/**/*.tsx", "ui/**/*.ts"],
|
||||
"rules": {
|
||||
"gridpilot-rules/ui-element-purity": "error"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Catches**:
|
||||
- `useState` in ui/
|
||||
- `useEffect` in ui/
|
||||
- `useContext` in ui/
|
||||
|
||||
### 3. **No Next.js in UI/Components**
|
||||
```json
|
||||
{
|
||||
"files": ["ui/**/*", "components/**/*"],
|
||||
"rules": {
|
||||
"gridpilot-rules/no-nextjs-imports-in-ui": "error"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Catches**:
|
||||
- `import { useRouter } from 'next/navigation'`
|
||||
- `import { redirect } from 'next/navigation'`
|
||||
- `import Link from 'next/link'`
|
||||
|
||||
### 4. **Component Classification (Suggestions)**
|
||||
```json
|
||||
{
|
||||
"files": ["components/**/*", "ui/**/*"],
|
||||
"rules": {
|
||||
"gridpilot-rules/component-classification": "warn"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Suggests**:
|
||||
- Move pure components to ui/
|
||||
- Move stateful elements to components/
|
||||
|
||||
---
|
||||
|
||||
## Dependency Flow
|
||||
|
||||
```
|
||||
app/ (pages)
|
||||
↓ imports from
|
||||
components/ (stateful)
|
||||
↓ imports from
|
||||
ui/ (pure)
|
||||
↓ imports from
|
||||
hooks/ (logic)
|
||||
```
|
||||
|
||||
**Never**:
|
||||
- app/ → hooks/ (pages don't need hooks directly)
|
||||
- ui/ → components/ (ui must stay pure)
|
||||
- ui/ → next/navigation (ui must be framework-agnostic)
|
||||
- components/ → next/navigation (navigation should be passed from pages)
|
||||
|
||||
---
|
||||
|
||||
## Examples
|
||||
|
||||
### ✅ Correct Architecture
|
||||
|
||||
```typescript
|
||||
// app/dashboard/page.tsx
|
||||
export default function DashboardPage() {
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<DashboardHeader />
|
||||
<DashboardStats />
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
|
||||
// components/DashboardHeader.tsx
|
||||
export function DashboardHeader() {
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
return (
|
||||
<header>
|
||||
<SearchInput value={search} onChange={setSearch} />
|
||||
<Button onClick={handleSearch}>Search</Button>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
// ui/SearchInput.tsx
|
||||
export function SearchInput({ value, onChange }) {
|
||||
return (
|
||||
<input
|
||||
value={value}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
className="border p-2"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// ui/Button.tsx
|
||||
export function Button({ children, onClick }) {
|
||||
return (
|
||||
<button onClick={onClick} className="bg-blue-500 text-white">
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### ❌ Wrong Architecture
|
||||
|
||||
```typescript
|
||||
// app/dashboard/page.tsx
|
||||
export default function DashboardPage() {
|
||||
const [search, setSearch] = useState(''); // ❌ State in page
|
||||
|
||||
return (
|
||||
<div className="p-6"> {/* ❌ Raw HTML */}
|
||||
<input
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)} // ❌ State logic
|
||||
/>
|
||||
<button onClick={() => router.push('/search')}> {/* ❌ Router in page */}
|
||||
Search
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Benefits
|
||||
|
||||
1. **Reusability**: UI elements can be used anywhere
|
||||
2. **Testability**: Pure functions are easy to test
|
||||
3. **Maintainability**: Clear separation of concerns
|
||||
4. **Performance**: No unnecessary re-renders
|
||||
5. **Type Safety**: Each layer has clear contracts
|
||||
|
||||
---
|
||||
|
||||
## Migration Guide
|
||||
|
||||
If you have existing code that violates these rules:
|
||||
|
||||
1. **Extract UI elements** from app/ to ui/
|
||||
2. **Move stateful logic** from ui/ to components/
|
||||
3. **Remove Next.js imports** from components/ui (pass callbacks from pages)
|
||||
4. **Use hooks/** for shared logic
|
||||
|
||||
**Example migration**:
|
||||
```typescript
|
||||
// Before
|
||||
// app/page.tsx
|
||||
export default function Page() {
|
||||
return <div className="bg-white p-6">...</div>;
|
||||
}
|
||||
|
||||
// After
|
||||
// app/page.tsx
|
||||
export default function Page() {
|
||||
return <HomePage />;
|
||||
}
|
||||
|
||||
// components/HomePage.tsx
|
||||
export function HomePage() {
|
||||
return (
|
||||
<PageContainer>
|
||||
<HomeContent />
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
// ui/PageContainer.tsx
|
||||
export function PageContainer({ children }) {
|
||||
return <div className="bg-white p-6">{children}</div>;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
| Layer | State | Next.js | HTML | Purpose |
|
||||
|-------|-------|---------|------|---------|
|
||||
| **app/** | ❌ No | ✅ Yes | ❌ No | Pages & layouts |
|
||||
| **components/** | ✅ Yes | ❌ No | ❌ No | App components |
|
||||
| **ui/** | ❌ No | ❌ No | ✅ Yes | Pure elements |
|
||||
| **hooks/** | ✅ Yes | ❌ No | ❌ No | Shared logic |
|
||||
|
||||
**Golden Rule**: Each layer should only depend on layers below it.
|
||||
Reference in New Issue
Block a user