@ethlete/eslint-plugin 0.1.0-next.0 → 0.1.0-next.10
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/CHANGELOG.md +64 -0
- package/package.json +6 -5
- package/src/configs/recommended.js +414 -0
- package/src/index.js +138 -0
- package/src/rules/angular-decorator-property-order.js +205 -0
- package/src/rules/class-constant-property.js +365 -0
- package/src/rules/class-member-order.js +456 -0
- package/src/rules/enforce-routing-view-naming.js +84 -0
- package/src/rules/guard-return-newline.js +81 -0
- package/src/rules/inject-member-accessibility.js +93 -0
- package/src/rules/internals/angular-member-visibility.js +233 -0
- package/src/rules/internals/implemented-contract-members.js +294 -0
- package/src/rules/no-angular-router-api.js +158 -0
- package/src/rules/no-angular-seo-services.js +82 -0
- package/src/rules/no-direct-dom-manipulation.js +160 -0
- package/src/rules/no-document-cookie.js +55 -0
- package/src/rules/no-dom-query.js +81 -0
- package/src/rules/no-empty-angular-metadata-arrays.js +104 -0
- package/src/rules/no-empty-newlines-between-imports.js +45 -0
- package/src/rules/no-inject-chain.js +44 -0
- package/src/rules/no-leading-underscore-class-member.js +120 -0
- package/src/rules/no-legacy-angular-decorators.js +405 -0
- package/src/rules/no-locale-id.js +57 -0
- package/src/rules/no-member-alias.js +151 -0
- package/src/rules/no-native-observers.js +102 -0
- package/src/rules/no-pipe-logic.js +76 -0
- package/src/rules/no-public-property.js +85 -0
- package/src/rules/no-readonly-signal.js +119 -0
- package/src/rules/no-redundant-internal.js +94 -0
- package/src/rules/no-redundant-on-push-change-detection.js +237 -0
- package/src/rules/no-rxjs-in-effect.js +88 -0
- package/src/rules/no-screaming-case-local.js +113 -0
- package/src/rules/no-standalone-flag.js +95 -0
- package/src/rules/no-subscribe-in-pipe.js +89 -0
- package/src/rules/no-subscribe-with-body.js +95 -0
- package/src/rules/no-trivial-return-type.js +104 -0
- package/src/rules/no-trivial-wrapper-method.js +120 -0
- package/src/rules/no-type-only-import.js +79 -0
- package/src/rules/no-typed-injected-element-ref.js +88 -0
- package/src/rules/no-unused-class-member.js +209 -0
- package/src/rules/no-window-location.js +109 -0
- package/src/rules/prefer-clone-equal.js +112 -0
- package/src/rules/prefer-concise-angular-host-directives.js +225 -0
- package/src/rules/prefer-concise-angular-style-metadata.js +108 -0
- package/src/rules/prefer-element-dimensions.js +128 -0
- package/src/rules/prefer-linked-signal.js +124 -0
- package/src/rules/prefer-match-media.js +101 -0
- package/src/rules/prefer-rxjs-timer.js +82 -0
- package/src/rules/prefer-scroll-state.js +158 -0
- package/src/rules/prefer-viewport-size.js +75 -0
- package/src/rules/require-dollar-suffix.js +123 -0
- package/src/rules/require-on-push-change-detection.js +320 -0
- package/src/rules/require-view-encapsulation-none.js +264 -0
- package/src/rules/template-member-accessibility.js +204 -0
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const COMPONENT_ORDER = ['selector', 'template', 'styleUrl', 'encapsulation', 'changeDetection'];
|
|
5
|
+
|
|
6
|
+
// OnPush became the default change detection strategy in Angular 22, so from
|
|
7
|
+
// v22 onward it must NOT be declared (see no-redundant-on-push-change-detection).
|
|
8
|
+
// This rule therefore only applies to older Angular, where OnPush is opt-in.
|
|
9
|
+
const MAX_MAJOR = 21;
|
|
10
|
+
|
|
11
|
+
/** @type {number | null | undefined} */
|
|
12
|
+
let cachedAngularMajor;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Detect the workspace's installed Angular major version. Cached across files.
|
|
16
|
+
*
|
|
17
|
+
* @returns {number | null} major version, or null when it cannot be determined
|
|
18
|
+
*/
|
|
19
|
+
const detectAngularMajor = () => {
|
|
20
|
+
if (cachedAngularMajor !== undefined) return cachedAngularMajor;
|
|
21
|
+
|
|
22
|
+
try {
|
|
23
|
+
const pkg = require('@angular/core/package.json');
|
|
24
|
+
const major = Number.parseInt(String(pkg.version).split('.')[0], 10);
|
|
25
|
+
cachedAngularMajor = Number.isNaN(major) ? null : major;
|
|
26
|
+
} catch {
|
|
27
|
+
cachedAngularMajor = null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return cachedAngularMajor;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Resolve the Angular major version to gate on. An explicit
|
|
35
|
+
* `settings.ethlete.angularMajor` wins over auto-detection — handy for
|
|
36
|
+
* pinning behaviour and for tests.
|
|
37
|
+
*
|
|
38
|
+
* @param {import('eslint').Rule.RuleContext} context
|
|
39
|
+
* @returns {number | null}
|
|
40
|
+
*/
|
|
41
|
+
const getAngularMajor = (context) => {
|
|
42
|
+
const settings = /** @type {any} */ (context.settings);
|
|
43
|
+
const override = settings?.ethlete?.angularMajor;
|
|
44
|
+
if (typeof override === 'number') return override;
|
|
45
|
+
|
|
46
|
+
return detectAngularMajor();
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* @param {import('estree').Property['key']} key
|
|
51
|
+
*/
|
|
52
|
+
const getPropertyName = (key) => {
|
|
53
|
+
if (key.type === 'Identifier') return key.name;
|
|
54
|
+
if (key.type === 'Literal' && typeof key.value === 'string') return key.value;
|
|
55
|
+
return null;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* @param {string | null} propertyName
|
|
60
|
+
*/
|
|
61
|
+
const getOrderKey = (propertyName) => {
|
|
62
|
+
if (propertyName === 'template' || propertyName === 'templateUrl') return 'template';
|
|
63
|
+
if (propertyName === 'styleUrl' || propertyName === 'styleUrls') return 'styleUrl';
|
|
64
|
+
return propertyName;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* @param {import('eslint').SourceCode} sourceCode
|
|
69
|
+
* @param {import('eslint').AST.Token | import('eslint').Rule.Node} node
|
|
70
|
+
*/
|
|
71
|
+
const getIndent = (sourceCode, node) => {
|
|
72
|
+
if (!node.loc) return '';
|
|
73
|
+
|
|
74
|
+
const line = sourceCode.lines[node.loc.start.line - 1] ?? '';
|
|
75
|
+
return line.slice(0, node.loc.start.column);
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* @param {import('eslint').SourceCode} sourceCode
|
|
80
|
+
* @param {any} property
|
|
81
|
+
*/
|
|
82
|
+
const hasTrailingComma = (sourceCode, property) => {
|
|
83
|
+
const tokenAfter = sourceCode.getTokenAfter(property);
|
|
84
|
+
return Boolean(tokenAfter && tokenAfter.type === 'Punctuator' && tokenAfter.value === ',');
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* @param {import('eslint').SourceCode} sourceCode
|
|
89
|
+
*/
|
|
90
|
+
const findAngularCoreImport = (sourceCode) =>
|
|
91
|
+
sourceCode.ast.body.find(
|
|
92
|
+
(node) =>
|
|
93
|
+
node.type === 'ImportDeclaration' && node.source.type === 'Literal' && node.source.value === '@angular/core',
|
|
94
|
+
) ?? null;
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* @param {import('eslint').SourceCode} sourceCode
|
|
98
|
+
* @param {any} importNode
|
|
99
|
+
*/
|
|
100
|
+
const buildAngularCoreImportFix = (sourceCode, importNode) => {
|
|
101
|
+
const specifiers = importNode.specifiers ?? [];
|
|
102
|
+
const hasChangeDetectionStrategy = specifiers.some(
|
|
103
|
+
(specifier) =>
|
|
104
|
+
specifier.type === 'ImportSpecifier' &&
|
|
105
|
+
specifier.imported.type === 'Identifier' &&
|
|
106
|
+
specifier.imported.name === 'ChangeDetectionStrategy',
|
|
107
|
+
);
|
|
108
|
+
if (hasChangeDetectionStrategy) return null;
|
|
109
|
+
|
|
110
|
+
const defaultSpecifier = specifiers.find((specifier) => specifier.type === 'ImportDefaultSpecifier') ?? null;
|
|
111
|
+
const namespaceSpecifier = specifiers.find((specifier) => specifier.type === 'ImportNamespaceSpecifier') ?? null;
|
|
112
|
+
const namedSpecifiers = specifiers.filter((specifier) => specifier.type === 'ImportSpecifier');
|
|
113
|
+
|
|
114
|
+
if (namespaceSpecifier) {
|
|
115
|
+
return (fixer) => fixer.insertTextAfter(importNode, `\nimport { ChangeDetectionStrategy } from '@angular/core';`);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const importParts = [];
|
|
119
|
+
if (defaultSpecifier) {
|
|
120
|
+
importParts.push(sourceCode.getText(defaultSpecifier));
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const namedTexts = namedSpecifiers.map((specifier) => sourceCode.getText(specifier));
|
|
124
|
+
importParts.push(`{ ${[...namedTexts, 'ChangeDetectionStrategy'].join(', ')} }`);
|
|
125
|
+
|
|
126
|
+
return (fixer) => fixer.replaceText(importNode, `import ${importParts.join(', ')} from '@angular/core';`);
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* @param {import('eslint').SourceCode} sourceCode
|
|
131
|
+
*/
|
|
132
|
+
const buildMissingAngularCoreImportFix = (sourceCode) => {
|
|
133
|
+
const importDeclarations = sourceCode.ast.body.filter((node) => node.type === 'ImportDeclaration');
|
|
134
|
+
const lastImport = importDeclarations[importDeclarations.length - 1] ?? null;
|
|
135
|
+
|
|
136
|
+
return lastImport
|
|
137
|
+
? (fixer) => fixer.insertTextAfter(lastImport, `\nimport { ChangeDetectionStrategy } from '@angular/core';`)
|
|
138
|
+
: (fixer) =>
|
|
139
|
+
fixer.replaceTextRange(
|
|
140
|
+
[0, sourceCode.ast.range[0]],
|
|
141
|
+
`import { ChangeDetectionStrategy } from '@angular/core';\n\n`,
|
|
142
|
+
);
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* @param {any} metadata
|
|
147
|
+
*/
|
|
148
|
+
const getChangeDetectionInsertionTarget = (metadata) => {
|
|
149
|
+
const changeDetectionIndex = COMPONENT_ORDER.indexOf('changeDetection');
|
|
150
|
+
|
|
151
|
+
return (
|
|
152
|
+
metadata.properties.find((property) => {
|
|
153
|
+
if (property.type !== 'Property') return false;
|
|
154
|
+
|
|
155
|
+
const propertyName = getOrderKey(getPropertyName(property.key));
|
|
156
|
+
const propertyIndex = propertyName === null ? COMPONENT_ORDER.length : COMPONENT_ORDER.indexOf(propertyName);
|
|
157
|
+
return propertyIndex > changeDetectionIndex || propertyIndex === -1;
|
|
158
|
+
}) ?? null
|
|
159
|
+
);
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* @param {import('eslint').SourceCode} sourceCode
|
|
164
|
+
* @param {any} metadata
|
|
165
|
+
*/
|
|
166
|
+
const buildMetadataFix = (sourceCode, metadata) => {
|
|
167
|
+
const changeDetectionText = 'changeDetection: ChangeDetectionStrategy.OnPush';
|
|
168
|
+
const properties = metadata.properties.filter((property) => property.type === 'Property');
|
|
169
|
+
const insertionTarget = getChangeDetectionInsertionTarget(metadata);
|
|
170
|
+
const isMultiline = Boolean(metadata.loc && metadata.loc.start.line !== metadata.loc.end.line);
|
|
171
|
+
|
|
172
|
+
if (properties.length === 0) {
|
|
173
|
+
return (fixer) => fixer.replaceText(metadata, `{ ${changeDetectionText} }`);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (insertionTarget) {
|
|
177
|
+
if (isMultiline) {
|
|
178
|
+
const propertyIndent = getIndent(sourceCode, insertionTarget);
|
|
179
|
+
return (fixer) => fixer.insertTextBefore(insertionTarget, `${changeDetectionText},\n${propertyIndent}`);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return (fixer) => fixer.insertTextBefore(insertionTarget, `${changeDetectionText}, `);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const lastProperty = properties[properties.length - 1];
|
|
186
|
+
const closingBrace = sourceCode.getLastToken(metadata);
|
|
187
|
+
if (!lastProperty || !closingBrace) return null;
|
|
188
|
+
|
|
189
|
+
if (isMultiline) {
|
|
190
|
+
const closingIndent = getIndent(sourceCode, closingBrace);
|
|
191
|
+
const propertyIndent = getIndent(sourceCode, lastProperty);
|
|
192
|
+
const separator = hasTrailingComma(sourceCode, lastProperty) ? '' : ',';
|
|
193
|
+
const rangeStart =
|
|
194
|
+
separator === ''
|
|
195
|
+
? (sourceCode.getTokenAfter(lastProperty)?.range[1] ?? lastProperty.range[1])
|
|
196
|
+
: lastProperty.range[1];
|
|
197
|
+
|
|
198
|
+
return (fixer) =>
|
|
199
|
+
fixer.replaceTextRange(
|
|
200
|
+
[rangeStart, closingBrace.range[0]],
|
|
201
|
+
`${separator}\n${propertyIndent}${changeDetectionText}\n${closingIndent}`,
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const separator = hasTrailingComma(sourceCode, lastProperty) ? '' : ',';
|
|
206
|
+
return (fixer) =>
|
|
207
|
+
fixer.replaceTextRange([lastProperty.range[1], closingBrace.range[0]], `${separator} ${changeDetectionText} `);
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* @param {import('eslint').SourceCode} sourceCode
|
|
212
|
+
* @param {any} metadata
|
|
213
|
+
* @param {any} valueNode
|
|
214
|
+
*/
|
|
215
|
+
const buildFix = (sourceCode, metadata, valueNode) => {
|
|
216
|
+
const fixes = [];
|
|
217
|
+
const angularCoreImport = findAngularCoreImport(sourceCode);
|
|
218
|
+
const importFix = angularCoreImport
|
|
219
|
+
? buildAngularCoreImportFix(sourceCode, angularCoreImport)
|
|
220
|
+
: buildMissingAngularCoreImportFix(sourceCode);
|
|
221
|
+
|
|
222
|
+
if (importFix) fixes.push(importFix);
|
|
223
|
+
|
|
224
|
+
if (valueNode) {
|
|
225
|
+
fixes.push((fixer) => fixer.replaceText(valueNode, 'ChangeDetectionStrategy.OnPush'));
|
|
226
|
+
} else {
|
|
227
|
+
const metadataFix = buildMetadataFix(sourceCode, metadata);
|
|
228
|
+
if (metadataFix) fixes.push(metadataFix);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return (fixer) => fixes.map((applyFix) => applyFix(fixer));
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
/** @type {import('eslint').Rule.RuleModule} */
|
|
235
|
+
const requireOnPushChangeDetection = {
|
|
236
|
+
meta: {
|
|
237
|
+
type: 'suggestion',
|
|
238
|
+
docs: {
|
|
239
|
+
description: 'Require `changeDetection: ChangeDetectionStrategy.OnPush` in all @Component decorators.',
|
|
240
|
+
},
|
|
241
|
+
fixable: 'code',
|
|
242
|
+
messages: {
|
|
243
|
+
missing:
|
|
244
|
+
'Add `changeDetection: ChangeDetectionStrategy.OnPush` to @Component. Default change detection is not allowed.',
|
|
245
|
+
notOnPush: '`changeDetection` must be `ChangeDetectionStrategy.OnPush`. Got `ChangeDetectionStrategy.{{value}}`.',
|
|
246
|
+
},
|
|
247
|
+
schema: [],
|
|
248
|
+
},
|
|
249
|
+
create(context) {
|
|
250
|
+
// Only enforce on Angular <= 21, where OnPush is opt-in. On v22+ (or when
|
|
251
|
+
// the version is unknown — this workspace targets modern Angular) stay
|
|
252
|
+
// silent so no-redundant-on-push-change-detection owns the metadata.
|
|
253
|
+
const angularMajor = getAngularMajor(context);
|
|
254
|
+
if (angularMajor === null || angularMajor > MAX_MAJOR) return {};
|
|
255
|
+
|
|
256
|
+
const sourceCode = context.sourceCode;
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* @param {import('eslint').Rule.Node} node
|
|
260
|
+
*/
|
|
261
|
+
const isComponentDecorator = (node) => {
|
|
262
|
+
const decorator = /** @type {any} */ (node);
|
|
263
|
+
if (decorator.type !== 'Decorator') return false;
|
|
264
|
+
|
|
265
|
+
const expression = decorator.expression;
|
|
266
|
+
if (expression.type === 'CallExpression') {
|
|
267
|
+
return expression.callee.type === 'Identifier' && expression.callee.name === 'Component';
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
return expression.type === 'Identifier' && expression.name === 'Component';
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
return {
|
|
274
|
+
Decorator(node) {
|
|
275
|
+
if (!isComponentDecorator(node)) return;
|
|
276
|
+
|
|
277
|
+
const decorator = /** @type {any} */ (node);
|
|
278
|
+
const expression = decorator.expression;
|
|
279
|
+
if (expression.type !== 'CallExpression' || expression.arguments.length === 0) return;
|
|
280
|
+
|
|
281
|
+
const metadata = expression.arguments[0];
|
|
282
|
+
if (!metadata || metadata.type !== 'ObjectExpression') return;
|
|
283
|
+
|
|
284
|
+
const changeDetectionProp = metadata.properties.find(
|
|
285
|
+
(property) =>
|
|
286
|
+
property.type === 'Property' &&
|
|
287
|
+
property.key.type === 'Identifier' &&
|
|
288
|
+
property.key.name === 'changeDetection',
|
|
289
|
+
);
|
|
290
|
+
|
|
291
|
+
if (!changeDetectionProp) {
|
|
292
|
+
context.report({
|
|
293
|
+
node: metadata,
|
|
294
|
+
messageId: 'missing',
|
|
295
|
+
fix: buildFix(sourceCode, metadata, null),
|
|
296
|
+
});
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const value = changeDetectionProp.value;
|
|
301
|
+
if (
|
|
302
|
+
value.type === 'MemberExpression' &&
|
|
303
|
+
value.object.type === 'Identifier' &&
|
|
304
|
+
value.object.name === 'ChangeDetectionStrategy' &&
|
|
305
|
+
value.property.type === 'Identifier' &&
|
|
306
|
+
value.property.name !== 'OnPush'
|
|
307
|
+
) {
|
|
308
|
+
context.report({
|
|
309
|
+
node: value,
|
|
310
|
+
messageId: 'notOnPush',
|
|
311
|
+
data: { value: value.property.name },
|
|
312
|
+
fix: buildFix(sourceCode, metadata, value),
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
},
|
|
316
|
+
};
|
|
317
|
+
},
|
|
318
|
+
};
|
|
319
|
+
|
|
320
|
+
module.exports = requireOnPushChangeDetection;
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const COMPONENT_ORDER = ['selector', 'template', 'styleUrl', 'encapsulation', 'changeDetection'];
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* @param {import('estree').Property['key']} key
|
|
8
|
+
*/
|
|
9
|
+
const getPropertyName = (key) => {
|
|
10
|
+
if (key.type === 'Identifier') return key.name;
|
|
11
|
+
if (key.type === 'Literal' && typeof key.value === 'string') return key.value;
|
|
12
|
+
return null;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* @param {string | null} propertyName
|
|
17
|
+
*/
|
|
18
|
+
const getOrderKey = (propertyName) => {
|
|
19
|
+
if (propertyName === 'template' || propertyName === 'templateUrl') return 'template';
|
|
20
|
+
if (propertyName === 'styleUrl' || propertyName === 'styleUrls') return 'styleUrl';
|
|
21
|
+
return propertyName;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* @param {import('eslint').SourceCode} sourceCode
|
|
26
|
+
* @param {import('eslint').AST.Token | import('eslint').Rule.Node} node
|
|
27
|
+
*/
|
|
28
|
+
const getIndent = (sourceCode, node) => {
|
|
29
|
+
if (!node.loc) return '';
|
|
30
|
+
|
|
31
|
+
const line = sourceCode.lines[node.loc.start.line - 1] ?? '';
|
|
32
|
+
return line.slice(0, node.loc.start.column);
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* @param {import('eslint').SourceCode} sourceCode
|
|
37
|
+
* @param {any} property
|
|
38
|
+
*/
|
|
39
|
+
const hasTrailingComma = (sourceCode, property) => {
|
|
40
|
+
const tokenAfter = sourceCode.getTokenAfter(property);
|
|
41
|
+
return Boolean(tokenAfter && tokenAfter.type === 'Punctuator' && tokenAfter.value === ',');
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* @param {import('eslint').SourceCode} sourceCode
|
|
46
|
+
*/
|
|
47
|
+
const findAngularCoreImport = (sourceCode) =>
|
|
48
|
+
sourceCode.ast.body.find(
|
|
49
|
+
(node) =>
|
|
50
|
+
node.type === 'ImportDeclaration' && node.source.type === 'Literal' && node.source.value === '@angular/core',
|
|
51
|
+
) ?? null;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* @param {import('eslint').SourceCode} sourceCode
|
|
55
|
+
* @param {any} importNode
|
|
56
|
+
*/
|
|
57
|
+
const buildAngularCoreImportFix = (sourceCode, importNode) => {
|
|
58
|
+
const specifiers = importNode.specifiers ?? [];
|
|
59
|
+
const hasViewEncapsulation = specifiers.some(
|
|
60
|
+
(specifier) =>
|
|
61
|
+
specifier.type === 'ImportSpecifier' &&
|
|
62
|
+
specifier.imported.type === 'Identifier' &&
|
|
63
|
+
specifier.imported.name === 'ViewEncapsulation',
|
|
64
|
+
);
|
|
65
|
+
if (hasViewEncapsulation) return null;
|
|
66
|
+
|
|
67
|
+
const defaultSpecifier = specifiers.find((specifier) => specifier.type === 'ImportDefaultSpecifier') ?? null;
|
|
68
|
+
const namespaceSpecifier = specifiers.find((specifier) => specifier.type === 'ImportNamespaceSpecifier') ?? null;
|
|
69
|
+
const namedSpecifiers = specifiers.filter((specifier) => specifier.type === 'ImportSpecifier');
|
|
70
|
+
|
|
71
|
+
if (namespaceSpecifier) {
|
|
72
|
+
return (fixer) => fixer.insertTextAfter(importNode, `\nimport { ViewEncapsulation } from '@angular/core';`);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const importParts = [];
|
|
76
|
+
if (defaultSpecifier) {
|
|
77
|
+
importParts.push(sourceCode.getText(defaultSpecifier));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const namedTexts = namedSpecifiers.map((specifier) => sourceCode.getText(specifier));
|
|
81
|
+
importParts.push(`{ ${[...namedTexts, 'ViewEncapsulation'].join(', ')} }`);
|
|
82
|
+
|
|
83
|
+
return (fixer) => fixer.replaceText(importNode, `import ${importParts.join(', ')} from '@angular/core';`);
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* @param {import('eslint').SourceCode} sourceCode
|
|
88
|
+
*/
|
|
89
|
+
const buildMissingAngularCoreImportFix = (sourceCode) => {
|
|
90
|
+
const importDeclarations = sourceCode.ast.body.filter((node) => node.type === 'ImportDeclaration');
|
|
91
|
+
const lastImport = importDeclarations[importDeclarations.length - 1] ?? null;
|
|
92
|
+
|
|
93
|
+
return lastImport
|
|
94
|
+
? (fixer) => fixer.insertTextAfter(lastImport, `\nimport { ViewEncapsulation } from '@angular/core';`)
|
|
95
|
+
: (fixer) =>
|
|
96
|
+
fixer.replaceTextRange([0, sourceCode.ast.range[0]], `import { ViewEncapsulation } from '@angular/core';\n\n`);
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* @param {any} metadata
|
|
101
|
+
*/
|
|
102
|
+
const getEncapsulationInsertionTarget = (metadata) => {
|
|
103
|
+
const encapsulationIndex = COMPONENT_ORDER.indexOf('encapsulation');
|
|
104
|
+
|
|
105
|
+
return (
|
|
106
|
+
metadata.properties.find((property) => {
|
|
107
|
+
if (property.type !== 'Property') return false;
|
|
108
|
+
|
|
109
|
+
const propertyName = getOrderKey(getPropertyName(property.key));
|
|
110
|
+
const propertyIndex = propertyName === null ? COMPONENT_ORDER.length : COMPONENT_ORDER.indexOf(propertyName);
|
|
111
|
+
return propertyIndex > encapsulationIndex || propertyIndex === -1;
|
|
112
|
+
}) ?? null
|
|
113
|
+
);
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* @param {import('eslint').SourceCode} sourceCode
|
|
118
|
+
* @param {any} metadata
|
|
119
|
+
*/
|
|
120
|
+
const buildMetadataFix = (sourceCode, metadata) => {
|
|
121
|
+
const encText = 'encapsulation: ViewEncapsulation.None';
|
|
122
|
+
const properties = metadata.properties.filter((property) => property.type === 'Property');
|
|
123
|
+
const insertionTarget = getEncapsulationInsertionTarget(metadata);
|
|
124
|
+
const isMultiline = Boolean(metadata.loc && metadata.loc.start.line !== metadata.loc.end.line);
|
|
125
|
+
|
|
126
|
+
if (properties.length === 0) {
|
|
127
|
+
return (fixer) => fixer.replaceText(metadata, `{ ${encText} }`);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (insertionTarget) {
|
|
131
|
+
if (isMultiline) {
|
|
132
|
+
const propertyIndent = getIndent(sourceCode, insertionTarget);
|
|
133
|
+
return (fixer) => fixer.insertTextBefore(insertionTarget, `${encText},\n${propertyIndent}`);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return (fixer) => fixer.insertTextBefore(insertionTarget, `${encText}, `);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const lastProperty = properties[properties.length - 1];
|
|
140
|
+
const closingBrace = sourceCode.getLastToken(metadata);
|
|
141
|
+
if (!lastProperty || !closingBrace) return null;
|
|
142
|
+
|
|
143
|
+
if (isMultiline) {
|
|
144
|
+
const closingIndent = getIndent(sourceCode, closingBrace);
|
|
145
|
+
const propertyIndent = getIndent(sourceCode, lastProperty);
|
|
146
|
+
const separator = hasTrailingComma(sourceCode, lastProperty) ? '' : ',';
|
|
147
|
+
const rangeStart =
|
|
148
|
+
separator === ''
|
|
149
|
+
? (sourceCode.getTokenAfter(lastProperty)?.range[1] ?? lastProperty.range[1])
|
|
150
|
+
: lastProperty.range[1];
|
|
151
|
+
|
|
152
|
+
return (fixer) =>
|
|
153
|
+
fixer.replaceTextRange(
|
|
154
|
+
[rangeStart, closingBrace.range[0]],
|
|
155
|
+
`${separator}\n${propertyIndent}${encText}\n${closingIndent}`,
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const separator = hasTrailingComma(sourceCode, lastProperty) ? '' : ',';
|
|
160
|
+
return (fixer) => fixer.replaceTextRange([lastProperty.range[1], closingBrace.range[0]], `${separator} ${encText} `);
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* @param {import('eslint').SourceCode} sourceCode
|
|
165
|
+
* @param {any} metadata
|
|
166
|
+
* @param {any} valueNode
|
|
167
|
+
*/
|
|
168
|
+
const buildFix = (sourceCode, metadata, valueNode) => {
|
|
169
|
+
const fixes = [];
|
|
170
|
+
const angularCoreImport = findAngularCoreImport(sourceCode);
|
|
171
|
+
const importFix = angularCoreImport
|
|
172
|
+
? buildAngularCoreImportFix(sourceCode, angularCoreImport)
|
|
173
|
+
: buildMissingAngularCoreImportFix(sourceCode);
|
|
174
|
+
|
|
175
|
+
if (importFix) fixes.push(importFix);
|
|
176
|
+
|
|
177
|
+
if (valueNode) {
|
|
178
|
+
fixes.push((fixer) => fixer.replaceText(valueNode, 'ViewEncapsulation.None'));
|
|
179
|
+
} else {
|
|
180
|
+
const metadataFix = buildMetadataFix(sourceCode, metadata);
|
|
181
|
+
if (metadataFix) fixes.push(metadataFix);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return (fixer) => fixes.map((applyFix) => applyFix(fixer));
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
/** @type {import('eslint').Rule.RuleModule} */
|
|
188
|
+
const requireViewEncapsulationNone = {
|
|
189
|
+
meta: {
|
|
190
|
+
type: 'suggestion',
|
|
191
|
+
docs: {
|
|
192
|
+
description: 'Require `encapsulation: ViewEncapsulation.None` in all @Component decorators.',
|
|
193
|
+
},
|
|
194
|
+
fixable: 'code',
|
|
195
|
+
messages: {
|
|
196
|
+
missing: 'Add `encapsulation: ViewEncapsulation.None` to @Component. The default (Emulated) is not allowed.',
|
|
197
|
+
notNone: '`encapsulation` must be `ViewEncapsulation.None`. Got `ViewEncapsulation.{{value}}`.',
|
|
198
|
+
},
|
|
199
|
+
schema: [],
|
|
200
|
+
},
|
|
201
|
+
create(context) {
|
|
202
|
+
const sourceCode = context.sourceCode;
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* @param {import('eslint').Rule.Node} node
|
|
206
|
+
*/
|
|
207
|
+
const isComponentDecorator = (node) => {
|
|
208
|
+
const decorator = /** @type {any} */ (node);
|
|
209
|
+
if (decorator.type !== 'Decorator') return false;
|
|
210
|
+
|
|
211
|
+
const expression = decorator.expression;
|
|
212
|
+
if (expression.type === 'CallExpression') {
|
|
213
|
+
return expression.callee.type === 'Identifier' && expression.callee.name === 'Component';
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return expression.type === 'Identifier' && expression.name === 'Component';
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
return {
|
|
220
|
+
Decorator(node) {
|
|
221
|
+
if (!isComponentDecorator(node)) return;
|
|
222
|
+
|
|
223
|
+
const decorator = /** @type {any} */ (node);
|
|
224
|
+
const expression = decorator.expression;
|
|
225
|
+
if (expression.type !== 'CallExpression' || expression.arguments.length === 0) return;
|
|
226
|
+
|
|
227
|
+
const metadata = expression.arguments[0];
|
|
228
|
+
if (!metadata || metadata.type !== 'ObjectExpression') return;
|
|
229
|
+
|
|
230
|
+
const encProp = metadata.properties.find(
|
|
231
|
+
(property) =>
|
|
232
|
+
property.type === 'Property' && property.key.type === 'Identifier' && property.key.name === 'encapsulation',
|
|
233
|
+
);
|
|
234
|
+
|
|
235
|
+
if (!encProp) {
|
|
236
|
+
context.report({
|
|
237
|
+
node: metadata,
|
|
238
|
+
messageId: 'missing',
|
|
239
|
+
fix: buildFix(sourceCode, metadata, null),
|
|
240
|
+
});
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const value = encProp.value;
|
|
245
|
+
if (
|
|
246
|
+
value.type === 'MemberExpression' &&
|
|
247
|
+
value.object.type === 'Identifier' &&
|
|
248
|
+
value.object.name === 'ViewEncapsulation' &&
|
|
249
|
+
value.property.type === 'Identifier' &&
|
|
250
|
+
value.property.name !== 'None'
|
|
251
|
+
) {
|
|
252
|
+
context.report({
|
|
253
|
+
node: value,
|
|
254
|
+
messageId: 'notNone',
|
|
255
|
+
data: { value: value.property.name },
|
|
256
|
+
fix: buildFix(sourceCode, metadata, value),
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
},
|
|
260
|
+
};
|
|
261
|
+
},
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
module.exports = requireViewEncapsulationNone;
|