diff --git a/docs/rules/attribute-names.md b/docs/rules/attribute-names.md index f65e4c3..bcffeff 100644 --- a/docs/rules/attribute-names.md +++ b/docs/rules/attribute-names.md @@ -4,7 +4,7 @@ Attributes are always treated lowercase, but it is common to have camelCase property names. In these situations, an explicit lowercase attribute should be supplied. -Further, camelCase names should ideally be exposed as snake-case attributes. +Further, camelCase names should ideally be exposed as kebab-case attributes. ## Rule Details @@ -33,6 +33,56 @@ The following patterns are not warnings: @property({attribute: 'camel-case-name'}) camelCaseName: string; +@property({attribute: 'camel-case-other-name'}) +camelCaseName: string; + +@property() +lower: string; +``` + +## Options + +### `convention` + +You can specify a `convention` to enforce a particular naming convention +on element attributes. + +The available values are: + +- `none` (default, no convention is enforced) +- `kebab` +- `snake` + +For example for a property named `camelCaseProp`, expected attribute names are: + +| Convention | Attribute | +|------------|----------------------| +| none | any lower case value | +| kebab | camel-case-prop | +| snake | camel_case_prop | + +The following patterns are considered warnings with `{"convention": "kebab"}` +specified: + +```ts +// Should have an attribute set to `camel-case-name` +@property() camelCaseName: string; + +// Attribute should match the property name when a convention is set +@property({attribute: 'camel-case-other-name'}) +camelCaseName: string; +``` + +The following patterns are not warnings with `{"convention": "kebab"}` +specified: + +```ts +@property({attribute: 'camel-case-name'}) +camelCaseName: string; + +@property({attribute: false}) +camelCaseName: string; + @property() lower: string; ``` diff --git a/src/rules/attribute-names.ts b/src/rules/attribute-names.ts index 70277d1..aab2800 100644 --- a/src/rules/attribute-names.ts +++ b/src/rules/attribute-names.ts @@ -5,7 +5,7 @@ import {Rule} from 'eslint'; import * as ESTree from 'estree'; -import {getPropertyMap, isLitClass} from '../util'; +import {getPropertyMap, isLitClass, toKebabCase, toSnakeCase} from '../util'; //------------------------------------------------------------------------------ // Rule Definition @@ -18,7 +18,15 @@ const rule: Rule.RuleModule = { recommended: true, url: 'https://github.com/43081j/eslint-plugin-lit/blob/master/docs/rules/attribute-names.md' }, - schema: [], + schema: [ + { + type: 'object', + properties: { + convention: {type: 'string', enum: ['none', 'kebab', 'snake']} + }, + additionalProperties: false + } + ], messages: { casedAttribute: 'Attributes are case-insensitive and therefore should be ' + @@ -26,11 +34,16 @@ const rule: Rule.RuleModule = { casedPropertyWithoutAttribute: 'Property has non-lowercase casing but no attribute. It should ' + 'instead have an explicit `attribute` set to the lower case ' + - 'name (usually snake-case)' + 'name (usually snake-case)', + casedAttributeConvention: + 'Attribute should be property name written in {{convention}} ' + + 'as "{{name}}"' } }, create(context): Rule.RuleListener { + const convention = context.options[0]?.convention ?? 'none'; + return { ClassDeclaration: (node: ESTree.Class): void => { if (isLitClass(node)) { @@ -57,6 +70,35 @@ const rule: Rule.RuleModule = { node: propConfig.expr ?? propConfig.key, messageId: 'casedAttribute' }); + } else if (convention !== 'none') { + let conventionName; + let expectedAttributeName; + + switch (convention) { + case 'snake': + conventionName = 'snake_case'; + expectedAttributeName = toSnakeCase(prop); + break; + case 'kebab': + conventionName = 'kebab-case'; + expectedAttributeName = toKebabCase(prop); + break; + } + + if ( + expectedAttributeName && + conventionName && + propConfig.attributeName !== expectedAttributeName + ) { + context.report({ + node: propConfig.expr ?? propConfig.key, + messageId: 'casedAttributeConvention', + data: { + convention: conventionName, + name: expectedAttributeName + } + }); + } } } } diff --git a/src/test/rules/attribute-names_test.ts b/src/test/rules/attribute-names_test.ts index 80ee3f3..36fe4c3 100644 --- a/src/test/rules/attribute-names_test.ts +++ b/src/test/rules/attribute-names_test.ts @@ -52,6 +52,76 @@ ruleTester.run('attribute-names', rule, { }; } }`, + { + code: `class Foo extends LitElement { + static get properties() { + return { + camelCase: {type: String, attribute: 'camel-case'} + }; + } + }`, + options: [{convention: 'kebab'}] + }, + { + code: `class Foo extends LitElement { + static get properties() { + return { + camelCase: {type: String, attribute: false} + }; + } + }`, + options: [{convention: 'kebab'}] + }, + { + code: `class Foo extends LitElement { + static get properties() { + return { + camelCase: {type: String, attribute: 'camelcase'} + }; + } + }`, + options: [{convention: 'none'}] + }, + { + code: `class Foo extends LitElement { + static get properties() { + return { + camelCase: {type: String, attribute: 'camel-case'} + }; + } + }`, + options: [{convention: 'none'}] + }, + { + code: `class Foo extends LitElement { + static get properties() { + return { + camelCase: {type: String, attribute: false} + }; + } + }`, + options: [{convention: 'none'}] + }, + { + code: `class Foo extends LitElement { + static get properties() { + return { + camelCase: {type: String, attribute: 'camel_case'} + }; + } + }`, + options: [{convention: 'snake'}] + }, + { + code: `class Foo extends LitElement { + static get properties() { + return { + camelCase: {type: String, attribute: false} + }; + } + }`, + options: [{convention: 'snake'}] + }, { code: `class Foo extends LitElement { @property({ type: String }) @@ -95,6 +165,127 @@ ruleTester.run('attribute-names', rule, { } ] }, + { + code: `class Foo extends LitElement { + static get properties() { + return { + camelCase: {type: String} + }; + } + }`, + options: [{convention: 'kebab'}], + errors: [ + { + line: 4, + column: 13, + messageId: 'casedPropertyWithoutAttribute' + } + ] + }, + { + code: `class Foo extends LitElement { + static get properties() { + return { + camelCase: {type: String, attribute: 'stillCamelCase'} + }; + } + }`, + options: [{convention: 'kebab'}], + errors: [ + { + line: 4, + column: 24, + messageId: 'casedAttribute' + } + ] + }, + { + code: `class Foo extends LitElement { + static get properties() { + return { + camelCase: {type: String, attribute: 'wrong-name'} + }; + } + }`, + options: [{convention: 'kebab'}], + errors: [ + { + line: 4, + column: 24, + messageId: 'casedAttributeConvention', + data: {convention: 'kebab-case', name: 'camel-case'} + } + ] + }, + { + code: `class Foo extends LitElement { + static get properties() { + return { + camelCase: {type: String} + }; + } + }`, + options: [{convention: 'none'}], + errors: [ + { + line: 4, + column: 13, + messageId: 'casedPropertyWithoutAttribute' + } + ] + }, + { + code: `class Foo extends LitElement { + static get properties() { + return { + camelCase: {type: String, attribute: 'camelCase'} + }; + } + }`, + options: [{convention: 'none'}], + errors: [ + { + line: 4, + column: 24, + messageId: 'casedAttribute' + } + ] + }, + { + code: `class Foo extends LitElement { + static get properties() { + return { + camelCase: {type: String} + }; + } + }`, + options: [{convention: 'snake'}], + errors: [ + { + line: 4, + column: 13, + messageId: 'casedPropertyWithoutAttribute' + } + ] + }, + { + code: `class Foo extends LitElement { + static get properties() { + return { + camelCase: {type: String, attribute: 'wrong-name'} + }; + } + }`, + options: [{convention: 'snake'}], + errors: [ + { + line: 4, + column: 24, + messageId: 'casedAttributeConvention', + data: {convention: 'snake_case', name: 'camel_case'} + } + ] + }, { code: `class Foo extends LitElement { static get properties() { diff --git a/src/util.ts b/src/util.ts index 88e81c8..65527e9 100644 --- a/src/util.ts +++ b/src/util.ts @@ -327,3 +327,23 @@ export function templateExpressionToHtml( return html; } + +/** + * Converts a camelCase string to snake_case string + * + * @param {string} camelCaseStr String to convert + * @return {string} + */ +export function toSnakeCase(camelCaseStr: string): string { + return camelCaseStr.replace(/[A-Z]/g, (m) => '_' + m.toLowerCase()); +} + +/** + * Converts a camelCase string to kebab-case string + * + * @param {string} camelCaseStr String to convert + * @return {string} + */ +export function toKebabCase(camelCaseStr: string): string { + return camelCaseStr.replace(/[A-Z]/g, (m) => '-' + m.toLowerCase()); +}