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..d25ee2fc2b3e 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 @@ -32,6 +32,8 @@ const memoize = (fn: (...args: any[]) => T) => { } } +// Default page extensions matching Next.js default configuration +const defaultPageExtensions = ['tsx', 'ts', 'jsx', 'js'] const cachedGetUrlFromPagesDirectories = memoize(getUrlFromPagesDirectories) const cachedGetUrlFromAppDirectory = memoize(getUrlFromAppDirectory) @@ -72,6 +74,12 @@ export default defineRule({ const ruleOptions: (string | string[])[] = context.options const [customPagesDirectory] = ruleOptions + // Get pageExtensions from settings.next or use defaults + const nextSettings: { pageExtensions?: string[] } = + context.settings.next || {} + const pageExtensions: string[] = + nextSettings.pageExtensions || defaultPageExtensions + const rootDirs = getRootDirs(context) const pagesDirs = ( @@ -107,7 +115,11 @@ export default defineRule({ return {} } - const pageUrls = cachedGetUrlFromPagesDirectories('/', foundPagesDirs) + const pageUrls = cachedGetUrlFromPagesDirectories( + '/', + foundPagesDirs, + pageExtensions + ) const appDirUrls = cachedGetUrlFromAppDirectory('/', foundAppDirs) const allUrlRegex = [...pageUrls, ...appDirUrls] diff --git a/packages/eslint-plugin-next/src/utils/url.ts b/packages/eslint-plugin-next/src/utils/url.ts index c0d7c22bddc9..cad43319e31f 100644 --- a/packages/eslint-plugin-next/src/utils/url.ts +++ b/packages/eslint-plugin-next/src/utils/url.ts @@ -5,28 +5,42 @@ import * as fs from 'fs' // Prevent multiple blocking IO requests that have already been calculated. const fsReadDirSyncCache = {} +const defaultPageExtensions = ['tsx', 'ts', 'jsx', 'js'] + /** * Recursively parse directory for page URLs. */ -function parseUrlForPages(urlprefix: string, directory: string) { +function parseUrlForPages( + urlprefix: string, + directory: string, + pageExtensions: string[] = defaultPageExtensions +) { fsReadDirSyncCache[directory] ??= fs.readdirSync(directory, { withFileTypes: true, }) + // Build regex from pageExtensions to match any configured extension + const extPattern = `\\.(${pageExtensions.map((ext) => ext.replace(/[.*+?^${}()|[\]\\]/g, '\\\\$&')).join('|')})$` + const extRegex = new RegExp(extPattern) + const indexPattern = `^index(${extPattern.replace('.$', '\\.($|')})$` + const indexRegex = new RegExp(indexPattern) + const res = [] 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 (extRegex.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(extRegex, '')}`) } 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 + ) + ) } } }) @@ -136,13 +150,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)}$` 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..e2df42ddcb68 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,10 @@ 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 withCustomExtensionsDir = path.join( + __dirname, + 'with-custom-page-extensions' +) const linters = { withoutPages: new Linter({ @@ -28,6 +32,10 @@ const linters = { cwd: withCustomPagesDir, configType: 'eslintrc', }), + withCustomExtensions: new Linter({ + cwd: withCustomExtensionsDir, + configType: 'eslintrc', + }), } const linterConfig: any = { @@ -72,6 +80,14 @@ const linterConfigWithNestedContentRootDirDirectory = { }, }, } +const linterConfigWithCustomExtensions: any = { + ...linterConfig, + settings: { + next: { + pageExtensions: ['ts', 'tsx'], + }, + }, +} for (const linter of Object.values(linters)) { linter.defineRules({ @@ -495,4 +511,64 @@ 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('does not report error for valid link with custom pageExtensions setting', function () { + const code = ` +import Link from 'next/link'; +export function Page() { + return ( +
+ About +
+ ); +} +` + const report = linters.withCustomExtensions.verify( + code, + linterConfigWithCustomExtensions, + { filename: 'foo.js' } + ) + assert.deepEqual(report, []) + }) + it('reports error for static page route with custom pageExtensions setting', function () { + const code = ` +export function Page() { + return ( +
+ About +
+ ); +} +` + const [report] = linters.withCustomExtensions.verify( + code, + linterConfigWithCustomExtensions, + { filename: 'foo.js' } + ) + assert.notEqual(report, undefined, 'No lint errors found.') + assert.equal( + report.message, + 'Do not use an `` element to navigate to `/about/`. Use `` from `next/link` instead. See: https://nextjs.org/docs/messages/no-html-link-for-pages' + ) + }) + it('reports error for dynamic page route with custom pageExtensions setting', function () { + const code = ` +export function Page() { + return ( +
+ Blog Post +
+ ); +} +` + const [report] = linters.withCustomExtensions.verify( + code, + linterConfigWithCustomExtensions, + { filename: 'foo.js' } + ) + assert.notEqual(report, undefined, 'No lint errors found.') + assert.equal( + report.message, + 'Do not use an `` element to navigate to `/blog/my-post/`. 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-custom-page-extensions/pages/about.ts b/test/unit/eslint-plugin-next/with-custom-page-extensions/pages/about.ts new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/test/unit/eslint-plugin-next/with-custom-page-extensions/pages/blog/[slug].ts b/test/unit/eslint-plugin-next/with-custom-page-extensions/pages/blog/[slug].ts new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/test/unit/eslint-plugin-next/with-custom-page-extensions/pages/index.ts b/test/unit/eslint-plugin-next/with-custom-page-extensions/pages/index.ts new file mode 100644 index 000000000000..e69de29bb2d1