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 769fd538d5f46..bbd5f1f3ea258 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 @@ -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() + +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 ')}. ` + @@ -32,8 +82,14 @@ const memoize = (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' @@ -73,6 +129,7 @@ export default defineRule({ const [customPagesDirectory] = ruleOptions const rootDirs = getRootDirs(context) + const pageExtensions = getPageExtensions(rootDirs, context.settings) const pagesDirs = ( customPagesDirectory @@ -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 { diff --git a/packages/eslint-plugin-next/src/utils/url.ts b/packages/eslint-plugin-next/src/utils/url.ts index c0d7c22bddc99..cc3a3e13e45d9 100644 --- a/packages/eslint-plugin-next/src/utils/url.ts +++ b/packages/eslint-plugin-next/src/utils/url.ts @@ -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) + ) } } }) @@ -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) + ) } } }) @@ -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)}$` @@ -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.