Skip to content
Closed
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
59 changes: 57 additions & 2 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 @@ -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 = <T = any>(fn: (...args: any[]) => T) => {
const cache = {}
return (...args: any[]): T => {
Expand Down Expand Up @@ -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 {
Expand Down
73 changes: 50 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,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))
}
}
})
Expand All @@ -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))
}
}
})
Expand Down Expand Up @@ -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)}$`
Expand All @@ -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.
Expand Down
Loading