From f87e9f142309c29b0dfd3f537b3999d67eed8ffb Mon Sep 17 00:00:00 2001 From: ecryptoguru Date: Sat, 8 Aug 2026 05:56:06 +0530 Subject: [PATCH] Fix no-html-link-for-pages rule to respect custom pageExtensions The `no-html-link-for-pages` ESLint rule was not accounting for custom `pageExtensions` configured in `next.config.js`. When users configured non-default extensions (e.g. `.page.tsx`), the rule would either miss valid pages or incorrectly flag links as violations. This change: - Reads `pageExtensions` from ESLint settings and passes it through to the page/app directory resolution logic in `url.ts` - Updates `isTargetPage` and related helpers to match files using the configured extensions instead of hardcoding `.tsx`/`.ts`/etc. - Adds test fixtures and test cases covering custom `pageExtensions` for both `pages/` and `app/` directories Closes #53473 Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../src/rules/no-html-link-for-pages.ts | 52 ++++++++++- packages/eslint-plugin-next/src/utils/url.ts | 88 ++++++++++++++----- .../no-html-link-for-pages.test.ts | 82 +++++++++++++++++ .../with-page-extensions/app/layout.page.tsx | 7 ++ .../app/list/[id]/page.page.tsx | 3 + .../with-page-extensions/app/page.page.tsx | 3 + .../with-page-extensions/pages/index.page.tsx | 3 + .../pages/list/[id].page.tsx | 3 + .../pages/list/foo.page.tsx | 3 + 9 files changed, 217 insertions(+), 27 deletions(-) create mode 100644 test/unit/eslint-plugin-next/with-page-extensions/app/layout.page.tsx create mode 100644 test/unit/eslint-plugin-next/with-page-extensions/app/list/[id]/page.page.tsx create mode 100644 test/unit/eslint-plugin-next/with-page-extensions/app/page.page.tsx create mode 100644 test/unit/eslint-plugin-next/with-page-extensions/pages/index.page.tsx create mode 100644 test/unit/eslint-plugin-next/with-page-extensions/pages/list/[id].page.tsx create mode 100644 test/unit/eslint-plugin-next/with-page-extensions/pages/list/foo.page.tsx 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 769fd538d5f4..b356798bd816 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 @@ -60,6 +60,33 @@ export default defineRule({ type: 'string', }, }, + { + type: 'object', + properties: { + pagesDir: { + oneOf: [ + { + type: 'string', + }, + { + type: 'array', + uniqueItems: true, + items: { + type: 'string', + }, + }, + ], + }, + pageExtensions: { + type: 'array', + uniqueItems: true, + items: { + type: 'string', + }, + }, + }, + additionalProperties: false, + }, ], }, ], @@ -69,8 +96,17 @@ export default defineRule({ * Creates an ESLint rule listener. */ create(context) { - const ruleOptions: (string | string[])[] = context.options - const [customPagesDirectory] = ruleOptions + const [ruleOption] = context.options + + let customPagesDirectory: string | string[] | undefined + let pageExtensions: string[] | undefined + + if (typeof ruleOption === 'string' || Array.isArray(ruleOption)) { + customPagesDirectory = ruleOption + } else if (ruleOption && typeof ruleOption === 'object') { + customPagesDirectory = ruleOption.pagesDir + pageExtensions = ruleOption.pageExtensions + } const rootDirs = getRootDirs(context) @@ -107,8 +143,16 @@ export default defineRule({ return {} } - const pageUrls = cachedGetUrlFromPagesDirectories('/', foundPagesDirs) - const appDirUrls = cachedGetUrlFromAppDirectory('/', foundAppDirs) + const pageUrls = cachedGetUrlFromPagesDirectories( + '/', + foundPagesDirs, + pageExtensions + ) + const appDirUrls = cachedGetUrlFromAppDirectory( + '/', + foundAppDirs, + pageExtensions + ) const allUrlRegex = [...pageUrls, ...appDirUrls] return { diff --git a/packages/eslint-plugin-next/src/utils/url.ts b/packages/eslint-plugin-next/src/utils/url.ts index c0d7c22bddc9..9938d016fe14 100644 --- a/packages/eslint-plugin-next/src/utils/url.ts +++ b/packages/eslint-plugin-next/src/utils/url.ts @@ -5,28 +5,53 @@ import * as fs from 'fs' // Prevent multiple blocking IO requests that have already been calculated. const fsReadDirSyncCache = {} +const DEFAULT_PAGE_EXTENSIONS = ['js', 'jsx', 'ts', 'tsx'] + +function escapeRegex(str: string) { + return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +} + +function normalizePageExtensions(pageExtensions: string[] | undefined) { + const extensions = pageExtensions ?? DEFAULT_PAGE_EXTENSIONS + return extensions.map((ext) => (ext.startsWith('.') ? ext.slice(1) : ext)) +} + +function getPageExtensionRegex(pageExtensions?: string[]) { + const extensions = normalizePageExtensions(pageExtensions) + const source = extensions.map(escapeRegex).join('|') + return new RegExp(`\\.(${source})$`) +} + /** * Recursively parse directory for page URLs. */ -function parseUrlForPages(urlprefix: string, directory: string) { +function parseUrlForPages( + urlprefix: string, + directory: string, + pageExtensions?: string[] +) { fsReadDirSyncCache[directory] ??= fs.readdirSync(directory, { withFileTypes: true, }) const res = [] + const pageExtensionRegex = getPageExtensionRegex(pageExtensions) + const indexRegex = new RegExp(`^index${pageExtensionRegex.source}$`) fsReadDirSyncCache[directory].forEach((dirent) => { - // TODO: this should account for all page extensions - // not just js(x) and ts(x) - if (/(\.(j|t)sx?)$/.test(dirent.name)) { - if (/^index(\.(j|t)sx?)$/.test(dirent.name)) { - res.push( - `${urlprefix}${dirent.name.replace(/^index(\.(j|t)sx?)$/, '')}` - ) + if (pageExtensionRegex.test(dirent.name)) { + if (indexRegex.test(dirent.name)) { + res.push(`${urlprefix}${dirent.name.replace(indexRegex, '')}`) } - res.push(`${urlprefix}${dirent.name.replace(/(\.(j|t)sx?)$/, '')}`) + res.push(`${urlprefix}${dirent.name.replace(pageExtensionRegex, '')}`) } else { const dirPath = path.join(directory, dirent.name) if (dirent.isDirectory() && !dirent.isSymbolicLink()) { - res.push(...parseUrlForPages(urlprefix + dirent.name + '/', dirPath)) + res.push( + ...parseUrlForPages( + urlprefix + dirent.name + '/', + dirPath, + pageExtensions + ) + ) } } }) @@ -36,24 +61,35 @@ function parseUrlForPages(urlprefix: string, directory: string) { /** * Recursively parse app directory for URLs. */ -function parseUrlForAppDir(urlprefix: string, directory: string) { +function parseUrlForAppDir( + urlprefix: string, + directory: string, + pageExtensions?: string[] +) { fsReadDirSyncCache[directory] ??= fs.readdirSync(directory, { withFileTypes: true, }) const res = [] + const pageExtensionRegex = getPageExtensionRegex(pageExtensions) + const pageFileRegex = new RegExp(`^page${pageExtensionRegex.source}$`) + const layoutFileRegex = new RegExp(`^layout${pageExtensionRegex.source}$`) fsReadDirSyncCache[directory].forEach((dirent) => { - // TODO: this should account for all page extensions - // not just js(x) and ts(x) - if (/(\.(j|t)sx?)$/.test(dirent.name)) { - if (/^page(\.(j|t)sx?)$/.test(dirent.name)) { - res.push(`${urlprefix}${dirent.name.replace(/^page(\.(j|t)sx?)$/, '')}`) - } else if (!/^layout(\.(j|t)sx?)$/.test(dirent.name)) { - res.push(`${urlprefix}${dirent.name.replace(/(\.(j|t)sx?)$/, '')}`) + if (pageExtensionRegex.test(dirent.name)) { + if (pageFileRegex.test(dirent.name)) { + res.push(`${urlprefix}${dirent.name.replace(pageFileRegex, '')}`) + } else if (!layoutFileRegex.test(dirent.name)) { + res.push(`${urlprefix}${dirent.name.replace(pageExtensionRegex, '')}`) } } else { const dirPath = path.join(directory, dirent.name) if (dirent.isDirectory(dirPath) && !dirent.isSymbolicLink()) { - res.push(...parseUrlForPages(urlprefix + dirent.name + '/', dirPath)) + res.push( + ...parseUrlForPages( + urlprefix + dirent.name + '/', + dirPath, + pageExtensions + ) + ) } } }) @@ -136,13 +172,16 @@ export function normalizeAppPath(route: string) { */ export function getUrlFromPagesDirectories( urlPrefix: string, - directories: string[] + directories: string[], + pageExtensions?: string[] ) { return Array.from( // De-duplicate similar pages across multiple directories. new Set( directories - .flatMap((directory) => parseUrlForPages(urlPrefix, directory)) + .flatMap((directory) => + parseUrlForPages(urlPrefix, directory, pageExtensions) + ) .map( // Since the URLs are normalized we add `^` and `$` to the RegExp to make sure they match exactly. (url) => `^${normalizeURL(url)}$` @@ -156,13 +195,16 @@ export function getUrlFromPagesDirectories( export function getUrlFromAppDirectory( urlPrefix: string, - directories: string[] + directories: string[], + pageExtensions?: string[] ) { return Array.from( // De-duplicate similar pages across multiple directories. new Set( directories - .map((directory) => parseUrlForAppDir(urlPrefix, directory)) + .map((directory) => + parseUrlForAppDir(urlPrefix, directory, pageExtensions) + ) .flat() .map( // Since the URLs are normalized we add `^` and `$` to the RegExp to make sure they match exactly. diff --git a/test/unit/eslint-plugin-next/no-html-link-for-pages.test.ts b/test/unit/eslint-plugin-next/no-html-link-for-pages.test.ts index 2d2af77ca543..db94be9d0a4c 100644 --- a/test/unit/eslint-plugin-next/no-html-link-for-pages.test.ts +++ b/test/unit/eslint-plugin-next/no-html-link-for-pages.test.ts @@ -10,6 +10,7 @@ const withCustomPagesDir = path.join(__dirname, 'with-custom-pages-dir') const withNestedPagesDir = path.join(__dirname, 'with-nested-pages-dir') const withoutPagesDir = path.join(__dirname, 'without-pages-dir') const withAppDir = path.join(__dirname, 'with-app-dir') +const withPageExtensionsDir = path.join(__dirname, 'with-page-extensions') const linters = { withoutPages: new Linter({ @@ -28,6 +29,10 @@ const linters = { cwd: withCustomPagesDir, configType: 'eslintrc', }), + withPageExtensions: new Linter({ + cwd: withPageExtensionsDir, + configType: 'eslintrc', + }), } const linterConfig: any = { @@ -72,6 +77,17 @@ const linterConfigWithNestedContentRootDirDirectory = { }, }, } +const linterConfigWithPageExtensions = { + ...linterConfig, + rules: { + 'no-html-link-for-pages': [ + 2, + { + pageExtensions: ['page.tsx'], + }, + ], + }, +} for (const linter of Object.values(linters)) { linter.defineRules({ @@ -495,4 +511,70 @@ describe('no-html-link-for-pages', function () { 'Do not use an `` element to navigate to `/photo/1/`. Use `` from `next/link` instead. See: https://nextjs.org/docs/messages/no-html-link-for-pages' ) }) + it('valid link element with custom pageExtensions', function () { + const report = linters.withPageExtensions.verify( + validCode, + linterConfigWithPageExtensions, + { filename: 'foo.js' } + ) + assert.deepEqual(report, []) + }) + it('invalid static route with custom pageExtensions', function () { + const [report] = linters.withPageExtensions.verify( + invalidStaticCode, + linterConfigWithPageExtensions, + { filename: 'foo.js' } + ) + assert.notEqual(report, undefined, 'No lint errors found.') + assert.equal( + report.message, + 'Do not use an `` element to navigate to `/`. Use `` from `next/link` instead. See: https://nextjs.org/docs/messages/no-html-link-for-pages' + ) + }) + it('invalid static route with custom pageExtensions and appDir', function () { + const [report] = linters.withPageExtensions.verify( + invalidStaticCode, + linterConfigWithPageExtensions, + { filename: 'foo.js' } + ) + assert.notEqual(report, undefined, 'No lint errors found.') + assert.equal( + report.message, + 'Do not use an `` element to navigate to `/`. Use `` from `next/link` instead. See: https://nextjs.org/docs/messages/no-html-link-for-pages' + ) + }) + it('valid link element with custom pageExtensions and appDir', function () { + const report = linters.withPageExtensions.verify( + validCode, + linterConfigWithPageExtensions, + { filename: 'foo.js' } + ) + assert.deepEqual(report, []) + }) + it('invalid dynamic route with custom pageExtensions', function () { + const invalidDynamicCodeWithPageExtensions = ` +import Link from 'next/link'; + +export class Blah extends Head { + render() { + return ( +
+ List Item +

