diff --git a/README.md b/README.md index 5cb02e1..50c602b 100644 --- a/README.md +++ b/README.md @@ -23,3 +23,4 @@ JSON ESLint config example: ### Rules - [Define Tag After Class Definition](./docs/rules/define-tag-after-class-definition.md) + - [Expose Class on Global](./docs/rules/expose-class-on-global.md) diff --git a/docs/rules/expose-class-on-global.md b/docs/rules/expose-class-on-global.md new file mode 100644 index 0000000..0f273c3 --- /dev/null +++ b/docs/rules/expose-class-on-global.md @@ -0,0 +1,56 @@ +# Expose Class on Global + +It is good practice to have any element which is accessible via a tag-name to also have the class definition assigned to the global object. This is similar to how the built-in elements operate, for example the `div` element class definition is available under the `HTMLDivElement` global. + +## Rule Details + +This rule enforces that any class extending from `HTMLElement` also be assigned to a global variable matching that name. + +👎 Examples of **incorrect** code for this rule: + +```js +class FooBarElement extends HTMLElement { + // ... +} + +// No assignment to `window.FooBarElement` +``` +```js +class FooBarElement extends HTMLElement { + // ... +} + +// Assigned, but using a different name to the class name +window.FooBar = FooBarElement +``` + +```js +// Assigned but as an Anonymous Class Expression +window.FooBarElement = class extends HTMLElement { + // ... +} +``` + +👍 Examples of **correct** code for this rule: + +```js +class FooBarElement extends HTMLElement { + // ... +} + +window.FooBarElement = FooBarElement +``` + +```js +window.FooBarElement = class FooBarElement extends HTMLElement { + // ... +} +``` + +## When Not To Use It + +If you don't want your elements available as globals, you may disable this rule. + +## Version + +This rule was introduced in 0.0.1 diff --git a/lib/class-ref-tracker.js b/lib/class-ref-tracker.js index c50412b..e7b88d8 100644 --- a/lib/class-ref-tracker.js +++ b/lib/class-ref-tracker.js @@ -1,11 +1,13 @@ class ClassRefTracker { - constructor(context) { + constructor(context, extendsFrom) { this.context = context this.classes = new Set() this.assignments = new Map() + this.extendsFrom = extendsFrom || (() => true) } add(node) { + if (!this.extendsFrom(node.superClass)) return false if (node.type === 'ClassExpression') { if (node.parent.type === 'AssignmentExpression') { const expr = node.parent.left @@ -15,12 +17,12 @@ class ClassRefTracker { } } this.classes.add(node) + return true } get(node) { - if (node.type === 'ClassExpression' || node.type === 'ClassDeclaration') { - this.classes.add(node) - return node + if (node && (node.type === 'ClassExpression' || node.type === 'ClassDeclaration')) { + return this.classes.add(node) ? node : null } const name = this.context.getSourceCode().getText(node) if (this.assignments.has(name)) { @@ -28,7 +30,8 @@ class ClassRefTracker { } else { const classVar = this.context.getScope().set.get(name) if (classVar && classVar.defs.length === 1) { - return classVar.defs[0].node + const classDef = classVar.defs[0].node + return this.add(classDef) ? classDef : null } } } @@ -41,6 +44,10 @@ class ClassRefTracker { [Symbol.iterator]() { return this.classes[Symbol.iterator]() } + + static customElements(context) { + return new ClassRefTracker(context, superClassRef => /^HTML.*Element$/.test(superClassRef.name)) + } } module.exports = ClassRefTracker diff --git a/lib/rules.js b/lib/rules.js index c70cc4f..e8be7e7 100644 --- a/lib/rules.js +++ b/lib/rules.js @@ -1,3 +1,4 @@ module.exports = { - 'define-tag-after-class-definition': require('./rules/define-tag-after-class-definition') + 'define-tag-after-class-definition': require('./rules/define-tag-after-class-definition'), + 'expose-class-on-global': require('./rules/expose-class-on-global') } diff --git a/lib/rules/define-tag-after-class-definition.js b/lib/rules/define-tag-after-class-definition.js index 44a3f50..2e8b817 100644 --- a/lib/rules/define-tag-after-class-definition.js +++ b/lib/rules/define-tag-after-class-definition.js @@ -7,7 +7,7 @@ module.exports = { }, schema: [], create(context) { - const classes = new ClassRefTracker(context) + const classes = ClassRefTracker.customElements(context) return { [s.HTMLElementClass](node) { classes.add(node) diff --git a/lib/rules/expose-class-on-global.js b/lib/rules/expose-class-on-global.js new file mode 100644 index 0000000..ce01bb6 --- /dev/null +++ b/lib/rules/expose-class-on-global.js @@ -0,0 +1,29 @@ +const s = require('../custom-selectors') +const ClassRefTracker = require('../class-ref-tracker') +module.exports = { + meta: { + type: 'suggestion', + docs: {description: '', url: require('../url')(module)} + }, + schema: [], + create(context) { + const classes = ClassRefTracker.customElements(context) + return { + [s.HTMLElementClass](node) { + classes.add(node) + }, + ['AssignmentExpression[left.object.name=window]:exit']: function (node) { + const classDef = classes.get(node.right) + classes.delete(classDef) + if (classDef && (!classDef.id || classDef.id.name !== node.left.property.name)) { + context.report(node.left.property, 'Custom Element global assignment must match class name') + } + }, + ['Program:exit']: function () { + for (const classDef of classes) { + context.report(classDef, 'Custom Element has not been exported onto `window`') + } + } + } + } +} diff --git a/test/expose-class-on-global.js b/test/expose-class-on-global.js new file mode 100644 index 0000000..232cec2 --- /dev/null +++ b/test/expose-class-on-global.js @@ -0,0 +1,60 @@ +const rule = require('../lib/rules/expose-class-on-global') +const RuleTester = require('eslint').RuleTester + +const ruleTester = new RuleTester({env: {es2020: true}}) + +ruleTester.run('expose-class-on-global', rule, { + valid: [ + {code: 'class SomeMap extends Map {}'}, + {code: 'class SomeMap extends Map {}\nwindow.Other = SomeMap'}, + {code: 'class FooBar extends HTMLElement {}\nwindow.FooBar = FooBar'}, + {code: 'window.FooBar = class FooBar extends HTMLElement {}'} + ], + invalid: [ + { + code: 'class FooBar extends HTMLElement {}', + errors: [ + { + message: 'Custom Element has not been exported onto `window`', + type: 'ClassDeclaration' + } + ] + }, + { + code: 'window.customElements.define("foo-bar", FooBar)\nclass FooBar extends HTMLElement {}', + errors: [ + { + message: 'Custom Element has not been exported onto `window`', + type: 'ClassDeclaration' + } + ] + }, + { + code: 'class FooBar extends HTMLElement {}\nwindow.Other = FooBar', + errors: [ + { + message: 'Custom Element global assignment must match class name', + type: 'Identifier' + } + ] + }, + { + code: 'window.Other = class FooBar extends HTMLElement {}', + errors: [ + { + message: 'Custom Element global assignment must match class name', + type: 'Identifier' + } + ] + }, + { + code: 'window.Other = class extends HTMLElement {}', + errors: [ + { + message: 'Custom Element global assignment must match class name', + type: 'Identifier' + } + ] + } + ] +})