Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(eslint-plugin-next): add pageExtensions option #54474

Open
wants to merge 1 commit into
base: canary
Choose a base branch
from
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
55 changes: 50 additions & 5 deletions errors/no-html-link-for-pages.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,26 @@ export default Home

### Options

The rule accepts the following options:

```ts
type NoHTMLLinkForPagesOptions =
| string
| string[]
| {
pagesDir?: string | string[]
pageExtensions?: string | string[]
}
```

The values can be:

- `string` - It's equivalent to the `pagesDir` argument.
- `string[]` - It's equivalent to the `pagesDir` argument.
- An object with the following properties:
- `pagesDir` - a path to a directory or an array of paths to directories
- `pageExtensions` - a string or an array of strings representing the extensions of the page files

#### `pagesDir`

This rule can normally locate your `pages` directory automatically.
Expand All @@ -52,14 +72,39 @@ If you're working in a monorepo, we recommend configuring the [`rootDir`](/docs/

In some cases, you may also need to configure this rule directly by providing a `pages` directory. This can be a path or an array of paths.

```json filename="eslint.config.json"
{
"rules": {
"@next/next/no-html-link-for-pages": ["error", "packages/my-app/pages/"]
}
```js filename=".eslintrc.js" highlight={3}
module.exports = {
rules: {
'@next/next/no-html-link-for-pages': ['error', 'packages/foo/pages/'],
// or
// "@next/next/no-html-link-for-pages": ["error", { pagesDir: "packages/foo/pages/" }],
},
}
```

#### `pageExtensions`

If you set the [`pageExtensions`](/docs/app/api-reference/next-config-js/pageExtensions) config, this rule will not work and the `pageExtensions` option must be set for it to work.

```js filename="next.config.js" highlight={3}
/** @type {import('next').NextConfig} */
module.exports = {
pageExtensions: ['page.tsx', 'mdx'],
}
```

```js filename=".eslintrc.js" highlight={5}
module.exports = {
rules: {
'@next/next/no-html-link-for-pages': [
'error',
{ pageExtensions: ['page.tsx', 'mdx'] },
],
},
}
```

## Useful Links

- [next/link API Reference](/docs/pages/api-reference/components/link)
- [next.config.js Options: pageExtensions](/docs/app/api-reference/next-config-js/pageExtensions)
50 changes: 45 additions & 5 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 @@ -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 getRuleOptions from '../utils/get-rule-options'

import {
getUrlFromPagesDirectories,
Expand Down Expand Up @@ -45,6 +46,40 @@ export = defineRule({
type: 'string',
},
},
{
type: 'object',
additionalProperties: false,
properties: {
pagesDir: {
oneOf: [
{
type: 'string',
},
{
type: 'array',
uniqueItems: true,
items: {
type: 'string',
},
},
],
},
pageExtensions: {
oneOf: [
{
type: 'string',
},
{
type: 'array',
uniqueItems: true,
items: {
type: 'string',
},
},
],
},
},
},
],
},
],
Expand All @@ -54,14 +89,14 @@ export = defineRule({
* Creates an ESLint rule listener.
*/
create(context) {
const ruleOptions: (string | string[])[] = context.options
const [customPagesDirectory] = ruleOptions
// TODO: get `pageExtensions` from `next.config.js`
const { pagesDir, pageExtensions } = getRuleOptions(context)

const rootDirs = getRootDirs(context)

const pagesDirs = (
customPagesDirectory
? [customPagesDirectory]
pagesDir.length > 0
? pagesDir
: rootDirs.map((dir) => [
path.join(dir, 'pages'),
path.join(dir, 'src', 'pages'),
Expand Down Expand Up @@ -92,7 +127,12 @@ export = defineRule({
return {}
}

const pageUrls = getUrlFromPagesDirectories('/', foundPagesDirs)
const pageUrls = getUrlFromPagesDirectories(
'/',
foundPagesDirs,
pageExtensions
)

return {
JSXOpeningElement(node) {
if (node.name.name !== 'a') {
Expand Down
42 changes: 42 additions & 0 deletions packages/eslint-plugin-next/src/utils/get-rule-options.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import type { Rule } from 'eslint'

type RuleOption =
| string
| string[]
| {
pagesDir?: string | string[]
pageExtensions?: string | string[]
}

/**
* Gets the rule options.
*/
export default function getRuleOptions(context: Rule.RuleContext) {
const options: RuleOption | undefined = context.options?.[0]

let pagesDir: string[] = []
let pageExtensions: string[] = ['tsx', 'ts', 'jsx', 'js']

if (typeof options === 'string') {
pagesDir = [options]
} else if (Array.isArray(options)) {
pagesDir = options
} else if (typeof options === 'object' && options !== null) {
if (typeof options.pagesDir === 'string') {
pagesDir = [options.pagesDir]
} else if (Array.isArray(options.pagesDir)) {
pagesDir = options.pagesDir
}

if (typeof options.pageExtensions === 'string') {
pageExtensions = [options.pageExtensions]
} else if (Array.isArray(options.pageExtensions)) {
pageExtensions = options.pageExtensions
}
}

return {
pagesDir,
pageExtensions,
}
}
65 changes: 48 additions & 17 deletions packages/eslint-plugin-next/src/utils/url.ts
Original file line number Diff line number Diff line change
@@ -1,35 +1,63 @@
import * as path from 'path'
import * as fs from 'fs'

/**
* These characters `(.*+?^${}()|[]\)` are considered special characters in regular expressions, and need to be escaped if they are to be matched literally.
*
* @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions#escaping
*/
function escapeRegExp(str: string) {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}

// Cache for fs.readdirSync lookup.
// Prevent multiple blocking IO requests that have already been calculated.
const fsReadDirSyncCache = {}
const fsReadDirSyncCache: Record<string, fs.Dirent[]> = {}

/**
* 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 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?)$/, '')}`
if (dirent.isDirectory() && !dirent.isSymbolicLink()) {
res.push(
...parseUrlForPages(
urlprefix + dirent.name + '/',
path.join(directory, dirent.name),
pageExtensions
)
}
res.push(`${urlprefix}${dirent.name.replace(/(\.(j|t)sx?)$/, '')}`)
)
return
}

const ext = pageExtensions.find((pageExtension) =>
new RegExp(`\\.${escapeRegExp(pageExtension)}$`).test(dirent.name)
)

if (!ext) return

const replaced = escapeRegExp(ext)
const startsIndexReg = new RegExp(`^index\\.${replaced}$`)

if (startsIndexReg.test(dirent.name)) {
res.push(urlprefix + dirent.name.replace(startsIndexReg, ''))
} else {
const dirPath = path.join(directory, dirent.name)
if (dirent.isDirectory() && !dirent.isSymbolicLink()) {
res.push(...parseUrlForPages(urlprefix + dirent.name + '/', dirPath))
}
res.push(
urlprefix + dirent.name.replace(new RegExp(`\\.${replaced}$`), '')
)
}
})

return res
}

Expand Down Expand Up @@ -59,13 +87,16 @@ export function normalizeURL(url: 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 Down
Loading