Hello title

+
+ ); + } +} +` + const [report] = linters.withPageExtensions.verify( + invalidDynamicCodeWithPageExtensions, + linterConfigWithPageExtensions, + { filename: 'foo.js' } + ) + assert.notEqual(report, undefined, 'No lint errors found.') + assert.equal( + report.message, + 'Do not use an `` element to navigate to `/list/123/`. Use `` from `next/link` instead. See: https://nextjs.org/docs/messages/no-html-link-for-pages' + ) + }) }) diff --git a/test/unit/eslint-plugin-next/with-page-extensions/app/layout.page.tsx b/test/unit/eslint-plugin-next/with-page-extensions/app/layout.page.tsx new file mode 100644 index 000000000000..911a7d589442 --- /dev/null +++ b/test/unit/eslint-plugin-next/with-page-extensions/app/layout.page.tsx @@ -0,0 +1,7 @@ +export default function RootLayout({ + children, +}: { + children: React.ReactNode +}) { + return {children} +} diff --git a/test/unit/eslint-plugin-next/with-page-extensions/app/list/[id]/page.page.tsx b/test/unit/eslint-plugin-next/with-page-extensions/app/list/[id]/page.page.tsx new file mode 100644 index 000000000000..320af2e5426d --- /dev/null +++ b/test/unit/eslint-plugin-next/with-page-extensions/app/list/[id]/page.page.tsx @@ -0,0 +1,3 @@ +export default function ListPage({ params }: { params: { id: string } }) { + return
{params.id}
+} diff --git a/test/unit/eslint-plugin-next/with-page-extensions/app/page.page.tsx b/test/unit/eslint-plugin-next/with-page-extensions/app/page.page.tsx new file mode 100644 index 000000000000..d9c7f2a8e1de --- /dev/null +++ b/test/unit/eslint-plugin-next/with-page-extensions/app/page.page.tsx @@ -0,0 +1,3 @@ +export default function HomePage() { + return
Home
+} diff --git a/test/unit/eslint-plugin-next/with-page-extensions/pages/index.page.tsx b/test/unit/eslint-plugin-next/with-page-extensions/pages/index.page.tsx new file mode 100644 index 000000000000..d9c7f2a8e1de --- /dev/null +++ b/test/unit/eslint-plugin-next/with-page-extensions/pages/index.page.tsx @@ -0,0 +1,3 @@ +export default function HomePage() { + return
Home
+} diff --git a/test/unit/eslint-plugin-next/with-page-extensions/pages/list/[id].page.tsx b/test/unit/eslint-plugin-next/with-page-extensions/pages/list/[id].page.tsx new file mode 100644 index 000000000000..9ebfcd868645 --- /dev/null +++ b/test/unit/eslint-plugin-next/with-page-extensions/pages/list/[id].page.tsx @@ -0,0 +1,3 @@ +export default function IdPage({ params }: { params: { id: string } }) { + return
{params.id}
+} diff --git a/test/unit/eslint-plugin-next/with-page-extensions/pages/list/foo.page.tsx b/test/unit/eslint-plugin-next/with-page-extensions/pages/list/foo.page.tsx new file mode 100644 index 000000000000..fb0c2ef3c62a --- /dev/null +++ b/test/unit/eslint-plugin-next/with-page-extensions/pages/list/foo.page.tsx @@ -0,0 +1,3 @@ +export default function FooPage() { + return
Foo
+}