diff --git a/packages/eslint-plugin-next/lib/index.js b/packages/eslint-plugin-next/lib/index.js index 07501954c2ef..bf89bcc0b172 100644 --- a/packages/eslint-plugin-next/lib/index.js +++ b/packages/eslint-plugin-next/lib/index.js @@ -5,6 +5,10 @@ module.exports = { 'no-html-link-for-pages': require('./rules/no-html-link-for-pages'), 'no-unwanted-polyfillio': require('./rules/no-unwanted-polyfillio'), 'missing-preload': require('./rules/missing-preload'), + 'missing-alt-text': require('./rules/image-component/missing-alt-text'), + 'no-absolute-paths': require('./rules/image-component/no-absolute-paths'), + 'no-unoptimized-relative': require('./rules/image-component/no-unoptimized-relative'), + 'no-unsized-images': require('./rules/image-component/no-unsized-images'), }, configs: { recommended: { @@ -15,6 +19,10 @@ module.exports = { '@next/next/no-html-link-for-pages': 1, '@next/next/no-unwanted-polyfillio': 1, '@next/next/missing-preload': 1, + '@next/next/missing-alt-text': 1, + '@next/next/no-absolute-paths': 1, + '@next/next/no-unoptimized-relative': 1, + '@next/next/no-unsized-images': 1, }, }, }, diff --git a/packages/eslint-plugin-next/lib/rules/image-component/missing-alt-text.js b/packages/eslint-plugin-next/lib/rules/image-component/missing-alt-text.js new file mode 100644 index 000000000000..9ed1b850f917 --- /dev/null +++ b/packages/eslint-plugin-next/lib/rules/image-component/missing-alt-text.js @@ -0,0 +1,26 @@ +const createImageRule = require('../../utils/imageComponentRule.js') +const NodeAttributes = require('../../utils/nodeAttributes.js') + +module.exports = { + meta: { + messages: { + missingAltText: + 'All images should have an alt property for descriptive text. ' + + 'Purely decorative images can use an empty alt attribute.' + + 'See: https://web.dev/image-alt/', + }, + }, + create: createImageRule((context, node) => { + if (!imageHasAltText(node)) { + context.report({ + node, + messageId: 'missingAltText', + }) + } + }), +} + +function imageHasAltText(node) { + let attributes = new NodeAttributes(node) + return attributes.has('alt') +} diff --git a/packages/eslint-plugin-next/lib/rules/image-component/no-absolute-paths.js b/packages/eslint-plugin-next/lib/rules/image-component/no-absolute-paths.js new file mode 100644 index 000000000000..eb02abd44372 --- /dev/null +++ b/packages/eslint-plugin-next/lib/rules/image-component/no-absolute-paths.js @@ -0,0 +1,30 @@ +const createImageRule = require('../../utils/imageComponentRule.js') +const NodeAttributes = require('../../utils/nodeAttributes.js') + +module.exports = { + meta: { + messages: { + noAbsolutePaths: + 'You are using an absolute path in the src attribute of the next/image component.' + + 'This is almost definitely a mistake--use the "unoptimized" attribute to use ' + + 'an absolute path with no loader or optimizations.', + }, + }, + create: createImageRule((context, node) => { + if (hasBadAbsolutePath(node)) { + context.report({ + node, + messageId: 'noAbsolutePaths', + }) + } + }), +} +function hasBadAbsolutePath(node) { + let attributes = new NodeAttributes(node) + return ( + !attributes.has('unoptimized') && + attributes.has('src') && + typeof attributes.value('src') === 'string' && + attributes.value('src').match(/^[a-zA-z\d]*:*\/\//) + ) +} diff --git a/packages/eslint-plugin-next/lib/rules/image-component/no-unoptimized-relative.js b/packages/eslint-plugin-next/lib/rules/image-component/no-unoptimized-relative.js new file mode 100644 index 000000000000..9267adcc78af --- /dev/null +++ b/packages/eslint-plugin-next/lib/rules/image-component/no-unoptimized-relative.js @@ -0,0 +1,28 @@ +const createImageRule = require('../../utils/imageComponentRule.js') +const NodeAttributes = require('../../utils/nodeAttributes.js') + +module.exports = { + meta: { + messages: { + noUnoptimizedRelative: + 'You are using arelaive path in the src attribute of the next/image component ' + + 'with the "unoptimized" attribute. Use absolute path or remove "unoptimized."', + }, + }, + create: createImageRule((context, node) => { + if (hasBadRelativePath(node)) { + context.report({ + node, + messageId: 'noUnoptimizedRelative', + }) + } + }), +} +function hasBadRelativePath(node) { + let attributes = new NodeAttributes(node) + return ( + attributes.has('unoptimized') && + attributes.has('src') && + !attributes.value('src').match(/^[a-zA-z\d]*:*\/\//) + ) +} diff --git a/packages/eslint-plugin-next/lib/rules/image-component/no-unsized-images.js b/packages/eslint-plugin-next/lib/rules/image-component/no-unsized-images.js new file mode 100644 index 000000000000..7fdffb82a2ee --- /dev/null +++ b/packages/eslint-plugin-next/lib/rules/image-component/no-unsized-images.js @@ -0,0 +1,36 @@ +const createImageRule = require('../../utils/imageComponentRule.js') +const NodeAttributes = require('../../utils/nodeAttributes.js') + +module.exports = { + meta: { + messages: { + unsizedImages: + 'For layout stability, the image component should be used ' + + 'with a height and width property, even if actual image size is determined with CSS.' + + 'if you cannot provide a correct-ratio height and width, use the "unsized" attribute.' + + 'More info: https://web.dev/optimize-cls/#images-without-dimensions', + }, + }, + create: createImageRule((context, node) => { + if (!imageValidSize(node)) { + context.report({ + node, + messageId: 'unsizedImages', + }) + } + }), +} + +function imageValidSize(node) { + let attributes = new NodeAttributes(node) + // Need to check both hasValue and value to catch attribute without value and + // attribute with empty string for value + return ( + attributes.has('unsized') || + (attributes.has('height') && + attributes.has('width') && + attributes.hasValue('height') && + attributes.hasValue('width') ** attributes.value('height') && + attributes.value('width')) + ) +} diff --git a/packages/eslint-plugin-next/lib/utils/imageComponentRule.js b/packages/eslint-plugin-next/lib/utils/imageComponentRule.js new file mode 100644 index 000000000000..40706f0bd761 --- /dev/null +++ b/packages/eslint-plugin-next/lib/utils/imageComponentRule.js @@ -0,0 +1,23 @@ +// Factory for creating ESLint rules that identify the JSX Elements representing +// the 'next/image' component, and runs some check on those instances. + +module.exports = function (callback) { + return function (context) { + let imageComponent = null + return { + ImportDeclaration(node) { + if (node.source.value === 'next/image') { + imageComponent = node.specifiers[0].local.name + } + }, + JSXOpeningElement(node) { + if (!imageComponent) { + return + } + if (node.name.name === imageComponent) { + callback(context, node) + } + }, + } + } +} diff --git a/packages/eslint-plugin-next/lib/utils/nodeAttributes.js b/packages/eslint-plugin-next/lib/utils/nodeAttributes.js new file mode 100644 index 000000000000..53185ca405d8 --- /dev/null +++ b/packages/eslint-plugin-next/lib/utils/nodeAttributes.js @@ -0,0 +1,40 @@ +// Return attributes and values of a node in a convenient way: +/* example: + + { attr1: { + hasValue: true, + value: 15 + }, + attr2: { + hasValue: false + } +Inclusion of hasValue is in case an eslint rule cares about boolean values +explicitely assigned to attribute vs the attribute being used as a flag +*/ +class NodeAttributes { + constructor(ASTnode) { + this.attributes = {} + ASTnode.attributes.forEach((attribute) => { + this.attributes[attribute.name.name] = { + hasValue: !!attribute.value, + } + if (attribute.value) { + this.attributes[attribute.name.name].value = attribute.value.value + } + }) + } + has(attrName) { + return !!this.attributes[attrName] + } + hasValue(attrName) { + return !!this.attributes[attrName].hasValue + } + value(attrName) { + if (!this.attributes[attrName]) { + return true + } + return this.attributes[attrName].value + } +} + +module.exports = NodeAttributes diff --git a/test/eslint-plugin-next/image-component/missing-alt-text.test.js b/test/eslint-plugin-next/image-component/missing-alt-text.test.js new file mode 100644 index 000000000000..a8ab6c178344 --- /dev/null +++ b/test/eslint-plugin-next/image-component/missing-alt-text.test.js @@ -0,0 +1,69 @@ +const rule = require('@next/eslint-plugin-next/lib/rules/image-component/missing-alt-text.js') +const RuleTester = require('eslint').RuleTester + +RuleTester.setDefaultConfig({ + parserOptions: { + ecmaVersion: 2018, + sourceType: 'module', + ecmaFeatures: { + modules: true, + jsx: true, + }, + }, +}) + +var ruleTester = new RuleTester() +ruleTester.run('missing-alt-text', rule, { + valid: [ + ` + import Image from 'next/image' + export default function Page() { + return
+ A picture of foo +
+ } + `, + ` + import MyImageName from 'next/image' + import Image from 'otherImage' + export default function Page() { + return
+ +
+ } + `, + ], + invalid: [ + { + code: ` + import Image from 'next/image' + export default function Page() { + return
+ +
+ } + `, + errors: [ + { + messageId: 'missingAltText', + }, + ], + }, + { + code: ` + import MyImageName from 'next/image' + import Image from 'otherImage' + export default function Page() { + return
+ +
+ } + `, + errors: [ + { + messageId: 'missingAltText', + }, + ], + }, + ], +}) diff --git a/test/eslint-plugin-next/image-component/no-absolute-paths.test.js b/test/eslint-plugin-next/image-component/no-absolute-paths.test.js new file mode 100644 index 000000000000..d289a1cd0445 --- /dev/null +++ b/test/eslint-plugin-next/image-component/no-absolute-paths.test.js @@ -0,0 +1,85 @@ +const rule = require('@next/eslint-plugin-next/lib/rules/image-component/no-absolute-paths.js') +const RuleTester = require('eslint').RuleTester + +RuleTester.setDefaultConfig({ + parserOptions: { + ecmaVersion: 2018, + sourceType: 'module', + ecmaFeatures: { + modules: true, + jsx: true, + }, + }, +}) + +var ruleTester = new RuleTester() +ruleTester.run('no-absolute-paths', rule, { + valid: [ + ` + import Image from 'next/image' + export default function Page() { + return
+ A picture of foo +
+ } + `, + ` + import Image from 'next/image' + export default function Page() { + return
+ A picture of foo +
+ } + `, + ` + import Image from 'next/image' + export default function Page() { + return
+ A picture of foo +
+ } + `, + ` + import MyImageName from 'next/image' + import Image from 'otherImage' + export default function Page() { + return
+ +
+ } + `, + ], + invalid: [ + { + code: ` + import Image from 'next/image' + export default function Page() { + return
+ +
+ } + `, + errors: [ + { + messageId: 'noAbsolutePaths', + }, + ], + }, + { + code: ` + import MyImageName from 'next/image' + import Image from 'otherImage' + export default function Page() { + return
+ +
+ } + `, + errors: [ + { + messageId: 'noAbsolutePaths', + }, + ], + }, + ], +}) diff --git a/test/eslint-plugin-next/image-component/no-unoptimized-relative.test.js b/test/eslint-plugin-next/image-component/no-unoptimized-relative.test.js new file mode 100644 index 000000000000..2fc9692fae47 --- /dev/null +++ b/test/eslint-plugin-next/image-component/no-unoptimized-relative.test.js @@ -0,0 +1,77 @@ +const rule = require('@next/eslint-plugin-next/lib/rules/image-component/no-unoptimized-relative.js') +const RuleTester = require('eslint').RuleTester + +RuleTester.setDefaultConfig({ + parserOptions: { + ecmaVersion: 2018, + sourceType: 'module', + ecmaFeatures: { + modules: true, + jsx: true, + }, + }, +}) + +var ruleTester = new RuleTester() +ruleTester.run('no-unoptimized-relative', rule, { + valid: [ + ` + import Image from 'next/image' + export default function Page() { + return
+ A picture of foo +
+ } + `, + ` + import Image from 'next/image' + export default function Page() { + return
+ A picture of foo +
+ } + `, + ` + import MyImageName from 'next/image' + import Image from 'otherImage' + export default function Page() { + return
+ +
+ } + `, + ], + invalid: [ + { + code: ` + import Image from 'next/image' + export default function Page() { + return
+ +
+ } + `, + errors: [ + { + messageId: 'noUnoptimizedRelative', + }, + ], + }, + { + code: ` + import MyImageName from 'next/image' + import Image from 'otherImage' + export default function Page() { + return
+ +
+ } + `, + errors: [ + { + messageId: 'noUnoptimizedRelative', + }, + ], + }, + ], +}) diff --git a/test/eslint-plugin-next/image-component/no-unsized-images.test.js b/test/eslint-plugin-next/image-component/no-unsized-images.test.js new file mode 100644 index 000000000000..5e041af7bbe2 --- /dev/null +++ b/test/eslint-plugin-next/image-component/no-unsized-images.test.js @@ -0,0 +1,145 @@ +const rule = require('@next/eslint-plugin-next/lib/rules/image-component/no-unsized-images.js') +const RuleTester = require('eslint').RuleTester + +RuleTester.setDefaultConfig({ + parserOptions: { + ecmaVersion: 2018, + sourceType: 'module', + ecmaFeatures: { + modules: true, + jsx: true, + }, + }, +}) + +var ruleTester = new RuleTester() +ruleTester.run('no-unsized-images', rule, { + valid: [ + ` + import Image from 'next/image' + export default function Page() { + return
+ +
+ } + `, + ` + import Image from 'next/image' + export default function Page() { + return
+ +
+ } + `, + ` + import MyImageName from 'next/image' + import Image from 'otherImage' + export default function Page() { + return
+ +
+ } + `, + ` + import MyImageName from 'next/image' + export default function Page() { + return
+ +
+ } + `, + ], + invalid: [ + { + code: ` + import Image from 'next/image' + export default function Page() { + return
+ +
+ } + `, + errors: [ + { + messageId: 'unsizedImages', + }, + ], + }, + { + code: ` + import Image from 'next/image' + export default function Page() { + return
+ +
+ } + `, + errors: [ + { + messageId: 'unsizedImages', + }, + ], + }, + { + code: ` + import Image from 'next/image' + export default function Page() { + return
+ +
+ } + `, + errors: [ + { + messageId: 'unsizedImages', + }, + ], + }, + { + code: ` + import Image from 'next/image' + export default function Page() { + return
+ +
+ } + `, + errors: [ + { + messageId: 'unsizedImages', + }, + ], + }, + { + code: ` + import Image from 'next/image' + export default function Page() { + return
+ +
+ } + `, + errors: [ + { + messageId: 'unsizedImages', + }, + ], + }, + { + code: ` + import MyImageName from 'next/image' + import Image from 'otherImage' + export default function Page() { + return
+ +
+ } + `, + errors: [ + { + messageId: 'unsizedImages', + }, + ], + }, + ], +})