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 (
+
+ );
+ }
+}
+`
+ 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
+}