diff --git a/src/rules/define-tag-after-class-definition.ts b/src/rules/define-tag-after-class-definition.ts new file mode 100644 index 0000000..c7275e2 --- /dev/null +++ b/src/rules/define-tag-after-class-definition.ts @@ -0,0 +1,89 @@ +/** + * @fileoverview Enforces that the `define(...)` call happens after the + * associated class has been defined + * @author James Garbutt + */ + +import {Rule} from 'eslint'; +import * as ESTree from 'estree'; +import {isCustomElement, isDefineCall} from '../util'; + +//------------------------------------------------------------------------------ +// Rule Definition +//------------------------------------------------------------------------------ + +const rule: Rule.RuleModule = { + meta: { + docs: { + description: + 'Enforces that the `define(...)` call happens after' + + ' the associated class has been defined', + url: 'https://github.com/43081j/eslint-plugin-wc/blob/master/docs/rules/define-tag-after-class-definition.md' + }, + messages: { + unregistered: + 'Custom element class has not been registered with ' + + ' a `customElements.define` call', + noExpressions: + 'Custom element classes should not be declared inline. ' + + 'They should be exported as concrete class declarations.' + } + }, + + create(context): Rule.RuleListener { + const seenClasses = new Set(); + const source = context.getSourceCode(); + + //---------------------------------------------------------------------- + // Helpers + //---------------------------------------------------------------------- + + //---------------------------------------------------------------------- + // Public + //---------------------------------------------------------------------- + + return { + 'ClassDeclaration,ClassExpression': (node: ESTree.Class): void => { + if ( + isCustomElement(context, node, source.getJSDocComment(node)) && + node.id?.type === 'Identifier' + ) { + seenClasses.add(node); + } + }, + CallExpression: (node: ESTree.CallExpression): void => { + const tagClass = node.arguments[1]; + + if (isDefineCall(node)) { + if (tagClass.type === 'Identifier') { + const ref = context + .getScope() + .references.find((r) => r.identifier.name === tagClass.name); + + if (ref?.resolved && ref.resolved.defs.length === 1) { + seenClasses.delete(ref.resolved.defs[0].node); + } + } else if (tagClass.type === 'ClassExpression') { + seenClasses.delete(tagClass); + context.report({ + node: tagClass, + messageId: 'noExpressions' + }); + } + } + }, + 'Program:exit': (): void => { + for (const node of seenClasses) { + context.report({ + node, + messageId: 'unregistered' + }); + } + + seenClasses.clear(); + } + }; + } +}; + +export default rule; diff --git a/src/rules/no-invalid-element-name.ts b/src/rules/no-invalid-element-name.ts index 1214b04..e28e980 100644 --- a/src/rules/no-invalid-element-name.ts +++ b/src/rules/no-invalid-element-name.ts @@ -7,6 +7,7 @@ import {Rule} from 'eslint'; import * as ESTree from 'estree'; import isValidElementName = require('is-valid-element-name'); import {knownNamespaces} from '../util/tag-names'; +import {isDefineCall} from '../util'; //------------------------------------------------------------------------------ // Rule Definition @@ -82,18 +83,7 @@ const rule: Rule.RuleModule = { return { CallExpression: (node: ESTree.CallExpression): void => { - if ( - node.callee.type === 'MemberExpression' && - ((node.callee.object.type === 'MemberExpression' && - node.callee.object.object.type === 'Identifier' && - node.callee.object.object.name === 'window' && - node.callee.object.property.type === 'Identifier' && - node.callee.object.property.name === 'customElements') || - (node.callee.object.type === 'Identifier' && - node.callee.object.name === 'customElements')) && - node.callee.property.type === 'Identifier' && - node.callee.property.name === 'define' - ) { + if (isDefineCall(node)) { const firstArg = node.arguments[0]; if ( firstArg && diff --git a/src/test/rules/define-tag-after-class-definition_test.ts b/src/test/rules/define-tag-after-class-definition_test.ts new file mode 100644 index 0000000..899d8f0 --- /dev/null +++ b/src/test/rules/define-tag-after-class-definition_test.ts @@ -0,0 +1,134 @@ +/** + * @fileoverview Enforces that the `define(...)` call happens after the + * associated class has been defined + * @author James Garbutt + */ + +//------------------------------------------------------------------------------ +// Requirements +//------------------------------------------------------------------------------ + +import rule from '../../rules/define-tag-after-class-definition'; +import {RuleTester} from 'eslint'; + +//------------------------------------------------------------------------------ +// Tests +//------------------------------------------------------------------------------ + +const ruleTester = new RuleTester({ + parserOptions: { + sourceType: 'module', + ecmaVersion: 2015 + } +}); + +ruleTester.run('define-tag-after-class-definition', rule, { + valid: [ + 'class Foo {}', + `class Foo extends HTMLElement {} + customElements.define('x-foo', Foo);`, + `class Foo extends HTMLElement {} + const x = 303; + customElements.define('x-foo', Foo);`, + `class Foo extends HTMLElement {} + window.customElements.define('x-foo', Foo);`, + `/** @customElement x-foo */ + class Foo extends Bar {} + customElements.define('x-foo', Foo);`, + `customElements.define('x-foo', Foo);`, + `const Foo = doSomeBlackBoxThing(); + customElements.define('x-foo', Foo);`, + { + code: `class Foo extends Bar {} + customElements.define('x-foo', Foo);`, + settings: { + wc: { + elementBaseClasses: ['Bar'] + } + } + } + ], + + invalid: [ + { + code: 'class Foo extends HTMLElement {}', + errors: [ + { + messageId: 'unregistered', + line: 1, + column: 1 + } + ] + }, + { + code: `class Foo extends HTMLElement {} + customElements.define('x-foo', someThingElse);`, + errors: [ + { + messageId: 'unregistered', + line: 1, + column: 1 + } + ] + }, + { + code: `class Foo extends HTMLElement {} + customElements.define('x-foo', someDynamicThing());`, + errors: [ + { + messageId: 'unregistered', + line: 1, + column: 1 + } + ] + }, + { + code: `class Foo extends HTMLElement {} + someOtherNonsense.define('x-foo', Foo);`, + errors: [ + { + messageId: 'unregistered', + line: 1, + column: 1 + } + ] + }, + { + code: `customElements.define('x-foo', class extends HTMLElement { + });`, + errors: [ + { + messageId: 'noExpressions', + line: 1, + column: 32 + } + ] + }, + { + code: 'class Foo extends Bar {}', + settings: { + wc: { + elementBaseClasses: ['Bar'] + } + }, + errors: [ + { + messageId: 'unregistered', + line: 1, + column: 1 + } + ] + }, + { + code: `/** @customElement x-foo */ + class Foo extends Bar {}`, + errors: [ + { + messageId: 'unregistered', + line: 2, + column: 7 + } + ] + } + ] +}); diff --git a/src/util.ts b/src/util.ts index d7d4203..196154a 100644 --- a/src/util.ts +++ b/src/util.ts @@ -89,3 +89,23 @@ export function isNativeCustomElement(node: ESTree.Class): boolean { node.superClass.name === 'HTMLElement' ); } + +/** + * Determines if a call expression is a `customElements.define` call + * @param {ESTree.CallExpression} node Node to test + * @return {boolean} + */ +export function isDefineCall(node: ESTree.CallExpression): boolean { + return ( + node.callee.type === 'MemberExpression' && + ((node.callee.object.type === 'MemberExpression' && + node.callee.object.object.type === 'Identifier' && + node.callee.object.object.name === 'window' && + node.callee.object.property.type === 'Identifier' && + node.callee.object.property.name === 'customElements') || + (node.callee.object.type === 'Identifier' && + node.callee.object.name === 'customElements')) && + node.callee.property.type === 'Identifier' && + node.callee.property.name === 'define' + ); +}