@blumintinc/eslint-plugin-blumint 1.20.27 → 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
@@ -223,7 +223,7 @@ function noFrontendImportsFromFunctionsPatterns(pattern) {
223
223
  module.exports = {
224
224
  meta: {
225
225
  name: '@blumintinc/eslint-plugin-blumint',
226
- version: '1.20.27',
226
+ version: '1.20.28',
227
227
  },
228
228
  parseOptions: {
229
229
  ecmaVersion: 2020,
@@ -1,7 +1,6 @@
1
- declare const _default: import("@typescript-eslint/utils/dist/ts-eslint/Rule").RuleModule<"useImageOptimized", {
1
+ import { TSESLint } from '@typescript-eslint/utils';
2
+ type Options = [{
2
3
  componentPath: string;
3
- }[], {
4
- JSXElement(node: any): void;
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: 'src/components/image/ImageOptimized',
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: 'src/components/image/ImageOptimized' }],
138
+ defaultOptions: [{ componentPath: DEFAULT_COMPONENT_PATH }],
31
139
  create(context) {
32
- const options = context.options[0] || {
33
- componentPath: 'src/components/image/ImageOptimized',
34
- };
35
- const sourceCode = context.sourceCode;
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
- if (node.openingElement.name.name === 'img') {
40
- context.report({
41
- node,
42
- messageId: 'useImageOptimized',
43
- data: {
44
- componentPath: options.componentPath,
45
- component: 'img tag',
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 === 'ImportDefaultSpecifier' ||
60
- spec.type === 'ImportSpecifier') &&
61
- (spec.local.name === 'Image' || spec.imported?.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: options.componentPath,
204
+ componentPath,
70
205
  component: 'next/image',
71
206
  },
72
207
  fix(fixer) {
73
- return fixer.replaceText(node, `import ${localName} from '${options.componentPath}';`);
208
+ return fixer.replaceText(node, `import ${localName} from '${componentPath}';`);
74
209
  },
75
210
  });
76
211
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blumintinc/eslint-plugin-blumint",
3
- "version": "1.20.27",
3
+ "version": "1.20.28",
4
4
  "description": "Custom eslint rules for use within BluMint",
5
5
  "author": {
6
6
  "name": "Brodie McGuire",
@@ -1,4 +1,18 @@
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
+ },
2
16
  {
3
17
  "version": "1.20.27",
4
18
  "date": "2026-07-30T06:36:11.493Z",