diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 07b92f460..8e6309650 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -36,7 +36,7 @@ jobs:
echo "NX_CI_EXECUTION_ENV=Node $(node --version) -" >> $GITHUB_ENV
- name: Start Nx Cloud CI Run
- run: npx nx-cloud start-ci-run --distributes-on="6 custom-linux-medium-plus-js"
+ run: npx nx-cloud start-ci-run --distributes-on="6 custom-linux-medium-plus-js" --stop-agents-after="e2e-suite"
- uses: actions/cache@v4
id: yarn-cache # use this to check for `cache-hit` (`steps.yarn-cache.outputs.cache-hit != 'true'`)
@@ -57,12 +57,14 @@ jobs:
with:
# Note that the typecheck target *also* typechecks tests and tools,
# whereas the build only checks src files
- cmd1: yarn nx run-many -t build,typecheck,check-rule-docs,lint
- cmd2: yarn nx run-many -t test --codeCoverage
- cmd3: yarn nx-cloud record -- yarn lint
- cmd4: yarn nx-cloud record -- yarn format-check
- # Run distributed e2e test suites with independent local registries (max 1 per agent via parallel=1)
- cmd5: yarn nx run-many -t e2e-suite --parallel 1
+ cmd1: npx nx run-many -t build,typecheck,check-rule-docs,lint
+ cmd2: npx nx run-many -t test --codeCoverage
+ cmd3: npx nx-cloud record -- yarn lint
+ cmd4: npx nx-cloud record -- yarn format-check
+
+ # Run distributed e2e test suites with independent local registries (max 1 per agent via parallel=1)
+ - name: Run e2e test suites
+ run: npx nx run-many -t e2e-suite --parallel 1
- name: Publish code coverage report
uses: codecov/codecov-action@v4
diff --git a/packages/eslint-plugin/README.md b/packages/eslint-plugin/README.md
index 9063affc6..16a88d0f8 100644
--- a/packages/eslint-plugin/README.md
+++ b/packages/eslint-plugin/README.md
@@ -43,6 +43,7 @@
| [`component-class-suffix`](https://github.com/angular-eslint/angular-eslint/blob/main/packages/eslint-plugin/docs/rules/component-class-suffix.md) | Classes decorated with @Component must have suffix "Component" (or custom) in their name. See more at https://angular.io/styleguide#style-02-03 | :white_check_mark: | | |
| [`component-max-inline-declarations`](https://github.com/angular-eslint/angular-eslint/blob/main/packages/eslint-plugin/docs/rules/component-max-inline-declarations.md) | Enforces a maximum number of lines in inline template, styles and animations. See more at https://angular.io/guide/styleguide#style-05-04 | | | |
| [`component-selector`](https://github.com/angular-eslint/angular-eslint/blob/main/packages/eslint-plugin/docs/rules/component-selector.md) | Component selectors should follow given naming rules. See more at https://angular.io/guide/styleguide#style-02-07, https://angular.io/guide/styleguide#style-05-02 and https://angular.io/guide/styleguide#style-05-03. | | | |
+| [`consistent-component-styles`](https://github.com/angular-eslint/angular-eslint/blob/main/packages/eslint-plugin/docs/rules/consistent-component-styles.md) | Ensures component `styles`/`styleUrl` with `string` is used over `styles`/`styleUrls` when there is only a single string in the array | | :wrench: | |
| [`contextual-decorator`](https://github.com/angular-eslint/angular-eslint/blob/main/packages/eslint-plugin/docs/rules/contextual-decorator.md) | Ensures that classes use contextual decorators in its body | | | |
| [`directive-class-suffix`](https://github.com/angular-eslint/angular-eslint/blob/main/packages/eslint-plugin/docs/rules/directive-class-suffix.md) | Classes decorated with @Directive must have suffix "Directive" (or custom) in their name. See more at https://angular.io/styleguide#style-02-03 | :white_check_mark: | | |
| [`directive-selector`](https://github.com/angular-eslint/angular-eslint/blob/main/packages/eslint-plugin/docs/rules/directive-selector.md) | Directive selectors should follow given naming rules. See more at https://angular.io/guide/styleguide#style-02-06 and https://angular.io/guide/styleguide#style-02-08. | | | |
diff --git a/packages/eslint-plugin/docs/rules/consistent-component-styles.md b/packages/eslint-plugin/docs/rules/consistent-component-styles.md
new file mode 100644
index 000000000..42aa88b68
--- /dev/null
+++ b/packages/eslint-plugin/docs/rules/consistent-component-styles.md
@@ -0,0 +1,1361 @@
+
+
+
+
+# `@angular-eslint/consistent-component-styles`
+
+Ensures consistent usage of `styles`/`styleUrls`/`styleUrl` within Component metadata
+
+- Type: suggestion
+- 🔧 Supports autofix (`--fix`)
+
+
+
+## Rule Options
+
+The rule accepts an options object with the following properties:
+
+```ts
+type Options = "array" | "string";
+
+```
+
+
+
+## Usage Examples
+
+> The following examples are generated automatically from the actual unit tests within the plugin, so you can be assured that their behavior is accurate based on the current commit.
+
+
+
+
+❌ - Toggle examples of incorrect code for this rule
+
+
+
+#### Default Config
+
+```json
+{
+ "rules": {
+ "@angular-eslint/consistent-component-styles": [
+ "error"
+ ]
+ }
+}
+```
+
+
+
+#### ❌ Invalid Code
+
+```ts
+@Component({
+ styles: [':host { display: block; }']
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+})
+class Test {}
+```
+
+
+
+---
+
+
+
+#### Default Config
+
+```json
+{
+ "rules": {
+ "@angular-eslint/consistent-component-styles": [
+ "error"
+ ]
+ }
+}
+```
+
+
+
+#### ❌ Invalid Code
+
+```ts
+@Component({
+ standalone: true,
+ imports: [MatButtonModule],
+ styles: [':host { display: block; }'],
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+ providers: []
+})
+class Test {}
+```
+
+
+
+---
+
+
+
+#### Default Config
+
+```json
+{
+ "rules": {
+ "@angular-eslint/consistent-component-styles": [
+ "error"
+ ]
+ }
+}
+```
+
+
+
+#### ❌ Invalid Code
+
+```ts
+@Component({
+ styleUrls: ['./test.component.css']
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+})
+class Test {}
+```
+
+
+
+---
+
+
+
+#### Default Config
+
+```json
+{
+ "rules": {
+ "@angular-eslint/consistent-component-styles": [
+ "error"
+ ]
+ }
+}
+```
+
+
+
+#### ❌ Invalid Code
+
+```ts
+@Component({
+ standalone: true,
+ imports: [MatButtonModule],
+ styleUrls: ['./test.component.css'],
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+ providers: []
+})
+class Test {}
+```
+
+
+
+---
+
+
+
+#### Default Config
+
+```json
+{
+ "rules": {
+ "@angular-eslint/consistent-component-styles": [
+ "error"
+ ]
+ }
+}
+```
+
+
+
+#### ❌ Invalid Code
+
+```ts
+const type = 'block';
+@Component({
+ styles: [`:host\t{ display: ${type}; }`]
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+})
+class Test {}
+```
+
+
+
+---
+
+
+
+#### Default Config
+
+```json
+{
+ "rules": {
+ "@angular-eslint/consistent-component-styles": [
+ "error"
+ ]
+ }
+}
+```
+
+
+
+#### ❌ Invalid Code
+
+```ts
+@Component({
+ styles: [':host\t{ display: block; }']
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+})
+class Test {}
+```
+
+
+
+---
+
+
+
+#### Default Config
+
+```json
+{
+ "rules": {
+ "@angular-eslint/consistent-component-styles": [
+ "error"
+ ]
+ }
+}
+```
+
+
+
+#### ❌ Invalid Code
+
+```ts
+@Component({
+ styles: [':host{ display: block; }']
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+})
+class Test {}
+```
+
+
+
+---
+
+
+
+#### Default Config
+
+```json
+{
+ "rules": {
+ "@angular-eslint/consistent-component-styles": [
+ "error"
+ ]
+ }
+}
+```
+
+
+
+#### ❌ Invalid Code
+
+```ts
+@Component({
+ styles: [":host{ display: block; }"]
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+})
+class Test {}
+```
+
+
+
+---
+
+
+
+#### Default Config
+
+```json
+{
+ "rules": {
+ "@angular-eslint/consistent-component-styles": [
+ "error"
+ ]
+ }
+}
+```
+
+
+
+#### ❌ Invalid Code
+
+```ts
+@Component({
+ styles: [`:host{ display: block; }`]
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+})
+class Test {}
+```
+
+
+
+---
+
+
+
+#### Default Config
+
+```json
+{
+ "rules": {
+ "@angular-eslint/consistent-component-styles": [
+ "error"
+ ]
+ }
+}
+```
+
+
+
+#### ❌ Invalid Code
+
+```ts
+@Component({
+ styleUrls: [`./test.component.css`]
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+})
+class Test {}
+```
+
+
+
+---
+
+
+
+#### Custom Config
+
+```json
+{
+ "rules": {
+ "@angular-eslint/consistent-component-styles": [
+ "error",
+ "string"
+ ]
+ }
+}
+```
+
+
+
+#### ❌ Invalid Code
+
+```ts
+@Component({
+ styles: [':host { display: block; }']
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+})
+class Test {}
+```
+
+
+
+---
+
+
+
+#### Custom Config
+
+```json
+{
+ "rules": {
+ "@angular-eslint/consistent-component-styles": [
+ "error",
+ "string"
+ ]
+ }
+}
+```
+
+
+
+#### ❌ Invalid Code
+
+```ts
+@Component({
+ styleUrls: ['./test.component.css']
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+})
+class Test {}
+```
+
+
+
+---
+
+
+
+#### Custom Config
+
+```json
+{
+ "rules": {
+ "@angular-eslint/consistent-component-styles": [
+ "error",
+ "array"
+ ]
+ }
+}
+```
+
+
+
+#### ❌ Invalid Code
+
+```ts
+@Component({
+ styles: ':host { display: block; }'
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~
+})
+class Test {}
+```
+
+
+
+---
+
+
+
+#### Custom Config
+
+```json
+{
+ "rules": {
+ "@angular-eslint/consistent-component-styles": [
+ "error",
+ "array"
+ ]
+ }
+}
+```
+
+
+
+#### ❌ Invalid Code
+
+```ts
+@Component({
+ standalone: true,
+ imports: [MatButtonModule],
+ styles: ':host { display: block; }',
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~
+ providers: []
+})
+class Test {}
+```
+
+
+
+---
+
+
+
+#### Custom Config
+
+```json
+{
+ "rules": {
+ "@angular-eslint/consistent-component-styles": [
+ "error",
+ "array"
+ ]
+ }
+}
+```
+
+
+
+#### ❌ Invalid Code
+
+```ts
+@Component({
+ styleUrl: './test.component.css',
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+})
+class Test {}
+```
+
+
+
+---
+
+
+
+#### Custom Config
+
+```json
+{
+ "rules": {
+ "@angular-eslint/consistent-component-styles": [
+ "error",
+ "array"
+ ]
+ }
+}
+```
+
+
+
+#### ❌ Invalid Code
+
+```ts
+const type = 'block';
+@Component({
+ styles: `:host\t{ display: ${type}; }`
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+})
+class Test {}
+```
+
+
+
+---
+
+
+
+#### Custom Config
+
+```json
+{
+ "rules": {
+ "@angular-eslint/consistent-component-styles": [
+ "error",
+ "array"
+ ]
+ }
+}
+```
+
+
+
+#### ❌ Invalid Code
+
+```ts
+@Component({
+ styles: ':host\t{ display: block; }'
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+})
+class Test {}
+```
+
+
+
+---
+
+
+
+#### Custom Config
+
+```json
+{
+ "rules": {
+ "@angular-eslint/consistent-component-styles": [
+ "error",
+ "array"
+ ]
+ }
+}
+```
+
+
+
+#### ❌ Invalid Code
+
+```ts
+@Component({
+ styles: ':host{ display: block; }'
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~
+})
+class Test {}
+```
+
+
+
+---
+
+
+
+#### Custom Config
+
+```json
+{
+ "rules": {
+ "@angular-eslint/consistent-component-styles": [
+ "error",
+ "array"
+ ]
+ }
+}
+```
+
+
+
+#### ❌ Invalid Code
+
+```ts
+@Component({
+ styles: ":host{ display: block; }"
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~
+})
+class Test {}
+```
+
+
+
+---
+
+
+
+#### Custom Config
+
+```json
+{
+ "rules": {
+ "@angular-eslint/consistent-component-styles": [
+ "error",
+ "array"
+ ]
+ }
+}
+```
+
+
+
+#### ❌ Invalid Code
+
+```ts
+@Component({
+ styles: `:host{ display: block; }`
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~
+})
+class Test {}
+```
+
+
+
+
+
+---
+
+
+
+
+✅ - Toggle examples of correct code for this rule
+
+
+
+#### Default Config
+
+```json
+{
+ "rules": {
+ "@angular-eslint/consistent-component-styles": [
+ "error"
+ ]
+ }
+}
+```
+
+
+
+#### ✅ Valid Code
+
+```ts
+@Component({
+ styles: ':host { display: block; }',
+})
+class Test {}
+```
+
+
+
+---
+
+
+
+#### Default Config
+
+```json
+{
+ "rules": {
+ "@angular-eslint/consistent-component-styles": [
+ "error"
+ ]
+ }
+}
+```
+
+
+
+#### ✅ Valid Code
+
+```ts
+@Component({
+ styles: `
+ :host { display: block; }
+ `,
+})
+class Test {}
+```
+
+
+
+---
+
+
+
+#### Default Config
+
+```json
+{
+ "rules": {
+ "@angular-eslint/consistent-component-styles": [
+ "error"
+ ]
+ }
+}
+```
+
+
+
+#### ✅ Valid Code
+
+```ts
+@Component({
+ selector: 'my-test',
+ standalone: true,
+ imports: [CommonModule],
+ styles: `
+ :host { display: block; }
+ `,
+ providers: [FooService]
+})
+class Test {}
+```
+
+
+
+---
+
+
+
+#### Default Config
+
+```json
+{
+ "rules": {
+ "@angular-eslint/consistent-component-styles": [
+ "error"
+ ]
+ }
+}
+```
+
+
+
+#### ✅ Valid Code
+
+```ts
+@Component({
+ styles: [
+ ':host { display: block; }',
+ `.foo { color: red; }`
+ ],
+})
+class Test {}
+```
+
+
+
+---
+
+
+
+#### Default Config
+
+```json
+{
+ "rules": {
+ "@angular-eslint/consistent-component-styles": [
+ "error"
+ ]
+ }
+}
+```
+
+
+
+#### ✅ Valid Code
+
+```ts
+@Component({
+ styleUrl: `./test.component.css`,
+})
+class Test {}
+```
+
+
+
+---
+
+
+
+#### Default Config
+
+```json
+{
+ "rules": {
+ "@angular-eslint/consistent-component-styles": [
+ "error"
+ ]
+ }
+}
+```
+
+
+
+#### ✅ Valid Code
+
+```ts
+@Component({
+ styleUrls: [
+ '../shared.css',
+ `./test.component.css`
+ ],
+})
+class Test {}
+```
+
+
+
+---
+
+
+
+#### Default Config
+
+```json
+{
+ "rules": {
+ "@angular-eslint/consistent-component-styles": [
+ "error"
+ ]
+ }
+}
+```
+
+
+
+#### ✅ Valid Code
+
+```ts
+@Component({
+ selector: 'my-test',
+ standalone: true,
+ imports: [CommonModule],
+ styleUrls: [
+ '../shared.css',
+ `./test.component.css`
+ ],
+ providers: [FooService]
+})
+class Test {}
+```
+
+
+
+---
+
+
+
+#### Custom Config
+
+```json
+{
+ "rules": {
+ "@angular-eslint/consistent-component-styles": [
+ "error",
+ "string"
+ ]
+ }
+}
+```
+
+
+
+#### ✅ Valid Code
+
+```ts
+@Component({
+ styles: ':host { display: block; }',
+})
+class Test {}
+```
+
+
+
+---
+
+
+
+#### Custom Config
+
+```json
+{
+ "rules": {
+ "@angular-eslint/consistent-component-styles": [
+ "error",
+ "string"
+ ]
+ }
+}
+```
+
+
+
+#### ✅ Valid Code
+
+```ts
+@Component({
+ styles: \`
+ :host { display: block; }
+ \`,
+})
+class Test {}
+```
+
+
+
+---
+
+
+
+#### Custom Config
+
+```json
+{
+ "rules": {
+ "@angular-eslint/consistent-component-styles": [
+ "error",
+ "string"
+ ]
+ }
+}
+```
+
+
+
+#### ✅ Valid Code
+
+```ts
+@Component({
+ selector: 'my-test',
+ standalone: true,
+ imports: [CommonModule],
+ styles: \`
+ :host { display: block; }
+ \`,
+ providers: [FooService]
+})
+class Test {}
+```
+
+
+
+---
+
+
+
+#### Custom Config
+
+```json
+{
+ "rules": {
+ "@angular-eslint/consistent-component-styles": [
+ "error",
+ "string"
+ ]
+ }
+}
+```
+
+
+
+#### ✅ Valid Code
+
+```ts
+@Component({
+ styles: [
+ ':host { display: block; }',
+ \`.foo { color: red; }\`
+ ],
+})
+class Test {}
+```
+
+
+
+---
+
+
+
+#### Custom Config
+
+```json
+{
+ "rules": {
+ "@angular-eslint/consistent-component-styles": [
+ "error",
+ "string"
+ ]
+ }
+}
+```
+
+
+
+#### ✅ Valid Code
+
+```ts
+@Component({
+ styleUrl: \`./test.component.css\`,
+})
+class Test {}
+```
+
+
+
+---
+
+
+
+#### Custom Config
+
+```json
+{
+ "rules": {
+ "@angular-eslint/consistent-component-styles": [
+ "error",
+ "string"
+ ]
+ }
+}
+```
+
+
+
+#### ✅ Valid Code
+
+```ts
+@Component({
+ styleUrls: [
+ '../shared.css',
+ \`./test.component.css\`
+ ],
+})
+class Test {}
+```
+
+
+
+---
+
+
+
+#### Custom Config
+
+```json
+{
+ "rules": {
+ "@angular-eslint/consistent-component-styles": [
+ "error",
+ "string"
+ ]
+ }
+}
+```
+
+
+
+#### ✅ Valid Code
+
+```ts
+@Component({
+ selector: 'my-test',
+ standalone: true,
+ imports: [CommonModule],
+ styleUrls: [
+ '../shared.css',
+ \`./test.component.css\`
+ ],
+ providers: [FooService]
+})
+class Test {}
+```
+
+
+
+---
+
+
+
+#### Custom Config
+
+```json
+{
+ "rules": {
+ "@angular-eslint/consistent-component-styles": [
+ "error",
+ "array"
+ ]
+ }
+}
+```
+
+
+
+#### ✅ Valid Code
+
+```ts
+@Component({
+ styles: [':host { display: block; }'],
+})
+class Test {}
+```
+
+
+
+---
+
+
+
+#### Custom Config
+
+```json
+{
+ "rules": {
+ "@angular-eslint/consistent-component-styles": [
+ "error",
+ "array"
+ ]
+ }
+}
+```
+
+
+
+#### ✅ Valid Code
+
+```ts
+@Component({
+ styles: [
+ \`
+ :host { display: block; }
+ \`
+ ],
+})
+class Test {}
+```
+
+
+
+---
+
+
+
+#### Custom Config
+
+```json
+{
+ "rules": {
+ "@angular-eslint/consistent-component-styles": [
+ "error",
+ "array"
+ ]
+ }
+}
+```
+
+
+
+#### ✅ Valid Code
+
+```ts
+@Component({
+ selector: 'my-test',
+ standalone: true,
+ imports: [CommonModule],
+ styles: [
+ \`
+ :host { display: block; }
+ \`
+ ],
+ providers: [FooService]
+})
+class Test {}
+```
+
+
+
+---
+
+
+
+#### Custom Config
+
+```json
+{
+ "rules": {
+ "@angular-eslint/consistent-component-styles": [
+ "error",
+ "array"
+ ]
+ }
+}
+```
+
+
+
+#### ✅ Valid Code
+
+```ts
+@Component({
+ styles: [
+ ':host { display: block; }',
+ \`.foo { color: red; }\`
+ ],
+})
+class Test {}
+```
+
+
+
+---
+
+
+
+#### Custom Config
+
+```json
+{
+ "rules": {
+ "@angular-eslint/consistent-component-styles": [
+ "error",
+ "array"
+ ]
+ }
+}
+```
+
+
+
+#### ✅ Valid Code
+
+```ts
+@Component({
+ styleUrls: [\`./test.component.css\`],
+})
+class Test {}
+```
+
+
+
+---
+
+
+
+#### Custom Config
+
+```json
+{
+ "rules": {
+ "@angular-eslint/consistent-component-styles": [
+ "error",
+ "array"
+ ]
+ }
+}
+```
+
+
+
+#### ✅ Valid Code
+
+```ts
+@Component({
+ styleUrls: [
+ '../shared.css',
+ \`./test.component.css\`
+ ],
+})
+class Test {}
+```
+
+
+
+---
+
+
+
+#### Custom Config
+
+```json
+{
+ "rules": {
+ "@angular-eslint/consistent-component-styles": [
+ "error",
+ "array"
+ ]
+ }
+}
+```
+
+
+
+#### ✅ Valid Code
+
+```ts
+@Component({
+ selector: 'my-test',
+ standalone: true,
+ imports: [CommonModule],
+ styleUrls: [
+ '../shared.css',
+ \`./test.component.css\`
+ ],
+ providers: [FooService]
+})
+class Test {}
+```
+
+
+
+
diff --git a/packages/eslint-plugin/src/configs/all.json b/packages/eslint-plugin/src/configs/all.json
index 8c043b2c5..5f0fdc680 100644
--- a/packages/eslint-plugin/src/configs/all.json
+++ b/packages/eslint-plugin/src/configs/all.json
@@ -5,6 +5,7 @@
"@angular-eslint/component-class-suffix": "error",
"@angular-eslint/component-max-inline-declarations": "error",
"@angular-eslint/component-selector": "error",
+ "@angular-eslint/consistent-component-styles": "error",
"@angular-eslint/contextual-decorator": "error",
"@angular-eslint/contextual-lifecycle": "error",
"@angular-eslint/directive-class-suffix": "error",
diff --git a/packages/eslint-plugin/src/index.ts b/packages/eslint-plugin/src/index.ts
index f64911150..ce72f8e0f 100644
--- a/packages/eslint-plugin/src/index.ts
+++ b/packages/eslint-plugin/src/index.ts
@@ -10,6 +10,9 @@ import componentMaxInlineDeclarations, {
import componentSelector, {
RULE_NAME as componentSelectorRuleName,
} from './rules/component-selector';
+import consistentComponentStyles, {
+ RULE_NAME as consistentComponentStylesRuleName,
+} from './rules/consistent-component-styles';
import contextualDecorator, {
RULE_NAME as contextualDecoratorRuleName,
} from './rules/contextual-decorator';
@@ -119,6 +122,7 @@ export = {
[componentClassSuffixRuleName]: componentClassSuffix,
[componentMaxInlineDeclarationsRuleName]: componentMaxInlineDeclarations,
[componentSelectorRuleName]: componentSelector,
+ [consistentComponentStylesRuleName]: consistentComponentStyles,
[contextualDecoratorRuleName]: contextualDecorator,
[contextualLifecycleRuleName]: contextualLifecycle,
[directiveClassSuffixRuleName]: directiveClassSuffix,
diff --git a/packages/eslint-plugin/src/rules/consistent-component-styles.ts b/packages/eslint-plugin/src/rules/consistent-component-styles.ts
new file mode 100644
index 000000000..a21a14d0b
--- /dev/null
+++ b/packages/eslint-plugin/src/rules/consistent-component-styles.ts
@@ -0,0 +1,134 @@
+import { ASTUtils, Selectors } from '@angular-eslint/utils';
+import type { TSESTree } from '@typescript-eslint/utils';
+import { createESLintRule } from '../utils/create-eslint-rule';
+
+type Mode = 'array' | 'string';
+type Options = [mode: Mode];
+export type MessageIds =
+ | 'useStylesArray'
+ | 'useStylesString'
+ | 'useStyleUrl'
+ | 'useStyleUrls';
+export const RULE_NAME = 'consistent-component-styles';
+
+export default createESLintRule({
+ name: RULE_NAME,
+ meta: {
+ type: 'suggestion',
+ docs: {
+ description:
+ 'Ensures consistent usage of `styles`/`styleUrls`/`styleUrl` within Component metadata',
+ },
+ fixable: 'code',
+ schema: [
+ {
+ type: 'string',
+ enum: ['array', 'string'],
+ },
+ ],
+ messages: {
+ useStyleUrl:
+ 'Use `styleUrl` instead of `styleUrls` for a single stylesheet',
+ useStyleUrls: 'Use `styleUrls` instead of `styleUrl`',
+ useStylesArray:
+ 'Use a `string[]` instead of a `string` for the `styles` property',
+ useStylesString:
+ 'Use a `string` instead of a `string[]` for the `styles` property',
+ },
+ },
+ defaultOptions: ['string'],
+ create(context, [mode]) {
+ const { COMPONENT_CLASS_DECORATOR, metadataProperty } = Selectors;
+ const LITERAL_OR_TEMPLATE_LITERAL = ':matches(Literal, TemplateLiteral)';
+
+ if (mode === 'array') {
+ const stylesStringExpression = `${COMPONENT_CLASS_DECORATOR} ${metadataProperty(
+ 'styles',
+ )} > ${LITERAL_OR_TEMPLATE_LITERAL}`;
+ const styleUrlProperty = `${COMPONENT_CLASS_DECORATOR} ${metadataProperty(
+ 'styleUrl',
+ )}:has(:matches(Literal, TemplateElement))`;
+
+ return {
+ [stylesStringExpression](
+ node: TSESTree.Literal | TSESTree.TemplateLiteral,
+ ) {
+ context.report({
+ node,
+ messageId: 'useStylesArray',
+ fix: (fixer) => {
+ return fixer.replaceText(
+ node,
+ ASTUtils.isStringLiteral(node)
+ ? `[${node.raw}]`
+ : `[${context.getSourceCode().getText(node)}]`,
+ );
+ },
+ });
+ },
+
+ [styleUrlProperty](node: TSESTree.Property) {
+ context.report({
+ node,
+ messageId: 'useStyleUrls',
+ fix: (fixer) => {
+ return fixer.replaceText(
+ node,
+ ASTUtils.isStringLiteral(node.value)
+ ? `styleUrls: [${node.value.raw}]`
+ : `styleUrls: [${context
+ .getSourceCode()
+ .getText(node.value)}]`,
+ );
+ },
+ });
+ },
+ };
+ } else {
+ const singleArrayStringLiteral = `ArrayExpression:matches([elements.length=1]:has(${LITERAL_OR_TEMPLATE_LITERAL}))`;
+ const singleStylesArrayExpression = `${COMPONENT_CLASS_DECORATOR} ${metadataProperty(
+ 'styles',
+ )} > ${singleArrayStringLiteral}`;
+ const singleStyleUrlsProperty = `${COMPONENT_CLASS_DECORATOR} ${metadataProperty(
+ 'styleUrls',
+ )}:has(${singleArrayStringLiteral})`;
+
+ return {
+ [singleStylesArrayExpression](node: TSESTree.ArrayExpression) {
+ // The selector ensures the element is not null.
+ const el = node.elements[0]!;
+
+ context.report({
+ node,
+ messageId: 'useStylesString',
+ fix: (fixer) => {
+ return fixer.replaceText(
+ node,
+ ASTUtils.isStringLiteral(el)
+ ? el.raw
+ : context.getSourceCode().getText(el),
+ );
+ },
+ });
+ },
+ [singleStyleUrlsProperty](node: TSESTree.Property) {
+ // The selector ensures the value is an array with a single non-null element.
+ const el = (node.value as TSESTree.ArrayExpression).elements[0]!;
+
+ context.report({
+ node,
+ messageId: 'useStyleUrl',
+ fix: (fixer) => {
+ return fixer.replaceText(
+ node,
+ ASTUtils.isStringLiteral(el)
+ ? `styleUrl: ${el.raw}`
+ : `styleUrl: ${context.getSourceCode().getText(el)}`,
+ );
+ },
+ });
+ },
+ };
+ }
+ },
+});
diff --git a/packages/eslint-plugin/tests/rules/consistent-component-styles/cases.ts b/packages/eslint-plugin/tests/rules/consistent-component-styles/cases.ts
new file mode 100644
index 000000000..0dc9912ec
--- /dev/null
+++ b/packages/eslint-plugin/tests/rules/consistent-component-styles/cases.ts
@@ -0,0 +1,646 @@
+import { convertAnnotatedSourceToFailureCase } from '@angular-eslint/utils';
+import type { MessageIds } from '../../../src/rules/consistent-component-styles';
+
+const messageIdUseStylesArray: MessageIds = 'useStylesArray';
+const messageIdUseStyleUrls: MessageIds = 'useStyleUrls';
+const messageIdUseStylesString: MessageIds = 'useStylesString';
+const messageIdUseStyleUrl: MessageIds = 'useStyleUrl';
+
+export const valid = [
+ // Default.
+ `
+ @Component({
+ styles: ':host { display: block; }',
+ })
+ class Test {}
+ `,
+ `
+ @Component({
+ styles: \`
+ :host { display: block; }
+ \`,
+ })
+ class Test {}
+ `,
+ `
+ @Component({
+ selector: 'my-test',
+ standalone: true,
+ imports: [CommonModule],
+ styles: \`
+ :host { display: block; }
+ \`,
+ providers: [FooService]
+ })
+ class Test {}
+ `,
+ `
+ @Component({
+ styles: [
+ ':host { display: block; }',
+ \`.foo { color: red; }\`
+ ],
+ })
+ class Test {}
+ `,
+ `
+ @Component({
+ styleUrl: \`./test.component.css\`,
+ })
+ class Test {}
+ `,
+ `
+ @Component({
+ styleUrls: [
+ '../shared.css',
+ \`./test.component.css\`
+ ],
+ })
+ class Test {}
+ `,
+ `
+ @Component({
+ selector: 'my-test',
+ standalone: true,
+ imports: [CommonModule],
+ styleUrls: [
+ '../shared.css',
+ \`./test.component.css\`
+ ],
+ providers: [FooService]
+ })
+ class Test {}
+ `,
+ // String.
+ {
+ code: `
+ @Component({
+ styles: ':host { display: block; }',
+ })
+ class Test {}
+ `,
+ options: ['string'],
+ },
+ {
+ code: `
+ @Component({
+ styles: \`
+ :host { display: block; }
+ \`,
+ })
+ class Test {}
+ `,
+ options: ['string'],
+ },
+ {
+ code: `
+ @Component({
+ selector: 'my-test',
+ standalone: true,
+ imports: [CommonModule],
+ styles: \`
+ :host { display: block; }
+ \`,
+ providers: [FooService]
+ })
+ class Test {}
+ `,
+ options: ['string'],
+ },
+ {
+ code: `
+ @Component({
+ styles: [
+ ':host { display: block; }',
+ \`.foo { color: red; }\`
+ ],
+ })
+ class Test {}
+ `,
+ options: ['string'],
+ },
+ {
+ code: `
+ @Component({
+ styleUrl: \`./test.component.css\`,
+ })
+ class Test {}
+ `,
+ options: ['string'],
+ },
+ {
+ code: `
+ @Component({
+ styleUrls: [
+ '../shared.css',
+ \`./test.component.css\`
+ ],
+ })
+ class Test {}
+ `,
+ options: ['string'],
+ },
+ {
+ code: `
+ @Component({
+ selector: 'my-test',
+ standalone: true,
+ imports: [CommonModule],
+ styleUrls: [
+ '../shared.css',
+ \`./test.component.css\`
+ ],
+ providers: [FooService]
+ })
+ class Test {}
+ `,
+ options: ['string'],
+ },
+ // Array.
+ {
+ code: `
+ @Component({
+ styles: [':host { display: block; }'],
+ })
+ class Test {}
+ `,
+ options: ['array'],
+ },
+ {
+ code: `
+ @Component({
+ styles: [
+ \`
+ :host { display: block; }
+ \`
+ ],
+ })
+ class Test {}
+ `,
+ options: ['array'],
+ },
+ {
+ code: `
+ @Component({
+ selector: 'my-test',
+ standalone: true,
+ imports: [CommonModule],
+ styles: [
+ \`
+ :host { display: block; }
+ \`
+ ],
+ providers: [FooService]
+ })
+ class Test {}
+ `,
+ options: ['array'],
+ },
+ {
+ code: `
+ @Component({
+ styles: [
+ ':host { display: block; }',
+ \`.foo { color: red; }\`
+ ],
+ })
+ class Test {}
+ `,
+ options: ['array'],
+ },
+ {
+ code: `
+ @Component({
+ styleUrls: [\`./test.component.css\`],
+ })
+ class Test {}
+ `,
+ options: ['array'],
+ },
+ {
+ code: `
+ @Component({
+ styleUrls: [
+ '../shared.css',
+ \`./test.component.css\`
+ ],
+ })
+ class Test {}
+ `,
+ options: ['array'],
+ },
+ {
+ code: `
+ @Component({
+ selector: 'my-test',
+ standalone: true,
+ imports: [CommonModule],
+ styleUrls: [
+ '../shared.css',
+ \`./test.component.css\`
+ ],
+ providers: [FooService]
+ })
+ class Test {}
+ `,
+ options: ['array'],
+ },
+];
+
+export const invalid = [
+ // Default
+ convertAnnotatedSourceToFailureCase({
+ description: `should fail when a component has a single style array value`,
+ annotatedSource: `
+ @Component({
+ styles: [':host { display: block; }']
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+ })
+ class Test {}
+ `,
+ messageId: messageIdUseStylesString,
+ annotatedOutput: `
+ @Component({
+ styles: ':host { display: block; }'
+
+ })
+ class Test {}
+ `,
+ }),
+ convertAnnotatedSourceToFailureCase({
+ description: `should fail when a component has a single styles array value with multiple decorator properties`,
+ annotatedSource: `
+ @Component({
+ standalone: true,
+ imports: [MatButtonModule],
+ styles: [':host { display: block; }'],
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+ providers: []
+ })
+ class Test {}
+ `,
+ messageId: messageIdUseStylesString,
+ annotatedOutput: `
+ @Component({
+ standalone: true,
+ imports: [MatButtonModule],
+ styles: ':host { display: block; }',
+
+ providers: []
+ })
+ class Test {}
+ `,
+ }),
+ convertAnnotatedSourceToFailureCase({
+ description: `should fail when a component has a single styleUrls array value`,
+ annotatedSource: `
+ @Component({
+ styleUrls: ['./test.component.css']
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+ })
+ class Test {}
+ `,
+ messageId: messageIdUseStyleUrl,
+ annotatedOutput: `
+ @Component({
+ styleUrl: './test.component.css'
+
+ })
+ class Test {}
+ `,
+ }),
+ convertAnnotatedSourceToFailureCase({
+ description: `should fail when a component has a single styleUrls array value with multiple decorator properties`,
+ annotatedSource: `
+ @Component({
+ standalone: true,
+ imports: [MatButtonModule],
+ styleUrls: ['./test.component.css'],
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+ providers: []
+ })
+ class Test {}
+ `,
+ messageId: messageIdUseStyleUrl,
+ annotatedOutput: `
+ @Component({
+ standalone: true,
+ imports: [MatButtonModule],
+ styleUrl: './test.component.css',
+
+ providers: []
+ })
+ class Test {}
+ `,
+ }),
+ convertAnnotatedSourceToFailureCase({
+ description: `should keep template strings when fixing styles array to string`,
+ annotatedSource: `
+ const type = 'block';
+ @Component({
+ styles: [\`:host\\t{ display: \${type}; }\`]
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+ })
+ class Test {}
+ `,
+ messageId: messageIdUseStylesString,
+ annotatedOutput: `
+ const type = 'block';
+ @Component({
+ styles: \`:host\\t{ display: \${type}; }\`
+
+ })
+ class Test {}
+ `,
+ }),
+ convertAnnotatedSourceToFailureCase({
+ description: `should keep escaped characters when fixing styles array to string`,
+ annotatedSource: `
+ @Component({
+ styles: [':host\\t{ display: block; }']
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+ })
+ class Test {}
+ `,
+ messageId: messageIdUseStylesString,
+ annotatedOutput: `
+ @Component({
+ styles: ':host\\t{ display: block; }'
+
+ })
+ class Test {}
+ `,
+ }),
+ convertAnnotatedSourceToFailureCase({
+ description: `should keep single quotes when fixing styles array to string`,
+ annotatedSource: `
+ @Component({
+ styles: [':host{ display: block; }']
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+ })
+ class Test {}
+ `,
+ messageId: messageIdUseStylesString,
+ annotatedOutput: `
+ @Component({
+ styles: ':host{ display: block; }'
+
+ })
+ class Test {}
+ `,
+ }),
+ convertAnnotatedSourceToFailureCase({
+ description: `should keep double quotes when fixing styles array to string`,
+ annotatedSource: `
+ @Component({
+ styles: [":host{ display: block; }"]
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+ })
+ class Test {}
+ `,
+ messageId: messageIdUseStylesString,
+ annotatedOutput: `
+ @Component({
+ styles: ":host{ display: block; }"
+
+ })
+ class Test {}
+ `,
+ }),
+ convertAnnotatedSourceToFailureCase({
+ description: `should keep backtick quotes when fixing styles array to string`,
+ annotatedSource: `
+ @Component({
+ styles: [\`:host{ display: block; }\`]
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+ })
+ class Test {}
+ `,
+ messageId: messageIdUseStylesString,
+ annotatedOutput: `
+ @Component({
+ styles: \`:host{ display: block; }\`
+
+ })
+ class Test {}
+ `,
+ }),
+ convertAnnotatedSourceToFailureCase({
+ description: `should keep backtick quotes when fixing styleUrls array to styleUrl string`,
+ annotatedSource: `
+ @Component({
+ styleUrls: [\`./test.component.css\`]
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+ })
+ class Test {}
+ `,
+ messageId: messageIdUseStyleUrl,
+ annotatedOutput: `
+ @Component({
+ styleUrl: \`./test.component.css\`
+
+ })
+ class Test {}
+ `,
+ }),
+ // String
+ convertAnnotatedSourceToFailureCase({
+ description: `should fail when a component has a single style array value when string is preferred`,
+ annotatedSource: `
+ @Component({
+ styles: [':host { display: block; }']
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+ })
+ class Test {}
+ `,
+ options: ['string'],
+ messageId: messageIdUseStylesString,
+ annotatedOutput: `
+ @Component({
+ styles: ':host { display: block; }'
+
+ })
+ class Test {}
+ `,
+ }),
+ convertAnnotatedSourceToFailureCase({
+ description: `should fail when a component has a single styleUrls array value when string is preferred`,
+ annotatedSource: `
+ @Component({
+ styleUrls: ['./test.component.css']
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+ })
+ class Test {}
+ `,
+ options: ['string'],
+ messageId: messageIdUseStyleUrl,
+ annotatedOutput: `
+ @Component({
+ styleUrl: './test.component.css'
+
+ })
+ class Test {}
+ `,
+ }),
+ // Array
+ convertAnnotatedSourceToFailureCase({
+ description: `should fail when a component has a styles string when an array is preferred`,
+ annotatedSource: `
+ @Component({
+ styles: ':host { display: block; }'
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~
+ })
+ class Test {}
+ `,
+ options: ['array'],
+ messageId: messageIdUseStylesArray,
+ annotatedOutput: `
+ @Component({
+ styles: [':host { display: block; }']
+
+ })
+ class Test {}
+ `,
+ }),
+ convertAnnotatedSourceToFailureCase({
+ description: `should fail when a component has a styles string with multiple decorator properties when an array is preferred`,
+ annotatedSource: `
+ @Component({
+ standalone: true,
+ imports: [MatButtonModule],
+ styles: ':host { display: block; }',
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~
+ providers: []
+ })
+ class Test {}
+ `,
+ options: ['array'],
+ messageId: messageIdUseStylesArray,
+ annotatedOutput: `
+ @Component({
+ standalone: true,
+ imports: [MatButtonModule],
+ styles: [':host { display: block; }'],
+
+ providers: []
+ })
+ class Test {}
+ `,
+ }),
+ convertAnnotatedSourceToFailureCase({
+ description: `should fail when a component has a styleUrl string value when an array is preferred`,
+ annotatedSource: `
+ @Component({
+ styleUrl: './test.component.css',
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+ })
+ class Test {}
+ `,
+ options: ['array'],
+ messageId: messageIdUseStyleUrls,
+ annotatedOutput: `
+ @Component({
+ styleUrls: ['./test.component.css'],
+
+ })
+ class Test {}
+ `,
+ }),
+ convertAnnotatedSourceToFailureCase({
+ description: `should keep template strings when fixing styles string to array`,
+ annotatedSource: `
+ const type = 'block';
+ @Component({
+ styles: \`:host\\t{ display: \${type}; }\`
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+ })
+ class Test {}
+ `,
+ options: ['array'],
+ messageId: messageIdUseStylesArray,
+ annotatedOutput: `
+ const type = 'block';
+ @Component({
+ styles: [\`:host\\t{ display: \${type}; }\`]
+
+ })
+ class Test {}
+ `,
+ }),
+ convertAnnotatedSourceToFailureCase({
+ description: `should keep escaped characters when fixing styles string to array`,
+ annotatedSource: `
+ @Component({
+ styles: ':host\\t{ display: block; }'
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+ })
+ class Test {}
+ `,
+ options: ['array'],
+ messageId: messageIdUseStylesArray,
+ annotatedOutput: `
+ @Component({
+ styles: [':host\\t{ display: block; }']
+
+ })
+ class Test {}
+ `,
+ }),
+ convertAnnotatedSourceToFailureCase({
+ description: `should keep single quotes when fixing styles string to array`,
+ annotatedSource: `
+ @Component({
+ styles: ':host{ display: block; }'
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~
+ })
+ class Test {}
+ `,
+ options: ['array'],
+ messageId: messageIdUseStylesArray,
+ annotatedOutput: `
+ @Component({
+ styles: [':host{ display: block; }']
+
+ })
+ class Test {}
+ `,
+ }),
+ convertAnnotatedSourceToFailureCase({
+ description: `should keep double quotes when fixing styles string to array`,
+ annotatedSource: `
+ @Component({
+ styles: ":host{ display: block; }"
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~
+ })
+ class Test {}
+ `,
+ options: ['array'],
+ messageId: messageIdUseStylesArray,
+ annotatedOutput: `
+ @Component({
+ styles: [":host{ display: block; }"]
+
+ })
+ class Test {}
+ `,
+ }),
+ convertAnnotatedSourceToFailureCase({
+ description: `should keep backtick quotes when fixing styles string to array`,
+ annotatedSource: `
+ @Component({
+ styles: \`:host{ display: block; }\`
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~
+ })
+ class Test {}
+ `,
+ options: ['array'],
+ messageId: messageIdUseStylesArray,
+ annotatedOutput: `
+ @Component({
+ styles: [\`:host{ display: block; }\`]
+
+ })
+ class Test {}
+ `,
+ }),
+];
diff --git a/packages/eslint-plugin/tests/rules/consistent-component-styles/spec.ts b/packages/eslint-plugin/tests/rules/consistent-component-styles/spec.ts
new file mode 100644
index 000000000..02a007fa1
--- /dev/null
+++ b/packages/eslint-plugin/tests/rules/consistent-component-styles/spec.ts
@@ -0,0 +1,14 @@
+import { RuleTester } from '@angular-eslint/utils';
+import rule, {
+ RULE_NAME,
+} from '../../../src/rules/consistent-component-styles';
+import { invalid, valid } from './cases';
+
+const ruleTester = new RuleTester({
+ parser: '@typescript-eslint/parser',
+});
+
+ruleTester.run(RULE_NAME, rule, {
+ valid,
+ invalid,
+});