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

Add new prefer-ideal-image eslint rule #8826

Draft
wants to merge 5 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
7 changes: 7 additions & 0 deletions .eslintrc.js
Expand Up @@ -381,6 +381,7 @@ module.exports = {
'@typescript-eslint/prefer-optional-chain': ERROR,
'@docusaurus/no-html-links': ERROR,
'@docusaurus/prefer-docusaurus-heading': ERROR,
'@docusaurus/prefer-ideal-image': OFF,
'@docusaurus/no-untranslated-text': [
WARNING,
{
Expand Down Expand Up @@ -510,5 +511,11 @@ module.exports = {
'@docusaurus/prefer-docusaurus-heading': OFF,
},
},
{
files: ['website/**'],
rules: {
'@docusaurus/prefer-ideal-image': ERROR,
},
},
],
};
1 change: 1 addition & 0 deletions packages/eslint-plugin/src/index.ts
Expand Up @@ -25,6 +25,7 @@ export = {
'@docusaurus/no-untranslated-text': 'warn',
'@docusaurus/no-html-links': 'warn',
'@docusaurus/prefer-docusaurus-heading': 'warn',
'@docusaurus/prefer-ideal-image': 'warn',
},
},
},
Expand Down
@@ -0,0 +1,48 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

import rule from '../prefer-ideal-image';
import {RuleTester} from './testUtils';

const errorsJSX = [{messageId: 'image'}] as const;

const ruleTester = new RuleTester({
parser: '@typescript-eslint/parser',
parserOptions: {
ecmaFeatures: {
jsx: true,
},
},
});

ruleTester.run('prefer-ideal-image', rule, {
valid: [
{
code: "<IdealImage img='./path/to/img.png' />",
},
{
code: "<IdealImage img={require('./path/to/img.png')} />",
},
{
code: "<IdealImage img={{ src: { src: './path/to/img.png', preSrc: '', images: [{ width: 100 }]}, preSrc: './path/to/placeholder.png'}} />",
},
],
invalid: [
{
code: "<img src='./path/to/img.png' alt='some alt text' />",
Copy link
Collaborator

Choose a reason for hiding this comment

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

should not be rejected?

errors: errorsJSX,
},
{
code: "<img src={require('./path/to/img.png')} alt='some alt text' />",
errors: errorsJSX,
},
{
code: "<img src='./path/to/img.png' srcset='./path/to/img-480w.jpg 480w, ./path/to/img-800w.png 800w' sizes='(max-width: 600px) 480px, 800px' alt='some alt text' />",
errors: errorsJSX,
Comment on lines +44 to +45
Copy link
Collaborator

Choose a reason for hiding this comment

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

should not be rejected?

},
],
});
2 changes: 2 additions & 0 deletions packages/eslint-plugin/src/rules/index.ts
Expand Up @@ -7,6 +7,7 @@

import noHtmlLinks from './no-html-links';
import preferDocusaurusHeading from './prefer-docusaurus-heading';
import preferIdealImage from './prefer-ideal-image';
import noUntranslatedText from './no-untranslated-text';
import stringLiteralI18nMessages from './string-literal-i18n-messages';

Expand All @@ -15,4 +16,5 @@ export default {
'string-literal-i18n-messages': stringLiteralI18nMessages,
'no-html-links': noHtmlLinks,
'prefer-docusaurus-heading': preferDocusaurusHeading,
'prefer-ideal-image': preferIdealImage,
};
46 changes: 46 additions & 0 deletions packages/eslint-plugin/src/rules/prefer-ideal-image.ts
@@ -0,0 +1,46 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

import {createRule} from '../util';
import type {TSESTree} from '@typescript-eslint/types/dist/ts-estree';

type Options = [];
type MessageIds = 'image';

const docsUrl =
'https://docusaurus.io/docs/api/plugins/@docusaurus/plugin-ideal-image';

export default createRule<Options, MessageIds>({
name: 'prefer-ideal-image',
meta: {
type: 'suggestion',
docs: {
description:
'enforce using Docusaurus IdealImage plugin component instead of <img> tags',
recommended: false,
},
schema: [],
messages: {
image: `Do not use an \`<img>\` element to embed images. Use the \`<IdealImage />\` component from \`@theme/IdealImage\` instead. See ${docsUrl}`,
},
},
defaultOptions: [],

create(context) {
return {
JSXOpeningElement(node) {
const elementName = (node.name as TSESTree.JSXIdentifier).name;

if (elementName !== 'img') {
return;
}

context.report({node, messageId: 'image'});
},
};
},
});
1 change: 1 addition & 0 deletions website/docs/api/misc/eslint-plugin/README.mdx
Expand Up @@ -54,6 +54,7 @@ For more fine-grained control, you can also enable the plugin manually and confi
| [`@docusaurus/string-literal-i18n-messages`](./string-literal-i18n-messages.mdx) | Enforce translate APIs to be called on plain text labels | ✅ |
| [`@docusaurus/no-html-links`](./no-html-links.mdx) | Ensures @docusaurus/Link is used instead of `<a>` tags | ✅ |
| [`@docusaurus/prefer-docusaurus-heading`](./prefer-docusaurus-heading.mdx) | Ensures @theme/Heading is used instead of `<hn>` tags for headings | ✅ |
| [`@docusaurus/prefer-ideal-image`](./prefer-ideal-image.mdx) | Ensures @theme/IdealImage is used instead of `<img>` tags for embedding images | |

