@ethlete/eslint-plugin 1.0.0-next.11 → 1.0.0-next.13
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 +18 -0
- package/package.json +1 -1
- package/src/configs/recommended.js +16 -3
- package/src/index.js +7 -0
- package/src/rules/internals/angular-io.js +78 -0
- package/src/rules/no-native-html-input-name.js +94 -0
- package/src/rules/prefer-present-tense-output.js +92 -0
- package/src/rules/prefer-static-boolean-properties.js +85 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.0.0-next.13
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- [#3022](https://github.com/ethlete-io/ethdk/pull/3022) [`5a43c83`](https://github.com/ethlete-io/ethdk/commit/5a43c8347a98a815562d1bb0f59bc0db1d765262) Thanks [@github-actions](https://github.com/apps/github-actions)! - Recommended config: the `@Injectable` ban now also flags Angular 22's `@Service` decorator — use `createProvider` / `createRootProvider` from `@ethlete/core` instead.
|
|
8
|
+
|
|
9
|
+
- [#3022](https://github.com/ethlete-io/ethdk/pull/3022) [`9be3738`](https://github.com/ethlete-io/ethdk/commit/9be3738e18ef1837841c79131127fa42406a3e92) Thanks [@github-actions](https://github.com/apps/github-actions)! - New template rule `ethlete/prefer-static-boolean-properties` (in `recommendedTemplate` as `warn`): flags property bindings of static booleans like `[isReadonly]="true"` and suggests the static-attribute form (`isReadonly` / `isReadonly="false"`). Suggestion-only, since the rewrite is only safe for inputs with a `booleanAttribute` transform.
|
|
10
|
+
|
|
11
|
+
## 1.0.0-next.12
|
|
12
|
+
|
|
13
|
+
### Minor Changes
|
|
14
|
+
|
|
15
|
+
- [`44adcac`](https://github.com/ethlete-io/ethdk/commit/44adcac94d7e0f56742e02901221c6e04da7df47) Thanks [@TomTomB](https://github.com/TomTomB)! - Add two component I/O naming rules:
|
|
16
|
+
- `no-native-html-input-name` (error) — flags an `input()`/`model()` named after a global HTML attribute (`title`, `id`, `hidden`, `role`, `tabindex`, …), which collides with the attribute the host element carries natively.
|
|
17
|
+
- `prefer-present-tense-output` (warn) — nudges `output()` names toward the present tense like native DOM events (`playerSelect`, not `playerSelected`).
|
|
18
|
+
|
|
19
|
+
The `on`-prefix case is already covered by `@angular-eslint/no-output-on-prefix`, so no rule is added for it.
|
|
20
|
+
|
|
3
21
|
## 1.0.0-next.11
|
|
4
22
|
|
|
5
23
|
### Major Changes
|
package/package.json
CHANGED
|
@@ -169,10 +169,12 @@ const recommendedTs = {
|
|
|
169
169
|
message:
|
|
170
170
|
'No legacy Angular lifecycle hooks. Use afterNextRender, inject(DestroyRef).onDestroy, or constructor-based effects instead.',
|
|
171
171
|
},
|
|
172
|
-
// No @Injectable — use createRootProvider / createProvider from @ethlete/core
|
|
172
|
+
// No @Injectable / @Service — use createRootProvider / createProvider from @ethlete/core
|
|
173
173
|
{
|
|
174
|
-
selector:
|
|
175
|
-
|
|
174
|
+
selector:
|
|
175
|
+
'Decorator[expression.callee.name=/^(Injectable|Service)$/], Decorator[expression.name=/^(Injectable|Service)$/]',
|
|
176
|
+
message:
|
|
177
|
+
'No @Injectable / @Service services. Use createRootProvider / createProvider from @ethlete/core instead.',
|
|
176
178
|
},
|
|
177
179
|
// No route guards — handle access control inside the component itself
|
|
178
180
|
{
|
|
@@ -382,6 +384,13 @@ const recommendedTs = {
|
|
|
382
384
|
|
|
383
385
|
// Routing components must use paths containing "-view" and class names ending in "ViewComponent"
|
|
384
386
|
'ethlete/enforce-routing-view-naming': 'error',
|
|
387
|
+
|
|
388
|
+
// Inputs/models must not be named after a global HTML attribute (title, id, hidden, …) — it collides with the host element
|
|
389
|
+
'ethlete/no-native-html-input-name': 'error',
|
|
390
|
+
|
|
391
|
+
// Outputs should read in the present tense like native DOM events: (playerSelect), not (playerSelected)
|
|
392
|
+
// (The `on` prefix is already covered by @angular-eslint/no-output-on-prefix.)
|
|
393
|
+
'ethlete/prefer-present-tense-output': 'warn',
|
|
385
394
|
},
|
|
386
395
|
};
|
|
387
396
|
|
|
@@ -396,6 +405,10 @@ const recommendedTemplate = {
|
|
|
396
405
|
'@angular-eslint/template/no-any': 'error',
|
|
397
406
|
// Prefer plain attribute over unnecessary property binding for static strings: etIcon="foo" not [etIcon]="'foo'"
|
|
398
407
|
'@angular-eslint/template/prefer-static-string-properties': 'error',
|
|
408
|
+
// Prefer plain attribute over unnecessary property binding for static booleans: isReadonly not [isReadonly]="true"
|
|
409
|
+
// (warn + suggestion-only: the rewrite is only safe when the input has a booleanAttribute transform,
|
|
410
|
+
// which a template rule cannot verify)
|
|
411
|
+
'ethlete/prefer-static-boolean-properties': 'warn',
|
|
399
412
|
},
|
|
400
413
|
};
|
|
401
414
|
|
package/src/index.js
CHANGED
|
@@ -48,6 +48,9 @@ const classConstantProperty = require('./rules/class-constant-property');
|
|
|
48
48
|
const preferConciseAngularHostDirectives = require('./rules/prefer-concise-angular-host-directives');
|
|
49
49
|
const preferConciseAngularStyleMetadata = require('./rules/prefer-concise-angular-style-metadata');
|
|
50
50
|
const noTypedInjectedElementRef = require('./rules/no-typed-injected-element-ref');
|
|
51
|
+
const noNativeHtmlInputName = require('./rules/no-native-html-input-name');
|
|
52
|
+
const preferPresentTenseOutput = require('./rules/prefer-present-tense-output');
|
|
53
|
+
const preferStaticBooleanProperties = require('./rules/prefer-static-boolean-properties');
|
|
51
54
|
const { recommendedTs, recommendedTemplate, recommendedSpec } = require('./configs/recommended');
|
|
52
55
|
|
|
53
56
|
/** @type {import('eslint').ESLint.Plugin} */
|
|
@@ -104,6 +107,9 @@ const plugin = {
|
|
|
104
107
|
'prefer-concise-angular-host-directives': preferConciseAngularHostDirectives,
|
|
105
108
|
'prefer-concise-angular-style-metadata': preferConciseAngularStyleMetadata,
|
|
106
109
|
'no-typed-injected-element-ref': noTypedInjectedElementRef,
|
|
110
|
+
'no-native-html-input-name': noNativeHtmlInputName,
|
|
111
|
+
'prefer-present-tense-output': preferPresentTenseOutput,
|
|
112
|
+
'prefer-static-boolean-properties': preferStaticBooleanProperties,
|
|
107
113
|
},
|
|
108
114
|
};
|
|
109
115
|
|
|
@@ -117,6 +123,7 @@ const recommendedTsWithPlugin = {
|
|
|
117
123
|
/** @type {import('eslint').Linter.Config} */
|
|
118
124
|
const recommendedTemplateWithPlugin = {
|
|
119
125
|
...recommendedTemplate,
|
|
126
|
+
plugins: { ethlete: plugin },
|
|
120
127
|
};
|
|
121
128
|
|
|
122
129
|
const configs = {
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Shared detection for Angular signal-based inputs and outputs.
|
|
6
|
+
*
|
|
7
|
+
* This repo declares component I/O exclusively via the signal factory functions
|
|
8
|
+
* (no `@Input()` / `@Output()` decorators):
|
|
9
|
+
*
|
|
10
|
+
* foo = input(...) → input
|
|
11
|
+
* foo = input.required<T>(...) → input
|
|
12
|
+
* foo = model(...) → model (two-way input)
|
|
13
|
+
* foo = model.required<T>(...) → model
|
|
14
|
+
* foo = output<T>() → output
|
|
15
|
+
* foo = outputFromObservable(...) → output
|
|
16
|
+
*
|
|
17
|
+
* `getReactiveIo` maps a class `PropertyDefinition` node onto one of those kinds
|
|
18
|
+
* (or null when the property is not a reactive I/O declaration).
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/** Factory identifier → I/O kind. */
|
|
22
|
+
const IO_FACTORIES = {
|
|
23
|
+
input: 'input',
|
|
24
|
+
model: 'model',
|
|
25
|
+
output: 'output',
|
|
26
|
+
outputFromObservable: 'output',
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Resolves the factory function name of a call expression, handling both the
|
|
31
|
+
* plain form (`input(...)`) and the `.required` member form
|
|
32
|
+
* (`input.required(...)`).
|
|
33
|
+
*
|
|
34
|
+
* @param {any} callee
|
|
35
|
+
* @returns {string | null}
|
|
36
|
+
*/
|
|
37
|
+
const getFactoryName = (callee) => {
|
|
38
|
+
if (!callee) return null;
|
|
39
|
+
|
|
40
|
+
if (callee.type === 'Identifier') return callee.name;
|
|
41
|
+
|
|
42
|
+
// input.required(...) / model.required(...)
|
|
43
|
+
if (
|
|
44
|
+
callee.type === 'MemberExpression' &&
|
|
45
|
+
!callee.computed &&
|
|
46
|
+
callee.object.type === 'Identifier' &&
|
|
47
|
+
callee.property.type === 'Identifier' &&
|
|
48
|
+
callee.property.name === 'required'
|
|
49
|
+
) {
|
|
50
|
+
return callee.object.name;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return null;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Classifies a class property as an Angular signal input/model/output.
|
|
58
|
+
*
|
|
59
|
+
* @param {any} node PropertyDefinition
|
|
60
|
+
* @returns {{ kind: 'input' | 'model' | 'output', name: string, keyNode: any } | null}
|
|
61
|
+
*/
|
|
62
|
+
const getReactiveIo = (node) => {
|
|
63
|
+
if (!node || node.type !== 'PropertyDefinition') return null;
|
|
64
|
+
if (node.computed || node.key.type !== 'Identifier') return null;
|
|
65
|
+
|
|
66
|
+
const value = node.value;
|
|
67
|
+
if (!value || value.type !== 'CallExpression') return null;
|
|
68
|
+
|
|
69
|
+
const factory = getFactoryName(value.callee);
|
|
70
|
+
if (!factory) return null;
|
|
71
|
+
|
|
72
|
+
const kind = /** @type {Record<string, 'input' | 'model' | 'output'>} */ (IO_FACTORIES)[factory];
|
|
73
|
+
if (!kind) return null;
|
|
74
|
+
|
|
75
|
+
return { kind, name: node.key.name, keyNode: node.key };
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
module.exports = { getReactiveIo };
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const { getReactiveIo } = require('./internals/angular-io');
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Disallows naming an `input()` / `model()` after a global HTML attribute.
|
|
8
|
+
*
|
|
9
|
+
* A component's host element carries every global attribute natively, so an
|
|
10
|
+
* input named `title`, `id`, `hidden`, `role`, … collides with the real DOM
|
|
11
|
+
* attribute: the browser applies its native behaviour (a `title` tooltip, a
|
|
12
|
+
* duplicated `id`, native `hidden`/`role` semantics) in addition to — or
|
|
13
|
+
* instead of — the component's intent. Rename the input (e.g. `label`,
|
|
14
|
+
* `overlayId`, `isHidden`) so it can't clash.
|
|
15
|
+
*
|
|
16
|
+
* Only the *global* attributes (those valid on any element) are flagged.
|
|
17
|
+
* Element-specific attributes that libraries routinely mirror on purpose
|
|
18
|
+
* (`disabled`, `value`, `placeholder`, `type`, `size`, `min`, `max`, …) are
|
|
19
|
+
* intentionally NOT flagged — they don't apply to an arbitrary host element and
|
|
20
|
+
* mirroring them is a deliberate, well-understood pattern.
|
|
21
|
+
*
|
|
22
|
+
* ❌ title = input('...')
|
|
23
|
+
* ❌ id = input(...)
|
|
24
|
+
* ❌ hidden = input(false)
|
|
25
|
+
* ✅ label = input('...')
|
|
26
|
+
* ✅ disabled = input(false) // element-specific, intentional mirror
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
// Global HTML attributes (valid on every element) + `role`, which behaves the
|
|
30
|
+
// same way. Element-specific attributes are deliberately excluded — see above.
|
|
31
|
+
//
|
|
32
|
+
// Microdata (`itemid`, `itemprop`, …) and other niche globals (`is`, `nonce`,
|
|
33
|
+
// `part`, `exportparts`, …) are also excluded: they are rarely used, so an input
|
|
34
|
+
// named after one is almost never a real collision, whereas the names double as
|
|
35
|
+
// common domain terms (e.g. `itemId` on a list item). Keeping them in would
|
|
36
|
+
// produce more false positives than caught footguns.
|
|
37
|
+
const GLOBAL_HTML_ATTRIBUTES = new Set([
|
|
38
|
+
'accesskey',
|
|
39
|
+
'autocapitalize',
|
|
40
|
+
'autofocus',
|
|
41
|
+
'class',
|
|
42
|
+
'contenteditable',
|
|
43
|
+
'dir',
|
|
44
|
+
'draggable',
|
|
45
|
+
'enterkeyhint',
|
|
46
|
+
'hidden',
|
|
47
|
+
'id',
|
|
48
|
+
'inert',
|
|
49
|
+
'inputmode',
|
|
50
|
+
'lang',
|
|
51
|
+
'popover',
|
|
52
|
+
'role',
|
|
53
|
+
'slot',
|
|
54
|
+
'spellcheck',
|
|
55
|
+
'style',
|
|
56
|
+
'tabindex',
|
|
57
|
+
'title',
|
|
58
|
+
'translate',
|
|
59
|
+
]);
|
|
60
|
+
|
|
61
|
+
/** @type {import('eslint').Rule.RuleModule} */
|
|
62
|
+
const rule = {
|
|
63
|
+
meta: {
|
|
64
|
+
type: 'problem',
|
|
65
|
+
docs: {
|
|
66
|
+
description: 'Disallow naming an input/model after a global HTML attribute (it collides with the host element).',
|
|
67
|
+
recommended: true,
|
|
68
|
+
},
|
|
69
|
+
schema: [],
|
|
70
|
+
messages: {
|
|
71
|
+
nativeName:
|
|
72
|
+
"Input '{{ name }}' shares its name with the global HTML attribute '{{ name }}', which every host element carries natively. Rename it to avoid the clash.",
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
create(context) {
|
|
76
|
+
return {
|
|
77
|
+
PropertyDefinition(node) {
|
|
78
|
+
const io = getReactiveIo(node);
|
|
79
|
+
if (!io) return;
|
|
80
|
+
if (io.kind === 'output') return;
|
|
81
|
+
|
|
82
|
+
if (!GLOBAL_HTML_ATTRIBUTES.has(io.name.toLowerCase())) return;
|
|
83
|
+
|
|
84
|
+
context.report({
|
|
85
|
+
node: io.keyNode,
|
|
86
|
+
messageId: 'nativeName',
|
|
87
|
+
data: { name: io.name },
|
|
88
|
+
});
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
module.exports = rule;
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const { getReactiveIo } = require('./internals/angular-io');
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Nudges `output()` names toward the present tense, matching native DOM events.
|
|
8
|
+
*
|
|
9
|
+
* Native events are named in the present tense after the thing that happens:
|
|
10
|
+
* `input`, `change`, `select`, `mousedown`, `dragstart`. Components should read
|
|
11
|
+
* the same way in a template — `(playerSelect)`, not `(playerSelected)`. A
|
|
12
|
+
* past-tense name describes a state after the fact rather than the event.
|
|
13
|
+
*
|
|
14
|
+
* ❌ playerSelected = output<...>()
|
|
15
|
+
* ❌ filesRejected = output<...>()
|
|
16
|
+
* ✅ playerSelect = output<...>()
|
|
17
|
+
* ✅ filesReject = output<...>()
|
|
18
|
+
*
|
|
19
|
+
* Detection is heuristic: the final camel-case segment is flagged when it ends
|
|
20
|
+
* in `-ed` (the dominant past-participle pattern), excluding an allowlist of
|
|
21
|
+
* base-form words that merely happen to end that way (`succeed`, `feed`,
|
|
22
|
+
* `speed`, …). Irregular past tenses that don't end in `-ed` (`sent`, `shown`,
|
|
23
|
+
* `built`, …) are NOT detected — this rule catches the common case, not every
|
|
24
|
+
* one. No auto-fix: de-conjugating a verb reliably (and renaming the matching
|
|
25
|
+
* template bindings) is out of scope, so the corrected name is left to the
|
|
26
|
+
* author.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
// Base-form / noun words ending in "-ed" that are not past participles.
|
|
30
|
+
const NON_PAST_ED_WORDS = new Set([
|
|
31
|
+
'succeed',
|
|
32
|
+
'proceed',
|
|
33
|
+
'exceed',
|
|
34
|
+
'feed',
|
|
35
|
+
'need',
|
|
36
|
+
'breed',
|
|
37
|
+
'bleed',
|
|
38
|
+
'speed',
|
|
39
|
+
'seed',
|
|
40
|
+
'weed',
|
|
41
|
+
'embed',
|
|
42
|
+
'shed',
|
|
43
|
+
'wed',
|
|
44
|
+
'indeed',
|
|
45
|
+
]);
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Returns the final camel-case segment of an identifier, lower-cased.
|
|
49
|
+
* `filesRejected` → `rejected`, `removed` → `removed`.
|
|
50
|
+
*
|
|
51
|
+
* @param {string} name
|
|
52
|
+
*/
|
|
53
|
+
const lastWord = (name) => {
|
|
54
|
+
const segments = name.split(/(?=[A-Z])/);
|
|
55
|
+
return segments[segments.length - 1].toLowerCase();
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
/** @type {import('eslint').Rule.RuleModule} */
|
|
59
|
+
const rule = {
|
|
60
|
+
meta: {
|
|
61
|
+
type: 'suggestion',
|
|
62
|
+
docs: {
|
|
63
|
+
description: 'Prefer present-tense output names (like native DOM events) over past-tense ones.',
|
|
64
|
+
recommended: true,
|
|
65
|
+
},
|
|
66
|
+
schema: [],
|
|
67
|
+
messages: {
|
|
68
|
+
pastTense:
|
|
69
|
+
"Output '{{ name }}' looks past tense ('{{ word }}'). Name outputs after the present-tense event like native DOM events (e.g. 'change', 'select'), not their past participle ('changed', 'selected').",
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
create(context) {
|
|
73
|
+
return {
|
|
74
|
+
PropertyDefinition(node) {
|
|
75
|
+
const io = getReactiveIo(node);
|
|
76
|
+
if (!io || io.kind !== 'output') return;
|
|
77
|
+
|
|
78
|
+
const word = lastWord(io.name);
|
|
79
|
+
if (word.length <= 2 || !word.endsWith('ed')) return;
|
|
80
|
+
if (NON_PAST_ED_WORDS.has(word)) return;
|
|
81
|
+
|
|
82
|
+
context.report({
|
|
83
|
+
node: io.keyNode,
|
|
84
|
+
messageId: 'pastTense',
|
|
85
|
+
data: { name: io.name, word },
|
|
86
|
+
});
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
module.exports = rule;
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Flags property bindings whose value is a static boolean literal:
|
|
6
|
+
*
|
|
7
|
+
* <my-cmp [isReadonly]="true" />
|
|
8
|
+
* <my-cmp [isReadonly]="false" />
|
|
9
|
+
*
|
|
10
|
+
* If the target input uses a `booleanAttribute` transform (the styleguide
|
|
11
|
+
* default for boolean inputs), the binding is unnecessary — a static attribute
|
|
12
|
+
* expresses the same thing without a change-detection expression:
|
|
13
|
+
*
|
|
14
|
+
* <my-cmp isReadonly /> (true)
|
|
15
|
+
* <my-cmp isReadonly="false" /> (false — `booleanAttribute` coerces the string 'false' to false)
|
|
16
|
+
*
|
|
17
|
+
* Whether the input actually has that transform is invisible to a template
|
|
18
|
+
* rule (no type information), so the rewrite is offered as a suggestion, not
|
|
19
|
+
* an auto-fix: without the transform a static attribute would pass the string
|
|
20
|
+
* 'true' / 'false' instead of a boolean.
|
|
21
|
+
*
|
|
22
|
+
* Companion to `@angular-eslint/template/prefer-static-string-properties`,
|
|
23
|
+
* which covers the string-literal case (where the rewrite is always safe).
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/** @type {import('eslint').Rule.RuleModule} */
|
|
27
|
+
const preferStaticBooleanProperties = {
|
|
28
|
+
meta: {
|
|
29
|
+
type: 'suggestion',
|
|
30
|
+
docs: {
|
|
31
|
+
description:
|
|
32
|
+
'Prefer a static attribute over a property binding for static boolean values when the input uses a booleanAttribute transform.',
|
|
33
|
+
},
|
|
34
|
+
hasSuggestions: true,
|
|
35
|
+
schema: [],
|
|
36
|
+
messages: {
|
|
37
|
+
preferStaticBooleanProperty:
|
|
38
|
+
'Binding a static boolean is unnecessary if the `{{ name }}` input uses a `booleanAttribute` transform — use a static attribute instead. Keep the binding if the input takes a plain boolean without a transform.',
|
|
39
|
+
useBareAttribute: 'Replace with the bare attribute `{{ name }}`',
|
|
40
|
+
useStaticAttribute: 'Replace with the static attribute `{{ name }}="false"`',
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
create(context) {
|
|
44
|
+
const parserServices = /** @type {any} */ (context.sourceCode.parserServices);
|
|
45
|
+
|
|
46
|
+
// Only meaningful when parsed with @angular-eslint/template-parser
|
|
47
|
+
if (!parserServices?.convertNodeSourceSpanToLoc) return {};
|
|
48
|
+
|
|
49
|
+
return {
|
|
50
|
+
/** @param {any} node */
|
|
51
|
+
['BoundAttribute.inputs'](node) {
|
|
52
|
+
const { name, sourceSpan, keySpan, value } = node;
|
|
53
|
+
|
|
54
|
+
// Exclude @animation, class./style./attr. sub-properties, and
|
|
55
|
+
// structural directives (*foo has no keySpan details)
|
|
56
|
+
const isBindingProperty = keySpan?.details && !keySpan.details.includes('@') && !keySpan.details.includes('.');
|
|
57
|
+
if (!isBindingProperty) return;
|
|
58
|
+
|
|
59
|
+
// Only literal `true` / `false` expressions. `value.ast` is a
|
|
60
|
+
// LiteralPrimitive for literals; the raw-source check guards against
|
|
61
|
+
// other node shapes that happen to carry a boolean `value`.
|
|
62
|
+
const source = typeof value?.source === 'string' ? value.source.trim() : null;
|
|
63
|
+
if (typeof value?.ast?.value !== 'boolean' || (source !== 'true' && source !== 'false')) return;
|
|
64
|
+
|
|
65
|
+
const isTrue = value.ast.value === true;
|
|
66
|
+
const replacement = isTrue ? name : `${name}="false"`;
|
|
67
|
+
|
|
68
|
+
context.report({
|
|
69
|
+
loc: parserServices.convertNodeSourceSpanToLoc(sourceSpan),
|
|
70
|
+
messageId: 'preferStaticBooleanProperty',
|
|
71
|
+
data: { name },
|
|
72
|
+
suggest: [
|
|
73
|
+
{
|
|
74
|
+
messageId: isTrue ? 'useBareAttribute' : 'useStaticAttribute',
|
|
75
|
+
data: { name },
|
|
76
|
+
fix: (fixer) => fixer.replaceTextRange([sourceSpan.start.offset, sourceSpan.end.offset], replacement),
|
|
77
|
+
},
|
|
78
|
+
],
|
|
79
|
+
});
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
module.exports = preferStaticBooleanProperties;
|