diff --git a/README.md b/README.md
index 50c602b..5a4c241 100644
--- a/README.md
+++ b/README.md
@@ -24,3 +24,4 @@ JSON ESLint config example:
- [Define Tag After Class Definition](./docs/rules/define-tag-after-class-definition.md)
- [Expose Class on Global](./docs/rules/expose-class-on-global.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 e8be7e7..e828d71 100644
--- a/lib/rules.js
+++ b/lib/rules.js
@@ -1,4 +1,5 @@
module.exports = {
'define-tag-after-class-definition': require('./rules/define-tag-after-class-definition'),
- 'expose-class-on-global': require('./rules/expose-class-on-global')
+ 'expose-class-on-global': require('./rules/expose-class-on-global'),
+ '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..12d301d
--- /dev/null
+++ b/lib/rules/file-name-matches-element.js
@@ -0,0 +1,120 @@
+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 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())) {
+ 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) {
+ if (!hasFileName(context)) return {}
+ return {
+ [s.HTMLElementClass](node) {
+ const name = node.id.name
+ const names = [name]
+ const filename = basename(context.getFilename(), extname(context.getFilename()))
+ 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..5d31ceb
--- /dev/null
+++ b/test/file-name-matches-element.js
@@ -0,0 +1,121 @@
+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},
+ {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'
+ }
+ ]
+ }
+ ]
+})