✅ = recommended

Expand Down
46 changes: 46 additions & 0 deletions website/docs/api/misc/eslint-plugin/prefer-ideal-image.mdx
@@ -0,0 +1,46 @@
---
slug: /api/misc/@docusaurus/eslint-plugin/prefer-ideal-image
---

# prefer-ideal-image

Ensure that the `<IdealImage />` component provided by [`@theme/IdealImage`](../../plugins/plugin-ideal-image.mdx) Docusaurus plugin is used instead of `<img>` tags.

The `@theme/IdealImage` Docusaurus plugin generates an almost ideal image (responsive, lazy-loading, and low quality placeholder).

## Rule Details {#details}

Examples of **incorrect** code for this rule:

```html
<img src="./path/to/img.png" alt="some alt text" />

<img src={require('./path/to/img.png')} alt='some alt text' />

<img
src="./path/to/img.png"
srcset="./path/to/img-480w.jpg 480w, ./path/to/img-800w.png 800w"
sizes="(max-width: 600px) 480px, 800px"
alt="some alt text" />
```

Examples of **correct** code for this rule:

```javascript
import Image from '@theme/IdealImage';

<IdealImage img='./path/to/img.png' />

<IdealImage img={require('./path/to/img.png')} />

<IdealImage
img={{
src: {
src: './path/to/img.png',
preSrc: '',
images: [{ width: 100 }]
},
preSrc: './path/to/placeholder.png'
}}
/>
```
5 changes: 3 additions & 2 deletions website/src/components/ProductHuntCard.tsx
Expand Up @@ -8,6 +8,7 @@
import type {ComponentProps} from 'react';
import React from 'react';
import Link from '@docusaurus/Link';
import Image from '@theme/IdealImage';

