eslint-plugin-next-route-params
Version:
eslint rule to enforce correct route parameters for Next.js
823 lines (822 loc) • 38.7 kB
JavaScript
//#region \0rolldown/runtime.js
var __create = Object.create;
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __getProtoOf = Object.getPrototypeOf;
var __hasOwnProp = Object.prototype.hasOwnProperty;
var __copyProps = (to, from, except, desc) => {
if (from && typeof from === "object" || typeof from === "function") for (var keys = __getOwnPropNames(from), i = 0, n = keys.length, key; i < n; i++) {
key = keys[i];
if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, {
get: ((k) => from[k]).bind(null, key),
enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable
});
}
return to;
};
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", {
value: mod,
enumerable: true
}) : target, mod));
//#endregion
let _typescript_eslint_utils = require("@typescript-eslint/utils");
let typescript = require("typescript");
typescript = __toESM(typescript, 1);
let path = require("path");
path = __toESM(path, 1);
let semver = require("semver");
semver = __toESM(semver, 1);
let fs = require("fs");
fs = __toESM(fs, 1);
//#region src/utils/constants.ts
const PARAMS_PROP_NAME = "params";
const SEARCHPARAMS_PROP_NAME = "searchParams";
const CHILDREN_PROP_NAME = "children";
const ALLOWED_PROPS_FOR_PAGE = [PARAMS_PROP_NAME, SEARCHPARAMS_PROP_NAME];
const ALLOWED_PROPS_FOR_LAYOUT = [PARAMS_PROP_NAME, CHILDREN_PROP_NAME];
const METADATA_FUNCTION_NAMES = ["generateMetadata", "generateMetadataFile"];
const ROUTE_HANDLERS_FUNCTION_NAMES = [
"GET",
"POST",
"PUT",
"PATCH",
"DELETE",
"HEAD",
"OPTIONS"
];
//#endregion
//#region src/utils/validation/unwrapPromise.ts
function unwrapPromise(potentialPromise, fn) {
if (potentialPromise != null && potentialPromise.type === _typescript_eslint_utils.AST_NODE_TYPES.TSPropertySignature && potentialPromise.typeAnnotation?.typeAnnotation.type === _typescript_eslint_utils.AST_NODE_TYPES.TSTypeReference) {
const typeName = potentialPromise.typeAnnotation.typeAnnotation.typeName;
if (typeName.type === _typescript_eslint_utils.AST_NODE_TYPES.Identifier && typeName.name === "Promise") {
const firstParam = potentialPromise.typeAnnotation.typeAnnotation.typeArguments?.params[0];
return {
isPromise: true,
promiseType: fn != null ? fn(firstParam) : firstParam
};
}
}
return {
isPromise: false,
promiseType: void 0
};
}
//#endregion
//#region src/utils/types/typeGenerator.ts
function wrapTypeWithIdentifier({ type, identifier }) {
return typescript.default.factory.createTypeReferenceNode(typescript.default.factory.createIdentifier(identifier), [type]);
}
function wrapTypeWithPromise({ type }) {
return wrapTypeWithIdentifier({
type,
identifier: "Promise"
});
}
function wrapTypeWithArray({ type }) {
return typescript.default.factory.createArrayTypeNode(type);
}
function makeUnionType(...types) {
return typescript.default.factory.createUnionTypeNode(types);
}
function makeRecordType(from, to) {
return typescript.default.factory.createTypeReferenceNode(typescript.default.factory.createIdentifier("Record"), [from, to]);
}
const STRING_TYPE_NODE = typescript.default.factory.createKeywordTypeNode(typescript.default.SyntaxKind.StringKeyword);
/**
* will equal to ```[key: string]```
*/
const SEARCHPARAMS_KEY_TYPE_NODE = typescript.default.factory.createParameterDeclaration(void 0, void 0, typescript.default.factory.createIdentifier("key"), void 0, STRING_TYPE_NODE);
/**
* will equal to ```string | string[] | undefined```
*/
const SEARCHPARAMS_VALUE_TYPE_NODE = makeUnionType(STRING_TYPE_NODE, wrapTypeWithArray({ type: STRING_TYPE_NODE }), typescript.default.factory.createKeywordTypeNode(typescript.default.SyntaxKind.UndefinedKeyword));
/**
* will equal to ```[key :string]: string | string[] | undefined```
*/
const SEARCHPARAMS_TYPE_NODE = (asyncRequestAPI) => {
const searchParamsType = typescript.default.factory.createTypeLiteralNode([typescript.default.factory.createIndexSignature(void 0, [SEARCHPARAMS_KEY_TYPE_NODE], SEARCHPARAMS_VALUE_TYPE_NODE)]);
if (!asyncRequestAPI) return searchParamsType;
return wrapTypeWithPromise({ type: searchParamsType });
};
/**
* will equal to ```Record<string, never>```
*/
const createEmptyParamsTypeNode = ({ asyncRequestAPI }) => {
const recordType = makeRecordType(STRING_TYPE_NODE, typescript.default.factory.createKeywordTypeNode(typescript.default.SyntaxKind.NeverKeyword));
if (asyncRequestAPI) return wrapTypeWithPromise({ type: recordType });
return recordType;
};
/**
* will equal to an object literal
*
* ```[ {name: "postId", catchAll: false } , { name: "userId", catchAll: true }]``` will equal to
* ```
* { postId: string,
* userId: string[]
* }
* ```
*
*/
function createParamsTypeNode({ asyncRequestAPI, params }) {
const paramsType = typescript.default.factory.createTypeLiteralNode(params.map((param) => typescript.default.factory.createPropertySignature(void 0, typescript.default.factory.createIdentifier(param.name), void 0, !param.catchAll ? STRING_TYPE_NODE : typescript.default.factory.createArrayTypeNode(STRING_TYPE_NODE))));
if (!asyncRequestAPI) return paramsType;
return wrapTypeWithPromise({ type: paramsType });
}
const createGenerateStaticParamsReturntypeArrayArgument = (params) => typescript.default.factory.createTypeLiteralNode(params.map((param) => typescript.default.factory.createPropertySignature(void 0, typescript.default.factory.createIdentifier(param.name), typescript.default.factory.createToken(typescript.default.SyntaxKind.QuestionToken), !param.catchAll ? STRING_TYPE_NODE : typescript.default.factory.createArrayTypeNode(STRING_TYPE_NODE))));
const createGenerateStaticParamsReturntype = (async, params) => {
const type = wrapTypeWithArray({ type: createGenerateStaticParamsReturntypeArrayArgument(params) });
if (async) return wrapTypeWithPromise({ type });
return type;
};
function stringify(typeNodeOrFactory) {
const stringifier = (typeNode) => typescript.default.createPrinter().printNode(typescript.default.EmitHint.Unspecified, typeNode, void 0);
if (typeof typeNodeOrFactory === "function") return (...params) => stringifier(typeNodeOrFactory(...params));
return stringifier(typeNodeOrFactory);
}
/***************** STRINGIFIED TYPES******************/
const SEARCHPARAMS_TYPE_NODE_STRING = stringify(SEARCHPARAMS_TYPE_NODE);
const creatEmptyParamsTypeNodeString = stringify(createEmptyParamsTypeNode);
function createParamsTypeNodeString(generatorParams) {
return generatorParams.params.length === 0 ? creatEmptyParamsTypeNodeString(generatorParams) : stringify(createParamsTypeNode)(generatorParams);
}
const createGenerateStaticParamsReturntypeArrayArgumentString = stringify(createGenerateStaticParamsReturntypeArrayArgument);
const createGenerateStaticParamsReturntypeString = (async, params) => `${stringify(createGenerateStaticParamsReturntype(async, params))} `;
//#endregion
//#region src/utils/validation/validateSearchParams.ts
function validateSearchParams(propsType, { ruleContext, customContext }) {
const searchParamsMember = propsType.members.find((member) => member.type === _typescript_eslint_utils.AST_NODE_TYPES.TSPropertySignature && member.key.type === _typescript_eslint_utils.AST_NODE_TYPES.Identifier && member.key.name === "searchParams");
if (!searchParamsMember || !("typeAnnotation" in searchParamsMember) || !searchParamsMember.typeAnnotation) return;
function reportWrongSearchParamsTypeIssue(node, asyncRequestApi) {
ruleContext.report({
loc: searchParamsMember.loc,
messageId: "issue:wrong-searchParams-type",
data: { promiseOrEmpty: asyncRequestApi ? "a Promise" : "" },
fix: (fixer) => fixer.replaceTextRange(node.range, SEARCHPARAMS_TYPE_NODE_STRING(asyncRequestApi))
});
}
let typeToValidate;
if (customContext.asyncRequestAPI) {
const isPromise = unwrapPromise(searchParamsMember);
if (!isPromise.isPromise) {
reportWrongSearchParamsTypeIssue(searchParamsMember.typeAnnotation.typeAnnotation, true);
return;
} else typeToValidate = isPromise.promiseType;
} else typeToValidate = searchParamsMember.typeAnnotation.typeAnnotation;
if (!customContext.allowedPropsForFileNameType?.includes("searchParams")) return;
if (!validateSearchParamsStructure(typeToValidate)) reportWrongSearchParamsTypeIssue(searchParamsMember.typeAnnotation.typeAnnotation, !!customContext.asyncRequestAPI);
}
function validateSearchParamsStructure(member) {
return validateRecordTypeStructure(member) || validateIndexSignatureStructure(member);
}
function validateIndexSignatureStructure(member) {
if (!member || member.type !== _typescript_eslint_utils.AST_NODE_TYPES.TSTypeLiteral) return false;
const members = member.members;
if (members.length !== 1) return false;
const firstMember = members[0];
if (firstMember?.type !== _typescript_eslint_utils.AST_NODE_TYPES.TSIndexSignature || firstMember.typeAnnotation?.type !== _typescript_eslint_utils.AST_NODE_TYPES.TSTypeAnnotation || firstMember.typeAnnotation.typeAnnotation.type !== _typescript_eslint_utils.AST_NODE_TYPES.TSUnionType) return false;
const unionType = firstMember.typeAnnotation?.typeAnnotation;
const areAllowedSearchParamsTypes = extractTypes(unionType.types);
if (areAllowedSearchParamsTypes == null || Object.values(areAllowedSearchParamsTypes).includes(false)) return false;
return true;
}
/**
* required that input type is ```Record<string, string | string[] | undefined>```
*/
function validateRecordTypeStructure(typeNode) {
if (!typeNode || typeNode.type !== _typescript_eslint_utils.AST_NODE_TYPES.TSTypeReference) return false;
if (typeNode.typeName.type !== _typescript_eslint_utils.AST_NODE_TYPES.Identifier || typeNode.typeName.name !== "Record") return false;
const typeArguments = typeNode.typeArguments?.params;
if (!typeArguments || typeArguments.length !== 2) return false;
const [keyType, valueType] = typeArguments;
if (keyType?.type !== _typescript_eslint_utils.AST_NODE_TYPES.TSStringKeyword) return false;
if (valueType?.type !== _typescript_eslint_utils.AST_NODE_TYPES.TSUnionType) return false;
const allowedValueTypes = extractTypes(valueType.types);
if (allowedValueTypes == null || Object.values(allowedValueTypes).includes(false)) return false;
return true;
}
function extractTypes(types) {
return types.reduce((acc, element) => {
if (acc == null) return acc;
if (element.type === _typescript_eslint_utils.AST_NODE_TYPES.TSStringKeyword) {
acc.str = true;
return acc;
} else if (element.type === _typescript_eslint_utils.AST_NODE_TYPES.TSArrayType && element.elementType.type === _typescript_eslint_utils.AST_NODE_TYPES.TSStringKeyword || element.type === _typescript_eslint_utils.AST_NODE_TYPES.TSTypeReference && element.typeName.type === _typescript_eslint_utils.AST_NODE_TYPES.Identifier && element.typeName.name === "Array" && element.typeArguments?.params.length === 1 && element.typeArguments.params[0]?.type === _typescript_eslint_utils.AST_NODE_TYPES.TSStringKeyword) {
acc.strArr = true;
return acc;
} else if (element.type === _typescript_eslint_utils.AST_NODE_TYPES.TSUndefinedKeyword) {
acc.undef = true;
return acc;
} else return;
}, {
str: false,
strArr: false,
undef: false
});
}
//#endregion
//#region src/utils/validation/findReferencedType.ts
function findReferencedType(typeReference, { ruleContext }) {
if (typeReference.typeName.type !== _typescript_eslint_utils.AST_NODE_TYPES.Identifier) {
ruleContext.report({
loc: typeReference.loc,
messageId: "issue:isNoLiteral"
});
return null;
}
const nameOfReferencedType = typeReference.typeName.name;
const node = ruleContext.sourceCode.scopeManager?.variables?.find(({ name, isTypeVariable }) => name === nameOfReferencedType && isTypeVariable)?.defs[0]?.node;
if (node?.type !== _typescript_eslint_utils.AST_NODE_TYPES.TSTypeAliasDeclaration || node.typeAnnotation.type !== _typescript_eslint_utils.AST_NODE_TYPES.TSTypeLiteral) {
ruleContext.report({
loc: typeReference.loc,
messageId: "issue:isNoLiteral"
});
return null;
}
return node.typeAnnotation;
}
//#endregion
//#region src/utils/validation/validateParams.ts
function validateParams(paramsType, context) {
const paramsMember = paramsType.members.find((member) => member.type === _typescript_eslint_utils.AST_NODE_TYPES.TSPropertySignature && member.key.type === _typescript_eslint_utils.AST_NODE_TYPES.Identifier && member.key.name === ALLOWED_PROPS_FOR_PAGE[0]);
if (!paramsMember || !("typeAnnotation" in paramsMember) || !paramsMember.typeAnnotation) return;
if (context.customContext.asyncRequestAPI) {
const unwrappedPromise = unwrapPromise(paramsMember, (param) => {
switch (param?.type) {
case _typescript_eslint_utils.AST_NODE_TYPES.TSTypeLiteral: return param;
case _typescript_eslint_utils.AST_NODE_TYPES.TSTypeReference: return findReferencedType(param, context) ?? void 0;
default: return;
}
});
if (!unwrappedPromise.isPromise) {
reportAsyncRequestApiWrongParameterIssue(context, paramsMember.typeAnnotation.typeAnnotation);
return;
} else validateParamsStructure({
typeToValidate: unwrappedPromise.promiseType,
typeToReplace: paramsMember.typeAnnotation.typeAnnotation
}, context);
} else if (paramsMember.typeAnnotation.typeAnnotation.type === _typescript_eslint_utils.AST_NODE_TYPES.TSTypeLiteral) validateParamsStructure({
typeToValidate: paramsMember.typeAnnotation.typeAnnotation,
typeToReplace: paramsMember.typeAnnotation.typeAnnotation
}, context);
}
function reportAsyncRequestApiWrongParameterIssue(context, paramsTypeNode) {
context.ruleContext.report({
loc: paramsTypeNode.loc,
messageId: "issue:asyncRequestApi-params",
fix: (fixer) => fixer.replaceTextRange(paramsTypeNode.range, createParamsTypeNodeString(context.customContext))
});
}
function getTypeOfMember(member) {
if ("typeAnnotation" in member && member.typeAnnotation?.typeAnnotation.type === _typescript_eslint_utils.AST_NODE_TYPES.TSStringKeyword) return "string";
else if ("typeAnnotation" in member && member.typeAnnotation?.typeAnnotation.type === _typescript_eslint_utils.AST_NODE_TYPES.TSArrayType && member.typeAnnotation.typeAnnotation.elementType.type === _typescript_eslint_utils.AST_NODE_TYPES.TSStringKeyword) return "string[]";
else if ("typeAnnotation" in member && member.typeAnnotation?.typeAnnotation.type === _typescript_eslint_utils.AST_NODE_TYPES.TSTypeReference && member.typeAnnotation.typeAnnotation.typeName.type === _typescript_eslint_utils.AST_NODE_TYPES.Identifier && member.typeAnnotation.typeAnnotation.typeName.name === "Array" && member.typeAnnotation.typeAnnotation.typeArguments && member.typeAnnotation.typeAnnotation.typeArguments.params.length === 1 && member.typeAnnotation.typeAnnotation.typeArguments.params[0]?.type === _typescript_eslint_utils.AST_NODE_TYPES.TSStringKeyword) return "string[]";
else return "other";
}
function validateParamsStructure({ typeToValidate, typeToReplace }, context) {
if (!typeToValidate) return;
if (!(typeToValidate.type === _typescript_eslint_utils.AST_NODE_TYPES.TSTypeLiteral)) {
context.ruleContext.report({
loc: typeToReplace.loc,
messageId: "issue:isNoLiteral"
});
return;
}
const params = typeToValidate.members.map((member) => ({
range: "typeAnnotation" in member ? member.typeAnnotation?.typeAnnotation.range ?? null : null,
isLiteral: member.type === _typescript_eslint_utils.AST_NODE_TYPES.TSPropertySignature && member.key.type === _typescript_eslint_utils.AST_NODE_TYPES.Identifier,
type: getTypeOfMember(member),
name: "key" in member && "name" in member.key ? member.key.name : null
}));
const actualParamNames = context.customContext.params.map((param) => param.name);
const unAllowedParams = params.filter((param) => param.name == null || !actualParamNames.includes(param.name));
if (unAllowedParams.length > 0) context.ruleContext.report({
loc: typeToReplace.loc,
messageId: "issue:unknown-parameter",
data: { name: unAllowedParams[0].name },
fix: (fixer) => fixer.replaceTextRange(typeToReplace.range, createParamsTypeNodeString({
asyncRequestAPI: context.customContext.asyncRequestAPI,
params: context.customContext.params
}))
});
const routeParams = context.customContext.params.filter((param) => !param.catchAll).map((param) => param.name);
const catchAllParams = context.customContext.params.filter((param) => param.catchAll).map((param) => param.name);
const mustBeString = params.find((param) => routeParams.find((p) => param.name === p && param.type !== "string"));
if (mustBeString) reportWrongParameterIssue(context, typeToValidate, mustBeString.range, {
name: mustBeString.name,
type: "string"
});
const mustBeStringArray = params.find((param) => catchAllParams.find((p) => param.name === p && param.type !== "string[]"));
if (mustBeStringArray) reportWrongParameterIssue(context, typeToValidate, mustBeStringArray.range, {
name: mustBeStringArray.name,
type: "string[]"
});
if (params.filter((param) => param.isLiteral === false).length > 0) context.ruleContext.report({
loc: typeToValidate.loc,
messageId: "issue:isNoLiteral"
});
}
//#endregion
//#region src/utils/utils.ts
const HELPER_TYPE_MAP = {
page: "PageProps",
layout: "LayoutProps",
route: "RouteContext",
default: "LayoutProps"
};
function getHelperTypeForFile(context) {
const fileNameType = context.customContext.appRouterFilename;
if (!fileNameType || !(fileNameType in HELPER_TYPE_MAP)) return null;
return HELPER_TYPE_MAP[fileNameType];
}
function isCorrectHelperType(typeAnnotation, helperType) {
const inner = typeAnnotation.typeAnnotation;
if (!inner || typeof inner !== "object" || inner.type !== _typescript_eslint_utils.AST_NODE_TYPES.TSTypeReference) return false;
const typeRef = inner;
if (typeRef.typeName.type !== _typescript_eslint_utils.AST_NODE_TYPES.Identifier || typeRef.typeName.name !== helperType) return false;
return true;
}
function getHelperTypePath(typeAnnotation) {
const inner = typeAnnotation.typeAnnotation;
if (typeof inner !== "object" || !inner) return null;
if (inner.type !== _typescript_eslint_utils.AST_NODE_TYPES.TSTypeReference) return null;
const typeRef = inner;
if (!("typeArguments" in typeRef) || !typeRef.typeArguments || typeRef.typeArguments.params.length === 0) return null;
const firstParam = typeRef.typeArguments.params[0];
if (!firstParam || firstParam.type !== _typescript_eslint_utils.AST_NODE_TYPES.TSLiteralType) return null;
const literal = firstParam.literal;
if (literal.type !== _typescript_eslint_utils.AST_NODE_TYPES.Literal || typeof literal.value !== "string") return null;
return literal.value;
}
function handleFunctionParameters({ props, context, options, allowHelperTypes = false, forGenerateStaticParams = false }) {
if (!props || !("typeAnnotation" in props)) return;
if (forGenerateStaticParams && options[0]?.helperTypes) {
const parentRoutePath = context.customContext.parentRoutePath;
if (parentRoutePath != null && props.typeAnnotation) {
const currentText = context.ruleContext.sourceCode.getText(props.typeAnnotation.typeAnnotation);
if (currentText.includes(`PageProps<'${parentRoutePath}'>`) || currentText.includes(`PageProps<"${parentRoutePath}">`)) return;
const expectedType = `{ params?: Awaited<Omit<PageProps<'${parentRoutePath}'>, "searchParams">["params"]> }`;
context.ruleContext.report({
loc: props.typeAnnotation.loc,
messageId: "issue:useHelperTypeForGenerateStaticParams",
data: { routePath: parentRoutePath },
fix: (fixer) => fixer.replaceTextRange(props.typeAnnotation.range, `: ${expectedType}`)
});
return;
}
}
const innerTypeAnnotation = props.typeAnnotation?.typeAnnotation;
if (allowHelperTypes && options[0]?.helperTypes) {
const helperType = getHelperTypeForFile(context);
const routePath = context.customContext.routePath;
if (helperType && routePath && props.typeAnnotation) {
const typeAnnotation = props.typeAnnotation;
if (isCorrectHelperType(typeAnnotation, helperType)) {
const actualPath = getHelperTypePath(typeAnnotation);
if (actualPath === routePath) return;
context.ruleContext.report({
loc: typeAnnotation.loc,
messageId: "issue:wrong-helperType-path",
data: {
helperType,
expectedPath: routePath,
actualPath: actualPath ?? "not specified"
},
fix: (fixer) => fixer.replaceTextRange(typeAnnotation.range, `: ${helperType}<'${routePath}'>`)
});
return;
}
context.ruleContext.report({
loc: typeAnnotation.loc,
messageId: "issue:useHelperType",
data: {
helperType,
routePath
},
fix: (fixer) => fixer.replaceTextRange(typeAnnotation.range, `: ${helperType}<'${routePath}'>`)
});
return;
}
}
switch (innerTypeAnnotation?.type) {
case _typescript_eslint_utils.AST_NODE_TYPES.TSTypeLiteral:
validateFirstParameter(innerTypeAnnotation, context, options);
break;
case _typescript_eslint_utils.AST_NODE_TYPES.TSTypeReference: {
const referencedTSTypeLiteral = findReferencedType(innerTypeAnnotation, context);
if (referencedTSTypeLiteral != null) validateFirstParameter(referencedTSTypeLiteral, context, options);
break;
}
}
}
function reportWrongParameterIssue(context, paramsTypeNode, wrongTypeRange, data) {
context.ruleContext.report({
loc: paramsTypeNode.loc,
messageId: "issue:isWrongParameterType",
data,
fix: (fixer) => fixer.replaceTextRange(wrongTypeRange, data.type)
});
}
function validateFirstParameter(propsType, context, options) {
propsType.members.filter((member) => member.type === _typescript_eslint_utils.AST_NODE_TYPES.TSPropertySignature && member.key.type === _typescript_eslint_utils.AST_NODE_TYPES.Identifier && !context.customContext.allowedPropsForFileNameType?.includes(member.key.name)).forEach((member) => {
context.ruleContext.report({
loc: member.loc,
messageId: "issue:forbiddenPropertyKey",
data: { key: member.key.name },
fix: (fixer) => {
const sourceCode = context.ruleContext.sourceCode;
const tokenBefore = sourceCode.getTokenBefore(member);
const tokenAfter = sourceCode.getTokenAfter(member);
let rangeToRemove = member.range;
if (tokenAfter && tokenAfter.value === ",") rangeToRemove = [member.range[0], tokenAfter.range[1]];
else if (tokenBefore && tokenBefore.value === ",") rangeToRemove = [tokenBefore.range[0], member.range[1]];
return fixer.removeRange(rangeToRemove);
}
});
});
if (options[0]?.searchParams) validateSearchParams(propsType, context);
validateParams(propsType, context);
}
//#endregion
//#region src/utils/readPackageJson.ts
function readPackageJson() {
const packageJsonPath = path.default.join(process.cwd(), "package.json");
if (!fs.default.existsSync(packageJsonPath)) return null;
return JSON.parse(fs.default.readFileSync(packageJsonPath, "utf-8"));
}
//#endregion
//#region src/utils/fs.ts
const toPosixPath = (p) => p.split(path.default.sep).join(path.posix.sep);
const getFilePathToPosix = (filename) => {
return toPosixPath(path.default.dirname(filename));
};
/**
*
* @param dirname the dirname containing the folders, the separator must be "/"
* @returns true if a folder named "app" is found
*/
function appRouterFolderExists(dirname) {
return dirname.split(path.posix.sep).includes("app");
}
/**
*
*
* @param dirname the dirname containing the folders, the separator must be "/"
* @returns a list of the dynamic parameters and if if they are catch all parameters
*/
function readFileBasedParameters(dirname) {
const folders = dirname.split(path.posix.sep);
if (folders.findIndex((folder) => folder === "app") === -1) return [];
const result = folders.filter((folder) => folder.startsWith("[") && folder.endsWith("]")).map((folder) => {
const optionalCatchAll = folder.startsWith("[[...");
const catchAll = optionalCatchAll || folder.startsWith("[...");
return {
catchAll,
name: folder.slice(catchAll ? optionalCatchAll ? 5 : 4 : 1, optionalCatchAll ? -2 : -1),
current: false
};
});
if (folders[folders.length - 1]?.endsWith("]")) result[result.length - 1].current = true;
return result;
}
/**
*
* @param filename the filename containing the folders
* @returns true if the filename is one of Next.js' app router files that accecpt paramaters ({@link https://nextjs.org/docs/app/api-reference/file-conventions})
*/
function isAppRouterFile(filename) {
return filename !== null;
}
const FileNameTypeToAllowedPropsMap = {
default: [PARAMS_PROP_NAME],
layout: [...ALLOWED_PROPS_FOR_LAYOUT],
page: [...ALLOWED_PROPS_FOR_PAGE],
route: [PARAMS_PROP_NAME]
};
function getFileInfo(filename) {
const parsedPath = path.default.parse(filename);
const dirname = getFilePathToPosix(filename);
const fileNameType = getFilenameType(parsedPath.name);
return {
isAppRouterFile: isAppRouterFile(fileNameType),
appRouterFilename: fileNameType,
inInAppRouterFolder: appRouterFolderExists(dirname),
params: readFileBasedParameters(dirname),
asyncRequestAPI: isAsyncRequestAPI(),
allowedPropsForFileNameType: fileNameType ? FileNameTypeToAllowedPropsMap[fileNameType] : null,
routePath: getRoutePath(filename),
parentRoutePath: getParentRoutePath(filename)
};
}
/**
* Derives the Next.js route path from a file path for use with helper types
* like PageProps, LayoutProps, or RouteContext.
*
* Example: src/app/blog/[slug]/page.tsx -> /blog/[slug]
*/
function getRoutePath(filename) {
const segments = toPosixPath(filename).split(path.posix.sep);
const appIndex = segments.findIndex((s) => s === "app");
if (appIndex === -1) return null;
const filteredSegments = segments.slice(appIndex + 1, -1).filter((segment) => !segment.startsWith("(") && !segment.startsWith("@"));
if (filteredSegments.length === 0) return "/";
return "/" + filteredSegments.join("/");
}
function getParentRoutePath(filename) {
const routePath = getRoutePath(filename);
if (!routePath) return null;
if (routePath === "/") return "/";
const segments = routePath.split("/").filter(Boolean);
segments.pop();
if (segments.length === 0) return "/";
return "/" + segments.join("/");
}
function getFilenameType(filename) {
switch (path.default.parse(filename).name) {
case "page": return "page";
case "layout": return "layout";
case "default": return "default";
case "route": return "route";
default: return null;
}
}
function isAsyncRequestAPI() {
const packageJson = readPackageJson();
if (!packageJson) return null;
const nextDependency = packageJson.dependencies?.next;
if (!nextDependency) return null;
const minVersion = semver.minVersion(nextDependency);
if (!minVersion) return null;
const nextVersion = semver.major(minVersion);
if (!nextVersion) return null;
const isNotAsycRequestAPI = nextVersion > 12 && nextVersion < 14;
const isAsycRequestAPI = nextVersion > 14;
if (isNotAsycRequestAPI) return false;
if (isAsycRequestAPI) return true;
return null;
}
//#endregion
//#region src/utils/validation/validateGenerateStaticParams/validateReturntype.ts
const validateGenerateStaticParamsInnerReturnTypeOfArray = (paramsMember, { ruleContext, customContext }) => {
const params = paramsMember.members.map((member) => ({
range: "typeAnnotation" in member ? member.typeAnnotation?.typeAnnotation.range ?? null : null,
isLiteral: member.type === _typescript_eslint_utils.AST_NODE_TYPES.TSPropertySignature && member.key.type === _typescript_eslint_utils.AST_NODE_TYPES.Identifier,
type: getTypeOfMember(member),
name: "key" in member && "name" in member.key ? member.key.name : null,
optional: "optional" in member && member.optional
}));
const actualParamNames = customContext.params.map((param) => param.name);
const unAllowedParams = params.filter((param) => param.name == null || !actualParamNames.includes(param.name));
if (unAllowedParams.length > 0) {
ruleContext.report({
loc: paramsMember.loc,
messageId: "issue:unknown-parameter",
data: { name: unAllowedParams[0].name },
fix: (fixer) => fixer.replaceTextRange(paramsMember.range, createGenerateStaticParamsReturntypeArrayArgumentString(customContext.params))
});
return;
}
const routeParams = customContext.params.filter((param) => !param.catchAll).map((param) => param.name);
const catchAllParams = customContext.params.filter((param) => param.catchAll).map((param) => param.name);
const mustBeString = params.find((param) => routeParams.find((p) => param.name === p && param.type !== "string"));
if (mustBeString) {
reportWrongParameterIssue({
customContext,
ruleContext
}, paramsMember, mustBeString.range, {
name: mustBeString.name,
type: "string"
});
return;
}
const mustBeStringArray = params.find((param) => catchAllParams.find((p) => param.name === p && param.type !== "string[]"));
if (mustBeStringArray) {
reportWrongParameterIssue({
customContext,
ruleContext
}, paramsMember, mustBeStringArray.range, {
name: mustBeStringArray.name,
type: "string[]"
});
return;
}
if (params.filter((param) => param.isLiteral === false).length > 0) {
ruleContext.report({
loc: paramsMember.loc,
messageId: "issue:isNoLiteral"
});
return;
}
return {
functionTypes: [],
paramTypes: []
};
};
//#endregion
//#region src/utils/validation/validateGenerateStaticParams/validateFunction.ts
function validateGenerateStaticParamsFunction(functionNode, context) {
const returnType = functionNode.returnType;
if (returnType == null) {
const arrowToken = context.ruleContext.sourceCode.getFirstToken(functionNode, (token) => token.value === "=>");
if (functionNode.type === _typescript_eslint_utils.AST_NODE_TYPES.ArrowFunctionExpression && arrowToken != null) context.ruleContext.report({
loc: functionNode.loc,
messageId: "issue:no-returntype",
fix: (fixer) => fixer.insertTextBeforeRange(arrowToken.range, `: ${createGenerateStaticParamsReturntypeString(functionNode.async, context.customContext.params)}`)
});
else context.ruleContext.report({
loc: functionNode.loc,
messageId: "issue:no-returntype",
fix: (fixer) => fixer.insertTextBeforeRange(functionNode.body.range, `: ${createGenerateStaticParamsReturntypeString(functionNode.async, context.customContext.params)}`)
});
return;
}
if (!("typeAnnotation" in returnType)) return;
const fullTypeAnnotation = returnType.typeAnnotation;
const typeAnnotation = fullTypeAnnotation.type === _typescript_eslint_utils.AST_NODE_TYPES.TSTypeReference && fullTypeAnnotation.typeName.type === _typescript_eslint_utils.AST_NODE_TYPES.Identifier && fullTypeAnnotation.typeName.name === "Promise" && fullTypeAnnotation.typeArguments?.params[0] ? fullTypeAnnotation.typeArguments.params[0] : fullTypeAnnotation;
if (!(typeAnnotation.type === _typescript_eslint_utils.AST_NODE_TYPES.TSArrayType)) {
context.ruleContext.report({
loc: functionNode.loc,
messageId: "issue:wrong-returntype",
fix: (fixer) => fixer.replaceTextRange(returnType.range, `: ${createGenerateStaticParamsReturntypeString(functionNode.async, context.customContext.params)}`)
});
return;
}
const arrayType = typeAnnotation.elementType;
switch (arrayType.type) {
case _typescript_eslint_utils.AST_NODE_TYPES.TSTypeLiteral:
validateGenerateStaticParamsInnerReturnTypeOfArray(arrayType, context);
return;
case _typescript_eslint_utils.AST_NODE_TYPES.TSTypeReference: {
const type = findReferencedType(arrayType, context);
if (type) {
validateGenerateStaticParamsInnerReturnTypeOfArray(type, context);
return;
} else {
context.ruleContext.report({
loc: functionNode.loc,
messageId: "issue:isNoLiteral",
fix: (fixer) => fixer.replaceTextRange(returnType.range, createGenerateStaticParamsReturntypeString(functionNode.async, context.customContext.params))
});
return;
}
}
default:
context.ruleContext.report({
loc: functionNode.loc,
messageId: "issue:isNoLiteral",
fix: (fixer) => fixer.replaceTextRange(returnType.range, createGenerateStaticParamsReturntypeString(functionNode.async, context.customContext.params))
});
return;
}
}
//#endregion
//#region src/rules/enforce-route-params.ts
const createRule = _typescript_eslint_utils.ESLintUtils.RuleCreator(() => `https://www.paulhe.de/blog/next-route-params-eslint-rule`);
const GENERATE_STATIC_PARAMS_FUNCTION_NAME = "generateStaticParams";
//#endregion
//#region src/index.ts
const plugin = { rules: { "enforce-route-params": createRule({
name: "enforce-route-params",
meta: {
docs: { description: "enforce correct route parameters built by Next.js' file based routes" },
type: "problem",
messages: {
"issue:isWrongParameterType": "{{ name }} must be of type {{ type }}",
"issue:isNoLiteral": "Consider using an explicit type annotation",
"issue:unknown-parameter": "The param {{ name }} does not exist in the corresponding route path of this file",
"issue:forbiddenPropertyKey": "The property {{ key }} is forbidden",
"issue:wrong-searchParams-type": "searchParams must be {{ promiseOrEmpty }} of type { [key: string]: string | string[] | undefined }",
"issue:no-returntype": "The function must specify a returntype",
"issue:wrong-returntype": "The function must specify a correct returntype",
"issue:asyncRequestApi-params": "params must be a Promise",
"issue:useHelperType": "Use {{ helperType }}<'{{ routePath }}'> instead of an inline type annotation",
"issue:wrong-helperType-path": "{{ helperType }} must use route path '{{ expectedPath }}', not '{{ actualPath }}'",
"issue:useHelperTypeForGenerateStaticParams": "Use { params?: Awaited<Omit<PageProps<'{{ routePath }}'>, 'searchParams'>['params']> } for generateStaticParams parameters"
},
schema: [{
type: "object",
properties: {
searchParams: {
type: "boolean",
enum: [true, false],
description: "If true, also strictly validates searchParams and enforces that the searchParams parameter is of type { [key: string]: string | string[] | undefined }"
},
helperTypes: {
type: "boolean",
enum: [true, false],
description: "If true, enforces the use of Next.js helper types (PageProps, LayoutProps, RouteContext) instead of inline type annotations for page, layout, and route files"
}
},
additionalProperties: false
}],
fixable: "code",
hasSuggestions: false,
defaultOptions: [{
searchParams: true,
helperTypes: false
}]
},
create: (ruleContext, options) => {
const fileInfo = getFileInfo(ruleContext.filename);
if (!fileInfo.inInAppRouterFolder || !fileInfo.isAppRouterFile || fileInfo.asyncRequestAPI == null) return {};
let nameOfDefaultExport = null;
const context = {
ruleContext,
customContext: { ...fileInfo }
};
const registeredRouteHandlerFunctions = [];
return {
FunctionDeclaration(node) {
if (node.id?.name === GENERATE_STATIC_PARAMS_FUNCTION_NAME) {
validateGenerateStaticParamsFunction(node, context);
if (options[0]?.helperTypes && node.params[0] != null) handleFunctionParameters({
context,
options,
props: node.params[0],
forGenerateStaticParams: true
});
} else if (node.id?.name && METADATA_FUNCTION_NAMES.includes(node.id?.name) && node.params[0] != null) handleFunctionParameters({
context,
options,
props: node.params[0],
allowHelperTypes: true
});
else if (node.id?.name && registeredRouteHandlerFunctions.includes(node.id?.name) && node.params[1] != null) handleFunctionParameters({
context,
options,
props: node.params[1],
allowHelperTypes: true
});
},
VariableDeclarator(node) {
if ((node.init?.type === _typescript_eslint_utils.AST_NODE_TYPES.ArrowFunctionExpression || node.init?.type === _typescript_eslint_utils.AST_NODE_TYPES.FunctionExpression) && node.id.type === _typescript_eslint_utils.AST_NODE_TYPES.Identifier) {
if (node.id.name === GENERATE_STATIC_PARAMS_FUNCTION_NAME) {
validateGenerateStaticParamsFunction(node.init, context);
if (options[0]?.helperTypes && node.init.params[0] != null) handleFunctionParameters({
context,
options,
props: node.init.params[0],
forGenerateStaticParams: true
});
} else if (METADATA_FUNCTION_NAMES.includes(node.id?.name) && node.init.params[0] != null) handleFunctionParameters({
context,
options,
props: node.init.params[0],
allowHelperTypes: true
});
else if (ROUTE_HANDLERS_FUNCTION_NAMES.includes(node.id?.name) && node.init.params[1] != null) handleFunctionParameters({
context,
options,
props: node.init.params[1],
allowHelperTypes: true
});
}
},
ExportNamedDeclaration(node) {
if (node.declaration?.type === _typescript_eslint_utils.AST_NODE_TYPES.FunctionDeclaration && node.declaration.id?.name != null && ROUTE_HANDLERS_FUNCTION_NAMES.includes(node.declaration.id?.name) && node.declaration.params[1] != null) handleFunctionParameters({
context,
options,
props: node.declaration.params[1],
allowHelperTypes: true
});
},
ExportDefaultDeclaration(node) {
if (node.declaration.type === _typescript_eslint_utils.AST_NODE_TYPES.Identifier) {
nameOfDefaultExport = node.declaration.name;
return;
}
if (!(node.declaration.type === _typescript_eslint_utils.AST_NODE_TYPES.FunctionDeclaration) || !node.declaration.params[0]) return;
handleFunctionParameters({
context,
options,
props: node.declaration.params[0],
allowHelperTypes: true
});
},
"Program:exit"() {
if (nameOfDefaultExport) {
const variable = ruleContext.sourceCode.scopeManager?.variables?.find((variable) => variable.name === nameOfDefaultExport);
if (!variable) return;
const node = variable.defs[0]?.node;
if (node?.type === _typescript_eslint_utils.AST_NODE_TYPES.VariableDeclarator && node.init) {
const initNode = node.init;
if ((initNode.type === _typescript_eslint_utils.AST_NODE_TYPES.ArrowFunctionExpression || initNode.type === _typescript_eslint_utils.AST_NODE_TYPES.FunctionExpression) && initNode.params[0]) handleFunctionParameters({
context,
options,
props: initNode.params[0],
allowHelperTypes: true
});
} else if (node?.type === _typescript_eslint_utils.AST_NODE_TYPES.FunctionDeclaration && node.params[0]) handleFunctionParameters({
context,
options,
props: node.params[0],
allowHelperTypes: true
});
}
}
};
}
}) } };
//#endregion
module.exports = plugin;