@blumintinc/eslint-plugin-blumint 1.21.18 → 1.23.0
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/README.md +1 -0
- package/lib/index.js +4 -1
- package/lib/rules/no-compositing-layer-props.d.ts +8 -1
- package/lib/rules/no-compositing-layer-props.js +577 -34
- package/lib/rules/no-margin-properties.d.ts +9 -1
- package/lib/rules/no-margin-properties.js +157 -6
- package/lib/rules/require-sx-props-theme.d.ts +2 -0
- package/lib/rules/require-sx-props-theme.js +361 -0
- package/lib/utils/fixtureTypeProgram.js +46 -0
- package/package.json +1 -1
- package/release-manifest.json +36 -0
|
@@ -1 +1,9 @@
|
|
|
1
|
-
|
|
1
|
+
type Options = [
|
|
2
|
+
{
|
|
3
|
+
exemptZero?: boolean;
|
|
4
|
+
exemptAuto?: boolean;
|
|
5
|
+
exemptNegative?: boolean;
|
|
6
|
+
}
|
|
7
|
+
];
|
|
8
|
+
export declare const noMarginProperties: import("@typescript-eslint/utils/dist/ts-eslint/Rule").RuleModule<"noMarginProperties", Options, import("@typescript-eslint/utils/dist/ts-eslint/Rule").RuleListener>;
|
|
9
|
+
export {};
|
|
@@ -65,6 +65,110 @@ const MARGIN_PROPERTIES = new Set([
|
|
|
65
65
|
'ml',
|
|
66
66
|
'm',
|
|
67
67
|
]);
|
|
68
|
+
const ZERO_LENGTH = /^[+-]?(?:0+\.?0*|\.0+)(?:[a-z]+|%)?$/;
|
|
69
|
+
const NEGATIVE_LENGTH = /^-(?:\d+\.?\d*|\.\d+)(?:[a-z]+|%)?$/;
|
|
70
|
+
function classifyMarginToken(token) {
|
|
71
|
+
if (token === 'auto')
|
|
72
|
+
return 'auto';
|
|
73
|
+
if (ZERO_LENGTH.test(token))
|
|
74
|
+
return 'zero';
|
|
75
|
+
if (NEGATIVE_LENGTH.test(token))
|
|
76
|
+
return 'negative';
|
|
77
|
+
return 'spacing';
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* A string value may be a 1-4 token `margin` shorthand (`'0 auto'`), so every
|
|
81
|
+
* token is classified; `!important` changes the cascade, not the value.
|
|
82
|
+
*/
|
|
83
|
+
function classifyMarginString(value) {
|
|
84
|
+
const tokens = value
|
|
85
|
+
.replace(/\s*!important\s*$/i, '')
|
|
86
|
+
.trim()
|
|
87
|
+
.toLowerCase()
|
|
88
|
+
.split(/\s+/)
|
|
89
|
+
.filter(Boolean);
|
|
90
|
+
return tokens.length === 0 ? ['spacing'] : tokens.map(classifyMarginToken);
|
|
91
|
+
}
|
|
92
|
+
function classifyMarginNumber(value) {
|
|
93
|
+
if (value === 0)
|
|
94
|
+
return 'zero';
|
|
95
|
+
return value < 0 ? 'negative' : 'spacing';
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Every kind a margin value can take, or `null` when any part of it is
|
|
99
|
+
* unreadable (a variable, a call, a template substitution). A conditional and
|
|
100
|
+
* MUI's responsive object and array forms yield the kinds of all their
|
|
101
|
+
* branches, since any one of them may render.
|
|
102
|
+
*/
|
|
103
|
+
function classifyMarginValue(node) {
|
|
104
|
+
const value = unwrapAssertions(node);
|
|
105
|
+
switch (value.type) {
|
|
106
|
+
case utils_1.AST_NODE_TYPES.Literal:
|
|
107
|
+
if (typeof value.value === 'number') {
|
|
108
|
+
return [classifyMarginNumber(value.value)];
|
|
109
|
+
}
|
|
110
|
+
return typeof value.value === 'string'
|
|
111
|
+
? classifyMarginString(value.value)
|
|
112
|
+
: null;
|
|
113
|
+
case utils_1.AST_NODE_TYPES.UnaryExpression:
|
|
114
|
+
if ((value.operator === '-' || value.operator === '+') &&
|
|
115
|
+
value.argument.type === utils_1.AST_NODE_TYPES.Literal &&
|
|
116
|
+
typeof value.argument.value === 'number') {
|
|
117
|
+
return [
|
|
118
|
+
classifyMarginNumber(value.operator === '-'
|
|
119
|
+
? -value.argument.value
|
|
120
|
+
: value.argument.value),
|
|
121
|
+
];
|
|
122
|
+
}
|
|
123
|
+
return null;
|
|
124
|
+
case utils_1.AST_NODE_TYPES.TemplateLiteral:
|
|
125
|
+
return value.expressions.length === 0
|
|
126
|
+
? classifyMarginString(value.quasis[0].value.cooked ?? value.quasis[0].value.raw)
|
|
127
|
+
: null;
|
|
128
|
+
case utils_1.AST_NODE_TYPES.JSXExpressionContainer:
|
|
129
|
+
return value.expression.type === utils_1.AST_NODE_TYPES.JSXEmptyExpression
|
|
130
|
+
? null
|
|
131
|
+
: classifyMarginValue(value.expression);
|
|
132
|
+
case utils_1.AST_NODE_TYPES.ConditionalExpression:
|
|
133
|
+
return combineMarginKinds([value.consequent, value.alternate]);
|
|
134
|
+
case utils_1.AST_NODE_TYPES.ObjectExpression:
|
|
135
|
+
return combineMarginKinds(value.properties.map((entry) => entry.type === utils_1.AST_NODE_TYPES.Property && !entry.computed
|
|
136
|
+
? entry.value
|
|
137
|
+
: null));
|
|
138
|
+
case utils_1.AST_NODE_TYPES.ArrayExpression:
|
|
139
|
+
return combineMarginKinds(value.elements.filter((element) => element !== null));
|
|
140
|
+
default:
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* MUI's responsive array and object forms read a `null` entry as "skip this
|
|
146
|
+
* breakpoint" (`[null, 0]` sets a margin from `sm` up only), and `sx` drops a
|
|
147
|
+
* `null` value outright, so a `null` part renders no margin at all.
|
|
148
|
+
*/
|
|
149
|
+
function isNullLiteral(node) {
|
|
150
|
+
const value = unwrapAssertions(node);
|
|
151
|
+
return value.type === utils_1.AST_NODE_TYPES.Literal && value.raw === 'null';
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* The kinds of every part of a composite value. A `null` part adds no kind,
|
|
155
|
+
* and a composite left with none (`[]`, `{}`, `[null, null]`) sets no margin
|
|
156
|
+
* that could earn an exemption, so it keeps the report like an unreadable one.
|
|
157
|
+
*/
|
|
158
|
+
function combineMarginKinds(nodes) {
|
|
159
|
+
const kinds = [];
|
|
160
|
+
for (const node of nodes) {
|
|
161
|
+
if (!node || node.type === utils_1.AST_NODE_TYPES.SpreadElement)
|
|
162
|
+
return null;
|
|
163
|
+
if (isNullLiteral(node))
|
|
164
|
+
continue;
|
|
165
|
+
const nodeKinds = classifyMarginValue(node);
|
|
166
|
+
if (!nodeKinds)
|
|
167
|
+
return null;
|
|
168
|
+
kinds.push(...nodeKinds);
|
|
169
|
+
}
|
|
170
|
+
return kinds.length === 0 ? null : kinds;
|
|
171
|
+
}
|
|
68
172
|
exports.noMarginProperties = (0, createRule_1.createRule)({
|
|
69
173
|
name: 'no-margin-properties',
|
|
70
174
|
meta: {
|
|
@@ -73,18 +177,61 @@ exports.noMarginProperties = (0, createRule_1.createRule)({
|
|
|
73
177
|
description: 'Prevent margin properties (margin, marginLeft, marginRight, marginTop, marginBottom, mx, my, etc.) in MUI styling because margins fight container-controlled spacing, double gutters, and misaligned breakpoints; keep spacing centralized with padding, gap, or spacing props instead.',
|
|
74
178
|
recommended: 'error',
|
|
75
179
|
},
|
|
76
|
-
schema: [
|
|
180
|
+
schema: [
|
|
181
|
+
{
|
|
182
|
+
type: 'object',
|
|
183
|
+
properties: {
|
|
184
|
+
exemptZero: {
|
|
185
|
+
type: 'boolean',
|
|
186
|
+
default: true,
|
|
187
|
+
},
|
|
188
|
+
exemptAuto: {
|
|
189
|
+
type: 'boolean',
|
|
190
|
+
default: true,
|
|
191
|
+
},
|
|
192
|
+
exemptNegative: {
|
|
193
|
+
type: 'boolean',
|
|
194
|
+
default: true,
|
|
195
|
+
},
|
|
196
|
+
},
|
|
197
|
+
additionalProperties: false,
|
|
198
|
+
},
|
|
199
|
+
],
|
|
77
200
|
messages: {
|
|
78
201
|
noMarginProperties: 'Margin property "{{property}}" in MUI styling fights container-controlled spacing (Stack/Grid spacing, gap, responsive gutters) and produces double gutters, misalignment, and overflow as layouts shift. Keep spacing inside the component with padding or let the parent handle separation via gap/spacing so layout remains predictable.',
|
|
79
202
|
},
|
|
80
203
|
},
|
|
81
|
-
defaultOptions: [
|
|
82
|
-
|
|
204
|
+
defaultOptions: [
|
|
205
|
+
{
|
|
206
|
+
exemptZero: true,
|
|
207
|
+
exemptAuto: true,
|
|
208
|
+
exemptNegative: true,
|
|
209
|
+
},
|
|
210
|
+
],
|
|
211
|
+
create(context, [options]) {
|
|
83
212
|
const seenNodes = new WeakSet();
|
|
213
|
+
const exemptKinds = new Set();
|
|
214
|
+
if (options.exemptZero !== false)
|
|
215
|
+
exemptKinds.add('zero');
|
|
216
|
+
if (options.exemptAuto !== false)
|
|
217
|
+
exemptKinds.add('auto');
|
|
218
|
+
if (options.exemptNegative !== false)
|
|
219
|
+
exemptKinds.add('negative');
|
|
84
220
|
function checkProperty(propertyName) {
|
|
85
221
|
const normalizedName = normalizePropertyName(propertyName);
|
|
86
222
|
return MARGIN_PROPERTIES.has(normalizedName);
|
|
87
223
|
}
|
|
224
|
+
/**
|
|
225
|
+
* A value is exempt only when every part of it is a reset, `auto` or a
|
|
226
|
+
* negative offset whose exemption is on: one spacing token (`'0 8px'`) or
|
|
227
|
+
* one unreadable branch keeps the report.
|
|
228
|
+
*/
|
|
229
|
+
function isExemptValue(value) {
|
|
230
|
+
if (!value)
|
|
231
|
+
return false;
|
|
232
|
+
const kinds = classifyMarginValue(value);
|
|
233
|
+
return !!kinds && kinds.every((kind) => exemptKinds.has(kind));
|
|
234
|
+
}
|
|
88
235
|
/**
|
|
89
236
|
* True if node is in a flagged MUI styling context (sx attribute, sx
|
|
90
237
|
* object property, or css() call). Excludes createTheme styleOverrides:
|
|
@@ -159,7 +306,9 @@ exports.noMarginProperties = (0, createRule_1.createRule)({
|
|
|
159
306
|
.join('');
|
|
160
307
|
propertyName = quasis + expressions;
|
|
161
308
|
}
|
|
162
|
-
if (propertyName &&
|
|
309
|
+
if (propertyName &&
|
|
310
|
+
checkProperty(propertyName) &&
|
|
311
|
+
!isExemptValue(node.value)) {
|
|
163
312
|
// Check if in MUI styling context
|
|
164
313
|
if (isMuiStylingContext(node)) {
|
|
165
314
|
context.report({
|
|
@@ -283,7 +432,9 @@ exports.noMarginProperties = (0, createRule_1.createRule)({
|
|
|
283
432
|
else if (key.type === utils_1.AST_NODE_TYPES.Literal) {
|
|
284
433
|
propertyName = String(key.value);
|
|
285
434
|
}
|
|
286
|
-
if (propertyName &&
|
|
435
|
+
if (propertyName &&
|
|
436
|
+
checkProperty(propertyName) &&
|
|
437
|
+
!isExemptValue(prop.value)) {
|
|
287
438
|
// Check if this variable is used in an sx prop
|
|
288
439
|
if (sourceText.includes(`sx={${variableName}}`) ||
|
|
289
440
|
sourceText.includes(`sx={{ ...${variableName}`) ||
|
|
@@ -307,7 +458,7 @@ exports.noMarginProperties = (0, createRule_1.createRule)({
|
|
|
307
458
|
if (attr.type === utils_1.AST_NODE_TYPES.JSXAttribute &&
|
|
308
459
|
attr.name.type === utils_1.AST_NODE_TYPES.JSXIdentifier) {
|
|
309
460
|
const attrName = attr.name.name;
|
|
310
|
-
if (checkProperty(attrName)) {
|
|
461
|
+
if (checkProperty(attrName) && !isExemptValue(attr.value)) {
|
|
311
462
|
context.report({
|
|
312
463
|
node: attr,
|
|
313
464
|
messageId: 'noMarginProperties',
|
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.requireSxPropsTheme = void 0;
|
|
4
|
+
const utils_1 = require("@typescript-eslint/utils");
|
|
5
|
+
const ASTHelpers_1 = require("../utils/ASTHelpers");
|
|
6
|
+
const createRule_1 = require("../utils/createRule");
|
|
7
|
+
/**
|
|
8
|
+
* Every module MUI re-exports `SxProps` from. Matching on the module and the
|
|
9
|
+
* imported name, never the local name, is what lets a renamed import report
|
|
10
|
+
* and a same-named local type or shim stay silent.
|
|
11
|
+
*/
|
|
12
|
+
const SX_PROPS_SOURCES = new Set([
|
|
13
|
+
'@mui/material',
|
|
14
|
+
'@mui/material/styles',
|
|
15
|
+
'@mui/system',
|
|
16
|
+
'@mui/system/styleFunctionSx',
|
|
17
|
+
]);
|
|
18
|
+
/**
|
|
19
|
+
* The module a MUI app augments its theme through
|
|
20
|
+
* (`declare module '@mui/material/styles' { interface Theme { … } }`), so it is
|
|
21
|
+
* the only source whose `Theme` is provably the app theme, whichever module
|
|
22
|
+
* `SxProps` itself came from.
|
|
23
|
+
*/
|
|
24
|
+
const THEME_SOURCE = '@mui/material/styles';
|
|
25
|
+
const SX_PROPS_NAME = 'SxProps';
|
|
26
|
+
const THEME_NAME = 'Theme';
|
|
27
|
+
/**
|
|
28
|
+
* The type argument list, read from whichever property the host
|
|
29
|
+
* `@typescript-eslint` version populates: v5 fills `typeParameters`, v6 adds
|
|
30
|
+
* `typeArguments`, and v8 drops `typeParameters` from `TSTypeReference`.
|
|
31
|
+
*/
|
|
32
|
+
function hasTypeArguments(node) {
|
|
33
|
+
const { typeArguments } = node;
|
|
34
|
+
return Boolean(typeArguments ?? node.typeParameters);
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* `import { "SxProps" as Sx }` arrives as a string literal on parsers that
|
|
38
|
+
* support arbitrary module namespace names, so both spellings are read.
|
|
39
|
+
*/
|
|
40
|
+
function importedNameOf(specifier) {
|
|
41
|
+
const imported = specifier.imported;
|
|
42
|
+
return imported.type === utils_1.AST_NODE_TYPES.Identifier
|
|
43
|
+
? imported.name
|
|
44
|
+
: imported.value;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* ES import declarations sit at the top level or directly inside an ambient
|
|
48
|
+
* `declare module '…' { }` block; nothing else can hold one.
|
|
49
|
+
*/
|
|
50
|
+
function collectImportDeclarations(statements, into) {
|
|
51
|
+
for (const statement of statements) {
|
|
52
|
+
if (statement.type === utils_1.AST_NODE_TYPES.ImportDeclaration) {
|
|
53
|
+
into.push(statement);
|
|
54
|
+
}
|
|
55
|
+
else if (statement.type === utils_1.AST_NODE_TYPES.TSModuleDeclaration &&
|
|
56
|
+
statement.body?.type === utils_1.AST_NODE_TYPES.TSModuleBlock) {
|
|
57
|
+
collectImportDeclarations(statement.body.body, into);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* The innermost binding of `name` visible from `scope`. A value-only binding
|
|
63
|
+
* stops the walk even though TypeScript would look past it for a type name;
|
|
64
|
+
* that errs toward silence, never toward a wrong report or fix.
|
|
65
|
+
*/
|
|
66
|
+
function findVariable(scope, name) {
|
|
67
|
+
let current = scope;
|
|
68
|
+
while (current) {
|
|
69
|
+
const variable = current.set.get(name);
|
|
70
|
+
if (variable) {
|
|
71
|
+
return variable;
|
|
72
|
+
}
|
|
73
|
+
current = current.upper;
|
|
74
|
+
}
|
|
75
|
+
return undefined;
|
|
76
|
+
}
|
|
77
|
+
function importClauseOf(variable) {
|
|
78
|
+
if (!variable || variable.defs.length !== 1) {
|
|
79
|
+
return undefined;
|
|
80
|
+
}
|
|
81
|
+
const [definition] = variable.defs;
|
|
82
|
+
if (definition.type !== 'ImportBinding') {
|
|
83
|
+
return undefined;
|
|
84
|
+
}
|
|
85
|
+
const clause = definition.node;
|
|
86
|
+
return clause.type === utils_1.AST_NODE_TYPES.ImportSpecifier ||
|
|
87
|
+
clause.type === utils_1.AST_NODE_TYPES.ImportDefaultSpecifier ||
|
|
88
|
+
clause.type === utils_1.AST_NODE_TYPES.ImportNamespaceSpecifier
|
|
89
|
+
? clause
|
|
90
|
+
: undefined;
|
|
91
|
+
}
|
|
92
|
+
function sourceOf(clause) {
|
|
93
|
+
return clause.parent.source.value;
|
|
94
|
+
}
|
|
95
|
+
function isNamedImportOf(clause, importedName, isAllowedSource) {
|
|
96
|
+
return (clause?.type === utils_1.AST_NODE_TYPES.ImportSpecifier &&
|
|
97
|
+
importedNameOf(clause) === importedName &&
|
|
98
|
+
isAllowedSource(sourceOf(clause)));
|
|
99
|
+
}
|
|
100
|
+
const isSxPropsSource = (source) => SX_PROPS_SOURCES.has(source);
|
|
101
|
+
const isThemeSource = (source) => source === THEME_SOURCE;
|
|
102
|
+
/**
|
|
103
|
+
* Inside `declare module '@mui/material/styles' { … }` the `Theme` in reach is
|
|
104
|
+
* the interface being augmented, and an import spliced into or above the
|
|
105
|
+
* ambient block changes what the declaration file means.
|
|
106
|
+
*/
|
|
107
|
+
function isInsideThemeAugmentation(node) {
|
|
108
|
+
let current = node.parent;
|
|
109
|
+
while (current) {
|
|
110
|
+
if (current.type === utils_1.AST_NODE_TYPES.TSModuleDeclaration &&
|
|
111
|
+
current.id.type === utils_1.AST_NODE_TYPES.Literal &&
|
|
112
|
+
current.id.value === THEME_SOURCE) {
|
|
113
|
+
return true;
|
|
114
|
+
}
|
|
115
|
+
current = current.parent;
|
|
116
|
+
}
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
function isTypeOnlyImport(clause) {
|
|
120
|
+
const declaration = clause.parent;
|
|
121
|
+
return declaration.importKind === 'type' || clause.importKind === 'type';
|
|
122
|
+
}
|
|
123
|
+
exports.requireSxPropsTheme = (0, createRule_1.createRule)({
|
|
124
|
+
name: 'require-sx-props-theme',
|
|
125
|
+
meta: {
|
|
126
|
+
type: 'problem',
|
|
127
|
+
docs: {
|
|
128
|
+
description: "Require MUI `SxProps` to carry the app theme as `SxProps<Theme>`, because the bare type defaults to `SxProps<{}>`: `sx` callbacks lose the theme's typing and TypeScript pays for a structural comparison against MUI's own `SxProps<Theme>`",
|
|
129
|
+
recommended: 'error',
|
|
130
|
+
},
|
|
131
|
+
fixable: 'code',
|
|
132
|
+
schema: [],
|
|
133
|
+
messages: {
|
|
134
|
+
requireSxPropsTheme: "`{{typeName}}` has no theme argument, so MUI defaults it to `SxProps<{}>`: an `sx` callback's `theme` is typed `{}`, and TypeScript compares this type structurally against MUI's `SxProps<Theme>` wherever the two meet, which can cost seconds of type checking per file. Write `{{typeName}}<Theme>` with `Theme` from '@mui/material/styles'.",
|
|
135
|
+
},
|
|
136
|
+
},
|
|
137
|
+
defaultOptions: [],
|
|
138
|
+
create(context) {
|
|
139
|
+
const sourceCode = context.getSourceCode();
|
|
140
|
+
const program = sourceCode.ast;
|
|
141
|
+
const allImports = [];
|
|
142
|
+
collectImportDeclarations(program.body, allImports);
|
|
143
|
+
const sxPropsLocalNames = new Set();
|
|
144
|
+
const sxNamespaceLocalNames = new Set();
|
|
145
|
+
for (const declaration of allImports) {
|
|
146
|
+
if (!isSxPropsSource(declaration.source.value)) {
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
for (const specifier of declaration.specifiers) {
|
|
150
|
+
if (specifier.type === utils_1.AST_NODE_TYPES.ImportSpecifier &&
|
|
151
|
+
importedNameOf(specifier) === SX_PROPS_NAME) {
|
|
152
|
+
sxPropsLocalNames.add(specifier.local.name);
|
|
153
|
+
}
|
|
154
|
+
else if (specifier.type === utils_1.AST_NODE_TYPES.ImportNamespaceSpecifier) {
|
|
155
|
+
sxNamespaceLocalNames.add(specifier.local.name);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
const importsSxProps = sxPropsLocalNames.size > 0 || sxNamespaceLocalNames.size > 0;
|
|
160
|
+
/**
|
|
161
|
+
* Only a top-level import can be extended or anchored after: one inside an
|
|
162
|
+
* ambient module block binds nothing outside it, and a file with no
|
|
163
|
+
* top-level import is a global script that a new import would turn into a
|
|
164
|
+
* module.
|
|
165
|
+
*/
|
|
166
|
+
const topLevelImports = program.body.filter((statement) => statement.type === utils_1.AST_NODE_TYPES.ImportDeclaration);
|
|
167
|
+
const themeSourceImports = topLevelImports.filter((declaration) => isThemeSource(declaration.source.value));
|
|
168
|
+
/**
|
|
169
|
+
* An unresolved `Theme` elsewhere in the file (a global, or a name the file
|
|
170
|
+
* never declared) would silently change meaning once an import binds the
|
|
171
|
+
* name at module scope.
|
|
172
|
+
*/
|
|
173
|
+
let hasUnresolvedTheme;
|
|
174
|
+
const themeIsReferencedUnresolved = () => {
|
|
175
|
+
if (hasUnresolvedTheme === undefined) {
|
|
176
|
+
const globalScope = sourceCode.scopeManager?.globalScope;
|
|
177
|
+
hasUnresolvedTheme = Boolean(globalScope?.through.some((reference) => reference.identifier.name === THEME_NAME));
|
|
178
|
+
}
|
|
179
|
+
return hasUnresolvedTheme;
|
|
180
|
+
};
|
|
181
|
+
function resolvesTo(scope, name, clause) {
|
|
182
|
+
return importClauseOf(findVariable(scope, name)) === clause;
|
|
183
|
+
}
|
|
184
|
+
function isReportableSxProps(typeName, scope) {
|
|
185
|
+
if (typeName.type === utils_1.AST_NODE_TYPES.Identifier) {
|
|
186
|
+
return (sxPropsLocalNames.has(typeName.name) &&
|
|
187
|
+
isNamedImportOf(importClauseOf(findVariable(scope, typeName.name)), SX_PROPS_NAME, isSxPropsSource));
|
|
188
|
+
}
|
|
189
|
+
if (typeName.type !== utils_1.AST_NODE_TYPES.TSQualifiedName ||
|
|
190
|
+
typeName.right.name !== SX_PROPS_NAME ||
|
|
191
|
+
typeName.left.type !== utils_1.AST_NODE_TYPES.Identifier ||
|
|
192
|
+
!sxNamespaceLocalNames.has(typeName.left.name)) {
|
|
193
|
+
return false;
|
|
194
|
+
}
|
|
195
|
+
const clause = importClauseOf(findVariable(scope, typeName.left.name));
|
|
196
|
+
return (clause?.type === utils_1.AST_NODE_TYPES.ImportNamespaceSpecifier &&
|
|
197
|
+
isSxPropsSource(sourceOf(clause)));
|
|
198
|
+
}
|
|
199
|
+
/** An existing `Theme` import, under whatever local name reaches the node. */
|
|
200
|
+
function existingThemeName(scope) {
|
|
201
|
+
if (isNamedImportOf(importClauseOf(findVariable(scope, THEME_NAME)), THEME_NAME, isThemeSource)) {
|
|
202
|
+
return THEME_NAME;
|
|
203
|
+
}
|
|
204
|
+
for (const declaration of themeSourceImports) {
|
|
205
|
+
for (const specifier of declaration.specifiers) {
|
|
206
|
+
if (specifier.type === utils_1.AST_NODE_TYPES.ImportSpecifier &&
|
|
207
|
+
importedNameOf(specifier) === THEME_NAME &&
|
|
208
|
+
resolvesTo(scope, specifier.local.name, specifier)) {
|
|
209
|
+
return specifier.local.name;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
return undefined;
|
|
214
|
+
}
|
|
215
|
+
/** `Styles.Theme` through a namespace import, which adds no binding. */
|
|
216
|
+
function namespaceThemeText(scope) {
|
|
217
|
+
for (const declaration of themeSourceImports) {
|
|
218
|
+
for (const specifier of declaration.specifiers) {
|
|
219
|
+
if (specifier.type === utils_1.AST_NODE_TYPES.ImportNamespaceSpecifier &&
|
|
220
|
+
resolvesTo(scope, specifier.local.name, specifier)) {
|
|
221
|
+
return `${specifier.local.name}.${THEME_NAME}`;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
return undefined;
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* Mirrors the declaration's own convention so a `verbatimModuleSyntax` or
|
|
229
|
+
* `consistent-type-imports` project stays clean: a type-only declaration
|
|
230
|
+
* takes a plain specifier, inline `type` specifiers call for another, and a
|
|
231
|
+
* declaration with no type marker follows how `SxProps` itself arrived.
|
|
232
|
+
*/
|
|
233
|
+
function themeSpecifierText(declaration, sxPropsClause) {
|
|
234
|
+
if (declaration.importKind === 'type') {
|
|
235
|
+
return THEME_NAME;
|
|
236
|
+
}
|
|
237
|
+
const usesInlineType = declaration.specifiers.some((specifier) => specifier.type === utils_1.AST_NODE_TYPES.ImportSpecifier &&
|
|
238
|
+
specifier.importKind === 'type');
|
|
239
|
+
const sxPropsIsTypeOnly = sxPropsClause !== undefined &&
|
|
240
|
+
sxPropsClause.parent !== declaration &&
|
|
241
|
+
isTypeOnlyImport(sxPropsClause);
|
|
242
|
+
return usesInlineType || sxPropsIsTypeOnly
|
|
243
|
+
? `type ${THEME_NAME}`
|
|
244
|
+
: THEME_NAME;
|
|
245
|
+
}
|
|
246
|
+
function extendNamedImport(declaration, specifiers, sxPropsClause) {
|
|
247
|
+
const text = themeSpecifierText(declaration, sxPropsClause);
|
|
248
|
+
const lastSpecifier = specifiers[specifiers.length - 1];
|
|
249
|
+
const openingBrace = sourceCode.getTokenBefore(specifiers[0]);
|
|
250
|
+
const isMultiline = openingBrace !== null &&
|
|
251
|
+
openingBrace.loc.end.line !== lastSpecifier.loc.start.line;
|
|
252
|
+
if (!isMultiline) {
|
|
253
|
+
return (fixer) => fixer.insertTextAfter(lastSpecifier, `, ${text}`);
|
|
254
|
+
}
|
|
255
|
+
const lineText = sourceCode.lines[lastSpecifier.loc.start.line - 1];
|
|
256
|
+
const indent = /^[\t ]*/.exec(lineText)?.[0] ?? '';
|
|
257
|
+
const trailingComma = sourceCode.getTokenAfter(lastSpecifier);
|
|
258
|
+
if (trailingComma?.value !== ',') {
|
|
259
|
+
return (fixer) => fixer.insertTextAfter(lastSpecifier, `,\n${indent}${text}`);
|
|
260
|
+
}
|
|
261
|
+
// A same-line comment after the trailing comma annotates the specifier
|
|
262
|
+
// above it, so the new specifier goes on the next line, below the comment.
|
|
263
|
+
let anchor = trailingComma;
|
|
264
|
+
for (const comment of sourceCode.getCommentsAfter(trailingComma)) {
|
|
265
|
+
if (comment.loc.start.line !== trailingComma.loc.end.line) {
|
|
266
|
+
break;
|
|
267
|
+
}
|
|
268
|
+
anchor = comment;
|
|
269
|
+
}
|
|
270
|
+
return (fixer) => fixer.insertTextAfterRange(anchor.range, `\n${indent}${text},`);
|
|
271
|
+
}
|
|
272
|
+
function insertNewImport(lastImport) {
|
|
273
|
+
const quote = lastImport.source.raw.startsWith('"') ? '"' : "'";
|
|
274
|
+
const lastToken = sourceCode.getLastToken(lastImport);
|
|
275
|
+
const semicolon = lastToken?.value === ';' ? ';' : '';
|
|
276
|
+
const statement = `import type { ${THEME_NAME} } from ${quote}${THEME_SOURCE}${quote}${semicolon}`;
|
|
277
|
+
// A trailing same-line comment stays with the import it annotates.
|
|
278
|
+
let anchor = lastImport;
|
|
279
|
+
for (const comment of sourceCode.getCommentsAfter(lastImport)) {
|
|
280
|
+
if (comment.loc.start.line !== lastImport.loc.end.line) {
|
|
281
|
+
break;
|
|
282
|
+
}
|
|
283
|
+
anchor = comment;
|
|
284
|
+
}
|
|
285
|
+
return (fixer) => fixer.insertTextAfterRange(anchor.range, `\n${statement}`);
|
|
286
|
+
}
|
|
287
|
+
function planFix(node, scope) {
|
|
288
|
+
if (isInsideThemeAugmentation(node)) {
|
|
289
|
+
return undefined;
|
|
290
|
+
}
|
|
291
|
+
const existing = existingThemeName(scope);
|
|
292
|
+
if (existing) {
|
|
293
|
+
return { themeText: existing };
|
|
294
|
+
}
|
|
295
|
+
// Adding a `Theme` binding is safe only where the name is free, both at
|
|
296
|
+
// the node and everywhere else in the file.
|
|
297
|
+
if (findVariable(scope, THEME_NAME) !== undefined ||
|
|
298
|
+
themeIsReferencedUnresolved()) {
|
|
299
|
+
const namespaced = namespaceThemeText(scope);
|
|
300
|
+
return namespaced ? { themeText: namespaced } : undefined;
|
|
301
|
+
}
|
|
302
|
+
const sxPropsClause = node.typeName.type === utils_1.AST_NODE_TYPES.Identifier
|
|
303
|
+
? importClauseOf(findVariable(scope, node.typeName.name))
|
|
304
|
+
: undefined;
|
|
305
|
+
for (const declaration of themeSourceImports) {
|
|
306
|
+
const specifiers = declaration.specifiers.filter((specifier) => specifier.type === utils_1.AST_NODE_TYPES.ImportSpecifier);
|
|
307
|
+
if (specifiers.length > 0) {
|
|
308
|
+
return {
|
|
309
|
+
themeText: THEME_NAME,
|
|
310
|
+
importFix: extendNamedImport(declaration, specifiers, sxPropsClause),
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
const namespaced = namespaceThemeText(scope);
|
|
315
|
+
if (namespaced) {
|
|
316
|
+
return { themeText: namespaced };
|
|
317
|
+
}
|
|
318
|
+
const lastImport = topLevelImports[topLevelImports.length - 1];
|
|
319
|
+
if (!lastImport) {
|
|
320
|
+
return undefined;
|
|
321
|
+
}
|
|
322
|
+
return {
|
|
323
|
+
themeText: THEME_NAME,
|
|
324
|
+
importFix: insertNewImport(lastImport),
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
return {
|
|
328
|
+
TSTypeReference(node) {
|
|
329
|
+
if (!importsSxProps || hasTypeArguments(node)) {
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
const scope = ASTHelpers_1.ASTHelpers.getScope(context, node);
|
|
333
|
+
if (!isReportableSxProps(node.typeName, scope)) {
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
const plan = planFix(node, scope);
|
|
337
|
+
context.report({
|
|
338
|
+
node,
|
|
339
|
+
messageId: 'requireSxPropsTheme',
|
|
340
|
+
data: { typeName: sourceCode.getText(node.typeName) },
|
|
341
|
+
// Each report carries its own import edit so any single fix is
|
|
342
|
+
// complete on its own. Two reports editing the same import overlap,
|
|
343
|
+
// so ESLint applies one per pass and the next pass sees `Theme`
|
|
344
|
+
// imported and needs only the insertions.
|
|
345
|
+
fix: plan
|
|
346
|
+
? (fixer) => {
|
|
347
|
+
const fixes = [
|
|
348
|
+
fixer.insertTextAfter(node.typeName, `<${plan.themeText}>`),
|
|
349
|
+
];
|
|
350
|
+
if (plan.importFix) {
|
|
351
|
+
fixes.push(plan.importFix(fixer));
|
|
352
|
+
}
|
|
353
|
+
return fixes;
|
|
354
|
+
}
|
|
355
|
+
: null,
|
|
356
|
+
});
|
|
357
|
+
},
|
|
358
|
+
};
|
|
359
|
+
},
|
|
360
|
+
});
|
|
361
|
+
//# sourceMappingURL=require-sx-props-theme.js.map
|
|
@@ -465,6 +465,51 @@ declare module 'functions/src/util/assertSafe' {
|
|
|
465
465
|
export function assertSafe<T extends PropertyKey>(key: T): T;
|
|
466
466
|
}
|
|
467
467
|
`;
|
|
468
|
+
/**
|
|
469
|
+
* `@mui/material/styles`, the module `require-sx-props-theme` draws `Theme`
|
|
470
|
+
* from, and `@mui/system/styleFunctionSx`, the deepest `SxProps` it rewrites.
|
|
471
|
+
* Under the wildcard alone a named import is a VALUE of type `any`, so writing
|
|
472
|
+
* `SxProps` or `Theme` in a type position is TS2709 on the input and output
|
|
473
|
+
* alike, and every pair of that fixer was held out. It earns a shape under the
|
|
474
|
+
* stated policy: the fix writes a type argument drawn from this module, and a
|
|
475
|
+
* wrong one — an unbound `Theme`, a second `Theme` import, a namespace member
|
|
476
|
+
* that does not exist — is a type error.
|
|
477
|
+
*
|
|
478
|
+
* `SxProps` is the shipped union from `styleFunctionSx.d.ts`, with
|
|
479
|
+
* `SystemStyleObject` reduced to an open record: the CSS property types add
|
|
480
|
+
* noise, not detection. `Theme` is open for the same reason, so a fixture
|
|
481
|
+
* reading `theme.palette.*` or augmenting the interface still compiles.
|
|
482
|
+
* `useTheme` and `createTheme` carry their shipped signatures because other
|
|
483
|
+
* rules' fixtures import them from here; the barrel `@mui/material` and
|
|
484
|
+
* `@mui/system` stay on the wildcard, since fixtures import members from them
|
|
485
|
+
* that exist in no typings (`FlexRow`, `CustomWidget`).
|
|
486
|
+
*/
|
|
487
|
+
const MUI_SX_PROPS_SHAPE = `
|
|
488
|
+
type SystemStyleObject<Theme extends object = {}> = { [key: string]: any };
|
|
489
|
+
export type SxProps<Theme extends object = {}> =
|
|
490
|
+
| SystemStyleObject<Theme>
|
|
491
|
+
| ((theme: Theme) => SystemStyleObject<Theme>)
|
|
492
|
+
| ReadonlyArray<
|
|
493
|
+
| boolean
|
|
494
|
+
| SystemStyleObject<Theme>
|
|
495
|
+
| ((theme: Theme) => SystemStyleObject<Theme>)
|
|
496
|
+
>;
|
|
497
|
+
`;
|
|
498
|
+
const MUI_STYLES_STUB = `
|
|
499
|
+
declare module '@mui/material/styles' {
|
|
500
|
+
export interface Theme { [key: string]: any }
|
|
501
|
+
${MUI_SX_PROPS_SHAPE}
|
|
502
|
+
export function useTheme<T = Theme>(): T;
|
|
503
|
+
export function createTheme(options?: any, ...args: object[]): Theme;
|
|
504
|
+
export const styled: any;
|
|
505
|
+
export type CSSObject = any;
|
|
506
|
+
export type Mixins = any;
|
|
507
|
+
export type Shadows = any;
|
|
508
|
+
}
|
|
509
|
+
declare module '@mui/system/styleFunctionSx' {
|
|
510
|
+
${MUI_SX_PROPS_SHAPE}
|
|
511
|
+
}
|
|
512
|
+
`;
|
|
468
513
|
exports.STUBS = `
|
|
469
514
|
declare module '*';
|
|
470
515
|
${exports.REACT_STUB}
|
|
@@ -472,6 +517,7 @@ ${exports.FIRESTORE_ADMIN_STUB}
|
|
|
472
517
|
${exports.FIRESTORE_CLIENT_STUB}
|
|
473
518
|
${exports.SUBSTITUTION_PARTNER_STUBS}
|
|
474
519
|
${ASSERT_SAFE_STUB}
|
|
520
|
+
${MUI_STYLES_STUB}
|
|
475
521
|
declare namespace JSX {
|
|
476
522
|
interface IntrinsicElements { [k: string]: any }
|
|
477
523
|
interface Element {}
|