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
65 changes: 61 additions & 4 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 @@ -10,6 +10,56 @@ import {
getUrlFromAppDirectory,
} from '../utils/url'

const DEFAULT_PAGE_EXTENSIONS = ['js', 'jsx', 'ts', 'tsx']

/**
* Tries to read `pageExtensions` from `next.config.js` (CJS only).
* Returns null if the config cannot be loaded or doesn't define pageExtensions.
*/
function loadPageExtensionsFromConfig(rootDir: string): string[] | null {
const configFiles = ['next.config.js', 'next.config.ts']
for (const configFile of configFiles) {
const configPath = path.join(rootDir, configFile)
if (!fs.existsSync(configPath)) continue
try {
// eslint-disable-next-line @typescript-eslint/no-var-requires
const config = require(configPath)
const resolved = config?.default ?? config
if (Array.isArray(resolved?.pageExtensions)) {
return resolved.pageExtensions
}
} catch {
// Config may use ESM or have side effects — skip silently
}
}
return null
}

const loadPageExtensionsFromConfigMemo = new Map<string, string[] | null>()

function getPageExtensions(rootDirs: string[], settings: any): string[] {
// 1. ESLint settings take highest priority (explicit user override)
const fromSettings = settings?.next?.pageExtensions
if (Array.isArray(fromSettings) && fromSettings.length > 0) {
return fromSettings
}

// 2. Try to read from next.config.js in each root dir
for (const rootDir of rootDirs) {
if (!loadPageExtensionsFromConfigMemo.has(rootDir)) {
loadPageExtensionsFromConfigMemo.set(
rootDir,
loadPageExtensionsFromConfig(rootDir)
)
}
const fromConfig = loadPageExtensionsFromConfigMemo.get(rootDir)
if (fromConfig) return fromConfig
}

// 3. Fall back to Next.js defaults
return DEFAULT_PAGE_EXTENSIONS
}

const pagesDirWarning = execOnce((pagesDirs) => {
console.warn(
`Pages directory cannot be found at ${pagesDirs.join(' or ')}. ` +
Expand All @@ -32,8 +82,14 @@ const memoize = <T = any>(fn: (...args: any[]) => T) => {
}
}

const cachedGetUrlFromPagesDirectories = memoize(getUrlFromPagesDirectories)
const cachedGetUrlFromAppDirectory = memoize(getUrlFromAppDirectory)
const cachedGetUrlFromPagesDirectories = memoize(
(urlPrefix: string, dirs: string[], exts: string[]) =>
getUrlFromPagesDirectories(urlPrefix, dirs, exts)
)
const cachedGetUrlFromAppDirectory = memoize(
(urlPrefix: string, dirs: string[], exts: string[]) =>
getUrlFromAppDirectory(urlPrefix, dirs, exts)
)

const url = 'https://nextjs.org/docs/messages/no-html-link-for-pages'

Expand Down Expand Up @@ -73,6 +129,7 @@ export default defineRule({
const [customPagesDirectory] = ruleOptions

const rootDirs = getRootDirs(context)
const pageExtensions = getPageExtensions(rootDirs, context.settings)

const pagesDirs = (
customPagesDirectory
Expand Down Expand Up @@ -107,8 +164,8 @@ 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 {
Expand Down
81 changes: 58 additions & 23 deletions packages/eslint-plugin-next/src/utils/url.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,28 +5,46 @@ 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']

/**
* Builds a RegExp that matches filenames with the given extensions.
*/
function buildExtensionsRegex(pageExtensions: string[]): RegExp {
const escaped = pageExtensions.map((ext) =>
ext.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
)
return new RegExp(`\\.(${escaped.join('|')})$`)
}

/**
* Recursively parse directory for page URLs.
*/
function parseUrlForPages(urlprefix: string, directory: string) {
function parseUrlForPages(
urlprefix: string,
directory: string,
pageExtensions: string[] = DEFAULT_PAGE_EXTENSIONS
) {
fsReadDirSyncCache[directory] ??= fs.readdirSync(directory, {
withFileTypes: true,
})
const extRegex = buildExtensionsRegex(pageExtensions)
const indexRegex = new RegExp(
`^index\\.(${pageExtensions.map((e) => e.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|')})$`
)
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 All @@ -36,24 +54,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[] = DEFAULT_PAGE_EXTENSIONS
) {
fsReadDirSyncCache[directory] ??= fs.readdirSync(directory, {
withFileTypes: true,
})
const extRegex = buildExtensionsRegex(pageExtensions)
const pageRegex = new RegExp(
`^page\\.(${pageExtensions.map((e) => e.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|')})$`
)
const layoutRegex = new RegExp(
`^layout\\.(${pageExtensions.map((e) => e.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|')})$`
)
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 (/^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 (extRegex.test(dirent.name)) {
if (pageRegex.test(dirent.name)) {
res.push(`${urlprefix}${dirent.name.replace(pageRegex, '')}`)
} else if (!layoutRegex.test(dirent.name)) {
res.push(`${urlprefix}${dirent.name.replace(extRegex, '')}`)
}
} 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)
)
Copy link
Contributor

Choose a reason for hiding this comment

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

In parseUrlForAppDir, the recursive call for subdirectories incorrectly calls parseUrlForPages instead of parseUrlForAppDir, causing nested app directory routes to be scanned with pages-directory logic.

Fix on Vercel

}
}
})
Expand Down Expand Up @@ -136,13 +165,16 @@ export function normalizeAppPath(route: string) {
*/
export function getUrlFromPagesDirectories(
urlPrefix: string,
directories: string[]
directories: string[],
pageExtensions: string[] = DEFAULT_PAGE_EXTENSIONS
) {
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 All @@ -156,13 +188,16 @@ export function getUrlFromPagesDirectories(

export function getUrlFromAppDirectory(
urlPrefix: string,
directories: string[]
directories: string[],
pageExtensions: string[] = DEFAULT_PAGE_EXTENSIONS
) {
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.
Expand Down
Loading