no-html-link-for-pages.js 3.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138
  1. // @ts-check
  2. const path = require('path')
  3. const fs = require('fs')
  4. const getRootDir = require('../utils/get-root-dirs')
  5. const {
  6. getUrlFromPagesDirectories,
  7. normalizeURL,
  8. execOnce,
  9. } = require('../utils/url')
  10. const pagesDirWarning = execOnce((pagesDirs) => {
  11. console.warn(
  12. `Pages directory cannot be found at ${pagesDirs.join(' or ')}. ` +
  13. 'If using a custom path, please configure with the `no-html-link-for-pages` rule in your eslint config file.'
  14. )
  15. })
  16. // Cache for fs.existsSync lookup.
  17. // Prevent multiple blocking IO requests that have already been calculated.
  18. const fsExistsSyncCache = {}
  19. const url = 'https://nextjs.org/docs/messages/no-html-link-for-pages'
  20. module.exports = {
  21. meta: {
  22. docs: {
  23. description:
  24. 'Prevent usage of `<a>` elements to navigate to internal Next.js pages.',
  25. category: 'HTML',
  26. recommended: true,
  27. url,
  28. },
  29. type: 'problem',
  30. schema: [
  31. {
  32. oneOf: [
  33. {
  34. type: 'string',
  35. },
  36. {
  37. type: 'array',
  38. uniqueItems: true,
  39. items: {
  40. type: 'string',
  41. },
  42. },
  43. ],
  44. },
  45. ],
  46. },
  47. /**
  48. * Creates an ESLint rule listener.
  49. *
  50. * @param {import('eslint').Rule.RuleContext} context - ESLint rule context
  51. * @returns {import('eslint').Rule.RuleListener} An ESLint rule listener
  52. */
  53. create: function (context) {
  54. /** @type {(string|string[])[]} */
  55. const ruleOptions = context.options
  56. const [customPagesDirectory] = ruleOptions
  57. const rootDirs = getRootDir(context)
  58. const pagesDirs = (
  59. customPagesDirectory
  60. ? [customPagesDirectory]
  61. : rootDirs.map((dir) => [
  62. path.join(dir, 'pages'),
  63. path.join(dir, 'src', 'pages'),
  64. ])
  65. ).flat()
  66. const foundPagesDirs = pagesDirs.filter((dir) => {
  67. if (fsExistsSyncCache[dir] === undefined) {
  68. fsExistsSyncCache[dir] = fs.existsSync(dir)
  69. }
  70. return fsExistsSyncCache[dir]
  71. })
  72. if (foundPagesDirs.length === 0) {
  73. pagesDirWarning(pagesDirs)
  74. return {}
  75. }
  76. const pageUrls = getUrlFromPagesDirectories('/', foundPagesDirs)
  77. return {
  78. JSXOpeningElement(node) {
  79. if (node.name.name !== 'a') {
  80. return
  81. }
  82. if (node.attributes.length === 0) {
  83. return
  84. }
  85. const target = node.attributes.find(
  86. (attr) => attr.type === 'JSXAttribute' && attr.name.name === 'target'
  87. )
  88. if (target && target.value.value === '_blank') {
  89. return
  90. }
  91. const href = node.attributes.find(
  92. (attr) => attr.type === 'JSXAttribute' && attr.name.name === 'href'
  93. )
  94. if (!href || (href.value && href.value.type !== 'Literal')) {
  95. return
  96. }
  97. const hasDownloadAttr = node.attributes.find(
  98. (attr) =>
  99. attr.type === 'JSXAttribute' && attr.name.name === 'download'
  100. )
  101. if (hasDownloadAttr) {
  102. return
  103. }
  104. const hrefPath = normalizeURL(href.value.value)
  105. // Outgoing links are ignored
  106. if (/^(https?:\/\/|\/\/)/.test(hrefPath)) {
  107. return
  108. }
  109. pageUrls.forEach((pageUrl) => {
  110. if (pageUrl.test(normalizeURL(hrefPath))) {
  111. context.report({
  112. node,
  113. message: `Do not use an \`<a>\` element to navigate to \`${hrefPath}\`. Use \`<Link />\` from \`next/link\` instead. See: ${url}`,
  114. })
  115. }
  116. })
  117. },
  118. }
  119. },
  120. }