From 187d2476e81a55bdd8c2f8c03c4e7b6fe5b54320 Mon Sep 17 00:00:00 2001 From: Marc Mintel Date: Tue, 13 Jan 2026 12:10:15 +0100 Subject: [PATCH] website refactor --- .../eslint-rules/component-classification.js | 82 ++++ apps/website/eslint-rules/index.js | 19 + .../eslint-rules/no-hardcoded-routes.js | 288 +++++++++++++ .../no-hardcoded-search-params.js | 124 ++++++ .../eslint-rules/no-nextjs-imports-in-ui.js | 94 ++++ .../eslint-rules/no-raw-html-in-app.js | 79 ++++ .../website/eslint-rules/ui-element-purity.js | 103 +++++ .../website/COMPONENT_ARCHITECTURE.md | 400 ++++++++++++++++++ 8 files changed, 1189 insertions(+) create mode 100644 apps/website/eslint-rules/component-classification.js create mode 100644 apps/website/eslint-rules/no-hardcoded-routes.js create mode 100644 apps/website/eslint-rules/no-hardcoded-search-params.js create mode 100644 apps/website/eslint-rules/no-nextjs-imports-in-ui.js create mode 100644 apps/website/eslint-rules/no-raw-html-in-app.js create mode 100644 apps/website/eslint-rules/ui-element-purity.js create mode 100644 docs/architecture/website/COMPONENT_ARCHITECTURE.md diff --git a/apps/website/eslint-rules/component-classification.js b/apps/website/eslint-rules/component-classification.js new file mode 100644 index 000000000..756dcd12d --- /dev/null +++ b/apps/website/eslint-rules/component-classification.js @@ -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', + }); + } + } + }, + }; + }, +}; diff --git a/apps/website/eslint-rules/index.js b/apps/website/eslint-rules/index.js index 3f9a27734..7ed952df3 100644 --- a/apps/website/eslint-rules/index.js +++ b/apps/website/eslint-rules/index.js @@ -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', }, }, diff --git a/apps/website/eslint-rules/no-hardcoded-routes.js b/apps/website/eslint-rules/no-hardcoded-routes.js new file mode 100644 index 000000000..88b5595b7 --- /dev/null +++ b/apps/website/eslint-rules/no-hardcoded-routes.js @@ -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); + } + } + }, + }; + }, +}; diff --git a/apps/website/eslint-rules/no-hardcoded-search-params.js b/apps/website/eslint-rules/no-hardcoded-search-params.js new file mode 100644 index 000000000..7c9298a04 --- /dev/null +++ b/apps/website/eslint-rules/no-hardcoded-search-params.js @@ -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', + }); + } + } + } + } + }, + }; + }, +}; diff --git a/apps/website/eslint-rules/no-nextjs-imports-in-ui.js b/apps/website/eslint-rules/no-nextjs-imports-in-ui.js new file mode 100644 index 000000000..b85d89ba5 --- /dev/null +++ b/apps/website/eslint-rules/no-nextjs-imports-in-ui.js @@ -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 }, + }); + } + } + } + }, + }; + }, +}; diff --git a/apps/website/eslint-rules/no-raw-html-in-app.js b/apps/website/eslint-rules/no-raw-html-in-app.js new file mode 100644 index 000000000..76556c9ac --- /dev/null +++ b/apps/website/eslint-rules/no-raw-html-in-app.js @@ -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', + }); + } + } + }, + }; + }, +}; diff --git a/apps/website/eslint-rules/ui-element-purity.js b/apps/website/eslint-rules/ui-element-purity.js new file mode 100644 index 000000000..df10bec0c --- /dev/null +++ b/apps/website/eslint-rules/ui-element-purity.js @@ -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', + }); + } + }, + }; + }, +}; diff --git a/docs/architecture/website/COMPONENT_ARCHITECTURE.md b/docs/architecture/website/COMPONENT_ARCHITECTURE.md new file mode 100644 index 000000000..1bbffe952 --- /dev/null +++ b/docs/architecture/website/COMPONENT_ARCHITECTURE.md @@ -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 ( + + + + + + ); +} +``` + +**Forbidden**: +```typescript +// ❌ WRONG - Raw HTML in app/ +export default function DashboardPage() { + return ( +
{/* ❌ No raw HTML with styling */} +

Dashboard

+ {/* ❌ No inline handlers */} +
+ ); +} +``` + +--- + +## 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 ( +
+ + {isOpen && } +
+ ); +} +``` + +**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 ( + + ); +} + +// ui/Card.tsx +export function Card({ children, className = '' }) { + return ( +
+ {children} +
+ ); +} +``` + +**Forbidden**: +```typescript +// ❌ WRONG - State in UI element +export function Button({ children }) { + const [isLoading, setIsLoading] = useState(false); // ❌ + + return ; +} + +// ❌ 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**: +- `
` in app/ +- ` + + ); +} + +// ui/SearchInput.tsx +export function SearchInput({ value, onChange }) { + return ( + onChange(e.target.value)} + className="border p-2" + /> + ); +} + +// ui/Button.tsx +export function Button({ children, onClick }) { + return ( + + ); +} +``` + +### ❌ Wrong Architecture + +```typescript +// app/dashboard/page.tsx +export default function DashboardPage() { + const [search, setSearch] = useState(''); // ❌ State in page + + return ( +
{/* ❌ Raw HTML */} + setSearch(e.target.value)} // ❌ State logic + /> + +
+ ); +} +``` + +--- + +## 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
...
; +} + +// After +// app/page.tsx +export default function Page() { + return ; +} + +// components/HomePage.tsx +export function HomePage() { + return ( + + + + ); +} + +// ui/PageContainer.tsx +export function PageContainer({ children }) { + return
{children}
; +} +``` + +--- + +## 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. \ No newline at end of file