From e9b7c79ac3bf4d483b9ff839e3ef4d91562cf9e1 Mon Sep 17 00:00:00 2001 From: Dmitry Zakharov Date: Wed, 28 Sep 2022 16:53:13 +0300 Subject: [PATCH] feat(eslint-plugin-template): [no-inline-styles] add rule --- .../docs/rules/no-inline-styles.md | 815 ++++++++++++++++++ .../src/configs/all.json | 1 + packages/eslint-plugin-template/src/index.ts | 4 + .../src/rules/no-inline-styles.ts | 131 +++ .../tests/rules/no-inline-styles/cases.ts | 135 +++ .../tests/rules/no-inline-styles/spec.ts | 12 + 6 files changed, 1098 insertions(+) create mode 100644 packages/eslint-plugin-template/docs/rules/no-inline-styles.md create mode 100644 packages/eslint-plugin-template/src/rules/no-inline-styles.ts create mode 100644 packages/eslint-plugin-template/tests/rules/no-inline-styles/cases.ts create mode 100644 packages/eslint-plugin-template/tests/rules/no-inline-styles/spec.ts diff --git a/packages/eslint-plugin-template/docs/rules/no-inline-styles.md b/packages/eslint-plugin-template/docs/rules/no-inline-styles.md new file mode 100644 index 000000000..4420da39b --- /dev/null +++ b/packages/eslint-plugin-template/docs/rules/no-inline-styles.md @@ -0,0 +1,815 @@ + + +
+ +# `@angular-eslint/template/no-inline-styles` + +Disallows the use of inline styles in HTML templates + +- Type: suggestion + +
+ +## Rule Options + +The rule accepts an options object with the following properties: + +```ts +interface Options { + allowNgStyle?: boolean; + allowBindToStyle?: boolean; +} + +``` + +
+ +## 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/template/no-inline-styles": [ + "error" + ] + } +} +``` + +
+ +#### ❌ Invalid Code + +```html + +
+ + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +
+
+``` + +
+ +--- + +
+ +#### Default Config + +```json +{ + "rules": { + "@angular-eslint/template/no-inline-styles": [ + "error" + ] + } +} +``` + +
+ +#### ❌ Invalid Code + +```html + +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +``` + +
+ +--- + +
+ +#### Default Config + +```json +{ + "rules": { + "@angular-eslint/template/no-inline-styles": [ + "error" + ] + } +} +``` + +
+ +#### ❌ Invalid Code + +```html + +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +``` + +
+ +--- + +
+ +#### Default Config + +```json +{ + "rules": { + "@angular-eslint/template/no-inline-styles": [ + "error" + ] + } +} +``` + +
+ +#### ❌ Invalid Code + +```html + +~~~~~~~~~~~~~~~~~~~~~~~~~~~ +``` + +
+ +--- + +
+ +#### Default Config + +```json +{ + "rules": { + "@angular-eslint/template/no-inline-styles": [ + "error" + ] + } +} +``` + +
+ +#### ❌ Invalid Code + +```html + +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +``` + +
+ +--- + +
+ +#### Default Config + +```json +{ + "rules": { + "@angular-eslint/template/no-inline-styles": [ + "error" + ] + } +} +``` + +
+ +#### ❌ Invalid Code + +```html + +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +``` + +
+ +--- + +
+ +#### Custom Config + +```json +{ + "rules": { + "@angular-eslint/template/no-inline-styles": [ + "error", + { + "allowNgStyle": true + } + ] + } +} +``` + +
+ +#### ❌ Invalid Code + +```html + +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +``` + +
+ +--- + +
+ +#### Default Config + +```json +{ + "rules": { + "@angular-eslint/template/no-inline-styles": [ + "error" + ] + } +} +``` + +
+ +#### ❌ Invalid Code + +```html + +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +``` + +
+ +--- + +
+ +#### Custom Config + +```json +{ + "rules": { + "@angular-eslint/template/no-inline-styles": [ + "error", + { + "allowNgStyle": true, + "allowBindToStyle": false + } + ] + } +} +``` + +
+ +#### ❌ Invalid Code + +```html + +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +``` + +
+ +--- + +
+ +#### Custom Config + +```json +{ + "rules": { + "@angular-eslint/template/no-inline-styles": [ + "error", + { + "allowNgStyle": true, + "allowBindToStyle": false + } + ] + } +} +``` + +
+ +#### ❌ Invalid Code + +```html + +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +``` + +
+ +
+ +--- + +
+ +
+✅ - Toggle examples of correct code for this rule + +
+ +#### Default Config + +```json +{ + "rules": { + "@angular-eslint/template/no-inline-styles": [ + "error" + ] + } +} +``` + +
+ +#### ✅ Valid Code + +```html +Foo eating a sandwich. +``` + +
+ +--- + +
+ +#### Default Config + +```json +{ + "rules": { + "@angular-eslint/template/no-inline-styles": [ + "error" + ] + } +} +``` + +
+ +#### ✅ Valid Code + +```html + +``` + +
+ +--- + +
+ +#### Default Config + +```json +{ + "rules": { + "@angular-eslint/template/no-inline-styles": [ + "error" + ] + } +} +``` + +
+ +#### ✅ Valid Code + +```html + +``` + +
+ +--- + +
+ +#### Default Config + +```json +{ + "rules": { + "@angular-eslint/template/no-inline-styles": [ + "error" + ] + } +} +``` + +
+ +#### ✅ Valid Code + +```html + +``` + +
+ +--- + +
+ +#### Default Config + +```json +{ + "rules": { + "@angular-eslint/template/no-inline-styles": [ + "error" + ] + } +} +``` + +
+ +#### ✅ Valid Code + +```html + +``` + +
+ +--- + +
+ +#### Default Config + +```json +{ + "rules": { + "@angular-eslint/template/no-inline-styles": [ + "error" + ] + } +} +``` + +
+ +#### ✅ Valid Code + +```html + +``` + +
+ +--- + +
+ +#### Default Config + +```json +{ + "rules": { + "@angular-eslint/template/no-inline-styles": [ + "error" + ] + } +} +``` + +
+ +#### ✅ Valid Code + +```html +Meaningful description +``` + +
+ +--- + +
+ +#### Default Config + +```json +{ + "rules": { + "@angular-eslint/template/no-inline-styles": [ + "error" + ] + } +} +``` + +
+ +#### ✅ Valid Code + +```html + +``` + +
+ +--- + +
+ +#### Default Config + +```json +{ + "rules": { + "@angular-eslint/template/no-inline-styles": [ + "error" + ] + } +} +``` + +
+ +#### ✅ Valid Code + +```html + +``` + +
+ +--- + +
+ +#### Default Config + +```json +{ + "rules": { + "@angular-eslint/template/no-inline-styles": [ + "error" + ] + } +} +``` + +
+ +#### ✅ Valid Code + +```html + +``` + +
+ +--- + +
+ +#### Default Config + +```json +{ + "rules": { + "@angular-eslint/template/no-inline-styles": [ + "error" + ] + } +} +``` + +
+ +#### ✅ Valid Code + +```html +This is descriptive! +``` + +
+ +--- + +
+ +#### Default Config + +```json +{ + "rules": { + "@angular-eslint/template/no-inline-styles": [ + "error" + ] + } +} +``` + +
+ +#### ✅ Valid Code + +```html + +``` + +
+ +--- + +
+ +#### Default Config + +```json +{ + "rules": { + "@angular-eslint/template/no-inline-styles": [ + "error" + ] + } +} +``` + +
+ +#### ✅ Valid Code + +```html + +``` + +
+ +--- + +
+ +#### Default Config + +```json +{ + "rules": { + "@angular-eslint/template/no-inline-styles": [ + "error" + ] + } +} +``` + +
+ +#### ✅ Valid Code + +```html + +``` + +
+ +--- + +
+ +#### Default Config + +```json +{ + "rules": { + "@angular-eslint/template/no-inline-styles": [ + "error" + ] + } +} +``` + +
+ +#### ✅ Valid Code + +```html + +``` + +
+ +--- + +
+ +#### Custom Config + +```json +{ + "rules": { + "@angular-eslint/template/no-inline-styles": [ + "error", + { + "allowNgStyle": true + } + ] + } +} +``` + +
+ +#### ✅ Valid Code + +```html + +``` + +
+ +--- + +
+ +#### Custom Config + +```json +{ + "rules": { + "@angular-eslint/template/no-inline-styles": [ + "error", + { + "allowBindToStyle": true + } + ] + } +} +``` + +
+ +#### ✅ Valid Code + +```html + +``` + +
+ +--- + +
+ +#### Custom Config + +```json +{ + "rules": { + "@angular-eslint/template/no-inline-styles": [ + "error", + { + "allowNgStyle": true, + "allowBindToStyle": true + } + ] + } +} +``` + +
+ +#### ✅ Valid Code + +```html + +``` + + + +
diff --git a/packages/eslint-plugin-template/src/configs/all.json b/packages/eslint-plugin-template/src/configs/all.json index 83ce809c7..1968d518d 100644 --- a/packages/eslint-plugin-template/src/configs/all.json +++ b/packages/eslint-plugin-template/src/configs/all.json @@ -23,6 +23,7 @@ "@angular-eslint/template/no-call-expression": "error", "@angular-eslint/template/no-distracting-elements": "error", "@angular-eslint/template/no-duplicate-attributes": "error", + "@angular-eslint/template/no-inline-styles": "error", "@angular-eslint/template/no-negated-async": "error", "@angular-eslint/template/no-positive-tabindex": "error", "@angular-eslint/template/use-track-by-function": "error" diff --git a/packages/eslint-plugin-template/src/index.ts b/packages/eslint-plugin-template/src/index.ts index ef923dd1e..e3092e7d4 100644 --- a/packages/eslint-plugin-template/src/index.ts +++ b/packages/eslint-plugin-template/src/index.ts @@ -63,6 +63,9 @@ import noDistractingElements, { import noDuplicateAttributes, { RULE_NAME as noDuplicateAttributesRuleName, } from './rules/no-duplicate-attributes'; +import noInlineStyles, { + RULE_NAME as noInlineStylesRuleName, +} from './rules/no-inline-styles'; import noNegatedAsync, { RULE_NAME as noNegatedAsyncRuleName, } from './rules/no-negated-async'; @@ -107,6 +110,7 @@ export default { [noCallExpressionRuleName]: noCallExpression, [noDistractingElementsRuleName]: noDistractingElements, [noDuplicateAttributesRuleName]: noDuplicateAttributes, + [noInlineStylesRuleName]: noInlineStyles, [noNegatedAsyncRuleName]: noNegatedAsync, [noPositiveTabindexRuleName]: noPositiveTabindex, [useTrackByFunctionRuleName]: useTrackByFunction, diff --git a/packages/eslint-plugin-template/src/rules/no-inline-styles.ts b/packages/eslint-plugin-template/src/rules/no-inline-styles.ts new file mode 100644 index 000000000..e8f535792 --- /dev/null +++ b/packages/eslint-plugin-template/src/rules/no-inline-styles.ts @@ -0,0 +1,131 @@ +import type { + ParseSourceSpan, + TmplAstElement, +} from '@angular-eslint/bundled-angular-compiler'; +import { + createESLintRule, + getTemplateParserServices, +} from '../utils/create-eslint-rule'; + +type Options = [ + { + readonly allowNgStyle?: boolean; + readonly allowBindToStyle?: boolean; + }, +]; +const DEFAULT_OPTIONS: Options[number] = { + allowNgStyle: false, + allowBindToStyle: false, +}; +export type MessageIds = 'noInlineStyles'; +export const RULE_NAME = 'no-inline-styles'; + +export default createESLintRule({ + name: RULE_NAME, + meta: { + type: 'suggestion', + docs: { + description: 'Disallows the use of inline styles in HTML templates', + recommended: false, + }, + schema: [ + { + type: 'object', + properties: { + allowNgStyle: { + type: 'boolean', + default: DEFAULT_OPTIONS.allowNgStyle, + }, + allowBindToStyle: { + type: 'boolean', + default: DEFAULT_OPTIONS.allowBindToStyle, + }, + }, + additionalProperties: false, + }, + ], + messages: { + noInlineStyles: + '<{{element}}/> element should not have inline styles via style attribute. Please use classes instead.', + }, + }, + defaultOptions: [DEFAULT_OPTIONS], + create(context, [{ allowNgStyle, allowBindToStyle }]) { + const parserServices = getTemplateParserServices(context); + + return { + Element$1(node: TmplAstElement) { + let isInvalid = false; + + if (!allowNgStyle && !allowBindToStyle) { + isInvalid = + isNodeHasStyleAttribute(node) || + isNodeHasNgStyleAttribute(node) || + isNodeHasBindingToStyleAttribute(node); + } else { + const ngStyle = allowNgStyle + ? false + : isNodeHasNgStyleAttribute(node); + const bindToStyle = allowBindToStyle + ? false + : isNodeHasBindingToStyleAttribute(node); + + isInvalid = isNodeHasStyleAttribute(node) || ngStyle || bindToStyle; + } + + if (isInvalid) { + const loc = parserServices.convertElementSourceSpanToLoc( + context, + node, + ); + + context.report({ + loc, + messageId: 'noInlineStyles', + data: { + element: node.name, + }, + }); + } + }, + }; + }, +}); + +/** + * Check that the any element for example `` has a `style` attribute or `attr.style` binding. + */ +function isNodeHasStyleAttribute(node: TmplAstElement): boolean { + return ( + node.attributes.some(({ name }) => isStyle(name)) || + node.inputs.some(({ name }) => isStyle(name)) + ); +} +/** + * Check that the any element for example `` has a `ngStyle` attribute binding. + */ +function isNodeHasNgStyleAttribute(node: TmplAstElement): boolean { + return node.inputs.some(({ name }) => isNgStyle(name)); +} + +/** + * Check that the any element for example `` has a `[style.background-color]` attribute binding. + */ +function isNodeHasBindingToStyleAttribute(node: TmplAstElement): boolean { + return node.inputs.some(({ keySpan }) => isStyleBound(keySpan)); +} + +/** + * Check element is style + */ +function isStyle(name: string): name is 'style' { + return name === 'style'; +} + +function isNgStyle(name: string): name is 'ngStyle' { + return name === 'ngStyle'; +} + +function isStyleBound(keySpan: ParseSourceSpan): boolean { + return keySpan?.details ? keySpan.details.includes('style.') : false; +} diff --git a/packages/eslint-plugin-template/tests/rules/no-inline-styles/cases.ts b/packages/eslint-plugin-template/tests/rules/no-inline-styles/cases.ts new file mode 100644 index 000000000..9e865bccf --- /dev/null +++ b/packages/eslint-plugin-template/tests/rules/no-inline-styles/cases.ts @@ -0,0 +1,135 @@ +import { convertAnnotatedSourceToFailureCase } from '@angular-eslint/utils'; +import type { MessageIds } from '../../../src/rules/no-inline-styles'; + +const messageId: MessageIds = 'noInlineStyles'; + +export const valid = [ + 'Foo eating a sandwich.', + '', + ``, + '', + '', + '', + 'Meaningful description', + '', + '', + '', + 'This is descriptive!', + '', + '', + '', + '', + { + code: ``, + options: [{ allowNgStyle: true }], + }, + { + code: ``, + options: [{ allowBindToStyle: true }], + }, + { + code: ``, + options: [{ allowNgStyle: true, allowBindToStyle: true }], + }, +]; + +export const invalid = [ + convertAnnotatedSourceToFailureCase({ + messageId, + description: 'should fail element when style attribute exist', + annotatedSource: ` + +
+ + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +
+
+ `, + data: { element: 'img' }, + }), + convertAnnotatedSourceToFailureCase({ + messageId, + description: 'should fail when object style attribute exist', + annotatedSource: ` + + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + `, + data: { element: 'object' }, + }), + convertAnnotatedSourceToFailureCase({ + messageId, + description: 'should fail when area style attribute exist', + annotatedSource: ` + + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + `, + data: { element: 'area' }, + }), + convertAnnotatedSourceToFailureCase({ + messageId, + description: 'should fail when input element with style attribute exist', + annotatedSource: ` + + ~~~~~~~~~~~~~~~~~~~~~~~~~~~ + `, + data: { element: 'input' }, + }), + convertAnnotatedSourceToFailureCase({ + messageId, + description: 'should fail when input element with style attribute exist', + annotatedSource: ` + + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + `, + data: { element: 'input' }, + }), + convertAnnotatedSourceToFailureCase({ + messageId, + description: 'should fail when input element with binding to style exist', + annotatedSource: ` + + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + `, + data: { element: 'input' }, + }), + convertAnnotatedSourceToFailureCase({ + messageId, + description: + 'should fail when input element with binding to style exist and allowNgStyle set to true', + annotatedSource: ` + + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + `, + data: { element: 'input' }, + options: [{ allowNgStyle: true }], + }), + convertAnnotatedSourceToFailureCase({ + messageId, + description: 'should fail when input element with ngStyle attribute exist', + annotatedSource: ` + + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + `, + data: { element: 'input' }, + }), + convertAnnotatedSourceToFailureCase({ + messageId, + description: 'should fail when input element with ngStyle attribute exist', + annotatedSource: ` + + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + `, + data: { element: 'input' }, + options: [{ allowNgStyle: true, allowBindToStyle: false }], + }), + convertAnnotatedSourceToFailureCase({ + messageId, + description: 'should fail when input element with ngStyle attribute exist', + annotatedSource: ` + + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + `, + data: { element: 'input' }, + options: [{ allowNgStyle: true, allowBindToStyle: false }], + }), +]; diff --git a/packages/eslint-plugin-template/tests/rules/no-inline-styles/spec.ts b/packages/eslint-plugin-template/tests/rules/no-inline-styles/spec.ts new file mode 100644 index 000000000..84745738b --- /dev/null +++ b/packages/eslint-plugin-template/tests/rules/no-inline-styles/spec.ts @@ -0,0 +1,12 @@ +import { RuleTester } from '@angular-eslint/utils'; +import rule, { RULE_NAME } from '../../../src/rules/no-inline-styles'; +import { invalid, valid } from './cases'; + +const ruleTester = new RuleTester({ + parser: '@angular-eslint/template-parser', +}); + +ruleTester.run(RULE_NAME, rule, { + valid, + invalid, +});