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..258d2a236738 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 @@ -21,6 +21,50 @@ const pagesDirWarning = execOnce((pagesDirs) => { // Prevent multiple blocking IO requests that have already been calculated. const fsExistsSyncCache = {} +/** + * Attempts to read pageExtensions from next.config.js or next.config.mjs + * in the project root directory. + */ +function getPageExtensions(context: any): string[] | undefined { + const rootDirs = getRootDirs(context) + const configNames = [ + 'next.config.js', + 'next.config.mjs', + 'next.config.ts', + 'next.config.cjs', + ] + + for (const rootDir of rootDirs) { + for (const configName of configNames) { + const configPath = path.join(rootDir, configName) + if (fs.existsSync(configPath)) { + try { + const content = fs.readFileSync(configPath, 'utf8') + // Match pageExtensions assignment: pageExtensions: [...] or "pageExtensions": [...] + const match = content.match(/pageExtensions\s*:\s*\[([^\]]+)\]/) + if (match) { + // Extract quoted strings from the array + const extensions: string[] = [] + const arrayContent = match[1] + const quoteMatches = arrayContent.match(/['"](\w+)['"]/g) + if (quoteMatches) { + for (const qm of quoteMatches) { + extensions.push(qm.slice(1, -1)) + } + } + if (extensions.length > 0) { + return extensions + } + } + } catch { + // Ignore read errors, continue to next config + } + } + } + } + return undefined +} + const memoize = (fn: (...args: any[]) => T) => { const cache = {} return (...args: any[]): T => { @@ -107,8 +151,19 @@ export default defineRule({ return {} } - const pageUrls = cachedGetUrlFromPagesDirectories('/', foundPagesDirs) - const appDirUrls = cachedGetUrlFromAppDirectory('/', foundAppDirs) + // Get pageExtensions from next.config.js if available + const pageExtensions = getPageExtensions(context) + + 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..f6cec9a50cca 100644 --- a/packages/eslint-plugin-next/src/utils/url.ts +++ b/packages/eslint-plugin-next/src/utils/url.ts @@ -5,28 +5,48 @@ import * as fs from 'fs' // Prevent multiple blocking IO requests that have already been calculated. const fsReadDirSyncCache = {} +/** + * Gets the default page extensions (js, jsx, ts, tsx). + */ +function getDefaultPageExtensions(): string[] { + return ['js', 'jsx', 'ts', 'tsx'] +} + +/** + * Builds a regex pattern from page extensions. + */ +function buildExtensionPattern(pageExtensions: string[]): RegExp { + const exts = pageExtensions.length > 0 ? pageExtensions : getDefaultPageExtensions() + const pattern = exts.map((ext) => ext.replace('.', '\\.')).join('|') + return new RegExp(`\\.(${pattern})$`) +} + /** * 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 extPattern = buildExtensionPattern(pageExtensions || getDefaultPageExtensions()) + const extMatcher = new RegExp(`(${extPattern.source})$`) 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 (extMatcher.test(dirent.name)) { + const baseName = dirent.name.replace(extMatcher, '') + if (/^index$/.test(baseName)) { + res.push(`${urlprefix}`) + } else { + res.push(`${urlprefix}${baseName}/`) } - res.push(`${urlprefix}${dirent.name.replace(/(\.(j|t)sx?)$/, '')}`) } 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 +56,29 @@ 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 extPattern = buildExtensionPattern(pageExtensions || getDefaultPageExtensions()) + const extMatcher = new RegExp(`(${extPattern.source})$`) 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 (extMatcher.test(dirent.name)) { + const baseName = dirent.name.replace(extMatcher, '') + if (/^page$/.test(baseName)) { + res.push(`${urlprefix}`) + } else if (!/^layout$/.test(baseName)) { + res.push(`${urlprefix}${baseName}/`) } } 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 +161,14 @@ 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 +182,14 @@ 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.