website refactor
This commit is contained in:
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);
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user