diff --git a/README.md b/README.md index e461f39..c2a6163 100644 --- a/README.md +++ b/README.md @@ -28,4 +28,5 @@ JSON ESLint config example: - [File Name Matches Element](./docs/rules/file-name-matches-element.md) - [No Constructor](./docs/rules/no-constructor.md) - [No Customized Built in Elements](./docs/rules/no-customized-built-in-elements.md) -- [One Element Per File](./docs/rules/one-element-per-file.md) \ No newline at end of file +- [One Element Per File](./docs/rules/one-element-per-file.md) +- [Valid Tag Name](./docs/rules/valid-tag-name.md) diff --git a/docs/rules/valid-tag-name.md b/docs/rules/valid-tag-name.md new file mode 100644 index 0000000..ae7065e --- /dev/null +++ b/docs/rules/valid-tag-name.md @@ -0,0 +1,36 @@ +# Valid Tag Name + +To be able to use a Custom Element, it must be defined in the `customElements` registry with a valid tag name. Certain characters are allowed for tag names, and invalid tag-names will throw a `DOMException` during runtime. + +## Rule Details + +This rule enforces that calls to `customElements.define` or `customElements.whenDefined` express a valid tag name. There are certain mandatory rules a tag name must follow - such as starting with a letter, and including one dash. This rule will check the name follows these rules. + +In addition, this rule can be customised to perform additional checks, such as requiring a prefix, or disallowing known prefixes such as `x-`. + +👎 Examples of **incorrect** code for this rule: + +```js +customElements.define('foobar', ...) +``` + +👍 Examples of **correct** code for this rule: + +```js +customElements.define('foo-bar', ...) +``` + +### Options + + - `onlyAlphanum` is a boolean option (default: `false`). When set to `true` it will only allow tag names to consist of alphanumeric characters from a-z, 0-9. The spec allows for a broad range of additional characters such as some emoji, which will be disallowed if this rule is set to `true`. + - `disallowNamespaces` is a boolean option (default: `false`). When set to `true` it will check the tag does not match an existing well known namespace, such as `x-` or `google-`, etc. Setting this rule to true limits the risk of collision with other third-party element names. + - `suffix` can be set to one or more strings, and elements must use at least one of these suffixes. + - `prefix` can be set to one or more strings, and elements must use at least one of these prefixes. + +## When Not To Use It + +If you do not want to validate the names passed to `customElements.define` or `customElements.whenDefined` then you can disable this rule. + +## Version + +This was added in v0.0.1 diff --git a/lib/rules.js b/lib/rules.js index 1c93995..0e58ae5 100644 --- a/lib/rules.js +++ b/lib/rules.js @@ -4,5 +4,6 @@ module.exports = { 'file-name-matches-element': require('./rules/file-name-matches-element'), 'no-constructor': require('./rules/no-constructor'), 'no-customized-built-in-elements': require('./rules/no-customized-built-in-elements'), - 'one-element-per-file': require('./rules/one-element-per-file') + 'one-element-per-file': require('./rules/one-element-per-file'), + 'valid-tag-name': require('./rules/valid-tag-name') } diff --git a/lib/rules/valid-tag-name.js b/lib/rules/valid-tag-name.js new file mode 100644 index 0000000..77370db --- /dev/null +++ b/lib/rules/valid-tag-name.js @@ -0,0 +1,66 @@ +const s = require('../custom-selectors') +const {validTagName, reservedTags, knownNamespaces} = require('../tag-names') +module.exports = { + meta: { + type: 'problem', + docs: {description: '', url: require('../url')(module)} + }, + schema: [ + { + type: 'object', + properties: { + disallowNamespaces: {type: 'boolean'}, + onlyAlphanum: {type: 'boolean'}, + prefix: { + oneOf: [{type: 'string'}, {type: 'array', items: {type: 'string'}}] + }, + suffix: { + oneOf: [{type: 'string'}, {type: 'array', items: {type: 'string'}}] + } + } + } + ], + create(context) { + return { + [s.customElements.define](node) { + const nameArg = node.arguments[0] + let name = nameArg.value + if (nameArg.type === 'TemplateLiteral') { + // Give up on TemplateLiteral expressions + if (nameArg.expressions.length) return + name = nameArg.quasis.map(q => q.value.raw).join('') + } + const {disallowNamespaces, onlyAlphanum} = context.options[0] || {} + let {prefix, suffix} = context.options[0] || {} + suffix = [].concat(suffix || []) + prefix = [].concat(prefix || []) + const namespace = (name.match(/^(.*)-/) || [])[1] + if (!validTagName(name)) { + const hints = [] + if (!/^[a-z]/.test(name)) hints.push('Custom Element names must start with a letter') + if (!/-/.test(name)) hints.push('Custom Element names must contain at least one dash (-)') + if (/[A-Z]/.test(name)) hints.push('Custom Element names must not contain capital letters') + context.report( + nameArg, + `${name} is not a valid custom element name${hints.length ? '\n' : ''}${hints.join('\n')}` + ) + } + if (reservedTags.has(name)) { + context.report(nameArg, `Custom Elements cannot be given the reserved name "${name}"`) + } + if (onlyAlphanum && !/^[a-z0-9-]+$/.test(name)) { + context.report(nameArg, `Non ASCII Custom Elements have been disallowed`) + } + if (disallowNamespaces && knownNamespaces.has(namespace) && !prefix.includes(namespace)) { + context.report(nameArg, `${namespace} is a common namespace, and has been disallowed`) + } + if (suffix.length && !suffix.some(end => name.endsWith(end))) { + context.report(nameArg, `Custom Element name must end with a suffix: ${suffix}`) + } + if (prefix.length && !prefix.some(start => name.startsWith(start))) { + context.report(nameArg, `Custom Element name must begin with a prefix: ${prefix}`) + } + } + } + } +} diff --git a/lib/tag-names.js b/lib/tag-names.js new file mode 100644 index 0000000..25cffd6 --- /dev/null +++ b/lib/tag-names.js @@ -0,0 +1,155 @@ +module.exports.knownNamespaces = new Set([ + 'primer', + 'polymer', + 'x', + 'rdf', + 'xml', + 'annotation', + 'color', + 'font', + 'missing', + + // https://www.webcomponents.org/collections + 'ct', + 'color', + 'currency', + 'smart', + 'github', + 'elementui', + 'smart', + 'github', + 'elementui', + 'progressive', + 'cpss', + 'xgd', + 'paper', + 'hy', + 'polymorph', + 'granite', + 'plastic', + 'vega', + 'catalyst', + 'Gathr', + 'fs', + 'cr', + 'mqtt', + 'simpla', + 'iron', + 'topseed', + 'awesome', + 'ts', + 'globalization', + 'super', + 's', + 'scary', + 'best', + 'paper', + 'platinum', + 'paper', + 'layout', + 'iron', + 'app', + 'data', + 'gold', + 'elements', + 'pf', + 'paperfire', + 'core', + 'nodecg', + 'titanium', + 'lrndesign', + 'lrn', + 'hax', + 'k4ng', + 'ros', + 'geo', + 'ibm', + 'google', + 'gfs', + 'geoloeg', + 'ami', + 'google', + 'medical', + 'cordova', + 'cth', + 'paper', + 'gui', + 'network', + 'dialogs', + 'layouts', + + // https://github.com/nuxodin/web-namespace-registry/ + 'lume', + 'three', + 'fast', + 'a11y', + 'lrn', + 'mdl', + 'mui', + 'pure', + 'uk', + 'auro', + 'ui5', + 's', + 'ng', + 'amp', + 'spectrum', + 'sp', + 'smart', + 'hiq', + 'ibm', + 'google', + 'a', + 'tangy', + 'bs', + 'ion', + 'mwc', + 'mdc', + 'mat', + 'mv', + 'iron', + 'paper', + 'gold', + 'std', + 'aria', + 'dile', + 'lion' +]) + +module.exports.reservedTags = new Set([ + 'annotation-xml', + 'color-profile', + 'font-face', + 'font-face-src', + 'font-face-uri', + 'font-face-format', + 'font-face-name', + 'missing-glyph' +]) + +// https://html.spec.whatwg.org/multipage/custom-elements.html#valid-custom-element-name +const PCENChar = [ + '(?:\\-', + '\\.', + '[0-9]', + '_', + '[a-z]', + '\\xB7', // · + '[\\u00C0-\\u00D6]', + '[\\u00D8-\\u00F6]', + '[\\u00F8-\\u037D]', + '[\\u037F-\\u1FFF]', + '[\\u200C-\\u200D]', + '[\\u203F-\\u2040]', + '[\\u2070-\\u218F]', + '[\\u2C00-\\u2FEF]', + '[\\u3001-\\uD7FF]', + '[\\uF900-\\uFDCF]', + '[\\uFDF0-\\uFFFD]', + '[\\uD800-\\uDB7F]', + '[\\uDC00-\\uDFFF])' +].join('|') +module.exports.PCENChar = PCENChar +const customElementNameRegExp = new RegExp(`^[a-z]${PCENChar}*-${PCENChar}*$`) + +module.exports.validTagName = name => customElementNameRegExp.test(name) diff --git a/test/valid-tag-name.js b/test/valid-tag-name.js new file mode 100644 index 0000000..c4fdb9b --- /dev/null +++ b/test/valid-tag-name.js @@ -0,0 +1,155 @@ +const rule = require('../lib/rules/valid-tag-name') +const RuleTester = require('eslint').RuleTester + +const ruleTester = new RuleTester({env: {es2020: true}}) + +ruleTester.run('valid-tag-name', rule, { + valid: [ + {code: 'customElements.define("foo-bar")'}, + {code: "customElements.define('foo-bar')"}, + {code: 'customElements.define(`foo-bar`)'}, + {code: 'customElements.define("foo-bar")'}, + {code: 'customElements.define("m---···---")'}, + {code: 'customElements.define("aÀ-")'}, + {code: 'customElements.define("leiðinlegt-tíst")'}, + {code: 'customElements.define("a-😶‍🌫️")'}, + {code: 'customElements.define("a-b-c-")', options: [{onlyAlphanum: true}]}, + {code: 'customElements.define("foo-bar")', options: [{disallowNamespaces: true}]}, + {code: 'customElements.define("ng-bar")', options: [{disallowNamespaces: true, prefix: 'ng'}]} + ], + invalid: [ + { + code: 'customElements.define("foo")', + errors: [ + { + message: 'foo is not a valid custom element name\nCustom Element names must contain at least one dash (-)', + type: 'Literal' + } + ] + }, + { + code: 'customElements.define("-foo-bar")', + errors: [ + { + message: '-foo-bar is not a valid custom element name\nCustom Element names must start with a letter', + type: 'Literal' + } + ] + }, + { + code: 'customElements.define("foo-BAR")', + errors: [ + { + message: 'foo-BAR is not a valid custom element name\nCustom Element names must not contain capital letters', + type: 'Literal' + } + ] + }, + { + code: 'customElements.define("")', + errors: [ + { + message: ' is not a valid custom element name\nCustom Element names must start with a letter', + type: 'Literal' + } + ] + }, + { + code: 'customElements.define("911-emergency")', + errors: [ + { + message: '911-emergency is not a valid custom element name\nCustom Element names must start with a letter', + type: 'Literal' + } + ] + }, + { + code: 'customElements.define("notice-©")', + errors: [ + { + message: 'notice-© is not a valid custom element name', + type: 'Literal' + } + ] + }, + { + code: 'customElements.define("annotation-xml")', + errors: [ + { + message: 'Custom Elements cannot be given the reserved name "annotation-xml"', + type: 'Literal' + } + ] + }, + { + code: 'customElements.define("a-😶‍🌫️")', + options: [ + { + onlyAlphanum: true + } + ], + errors: [ + { + message: 'Non ASCII Custom Elements have been disallowed', + type: 'Literal' + } + ] + }, + { + code: 'customElements.define("a-😶‍🌫️")', + options: [ + { + onlyAlphanum: true + } + ], + errors: [ + { + message: 'Non ASCII Custom Elements have been disallowed', + type: 'Literal' + } + ] + }, + { + code: 'customElements.define("ng-element")', + options: [ + { + disallowNamespaces: true + } + ], + errors: [ + { + message: 'ng is a common namespace, and has been disallowed', + type: 'Literal' + } + ] + }, + { + code: 'customElements.define("abc-element")', + options: [ + { + prefix: 'foo' + } + ], + errors: [ + { + message: 'Custom Element name must begin with a prefix: foo', + type: 'Literal' + } + ] + }, + { + code: 'customElements.define("abc-component")', + options: [ + { + suffix: 'element' + } + ], + errors: [ + { + message: 'Custom Element name must end with a suffix: element', + type: 'Literal' + } + ] + } + ] +})