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
6 changes: 6 additions & 0 deletions packages/eslint-config-next/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] = [
{
Expand Down Expand Up @@ -60,6 +63,9 @@ const config: Linter.Config[] = [
alwaysTryTypes: true,
},
},
...(detectedPageExtensions
? { next: { pageExtensions: detectedPageExtensions } }
: {}),
},
rules: {
...react.configs.recommended.rules,
Expand Down
103 changes: 103 additions & 0 deletions packages/eslint-config-next/src/utils/get-page-extensions.ts
Original file line number Diff line number Diff line change
@@ -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<string, NullableExtensions>()

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));
}
`
}
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 {
Expand Down
26 changes: 26 additions & 0 deletions packages/eslint-plugin-next/src/utils/page-extensions.ts
Original file line number Diff line number Diff line change
@@ -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
}
149 changes: 113 additions & 36 deletions packages/eslint-plugin-next/src/utils/url.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,59 +3,130 @@ 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<string, fs.Dirent[]> = {}

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
}

/**
* 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
}
Expand Down Expand Up @@ -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)}$`
Expand All @@ -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.
Expand Down
Loading
Loading