Skip to content
Open
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
14 changes: 13 additions & 1 deletion packages/eslint-plugin-next/src/rules/no-html-link-for-pages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ const memoize = <T = any>(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)

Expand Down Expand Up @@ -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 = (
Expand Down Expand Up @@ -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]

Expand Down
41 changes: 29 additions & 12 deletions packages/eslint-plugin-next/src/utils/url.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
)
}
}
})
Expand Down Expand Up @@ -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)}$`
Expand Down
76 changes: 76 additions & 0 deletions test/unit/eslint-plugin-next/no-html-link-for-pages.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -28,6 +32,10 @@ const linters = {
cwd: withCustomPagesDir,
configType: 'eslintrc',
}),
withCustomExtensions: new Linter({
cwd: withCustomExtensionsDir,
configType: 'eslintrc',
}),
}

const linterConfig: any = {
Expand Down Expand Up @@ -72,6 +80,14 @@ const linterConfigWithNestedContentRootDirDirectory = {
},
},
}
const linterConfigWithCustomExtensions: any = {
...linterConfig,
settings: {
next: {
pageExtensions: ['ts', 'tsx'],
},
},
}

for (const linter of Object.values(linters)) {
linter.defineRules({
Expand Down Expand Up @@ -495,4 +511,64 @@ describe('no-html-link-for-pages', function () {
'Do not use an `<a>` element to navigate to `/photo/1/`. Use `<Link />` 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 (
<div>
<Link href='/about'><a>About</a></Link>
</div>
);
}
`
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 (
<div>
<a href='/about'>About</a>
</div>
);
}
`
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 `<a>` element to navigate to `/about/`. Use `<Link />` 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 (
<div>
<a href='/blog/my-post'>Blog Post</a>
</div>
);
}
`
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 `<a>` element to navigate to `/blog/my-post/`. Use `<Link />` from `next/link` instead. See: https://nextjs.org/docs/messages/no-html-link-for-pages'
)
})
})
Empty file.
Empty file.
Loading