export default function ProductHuntCard({
className,
Expand All @@ -21,8 +22,8 @@ export default function ProductHuntCard({
to="https://www.producthunt.com/posts/docusaurus-2-0?utm_source=badge-featured&utm_medium=badge&utm_souce=badge-docusaurus-2-0"
className={className}
style={{display: 'block', width: 250, height: 54, ...style}}>
<img
src="https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=353916&theme=light"
<Image
img="https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=353916&theme=light"
Comment on lines -24 to +26
Copy link
Collaborator

Choose a reason for hiding this comment

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

Ideal image is not designed to work with external URLs. <img> is fine in this case

alt="Docusaurus 2.0 - Build optimized websites quickly, focus on your content. | Product Hunt"
style={{width: 250, height: 54, maxWidth: 'initial'}}
width={250}
Expand Down
5 changes: 3 additions & 2 deletions website/src/components/TeamProfileCards/index.tsx
Expand Up @@ -9,6 +9,7 @@ import React, {type ReactNode} from 'react';
import Translate from '@docusaurus/Translate';
import Link from '@docusaurus/Link';
import Heading from '@theme/Heading';
import Image from '@theme/IdealImage';

function WebsiteLink({to, children}: {to: string; children?: ReactNode}) {
return (
Expand Down Expand Up @@ -40,9 +41,9 @@ function TeamProfileCard({
<div className="card card--full-height">
<div className="card__header">
<div className="avatar avatar--vertical">
<img
<Image
className="avatar__photo avatar__photo--xl"
src={`${githubUrl}.png`}
img={`${githubUrl}.png`}
Comment on lines -43 to +46
Copy link
Collaborator

Choose a reason for hiding this comment

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

Ideal image is not designed to work with external URLs. <img> is fine in this case

alt={`${name}'s avatar`}
/>
<div className="avatar__intro">
Expand Down
5 changes: 3 additions & 2 deletions website/src/components/Tweet/index.tsx
Expand Up @@ -10,6 +10,7 @@ import React, {type ReactNode} from 'react';
import clsx from 'clsx';

import Link from '@docusaurus/Link';
import Image from '@theme/IdealImage';
import styles from './styles.module.css';

export interface Props {
Expand All @@ -33,10 +34,10 @@ export default function Tweet({
<div className={clsx('card', styles.tweet)}>
<div className="card__header">
<div className="avatar">
<img
<Image
alt={name}
className="avatar__photo"
src={`https://unavatar.io/twitter/${handle}?fallback=https://github.com/${githubUsername}.png`}
img={`https://unavatar.io/twitter/${handle}?fallback=https://github.com/${githubUsername}.png`}
Comment on lines -36 to +40
Copy link
Collaborator

Choose a reason for hiding this comment

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

Ideal image is not designed to work with external URLs. <img> is fine in this case

width="48"
height="48"
loading="lazy"
Expand Down
5 changes: 3 additions & 2 deletions website/src/components/TweetQuote/index.tsx
Expand Up @@ -10,6 +10,7 @@ import React, {type ReactNode} from 'react';
import clsx from 'clsx';

import Link from '@docusaurus/Link';
import Image from '@theme/IdealImage';
import styles from './styles.module.css';

export interface Props {
Expand Down Expand Up @@ -37,10 +38,10 @@ export default function TweetQuote({
<figcaption>
<Link to={profileUrl} rel="nofollow">
<div className="avatar">
<img
<Image
alt={name}
className={clsx('avatar__photo', styles.avatarImg)}
src={avatar}
img={avatar}
Comment on lines +41 to +44
Copy link
Collaborator

Choose a reason for hiding this comment

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

Ideal image is not designed to work with external URLs. <img> is fine in this case

// loading="lazy"
/>
<div className={clsx('avatar__intro')}>
Expand Down
8 changes: 4 additions & 4 deletions website/src/pages/index.tsx
Expand Up @@ -31,10 +31,10 @@ function HeroBanner() {
<div className={styles.hero} data-theme="dark">
<div className={styles.heroInner}>
<Heading as="h1" className={styles.heroProjectTagline}>
<img
<Image
alt={translate({message: 'Docusaurus with Keytar'})}
className={styles.heroLogo}
src={useBaseUrl('/img/docusaurus_keytar.svg')}
img={useBaseUrl('/img/docusaurus_keytar.svg')}
Comment on lines -34 to +37
Copy link
Collaborator

Choose a reason for hiding this comment

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

the ideal image plugin is not designed to work with SVGs

Eventually we could import the svgs and use them as components instead of using <img src="xyz.svg"/> like we do atm

width="200"
height="200"
/>
Expand Down Expand Up @@ -190,12 +190,12 @@ function Feature({

return (
<div className={clsx('col', className)}>
<img
<Image
className={styles.featureImage}
alt={feature.title}
width={Math.floor(feature.image.width)}
height={Math.floor(feature.image.height)}
src={withBaseUrl(feature.image.src)}
img={withBaseUrl(feature.image.src)}
Comment on lines +193 to +198
Copy link
Collaborator

Choose a reason for hiding this comment

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

the ideal image plugin is not designed to work with SVGs

Eventually we could import the svgs and use them as components instead of using <img src="xyz.svg"/> like we do atm

Copy link
Contributor Author

Choose a reason for hiding this comment

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

the ideal image plugin is not designed to work with SVGs

I had noticed that it only works with JPEGs and PNGs but somehow completely missed out in this case 😅

Eventually we could import the svgs and use them as components instead of using <img src="xyz.svg"/> like we do atm

@slorber Should I address this change in this PR?

loading="lazy"
/>
<Heading as="h3" className={clsx(styles.featureHeading)}>
Expand Down
Expand Up @@ -9,6 +9,7 @@ import React from 'react';
import clsx from 'clsx';
import Link from '@docusaurus/Link';
import type {Props} from '@theme/BlogPostItem/Header/Author';
import Image from '@theme/IdealImage';

import styles from './styles.module.css';

Expand All @@ -21,16 +22,25 @@ export default function ChangelogAuthor({
<div className={clsx('avatar margin-bottom--sm', className)}>
{imageURL && (
<Link className="avatar__photo-link avatar__photo" href={url}>
<img
<Image
className={styles.image}
src={imageURL}
alt={name}
onError={(e) => {
// Image returns 404 if the user's handle changes. We display a
// fallback instead.
e.currentTarget.src =
'data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="600" height="600" fill="none" stroke="%2325c2a0" stroke-width="30" version="1.1"><circle cx="300" cy="230" r="115"/><path stroke-linecap="butt" d="M106.81863443903,481.4 a205,205 1 0,1 386.36273112194,0"/></svg>';
img={{
src: {
src: imageURL,
preSrc:
'data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="600" height="600" fill="none" stroke="%2325c2a0" stroke-width="30" version="1.1"><circle cx="300" cy="230" r="115"/><path stroke-linecap="butt" d="M106.81863443903,481.4 a205,205 1 0,1 386.36273112194,0"/></svg>',
images: [],
},
preSrc:
'data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="600" height="600" fill="none" stroke="%2325c2a0" stroke-width="30" version="1.1"><circle cx="300" cy="230" r="115"/><path stroke-linecap="butt" d="M106.81863443903,481.4 a205,205 1 0,1 386.36273112194,0"/></svg>',
}}
alt={name}
// onError={(e) => {
// // Image returns 404 if the user's handle changes. We display a
// // fallback instead.
// e.currentTarget.src =
// 'data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="600" height="600" fill="none" stroke="%2325c2a0" stroke-width="30" version="1.1"><circle cx="300" cy="230" r="115"/><path stroke-linecap="butt" d="M106.81863443903,481.4 a205,205 1 0,1 386.36273112194,0"/></svg>';
// }}
Comment on lines -24 to +43
Copy link
Collaborator

Choose a reason for hiding this comment

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

that seems overly complicated, using <img> is fine in this case IMHO

/>
</Link>
)}
Expand Down