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, +});