diff --git a/README.md b/README.md index e126971..623625e 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,8 @@ If you want more fine-grained configuration, you can instead add a snippet like - [lit/no-useless-template-literals](docs/rules/no-useless-template-literals.md) - [lit/no-value-attribute](docs/rules/no-value-attribute.md) - [lit/quoted-expressions](docs/rules/quoted-expressions.md) +- [lit/file-name-matches-element-class](docs/rules/file-name-matches-element-class.md) +- [lit/file-name-matches-element-name](docs/rules/file-name-matches-element-name.md) ## Shareable configurations diff --git a/docs/rules/file-name-matches-element-class.md b/docs/rules/file-name-matches-element-class.md new file mode 100644 index 0000000..37b1057 --- /dev/null +++ b/docs/rules/file-name-matches-element-class.md @@ -0,0 +1,39 @@ +# Disallow different name between the class of the lit element and the filename (file-name-matches-element-class) + +It's hard to find a class name when the filename does not match (very useful in typescript). + +## Rule Details + +This rule disallows use of name between file and class. + +The following patterns are considered warnings: + +```ts + // filename: my-file.ts + @customElement('not-foo-bar') + class FooBarElement extends LitElement {} + + // filename: my-file.js + class FooBarElement extends LitElement {} + customElements.define('not-foo-bar'); +``` + +The following patterns are not warnings: + +```ts + // filename: foo-bar.ts + @customElement('foo-bar') + class FooBarElement extends LitElement {} + + // filename: foo-bar.js + class FooBarElement extends LitElement {} + customElements.define('foo-bar'); +``` + +## Options + +You can specify the format of the name of the file `['none', 'snake', 'kebab', 'pascal']` + +## When Not To Use It + +If you don't care about filename vs element name. diff --git a/docs/rules/file-name-matches-element-name.md b/docs/rules/file-name-matches-element-name.md new file mode 100644 index 0000000..da75645 --- /dev/null +++ b/docs/rules/file-name-matches-element-name.md @@ -0,0 +1,47 @@ +# Disallow different name between the name of the lit element and the filename (file-name-matches-element-name) + +It's hard to find a component name when the filename does not match. Typically when you find a component through the chrome inspector. + +## Rule Details + +This rule disallows use of name between file and element. + +The following patterns are considered warnings: + +```ts +// filename: my-file.ts +class FooBarElement extends LitElement {} + +// filename: my-file.js +class FooBarElement extends LitElement {} + +// filename: my-file.jsx +class FooBarElement extends LitElement {} +``` + +The following patterns are not warnings: + +```ts +// filename: not-related.ts +class FooBarElement {} + +// filename: foo-bar-element.ts +class FooBarElement LitElement {} + +// filename: foo-bar-element.js +class FooBarElement LitElement {} + +// filename: foo-bar-element.jsx +class FooBarElement LitElement {} + +// filename: foo-bar-element.jsx +class FooBarElement SuperBehavior(LitElement) {} +``` + +## Options + +You can specify the format of the name of the file `['none', 'snake', 'kebab', 'pascal']` + +## When Not To Use It + +If you don't care about filename vs class name. diff --git a/src/configs/all.ts b/src/configs/all.ts index 7e277df..fcd10cd 100644 --- a/src/configs/all.ts +++ b/src/configs/all.ts @@ -17,7 +17,9 @@ const config = { 'lit/no-useless-template-literals': 'error', 'lit/no-value-attribute': 'error', 'lit/prefer-static-styles': 'error', - 'lit/quoted-expressions': 'error' + 'lit/quoted-expressions': 'error', + 'lit/file-name-matches-element-class': 'error', + 'lit/file-name-matches-element-name': 'error' } }; diff --git a/src/rules/file-name-matches-element-class.ts b/src/rules/file-name-matches-element-class.ts new file mode 100644 index 0000000..033510a --- /dev/null +++ b/src/rules/file-name-matches-element-class.ts @@ -0,0 +1,72 @@ +/** + * @fileoverview Disallow different name between + * the class of the lit element and the filename + * @author Julien Rousseau + */ + +import {Rule} from 'eslint'; +import * as ESTree from 'estree'; +import {hasFileName, isValidFilename} from '../util'; + +const isLitElementClass = ( + args: Array +): boolean => { + return args.some((a) => { + if (a.type === 'CallExpression') { + return isLitElementClass(a.arguments); + } else if (a.type === 'Identifier' && a.name === 'LitElement') { + return true; + } + return false; + }); +}; + +const rule: Rule.RuleModule = { + meta: { + type: 'suggestion', + docs: { + description: 'Enforce usage of the same element class name and filename' + }, + schema: [ + { + type: 'object', + properties: { + transform: { + oneOf: [ + { + enum: ['none', 'snake', 'kebab', 'pascal'] + }, + { + type: 'array', + items: { + enum: ['none', 'snake', 'kebab', 'pascal'] + }, + minItems: 1, + maxItems: 4 + } + ] + } + } + } + ] + }, + create(context): Rule.RuleListener { + if (!hasFileName(context)) return {}; + return { + 'ClassDeclaration, ClassExpression': (node: ESTree.Class): void => { + if ( + node.superClass?.type === 'CallExpression' && + isLitElementClass(node.superClass.arguments) + ) { + isValidFilename(context, node, node.id?.name || ''); + } + }, + [`:matches(ClassDeclaration, ClassExpression)[superClass.name=/.*LitElement.*/]`]: + (node: ESTree.Class): void => { + isValidFilename(context, node, node.id?.name || ''); + } + }; + } +}; + +export = rule; diff --git a/src/rules/file-name-matches-element-name.ts b/src/rules/file-name-matches-element-name.ts new file mode 100644 index 0000000..8ddbb10 --- /dev/null +++ b/src/rules/file-name-matches-element-name.ts @@ -0,0 +1,101 @@ +/** + * @fileoverview Disallow different name between + * the name of the lit element and the filename + * @author Julien Rousseau + */ + +import {Rule} from 'eslint'; +import * as ESTree from 'estree'; +import {hasFileName, isValidFilename} from '../util'; + +const kebabCaseToPascalCase = (kebabElementName: string): string => { + const camelCaseElementName = kebabElementName.replace(/-./g, (x) => + x[1].toUpperCase() + ); + return ( + camelCaseElementName.charAt(0).toUpperCase() + camelCaseElementName.slice(1) + ); +}; + +const rule: Rule.RuleModule = { + meta: { + type: 'suggestion', + docs: { + description: 'Enforce usage of the same element name and filename' + }, + schema: [ + { + type: 'object', + properties: { + transform: { + oneOf: [ + { + enum: ['none', 'snake', 'kebab', 'pascal'] + }, + { + type: 'array', + items: { + enum: ['none', 'snake', 'kebab', 'pascal'] + }, + minItems: 1, + maxItems: 4 + } + ] + } + } + } + ] + }, + create(context): Rule.RuleListener { + if (!hasFileName(context)) return {}; + return { + 'CallExpression[callee.name=customElement]': ( + node: ESTree.CallExpression & { + parent: ESTree.Node & {parent: ESTree.Node}; + } + ): void => { + if ( + node.callee.type === 'Identifier' && + node.callee.name === 'customElement' && + node.parent.parent.type === 'ClassDeclaration' + ) { + const literalArgument = node.arguments.find( + (a) => a.type === 'Literal' + ); + if ( + literalArgument?.type === 'Literal' && + literalArgument.value && + typeof literalArgument.value === 'string' + ) { + isValidFilename( + context, + node, + kebabCaseToPascalCase(literalArgument.value) + ); + } + } + }, + [`CallExpression[callee.property.name=define]:matches([callee.object.type=Identifier][callee.object.name=customElements],[callee.object.type=MemberExpression][callee.object.property.name=customElements]):exit`]: + (node: ESTree.CallExpression): void => { + if (node.callee.type === 'MemberExpression') { + const literalArgument = node.arguments.find( + (a) => a.type === 'Literal' + ); + if ( + literalArgument?.type === 'Literal' && + literalArgument.value && + typeof literalArgument.value === 'string' + ) { + isValidFilename( + context, + node, + kebabCaseToPascalCase(literalArgument.value) + ); + } + } + } + }; + } +}; + +export = rule; diff --git a/src/test/rules/file-name-matches-element-class_test.ts b/src/test/rules/file-name-matches-element-class_test.ts new file mode 100644 index 0000000..8fda2bd --- /dev/null +++ b/src/test/rules/file-name-matches-element-class_test.ts @@ -0,0 +1,122 @@ +/** + * @fileoverview Disallow different name between + * the class of the lit element and the filename + * @author Julien Rousseau + */ + +//------------------------------------------------------------------------------ +// Requirements +//------------------------------------------------------------------------------ + +import rule = require('../../rules/file-name-matches-element-class'); +import {RuleTester} from 'eslint'; + +//------------------------------------------------------------------------------ +// Tests +//------------------------------------------------------------------------------ + +const ruleTester = new RuleTester({ + parserOptions: { + sourceType: 'module', + ecmaVersion: 2015 + } +}); + +const code = 'class FooBarElement extends LitElement {}'; +const codeWithExtends = + 'class FooBarElement extends MySuperBehavior(LitElement) {}'; + +ruleTester.run('file-name-matches-element-class', rule, { + valid: [ + {code: 'class SomeMap extends Map {}', filename: 'not-an-element.js'}, + {code: 'class FooBarElement {}', filename: 'not-related.js'}, + {code}, + {code, filename: ''}, + {code, filename: ''}, + {code, filename: 'foo-bar-element.js'}, + {code, filename: 'foo-bar-element.ts'}, + {code, filename: 'foo-bar-element.jsx'}, + {code: codeWithExtends, filename: 'foo-bar-element.js'}, + {code, filename: 'FooBarElement.js', options: [{transform: 'none'}]}, + {code, filename: 'FooBarElement.ts', options: [{transform: 'none'}]}, + {code, filename: 'FooBarElement.jsx', options: [{transform: 'none'}]}, + {code, filename: 'foo_bar_element.js', options: [{transform: 'snake'}]}, + {code, filename: 'foo_bar_element.ts', options: [{transform: 'snake'}]}, + {code, filename: 'foo_bar_element.jsx', options: [{transform: 'snake'}]}, + {code, filename: 'foo-bar-element.js', options: [{transform: 'kebab'}]}, + {code, filename: 'foo-bar-element.ts', options: [{transform: 'kebab'}]}, + {code, filename: 'foo-bar-element.jsx', options: [{transform: 'kebab'}]}, + {code, filename: 'fooBarElement.js', options: [{transform: 'pascal'}]}, + {code, filename: 'fooBarElement.ts', options: [{transform: 'pascal'}]}, + {code, filename: 'fooBarElement.jsx', options: [{transform: 'pascal'}]}, + { + code, + filename: 'fooBarElement.js', + options: [{transform: ['snake', 'kebab', 'pascal']}] + }, + { + code, + filename: 'fooBarElement.ts', + options: [{transform: ['snake', 'kebab', 'pascal']}] + }, + { + code, + filename: 'fooBarElement.jsx', + options: [{transform: ['snake', 'kebab', 'pascal']}] + } + ], + invalid: [ + { + code, + filename: 'FooBarElement.js', + errors: [ + { + message: `File name should be one of ["foo-bar-element.js"] but was "FooBarElement"`, + type: 'ClassDeclaration' + } + ] + }, + { + code, + filename: 'barfooelement.ts', + errors: [ + { + message: `File name should be one of ["foo-bar-element.ts"] but was "barfooelement"`, + type: 'ClassDeclaration' + } + ] + }, + { + code, + filename: 'foobarelement.ts', + errors: [ + { + message: `File name should be one of ["foo-bar-element.ts"] but was "foobarelement"`, + type: 'ClassDeclaration' + } + ] + }, + { + code, + filename: 'foobarelement.ts', + options: [{transform: ['snake']}], + errors: [ + { + message: `File name should be one of ["foo_bar_element.ts"] but was "foobarelement"`, + type: 'ClassDeclaration' + } + ] + }, + { + code, + filename: 'foo-bar_element.ts', + options: [{transform: ['kebab']}], + errors: [ + { + message: `File name should be one of ["foo-bar-element.ts"] but was "foo-bar_element"`, + type: 'ClassDeclaration' + } + ] + } + ] +}); diff --git a/src/test/rules/file-name-matches-element-name_test.ts b/src/test/rules/file-name-matches-element-name_test.ts new file mode 100644 index 0000000..160286e --- /dev/null +++ b/src/test/rules/file-name-matches-element-name_test.ts @@ -0,0 +1,222 @@ +/** + * @fileoverview Disallow different name between + * the name of the lit element and the filename + * @author Julien Rousseau + */ + +//------------------------------------------------------------------------------ +// Requirements +//------------------------------------------------------------------------------ + +import rule = require('../../rules/file-name-matches-element-name'); +import {RuleTester} from 'eslint'; + +const parser = require.resolve('@babel/eslint-parser'); +const parserOptions = { + sourceType: 'module', + ecmaVersion: 12, + requireConfigFile: false, + babelOptions: { + plugins: [ + ['@babel/plugin-proposal-decorators', {decoratorsBeforeExport: true}] + ] + } +}; + +//------------------------------------------------------------------------------ +// Tests +//------------------------------------------------------------------------------ + +const ruleTester = new RuleTester({ + parserOptions, + parser +}); + +const code = (elementName = 'foo-bar-element'): string => ` + +@customElement('${elementName}') +class FooBarElement extends LitElement {}`; + +const codeWithCustomElement = (elementName = 'foo-bar-element'): string => ` +class FooBarElement extends LitElement {} +customElements.define('${elementName}'); +`; + +ruleTester.run('file-name-matches-element-class', rule, { + valid: [ + {code: 'class SomeMap extends Map {}', filename: 'not-an-element.js'}, + {code: 'class FooBarElement {}', filename: 'not-related.js'}, + {code: code('foo-bar-element'), filename: ''}, + {code: code('foo-bar-element'), filename: ''}, + {code: code('foo-bar-element'), filename: 'foo-bar-element.js'}, + {code: code('foo-bar-element'), filename: 'foo-bar-element.js'}, + {code: code('foo-bar-element'), filename: 'foo-bar-element.ts'}, + {code: code('foo-bar-element'), filename: 'foo-bar-element.jsx'}, + { + code: codeWithCustomElement('foo-bar-element'), + filename: 'FooBarElement.js', + options: [{transform: 'none'}] + }, + { + code: code('foo-bar-element'), + filename: 'FooBarElement.js', + options: [{transform: 'none'}] + }, + { + code: code('foo-bar-element'), + filename: 'FooBarElement.ts', + options: [{transform: 'none'}] + }, + { + code: code('foo-bar-element'), + filename: 'FooBarElement.jsx', + options: [{transform: 'none'}] + }, + { + code: code('foo-bar-element'), + filename: 'foo_bar_element.js', + options: [{transform: 'snake'}] + }, + { + code: code('foo-bar-element'), + filename: 'foo_bar_element.ts', + options: [{transform: 'snake'}] + }, + { + code: code('foo-bar-element'), + filename: 'foo_bar_element.jsx', + options: [{transform: 'snake'}] + }, + { + code: code('foo-bar-element'), + filename: 'foo-bar-element.js', + options: [{transform: 'kebab'}] + }, + { + code: code('foo-bar-element'), + filename: 'foo-bar-element.ts', + options: [{transform: 'kebab'}] + }, + { + code: code('foo-bar-element'), + filename: 'foo-bar-element.jsx', + options: [{transform: 'kebab'}] + }, + { + code: code('foo-bar-element'), + filename: 'fooBarElement.js', + options: [{transform: 'pascal'}] + }, + { + code: code('foo-bar-element'), + filename: 'foo_bar_element.js', + options: [{transform: 'snake'}] + }, + { + code: code('foo-bar-element'), + filename: 'fooBarElement.ts', + options: [{transform: 'pascal'}] + }, + { + code: code('foo-bar-element'), + filename: 'fooBarElement.jsx', + options: [{transform: 'pascal'}] + }, + { + code: code('foo-bar-element'), + filename: 'fooBarElement.js', + options: [{transform: ['snake', 'kebab', 'pascal']}] + }, + { + code: code('foo-bar-element'), + filename: 'fooBarElement.ts', + options: [{transform: ['snake', 'kebab', 'pascal']}] + }, + { + code: code('foo-bar-element'), + filename: 'fooBarElement.jsx', + options: [{transform: ['snake', 'kebab', 'pascal']}] + }, + { + code: codeWithCustomElement('foo-bar-element'), + filename: 'fooBarElement.ts', + options: [{transform: ['snake', 'kebab', 'pascal']}] + } + ], + invalid: [ + { + code: code('foo-bar-element'), + filename: 'FooBarElement.js', + errors: [ + { + message: `File name should be one of ["foo-bar-element.js"] but was "FooBarElement"`, + type: 'CallExpression' + } + ] + }, + { + code: code('foo-bar-element'), + filename: 'barfooelement.ts', + errors: [ + { + message: `File name should be one of ["foo-bar-element.ts"] but was "barfooelement"`, + type: 'CallExpression' + } + ] + }, + { + code: code('foo-bar-element'), + filename: 'foobarelement.ts', + errors: [ + { + message: `File name should be one of ["foo-bar-element.ts"] but was "foobarelement"`, + type: 'CallExpression' + } + ] + }, + { + code: code('foo-bar-element'), + filename: 'foobarelement.ts', + options: [{transform: ['snake']}], + errors: [ + { + message: `File name should be one of ["foo_bar_element.ts"] but was "foobarelement"`, + type: 'CallExpression' + } + ] + }, + { + code: code('foo-bar-element'), + filename: 'foo-bar_element.ts', + options: [{transform: ['kebab']}], + errors: [ + { + message: `File name should be one of ["foo-bar-element.ts"] but was "foo-bar_element"`, + type: 'CallExpression' + } + ] + }, + { + code: code('foo_bar_element'), + filename: 'foo-bar_element.ts', + options: [{transform: ['kebab']}], + errors: [ + { + message: `File name should be one of ["foo_bar_element.ts"] but was "foo-bar_element"`, + type: 'CallExpression' + } + ] + }, + { + code: codeWithCustomElement('foo_bar_element'), + filename: 'foo-bar_element.ts', + options: [{transform: ['kebab']}], + errors: [ + { + message: `File name should be one of ["foo_bar_element.ts"] but was "foo-bar_element"`, + type: 'CallExpression' + } + ] + } + ] +}); diff --git a/src/util.ts b/src/util.ts index 4ba051e..32ada14 100644 --- a/src/util.ts +++ b/src/util.ts @@ -1,4 +1,6 @@ import * as ESTree from 'estree'; +import {extname, basename} from 'path'; +import {Rule} from 'eslint'; export interface BabelDecorator extends ESTree.BaseNode { type: 'Decorator'; @@ -189,3 +191,53 @@ export function templateExpressionToHtml( return html; } + +const transformFuncs = { + snake(str: string): string { + return str + .replace(/([A-Z]($|[a-z]))/g, '_$1') + .replace(/^_/g, '') + .toLowerCase(); + }, + kebab(str: string): string { + return str + .replace(/([A-Z]($|[a-z]))/g, '-$1') + .replace(/^-/g, '') + .toLowerCase(); + }, + pascal(str: string): string { + return str.replace(/^./g, (c) => c.toLowerCase()); + }, + none(str: string): string { + return str; + } +}; + +export const hasFileName = (context: Rule.RuleContext): boolean => { + const file = context.getFilename(); + return !(file === '' || file === ''); +}; + +export const isValidFilename = ( + context: Rule.RuleContext, + node: ESTree.Node, + elementName: string +): void => { + const ext = extname(context.getFilename()); + const filename = basename(context.getFilename(), ext); + const transforms: Array<'snake' | 'kebab' | 'pascal'> = [].concat( + context.options?.[0]?.transform || ['kebab'] + ); + + const allowedFilenames = transforms.map((transform) => { + return transformFuncs[transform](elementName); + }); + if (!allowedFilenames.some((f) => f === filename)) { + context.report({ + node, + message: `File name should be one of ["${allowedFilenames + .map((f) => `${f}${ext}`) + .join(',')}"] but was "${filename}"` + }); + } +};