diff --git a/.changeset/no-raw-hrefs-api.md b/.changeset/no-raw-hrefs-api.md new file mode 100644 index 0000000..7faf39d --- /dev/null +++ b/.changeset/no-raw-hrefs-api.md @@ -0,0 +1,5 @@ +--- +"@paramour-js/eslint-plugin": patch +--- + +`no-raw-hrefs` no longer reports paths under `/api` (`/api`, `/api/auth/signin`, `/api?x=1`, but not `/apiary`). They point at route handlers, which `href()` cannot build because the route registry lists only pages, so the warning had no fix. The exemption needs no configuration and applies in addition to `ignorePaths`. diff --git a/docs/content/docs/reference/eslint-plugin.mdx b/docs/content/docs/reference/eslint-plugin.mdx index 71f4557..8eb466d 100644 --- a/docs/content/docs/reference/eslint-plugin.mdx +++ b/docs/content/docs/reference/eslint-plugin.mdx @@ -134,6 +134,14 @@ empty strings all pass without a protocol allowlist to maintain. Protocol-relative URLs (`//cdn.example.com/x`) are external too, and are exempt despite their leading slashes. +Paths under `/api` (`/api`, `/api/auth/signin`, `/api?x=1`, but not +`/apiary`) are always exempt, with no configuration. They point at route +handlers, and `href()` cannot build those because the registry lists only +pages, so a warning there would have no fix. The exemption follows the +convention: `/api` is the Pages Router's reserved API directory and where +App Router projects put route handlers. Route handlers elsewhere need +`ignorePaths`, and a page that lives under `/api` is not checked. + Type-only imports (`import type Link from "next/link"`) never fire — a value usage of one is already a TypeScript error. diff --git a/packages/eslint-plugin/README.md b/packages/eslint-plugin/README.md index 070a910..42b1a25 100644 --- a/packages/eslint-plugin/README.md +++ b/packages/eslint-plugin/README.md @@ -62,7 +62,7 @@ rules: { "paramour/no-raw-hrefs": "error" } Reports string literals (and expression-free template literals) starting with `/` in six Next.js surfaces spanning both routers: the `href` attribute of `Link` imported from `next/link` (any local name — imports are tracked, not names matched), including the `UrlObject` form `href={{ pathname: "/foo" }}`; the first argument of `push`, `replace`, and `prefetch` on a router obtained from `useRouter()` — `next/navigation` or `next/router`, destructured forms included; the static `Router.push` form on the `next/router` default export; arguments to `redirect` and `permanentRedirect` imported from `next/navigation`; string `action` values on `Form` from `next/form`; and `NextResponse.redirect`/`rewrite` from `next/server`, both as a direct string and inside an inline `new URL("/path", base)` first argument. The `linkComponents` option (`{ name, source, prop? }` entries, `name` being the _imported_ name) extends the `Link` surface to design-system wrappers. -External URLs (`https://…`, protocol-relative `//…`), fragments (`#…`), `mailto:`/`tel:`, relative paths, and empty strings are ignored, as are function-valued `Form` actions and `URL`-typed variables at `NextResponse`. `ignorePaths` (path-segment prefixes, not substrings or globs) exempts sections a migration has not reached yet: +External URLs (`https://…`, protocol-relative `//…`), fragments (`#…`), `mailto:`/`tel:`, relative paths, and empty strings are ignored, as are function-valued `Form` actions and `URL`-typed variables at `NextResponse`. Paths under `/api` are always exempt: they point at route handlers, which `href()` cannot build. `ignorePaths` (path-segment prefixes, not substrings or globs) exempts sections a migration has not reached yet: ```js rules: { diff --git a/packages/eslint-plugin/src/rules/no-raw-hrefs.ts b/packages/eslint-plugin/src/rules/no-raw-hrefs.ts index 96b1750..11b30f2 100644 --- a/packages/eslint-plugin/src/rules/no-raw-hrefs.ts +++ b/packages/eslint-plugin/src/rules/no-raw-hrefs.ts @@ -80,6 +80,15 @@ function isIgnored(path: string, ignorePaths: readonly string[]): boolean { }); } +/** + * Always exempt, on top of `ignorePaths`. Links to route handlers can't be + * built with href(): the registry lists only pages, so a warning there has + * no fix. `/api` is the Pages Router's reserved API directory and the App + * Router convention for route handlers. A page under /api goes unflagged, + * which is the accepted cost. + */ +const ROUTE_HANDLER_PREFIX = "/api"; + /** * Flags any literal starting with "/"; everything else (external URLs, * "#hash", "mailto:", relative paths, "") is exempt by not starting with "/". @@ -91,7 +100,7 @@ function isRawInternalPath(path: string): boolean { export const noRawHrefs = createRule({ create(context, [options]) { - const ignorePaths = options.ignorePaths ?? []; + const ignorePaths = [ROUTE_HANDLER_PREFIX, ...(options.ignorePaths ?? [])]; const linkComponents = options.linkComponents ?? []; const { sourceCode } = context; diff --git a/packages/eslint-plugin/test/no-raw-hrefs.test.ts b/packages/eslint-plugin/test/no-raw-hrefs.test.ts index 22ec75b..130a516 100644 --- a/packages/eslint-plugin/test/no-raw-hrefs.test.ts +++ b/packages/eslint-plugin/test/no-raw-hrefs.test.ts @@ -492,6 +492,22 @@ NextResponse.redirect("/legacybar");`, ], options: [{ ignorePaths: ["/legacy"] }], }, + // The built-in /api exemption is segment-bounded too + { + code: `import Link from "next/link"; +export const el = ;`, + errors: [{ data: { path: "/apiary" }, messageId: "rawHref" }], + }, + { + code: `import { redirect } from "next/navigation"; +redirect("/apis/v2");`, + errors: [ + { + data: { callee: "redirect", path: "/apis/v2" }, + messageId: "rawRedirect", + }, + ], + }, // Multiple violations in one file, with report locations { code: `import Link from "next/link"; @@ -669,6 +685,28 @@ export const el = ;`, }, ], }, + // Route handlers under /api are exempt with no options: href() cannot + // build them, because the registry lists only pages. + `import Link from "next/link"; +export const el = ;`, + `import Link from "next/link"; +export const el = ;`, + `import Link from "next/link"; +export const el = ;`, + `import { useRouter } from "next/navigation"; +export function C() { + const router = useRouter(); + router.push("/api/auth/sign-out?callbackUrl=%2F"); +}`, + `import { NextResponse } from "next/server"; +NextResponse.redirect("/api/auth/signin");`, + // ...and the exemption stacks with configured ignorePaths + { + code: `import Link from "next/link"; +export const a = ; +export const b = ;`, + options: [{ ignorePaths: ["/legacy"] }], + }, // ignorePaths: prefix boundaries { code: `import Link from "next/link";