Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
/**
* 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 errorMsg = [{messageId: 'idealImageError'}] as const;
const errorWarning = [{messageId: 'idealImageWarning'}] 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: "<img src='https://example.com/logo.png' />",
},
{
code: '<img src={`https://achintya-rai.com/x/${handle}`} />',
},
{
code: '<img src={props.src} />',
},
{
code: '<img src={someVariable} />',
},
{
code: "<img src='./img.svg' />",
},
],
invalid: [
{
code: "<img src={require('./img.png')} />",
errors: errorMsg,
},
{
code: "<img src='./img.png' />",
errors: errorWarning,
},
{
code: "<img src='/static/img.png' />",
errors: errorWarning,
},
{
code: "<img src='../parent.png' />",
errors: errorWarning,
},
],
});
95 changes: 95 additions & 0 deletions packages/eslint-plugin/src/rules/prefer-ideal-image.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
/**
* 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 = 'idealImageError' | 'idealImageWarning';

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

export default createRule<Options, MessageIds>({
name: 'prefer-ideal-image',
meta: {
type: 'problem',
docs: {
description:
'enforce using Docusaurus IdealImage plugin component instead of <img> tags',
recommended: false,
},
schema: [],
messages: {
idealImageError: `Do not use an \`<img>\` element to embed images. Use the \`<IdealImage />\` component from \`@theme/IdealImage\` instead. See ${docsUrl}`,
idealImageWarning: `If this is a local file 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;
}

const srcAttr = node.attributes.find(
(attr): attr is TSESTree.JSXAttribute =>
attr.type === 'JSXAttribute' && attr.name.name === 'src',
);

const value = srcAttr?.value;

if (!value) {
return;
}

if (value.type === 'Literal' && typeof value.value === 'string') {
const val = value.value;

if (val.toLowerCase().endsWith('.svg')) {
return;
}

if (val.startsWith('http') || val.startsWith('//')) {
return;
}
if (
val.startsWith('./') ||
val.startsWith('../') ||
val.startsWith('/')
) {
context.report({node: value, messageId: 'idealImageWarning'});
}
return;
}

if (value.type === 'JSXExpressionContainer') {
const expr = value.expression;

if (expr.type === 'TemplateLiteral') {
const firstPart = expr.quasis[0]?.value.raw;
if (firstPart?.startsWith('http') || firstPart?.startsWith('//')) {
return;
}
}

if (
expr.type === 'CallExpression' &&
expr.callee.type === 'Identifier' &&
expr.callee.name === 'require'
) {
context.report({node, messageId: 'idealImageError'});
}
}
},
};
},
});
1 change: 1 addition & 0 deletions website/docs/api/misc/eslint-plugin/README.mdx
Original file line number Diff line number Diff line change
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
50 changes: 50 additions & 0 deletions website/docs/api/misc/eslint-plugin/prefer-ideal-image.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
---
slug: /api/misc/@docusaurus/eslint-plugin/prefer-ideal-image
---

# prefer-ideal-image

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

The `@theme/IdealImage` component automatically generates responsive images, provides lazy-loading, and adds low-quality image placeholders (LQIP) to improve LCP and user experience.

## Rule Details {#details}

This rule flags standard HTML `<img>` tags that point to local files, suggesting the use of `IdealImage` for better optimization.

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

```jsx
// Error: Definitely a local image via require()
<img src={require('./thumbnail.png')} />

// Warning: Likely a local image via relative path
<img src="./img/logo.png" />

// Warning: Root-relative path usually points to the static folder
<img src="/img/hero.jpg" />
```

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

```jsx
// Optimized using the IdealImage component
import IdealImage from '@theme/IdealImage';

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

// External images are ignored
<img src="[https://example.com/external.png](https://example.com/external.png)" />

// SVGs are ignored as IdealImage does not support them
<img src="./icon.svg" />
```

## When to not use it

If you have not installed or do not plan to use @docusaurus/plugin-ideal-image, you should keep this rule disabled.

## Further Reading {#further-reading}

- https://docusaurus.io/docs/api/plugins/@docusaurus/plugin-ideal-image
- https://web.dev/articles/browser-level-image-lazy-loading