diff --git a/packages/eslint-plugin-next/lib/rules/no-html-link-for-pages.js b/packages/eslint-plugin-next/lib/rules/no-html-link-for-pages.js index 77b9876d6a..f79d6667ab 100644 --- a/packages/eslint-plugin-next/lib/rules/no-html-link-for-pages.js +++ b/packages/eslint-plugin-next/lib/rules/no-html-link-for-pages.js @@ -1,7 +1,7 @@ const path = require('path') const fs = require('fs') const { - getUrlFromPagesDirectory, + getUrlFromPagesDirectories, normalizeURL, execOnce, } = require('../utils/url') @@ -13,6 +13,10 @@ const pagesDirWarning = execOnce((pagesDirs) => { ) }) +// Cache for fs.existsSync lookup. +// Prevent multiple blocking IO requests that have already been calculated. +const fsExistsSyncCache = {} + module.exports = { meta: { docs: { @@ -21,24 +25,44 @@ module.exports = { recommended: true, }, fixable: null, // or "code" or "whitespace" - schema: ['pagesDirectory'], + schema: [ + { + oneOf: [ + { + type: 'string', + }, + { + type: 'array', + uniqueItems: true, + items: { + type: 'string', + }, + }, + ], + }, + ], }, create: function (context) { const [customPagesDirectory] = context.options const pagesDirs = customPagesDirectory - ? [customPagesDirectory] + ? [customPagesDirectory].flat() : [ path.join(context.getCwd(), 'pages'), path.join(context.getCwd(), 'src', 'pages'), ] - const pagesDir = pagesDirs.find((dir) => fs.existsSync(dir)) - if (!pagesDir) { + const foundPagesDirs = pagesDirs.filter((dir) => { + if (fsExistsSyncCache[dir] === undefined) { + fsExistsSyncCache[dir] = fs.existsSync(dir) + } + return fsExistsSyncCache[dir] + }) + if (foundPagesDirs.length === 0) { pagesDirWarning(pagesDirs) return {} } - const urls = getUrlFromPagesDirectory('/', pagesDir) + const urls = getUrlFromPagesDirectories('/', foundPagesDirs) return { JSXOpeningElement(node) { if (node.name.name !== 'a') { diff --git a/packages/eslint-plugin-next/lib/utils/url.js b/packages/eslint-plugin-next/lib/utils/url.js index ae1163b47f..838b40c4d3 100644 --- a/packages/eslint-plugin-next/lib/utils/url.js +++ b/packages/eslint-plugin-next/lib/utils/url.js @@ -1,12 +1,20 @@ const fs = require('fs') const path = require('path') +// Cache for fs.lstatSync lookup. +// Prevent multiple blocking IO requests that have already been calculated. +const fsLstatSyncCache = {} +const fsLstatSync = (source) => { + fsLstatSyncCache[source] = fsLstatSyncCache[source] || fs.lstatSync(source) + return fsLstatSyncCache[source] +} + /** * Checks if the source is a directory. * @param {string} source */ function isDirectory(source) { - return fs.lstatSync(source).isDirectory() + return fsLstatSync(source).isDirectory() } /** @@ -14,30 +22,43 @@ function isDirectory(source) { * @param {string} source */ function isSymlink(source) { - return fs.lstatSync(source).isSymbolicLink() + return fsLstatSync(source).isSymbolicLink() } /** * Gets the possible URLs from a directory. * @param {string} urlprefix - * @param {string} directory + * @param {string[]} directories */ -function getUrlFromPagesDirectory(urlPrefix, directory) { - return parseUrlForPages(urlPrefix, directory).map( - // Since the URLs are normalized we add `^` and `$` to the RegExp to make sure they match exactly. - (url) => new RegExp(`^${normalizeURL(url)}$`) - ) +function getUrlFromPagesDirectories(urlPrefix, directories) { + return Array.from( + // De-duplicate similar pages across multiple directories. + new Set( + directories + .map((directory) => parseUrlForPages(urlPrefix, directory)) + .flat() + .map( + // Since the URLs are normalized we add `^` and `$` to the RegExp to make sure they match exactly. + (url) => `^${normalizeURL(url)}$` + ) + ) + ).map((urlReg) => new RegExp(urlReg)) } +// Cache for fs.readdirSync lookup. +// Prevent multiple blocking IO requests that have already been calculated. +const fsReadDirSyncCache = {} + /** * Recursively parse directory for page URLs. * @param {string} urlprefix * @param {string} directory */ function parseUrlForPages(urlprefix, directory) { - const files = fs.readdirSync(directory) + fsReadDirSyncCache[directory] = + fsReadDirSyncCache[directory] || fs.readdirSync(directory) const res = [] - files.forEach((fname) => { + fsReadDirSyncCache[directory].forEach((fname) => { if (/(\.(j|t)sx?)$/.test(fname)) { fname = fname.replace(/\[.*\]/g, '.*') if (/^index(\.(j|t)sx?)$/.test(fname)) { @@ -90,7 +111,7 @@ function execOnce(fn) { } module.exports = { - getUrlFromPagesDirectory, + getUrlFromPagesDirectories, normalizeURL, execOnce, } diff --git a/test/eslint-plugin-next/no-html-link-for-pages.unit.test.js b/test/eslint-plugin-next/no-html-link-for-pages.unit.test.js index 2e5e1c294b..6f292c9cfb 100644 --- a/test/eslint-plugin-next/no-html-link-for-pages.unit.test.js +++ b/test/eslint-plugin-next/no-html-link-for-pages.unit.test.js @@ -18,6 +18,18 @@ const linterConfig = { }, }, } +const linterConfigWithMultipleDirectories = { + ...linterConfig, + rules: { + 'no-html-link-for-pages': [ + 2, + [ + path.join(__dirname, 'custom-pages'), + path.join(__dirname, 'custom-pages/list'), + ], + ], + }, +} linter.defineRules({ 'no-html-link-for-pages': rule, @@ -108,6 +120,17 @@ describe('no-html-link-for-pages', function () { assert.deepEqual(report, []) }) + it('valid link element with multiple directories', function () { + const report = linter.verify( + validCode, + linterConfigWithMultipleDirectories, + { + filename: 'foo.js', + } + ) + assert.deepEqual(report, []) + }) + it('valid anchor element', function () { const report = linter.verify(validAnchorCode, linterConfig, { filename: 'foo.js',