UNPKG

eslint-plugin-next-route-params

Version:

eslint rule to enforce correct route parameters for Next.js

823 lines (822 loc) 38.7 kB
//#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;