From 23ba11d658772617e2313d52d6301197ddf37db4 Mon Sep 17 00:00:00 2001 From: Keith Cirkel Date: Tue, 23 Mar 2021 17:50:36 +0000 Subject: [PATCH 1/2] feat: add file-name-matches-element rule --- README.md | 1 + docs/rules/file-name-matches-element.md | 75 +++++++++++++++ lib/rules.js | 3 +- lib/rules/file-name-matches-element.js | 115 +++++++++++++++++++++++ test/file-name-matches-element.js | 120 ++++++++++++++++++++++++ 5 files changed, 313 insertions(+), 1 deletion(-) create mode 100644 docs/rules/file-name-matches-element.md create mode 100644 lib/rules/file-name-matches-element.js create mode 100644 test/file-name-matches-element.js diff --git a/README.md b/README.md index 5cb02e1..a36b7f5 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) + - [File Name Matches Element](./docs/rules/file-name-matches-element.md) diff --git a/docs/rules/file-name-matches-element.md b/docs/rules/file-name-matches-element.md new file mode 100644 index 0000000..fff5f53 --- /dev/null +++ b/docs/rules/file-name-matches-element.md @@ -0,0 +1,75 @@ +# File Name Matches Element + +For code organisation, it is useful if a file name matches the contents of a class inside it. In this instance, having the file name match the name of the element. + +## Rule Details + +This rule enforces the name of the file an Element exists in matches the name of the Element itself. + +👎 Examples of **incorrect** code for this rule: + +```js +// elements.ts +class FooBarElement extends HTMLElement { +} +``` + +👍 Examples of **correct** code for this rule: + +```js +// foo-bar-elements.ts +class FooBarElement extends HTMLElement { +} +``` + +### Options + + - `transform` can define how a class name would be transformed into a file name. You can specify multiple transforms to allow multiple options. The default is `['kebab', 'pascal']`. The options are: + - `none`: Files must be named exactly as the class name: `FooBarElement` -> `FooBarElement.js` + - `snake`: Files must be named lowercase, separated by underscores: `FooBarElement` -> `foo_bar_element.js` + - `kebab`: Files must be named lowercase, separated by dashes: `FooBarElement` -> `foo-bar-element.js` + - `pascal`: Files must match case, but the first character must be lower: `FooBarElement` -> `fooBarElement.js` + + - `suffix` can allow omission of a suffix from a file name. Case insensitive. + - `prefix` can allow omission of a prefix from a file name. Case insensitive. + - `matchDirectory` the directory structure of the file is also counted as a valid prefix. + +👍 Examples of **correct** file names to class names: + + - `["error", {"transform": "snake", "suffix": ["Element"]}]` + - `FooBarElement` -> `foo_bar.js` + - `FooBarElement` -> `foo_bar_element.js` + + - `["error", {"transform": ["snake", "kebab"], "suffix": ["Element"]}]` + - `FooBarElement` -> `foo-bar.js` + - `FooBarElement` -> `foo-bar.js` + - `FooBarElement` -> `foo_bar_element.js` + - `FooBarElement` -> `foo-bar-element.js` + + - `["error", {"transform": ["snake", "kebab"], "suffix": ["Element", "Component"]}]` + - `FooBarElement` -> `foo-bar.js` + - `FooBarElement` -> `foo-bar.js` + - `FooBarElement` -> `foo_bar_element.js` + - `FooBarElement` -> `foo-bar-element.js` + - `FooBarComponent` -> `foo-bar.js` + - `FooBarComponent` -> `foo-bar.js` + - `FooBarComponent` -> `foo_bar_component.js` + - `FooBarComponent` -> `foo-bar-component.js` + + - `["error", {"transform": ["kebab"], "prefix": ["foo"]}]` + - `FooBarElement` -> `foo-bar-element.js` + - `FooBarElement` -> `bar-element.js` + + - `["error", {"transform": ["kebab"], "matchDirectory": true}]` + - `FooBarElement` -> `foo-bar-element.js` + - `FooBarElement` -> `foo/bar-element.js` + - `FooBarElement` -> `foo/bar/element.js` + - `FooBarElement` -> `foobar/element.js` + +## When Not To Use It + +If you intentionally want to name your custom elements differently to the file names, then you may disable this rule. + +## Version + +This rule was introduced in v0.0.1 diff --git a/lib/rules.js b/lib/rules.js index c70cc4f..5752de8 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'), + 'file-name-matches-element': require('./rules/file-name-matches-element') } diff --git a/lib/rules/file-name-matches-element.js b/lib/rules/file-name-matches-element.js new file mode 100644 index 0000000..62b2eef --- /dev/null +++ b/lib/rules/file-name-matches-element.js @@ -0,0 +1,115 @@ +const s = require('../custom-selectors') +const {basename, dirname, extname} = require('path') +const transformFuncs = { + snake(str) { + return str + .replace(/([A-Z]($|[a-z]))/g, '_$1') + .replace(/^_/g, '') + .toLowerCase() + }, + kebab(str) { + return str + .replace(/([A-Z]($|[a-z]))/g, '-$1') + .replace(/^-/g, '') + .toLowerCase() + }, + pascal(str) { + return str.replace(/^./g, c => c.toLowerCase()) + }, + none(str) { + return str + } +} + +function* generateNames(prefixes, suffixes, name) { + for (const prefix of prefixes) { + if (name.toLowerCase().startsWith(prefix.toLowerCase())) { + const truncated = name.substr(prefix.length) + yield truncated + for (const suffix of suffixes) { + if (truncated.toLowerCase().endsWith(suffix.toLowerCase())) { + yield truncated.substr(0, truncated.length - suffix.length) + } + } + } + } + for (const suffix of suffixes) { + if (name.toLowerCase().endsWith(suffix.toLowerCase())) { + const truncated = name.substr(0, name.length - suffix.length) + yield truncated + for (const prefix of prefixes) { + if (truncated.toLowerCase().startsWith(prefix.toLowerCase())) { + yield truncated.substr(prefix.length) + } + } + } + } +} + +function* expandDirectoryNames(path) { + const dirs = path.toLowerCase().split(/[/\\]/g) + while (dirs.length) { + yield dirs.join('') + dirs.shift() + } +} + +module.exports = { + meta: { + type: 'suggestion', + docs: {description: '', url: require('../url')(module)} + }, + schema: [ + { + type: 'object', + properties: { + transform: { + oneOf: [ + {enum: ['none', 'snake', 'kebab', 'pascal']}, + { + type: 'array', + items: {enum: ['none', 'snake', 'kebab', 'pascal']}, + minItems: 1, + maxItems: 4 + } + ] + }, + suffix: { + onfOf: [{type: 'string'}, {type: 'array', items: {type: 'string'}}] + }, + matchDirectory: { + type: 'boolean' + } + } + } + ], + create(context) { + return { + [s.HTMLElementClass](node) { + const name = node.id.name + const names = [name] + const filename = basename(context.getFilename(), extname(context.getFilename())) + if (filename === '' || filename === '') return + const transforms = [].concat(context.options?.[0]?.transform || ['kebab', 'pascal']) + const suffixes = [].concat(context.options?.[0]?.suffix || []) + const prefixes = [].concat(context.options?.[0]?.prefix || []) + if (context.options?.[0]?.matchDirectory) { + prefixes.push(...expandDirectoryNames(dirname(context.getFilename()))) + } + for (const newName of generateNames(prefixes, suffixes, name)) { + names.push(newName) + } + const allowedFileNames = new Set() + for (const className of names) { + for (const transform of transforms) { + allowedFileNames.add(transformFuncs[transform](className)) + } + } + if (!allowedFileNames.has(filename)) { + const allowed = Array.from(allowedFileNames).join('" or "') + context.report(node, `File name should be "${allowed}" but was "${filename}"`) + } + } + } + } +} diff --git a/test/file-name-matches-element.js b/test/file-name-matches-element.js new file mode 100644 index 0000000..b6eb29b --- /dev/null +++ b/test/file-name-matches-element.js @@ -0,0 +1,120 @@ +const rule = require('../lib/rules/file-name-matches-element') +const RuleTester = require('eslint').RuleTester + +const ruleTester = new RuleTester({env: {es2020: true}}) +const code = 'class FooBarElement extends HTMLElement {}' + +ruleTester.run('file-name-matches-element', rule, { + valid: [ + {code: 'class SomeMap extends Map {}', filename: 'not-an-element.js'}, + {code: 'class FooBarElement {}', filename: 'not-related.js'}, + {code, filename: ''}, + {code, filename: ''}, + {code, filename: 'fooBarElement.js'}, + {code, filename: 'fooBarElement.ts'}, + {code, filename: 'fooBarElement.jsx'}, + {code, filename: 'foo-bar-element.js'}, + {code, filename: 'foo-bar-element.ts'}, + {code, filename: 'foo-bar-element.jsx'}, + {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']}]}, + {code, filename: 'fooBar.js', options: [{transform: 'pascal', suffix: 'Element'}]}, + {code, filename: 'foo-bar.js', options: [{transform: 'kebab', suffix: 'element'}]}, + {code, filename: 'foo_bar.js', options: [{transform: 'snake', suffix: 'element'}]}, + {code, filename: 'foo_bar.js', options: [{transform: ['kebab', 'snake', 'pascal'], suffix: 'Element'}]}, + {code, filename: 'foo_bar_element.js', options: [{transform: ['kebab', 'snake'], suffix: ['Element']}]}, + {code, filename: 'fooBarElement.js', options: [{transform: ['kebab', 'pascal'], suffix: ['ElEmEnT']}]}, + {code, filename: 'foo_bar_element.js', options: [{transform: ['kebab', 'snake'], suffix: ['Element']}]}, + {code, filename: 'bar_element.js', options: [{transform: ['kebab', 'snake'], prefix: ['Foo']}]}, + {code, filename: 'bar_element.js', options: [{transform: ['kebab', 'snake'], prefix: ['fOo']}]}, + { + code: 'class FooBarController extends HTMLElement {}', + filename: 'components/foo/bar.ts', + options: [{transform: 'kebab', suffix: ['Controller'], matchDirectory: true}] + }, + { + code: 'class FooBarController extends HTMLElement {}', + filename: 'components/foo/foo-bar.ts', + options: [{transform: 'kebab', suffix: ['Controller']}] + } + ], + invalid: [ + { + code, + filename: 'barfooelement.ts', + errors: [ + { + message: 'File name should be "foo-bar-element" or "fooBarElement" but was "barfooelement"', + type: 'ClassDeclaration' + } + ] + }, + { + code, + filename: 'foobarelement.ts', + errors: [ + { + message: 'File name should be "foo-bar-element" or "fooBarElement" but was "foobarelement"', + type: 'ClassDeclaration' + } + ] + }, + { + code, + filename: 'foobarelement.ts', + options: [{transform: ['snake']}], + errors: [ + { + message: 'File name should be "foo_bar_element" but was "foobarelement"', + type: 'ClassDeclaration' + } + ] + }, + { + code, + filename: 'foo-bar_element.ts', + options: [{transform: ['kebab']}], + errors: [ + { + message: 'File name should be "foo-bar-element" but was "foo-bar_element"', + type: 'ClassDeclaration' + } + ] + }, + { + code, + filename: 'foo-bar-controller.ts', + options: [{transform: 'kebab', suffix: ['Element']}], + errors: [ + { + message: 'File name should be "foo-bar-element" or "foo-bar" but was "foo-bar-controller"', + type: 'ClassDeclaration' + } + ] + }, + { + code, + filename: 'foo-bar-controller.ts', + options: [{transform: 'kebab', suffix: ['Controller']}], + errors: [ + { + message: 'File name should be "foo-bar-element" but was "foo-bar-controller"', + type: 'ClassDeclaration' + } + ] + } + ] +}) From ed3095461c35fd9ab4b401ee3792a7ba8e85d1c1 Mon Sep 17 00:00:00 2001 From: Keith Cirkel Date: Thu, 25 Mar 2021 11:01:08 +0000 Subject: [PATCH 2/2] refactor(file-name-matches-element): make lack of file name clearer --- lib/rules/file-name-matches-element.js | 7 ++++++- test/file-name-matches-element.js | 1 + 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/lib/rules/file-name-matches-element.js b/lib/rules/file-name-matches-element.js index 62b2eef..12d301d 100644 --- a/lib/rules/file-name-matches-element.js +++ b/lib/rules/file-name-matches-element.js @@ -21,6 +21,11 @@ const transformFuncs = { } } +function hasFileName(context) { + const file = context.getFilename() + return !(file === '' || file === '') +} + function* generateNames(prefixes, suffixes, name) { for (const prefix of prefixes) { if (name.toLowerCase().startsWith(prefix.toLowerCase())) { @@ -84,12 +89,12 @@ module.exports = { } ], create(context) { + if (!hasFileName(context)) return {} return { [s.HTMLElementClass](node) { const name = node.id.name const names = [name] const filename = basename(context.getFilename(), extname(context.getFilename())) - if (filename === '' || filename === '') return const transforms = [].concat(context.options?.[0]?.transform || ['kebab', 'pascal']) const suffixes = [].concat(context.options?.[0]?.suffix || []) const prefixes = [].concat(context.options?.[0]?.prefix || []) diff --git a/test/file-name-matches-element.js b/test/file-name-matches-element.js index b6eb29b..5d31ceb 100644 --- a/test/file-name-matches-element.js +++ b/test/file-name-matches-element.js @@ -8,6 +8,7 @@ ruleTester.run('file-name-matches-element', 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: 'fooBarElement.js'},