diff --git a/packages/eslint-config-next/src/index.ts b/packages/eslint-config-next/src/index.ts index ea24e0c9d94f2e..ba04706306fbd2 100644 --- a/packages/eslint-config-next/src/index.ts +++ b/packages/eslint-config-next/src/index.ts @@ -12,6 +12,9 @@ import * as jsxA11yPlugin from 'eslint-plugin-jsx-a11y' // utils import globals from 'globals' import eslintParser from './parser' +import { getPageExtensionsFromConfig } from './utils/get-page-extensions' + +const detectedPageExtensions = getPageExtensionsFromConfig(process.cwd()) const config: Linter.Config[] = [ { @@ -60,6 +63,9 @@ const config: Linter.Config[] = [ alwaysTryTypes: true, }, }, + ...(detectedPageExtensions + ? { next: { pageExtensions: detectedPageExtensions } } + : {}), }, rules: { ...react.configs.recommended.rules, diff --git a/packages/eslint-config-next/src/utils/get-page-extensions.ts b/packages/eslint-config-next/src/utils/get-page-extensions.ts new file mode 100644 index 00000000000000..e19ea7edb78b8d --- /dev/null +++ b/packages/eslint-config-next/src/utils/get-page-extensions.ts @@ -0,0 +1,103 @@ +import fs from 'fs' +import path from 'path' +import { spawnSync } from 'child_process' + +const NEXT_CONFIG_FILES = [ + 'next.config.js', + 'next.config.cjs', + 'next.config.mjs', + 'next.config.ts', + 'next.config.cts', + 'next.config.mts', +] + +type NullableExtensions = string[] | undefined + +const cache = new Map() + +export function getPageExtensionsFromConfig(cwd: string): NullableExtensions { + if (cache.has(cwd)) { + return cache.get(cwd)! + } + + for (const file of NEXT_CONFIG_FILES) { + const configPath = path.join(cwd, file) + if (!fs.existsSync(configPath)) continue + + const result = readPageExtensions(configPath) + if (result && result.length > 0) { + const normalized = Array.from( + new Set( + result + .filter((ext): ext is string => typeof ext === 'string') + .map((ext) => ext.trim()) + .filter(Boolean) + .map((ext) => ext.replace(/^\./, '').toLowerCase()) + ) + ) + + if (normalized.length > 0) { + cache.set(cwd, normalized) + return normalized + } + } + } + + cache.set(cwd, undefined) + return undefined +} + +function readPageExtensions(configPath: string): string[] | undefined { + try { + const evaluate = spawnSync( + process.execPath, + [ + '--input-type=module', + '--eval', + createEvalScript(configPath), + ], + { + cwd: path.dirname(configPath), + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + timeout: 5000, + } + ) + + if (evaluate.status !== 0) { + return undefined + } + + const output = (evaluate.stdout || '').toString().trim() + if (!output) { + return undefined + } + + try { + return JSON.parse(output) + } catch { + return undefined + } + } catch { + return undefined + } +} + +function createEvalScript(configPath: string) { + return ` + import { pathToFileURL } from 'url'; + const CONFIG_PATH = pathToFileURL(${JSON.stringify(configPath)}).href; + let config = await import(CONFIG_PATH); + config = config && config.default ? config.default : config; + if (typeof config === 'function') { + try { + const maybeConfig = await config('phase-production-build', {}); + if (maybeConfig) config = maybeConfig; + } catch {} + } + if (config && Array.isArray(config.pageExtensions)) { + const pageExtensions = config.pageExtensions; + process.stdout.write(JSON.stringify(pageExtensions)); + } + ` +} 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 769fd538d5f46e..a07281fe7bb70e 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 @@ -2,6 +2,7 @@ import { defineRule } from '../utils/define-rule' import * as path from 'path' import * as fs from 'fs' import { getRootDirs } from '../utils/get-root-dirs' +import { getConfiguredPageExtensions } from '../utils/page-extensions' import { getUrlFromPagesDirectories, @@ -107,8 +108,10 @@ export default defineRule({ return {} } - const pageUrls = cachedGetUrlFromPagesDirectories('/', foundPagesDirs) - const appDirUrls = cachedGetUrlFromAppDirectory('/', foundAppDirs) + const pageExtensions = getConfiguredPageExtensions(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/page-extensions.ts b/packages/eslint-plugin-next/src/utils/page-extensions.ts new file mode 100644 index 00000000000000..8ebf53e2fa92e5 --- /dev/null +++ b/packages/eslint-plugin-next/src/utils/page-extensions.ts @@ -0,0 +1,26 @@ +import type { Rule } from 'eslint' + +const DEFAULT_PAGE_EXTENSIONS = ['js', 'jsx', 'ts', 'tsx'] + +export function getConfiguredPageExtensions(context: Rule.RuleContext): string[] { + const settings = context.settings?.next as { + pageExtensions?: unknown + } | undefined + const configured = Array.isArray(settings?.pageExtensions) + ? settings?.pageExtensions + : undefined + + if (configured && configured.length > 0) { + const normalized = configured + .filter((ext): ext is string => typeof ext === 'string') + .map((ext) => ext.trim()) + .filter(Boolean) + .map((ext) => ext.replace(/^\./, '').toLowerCase()) + + if (normalized.length > 0) { + return Array.from(new Set(normalized)) + } + } + + return DEFAULT_PAGE_EXTENSIONS +} diff --git a/packages/eslint-plugin-next/src/utils/url.ts b/packages/eslint-plugin-next/src/utils/url.ts index c0d7c22bddc99e..55f8c74f18ead1 100644 --- a/packages/eslint-plugin-next/src/utils/url.ts +++ b/packages/eslint-plugin-next/src/utils/url.ts @@ -3,32 +3,88 @@ import * as fs from 'fs' // Cache for fs.readdirSync lookup. // Prevent multiple blocking IO requests that have already been calculated. -const fsReadDirSyncCache = {} +const fsReadDirSyncCache: Record = {} + +const DEFAULT_EXTENSIONS = ['js', 'jsx', 'ts', 'tsx'] + +type PageExtensionUtils = { + matchesFile: (name: string) => boolean + stripExtension: (name: string) => string +} + +const sanitizeExtensions = (extensions: string[]): string[] => { + if (!extensions.length) { + return DEFAULT_EXTENSIONS + } + + const normalized = extensions + .map((extension) => extension.replace(/^\./, '').toLowerCase()) + .filter(Boolean) + + if (!normalized.length) { + return DEFAULT_EXTENSIONS + } + + return Array.from(new Set(normalized)) +} + +const createPageExtensionUtils = (pageExtensions: string[]): PageExtensionUtils => { + const normalized = sanitizeExtensions(pageExtensions) + const sortedExtensions = [...normalized].sort((a, b) => b.length - a.length) + + return { + matchesFile(name: string) { + const lowerName = name.toLowerCase() + return sortedExtensions.some((extension) => + lowerName.endsWith(`.${extension}`) + ) + }, + stripExtension(name: string) { + const lowerName = name.toLowerCase() + for (const extension of sortedExtensions) { + const suffix = `.${extension}` + if (lowerName.endsWith(suffix)) { + return name.slice(0, -suffix.length) + } + } + return name + }, + } +} /** * Recursively parse directory for page URLs. */ -function parseUrlForPages(urlprefix: string, directory: string) { +function parseUrlForPages( + urlprefix: string, + directory: string, + utils: PageExtensionUtils +) { fsReadDirSyncCache[directory] ??= fs.readdirSync(directory, { withFileTypes: true, }) - const res = [] + const res: string[] = [] 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?)$/, '')}` - ) - } - 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)) - } + const dirPath = path.join(directory, dirent.name) + + if (dirent.isDirectory() && !dirent.isSymbolicLink()) { + res.push( + ...parseUrlForPages(urlprefix + dirent.name + '/', dirPath, utils) + ) + return } + + if (!dirent.isFile() || !utils.matchesFile(dirent.name)) { + return + } + + const strippedName = utils.stripExtension(dirent.name) + + if (/^index$/i.test(strippedName)) { + res.push(`${urlprefix}`) + } + + res.push(`${urlprefix}${strippedName}`) }) return res } @@ -36,26 +92,41 @@ function parseUrlForPages(urlprefix: string, directory: string) { /** * Recursively parse app directory for URLs. */ -function parseUrlForAppDir(urlprefix: string, directory: string) { +function parseUrlForAppDir( + urlprefix: string, + directory: string, + utils: PageExtensionUtils +) { fsReadDirSyncCache[directory] ??= fs.readdirSync(directory, { withFileTypes: true, }) - const res = [] + const res: string[] = [] 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?)$/, '')}`) - } - } else { - const dirPath = path.join(directory, dirent.name) - if (dirent.isDirectory(dirPath) && !dirent.isSymbolicLink()) { - res.push(...parseUrlForPages(urlprefix + dirent.name + '/', dirPath)) - } + const dirPath = path.join(directory, dirent.name) + + if (dirent.isDirectory() && !dirent.isSymbolicLink()) { + res.push( + ...parseUrlForAppDir(urlprefix + dirent.name + '/', dirPath, utils) + ) + return } + + if (!dirent.isFile() || !utils.matchesFile(dirent.name)) { + return + } + + const strippedName = utils.stripExtension(dirent.name) + + if (strippedName === 'layout') { + return + } + + if (strippedName === 'page') { + res.push(`${urlprefix}`) + return + } + + res.push(`${urlprefix}${strippedName}`) }) return res } @@ -136,13 +207,16 @@ export function normalizeAppPath(route: string) { */ export function getUrlFromPagesDirectories( urlPrefix: string, - directories: string[] + directories: string[], + pageExtensions: string[] ) { + const utils = createPageExtensionUtils(pageExtensions) + return Array.from( // De-duplicate similar pages across multiple directories. new Set( directories - .flatMap((directory) => parseUrlForPages(urlPrefix, directory)) + .flatMap((directory) => parseUrlForPages(urlPrefix, directory, utils)) .map( // Since the URLs are normalized we add `^` and `$` to the RegExp to make sure they match exactly. (url) => `^${normalizeURL(url)}$` @@ -156,13 +230,16 @@ export function getUrlFromPagesDirectories( export function getUrlFromAppDirectory( urlPrefix: string, - directories: string[] + directories: string[], + pageExtensions: string[] ) { + const utils = createPageExtensionUtils(pageExtensions) + return Array.from( // De-duplicate similar pages across multiple directories. new Set( directories - .map((directory) => parseUrlForAppDir(urlPrefix, directory)) + .map((directory) => parseUrlForAppDir(urlPrefix, directory, utils)) .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 2d2af77ca54321..43c5f6576df6a0 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 @@ -64,7 +64,7 @@ const linterConfigWithMultipleDirectories = { ], }, } -const linterConfigWithNestedContentRootDirDirectory = { +const linterConfigWithCustomPageExtensions: any = {\n ...linterConfig,\n settings: {\n ...(linterConfig.settings ?? {}),\n next: {\n ...((linterConfig.settings && linterConfig.settings.next) ?? {}),\n pageExtensions: ['page.tsx'],\n },\n },\n}\n\nconst linterConfigWithNestedContentRootDirDirectory = { ...linterConfig, settings: { next: { @@ -391,6 +391,40 @@ describe('no-html-link-for-pages', function () { 'Do not use an `` element to navigate to `/list/lorem-ipsum/`. Use `` from `next/link` instead. See: https://nextjs.org/docs/messages/no-html-link-for-pages' ) }) + it('invalid static route with custom page extensions', function () { + const [report] = linters.withCustomPageExtensions.verify( + invalidStaticCode, + linterConfigWithCustomPageExtensions, + { 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 dynamic route with custom page extensions', function () { + const [report] = linters.withCustomPageExtensions.verify( + invalidDynamicCode, + linterConfigWithCustomPageExtensions, + { filename: 'foo.js' } + ) + assert.notEqual(report, undefined, 'No lint errors found.') + assert.equal( + report.message, + 'Do not use an `` element to navigate to `/list/foo/bar/`. Use `` from `next/link` instead. See: https://nextjs.org/docs/messages/no-html-link-for-pages' + ) + const [secondReport] = linters.withCustomPageExtensions.verify( + secondInvalidDynamicCode, + linterConfigWithCustomPageExtensions, + { filename: 'foo.js' } + ) + assert.notEqual(secondReport, undefined, 'No lint errors found.') + assert.equal( + secondReport.message, + 'Do not use an `` element to navigate to `/list/foo/`. Use `` from `next/link` instead. See: https://nextjs.org/docs/messages/no-html-link-for-pages' + ) + }) it('valid link element with appDir', function () { const report = linters.withApp.verify(validCode, linterConfig, { filename: 'foo.js', diff --git a/test/unit/eslint-plugin-next/with-custom-page-extensions/pages/about.page.tsx b/test/unit/eslint-plugin-next/with-custom-page-extensions/pages/about.page.tsx new file mode 100644 index 00000000000000..cf101c46fa0d49 --- /dev/null +++ b/test/unit/eslint-plugin-next/with-custom-page-extensions/pages/about.page.tsx @@ -0,0 +1 @@ +export default function About() {\n return
about
\n}\n diff --git a/test/unit/eslint-plugin-next/with-custom-page-extensions/pages/index.page.tsx b/test/unit/eslint-plugin-next/with-custom-page-extensions/pages/index.page.tsx new file mode 100644 index 00000000000000..eb6d5a03ac9fe5 --- /dev/null +++ b/test/unit/eslint-plugin-next/with-custom-page-extensions/pages/index.page.tsx @@ -0,0 +1 @@ +export default function Page() {\n return
home
\n}\n diff --git a/test/unit/eslint-plugin-next/with-custom-page-extensions/pages/list/[foo]/index.page.tsx b/test/unit/eslint-plugin-next/with-custom-page-extensions/pages/list/[foo]/index.page.tsx new file mode 100644 index 00000000000000..c9f15627f14f51 --- /dev/null +++ b/test/unit/eslint-plugin-next/with-custom-page-extensions/pages/list/[foo]/index.page.tsx @@ -0,0 +1 @@ +export default function FooPage() {\n return
foo
\n}\n