Skip to content
Open
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
33 changes: 27 additions & 6 deletions packages/eslint-plugin-next/src/rules/no-html-link-for-pages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import { defineRule } from '../utils/define-rule'
import * as path from 'path'
import * as fs from 'fs'
import { getRootDirs } from '../utils/get-root-dirs'

import {
getUrlFromPagesDirectories,
normalizeURL,
Expand All @@ -18,11 +17,10 @@ const pagesDirWarning = execOnce((pagesDirs) => {
})

// Cache for fs.existsSync lookup.
// Prevent multiple blocking IO requests that have already been calculated.
const fsExistsSyncCache = {}
const fsExistsSyncCache: Record<string, boolean> = {}

const memoize = <T = any>(fn: (...args: any[]) => T) => {
const cache = {}
const cache: Record<string, T> = {}
return (...args: any[]): T => {
const key = JSON.stringify(args)
if (cache[key] === undefined) {
Expand Down Expand Up @@ -101,7 +99,7 @@ export default defineRule({
return fsExistsSyncCache[dir]
})

// warn if there are no pages and app directories
// Warn if no directories found
if (foundPagesDirs.length === 0 && foundAppDirs.length === 0) {
pagesDirWarning(pagesDirs)
return {}
Expand All @@ -111,6 +109,24 @@ export default defineRule({
const appDirUrls = cachedGetUrlFromAppDirectory('/', foundAppDirs)
const allUrlRegex = [...pageUrls, ...appDirUrls]

// --- NEW CODE START: Support for custom pageExtensions ---
let pageExtensions: string[] = ['js', 'jsx', 'ts', 'tsx']

try {
const nextConfigPath = path.join(process.cwd(), 'next.config.js')
if (fs.existsSync(nextConfigPath)) {
const nextConfig = require(nextConfigPath)
if (nextConfig.pageExtensions && Array.isArray(nextConfig.pageExtensions)) {
pageExtensions = nextConfig.pageExtensions
}
}
} catch {
// ignore config errors
}

const allowedExtRegex = new RegExp(`\\.(${pageExtensions.join('|')})$`)
// --- NEW CODE END ---

return {
JSXOpeningElement(node) {
if (node.name.name !== 'a') {
Expand All @@ -125,7 +141,7 @@ export default defineRule({
(attr) => attr.type === 'JSXAttribute' && attr.name.name === 'target'
)

if (target && target.value.value === '_blank') {
if (target && target.value && target.value.value === '_blank') {
return
}

Expand All @@ -152,6 +168,11 @@ export default defineRule({
return
}

// --- NEW CODE: skip internal links with allowed extensions ---
if (allowedExtRegex.test(hrefPath)) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new file extension check will never match because it tests the normalized URL (which has a trailing slash) against a regex that expects the string to end with a file extension.

View Details
📝 Patch Details
diff --git a/packages/eslint-plugin-next/src/rules/no-html-link-for-pages.ts b/packages/eslint-plugin-next/src/rules/no-html-link-for-pages.ts
index f38216066d..4b849d660c 100644
--- a/packages/eslint-plugin-next/src/rules/no-html-link-for-pages.ts
+++ b/packages/eslint-plugin-next/src/rules/no-html-link-for-pages.ts
@@ -162,14 +162,15 @@ export default defineRule({
           return
         }
 
-        const hrefPath = normalizeURL(href.value.value)
-        // Outgoing links are ignored
-        if (/^(https?:\/\/|\/\/)/.test(hrefPath)) {
+        // --- NEW CODE: skip internal links with allowed extensions ---
+        // Check before normalization to avoid the trailing slash added by normalizeURL
+        if (allowedExtRegex.test(href.value.value)) {
           return
         }
 
-        // --- NEW CODE: skip internal links with allowed extensions ---
-        if (allowedExtRegex.test(hrefPath)) {
+        const hrefPath = normalizeURL(href.value.value)
+        // Outgoing links are ignored
+        if (/^(https?:\/\/|\/\/)/.test(hrefPath)) {
           return
         }
 

Analysis

File extension check never matches due to trailing slash from URL normalization

What fails: In packages/eslint-plugin-next/src/rules/no-html-link-for-pages.ts, the allowedExtRegex check at line 172 never matches links with file extensions (.js, .jsx, .ts, .tsx) because it tests against the normalized URL which has a trailing slash appended.

How to reproduce:

// In the ESLint rule, with href="/public/script.js"
const hrefPath = normalizeURL("/public/script.js");  // Returns "/public/script.js/"
const allowedExtRegex = /\.(js|jsx|ts|tsx)$/;
allowedExtRegex.test(hrefPath);  // Returns false (expects to end with extension, but ends with "/")

Result: Links to static files like <a href="/public/script.js"> are incorrectly flagged by the ESLint rule, even though they should be skipped.

Expected: The regex should match and skip these links, as they point to static resources rather than Next.js pages. Testing against the original href.value.value before normalization correctly identifies file extensions.

Fix: Check for file extensions before URL normalization, since normalizeURL() appends a trailing slash that breaks the end-of-string anchor ($) in the regex pattern.

return
}

allUrlRegex.forEach((foundUrl) => {
if (foundUrl.test(normalizeURL(hrefPath))) {
context.report({
Expand Down
Loading