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

ESLint rules for the image component #17694

Closed
wants to merge 8 commits into from
8 changes: 8 additions & 0 deletions packages/eslint-plugin-next/lib/index.js
Expand Up @@ -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: {
Expand All @@ -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,
},
},
},
Expand Down
@@ -0,0 +1,26 @@
const createImageRule = require('../../utils/imageComponentRule.js')
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can omit the .js suffix for all the imports

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')
}
@@ -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 ' +

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

adding "unoptimized" is a last resort opt out right?
point to documentation link to what they should actually do to optimize?

'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]*:*\/\//)
)
}
@@ -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 ' +
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
'You are using arelaive path in the src attribute of the next/image component ' +
'You are using a relative 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]*:*\/\//)
)
}
@@ -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.' +

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do we know of legit cases for the opt out? is this the right way to opt out vs. via config file?

'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'))
)
}
23 changes: 23 additions & 0 deletions 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)
}
},
}
}
}
40 changes: 40 additions & 0 deletions packages/eslint-plugin-next/lib/utils/nodeAttributes.js
@@ -0,0 +1,40 @@
// Return attributes and values of a node in a convenient way:
/* example:
<ExampleElement attr1="15" attr2>
{ 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
@@ -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 <div>
<Image height="150" width="100" src="foo.jpg" alt="A picture of foo" />
</div>
}
`,
`
import MyImageName from 'next/image'
import Image from 'otherImage'
export default function Page() {
return <div>
<Image src="foo.jpg" />
</div>
}
`,
],
invalid: [
{
code: `
import Image from 'next/image'
export default function Page() {
return <div>
<Image src="foo.jpg" />
</div>
}
`,
errors: [
{
messageId: 'missingAltText',
},
],
},
{
code: `
import MyImageName from 'next/image'
import Image from 'otherImage'
export default function Page() {
return <div>
<MyImageName src="foo.jpg" />
</div>
}
`,
errors: [
{
messageId: 'missingAltText',
},
],
},
],
})
@@ -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 <div>
<Image height="150" width="100" src="foo.jpg" alt="A picture of foo" />
</div>
}
`,
`
import Image from 'next/image'
export default function Page() {
return <div>
<Image height="150" width="100" src="/bar/foo.jpg" alt="A picture of foo" />
</div>
}
`,
`
import Image from 'next/image'
export default function Page() {
return <div>
<Image height="150" width="100" src="https://www.foo.com/foo.jpg" unoptimized alt="A picture of foo" />
atcastle marked this conversation as resolved.
Show resolved Hide resolved
</div>
}
`,
`
import MyImageName from 'next/image'
import Image from 'otherImage'
export default function Page() {
return <div>
<Image src="https://www.foo.com/foo.jpg" />
</div>
}
`,
],
invalid: [
{
code: `
import Image from 'next/image'
export default function Page() {
return <div>
<Image src="https://www.foo.com/foo.jpg" />
</div>
}
`,
errors: [
{
messageId: 'noAbsolutePaths',
},
],
},
{
code: `
import MyImageName from 'next/image'
import Image from 'otherImage'
export default function Page() {
return <div>
<MyImageName src="https://www.foo.com/foo.jpg" />
</div>
}
`,
errors: [
{
messageId: 'noAbsolutePaths',
},
],
},
],
})