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
Changes from all commits
89f9f2d
1b13c25
58dca0c
67f0e1a
03e673e
d352e39
81a5a74
28ac11e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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') | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 ' + | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. adding "unoptimized" is a last resort opt out right? |
||
'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]*:*\/\//) | ||
) | ||
} |
Original file line number | Diff line number | Diff line change | ||||
---|---|---|---|---|---|---|
@@ -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 ' + | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||
'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]*:*\/\//) | ||||||
) | ||||||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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.' + | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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')) | ||
) | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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) | ||
} | ||
}, | ||
} | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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', | ||
}, | ||
], | ||
}, | ||
], | ||
}) |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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', | ||
}, | ||
], | ||
}, | ||
], | ||
}) |
There was a problem hiding this comment.
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