Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/no-raw-hrefs-api.md
Original file line number Diff line number Diff line change
@@ -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`.
8 changes: 8 additions & 0 deletions docs/content/docs/reference/eslint-plugin.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
2 changes: 1 addition & 1 deletion packages/eslint-plugin/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
11 changes: 10 additions & 1 deletion packages/eslint-plugin/src/rules/no-raw-hrefs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 "/".
Expand All @@ -91,7 +100,7 @@ function isRawInternalPath(path: string): boolean {

export const noRawHrefs = createRule<Options, MessageIds>({
create(context, [options]) {
const ignorePaths = options.ignorePaths ?? [];
const ignorePaths = [ROUTE_HANDLER_PREFIX, ...(options.ignorePaths ?? [])];
const linkComponents = options.linkComponents ?? [];
const { sourceCode } = context;

Expand Down
38 changes: 38 additions & 0 deletions packages/eslint-plugin/test/no-raw-hrefs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = <Link href="/apiary" />;`,
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";
Expand Down Expand Up @@ -669,6 +685,28 @@ export const el = <Anchor href="/x" />;`,
},
],
},
// 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 = <Link href="/api/auth/auto-signin" />;`,
`import Link from "next/link";
export const el = <Link href="/api" />;`,
`import Link from "next/link";
export const el = <Link href={{ pathname: "/api/export" }} />;`,
`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 = <Link href="/api/x" />;
export const b = <Link href="/legacy/y" />;`,
options: [{ ignorePaths: ["/legacy"] }],
},
// ignorePaths: prefix boundaries
{
code: `import Link from "next/link";
Expand Down
Loading