@blumintinc/eslint-plugin-blumint 1.20.26 → 1.20.28
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js
CHANGED
|
@@ -681,8 +681,27 @@ function findEarliestSafeIndex(body, startIndex, dependencies, { allowHooks }) {
|
|
|
681
681
|
}
|
|
682
682
|
return targetIndex;
|
|
683
683
|
}
|
|
684
|
-
|
|
684
|
+
/**
|
|
685
|
+
* A comment sharing a line with the code before it annotates that code, so it belongs
|
|
686
|
+
* to the preceding statement even though it lexically precedes this one. Counting it as
|
|
687
|
+
* this statement's leading comment cuts the segment boundary mid-line, which strands
|
|
688
|
+
* the comment against the wrong statement as soon as either one moves.
|
|
689
|
+
*
|
|
690
|
+
* Comments from the first own-line one onward are this statement's: a run of own-line
|
|
691
|
+
* comments directly above it reads as its preamble.
|
|
692
|
+
*/
|
|
693
|
+
function getLeadingComments(statement, sourceCode) {
|
|
685
694
|
const comments = sourceCode.getCommentsBefore(statement);
|
|
695
|
+
const ownLine = comments.findIndex((comment) => {
|
|
696
|
+
const previous = sourceCode.getTokenBefore(comment, {
|
|
697
|
+
includeComments: true,
|
|
698
|
+
});
|
|
699
|
+
return !previous || previous.loc.end.line < comment.loc.start.line;
|
|
700
|
+
});
|
|
701
|
+
return ownLine === -1 ? [] : comments.slice(ownLine);
|
|
702
|
+
}
|
|
703
|
+
function getStartWithComments(statement, sourceCode) {
|
|
704
|
+
const comments = getLeadingComments(statement, sourceCode);
|
|
686
705
|
const start = comments.length === 0 ? statement.range[0] : comments[0].range[0];
|
|
687
706
|
const text = sourceCode.getText();
|
|
688
707
|
let cursor = start - 1;
|
|
@@ -1,7 +1,6 @@
|
|
|
1
|
-
|
|
1
|
+
import { TSESLint } from '@typescript-eslint/utils';
|
|
2
|
+
type Options = [{
|
|
2
3
|
componentPath: string;
|
|
3
|
-
}
|
|
4
|
-
|
|
5
|
-
ImportDeclaration(node: any): void;
|
|
6
|
-
}>;
|
|
4
|
+
}];
|
|
5
|
+
declare const _default: TSESLint.RuleModule<"useImageOptimized", Options, TSESLint.RuleListener>;
|
|
7
6
|
export = _default;
|
|
@@ -1,5 +1,113 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
+
const utils_1 = require("@typescript-eslint/utils");
|
|
3
|
+
const path_1 = require("path");
|
|
2
4
|
const createRule_1 = require("../utils/createRule");
|
|
5
|
+
const DEFAULT_COMPONENT_PATH = 'src/components/image/ImageOptimized';
|
|
6
|
+
/** The JSX name the fixer writes, and the named export it comes from. */
|
|
7
|
+
const COMPONENT_NAME = 'ImageOptimized';
|
|
8
|
+
/**
|
|
9
|
+
* `jest.mock` is the hoisted form; `doMock`/`setMock` register the same factory
|
|
10
|
+
* at call time. A factory handed to any of them *implements* the module, so an
|
|
11
|
+
* `<img>` inside one is the optimized component rather than a consumer of it.
|
|
12
|
+
*/
|
|
13
|
+
const MOCK_REGISTRARS = new Set(['mock', 'doMock', 'setMock']);
|
|
14
|
+
/**
|
|
15
|
+
* Final segment of a module specifier or file path, extension stripped, so a
|
|
16
|
+
* relative import (`../image/ImageOptimized`), a manual mock
|
|
17
|
+
* (`../image/__mocks__/ImageOptimized`) and the configured path all reduce to
|
|
18
|
+
* the same module identity.
|
|
19
|
+
*/
|
|
20
|
+
const moduleNameOf = (specifier) => (0, path_1.basename)(specifier, (0, path_1.extname)(specifier));
|
|
21
|
+
/** Module specifier of a `jest.mock` call, when it is statically knowable. */
|
|
22
|
+
const staticSpecifierOf = (node) => {
|
|
23
|
+
if (node.type === utils_1.AST_NODE_TYPES.Literal && typeof node.value === 'string') {
|
|
24
|
+
return node.value;
|
|
25
|
+
}
|
|
26
|
+
if (node.type === utils_1.AST_NODE_TYPES.TemplateLiteral &&
|
|
27
|
+
node.expressions.length === 0 &&
|
|
28
|
+
node.quasis.length === 1) {
|
|
29
|
+
return node.quasis[0].value.cooked;
|
|
30
|
+
}
|
|
31
|
+
return null;
|
|
32
|
+
};
|
|
33
|
+
const isComponentMockCall = (node, componentModule) => {
|
|
34
|
+
const { callee } = node;
|
|
35
|
+
if (callee.type !== utils_1.AST_NODE_TYPES.MemberExpression || callee.computed) {
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
const { object, property } = callee;
|
|
39
|
+
if (object.type !== utils_1.AST_NODE_TYPES.Identifier || object.name !== 'jest') {
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
if (property.type !== utils_1.AST_NODE_TYPES.Identifier ||
|
|
43
|
+
!MOCK_REGISTRARS.has(property.name)) {
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
const [specifier] = node.arguments;
|
|
47
|
+
if (!specifier) {
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
const modulePath = staticSpecifierOf(specifier);
|
|
51
|
+
return !!modulePath && moduleNameOf(modulePath) === componentModule;
|
|
52
|
+
};
|
|
53
|
+
const isInsideComponentMock = (node, componentModule) => {
|
|
54
|
+
let current = node.parent;
|
|
55
|
+
while (current) {
|
|
56
|
+
if (current.type === utils_1.AST_NODE_TYPES.CallExpression &&
|
|
57
|
+
isComponentMockCall(current, componentModule)) {
|
|
58
|
+
return true;
|
|
59
|
+
}
|
|
60
|
+
current = current.parent;
|
|
61
|
+
}
|
|
62
|
+
return false;
|
|
63
|
+
};
|
|
64
|
+
/** A type-only binding cannot be rendered, so it is no basis for a fix. */
|
|
65
|
+
const isTypeOnlyImport = (definition) => {
|
|
66
|
+
const { node } = definition;
|
|
67
|
+
if (node.type === utils_1.AST_NODE_TYPES.ImportSpecifier) {
|
|
68
|
+
return (node.importKind === 'type' ||
|
|
69
|
+
node.parent?.importKind ===
|
|
70
|
+
'type');
|
|
71
|
+
}
|
|
72
|
+
if (node.type === utils_1.AST_NODE_TYPES.ImportDefaultSpecifier ||
|
|
73
|
+
node.type === utils_1.AST_NODE_TYPES.ImportNamespaceSpecifier) {
|
|
74
|
+
return (node.parent?.importKind ===
|
|
75
|
+
'type');
|
|
76
|
+
}
|
|
77
|
+
return false;
|
|
78
|
+
};
|
|
79
|
+
const isBoundAsValue = (scope, name) => {
|
|
80
|
+
const variable = utils_1.ASTUtils.findVariable(scope, name);
|
|
81
|
+
return (!!variable &&
|
|
82
|
+
variable.defs.length > 0 &&
|
|
83
|
+
variable.defs.some((definition) => !isTypeOnlyImport(definition)));
|
|
84
|
+
};
|
|
85
|
+
/**
|
|
86
|
+
* Local name the component is imported under, when it is aliased away from
|
|
87
|
+
* `ImageOptimized`. Reusing the alias keeps the fix bound to a real import
|
|
88
|
+
* instead of introducing a second, unimported name.
|
|
89
|
+
*/
|
|
90
|
+
const aliasedLocalName = (program, componentModule) => {
|
|
91
|
+
for (const statement of program.body) {
|
|
92
|
+
if (statement.type !== utils_1.AST_NODE_TYPES.ImportDeclaration ||
|
|
93
|
+
statement.importKind === 'type' ||
|
|
94
|
+
typeof statement.source.value !== 'string' ||
|
|
95
|
+
moduleNameOf(statement.source.value) !== componentModule) {
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
for (const specifier of statement.specifiers) {
|
|
99
|
+
if (specifier.type === utils_1.AST_NODE_TYPES.ImportDefaultSpecifier) {
|
|
100
|
+
return specifier.local.name;
|
|
101
|
+
}
|
|
102
|
+
if (specifier.type === utils_1.AST_NODE_TYPES.ImportSpecifier &&
|
|
103
|
+
specifier.imported.name === COMPONENT_NAME &&
|
|
104
|
+
specifier.importKind !== 'type') {
|
|
105
|
+
return specifier.local.name;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return null;
|
|
110
|
+
};
|
|
3
111
|
module.exports = (0, createRule_1.createRule)({
|
|
4
112
|
name: 'require-image-optimized',
|
|
5
113
|
meta: {
|
|
@@ -17,7 +125,7 @@ module.exports = (0, createRule_1.createRule)({
|
|
|
17
125
|
componentPath: {
|
|
18
126
|
type: 'string',
|
|
19
127
|
description: 'The import path for the ImageOptimized component',
|
|
20
|
-
default:
|
|
128
|
+
default: DEFAULT_COMPONENT_PATH,
|
|
21
129
|
},
|
|
22
130
|
},
|
|
23
131
|
additionalProperties: false,
|
|
@@ -27,38 +135,65 @@ module.exports = (0, createRule_1.createRule)({
|
|
|
27
135
|
useImageOptimized: 'Use ImageOptimized from {{ componentPath }} instead of {{ component }}. The shared wrapper handles responsive sizing, lazy loading, and blur placeholders so images stay optimized and do not hurt Core Web Vitals. Replace this usage with ImageOptimized to send the asset through the optimization pipeline.',
|
|
28
136
|
},
|
|
29
137
|
},
|
|
30
|
-
defaultOptions: [{ componentPath:
|
|
138
|
+
defaultOptions: [{ componentPath: DEFAULT_COMPONENT_PATH }],
|
|
31
139
|
create(context) {
|
|
32
|
-
const
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
140
|
+
const componentPath = context.options[0]?.componentPath || DEFAULT_COMPONENT_PATH;
|
|
141
|
+
const sourceCode = context.getSourceCode();
|
|
142
|
+
const componentModule = moduleNameOf(componentPath);
|
|
143
|
+
/**
|
|
144
|
+
* The component's own module and its manual mock implement the wrapper, so
|
|
145
|
+
* every image primitive they reach for is the implementation detail the
|
|
146
|
+
* rule exists to centralize, not a violation of it.
|
|
147
|
+
*/
|
|
148
|
+
const isComponentImplementationFile = moduleNameOf(context.getFilename()) === componentModule;
|
|
36
149
|
return {
|
|
37
150
|
// Handle JSX img elements
|
|
38
151
|
JSXElement(node) {
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
fix(fixer) {
|
|
48
|
-
const attributes = node.openingElement.attributes
|
|
49
|
-
.map((attr) => sourceCode.getText(attr))
|
|
50
|
-
.join(' ');
|
|
51
|
-
return fixer.replaceText(node, `<ImageOptimized ${attributes} />`);
|
|
52
|
-
},
|
|
53
|
-
});
|
|
152
|
+
const elementName = node.openingElement.name;
|
|
153
|
+
if (elementName.type !== utils_1.AST_NODE_TYPES.JSXIdentifier ||
|
|
154
|
+
elementName.name !== 'img') {
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
if (isComponentImplementationFile ||
|
|
158
|
+
isInsideComponentMock(node, componentModule)) {
|
|
159
|
+
return;
|
|
54
160
|
}
|
|
161
|
+
const scope = context.getScope();
|
|
162
|
+
const localName = isBoundAsValue(scope, COMPONENT_NAME)
|
|
163
|
+
? COMPONENT_NAME
|
|
164
|
+
: aliasedLocalName(sourceCode.ast, componentModule);
|
|
165
|
+
context.report({
|
|
166
|
+
node,
|
|
167
|
+
messageId: 'useImageOptimized',
|
|
168
|
+
data: {
|
|
169
|
+
componentPath,
|
|
170
|
+
component: 'img tag',
|
|
171
|
+
},
|
|
172
|
+
fix(fixer) {
|
|
173
|
+
// Swapping in an unimported name would strand the reference, and
|
|
174
|
+
// picking an import path for it is a product decision the rule
|
|
175
|
+
// cannot make; reporting without fixing keeps the file valid.
|
|
176
|
+
if (!localName) {
|
|
177
|
+
return null;
|
|
178
|
+
}
|
|
179
|
+
const attributes = node.openingElement.attributes
|
|
180
|
+
.map((attribute) => sourceCode.getText(attribute))
|
|
181
|
+
.join(' ');
|
|
182
|
+
return fixer.replaceText(node, `<${localName}${attributes ? ` ${attributes}` : ''} />`);
|
|
183
|
+
},
|
|
184
|
+
});
|
|
55
185
|
},
|
|
56
186
|
// Handle next/image imports and usage
|
|
57
187
|
ImportDeclaration(node) {
|
|
188
|
+
if (isComponentImplementationFile) {
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
58
191
|
if (node.source.value === 'next/image' && node.specifiers.length > 0) {
|
|
59
|
-
const imageSpecifier = node.specifiers.find((spec) => (spec.type ===
|
|
60
|
-
spec.type ===
|
|
61
|
-
(spec.local.name === 'Image' ||
|
|
192
|
+
const imageSpecifier = node.specifiers.find((spec) => (spec.type === utils_1.AST_NODE_TYPES.ImportDefaultSpecifier ||
|
|
193
|
+
spec.type === utils_1.AST_NODE_TYPES.ImportSpecifier) &&
|
|
194
|
+
(spec.local.name === 'Image' ||
|
|
195
|
+
(spec.type === utils_1.AST_NODE_TYPES.ImportSpecifier &&
|
|
196
|
+
spec.imported.name === 'Image')));
|
|
62
197
|
if (imageSpecifier) {
|
|
63
198
|
const localName = imageSpecifier.local.name;
|
|
64
199
|
// Report the import
|
|
@@ -66,11 +201,11 @@ module.exports = (0, createRule_1.createRule)({
|
|
|
66
201
|
node,
|
|
67
202
|
messageId: 'useImageOptimized',
|
|
68
203
|
data: {
|
|
69
|
-
componentPath
|
|
204
|
+
componentPath,
|
|
70
205
|
component: 'next/image',
|
|
71
206
|
},
|
|
72
207
|
fix(fixer) {
|
|
73
|
-
return fixer.replaceText(node, `import ${localName} from '${
|
|
208
|
+
return fixer.replaceText(node, `import ${localName} from '${componentPath}';`);
|
|
74
209
|
},
|
|
75
210
|
});
|
|
76
211
|
}
|
package/package.json
CHANGED
package/release-manifest.json
CHANGED
|
@@ -1,4 +1,32 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "1.20.28",
|
|
4
|
+
"date": "2026-07-30T07:59:36.776Z",
|
|
5
|
+
"rules": [
|
|
6
|
+
{
|
|
7
|
+
"name": "require-image-optimized",
|
|
8
|
+
"changeType": "fix",
|
|
9
|
+
"issues": [
|
|
10
|
+
1417
|
|
11
|
+
],
|
|
12
|
+
"summary": "exempt the component's own implementation and decline unbound fixes (closes #1417)"
|
|
13
|
+
}
|
|
14
|
+
]
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
"version": "1.20.27",
|
|
18
|
+
"date": "2026-07-30T06:36:11.493Z",
|
|
19
|
+
"rules": [
|
|
20
|
+
{
|
|
21
|
+
"name": "logical-top-to-bottom-grouping",
|
|
22
|
+
"changeType": "fix",
|
|
23
|
+
"issues": [
|
|
24
|
+
1416
|
|
25
|
+
],
|
|
26
|
+
"summary": "keep a trailing comment with the statement it annotates (closes #1416)"
|
|
27
|
+
}
|
|
28
|
+
]
|
|
29
|
+
},
|
|
2
30
|
{
|
|
3
31
|
"version": "1.20.26",
|
|
4
32
|
"date": "2026-07-30T06:12:23.979Z",
|